From 39d5eaa13665ed5591b40a56f97d7e44d723e55e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Wed, 17 Dec 2025 14:49:35 +0100 Subject: [PATCH 01/20] Support string raw and prefixed typed IDs --- .../IdSource.cs | 323 ++++++++++++++++-- .../TypedIdData.cs | 7 +- .../TypedIdFormat.cs | 6 +- .../TypedIdGenerator.cs | 5 + .../LeanCode.DomainModels/Ids/ITypedId.cs | 21 ++ .../Ids/TypedIdAttribute.cs | 25 +- .../Ids/TypedIdConverter.cs | 20 ++ .../Ids/PrefixedStringTests.cs | 302 ++++++++++++++++ .../Ids/RawStringTests.cs | 255 ++++++++++++++ .../Ids/ValidConstructTests.cs | 88 ++++- 10 files changed, 1007 insertions(+), 45 deletions(-) create mode 100644 test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs create mode 100644 test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs diff --git a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs index 85acc4b42..548691672 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs @@ -4,42 +4,33 @@ internal static class IdSource { public static string Build(TypedIdData data) { - switch (data.Format) + return data.Format switch { - case TypedIdFormat.RawInt: - return BuildRaw( - data, - "int", - "Int", - null, - "0", - "CultureInfo.InvariantCulture", - "string.Empty, CultureInfo.InvariantCulture" - ); - - case TypedIdFormat.RawLong: - return BuildRaw( - data, - "long", - "Long", - null, - "0", - "CultureInfo.InvariantCulture", - "string.Empty, CultureInfo.InvariantCulture" - ); - - case TypedIdFormat.RawGuid: - return BuildRaw(data, "Guid", "Guid", "Guid.NewGuid()", "Guid.Empty", "", "string.Empty"); - - case TypedIdFormat.PrefixedGuid: - return BuildPrefixedGuid(data); - - case TypedIdFormat.PrefixedUlid: - return BuildPrefixedUlid(data); - - default: - throw new ArgumentException("Unsupported ID format."); - } + TypedIdFormat.RawInt => BuildRaw( + data, + "int", + "Int", + null, + "0", + "CultureInfo.InvariantCulture", + "string.Empty, CultureInfo.InvariantCulture" + ), + TypedIdFormat.RawLong => BuildRaw( + data, + "long", + "Long", + null, + "0", + "CultureInfo.InvariantCulture", + "string.Empty, CultureInfo.InvariantCulture" + ), + TypedIdFormat.RawGuid => BuildRaw(data, "Guid", "Guid", "Guid.NewGuid()", "Guid.Empty", "", "string.Empty"), + TypedIdFormat.RawString => BuildRawString(data), + TypedIdFormat.PrefixedGuid => BuildPrefixedGuid(data), + TypedIdFormat.PrefixedUlid => BuildPrefixedUlid(data), + TypedIdFormat.PrefixedString => BuildPrefixedString(data), + _ => throw new ArgumentException("Unsupported ID format."), + }; } private static string BuildPrefixedGuid(TypedIdData data) @@ -300,6 +291,158 @@ public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnly """; } + private static string BuildPrefixedString(TypedIdData data) + { + var prefix = data.CustomPrefix?.ToLowerInvariant() ?? GetDefaultPrefix(data.TypeName); + + var maxLengthProperties = data.MaxValuePartLength is int maxLen + ? $$""" + public static int MaxValuePartLength { get; } = {{maxLen}}; + public static int MaxLength { get; } = {{prefix.Length + 1 + maxLen}}; +""" + : ""; + + var maxLengthValidation = data.MaxValuePartLength.HasValue ? " && valuePart.Length <= MaxValuePartLength" : ""; + + return $$""" +// +#nullable enable +namespace {{data.Namespace}} +{ + #pragma warning disable CS8019 + using global::System; + using global::System.ComponentModel; + using global::System.Diagnostics.CodeAnalysis; + using global::System.Diagnostics; + using global::System.Linq.Expressions; + using global::System.Text; + using global::System.Text.Json.Serialization; + using global::LeanCode.DomainModels.Ids; + #pragma warning restore CS8019 + + [JsonConverter(typeof(RawStringTypedIdConverter<{{data.TypeName}}>))] + [DebuggerDisplay("{Value}")] + [ExcludeFromCodeCoverage] + public readonly partial record struct {{data.TypeName}} : IRawStringTypedId<{{data.TypeName}}> + { + private const char Separator = '_'; + private const string TypePrefix = "{{prefix}}"; + + public static readonly {{data.TypeName}} Empty = new(string.Empty); +{{maxLengthProperties}} + private readonly string? value; + + public string Value => value ?? Empty.Value; + public bool IsEmpty => string.IsNullOrEmpty(value); + + private {{data.TypeName}}(string v) => value = v; + + public {{data.TypeName}}(string valuePart, bool _) + { + if (valuePart is null) + { + throw new ArgumentNullException(nameof(valuePart)); + } + value = $"{TypePrefix}{Separator}{valuePart}"; + } + + public static {{data.TypeName}} FromValuePart(string valuePart) + { + if (valuePart is null) + { + throw new ArgumentNullException(nameof(valuePart)); + } + return new {{data.TypeName}}($"{TypePrefix}{Separator}{valuePart}"); + } + + public static {{data.TypeName}} Parse(string v) + { + if (IsValid(v)) + { + return new {{data.TypeName}}(v); + } + else + { + throw new FormatException( + $"The ID has invalid format. It should look like {TypePrefix}{Separator}(value)." + ); + } + } + + [return: NotNullIfNotNull("id")] + public static {{data.TypeName}}? ParseNullable(string? id) => id is string v ? Parse(v) : ({{data.TypeName}}?)null; + + public static bool TryParse([NotNullWhen(true)] string? v, out {{data.TypeName}} id) + { + if (IsValid(v)) + { + id = new {{data.TypeName}}(v); + return true; + } + else + { + id = default; + return false; + } + } + + public static bool IsValid([NotNullWhen(true)] string? v) + { + if (v is null) + { + return false; + } + + var span = v.AsSpan(); + if (span.Length < {{prefix.Length + + 1}} || !span.StartsWith(TypePrefix) || span[{{prefix.Length}}] != Separator) + { + return false; + } + + var valuePart = span[{{prefix.Length + 1}}..]; + return valuePart.Length > 0{{maxLengthValidation}}; + } + + public ReadOnlySpan GetValuePart() => Value.AsSpan()[{{prefix.Length + 1}}..]; + + public bool Equals({{data.TypeName}} other) => Value.Equals(other.Value, StringComparison.Ordinal); + public int CompareTo({{data.TypeName}} other) => string.Compare(Value, other.Value, StringComparison.Ordinal); + public override int GetHashCode() => Value.GetHashCode(StringComparison.Ordinal); + public static implicit operator string({{data.TypeName}} id) => id.Value; + + public static bool operator <({{data.TypeName}} a, {{data.TypeName}} b) => a.CompareTo(b) < 0; + public static bool operator <=({{data.TypeName}} a, {{data.TypeName}} b) => a.CompareTo(b) <= 0; + public static bool operator >({{data.TypeName}} a, {{data.TypeName}} b) => a.CompareTo(b) > 0; + public static bool operator >=({{data.TypeName}} a, {{data.TypeName}} b) => a.CompareTo(b) >= 0; + + static Expression> IRawStringTypedId<{{data.TypeName}}>.FromDatabase { get; } = d => Parse(d); + static Expression> IRawStringTypedId<{{data.TypeName}}>.DatabaseEquals { get; } = (a, b) => a == b; + + public override string ToString() => Value; + public string ToString(string? format, IFormatProvider? formatProvider) => Value; + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + if (destination.Length >= Value.Length) + { + Value.CopyTo(destination); + charsWritten = Value.Length; + return true; + } + else + { + charsWritten = 0; + return false; + } + } + + public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) + => Encoding.UTF8.TryGetBytes(Value, utf8Destination, out bytesWritten); + } +} +"""; + } + private static string BuildRaw( TypedIdData data, string backingType, @@ -395,6 +538,116 @@ public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnly """; } + private static string BuildRawString(TypedIdData data) + { + var maxLengthProperty = data.MaxValuePartLength is int maxLen + ? $"public static int MaxValuePartLength {{ get; }} = {maxLen};" + : ""; + + var maxLengthValidation = data.MaxValuePartLength.HasValue ? " && v.Length <= MaxValuePartLength" : ""; + + return $$""" +// +#nullable enable +namespace {{data.Namespace}} +{ + #pragma warning disable CS8019 + using global::System; + using global::System.ComponentModel; + using global::System.Diagnostics.CodeAnalysis; + using global::System.Diagnostics; + using global::System.Linq.Expressions; + using global::System.Text; + using global::System.Text.Json.Serialization; + using global::LeanCode.DomainModels.Ids; + #pragma warning restore CS8019 + + [JsonConverter(typeof(RawStringTypedIdConverter<{{data.TypeName}}>))] + [DebuggerDisplay("{Value}")] + [ExcludeFromCodeCoverage] + public readonly partial record struct {{data.TypeName}} : IRawStringTypedId<{{data.TypeName}}> + { + public static readonly {{data.TypeName}} Empty = new(string.Empty); + {{maxLengthProperty}} + + private readonly string? value; + + public string Value => value ?? string.Empty; + public bool IsEmpty => string.IsNullOrEmpty(value); + + public {{data.TypeName}}(string v) => value = v ?? throw new ArgumentNullException(nameof(v)); + + public static {{data.TypeName}} Parse(string v) + { + if (IsValid(v)) + { + return new {{data.TypeName}}(v); + } + else + { + throw new FormatException("The ID has invalid format."); + } + } + + [return: NotNullIfNotNull("id")] + public static {{data.TypeName}}? ParseNullable(string? id) => id is string v ? Parse(v) : ({{data.TypeName}}?)null; + + public static bool TryParse([NotNullWhen(true)] string? v, out {{data.TypeName}} id) + { + if (IsValid(v)) + { + id = new {{data.TypeName}}(v); + return true; + } + else + { + id = default; + return false; + } + } + + public static bool IsValid([NotNullWhen(true)] string? v) + { + return v is not null{{maxLengthValidation}}; + } + + public bool Equals({{data.TypeName}} other) => Value.Equals(other.Value, StringComparison.Ordinal); + public int CompareTo({{data.TypeName}} other) => string.Compare(Value, other.Value, StringComparison.Ordinal); + public override int GetHashCode() => Value.GetHashCode(StringComparison.Ordinal); + public static implicit operator string({{data.TypeName}} id) => id.Value; + + public static bool operator <({{data.TypeName}} a, {{data.TypeName}} b) => a.CompareTo(b) < 0; + public static bool operator <=({{data.TypeName}} a, {{data.TypeName}} b) => a.CompareTo(b) <= 0; + public static bool operator >({{data.TypeName}} a, {{data.TypeName}} b) => a.CompareTo(b) > 0; + public static bool operator >=({{data.TypeName}} a, {{data.TypeName}} b) => a.CompareTo(b) >= 0; + + static Expression> IRawStringTypedId<{{data.TypeName}}>.FromDatabase { get; } = d => Parse(d); + static Expression> IRawStringTypedId<{{data.TypeName}}>.DatabaseEquals { get; } = (a, b) => a == b; + + public override string ToString() => Value; + public string ToString(string? format, IFormatProvider? formatProvider) => Value; + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + if (destination.Length >= Value.Length) + { + Value.CopyTo(destination); + charsWritten = Value.Length; + return true; + } + else + { + charsWritten = 0; + return false; + } + } + + public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) + => Encoding.UTF8.TryGetBytes(Value, utf8Destination, out bytesWritten); + } +} +"""; + } + private static string GetDefaultPrefix(string typeName) { typeName = typeName.ToLowerInvariant(); diff --git a/src/Domain/LeanCode.DomainModels.Generators/TypedIdData.cs b/src/Domain/LeanCode.DomainModels.Generators/TypedIdData.cs index e60efbe4b..decad7535 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/TypedIdData.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/TypedIdData.cs @@ -9,6 +9,7 @@ public sealed class TypedIdData public string TypeName { get; } public string? CustomPrefix { get; } public bool SkipRandomGenerator { get; } + public int? MaxValuePartLength { get; } public bool IsValid { get; } public Location? Location { get; } @@ -16,8 +17,9 @@ public TypedIdData( TypedIdFormat format, string @namespace, string typeName, - string? customSlug, + string? customPrefix, bool skipRandomGenerator, + int? maxValuePartLength, bool isValid, Location? location ) @@ -25,8 +27,9 @@ public TypedIdData( Format = format; Namespace = @namespace; TypeName = typeName; - CustomPrefix = customSlug; + CustomPrefix = customPrefix; SkipRandomGenerator = skipRandomGenerator; + MaxValuePartLength = maxValuePartLength; IsValid = isValid; Location = location; } diff --git a/src/Domain/LeanCode.DomainModels.Generators/TypedIdFormat.cs b/src/Domain/LeanCode.DomainModels.Generators/TypedIdFormat.cs index ad04b0218..6b48d6c59 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/TypedIdFormat.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/TypedIdFormat.cs @@ -5,6 +5,8 @@ public enum TypedIdFormat RawInt = 0, RawLong = 1, RawGuid = 2, - PrefixedGuid = 3, - PrefixedUlid = 4, + RawString = 3, + PrefixedGuid = 4, + PrefixedUlid = 5, + PrefixedString = 6, } diff --git a/src/Domain/LeanCode.DomainModels.Generators/TypedIdGenerator.cs b/src/Domain/LeanCode.DomainModels.Generators/TypedIdGenerator.cs index ec3203955..8209a7fae 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/TypedIdGenerator.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/TypedIdGenerator.cs @@ -10,6 +10,7 @@ public sealed class TypedIdGenerator : IIncrementalGenerator private const string AttributeName = "LeanCode.DomainModels.Ids.TypedIdAttribute"; private const string CustomPrefixField = "CustomPrefix"; private const string SkipRandomGeneratorField = "SkipRandomGenerator"; + private const string MaxValuePartLengthField = "MaxValuePartLength"; private static readonly DiagnosticDescriptor InvalidTypeRule = new( "LNCD0005", @@ -36,6 +37,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var skipRandomGenerator = attribute .NamedArguments.FirstOrDefault(a => a.Key == SkipRandomGeneratorField) .Value.Value; + var maxValuePartLength = attribute + .NamedArguments.FirstOrDefault(a => a.Key == MaxValuePartLengthField) + .Value.Value; var isValid = IsValidSyntaxNode(n.TargetNode); return new TypedIdData( (TypedIdFormat)idFormat, @@ -43,6 +47,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) n.TargetSymbol.Name, (string?)customPrefix, skipRandomGenerator is true, + maxValuePartLength is int mvpl && mvpl >= 0 ? mvpl : null, isValid, !isValid ? n.TargetNode.GetLocation() : null ); diff --git a/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs b/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs index c01ee4caf..f13e60e2f 100644 --- a/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs +++ b/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs @@ -47,3 +47,24 @@ public interface IRawTypedId [EditorBrowsable(EditorBrowsableState.Never)] static abstract Expression> DatabaseEquals { get; } } + +[SuppressMessage("?", "CA1000", Justification = "Roslyn bug.")] +[EditorBrowsable(EditorBrowsableState.Never)] +public interface IRawStringTypedId + : IEquatable, + IComparable, + ISpanFormattable, + IUtf8SpanFormattable, + IEqualityOperators + where TSelf : struct, IRawStringTypedId +{ + string Value { get; } + static abstract TSelf Parse(string v); + static abstract bool IsValid(string? v); + + [EditorBrowsable(EditorBrowsableState.Never)] + static abstract Expression> FromDatabase { get; } + + [EditorBrowsable(EditorBrowsableState.Never)] + static abstract Expression> DatabaseEquals { get; } +} diff --git a/src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs b/src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs index bc3f05ef1..f22643182 100644 --- a/src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs +++ b/src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs @@ -23,17 +23,28 @@ public enum TypedIdFormat /// RawGuid = 2, + /// + /// Raw , without prefix. It's backing type is . + /// + RawString = 3, + /// /// prefixed with the class name or . It's backing /// type is . /// - PrefixedGuid = 3, + PrefixedGuid = 4, /// /// prefixed with the class name or . It's backing /// type is . /// - PrefixedUlid = 4, + PrefixedUlid = 5, + + /// + /// Arbitrary prefixed with the class name or . + /// It's backing type is . + /// + PrefixedString = 6, } /// @@ -66,6 +77,16 @@ public sealed class TypedIdAttribute : Attribute /// public bool SkipRandomGenerator { get; set; } + /// + /// Maximum length of the value part. For , this is the entire string. + /// For , this excludes the prefix and separator. + /// If not set (default value of -1), no length validation is performed. + /// + /// + /// Consider SQL Server's 900-byte key limit (~450 nvarchar chars) when choosing this value. + /// + public int MaxValuePartLength { get; set; } = -1; + public TypedIdAttribute(TypedIdFormat format) { Format = format; diff --git a/src/Domain/LeanCode.DomainModels/Ids/TypedIdConverter.cs b/src/Domain/LeanCode.DomainModels/Ids/TypedIdConverter.cs index 44e802339..501021f5e 100644 --- a/src/Domain/LeanCode.DomainModels/Ids/TypedIdConverter.cs +++ b/src/Domain/LeanCode.DomainModels/Ids/TypedIdConverter.cs @@ -27,6 +27,26 @@ public override void WriteAsPropertyName(Utf8JsonWriter writer, TId value, JsonS writer.WritePropertyName(value.Value); } +[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] +public class RawStringTypedIdConverter : JsonConverter + where TId : struct, IRawStringTypedId +{ + public override TId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + TId.Parse(reader.GetString() ?? throw new JsonException("Expected an id string")); + + public override void Write(Utf8JsonWriter writer, TId value, JsonSerializerOptions options) => + writer.WriteStringValue(value.Value); + + public override TId ReadAsPropertyName( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) => Read(ref reader, typeToConvert, options); + + public override void WriteAsPropertyName(Utf8JsonWriter writer, TId value, JsonSerializerOptions options) => + writer.WritePropertyName(value.Value); +} + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] public class IntTypedIdConverter : JsonConverter where TId : struct, IRawTypedId diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs new file mode 100644 index 000000000..a7294169d --- /dev/null +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs @@ -0,0 +1,302 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using LeanCode.DomainModels.Ids; +using Xunit; + +namespace LeanCode.DomainModels.Tests.Ids; + +[TypedId(TypedIdFormat.PrefixedString, CustomPrefix = "tps")] +public readonly partial record struct TestPrefixedStringId; + +[TypedId(TypedIdFormat.PrefixedString, CustomPrefix = "tpm", MaxValuePartLength = 10)] +public readonly partial record struct TestPrefixedStringIdWithMaxLength; + +public class PrefixedStringIdTests +{ + private const string TPSEmpty = ""; + private const string TPS1 = "tps_abc123"; + private const string TPS2 = "tps_def456"; + private const string TPS3 = "tps_xyz789"; + + [Fact] + [SuppressMessage("?", "xUnit2007", Justification = "Cannot use `IRawStringTypedId` as generic parameter.")] + public void Generated_class_implements_ITypedId() + { + Assert.IsAssignableFrom(typeof(IRawStringTypedId), new TestPrefixedStringId()); + } + + [Fact] + public void Default_and_empty_are_equal() + { + Assert.Equal(TestPrefixedStringId.Empty, default); + } + + [Fact] + public void Default_value_has_empty_string_value() + { + Assert.Equal(TPSEmpty, TestPrefixedStringId.Empty.Value); + } + + [Fact] + public void From_null_behaves_correctly() + { + Assert.False(TestPrefixedStringId.IsValid(null)); + + Assert.Throws(() => TestPrefixedStringId.Parse(null!)); + Assert.Throws(() => TestPrefixedStringId.ParseNullable("invalid")); + Assert.False(TestPrefixedStringId.TryParse(null, out var value)); + Assert.Equal(value, default); + } + + [Fact] + public void From_malformed_value_behaves_correctly() + { + Assert.False(TestPrefixedStringId.IsValid("invalid")); + + Assert.Throws(() => TestPrefixedStringId.Parse("invalid")); + Assert.Throws(() => TestPrefixedStringId.ParseNullable("invalid")); + Assert.False(TestPrefixedStringId.TryParse("invalid", out var value)); + Assert.Equal(value, default); + } + + [Fact] + public void From_value_with_invalid_prefix_behaves_correctly() + { + Assert.False(TestPrefixedStringId.IsValid("tps2_abc123")); + + Assert.Throws(() => TestPrefixedStringId.Parse("tps2_abc123")); + Assert.Throws(() => TestPrefixedStringId.ParseNullable("tps2_abc123")); + Assert.False(TestPrefixedStringId.TryParse("tps2_abc123", out var value)); + Assert.Equal(value, default); + } + + [Fact] + public void From_value_without_separator_behaves_correctly() + { + Assert.False(TestPrefixedStringId.IsValid("tpsabc123")); + + Assert.Throws(() => TestPrefixedStringId.Parse("tpsabc123")); + Assert.False(TestPrefixedStringId.TryParse("tpsabc123", out _)); + } + + [Fact] + public void From_value_with_empty_value_part_behaves_correctly() + { + Assert.False(TestPrefixedStringId.IsValid("tps_")); + + Assert.Throws(() => TestPrefixedStringId.Parse("tps_")); + Assert.False(TestPrefixedStringId.TryParse("tps_", out _)); + } + + [Fact] + public void From_valid_value_behaves_correctly() + { + Assert.True(TestPrefixedStringId.IsValid(TPS1)); + + Assert.Equal(TPS1, TestPrefixedStringId.Parse(TPS1).Value); + Assert.Equal(TPS1, TestPrefixedStringId.ParseNullable(TPS1)!.Value.Value); + Assert.True(TestPrefixedStringId.TryParse(TPS1, out var value)); + Assert.Equal(TPS1, value.Value); + } + + [Fact] + public void FromValuePart_creates_prefixed_id() + { + var id = TestPrefixedStringId.FromValuePart("abc123"); + Assert.Equal(TPS1, id.Value); + } + + [Fact] + public void GetValuePart_extracts_value_part() + { + var id = TestPrefixedStringId.Parse(TPS1); + Assert.Equal("abc123", id.GetValuePart().ToString()); + } + + [Fact] + public void Equals_behaves_correctly() + { + Assert.True(TestPrefixedStringId.Parse(TPS1).Equals(TestPrefixedStringId.Parse(TPS1))); + Assert.False(TestPrefixedStringId.Parse(TPS1).Equals(TestPrefixedStringId.Parse(TPS2))); + Assert.False(TestPrefixedStringId.Parse(TPS1).Equals(null)); + } + + [Fact] + public void CompareTo_behaves_correctly() + { + Assert.Equal(0, TestPrefixedStringId.Parse(TPS1).CompareTo(TestPrefixedStringId.Parse(TPS1))); + Assert.True(TestPrefixedStringId.Parse(TPS1).CompareTo(TestPrefixedStringId.Parse(TPS2)) < 0); + Assert.True(TestPrefixedStringId.Parse(TPS2).CompareTo(TestPrefixedStringId.Parse(TPS1)) > 0); + } + + [Fact] + public void Comparisons_behave_correctly() + { + Assert.True(TestPrefixedStringId.Parse(TPS1) < TestPrefixedStringId.Parse(TPS2)); + Assert.False(TestPrefixedStringId.Parse(TPS2) < TestPrefixedStringId.Parse(TPS2)); + Assert.False(TestPrefixedStringId.Parse(TPS3) < TestPrefixedStringId.Parse(TPS2)); + + Assert.True(TestPrefixedStringId.Parse(TPS1) <= TestPrefixedStringId.Parse(TPS2)); + Assert.True(TestPrefixedStringId.Parse(TPS2) <= TestPrefixedStringId.Parse(TPS2)); + Assert.False(TestPrefixedStringId.Parse(TPS3) <= TestPrefixedStringId.Parse(TPS2)); + + Assert.False(TestPrefixedStringId.Parse(TPS1) > TestPrefixedStringId.Parse(TPS2)); + Assert.False(TestPrefixedStringId.Parse(TPS2) > TestPrefixedStringId.Parse(TPS2)); + Assert.True(TestPrefixedStringId.Parse(TPS3) > TestPrefixedStringId.Parse(TPS2)); + + Assert.False(TestPrefixedStringId.Parse(TPS1) >= TestPrefixedStringId.Parse(TPS2)); + Assert.True(TestPrefixedStringId.Parse(TPS2) >= TestPrefixedStringId.Parse(TPS2)); + Assert.True(TestPrefixedStringId.Parse(TPS3) >= TestPrefixedStringId.Parse(TPS2)); + + Assert.True(TestPrefixedStringId.Parse(TPS1) == TestPrefixedStringId.Parse(TPS1)); + Assert.False(TestPrefixedStringId.Parse(TPS1) == TestPrefixedStringId.Parse(TPS2)); + Assert.True(TestPrefixedStringId.Parse(TPS1) != TestPrefixedStringId.Parse(TPS2)); + Assert.False(TestPrefixedStringId.Parse(TPS1) != TestPrefixedStringId.Parse(TPS1)); + } + + [Fact] + public void The_hash_code_is_equal_to_underlying_value() + { + Assert.Equal(TPS1.GetHashCode(StringComparison.Ordinal), TestPrefixedStringId.Parse(TPS1).GetHashCode()); + } + + [Fact] + public void Casts_to_underlying_type_extract_the_value() + { + string implicitValue = TestPrefixedStringId.Parse(TPS1); + var explicitValue = (string)TestPrefixedStringId.Parse(TPS2); + + Assert.Equal(TPS1, implicitValue); + Assert.Equal(TPS2, explicitValue); + } + + [Fact] + public void ToString_returns_string_representation_of_the_underlying_value() + { + Assert.Equal(TPS1, TestPrefixedStringId.Parse(TPS1).ToString()); + } + + [Fact] + public void ToString_from_FromValuePart_returns_the_prefixed_value() + { + Assert.Equal(TPS1, TestPrefixedStringId.FromValuePart("abc123").ToString()); + } + + [Fact] + public void The_type_can_be_serialized_and_deserialized_to_from_JSON() + { + var value = TestPrefixedStringId.Parse(TPS1); + + var json = JsonSerializer.Serialize(value); + var deserialized = JsonSerializer.Deserialize(json); + + Assert.Equal(value, deserialized); + } + + [Fact] + public void The_type_serializes_as_raw_underlying_type() + { + var value = TestPrefixedStringId.Parse(TPS1); + + var json = JsonSerializer.Serialize(value); + + Assert.Equal("\"" + TPS1 + "\"", json); + } + + [Fact] + public void The_type_can_be_serialized_and_deserialized_as_dictionary_key_from_JSON() + { + var value = TestPrefixedStringId.Parse(TPS1); + var dict = new Dictionary { [value] = 1 }; + + var json = JsonSerializer.Serialize(dict); + var deserialized = JsonSerializer.Deserialize>(json); + + Assert.Equal(dict, deserialized); + } + + [Fact] + public void Database_expressions_work() + { + DatabaseExpressionsWork(); + + static void DatabaseExpressionsWork() + where T : struct, IRawStringTypedId + { + Assert.Equal(T.FromDatabase.Compile().Invoke(TPS1), T.Parse(TPS1)); + Assert.True(T.DatabaseEquals.Compile().Invoke(T.Parse(TPS1), T.Parse(TPS1))); + } + } + + [Fact] + public void TryFormatChar_is_correct() + { + var id = TestPrefixedStringId.Parse(TPS1); + var buffer = new char[50]; + + id.TryFormat(buffer.AsSpan(0, 5), out var charsWritten, "", null).Should().BeFalse(); + charsWritten.Should().Be(0); + + id.TryFormat(buffer, out charsWritten, "", null).Should().BeTrue(); + charsWritten.Should().Be(TPS1.Length); + new string(buffer[..TPS1.Length]).Should().Be(TPS1); + buffer[TPS1.Length..].Should().AllBeEquivalentTo(default(char)); + } + + [Fact] + public void TryFormatUtf8Byte_is_correct() + { + var id = TestPrefixedStringId.Parse(TPS1); + var buffer = new byte[50]; + var expectedBytes = Encoding.UTF8.GetBytes(TPS1); + + id.TryFormat(buffer.AsSpan(0, 5), out var bytesWritten, "", null).Should().BeFalse(); + bytesWritten.Should().Be(0); + + id.TryFormat(buffer, out bytesWritten, "", null).Should().BeTrue(); + bytesWritten.Should().Be(TPS1.Length); + buffer[..TPS1.Length].Should().BeEquivalentTo(expectedBytes); + buffer[TPS1.Length..].Should().AllBeEquivalentTo(default(byte)); + } + + [Fact] + public void IsEmpty_works_correctly() + { + Assert.True(TestPrefixedStringId.Empty.IsEmpty); + Assert.False(TestPrefixedStringId.Parse(TPS1).IsEmpty); + } +} + +public class PrefixedStringIdWithMaxLengthTests +{ + [Fact] + public void MaxValuePartLength_is_exposed() + { + Assert.Equal(10, TestPrefixedStringIdWithMaxLength.MaxValuePartLength); + } + + [Fact] + public void MaxLength_is_calculated_correctly() + { + // prefix "tpm" (3) + separator "_" (1) + max value part (10) = 14 + Assert.Equal(14, TestPrefixedStringIdWithMaxLength.MaxLength); + } + + [Fact] + public void String_within_max_length_is_valid() + { + Assert.True(TestPrefixedStringIdWithMaxLength.IsValid("tpm_1234567890")); // value part = 10 chars + Assert.True(TestPrefixedStringIdWithMaxLength.IsValid("tpm_short")); + } + + [Fact] + public void String_exceeding_max_length_is_invalid() + { + Assert.False(TestPrefixedStringIdWithMaxLength.IsValid("tpm_12345678901")); // value part = 11 chars + + Assert.Throws(() => TestPrefixedStringIdWithMaxLength.Parse("tpm_12345678901")); + Assert.False(TestPrefixedStringIdWithMaxLength.TryParse("tpm_this_is_too_long", out _)); + } +} diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs new file mode 100644 index 000000000..853a1c9db --- /dev/null +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs @@ -0,0 +1,255 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using LeanCode.DomainModels.Ids; +using Xunit; + +namespace LeanCode.DomainModels.Tests.Ids; + +[TypedId(TypedIdFormat.RawString)] +public readonly partial record struct TestRawStringId; + +[TypedId(TypedIdFormat.RawString, MaxValuePartLength = 10)] +public readonly partial record struct TestRawStringIdWithMaxLength; + +public class RawStringIdTests +{ + private const string String1 = "abc123"; + private const string String2 = "def456"; + private const string String3 = "xyz789"; + + [Fact] + [SuppressMessage("?", "xUnit2007", Justification = "Cannot use `IRawStringTypedId` as generic parameter.")] + public void Generated_class_implements_ITypedId() + { + Assert.IsAssignableFrom(typeof(IRawStringTypedId), new TestRawStringId()); + } + + [Fact] + public void Default_and_empty_are_equal() + { + Assert.Equal(TestRawStringId.Empty, default); + } + + [Fact] + public void Default_value_has_empty_string_value() + { + Assert.Equal(string.Empty, TestRawStringId.Empty.Value); + } + + [Fact] + public void Creating_value_out_of_string_works() + { + Assert.Equal(String1, new TestRawStringId(String1).Value); + } + + [Fact] + public void From_null_behaves_correctly() + { + Assert.False(TestRawStringId.IsValid(null)); + + Assert.Throws(() => TestRawStringId.Parse(null!)); + Assert.Null(TestRawStringId.ParseNullable(null)); + Assert.False(TestRawStringId.TryParse(null, out var value)); + Assert.Equal(value, default); + } + + [Fact] + public void From_string_behaves_correctly() + { + Assert.True(TestRawStringId.IsValid(String1)); + + Assert.Equal(new TestRawStringId(String1), TestRawStringId.Parse(String1)); + Assert.Equal(new TestRawStringId(String1), TestRawStringId.ParseNullable(String1)); + Assert.True(TestRawStringId.TryParse(String1, out var value)); + Assert.Equal(new TestRawStringId(String1), value); + } + + [Fact] + public void Empty_string_is_valid() + { + Assert.True(TestRawStringId.IsValid(string.Empty)); + Assert.Equal(string.Empty, TestRawStringId.Parse(string.Empty).Value); + } + + [Fact] + public void Equals_behaves_correctly() + { + Assert.True(new TestRawStringId(String1).Equals(new TestRawStringId(String1))); + Assert.False(new TestRawStringId(String1).Equals(new TestRawStringId(String2))); + Assert.False(new TestRawStringId(String1).Equals(null)); + } + + [Fact] + public void CompareTo_behaves_correctly() + { + Assert.Equal(0, new TestRawStringId(String1).CompareTo(new TestRawStringId(String1))); + Assert.True(new TestRawStringId(String1).CompareTo(new TestRawStringId(String2)) < 0); + Assert.True(new TestRawStringId(String2).CompareTo(new TestRawStringId(String1)) > 0); + } + + [Fact] + public void Comparisons_behave_correctly() + { + Assert.True(new TestRawStringId(String1) < new TestRawStringId(String2)); + Assert.False(new TestRawStringId(String2) < new TestRawStringId(String2)); + Assert.False(new TestRawStringId(String3) < new TestRawStringId(String2)); + + Assert.True(new TestRawStringId(String1) <= new TestRawStringId(String2)); + Assert.True(new TestRawStringId(String2) <= new TestRawStringId(String2)); + Assert.False(new TestRawStringId(String3) <= new TestRawStringId(String2)); + + Assert.False(new TestRawStringId(String1) > new TestRawStringId(String2)); + Assert.False(new TestRawStringId(String2) > new TestRawStringId(String2)); + Assert.True(new TestRawStringId(String3) > new TestRawStringId(String2)); + + Assert.False(new TestRawStringId(String1) >= new TestRawStringId(String2)); + Assert.True(new TestRawStringId(String2) >= new TestRawStringId(String2)); + Assert.True(new TestRawStringId(String3) >= new TestRawStringId(String2)); + + Assert.True(new TestRawStringId(String1) == new TestRawStringId(String1)); + Assert.False(new TestRawStringId(String1) == new TestRawStringId(String2)); + Assert.True(new TestRawStringId(String1) != new TestRawStringId(String2)); + Assert.False(new TestRawStringId(String1) != new TestRawStringId(String1)); + } + + [Fact] + public void The_hash_code_is_equal_to_underlying_value() + { + Assert.Equal(String1.GetHashCode(StringComparison.Ordinal), new TestRawStringId(String1).GetHashCode()); + } + + [Fact] + public void Casts_to_underlying_type_extract_the_value() + { + string implicitValue = new TestRawStringId(String1); + var explicitValue = (string)new TestRawStringId(String2); + + Assert.Equal(String1, implicitValue); + Assert.Equal(String2, explicitValue); + } + + [Fact] + public void ToString_returns_string_representation_of_the_underlying_value() + { + Assert.Equal(String1, new TestRawStringId(String1).ToString()); + } + + [Fact] + public void The_type_can_be_serialized_and_deserialized_to_from_JSON() + { + var value = new TestRawStringId(String1); + + var json = JsonSerializer.Serialize(value); + var deserialized = JsonSerializer.Deserialize(json); + + Assert.Equal(value, deserialized); + } + + [Fact] + public void The_type_serializes_as_raw_underlying_type() + { + var value = new TestRawStringId(String1); + + var json = JsonSerializer.Serialize(value); + + Assert.Equal("\"" + String1 + "\"", json); + } + + [Fact] + public void The_type_can_be_serialized_and_deserialized_as_dictionary_key_from_JSON() + { + var value = new TestRawStringId(String1); + var dict = new Dictionary { [value] = 1 }; + + var json = JsonSerializer.Serialize(dict); + var deserialized = JsonSerializer.Deserialize>(json); + + Assert.Equal(dict, deserialized); + } + + [Fact] + public void Database_expressions_work() + { + DatabaseExpressionsWork(); + + static void DatabaseExpressionsWork() + where T : struct, IRawStringTypedId + { + var str = "test_value"; + Assert.Equal(T.FromDatabase.Compile().Invoke(str), T.Parse(str)); + Assert.True(T.DatabaseEquals.Compile().Invoke(T.Parse(str), T.Parse(str))); + } + } + + [Fact] + public void TryFormatChar_is_correct() + { + var id = TestRawStringId.Parse(String1); + var buffer = new char[50]; + + id.TryFormat(buffer.AsSpan(0, 3), out var charsWritten, "", null).Should().BeFalse(); + charsWritten.Should().Be(0); + + id.TryFormat(buffer, out charsWritten, "", null).Should().BeTrue(); + charsWritten.Should().Be(String1.Length); + new string(buffer[..String1.Length]).Should().Be(String1); + buffer[String1.Length..].Should().AllBeEquivalentTo(default(char)); + } + + [Fact] + public void TryFormatUtf8Byte_is_correct() + { + var id = TestRawStringId.Parse(String1); + var buffer = new byte[50]; + var expectedBytes = Encoding.UTF8.GetBytes(String1); + + id.TryFormat(buffer.AsSpan(0, 3), out var bytesWritten, "", null).Should().BeFalse(); + bytesWritten.Should().Be(0); + + id.TryFormat(buffer, out bytesWritten, "", null).Should().BeTrue(); + bytesWritten.Should().Be(String1.Length); + buffer[..String1.Length].Should().BeEquivalentTo(expectedBytes); + buffer[String1.Length..].Should().AllBeEquivalentTo(default(byte)); + } + + [Fact] + public void IsEmpty_works_correctly() + { + Assert.True(TestRawStringId.Empty.IsEmpty); + Assert.True(new TestRawStringId(string.Empty).IsEmpty); + Assert.False(new TestRawStringId(String1).IsEmpty); + } +} + +public class RawStringIdWithMaxLengthTests +{ + [Fact] + public void MaxValuePartLength_is_exposed() + { + Assert.Equal(10, TestRawStringIdWithMaxLength.MaxValuePartLength); + } + + [Fact] + public void String_within_max_length_is_valid() + { + Assert.True(TestRawStringIdWithMaxLength.IsValid("1234567890")); + Assert.True(TestRawStringIdWithMaxLength.IsValid("short")); + } + + [Fact] + public void String_exceeding_max_length_is_invalid() + { + Assert.False(TestRawStringIdWithMaxLength.IsValid("12345678901")); // 11 chars + + Assert.Throws(() => TestRawStringIdWithMaxLength.Parse("12345678901")); + Assert.False(TestRawStringIdWithMaxLength.TryParse("this_is_too_long", out _)); + } + + [Fact] + public void Empty_string_is_valid_with_max_length() + { + Assert.True(TestRawStringIdWithMaxLength.IsValid(string.Empty)); + } +} diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs index 465fc771d..89a2decef 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs @@ -38,7 +38,7 @@ namespace Test; """ using LeanCode.DomainModels.Ids; namespace Test; - [TypedId(TypedIdFormat.RawInt, CustomPrefix = "ignored", SkipRandomGenerator = true)] + [TypedId(TypedIdFormat.RawInt, CustomPrefix = "ignored", SkipRandomGenerator = true, MaxValuePartLength = 50)] public readonly partial record struct Id; """ ); @@ -78,7 +78,7 @@ namespace Test; """ using LeanCode.DomainModels.Ids; namespace Test; - [TypedId(TypedIdFormat.RawLong, CustomPrefix = "ignored", SkipRandomGenerator = true)] + [TypedId(TypedIdFormat.RawLong, CustomPrefix = "ignored", SkipRandomGenerator = true, MaxValuePartLength = 50)] public readonly partial record struct Id; """ ); @@ -118,7 +118,7 @@ namespace Test; """ using LeanCode.DomainModels.Ids; namespace Test; - [TypedId(TypedIdFormat.RawGuid, CustomPrefix = "ignored", SkipRandomGenerator = true)] + [TypedId(TypedIdFormat.RawGuid, CustomPrefix = "ignored", SkipRandomGenerator = true, MaxValuePartLength = 50)] public readonly partial record struct Id; """ ); @@ -158,7 +158,87 @@ namespace Test; """ using LeanCode.DomainModels.Ids; namespace Test; - [TypedId(TypedIdFormat.PrefixedGuid, CustomPrefix = "prefix", SkipRandomGenerator = true)] + [TypedId(TypedIdFormat.PrefixedGuid, CustomPrefix = "prefix", SkipRandomGenerator = true, MaxValuePartLength = 50)] + public readonly partial record struct Id; + """ + ); + } + + [Fact] + public void Correct_RawString() + { + AssertCorrect( + """ + using LeanCode.DomainModels.Ids; + namespace Test; + [TypedId(TypedIdFormat.RawString)] + public readonly partial record struct Id; + """ + ); + + AssertCorrect( + """ + using LeanCode.DomainModels.Ids; + namespace Test; + [TypedId(TypedIdFormat.RawString, CustomPrefix = "ignored")] + public readonly partial record struct Id; + """ + ); + + AssertCorrect( + """ + using LeanCode.DomainModels.Ids; + namespace Test; + [TypedId(TypedIdFormat.RawString, MaxValuePartLength = 100)] + public readonly partial record struct Id; + """ + ); + + AssertCorrect( + """ + using LeanCode.DomainModels.Ids; + namespace Test; + [TypedId(TypedIdFormat.RawString, CustomPrefix = "ignored", SkipRandomGenerator = true, MaxValuePartLength = 50)] + public readonly partial record struct Id; + """ + ); + } + + [Fact] + public void Correct_PrefixedString() + { + AssertCorrect( + """ + using LeanCode.DomainModels.Ids; + namespace Test; + [TypedId(TypedIdFormat.PrefixedString)] + public readonly partial record struct Id; + """ + ); + + AssertCorrect( + """ + using LeanCode.DomainModels.Ids; + namespace Test; + [TypedId(TypedIdFormat.PrefixedString, CustomPrefix = "prefix")] + public readonly partial record struct Id; + """ + ); + + AssertCorrect( + """ + using LeanCode.DomainModels.Ids; + namespace Test; + [TypedId(TypedIdFormat.PrefixedString, MaxValuePartLength = 100)] + public readonly partial record struct Id; + """ + ); + + AssertCorrect( + """ + using LeanCode.DomainModels.Ids; + namespace Test; + [TypedId(TypedIdFormat.PrefixedString, CustomPrefix = "prefix", SkipRandomGenerator = true, MaxValuePartLength = 50)] public readonly partial record struct Id; """ ); From 9bf5c22d70004f7216a8e3378e26400adfe8b338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Wed, 17 Dec 2025 14:52:57 +0100 Subject: [PATCH 02/20] Add language annotations for C# in strings for source generation code --- src/Domain/LeanCode.DomainModels.Generators/IdSource.cs | 7 +++++++ .../LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs index 548691672..c05327f32 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs @@ -41,6 +41,8 @@ private static string BuildPrefixedGuid(TypedIdData data) var randomFactory = !data.SkipRandomGenerator ? $"public static {data.TypeName} New() => new(Guid.NewGuid());" : ""; + + // language=C# return $$""" // #nullable enable @@ -167,6 +169,7 @@ private static string BuildPrefixedUlid(TypedIdData data) var prefix = data.CustomPrefix?.ToLowerInvariant() ?? GetDefaultPrefix(data.TypeName); var valueLength = 26; + // language=C# return $$""" // #nullable enable @@ -304,6 +307,7 @@ private static string BuildPrefixedString(TypedIdData data) var maxLengthValidation = data.MaxValuePartLength.HasValue ? " && valuePart.Length <= MaxValuePartLength" : ""; + // language=C# return $$""" // #nullable enable @@ -457,6 +461,8 @@ string tryFormatParams !data.SkipRandomGenerator && randomValueGenerator is not null ? $"public static {data.TypeName} New() => new({randomValueGenerator});" : ""; + + // language=C# return $$""" // #nullable enable @@ -546,6 +552,7 @@ private static string BuildRawString(TypedIdData data) var maxLengthValidation = data.MaxValuePartLength.HasValue ? " && v.Length <= MaxValuePartLength" : ""; + // language=C# return $$""" // #nullable enable diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs index 89a2decef..d85b6a4ef 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Xunit; namespace LeanCode.DomainModels.Tests.Ids; @@ -244,7 +245,7 @@ namespace Test; ); } - private static void AssertCorrect(string source) + private static void AssertCorrect([StringSyntax("C#")] string source) { var diag = GeneratorRunner.RunDiagnostics(source); Assert.Empty(diag); From dc637b702dab093495ad7ea73657e1812516b782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Wed, 17 Dec 2025 15:03:21 +0100 Subject: [PATCH 03/20] Rename MaxValuePartLength to MaxValueLength and update related logic --- .../IdSource.cs | 21 +++++++------------ .../TypedIdData.cs | 6 +++--- .../TypedIdGenerator.cs | 8 +++---- .../Ids/TypedIdAttribute.cs | 2 +- .../Ids/PrefixedStringTests.cs | 10 ++++----- .../Ids/RawStringTests.cs | 6 +++--- .../Ids/ValidConstructTests.cs | 19 ++++++++--------- 7 files changed, 32 insertions(+), 40 deletions(-) diff --git a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs index c05327f32..2d314da36 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs @@ -41,8 +41,6 @@ private static string BuildPrefixedGuid(TypedIdData data) var randomFactory = !data.SkipRandomGenerator ? $"public static {data.TypeName} New() => new(Guid.NewGuid());" : ""; - - // language=C# return $$""" // #nullable enable @@ -169,7 +167,6 @@ private static string BuildPrefixedUlid(TypedIdData data) var prefix = data.CustomPrefix?.ToLowerInvariant() ?? GetDefaultPrefix(data.TypeName); var valueLength = 26; - // language=C# return $$""" // #nullable enable @@ -298,16 +295,15 @@ private static string BuildPrefixedString(TypedIdData data) { var prefix = data.CustomPrefix?.ToLowerInvariant() ?? GetDefaultPrefix(data.TypeName); - var maxLengthProperties = data.MaxValuePartLength is int maxLen + var maxLengthProperties = data.MaxValueLength is int maxLen ? $$""" - public static int MaxValuePartLength { get; } = {{maxLen}}; - public static int MaxLength { get; } = {{prefix.Length + 1 + maxLen}}; + public static int MaxValueLength { get; } = {{maxLen}}; + public static int MaxRawLength { get; } = {{prefix.Length + 1 + maxLen}}; """ : ""; - var maxLengthValidation = data.MaxValuePartLength.HasValue ? " && valuePart.Length <= MaxValuePartLength" : ""; + var maxLengthValidation = data.MaxValueLength.HasValue ? " && valuePart.Length <= MaxValueLength" : ""; - // language=C# return $$""" // #nullable enable @@ -461,8 +457,6 @@ string tryFormatParams !data.SkipRandomGenerator && randomValueGenerator is not null ? $"public static {data.TypeName} New() => new({randomValueGenerator});" : ""; - - // language=C# return $$""" // #nullable enable @@ -546,13 +540,12 @@ public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnly private static string BuildRawString(TypedIdData data) { - var maxLengthProperty = data.MaxValuePartLength is int maxLen - ? $"public static int MaxValuePartLength {{ get; }} = {maxLen};" + var maxLengthProperty = data.MaxValueLength is int maxLen + ? $"public static int MaxLength {{ get; }} = {maxLen};" : ""; - var maxLengthValidation = data.MaxValuePartLength.HasValue ? " && v.Length <= MaxValuePartLength" : ""; + var maxLengthValidation = data.MaxValueLength.HasValue ? " && v.Length <= MaxLength" : ""; - // language=C# return $$""" // #nullable enable diff --git a/src/Domain/LeanCode.DomainModels.Generators/TypedIdData.cs b/src/Domain/LeanCode.DomainModels.Generators/TypedIdData.cs index decad7535..0f3db8a86 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/TypedIdData.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/TypedIdData.cs @@ -9,7 +9,7 @@ public sealed class TypedIdData public string TypeName { get; } public string? CustomPrefix { get; } public bool SkipRandomGenerator { get; } - public int? MaxValuePartLength { get; } + public int? MaxValueLength { get; } public bool IsValid { get; } public Location? Location { get; } @@ -19,7 +19,7 @@ public TypedIdData( string typeName, string? customPrefix, bool skipRandomGenerator, - int? maxValuePartLength, + int? maxValueLength, bool isValid, Location? location ) @@ -29,7 +29,7 @@ public TypedIdData( TypeName = typeName; CustomPrefix = customPrefix; SkipRandomGenerator = skipRandomGenerator; - MaxValuePartLength = maxValuePartLength; + MaxValueLength = maxValueLength; IsValid = isValid; Location = location; } diff --git a/src/Domain/LeanCode.DomainModels.Generators/TypedIdGenerator.cs b/src/Domain/LeanCode.DomainModels.Generators/TypedIdGenerator.cs index 8209a7fae..76696e82f 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/TypedIdGenerator.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/TypedIdGenerator.cs @@ -10,7 +10,7 @@ public sealed class TypedIdGenerator : IIncrementalGenerator private const string AttributeName = "LeanCode.DomainModels.Ids.TypedIdAttribute"; private const string CustomPrefixField = "CustomPrefix"; private const string SkipRandomGeneratorField = "SkipRandomGenerator"; - private const string MaxValuePartLengthField = "MaxValuePartLength"; + private const string MaxValueLengthField = "MaxValueLength"; private static readonly DiagnosticDescriptor InvalidTypeRule = new( "LNCD0005", @@ -37,8 +37,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var skipRandomGenerator = attribute .NamedArguments.FirstOrDefault(a => a.Key == SkipRandomGeneratorField) .Value.Value; - var maxValuePartLength = attribute - .NamedArguments.FirstOrDefault(a => a.Key == MaxValuePartLengthField) + var maxValueLength = attribute + .NamedArguments.FirstOrDefault(a => a.Key == MaxValueLengthField) .Value.Value; var isValid = IsValidSyntaxNode(n.TargetNode); return new TypedIdData( @@ -47,7 +47,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) n.TargetSymbol.Name, (string?)customPrefix, skipRandomGenerator is true, - maxValuePartLength is int mvpl && mvpl >= 0 ? mvpl : null, + maxValueLength is int mvl && mvl >= 0 ? mvl : null, isValid, !isValid ? n.TargetNode.GetLocation() : null ); diff --git a/src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs b/src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs index f22643182..d52e46809 100644 --- a/src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs +++ b/src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs @@ -85,7 +85,7 @@ public sealed class TypedIdAttribute : Attribute /// /// Consider SQL Server's 900-byte key limit (~450 nvarchar chars) when choosing this value. /// - public int MaxValuePartLength { get; set; } = -1; + public int MaxValueLength { get; set; } = -1; public TypedIdAttribute(TypedIdFormat format) { diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs index a7294169d..36adcee50 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs @@ -10,7 +10,7 @@ namespace LeanCode.DomainModels.Tests.Ids; [TypedId(TypedIdFormat.PrefixedString, CustomPrefix = "tps")] public readonly partial record struct TestPrefixedStringId; -[TypedId(TypedIdFormat.PrefixedString, CustomPrefix = "tpm", MaxValuePartLength = 10)] +[TypedId(TypedIdFormat.PrefixedString, CustomPrefix = "tpm", MaxValueLength = 10)] public readonly partial record struct TestPrefixedStringIdWithMaxLength; public class PrefixedStringIdTests @@ -272,16 +272,16 @@ public void IsEmpty_works_correctly() public class PrefixedStringIdWithMaxLengthTests { [Fact] - public void MaxValuePartLength_is_exposed() + public void MaxValueLength_is_exposed() { - Assert.Equal(10, TestPrefixedStringIdWithMaxLength.MaxValuePartLength); + Assert.Equal(10, TestPrefixedStringIdWithMaxLength.MaxValueLength); } [Fact] - public void MaxLength_is_calculated_correctly() + public void MaxRawLength_is_calculated_correctly() { // prefix "tpm" (3) + separator "_" (1) + max value part (10) = 14 - Assert.Equal(14, TestPrefixedStringIdWithMaxLength.MaxLength); + Assert.Equal(14, TestPrefixedStringIdWithMaxLength.MaxRawLength); } [Fact] diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs index 853a1c9db..96ef45482 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs @@ -10,7 +10,7 @@ namespace LeanCode.DomainModels.Tests.Ids; [TypedId(TypedIdFormat.RawString)] public readonly partial record struct TestRawStringId; -[TypedId(TypedIdFormat.RawString, MaxValuePartLength = 10)] +[TypedId(TypedIdFormat.RawString, MaxValueLength = 10)] public readonly partial record struct TestRawStringIdWithMaxLength; public class RawStringIdTests @@ -226,9 +226,9 @@ public void IsEmpty_works_correctly() public class RawStringIdWithMaxLengthTests { [Fact] - public void MaxValuePartLength_is_exposed() + public void MaxLength_is_exposed() { - Assert.Equal(10, TestRawStringIdWithMaxLength.MaxValuePartLength); + Assert.Equal(10, TestRawStringIdWithMaxLength.MaxLength); } [Fact] diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs index d85b6a4ef..7cadc9ae9 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using Xunit; namespace LeanCode.DomainModels.Tests.Ids; @@ -39,7 +38,7 @@ namespace Test; """ using LeanCode.DomainModels.Ids; namespace Test; - [TypedId(TypedIdFormat.RawInt, CustomPrefix = "ignored", SkipRandomGenerator = true, MaxValuePartLength = 50)] + [TypedId(TypedIdFormat.RawInt, CustomPrefix = "ignored", SkipRandomGenerator = true, MaxValueLength = 50)] public readonly partial record struct Id; """ ); @@ -79,7 +78,7 @@ namespace Test; """ using LeanCode.DomainModels.Ids; namespace Test; - [TypedId(TypedIdFormat.RawLong, CustomPrefix = "ignored", SkipRandomGenerator = true, MaxValuePartLength = 50)] + [TypedId(TypedIdFormat.RawLong, CustomPrefix = "ignored", SkipRandomGenerator = true, MaxValueLength = 50)] public readonly partial record struct Id; """ ); @@ -119,7 +118,7 @@ namespace Test; """ using LeanCode.DomainModels.Ids; namespace Test; - [TypedId(TypedIdFormat.RawGuid, CustomPrefix = "ignored", SkipRandomGenerator = true, MaxValuePartLength = 50)] + [TypedId(TypedIdFormat.RawGuid, CustomPrefix = "ignored", SkipRandomGenerator = true, MaxValueLength = 50)] public readonly partial record struct Id; """ ); @@ -159,7 +158,7 @@ namespace Test; """ using LeanCode.DomainModels.Ids; namespace Test; - [TypedId(TypedIdFormat.PrefixedGuid, CustomPrefix = "prefix", SkipRandomGenerator = true, MaxValuePartLength = 50)] + [TypedId(TypedIdFormat.PrefixedGuid, CustomPrefix = "prefix", SkipRandomGenerator = true, MaxValueLength = 50)] public readonly partial record struct Id; """ ); @@ -190,7 +189,7 @@ namespace Test; """ using LeanCode.DomainModels.Ids; namespace Test; - [TypedId(TypedIdFormat.RawString, MaxValuePartLength = 100)] + [TypedId(TypedIdFormat.RawString, MaxValueLength = 100)] public readonly partial record struct Id; """ ); @@ -199,7 +198,7 @@ namespace Test; """ using LeanCode.DomainModels.Ids; namespace Test; - [TypedId(TypedIdFormat.RawString, CustomPrefix = "ignored", SkipRandomGenerator = true, MaxValuePartLength = 50)] + [TypedId(TypedIdFormat.RawString, CustomPrefix = "ignored", SkipRandomGenerator = true, MaxValueLength = 50)] public readonly partial record struct Id; """ ); @@ -230,7 +229,7 @@ namespace Test; """ using LeanCode.DomainModels.Ids; namespace Test; - [TypedId(TypedIdFormat.PrefixedString, MaxValuePartLength = 100)] + [TypedId(TypedIdFormat.PrefixedString, MaxValueLength = 100)] public readonly partial record struct Id; """ ); @@ -239,13 +238,13 @@ namespace Test; """ using LeanCode.DomainModels.Ids; namespace Test; - [TypedId(TypedIdFormat.PrefixedString, CustomPrefix = "prefix", SkipRandomGenerator = true, MaxValuePartLength = 50)] + [TypedId(TypedIdFormat.PrefixedString, CustomPrefix = "prefix", SkipRandomGenerator = true, MaxValueLength = 50)] public readonly partial record struct Id; """ ); } - private static void AssertCorrect([StringSyntax("C#")] string source) + private static void AssertCorrect(string source) { var diag = GeneratorRunner.RunDiagnostics(source); Assert.Empty(diag); From 714043b9ba263ac8b1e39c53f9ea8b4adc81d867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Wed, 17 Dec 2025 15:17:23 +0100 Subject: [PATCH 04/20] Update ID docs --- docs/domain/id/index.md | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/domain/id/index.md b/docs/domain/id/index.md index 6fa07fc0a..28e586509 100644 --- a/docs/domain/id/index.md +++ b/docs/domain/id/index.md @@ -74,11 +74,15 @@ The format of the ID can be configured using: - `RawInt` - uses `int` as the underlying type; works as a wrapper over `int`; does not support generating new IDs at runtime by default. - `RawLong` - uses `long` as the underlying type; works as a wrapper over `long`; does not support generating new IDs at runtime by default. - `RawGuid` - uses `Guid` as the underlying type; works as a wrapper over `Guid`; can generate new ID at runtime using `Guid.NewGuid`. - - `PrefixedGuid` - uses `string` as the underlying type; it is represented as a `(prefix)_(guid)` string that can be generated at runtime; by default `(prefix)` is a lowercase class name with `id` at the end removed. + - `RawString` - uses `string` as the underlying type; works as a wrapper over arbitrary strings; does not support generating new IDs at runtime. + - `PrefixedGuid` - uses `string` as the underlying type; it is represented as a `(prefix)_(guid)` string that can be generated at runtime; by default `(prefix)` is a lowercase class name with `Id` suffix removed. + - `PrefixedUlid` - uses `string` as the underlying type; it is represented as a `(prefix)_(ulid)` string that can be generated at runtime; by default `(prefix)` is a lowercase class name with `Id` suffix removed. + - `PrefixedString` - uses `string` as the underlying type; it is represented as a `(prefix)_(value)` string where `(value)` is an arbitrary string; does not support generating new IDs at runtime. - `CustomPrefix` - for `Prefixed*` formats, you can configure what prefix it uses (if you e.g. want to use a shorter one). -- `SkipRandomGenerator` - setting this to `true` will skip generating `New` factory method (for `Prefixed` types only). +- `SkipRandomGenerator` - setting this to `true` will skip generating `New` factory method (for formats that support generation). +- `MaxValueLength` - optional maximum length constraint for the value part. For `RawString`, this is the entire string length. For `PrefixedString`, this excludes the prefix and separator. When set, validation is performed in `Parse`/`IsValid` methods. Consider SQL Server's 900-byte key limit (~450 nvarchar chars) when choosing this value. -Example: +Examples: ```cs [TypedId(TypedIdFormat.PrefixedGuid, CustomPrefix = "employee")] @@ -87,6 +91,21 @@ public readonly partial record struct VeryLongEmployeeId; // The `VeryLongEmployeeId` will have format `employee_(guid)`, with `New` using `Guid.NewGuid` as random source. ``` +```cs +[TypedId(TypedIdFormat.RawString, MaxValueLength = 100)] +public readonly partial record struct ExternalId; + +// The `ExternalId` wraps any string up to 100 characters. Exposes `MaxLength` static property. +``` + +```cs +[TypedId(TypedIdFormat.PrefixedString, CustomPrefix = "ext", MaxValueLength = 50)] +public readonly partial record struct ExternalRefId; + +// The `ExternalRefId` has format `ext_(value)` where value can be up to 50 characters. +// Exposes `MaxValueLength` (50) and `MaxRawLength` (54 = 3 + 1 + 50) static properties. +``` + ## Generic type wrappers The domain part of the library supports a set of generic IDs: From d204aa2734f71ceb862db1352caf5bdd278060af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Wed, 17 Dec 2025 15:23:58 +0100 Subject: [PATCH 05/20] Improve MaxValueLength documentation --- src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs b/src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs index d52e46809..65778b5eb 100644 --- a/src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs +++ b/src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs @@ -78,8 +78,10 @@ public sealed class TypedIdAttribute : Attribute public bool SkipRandomGenerator { get; set; } /// - /// Maximum length of the value part. For , this is the entire string. - /// For , this excludes the prefix and separator. + /// Maximum length of the value part. Only applies to and + /// formats; ignored for other formats. + /// For , this constrains the entire string length. + /// For , this constrains only the value part (excludes prefix and separator). /// If not set (default value of -1), no length validation is performed. /// /// From 3ba9c5e50329035202aac092df877516c8570473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Wed, 17 Dec 2025 15:24:45 +0100 Subject: [PATCH 06/20] Fix GetValuePart to handle Empty instance --- src/Domain/LeanCode.DomainModels.Generators/IdSource.cs | 2 +- .../LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs index 2d314da36..9177d09ce 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs @@ -404,7 +404,7 @@ public static bool IsValid([NotNullWhen(true)] string? v) return valuePart.Length > 0{{maxLengthValidation}}; } - public ReadOnlySpan GetValuePart() => Value.AsSpan()[{{prefix.Length + 1}}..]; + public ReadOnlySpan GetValuePart() => IsEmpty ? ReadOnlySpan.Empty : Value.AsSpan()[{{prefix.Length + 1}}..]; public bool Equals({{data.TypeName}} other) => Value.Equals(other.Value, StringComparison.Ordinal); public int CompareTo({{data.TypeName}} other) => string.Compare(Value, other.Value, StringComparison.Ordinal); diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs index 36adcee50..4beafcbd9 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs @@ -115,6 +115,13 @@ public void GetValuePart_extracts_value_part() Assert.Equal("abc123", id.GetValuePart().ToString()); } + [Fact] + public void GetValuePart_returns_empty_span_for_Empty_instance() + { + var empty = TestPrefixedStringId.Empty; + Assert.True(empty.GetValuePart().IsEmpty); + } + [Fact] public void Equals_behaves_correctly() { From dd1f33ba5288cca47cf5ecba228b2ec15c65f413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Wed, 17 Dec 2025 15:29:21 +0100 Subject: [PATCH 07/20] Add MaxValueLength validation to FromValuePart and constructor --- .../IdSource.cs | 14 ++++++++++-- .../Ids/PrefixedStringTests.cs | 22 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs index 9177d09ce..84873aa6f 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs @@ -304,6 +304,16 @@ private static string BuildPrefixedString(TypedIdData data) var maxLengthValidation = data.MaxValueLength.HasValue ? " && valuePart.Length <= MaxValueLength" : ""; + var maxLengthThrowValidation = data.MaxValueLength.HasValue + ? $$""" + + if (valuePart.Length > MaxValueLength) + { + throw new ArgumentException($"The value part exceeds maximum length of {MaxValueLength}.", nameof(valuePart)); + } + """ + : ""; + return $$""" // #nullable enable @@ -342,7 +352,7 @@ namespace {{data.Namespace}} if (valuePart is null) { throw new ArgumentNullException(nameof(valuePart)); - } + }{{maxLengthThrowValidation}} value = $"{TypePrefix}{Separator}{valuePart}"; } @@ -351,7 +361,7 @@ namespace {{data.Namespace}} if (valuePart is null) { throw new ArgumentNullException(nameof(valuePart)); - } + }{{maxLengthThrowValidation}} return new {{data.TypeName}}($"{TypePrefix}{Separator}{valuePart}"); } diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs index 4beafcbd9..732acb0bf 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs @@ -306,4 +306,26 @@ public void String_exceeding_max_length_is_invalid() Assert.Throws(() => TestPrefixedStringIdWithMaxLength.Parse("tpm_12345678901")); Assert.False(TestPrefixedStringIdWithMaxLength.TryParse("tpm_this_is_too_long", out _)); } + + [Fact] + public void FromValuePart_validates_max_length() + { + // Within limit - should work + var id = TestPrefixedStringIdWithMaxLength.FromValuePart("1234567890"); + Assert.Equal("tpm_1234567890", id.Value); + + // Exceeds limit - should throw + Assert.Throws(() => TestPrefixedStringIdWithMaxLength.FromValuePart("12345678901")); + } + + [Fact] + public void Constructor_validates_max_length() + { + // Within limit - should work + var id = new TestPrefixedStringIdWithMaxLength("1234567890", true); + Assert.Equal("tpm_1234567890", id.Value); + + // Exceeds limit - should throw + Assert.Throws(() => new TestPrefixedStringIdWithMaxLength("12345678901", true)); + } } From 4f9a86004911837a6307026fe3f9f754e2b48355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Wed, 17 Dec 2025 15:32:20 +0100 Subject: [PATCH 08/20] Improve RawString parse error message --- src/Domain/LeanCode.DomainModels.Generators/IdSource.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs index 84873aa6f..dc637cd69 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs @@ -556,6 +556,10 @@ private static string BuildRawString(TypedIdData data) var maxLengthValidation = data.MaxValueLength.HasValue ? " && v.Length <= MaxLength" : ""; + var parseErrorMessage = data.MaxValueLength.HasValue + ? "The ID has invalid format. It must be a non-null string with maximum length of {MaxLength}." + : "The ID has invalid format. It must be a non-null string."; + return $$""" // #nullable enable @@ -595,7 +599,7 @@ namespace {{data.Namespace}} } else { - throw new FormatException("The ID has invalid format."); + throw new FormatException($"{{parseErrorMessage}}"); } } From 5dcd7e085c24aac259b19a0304d4bfed86af8a2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Thu, 18 Dec 2025 12:37:04 +0100 Subject: [PATCH 09/20] Add language annotations for C# in strings for source generation code --- src/Domain/LeanCode.DomainModels.Generators/IdSource.cs | 7 +++++++ .../LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs index dc637cd69..d555df2e3 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs @@ -41,6 +41,8 @@ private static string BuildPrefixedGuid(TypedIdData data) var randomFactory = !data.SkipRandomGenerator ? $"public static {data.TypeName} New() => new(Guid.NewGuid());" : ""; + + // language=C# return $$""" // #nullable enable @@ -167,6 +169,7 @@ private static string BuildPrefixedUlid(TypedIdData data) var prefix = data.CustomPrefix?.ToLowerInvariant() ?? GetDefaultPrefix(data.TypeName); var valueLength = 26; + // language=C# return $$""" // #nullable enable @@ -314,6 +317,7 @@ private static string BuildPrefixedString(TypedIdData data) """ : ""; + // language=C# return $$""" // #nullable enable @@ -467,6 +471,8 @@ string tryFormatParams !data.SkipRandomGenerator && randomValueGenerator is not null ? $"public static {data.TypeName} New() => new({randomValueGenerator});" : ""; + + // language=C# return $$""" // #nullable enable @@ -560,6 +566,7 @@ private static string BuildRawString(TypedIdData data) ? "The ID has invalid format. It must be a non-null string with maximum length of {MaxLength}." : "The ID has invalid format. It must be a non-null string."; + // language=C# return $$""" // #nullable enable diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs index 7cadc9ae9..1996fd4d4 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Xunit; namespace LeanCode.DomainModels.Tests.Ids; @@ -244,7 +245,7 @@ namespace Test; ); } - private static void AssertCorrect(string source) + private static void AssertCorrect([StringSyntax("C#")] string source) { var diag = GeneratorRunner.RunDiagnostics(source); Assert.Empty(diag); From 1cec43b1542aa17518acf47df4d324defaa8c1f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Thu, 18 Dec 2025 14:08:30 +0100 Subject: [PATCH 10/20] Refactor typed ID structures to implement IConstSizeTypedId and IMaxLengthTypedId interfaces --- ...ropertiesConfigurationBuilderExtensions.cs | 37 +++++++++++++---- .../PropertyBuilderExtensions.cs | 39 ++++++++++++++---- .../TypedIdConverter.cs | 4 ++ .../IdSource.cs | 40 +++++++------------ .../LeanCode.DomainModels/Ids/ITypedId.cs | 14 ++++++- .../Ids/PrefixedStringTests.cs | 21 +++------- 6 files changed, 97 insertions(+), 58 deletions(-) diff --git a/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs b/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs index ea34570f9..edb1117fc 100644 --- a/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs +++ b/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs @@ -1,3 +1,4 @@ +using System.Reflection; using LeanCode.DomainModels.Ids; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -59,10 +60,20 @@ this PropertiesConfigurationBuilder builder ) where TId : struct, IPrefixedTypedId { - return builder - .HaveConversion, PrefixedTypedIdComparer>() - .HaveMaxLength(TId.RawLength) - .AreFixedLength(); + builder = builder.HaveConversion, PrefixedTypedIdComparer>(); + + if (TId.Empty is IConstSizeTypedId) + { + var rawLength = (int)typeof(TId).GetProperty(nameof(IConstSizeTypedId.RawLength))!.GetValue(null, null)!; + builder.HaveMaxLength(rawLength).AreFixedLength(); + } + else if (TId.Empty is IMaxLengthTypedId) + { + var maxLength = (int)typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; + builder.HaveMaxLength(maxLength); + } + + return builder; } public static PropertiesConfigurationBuilder ArePrefixedTypedId( @@ -70,10 +81,20 @@ this PropertiesConfigurationBuilder builder ) where TId : struct, IPrefixedTypedId { - return builder - .HaveConversion, PrefixedTypedIdComparer>() - .HaveMaxLength(TId.RawLength) - .AreFixedLength(); + builder = builder.HaveConversion, PrefixedTypedIdComparer>(); + + if (TId.Empty is IConstSizeTypedId) + { + var rawLength = (int)typeof(TId).GetProperty(nameof(IConstSizeTypedId.RawLength))!.GetValue(null, null)!; + builder.HaveMaxLength(rawLength).AreFixedLength(); + } + else if (TId.Empty is IMaxLengthTypedId) + { + var maxLength = (int)typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; + builder.HaveMaxLength(maxLength); + } + + return builder; } private static PropertiesConfigurationBuilder AreRawTypedId( diff --git a/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs b/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs index 1301085fe..17e01bbf2 100644 --- a/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs +++ b/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs @@ -1,3 +1,4 @@ +using System.Reflection; using LeanCode.DomainModels.Ids; using LeanCode.DomainModels.Model; using Microsoft.EntityFrameworkCore; @@ -110,20 +111,42 @@ public static PropertyBuilder IsGuidTypedId(this PropertyBuilder public static PropertyBuilder IsPrefixedTypedId(this PropertyBuilder builder) where TId : struct, IPrefixedTypedId { - return builder - .HasConversion(PrefixedTypedIdConverter.Instance) - .HasMaxLength(TId.RawLength) - .IsFixedLength() + builder = builder + .HasConversion(PrefixedTypedIdConverter.Instance, PrefixedTypedIdComparer.Instance) .ValueGeneratedNever(); + + if (TId.Empty is IConstSizeTypedId) + { + var rawLength = (int)typeof(TId).GetProperty(nameof(IConstSizeTypedId.RawLength))!.GetValue(null, null)!; + builder.HasMaxLength(rawLength).IsFixedLength(); + } + else if (TId.Empty is IMaxLengthTypedId) + { + var maxLength = (int)typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; + builder.HasMaxLength(maxLength); + } + + return builder; } public static PropertyBuilder IsPrefixedTypedId(this PropertyBuilder builder) where TId : struct, IPrefixedTypedId { - return builder - .HasConversion(PrefixedTypedIdConverter.Instance) - .HasMaxLength(TId.RawLength) - .IsFixedLength() + builder = builder + .HasConversion(PrefixedTypedIdConverter.Instance, PrefixedTypedIdComparer.Instance) .ValueGeneratedNever(); + + if (TId.Empty is IConstSizeTypedId) + { + var rawLength = (int)typeof(TId).GetProperty(nameof(IConstSizeTypedId.RawLength))!.GetValue(null, null)!; + builder.HasMaxLength(rawLength).IsFixedLength(); + } + else if (TId.Empty is IMaxLengthTypedId) + { + var maxLength = (int)typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; + builder.HasMaxLength(maxLength); + } + + return builder; } } diff --git a/src/Domain/LeanCode.DomainModels.EF/TypedIdConverter.cs b/src/Domain/LeanCode.DomainModels.EF/TypedIdConverter.cs index 2a9191312..b1b20b5fd 100644 --- a/src/Domain/LeanCode.DomainModels.EF/TypedIdConverter.cs +++ b/src/Domain/LeanCode.DomainModels.EF/TypedIdConverter.cs @@ -29,6 +29,8 @@ public RawTypedIdConverter() public class PrefixedTypedIdComparer : ValueComparer where TId : struct, IPrefixedTypedId { + public static readonly PrefixedTypedIdComparer Instance = new(); + public PrefixedTypedIdComparer() : base(TId.DatabaseEquals, d => d.GetHashCode()) { } } @@ -38,6 +40,8 @@ public class RawTypedIdComparer : ValueComparer where TBacking : struct where TId : struct, IRawTypedId { + public static readonly RawTypedIdComparer Instance = new(); + public RawTypedIdComparer() : base(TId.DatabaseEquals, d => d.GetHashCode()) { } } diff --git a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs index d555df2e3..8f3cf16a4 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs @@ -62,14 +62,14 @@ namespace {{data.Namespace}} [JsonConverter(typeof(StringTypedIdConverter<{{data.TypeName}}>))] [DebuggerDisplay("{Value}")] [ExcludeFromCodeCoverage] - public readonly partial record struct {{data.TypeName}} : IPrefixedTypedId<{{data.TypeName}}> + public readonly partial record struct {{data.TypeName}} : IPrefixedTypedId<{{data.TypeName}}>, IConstSizeTypedId { private const int ValueLength = {{valueLength}}; private const char Separator = '_'; private const string TypePrefix = "{{prefix}}"; public static int RawLength { get; } = {{valueLength + 1 + prefix.Length}}; - public static readonly {{data.TypeName}} Empty = new(Guid.Empty); + public static {{data.TypeName}} Empty { get; } = new(Guid.Empty); private readonly string? value; @@ -190,14 +190,14 @@ namespace {{data.Namespace}} [JsonConverter(typeof(StringTypedIdConverter<{{data.TypeName}}>))] [DebuggerDisplay("{Value}")] [ExcludeFromCodeCoverage] - public readonly partial record struct {{data.TypeName}} : IPrefixedTypedId<{{data.TypeName}}> + public readonly partial record struct {{data.TypeName}} : IPrefixedTypedId<{{data.TypeName}}>, IConstSizeTypedId { private const int ValueLength = {{valueLength}}; private const char Separator = '_'; private const string TypePrefix = "{{prefix}}"; public static int RawLength { get; } = {{valueLength + 1 + prefix.Length}}; - public static readonly {{data.TypeName}} Empty = new(Ulid.Empty); + public static {{data.TypeName}} Empty { get; } = new(Ulid.Empty); private readonly string? value; @@ -298,15 +298,14 @@ private static string BuildPrefixedString(TypedIdData data) { var prefix = data.CustomPrefix?.ToLowerInvariant() ?? GetDefaultPrefix(data.TypeName); + var maxLengthInterfaces = data.MaxValueLength.HasValue ? ", IMaxLengthTypedId" : ""; var maxLengthProperties = data.MaxValueLength is int maxLen ? $$""" public static int MaxValueLength { get; } = {{maxLen}}; - public static int MaxRawLength { get; } = {{prefix.Length + 1 + maxLen}}; + public static int MaxLength { get; } = {{prefix.Length + 1 + maxLen}}; """ : ""; - var maxLengthValidation = data.MaxValueLength.HasValue ? " && valuePart.Length <= MaxValueLength" : ""; - var maxLengthThrowValidation = data.MaxValueLength.HasValue ? $$""" @@ -334,15 +333,15 @@ namespace {{data.Namespace}} using global::LeanCode.DomainModels.Ids; #pragma warning restore CS8019 - [JsonConverter(typeof(RawStringTypedIdConverter<{{data.TypeName}}>))] + [JsonConverter(typeof(StringTypedIdConverter<{{data.TypeName}}>))] [DebuggerDisplay("{Value}")] [ExcludeFromCodeCoverage] - public readonly partial record struct {{data.TypeName}} : IRawStringTypedId<{{data.TypeName}}> + public readonly partial record struct {{data.TypeName}} : IPrefixedTypedId<{{data.TypeName}}>{{maxLengthInterfaces}} { private const char Separator = '_'; private const string TypePrefix = "{{prefix}}"; - public static readonly {{data.TypeName}} Empty = new(string.Empty); + public static {{data.TypeName}} Empty { get; } = new(string.Empty); {{maxLengthProperties}} private readonly string? value; @@ -351,15 +350,6 @@ namespace {{data.Namespace}} private {{data.TypeName}}(string v) => value = v; - public {{data.TypeName}}(string valuePart, bool _) - { - if (valuePart is null) - { - throw new ArgumentNullException(nameof(valuePart)); - }{{maxLengthThrowValidation}} - value = $"{TypePrefix}{Separator}{valuePart}"; - } - public static {{data.TypeName}} FromValuePart(string valuePart) { if (valuePart is null) @@ -430,8 +420,8 @@ public static bool IsValid([NotNullWhen(true)] string? v) public static bool operator >({{data.TypeName}} a, {{data.TypeName}} b) => a.CompareTo(b) > 0; public static bool operator >=({{data.TypeName}} a, {{data.TypeName}} b) => a.CompareTo(b) >= 0; - static Expression> IRawStringTypedId<{{data.TypeName}}>.FromDatabase { get; } = d => Parse(d); - static Expression> IRawStringTypedId<{{data.TypeName}}>.DatabaseEquals { get; } = (a, b) => a == b; + static Expression> IPrefixedTypedId<{{data.TypeName}}>.FromDatabase { get; } = d => Parse(d); + static Expression> IPrefixedTypedId<{{data.TypeName}}>.DatabaseEquals { get; } = (a, b) => a == b; public override string ToString() => Value; public string ToString(string? format, IFormatProvider? formatProvider) => Value; @@ -494,7 +484,7 @@ namespace {{data.Namespace}} [ExcludeFromCodeCoverage] public readonly partial record struct {{data.TypeName}} : IRawTypedId<{{backingType}}, {{data.TypeName}}> { - public static readonly {{data.TypeName}} Empty = new({{defaultValue}}); + public static {{data.TypeName}} Empty { get; } = new({{defaultValue}}); public {{backingType}} Value {get;} public bool IsEmpty => Value == Empty; @@ -556,10 +546,10 @@ public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnly private static string BuildRawString(TypedIdData data) { + var maxLengthInterfaces = data.MaxValueLength.HasValue ? ", IMaxLengthTypedId" : ""; var maxLengthProperty = data.MaxValueLength is int maxLen ? $"public static int MaxLength {{ get; }} = {maxLen};" : ""; - var maxLengthValidation = data.MaxValueLength.HasValue ? " && v.Length <= MaxLength" : ""; var parseErrorMessage = data.MaxValueLength.HasValue @@ -586,9 +576,9 @@ namespace {{data.Namespace}} [JsonConverter(typeof(RawStringTypedIdConverter<{{data.TypeName}}>))] [DebuggerDisplay("{Value}")] [ExcludeFromCodeCoverage] - public readonly partial record struct {{data.TypeName}} : IRawStringTypedId<{{data.TypeName}}> + public readonly partial record struct {{data.TypeName}} : IRawStringTypedId<{{data.TypeName}}>{{maxLengthInterfaces}} { - public static readonly {{data.TypeName}} Empty = new(string.Empty); + public static {{data.TypeName}} Empty { get; } = new(string.Empty); {{maxLengthProperty}} private readonly string? value; diff --git a/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs b/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs index f13e60e2f..15a810303 100644 --- a/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs +++ b/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs @@ -16,7 +16,7 @@ public interface IPrefixedTypedId where TSelf : struct, IPrefixedTypedId { string Value { get; } - static abstract int RawLength { get; } + static abstract TSelf Empty { get; } static abstract TSelf Parse(string v); static abstract bool IsValid(string? v); @@ -39,6 +39,7 @@ public interface IRawTypedId where TSelf : struct, IRawTypedId { TBacking Value { get; } + static abstract TSelf Empty { get; } static abstract TSelf Parse(TBacking v); [EditorBrowsable(EditorBrowsableState.Never)] @@ -59,6 +60,7 @@ public interface IRawStringTypedId where TSelf : struct, IRawStringTypedId { string Value { get; } + static abstract TSelf Empty { get; } static abstract TSelf Parse(string v); static abstract bool IsValid(string? v); @@ -68,3 +70,13 @@ public interface IRawStringTypedId [EditorBrowsable(EditorBrowsableState.Never)] static abstract Expression> DatabaseEquals { get; } } + +public interface IConstSizeTypedId +{ + static abstract int RawLength { get; } +} + +public interface IMaxLengthTypedId +{ + static abstract int MaxLength { get; } +} diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs index 732acb0bf..1eb80fa15 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs @@ -21,10 +21,10 @@ public class PrefixedStringIdTests private const string TPS3 = "tps_xyz789"; [Fact] - [SuppressMessage("?", "xUnit2007", Justification = "Cannot use `IRawStringTypedId` as generic parameter.")] + [SuppressMessage("?", "xUnit2007", Justification = "Cannot use `IPrefixedTypedId` as generic parameter.")] public void Generated_class_implements_ITypedId() { - Assert.IsAssignableFrom(typeof(IRawStringTypedId), new TestPrefixedStringId()); + Assert.IsAssignableFrom(typeof(IPrefixedTypedId), new TestPrefixedStringId()); } [Fact] @@ -230,7 +230,7 @@ public void Database_expressions_work() DatabaseExpressionsWork(); static void DatabaseExpressionsWork() - where T : struct, IRawStringTypedId + where T : struct, IPrefixedTypedId { Assert.Equal(T.FromDatabase.Compile().Invoke(TPS1), T.Parse(TPS1)); Assert.True(T.DatabaseEquals.Compile().Invoke(T.Parse(TPS1), T.Parse(TPS1))); @@ -285,10 +285,10 @@ public void MaxValueLength_is_exposed() } [Fact] - public void MaxRawLength_is_calculated_correctly() + public void MaxLength_is_calculated_correctly() { // prefix "tpm" (3) + separator "_" (1) + max value part (10) = 14 - Assert.Equal(14, TestPrefixedStringIdWithMaxLength.MaxRawLength); + Assert.Equal(14, TestPrefixedStringIdWithMaxLength.MaxLength); } [Fact] @@ -317,15 +317,4 @@ public void FromValuePart_validates_max_length() // Exceeds limit - should throw Assert.Throws(() => TestPrefixedStringIdWithMaxLength.FromValuePart("12345678901")); } - - [Fact] - public void Constructor_validates_max_length() - { - // Within limit - should work - var id = new TestPrefixedStringIdWithMaxLength("1234567890", true); - Assert.Equal("tpm_1234567890", id.Value); - - // Exceeds limit - should throw - Assert.Throws(() => new TestPrefixedStringIdWithMaxLength("12345678901", true)); - } } From 0f2f8edd7bab9143e32eb0c0477a20256c047666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Thu, 18 Dec 2025 15:48:03 +0100 Subject: [PATCH 11/20] Add EF Core support for string-typed IDs with max length validation --- ...ropertiesConfigurationBuilderExtensions.cs | 115 ++++++++++++++---- .../PropertyBuilderExtensions.cs | 107 ++++++++++++---- .../TypedIdConverter.cs | 20 +++ .../IdSource.cs | 16 ++- .../TypedIdConverterTests.cs | 113 +++++++++++++++++ .../TypedIdDatabaseIntegrationTests.cs | 54 +++++--- .../TypedIds.cs | 9 ++ .../Ids/PrefixedStringTests.cs | 10 +- 8 files changed, 376 insertions(+), 68 deletions(-) diff --git a/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs b/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs index edb1117fc..e1bb354ac 100644 --- a/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs +++ b/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs @@ -1,4 +1,3 @@ -using System.Reflection; using LeanCode.DomainModels.Ids; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -55,46 +54,120 @@ this PropertiesConfigurationBuilder builder return builder.AreRawTypedId(); } + public static PropertiesConfigurationBuilder AreStringTypedId( + this PropertiesConfigurationBuilder builder + ) + where TId : struct, IRawStringTypedId + { + return builder + .HaveConversion, RawStringTypedIdComparer>() + .ConfigureMaxLengthIfPresent(); + } + + public static PropertiesConfigurationBuilder AreStringTypedId( + this PropertiesConfigurationBuilder builder + ) + where TId : struct, IRawStringTypedId + { + return builder + .HaveConversion, RawStringTypedIdComparer>() + .ConfigureMaxLengthIfPresent(); + } + public static PropertiesConfigurationBuilder ArePrefixedTypedId( this PropertiesConfigurationBuilder builder ) where TId : struct, IPrefixedTypedId { - builder = builder.HaveConversion, PrefixedTypedIdComparer>(); + return builder + .HaveConversion, PrefixedTypedIdComparer>() + .ConfigureFixedSizeOrMaxLengthIfPresent(); + } - if (TId.Empty is IConstSizeTypedId) - { - var rawLength = (int)typeof(TId).GetProperty(nameof(IConstSizeTypedId.RawLength))!.GetValue(null, null)!; - builder.HaveMaxLength(rawLength).AreFixedLength(); - } - else if (TId.Empty is IMaxLengthTypedId) + public static PropertiesConfigurationBuilder ArePrefixedTypedId( + this PropertiesConfigurationBuilder builder + ) + where TId : struct, IPrefixedTypedId + { + return builder + .HaveConversion, PrefixedTypedIdComparer>() + .ConfigureFixedSizeOrMaxLengthIfPresent(); + } + + private static PropertiesConfigurationBuilder ConfigureFixedSizeOrMaxLengthIfPresent( + this PropertiesConfigurationBuilder builder + ) + where TId : struct, IPrefixedTypedId + { + switch (TId.Empty) { - var maxLength = (int)typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; - builder.HaveMaxLength(maxLength); + case IConstSizeTypedId: + { + var rawLength = (int) + typeof(TId).GetProperty(nameof(IConstSizeTypedId.RawLength))!.GetValue(null, null)!; + return builder.HaveMaxLength(rawLength).AreFixedLength(); + } + case IMaxLengthTypedId: + { + var maxLength = (int) + typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; + return builder.HaveMaxLength(maxLength); + } + default: + return builder; } - - return builder; } - public static PropertiesConfigurationBuilder ArePrefixedTypedId( + private static PropertiesConfigurationBuilder ConfigureFixedSizeOrMaxLengthIfPresent( this PropertiesConfigurationBuilder builder ) where TId : struct, IPrefixedTypedId { - builder = builder.HaveConversion, PrefixedTypedIdComparer>(); + switch (TId.Empty) + { + case IConstSizeTypedId: + { + var rawLength = (int) + typeof(TId).GetProperty(nameof(IConstSizeTypedId.RawLength))!.GetValue(null, null)!; + return builder.HaveMaxLength(rawLength).AreFixedLength(); + } + case IMaxLengthTypedId: + { + var maxLength = (int) + typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; + return builder.HaveMaxLength(maxLength); + } + default: + return builder; + } + } - if (TId.Empty is IConstSizeTypedId) + private static PropertiesConfigurationBuilder ConfigureMaxLengthIfPresent( + this PropertiesConfigurationBuilder builder + ) + where TId : struct, IRawStringTypedId + { + if (TId.Empty is not IMaxLengthTypedId) { - var rawLength = (int)typeof(TId).GetProperty(nameof(IConstSizeTypedId.RawLength))!.GetValue(null, null)!; - builder.HaveMaxLength(rawLength).AreFixedLength(); + return builder; } - else if (TId.Empty is IMaxLengthTypedId) + + var maxLength = (int)typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; + return builder.HaveMaxLength(maxLength); + } + + private static PropertiesConfigurationBuilder ConfigureMaxLengthIfPresent( + this PropertiesConfigurationBuilder builder + ) + where TId : struct, IRawStringTypedId + { + if (TId.Empty is not IMaxLengthTypedId) { - var maxLength = (int)typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; - builder.HaveMaxLength(maxLength); + return builder; } - return builder; + var maxLength = (int)typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; + return builder.HaveMaxLength(maxLength); } private static PropertiesConfigurationBuilder AreRawTypedId( diff --git a/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs b/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs index 17e01bbf2..302cb0fcc 100644 --- a/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs +++ b/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs @@ -1,4 +1,3 @@ -using System.Reflection; using LeanCode.DomainModels.Ids; using LeanCode.DomainModels.Model; using Microsoft.EntityFrameworkCore; @@ -111,42 +110,102 @@ public static PropertyBuilder IsGuidTypedId(this PropertyBuilder public static PropertyBuilder IsPrefixedTypedId(this PropertyBuilder builder) where TId : struct, IPrefixedTypedId { - builder = builder + return builder .HasConversion(PrefixedTypedIdConverter.Instance, PrefixedTypedIdComparer.Instance) - .ValueGeneratedNever(); + .ValueGeneratedNever() + .ConfigureFixedSizeOrMaxLengthIfPresent(); + } - if (TId.Empty is IConstSizeTypedId) - { - var rawLength = (int)typeof(TId).GetProperty(nameof(IConstSizeTypedId.RawLength))!.GetValue(null, null)!; - builder.HasMaxLength(rawLength).IsFixedLength(); - } - else if (TId.Empty is IMaxLengthTypedId) + public static PropertyBuilder IsPrefixedTypedId(this PropertyBuilder builder) + where TId : struct, IPrefixedTypedId + { + return builder + .HasConversion(PrefixedTypedIdConverter.Instance, PrefixedTypedIdComparer.Instance) + .ValueGeneratedNever() + .ConfigureFixedSizeOrMaxLengthIfPresent(); + } + + public static PropertyBuilder IsStringTypedId(this PropertyBuilder builder) + where TId : struct, IRawStringTypedId + { + return builder + .HasConversion(RawStringTypedIdConverter.Instance, RawStringTypedIdComparer.Instance) + .ConfigureMaxLengthIfPresent(); + } + + public static PropertyBuilder IsStringTypedId(this PropertyBuilder builder) + where TId : struct, IRawStringTypedId + { + return builder + .HasConversion(RawStringTypedIdConverter.Instance, RawStringTypedIdComparer.Instance) + .ConfigureMaxLengthIfPresent(); + } + + private static PropertyBuilder ConfigureFixedSizeOrMaxLengthIfPresent(this PropertyBuilder builder) + where TId : struct, IPrefixedTypedId + { + switch (TId.Empty) { - var maxLength = (int)typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; - builder.HasMaxLength(maxLength); + case IConstSizeTypedId: + { + var rawLength = (int) + typeof(TId).GetProperty(nameof(IConstSizeTypedId.RawLength))!.GetValue(null, null)!; + return builder.HasMaxLength(rawLength).IsFixedLength(); + } + case IMaxLengthTypedId: + { + var maxLength = (int) + typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; + return builder.HasMaxLength(maxLength); + } + default: + return builder; } - - return builder; } - public static PropertyBuilder IsPrefixedTypedId(this PropertyBuilder builder) + private static PropertyBuilder ConfigureFixedSizeOrMaxLengthIfPresent(this PropertyBuilder builder) where TId : struct, IPrefixedTypedId { - builder = builder - .HasConversion(PrefixedTypedIdConverter.Instance, PrefixedTypedIdComparer.Instance) - .ValueGeneratedNever(); + switch (TId.Empty) + { + case IConstSizeTypedId: + { + var rawLength = (int) + typeof(TId).GetProperty(nameof(IConstSizeTypedId.RawLength))!.GetValue(null, null)!; + return builder.HasMaxLength(rawLength).IsFixedLength(); + } + case IMaxLengthTypedId: + { + var maxLength = (int) + typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; + return builder.HasMaxLength(maxLength); + } + default: + return builder; + } + } - if (TId.Empty is IConstSizeTypedId) + private static PropertyBuilder ConfigureMaxLengthIfPresent(this PropertyBuilder builder) + where TId : struct, IRawStringTypedId + { + if (TId.Empty is not IMaxLengthTypedId) { - var rawLength = (int)typeof(TId).GetProperty(nameof(IConstSizeTypedId.RawLength))!.GetValue(null, null)!; - builder.HasMaxLength(rawLength).IsFixedLength(); + return builder; } - else if (TId.Empty is IMaxLengthTypedId) + + var maxLength = (int)typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; + return builder.HasMaxLength(maxLength); + } + + private static PropertyBuilder ConfigureMaxLengthIfPresent(this PropertyBuilder builder) + where TId : struct, IRawStringTypedId + { + if (TId.Empty is not IMaxLengthTypedId) { - var maxLength = (int)typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; - builder.HasMaxLength(maxLength); + return builder; } - return builder; + var maxLength = (int)typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; + return builder.HasMaxLength(maxLength); } } diff --git a/src/Domain/LeanCode.DomainModels.EF/TypedIdConverter.cs b/src/Domain/LeanCode.DomainModels.EF/TypedIdConverter.cs index b1b20b5fd..90338b661 100644 --- a/src/Domain/LeanCode.DomainModels.EF/TypedIdConverter.cs +++ b/src/Domain/LeanCode.DomainModels.EF/TypedIdConverter.cs @@ -25,6 +25,16 @@ public RawTypedIdConverter() : base(d => d.Value, TId.FromDatabase, mappingHints: null) { } } +[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] +public class RawStringTypedIdConverter : ValueConverter + where TId : struct, IRawStringTypedId +{ + public static readonly RawStringTypedIdConverter Instance = new(); + + public RawStringTypedIdConverter() + : base(d => d.Value, TId.FromDatabase, mappingHints: null) { } +} + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] public class PrefixedTypedIdComparer : ValueComparer where TId : struct, IPrefixedTypedId @@ -45,3 +55,13 @@ public class RawTypedIdComparer : ValueComparer public RawTypedIdComparer() : base(TId.DatabaseEquals, d => d.GetHashCode()) { } } + +[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] +public class RawStringTypedIdComparer : ValueComparer + where TId : struct, IRawStringTypedId +{ + public static readonly RawStringTypedIdComparer Instance = new(); + + public RawStringTypedIdComparer() + : base(TId.DatabaseEquals, d => d.GetHashCode()) { } +} diff --git a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs index 8f3cf16a4..e89d6dd6a 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs @@ -305,7 +305,14 @@ private static string BuildPrefixedString(TypedIdData data) public static int MaxLength { get; } = {{prefix.Length + 1 + maxLen}}; """ : ""; - var maxLengthValidation = data.MaxValueLength.HasValue ? " && valuePart.Length <= MaxValueLength" : ""; + var maxLengthValidation = data.MaxValueLength.HasValue + ? $$""" + var valuePart = span[{{prefix.Length + 1}}..]; + return valuePart.Length <= MaxValueLength; + """ + : """ + return true; + """; var maxLengthThrowValidation = data.MaxValueLength.HasValue ? $$""" @@ -404,11 +411,12 @@ public static bool IsValid([NotNullWhen(true)] string? v) return false; } - var valuePart = span[{{prefix.Length + 1}}..]; - return valuePart.Length > 0{{maxLengthValidation}}; +{{maxLengthValidation}} } - public ReadOnlySpan GetValuePart() => IsEmpty ? ReadOnlySpan.Empty : Value.AsSpan()[{{prefix.Length + 1}}..]; + public ReadOnlySpan GetValuePart() => IsEmpty + ? ReadOnlySpan.Empty + : Value.AsSpan()[{{prefix.Length + 1}}..]; public bool Equals({{data.TypeName}} other) => Value.Equals(other.Value, StringComparison.Ordinal); public int CompareTo({{data.TypeName}} other) => string.Compare(Value, other.Value, StringComparison.Ordinal); diff --git a/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdConverterTests.cs b/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdConverterTests.cs index 02dc59a43..93a161b81 100644 --- a/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdConverterTests.cs +++ b/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdConverterTests.cs @@ -29,6 +29,14 @@ public void RawGuid_conversion_to_guid_and_back_works() AssertConvertsGuid(new(Guid.NewGuid())); } + [Fact] + public void RawString_conversion_to_string_and_back_works() + { + AssertConvertsRawString(new("")); + AssertConvertsRawString(new("test-id")); + AssertConvertsRawString(new("another-value")); + } + [Fact] public void PrefixedGuid_conversion_to_guid_and_back_works() { @@ -37,6 +45,14 @@ public void PrefixedGuid_conversion_to_guid_and_back_works() AssertConvertsPrefixedGuid(PrefixedGuidId.New()); } + [Fact] + public void PrefixedString_conversion_to_string_and_back_works() + { + AssertConvertsPrefixedString(PrefixedStringId.FromValuePart("")); + AssertConvertsPrefixedString(PrefixedStringId.FromValuePart("test-id")); + AssertConvertsPrefixedString(PrefixedStringId.FromValuePart("another-value")); + } + [Fact] public void RawInt_convention_is_registered_properly() { @@ -82,6 +98,38 @@ public void RawGuid_convention_is_registered_properly() Assert.Null(mapping["Relational:ColumnType"]); } + [Fact] + public void RawString_convention_is_registered_properly() + { + var builder = new ModelConfigurationBuilderWrapper(); + builder.Properties().AreStringTypedId(); + var model = builder.Build(); + + var mapping = model.FindProperty(typeof(StringId)); + Assert.NotNull(mapping); + Assert.IsType>(mapping.GetValueConverter()); + Assert.Equal(typeof(RawStringTypedIdComparer), mapping["ValueComparerType"]); + Assert.Equal(typeof(StringId), mapping.ClrType); + Assert.Null(mapping.GetMaxLength()); + Assert.Null(mapping["Relational:ColumnType"]); + } + + [Fact] + public void RawString_with_max_length_convention_is_registered_properly() + { + var builder = new ModelConfigurationBuilderWrapper(); + builder.Properties().AreStringTypedId(); + var model = builder.Build(); + + var mapping = model.FindProperty(typeof(StringIdWithMaxLength)); + Assert.NotNull(mapping); + Assert.IsType>(mapping.GetValueConverter()); + Assert.Equal(typeof(RawStringTypedIdComparer), mapping["ValueComparerType"]); + Assert.Equal(typeof(StringIdWithMaxLength), mapping.ClrType); + Assert.Equal(100, mapping.GetMaxLength()); + Assert.Null(mapping["Relational:ColumnType"]); + } + [Fact] public void PrefixedGuid_convention_is_registered_properly() { @@ -99,6 +147,23 @@ public void PrefixedGuid_convention_is_registered_properly() Assert.Null(mapping["Relational:ColumnType"]); } + [Fact] + public void PrefixedString_convention_is_registered_properly() + { + var builder = new ModelConfigurationBuilderWrapper(); + builder.Properties().ArePrefixedTypedId(); + var model = builder.Build(); + + var mapping = model.FindProperty(typeof(PrefixedStringId)); + Assert.NotNull(mapping); + Assert.IsType>(mapping.GetValueConverter()); + Assert.Equal(typeof(PrefixedTypedIdComparer), mapping["ValueComparerType"]); + Assert.Equal(typeof(PrefixedStringId), mapping.ClrType); + Assert.Equal(PrefixedStringId.MaxLength, mapping.GetMaxLength()); + Assert.Null(mapping["Relational:IsFixedLength"]); + Assert.Null(mapping["Relational:ColumnType"]); + } + [Fact] public void OptionalRawInt_convention_is_registered_properly() { @@ -144,6 +209,38 @@ public void OptionalRawGuid_convention_is_registered_properly() Assert.Null(mapping["Relational:ColumnType"]); } + [Fact] + public void OptionalPrefixedString_convention_is_registered_properly() + { + var builder = new ModelConfigurationBuilderWrapper(); + builder.Properties().ArePrefixedTypedId(); + var model = builder.Build(); + + var mapping = model.FindProperty(typeof(PrefixedStringId?)); + Assert.NotNull(mapping); + Assert.IsType>(mapping.GetValueConverter()); + Assert.Equal(typeof(PrefixedTypedIdComparer), mapping["ValueComparerType"]); + Assert.Equal(typeof(PrefixedStringId?), mapping.ClrType); + Assert.Equal(PrefixedStringId.MaxLength, mapping.GetMaxLength()); + Assert.Null(mapping["Relational:IsFixedLength"]); + Assert.Null(mapping["Relational:ColumnType"]); + } + + [Fact] + public void OptionalRawString_convention_is_registered_properly() + { + var builder = new ModelConfigurationBuilderWrapper(); + builder.Properties().AreStringTypedId(); + var model = builder.Build(); + + var mapping = model.FindProperty(typeof(StringId?)); + Assert.NotNull(mapping); + Assert.IsType>(mapping.GetValueConverter()); + Assert.Equal(typeof(RawStringTypedIdComparer), mapping["ValueComparerType"]); + Assert.Equal(typeof(StringId?), mapping.ClrType); + Assert.Null(mapping["Relational:ColumnType"]); + } + [Fact] public void OptionalPrefixedGuid_convention_is_registered_properly() { @@ -185,6 +282,14 @@ private static void AssertConvertsGuid(GuidId id) Assert.Equal(id, fromResult); } + private static void AssertConvertsRawString(StringId id) + { + var toResult = RawStringTypedIdConverter.Instance.ConvertToProvider(id); + var fromResult = RawStringTypedIdConverter.Instance.ConvertFromProvider(id.Value); + Assert.Equal(id.Value, toResult); + Assert.Equal(id, fromResult); + } + private static void AssertConvertsPrefixedGuid(PrefixedGuidId id) { var toResult = PrefixedTypedIdConverter.Instance.ConvertToProvider(id); @@ -192,4 +297,12 @@ private static void AssertConvertsPrefixedGuid(PrefixedGuidId id) Assert.Equal(id.Value, toResult); Assert.Equal(id, fromResult); } + + private static void AssertConvertsPrefixedString(PrefixedStringId id) + { + var toResult = PrefixedTypedIdConverter.Instance.ConvertToProvider(id); + var fromResult = PrefixedTypedIdConverter.Instance.ConvertFromProvider(id.Value); + Assert.Equal(id.Value, toResult); + Assert.Equal(id, fromResult); + } } diff --git a/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdDatabaseIntegrationTests.cs b/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdDatabaseIntegrationTests.cs index 0a859df96..aa619d799 100644 --- a/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdDatabaseIntegrationTests.cs +++ b/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdDatabaseIntegrationTests.cs @@ -43,6 +43,10 @@ private static void AssertEqual(Entity a, Entity b) Assert.Equal(a.F, b.F); Assert.Equal(a.G, b.G); Assert.Equal(a.H, b.H); + Assert.Equal(a.I, b.I); + Assert.Equal(a.J, b.J); + Assert.Equal(a.K, b.K); + Assert.Equal(a.L, b.L); } private sealed class TestDbContext : DbContext @@ -73,12 +77,16 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura configurationBuilder.Properties().AreIntTypedId(); configurationBuilder.Properties().AreLongTypedId(); configurationBuilder.Properties().AreGuidTypedId(); + configurationBuilder.Properties().AreStringTypedId(); configurationBuilder.Properties().ArePrefixedTypedId(); + configurationBuilder.Properties().ArePrefixedTypedId(); configurationBuilder.Properties().AreIntTypedId(); configurationBuilder.Properties().AreLongTypedId(); configurationBuilder.Properties().AreGuidTypedId(); + configurationBuilder.Properties().AreStringTypedId(); configurationBuilder.Properties().ArePrefixedTypedId(); + configurationBuilder.Properties().ArePrefixedTypedId(); } } @@ -91,11 +99,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) cfg.Property(e => e.A).IsIntTypedId(); cfg.Property(e => e.B).IsLongTypedId(); cfg.Property(e => e.C).IsGuidTypedId(); - cfg.Property(e => e.D).IsPrefixedTypedId(); - cfg.Property(e => e.E).IsIntTypedId(); - cfg.Property(e => e.F).IsLongTypedId(); - cfg.Property(e => e.G).IsGuidTypedId(); - cfg.Property(e => e.H).IsPrefixedTypedId(); + cfg.Property(e => e.D).IsStringTypedId(); + cfg.Property(e => e.E).IsPrefixedTypedId(); + cfg.Property(e => e.F).IsPrefixedTypedId(); + cfg.Property(e => e.G).IsIntTypedId(); + cfg.Property(e => e.H).IsLongTypedId(); + cfg.Property(e => e.I).IsGuidTypedId(); + cfg.Property(e => e.J).IsStringTypedId(); + cfg.Property(e => e.K).IsPrefixedTypedId(); + cfg.Property(e => e.L).IsPrefixedTypedId(); } cfg.HasKey(e => e.A); @@ -108,12 +120,16 @@ private sealed record Entity public IntId A { get; set; } public LongId B { get; set; } public GuidId C { get; set; } - public PrefixedGuidId D { get; set; } + public StringId D { get; set; } + public PrefixedGuidId E { get; set; } + public PrefixedStringId F { get; set; } - public IntId? E { get; set; } - public LongId? F { get; set; } - public GuidId? G { get; set; } - public PrefixedGuidId? H { get; set; } + public IntId? G { get; set; } + public LongId? H { get; set; } + public GuidId? I { get; set; } + public StringId? J { get; set; } + public PrefixedGuidId? K { get; set; } + public PrefixedStringId? L { get; set; } public static Entity CreateFull() { @@ -122,11 +138,15 @@ public static Entity CreateFull() A = new(1), B = new(2), C = new(Guid.NewGuid()), - D = new(Guid.NewGuid()), - E = new(3), - F = new(4), - G = new(Guid.NewGuid()), - H = new(Guid.NewGuid()), + D = new("a"), + E = new(Guid.NewGuid()), + F = PrefixedStringId.FromValuePart("b"), + G = new(3), + H = new(4), + I = new(Guid.NewGuid()), + J = new("c"), + K = new(Guid.NewGuid()), + L = PrefixedStringId.FromValuePart("d"), }; } @@ -137,7 +157,9 @@ public static Entity CreatePartial() A = new(5), B = new(6), C = new(Guid.NewGuid()), - D = new(Guid.NewGuid()), + D = new("e"), + E = new(Guid.NewGuid()), + F = PrefixedStringId.FromValuePart("f"), }; } } diff --git a/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIds.cs b/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIds.cs index 949ca7e4d..098ced646 100644 --- a/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIds.cs +++ b/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIds.cs @@ -11,5 +11,14 @@ namespace LeanCode.DomainModels.EF.Tests; [TypedId(TypedIdFormat.RawGuid)] public readonly partial record struct GuidId; +[TypedId(TypedIdFormat.RawString)] +public readonly partial record struct StringId; + +[TypedId(TypedIdFormat.RawString, MaxValueLength = 100)] +public readonly partial record struct StringIdWithMaxLength; + [TypedId(TypedIdFormat.PrefixedGuid)] public readonly partial record struct PrefixedGuidId; + +[TypedId(TypedIdFormat.PrefixedString, MaxValueLength = 50)] +public readonly partial record struct PrefixedStringId; diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs index 1eb80fa15..cba13123e 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs @@ -84,10 +84,14 @@ public void From_value_without_separator_behaves_correctly() [Fact] public void From_value_with_empty_value_part_behaves_correctly() { - Assert.False(TestPrefixedStringId.IsValid("tps_")); + Assert.True(TestPrefixedStringId.IsValid("tps_")); - Assert.Throws(() => TestPrefixedStringId.Parse("tps_")); - Assert.False(TestPrefixedStringId.TryParse("tps_", out _)); + var id = TestPrefixedStringId.Parse("tps_"); + Assert.Equal("tps_", id.Value); + Assert.True(id.GetValuePart().IsEmpty); + + Assert.True(TestPrefixedStringId.TryParse("tps_", out var parsed)); + Assert.Equal("tps_", parsed.Value); } [Fact] From aa97bab4aef9e88741fa65c3ca3828d26c4d9bd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Thu, 18 Dec 2025 15:54:56 +0100 Subject: [PATCH 12/20] Add integration tests for PrefixedUlidId --- .../TypedIdDatabaseIntegrationTests.cs | 56 +++++++++++-------- .../TypedIds.cs | 3 + 2 files changed, 37 insertions(+), 22 deletions(-) diff --git a/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdDatabaseIntegrationTests.cs b/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdDatabaseIntegrationTests.cs index aa619d799..98dfec804 100644 --- a/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdDatabaseIntegrationTests.cs +++ b/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdDatabaseIntegrationTests.cs @@ -1,3 +1,4 @@ +using LeanCode.DomainModels.Ulids; using Microsoft.EntityFrameworkCore; using Xunit; @@ -47,6 +48,8 @@ private static void AssertEqual(Entity a, Entity b) Assert.Equal(a.J, b.J); Assert.Equal(a.K, b.K); Assert.Equal(a.L, b.L); + Assert.Equal(a.M, b.M); + Assert.Equal(a.N, b.N); } private sealed class TestDbContext : DbContext @@ -79,6 +82,7 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura configurationBuilder.Properties().AreGuidTypedId(); configurationBuilder.Properties().AreStringTypedId(); configurationBuilder.Properties().ArePrefixedTypedId(); + configurationBuilder.Properties().ArePrefixedTypedId(); configurationBuilder.Properties().ArePrefixedTypedId(); configurationBuilder.Properties().AreIntTypedId(); @@ -86,6 +90,7 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura configurationBuilder.Properties().AreGuidTypedId(); configurationBuilder.Properties().AreStringTypedId(); configurationBuilder.Properties().ArePrefixedTypedId(); + configurationBuilder.Properties().ArePrefixedTypedId(); configurationBuilder.Properties().ArePrefixedTypedId(); } } @@ -102,12 +107,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) cfg.Property(e => e.D).IsStringTypedId(); cfg.Property(e => e.E).IsPrefixedTypedId(); cfg.Property(e => e.F).IsPrefixedTypedId(); - cfg.Property(e => e.G).IsIntTypedId(); - cfg.Property(e => e.H).IsLongTypedId(); - cfg.Property(e => e.I).IsGuidTypedId(); - cfg.Property(e => e.J).IsStringTypedId(); - cfg.Property(e => e.K).IsPrefixedTypedId(); + cfg.Property(e => e.G).IsPrefixedTypedId(); + cfg.Property(e => e.H).IsIntTypedId(); + cfg.Property(e => e.I).IsLongTypedId(); + cfg.Property(e => e.J).IsGuidTypedId(); + cfg.Property(e => e.K).IsStringTypedId(); cfg.Property(e => e.L).IsPrefixedTypedId(); + cfg.Property(e => e.M).IsPrefixedTypedId(); + cfg.Property(e => e.N).IsPrefixedTypedId(); } cfg.HasKey(e => e.A); @@ -122,44 +129,49 @@ private sealed record Entity public GuidId C { get; set; } public StringId D { get; set; } public PrefixedGuidId E { get; set; } - public PrefixedStringId F { get; set; } + public PrefixedUlidId F { get; set; } + public PrefixedStringId G { get; set; } - public IntId? G { get; set; } - public LongId? H { get; set; } - public GuidId? I { get; set; } - public StringId? J { get; set; } - public PrefixedGuidId? K { get; set; } - public PrefixedStringId? L { get; set; } + public IntId? H { get; set; } + public LongId? I { get; set; } + public GuidId? J { get; set; } + public StringId? K { get; set; } + public PrefixedGuidId? L { get; set; } + public PrefixedUlidId? M { get; set; } + public PrefixedStringId? N { get; set; } public static Entity CreateFull() { - return new Entity + return new() { A = new(1), B = new(2), C = new(Guid.NewGuid()), D = new("a"), E = new(Guid.NewGuid()), - F = PrefixedStringId.FromValuePart("b"), - G = new(3), - H = new(4), - I = new(Guid.NewGuid()), - J = new("c"), - K = new(Guid.NewGuid()), - L = PrefixedStringId.FromValuePart("d"), + F = new(Ulid.NewUlid()), + G = PrefixedStringId.FromValuePart("b"), + H = new(3), + I = new(4), + J = new(Guid.NewGuid()), + K = new("c"), + L = new(Guid.NewGuid()), + M = new(Ulid.NewUlid()), + N = PrefixedStringId.FromValuePart("d"), }; } public static Entity CreatePartial() { - return new Entity + return new() { A = new(5), B = new(6), C = new(Guid.NewGuid()), D = new("e"), E = new(Guid.NewGuid()), - F = PrefixedStringId.FromValuePart("f"), + F = new(Ulid.NewUlid()), + G = PrefixedStringId.FromValuePart("f"), }; } } diff --git a/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIds.cs b/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIds.cs index 098ced646..073e343bc 100644 --- a/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIds.cs +++ b/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIds.cs @@ -20,5 +20,8 @@ namespace LeanCode.DomainModels.EF.Tests; [TypedId(TypedIdFormat.PrefixedGuid)] public readonly partial record struct PrefixedGuidId; +[TypedId(TypedIdFormat.PrefixedUlid)] +public readonly partial record struct PrefixedUlidId; + [TypedId(TypedIdFormat.PrefixedString, MaxValueLength = 50)] public readonly partial record struct PrefixedStringId; From 54ec58b2e735929db42820e57545b540b0c2f8e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Thu, 18 Dec 2025 15:56:43 +0100 Subject: [PATCH 13/20] Enhance typed ID property builders to include comparers for int, long, and Guid types --- .../PropertyBuilderExtensions.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs b/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs index 302cb0fcc..dbbe995de 100644 --- a/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs +++ b/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs @@ -74,37 +74,37 @@ public static PropertyBuilder> IsTypedId(this PropertyBuilder> public static PropertyBuilder IsIntTypedId(this PropertyBuilder builder) where TId : struct, IRawTypedId { - return builder.HasConversion(RawTypedIdConverter.Instance); + return builder.HasConversion(RawTypedIdConverter.Instance, RawTypedIdComparer.Instance); } public static PropertyBuilder IsIntTypedId(this PropertyBuilder builder) where TId : struct, IRawTypedId { - return builder.HasConversion(RawTypedIdConverter.Instance); + return builder.HasConversion(RawTypedIdConverter.Instance, RawTypedIdComparer.Instance); } public static PropertyBuilder IsLongTypedId(this PropertyBuilder builder) where TId : struct, IRawTypedId { - return builder.HasConversion(RawTypedIdConverter.Instance); + return builder.HasConversion(RawTypedIdConverter.Instance, RawTypedIdComparer.Instance); } public static PropertyBuilder IsLongTypedId(this PropertyBuilder builder) where TId : struct, IRawTypedId { - return builder.HasConversion(RawTypedIdConverter.Instance); + return builder.HasConversion(RawTypedIdConverter.Instance, RawTypedIdComparer.Instance); } public static PropertyBuilder IsGuidTypedId(this PropertyBuilder builder) where TId : struct, IRawTypedId { - return builder.HasConversion(RawTypedIdConverter.Instance); + return builder.HasConversion(RawTypedIdConverter.Instance, RawTypedIdComparer.Instance); } public static PropertyBuilder IsGuidTypedId(this PropertyBuilder builder) where TId : struct, IRawTypedId { - return builder.HasConversion(RawTypedIdConverter.Instance); + return builder.HasConversion(RawTypedIdConverter.Instance, RawTypedIdComparer.Instance); } public static PropertyBuilder IsPrefixedTypedId(this PropertyBuilder builder) From 1730e1f481c8e80ec3d6c1e7c2be48afd5a8529d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Fri, 19 Dec 2025 11:35:34 +0100 Subject: [PATCH 14/20] Update documentation for IDs to correct MaxLength property name --- docs/domain/id/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/domain/id/index.md b/docs/domain/id/index.md index 28e586509..35c133c3f 100644 --- a/docs/domain/id/index.md +++ b/docs/domain/id/index.md @@ -103,7 +103,7 @@ public readonly partial record struct ExternalId; public readonly partial record struct ExternalRefId; // The `ExternalRefId` has format `ext_(value)` where value can be up to 50 characters. -// Exposes `MaxValueLength` (50) and `MaxRawLength` (54 = 3 + 1 + 50) static properties. +// Exposes `MaxValueLength` (50) and `MaxLength` (54 = 3 + 1 + 50) static properties. ``` ## Generic type wrappers From ff35af7e86170482c72ca604cf2bf4d9fdf889d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Fri, 19 Dec 2025 12:28:52 +0100 Subject: [PATCH 15/20] Add IHasEmptyId interface and refactor typed ID EF builders max length support --- ...ropertiesConfigurationBuilderExtensions.cs | 72 +++++------- .../PropertyBuilderExtensions.cs | 106 +++++++----------- .../TypedIdExtensions.cs | 30 +++++ .../LeanCode.DomainModels/Ids/ITypedId.cs | 21 +++- 4 files changed, 112 insertions(+), 117 deletions(-) create mode 100644 src/Domain/LeanCode.DomainModels.EF/TypedIdExtensions.cs diff --git a/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs b/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs index e1bb354ac..996975e49 100644 --- a/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs +++ b/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs @@ -99,75 +99,53 @@ this PropertiesConfigurationBuilder builder ) where TId : struct, IPrefixedTypedId { - switch (TId.Empty) + if (IHasEmptyId.GetRawLength() is { } rawLength) { - case IConstSizeTypedId: - { - var rawLength = (int) - typeof(TId).GetProperty(nameof(IConstSizeTypedId.RawLength))!.GetValue(null, null)!; - return builder.HaveMaxLength(rawLength).AreFixedLength(); - } - case IMaxLengthTypedId: - { - var maxLength = (int) - typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; - return builder.HaveMaxLength(maxLength); - } - default: - return builder; + return builder.HaveMaxLength(rawLength).AreFixedLength(); + } + else if (IHasEmptyId.GetMaxLength() is { } maxLength) + { + return builder.HaveMaxLength(maxLength); + } + else + { + return builder; } } private static PropertiesConfigurationBuilder ConfigureFixedSizeOrMaxLengthIfPresent( this PropertiesConfigurationBuilder builder ) - where TId : struct, IPrefixedTypedId + where TId : struct, IHasEmptyId { - switch (TId.Empty) + if (IHasEmptyId.GetRawLength() is { } rawLength) { - case IConstSizeTypedId: - { - var rawLength = (int) - typeof(TId).GetProperty(nameof(IConstSizeTypedId.RawLength))!.GetValue(null, null)!; - return builder.HaveMaxLength(rawLength).AreFixedLength(); - } - case IMaxLengthTypedId: - { - var maxLength = (int) - typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; - return builder.HaveMaxLength(maxLength); - } - default: - return builder; + return builder.HaveMaxLength(rawLength).AreFixedLength(); + } + else if (IHasEmptyId.GetMaxLength() is { } maxLength) + { + return builder.HaveMaxLength(maxLength); + } + else + { + return builder; } } private static PropertiesConfigurationBuilder ConfigureMaxLengthIfPresent( this PropertiesConfigurationBuilder builder ) - where TId : struct, IRawStringTypedId + where TId : struct, IHasEmptyId { - if (TId.Empty is not IMaxLengthTypedId) - { - return builder; - } - - var maxLength = (int)typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; - return builder.HaveMaxLength(maxLength); + return IHasEmptyId.GetMaxLength() is { } maxLength ? builder.HaveMaxLength(maxLength) : builder; } private static PropertiesConfigurationBuilder ConfigureMaxLengthIfPresent( this PropertiesConfigurationBuilder builder ) - where TId : struct, IRawStringTypedId + where TId : struct, IHasEmptyId { - if (TId.Empty is not IMaxLengthTypedId) - { - return builder; - } - - var maxLength = (int)typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; - return builder.HaveMaxLength(maxLength); + return IHasEmptyId.GetMaxLength() is { } maxLength ? builder.HaveMaxLength(maxLength) : builder; } private static PropertiesConfigurationBuilder AreRawTypedId( diff --git a/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs b/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs index dbbe995de..f897c8729 100644 --- a/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs +++ b/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs @@ -107,6 +107,22 @@ public static PropertyBuilder IsGuidTypedId(this PropertyBuilder return builder.HasConversion(RawTypedIdConverter.Instance, RawTypedIdComparer.Instance); } + public static PropertyBuilder IsStringTypedId(this PropertyBuilder builder) + where TId : struct, IRawStringTypedId + { + return builder + .HasConversion(RawStringTypedIdConverter.Instance, RawStringTypedIdComparer.Instance) + .ConfigureMaxLengthIfPresent(); + } + + public static PropertyBuilder IsStringTypedId(this PropertyBuilder builder) + where TId : struct, IRawStringTypedId + { + return builder + .HasConversion(RawStringTypedIdConverter.Instance, RawStringTypedIdComparer.Instance) + .ConfigureMaxLengthIfPresent(); + } + public static PropertyBuilder IsPrefixedTypedId(this PropertyBuilder builder) where TId : struct, IPrefixedTypedId { @@ -125,87 +141,49 @@ public static PropertyBuilder IsPrefixedTypedId(this PropertyBuilder IsStringTypedId(this PropertyBuilder builder) - where TId : struct, IRawStringTypedId - { - return builder - .HasConversion(RawStringTypedIdConverter.Instance, RawStringTypedIdComparer.Instance) - .ConfigureMaxLengthIfPresent(); - } - - public static PropertyBuilder IsStringTypedId(this PropertyBuilder builder) - where TId : struct, IRawStringTypedId - { - return builder - .HasConversion(RawStringTypedIdConverter.Instance, RawStringTypedIdComparer.Instance) - .ConfigureMaxLengthIfPresent(); - } - private static PropertyBuilder ConfigureFixedSizeOrMaxLengthIfPresent(this PropertyBuilder builder) - where TId : struct, IPrefixedTypedId + where TId : struct, IHasEmptyId { - switch (TId.Empty) + if (IHasEmptyId.GetRawLength() is { } rawLength) + { + return builder.HasMaxLength(rawLength).IsFixedLength(); + } + else if (IHasEmptyId.GetMaxLength() is { } maxLength) + { + return builder.HasMaxLength(maxLength); + } + else { - case IConstSizeTypedId: - { - var rawLength = (int) - typeof(TId).GetProperty(nameof(IConstSizeTypedId.RawLength))!.GetValue(null, null)!; - return builder.HasMaxLength(rawLength).IsFixedLength(); - } - case IMaxLengthTypedId: - { - var maxLength = (int) - typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; - return builder.HasMaxLength(maxLength); - } - default: - return builder; + return builder; } } private static PropertyBuilder ConfigureFixedSizeOrMaxLengthIfPresent(this PropertyBuilder builder) - where TId : struct, IPrefixedTypedId + where TId : struct, IHasEmptyId { - switch (TId.Empty) + if (IHasEmptyId.GetRawLength() is { } rawLength) { - case IConstSizeTypedId: - { - var rawLength = (int) - typeof(TId).GetProperty(nameof(IConstSizeTypedId.RawLength))!.GetValue(null, null)!; - return builder.HasMaxLength(rawLength).IsFixedLength(); - } - case IMaxLengthTypedId: - { - var maxLength = (int) - typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; - return builder.HasMaxLength(maxLength); - } - default: - return builder; + return builder.HasMaxLength(rawLength).IsFixedLength(); } - } - - private static PropertyBuilder ConfigureMaxLengthIfPresent(this PropertyBuilder builder) - where TId : struct, IRawStringTypedId - { - if (TId.Empty is not IMaxLengthTypedId) + else if (IHasEmptyId.GetMaxLength() is { } maxLength) + { + return builder.HasMaxLength(maxLength); + } + else { return builder; } + } - var maxLength = (int)typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; - return builder.HasMaxLength(maxLength); + private static PropertyBuilder ConfigureMaxLengthIfPresent(this PropertyBuilder builder) + where TId : struct, IHasEmptyId + { + return IHasEmptyId.GetMaxLength() is { } maxLength ? builder.HasMaxLength(maxLength) : builder; } private static PropertyBuilder ConfigureMaxLengthIfPresent(this PropertyBuilder builder) - where TId : struct, IRawStringTypedId + where TId : struct, IHasEmptyId { - if (TId.Empty is not IMaxLengthTypedId) - { - return builder; - } - - var maxLength = (int)typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; - return builder.HasMaxLength(maxLength); + return IHasEmptyId.GetMaxLength() is { } maxLength ? builder.HasMaxLength(maxLength) : builder; } } diff --git a/src/Domain/LeanCode.DomainModels.EF/TypedIdExtensions.cs b/src/Domain/LeanCode.DomainModels.EF/TypedIdExtensions.cs new file mode 100644 index 000000000..a8e8ea66d --- /dev/null +++ b/src/Domain/LeanCode.DomainModels.EF/TypedIdExtensions.cs @@ -0,0 +1,30 @@ +using LeanCode.DomainModels.Ids; + +namespace LeanCode.DomainModels.EF; + +internal static class TypedIdExtensions +{ + extension(IHasEmptyId) + where TId : struct, IHasEmptyId + { + public static int? GetRawLength() + { + if (TId.Empty is not IConstSizeTypedId) + { + return null; + } + + return (int)typeof(TId).GetProperty(nameof(IConstSizeTypedId.RawLength))!.GetValue(null, null)!; + } + + public static int? GetMaxLength() + { + if (TId.Empty is not IMaxLengthTypedId) + { + return null; + } + + return (int)typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; + } + } +} diff --git a/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs b/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs index 15a810303..c7c24d11a 100644 --- a/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs +++ b/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs @@ -12,11 +12,11 @@ public interface IPrefixedTypedId IComparable, ISpanFormattable, IUtf8SpanFormattable, - IEqualityOperators + IEqualityOperators, + IHasEmptyId where TSelf : struct, IPrefixedTypedId { string Value { get; } - static abstract TSelf Empty { get; } static abstract TSelf Parse(string v); static abstract bool IsValid(string? v); @@ -34,12 +34,12 @@ public interface IRawTypedId IComparable, ISpanFormattable, IUtf8SpanFormattable, - IEqualityOperators + IEqualityOperators, + IHasEmptyId where TBacking : struct where TSelf : struct, IRawTypedId { TBacking Value { get; } - static abstract TSelf Empty { get; } static abstract TSelf Parse(TBacking v); [EditorBrowsable(EditorBrowsableState.Never)] @@ -56,11 +56,11 @@ public interface IRawStringTypedId IComparable, ISpanFormattable, IUtf8SpanFormattable, - IEqualityOperators + IEqualityOperators, + IHasEmptyId where TSelf : struct, IRawStringTypedId { string Value { get; } - static abstract TSelf Empty { get; } static abstract TSelf Parse(string v); static abstract bool IsValid(string? v); @@ -71,11 +71,20 @@ public interface IRawStringTypedId static abstract Expression> DatabaseEquals { get; } } +[EditorBrowsable(EditorBrowsableState.Never)] +public interface IHasEmptyId + where TSelf : struct, IHasEmptyId +{ + static abstract TSelf Empty { get; } +} + +[EditorBrowsable(EditorBrowsableState.Never)] public interface IConstSizeTypedId { static abstract int RawLength { get; } } +[EditorBrowsable(EditorBrowsableState.Never)] public interface IMaxLengthTypedId { static abstract int MaxLength { get; } From 6fc5c3dc84af78c92344fe895c201793ba2ac8ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Mon, 22 Dec 2025 14:24:17 +0100 Subject: [PATCH 16/20] Refactor string-typed ID implementation to unify max length handling and improve validation --- ...ropertiesConfigurationBuilderExtensions.cs | 58 +++---------- .../PropertyBuilderExtensions.cs | 54 +++--------- .../TypedIdExtensions.cs | 30 ------- .../IdSource.cs | 84 +++++++------------ .../TypedIdGenerator.cs | 26 +++++- .../LeanCode.DomainModels/Ids/ITypedId.cs | 8 +- .../TypedIdConverterTests.cs | 59 +++++-------- .../TypedIds.cs | 7 +- .../Ids/PrefixedGuidTests.cs | 16 ++-- .../Ids/PrefixedStringTests.cs | 28 +++---- .../Ids/PrefixedStringVariationsTests.cs | 43 ++++++++++ .../Ids/PrefixedUlidTests.cs | 16 ++-- .../Ids/RawStringTests.cs | 22 ++--- .../Ids/ValidConstructTests.cs | 36 -------- 14 files changed, 172 insertions(+), 315 deletions(-) delete mode 100644 src/Domain/LeanCode.DomainModels.EF/TypedIdExtensions.cs create mode 100644 test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringVariationsTests.cs diff --git a/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs b/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs index 996975e49..4ed5af6f6 100644 --- a/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs +++ b/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs @@ -61,7 +61,7 @@ this PropertiesConfigurationBuilder builder { return builder .HaveConversion, RawStringTypedIdComparer>() - .ConfigureMaxLengthIfPresent(); + .ConfigureMaxLength(); } public static PropertiesConfigurationBuilder AreStringTypedId( @@ -71,7 +71,7 @@ this PropertiesConfigurationBuilder builder { return builder .HaveConversion, RawStringTypedIdComparer>() - .ConfigureMaxLengthIfPresent(); + .ConfigureMaxLength(); } public static PropertiesConfigurationBuilder ArePrefixedTypedId( @@ -81,7 +81,7 @@ this PropertiesConfigurationBuilder builder { return builder .HaveConversion, PrefixedTypedIdComparer>() - .ConfigureFixedSizeOrMaxLengthIfPresent(); + .ConfigureMaxLength(); } public static PropertiesConfigurationBuilder ArePrefixedTypedId( @@ -91,61 +91,23 @@ this PropertiesConfigurationBuilder builder { return builder .HaveConversion, PrefixedTypedIdComparer>() - .ConfigureFixedSizeOrMaxLengthIfPresent(); + .ConfigureMaxLength(); } - private static PropertiesConfigurationBuilder ConfigureFixedSizeOrMaxLengthIfPresent( + private static PropertiesConfigurationBuilder ConfigureMaxLength( this PropertiesConfigurationBuilder builder ) - where TId : struct, IPrefixedTypedId - { - if (IHasEmptyId.GetRawLength() is { } rawLength) - { - return builder.HaveMaxLength(rawLength).AreFixedLength(); - } - else if (IHasEmptyId.GetMaxLength() is { } maxLength) - { - return builder.HaveMaxLength(maxLength); - } - else - { - return builder; - } - } - - private static PropertiesConfigurationBuilder ConfigureFixedSizeOrMaxLengthIfPresent( - this PropertiesConfigurationBuilder builder - ) - where TId : struct, IHasEmptyId - { - if (IHasEmptyId.GetRawLength() is { } rawLength) - { - return builder.HaveMaxLength(rawLength).AreFixedLength(); - } - else if (IHasEmptyId.GetMaxLength() is { } maxLength) - { - return builder.HaveMaxLength(maxLength); - } - else - { - return builder; - } - } - - private static PropertiesConfigurationBuilder ConfigureMaxLengthIfPresent( - this PropertiesConfigurationBuilder builder - ) - where TId : struct, IHasEmptyId + where TId : struct, IMaxLengthTypedId { - return IHasEmptyId.GetMaxLength() is { } maxLength ? builder.HaveMaxLength(maxLength) : builder; + return builder.HaveMaxLength(TId.MaxLength); } - private static PropertiesConfigurationBuilder ConfigureMaxLengthIfPresent( + private static PropertiesConfigurationBuilder ConfigureMaxLength( this PropertiesConfigurationBuilder builder ) - where TId : struct, IHasEmptyId + where TId : struct, IMaxLengthTypedId { - return IHasEmptyId.GetMaxLength() is { } maxLength ? builder.HaveMaxLength(maxLength) : builder; + return builder.HaveMaxLength(TId.MaxLength); } private static PropertiesConfigurationBuilder AreRawTypedId( diff --git a/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs b/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs index f897c8729..d3b13cd9e 100644 --- a/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs +++ b/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs @@ -112,7 +112,7 @@ public static PropertyBuilder IsStringTypedId(this PropertyBuilder.Instance, RawStringTypedIdComparer.Instance) - .ConfigureMaxLengthIfPresent(); + .ConfigureMaxLength(); } public static PropertyBuilder IsStringTypedId(this PropertyBuilder builder) @@ -120,7 +120,7 @@ public static PropertyBuilder IsStringTypedId(this PropertyBuilder.Instance, RawStringTypedIdComparer.Instance) - .ConfigureMaxLengthIfPresent(); + .ConfigureMaxLength(); } public static PropertyBuilder IsPrefixedTypedId(this PropertyBuilder builder) @@ -129,7 +129,7 @@ public static PropertyBuilder IsPrefixedTypedId(this PropertyBuilder.Instance, PrefixedTypedIdComparer.Instance) .ValueGeneratedNever() - .ConfigureFixedSizeOrMaxLengthIfPresent(); + .ConfigureMaxLength(); } public static PropertyBuilder IsPrefixedTypedId(this PropertyBuilder builder) @@ -138,52 +138,18 @@ public static PropertyBuilder IsPrefixedTypedId(this PropertyBuilder.Instance, PrefixedTypedIdComparer.Instance) .ValueGeneratedNever() - .ConfigureFixedSizeOrMaxLengthIfPresent(); + .ConfigureMaxLength(); } - private static PropertyBuilder ConfigureFixedSizeOrMaxLengthIfPresent(this PropertyBuilder builder) - where TId : struct, IHasEmptyId + private static PropertyBuilder ConfigureMaxLength(this PropertyBuilder builder) + where TId : struct, IMaxLengthTypedId { - if (IHasEmptyId.GetRawLength() is { } rawLength) - { - return builder.HasMaxLength(rawLength).IsFixedLength(); - } - else if (IHasEmptyId.GetMaxLength() is { } maxLength) - { - return builder.HasMaxLength(maxLength); - } - else - { - return builder; - } + return builder.HasMaxLength(TId.MaxLength); } - private static PropertyBuilder ConfigureFixedSizeOrMaxLengthIfPresent(this PropertyBuilder builder) - where TId : struct, IHasEmptyId + private static PropertyBuilder ConfigureMaxLength(this PropertyBuilder builder) + where TId : struct, IMaxLengthTypedId { - if (IHasEmptyId.GetRawLength() is { } rawLength) - { - return builder.HasMaxLength(rawLength).IsFixedLength(); - } - else if (IHasEmptyId.GetMaxLength() is { } maxLength) - { - return builder.HasMaxLength(maxLength); - } - else - { - return builder; - } - } - - private static PropertyBuilder ConfigureMaxLengthIfPresent(this PropertyBuilder builder) - where TId : struct, IHasEmptyId - { - return IHasEmptyId.GetMaxLength() is { } maxLength ? builder.HasMaxLength(maxLength) : builder; - } - - private static PropertyBuilder ConfigureMaxLengthIfPresent(this PropertyBuilder builder) - where TId : struct, IHasEmptyId - { - return IHasEmptyId.GetMaxLength() is { } maxLength ? builder.HasMaxLength(maxLength) : builder; + return builder.HasMaxLength(TId.MaxLength); } } diff --git a/src/Domain/LeanCode.DomainModels.EF/TypedIdExtensions.cs b/src/Domain/LeanCode.DomainModels.EF/TypedIdExtensions.cs deleted file mode 100644 index a8e8ea66d..000000000 --- a/src/Domain/LeanCode.DomainModels.EF/TypedIdExtensions.cs +++ /dev/null @@ -1,30 +0,0 @@ -using LeanCode.DomainModels.Ids; - -namespace LeanCode.DomainModels.EF; - -internal static class TypedIdExtensions -{ - extension(IHasEmptyId) - where TId : struct, IHasEmptyId - { - public static int? GetRawLength() - { - if (TId.Empty is not IConstSizeTypedId) - { - return null; - } - - return (int)typeof(TId).GetProperty(nameof(IConstSizeTypedId.RawLength))!.GetValue(null, null)!; - } - - public static int? GetMaxLength() - { - if (TId.Empty is not IMaxLengthTypedId) - { - return null; - } - - return (int)typeof(TId).GetProperty(nameof(IMaxLengthTypedId.MaxLength))!.GetValue(null, null)!; - } - } -} diff --git a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs index e89d6dd6a..834becf9a 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs @@ -62,13 +62,13 @@ namespace {{data.Namespace}} [JsonConverter(typeof(StringTypedIdConverter<{{data.TypeName}}>))] [DebuggerDisplay("{Value}")] [ExcludeFromCodeCoverage] - public readonly partial record struct {{data.TypeName}} : IPrefixedTypedId<{{data.TypeName}}>, IConstSizeTypedId + public readonly partial record struct {{data.TypeName}} : IPrefixedTypedId<{{data.TypeName}}> { private const int ValueLength = {{valueLength}}; private const char Separator = '_'; private const string TypePrefix = "{{prefix}}"; - public static int RawLength { get; } = {{valueLength + 1 + prefix.Length}}; + public static int MaxLength { get; } = {{valueLength + 1 + prefix.Length}}; public static {{data.TypeName}} Empty { get; } = new(Guid.Empty); private readonly string? value; @@ -77,7 +77,7 @@ namespace {{data.Namespace}} public bool IsEmpty => value is null || value == Empty; private {{data.TypeName}}(string v) => value = v; - public {{data.TypeName}}(Guid v) => value = string.Create(null, stackalloc char[RawLength], $"{TypePrefix}{Separator}{v:N}"); + public {{data.TypeName}}(Guid v) => value = string.Create(null, stackalloc char[MaxLength], $"{TypePrefix}{Separator}{v:N}"); {{randomFactory}} public static {{data.TypeName}} Parse(string v) @@ -120,7 +120,7 @@ public static bool IsValid([NotNullWhen(true)] string? v) else { var span = v.AsSpan(); - return span.Length == RawLength + return span.Length == MaxLength && span.StartsWith(TypePrefix) && span[{{prefix.Length}}] == Separator && Guid.TryParseExact(span[{{prefix.Length + 1}}..], "N", out _); @@ -190,13 +190,13 @@ namespace {{data.Namespace}} [JsonConverter(typeof(StringTypedIdConverter<{{data.TypeName}}>))] [DebuggerDisplay("{Value}")] [ExcludeFromCodeCoverage] - public readonly partial record struct {{data.TypeName}} : IPrefixedTypedId<{{data.TypeName}}>, IConstSizeTypedId + public readonly partial record struct {{data.TypeName}} : IPrefixedTypedId<{{data.TypeName}}> { private const int ValueLength = {{valueLength}}; private const char Separator = '_'; private const string TypePrefix = "{{prefix}}"; - public static int RawLength { get; } = {{valueLength + 1 + prefix.Length}}; + public static int MaxLength { get; } = {{valueLength + 1 + prefix.Length}}; public static {{data.TypeName}} Empty { get; } = new(Ulid.Empty); private readonly string? value; @@ -207,7 +207,7 @@ namespace {{data.Namespace}} private {{data.TypeName}}(string v) => value = v; - public {{data.TypeName}}(Ulid v) => value = string.Create(null, stackalloc char[RawLength], $"{TypePrefix}{Separator}{v}"); + public {{data.TypeName}}(Ulid v) => value = string.Create(null, stackalloc char[MaxLength], $"{TypePrefix}{Separator}{v}"); public static {{data.TypeName}} New() => new(Ulid.NewUlid()); @@ -246,7 +246,7 @@ public static bool TryDeconstruct(ReadOnlySpan span, out Ulid rawUlid) { rawUlid = Ulid.Empty; - return span.Length == RawLength + return span.Length == MaxLength && span.StartsWith(TypePrefix) && span[{{prefix.Length}}] == Separator && Ulid.TryParse(span[{{prefix.Length + 1}}..], out rawUlid); @@ -297,31 +297,7 @@ public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnly private static string BuildPrefixedString(TypedIdData data) { var prefix = data.CustomPrefix?.ToLowerInvariant() ?? GetDefaultPrefix(data.TypeName); - - var maxLengthInterfaces = data.MaxValueLength.HasValue ? ", IMaxLengthTypedId" : ""; - var maxLengthProperties = data.MaxValueLength is int maxLen - ? $$""" - public static int MaxValueLength { get; } = {{maxLen}}; - public static int MaxLength { get; } = {{prefix.Length + 1 + maxLen}}; -""" - : ""; - var maxLengthValidation = data.MaxValueLength.HasValue - ? $$""" - var valuePart = span[{{prefix.Length + 1}}..]; - return valuePart.Length <= MaxValueLength; - """ - : """ - return true; - """; - var maxLengthThrowValidation = data.MaxValueLength.HasValue - ? $$""" - - if (valuePart.Length > MaxValueLength) - { - throw new ArgumentException($"The value part exceeds maximum length of {MaxValueLength}.", nameof(valuePart)); - } - """ - : ""; + var maxValueLength = data.MaxValueLength!.Value; // language=C# return $$""" @@ -343,13 +319,15 @@ namespace {{data.Namespace}} [JsonConverter(typeof(StringTypedIdConverter<{{data.TypeName}}>))] [DebuggerDisplay("{Value}")] [ExcludeFromCodeCoverage] - public readonly partial record struct {{data.TypeName}} : IPrefixedTypedId<{{data.TypeName}}>{{maxLengthInterfaces}} + public readonly partial record struct {{data.TypeName}} : IPrefixedTypedId<{{data.TypeName}}> { private const char Separator = '_'; private const string TypePrefix = "{{prefix}}"; public static {{data.TypeName}} Empty { get; } = new(string.Empty); -{{maxLengthProperties}} + public static int MaxValueLength { get; } = {{maxValueLength}}; + public static int MaxLength { get; } = {{prefix.Length + 1 + maxValueLength}}; + private readonly string? value; public string Value => value ?? Empty.Value; @@ -362,7 +340,13 @@ namespace {{data.Namespace}} if (valuePart is null) { throw new ArgumentNullException(nameof(valuePart)); - }{{maxLengthThrowValidation}} + } + if (valuePart.Length > MaxValueLength) + { + throw new ArgumentException( + $"The value part exceeds maximum length of {MaxValueLength}.", + nameof(valuePart)); + } return new {{data.TypeName}}($"{TypePrefix}{Separator}{valuePart}"); } @@ -405,13 +389,8 @@ public static bool IsValid([NotNullWhen(true)] string? v) } var span = v.AsSpan(); - if (span.Length < {{prefix.Length - + 1}} || !span.StartsWith(TypePrefix) || span[{{prefix.Length}}] != Separator) - { - return false; - } - -{{maxLengthValidation}} + return span.Length >= {{prefix.Length + + 1}} && span.Length <= MaxLength && span.StartsWith(TypePrefix) && span[{{prefix.Length}}] == Separator; } public ReadOnlySpan GetValuePart() => IsEmpty @@ -554,15 +533,7 @@ public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnly private static string BuildRawString(TypedIdData data) { - var maxLengthInterfaces = data.MaxValueLength.HasValue ? ", IMaxLengthTypedId" : ""; - var maxLengthProperty = data.MaxValueLength is int maxLen - ? $"public static int MaxLength {{ get; }} = {maxLen};" - : ""; - var maxLengthValidation = data.MaxValueLength.HasValue ? " && v.Length <= MaxLength" : ""; - - var parseErrorMessage = data.MaxValueLength.HasValue - ? "The ID has invalid format. It must be a non-null string with maximum length of {MaxLength}." - : "The ID has invalid format. It must be a non-null string."; + var maxValueLength = data.MaxValueLength!.Value; // language=C# return $$""" @@ -584,10 +555,10 @@ namespace {{data.Namespace}} [JsonConverter(typeof(RawStringTypedIdConverter<{{data.TypeName}}>))] [DebuggerDisplay("{Value}")] [ExcludeFromCodeCoverage] - public readonly partial record struct {{data.TypeName}} : IRawStringTypedId<{{data.TypeName}}>{{maxLengthInterfaces}} + public readonly partial record struct {{data.TypeName}} : IRawStringTypedId<{{data.TypeName}}> { public static {{data.TypeName}} Empty { get; } = new(string.Empty); - {{maxLengthProperty}} + public static int MaxLength { get; } = {{maxValueLength}}; private readonly string? value; @@ -604,7 +575,8 @@ namespace {{data.Namespace}} } else { - throw new FormatException($"{{parseErrorMessage}}"); + throw new FormatException( + $"The ID has invalid format. It must be a non-null string with maximum length of {MaxLength}."); } } @@ -627,7 +599,7 @@ public static bool TryParse([NotNullWhen(true)] string? v, out {{data.TypeName}} public static bool IsValid([NotNullWhen(true)] string? v) { - return v is not null{{maxLengthValidation}}; + return v is not null && v.Length <= MaxLength; } public bool Equals({{data.TypeName}} other) => Value.Equals(other.Value, StringComparison.Ordinal); diff --git a/src/Domain/LeanCode.DomainModels.Generators/TypedIdGenerator.cs b/src/Domain/LeanCode.DomainModels.Generators/TypedIdGenerator.cs index 76696e82f..5ff8d8a66 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/TypedIdGenerator.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/TypedIdGenerator.cs @@ -15,7 +15,16 @@ public sealed class TypedIdGenerator : IIncrementalGenerator private static readonly DiagnosticDescriptor InvalidTypeRule = new( "LNCD0005", "Typed id must be `readonly partial record struct`", - @"`{0}` is invalid. For typed ids to work, the type must be `readonly partial record struct`.", + "`{0}` is invalid. For typed ids to work, the type must be `readonly partial record struct`.", + "Domain", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor MaxLengthRequiredForStringIdRule = new( + "LNCD0012", + "String typed id must provide positive `MaxValueLength` in attribute", + "`{0}` is invalid string ID. For string typed ids to work, they must provide positive `MaxValueLength` in the `TypedId` attribute.", "Domain", DiagnosticSeverity.Error, isEnabledByDefault: true @@ -58,13 +67,22 @@ public void Initialize(IncrementalGeneratorInitializationContext context) src, static (sources, data) => { - if (data.IsValid) + if (!data.IsValid) { - sources.AddSource($"{data.TypeName}.g.cs", IdSource.Build(data)); + sources.ReportDiagnostic(Diagnostic.Create(InvalidTypeRule, data.Location, data.TypeName)); + } + else if ( + data.Format is TypedIdFormat.RawString or TypedIdFormat.PrefixedString + && data.MaxValueLength is null + ) + { + sources.ReportDiagnostic( + Diagnostic.Create(MaxLengthRequiredForStringIdRule, data.Location, data.TypeName) + ); } else { - sources.ReportDiagnostic(Diagnostic.Create(InvalidTypeRule, data.Location, data.TypeName)); + sources.AddSource($"{data.TypeName}.g.cs", IdSource.Build(data)); } } ); diff --git a/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs b/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs index c7c24d11a..9d390b281 100644 --- a/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs +++ b/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs @@ -13,6 +13,7 @@ public interface IPrefixedTypedId ISpanFormattable, IUtf8SpanFormattable, IEqualityOperators, + IMaxLengthTypedId, IHasEmptyId where TSelf : struct, IPrefixedTypedId { @@ -57,6 +58,7 @@ public interface IRawStringTypedId ISpanFormattable, IUtf8SpanFormattable, IEqualityOperators, + IMaxLengthTypedId, IHasEmptyId where TSelf : struct, IRawStringTypedId { @@ -78,12 +80,6 @@ public interface IHasEmptyId static abstract TSelf Empty { get; } } -[EditorBrowsable(EditorBrowsableState.Never)] -public interface IConstSizeTypedId -{ - static abstract int RawLength { get; } -} - [EditorBrowsable(EditorBrowsableState.Never)] public interface IMaxLengthTypedId { diff --git a/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdConverterTests.cs b/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdConverterTests.cs index 93a161b81..0ab7036ca 100644 --- a/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdConverterTests.cs +++ b/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdConverterTests.cs @@ -110,23 +110,7 @@ public void RawString_convention_is_registered_properly() Assert.IsType>(mapping.GetValueConverter()); Assert.Equal(typeof(RawStringTypedIdComparer), mapping["ValueComparerType"]); Assert.Equal(typeof(StringId), mapping.ClrType); - Assert.Null(mapping.GetMaxLength()); - Assert.Null(mapping["Relational:ColumnType"]); - } - - [Fact] - public void RawString_with_max_length_convention_is_registered_properly() - { - var builder = new ModelConfigurationBuilderWrapper(); - builder.Properties().AreStringTypedId(); - var model = builder.Build(); - - var mapping = model.FindProperty(typeof(StringIdWithMaxLength)); - Assert.NotNull(mapping); - Assert.IsType>(mapping.GetValueConverter()); - Assert.Equal(typeof(RawStringTypedIdComparer), mapping["ValueComparerType"]); - Assert.Equal(typeof(StringIdWithMaxLength), mapping.ClrType); - Assert.Equal(100, mapping.GetMaxLength()); + Assert.Equal(StringId.MaxLength, mapping.GetMaxLength()); Assert.Null(mapping["Relational:ColumnType"]); } @@ -142,8 +126,7 @@ public void PrefixedGuid_convention_is_registered_properly() Assert.IsType>(mapping.GetValueConverter()); Assert.Equal(typeof(PrefixedTypedIdComparer), mapping["ValueComparerType"]); Assert.Equal(typeof(PrefixedGuidId), mapping.ClrType); - Assert.Equal(mapping.GetMaxLength(), PrefixedGuidId.RawLength); - Assert.Equal(mapping["Relational:IsFixedLength"], true); + Assert.Equal(PrefixedGuidId.MaxLength, mapping.GetMaxLength()); Assert.Null(mapping["Relational:ColumnType"]); } @@ -160,7 +143,6 @@ public void PrefixedString_convention_is_registered_properly() Assert.Equal(typeof(PrefixedTypedIdComparer), mapping["ValueComparerType"]); Assert.Equal(typeof(PrefixedStringId), mapping.ClrType); Assert.Equal(PrefixedStringId.MaxLength, mapping.GetMaxLength()); - Assert.Null(mapping["Relational:IsFixedLength"]); Assert.Null(mapping["Relational:ColumnType"]); } @@ -209,23 +191,6 @@ public void OptionalRawGuid_convention_is_registered_properly() Assert.Null(mapping["Relational:ColumnType"]); } - [Fact] - public void OptionalPrefixedString_convention_is_registered_properly() - { - var builder = new ModelConfigurationBuilderWrapper(); - builder.Properties().ArePrefixedTypedId(); - var model = builder.Build(); - - var mapping = model.FindProperty(typeof(PrefixedStringId?)); - Assert.NotNull(mapping); - Assert.IsType>(mapping.GetValueConverter()); - Assert.Equal(typeof(PrefixedTypedIdComparer), mapping["ValueComparerType"]); - Assert.Equal(typeof(PrefixedStringId?), mapping.ClrType); - Assert.Equal(PrefixedStringId.MaxLength, mapping.GetMaxLength()); - Assert.Null(mapping["Relational:IsFixedLength"]); - Assert.Null(mapping["Relational:ColumnType"]); - } - [Fact] public void OptionalRawString_convention_is_registered_properly() { @@ -238,6 +203,7 @@ public void OptionalRawString_convention_is_registered_properly() Assert.IsType>(mapping.GetValueConverter()); Assert.Equal(typeof(RawStringTypedIdComparer), mapping["ValueComparerType"]); Assert.Equal(typeof(StringId?), mapping.ClrType); + Assert.Equal(StringId.MaxLength, mapping.GetMaxLength()); Assert.Null(mapping["Relational:ColumnType"]); } @@ -253,8 +219,23 @@ public void OptionalPrefixedGuid_convention_is_registered_properly() Assert.IsType>(mapping.GetValueConverter()); Assert.Equal(typeof(PrefixedTypedIdComparer), mapping["ValueComparerType"]); Assert.Equal(typeof(PrefixedGuidId?), mapping.ClrType); - Assert.Equal(mapping.GetMaxLength(), PrefixedGuidId.RawLength); - Assert.Equal(mapping["Relational:IsFixedLength"], true); + Assert.Equal(PrefixedGuidId.MaxLength, mapping.GetMaxLength()); + Assert.Null(mapping["Relational:ColumnType"]); + } + + [Fact] + public void OptionalPrefixedString_convention_is_registered_properly() + { + var builder = new ModelConfigurationBuilderWrapper(); + builder.Properties().ArePrefixedTypedId(); + var model = builder.Build(); + + var mapping = model.FindProperty(typeof(PrefixedStringId?)); + Assert.NotNull(mapping); + Assert.IsType>(mapping.GetValueConverter()); + Assert.Equal(typeof(PrefixedTypedIdComparer), mapping["ValueComparerType"]); + Assert.Equal(typeof(PrefixedStringId?), mapping.ClrType); + Assert.Equal(PrefixedStringId.MaxLength, mapping.GetMaxLength()); Assert.Null(mapping["Relational:ColumnType"]); } diff --git a/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIds.cs b/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIds.cs index 073e343bc..ae8b715df 100644 --- a/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIds.cs +++ b/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIds.cs @@ -11,11 +11,8 @@ namespace LeanCode.DomainModels.EF.Tests; [TypedId(TypedIdFormat.RawGuid)] public readonly partial record struct GuidId; -[TypedId(TypedIdFormat.RawString)] -public readonly partial record struct StringId; - [TypedId(TypedIdFormat.RawString, MaxValueLength = 100)] -public readonly partial record struct StringIdWithMaxLength; +public readonly partial record struct StringId; [TypedId(TypedIdFormat.PrefixedGuid)] public readonly partial record struct PrefixedGuidId; @@ -23,5 +20,5 @@ namespace LeanCode.DomainModels.EF.Tests; [TypedId(TypedIdFormat.PrefixedUlid)] public readonly partial record struct PrefixedUlidId; -[TypedId(TypedIdFormat.PrefixedString, MaxValueLength = 50)] +[TypedId(TypedIdFormat.PrefixedString, MaxValueLength = 100)] public readonly partial record struct PrefixedStringId; diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedGuidTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedGuidTests.cs index f0f45bc8f..dbd975920 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedGuidTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedGuidTests.cs @@ -218,9 +218,9 @@ static void DatabaseExpressionsWork() } [Fact] - public void RawLength_is_correct() + public void MaxLength_is_correct() { - Assert.Equal(TestPrefixedGuidId.RawLength, TPG1.Length); + Assert.Equal(TestPrefixedGuidId.MaxLength, TPG1.Length); } [Fact] @@ -233,9 +233,9 @@ public void TryFormatChar_is_correct() charsWritten.Should().Be(0); id.TryFormat(buffer, out charsWritten, "", null).Should().BeTrue(); - charsWritten.Should().Be(TestPrefixedGuidId.RawLength); - new string(buffer[..TestPrefixedGuidId.RawLength]).Should().Be(TPG1); - buffer[TestPrefixedGuidId.RawLength..].Should().AllBeEquivalentTo(default(char)); + charsWritten.Should().Be(TestPrefixedGuidId.MaxLength); + new string(buffer[..TestPrefixedGuidId.MaxLength]).Should().Be(TPG1); + buffer[TestPrefixedGuidId.MaxLength..].Should().AllBeEquivalentTo(default(char)); } [Fact] @@ -249,8 +249,8 @@ public void TryFormatUtf8Byte_is_correct() bytesWritten.Should().Be(0); id.TryFormat(buffer, out bytesWritten, "", null).Should().BeTrue(); - bytesWritten.Should().Be(TestPrefixedGuidId.RawLength); - buffer[..TestPrefixedGuidId.RawLength].Should().BeEquivalentTo(expectedBytes); - buffer[TestPrefixedGuidId.RawLength..].Should().AllBeEquivalentTo(default(byte)); + bytesWritten.Should().Be(TestPrefixedGuidId.MaxLength); + buffer[..TestPrefixedGuidId.MaxLength].Should().BeEquivalentTo(expectedBytes); + buffer[TestPrefixedGuidId.MaxLength..].Should().AllBeEquivalentTo(default(byte)); } } diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs index cba13123e..8f9c73e72 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs @@ -7,12 +7,9 @@ namespace LeanCode.DomainModels.Tests.Ids; -[TypedId(TypedIdFormat.PrefixedString, CustomPrefix = "tps")] +[TypedId(TypedIdFormat.PrefixedString, CustomPrefix = "tps", MaxValueLength = 10)] public readonly partial record struct TestPrefixedStringId; -[TypedId(TypedIdFormat.PrefixedString, CustomPrefix = "tpm", MaxValueLength = 10)] -public readonly partial record struct TestPrefixedStringIdWithMaxLength; - public class PrefixedStringIdTests { private const string TPSEmpty = ""; @@ -278,47 +275,44 @@ public void IsEmpty_works_correctly() Assert.True(TestPrefixedStringId.Empty.IsEmpty); Assert.False(TestPrefixedStringId.Parse(TPS1).IsEmpty); } -} -public class PrefixedStringIdWithMaxLengthTests -{ [Fact] public void MaxValueLength_is_exposed() { - Assert.Equal(10, TestPrefixedStringIdWithMaxLength.MaxValueLength); + Assert.Equal(10, TestPrefixedStringId.MaxValueLength); } [Fact] public void MaxLength_is_calculated_correctly() { // prefix "tpm" (3) + separator "_" (1) + max value part (10) = 14 - Assert.Equal(14, TestPrefixedStringIdWithMaxLength.MaxLength); + Assert.Equal(14, TestPrefixedStringId.MaxLength); } [Fact] public void String_within_max_length_is_valid() { - Assert.True(TestPrefixedStringIdWithMaxLength.IsValid("tpm_1234567890")); // value part = 10 chars - Assert.True(TestPrefixedStringIdWithMaxLength.IsValid("tpm_short")); + Assert.True(TestPrefixedStringId.IsValid("tps_1234567890")); // value part = 10 chars + Assert.True(TestPrefixedStringId.IsValid("tps_short")); } [Fact] public void String_exceeding_max_length_is_invalid() { - Assert.False(TestPrefixedStringIdWithMaxLength.IsValid("tpm_12345678901")); // value part = 11 chars + Assert.False(TestPrefixedStringId.IsValid("tps_12345678901")); // value part = 11 chars - Assert.Throws(() => TestPrefixedStringIdWithMaxLength.Parse("tpm_12345678901")); - Assert.False(TestPrefixedStringIdWithMaxLength.TryParse("tpm_this_is_too_long", out _)); + Assert.Throws(() => TestPrefixedStringId.Parse("tps_12345678901")); + Assert.False(TestPrefixedStringId.TryParse("tps_this_is_too_long", out _)); } [Fact] public void FromValuePart_validates_max_length() { // Within limit - should work - var id = TestPrefixedStringIdWithMaxLength.FromValuePart("1234567890"); - Assert.Equal("tpm_1234567890", id.Value); + var id = TestPrefixedStringId.FromValuePart("1234567890"); + Assert.Equal("tps_1234567890", id.Value); // Exceeds limit - should throw - Assert.Throws(() => TestPrefixedStringIdWithMaxLength.FromValuePart("12345678901")); + Assert.Throws(() => TestPrefixedStringId.FromValuePart("12345678901")); } } diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringVariationsTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringVariationsTests.cs new file mode 100644 index 000000000..d67f4432d --- /dev/null +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringVariationsTests.cs @@ -0,0 +1,43 @@ +using FluentAssertions; +using LeanCode.DomainModels.Ids; +using Xunit; + +namespace LeanCode.DomainModels.Tests.Ids; + +[TypedId(TypedIdFormat.PrefixedString, CustomPrefix = "cp", MaxValueLength = 50)] +public readonly partial record struct CustomPrefixStringId; + +[TypedId(TypedIdFormat.PrefixedString, MaxValueLength = 50)] +public readonly partial record struct NormalStringPrefixWithId; + +[TypedId(TypedIdFormat.PrefixedString, MaxValueLength = 50)] +public readonly partial record struct NormalStringPrefixWithoutIdAtTheEnd; + +public class PrefixedStringVariationsTests +{ + [Fact] + public void CustomPrefix_starts_with_custom_prefix() + { + CustomPrefixStringId.FromValuePart("test").Value.Should().StartWith("cp_"); + } + + [Fact] + public void NormalPrefixWithId_starts_with_class_name_without_id() + { + Assert.StartsWith( + "normalstringprefixwith_", + NormalStringPrefixWithId.FromValuePart("test").Value, + StringComparison.Ordinal + ); + } + + [Fact] + public void NormalPrefixWithoutIdAtTheEnd_starts_with_class_name_as_is() + { + Assert.StartsWith( + "normalstringprefixwithoutidattheend_", + NormalStringPrefixWithoutIdAtTheEnd.FromValuePart("test").Value, + StringComparison.Ordinal + ); + } +} diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedUlidTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedUlidTests.cs index 8733a4191..d8e5c3c58 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedUlidTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedUlidTests.cs @@ -237,9 +237,9 @@ static void DatabaseExpressionsWork() } [Fact] - public void RawLength_is_correct() + public void MaxLength_is_correct() { - TestPrefixedUlidId.RawLength.Should().Be(TPU1.Length); + TestPrefixedUlidId.MaxLength.Should().Be(TPU1.Length); } [Fact] @@ -270,9 +270,9 @@ public void TryFormatChar_is_correct() charsWritten.Should().Be(0); id.TryFormat(buffer, out charsWritten, "", null).Should().BeTrue(); - charsWritten.Should().Be(TestPrefixedUlidId.RawLength); - new string(buffer[..TestPrefixedUlidId.RawLength]).Should().Be(TPU1); - buffer[TestPrefixedUlidId.RawLength..].Should().AllBeEquivalentTo(default(char)); + charsWritten.Should().Be(TestPrefixedUlidId.MaxLength); + new string(buffer[..TestPrefixedUlidId.MaxLength]).Should().Be(TPU1); + buffer[TestPrefixedUlidId.MaxLength..].Should().AllBeEquivalentTo(default(char)); } [Fact] @@ -286,8 +286,8 @@ public void TryFormatUtf8Byte_is_correct() bytesWritten.Should().Be(0); id.TryFormat(buffer, out bytesWritten, "", null).Should().BeTrue(); - bytesWritten.Should().Be(TestPrefixedUlidId.RawLength); - buffer[..TestPrefixedUlidId.RawLength].Should().BeEquivalentTo(expectedBytes); - buffer[TestPrefixedUlidId.RawLength..].Should().AllBeEquivalentTo(default(byte)); + bytesWritten.Should().Be(TestPrefixedUlidId.MaxLength); + buffer[..TestPrefixedUlidId.MaxLength].Should().BeEquivalentTo(expectedBytes); + buffer[TestPrefixedUlidId.MaxLength..].Should().AllBeEquivalentTo(default(byte)); } } diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs index 96ef45482..3042f1206 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs @@ -7,11 +7,8 @@ namespace LeanCode.DomainModels.Tests.Ids; -[TypedId(TypedIdFormat.RawString)] -public readonly partial record struct TestRawStringId; - [TypedId(TypedIdFormat.RawString, MaxValueLength = 10)] -public readonly partial record struct TestRawStringIdWithMaxLength; +public readonly partial record struct TestRawStringId; public class RawStringIdTests { @@ -221,35 +218,32 @@ public void IsEmpty_works_correctly() Assert.True(new TestRawStringId(string.Empty).IsEmpty); Assert.False(new TestRawStringId(String1).IsEmpty); } -} -public class RawStringIdWithMaxLengthTests -{ [Fact] public void MaxLength_is_exposed() { - Assert.Equal(10, TestRawStringIdWithMaxLength.MaxLength); + Assert.Equal(10, TestRawStringId.MaxLength); } [Fact] public void String_within_max_length_is_valid() { - Assert.True(TestRawStringIdWithMaxLength.IsValid("1234567890")); - Assert.True(TestRawStringIdWithMaxLength.IsValid("short")); + Assert.True(TestRawStringId.IsValid("1234567890")); + Assert.True(TestRawStringId.IsValid("short")); } [Fact] public void String_exceeding_max_length_is_invalid() { - Assert.False(TestRawStringIdWithMaxLength.IsValid("12345678901")); // 11 chars + Assert.False(TestRawStringId.IsValid("12345678901")); // 11 chars - Assert.Throws(() => TestRawStringIdWithMaxLength.Parse("12345678901")); - Assert.False(TestRawStringIdWithMaxLength.TryParse("this_is_too_long", out _)); + Assert.Throws(() => TestRawStringId.Parse("12345678901")); + Assert.False(TestRawStringId.TryParse("this_is_too_long", out _)); } [Fact] public void Empty_string_is_valid_with_max_length() { - Assert.True(TestRawStringIdWithMaxLength.IsValid(string.Empty)); + Assert.True(TestRawStringId.IsValid(string.Empty)); } } diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs index 1996fd4d4..2583b2e45 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs @@ -168,24 +168,6 @@ namespace Test; [Fact] public void Correct_RawString() { - AssertCorrect( - """ - using LeanCode.DomainModels.Ids; - namespace Test; - [TypedId(TypedIdFormat.RawString)] - public readonly partial record struct Id; - """ - ); - - AssertCorrect( - """ - using LeanCode.DomainModels.Ids; - namespace Test; - [TypedId(TypedIdFormat.RawString, CustomPrefix = "ignored")] - public readonly partial record struct Id; - """ - ); - AssertCorrect( """ using LeanCode.DomainModels.Ids; @@ -208,24 +190,6 @@ namespace Test; [Fact] public void Correct_PrefixedString() { - AssertCorrect( - """ - using LeanCode.DomainModels.Ids; - namespace Test; - [TypedId(TypedIdFormat.PrefixedString)] - public readonly partial record struct Id; - """ - ); - - AssertCorrect( - """ - using LeanCode.DomainModels.Ids; - namespace Test; - [TypedId(TypedIdFormat.PrefixedString, CustomPrefix = "prefix")] - public readonly partial record struct Id; - """ - ); - AssertCorrect( """ using LeanCode.DomainModels.Ids; From 839fe295504439bcb5a30bf3e456bfb88a699fad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Mon, 22 Dec 2025 14:30:15 +0100 Subject: [PATCH 17/20] Add tests for raw and prefixed string ID max value length requirement --- .../Ids/GeneratorRunner.cs | 8 ++++---- .../Ids/InvalidConstructTests.cs | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/GeneratorRunner.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/GeneratorRunner.cs index b816a4cca..32c05f210 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/GeneratorRunner.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/GeneratorRunner.cs @@ -8,15 +8,15 @@ namespace LeanCode.DomainModels.Tests.Ids; public static class GeneratorRunner { - private static readonly IReadOnlyList DefaultAssemblies = new[] - { + private static readonly IReadOnlyList DefaultAssemblies = + [ LoadRefLib("System.Linq"), LoadRefLib("System.Linq.Expressions"), LoadRefLib("System.Memory"), LoadRefLib("System.Runtime"), LoadRefLib("System.Text.Json"), MetadataReference.CreateFromFile(typeof(TypedIdAttribute).Assembly.Location), - }; + ]; private static PortableExecutableReference LoadRefLib(string name) { @@ -29,7 +29,7 @@ public static ImmutableArray RunDiagnostics(string source) var syntaxTree = CSharpSyntaxTree.ParseText(source); var compilation = CSharpCompilation.Create( assemblyName: "Tests", - syntaxTrees: new[] { syntaxTree }, + syntaxTrees: [syntaxTree], references: DefaultAssemblies, options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) ); diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/InvalidConstructTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/InvalidConstructTests.cs index be190dae1..cca6136db 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/InvalidConstructTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/InvalidConstructTests.cs @@ -33,4 +33,24 @@ public void No_readonly() var diag = GeneratorRunner.RunDiagnostics(Source); Assert.Single(diag, d => d.Id == "LNCD0005"); } + + [Fact] + public void No_max_value_length_for_raw_string_id() + { + const string Source = + "using LeanCode.DomainModels.Ids; [TypedId(TypedIdFormat.RawString)] public readonly partial record struct Id;"; + + var diag = GeneratorRunner.RunDiagnostics(Source); + Assert.Single(diag, d => d.Id == "LNCD0012"); + } + + [Fact] + public void No_max_value_length_for_prefixed_string_id() + { + const string Source = + "using LeanCode.DomainModels.Ids; [TypedId(TypedIdFormat.PrefixedString)] public readonly partial record struct Id;"; + + var diag = GeneratorRunner.RunDiagnostics(Source); + Assert.Single(diag, d => d.Id == "LNCD0012"); + } } From d8438b1f83832747997cc69dfbb0fd7a3e6e4383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Mon, 22 Dec 2025 14:35:22 +0100 Subject: [PATCH 18/20] Address string typed IDs in changelog and fix their attribute XML docs --- CHANGELOG.md | 3 ++- src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c90c61f29..919875c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ but this project DOES NOT adhere to [Semantic Versioning](http://semver.org/). * Upgrade to .NET 10 * Response serialization moved from `CQRSMiddleware` to individual CQRS middlewares to support output caching * Add CQRS output caching support with new `LeanCode.CQRS.OutputCaching` package -* Test infrastructure moved to Microsoft Testing Platform v2 and xunit v3 +* Migrate Test infrastructure to Microsoft Testing Platform v2 and xunit v3 +* Add `RawString` and `PrefixedString` source generated typed IDs support ## 9.0 diff --git a/src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs b/src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs index 65778b5eb..c5e2f1f75 100644 --- a/src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs +++ b/src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs @@ -78,11 +78,12 @@ public sealed class TypedIdAttribute : Attribute public bool SkipRandomGenerator { get; set; } /// - /// Maximum length of the value part. Only applies to and + /// Maximum length of the value part. + /// Only applies to and /// formats; ignored for other formats. /// For , this constrains the entire string length. /// For , this constrains only the value part (excludes prefix and separator). - /// If not set (default value of -1), no length validation is performed. + /// Required for string-based IDs. /// /// /// Consider SQL Server's 900-byte key limit (~450 nvarchar chars) when choosing this value. From cf1598aa47919b2a26a73dd0c189e5ff31abad62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Mon, 22 Dec 2025 16:42:57 +0100 Subject: [PATCH 19/20] Include strongly typed IDs in integration tests --- test/LeanCode.IntegrationTests/App/CQRS.cs | 6 +++--- test/LeanCode.IntegrationTests/App/Entity.cs | 12 ++++++++---- test/LeanCode.IntegrationTests/App/Meeting.cs | 6 +++++- test/LeanCode.IntegrationTests/App/TestDbContext.cs | 8 ++++++++ test/LeanCode.IntegrationTests/CQRSTests.cs | 4 ++-- test/LeanCode.IntegrationTests/EFRepositoryTests.cs | 4 ++-- .../LeanCode.IntegrationTests.csproj | 4 ++++ test/LeanCode.IntegrationTests/TimestampTzTests.cs | 4 ++-- .../docker/docker-compose.yml | 4 ++-- 9 files changed, 36 insertions(+), 16 deletions(-) diff --git a/test/LeanCode.IntegrationTests/App/CQRS.cs b/test/LeanCode.IntegrationTests/App/CQRS.cs index b3dc3a191..b8bede75c 100644 --- a/test/LeanCode.IntegrationTests/App/CQRS.cs +++ b/test/LeanCode.IntegrationTests/App/CQRS.cs @@ -29,7 +29,7 @@ public class ListEntities : IQuery> { } public class EntityDTO { - public Guid Id { get; set; } + public string Id { get; set; } = default!; public string Value { get; set; } = default!; } @@ -69,7 +69,7 @@ public AddEntityCH(TestDbContext dbContext) public Task ExecuteAsync(HttpContext context, AddEntity command) { - var entity = new Entity { Id = Guid.NewGuid(), Value = command.Value }; + var entity = new Entity { Id = EntityId.New(), Value = command.Value }; DomainEvents.Raise(new EntityAdded(entity)); dbContext.Entities.Add(entity); @@ -90,7 +90,7 @@ public EntityAddedConsumer(TestDbContext dbContext) public Task Consume(ConsumeContext context) { - var entity = new Entity { Id = Guid.NewGuid(), Value = $"{context.Message.Value}-consumer" }; + var entity = new Entity { Id = EntityId.New(), Value = $"{context.Message.Value}-consumer" }; dbContext.Entities.Add(entity); // No dbContext.SaveChanges - infrastructure will be handling this diff --git a/test/LeanCode.IntegrationTests/App/Entity.cs b/test/LeanCode.IntegrationTests/App/Entity.cs index 0951c6a86..6babf0926 100644 --- a/test/LeanCode.IntegrationTests/App/Entity.cs +++ b/test/LeanCode.IntegrationTests/App/Entity.cs @@ -1,12 +1,16 @@ using System.Text.Json.Serialization; +using LeanCode.DomainModels.Ids; using LeanCode.DomainModels.Model; using LeanCode.TimeProvider; namespace LeanCode.IntegrationTests.App; -public class Entity : IAggregateRootWithoutOptimisticConcurrency +[TypedId(TypedIdFormat.PrefixedGuid)] +public readonly partial record struct EntityId; + +public class Entity : IAggregateRootWithoutOptimisticConcurrency { - public Guid Id { get; set; } + public EntityId Id { get; set; } public string Value { get; set; } = null!; } @@ -16,10 +20,10 @@ public class EntityAdded : IDomainEvent public DateTime DateOccurred { get; private init; } public string Value { get; private init; } - public Guid EntityId { get; private init; } + public EntityId EntityId { get; private init; } [JsonConstructor] - public EntityAdded(Guid id, DateTime dateOccurred, string value, Guid entityId) + public EntityAdded(Guid id, DateTime dateOccurred, string value, EntityId entityId) { Id = id; DateOccurred = dateOccurred; diff --git a/test/LeanCode.IntegrationTests/App/Meeting.cs b/test/LeanCode.IntegrationTests/App/Meeting.cs index d0f553f66..06d4d83ee 100644 --- a/test/LeanCode.IntegrationTests/App/Meeting.cs +++ b/test/LeanCode.IntegrationTests/App/Meeting.cs @@ -1,10 +1,14 @@ +using LeanCode.DomainModels.Ids; using LeanCode.DomainModels.Model; namespace LeanCode.IntegrationTests.App; +[TypedId(TypedIdFormat.PrefixedGuid)] +public readonly partial record struct MeetingId; + public class Meeting { - public Guid Id { get; set; } + public MeetingId Id { get; set; } public string Name { get; set; } = default!; public TimestampTz StartTime { get; set; } = default!; } diff --git a/test/LeanCode.IntegrationTests/App/TestDbContext.cs b/test/LeanCode.IntegrationTests/App/TestDbContext.cs index bfc6d7cae..d2285d33e 100644 --- a/test/LeanCode.IntegrationTests/App/TestDbContext.cs +++ b/test/LeanCode.IntegrationTests/App/TestDbContext.cs @@ -1,3 +1,4 @@ +using LeanCode.DomainModels.EF; using LeanCode.Firebase.FCM; using MassTransit; using Microsoft.EntityFrameworkCore; @@ -33,4 +34,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ConfigurePushNotificationTokenEntity(setTokenColumnMaxLength: true); } + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + base.ConfigureConventions(configurationBuilder); + configurationBuilder.Properties().ArePrefixedTypedId(); + configurationBuilder.Properties().ArePrefixedTypedId(); + } } diff --git a/test/LeanCode.IntegrationTests/CQRSTests.cs b/test/LeanCode.IntegrationTests/CQRSTests.cs index d7d894c5c..dd7b09029 100644 --- a/test/LeanCode.IntegrationTests/CQRSTests.cs +++ b/test/LeanCode.IntegrationTests/CQRSTests.cs @@ -65,8 +65,8 @@ private async Task AddEntityAndVerifyResultsAsync() entities .Should() .Satisfy( - e1 => e1.Value == "test-entity" && e1.Id != Guid.Empty, - e2 => e2.Value == "test-entity-consumer" && e2.Id != Guid.Empty + e1 => e1.Value == "test-entity" && e1.Id != EntityId.Empty, + e2 => e2.Value == "test-entity-consumer" && e2.Id != EntityId.Empty ); } diff --git a/test/LeanCode.IntegrationTests/EFRepositoryTests.cs b/test/LeanCode.IntegrationTests/EFRepositoryTests.cs index e22f5d166..e7ed9f791 100644 --- a/test/LeanCode.IntegrationTests/EFRepositoryTests.cs +++ b/test/LeanCode.IntegrationTests/EFRepositoryTests.cs @@ -15,7 +15,7 @@ public class EFRepositoryTests : IAsyncLifetime [IntegrationFact] public async Task Default_implementation_of_EFRepository_works() { - var entity = new Entity { Id = Guid.NewGuid(), Value = "test value" }; + var entity = new Entity { Id = EntityId.New(), Value = "test value" }; await EnsureEntityDoesNotExistAsync(entity); await AddEntityAsync(entity); @@ -55,7 +55,7 @@ private async Task EnsureEntityDoesNotExistAsync(Entity entity) public ValueTask DisposeAsync() => app.DisposeAsync(); - private sealed class EntityRepository : EFRepository + private sealed class EntityRepository : EFRepository { public EntityRepository(TestDbContext dbContext) : base(dbContext) { } diff --git a/test/LeanCode.IntegrationTests/LeanCode.IntegrationTests.csproj b/test/LeanCode.IntegrationTests/LeanCode.IntegrationTests.csproj index b726c5eb6..f947ae543 100644 --- a/test/LeanCode.IntegrationTests/LeanCode.IntegrationTests.csproj +++ b/test/LeanCode.IntegrationTests/LeanCode.IntegrationTests.csproj @@ -10,6 +10,10 @@ + diff --git a/test/LeanCode.IntegrationTests/TimestampTzTests.cs b/test/LeanCode.IntegrationTests/TimestampTzTests.cs index 7d9e2c2ef..ea95e01ac 100644 --- a/test/LeanCode.IntegrationTests/TimestampTzTests.cs +++ b/test/LeanCode.IntegrationTests/TimestampTzTests.cs @@ -13,14 +13,14 @@ public class TimestampTzTests : IAsyncLifetime private readonly Meeting meeting1 = new() { - Id = Guid.NewGuid(), + Id = MeetingId.New(), Name = "First", StartTime = new(Date.ToDateTime(new(10, 0), DateTimeKind.Utc), "Asia/Tokyo"), }; private readonly Meeting meeting2 = new() { - Id = Guid.NewGuid(), + Id = MeetingId.New(), Name = "Second", StartTime = new(Date.ToDateTime(new(14, 0), DateTimeKind.Utc), "America/Los_Angeles"), }; diff --git a/test/LeanCode.IntegrationTests/docker/docker-compose.yml b/test/LeanCode.IntegrationTests/docker/docker-compose.yml index 32cdecab0..9ad141e03 100755 --- a/test/LeanCode.IntegrationTests/docker/docker-compose.yml +++ b/test/LeanCode.IntegrationTests/docker/docker-compose.yml @@ -33,7 +33,7 @@ services: #### Infrastructure sqlserver: - image: mcr.microsoft.com/mssql/server:2022-latest + image: mcr.microsoft.com/mssql/server:2025-latest environment: ACCEPT_EULA: Y MSSQL_SA_PASSWORD: Passw12# @@ -41,7 +41,7 @@ services: - "1433:1433" postgres: - image: postgres:15 + image: postgres:17 environment: POSTGRES_PASSWORD: Passw12# ports: From fa4074e1a0b4ca31e162069212671a087c083c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Mon, 22 Dec 2025 16:50:47 +0100 Subject: [PATCH 20/20] Bring back `AreFixedLength()` ID column configurations for backcomp --- .../PropertiesConfigurationBuilderExtensions.cs | 4 ++-- .../LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs b/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs index 4ed5af6f6..9495eafb5 100644 --- a/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs +++ b/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs @@ -99,7 +99,7 @@ this PropertiesConfigurationBuilder builder ) where TId : struct, IMaxLengthTypedId { - return builder.HaveMaxLength(TId.MaxLength); + return builder.HaveMaxLength(TId.MaxLength).AreFixedLength(); } private static PropertiesConfigurationBuilder ConfigureMaxLength( @@ -107,7 +107,7 @@ this PropertiesConfigurationBuilder builder ) where TId : struct, IMaxLengthTypedId { - return builder.HaveMaxLength(TId.MaxLength); + return builder.HaveMaxLength(TId.MaxLength).AreFixedLength(); } private static PropertiesConfigurationBuilder AreRawTypedId( diff --git a/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs b/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs index d3b13cd9e..7d9b6937f 100644 --- a/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs +++ b/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs @@ -144,12 +144,12 @@ public static PropertyBuilder IsPrefixedTypedId(this PropertyBuilder ConfigureMaxLength(this PropertyBuilder builder) where TId : struct, IMaxLengthTypedId { - return builder.HasMaxLength(TId.MaxLength); + return builder.HasMaxLength(TId.MaxLength).IsFixedLength(); } private static PropertyBuilder ConfigureMaxLength(this PropertyBuilder builder) where TId : struct, IMaxLengthTypedId { - return builder.HasMaxLength(TId.MaxLength); + return builder.HasMaxLength(TId.MaxLength).IsFixedLength(); } }