diff --git a/docs/src/content/docs/packages/typegen/configuration.md b/docs/src/content/docs/packages/typegen/configuration.md index 3fc6869..c71a81e 100644 --- a/docs/src/content/docs/packages/typegen/configuration.md +++ b/docs/src/content/docs/packages/typegen/configuration.md @@ -65,6 +65,12 @@ public sealed class TypeGenConfig : ITypeGenConfigurator .WithGeneratedTypes(TypeTarget.TypeScript | TypeTarget.OpenApi) .TsName("ArticleDto") .Property(p => p.Body).TsType("string | null"); + + // Runtime-schema override from a frontend dependency. This is separate + // from TsType because TypeScript types do not validate runtime values. + b.ForType() + .Property(p => p.DeliveryPoint) + .ZodSchema("GeoJSONPointSchema", "zod-geojson"); } } ``` diff --git a/docs/src/content/docs/packages/typegen/emitters/zod.md b/docs/src/content/docs/packages/typegen/emitters/zod.md index 0e8894f..2ca8c9d 100644 --- a/docs/src/content/docs/packages/typegen/emitters/zod.md +++ b/docs/src/content/docs/packages/typegen/emitters/zod.md @@ -92,6 +92,70 @@ b.ForType() Available formats are email, URL, UUID, date, date-time, hostname, ULID, NanoID, Base64, Base64URL, credit card, and IBAN. +## External schemas with `[ZodSchema]` + +`[TsType]` controls only the static TypeScript type; it cannot provide runtime +validation. Use `[ZodSchema]` when a third-party package or shared frontend +module already exports the runtime schema for a C# property: + +```csharp +public sealed class OrderDto +{ + [TsType("Point", ImportFrom = "geojson")] + [ZodSchema("GeoJSONPointSchema", ImportFrom = "zod-geojson")] + public Point? DeliveryAreaPoint { get; init; } + + [TsType("Feature", ImportFrom = "geojson")] + [ZodSchema("GeoJSONFeatureSchema", ImportFrom = "zod-geojson")] + public Feature? DeliveryAreaFeature { get; init; } +} +``` + +The Zod file imports and uses those schemas directly: + +```typescript +import { GeoJSONFeatureSchema, GeoJSONPointSchema } from 'zod-geojson'; + +export const OrderDtoSchema = z.object({ + deliveryAreaPoint: GeoJSONPointSchema.nullish(), + deliveryAreaFeature: GeoJSONFeatureSchema.nullish(), +}); +``` + +Install the referenced runtime package in the generated client's project: + +```bash +npm install zod zod-geojson +npm install --save-dev @types/geojson +``` + +The same override is available when the C# model cannot be annotated: + +```csharp +b.ForType() + .Property(x => x.DeliveryAreaPoint) + .ZodSchema("GeoJSONPointSchema", "zod-geojson"); +``` + +The schema expression is emitted verbatim. Omit `ImportFrom` for inline +expressions such as `z.string().startsWith('ord_')`. TypeGen still applies the +property's nullable, optional, read-only, and `PatchField` modifier after the +override. Other inferred validation constraints are not appended because the +explicit schema owns validation for that property. + +Imports sharing a module are grouped and deduplicated in both single-file and +file-per-class output. External schemas also participate normally in +`z.compile(...)`, generated guards, and TanStack payload parsing. With +`ConformToTypeScriptTypes`, the external schema's inferred type must exactly +match the `[TsType]` expression—not merely be structurally assignable. For +`zod-geojson`, use its own inferred aliases when exact conformance is enabled: + +```csharp +[TsType("GeoJSONPoint", ImportFrom = "zod-geojson")] +[ZodSchema("GeoJSONPointSchema", ImportFrom = "zod-geojson")] +public Point? DeliveryAreaPoint { get; init; } +``` + ## Type mapping | C# | Zod | @@ -106,6 +170,7 @@ NanoID, Base64, Base64URL, credit card, and IBAN. | `List`, `T[]` | `z.array(T)` | | `Dictionary` | `z.record(z.string(), V)` | | user DTO | direct ref `{Name}Schema` (cross-file import) | +| `[ZodSchema("X", ImportFrom = "pkg")]` | imported runtime schema `X` | | numeric `enum` | `z.union([z.literal(0), z.literal(1), …])` | | `enum` + `[JsonStringEnumConverter]` | `z.enum(['A', 'B', …])` | diff --git a/docs/src/content/docs/packages/typegen/type-mapping.md b/docs/src/content/docs/packages/typegen/type-mapping.md index 2f02925..8b8024c 100644 --- a/docs/src/content/docs/packages/typegen/type-mapping.md +++ b/docs/src/content/docs/packages/typegen/type-mapping.md @@ -22,7 +22,10 @@ The emitters translate C# types to target-language equivalents. Defaults: | `enum` | `export enum` (numeric values) | `type: string, enum: [...]` | `IntEnum` | | `enum` with `[JsonConverter(typeof(JsonStringEnumConverter))]` | `export type X = "A" \| "B";` (default — `TsEnumStyle.Union`) | `type: string, enum: [...]` | `(str, Enum)` | -Override any single property with `[TsType("...")]` or `[OpenApiProperty(Format = "...")]`. +Override any single property with `[TsType("...")]`, `[ZodSchema("...")]`, or +`[OpenApiProperty(Format = "...")]`. `[TsType]` is static type information; +`[ZodSchema]` supplies a runtime validator. See the [Zod emitter](/packages/typegen/emitters/zod/) +for external-schema imports such as `zod-geojson`. ## `[TsType]` with imports diff --git a/packages/ZibStack.NET.TypeGen/README.md b/packages/ZibStack.NET.TypeGen/README.md index 7f168cb..4ce9293 100644 --- a/packages/ZibStack.NET.TypeGen/README.md +++ b/packages/ZibStack.NET.TypeGen/README.md @@ -93,6 +93,10 @@ public sealed class Payment public string PublicToken { get; set; } = ""; + [TsType("GeoJSONPoint", ImportFrom = "zod-geojson")] + [ZodSchema("GeoJSONPointSchema", ImportFrom = "zod-geojson")] + public object? DeliveryPoint { get; set; } + public Payment? Parent { get; set; } // emitted through z.lazy(...) } @@ -132,7 +136,17 @@ export const isPayment = (value: unknown): value is z.output() + .Property(x => x.DeliveryPoint) + .ZodSchema("GeoJSONPointSchema", "zod-geojson"); +``` + +The frontend must install any package named by `ImportFrom`; for this example, +use `npm install zod zod-geojson`. ## Docs diff --git a/packages/ZibStack.NET.TypeGen/sample/SampleApi/Models/Order.cs b/packages/ZibStack.NET.TypeGen/sample/SampleApi/Models/Order.cs index 7a828f6..e5705f7 100644 --- a/packages/ZibStack.NET.TypeGen/sample/SampleApi/Models/Order.cs +++ b/packages/ZibStack.NET.TypeGen/sample/SampleApi/Models/Order.cs @@ -98,5 +98,15 @@ public class ZodFeatureExample // Configured with .ZodNanoId(16) in TypeGenConfig.cs. public string PublicToken { get; set; } = ""; + // External runtime schema override. The frontend installs `zod-geojson`; + // TypeGen emits both imports instead of z.unknown(). Using the schema + // package's inferred alias also satisfies exact z.toZod() mode. + [TsType("GeoJSONPoint", ImportFrom = "zod-geojson")] + [ZodSchema("GeoJSONPointSchema", ImportFrom = "zod-geojson")] + public System.Text.Json.Nodes.JsonObject? DeliveryPoint { get; set; } + + [TsType("GeoJSONFeature", ImportFrom = "zod-geojson")] + public System.Text.Json.Nodes.JsonObject? DeliveryFeature { get; set; } + public ZodFeatureExample? Parent { get; set; } } diff --git a/packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs b/packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs index c9351c0..8530fa9 100644 --- a/packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs +++ b/packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs @@ -61,7 +61,11 @@ public void Configure(ITypeGenBuilder b) // when the model lives in another project and cannot be annotated. b.ForType() .Property(x => x.PublicToken) - .ZodNanoId(16); + .ZodNanoId(16) + // Same external-schema override through the fluent API, useful + // when DeliveryFeature belongs to a referenced assembly. + .Property(x => x.DeliveryFeature) + .ZodSchema("GeoJSONFeatureSchema", "zod-geojson"); b.ForType() .WithGeneratedTypes(TypeTarget.TypeScript) diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/ITypeGenConfigurator.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/ITypeGenConfigurator.cs index ff2f7b9..81ee018 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/ITypeGenConfigurator.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/ITypeGenConfigurator.cs @@ -166,7 +166,8 @@ public interface ITypeBuilder /// /// Per-property fluent overrides. Mirrors the per-property attributes /// (, , -/// , etc.). Use when you can't or don't +/// , , etc.). +/// Use when you can't or don't /// want to annotate the source — e.g. DTOs from a referenced library. /// /// Owning class. @@ -227,6 +228,16 @@ public interface IPropertyBuilder /// Validate a NanoID with an exact custom length. IPropertyBuilder ZodNanoId(int length); + /// Replace the inferred validator with a Zod schema expression emitted verbatim. + IPropertyBuilder ZodSchema(string schemaExpression); + + /// + /// Replace the inferred validator with a Zod schema expression and import its + /// named symbols from . Equivalent to + /// [ZodSchema(schemaExpression, ImportFrom = importFrom)]. + /// + IPropertyBuilder ZodSchema(string schemaExpression, string? importFrom); + /// Equivalent to [OpenApiProperty(Description = description)]. IPropertyBuilder OpenApiDescription(string description); diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/ZodOverrides.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/ZodOverrides.cs index 59130e3..6d955cd 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/ZodOverrides.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/ZodOverrides.cs @@ -16,3 +16,21 @@ public sealed class ZodFormatAttribute : Attribute public ZodFormatAttribute(ZodStringFormat format) => Format = format; } + +/// +/// Replaces the inferred Zod validator for a property with a user-supplied Zod +/// schema expression. Use when the expression references +/// named exports from another module, such as GeoJSONPointSchema from +/// zod-geojson. +/// +[AttributeUsage(AttributeTargets.Property, Inherited = true)] +public sealed class ZodSchemaAttribute : Attribute +{ + /// Zod schema expression emitted verbatim for the property. + public string SchemaExpression { get; } + + /// Optional module specifier supplying named symbols used by the expression. + public string? ImportFrom { get; set; } + + public ZodSchemaAttribute(string schemaExpression) => SchemaExpression = schemaExpression; +} diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/ZodEmitter.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/ZodEmitter.cs index 9a7247b..3215471 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/ZodEmitter.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/ZodEmitter.cs @@ -56,6 +56,7 @@ public static IReadOnlyList Emit(SchemaModel model, GlobalSettings EmitConformanceImports(sb, model.Classes.Where(c => !SkipClass(c) && (c.Targets & TypeTarget.TypeScript) != 0).Select(c => c.EmittedName) .Concat(model.Enums.Where(e => !SkipEnum(e) && (e.Targets & TypeTarget.TypeScript) != 0).Select(e => e.EmittedName)), ResolveOutputDir(zs.OutputDir, model), settings.TypeScript, zs, model); + EmitExternalSchemaImports(sb, model.Classes.Where(c => !SkipClass(c))); sb.AppendLine(); // In SingleFile mode order matters — a schema has to be declared @@ -85,6 +86,7 @@ public static IReadOnlyList Emit(SchemaModel model, GlobalSettings var outputDir = (cls.HasExplicitOutputDir ? cls.OutputDir : !string.IsNullOrEmpty(globalZodDir) ? globalZodDir : cls.OutputDir) ?? "."; EmitConformanceImports(sb, (cls.Targets & TypeTarget.TypeScript) != 0 ? new[] { cls.EmittedName } : System.Array.Empty(), outputDir, settings.TypeScript, zs, model); EmitImports(sb, CollectClassReferences(cls, nameByCSharp), cls.EmittedName, zs); + EmitExternalSchemaImports(sb, new[] { cls }); sb.AppendLine(); EmitClass(sb, cls, zs, nameByCSharp, model); files.Add(new EmittedFile( @@ -137,6 +139,39 @@ private static void EmitImports(StringBuilder sb, IEnumerable refs, stri sb.AppendLine($"import {{ {r}{zs.SchemaConstSuffix} }} from './{r}{zs.FileSuffix}';"); } + private static void EmitExternalSchemaImports(StringBuilder sb, IEnumerable classes) + { + var byPath = new Dictionary>(System.StringComparer.Ordinal); + foreach (var prop in classes.SelectMany(c => c.Properties)) + { + if (prop.TsIgnore || string.IsNullOrWhiteSpace(prop.ZodSchemaOverride) + || string.IsNullOrWhiteSpace(prop.ZodSchemaImportFrom)) + continue; + + if (!byPath.TryGetValue(prop.ZodSchemaImportFrom!, out var names)) + byPath[prop.ZodSchemaImportFrom!] = names = new HashSet(System.StringComparer.Ordinal); + foreach (var name in ExtractImportedSchemaIdentifiers(prop.ZodSchemaOverride!)) + names.Add(name); + } + + foreach (var entry in byPath.OrderBy(x => x.Key, System.StringComparer.Ordinal)) + { + if (entry.Value.Count == 0) continue; + sb.AppendLine($"import {{ {string.Join(", ", entry.Value.OrderBy(x => x, System.StringComparer.Ordinal))} }} from '{entry.Key}';"); + } + } + + private static IEnumerable ExtractImportedSchemaIdentifiers(string expression) + { + var trimmed = expression.Trim(); + if (System.Text.RegularExpressions.Regex.IsMatch(trimmed, @"^[A-Za-z_$][A-Za-z0-9_$]*$")) + return new[] { trimmed }; + + return System.Text.RegularExpressions.Regex.Matches(expression, @"[A-Z][A-Za-z0-9_$]*") + .Cast() + .Select(match => match.Value); + } + private static void EmitConformanceImports( StringBuilder sb, IEnumerable typeNames, @@ -375,12 +410,18 @@ private static string BuildPropertyZodExpr( bool conformToTypeScript = false) { var targetFqn = prop.TargetTypeCSharpFqn ?? prop.CSharpTypeFullName; - var core = MapCSharpToZod(targetFqn, prop.IsNullable, nameByCSharp, schemaConstSuffix, typeParameters, lazySchemaNames); + var hasSchemaOverride = !string.IsNullOrWhiteSpace(prop.ZodSchemaOverride); + var core = hasSchemaOverride + ? prop.ZodSchemaOverride!.Trim() + : MapCSharpToZod(targetFqn, prop.IsNullable, nameByCSharp, schemaConstSuffix, typeParameters, lazySchemaNames); - // Apply string-shaped constraints (length, regex, email/url/uuid formats). - // Numeric constraints use gte/lte. - core = ApplyStringConstraints(core, prop); - core = ApplyNumericConstraints(core, prop); + // An explicit schema owns all validation. Inferred schemas continue to + // receive constraints discovered from validation/format attributes. + if (!hasSchemaOverride) + { + core = ApplyStringConstraints(core, prop); + core = ApplyNumericConstraints(core, prop); + } // Nullable + optional → .nullish() by default. In TypeScript conformance // mode, mirror the TS emitter's optional-only contract so z.toZod() @@ -573,6 +614,7 @@ private static HashSet CollectClassReferences(SchemaClass cls, IReadOnly foreach (var prop in cls.Properties) { if (prop.TsIgnore) continue; + if (!string.IsNullOrWhiteSpace(prop.ZodSchemaOverride)) continue; CollectRefs(prop.TargetTypeCSharpFqn ?? prop.CSharpTypeFullName, nameByCSharp, acc); } return acc; @@ -602,6 +644,7 @@ private static HashSet CollectLazySchemaNames( var result = new HashSet(System.StringComparer.Ordinal); foreach (var prop in owner.Properties) { + if (!string.IsNullOrWhiteSpace(prop.ZodSchemaOverride)) continue; foreach (var referenced in EnumerateReferencedTypes(prop.TargetTypeCSharpFqn ?? prop.CSharpTypeFullName, classes)) { if (CanReach(referenced, owner.CSharpFullName, classes, new HashSet(System.StringComparer.Ordinal)) @@ -621,8 +664,11 @@ private static bool CanReach( if (current == target) return true; if (!visited.Add(current) || !classes.TryGetValue(current, out var cls)) return false; foreach (var prop in cls.Properties) + { + if (!string.IsNullOrWhiteSpace(prop.ZodSchemaOverride)) continue; foreach (var next in EnumerateReferencedTypes(prop.TargetTypeCSharpFqn ?? prop.CSharpTypeFullName, classes)) if (CanReach(next, target, classes, visited)) return true; + } return false; } @@ -684,6 +730,7 @@ void Visit(SchemaClass c) // (e.g. items: z.array(OrderItemSchema)) must come after the referenced one. foreach (var prop in c.Properties) { + if (!string.IsNullOrWhiteSpace(prop.ZodSchemaOverride)) continue; var propType = (prop.TargetTypeCSharpFqn ?? prop.CSharpTypeFullName).TrimEnd('?'); // Unwrap collections: List, X[], IEnumerable etc. var inner = ExtractGeneric(propType, "List", "IList", "ICollection", "IEnumerable", diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Model/SchemaModel.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Model/SchemaModel.cs index 46e51e8..1564ade 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Model/SchemaModel.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Model/SchemaModel.cs @@ -522,6 +522,12 @@ internal sealed class SchemaProperty public ZodStringFormat? ZodFormat { get; set; } public int? ZodFormatLength { get; set; } + /// User-supplied Zod schema expression that replaces inferred property mapping. + public string? ZodSchemaOverride { get; set; } + + /// Optional module specifier supplying named symbols used by . + public string? ZodSchemaImportFrom { get; set; } + /// True for ZibStack.NET.Dto's tri-state PatchField<T>. public bool IsPatchField { get; set; } diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/ConfiguratorParser.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/ConfiguratorParser.cs index 8cb3146..219ec57 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/ConfiguratorParser.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/ConfiguratorParser.cs @@ -60,6 +60,8 @@ public sealed class PerPropertyOverrides public string? OpenApiDescription { get; set; } public ZodStringFormat? ZodFormat { get; set; } public int? ZodFormatLength { get; set; } + public string? ZodSchema { get; set; } + public string? ZodSchemaImportFrom { get; set; } public bool? OpenApiNullable { get; set; } public bool Ignore { get; set; } public bool TsIgnore { get; set; } @@ -556,6 +558,20 @@ private static void ApplyPropertyLevelCall( return; } + if (name == "ZodSchema") + { + o.ZodSchema = ReadStringArg(inv, name, sm, report); + if (inv.ArgumentList.Arguments.Count >= 2) + { + var importArg = ReadLiteralValue(inv.ArgumentList.Arguments[1].Expression, sm); + if (importArg is string s) o.ZodSchemaImportFrom = s; + else if (importArg is NonLiteralMarker) report(Diagnostic.Create( + TypeGenDiagnostics.NonLiteralArgument, + inv.ArgumentList.Arguments[1].GetLocation(), name)); + } + return; + } + string? arg = ReadStringArg(inv, name, sm, report); switch (name) { diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/SchemaParser.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/SchemaParser.cs index 44b6c21..55e159c 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/SchemaParser.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/SchemaParser.cs @@ -23,6 +23,7 @@ internal static class SchemaParser private const string OpenApiSchemaNameAttr = "ZibStack.NET.TypeGen.OpenApiSchemaNameAttribute"; private const string OpenApiPropertyAttr = "ZibStack.NET.TypeGen.OpenApiPropertyAttribute"; private const string ZodFormatAttr = "ZibStack.NET.TypeGen.ZodFormatAttribute"; + private const string ZodSchemaAttr = "ZibStack.NET.TypeGen.ZodSchemaAttribute"; private const string OpenApiIgnoreAttr = "ZibStack.NET.TypeGen.OpenApiIgnoreAttribute"; // String-only — no reference to ZibStack.NET.Dto. The attribute is generated // by Dto's source generator into the user's compilation, so we read it via @@ -893,6 +894,8 @@ private static SchemaProperty ParseProperty(IPropertySymbol prop) TsNameOverride = ReadStringArg(prop, TsNameAttr, "Name"), TsTypeOverride = ReadStringArg(prop, TsTypeAttr, "TypeExpression"), TsImportFrom = ReadNamedStringArg(prop, TsTypeAttr, "ImportFrom"), + ZodSchemaOverride = ReadStringArg(prop, ZodSchemaAttr, "SchemaExpression"), + ZodSchemaImportFrom = ReadNamedStringArg(prop, ZodSchemaAttr, "ImportFrom"), OpenApiNameOverride = ReadStringArg(prop, OpenApiSchemaNameAttr, "Name"), TsIgnore = HasAttr(prop, TsIgnoreAttr), OpenApiIgnore = HasAttr(prop, OpenApiIgnoreAttr), diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/TypeGenGenerator.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/TypeGenGenerator.cs index 1519a54..0d46dbb 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/TypeGenGenerator.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/TypeGenGenerator.cs @@ -542,6 +542,8 @@ private static void ApplyFluentToClass(SchemaClass cls, ConfiguratorParser.Parse prop.OpenApiNullableOverride ??= po.OpenApiNullable; prop.ZodFormat ??= po.ZodFormat; prop.ZodFormatLength ??= po.ZodFormatLength; + prop.ZodSchemaOverride ??= po.ZodSchema; + prop.ZodSchemaImportFrom ??= po.ZodSchemaImportFrom; if (po.Ignore) { prop.TsIgnore = true; prop.OpenApiIgnore = true; } prop.TsIgnore |= po.TsIgnore; prop.OpenApiIgnore |= po.OpenApiIgnore; diff --git a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ConfiguratorParserTests.cs b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ConfiguratorParserTests.cs index 2e8f8fa..7a8bcdf 100644 --- a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ConfiguratorParserTests.cs +++ b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ConfiguratorParserTests.cs @@ -127,6 +127,7 @@ public void Configure(ITypeGenBuilder b) { }); b.ForType().Property(x => x.Card).ZodFormat(ZodStringFormat.CreditCard); b.ForType().Property(x => x.Card).ZodNanoId(16); + b.ForType().Property(x => x.Card).ZodSchema("ExternalCardSchema", "@company/schemas"); } } """, out var diags); @@ -137,6 +138,8 @@ public void Configure(ITypeGenBuilder b) { Assert.True(parsed.Settings.Zod.EmitValidationGuards); Assert.Equal(ZodStringFormat.NanoId, parsed.PerType["Payment"].Properties["Card"].ZodFormat); Assert.Equal(16, parsed.PerType["Payment"].Properties["Card"].ZodFormatLength); + Assert.Equal("ExternalCardSchema", parsed.PerType["Payment"].Properties["Card"].ZodSchema); + Assert.Equal("@company/schemas", parsed.PerType["Payment"].Properties["Card"].ZodSchemaImportFrom); } [Fact] @@ -564,6 +567,7 @@ public interface IPropertyBuilder { IPropertyBuilder OpenApiFormat(string f); IPropertyBuilder ZodFormat(ZodStringFormat f); IPropertyBuilder ZodNanoId(int length); + IPropertyBuilder ZodSchema(string expression, string importFrom); IPropertyBuilder OpenApiDescription(string d); IPropertyBuilder OpenApiNullable(bool n); IPropertyBuilder Ignore(); diff --git a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodCompilationTests.cs b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodCompilationTests.cs index 0720449..28e53be 100644 --- a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodCompilationTests.cs +++ b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodCompilationTests.cs @@ -23,6 +23,8 @@ public sealed class ZodCompilationTests : IDisposable // Pin both packages for determinism across machines / CI. private const string TscPackageSpec = "typescript@5.7.3"; private const string ZodPackageSpec = "zod@4.6.1"; + private const string ZodGeoJsonPackageSpec = "zod-geojson@1.7.1"; + private const string GeoJsonTypesPackageSpec = "@types/geojson@7946.0.16"; private readonly string _tempDir; private readonly bool _skip; @@ -279,14 +281,90 @@ public async Task TanStackPayloadValidation_CompilesWithGeneratedSchemas() $"tsc failed (exit {exitCode}):{Environment.NewLine}{stdout}{Environment.NewLine}{stderr}"); } + [Fact] + public async Task ExternalZodGeoJsonSchema_CompilesAndInfersPropertyType() + { + if (_skip) return; + + var model = new SchemaModel(); + var order = ClsModel("Order", new[] { ("DeliveryPoint", "GeoJSON.Text.Geometry.Point", true) }); + order.Targets = TypeTarget.TypeScript | TypeTarget.Zod; + order.Properties[0].TsTypeOverride = "Point"; + order.Properties[0].TsImportFrom = "geojson"; + order.Properties[0].ZodSchemaOverride = "GeoJSONPointSchema"; + order.Properties[0].ZodSchemaImportFrom = "zod-geojson"; + model.Classes.Add(order); + + var settings = new GlobalSettings(); + settings.Zod.Compilation = ZodCompilationMode.Compile; + var files = TypeScriptEmitter.Emit(model, settings).Concat(ZodEmitter.Emit(model, settings)).ToList(); + await PrepareWorkspaceAsync(ZodGeoJsonPackageSpec, GeoJsonTypesPackageSpec); + foreach (var file in files) + File.WriteAllText(Path.Combine(_tempDir, file.FileName), file.Content); + + File.WriteAllText(Path.Combine(_tempDir, "consumer.ts"), """ + import type { Point } from 'geojson'; + import { OrderSchema } from './Order.schema'; + + const order = OrderSchema.parse({ + deliveryPoint: { type: 'Point', coordinates: [1, 2] }, + }); + const point: Point | null | undefined = order.deliveryPoint; + void point; + """); + + var compileFiles = files.Select(file => file.FileName).Append("consumer.ts"); + var (exitCode, stdout, stderr) = await RunAsync( + "npx", + $"-y -p {TscPackageSpec} tsc --noEmit --strict --skipLibCheck --esModuleInterop --target ES2020 --moduleResolution node " + + string.Join(" ", compileFiles), + workingDir: _tempDir); + + Assert.True(exitCode == 0, + $"tsc failed (exit {exitCode}):{Environment.NewLine}{stdout}{Environment.NewLine}{stderr}"); + } + + [Fact] + public async Task ExternalZodGeoJsonSchema_ExactPackageAliasSupportsConformance() + { + if (_skip) return; + + var model = new SchemaModel(); + var order = ClsModel("ConformingOrder", new[] { ("DeliveryPoint", "GeoJSON.Text.Geometry.Point", true) }); + order.Targets = TypeTarget.TypeScript | TypeTarget.Zod; + order.Properties[0].TsTypeOverride = "GeoJSONPoint"; + order.Properties[0].TsImportFrom = "zod-geojson"; + order.Properties[0].ZodSchemaOverride = "GeoJSONPointSchema"; + order.Properties[0].ZodSchemaImportFrom = "zod-geojson"; + model.Classes.Add(order); + + var settings = new GlobalSettings(); + settings.Zod.ConformToTypeScriptTypes = true; + settings.Zod.Compilation = ZodCompilationMode.Compile; + var files = TypeScriptEmitter.Emit(model, settings).Concat(ZodEmitter.Emit(model, settings)).ToList(); + await PrepareWorkspaceAsync(ZodGeoJsonPackageSpec); + foreach (var file in files) + File.WriteAllText(Path.Combine(_tempDir, file.FileName), file.Content); + + var (exitCode, stdout, stderr) = await RunAsync( + "npx", + $"-y -p {TscPackageSpec} tsc --noEmit --strict --skipLibCheck --esModuleInterop --target ES2020 --moduleResolution node " + + string.Join(" ", files.Select(file => file.FileName)), + workingDir: _tempDir); + + Assert.True(exitCode == 0, + $"tsc failed (exit {exitCode}):{Environment.NewLine}{stdout}{Environment.NewLine}{stderr}"); + } + // ── helpers ───────────────────────────────────────────────────────────── - private async Task PrepareWorkspaceAsync() + private async Task PrepareWorkspaceAsync(params string[] additionalPackages) { // Install zod locally so the emitted `import { z } from 'zod';` resolves. // Node resolution walks up from temp dir; a sibling node_modules is enough. + var packages = string.Join(" ", new[] { ZodPackageSpec }.Concat(additionalPackages)); var (code, _, err) = await RunAsync("npx", - $"-y -p npm@10 npm install --silent --no-audit --no-fund --no-package-lock {ZodPackageSpec}", + $"-y -p npm@10 npm install --silent --no-audit --no-fund --no-package-lock {packages}", workingDir: _tempDir); if (code != 0) throw new InvalidOperationException($"zod install failed (exit {code}): {err}"); diff --git a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodEmitterTests.cs b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodEmitterTests.cs index 0c77523..b7b342d 100644 --- a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodEmitterTests.cs +++ b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodEmitterTests.cs @@ -108,6 +108,62 @@ public void Nullable_InTypeScriptConformanceMode_MatchesOptionalProperty() Assert.DoesNotContain("note: z.string().nullish()", content); } + [Fact] + public void ExternalSchemaOverride_ReplacesInferenceAndEmitsImport() + { + var cls = Cls("Order", props: new[] { ("DeliveryPoint", "GeoJSON.Text.Geometry.Point", true) }); + cls.Properties[0].ZodSchemaOverride = "GeoJSONPointSchema"; + cls.Properties[0].ZodSchemaImportFrom = "zod-geojson"; + + var content = ZodEmitter.Emit(ModelWith(cls), new GlobalSettings()).Single().Content; + + Assert.Contains("import { GeoJSONPointSchema } from 'zod-geojson';", content); + Assert.Contains("deliveryPoint: GeoJSONPointSchema.nullish()", content); + Assert.DoesNotContain("deliveryPoint: z.unknown()", content); + } + + [Fact] + public void ExternalSchemaImports_AreGroupedAndDeduplicatedInSingleFile() + { + var order = Cls("Order", props: new[] { ("Point", "object", false), ("Feature", "object", false) }); + order.Properties[0].ZodSchemaOverride = "GeoJSONPointSchema"; + order.Properties[0].ZodSchemaImportFrom = "zod-geojson"; + order.Properties[1].ZodSchemaOverride = "GeoJSONFeatureSchema"; + order.Properties[1].ZodSchemaImportFrom = "zod-geojson"; + var settings = new GlobalSettings { Zod = { FileLayout = ZodFileLayout.SingleFile } }; + + var content = ZodEmitter.Emit(ModelWith(order), settings).Single().Content; + + Assert.Contains("import { GeoJSONFeatureSchema, GeoJSONPointSchema } from 'zod-geojson';", content); + Assert.Equal(1, content.Split('\n').Count(line => line.Contains("from 'zod-geojson'"))); + } + + [Fact] + public void ExternalSchemaImport_SupportsLowerCamelCaseNamedExport() + { + var cls = Cls("Order", props: new[] { ("Payload", "object", false) }); + cls.Properties[0].ZodSchemaOverride = "payloadSchema"; + cls.Properties[0].ZodSchemaImportFrom = "@company/schemas"; + + var content = ZodEmitter.Emit(ModelWith(cls), new GlobalSettings()).Single().Content; + + Assert.Contains("import { payloadSchema } from '@company/schemas';", content); + Assert.Contains("payload: payloadSchema", content); + } + + [Fact] + public void InlineSchemaOverride_RequiresNoImportAndOwnsValidation() + { + var cls = Cls("Order", props: new[] { ("Code", "string", false) }); + cls.Properties[0].ZodSchemaOverride = "z.string().startsWith('ord_')"; + cls.Properties[0].MinLength = 20; + + var content = ZodEmitter.Emit(ModelWith(cls), new GlobalSettings()).Single().Content; + + Assert.Contains("code: z.string().startsWith('ord_')", content); + Assert.DoesNotContain("startsWith('ord_').min(20)", content); + } + [Fact] public void ReadOnly_BecomesOptional() { diff --git a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodSchemaOverrideTests.cs b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodSchemaOverrideTests.cs new file mode 100644 index 0000000..3ebff52 --- /dev/null +++ b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodSchemaOverrideTests.cs @@ -0,0 +1,51 @@ +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; +using ZibStack.NET.TypeGen; +using ZibStack.NET.TypeGen.Generator; + +namespace TypeGenTests; + +public sealed class ZodSchemaOverrideTests +{ + [Fact] + public void Attribute_IsParsedAndEmittedWithItsImport() + { + const string source = """ + #nullable enable + using ZibStack.NET.TypeGen; + + [GenerateTypes(Targets = TypeTarget.Zod)] + public sealed class Order + { + [ZodSchema("GeoJSONPointSchema", ImportFrom = "zod-geojson")] + public object? DeliveryPoint { get; init; } + } + """; + + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var refs = ((string)System.AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(System.IO.Path.PathSeparator) + .Select(path => MetadataReference.CreateFromFile(path)) + .Append(MetadataReference.CreateFromFile(typeof(ZodSchemaAttribute).Assembly.Location)); + var compilation = CSharpCompilation.Create( + "ZodSchemaAttributeTest", + new[] { syntaxTree }, + refs, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable)); + + Assert.Empty(compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)); + var cls = SchemaParser.ParseClass(compilation.GetTypeByMetadataName("Order")!)!; + var property = Assert.Single(cls.Properties); + Assert.Equal("GeoJSONPointSchema", property.ZodSchemaOverride); + Assert.Equal("zod-geojson", property.ZodSchemaImportFrom); + + var model = new SchemaModel(); + model.Classes.Add(cls); + var output = ZodEmitter.Emit(model, new GlobalSettings()).Single().Content; + Assert.Contains("import { GeoJSONPointSchema } from 'zod-geojson';", output); + Assert.Contains("deliveryPoint: GeoJSONPointSchema.nullish()", output); + } +}