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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<Product>Domain Primitives</Product>
<Company>ALTA Software llc.</Company>
<Copyright>Copyright © 2024 ALTA Software llc.</Copyright>
<Version>8.0.0</Version>
<Version>8.0.1</Version>
</PropertyGroup>

<PropertyGroup>
Expand Down
3 changes: 2 additions & 1 deletion Examples/AltaSoft.DomainPrimitives.Demo/Customer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ public sealed record Customer(

[Required] CustomerId A_CustomerId, [Required] Guid A_CustomerIdDotNet,
[Required] BirthDate B_BirthDate, [Required] DateOnly B_BirthDateDotNet,
[Required] CustomerName C_CustomerName, [Required] string C_CustomerNameDotNet,
[Required] CustomerName C_CustomerName,
[Required][property: RegularExpression("\\Axxx")] string C_CustomerNameDotNet,
[Required] PositiveAmount D_Amount, [Required] decimal D_AmountDotnet)
{
public CustomerAddress? CustomerAddress { get; set; } //ignore
Expand Down
39 changes: 39 additions & 0 deletions src/AltaSoft.DomainPrimitives.Generator/Executor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using AltaSoft.DomainPrimitives.Generator.Helpers;
using AltaSoft.DomainPrimitives.Generator.Models;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;

namespace AltaSoft.DomainPrimitives.Generator;

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

if (data.ValidatePattern)
{
usings.Add("System.Text.RegularExpressions");
}

var needsMathOperators = data.GenerateAdditionOperators || data.GenerateDivisionOperators ||
data.GenerateMultiplyOperators || data.GenerateSubtractionOperators || data.GenerateModulusOperator;

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

if (data.UnderlyingType == DomainPrimitiveUnderlyingType.String)
{
AddStringLengthAttributeValidation(type, data, builder);
AddPatternAttribute(type, data, builder);

}

builder.AppendLine("ValidateOrThrow(value);");
builder.CloseBracket()
Expand Down Expand Up @@ -836,4 +846,33 @@ private static void AddStringLengthAttributeValidation(ISymbol domainPrimitiveTy
.AppendLine($"\tthrow InvalidDomainValueException.StringRangeException(typeof({data.ClassName}), value, {minValue.ToString(CultureInfo.InvariantCulture)}, {maxValue.ToString(CultureInfo.InvariantCulture)});")
.NewLine();
}

/// <summary>
/// Adds pattern validation to the constructor if the Domain Primitive type is decorated with the PatternAttribute.
/// </summary>
private static void AddPatternAttribute(ISymbol domainPrimitiveType, GeneratorData data, SourceCodeBuilder sb)
{
var attr = domainPrimitiveType.GetAttributes()
.FirstOrDefault(x => string.Equals(x.AttributeClass?.ToDisplayString(), Constants.PatternAttributeFullName, StringComparison.Ordinal));

if (attr is null)
return;

var pattern = (string)attr.ConstructorArguments[0].Value!;
var validate = (bool)attr.ConstructorArguments[1].Value!;

if (string.IsNullOrEmpty(pattern))
return;

data.Pattern = pattern;
data.ValidatePattern = validate;
var quotedPattern = SymbolDisplay.FormatLiteral(data.Pattern, quote: true);

if (validate)
{
sb.AppendLine($"if (!Regex.IsMatch(value, {quotedPattern}, RegexOptions.Compiled))")
.AppendLine($"\tthrow InvalidDomainValueException.InvalidPatternException(typeof({data.ClassName}), value, {quotedPattern});")
.NewLine();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ internal static class Constants
internal const string SupportedOperationsAttribute = "SupportedOperationsAttribute";
internal const string SupportedOperationsAttributeFullName = "AltaSoft.DomainPrimitives.SupportedOperationsAttribute";
internal const string StringLengthAttributeFullName = "AltaSoft.DomainPrimitives.StringLengthAttribute";
internal const string PatternAttributeFullName = "AltaSoft.DomainPrimitives.PatternAttribute";
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using AltaSoft.DomainPrimitives.Generator.Extensions;
using AltaSoft.DomainPrimitives.Generator.Models;
Expand Down Expand Up @@ -112,6 +113,18 @@ void AddMapping()
}
}

if (data.StringLengthAttributeValidation is { } stringLengthAttribute)
{
var (min, max) = stringLengthAttribute;
builder.Append("MinLength = ").Append(min.ToString(CultureInfo.InvariantCulture)).AppendLine(",");
builder.Append("MaxLength = ").Append(max.ToString(CultureInfo.InvariantCulture)).AppendLine(",");
}

if (data.Pattern is not null)
{
builder.Append("Pattern = ").Append(QuoteAndEscape(data.Pattern)).AppendLine(",");
}

builder.Length -= SourceCodeBuilder.s_newLineLength + 1;
builder.NewLine();
builder.AppendLine("}");
Expand Down Expand Up @@ -424,6 +437,7 @@ internal static void GenerateMandatoryMethods(GeneratorData data, SourceCodeBuil
}

AddStringLengthValidation(data, builder);
AddPatternValidation(data, builder);

builder.AppendLine("var validationResult = Validate(value);")
.AppendLine("if (!validationResult.IsValid)")
Expand Down Expand Up @@ -477,6 +491,24 @@ static void AddStringLengthValidation(GeneratorData data, SourceCodeBuilder sb)
.CloseBracket()
.NewLine();
}

static void AddPatternValidation(GeneratorData data, SourceCodeBuilder sb)
{
if (data.Pattern is null)
return;

if (!data.ValidatePattern)
return;

var quoted = QuoteAndEscape(data.Pattern);
sb.AppendLine($"if (!Regex.IsMatch(value, {quoted}, RegexOptions.Compiled))")
.OpenBracket()
.AppendLine("result = null;")
.AppendLine($"errorMessage = \"String does not match the required pattern: \" + {quoted};")
.AppendLine("return false;")
.CloseBracket()
.NewLine();
}
}

/// <summary>
Expand Down
11 changes: 11 additions & 0 deletions src/AltaSoft.DomainPrimitives.Generator/Models/GeneratorData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,15 @@ internal sealed class GeneratorData
/// Indicates whether the `Transform` method should be invoked before validation and instantiation.
/// </summary>
public bool UseTransformMethod { get; set; }

/// <summary>
/// Gets or sets pattern for OpenAPI schema generation, or validation.
/// </summary>
public string? Pattern { get; set; }

/// <summary>
/// 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.
/// If set to `false`, the pattern will only be used for OpenAPI schema generation and will not be validated at runtime.
/// </summary>
public bool ValidatePattern { get; set; }
}
13 changes: 13 additions & 0 deletions src/AltaSoft.DomainPrimitives/InvalidDomainValueException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ public static InvalidDomainValueException LimitExceededException(Type type, int
return new InvalidDomainValueException($"The value has exceeded a {underlyingTypeName} limit", type, value);
}

/// <summary>
/// Creates an <see cref="InvalidDomainValueException"/> for string pattern mismatch errors.
/// </summary>
/// <param name="type">The <see cref="Type"/> of the domain primitive.</param>
/// <param name="value">The string value that failed to match the pattern.</param>
/// <param name="pattern">The expected regex pattern.</param>
/// <returns>An <see cref="InvalidDomainValueException"/> describing the pattern mismatch.</returns>
[EditorBrowsable(EditorBrowsableState.Never)]
public static InvalidDomainValueException InvalidPatternException(Type type, string value, string pattern)
{
return new InvalidDomainValueException($"String value does not match the required pattern '{pattern}'", type, value);
}

/// <summary>
/// Generates the error message for the <see cref="InvalidDomainValueException"/> including the underlying value.
/// </summary>
Expand Down
39 changes: 39 additions & 0 deletions src/AltaSoft.DomainPrimitives/PatternAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System;
using System.Diagnostics.CodeAnalysis;

namespace AltaSoft.DomainPrimitives;

/// <summary>
/// Specifies a regex pattern that is always emitted to the OpenAPI schema and can optionally be used for runtime validation.
/// By default, the pattern is not validated at runtime; validation occurs only when explicitly requested via <see cref="Validate"/>.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class PatternAttribute : Attribute
{
/// <summary>
/// Gets the regex pattern used in the generated OpenAPI schema and, when enabled, for runtime validation.
/// </summary>
public string Pattern { get; }

/// <summary>
/// Gets a value indicating whether the <see cref="Pattern"/> should also be enforced via runtime validation.
/// </summary>
public bool Validate { get; }

/// <summary>
/// Initializes a new instance of <see cref="PatternAttribute"/>.
/// </summary>
/// <param name="pattern">
/// The regex pattern that will always be included in the OpenAPI schema and may also be used for runtime validation.
/// </param>
/// <param name="validate">
/// A value indicating whether runtime validation should be performed using <paramref name="pattern"/>. Defaults to <see langword="false"/>
/// to avoid incurring runtime validation overhead unless explicitly requested.
/// </param>

public PatternAttribute([StringSyntax(StringSyntaxAttribute.Regex)] string pattern, bool validate = false)
{
Pattern = pattern;
Validate = validate;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,76 @@ namespace AltaSoft.DomainPrimitives.Generator.Tests;
public class DomainPrimitiveGeneratorTest
{

[Fact]
public Task StringValue_WithTransStringLengthAndPattern()
{
const string source = """
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using AltaSoft.DomainPrimitives;

namespace AltaSoft.DomainPrimitives;

/// <summary>
/// A string domain primitive with both length and pattern validation attributes, as well as a custom validation method.
/// </summary>
[StringLength(1, 100)]
[Pattern(@"[A-Z]{100}")]
internal partial class StringWithLengthAndPattern : IDomainValue<string>
{
/// <inheritdoc/>
public static PrimitiveValidationResult Validate(string value)
{
if (value == "Test")
return "Invalid Value";

return PrimitiveValidationResult.Ok;
}
}

""";

return TestHelper.Verify(source, (_, x, _) => Assert.Equal(4, x.Count));
}

[Fact]
public Task StringValue_WithTransStringLengthAndPatternWithValidation()
{
const string source = """
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using AltaSoft.DomainPrimitives;

namespace AltaSoft.DomainPrimitives;

/// <summary>
/// A string domain primitive with both length and pattern validation attributes, as well as a custom validation method.
/// </summary>
[StringLength(1, 100)]
[Pattern(@"[A-Z]{100}",true)]
internal partial class StringWithLengthAndPattern : IDomainValue<string>
{
/// <inheritdoc/>
public static PrimitiveValidationResult Validate(string value)
{
if (value == "Test")
return "Invalid Value";

return PrimitiveValidationResult.Ok;
}
}

""";

return TestHelper.Verify(source, (_, x, _) => Assert.Equal(4, x.Count));
}

[Fact]
public Task StringValue_WithTransformerGeneratesTransformerCall()
{
Expand All @@ -18,7 +88,6 @@ public Task StringValue_WithTransformerGeneratesTransformerCall()

namespace AltaSoft.DomainPrimitives;

/// <inheritdoc/>
[StringLength(1, 100)]
internal partial class TransformableString : IDomainValue<string>
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
namespace AltaSoft.DomainPrimitives.Generator.Tests;

public class PrimitivesWithPatternAttributeTests
{
[Theory]
[InlineData("[A-Z]{3}")]
[InlineData("[A-Z]{10}")]
[InlineData("\\d+")]
[InlineData("\\w+")]
[InlineData("\\s+")]
[InlineData("[A-Z]{3}\\d{2}")]
[InlineData("[A-Z]{3}-\\d{3}")]
[InlineData("^\\d{4}-\\d{2}-\\d{2}$")]
[InlineData("^\\w+@\\w+\\.\\w+$")]
[InlineData("^\\d{3}-\\d{2}-\\d{4}$")]
public void Pattern_Should_Compile(string pattern)
{
var source = $$"""
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using AltaSoft.DomainPrimitives;

namespace AltaSoft.DomainPrimitives;

/// <summary>
/// A string domain primitive with both length and pattern validation attributes, as well as a custom validation method.
/// </summary>
[StringLength(1, 100)]
[Pattern(@"{{pattern}}, true")]
internal partial class StringWithLengthAndPattern : IDomainValue<string>
{
/// <inheritdoc/>
public static PrimitiveValidationResult Validate(string value)
{
return PrimitiveValidationResult.Ok;
}
}

""";

TestHelper.Compile(source, (_, sources, _) => Assert.Equal(4, sources.Count));

}

}
Loading