Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/src/content/docs/packages/dto.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ This page covers the core mode + generated shape. Detailed reference and feature
- [`PaginatedResponse<T>`](/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<T>`.

## Related guides

Expand Down
39 changes: 1 addition & 38 deletions docs/src/content/docs/packages/dto/json-and-validation.md
Original file line number Diff line number Diff line change
@@ -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<T>.
---

## JSON serializer support
Expand Down Expand Up @@ -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<T>` -- base class bridging FluentValidation with `IDtoValidator<T>`
- `{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<CreatePlayerRequest>
{
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -365,29 +365,6 @@ public interface ICanValidate
DtoValidationResult Validate();
}
}
";

private const string FluentDtoValidatorSource = @"// <auto-generated />
#nullable enable

namespace ZibStack.NET.Dto
{
/// <summary>
/// Base class that bridges FluentValidation with ZibStack.NET.Dto validation.
/// Extend this instead of AbstractValidator to use with [Dto(CreateValidator/UpdateValidator)].
/// </summary>
public abstract class FluentDtoValidator<T> : FluentValidation.AbstractValidator<T>, IDtoValidator<T>
{
DtoValidationResult IDtoValidator<T>.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 = @"// <auto-generated />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
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("// <auto-generated />");
Expand All @@ -45,31 +45,14 @@
{
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;
}

Expand Down Expand Up @@ -311,7 +294,7 @@
/// <summary>Non-nullable reference types need null validation.</summary>
private static void EmitAutoNestedDto(StringBuilder sb, DtoClassInfo nested)
{
var nestedSource = GenerateSource(nested, false);

Check failure on line 297 in packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Generation.cs

View workflow job for this annotation

GitHub Actions / build-and-test

No overload for method 'GenerateSource' takes 2 arguments

Check failure on line 297 in packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Generation.cs

View workflow job for this annotation

GitHub Actions / build-and-test

No overload for method 'GenerateSource' takes 2 arguments
var lines = nestedSource.Split('\n');
var skip = true;
foreach (var line in lines)
Expand Down Expand Up @@ -987,45 +970,6 @@
sb.AppendLine(" }");
}

private static void GenerateFluentBaseValidator(StringBuilder sb, DtoClassInfo classInfo, bool isCreate, bool combined)
{
var requestName = combined
? classInfo.RequestName
: (isCreate ? classInfo.RequestName : classInfo.RequestName);
var validatorSuffix = isCreate ? "CreateBaseValidator" : "UpdateBaseValidator";
var props = isCreate
? classInfo.Properties.Where(p => !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();
Expand Down
72 changes: 31 additions & 41 deletions packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
Expand Down Expand Up @@ -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<string>();

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);
}
});

Expand Down Expand Up @@ -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<string>();

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)
{
Expand Down Expand Up @@ -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<string>();
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)
{
Expand Down Expand Up @@ -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<string>();
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -668,17 +658,17 @@ void VisitType(INamedTypeSymbol type)
});
}

private static void EmitDeduplicatedNested(SourceProductionContext spc, List<DtoClassInfo> nested, HashSet<string> seen, bool hasFluent)
private static void EmitDeduplicatedNested(SourceProductionContext spc, List<DtoClassInfo> nested, HashSet<string> 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);
}
}

Expand Down
Loading