diff --git a/docs/extending.md b/docs/extending.md index ca41dc4..a28817e 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -80,7 +80,7 @@ if (!OrderNumberRequirements.HasOrderPrefix(instance.OrderNumber)) ## Requirement Rule Requirements -- The member selector must be a simple member access, such as `x => x.OrderNumber`. +- The member selector must be a simple member path, such as `x => x.OrderNumber` or `x => x.Profile.Email`. - The requirement must be a static method group. - The requirement must return `bool`. - The requirement must have exactly one parameter. diff --git a/docs/rules.md b/docs/rules.md index 8f709c1..7218030 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -11,6 +11,16 @@ public void Define(ValidationRules rules) Built-in rules are generated as direct C# checks. +Member selectors can target direct members or nested member paths: + +```csharp +rules.Required(x => x.Email); +rules.Required(x => x.Profile.Email); +``` + +Nested selectors report dotted member paths, such as `Profile.Email`. +Intermediate null values are handled as missing nested values. + ## Required Requires the value to be present. For strings, whitespace is treated as missing. diff --git a/src/TinyValidations.SourceGen/Analysis/Declarations/DefineMethodAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/Declarations/DefineMethodAnalyzer.cs index e8772b7..b5ed211 100644 --- a/src/TinyValidations.SourceGen/Analysis/Declarations/DefineMethodAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/Declarations/DefineMethodAnalyzer.cs @@ -21,7 +21,7 @@ internal sealed class DefineMethodAnalyzer { var defineMethod = declaration.Members .OfType() - .FirstOrDefault(IsDefineMethod); + .FirstOrDefault(method => IsDefineMethod(semanticModel, method, commandType, validationRules)); if (defineMethod == null) { @@ -58,19 +58,71 @@ internal sealed class DefineMethodAnalyzer rules); } - private static bool IsDefineMethod(MethodDeclarationSyntax method) + private static bool IsDefineMethod( + SemanticModel semanticModel, + MethodDeclarationSyntax method, + INamedTypeSymbol commandType, + INamedTypeSymbol validationRules) { if (method.Identifier.ValueText != "Define") { return false; } - return HasSingleParameter(method); + if (!HasSingleParameter(method)) + { + return false; + } + + if (!ReturnsVoid(semanticModel, method)) + { + return false; + } + + return HasValidationRulesParameter(semanticModel, method, commandType, validationRules); } private static bool HasSingleParameter(MethodDeclarationSyntax method) { return method.ParameterList.Parameters.Count == 1; } + + private static bool ReturnsVoid(SemanticModel semanticModel, MethodDeclarationSyntax method) + { + var symbol = semanticModel.GetDeclaredSymbol(method); + if (!(symbol is IMethodSymbol methodSymbol)) + { + return false; + } + + return methodSymbol.ReturnsVoid; + } + + private static bool HasValidationRulesParameter( + SemanticModel semanticModel, + MethodDeclarationSyntax method, + INamedTypeSymbol commandType, + INamedTypeSymbol validationRules) + { + var parameter = method.ParameterList.Parameters[0]; + var type = semanticModel.GetTypeInfo(parameter.Type!).Type; + + if (!(type is INamedTypeSymbol namedType)) + { + return false; + } + + if (!SymbolEqualityComparer.Default.Equals(namedType.OriginalDefinition, validationRules)) + { + return false; + } + + if (namedType.TypeArguments.Length != 1) + { + return false; + } + + return SymbolEqualityComparer.Default.Equals(namedType.TypeArguments[0], commandType); + } } } diff --git a/src/TinyValidations.SourceGen/Analysis/Members/AnalyzedMemberAccess.cs b/src/TinyValidations.SourceGen/Analysis/Rules/AnalyzedMemberAccess.cs similarity index 83% rename from src/TinyValidations.SourceGen/Analysis/Members/AnalyzedMemberAccess.cs rename to src/TinyValidations.SourceGen/Analysis/Rules/AnalyzedMemberAccess.cs index b657c93..72cf99b 100644 --- a/src/TinyValidations.SourceGen/Analysis/Members/AnalyzedMemberAccess.cs +++ b/src/TinyValidations.SourceGen/Analysis/Rules/AnalyzedMemberAccess.cs @@ -1,4 +1,4 @@ -namespace TinyValidations.SourceGen.Analysis.Members +namespace TinyValidations.SourceGen.Analysis.Rules { internal sealed class AnalyzedMemberAccess { diff --git a/src/TinyValidations.SourceGen/Analysis/Members/MemberAccessAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/Rules/MemberAccessAnalyzer.cs similarity index 87% rename from src/TinyValidations.SourceGen/Analysis/Members/MemberAccessAnalyzer.cs rename to src/TinyValidations.SourceGen/Analysis/Rules/MemberAccessAnalyzer.cs index 5e07359..87a5982 100644 --- a/src/TinyValidations.SourceGen/Analysis/Members/MemberAccessAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/Rules/MemberAccessAnalyzer.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace TinyValidations.SourceGen.Analysis.Members +namespace TinyValidations.SourceGen.Analysis.Rules { internal sealed class MemberAccessAnalyzer { @@ -25,7 +25,7 @@ internal sealed class MemberAccessAnalyzer } var path = string.Join(".", members); - return new AnalyzedMemberAccess(path, "instance." + path); + return new AnalyzedMemberAccess(path, CreateAccess(members)); } private static string GetParameterName(LambdaExpressionSyntax lambda) @@ -86,5 +86,15 @@ private static bool IsOriginalParameter(ExpressionSyntax? expression, string par return identifier.Identifier.ValueText == parameterName; } + + private static string CreateAccess(List members) + { + if (members.Count == 1) + { + return "instance." + members[0]; + } + + return "instance." + string.Join("?.", members); + } } } diff --git a/src/TinyValidations.SourceGen/Analysis/Rules/RuleInvocationAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/Rules/RuleInvocationAnalyzer.cs index 0d1cb9e..1f64f56 100644 --- a/src/TinyValidations.SourceGen/Analysis/Rules/RuleInvocationAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/Rules/RuleInvocationAnalyzer.cs @@ -1,6 +1,5 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; -using TinyValidations.SourceGen.Analysis.Members; using TinyValidations.SourceGen.Model; using TinyValidations.SourceGen.Validation; diff --git a/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs b/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs new file mode 100644 index 0000000..52ab100 --- /dev/null +++ b/tests/TinyValidations.SourceGen.Tests/DiagnosticTests.cs @@ -0,0 +1,336 @@ +using Xunit; + +namespace TinyValidations.SourceGen.Tests; + +public sealed class DiagnosticTests +{ + [Fact] + public void Reports_diagnostic_when_validation_has_no_supported_rules() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + } +} + +public sealed class CreateUser +{ + public string? Email { get; init; } +} +"""; + + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + + Assert.Equal("TV0006", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_define_signature_is_wrong() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define() + { + } +} + +public sealed class CreateUser +{ +} +"""; + + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + + Assert.Equal("TV0001", diagnostic.Id); + Assert.Equal( + "Validation declaration 'CreateUserValidation' must contain a Define method with one rules parameter", + diagnostic.GetMessage()); + Assert.Equal( + source.IndexOf("CreateUserValidation", StringComparison.Ordinal), + diagnostic.Location.SourceSpan.Start); + } + + [Fact] + public void Reports_diagnostic_when_define_parameter_type_is_wrong() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(string rules) + { + } +} + +public sealed class CreateUser +{ +} +"""; + + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + + Assert.Equal("TV0001", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_define_return_type_is_wrong() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public int Define(ValidationRules rules) + { + return 0; + } +} + +public sealed class CreateUser +{ +} +"""; + + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + + Assert.Equal("TV0001", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_rule_call_is_unsupported() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Unknown(x => x.Email); + } +} + +public sealed class CreateUser +{ + public string? Email { get; init; } +} +"""; + + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + + Assert.Equal("TV0002", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_selector_is_unsupported() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Required(x => x.Email!.ToString()); + } +} + +public sealed class CreateUser +{ + public string? Email { get; init; } +} +"""; + + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + + Assert.Equal("TV0003", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_argument_is_not_literal() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + var length = 2; + rules.TextLengthAtLeast(x => x.Email, length); + } +} + +public sealed class CreateUser +{ + public string? Email { get; init; } +} +"""; + + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + + Assert.Equal("TV0004", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_message_is_not_literal() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + var message = "Email is required."; + rules.Required(x => x.Email, message); + } +} + +public sealed class CreateUser +{ + public string? Email { get; init; } +} +"""; + + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + + Assert.Equal("TV0004", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_requires_method_is_not_static() + { + var source = """ +using TinyValidations; + +public sealed class CreateOrderValidation : IValidation +{ + public void Define(ValidationRules rules) + { + var requirements = new OrderNumberRequirements(); + rules.Requires(x => x.OrderNumber, requirements.HasOrderPrefix, "Order number must start with ORD-."); + } +} + +public sealed class OrderNumberRequirements +{ + public bool HasOrderPrefix(string? value) + { + return value is not null && value.StartsWith("ORD-"); + } +} + +public sealed class CreateOrder +{ + public string? OrderNumber { get; init; } +} +"""; + + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + + Assert.Equal("TV0004", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_requires_method_does_not_return_bool() + { + 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-."); + } +} + +public static class OrderNumberRequirements +{ + public static string HasOrderPrefix(string? value) + { + return ""; + } +} + +public sealed class CreateOrder +{ + public string? OrderNumber { get; init; } +} +"""; + + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + + Assert.Equal("TV0004", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_requires_method_has_wrong_parameter_count() + { + 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-."); + } +} + +public static class OrderNumberRequirements +{ + public static bool HasOrderPrefix(string? value, string prefix) + { + return value is not null && value.StartsWith(prefix); + } +} + +public sealed class CreateOrder +{ + public string? OrderNumber { get; init; } +} +"""; + + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + + Assert.Equal("TV0004", diagnostic.Id); + } + + [Fact] + public void Reports_diagnostic_when_custom_rule_type_is_invalid() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Use(); + } +} + +public sealed class NotACustomRule +{ +} + +public sealed class CreateUser +{ +} +"""; + + var diagnostic = SourceGeneratorTestHost.Run(source).SingleDiagnostic(); + + Assert.Equal("TV0005", diagnostic.Id); + } +} diff --git a/tests/TinyValidations.SourceGen.Tests/DiscoveryTests.cs b/tests/TinyValidations.SourceGen.Tests/DiscoveryTests.cs new file mode 100644 index 0000000..26b8fc3 --- /dev/null +++ b/tests/TinyValidations.SourceGen.Tests/DiscoveryTests.cs @@ -0,0 +1,65 @@ +using Xunit; + +namespace TinyValidations.SourceGen.Tests; + +public sealed class DiscoveryTests +{ + [Fact] + public void Ignores_unrelated_i_validation_interfaces() + { + var source = """ +namespace Other; + +public interface IValidation +{ +} + +public sealed class CreateUserValidation : IValidation +{ +} + +public sealed class CreateUser +{ +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + + result.ShouldHaveNoDiagnostics(); + result.ShouldGenerateNoSource(); + } + + [Fact] + public void Ignores_methods_that_only_look_like_validation_rules() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + var other = new OtherRules(); + other.Required(x => x.Email); + } +} + +public sealed class OtherRules +{ + public void Required(System.Func member) + { + } +} + +public sealed class CreateUser +{ + public string? Email { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var text = result.SingleGeneratedSource(); + + Assert.DoesNotContain("Email is required.", text); + } +} diff --git a/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs b/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs new file mode 100644 index 0000000..fb01a1c --- /dev/null +++ b/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs @@ -0,0 +1,117 @@ +using Xunit; + +namespace TinyValidations.SourceGen.Tests; + +public sealed class GenerationTests +{ + [Fact] + public void Generates_runner_contribution_and_module_initializer() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Required(x => x.Email); + rules.Email(x => x.Email); + rules.AtLeast(x => x.Age, 18); + 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 +{ + public string? Email { get; init; } + public int Age { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var text = result.SingleGeneratedSource(); + + Assert.Contains("ITinyValidationRunner", text); + Assert.Contains("TinyGeneratedValidationContribution", text); + Assert.Contains("TinyGeneratedValidationModuleInitializer", text); + Assert.Contains("TinyValidationBootstrap.AddContribution", text); + Assert.Contains("TinyValidationContributionAttribute(typeof(global::CreateUser)", text); + Assert.Contains("UniqueEmailRule", text); + Assert.Contains("await _uniqueemailrule.ValidateAsync", text); + Assert.Contains("ServiceDescriptor.Scoped", text); + Assert.Contains("TryAddEnumerable", text); + Assert.Contains("TryAddScoped", text); + Assert.DoesNotContain("System.Reflection", text); + Assert.DoesNotContain("GetType()", text); + Assert.DoesNotContain("typeof(T)", text); + } + + [Fact] + public void Generates_rules_from_explicit_define_implementation() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + void IValidation.Define(ValidationRules rules) + { + rules.Required(x => x.Email); + } +} + +public sealed class CreateUser +{ + public string? Email { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var text = result.SingleGeneratedSource(); + + result.ShouldHaveNoDiagnostics(); + Assert.Contains("ITinyValidationRunner", text); + Assert.Contains("Email is required.", text); + } + + [Fact] + public void Generates_static_requires_rule_call() + { + 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-."); + } +} + +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; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var text = result.SingleGeneratedSource(); + + result.ShouldHaveNoDiagnostics(); + 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/GeneratorSmokeTests.cs b/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs deleted file mode 100644 index 55a5967..0000000 --- a/tests/TinyValidations.SourceGen.Tests/GeneratorSmokeTests.cs +++ /dev/null @@ -1,418 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using TinyValidations.SourceGen; -using Xunit; - -namespace TinyValidations.SourceGen.Tests; - -public sealed class GeneratorSmokeTests -{ - [Fact] - public void Generates_runner_contribution_and_module_initializer() - { - var source = """ -using TinyValidations; - -public sealed class CreateUserValidation : IValidation -{ - public void Define(ValidationRules rules) - { - rules.Required(x => x.Email); - rules.Email(x => x.Email); - rules.AtLeast(x => x.Age, 18); - 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 -{ - public string? Email { get; init; } - public int Age { get; init; } -} -"""; - - var compilation = CSharpCompilation.Create( - "Tests", - new[] { CSharpSyntaxTree.ParseText(source) }, - new[] - { - MetadataReference.CreateFromFile(typeof(object).Assembly.Location), - MetadataReference.CreateFromFile(typeof(TinyValidations.IValidation<>).Assembly.Location) - }, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - - GeneratorDriver driver = CSharpGeneratorDriver.Create(new ValidationSourceGenerator()); - driver = driver.RunGenerators(compilation); - - var result = driver.GetRunResult(); - var generated = Assert.Single(result.GeneratedTrees); - var text = generated.GetText().ToString(); - - Assert.Contains("ITinyValidationRunner", text); - Assert.Contains("TinyGeneratedValidationContribution", text); - Assert.Contains("TinyGeneratedValidationModuleInitializer", text); - Assert.Contains("TinyValidationBootstrap.AddContribution", text); - Assert.Contains("TinyValidationContributionAttribute(typeof(global::CreateUser)", text); - Assert.Contains("UniqueEmailRule", text); - Assert.Contains("await _uniqueemailrule.ValidateAsync", text); - Assert.Contains("ServiceDescriptor.Scoped", text); - Assert.Contains("TryAddEnumerable", text); - Assert.Contains("TryAddScoped", text); - Assert.DoesNotContain("System.Reflection", text); - Assert.DoesNotContain("GetType()", text); - Assert.DoesNotContain("typeof(T)", text); - } - - [Fact] - public void Reports_diagnostic_when_validation_has_no_supported_rules() - { - var source = """ -using TinyValidations; - -public sealed class CreateUserValidation : IValidation -{ - public void Define(ValidationRules rules) - { - } -} - -public sealed class CreateUser -{ - public string? Email { get; init; } -} -"""; - - var compilation = CSharpCompilation.Create( - "Tests", - new[] { CSharpSyntaxTree.ParseText(source) }, - new[] - { - MetadataReference.CreateFromFile(typeof(object).Assembly.Location), - MetadataReference.CreateFromFile(typeof(TinyValidations.IValidation<>).Assembly.Location) - }, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - - GeneratorDriver driver = CSharpGeneratorDriver.Create(new ValidationSourceGenerator()); - driver = driver.RunGenerators(compilation); - - var result = driver.GetRunResult(); - var diagnostic = Assert.Single(result.Diagnostics); - - Assert.Equal("TV0006", diagnostic.Id); - } - - [Fact] - public void Reports_diagnostic_when_define_signature_is_wrong() - { - var source = """ -using TinyValidations; - -public sealed class CreateUserValidation : IValidation -{ - public void Define() - { - } -} - -public sealed class CreateUser -{ -} -"""; - - var result = RunGenerator(source); - var diagnostic = Assert.Single(result.Diagnostics); - - Assert.Equal("TV0001", diagnostic.Id); - } - - [Fact] - public void Reports_diagnostic_when_rule_call_is_unsupported() - { - var source = """ -using TinyValidations; - -public sealed class CreateUserValidation : IValidation -{ - public void Define(ValidationRules rules) - { - rules.Unknown(x => x.Email); - } -} - -public sealed class CreateUser -{ - public string? Email { get; init; } -} -"""; - - var result = RunGenerator(source); - var diagnostic = Assert.Single(result.Diagnostics); - - Assert.Equal("TV0002", diagnostic.Id); - } - - [Fact] - public void Reports_diagnostic_when_selector_is_unsupported() - { - var source = """ -using TinyValidations; - -public sealed class CreateUserValidation : IValidation -{ - public void Define(ValidationRules rules) - { - rules.Required(x => x.Email!.ToString()); - } -} - -public sealed class CreateUser -{ - public string? Email { get; init; } -} -"""; - - var result = RunGenerator(source); - var diagnostic = Assert.Single(result.Diagnostics); - - Assert.Equal("TV0003", diagnostic.Id); - } - - [Fact] - public void Reports_diagnostic_when_argument_is_not_literal() - { - var source = """ -using TinyValidations; - -public sealed class CreateUserValidation : IValidation -{ - public void Define(ValidationRules rules) - { - var length = 2; - rules.TextLengthAtLeast(x => x.Email, length); - } -} - -public sealed class CreateUser -{ - public string? Email { get; init; } -} -"""; - - var result = RunGenerator(source); - var diagnostic = Assert.Single(result.Diagnostics); - - Assert.Equal("TV0004", diagnostic.Id); - } - - [Fact] - public void Generates_static_requires_rule_call() - { - 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-."); - } -} - -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; } -} -"""; - - var result = RunGenerator(source); - var generated = Assert.Single(result.GeneratedTrees); - var text = generated.GetText().ToString(); - - Assert.Empty(result.Diagnostics); - Assert.Contains("if (!global::OrderNumberRequirements.HasOrderPrefix(instance.OrderNumber))", text); - Assert.Contains("errors.Add(\"OrderNumber\", \"Order number must start with ORD-.\");", text); - } - - [Fact] - public void Reports_diagnostic_when_requires_method_is_not_static() - { - var source = """ -using TinyValidations; - -public sealed class CreateOrderValidation : IValidation -{ - public void Define(ValidationRules rules) - { - var requirements = new OrderNumberRequirements(); - rules.Requires(x => x.OrderNumber, requirements.HasOrderPrefix, "Order number must start with ORD-."); - } -} - -public sealed class OrderNumberRequirements -{ - public bool HasOrderPrefix(string? value) - { - return value is not null && value.StartsWith("ORD-"); - } -} - -public sealed class CreateOrder -{ - public string? OrderNumber { get; init; } -} -"""; - - var result = RunGenerator(source); - var diagnostic = Assert.Single(result.Diagnostics); - - Assert.Equal("TV0004", diagnostic.Id); - } - - [Fact] - public void Reports_diagnostic_when_custom_rule_type_is_invalid() - { - var source = """ -using TinyValidations; - -public sealed class CreateUserValidation : IValidation -{ - public void Define(ValidationRules rules) - { - rules.Use(); - } -} - -public sealed class NotACustomRule -{ -} - -public sealed class CreateUser -{ -} -"""; - - var result = RunGenerator(source); - var diagnostic = Assert.Single(result.Diagnostics); - - Assert.Equal("TV0005", diagnostic.Id); - } - - [Fact] - public void Ignores_unrelated_i_validation_interfaces() - { - var source = """ -namespace Other; - -public interface IValidation -{ -} - -public sealed class CreateUserValidation : IValidation -{ -} - -public sealed class CreateUser -{ -} -"""; - - var compilation = CSharpCompilation.Create( - "Tests", - new[] { CSharpSyntaxTree.ParseText(source) }, - new[] - { - MetadataReference.CreateFromFile(typeof(object).Assembly.Location), - MetadataReference.CreateFromFile(typeof(TinyValidations.IValidation<>).Assembly.Location) - }, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - - GeneratorDriver driver = CSharpGeneratorDriver.Create(new ValidationSourceGenerator()); - driver = driver.RunGenerators(compilation); - - var result = driver.GetRunResult(); - - Assert.Empty(result.Diagnostics); - Assert.Empty(result.GeneratedTrees); - } - - [Fact] - public void Ignores_methods_that_only_look_like_validation_rules() - { - var source = """ -using TinyValidations; - -public sealed class CreateUserValidation : IValidation -{ - public void Define(ValidationRules rules) - { - var other = new OtherRules(); - other.Required(x => x.Email); - } -} - -public sealed class OtherRules -{ - public void Required(System.Func member) - { - } -} - -public sealed class CreateUser -{ - public string? Email { get; init; } -} -"""; - - 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) - }, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - - GeneratorDriver driver = CSharpGeneratorDriver.Create(new ValidationSourceGenerator()); - driver = driver.RunGenerators(compilation); - - var result = driver.GetRunResult(); - var generated = Assert.Single(result.GeneratedTrees); - var text = generated.GetText().ToString(); - - Assert.DoesNotContain("Email is required.", text); - } - - private static GeneratorDriverRunResult RunGenerator(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) - }, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - - GeneratorDriver driver = CSharpGeneratorDriver.Create(new ValidationSourceGenerator()); - driver = driver.RunGenerators(compilation); - - return driver.GetRunResult(); - } -} diff --git a/tests/TinyValidations.SourceGen.Tests/SourceGeneratorTestHost.cs b/tests/TinyValidations.SourceGen.Tests/SourceGeneratorTestHost.cs new file mode 100644 index 0000000..9bac42b --- /dev/null +++ b/tests/TinyValidations.SourceGen.Tests/SourceGeneratorTestHost.cs @@ -0,0 +1,59 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using TinyValidations.SourceGen; +using Xunit; + +namespace TinyValidations.SourceGen.Tests; + +internal static class SourceGeneratorTestHost +{ + 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) + }, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(new ValidationSourceGenerator()); + driver = driver.RunGenerators(compilation); + + return new SourceGeneratorRun(driver.GetRunResult()); + } +} + +internal sealed class SourceGeneratorRun +{ + private readonly GeneratorDriverRunResult _result; + + public SourceGeneratorRun(GeneratorDriverRunResult result) + { + _result = result; + } + + public Diagnostic SingleDiagnostic() + { + return Assert.Single(_result.Diagnostics); + } + + public string SingleGeneratedSource() + { + var generated = Assert.Single(_result.GeneratedTrees); + return generated.GetText().ToString(); + } + + public void ShouldHaveNoDiagnostics() + { + Assert.Empty(_result.Diagnostics); + } + + public void ShouldGenerateNoSource() + { + Assert.Empty(_result.GeneratedTrees); + } +} diff --git a/tests/TinyValidations.Tests/ValidationBehaviorTests.cs b/tests/TinyValidations.Tests/ValidationBehaviorTests.cs index dff9aa1..2c38342 100644 --- a/tests/TinyValidations.Tests/ValidationBehaviorTests.cs +++ b/tests/TinyValidations.Tests/ValidationBehaviorTests.cs @@ -26,6 +26,50 @@ public async Task Built_in_rules_return_validation_errors() AssertHasError(result, nameof(CreateProfile.Roles), "Roles must contain at least one item."); } + [Fact] + public async Task Built_in_rules_use_custom_messages() + { + var validator = BuildValidator(); + var command = new CreateProfileWithCustomMessages( + string.Empty, + "A", + 17, + "abc", + Array.Empty()); + + var result = await validator.ValidateAsync(command); + + Assert.False(result.IsValid); + AssertHasError(result, nameof(CreateProfileWithCustomMessages.Email), "Please provide an email."); + AssertHasError(result, nameof(CreateProfileWithCustomMessages.DisplayName), "Display name is too short."); + AssertHasError(result, nameof(CreateProfileWithCustomMessages.Age), "Adults only."); + AssertHasError(result, nameof(CreateProfileWithCustomMessages.Code), "Code must be three uppercase letters."); + AssertHasError(result, nameof(CreateProfileWithCustomMessages.Roles), "Choose at least one role."); + } + + [Fact] + public async Task Remaining_built_in_rules_return_validation_errors() + { + var validator = BuildValidator(); + var command = new ConfigureProduct( + string.Empty, + null, + "TOO-LONG", + 0, + 11, + 6); + + var result = await validator.ValidateAsync(command); + + Assert.False(result.IsValid); + AssertHasError(result, nameof(ConfigureProduct.Name), "Name must contain text."); + AssertHasError(result, nameof(ConfigureProduct.Category), "Category must not be null."); + AssertHasError(result, nameof(ConfigureProduct.Code), "Code must contain at most 3 characters."); + AssertHasError(result, nameof(ConfigureProduct.MinimumQuantity), "MinimumQuantity must be above 0."); + AssertHasError(result, nameof(ConfigureProduct.DiscountPercent), "DiscountPercent must be below 10."); + AssertHasError(result, nameof(ConfigureProduct.Rating), "Rating must be at most 5."); + } + [Fact] public async Task Custom_rules_are_resolved_from_dependency_injection() { @@ -51,6 +95,30 @@ public async Task Multiple_validations_for_the_same_command_are_aggregated() AssertHasError(result, nameof(ShipPackage.TrackingCode), "TrackingCode has an invalid format."); } + [Fact] + public async Task Nested_member_rules_return_dotted_member_paths() + { + var validator = BuildValidator(); + var command = new UpdateAccount(new AccountProfile(string.Empty)); + + var result = await validator.ValidateAsync(command); + + Assert.False(result.IsValid); + AssertHasError(result, "Profile.Email", "Profile email is required."); + } + + [Fact] + public async Task Nested_member_rules_handle_null_intermediate_members() + { + var validator = BuildValidator(); + var command = new UpdateAccount(null!); + + var result = await validator.ValidateAsync(command); + + Assert.False(result.IsValid); + AssertHasError(result, "Profile.Email", "Profile email is required."); + } + [Fact] public async Task Requires_rules_call_static_requirement_methods() { @@ -165,6 +233,46 @@ private static async Task ValidateWithScopeAsync(ServiceProvider provide } } +public sealed record CreateProfileWithCustomMessages( + string Email, + string DisplayName, + int Age, + string Code, + IReadOnlyCollection Roles); + +public sealed class CreateProfileWithCustomMessagesValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Required(x => x.Email, "Please provide an email."); + rules.TextLengthAtLeast(x => x.DisplayName, 2, "Display name is too short."); + rules.AtLeast(x => x.Age, 18, "Adults only."); + rules.Matches(x => x.Code, "^[A-Z]{3}$", "Code must be three uppercase letters."); + rules.HasItems(x => x.Roles, "Choose at least one role."); + } +} + +public sealed record ConfigureProduct( + string Name, + string? Category, + string Code, + int MinimumQuantity, + int DiscountPercent, + int Rating); + +public sealed class ConfigureProductValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.HasText(x => x.Name); + rules.NotNull(x => x.Category); + rules.TextLengthAtMost(x => x.Code, 3); + rules.Above(x => x.MinimumQuantity, 0); + rules.Below(x => x.DiscountPercent, 10); + rules.AtMost(x => x.Rating, 5); + } +} + public sealed record CreateProfile( string Email, string DisplayName, @@ -244,6 +352,18 @@ public void Define(ValidationRules rules) } } +public sealed record UpdateAccount(AccountProfile Profile); + +public sealed record AccountProfile(string Email); + +public sealed class UpdateAccountValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Required(x => x.Profile.Email, "Profile email is required."); + } +} + public sealed record CreateOrder(string OrderNumber); public sealed class CreateOrderValidation : IValidation