From 1073fb141a69ac4561f36c0e64a33d0f1dff21d1 Mon Sep 17 00:00:00 2001 From: Jorge Durban Date: Wed, 3 Jun 2026 08:48:16 +0200 Subject: [PATCH 1/9] Add rule coverage tests --- .../RuleGenerationTests.cs | 97 +++++ .../RuleBehaviorTests.cs | 399 ++++++++++++++++++ 2 files changed, 496 insertions(+) create mode 100644 tests/TinyValidations.SourceGen.Tests/RuleGenerationTests.cs create mode 100644 tests/TinyValidations.Tests/RuleBehaviorTests.cs diff --git a/tests/TinyValidations.SourceGen.Tests/RuleGenerationTests.cs b/tests/TinyValidations.SourceGen.Tests/RuleGenerationTests.cs new file mode 100644 index 0000000..7843f67 --- /dev/null +++ b/tests/TinyValidations.SourceGen.Tests/RuleGenerationTests.cs @@ -0,0 +1,97 @@ +using Xunit; + +namespace TinyValidations.SourceGen.Tests; + +public sealed class RuleGenerationTests +{ + [Fact] + public void Generates_runtime_checks_for_every_allowed_rule() + { + var source = """ +using TinyValidations; + +public sealed class RuleCoverageValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Required(x => x.Required); + rules.HasText(x => x.Text); + rules.NotNull(x => x.NotNull); + rules.HasItems(x => x.Items); + rules.Email(x => x.Email); + rules.TextLengthAtLeast(x => x.MinimumLength, 3); + rules.TextLengthAtMost(x => x.MaximumLength, 5); + rules.Above(x => x.Above, 10); + rules.AtLeast(x => x.AtLeast, 10); + rules.Below(x => x.Below, 10); + rules.AtMost(x => x.AtMost, 10); + rules.Matches(x => x.Pattern, "^[A-Z]{3}$"); + rules.Requires(x => x.RequiredPrefix, RuleCoverageRequirements.StartsWithOk, "RequiredPrefix must start with OK-."); + rules.Use(); + } +} + +public static class RuleCoverageRequirements +{ + public static bool StartsWithOk(string? value) => value is not null && value.StartsWith("OK-"); +} + +public sealed class RuleCoverageCustomRule : IAsyncValidationRule +{ + public System.Threading.Tasks.ValueTask ValidateAsync(RuleCoverageCommand instance, ValidationErrorCollection errors, System.Threading.CancellationToken cancellationToken) => System.Threading.Tasks.ValueTask.CompletedTask; +} + +public sealed class RuleCoverageCommand +{ + public string? Required { get; init; } + public string? Text { get; init; } + public string? NotNull { get; init; } + public System.Collections.Generic.IEnumerable? Items { get; init; } + public string? Email { get; init; } + public string? MinimumLength { get; init; } + public string? MaximumLength { get; init; } + public int Above { get; init; } + public int AtLeast { get; init; } + public int Below { get; init; } + public int AtMost { get; init; } + public string? Pattern { get; init; } + public string? RequiredPrefix { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var text = result.SingleGeneratedSource(); + + result.ShouldHaveNoDiagnostics(); + Assert.Contains("if (instance.Required is null)", text); + Assert.Contains("else if (instance.Required is string __text)", text); + Assert.Contains("if (string.IsNullOrWhiteSpace(__text))", text); + Assert.Contains("errors.Add(\"Required\", \"Required is required.\");", text); + Assert.Contains("if (string.IsNullOrWhiteSpace(instance.Text))", text); + Assert.Contains("errors.Add(\"Text\", \"Text must contain text.\");", text); + Assert.Contains("if (instance.NotNull is null)", text); + Assert.Contains("errors.Add(\"NotNull\", \"NotNull must not be null.\");", text); + Assert.Contains("if (instance.Items is null)", text); + Assert.Contains("else if (!instance.Items.Any())", text); + Assert.Contains("errors.Add(\"Items\", \"Items must contain at least one item.\");", text); + Assert.Contains("if (!global::TinyValidations.TinyEmailAddress.IsValid(instance.Email))", text); + Assert.Contains("errors.Add(\"Email\", \"Email must be a valid email address.\");", text); + Assert.Contains("if (instance.MinimumLength.Length < 3)", text); + Assert.Contains("errors.Add(\"MinimumLength\", \"MinimumLength must contain at least 3 characters.\");", text); + Assert.Contains("if (instance.MaximumLength.Length > 5)", text); + Assert.Contains("errors.Add(\"MaximumLength\", \"MaximumLength must contain at most 5 characters.\");", text); + Assert.Contains("if (instance.Above <= 10)", text); + Assert.Contains("errors.Add(\"Above\", \"Above must be above 10.\");", text); + Assert.Contains("if (instance.AtLeast < 10)", text); + Assert.Contains("errors.Add(\"AtLeast\", \"AtLeast must be at least 10.\");", text); + Assert.Contains("if (instance.Below >= 10)", text); + Assert.Contains("errors.Add(\"Below\", \"Below must be below 10.\");", text); + Assert.Contains("if (instance.AtMost > 10)", text); + Assert.Contains("errors.Add(\"AtMost\", \"AtMost must be at most 10.\");", text); + Assert.Contains("if (!global::System.Text.RegularExpressions.Regex.IsMatch(instance.Pattern, \"^[A-Z]{3}$\"))", text); + Assert.Contains("errors.Add(\"Pattern\", \"Pattern has an invalid format.\");", text); + Assert.Contains("if (!global::RuleCoverageRequirements.StartsWithOk(instance.RequiredPrefix))", text); + Assert.Contains("errors.Add(\"RequiredPrefix\", \"RequiredPrefix must start with OK-.\");", text); + Assert.Contains("await _rulecoveragecustomrule.ValidateAsync(instance, errors, cancellationToken).ConfigureAwait(false);", text); + } +} diff --git a/tests/TinyValidations.Tests/RuleBehaviorTests.cs b/tests/TinyValidations.Tests/RuleBehaviorTests.cs new file mode 100644 index 0000000..9cd8459 --- /dev/null +++ b/tests/TinyValidations.Tests/RuleBehaviorTests.cs @@ -0,0 +1,399 @@ +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace TinyValidations.Tests; + +public sealed class RuleBehaviorTests +{ + [Fact] + public async Task Required_rejects_null_empty_and_whitespace_text() + { + var validator = BuildValidator(); + + var nullResult = await validator.ValidateAsync(new RequiredRuleCommand(null)); + var emptyResult = await validator.ValidateAsync(new RequiredRuleCommand(string.Empty)); + var whitespaceResult = await validator.ValidateAsync(new RequiredRuleCommand(" ")); + var validResult = await validator.ValidateAsync(new RequiredRuleCommand("value")); + + AssertHasError(nullResult, nameof(RequiredRuleCommand.Value), "Value is required."); + AssertHasError(emptyResult, nameof(RequiredRuleCommand.Value), "Value is required."); + AssertHasError(whitespaceResult, nameof(RequiredRuleCommand.Value), "Value is required."); + AssertValid(validResult); + } + + [Fact] + public async Task HasText_rejects_null_empty_and_whitespace_text() + { + var validator = BuildValidator(); + + var nullResult = await validator.ValidateAsync(new HasTextRuleCommand(null)); + var emptyResult = await validator.ValidateAsync(new HasTextRuleCommand(string.Empty)); + var whitespaceResult = await validator.ValidateAsync(new HasTextRuleCommand(" ")); + var validResult = await validator.ValidateAsync(new HasTextRuleCommand("value")); + + AssertHasError(nullResult, nameof(HasTextRuleCommand.Value), "Value must contain text."); + AssertHasError(emptyResult, nameof(HasTextRuleCommand.Value), "Value must contain text."); + AssertHasError(whitespaceResult, nameof(HasTextRuleCommand.Value), "Value must contain text."); + AssertValid(validResult); + } + + [Fact] + public async Task NotNull_rejects_null_values() + { + var validator = BuildValidator(); + + var nullResult = await validator.ValidateAsync(new NotNullRuleCommand(null)); + var validResult = await validator.ValidateAsync(new NotNullRuleCommand("value")); + + AssertHasError(nullResult, nameof(NotNullRuleCommand.Value), "Value must not be null."); + AssertValid(validResult); + } + + [Fact] + public async Task HasItems_rejects_null_and_empty_collections() + { + var validator = BuildValidator(); + + var nullResult = await validator.ValidateAsync(new HasItemsRuleCommand(null)); + var emptyResult = await validator.ValidateAsync(new HasItemsRuleCommand(Array.Empty())); + var validResult = await validator.ValidateAsync(new HasItemsRuleCommand(new[] { "value" })); + + AssertHasError(nullResult, nameof(HasItemsRuleCommand.Values), "Values must contain at least one item."); + AssertHasError(emptyResult, nameof(HasItemsRuleCommand.Values), "Values must contain at least one item."); + AssertValid(validResult); + } + + [Fact] + public async Task Email_rejects_invalid_email_and_allows_empty_values() + { + var validator = BuildValidator(); + + var invalidResult = await validator.ValidateAsync(new EmailRuleCommand("not-email")); + var nullResult = await validator.ValidateAsync(new EmailRuleCommand(null)); + var emptyResult = await validator.ValidateAsync(new EmailRuleCommand(string.Empty)); + var validResult = await validator.ValidateAsync(new EmailRuleCommand("person@example.com")); + + AssertHasError(invalidResult, nameof(EmailRuleCommand.Value), "Value must be a valid email address."); + AssertValid(nullResult); + AssertValid(emptyResult); + AssertValid(validResult); + } + + [Fact] + public async Task TextLengthAtLeast_rejects_short_text_and_allows_null() + { + var validator = BuildValidator(); + + var invalidResult = await validator.ValidateAsync(new TextLengthAtLeastRuleCommand("ab")); + var nullResult = await validator.ValidateAsync(new TextLengthAtLeastRuleCommand(null)); + var boundaryResult = await validator.ValidateAsync(new TextLengthAtLeastRuleCommand("abc")); + + AssertHasError(invalidResult, nameof(TextLengthAtLeastRuleCommand.Value), "Value must contain at least 3 characters."); + AssertValid(nullResult); + AssertValid(boundaryResult); + } + + [Fact] + public async Task TextLengthAtMost_rejects_long_text_and_allows_null() + { + var validator = BuildValidator(); + + var invalidResult = await validator.ValidateAsync(new TextLengthAtMostRuleCommand("abcd")); + var nullResult = await validator.ValidateAsync(new TextLengthAtMostRuleCommand(null)); + var boundaryResult = await validator.ValidateAsync(new TextLengthAtMostRuleCommand("abc")); + + AssertHasError(invalidResult, nameof(TextLengthAtMostRuleCommand.Value), "Value must contain at most 3 characters."); + AssertValid(nullResult); + AssertValid(boundaryResult); + } + + [Fact] + public async Task Above_rejects_values_equal_to_or_below_threshold() + { + var validator = BuildValidator(); + + var belowResult = await validator.ValidateAsync(new AboveRuleCommand(9)); + var equalResult = await validator.ValidateAsync(new AboveRuleCommand(10)); + var validResult = await validator.ValidateAsync(new AboveRuleCommand(11)); + + AssertHasError(belowResult, nameof(AboveRuleCommand.Value), "Value must be above 10."); + AssertHasError(equalResult, nameof(AboveRuleCommand.Value), "Value must be above 10."); + AssertValid(validResult); + } + + [Fact] + public async Task AtLeast_rejects_values_below_threshold() + { + var validator = BuildValidator(); + + var invalidResult = await validator.ValidateAsync(new AtLeastRuleCommand(9)); + var boundaryResult = await validator.ValidateAsync(new AtLeastRuleCommand(10)); + + AssertHasError(invalidResult, nameof(AtLeastRuleCommand.Value), "Value must be at least 10."); + AssertValid(boundaryResult); + } + + [Fact] + public async Task Below_rejects_values_equal_to_or_above_threshold() + { + var validator = BuildValidator(); + + var equalResult = await validator.ValidateAsync(new BelowRuleCommand(10)); + var aboveResult = await validator.ValidateAsync(new BelowRuleCommand(11)); + var validResult = await validator.ValidateAsync(new BelowRuleCommand(9)); + + AssertHasError(equalResult, nameof(BelowRuleCommand.Value), "Value must be below 10."); + AssertHasError(aboveResult, nameof(BelowRuleCommand.Value), "Value must be below 10."); + AssertValid(validResult); + } + + [Fact] + public async Task AtMost_rejects_values_above_threshold() + { + var validator = BuildValidator(); + + var invalidResult = await validator.ValidateAsync(new AtMostRuleCommand(11)); + var boundaryResult = await validator.ValidateAsync(new AtMostRuleCommand(10)); + + AssertHasError(invalidResult, nameof(AtMostRuleCommand.Value), "Value must be at most 10."); + AssertValid(boundaryResult); + } + + [Fact] + public async Task Matches_rejects_pattern_mismatches_and_allows_empty_values() + { + var validator = BuildValidator(); + + var invalidResult = await validator.ValidateAsync(new MatchesRuleCommand("abc")); + var nullResult = await validator.ValidateAsync(new MatchesRuleCommand(null)); + var emptyResult = await validator.ValidateAsync(new MatchesRuleCommand(string.Empty)); + var validResult = await validator.ValidateAsync(new MatchesRuleCommand("ABC")); + + AssertHasError(invalidResult, nameof(MatchesRuleCommand.Value), "Value has an invalid format."); + AssertValid(nullResult); + AssertValid(emptyResult); + AssertValid(validResult); + } + + [Fact] + public async Task Requires_rejects_when_requirement_returns_false() + { + var validator = BuildValidator(); + + var invalidResult = await validator.ValidateAsync(new RequiresRuleCommand("BAD-123")); + var validResult = await validator.ValidateAsync(new RequiresRuleCommand("OK-123")); + + AssertHasError(invalidResult, nameof(RequiresRuleCommand.Value), "Value must start with OK-."); + AssertValid(validResult); + } + + [Fact] + public async Task Use_invokes_custom_async_rule() + { + var validator = BuildValidator(); + + var invalidResult = await validator.ValidateAsync(new UseRuleCommand("reserved")); + var validResult = await validator.ValidateAsync(new UseRuleCommand("available")); + + AssertHasError(invalidResult, nameof(UseRuleCommand.Value), "Value is reserved."); + AssertValid(validResult); + } + + private static ITinyValidator BuildValidator() + { + var services = new ServiceCollection(); + + services.UseTinyValidations(); + + var provider = services.BuildServiceProvider(); + return provider.GetRequiredService(); + } + + private static void AssertValid(ValidationResult result) + { + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + } + + private static void AssertHasError(ValidationResult result, string member, string message) + { + Assert.False(result.IsValid); + + foreach (var error in result.Errors) + { + if (error.Member != member) + { + continue; + } + + Assert.Equal(message, error.Message); + return; + } + + Assert.Fail("Expected validation error for " + member + "."); + } +} + +public sealed record RequiredRuleCommand(string? Value); + +public sealed class RequiredRuleCommandValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Required(x => x.Value); + } +} + +public sealed record HasTextRuleCommand(string? Value); + +public sealed class HasTextRuleCommandValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.HasText(x => x.Value); + } +} + +public sealed record NotNullRuleCommand(string? Value); + +public sealed class NotNullRuleCommandValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.NotNull(x => x.Value); + } +} + +public sealed record HasItemsRuleCommand(IReadOnlyCollection? Values); + +public sealed class HasItemsRuleCommandValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.HasItems(x => x.Values); + } +} + +public sealed record EmailRuleCommand(string? Value); + +public sealed class EmailRuleCommandValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Email(x => x.Value); + } +} + +public sealed record TextLengthAtLeastRuleCommand(string? Value); + +public sealed class TextLengthAtLeastRuleCommandValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.TextLengthAtLeast(x => x.Value, 3); + } +} + +public sealed record TextLengthAtMostRuleCommand(string? Value); + +public sealed class TextLengthAtMostRuleCommandValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.TextLengthAtMost(x => x.Value, 3); + } +} + +public sealed record AboveRuleCommand(int Value); + +public sealed class AboveRuleCommandValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Above(x => x.Value, 10); + } +} + +public sealed record AtLeastRuleCommand(int Value); + +public sealed class AtLeastRuleCommandValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.AtLeast(x => x.Value, 10); + } +} + +public sealed record BelowRuleCommand(int Value); + +public sealed class BelowRuleCommandValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Below(x => x.Value, 10); + } +} + +public sealed record AtMostRuleCommand(int Value); + +public sealed class AtMostRuleCommandValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.AtMost(x => x.Value, 10); + } +} + +public sealed record MatchesRuleCommand(string? Value); + +public sealed class MatchesRuleCommandValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Matches(x => x.Value, "^[A-Z]{3}$"); + } +} + +public sealed record RequiresRuleCommand(string? Value); + +public sealed class RequiresRuleCommandValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Requires(x => x.Value, RequiresRuleRequirements.StartsWithOk, "Value must start with OK-."); + } +} + +public static class RequiresRuleRequirements +{ + public static bool StartsWithOk(string? value) + { + return value is not null && value.StartsWith("OK-", StringComparison.Ordinal); + } +} + +public sealed record UseRuleCommand(string Value); + +public sealed class UseRuleCommandValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Use(); + } +} + +public sealed class UseRuleCustomRule : IAsyncValidationRule +{ + public ValueTask ValidateAsync( + UseRuleCommand instance, + ValidationErrorCollection errors, + CancellationToken cancellationToken) + { + if (instance.Value == "reserved") + { + errors.Add(nameof(UseRuleCommand.Value), "Value is reserved."); + } + + return ValueTask.CompletedTask; + } +} From 0e392c655d1fac0847174159065ff718908fad37 Mon Sep 17 00:00:00 2001 From: Jorge Durban Date: Wed, 3 Jun 2026 08:52:42 +0200 Subject: [PATCH 2/9] Harden source generator diagnostics --- .../Generation/SourceGenerator.cs | 17 +++++++ .../DiagnosticTests.cs | 35 ++++++++++----- .../DiscoveryTests.cs | 5 +++ .../GenerationTests.cs | 4 ++ .../RuleGenerationTests.cs | 1 + .../SourceGeneratorTestHost.cs | 44 +++++++++++++++---- 6 files changed, 85 insertions(+), 21 deletions(-) diff --git a/src/TinyValidations.SourceGen/Generation/SourceGenerator.cs b/src/TinyValidations.SourceGen/Generation/SourceGenerator.cs index 84ec0db..61085b2 100644 --- a/src/TinyValidations.SourceGen/Generation/SourceGenerator.cs +++ b/src/TinyValidations.SourceGen/Generation/SourceGenerator.cs @@ -1,7 +1,9 @@ using System.Collections.Immutable; +using System.Linq; using Microsoft.CodeAnalysis; using TinyValidations.SourceGen.Analysis.Declarations; using TinyValidations.SourceGen.Emission.Source; +using TinyValidations.SourceGen.Model; using TinyValidations.SourceGen.Planning; using TinyValidations.SourceGen.Validation; @@ -22,6 +24,11 @@ internal sealed class SourceGenerator var model = _analysis.Analyze(compilation, candidates); _validation.Validate(model, reportDiagnostic); + if (HasInvalidDefinitions(model)) + { + return null; + } + if (model.Validations.Count == 0) { return null; @@ -30,5 +37,15 @@ internal sealed class SourceGenerator var plan = _planning.Build(model); return _emission.Emit(plan); } + + private static bool HasInvalidDefinitions(ValidationDefinitionSet model) + { + if (model.Issues.Count > 0) + { + return true; + } + + return model.Validations.Any(validation => validation.Rules.Count == 0); + } } } diff --git a/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs b/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs index 52ab100..0c652ab 100644 --- a/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs @@ -23,7 +23,7 @@ public sealed class CreateUser } """; - var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + var diagnostic = SingleDiagnosticWithNoSource(source); Assert.Equal("TV0006", diagnostic.Id); } @@ -46,7 +46,7 @@ public sealed class CreateUser } """; - var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + var diagnostic = SingleDiagnosticWithNoSource(source); Assert.Equal("TV0001", diagnostic.Id); Assert.Equal( @@ -75,7 +75,7 @@ public sealed class CreateUser } """; - var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + var diagnostic = SingleDiagnosticWithNoSource(source); Assert.Equal("TV0001", diagnostic.Id); } @@ -99,7 +99,7 @@ public sealed class CreateUser } """; - var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + var diagnostic = SingleDiagnosticWithNoSource(source); Assert.Equal("TV0001", diagnostic.Id); } @@ -124,7 +124,7 @@ public sealed class CreateUser } """; - var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + var diagnostic = SingleDiagnosticWithNoSource(source); Assert.Equal("TV0002", diagnostic.Id); } @@ -149,7 +149,7 @@ public sealed class CreateUser } """; - var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + var diagnostic = SingleDiagnosticWithNoSource(source); Assert.Equal("TV0003", diagnostic.Id); } @@ -175,7 +175,7 @@ public sealed class CreateUser } """; - var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + var diagnostic = SingleDiagnosticWithNoSource(source); Assert.Equal("TV0004", diagnostic.Id); } @@ -201,7 +201,7 @@ public sealed class CreateUser } """; - var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + var diagnostic = SingleDiagnosticWithNoSource(source); Assert.Equal("TV0004", diagnostic.Id); } @@ -235,7 +235,7 @@ public sealed class CreateOrder } """; - var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + var diagnostic = SingleDiagnosticWithNoSource(source); Assert.Equal("TV0004", diagnostic.Id); } @@ -268,7 +268,7 @@ public sealed class CreateOrder } """; - var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + var diagnostic = SingleDiagnosticWithNoSource(source); Assert.Equal("TV0004", diagnostic.Id); } @@ -301,7 +301,7 @@ public sealed class CreateOrder } """; - var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + var diagnostic = SingleDiagnosticWithNoSource(source); Assert.Equal("TV0004", diagnostic.Id); } @@ -329,8 +329,19 @@ public sealed class CreateUser } """; - var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + var diagnostic = SingleDiagnosticWithNoSource(source); Assert.Equal("TV0005", diagnostic.Id); } + + private static Microsoft.CodeAnalysis.Diagnostic SingleDiagnosticWithNoSource(string source) + { + var result = SourceGeneratorTestHost.Run(source); + var diagnostic = result.SingleDiagnostic(); + + result.ShouldGenerateNoSource(); + + return diagnostic; + } } + diff --git a/tests/TinyValidations.SourceGen.Tests/DiscoveryTests.cs b/tests/TinyValidations.SourceGen.Tests/DiscoveryTests.cs index 2289fd8..6d45702 100644 --- a/tests/TinyValidations.SourceGen.Tests/DiscoveryTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/DiscoveryTests.cs @@ -39,6 +39,8 @@ public sealed class CreateUserValidation : IValidation { public void Define(ValidationRules rules) { + rules.Required(x => x.Name); + var other = new OtherRules(); other.Required(x => x.Email); } @@ -53,6 +55,7 @@ public void Required(System.Func member) public sealed class CreateUser { + public string? Name { get; init; } public string? Email { get; init; } } """; @@ -60,6 +63,8 @@ public sealed class CreateUser var result = SourceGeneratorTestHost.Run(source); var text = result.SingleGeneratedSource(); + result.ShouldHaveNoDiagnostics(); + Assert.Contains("Name is required.", text); Assert.DoesNotContain("Email is required.", text); } diff --git a/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs b/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs index b064e14..5ce9200 100644 --- a/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs @@ -36,6 +36,7 @@ public sealed class CreateUser var result = SourceGeneratorTestHost.Run(source); var text = result.SingleGeneratedSource(); + result.ShouldHaveNoCompilationErrors(); Assert.Contains("ITinyValidationRunner", text); Assert.Contains("TinyGeneratedValidationContribution", text); Assert.Contains("TinyGeneratedValidationModuleInitializer", text); @@ -75,6 +76,7 @@ public sealed class CreateUser var text = result.SingleGeneratedSource(); result.ShouldHaveNoDiagnostics(); + result.ShouldHaveNoCompilationErrors(); Assert.Contains("ITinyValidationRunner", text); Assert.Contains("Email is required.", text); } @@ -105,6 +107,7 @@ public sealed class CreateUser var text = result.SingleGeneratedSource(); result.ShouldHaveNoDiagnostics(); + result.ShouldHaveNoCompilationErrors(); Assert.Contains("Email is required.", text); Assert.Contains("DisplayName must contain at least 2 characters.", text); } @@ -141,6 +144,7 @@ public sealed class CreateOrder var text = result.SingleGeneratedSource(); result.ShouldHaveNoDiagnostics(); + result.ShouldHaveNoCompilationErrors(); Assert.Contains("if (!global::OrderNumberRequirements.HasOrderPrefix(instance.OrderNumber))", text); Assert.Contains("errors.Add(\"OrderNumber\", \"Order number must start with ORD-.\");", text); } diff --git a/tests/TinyValidations.SourceGen.Tests/RuleGenerationTests.cs b/tests/TinyValidations.SourceGen.Tests/RuleGenerationTests.cs index 7843f67..9c48518 100644 --- a/tests/TinyValidations.SourceGen.Tests/RuleGenerationTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/RuleGenerationTests.cs @@ -63,6 +63,7 @@ public sealed class RuleCoverageCommand var text = result.SingleGeneratedSource(); result.ShouldHaveNoDiagnostics(); + result.ShouldHaveNoCompilationErrors(); Assert.Contains("if (instance.Required is null)", text); Assert.Contains("else if (instance.Required is string __text)", text); Assert.Contains("if (string.IsNullOrWhiteSpace(__text))", text); diff --git a/tests/TinyValidations.SourceGen.Tests/SourceGeneratorTestHost.cs b/tests/TinyValidations.SourceGen.Tests/SourceGeneratorTestHost.cs index 9bac42b..d14d7a3 100644 --- a/tests/TinyValidations.SourceGen.Tests/SourceGeneratorTestHost.cs +++ b/tests/TinyValidations.SourceGen.Tests/SourceGeneratorTestHost.cs @@ -12,28 +12,46 @@ public static SourceGeneratorRun Run(string source) var compilation = CSharpCompilation.Create( "Tests", new[] { CSharpSyntaxTree.ParseText(source) }, - new[] - { - MetadataReference.CreateFromFile(typeof(object).Assembly.Location), - MetadataReference.CreateFromFile(typeof(System.Linq.Expressions.Expression).Assembly.Location), - MetadataReference.CreateFromFile(typeof(TinyValidations.IValidation<>).Assembly.Location) - }, + GetMetadataReferences(), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); GeneratorDriver driver = CSharpGeneratorDriver.Create(new ValidationSourceGenerator()); - driver = driver.RunGenerators(compilation); + driver = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out var outputCompilation, + out _); + + return new SourceGeneratorRun(driver.GetRunResult(), outputCompilation); + } + + private static MetadataReference[] GetMetadataReferences() + { + var trustedAssemblies = + ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); - return new SourceGeneratorRun(driver.GetRunResult()); + return trustedAssemblies + .Concat(new[] + { + typeof(TinyValidations.IValidation<>).Assembly.Location, + typeof(Microsoft.Extensions.DependencyInjection.ServiceDescriptor).Assembly.Location, + typeof(Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions).Assembly.Location + }) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(path => MetadataReference.CreateFromFile(path)) + .ToArray(); } } internal sealed class SourceGeneratorRun { private readonly GeneratorDriverRunResult _result; + private readonly Compilation _compilation; - public SourceGeneratorRun(GeneratorDriverRunResult result) + public SourceGeneratorRun(GeneratorDriverRunResult result, Compilation compilation) { _result = result; + _compilation = compilation; } public Diagnostic SingleDiagnostic() @@ -52,6 +70,14 @@ public void ShouldHaveNoDiagnostics() Assert.Empty(_result.Diagnostics); } + public void ShouldHaveNoCompilationErrors() + { + var errors = _compilation.GetDiagnostics() + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error); + + Assert.Empty(errors); + } + public void ShouldGenerateNoSource() { Assert.Empty(_result.GeneratedTrees); From ab9d1124c507501ac6b89709b344a0f31bd874c9 Mon Sep 17 00:00:00 2001 From: Jorge Durban Date: Wed, 3 Jun 2026 09:02:17 +0200 Subject: [PATCH 3/9] Let validation control generation --- .../Generation/SourceGenerator.cs | 17 ++--------------- .../Validation/ValidationDefinitionValidator.cs | 10 ++++++++-- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/TinyValidations.SourceGen/Generation/SourceGenerator.cs b/src/TinyValidations.SourceGen/Generation/SourceGenerator.cs index 61085b2..236030b 100644 --- a/src/TinyValidations.SourceGen/Generation/SourceGenerator.cs +++ b/src/TinyValidations.SourceGen/Generation/SourceGenerator.cs @@ -1,9 +1,7 @@ using System.Collections.Immutable; -using System.Linq; using Microsoft.CodeAnalysis; using TinyValidations.SourceGen.Analysis.Declarations; using TinyValidations.SourceGen.Emission.Source; -using TinyValidations.SourceGen.Model; using TinyValidations.SourceGen.Planning; using TinyValidations.SourceGen.Validation; @@ -22,9 +20,8 @@ internal sealed class SourceGenerator System.Action reportDiagnostic) { var model = _analysis.Analyze(compilation, candidates); - _validation.Validate(model, reportDiagnostic); - - if (HasInvalidDefinitions(model)) + var canGenerate = _validation.Validate(model, reportDiagnostic); + if (!canGenerate) { return null; } @@ -37,15 +34,5 @@ internal sealed class SourceGenerator var plan = _planning.Build(model); return _emission.Emit(plan); } - - private static bool HasInvalidDefinitions(ValidationDefinitionSet model) - { - if (model.Issues.Count > 0) - { - return true; - } - - return model.Validations.Any(validation => validation.Rules.Count == 0); - } } } diff --git a/src/TinyValidations.SourceGen/Validation/ValidationDefinitionValidator.cs b/src/TinyValidations.SourceGen/Validation/ValidationDefinitionValidator.cs index 0a29e26..f02aa48 100644 --- a/src/TinyValidations.SourceGen/Validation/ValidationDefinitionValidator.cs +++ b/src/TinyValidations.SourceGen/Validation/ValidationDefinitionValidator.cs @@ -6,18 +6,21 @@ namespace TinyValidations.SourceGen.Validation { internal sealed class ValidationDefinitionValidator { - public void Validate( + public bool Validate( ValidationDefinitionSet definitions, Action reportDiagnostic) { + var canGenerate = true; + foreach (var issue in definitions.Issues) { ReportIssue(issue, reportDiagnostic); + canGenerate = false; } if (definitions.Issues.Count > 0) { - return; + return false; } foreach (var definition in definitions.Validations) @@ -28,7 +31,10 @@ public void Validate( } ReportEmptyValidation(definition, reportDiagnostic); + canGenerate = false; } + + return canGenerate; } private static bool HasRules(ValidationDefinition definition) From 66819139736fd7206c9256da3361876e21d6b7c8 Mon Sep 17 00:00:00 2001 From: Jorge Durban Date: Wed, 3 Jun 2026 09:16:46 +0200 Subject: [PATCH 4/9] Harden comparable rule generation --- .../RuleInvocations/CustomRuleAnalyzer.cs | 13 ++-- .../RuleInvocations/MemberRuleAnalyzer.cs | 59 ++++++++++++++++++- .../RuleInvocations/RequiresRuleAnalyzer.cs | 15 ++--- .../RuleInvocations/RuleArgumentAnalyzer.cs | 22 +++++++ .../RuleInvocations/RuleInvocationAnalyzer.cs | 2 +- .../Emission/Rules/ComparableRuleEmitter.cs | 16 +++-- .../Model/RuleDefinition.cs | 10 +++- .../Abstractions/ValidationRules.cs | 12 ++-- .../RuleGenerationTests.cs | 36 +++++++++-- .../RuleBehaviorTests.cs | 24 ++++++++ 10 files changed, 178 insertions(+), 31 deletions(-) diff --git a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/CustomRuleAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/CustomRuleAnalyzer.cs index 1957876..63d5957 100644 --- a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/CustomRuleAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/CustomRuleAnalyzer.cs @@ -35,12 +35,13 @@ public RuleAnalysisResult Analyze( private static RuleAnalysisResult CreateRule(string customRuleType) { return RuleAnalysisResult.ForRule(new RuleDefinition( - RuleKind.Use, - string.Empty, - string.Empty, - string.Empty, - string.Empty, - customRuleType)); + kind: RuleKind.Use, + memberPath: string.Empty, + memberAccess: string.Empty, + argument: string.Empty, + argumentDisplay: string.Empty, + message: string.Empty, + customRuleType: customRuleType)); } private static bool HasSingleTypeArgument(GenericNameSyntax genericName) diff --git a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberRuleAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberRuleAnalyzer.cs index eeb9d33..9192944 100644 --- a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberRuleAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberRuleAnalyzer.cs @@ -1,3 +1,4 @@ +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; using TinyValidations.SourceGen.Model; @@ -8,7 +9,10 @@ internal sealed class MemberRuleAnalyzer private readonly MemberAccessAnalyzer _memberAccessAnalyzer = new MemberAccessAnalyzer(); private readonly RuleArgumentAnalyzer _argumentAnalyzer = new RuleArgumentAnalyzer(); - public RuleAnalysisResult Analyze(RuleKind kind, InvocationExpressionSyntax invocation) + public RuleAnalysisResult Analyze( + SemanticModel semanticModel, + RuleKind kind, + InvocationExpressionSyntax invocation) { if (!HasSelector(invocation)) { @@ -29,24 +33,29 @@ public RuleAnalysisResult Analyze(RuleKind kind, InvocationExpressionSyntax invo return RuleAnalysisIssue.UnsupportedArgument(invocation, invocation.ToString()); } - return CreateRule(kind, invocation, member); + return CreateRule(semanticModel, kind, invocation, member); } private RuleAnalysisResult CreateRule( + SemanticModel semanticModel, RuleKind kind, InvocationExpressionSyntax invocation, AnalyzedMemberAccess member) { var argument = _argumentAnalyzer.GetRuleArgument(kind, invocation); + var argumentDisplay = _argumentAnalyzer.GetRuleArgumentDisplay(kind, invocation); var message = _argumentAnalyzer.GetMessage(kind, invocation); + var comparisonTypeName = GetComparisonTypeName(semanticModel, kind, invocation); return RuleAnalysisResult.ForRule(new RuleDefinition( kind, member.Path, member.Access, argument, + argumentDisplay, message, - string.Empty)); + string.Empty, + comparisonTypeName: comparisonTypeName)); } private AnalyzedMemberAccess? AnalyzeSelector(ArgumentSyntax selectorArgument) @@ -105,5 +114,49 @@ private static bool HasArgument(InvocationExpressionSyntax invocation, int argum { return invocation.ArgumentList.Arguments.Count > argumentIndex; } + + private static string GetComparisonTypeName( + SemanticModel semanticModel, + RuleKind kind, + InvocationExpressionSyntax invocation) + { + if (!IsComparableRule(kind)) + { + return string.Empty; + } + + var symbol = semanticModel.GetSymbolInfo(invocation).Symbol; + if (!(symbol is IMethodSymbol method)) + { + return string.Empty; + } + + if (method.TypeArguments.Length != 1) + { + return string.Empty; + } + + return method.TypeArguments[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + + private static bool IsComparableRule(RuleKind kind) + { + if (kind == RuleKind.Above) + { + return true; + } + + if (kind == RuleKind.AtLeast) + { + return true; + } + + if (kind == RuleKind.Below) + { + return true; + } + + return kind == RuleKind.AtMost; + } } } diff --git a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RequiresRuleAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RequiresRuleAnalyzer.cs index 6260e1d..c81426c 100644 --- a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RequiresRuleAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RequiresRuleAnalyzer.cs @@ -54,13 +54,14 @@ private RuleAnalysisResult CreateRule( var message = messageArgument.Expression.ToString(); return RuleAnalysisResult.ForRule(new RuleDefinition( - RuleKind.Requires, - member.Path, - member.Access, - string.Empty, - message, - string.Empty, - requirementMethod)); + kind: RuleKind.Requires, + memberPath: member.Path, + memberAccess: member.Access, + argument: string.Empty, + argumentDisplay: string.Empty, + message: message, + customRuleType: string.Empty, + requirementMethod: requirementMethod)); } private AnalyzedMemberAccess? AnalyzeSelector(ArgumentSyntax selectorArgument) diff --git a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleArgumentAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleArgumentAnalyzer.cs index 85ccde2..57c3f91 100644 --- a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleArgumentAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleArgumentAnalyzer.cs @@ -21,6 +21,28 @@ public string GetMessage(RuleKind kind, InvocationExpressionSyntax invocation) return GetArgument(invocation, messageIndex); } + public string GetRuleArgumentDisplay(RuleKind kind, InvocationExpressionSyntax invocation) + { + if (!RuleShape.RequiresValueArgument(kind)) + { + return string.Empty; + } + + var argumentIndex = RuleShape.ValueArgumentIndex(kind); + if (!HasArgument(invocation, argumentIndex)) + { + return string.Empty; + } + + var expression = invocation.ArgumentList.Arguments[argumentIndex].Expression; + if (expression is LiteralExpressionSyntax literal) + { + return literal.Token.ValueText; + } + + return expression.ToString(); + } + private static string GetArgument(InvocationExpressionSyntax invocation, int argumentIndex) { if (!HasArgument(invocation, argumentIndex)) diff --git a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleInvocationAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleInvocationAnalyzer.cs index 0ae29d3..6029ba9 100644 --- a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleInvocationAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleInvocationAnalyzer.cs @@ -60,7 +60,7 @@ private RuleAnalysisResult AnalyzeKnownRule( return _requiresRuleAnalyzer.Analyze(semanticModel, invocation); } - return _memberRuleAnalyzer.Analyze(ruleKind, invocation); + return _memberRuleAnalyzer.Analyze(semanticModel, ruleKind, invocation); } } } diff --git a/src/TinyValidations.SourceGen/Emission/Rules/ComparableRuleEmitter.cs b/src/TinyValidations.SourceGen/Emission/Rules/ComparableRuleEmitter.cs index b117734..1639f4a 100644 --- a/src/TinyValidations.SourceGen/Emission/Rules/ComparableRuleEmitter.cs +++ b/src/TinyValidations.SourceGen/Emission/Rules/ComparableRuleEmitter.cs @@ -30,13 +30,19 @@ public void Emit(RuleDefinition rule, SourceWriter writer) var comparison = GetComparison(rule.Kind); var text = GetMessageText(rule); var message = RuleMessage.For(rule, text); + var compare = CreateCompareExpression(rule); - writer.WriteLine("if (" + rule.MemberAccess + " " + comparison + " " + rule.Argument + ")"); + writer.WriteLine("if (" + compare + " " + comparison + " 0)"); writer.OpenBlock(); writer.WriteLine("errors.Add(" + StringLiteral.Create(rule.MemberPath) + ", " + message + ");"); writer.CloseBlock(); } + private static string CreateCompareExpression(RuleDefinition rule) + { + return "global::System.Collections.Generic.Comparer<" + rule.ComparisonTypeName + ">.Default.Compare(" + rule.MemberAccess + ", " + rule.Argument + ")"; + } + private static string GetComparison(RuleKind kind) { if (kind == RuleKind.Above) @@ -61,20 +67,20 @@ private static string GetMessageText(RuleDefinition rule) { if (rule.Kind == RuleKind.Above) { - return rule.MemberPath + " must be above " + rule.Argument + "."; + return rule.MemberPath + " must be above " + rule.ArgumentDisplay + "."; } if (rule.Kind == RuleKind.AtLeast) { - return rule.MemberPath + " must be at least " + rule.Argument + "."; + return rule.MemberPath + " must be at least " + rule.ArgumentDisplay + "."; } if (rule.Kind == RuleKind.Below) { - return rule.MemberPath + " must be below " + rule.Argument + "."; + return rule.MemberPath + " must be below " + rule.ArgumentDisplay + "."; } - return rule.MemberPath + " must be at most " + rule.Argument + "."; + return rule.MemberPath + " must be at most " + rule.ArgumentDisplay + "."; } } } diff --git a/src/TinyValidations.SourceGen/Model/RuleDefinition.cs b/src/TinyValidations.SourceGen/Model/RuleDefinition.cs index cccbd21..da8db87 100644 --- a/src/TinyValidations.SourceGen/Model/RuleDefinition.cs +++ b/src/TinyValidations.SourceGen/Model/RuleDefinition.cs @@ -7,17 +7,21 @@ public RuleDefinition( string memberPath, string memberAccess, string argument, + string argumentDisplay, string message, string customRuleType, - string requirementMethod = "") + string requirementMethod = "", + string comparisonTypeName = "") { Kind = kind; MemberPath = memberPath; MemberAccess = memberAccess; Argument = argument; + ArgumentDisplay = argumentDisplay; Message = message; CustomRuleType = customRuleType; RequirementMethod = requirementMethod; + ComparisonTypeName = comparisonTypeName; } public RuleKind Kind { get; } @@ -28,10 +32,14 @@ public RuleDefinition( public string Argument { get; } + public string ArgumentDisplay { get; } + public string Message { get; } public string CustomRuleType { get; } public string RequirementMethod { get; } + + public string ComparisonTypeName { get; } } } diff --git a/src/TinyValidations/Abstractions/ValidationRules.cs b/src/TinyValidations/Abstractions/ValidationRules.cs index f3a7eda..65f61e7 100644 --- a/src/TinyValidations/Abstractions/ValidationRules.cs +++ b/src/TinyValidations/Abstractions/ValidationRules.cs @@ -19,13 +19,17 @@ public void TextLengthAtLeast(Expression> member, int length, s public void TextLengthAtMost(Expression> member, int length, string? message = null) { } - public void Above(Expression> member, TValue value, string? message = null) { } + public void Above(Expression> member, TValue value, string? message = null) + where TValue : IComparable { } - public void AtLeast(Expression> member, TValue value, string? message = null) { } + public void AtLeast(Expression> member, TValue value, string? message = null) + where TValue : IComparable { } - public void Below(Expression> member, TValue value, string? message = null) { } + public void Below(Expression> member, TValue value, string? message = null) + where TValue : IComparable { } - public void AtMost(Expression> member, TValue value, string? message = null) { } + public void AtMost(Expression> member, TValue value, string? message = null) + where TValue : IComparable { } public void Matches(Expression> member, string pattern, string? message = null) { } diff --git a/tests/TinyValidations.SourceGen.Tests/RuleGenerationTests.cs b/tests/TinyValidations.SourceGen.Tests/RuleGenerationTests.cs index 9c48518..5a687dd 100644 --- a/tests/TinyValidations.SourceGen.Tests/RuleGenerationTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/RuleGenerationTests.cs @@ -81,13 +81,13 @@ public sealed class RuleCoverageCommand Assert.Contains("errors.Add(\"MinimumLength\", \"MinimumLength must contain at least 3 characters.\");", text); Assert.Contains("if (instance.MaximumLength.Length > 5)", text); Assert.Contains("errors.Add(\"MaximumLength\", \"MaximumLength must contain at most 5 characters.\");", text); - Assert.Contains("if (instance.Above <= 10)", text); + Assert.Contains("if (global::System.Collections.Generic.Comparer.Default.Compare(instance.Above, 10) <= 0)", text); Assert.Contains("errors.Add(\"Above\", \"Above must be above 10.\");", text); - Assert.Contains("if (instance.AtLeast < 10)", text); + Assert.Contains("if (global::System.Collections.Generic.Comparer.Default.Compare(instance.AtLeast, 10) < 0)", text); Assert.Contains("errors.Add(\"AtLeast\", \"AtLeast must be at least 10.\");", text); - Assert.Contains("if (instance.Below >= 10)", text); + Assert.Contains("if (global::System.Collections.Generic.Comparer.Default.Compare(instance.Below, 10) >= 0)", text); Assert.Contains("errors.Add(\"Below\", \"Below must be below 10.\");", text); - Assert.Contains("if (instance.AtMost > 10)", text); + Assert.Contains("if (global::System.Collections.Generic.Comparer.Default.Compare(instance.AtMost, 10) > 0)", text); Assert.Contains("errors.Add(\"AtMost\", \"AtMost must be at most 10.\");", text); Assert.Contains("if (!global::System.Text.RegularExpressions.Regex.IsMatch(instance.Pattern, \"^[A-Z]{3}$\"))", text); Assert.Contains("errors.Add(\"Pattern\", \"Pattern has an invalid format.\");", text); @@ -95,4 +95,32 @@ public sealed class RuleCoverageCommand Assert.Contains("errors.Add(\"RequiredPrefix\", \"RequiredPrefix must start with OK-.\");", text); Assert.Contains("await _rulecoveragecustomrule.ValidateAsync(instance, errors, cancellationToken).ConfigureAwait(false);", text); } + + [Fact] + public void Comparable_rules_generate_code_for_string_values() + { + var source = """ +using TinyValidations; + +public sealed class SortKeyValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.AtLeast(x => x.Name, "M"); + } +} + +public sealed class SortKeyCommand +{ + public string? Name { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var text = result.SingleGeneratedSource(); + + result.ShouldHaveNoDiagnostics(); + result.ShouldHaveNoCompilationErrors(); + Assert.Contains("global::System.Collections.Generic.Comparer.Default.Compare(instance.Name, \"M\") < 0", text); + } } diff --git a/tests/TinyValidations.Tests/RuleBehaviorTests.cs b/tests/TinyValidations.Tests/RuleBehaviorTests.cs index 9cd8459..0759e95 100644 --- a/tests/TinyValidations.Tests/RuleBehaviorTests.cs +++ b/tests/TinyValidations.Tests/RuleBehaviorTests.cs @@ -133,6 +133,20 @@ public async Task AtLeast_rejects_values_below_threshold() AssertValid(boundaryResult); } + [Fact] + public async Task Comparable_rules_use_default_ordering_for_strings() + { + var validator = BuildValidator(); + + var invalidResult = await validator.ValidateAsync(new StringAtLeastRuleCommand("L")); + var boundaryResult = await validator.ValidateAsync(new StringAtLeastRuleCommand("M")); + var validResult = await validator.ValidateAsync(new StringAtLeastRuleCommand("N")); + + AssertHasError(invalidResult, nameof(StringAtLeastRuleCommand.Value), "Value must be at least M."); + AssertValid(boundaryResult); + AssertValid(validResult); + } + [Fact] public async Task Below_rejects_values_equal_to_or_above_threshold() { @@ -324,6 +338,16 @@ public void Define(ValidationRules rules) } } +public sealed record StringAtLeastRuleCommand(string Value); + +public sealed class StringAtLeastRuleCommandValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.AtLeast(x => x.Value, "M"); + } +} + public sealed record BelowRuleCommand(int Value); public sealed class BelowRuleCommandValidation : IValidation From f2536948d94f69d9613782e379031ba9d1a7aa0c Mon Sep 17 00:00:00 2001 From: Jorge Durban Date: Wed, 3 Jun 2026 09:18:36 +0200 Subject: [PATCH 5/9] Document comparable rule ordering --- docs/rules.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/rules.md b/docs/rules.md index 7218030..ace9ee1 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -72,7 +72,7 @@ rules.TextLengthAtMost(x => x.Name, 100); Null values are ignored by text length rules. Use `Required`, `HasText`, or `NotNull` when needed. -## Numeric And Comparable Rules +## Comparable Rules ```csharp rules.Above(x => x.Age, 17); @@ -81,6 +81,12 @@ rules.Below(x => x.Age, 130); rules.AtMost(x => x.Age, 129); ``` +Comparison rules support values that implement `IComparable`. Generated code uses `Comparer.Default`, so numeric values, dates, times, strings, and custom comparable value objects use their default ordering. + +For strings, comparison is ordering comparison, not length validation. Use `TextLengthAtLeast`, `TextLengthAtMost`, `HasText`, or `Matches` for text-specific validation. + +Nullable reference values are compared with the default comparer. For example, `AtLeast(x => x.Name, "M")` treats `null` as below `"M"`. Use `Required`, `HasText`, or `NotNull` when the value must be present. + ## Matches Validates a non-empty string against a regular expression pattern. From 642e918919211b0a017e11417a6b84473dce8b93 Mon Sep 17 00:00:00 2001 From: Jorge Durban Date: Wed, 3 Jun 2026 09:23:08 +0200 Subject: [PATCH 6/9] Harden nested comparable members --- .../RuleInvocations/AnalyzedMemberAccess.cs | 5 +- .../RuleInvocations/MemberAccessAnalyzer.cs | 14 +++- .../RuleInvocations/MemberRuleAnalyzer.cs | 35 +++++++++- .../RuleGenerationTests.cs | 66 +++++++++++++++++++ .../RuleBehaviorTests.cs | 26 ++++++++ 5 files changed, 140 insertions(+), 6 deletions(-) diff --git a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/AnalyzedMemberAccess.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/AnalyzedMemberAccess.cs index de0ce42..196b71e 100644 --- a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/AnalyzedMemberAccess.cs +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/AnalyzedMemberAccess.cs @@ -2,14 +2,17 @@ namespace TinyValidations.SourceGen.Analysis.RuleInvocations { internal sealed class AnalyzedMemberAccess { - public AnalyzedMemberAccess(string path, string access) + public AnalyzedMemberAccess(string path, string access, bool isNullSafe) { Path = path; Access = access; + IsNullSafe = isNullSafe; } public string Path { get; } public string Access { get; } + + public bool IsNullSafe { get; } } } diff --git a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberAccessAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberAccessAnalyzer.cs index 71ce931..b79fb80 100644 --- a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberAccessAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberAccessAnalyzer.cs @@ -26,7 +26,7 @@ internal sealed class MemberAccessAnalyzer } var path = string.Join(".", members); - return new AnalyzedMemberAccess(path, CreateAccess(members)); + return new AnalyzedMemberAccess(path, CreateAccess(members), members.Count > 1); } private static string GetParameterName(LambdaExpressionSyntax lambda) @@ -67,7 +67,7 @@ private static List ReadMembers(SyntaxNode body, string parameterName) while (current is MemberAccessExpressionSyntax memberAccess) { members.Insert(0, memberAccess.Name.Identifier.ValueText); - current = memberAccess.Expression; + current = UnwrapNullForgivingExpression(memberAccess.Expression); } if (IsOriginalParameter(current, parameterName)) @@ -88,6 +88,16 @@ private static bool IsOriginalParameter(ExpressionSyntax? expression, string par return identifier.Identifier.ValueText == parameterName; } + private static ExpressionSyntax UnwrapNullForgivingExpression(ExpressionSyntax expression) + { + if (expression is PostfixUnaryExpressionSyntax postfix) + { + return postfix.Operand; + } + + return expression; + } + private static string CreateAccess(List members) { if (members.Count == 1) diff --git a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberRuleAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberRuleAnalyzer.cs index 9192944..b06c3c6 100644 --- a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberRuleAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberRuleAnalyzer.cs @@ -45,7 +45,7 @@ private RuleAnalysisResult CreateRule( var argument = _argumentAnalyzer.GetRuleArgument(kind, invocation); var argumentDisplay = _argumentAnalyzer.GetRuleArgumentDisplay(kind, invocation); var message = _argumentAnalyzer.GetMessage(kind, invocation); - var comparisonTypeName = GetComparisonTypeName(semanticModel, kind, invocation); + var comparisonTypeName = GetComparisonTypeName(semanticModel, kind, invocation, member); return RuleAnalysisResult.ForRule(new RuleDefinition( kind, @@ -118,7 +118,8 @@ private static bool HasArgument(InvocationExpressionSyntax invocation, int argum private static string GetComparisonTypeName( SemanticModel semanticModel, RuleKind kind, - InvocationExpressionSyntax invocation) + InvocationExpressionSyntax invocation, + AnalyzedMemberAccess member) { if (!IsComparableRule(kind)) { @@ -136,7 +137,35 @@ private static string GetComparisonTypeName( return string.Empty; } - return method.TypeArguments[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var typeArgument = method.TypeArguments[0]; + if (RequiresNullableComparisonType(typeArgument, member)) + { + return "global::System.Nullable<" + typeArgument.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ">"; + } + + return typeArgument.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + + private static bool RequiresNullableComparisonType( + ITypeSymbol type, + AnalyzedMemberAccess member) + { + if (!member.IsNullSafe) + { + return false; + } + + if (type.IsReferenceType) + { + return false; + } + + if (type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + return false; + } + + return type.IsValueType; } private static bool IsComparableRule(RuleKind kind) diff --git a/tests/TinyValidations.SourceGen.Tests/RuleGenerationTests.cs b/tests/TinyValidations.SourceGen.Tests/RuleGenerationTests.cs index 5a687dd..8e6444a 100644 --- a/tests/TinyValidations.SourceGen.Tests/RuleGenerationTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/RuleGenerationTests.cs @@ -123,4 +123,70 @@ public sealed class SortKeyCommand result.ShouldHaveNoCompilationErrors(); Assert.Contains("global::System.Collections.Generic.Comparer.Default.Compare(instance.Name, \"M\") < 0", text); } + + [Fact] + public void Comparable_rules_generate_code_for_nested_value_members() + { + var source = """ +using TinyValidations; + +public sealed class AccountValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.AtLeast(x => x.Profile.Age, 18); + } +} + +public sealed class AccountCommand +{ + public AccountProfile? Profile { get; init; } +} + +public sealed class AccountProfile +{ + public int Age { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var text = result.SingleGeneratedSource(); + + result.ShouldHaveNoDiagnostics(); + result.ShouldHaveNoCompilationErrors(); + Assert.Contains("global::System.Collections.Generic.Comparer>.Default.Compare(instance.Profile?.Age, 18) < 0", text); + } + + [Fact] + public void Selectors_allow_null_forgiving_member_paths() + { + var source = """ +using TinyValidations; + +public sealed class AccountValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Required(x => x.Profile!.Email); + } +} + +public sealed class AccountCommand +{ + public AccountProfile? Profile { get; init; } +} + +public sealed class AccountProfile +{ + public string? Email { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var text = result.SingleGeneratedSource(); + + result.ShouldHaveNoDiagnostics(); + result.ShouldHaveNoCompilationErrors(); + Assert.Contains("errors.Add(\"Profile.Email\", \"Profile.Email is required.\");", text); + } } diff --git a/tests/TinyValidations.Tests/RuleBehaviorTests.cs b/tests/TinyValidations.Tests/RuleBehaviorTests.cs index 0759e95..9626b49 100644 --- a/tests/TinyValidations.Tests/RuleBehaviorTests.cs +++ b/tests/TinyValidations.Tests/RuleBehaviorTests.cs @@ -147,6 +147,20 @@ public async Task Comparable_rules_use_default_ordering_for_strings() AssertValid(validResult); } + [Fact] + public async Task Comparable_rules_handle_null_intermediate_members_for_nested_value_paths() + { + var validator = BuildValidator(); + + var nullProfileResult = await validator.ValidateAsync(new NestedComparableRuleCommand(null)); + var invalidResult = await validator.ValidateAsync(new NestedComparableRuleCommand(new NestedComparableProfile(17))); + var validResult = await validator.ValidateAsync(new NestedComparableRuleCommand(new NestedComparableProfile(18))); + + AssertHasError(nullProfileResult, "Profile.Age", "Profile.Age must be at least 18."); + AssertHasError(invalidResult, "Profile.Age", "Profile.Age must be at least 18."); + AssertValid(validResult); + } + [Fact] public async Task Below_rejects_values_equal_to_or_above_threshold() { @@ -348,6 +362,18 @@ public void Define(ValidationRules rules) } } +public sealed record NestedComparableRuleCommand(NestedComparableProfile? Profile); + +public sealed record NestedComparableProfile(int Age); + +public sealed class NestedComparableRuleCommandValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.AtLeast(x => x.Profile!.Age, 18); + } +} + public sealed record BelowRuleCommand(int Value); public sealed class BelowRuleCommandValidation : IValidation From 6df59aedb66ca5990dd3d95c53d5ba6a8d169264 Mon Sep 17 00:00:00 2001 From: Jorge Durban Date: Wed, 3 Jun 2026 09:38:49 +0200 Subject: [PATCH 7/9] Validate regex rule patterns --- .../RuleInvocations/MemberRuleAnalyzer.cs | 37 +++++++++++++++++++ .../DiagnosticTests.cs | 25 +++++++++++++ .../RuleGenerationTests.cs | 28 ++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberRuleAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberRuleAnalyzer.cs index b06c3c6..e781548 100644 --- a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberRuleAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberRuleAnalyzer.cs @@ -1,3 +1,5 @@ +using System; +using System.Text.RegularExpressions; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; using TinyValidations.SourceGen.Model; @@ -33,6 +35,11 @@ public RuleAnalysisResult Analyze( return RuleAnalysisIssue.UnsupportedArgument(invocation, invocation.ToString()); } + if (HasInvalidRegexPattern(kind, invocation)) + { + return RuleAnalysisIssue.UnsupportedArgument(invocation, invocation.ToString()); + } + return CreateRule(semanticModel, kind, invocation, member); } @@ -115,6 +122,36 @@ private static bool HasArgument(InvocationExpressionSyntax invocation, int argum return invocation.ArgumentList.Arguments.Count > argumentIndex; } + private static bool HasInvalidRegexPattern(RuleKind kind, InvocationExpressionSyntax invocation) + { + if (kind != RuleKind.Matches) + { + return false; + } + + var argumentIndex = RuleShape.ValueArgumentIndex(kind); + if (!HasArgument(invocation, argumentIndex)) + { + return true; + } + + var expression = invocation.ArgumentList.Arguments[argumentIndex].Expression; + if (!(expression is LiteralExpressionSyntax literal)) + { + return true; + } + + try + { + _ = new Regex(literal.Token.ValueText); + return false; + } + catch (ArgumentException) + { + return true; + } + } + private static string GetComparisonTypeName( SemanticModel semanticModel, RuleKind kind, diff --git a/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs b/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs index 0c652ab..f024e07 100644 --- a/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs @@ -206,6 +206,31 @@ public sealed class CreateUser Assert.Equal("TV0004", diagnostic.Id); } + [Fact] + public void Reports_diagnostic_when_matches_pattern_is_invalid() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Matches(x => x.Code, "["); + } +} + +public sealed class CreateUser +{ + public string? Code { get; init; } +} +"""; + + var diagnostic = SingleDiagnosticWithNoSource(source); + + Assert.Equal("TV0004", diagnostic.Id); + } + [Fact] public void Reports_diagnostic_when_requires_method_is_not_static() { diff --git a/tests/TinyValidations.SourceGen.Tests/RuleGenerationTests.cs b/tests/TinyValidations.SourceGen.Tests/RuleGenerationTests.cs index 8e6444a..d989cd8 100644 --- a/tests/TinyValidations.SourceGen.Tests/RuleGenerationTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/RuleGenerationTests.cs @@ -124,6 +124,34 @@ public sealed class SortKeyCommand Assert.Contains("global::System.Collections.Generic.Comparer.Default.Compare(instance.Name, \"M\") < 0", text); } + [Fact] + public void Matches_rules_generate_code_for_valid_regex_patterns() + { + var source = """ +using TinyValidations; + +public sealed class CodeValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Matches(x => x.Code, "^[A-Z]{3}$"); + } +} + +public sealed class CodeCommand +{ + public string? Code { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var text = result.SingleGeneratedSource(); + + result.ShouldHaveNoDiagnostics(); + result.ShouldHaveNoCompilationErrors(); + Assert.Contains("global::System.Text.RegularExpressions.Regex.IsMatch(instance.Code, \"^[A-Z]{3}$\")", text); + } + [Fact] public void Comparable_rules_generate_code_for_nested_value_members() { From a0ab626135ce0ed5a00eb1d86daa76a86e793471 Mon Sep 17 00:00:00 2001 From: Jorge Durban Date: Wed, 3 Jun 2026 09:44:26 +0200 Subject: [PATCH 8/9] Validate rule argument counts --- .../RuleInvocations/MemberRuleAnalyzer.cs | 10 ++- .../RuleInvocations/RequiresRuleAnalyzer.cs | 7 +- .../Analysis/RuleInvocations/RuleShape.cs | 40 +++++++++ .../DiagnosticTests.cs | 83 +++++++++++++++++++ 4 files changed, 135 insertions(+), 5 deletions(-) diff --git a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberRuleAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberRuleAnalyzer.cs index e781548..01a5f64 100644 --- a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberRuleAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberRuleAnalyzer.cs @@ -16,9 +16,9 @@ public RuleAnalysisResult Analyze( RuleKind kind, InvocationExpressionSyntax invocation) { - if (!HasSelector(invocation)) + if (!HasSupportedArgumentCount(kind, invocation)) { - return RuleAnalysisIssue.UnsupportedSelector(invocation, invocation.ToString()); + return RuleAnalysisIssue.UnsupportedArgument(invocation, invocation.ToString()); } var selectorArgument = invocation.ArgumentList.Arguments[0]; @@ -70,9 +70,11 @@ private RuleAnalysisResult CreateRule( return _memberAccessAnalyzer.Analyze(selectorArgument.Expression); } - private static bool HasSelector(InvocationExpressionSyntax invocation) + private static bool HasSupportedArgumentCount(RuleKind kind, InvocationExpressionSyntax invocation) { - return invocation.ArgumentList.Arguments.Count > 0; + var count = invocation.ArgumentList.Arguments.Count; + return count >= RuleShape.MinimumArgumentCount(kind) + && count <= RuleShape.MaximumArgumentCount(kind); } private static bool HasUnsupportedArgument(RuleKind kind, InvocationExpressionSyntax invocation) diff --git a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RequiresRuleAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RequiresRuleAnalyzer.cs index c81426c..f3d2a51 100644 --- a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RequiresRuleAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RequiresRuleAnalyzer.cs @@ -12,7 +12,7 @@ public RuleAnalysisResult Analyze( SemanticModel semanticModel, InvocationExpressionSyntax invocation) { - if (invocation.ArgumentList.Arguments.Count < 3) + if (!HasSupportedArgumentCount(invocation)) { return RuleAnalysisIssue.UnsupportedArgument(invocation, invocation.ToString()); } @@ -69,6 +69,11 @@ private RuleAnalysisResult CreateRule( return _memberAccessAnalyzer.Analyze(selectorArgument.Expression); } + private static bool HasSupportedArgumentCount(InvocationExpressionSyntax invocation) + { + return invocation.ArgumentList.Arguments.Count == RuleShape.MinimumArgumentCount(RuleKind.Requires); + } + private static bool IsSupportedMessage(ArgumentSyntax messageArgument) { if (!(messageArgument.Expression is LiteralExpressionSyntax)) diff --git a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleShape.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleShape.cs index 14cf6d2..50bb894 100644 --- a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleShape.cs +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleShape.cs @@ -34,5 +34,45 @@ public static int MessageArgumentIndex(RuleKind kind) return 1; } + + public static int MinimumArgumentCount(RuleKind kind) + { + if (kind == RuleKind.Requires) + { + return 3; + } + + if (kind == RuleKind.Use) + { + return 0; + } + + if (RequiresValueArgument(kind)) + { + return 2; + } + + return 1; + } + + public static int MaximumArgumentCount(RuleKind kind) + { + if (kind == RuleKind.Requires) + { + return 3; + } + + if (kind == RuleKind.Use) + { + return 0; + } + + if (RequiresValueArgument(kind)) + { + return 3; + } + + return 2; + } } } diff --git a/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs b/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs index f024e07..da001e5 100644 --- a/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs @@ -169,6 +169,56 @@ public void Define(ValidationRules rules) } } +public sealed class CreateUser +{ + public string? Email { get; init; } +} +"""; + + var diagnostic = SingleDiagnosticWithNoSource(source); + + Assert.Equal("TV0004", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_rule_value_argument_is_missing() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.TextLengthAtLeast(x => x.Email); + } +} + +public sealed class CreateUser +{ + public string? Email { get; init; } +} +"""; + + var diagnostic = SingleDiagnosticWithNoSource(source); + + Assert.Equal("TV0004", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_rule_has_too_many_arguments() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Required(x => x.Email, "Email is required.", "extra"); + } +} + public sealed class CreateUser { public string? Email { get; init; } @@ -320,6 +370,39 @@ public static bool HasOrderPrefix(string? value, string prefix) } } +public sealed class CreateOrder +{ + public string? OrderNumber { get; init; } +} +"""; + + var diagnostic = SingleDiagnosticWithNoSource(source); + + Assert.Equal("TV0004", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_requires_rule_has_too_many_arguments() + { + var source = """ +using TinyValidations; + +public sealed class CreateOrderValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Requires(x => x.OrderNumber, OrderNumberRequirements.HasOrderPrefix, "Order number must start with ORD-.", "extra"); + } +} + +public static class OrderNumberRequirements +{ + public static bool HasOrderPrefix(string? value) + { + return value is not null && value.StartsWith("ORD-"); + } +} + public sealed class CreateOrder { public string? OrderNumber { get; init; } From 95fa5fb8551e396507edc8b85e8e15785024db4c Mon Sep 17 00:00:00 2001 From: Jorge Durban Date: Wed, 3 Jun 2026 09:55:09 +0200 Subject: [PATCH 9/9] Deduplicate custom rule invocations --- .../Planning/ValidationPlanBuilder.cs | 47 +++++++- .../GenerationTests.cs | 55 +++++++++ .../ValidationBehaviorTests.cs | 109 ++++++++++++++++++ 3 files changed, 205 insertions(+), 6 deletions(-) diff --git a/src/TinyValidations.SourceGen/Planning/ValidationPlanBuilder.cs b/src/TinyValidations.SourceGen/Planning/ValidationPlanBuilder.cs index 3a0b91b..6801ccf 100644 --- a/src/TinyValidations.SourceGen/Planning/ValidationPlanBuilder.cs +++ b/src/TinyValidations.SourceGen/Planning/ValidationPlanBuilder.cs @@ -12,20 +12,55 @@ public GeneratedValidationPlan Build(ValidationDefinitionSet model) foreach (var validation in model.Validations) { - var customRules = validation.Rules - .Where(rule => rule.Kind == RuleKind.Use) - .Select(rule => rule.CustomRuleType) - .Distinct() - .ToArray(); + var rules = DeduplicateCustomRuleInvocations(validation.Rules); + var customRules = GetCustomRuleTypes(rules); runners.Add(new GeneratedRunnerPlan( SafeName.Create(validation.ValidationTypeName) + "Runner", validation.CommandTypeName, - validation.Rules, + rules, customRules)); } return new GeneratedValidationPlan(runners); } + + private static string[] GetCustomRuleTypes(IEnumerable rules) + { + return rules + .Where(rule => rule.Kind == RuleKind.Use) + .Select(rule => rule.CustomRuleType) + .ToArray(); + } + + private static RuleDefinition[] DeduplicateCustomRuleInvocations(IReadOnlyCollection rules) + { + var customRuleTypes = new HashSet(); + var deduplicated = new List(); + + foreach (var rule in rules) + { + if (ShouldSkipDuplicateCustomRule(rule, customRuleTypes)) + { + continue; + } + + deduplicated.Add(rule); + } + + return deduplicated.ToArray(); + } + + private static bool ShouldSkipDuplicateCustomRule( + RuleDefinition rule, + HashSet customRuleTypes) + { + if (rule.Kind != RuleKind.Use) + { + return false; + } + + return !customRuleTypes.Add(rule.CustomRuleType); + } } } diff --git a/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs b/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs index 5ce9200..26be4cb 100644 --- a/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs @@ -148,4 +148,59 @@ public sealed class CreateOrder Assert.Contains("if (!global::OrderNumberRequirements.HasOrderPrefix(instance.OrderNumber))", text); Assert.Contains("errors.Add(\"OrderNumber\", \"Order number must start with ORD-.\");", text); } + + [Fact] + public void Deduplicates_duplicate_custom_rule_calls() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Use(); + rules.Use(); + } +} + +public sealed class UniqueEmailRule : IAsyncValidationRule +{ + public System.Threading.Tasks.ValueTask ValidateAsync(CreateUser instance, ValidationErrorCollection errors, System.Threading.CancellationToken cancellationToken) => System.Threading.Tasks.ValueTask.CompletedTask; +} + +public sealed class CreateUser +{ +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var text = result.SingleGeneratedSource(); + + result.ShouldHaveNoDiagnostics(); + result.ShouldHaveNoCompilationErrors(); + Assert.Equal(1, CountOccurrences(text, "private readonly global::UniqueEmailRule _uniqueemailrule;")); + Assert.Equal(1, CountOccurrences(text, "global::UniqueEmailRule uniqueemailrule)")); + Assert.Equal(1, CountOccurrences(text, "await _uniqueemailrule.ValidateAsync(instance, errors, cancellationToken).ConfigureAwait(false);")); + } + + private static int CountOccurrences(string text, string value) + { + var count = 0; + var index = 0; + + while (index < text.Length) + { + index = text.IndexOf(value, index, StringComparison.Ordinal); + if (index < 0) + { + return count; + } + + count++; + index += value.Length; + } + + return count; + } } diff --git a/tests/TinyValidations.Tests/ValidationBehaviorTests.cs b/tests/TinyValidations.Tests/ValidationBehaviorTests.cs index 2f998f9..707acff 100644 --- a/tests/TinyValidations.Tests/ValidationBehaviorTests.cs +++ b/tests/TinyValidations.Tests/ValidationBehaviorTests.cs @@ -143,6 +143,32 @@ public async Task Custom_rules_are_resolved_from_dependency_injection() AssertHasError(result, nameof(CreateTeam.Name), "Team name is reserved."); } + [Fact] + public async Task Multiple_custom_rules_for_the_same_command_are_aggregated() + { + var validator = BuildValidator(); + var command = new PublishArticle(string.Empty); + + var result = await validator.ValidateAsync(command); + + Assert.False(result.IsValid); + AssertHasErrorWithMessage(result, nameof(PublishArticle.Title), "Title is required by first rule."); + AssertHasErrorWithMessage(result, nameof(PublishArticle.Title), "Title is required by second rule."); + } + + [Fact] + public async Task Duplicate_custom_rule_declarations_are_invoked_once() + { + var validator = BuildValidator(); + var command = new ReviewArticle(string.Empty); + + var result = await validator.ValidateAsync(command); + + Assert.False(result.IsValid); + Assert.Equal(1, CountErrors(result, nameof(ReviewArticle.Title))); + AssertHasError(result, nameof(ReviewArticle.Title), "Title is required."); + } + [Fact] public async Task Multiple_validations_for_the_same_command_are_aggregated() { @@ -267,6 +293,19 @@ private static void AssertHasError(ValidationResult result, string member, strin Assert.Fail("Expected validation error for " + member + "."); } + private static void AssertHasErrorWithMessage(ValidationResult result, string member, string message) + { + foreach (var error in result.Errors) + { + if (error.Member == member && error.Message == message) + { + return; + } + } + + Assert.Fail("Expected validation error for " + member + " with message '" + message + "'."); + } + private static int CountErrors(ValidationResult result, string member) { var count = 0; @@ -422,6 +461,76 @@ public bool IsReserved(string name) } } +public sealed record PublishArticle(string Title); + +public sealed class PublishArticleValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Use(); + rules.Use(); + } +} + +public sealed class PublishArticleFirstRule : IAsyncValidationRule +{ + public ValueTask ValidateAsync( + PublishArticle instance, + ValidationErrorCollection errors, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(instance.Title)) + { + errors.Add(nameof(PublishArticle.Title), "Title is required by first rule."); + } + + return ValueTask.CompletedTask; + } +} + +public sealed class PublishArticleSecondRule : IAsyncValidationRule +{ + public ValueTask ValidateAsync( + PublishArticle instance, + ValidationErrorCollection errors, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(instance.Title)) + { + errors.Add(nameof(PublishArticle.Title), "Title is required by second rule."); + } + + return ValueTask.CompletedTask; + } +} + +public sealed record ReviewArticle(string Title); + +public sealed class ReviewArticleValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Use(); + rules.Use(); + } +} + +public sealed class ReviewArticleTitleRule : IAsyncValidationRule +{ + public ValueTask ValidateAsync( + ReviewArticle instance, + ValidationErrorCollection errors, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(instance.Title)) + { + errors.Add(nameof(ReviewArticle.Title), "Title is required."); + } + + return ValueTask.CompletedTask; + } +} + public sealed record ShipPackage(string Address, string TrackingCode); public sealed class ShipPackageAddressValidation : IValidation