From c204839649568a325c462e0258e5939146db8d10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20Szepczy=C5=84ski?= Date: Tue, 21 Apr 2026 08:32:10 +0000 Subject: [PATCH] [Dto]: drop FluentValidation integration from CRUD/DTO generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generator used to detect FluentValidation.AbstractValidator`1 in the compilation and, when present, emit a FluentDtoValidator bridge class plus {RequestName}Create/UpdateBaseValidator scaffolds wired onto each generated DTO. This dragged the external FluentValidation package into every project that generated CRUD DTOs and had it referenced anywhere transitively. We no longer want that dependency. Removed: - FluentDtoValidatorSource constant (the FluentValidation bridge base class) - hasFluentValidation CompilationProvider gate - RegisterSourceOutput that emitted FluentDtoValidator.g.cs - GenerateFluentBaseValidator and all its call-sites - hasFluent parameter plumbing through GenerateSource / EmitDeduplicatedNested and every Combine() chain that fed it - docs pages that described the FluentValidation bridge Kept: IDtoValidator, DtoValidationResult, and [Create/Update/CreateOrUpdate] Validator / CreateValidator / UpdateValidator attribute properties — those are generic and let users point at any custom validator implementation. --- docs/src/content/docs/packages/dto.md | 2 +- .../docs/packages/dto/json-and-validation.md | 39 +--------- .../DtoGenerator.AttributeSources.cs | 23 ------ .../DtoGenerator.Generation.cs | 58 +-------------- .../src/ZibStack.NET.Dto/DtoGenerator.cs | 72 ++++++++----------- 5 files changed, 34 insertions(+), 160 deletions(-) diff --git a/docs/src/content/docs/packages/dto.md b/docs/src/content/docs/packages/dto.md index 30eaa55..9916ba7 100644 --- a/docs/src/content/docs/packages/dto.md +++ b/docs/src/content/docs/packages/dto.md @@ -230,7 +230,7 @@ This page covers the core mode + generated shape. Detailed reference and feature - [`PaginatedResponse`](/ZibStack.NET/packages/dto/paginated/) — the standard list wrapper. - [CRUD API (`[CrudApi]`)](/ZibStack.NET/packages/dto/crud-api/) — full endpoint generation. - [Response DTOs, mapping, `ApplyWithChanges`](/ZibStack.NET/packages/dto/response-mapping/) — `FromEntity` / `ProjectFrom`, nested + flatten, Diff, DtoMapper, Swagger. -- [JSON serializer & custom validation](/ZibStack.NET/packages/dto/json-and-validation/) — `PatchField` JSON registration + FluentValidation. +- [JSON serializer & custom validation](/ZibStack.NET/packages/dto/json-and-validation/) — `PatchField` JSON registration + `IDtoValidator`. ## Related guides diff --git a/docs/src/content/docs/packages/dto/json-and-validation.md b/docs/src/content/docs/packages/dto/json-and-validation.md index e11a2ba..199cefe 100644 --- a/docs/src/content/docs/packages/dto/json-and-validation.md +++ b/docs/src/content/docs/packages/dto/json-and-validation.md @@ -1,6 +1,6 @@ --- title: JSON serializer & custom validation -description: PatchField JSON converter registration (System.Text.Json + Newtonsoft) and how to plug in your own validator (manual or FluentValidation). +description: PatchField JSON converter registration (System.Text.Json + Newtonsoft) and how to plug in your own validator via IDtoValidator. --- ## JSON serializer support @@ -38,43 +38,6 @@ public class Player { ... } When a validator is set, `Validate()` delegates entirely to it -- the default generated rules are replaced. -### FluentValidation - -When FluentValidation is installed, the generator additionally produces: - -- `FluentDtoValidator` -- base class bridging FluentValidation with `IDtoValidator` -- `{RequestName}CreateBaseValidator` -- contains the generated required/null rules -- `{RequestName}UpdateBaseValidator` -- contains the generated null rules - -Inherit to extend: - -```csharp -public class MyCreateValidator : CreatePlayerRequestCreateBaseValidator -{ - public MyCreateValidator() - { - RuleFor(x => x.Name) - .Must(f => !f.HasValue || f.Value.Length >= 3) - .WithMessage("Name must be at least 3 characters."); - } -} - -[CreateDto(Validator = typeof(MyCreateValidator))] -public class Player { ... } -``` - -Or start from scratch: - -```csharp -public class MyCreateValidator : FluentDtoValidator -{ - public MyCreateValidator() - { - // your rules only - } -} -``` - ## Related guides - [Full CRUD with SQLite](/ZibStack.NET/guides/crud-sqlite/) — end-to-end project with `[CrudApi]`, relationships, Query DSL, observability, and PatchField tri-state demo diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.AttributeSources.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.AttributeSources.cs index a7238f8..c95649b 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.AttributeSources.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.AttributeSources.cs @@ -365,29 +365,6 @@ public interface ICanValidate DtoValidationResult Validate(); } } -"; - - private const string FluentDtoValidatorSource = @"// -#nullable enable - -namespace ZibStack.NET.Dto -{ - /// - /// Base class that bridges FluentValidation with ZibStack.NET.Dto validation. - /// Extend this instead of AbstractValidator to use with [Dto(CreateValidator/UpdateValidator)]. - /// - public abstract class FluentDtoValidator : FluentValidation.AbstractValidator, IDtoValidator - { - DtoValidationResult IDtoValidator.Validate(T instance) - { - var fluentResult = base.Validate(instance); - var result = new DtoValidationResult(); - foreach (var failure in fluentResult.Errors) - result.AddError(failure.PropertyName, failure.ErrorMessage); - return result; - } - } -} "; private const string PatchFieldSchemaFilterSource = @"// diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Generation.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Generation.cs index 581f21c..941d94f 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Generation.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Generation.cs @@ -23,7 +23,7 @@ private static void EmitTypeGenAttribute(StringBuilder sb, bool hasTypeGen) sb.AppendLine("[global::ZibStack.NET.TypeGen.GenerateTypes(global::ZibStack.NET.TypeGen.TypeTarget.OpenApi)]"); } - private static string GenerateSource(DtoClassInfo classInfo, bool hasFluentValidation) + private static string GenerateSource(DtoClassInfo classInfo) { var sb = new StringBuilder(); sb.AppendLine("// "); @@ -45,31 +45,14 @@ private static string GenerateSource(DtoClassInfo classInfo, bool hasFluentValid { case DtoKind.Create: GenerateCreateRequestClass(sb, classInfo); - if (hasFluentValidation) - { - sb.AppendLine(); - GenerateFluentBaseValidator(sb, classInfo, isCreate: true, combined: false); - } break; case DtoKind.Update: GenerateUpdateRequestClass(sb, classInfo); - if (hasFluentValidation) - { - sb.AppendLine(); - GenerateFluentBaseValidator(sb, classInfo, isCreate: false, combined: false); - } break; case DtoKind.Combined: GenerateCombinedRequestClass(sb, classInfo); - if (hasFluentValidation) - { - sb.AppendLine(); - GenerateFluentBaseValidator(sb, classInfo, isCreate: true, combined: true); - sb.AppendLine(); - GenerateFluentBaseValidator(sb, classInfo, isCreate: false, combined: true); - } break; } @@ -987,45 +970,6 @@ private static void GenerateApplyWithChangesMethod(StringBuilder sb, List !p.IsIgnoredFrom(1) && !p.IsReadOnly).ToList() - : classInfo.Properties.Where(p => - !p.IsIgnoredFrom(2) && !p.IsReadOnly && !p.IsInitOnly).ToList(); - - sb.AppendLine($"public class {requestName}{validatorSuffix} : ZibStack.NET.Dto.FluentDtoValidator<{requestName}>"); - sb.AppendLine("{"); - sb.AppendLine($" public {requestName}{validatorSuffix}()"); - sb.AppendLine(" {"); - - foreach (var prop in props) - { - var jsonName = prop.JsonName; - - if (isCreate && prop.IsRequired) - { - sb.AppendLine($" RuleFor(x => x.{prop.PropertyName}).Must(f => f.HasValue).WithMessage(\"Property '{jsonName}' is required.\");"); - - if (NeedsNullCheck(prop)) - { - sb.AppendLine($" RuleFor(x => x.{prop.PropertyName}).Must(f => !f.HasValue || f.Value is not null).WithMessage(\"Property '{jsonName}' cannot be null.\");"); - } - } - else if (NeedsNullCheck(prop)) - { - sb.AppendLine($" RuleFor(x => x.{prop.PropertyName}).Must(f => !f.HasValue || f.Value is not null).WithMessage(\"Property '{jsonName}' cannot be null.\");"); - } - } - - sb.AppendLine(" }"); - sb.AppendLine("}"); - } - private static string GenerateCreateDtoForSource(DtoForInfo info) { var sb = new StringBuilder(); diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs index 96d25f1..3a210d3 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs @@ -82,10 +82,6 @@ public void Initialize(IncrementalGeneratorInitializationContext context) spc.AddSource("PatchFieldSchemaFilter.g.cs", PatchFieldSchemaFilterV10Source); }); - // Detect FluentValidation and emit adapter - var hasFluentValidation = context.CompilationProvider.Select(static (compilation, _) => - compilation.GetTypeByMetadataName("FluentValidation.AbstractValidator`1") is not null); - // Parse fluent IDtoConfigurator once and feed it into every downstream pipeline. // CrudImplied / CrudApi / allDtoClasses callbacks combine with this so per-property // overrides + CrudApi options can mix with attribute markers in either direction. @@ -107,12 +103,6 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var hasEfCore = context.CompilationProvider.Select(static (compilation, _) => compilation.GetTypeByMetadataName("Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions") is not null); - context.RegisterSourceOutput(hasFluentValidation, static (spc, hasFluent) => - { - if (hasFluent) - spc.AddSource("FluentDtoValidator.g.cs", FluentDtoValidatorSource); - }); - // Run diagnostics on all types with ZibStack.Dto attributes var diagnosticsTargets = context.SyntaxProvider .CreateSyntaxProvider( @@ -264,34 +254,34 @@ void VisitType(INamedTypeSymbol type) .Combine(updateDtoClasses.Collect()) .Combine(combinedDtoClasses.Collect()); - context.RegisterSourceOutput(allDtoClasses.Combine(hasFluentValidation).Combine(hasTypeGen).Combine(fluentConfig), static (spc, pair) => + context.RegisterSourceOutput(allDtoClasses.Combine(hasTypeGen).Combine(fluentConfig), static (spc, pair) => { - var (((((createInfos, updateInfos), combinedInfos), hasFluent), hasTg), fluent) = pair; + var ((((createInfos, updateInfos), combinedInfos), hasTg), fluent) = pair; var nestedSeen = new HashSet(); foreach (var classInfo in createInfos) { classInfo.HasTypeGen = hasTg; ApplyFluentOverridesByClassName(classInfo, DtoKind.Create, fluent); - var source = GenerateSource(classInfo, hasFluent); + var source = GenerateSource(classInfo); spc.AddSource($"{classInfo.FullyQualifiedName}.Create.g.cs", source); - EmitDeduplicatedNested(spc, classInfo.AutoNestedDtos, nestedSeen, hasFluent); + EmitDeduplicatedNested(spc, classInfo.AutoNestedDtos, nestedSeen); } foreach (var classInfo in updateInfos) { classInfo.HasTypeGen = hasTg; ApplyFluentOverridesByClassName(classInfo, DtoKind.Update, fluent); - var source = GenerateSource(classInfo, hasFluent); + var source = GenerateSource(classInfo); spc.AddSource($"{classInfo.FullyQualifiedName}.Update.g.cs", source); - EmitDeduplicatedNested(spc, classInfo.AutoNestedDtos, nestedSeen, hasFluent); + EmitDeduplicatedNested(spc, classInfo.AutoNestedDtos, nestedSeen); } foreach (var classInfo in combinedInfos) { classInfo.HasTypeGen = hasTg; ApplyFluentOverridesByClassName(classInfo, DtoKind.Combined, fluent); - var source = GenerateSource(classInfo, hasFluent); + var source = GenerateSource(classInfo); spc.AddSource($"{classInfo.FullyQualifiedName}.Combined.g.cs", source); - EmitDeduplicatedNested(spc, classInfo.AutoNestedDtos, nestedSeen, hasFluent); + EmitDeduplicatedNested(spc, classInfo.AutoNestedDtos, nestedSeen); } }); @@ -376,26 +366,26 @@ void VisitType(INamedTypeSymbol type) .Where(static info => info is not null) .Select(static (info, _) => info!); - context.RegisterSourceOutput(crudImpliedDtos.Combine(hasFluentValidation).Combine(hasQueryDsl).Combine(hasEfCore).Combine(hasTypeGen).Combine(fluentConfig), static (spc, pair) => + context.RegisterSourceOutput(crudImpliedDtos.Combine(hasQueryDsl).Combine(hasEfCore).Combine(hasTypeGen).Combine(fluentConfig), static (spc, pair) => { - var (((((implied, hasFluent), hasDsl), hasEf), hasTg), fluent) = pair; + var ((((implied, hasDsl), hasEf), hasTg), fluent) = pair; var nestedSeen = new HashSet(); foreach (var classInfo in implied.CreateDtos) { classInfo.HasTypeGen = hasTg; ApplyFluentOverridesByClassName(classInfo, DtoKind.Create, fluent); - var source = GenerateSource(classInfo, hasFluent); + var source = GenerateSource(classInfo); spc.AddSource($"{classInfo.FullyQualifiedName}.Create.CrudImplied.g.cs", source); - EmitDeduplicatedNested(spc, classInfo.AutoNestedDtos, nestedSeen, hasFluent); + EmitDeduplicatedNested(spc, classInfo.AutoNestedDtos, nestedSeen); } foreach (var classInfo in implied.UpdateDtos) { classInfo.HasTypeGen = hasTg; ApplyFluentOverridesByClassName(classInfo, DtoKind.Update, fluent); - var source = GenerateSource(classInfo, hasFluent); + var source = GenerateSource(classInfo); spc.AddSource($"{classInfo.FullyQualifiedName}.Update.CrudImplied.g.cs", source); - EmitDeduplicatedNested(spc, classInfo.AutoNestedDtos, nestedSeen, hasFluent); + EmitDeduplicatedNested(spc, classInfo.AutoNestedDtos, nestedSeen); } foreach (var responseInfo in implied.ResponseDtos) { @@ -436,25 +426,25 @@ void VisitType(INamedTypeSymbol type) .Where(static info => info is not null) .Select(static (info, _) => info!); - context.RegisterSourceOutput(modelImpliedDtos.Combine(hasFluentValidation).Combine(hasQueryDsl).Combine(hasEfCore).Combine(hasTypeGen).Combine(fluentConfig), static (spc, pair) => + context.RegisterSourceOutput(modelImpliedDtos.Combine(hasQueryDsl).Combine(hasEfCore).Combine(hasTypeGen).Combine(fluentConfig), static (spc, pair) => { - var (((((implied, hasFluent), hasDsl), hasEf), hasTg), fluent) = pair; + var ((((implied, hasDsl), hasEf), hasTg), fluent) = pair; var nestedSeen = new HashSet(); foreach (var classInfo in implied.CreateDtos) { classInfo.HasTypeGen = hasTg; ApplyFluentOverridesByClassName(classInfo, DtoKind.Create, fluent); - var source = GenerateSource(classInfo, hasFluent); + var source = GenerateSource(classInfo); spc.AddSource($"{classInfo.FullyQualifiedName}.Create.Model.g.cs", source); - EmitDeduplicatedNested(spc, classInfo.AutoNestedDtos, nestedSeen, hasFluent); + EmitDeduplicatedNested(spc, classInfo.AutoNestedDtos, nestedSeen); } foreach (var classInfo in implied.UpdateDtos) { classInfo.HasTypeGen = hasTg; ApplyFluentOverridesByClassName(classInfo, DtoKind.Update, fluent); - var source = GenerateSource(classInfo, hasFluent); + var source = GenerateSource(classInfo); spc.AddSource($"{classInfo.FullyQualifiedName}.Update.Model.g.cs", source); - EmitDeduplicatedNested(spc, classInfo.AutoNestedDtos, nestedSeen, hasFluent); + EmitDeduplicatedNested(spc, classInfo.AutoNestedDtos, nestedSeen); } foreach (var responseInfo in implied.ResponseDtos) { @@ -485,10 +475,10 @@ void VisitType(INamedTypeSymbol type) // fluent is the sole source of truth. For classes with markers, this pipeline // is a no-op — those go through the attribute pipelines above (which themselves // pick up fluent overrides via ApplyFluentOverridesByClassName). - context.RegisterSourceOutput(fluentConfig.Combine(hasFluentValidation).Combine(hasQueryDsl).Combine(hasEfCore).Combine(hasTypeGen), + context.RegisterSourceOutput(fluentConfig.Combine(hasQueryDsl).Combine(hasEfCore).Combine(hasTypeGen), static (spc, pair) => { - var ((((parsed, hasFluent), hasDsl), hasEf), hasTg) = pair; + var (((parsed, hasDsl), hasEf), hasTg) = pair; if (parsed is null) return; var nestedSeen = new HashSet(); @@ -502,8 +492,8 @@ void VisitType(INamedTypeSymbol type) if (info is not null) { info.HasTypeGen = hasTg; - spc.AddSource($"{info.FullyQualifiedName}.Create.Fluent.g.cs", GenerateSource(info, hasFluent)); - EmitDeduplicatedNested(spc, info.AutoNestedDtos, nestedSeen, hasFluent); + spc.AddSource($"{info.FullyQualifiedName}.Create.Fluent.g.cs", GenerateSource(info)); + EmitDeduplicatedNested(spc, info.AutoNestedDtos, nestedSeen); } } if (tc.Update) @@ -512,8 +502,8 @@ void VisitType(INamedTypeSymbol type) if (info is not null) { info.HasTypeGen = hasTg; - spc.AddSource($"{info.FullyQualifiedName}.Update.Fluent.g.cs", GenerateSource(info, hasFluent)); - EmitDeduplicatedNested(spc, info.AutoNestedDtos, nestedSeen, hasFluent); + spc.AddSource($"{info.FullyQualifiedName}.Update.Fluent.g.cs", GenerateSource(info)); + EmitDeduplicatedNested(spc, info.AutoNestedDtos, nestedSeen); } } if (tc.CreateOrUpdate) @@ -522,8 +512,8 @@ void VisitType(INamedTypeSymbol type) if (info is not null) { info.HasTypeGen = hasTg; - spc.AddSource($"{info.FullyQualifiedName}.Combined.Fluent.g.cs", GenerateSource(info, hasFluent)); - EmitDeduplicatedNested(spc, info.AutoNestedDtos, nestedSeen, hasFluent); + spc.AddSource($"{info.FullyQualifiedName}.Combined.Fluent.g.cs", GenerateSource(info)); + EmitDeduplicatedNested(spc, info.AutoNestedDtos, nestedSeen); } } if (tc.Response) @@ -668,17 +658,17 @@ void VisitType(INamedTypeSymbol type) }); } - private static void EmitDeduplicatedNested(SourceProductionContext spc, List nested, HashSet seen, bool hasFluent) + private static void EmitDeduplicatedNested(SourceProductionContext spc, List nested, HashSet seen) { foreach (var n in nested) { var key = $"{n.RequestName}:{n.Kind}"; if (!seen.Add(key)) continue; - var source = GenerateSource(n, hasFluent); + var source = GenerateSource(n); var hintName = n.FullyQualifiedName.Replace("?", "_").Replace("<", "_").Replace(">", "_"); spc.AddSource($"{hintName}.{n.Kind}.AutoNested.g.cs", source); // Recurse for deeply nested - EmitDeduplicatedNested(spc, n.AutoNestedDtos, seen, hasFluent); + EmitDeduplicatedNested(spc, n.AutoNestedDtos, seen); } }