Skip to content

Commit 1c06339

Browse files
authored
Merge pull request #43 from altasoft/bugfix/openapiAndRegexPatterns
Added Pattern attribute support for string domain primitives.
2 parents e5a3a9b + a82093a commit 1c06339

File tree

26 files changed

+1839
-14
lines changed

26 files changed

+1839
-14
lines changed

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<Product>Domain Primitives</Product>
1212
<Company>ALTA Software llc.</Company>
1313
<Copyright>Copyright © 2024 ALTA Software llc.</Copyright>
14-
<Version>8.0.0</Version>
14+
<Version>8.0.1</Version>
1515
</PropertyGroup>
1616

1717
<PropertyGroup>

Examples/AltaSoft.DomainPrimitives.Demo/Customer.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ public sealed record Customer(
77

88
[Required] CustomerId A_CustomerId, [Required] Guid A_CustomerIdDotNet,
99
[Required] BirthDate B_BirthDate, [Required] DateOnly B_BirthDateDotNet,
10-
[Required] CustomerName C_CustomerName, [Required] string C_CustomerNameDotNet,
10+
[Required] CustomerName C_CustomerName,
11+
[Required][property: RegularExpression("\\Axxx")] string C_CustomerNameDotNet,
1112
[Required] PositiveAmount D_Amount, [Required] decimal D_AmountDotnet)
1213
{
1314
public CustomerAddress? CustomerAddress { get; set; } //ignore

src/AltaSoft.DomainPrimitives.Generator/Executor.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using AltaSoft.DomainPrimitives.Generator.Helpers;
1010
using AltaSoft.DomainPrimitives.Generator.Models;
1111
using Microsoft.CodeAnalysis;
12+
using Microsoft.CodeAnalysis.CSharp;
1213

1314
namespace AltaSoft.DomainPrimitives.Generator;
1415

@@ -389,6 +390,11 @@ private static void Process(GeneratorData data, string ctorCode, DomainPrimitive
389390
usings.Add("System.Globalization");
390391
}
391392

393+
if (data.ValidatePattern)
394+
{
395+
usings.Add("System.Text.RegularExpressions");
396+
}
397+
392398
var needsMathOperators = data.GenerateAdditionOperators || data.GenerateDivisionOperators ||
393399
data.GenerateMultiplyOperators || data.GenerateSubtractionOperators || data.GenerateModulusOperator;
394400

@@ -780,7 +786,11 @@ private static bool ProcessConstructor(GeneratorData data, SourceCodeBuilder bui
780786
.OpenBracket();
781787

782788
if (data.UnderlyingType == DomainPrimitiveUnderlyingType.String)
789+
{
783790
AddStringLengthAttributeValidation(type, data, builder);
791+
AddPatternAttribute(type, data, builder);
792+
793+
}
784794

785795
builder.AppendLine("ValidateOrThrow(value);");
786796
builder.CloseBracket()
@@ -836,4 +846,33 @@ private static void AddStringLengthAttributeValidation(ISymbol domainPrimitiveTy
836846
.AppendLine($"\tthrow InvalidDomainValueException.StringRangeException(typeof({data.ClassName}), value, {minValue.ToString(CultureInfo.InvariantCulture)}, {maxValue.ToString(CultureInfo.InvariantCulture)});")
837847
.NewLine();
838848
}
849+
850+
/// <summary>
851+
/// Adds pattern validation to the constructor if the Domain Primitive type is decorated with the PatternAttribute.
852+
/// </summary>
853+
private static void AddPatternAttribute(ISymbol domainPrimitiveType, GeneratorData data, SourceCodeBuilder sb)
854+
{
855+
var attr = domainPrimitiveType.GetAttributes()
856+
.FirstOrDefault(x => string.Equals(x.AttributeClass?.ToDisplayString(), Constants.PatternAttributeFullName, StringComparison.Ordinal));
857+
858+
if (attr is null)
859+
return;
860+
861+
var pattern = (string)attr.ConstructorArguments[0].Value!;
862+
var validate = (bool)attr.ConstructorArguments[1].Value!;
863+
864+
if (string.IsNullOrEmpty(pattern))
865+
return;
866+
867+
data.Pattern = pattern;
868+
data.ValidatePattern = validate;
869+
var quotedPattern = SymbolDisplay.FormatLiteral(data.Pattern, quote: true);
870+
871+
if (validate)
872+
{
873+
sb.AppendLine($"if (!Regex.IsMatch(value, {quotedPattern}, RegexOptions.Compiled))")
874+
.AppendLine($"\tthrow InvalidDomainValueException.InvalidPatternException(typeof({data.ClassName}), value, {quotedPattern});")
875+
.NewLine();
876+
}
877+
}
839878
}

src/AltaSoft.DomainPrimitives.Generator/Helpers/Constants.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ internal static class Constants
77
internal const string SupportedOperationsAttribute = "SupportedOperationsAttribute";
88
internal const string SupportedOperationsAttributeFullName = "AltaSoft.DomainPrimitives.SupportedOperationsAttribute";
99
internal const string StringLengthAttributeFullName = "AltaSoft.DomainPrimitives.StringLengthAttribute";
10+
internal const string PatternAttributeFullName = "AltaSoft.DomainPrimitives.PatternAttribute";
1011
}
1112
}

src/AltaSoft.DomainPrimitives.Generator/Helpers/MethodGeneratorHelper.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Collections.Generic;
2+
using System.Globalization;
23
using System.Linq;
34
using AltaSoft.DomainPrimitives.Generator.Extensions;
45
using AltaSoft.DomainPrimitives.Generator.Models;
@@ -112,6 +113,18 @@ void AddMapping()
112113
}
113114
}
114115

116+
if (data.StringLengthAttributeValidation is { } stringLengthAttribute)
117+
{
118+
var (min, max) = stringLengthAttribute;
119+
builder.Append("MinLength = ").Append(min.ToString(CultureInfo.InvariantCulture)).AppendLine(",");
120+
builder.Append("MaxLength = ").Append(max.ToString(CultureInfo.InvariantCulture)).AppendLine(",");
121+
}
122+
123+
if (data.Pattern is not null)
124+
{
125+
builder.Append("Pattern = ").Append(QuoteAndEscape(data.Pattern)).AppendLine(",");
126+
}
127+
115128
builder.Length -= SourceCodeBuilder.s_newLineLength + 1;
116129
builder.NewLine();
117130
builder.AppendLine("}");
@@ -424,6 +437,7 @@ internal static void GenerateMandatoryMethods(GeneratorData data, SourceCodeBuil
424437
}
425438

426439
AddStringLengthValidation(data, builder);
440+
AddPatternValidation(data, builder);
427441

428442
builder.AppendLine("var validationResult = Validate(value);")
429443
.AppendLine("if (!validationResult.IsValid)")
@@ -477,6 +491,24 @@ static void AddStringLengthValidation(GeneratorData data, SourceCodeBuilder sb)
477491
.CloseBracket()
478492
.NewLine();
479493
}
494+
495+
static void AddPatternValidation(GeneratorData data, SourceCodeBuilder sb)
496+
{
497+
if (data.Pattern is null)
498+
return;
499+
500+
if (!data.ValidatePattern)
501+
return;
502+
503+
var quoted = QuoteAndEscape(data.Pattern);
504+
sb.AppendLine($"if (!Regex.IsMatch(value, {quoted}, RegexOptions.Compiled))")
505+
.OpenBracket()
506+
.AppendLine("result = null;")
507+
.AppendLine($"errorMessage = \"String does not match the required pattern: \" + {quoted};")
508+
.AppendLine("return false;")
509+
.CloseBracket()
510+
.NewLine();
511+
}
480512
}
481513

482514
/// <summary>

src/AltaSoft.DomainPrimitives.Generator/Models/GeneratorData.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,15 @@ internal sealed class GeneratorData
127127
/// Indicates whether the `Transform` method should be invoked before validation and instantiation.
128128
/// </summary>
129129
public bool UseTransformMethod { get; set; }
130+
131+
/// <summary>
132+
/// Gets or sets pattern for OpenAPI schema generation, or validation.
133+
/// </summary>
134+
public string? Pattern { get; set; }
135+
136+
/// <summary>
137+
/// Gets or sets a value indicating whether to validate the regex pattern at runtime when the `PatternAttribute` is applied to a Domain Primitive type. If set to `true`, the generated code will include logic to validate the pattern during instantiation and throw an exception if the value does not match the specified regex pattern.
138+
/// If set to `false`, the pattern will only be used for OpenAPI schema generation and will not be validated at runtime.
139+
/// </summary>
140+
public bool ValidatePattern { get; set; }
130141
}

src/AltaSoft.DomainPrimitives/InvalidDomainValueException.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,19 @@ public static InvalidDomainValueException LimitExceededException(Type type, int
6868
return new InvalidDomainValueException($"The value has exceeded a {underlyingTypeName} limit", type, value);
6969
}
7070

71+
/// <summary>
72+
/// Creates an <see cref="InvalidDomainValueException"/> for string pattern mismatch errors.
73+
/// </summary>
74+
/// <param name="type">The <see cref="Type"/> of the domain primitive.</param>
75+
/// <param name="value">The string value that failed to match the pattern.</param>
76+
/// <param name="pattern">The expected regex pattern.</param>
77+
/// <returns>An <see cref="InvalidDomainValueException"/> describing the pattern mismatch.</returns>
78+
[EditorBrowsable(EditorBrowsableState.Never)]
79+
public static InvalidDomainValueException InvalidPatternException(Type type, string value, string pattern)
80+
{
81+
return new InvalidDomainValueException($"String value does not match the required pattern '{pattern}'", type, value);
82+
}
83+
7184
/// <summary>
7285
/// Generates the error message for the <see cref="InvalidDomainValueException"/> including the underlying value.
7386
/// </summary>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using System;
2+
using System.Diagnostics.CodeAnalysis;
3+
4+
namespace AltaSoft.DomainPrimitives;
5+
6+
/// <summary>
7+
/// Specifies a regex pattern that is always emitted to the OpenAPI schema and can optionally be used for runtime validation.
8+
/// By default, the pattern is not validated at runtime; validation occurs only when explicitly requested via <see cref="Validate"/>.
9+
/// </summary>
10+
[AttributeUsage(AttributeTargets.Class)]
11+
public class PatternAttribute : Attribute
12+
{
13+
/// <summary>
14+
/// Gets the regex pattern used in the generated OpenAPI schema and, when enabled, for runtime validation.
15+
/// </summary>
16+
public string Pattern { get; }
17+
18+
/// <summary>
19+
/// Gets a value indicating whether the <see cref="Pattern"/> should also be enforced via runtime validation.
20+
/// </summary>
21+
public bool Validate { get; }
22+
23+
/// <summary>
24+
/// Initializes a new instance of <see cref="PatternAttribute"/>.
25+
/// </summary>
26+
/// <param name="pattern">
27+
/// The regex pattern that will always be included in the OpenAPI schema and may also be used for runtime validation.
28+
/// </param>
29+
/// <param name="validate">
30+
/// A value indicating whether runtime validation should be performed using <paramref name="pattern"/>. Defaults to <see langword="false"/>
31+
/// to avoid incurring runtime validation overhead unless explicitly requested.
32+
/// </param>
33+
34+
public PatternAttribute([StringSyntax(StringSyntaxAttribute.Regex)] string pattern, bool validate = false)
35+
{
36+
Pattern = pattern;
37+
Validate = validate;
38+
}
39+
}

tests/AltaSoft.DomainPrimitives.Generator.Tests/DomainPrimitiveGeneratorTest.cs

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,76 @@ namespace AltaSoft.DomainPrimitives.Generator.Tests;
55
public class DomainPrimitiveGeneratorTest
66
{
77

8+
[Fact]
9+
public Task StringValue_WithTransStringLengthAndPattern()
10+
{
11+
const string source = """
12+
using System;
13+
using System.Collections.Generic;
14+
using System.Linq;
15+
using System.Text;
16+
using System.Threading.Tasks;
17+
using AltaSoft.DomainPrimitives;
18+
19+
namespace AltaSoft.DomainPrimitives;
20+
21+
/// <summary>
22+
/// A string domain primitive with both length and pattern validation attributes, as well as a custom validation method.
23+
/// </summary>
24+
[StringLength(1, 100)]
25+
[Pattern(@"[A-Z]{100}")]
26+
internal partial class StringWithLengthAndPattern : IDomainValue<string>
27+
{
28+
/// <inheritdoc/>
29+
public static PrimitiveValidationResult Validate(string value)
30+
{
31+
if (value == "Test")
32+
return "Invalid Value";
33+
34+
return PrimitiveValidationResult.Ok;
35+
}
36+
}
37+
38+
""";
39+
40+
return TestHelper.Verify(source, (_, x, _) => Assert.Equal(4, x.Count));
41+
}
42+
43+
[Fact]
44+
public Task StringValue_WithTransStringLengthAndPatternWithValidation()
45+
{
46+
const string source = """
47+
using System;
48+
using System.Collections.Generic;
49+
using System.Linq;
50+
using System.Text;
51+
using System.Threading.Tasks;
52+
using AltaSoft.DomainPrimitives;
53+
54+
namespace AltaSoft.DomainPrimitives;
55+
56+
/// <summary>
57+
/// A string domain primitive with both length and pattern validation attributes, as well as a custom validation method.
58+
/// </summary>
59+
[StringLength(1, 100)]
60+
[Pattern(@"[A-Z]{100}",true)]
61+
internal partial class StringWithLengthAndPattern : IDomainValue<string>
62+
{
63+
/// <inheritdoc/>
64+
public static PrimitiveValidationResult Validate(string value)
65+
{
66+
if (value == "Test")
67+
return "Invalid Value";
68+
69+
return PrimitiveValidationResult.Ok;
70+
}
71+
}
72+
73+
""";
74+
75+
return TestHelper.Verify(source, (_, x, _) => Assert.Equal(4, x.Count));
76+
}
77+
878
[Fact]
979
public Task StringValue_WithTransformerGeneratesTransformerCall()
1080
{
@@ -18,7 +88,6 @@ public Task StringValue_WithTransformerGeneratesTransformerCall()
1888
1989
namespace AltaSoft.DomainPrimitives;
2090
21-
/// <inheritdoc/>
2291
[StringLength(1, 100)]
2392
internal partial class TransformableString : IDomainValue<string>
2493
{
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
namespace AltaSoft.DomainPrimitives.Generator.Tests;
2+
3+
public class PrimitivesWithPatternAttributeTests
4+
{
5+
[Theory]
6+
[InlineData("[A-Z]{3}")]
7+
[InlineData("[A-Z]{10}")]
8+
[InlineData("\\d+")]
9+
[InlineData("\\w+")]
10+
[InlineData("\\s+")]
11+
[InlineData("[A-Z]{3}\\d{2}")]
12+
[InlineData("[A-Z]{3}-\\d{3}")]
13+
[InlineData("^\\d{4}-\\d{2}-\\d{2}$")]
14+
[InlineData("^\\w+@\\w+\\.\\w+$")]
15+
[InlineData("^\\d{3}-\\d{2}-\\d{4}$")]
16+
public void Pattern_Should_Compile(string pattern)
17+
{
18+
var source = $$"""
19+
using System;
20+
using System.Collections.Generic;
21+
using System.Linq;
22+
using System.Text;
23+
using System.Threading.Tasks;
24+
using AltaSoft.DomainPrimitives;
25+
26+
namespace AltaSoft.DomainPrimitives;
27+
28+
/// <summary>
29+
/// A string domain primitive with both length and pattern validation attributes, as well as a custom validation method.
30+
/// </summary>
31+
[StringLength(1, 100)]
32+
[Pattern(@"{{pattern}}, true")]
33+
internal partial class StringWithLengthAndPattern : IDomainValue<string>
34+
{
35+
/// <inheritdoc/>
36+
public static PrimitiveValidationResult Validate(string value)
37+
{
38+
return PrimitiveValidationResult.Ok;
39+
}
40+
}
41+
42+
""";
43+
44+
TestHelper.Compile(source, (_, sources, _) => Assert.Equal(4, sources.Count));
45+
46+
}
47+
48+
}

0 commit comments

Comments
 (0)