From d755cbb223f9c63c72756ad1a17669c356bf82f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Mon, 22 Dec 2025 11:18:31 +0100 Subject: [PATCH 1/9] Update typed IDs Guid generation to use CreateVersion7 --- .../LeanCode.DomainModels.Generators/IdSource.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs index 834becf9..e3dad646 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs @@ -24,7 +24,15 @@ public static string Build(TypedIdData data) "CultureInfo.InvariantCulture", "string.Empty, CultureInfo.InvariantCulture" ), - TypedIdFormat.RawGuid => BuildRaw(data, "Guid", "Guid", "Guid.NewGuid()", "Guid.Empty", "", "string.Empty"), + TypedIdFormat.RawGuid => BuildRaw( + data, + "Guid", + "Guid", + "Guid.CreateVersion7()", + "Guid.Empty", + "", + "string.Empty" + ), TypedIdFormat.RawString => BuildRawString(data), TypedIdFormat.PrefixedGuid => BuildPrefixedGuid(data), TypedIdFormat.PrefixedUlid => BuildPrefixedUlid(data), @@ -39,7 +47,7 @@ private static string BuildPrefixedGuid(TypedIdData data) var valueLength = 32; var randomFactory = !data.SkipRandomGenerator - ? $"public static {data.TypeName} New() => new(Guid.NewGuid());" + ? $"public static {data.TypeName} New() => new(Guid.CreateVersion7());" : ""; // language=C# From eb9b9042da025dfb1e8d6805013d173313497883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Mon, 22 Dec 2025 12:33:53 +0100 Subject: [PATCH 2/9] Implement Destructure method for typed IDs --- .../IdSource.cs | 11 ++++++++--- .../Ids/PrefixedGuidTests.cs | 19 +++++++++++++++++++ .../Ids/PrefixedStringTests.cs | 18 +++++++++++------- .../Ids/PrefixedUlidTests.cs | 10 ++++++++++ 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs index e3dad646..1408df2a 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs @@ -83,6 +83,7 @@ namespace {{data.Namespace}} public string Value => value ?? Empty.Value; public bool IsEmpty => value is null || value == Empty; + public Guid Guid => value is null ? Guid.Empty : Guid.ParseExact(value.AsSpan()[{{prefix.Length + 1}}..], "N"); private {{data.TypeName}}(string v) => value = v; public {{data.TypeName}}(Guid v) => value = string.Create(null, stackalloc char[MaxLength], $"{TypePrefix}{Separator}{v:N}"); @@ -135,6 +136,8 @@ public static bool IsValid([NotNullWhen(true)] string? v) } } + public (string prefix, Guid data) Destructure() => (TypePrefix, Guid); + 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); @@ -265,6 +268,8 @@ public static bool IsValid([NotNullWhen(true)] string? v) return TryDeconstruct(v.AsSpan(), out _); } + public (string prefix, Ulid data) Destructure() => (TypePrefix, Ulid); + 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); @@ -340,6 +345,7 @@ namespace {{data.Namespace}} public string Value => value ?? Empty.Value; public bool IsEmpty => string.IsNullOrEmpty(value); + public string ValuePart => Value[{{prefix.Length + 1}}..]; private {{data.TypeName}}(string v) => value = v; @@ -401,9 +407,8 @@ public static bool IsValid([NotNullWhen(true)] string? v) + 1}} && span.Length <= MaxLength && span.StartsWith(TypePrefix) && span[{{prefix.Length}}] == Separator; } - public ReadOnlySpan GetValuePart() => IsEmpty - ? ReadOnlySpan.Empty - : Value.AsSpan()[{{prefix.Length + 1}}..]; + + public (string prefix, string data) Destructure() => (TypePrefix, ValuePart); 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/PrefixedGuidTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedGuidTests.cs index dbd97592..7137ba72 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedGuidTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedGuidTests.cs @@ -223,6 +223,25 @@ public void MaxLength_is_correct() Assert.Equal(TestPrefixedGuidId.MaxLength, TPG1.Length); } + [Fact] + public void Raw_guid_can_be_extracted_from_type() + { + var id = TestPrefixedGuidId.Parse(TPG1); + var guid = id.Guid; + + guid.Should().Be(TPG1Guid); + } + + [Fact] + public void Destructure_extracts_prefix_and_guid() + { + var id = TestPrefixedGuidId.Parse(TPG1); + var (prefix, data) = id.Destructure(); + + Assert.Equal("tpg", prefix); + Assert.Equal(TPG1Guid, data); + } + [Fact] public void TryFormatChar_is_correct() { diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs index 8f9c73e7..101449e9 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs @@ -79,16 +79,17 @@ public void From_value_without_separator_behaves_correctly() } [Fact] - public void From_value_with_empty_value_part_behaves_correctly() + public void Empty_value_part_behaves_correctly() { Assert.True(TestPrefixedStringId.IsValid("tps_")); var id = TestPrefixedStringId.Parse("tps_"); Assert.Equal("tps_", id.Value); - Assert.True(id.GetValuePart().IsEmpty); + Assert.Equal(0, id.ValuePart.Length); Assert.True(TestPrefixedStringId.TryParse("tps_", out var parsed)); Assert.Equal("tps_", parsed.Value); + Assert.Equal(0, parsed.ValuePart.Length); } [Fact] @@ -110,17 +111,20 @@ public void FromValuePart_creates_prefixed_id() } [Fact] - public void GetValuePart_extracts_value_part() + public void ValuePart_extracts_value_part() { var id = TestPrefixedStringId.Parse(TPS1); - Assert.Equal("abc123", id.GetValuePart().ToString()); + Assert.Equal("abc123", id.ValuePart); } [Fact] - public void GetValuePart_returns_empty_span_for_Empty_instance() + public void Destructure_extracts_prefix_and_data() { - var empty = TestPrefixedStringId.Empty; - Assert.True(empty.GetValuePart().IsEmpty); + var id = TestPrefixedStringId.Parse(TPS1); + var (prefix, data) = id.Destructure(); + + Assert.Equal("tps", prefix); + Assert.Equal("abc123", data); } [Fact] diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedUlidTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedUlidTests.cs index d8e5c3c5..065e332e 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedUlidTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedUlidTests.cs @@ -251,6 +251,16 @@ public void Raw_ulid_can_be_extracted_from_type() ulid.Should().Be(TPG1Ulid); } + [Fact] + public void Destructure_extracts_prefix_and_ulid() + { + var id = TestPrefixedUlidId.Parse(TPU1); + var (prefix, data) = id.Destructure(); + + prefix.Should().Be("tpu"); + data.Should().Be(TPG1Ulid); + } + [Fact] public void Ids_are_case_insensitive() { From a855e740ef032eda52a66e488bc647a25dc645d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Tue, 23 Dec 2025 11:41:50 +0100 Subject: [PATCH 3/9] Implement `ISpanParsable` in source generated IDs --- ...ropertiesConfigurationBuilderExtensions.cs | 4 +- .../TypedIdConverter.cs | 4 +- .../IdSource.cs | 236 ++++++++++++++---- .../LeanCode.DomainModels/Ids/ITypedId.cs | 7 +- .../Ids/TypedIdConverter.cs | 4 +- .../PrefixedTypedUserIdExtractor.cs | 2 +- .../Extractors/RawTypedUserIdExtractor.cs | 2 +- .../ServiceProviderExtensions.cs | 2 +- .../Ids/PrefixedGuidTests.cs | 4 +- .../Ids/PrefixedStringTests.cs | 4 +- .../Ids/PrefixedUlidTests.cs | 4 +- .../Ids/RawStringTests.cs | 4 +- .../LeanCode.DomainModels.Tests.csproj | 1 + 13 files changed, 204 insertions(+), 74 deletions(-) diff --git a/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs b/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs index 9495eafb..14dd15ec 100644 --- a/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs +++ b/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs @@ -113,7 +113,7 @@ this PropertiesConfigurationBuilder builder private static PropertiesConfigurationBuilder AreRawTypedId( this PropertiesConfigurationBuilder builder ) - where TBacking : struct + where TBacking : struct, IEquatable, IComparable, ISpanParsable where TId : struct, IRawTypedId { return builder.HaveConversion, RawTypedIdComparer>(); @@ -122,7 +122,7 @@ this PropertiesConfigurationBuilder builder private static PropertiesConfigurationBuilder AreRawTypedId( this PropertiesConfigurationBuilder builder ) - where TBacking : struct + where TBacking : struct, IEquatable, IComparable, ISpanParsable where TId : struct, IRawTypedId { return builder.HaveConversion, RawTypedIdComparer>(); diff --git a/src/Domain/LeanCode.DomainModels.EF/TypedIdConverter.cs b/src/Domain/LeanCode.DomainModels.EF/TypedIdConverter.cs index 90338b66..108cbef0 100644 --- a/src/Domain/LeanCode.DomainModels.EF/TypedIdConverter.cs +++ b/src/Domain/LeanCode.DomainModels.EF/TypedIdConverter.cs @@ -16,7 +16,7 @@ public PrefixedTypedIdConverter() [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] public class RawTypedIdConverter : ValueConverter - where TBacking : struct + where TBacking : struct, IEquatable, IComparable, ISpanParsable where TId : struct, IRawTypedId { public static readonly RawTypedIdConverter Instance = new(); @@ -47,7 +47,7 @@ public PrefixedTypedIdComparer() [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] public class RawTypedIdComparer : ValueComparer - where TBacking : struct + where TBacking : struct, IEquatable, IComparable, ISpanParsable where TId : struct, IRawTypedId { public static readonly RawTypedIdComparer Instance = new(); diff --git a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs index 1408df2a..ee1f4634 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs @@ -89,11 +89,18 @@ namespace {{data.Namespace}} public {{data.TypeName}}(Guid v) => value = string.Create(null, stackalloc char[MaxLength], $"{TypePrefix}{Separator}{v:N}"); {{randomFactory}} - public static {{data.TypeName}} Parse(string v) + public static bool IsValid([NotNullWhen(true)] string? v) + => v is not null && IsValid(v.AsSpan()); + + public static bool IsValid(ReadOnlySpan v) + => TryParse(v, null, out _); + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Guid parsing.")] + public static {{data.TypeName}} Parse(string v, IFormatProvider? provider = null) { - if (IsValid(v)) + if (TryParse(v.AsSpan(), provider, out var id)) { - return new {{data.TypeName}}(v); + return id; } else { @@ -106,33 +113,43 @@ namespace {{data.Namespace}} [return: NotNullIfNotNull("id")] public static {{data.TypeName}}? ParseNullable(string? id) => id is string v ? Parse(v) : ({{data.TypeName}}?)null; + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Guid parsing.")] + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) + => TryParse(s.AsSpan(), provider, out result); + public static bool TryParse([NotNullWhen(true)] string? v, out {{data.TypeName}} id) + => TryParse(v, null, out id); + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Guid parsing.")] + public static {{data.TypeName}} Parse(ReadOnlySpan s, IFormatProvider? provider = null) { - if (IsValid(v)) + if (TryParse(s, provider, out var result)) { - id = new {{data.TypeName}}(v); - return true; + return result; } else { - id = default; - return false; + throw new FormatException( + $"The ID has invalid format. It should look like {TypePrefix}{Separator}(id value)." + ); } } - public static bool IsValid([NotNullWhen(true)] string? v) + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Guid parsing.")] + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out {{data.TypeName}} result) { - if (v is null) + if (s.Length == MaxLength + && s.StartsWith(TypePrefix) + && s[{{prefix.Length}}] == Separator + && Guid.TryParseExact(s[{{prefix.Length + 1}}..], "N", out var guid)) { - return false; + result = new {{data.TypeName}}(guid); + return true; } else { - var span = v.AsSpan(); - return span.Length == MaxLength - && span.StartsWith(TypePrefix) - && span[{{prefix.Length}}] == Separator - && Guid.TryParseExact(span[{{prefix.Length + 1}}..], "N", out _); + result = default; + return false; } } @@ -222,11 +239,18 @@ namespace {{data.Namespace}} public static {{data.TypeName}} New() => new(Ulid.NewUlid()); - public static {{data.TypeName}} Parse(string v) + public static bool IsValid([NotNullWhen(true)] string? v) + => v is not null && IsValid(v.AsSpan()); + + public static bool IsValid(ReadOnlySpan v) + => TryParse(v, null, out _); + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Ulid parsing.")] + public static {{data.TypeName}} Parse(string v, IFormatProvider? provider = null) { - if (TryDeconstruct(v.AsSpan(), out var ulid)) + if (TryParse(v.AsSpan(), provider, out var result)) { - return new {{data.TypeName}}(ulid); + return result; } else { @@ -239,16 +263,39 @@ namespace {{data.Namespace}} [return: NotNullIfNotNull("id")] public static {{data.TypeName}}? ParseNullable(string? id) => id is string v ? Parse(v) : ({{data.TypeName}}?)null; + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Ulid parsing.")] + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) + => TryParse(s.AsSpan(), provider, out result); + public static bool TryParse([NotNullWhen(true)] string? v, out {{data.TypeName}} id) + => TryParse(v, null, out id); + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Ulid parsing.")] + public static {{data.TypeName}} Parse(ReadOnlySpan s, IFormatProvider? provider = null) { - if (TryDeconstruct(v, out var ulid)) + if (TryParse(s, provider, out var result)) + { + return result; + } + else { - id = new {{data.TypeName}}(ulid); + throw new FormatException( + $"The ID has invalid format. It should look like {TypePrefix}{Separator}(id value)." + ); + } + } + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Ulid parsing.")] + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out {{data.TypeName}} result) + { + if (TryDeconstruct(s, out var ulid)) + { + result = new {{data.TypeName}}(ulid); return true; } else { - id = default; + result = default; return false; } } @@ -263,11 +310,6 @@ public static bool TryDeconstruct(ReadOnlySpan span, out Ulid rawUlid) && Ulid.TryParse(span[{{prefix.Length + 1}}..], out rawUlid); } - public static bool IsValid([NotNullWhen(true)] string? v) - { - return TryDeconstruct(v.AsSpan(), out _); - } - public (string prefix, Ulid data) Destructure() => (TypePrefix, Ulid); public bool Equals({{data.TypeName}} other) => Value.Equals(other.Value, StringComparison.Ordinal); @@ -364,11 +406,18 @@ namespace {{data.Namespace}} return new {{data.TypeName}}($"{TypePrefix}{Separator}{valuePart}"); } - public static {{data.TypeName}} Parse(string v) + public static bool IsValid([NotNullWhen(true)] string? v) + => v is not null && IsValid(v.AsSpan()); + + public static bool IsValid(ReadOnlySpan v) + => TryParse(v, null, out _); + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string format validation.")] + public static {{data.TypeName}} Parse(string v, IFormatProvider? provider = null) { - if (IsValid(v)) + if (TryParse(v.AsSpan(), provider, out var result)) { - return new {{data.TypeName}}(v); + return result; } else { @@ -381,33 +430,46 @@ namespace {{data.Namespace}} [return: NotNullIfNotNull("id")] public static {{data.TypeName}}? ParseNullable(string? id) => id is string v ? Parse(v) : ({{data.TypeName}}?)null; + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string format validation.")] + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) + => TryParse(s.AsSpan(), provider, out result); + public static bool TryParse([NotNullWhen(true)] string? v, out {{data.TypeName}} id) + => TryParse(v, null, out id); + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string format validation.")] + public static {{data.TypeName}} Parse(ReadOnlySpan s, IFormatProvider? provider = null) { - if (IsValid(v)) + if (TryParse(s, provider, out var result)) { - id = new {{data.TypeName}}(v); - return true; + return result; } else { - id = default; - return false; + throw new FormatException( + $"The ID has invalid format. It should look like {TypePrefix}{Separator}(value)." + ); } } - public static bool IsValid([NotNullWhen(true)] string? v) + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string format validation.")] + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out {{data.TypeName}} result) { - if (v is null) + if (s.Length >= {{prefix.Length + 1}} + && s.Length <= MaxLength + && s.StartsWith(TypePrefix) + && s[{{prefix.Length}}] == Separator) { + result = new {{data.TypeName}}(s.ToString()); + return true; + } + else + { + result = default; return false; } - - var span = v.AsSpan(); - return span.Length >= {{prefix.Length - + 1}} && span.Length <= MaxLength && span.StartsWith(TypePrefix) && span[{{prefix.Length}}] == Separator; } - public (string prefix, string data) Destructure() => (TypePrefix, ValuePart); public bool Equals({{data.TypeName}} other) => Value.Equals(other.Value, StringComparison.Ordinal); @@ -492,6 +554,11 @@ namespace {{data.Namespace}} public {{data.TypeName}}({{backingType}} v) => Value = v; {{randomFactory}} + public static bool IsValid([NotNullWhen(true)] {{backingType}}? v) + { + return v is not null; + } + public static {{data.TypeName}} Parse({{backingType}} v) { return new {{data.TypeName}}(v); @@ -514,9 +581,45 @@ public static bool TryParse([NotNullWhen(true)] {{backingType}}? v, out {{data.T } } - public static bool IsValid([NotNullWhen(true)] {{backingType}}? v) + public static {{data.TypeName}} Parse(string s, IFormatProvider? provider = null) { - return v is not null; + if (TryParse(s.AsSpan(), provider, out var result)) + { + return result; + } + else + { + throw new FormatException($"Unable to parse '{s}' as {{data.TypeName}}."); + } + } + + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) + => TryParse(s.AsSpan(), provider, out result); + + public static {{data.TypeName}} Parse(ReadOnlySpan s, IFormatProvider? provider = null) + { + if (TryParse(s, provider, out var result)) + { + return result; + } + else + { + throw new FormatException($"Unable to parse the span as {{data.TypeName}}."); + } + } + + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out {{data.TypeName}} result) + { + if ({{backingType}}.TryParse(s, provider, out var v)) + { + result = new {{data.TypeName}}(v); + return true; + } + else + { + result = default; + return false; + } } public bool Equals({{data.TypeName}} other) => Value == other.Value; @@ -580,11 +683,18 @@ namespace {{data.Namespace}} public {{data.TypeName}}(string v) => value = v ?? throw new ArgumentNullException(nameof(v)); - public static {{data.TypeName}} Parse(string v) + public static bool IsValid([NotNullWhen(true)] string? v) + => v is not null && IsValid(v.AsSpan()); + + public static bool IsValid(ReadOnlySpan v) + => TryParse(v, null, out _); + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string length validation.")] + public static {{data.TypeName}} Parse(string v, IFormatProvider? provider = null) { - if (IsValid(v)) + if (TryParse(v.AsSpan(), provider, out var result)) { - return new {{data.TypeName}}(v); + return result; } else { @@ -596,23 +706,41 @@ namespace {{data.Namespace}} [return: NotNullIfNotNull("id")] public static {{data.TypeName}}? ParseNullable(string? id) => id is string v ? Parse(v) : ({{data.TypeName}}?)null; + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string length validation.")] + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) + => TryParse(s.AsSpan(), provider, out result); + public static bool TryParse([NotNullWhen(true)] string? v, out {{data.TypeName}} id) + => TryParse(v, null, out id); + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string length validation.")] + public static {{data.TypeName}} Parse(ReadOnlySpan s, IFormatProvider? provider = null) { - if (IsValid(v)) + if (TryParse(s, provider, out var result)) { - id = new {{data.TypeName}}(v); - return true; + return result; } else { - id = default; - return false; + throw new FormatException( + $"The ID has invalid format. It must be a non-null string with maximum length of {MaxLength}." + ); } } - public static bool IsValid([NotNullWhen(true)] string? v) + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string length validation.")] + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out {{data.TypeName}} result) { - return v is not null && v.Length <= MaxLength; + if (s.Length <= MaxLength) + { + result = new {{data.TypeName}}(s.ToString()); + return true; + } + else + { + result = default; + return false; + } } public bool Equals({{data.TypeName}} other) => Value.Equals(other.Value, StringComparison.Ordinal); diff --git a/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs b/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs index 9d390b28..bf1aacc7 100644 --- a/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs +++ b/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs @@ -12,13 +12,13 @@ public interface IPrefixedTypedId IComparable, ISpanFormattable, IUtf8SpanFormattable, + ISpanParsable, IEqualityOperators, IMaxLengthTypedId, IHasEmptyId where TSelf : struct, IPrefixedTypedId { string Value { get; } - static abstract TSelf Parse(string v); static abstract bool IsValid(string? v); [EditorBrowsable(EditorBrowsableState.Never)] @@ -35,9 +35,10 @@ public interface IRawTypedId IComparable, ISpanFormattable, IUtf8SpanFormattable, + ISpanParsable, IEqualityOperators, IHasEmptyId - where TBacking : struct + where TBacking : struct, IEquatable, IComparable, ISpanParsable where TSelf : struct, IRawTypedId { TBacking Value { get; } @@ -57,13 +58,13 @@ public interface IRawStringTypedId IComparable, ISpanFormattable, IUtf8SpanFormattable, + ISpanParsable, IEqualityOperators, IMaxLengthTypedId, IHasEmptyId where TSelf : struct, IRawStringTypedId { string Value { get; } - static abstract TSelf Parse(string v); static abstract bool IsValid(string? v); [EditorBrowsable(EditorBrowsableState.Never)] diff --git a/src/Domain/LeanCode.DomainModels/Ids/TypedIdConverter.cs b/src/Domain/LeanCode.DomainModels/Ids/TypedIdConverter.cs index 501021f5..a27e11a7 100644 --- a/src/Domain/LeanCode.DomainModels/Ids/TypedIdConverter.cs +++ b/src/Domain/LeanCode.DomainModels/Ids/TypedIdConverter.cs @@ -12,7 +12,7 @@ public class StringTypedIdConverter : JsonConverter where TId : struct, IPrefixedTypedId { public override TId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => - TId.Parse(reader.GetString() ?? throw new JsonException("Expected an id string")); + TId.Parse(reader.GetString() ?? throw new JsonException("Expected an id string"), null); public override void Write(Utf8JsonWriter writer, TId value, JsonSerializerOptions options) => writer.WriteStringValue(value.Value); @@ -32,7 +32,7 @@ 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")); + TId.Parse(reader.GetString() ?? throw new JsonException("Expected an id string"), null); public override void Write(Utf8JsonWriter writer, TId value, JsonSerializerOptions options) => writer.WriteStringValue(value.Value); diff --git a/src/Helpers/LeanCode.UserIdExtractors/Extractors/PrefixedTypedUserIdExtractor.cs b/src/Helpers/LeanCode.UserIdExtractors/Extractors/PrefixedTypedUserIdExtractor.cs index cc60a68a..a76f68fe 100644 --- a/src/Helpers/LeanCode.UserIdExtractors/Extractors/PrefixedTypedUserIdExtractor.cs +++ b/src/Helpers/LeanCode.UserIdExtractors/Extractors/PrefixedTypedUserIdExtractor.cs @@ -18,6 +18,6 @@ public TId Extract(ClaimsPrincipal user) var claim = user.FindFirst(userIdClaim)?.Value; ArgumentException.ThrowIfNullOrEmpty(claim); - return TId.Parse(claim); + return TId.Parse(claim, null); } } diff --git a/src/Helpers/LeanCode.UserIdExtractors/Extractors/RawTypedUserIdExtractor.cs b/src/Helpers/LeanCode.UserIdExtractors/Extractors/RawTypedUserIdExtractor.cs index 976d8def..4acbbee5 100644 --- a/src/Helpers/LeanCode.UserIdExtractors/Extractors/RawTypedUserIdExtractor.cs +++ b/src/Helpers/LeanCode.UserIdExtractors/Extractors/RawTypedUserIdExtractor.cs @@ -6,7 +6,7 @@ namespace LeanCode.UserIdExtractors.Extractors; public sealed class RawTypedUserIdExtractor : IUserIdExtractor - where TBacking : struct + where TBacking : struct, IEquatable, IComparable, ISpanParsable where TId : struct, IRawTypedId { private readonly string userIdClaim; diff --git a/src/Helpers/LeanCode.UserIdExtractors/ServiceProviderExtensions.cs b/src/Helpers/LeanCode.UserIdExtractors/ServiceProviderExtensions.cs index e7d82e9a..f248a47c 100644 --- a/src/Helpers/LeanCode.UserIdExtractors/ServiceProviderExtensions.cs +++ b/src/Helpers/LeanCode.UserIdExtractors/ServiceProviderExtensions.cs @@ -34,7 +34,7 @@ public static IServiceCollection AddRawTypedUserIdExtractor( this IServiceCollection services, string userIdClaim = DefaultUserIdClaim ) - where TBacking : struct + where TBacking : struct, IEquatable, IComparable, ISpanParsable where TUserId : struct, IRawTypedId { services.AddSingleton(new StringUserIdExtractor(userIdClaim)); diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedGuidTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedGuidTests.cs index 7137ba72..0f162947 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedGuidTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedGuidTests.cs @@ -212,8 +212,8 @@ public void Database_expressions_work() static void DatabaseExpressionsWork() where T : struct, IPrefixedTypedId { - Assert.Equal(T.FromDatabase.Compile().Invoke(TPG1), T.Parse(TPG1)); - Assert.True(T.DatabaseEquals.Compile().Invoke(T.Parse(TPG1), T.Parse(TPG1))); + Assert.Equal(T.FromDatabase.Compile().Invoke(TPG1), T.Parse(TPG1, null)); + Assert.True(T.DatabaseEquals.Compile().Invoke(T.Parse(TPG1, null), T.Parse(TPG1, null))); } } diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs index 101449e9..d3f32991 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs @@ -237,8 +237,8 @@ public void Database_expressions_work() static void DatabaseExpressionsWork() 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))); + Assert.Equal(T.FromDatabase.Compile().Invoke(TPS1), T.Parse(TPS1, null)); + Assert.True(T.DatabaseEquals.Compile().Invoke(T.Parse(TPS1, null), T.Parse(TPS1, null))); } } diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedUlidTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedUlidTests.cs index 065e332e..402a048e 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedUlidTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedUlidTests.cs @@ -231,8 +231,8 @@ public void Database_expressions_work() static void DatabaseExpressionsWork() where T : struct, IPrefixedTypedId { - T.FromDatabase.Compile().Invoke(TPU1).Should().Be(T.Parse(TPU1)); - T.DatabaseEquals.Compile().Invoke(T.Parse(TPU1), T.Parse(TPU1)).Should().BeTrue(); + T.FromDatabase.Compile().Invoke(TPU1).Should().Be(T.Parse(TPU1, null)); + T.DatabaseEquals.Compile().Invoke(T.Parse(TPU1, null), T.Parse(TPU1, null)).Should().BeTrue(); } } diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs index 3042f120..8411f445 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs @@ -175,8 +175,8 @@ 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))); + Assert.Equal(T.FromDatabase.Compile().Invoke(str), T.Parse(str, null)); + Assert.True(T.DatabaseEquals.Compile().Invoke(T.Parse(str, null), T.Parse(str, null))); } } diff --git a/test/Domain/LeanCode.DomainModels.Tests/LeanCode.DomainModels.Tests.csproj b/test/Domain/LeanCode.DomainModels.Tests/LeanCode.DomainModels.Tests.csproj index 89863939..a934da6a 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/LeanCode.DomainModels.Tests.csproj +++ b/test/Domain/LeanCode.DomainModels.Tests/LeanCode.DomainModels.Tests.csproj @@ -1,6 +1,7 @@ enable + $(NoWarn);CA1305 From fcd283104f8bc7377b1cf932c480824ed03e5db3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Tue, 23 Dec 2025 12:23:58 +0100 Subject: [PATCH 4/9] Add tests for parsing source generated IDs --- .../IdSource.cs | 77 +++++++++++++++---- .../Ids/GuidTests.cs | 71 ++++++++++++++++- .../Ids/IntTests.cs | 71 ++++++++++++++++- .../Ids/LongTests.cs | 71 ++++++++++++++++- .../Ids/PrefixedGuidTests.cs | 74 +++++++++++++++++- .../Ids/PrefixedStringTests.cs | 74 +++++++++++++++++- .../Ids/PrefixedUlidTests.cs | 74 +++++++++++++++++- .../Ids/RawStringTests.cs | 74 +++++++++++++++++- 8 files changed, 562 insertions(+), 24 deletions(-) diff --git a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs index ee1f4634..c6aecff0 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs @@ -98,6 +98,7 @@ public static bool IsValid(ReadOnlySpan v) [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Guid parsing.")] public static {{data.TypeName}} Parse(string v, IFormatProvider? provider = null) { + ArgumentNullException.ThrowIfNull(v); if (TryParse(v.AsSpan(), provider, out var id)) { return id; @@ -113,13 +114,20 @@ public static bool IsValid(ReadOnlySpan v) [return: NotNullIfNotNull("id")] public static {{data.TypeName}}? ParseNullable(string? id) => id is string v ? Parse(v) : ({{data.TypeName}}?)null; - [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Guid parsing.")] - public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) - => TryParse(s.AsSpan(), provider, out result); - public static bool TryParse([NotNullWhen(true)] string? v, out {{data.TypeName}} id) => TryParse(v, null, out id); + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Guid parsing.")] + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) + { + if (s is null) + { + result = default; + return false; + } + return TryParse(s.AsSpan(), provider, out result); + } + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Guid parsing.")] public static {{data.TypeName}} Parse(ReadOnlySpan s, IFormatProvider? provider = null) { @@ -248,6 +256,7 @@ public static bool IsValid(ReadOnlySpan v) [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Ulid parsing.")] public static {{data.TypeName}} Parse(string v, IFormatProvider? provider = null) { + ArgumentNullException.ThrowIfNull(v); if (TryParse(v.AsSpan(), provider, out var result)) { return result; @@ -263,13 +272,20 @@ public static bool IsValid(ReadOnlySpan v) [return: NotNullIfNotNull("id")] public static {{data.TypeName}}? ParseNullable(string? id) => id is string v ? Parse(v) : ({{data.TypeName}}?)null; - [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Ulid parsing.")] - public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) - => TryParse(s.AsSpan(), provider, out result); - public static bool TryParse([NotNullWhen(true)] string? v, out {{data.TypeName}} id) => TryParse(v, null, out id); + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Ulid parsing.")] + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) + { + if (s is null) + { + result = default; + return false; + } + return TryParse(s.AsSpan(), provider, out result); + } + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Ulid parsing.")] public static {{data.TypeName}} Parse(ReadOnlySpan s, IFormatProvider? provider = null) { @@ -415,6 +431,7 @@ public static bool IsValid(ReadOnlySpan v) [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string format validation.")] public static {{data.TypeName}} Parse(string v, IFormatProvider? provider = null) { + ArgumentNullException.ThrowIfNull(v); if (TryParse(v.AsSpan(), provider, out var result)) { return result; @@ -430,13 +447,20 @@ public static bool IsValid(ReadOnlySpan v) [return: NotNullIfNotNull("id")] public static {{data.TypeName}}? ParseNullable(string? id) => id is string v ? Parse(v) : ({{data.TypeName}}?)null; - [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string format validation.")] - public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) - => TryParse(s.AsSpan(), provider, out result); - public static bool TryParse([NotNullWhen(true)] string? v, out {{data.TypeName}} id) => TryParse(v, null, out id); + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string format validation.")] + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) + { + if (s is null) + { + result = default; + return false; + } + return TryParse(s.AsSpan(), provider, out result); + } + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string format validation.")] public static {{data.TypeName}} Parse(ReadOnlySpan s, IFormatProvider? provider = null) { @@ -583,6 +607,7 @@ public static bool TryParse([NotNullWhen(true)] {{backingType}}? v, out {{data.T public static {{data.TypeName}} Parse(string s, IFormatProvider? provider = null) { + ArgumentNullException.ThrowIfNull(s); if (TryParse(s.AsSpan(), provider, out var result)) { return result; @@ -593,8 +618,18 @@ public static bool TryParse([NotNullWhen(true)] {{backingType}}? v, out {{data.T } } + public static bool TryParse([NotNullWhen(true)] string? v, out {{data.TypeName}} id) + => TryParse(v, null, out id); + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) - => TryParse(s.AsSpan(), provider, out result); + { + if (s is null) + { + result = default; + return false; + } + return TryParse(s.AsSpan(), provider, out result); + } public static {{data.TypeName}} Parse(ReadOnlySpan s, IFormatProvider? provider = null) { @@ -692,6 +727,7 @@ public static bool IsValid(ReadOnlySpan v) [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string length validation.")] public static {{data.TypeName}} Parse(string v, IFormatProvider? provider = null) { + ArgumentNullException.ThrowIfNull(v); if (TryParse(v.AsSpan(), provider, out var result)) { return result; @@ -706,13 +742,20 @@ public static bool IsValid(ReadOnlySpan v) [return: NotNullIfNotNull("id")] public static {{data.TypeName}}? ParseNullable(string? id) => id is string v ? Parse(v) : ({{data.TypeName}}?)null; - [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string length validation.")] - public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) - => TryParse(s.AsSpan(), provider, out result); - public static bool TryParse([NotNullWhen(true)] string? v, out {{data.TypeName}} id) => TryParse(v, null, out id); + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string length validation.")] + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) + { + if (s is null) + { + result = default; + return false; + } + return TryParse(s.AsSpan(), provider, out result); + } + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string length validation.")] public static {{data.TypeName}} Parse(ReadOnlySpan s, IFormatProvider? provider = null) { diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/GuidTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/GuidTests.cs index cce8dfea..c5c5c586 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/GuidTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/GuidTests.cs @@ -47,7 +47,7 @@ public void From_null_behaves_correctly() Assert.False(TestGuidId.IsValid(null)); Assert.Null(TestGuidId.ParseNullable(null)); - Assert.False(TestGuidId.TryParse(null, out var value)); + Assert.False(TestGuidId.TryParse((string?)null, out var value)); Assert.Equal(value, default); } @@ -211,4 +211,73 @@ public void TryFormatUtf8Byte_is_correct() buffer[..length].Should().BeEquivalentTo(expectedBytes); buffer[length..].Should().AllBeEquivalentTo(default(byte)); } + + [Fact] + public void Parse_ReadOnlySpan_works_correctly() + { + var guidString = Guid1.ToString(); + var span = guidString.AsSpan(); + var parsed = TestGuidId.Parse(span); + parsed.Value.Should().Be(Guid1); + } + + [Fact] + public void Parse_ReadOnlySpan_with_provider_works_correctly() + { + var guidString = Guid1.ToString(); + var span = guidString.AsSpan(); + var parsed = TestGuidId.Parse(span, null); + parsed.Value.Should().Be(Guid1); + } + + [Fact] + public void Parse_ReadOnlySpan_invalid_throws() + { + var invalidString = "invalid"; + Assert.Throws(() => TestGuidId.Parse(invalidString.AsSpan())); + } + + [Fact] + public void TryParse_ReadOnlySpan_works_correctly() + { + var guidString = Guid1.ToString(); + var span = guidString.AsSpan(); + var success = TestGuidId.TryParse(span, null, out var result); + success.Should().BeTrue(); + result.Value.Should().Be(Guid1); + } + + [Fact] + public void TryParse_ReadOnlySpan_invalid_returns_false() + { + var span = "invalid".AsSpan(); + var success = TestGuidId.TryParse(span, null, out var result); + success.Should().BeFalse(); + result.Should().Be(default(TestGuidId)); + } + + [Fact] + public void TryParse_string_with_provider_works_correctly() + { + var guidString = Guid1.ToString(); + var success = TestGuidId.TryParse(guidString, null, out var result); + success.Should().BeTrue(); + result.Value.Should().Be(Guid1); + } + + [Fact] + public void TryParse_string_with_provider_invalid_returns_false() + { + var success = TestGuidId.TryParse("invalid", null, out var result); + success.Should().BeFalse(); + result.Should().Be(default(TestGuidId)); + } + + [Fact] + public void TryParse_string_with_provider_null_returns_false() + { + var success = TestGuidId.TryParse((string?)null, null, out var result); + success.Should().BeFalse(); + result.Should().Be(default(TestGuidId)); + } } diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/IntTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/IntTests.cs index e897c57d..ef873d98 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/IntTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/IntTests.cs @@ -36,7 +36,7 @@ public void From_null_behaves_correctly() Assert.False(TestIntId.IsValid(null)); Assert.Null(TestIntId.ParseNullable(null)); - Assert.False(TestIntId.TryParse(null, out var value)); + Assert.False(TestIntId.TryParse((string?)null, out var value)); Assert.Equal(value, default); } @@ -191,4 +191,73 @@ public void TryFormatUtf8Byte_is_correct() buffer[..4].Should().BeEquivalentTo(expectedBuffer); buffer[4..].Should().AllBeEquivalentTo(default(byte)); } + + [Fact] + public void Parse_ReadOnlySpan_works_correctly() + { + var span = "1234".AsSpan(); + var parsed = TestIntId.Parse(span); + parsed.Value.Should().Be(1234); + } + + [Fact] + public void Parse_ReadOnlySpan_with_provider_works_correctly() + { + var span = "1234".AsSpan(); + var parsed = TestIntId.Parse(span, System.Globalization.CultureInfo.InvariantCulture); + parsed.Value.Should().Be(1234); + } + + [Fact] + public void Parse_ReadOnlySpan_invalid_throws() + { + var invalidString = "invalid"; + Assert.Throws(() => TestIntId.Parse(invalidString.AsSpan())); + } + + [Fact] + public void TryParse_ReadOnlySpan_works_correctly() + { + var span = "1234".AsSpan(); + var success = TestIntId.TryParse(span, System.Globalization.CultureInfo.InvariantCulture, out var result); + success.Should().BeTrue(); + result.Value.Should().Be(1234); + } + + [Fact] + public void TryParse_ReadOnlySpan_invalid_returns_false() + { + var span = "invalid".AsSpan(); + var success = TestIntId.TryParse(span, System.Globalization.CultureInfo.InvariantCulture, out var result); + success.Should().BeFalse(); + result.Should().Be(default(TestIntId)); + } + + [Fact] + public void TryParse_string_with_provider_works_correctly() + { + var success = TestIntId.TryParse("1234", System.Globalization.CultureInfo.InvariantCulture, out var result); + success.Should().BeTrue(); + result.Value.Should().Be(1234); + } + + [Fact] + public void TryParse_string_with_provider_invalid_returns_false() + { + var success = TestIntId.TryParse("invalid", System.Globalization.CultureInfo.InvariantCulture, out var result); + success.Should().BeFalse(); + result.Should().Be(default(TestIntId)); + } + + [Fact] + public void TryParse_string_with_provider_null_returns_false() + { + var success = TestIntId.TryParse( + (string?)null, + System.Globalization.CultureInfo.InvariantCulture, + out var result + ); + success.Should().BeFalse(); + result.Should().Be(default(TestIntId)); + } } diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/LongTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/LongTests.cs index 518eb470..43882cca 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/LongTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/LongTests.cs @@ -36,7 +36,7 @@ public void From_null_behaves_correctly() Assert.False(TestLongId.IsValid(null)); Assert.Null(TestLongId.ParseNullable(null)); - Assert.False(TestLongId.TryParse(null, out var value)); + Assert.False(TestLongId.TryParse((string?)null, out var value)); Assert.Equal(value, default); } @@ -191,4 +191,73 @@ public void TryFormatUtf8Byte_is_correct() buffer[..4].Should().BeEquivalentTo(expectedBuffer); buffer[4..].Should().AllBeEquivalentTo(default(byte)); } + + [Fact] + public void Parse_ReadOnlySpan_works_correctly() + { + var span = "1234".AsSpan(); + var parsed = TestLongId.Parse(span); + parsed.Value.Should().Be(1234L); + } + + [Fact] + public void Parse_ReadOnlySpan_with_provider_works_correctly() + { + var span = "1234".AsSpan(); + var parsed = TestLongId.Parse(span, System.Globalization.CultureInfo.InvariantCulture); + parsed.Value.Should().Be(1234L); + } + + [Fact] + public void Parse_ReadOnlySpan_invalid_throws() + { + var invalidString = "invalid"; + Assert.Throws(() => TestLongId.Parse(invalidString.AsSpan())); + } + + [Fact] + public void TryParse_ReadOnlySpan_works_correctly() + { + var span = "1234".AsSpan(); + var success = TestLongId.TryParse(span, System.Globalization.CultureInfo.InvariantCulture, out var result); + success.Should().BeTrue(); + result.Value.Should().Be(1234L); + } + + [Fact] + public void TryParse_ReadOnlySpan_invalid_returns_false() + { + var span = "invalid".AsSpan(); + var success = TestLongId.TryParse(span, System.Globalization.CultureInfo.InvariantCulture, out var result); + success.Should().BeFalse(); + result.Should().Be(default(TestLongId)); + } + + [Fact] + public void TryParse_string_with_provider_works_correctly() + { + var success = TestLongId.TryParse("1234", System.Globalization.CultureInfo.InvariantCulture, out var result); + success.Should().BeTrue(); + result.Value.Should().Be(1234L); + } + + [Fact] + public void TryParse_string_with_provider_invalid_returns_false() + { + var success = TestLongId.TryParse("invalid", System.Globalization.CultureInfo.InvariantCulture, out var result); + success.Should().BeFalse(); + result.Should().Be(default(TestLongId)); + } + + [Fact] + public void TryParse_string_with_provider_null_returns_false() + { + var success = TestLongId.TryParse( + (string?)null, + System.Globalization.CultureInfo.InvariantCulture, + out var result + ); + success.Should().BeFalse(); + result.Should().Be(default(TestLongId)); + } } diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedGuidTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedGuidTests.cs index 0f162947..7b1421bf 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedGuidTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedGuidTests.cs @@ -42,7 +42,7 @@ public void From_null_behaves_correctly() { Assert.False(TestPrefixedGuidId.IsValid(null)); - Assert.Throws(() => TestPrefixedGuidId.Parse(null!)); + Assert.Throws(() => TestPrefixedGuidId.Parse(null!)); Assert.Throws(() => TestPrefixedGuidId.ParseNullable("invalid")); Assert.False(TestPrefixedGuidId.TryParse(null, out var value)); Assert.Equal(value, default); @@ -272,4 +272,76 @@ public void TryFormatUtf8Byte_is_correct() buffer[..TestPrefixedGuidId.MaxLength].Should().BeEquivalentTo(expectedBytes); buffer[TestPrefixedGuidId.MaxLength..].Should().AllBeEquivalentTo(default(byte)); } + + [Fact] + public void Parse_ReadOnlySpan_works_correctly() + { + var span = TPG1.AsSpan(); + var parsed = TestPrefixedGuidId.Parse(span); + parsed.Value.Should().Be(TPG1); + } + + [Fact] + public void Parse_ReadOnlySpan_with_provider_works_correctly() + { + var span = TPG1.AsSpan(); + var parsed = TestPrefixedGuidId.Parse(span, null); + parsed.Value.Should().Be(TPG1); + } + + [Fact] + public void Parse_ReadOnlySpan_invalid_throws() + { + var invalidString = "invalid"; + Assert.Throws(() => TestPrefixedGuidId.Parse(invalidString.AsSpan())); + } + + [Fact] + public void TryParse_ReadOnlySpan_works_correctly() + { + var span = TPG1.AsSpan(); + var success = TestPrefixedGuidId.TryParse(span, null, out var result); + success.Should().BeTrue(); + result.Value.Should().Be(TPG1); + } + + [Fact] + public void TryParse_ReadOnlySpan_invalid_returns_false() + { + var span = "invalid".AsSpan(); + var success = TestPrefixedGuidId.TryParse(span, null, out var result); + success.Should().BeFalse(); + result.Should().Be(default(TestPrefixedGuidId)); + } + + [Fact] + public void IsValid_ReadOnlySpan_works_correctly() + { + TestPrefixedGuidId.IsValid(TPG1.AsSpan()).Should().BeTrue(); + TestPrefixedGuidId.IsValid("invalid".AsSpan()).Should().BeFalse(); + } + + [Fact] + public void TryParse_string_with_provider_works_correctly() + { + var success = TestPrefixedGuidId.TryParse(TPG1, null, out var result); + success.Should().BeTrue(); + result.Value.Should().Be(TPG1); + } + + [Fact] + public void TryParse_string_with_provider_invalid_returns_false() + { + var success = TestPrefixedGuidId.TryParse("invalid", null, out var result); + success.Should().BeFalse(); + result.Should().Be(default(TestPrefixedGuidId)); + } + + [Fact] + public void TryParse_string_with_provider_null_returns_false() + { + var success = TestPrefixedGuidId.TryParse((string?)null, null, out var result); + success.Should().BeFalse(); + result.Should().Be(default(TestPrefixedGuidId)); + } } diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs index d3f32991..fb943ee1 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs @@ -41,7 +41,7 @@ public void From_null_behaves_correctly() { Assert.False(TestPrefixedStringId.IsValid(null)); - Assert.Throws(() => TestPrefixedStringId.Parse(null!)); + Assert.Throws(() => TestPrefixedStringId.Parse(null!)); Assert.Throws(() => TestPrefixedStringId.ParseNullable("invalid")); Assert.False(TestPrefixedStringId.TryParse(null, out var value)); Assert.Equal(value, default); @@ -319,4 +319,76 @@ public void FromValuePart_validates_max_length() // Exceeds limit - should throw Assert.Throws(() => TestPrefixedStringId.FromValuePart("12345678901")); } + + [Fact] + public void Parse_ReadOnlySpan_works_correctly() + { + var span = TPS1.AsSpan(); + var parsed = TestPrefixedStringId.Parse(span); + parsed.Value.Should().Be(TPS1); + } + + [Fact] + public void Parse_ReadOnlySpan_with_provider_works_correctly() + { + var span = TPS1.AsSpan(); + var parsed = TestPrefixedStringId.Parse(span, null); + parsed.Value.Should().Be(TPS1); + } + + [Fact] + public void Parse_ReadOnlySpan_invalid_throws() + { + var invalidString = "invalid"; + Assert.Throws(() => TestPrefixedStringId.Parse(invalidString.AsSpan())); + } + + [Fact] + public void TryParse_ReadOnlySpan_works_correctly() + { + var span = TPS1.AsSpan(); + var success = TestPrefixedStringId.TryParse(span, null, out var result); + success.Should().BeTrue(); + result.Value.Should().Be(TPS1); + } + + [Fact] + public void TryParse_ReadOnlySpan_invalid_returns_false() + { + var span = "invalid".AsSpan(); + var success = TestPrefixedStringId.TryParse(span, null, out var result); + success.Should().BeFalse(); + result.Should().Be(default(TestPrefixedStringId)); + } + + [Fact] + public void IsValid_ReadOnlySpan_works_correctly() + { + TestPrefixedStringId.IsValid(TPS1.AsSpan()).Should().BeTrue(); + TestPrefixedStringId.IsValid("invalid".AsSpan()).Should().BeFalse(); + } + + [Fact] + public void TryParse_string_with_provider_works_correctly() + { + var success = TestPrefixedStringId.TryParse(TPS1, null, out var result); + success.Should().BeTrue(); + result.Value.Should().Be(TPS1); + } + + [Fact] + public void TryParse_string_with_provider_invalid_returns_false() + { + var success = TestPrefixedStringId.TryParse("invalid", null, out var result); + success.Should().BeFalse(); + result.Should().Be(default(TestPrefixedStringId)); + } + + [Fact] + public void TryParse_string_with_provider_null_returns_false() + { + var success = TestPrefixedStringId.TryParse((string?)null, null, out var result); + success.Should().BeFalse(); + result.Should().Be(default(TestPrefixedStringId)); + } } diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedUlidTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedUlidTests.cs index 402a048e..3813716b 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedUlidTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedUlidTests.cs @@ -46,7 +46,7 @@ public void From_null_behaves_correctly() var parseNull = () => TestPrefixedUlidId.Parse(null!); var parseInvalid = () => TestPrefixedUlidId.ParseNullable("invalid"); - parseNull.Should().Throw(); + parseNull.Should().Throw(); parseInvalid.Should().Throw(); TestPrefixedUlidId.TryParse(null, out var value).Should().BeFalse(); @@ -300,4 +300,76 @@ public void TryFormatUtf8Byte_is_correct() buffer[..TestPrefixedUlidId.MaxLength].Should().BeEquivalentTo(expectedBytes); buffer[TestPrefixedUlidId.MaxLength..].Should().AllBeEquivalentTo(default(byte)); } + + [Fact] + public void Parse_ReadOnlySpan_works_correctly() + { + var span = TPU1.AsSpan(); + var parsed = TestPrefixedUlidId.Parse(span); + parsed.Value.Should().Be(TPU1); + } + + [Fact] + public void Parse_ReadOnlySpan_with_provider_works_correctly() + { + var span = TPU1.AsSpan(); + var parsed = TestPrefixedUlidId.Parse(span, null); + parsed.Value.Should().Be(TPU1); + } + + [Fact] + public void Parse_ReadOnlySpan_invalid_throws() + { + var invalidString = "invalid"; + Assert.Throws(() => TestPrefixedUlidId.Parse(invalidString.AsSpan())); + } + + [Fact] + public void TryParse_ReadOnlySpan_works_correctly() + { + var span = TPU1.AsSpan(); + var success = TestPrefixedUlidId.TryParse(span, null, out var result); + success.Should().BeTrue(); + result.Value.Should().Be(TPU1); + } + + [Fact] + public void TryParse_ReadOnlySpan_invalid_returns_false() + { + var span = "invalid".AsSpan(); + var success = TestPrefixedUlidId.TryParse(span, null, out var result); + success.Should().BeFalse(); + result.Should().Be(default(TestPrefixedUlidId)); + } + + [Fact] + public void IsValid_ReadOnlySpan_works_correctly() + { + TestPrefixedUlidId.IsValid(TPU1.AsSpan()).Should().BeTrue(); + TestPrefixedUlidId.IsValid("invalid".AsSpan()).Should().BeFalse(); + } + + [Fact] + public void TryParse_string_with_provider_works_correctly() + { + var success = TestPrefixedUlidId.TryParse(TPU1, null, out var result); + success.Should().BeTrue(); + result.Value.Should().Be(TPU1); + } + + [Fact] + public void TryParse_string_with_provider_invalid_returns_false() + { + var success = TestPrefixedUlidId.TryParse("invalid", null, out var result); + success.Should().BeFalse(); + result.Should().Be(default(TestPrefixedUlidId)); + } + + [Fact] + public void TryParse_string_with_provider_null_returns_false() + { + var success = TestPrefixedUlidId.TryParse((string?)null, null, out var result); + success.Should().BeFalse(); + result.Should().Be(default(TestPrefixedUlidId)); + } } diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs index 8411f445..5197cd09 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs @@ -46,7 +46,7 @@ public void From_null_behaves_correctly() { Assert.False(TestRawStringId.IsValid(null)); - Assert.Throws(() => TestRawStringId.Parse(null!)); + Assert.Throws(() => TestRawStringId.Parse(null!)); Assert.Null(TestRawStringId.ParseNullable(null)); Assert.False(TestRawStringId.TryParse(null, out var value)); Assert.Equal(value, default); @@ -246,4 +246,76 @@ public void Empty_string_is_valid_with_max_length() { Assert.True(TestRawStringId.IsValid(string.Empty)); } + + [Fact] + public void Parse_ReadOnlySpan_works_correctly() + { + var span = String1.AsSpan(); + var parsed = TestRawStringId.Parse(span); + parsed.Value.Should().Be(String1); + } + + [Fact] + public void Parse_ReadOnlySpan_with_provider_works_correctly() + { + var span = String1.AsSpan(); + var parsed = TestRawStringId.Parse(span, null); + parsed.Value.Should().Be(String1); + } + + [Fact] + public void Parse_ReadOnlySpan_invalid_throws() + { + var invalidString = "12345678901"; // exceeds max length + Assert.Throws(() => TestRawStringId.Parse(invalidString.AsSpan())); + } + + [Fact] + public void TryParse_ReadOnlySpan_works_correctly() + { + var span = String1.AsSpan(); + var success = TestRawStringId.TryParse(span, null, out var result); + success.Should().BeTrue(); + result.Value.Should().Be(String1); + } + + [Fact] + public void TryParse_ReadOnlySpan_invalid_returns_false() + { + var span = "12345678901".AsSpan(); // exceeds max length + var success = TestRawStringId.TryParse(span, null, out var result); + success.Should().BeFalse(); + result.Should().Be(default(TestRawStringId)); + } + + [Fact] + public void IsValid_ReadOnlySpan_works_correctly() + { + TestRawStringId.IsValid(String1.AsSpan()).Should().BeTrue(); + TestRawStringId.IsValid("12345678901".AsSpan()).Should().BeFalse(); // exceeds max length + } + + [Fact] + public void TryParse_string_with_provider_works_correctly() + { + var success = TestRawStringId.TryParse(String1, null, out var result); + success.Should().BeTrue(); + result.Value.Should().Be(String1); + } + + [Fact] + public void TryParse_string_with_provider_invalid_returns_false() + { + var success = TestRawStringId.TryParse("12345678901", null, out var result); // exceeds max length + success.Should().BeFalse(); + result.Should().Be(default(TestRawStringId)); + } + + [Fact] + public void TryParse_string_with_provider_null_returns_false() + { + var success = TestRawStringId.TryParse((string?)null, null, out var result); + success.Should().BeFalse(); + result.Should().Be(default(TestRawStringId)); + } } From a011a5dd9f73913ffd8c5ede47586068de1cddd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Tue, 23 Dec 2025 12:50:34 +0100 Subject: [PATCH 5/9] Split ID source builders into files by various formats --- .../IdSource.cs | 837 ------------------ .../SourceBuilders/IdSource.cs | 55 ++ .../PrefixedGuidIdSourceBuilder.cs | 162 ++++ .../PrefixedStringIdSourceBuilder.cs | 169 ++++ .../PrefixedUlidIdSourceBuilder.cs | 167 ++++ .../SourceBuilders/RawIdSourceBuilder.cs | 148 ++++ .../RawStringIdSourceBuilder.cs | 142 +++ .../TypedIdGenerator.cs | 1 + 8 files changed, 844 insertions(+), 837 deletions(-) delete mode 100644 src/Domain/LeanCode.DomainModels.Generators/IdSource.cs create mode 100644 src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/IdSource.cs create mode 100644 src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/PrefixedGuidIdSourceBuilder.cs create mode 100644 src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/PrefixedStringIdSourceBuilder.cs create mode 100644 src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/PrefixedUlidIdSourceBuilder.cs create mode 100644 src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/RawIdSourceBuilder.cs create mode 100644 src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/RawStringIdSourceBuilder.cs diff --git a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs deleted file mode 100644 index c6aecff0..00000000 --- a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs +++ /dev/null @@ -1,837 +0,0 @@ -namespace LeanCode.DomainModels.Generators; - -internal static class IdSource -{ - public static string Build(TypedIdData data) - { - return data.Format switch - { - 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.CreateVersion7()", - "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) - { - var prefix = data.CustomPrefix?.ToLowerInvariant() ?? GetDefaultPrefix(data.TypeName); - var valueLength = 32; - - var randomFactory = !data.SkipRandomGenerator - ? $"public static {data.TypeName} New() => new(Guid.CreateVersion7());" - : ""; - - // language=C# - 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(StringTypedIdConverter<{{data.TypeName}}>))] - [DebuggerDisplay("{Value}")] - [ExcludeFromCodeCoverage] - 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 MaxLength { get; } = {{valueLength + 1 + prefix.Length}}; - public static {{data.TypeName}} Empty { get; } = new(Guid.Empty); - - private readonly string? value; - - public string Value => value ?? Empty.Value; - public bool IsEmpty => value is null || value == Empty; - public Guid Guid => value is null ? Guid.Empty : Guid.ParseExact(value.AsSpan()[{{prefix.Length + 1}}..], "N"); - - private {{data.TypeName}}(string v) => value = v; - public {{data.TypeName}}(Guid v) => value = string.Create(null, stackalloc char[MaxLength], $"{TypePrefix}{Separator}{v:N}"); - {{randomFactory}} - - public static bool IsValid([NotNullWhen(true)] string? v) - => v is not null && IsValid(v.AsSpan()); - - public static bool IsValid(ReadOnlySpan v) - => TryParse(v, null, out _); - - [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Guid parsing.")] - public static {{data.TypeName}} Parse(string v, IFormatProvider? provider = null) - { - ArgumentNullException.ThrowIfNull(v); - if (TryParse(v.AsSpan(), provider, out var id)) - { - return id; - } - else - { - throw new FormatException( - $"The ID has invalid format. It should look like {TypePrefix}{Separator}(id 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) - => TryParse(v, null, out id); - - [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Guid parsing.")] - public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) - { - if (s is null) - { - result = default; - return false; - } - return TryParse(s.AsSpan(), provider, out result); - } - - [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Guid parsing.")] - public static {{data.TypeName}} Parse(ReadOnlySpan s, IFormatProvider? provider = null) - { - if (TryParse(s, provider, out var result)) - { - return result; - } - else - { - throw new FormatException( - $"The ID has invalid format. It should look like {TypePrefix}{Separator}(id value)." - ); - } - } - - [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Guid parsing.")] - public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out {{data.TypeName}} result) - { - if (s.Length == MaxLength - && s.StartsWith(TypePrefix) - && s[{{prefix.Length}}] == Separator - && Guid.TryParseExact(s[{{prefix.Length + 1}}..], "N", out var guid)) - { - result = new {{data.TypeName}}(guid); - return true; - } - else - { - result = default; - return false; - } - } - - public (string prefix, Guid data) Destructure() => (TypePrefix, Guid); - - 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> 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; - 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 BuildPrefixedUlid(TypedIdData data) - { - var prefix = data.CustomPrefix?.ToLowerInvariant() ?? GetDefaultPrefix(data.TypeName); - var valueLength = 26; - - // language=C# - 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; - using global::LeanCode.DomainModels.Ulids; - #pragma warning restore CS8019 - - [JsonConverter(typeof(StringTypedIdConverter<{{data.TypeName}}>))] - [DebuggerDisplay("{Value}")] - [ExcludeFromCodeCoverage] - 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 MaxLength { get; } = {{valueLength + 1 + prefix.Length}}; - public static {{data.TypeName}} Empty { get; } = new(Ulid.Empty); - - private readonly string? value; - - public string Value => value ?? Empty.Value; - public bool IsEmpty => value is null || value == Empty; - public Ulid Ulid => value is null ? Ulid.Empty : Ulid.Parse(value.AsSpan()[{{prefix.Length + 1}}..]); - - private {{data.TypeName}}(string v) => value = v; - - public {{data.TypeName}}(Ulid v) => value = string.Create(null, stackalloc char[MaxLength], $"{TypePrefix}{Separator}{v}"); - - public static {{data.TypeName}} New() => new(Ulid.NewUlid()); - - public static bool IsValid([NotNullWhen(true)] string? v) - => v is not null && IsValid(v.AsSpan()); - - public static bool IsValid(ReadOnlySpan v) - => TryParse(v, null, out _); - - [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Ulid parsing.")] - public static {{data.TypeName}} Parse(string v, IFormatProvider? provider = null) - { - ArgumentNullException.ThrowIfNull(v); - if (TryParse(v.AsSpan(), provider, out var result)) - { - return result; - } - else - { - throw new FormatException( - $"The ID has invalid format. It should look like {TypePrefix}{Separator}(id 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) - => TryParse(v, null, out id); - - [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Ulid parsing.")] - public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) - { - if (s is null) - { - result = default; - return false; - } - return TryParse(s.AsSpan(), provider, out result); - } - - [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Ulid parsing.")] - public static {{data.TypeName}} Parse(ReadOnlySpan s, IFormatProvider? provider = null) - { - if (TryParse(s, provider, out var result)) - { - return result; - } - else - { - throw new FormatException( - $"The ID has invalid format. It should look like {TypePrefix}{Separator}(id value)." - ); - } - } - - [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Ulid parsing.")] - public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out {{data.TypeName}} result) - { - if (TryDeconstruct(s, out var ulid)) - { - result = new {{data.TypeName}}(ulid); - return true; - } - else - { - result = default; - return false; - } - } - - public static bool TryDeconstruct(ReadOnlySpan span, out Ulid rawUlid) - { - rawUlid = Ulid.Empty; - - return span.Length == MaxLength - && span.StartsWith(TypePrefix) - && span[{{prefix.Length}}] == Separator - && Ulid.TryParse(span[{{prefix.Length + 1}}..], out rawUlid); - } - - public (string prefix, Ulid data) Destructure() => (TypePrefix, Ulid); - - 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> 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; - 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 BuildPrefixedString(TypedIdData data) - { - var prefix = data.CustomPrefix?.ToLowerInvariant() ?? GetDefaultPrefix(data.TypeName); - var maxValueLength = data.MaxValueLength!.Value; - - // language=C# - 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(StringTypedIdConverter<{{data.TypeName}}>))] - [DebuggerDisplay("{Value}")] - [ExcludeFromCodeCoverage] - 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); - 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; - public bool IsEmpty => string.IsNullOrEmpty(value); - public string ValuePart => Value[{{prefix.Length + 1}}..]; - - private {{data.TypeName}}(string v) => value = v; - - public static {{data.TypeName}} FromValuePart(string valuePart) - { - if (valuePart is null) - { - throw new ArgumentNullException(nameof(valuePart)); - } - if (valuePart.Length > MaxValueLength) - { - throw new ArgumentException( - $"The value part exceeds maximum length of {MaxValueLength}.", - nameof(valuePart)); - } - return new {{data.TypeName}}($"{TypePrefix}{Separator}{valuePart}"); - } - - public static bool IsValid([NotNullWhen(true)] string? v) - => v is not null && IsValid(v.AsSpan()); - - public static bool IsValid(ReadOnlySpan v) - => TryParse(v, null, out _); - - [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string format validation.")] - public static {{data.TypeName}} Parse(string v, IFormatProvider? provider = null) - { - ArgumentNullException.ThrowIfNull(v); - if (TryParse(v.AsSpan(), provider, out var result)) - { - return result; - } - 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) - => TryParse(v, null, out id); - - [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string format validation.")] - public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) - { - if (s is null) - { - result = default; - return false; - } - return TryParse(s.AsSpan(), provider, out result); - } - - [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string format validation.")] - public static {{data.TypeName}} Parse(ReadOnlySpan s, IFormatProvider? provider = null) - { - if (TryParse(s, provider, out var result)) - { - return result; - } - else - { - throw new FormatException( - $"The ID has invalid format. It should look like {TypePrefix}{Separator}(value)." - ); - } - } - - [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string format validation.")] - public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out {{data.TypeName}} result) - { - if (s.Length >= {{prefix.Length + 1}} - && s.Length <= MaxLength - && s.StartsWith(TypePrefix) - && s[{{prefix.Length}}] == Separator) - { - result = new {{data.TypeName}}(s.ToString()); - return true; - } - else - { - result = default; - return false; - } - } - - public (string prefix, string data) Destructure() => (TypePrefix, ValuePart); - - 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> 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; - 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, - string converterPrefix, - string? randomValueGenerator, - string defaultValue, - string toStringParam, - string tryFormatParams - ) - { - var randomFactory = - !data.SkipRandomGenerator && randomValueGenerator is not null - ? $"public static {data.TypeName} New() => new({randomValueGenerator});" - : ""; - - // language=C# - 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.Globalization; - using global::System.Linq.Expressions; - using global::System.Text.Json.Serialization; - using global::LeanCode.DomainModels.Ids; - #pragma warning restore CS8019 - - [JsonConverter(typeof({{converterPrefix}}TypedIdConverter<{{data.TypeName}}>))] - [DebuggerDisplay("{Value}")] - [ExcludeFromCodeCoverage] - public readonly partial record struct {{data.TypeName}} : IRawTypedId<{{backingType}}, {{data.TypeName}}> - { - public static {{data.TypeName}} Empty { get; } = new({{defaultValue}}); - - public {{backingType}} Value {get;} - public bool IsEmpty => Value == Empty; - - public {{data.TypeName}}({{backingType}} v) => Value = v; - {{randomFactory}} - - public static bool IsValid([NotNullWhen(true)] {{backingType}}? v) - { - return v is not null; - } - - public static {{data.TypeName}} Parse({{backingType}} v) - { - return new {{data.TypeName}}(v); - } - - [return: NotNullIfNotNull("id")] - public static {{data.TypeName}}? ParseNullable({{backingType}}? id) => id is {{backingType}} v ? Parse(v) : ({{data.TypeName}}?)null; - - public static bool TryParse([NotNullWhen(true)] {{backingType}}? v, out {{data.TypeName}} id) - { - if (IsValid(v)) - { - id = new {{data.TypeName}}(v.Value); - return true; - } - else - { - id = default; - return false; - } - } - - public static {{data.TypeName}} Parse(string s, IFormatProvider? provider = null) - { - ArgumentNullException.ThrowIfNull(s); - if (TryParse(s.AsSpan(), provider, out var result)) - { - return result; - } - else - { - throw new FormatException($"Unable to parse '{s}' as {{data.TypeName}}."); - } - } - - public static bool TryParse([NotNullWhen(true)] string? v, out {{data.TypeName}} id) - => TryParse(v, null, out id); - - public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) - { - if (s is null) - { - result = default; - return false; - } - return TryParse(s.AsSpan(), provider, out result); - } - - public static {{data.TypeName}} Parse(ReadOnlySpan s, IFormatProvider? provider = null) - { - if (TryParse(s, provider, out var result)) - { - return result; - } - else - { - throw new FormatException($"Unable to parse the span as {{data.TypeName}}."); - } - } - - public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out {{data.TypeName}} result) - { - if ({{backingType}}.TryParse(s, provider, out var v)) - { - result = new {{data.TypeName}}(v); - return true; - } - else - { - result = default; - return false; - } - } - - public bool Equals({{data.TypeName}} other) => Value == other.Value; - public int CompareTo({{data.TypeName}} other) => Value.CompareTo(other.Value); - public override int GetHashCode() => Value.GetHashCode(); - public static implicit operator {{backingType}}({{data.TypeName}} id) => id.Value; - - public static bool operator <({{data.TypeName}} a, {{data.TypeName}} b) => a.Value < b.Value; - public static bool operator <=({{data.TypeName}} a, {{data.TypeName}} b) => a.Value <= b.Value; - public static bool operator >({{data.TypeName}} a, {{data.TypeName}} b) => a.Value > b.Value; - public static bool operator >=({{data.TypeName}} a, {{data.TypeName}} b) => a.Value >= b.Value; - - static Expression> IRawTypedId<{{backingType}}, {{data.TypeName}}>.FromDatabase { get; } = d => Parse(d); - static Expression> IRawTypedId<{{backingType}}, {{data.TypeName}}>.DatabaseEquals { get; } = (a, b) => a == b; - - public override string ToString() => Value.ToString({{toStringParam}}); - public string ToString(string? format, IFormatProvider? formatProvider) => ToString(); - public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) - => Value.TryFormat(destination, out charsWritten, {{tryFormatParams}}); - - public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) - => Value.TryFormat(utf8Destination, out bytesWritten, {{tryFormatParams}}); - } -} -"""; - } - - private static string BuildRawString(TypedIdData data) - { - var maxValueLength = data.MaxValueLength!.Value; - - // language=C# - 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 {{data.TypeName}} Empty { get; } = new(string.Empty); - public static int MaxLength { get; } = {{maxValueLength}}; - - 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 bool IsValid([NotNullWhen(true)] string? v) - => v is not null && IsValid(v.AsSpan()); - - public static bool IsValid(ReadOnlySpan v) - => TryParse(v, null, out _); - - [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string length validation.")] - public static {{data.TypeName}} Parse(string v, IFormatProvider? provider = null) - { - ArgumentNullException.ThrowIfNull(v); - if (TryParse(v.AsSpan(), provider, out var result)) - { - return result; - } - else - { - throw new FormatException( - $"The ID has invalid format. It must be a non-null string with maximum length of {MaxLength}."); - } - } - - [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) - => TryParse(v, null, out id); - - [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string length validation.")] - public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) - { - if (s is null) - { - result = default; - return false; - } - return TryParse(s.AsSpan(), provider, out result); - } - - [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string length validation.")] - public static {{data.TypeName}} Parse(ReadOnlySpan s, IFormatProvider? provider = null) - { - if (TryParse(s, provider, out var result)) - { - return result; - } - else - { - throw new FormatException( - $"The ID has invalid format. It must be a non-null string with maximum length of {MaxLength}." - ); - } - } - - [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string length validation.")] - public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out {{data.TypeName}} result) - { - if (s.Length <= MaxLength) - { - result = new {{data.TypeName}}(s.ToString()); - return true; - } - else - { - result = default; - return false; - } - } - - 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(); - - if (typeName.EndsWith("id", StringComparison.OrdinalIgnoreCase)) - { - typeName = typeName.Substring(0, typeName.Length - 2); - } - - return typeName; - } -} diff --git a/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/IdSource.cs b/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/IdSource.cs new file mode 100644 index 00000000..98794da9 --- /dev/null +++ b/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/IdSource.cs @@ -0,0 +1,55 @@ +namespace LeanCode.DomainModels.Generators.SourceBuilders; + +internal static class IdSource +{ + public static string Build(TypedIdData data) + { + return data.Format switch + { + TypedIdFormat.RawInt => RawIdSourceBuilder.Build( + data, + "int", + "Int", + null, + "0", + "CultureInfo.InvariantCulture", + "string.Empty, CultureInfo.InvariantCulture" + ), + TypedIdFormat.RawLong => RawIdSourceBuilder.Build( + data, + "long", + "Long", + null, + "0", + "CultureInfo.InvariantCulture", + "string.Empty, CultureInfo.InvariantCulture" + ), + TypedIdFormat.RawGuid => RawIdSourceBuilder.Build( + data, + "Guid", + "Guid", + "Guid.CreateVersion7()", + "Guid.Empty", + "", + "string.Empty" + ), + TypedIdFormat.RawString => RawStringIdSourceBuilder.Build(data), + TypedIdFormat.PrefixedGuid => PrefixedGuidIdSourceBuilder.Build(data), + TypedIdFormat.PrefixedUlid => PrefixedUlidIdSourceBuilder.Build(data), + TypedIdFormat.PrefixedString => PrefixedStringIdSourceBuilder.Build(data), + _ => throw new ArgumentException("Unsupported ID format."), + }; + } + + public static string GetDefaultPrefix(string typeName) + { + typeName = typeName.ToLowerInvariant(); + + if (typeName.EndsWith("id", StringComparison.OrdinalIgnoreCase)) + { + typeName = typeName.Substring(0, typeName.Length - 2); + } + + return typeName; + } +} diff --git a/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/PrefixedGuidIdSourceBuilder.cs b/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/PrefixedGuidIdSourceBuilder.cs new file mode 100644 index 00000000..b2c0ddcf --- /dev/null +++ b/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/PrefixedGuidIdSourceBuilder.cs @@ -0,0 +1,162 @@ +namespace LeanCode.DomainModels.Generators.SourceBuilders; + +internal static class PrefixedGuidIdSourceBuilder +{ + private const int ValueLength = 32; + + public static string Build(TypedIdData data) + { + var prefix = data.CustomPrefix?.ToLowerInvariant() ?? IdSource.GetDefaultPrefix(data.TypeName); + + var randomFactory = !data.SkipRandomGenerator + ? $"public static {data.TypeName} New() => new(Guid.CreateVersion7());" + : ""; + + // language=C# + 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(StringTypedIdConverter<{{data.TypeName}}>))] + [DebuggerDisplay("{Value}")] + [ExcludeFromCodeCoverage] + 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 MaxLength { get; } = {{ValueLength + 1 + prefix.Length}}; + public static {{data.TypeName}} Empty { get; } = new(Guid.Empty); + + private readonly string? value; + + public string Value => value ?? Empty.Value; + public bool IsEmpty => value is null || value == Empty; + public Guid Guid => value is null ? Guid.Empty : Guid.ParseExact(value.AsSpan()[{{prefix.Length + 1}}..], "N"); + + private {{data.TypeName}}(string v) => value = v; + + public {{data.TypeName}}(Guid v) => value = string.Create(null, stackalloc char[MaxLength], $"{TypePrefix}{Separator}{v:N}"); + {{randomFactory}} + + public static bool IsValid([NotNullWhen(true)] string? v) => v is not null && IsValid(v.AsSpan()); + public static bool IsValid(ReadOnlySpan v) => TryParse(v, null, out _); + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Guid parsing.")] + public static {{data.TypeName}} Parse(string v, IFormatProvider? provider = null) + { + ArgumentNullException.ThrowIfNull(v); + if (TryParse(v.AsSpan(), provider, out var id)) + { + return id; + } + else + { + throw new FormatException( + $"The ID has invalid format. It should look like {TypePrefix}{Separator}(id 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) + => TryParse(v, null, out id); + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Guid parsing.")] + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) + { + if (s is null) + { + result = default; + return false; + } + return TryParse(s.AsSpan(), provider, out result); + } + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Guid parsing.")] + public static {{data.TypeName}} Parse(ReadOnlySpan s, IFormatProvider? provider = null) + { + if (TryParse(s, provider, out var result)) + { + return result; + } + else + { + throw new FormatException( + $"The ID has invalid format. It should look like {TypePrefix}{Separator}(id value)." + ); + } + } + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Guid parsing.")] + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out {{data.TypeName}} result) + { + if (s.Length == MaxLength + && s.StartsWith(TypePrefix) + && s[{{prefix.Length}}] == Separator + && Guid.TryParseExact(s[{{prefix.Length + 1}}..], "N", out var guid)) + { + result = new {{data.TypeName}}(guid); + return true; + } + else + { + result = default; + return false; + } + } + + public (string prefix, Guid data) Destructure() => (TypePrefix, Guid); + + 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> 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; + 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); + } +} +"""; + } +} diff --git a/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/PrefixedStringIdSourceBuilder.cs b/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/PrefixedStringIdSourceBuilder.cs new file mode 100644 index 00000000..06c66cd2 --- /dev/null +++ b/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/PrefixedStringIdSourceBuilder.cs @@ -0,0 +1,169 @@ +namespace LeanCode.DomainModels.Generators.SourceBuilders; + +internal static class PrefixedStringIdSourceBuilder +{ + public static string Build(TypedIdData data) + { + var prefix = data.CustomPrefix?.ToLowerInvariant() ?? IdSource.GetDefaultPrefix(data.TypeName); + var maxValueLength = data.MaxValueLength!.Value; + + // language=C# + 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(StringTypedIdConverter<{{data.TypeName}}>))] + [DebuggerDisplay("{Value}")] + [ExcludeFromCodeCoverage] + 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); + 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; + public bool IsEmpty => string.IsNullOrEmpty(value); + public string ValuePart => Value[{{prefix.Length + 1}}..]; + + private {{data.TypeName}}(string v) => value = v; + + public static {{data.TypeName}} FromValuePart(string valuePart) + { + if (valuePart is null) + { + throw new ArgumentNullException(nameof(valuePart)); + } + if (valuePart.Length > MaxValueLength) + { + throw new ArgumentException( + $"The value part exceeds maximum length of {MaxValueLength}.", + nameof(valuePart)); + } + return new {{data.TypeName}}($"{TypePrefix}{Separator}{valuePart}"); + } + + public static bool IsValid([NotNullWhen(true)] string? v) => v is not null && IsValid(v.AsSpan()); + public static bool IsValid(ReadOnlySpan v) => TryParse(v, null, out _); + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string format validation.")] + public static {{data.TypeName}} Parse(string v, IFormatProvider? provider = null) + { + ArgumentNullException.ThrowIfNull(v); + if (TryParse(v.AsSpan(), provider, out var result)) + { + return result; + } + 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) + => TryParse(v, null, out id); + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string format validation.")] + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) + { + if (s is null) + { + result = default; + return false; + } + return TryParse(s.AsSpan(), provider, out result); + } + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string format validation.")] + public static {{data.TypeName}} Parse(ReadOnlySpan s, IFormatProvider? provider = null) + { + if (TryParse(s, provider, out var result)) + { + return result; + } + else + { + throw new FormatException( + $"The ID has invalid format. It should look like {TypePrefix}{Separator}(value)." + ); + } + } + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string format validation.")] + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out {{data.TypeName}} result) + { + if (s.Length >= {{prefix.Length + 1}} + && s.Length <= MaxLength + && s.StartsWith(TypePrefix) + && s[{{prefix.Length}}] == Separator) + { + result = new {{data.TypeName}}(s.ToString()); + return true; + } + else + { + result = default; + return false; + } + } + + public (string prefix, string data) Destructure() => (TypePrefix, ValuePart); + + 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> 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; + 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); + } +} +"""; + } +} diff --git a/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/PrefixedUlidIdSourceBuilder.cs b/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/PrefixedUlidIdSourceBuilder.cs new file mode 100644 index 00000000..97f1cad2 --- /dev/null +++ b/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/PrefixedUlidIdSourceBuilder.cs @@ -0,0 +1,167 @@ +namespace LeanCode.DomainModels.Generators.SourceBuilders; + +internal static class PrefixedUlidIdSourceBuilder +{ + private const int ValueLength = 26; + + public static string Build(TypedIdData data) + { + var prefix = data.CustomPrefix?.ToLowerInvariant() ?? IdSource.GetDefaultPrefix(data.TypeName); + + // language=C# + 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; + using global::LeanCode.DomainModels.Ulids; + #pragma warning restore CS8019 + + [JsonConverter(typeof(StringTypedIdConverter<{{data.TypeName}}>))] + [DebuggerDisplay("{Value}")] + [ExcludeFromCodeCoverage] + 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 MaxLength { get; } = {{ValueLength + 1 + prefix.Length}}; + public static {{data.TypeName}} Empty { get; } = new(Ulid.Empty); + + private readonly string? value; + + public string Value => value ?? Empty.Value; + public bool IsEmpty => value is null || value == Empty; + public Ulid Ulid => value is null ? Ulid.Empty : Ulid.Parse(value.AsSpan()[{{prefix.Length + 1}}..]); + + private {{data.TypeName}}(string v) => value = v; + + public {{data.TypeName}}(Ulid v) => value = string.Create(null, stackalloc char[MaxLength], $"{TypePrefix}{Separator}{v}"); + + public static {{data.TypeName}} New() => new(Ulid.NewUlid()); + + public static bool IsValid([NotNullWhen(true)] string? v) => v is not null && IsValid(v.AsSpan()); + public static bool IsValid(ReadOnlySpan v) => TryParse(v, null, out _); + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Ulid parsing.")] + public static {{data.TypeName}} Parse(string v, IFormatProvider? provider = null) + { + ArgumentNullException.ThrowIfNull(v); + if (TryParse(v.AsSpan(), provider, out var result)) + { + return result; + } + else + { + throw new FormatException( + $"The ID has invalid format. It should look like {TypePrefix}{Separator}(id 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) + => TryParse(v, null, out id); + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Ulid parsing.")] + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) + { + if (s is null) + { + result = default; + return false; + } + return TryParse(s.AsSpan(), provider, out result); + } + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Ulid parsing.")] + public static {{data.TypeName}} Parse(ReadOnlySpan s, IFormatProvider? provider = null) + { + if (TryParse(s, provider, out var result)) + { + return result; + } + else + { + throw new FormatException( + $"The ID has invalid format. It should look like {TypePrefix}{Separator}(id value)." + ); + } + } + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for Ulid parsing.")] + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out {{data.TypeName}} result) + { + if (TryDeconstruct(s, out var ulid)) + { + result = new {{data.TypeName}}(ulid); + return true; + } + else + { + result = default; + return false; + } + } + + public static bool TryDeconstruct(ReadOnlySpan span, out Ulid rawUlid) + { + rawUlid = Ulid.Empty; + + return span.Length == MaxLength + && span.StartsWith(TypePrefix) + && span[{{prefix.Length}}] == Separator + && Ulid.TryParse(span[{{prefix.Length + 1}}..], out rawUlid); + } + + public (string prefix, Ulid data) Destructure() => (TypePrefix, Ulid); + + 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> 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; + 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); + } +} +"""; + } +} diff --git a/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/RawIdSourceBuilder.cs b/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/RawIdSourceBuilder.cs new file mode 100644 index 00000000..43cf817c --- /dev/null +++ b/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/RawIdSourceBuilder.cs @@ -0,0 +1,148 @@ +namespace LeanCode.DomainModels.Generators.SourceBuilders; + +internal static class RawIdSourceBuilder +{ + public static string Build( + TypedIdData data, + string backingType, + string converterPrefix, + string? randomValueGenerator, + string defaultValue, + string toStringParam, + string tryFormatParams + ) + { + var randomFactory = + !data.SkipRandomGenerator && randomValueGenerator is not null + ? $"public static {data.TypeName} New() => new({randomValueGenerator});" + : ""; + + // language=C# + 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.Globalization; + using global::System.Linq.Expressions; + using global::System.Text.Json.Serialization; + using global::LeanCode.DomainModels.Ids; + #pragma warning restore CS8019 + + [JsonConverter(typeof({{converterPrefix}}TypedIdConverter<{{data.TypeName}}>))] + [DebuggerDisplay("{Value}")] + [ExcludeFromCodeCoverage] + public readonly partial record struct {{data.TypeName}} : IRawTypedId<{{backingType}}, {{data.TypeName}}> + { + public static {{data.TypeName}} Empty { get; } = new({{defaultValue}}); + + public {{backingType}} Value {get;} + public bool IsEmpty => Value == Empty; + + public {{data.TypeName}}({{backingType}} v) => Value = v; + {{randomFactory}} + + public static bool IsValid([NotNullWhen(true)] {{backingType}}? v) => v is not null; + + public static {{data.TypeName}} Parse({{backingType}} v) => new {{data.TypeName}}(v); + + [return: NotNullIfNotNull("id")] + public static {{data.TypeName}}? ParseNullable({{backingType}}? id) + => id is {{backingType}} v ? Parse(v) : ({{data.TypeName}}?)null; + + public static bool TryParse([NotNullWhen(true)] {{backingType}}? v, out {{data.TypeName}} id) + { + if (IsValid(v)) + { + id = new {{data.TypeName}}(v.Value); + return true; + } + else + { + id = default; + return false; + } + } + + public static {{data.TypeName}} Parse(string s, IFormatProvider? provider = null) + { + ArgumentNullException.ThrowIfNull(s); + if (TryParse(s.AsSpan(), provider, out var result)) + { + return result; + } + else + { + throw new FormatException($"Unable to parse '{s}' as {{data.TypeName}}."); + } + } + + public static bool TryParse([NotNullWhen(true)] string? v, out {{data.TypeName}} id) + => TryParse(v, null, out id); + + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) + { + if (s is null) + { + result = default; + return false; + } + return TryParse(s.AsSpan(), provider, out result); + } + + public static {{data.TypeName}} Parse(ReadOnlySpan s, IFormatProvider? provider = null) + { + if (TryParse(s, provider, out var result)) + { + return result; + } + else + { + throw new FormatException($"Unable to parse the span as {{data.TypeName}}."); + } + } + + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out {{data.TypeName}} result) + { + if ({{backingType}}.TryParse(s, provider, out var v)) + { + result = new {{data.TypeName}}(v); + return true; + } + else + { + result = default; + return false; + } + } + + public bool Equals({{data.TypeName}} other) => Value == other.Value; + public int CompareTo({{data.TypeName}} other) => Value.CompareTo(other.Value); + public override int GetHashCode() => Value.GetHashCode(); + public static implicit operator {{backingType}}({{data.TypeName}} id) => id.Value; + + public static bool operator <({{data.TypeName}} a, {{data.TypeName}} b) => a.Value < b.Value; + public static bool operator <=({{data.TypeName}} a, {{data.TypeName}} b) => a.Value <= b.Value; + public static bool operator >({{data.TypeName}} a, {{data.TypeName}} b) => a.Value > b.Value; + public static bool operator >=({{data.TypeName}} a, {{data.TypeName}} b) => a.Value >= b.Value; + + static Expression> IRawTypedId<{{backingType}}, {{data.TypeName}}>.FromDatabase { get; } = d => Parse(d); + static Expression> IRawTypedId<{{backingType}}, {{data.TypeName}}>.DatabaseEquals { get; } = (a, b) => a == b; + + public override string ToString() => Value.ToString({{toStringParam}}); + public string ToString(string? format, IFormatProvider? formatProvider) => ToString(); + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + => Value.TryFormat(destination, out charsWritten, {{tryFormatParams}}); + + public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) + => Value.TryFormat(utf8Destination, out bytesWritten, {{tryFormatParams}}); + } +} +"""; + } +} diff --git a/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/RawStringIdSourceBuilder.cs b/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/RawStringIdSourceBuilder.cs new file mode 100644 index 00000000..dcb8f9d7 --- /dev/null +++ b/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/RawStringIdSourceBuilder.cs @@ -0,0 +1,142 @@ +namespace LeanCode.DomainModels.Generators.SourceBuilders; + +internal static class RawStringIdSourceBuilder +{ + public static string Build(TypedIdData data) + { + var maxValueLength = data.MaxValueLength!.Value; + + // language=C# + 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 {{data.TypeName}} Empty { get; } = new(string.Empty); + public static int MaxLength { get; } = {{maxValueLength}}; + + 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 bool IsValid([NotNullWhen(true)] string? v) => v is not null && IsValid(v.AsSpan()); + public static bool IsValid(ReadOnlySpan v) => TryParse(v, null, out _); + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string length validation.")] + public static {{data.TypeName}} Parse(string v, IFormatProvider? provider = null) + { + ArgumentNullException.ThrowIfNull(v); + if (TryParse(v.AsSpan(), provider, out var result)) + { + return result; + } + else + { + throw new FormatException( + $"The ID has invalid format. It must be a non-null string with maximum length of {MaxLength}."); + } + } + + [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) + => TryParse(v, null, out id); + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string length validation.")] + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out {{data.TypeName}} result) + { + if (s is null) + { + result = default; + return false; + } + return TryParse(s.AsSpan(), provider, out result); + } + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string length validation.")] + public static {{data.TypeName}} Parse(ReadOnlySpan s, IFormatProvider? provider = null) + { + if (TryParse(s, provider, out var result)) + { + return result; + } + else + { + throw new FormatException( + $"The ID has invalid format. It must be a non-null string with maximum length of {MaxLength}." + ); + } + } + + [SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "IFormatProvider is ignored for string length validation.")] + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out {{data.TypeName}} result) + { + if (s.Length <= MaxLength) + { + result = new {{data.TypeName}}(s.ToString()); + return true; + } + else + { + result = default; + return false; + } + } + + 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); + } +} +"""; + } +} diff --git a/src/Domain/LeanCode.DomainModels.Generators/TypedIdGenerator.cs b/src/Domain/LeanCode.DomainModels.Generators/TypedIdGenerator.cs index 5ff8d8a6..e8b80dd3 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/TypedIdGenerator.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/TypedIdGenerator.cs @@ -1,4 +1,5 @@ using System.Globalization; +using LeanCode.DomainModels.Generators.SourceBuilders; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; From af61df52156fd9fa229d0c41c73c50bfc8e38285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Tue, 23 Dec 2025 12:58:35 +0100 Subject: [PATCH 6/9] Fix PrefixedStringId ValuePart property to return empty string for empty values --- .../SourceBuilders/PrefixedStringIdSourceBuilder.cs | 2 +- .../LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/PrefixedStringIdSourceBuilder.cs b/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/PrefixedStringIdSourceBuilder.cs index 06c66cd2..1f5646d1 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/PrefixedStringIdSourceBuilder.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/SourceBuilders/PrefixedStringIdSourceBuilder.cs @@ -40,7 +40,7 @@ namespace {{data.Namespace}} public string Value => value ?? Empty.Value; public bool IsEmpty => string.IsNullOrEmpty(value); - public string ValuePart => Value[{{prefix.Length + 1}}..]; + public string ValuePart => IsEmpty ? string.Empty : Value[{{prefix.Length + 1}}..]; private {{data.TypeName}}(string v) => value = v; diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs index fb943ee1..762814ba 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs @@ -280,6 +280,13 @@ public void IsEmpty_works_correctly() Assert.False(TestPrefixedStringId.Parse(TPS1).IsEmpty); } + [Fact] + public void Empty_ValuePart_returns_empty_string() + { + var empty = TestPrefixedStringId.Empty; + Assert.Equal(string.Empty, empty.ValuePart); + } + [Fact] public void MaxValueLength_is_exposed() { From d7198d44e947617cc07d115e2e40a6ea8da50e62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Tue, 23 Dec 2025 13:01:48 +0100 Subject: [PATCH 7/9] Update CHANGELOG.md with typed IDs improvements --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 919875c3..42156415 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ but this project DOES NOT adhere to [Semantic Versioning](http://semver.org/). * Add CQRS output caching support with new `LeanCode.CQRS.OutputCaching` package * Migrate Test infrastructure to Microsoft Testing Platform v2 and xunit v3 * Add `RawString` and `PrefixedString` source generated typed IDs support +* Implement `ISpanParsable` in Typed IDs with span-based and string-based parsing APIs +* Add `Destructure()` method and raw value accessors (`Guid`, `Ulid`, `ValuePart`) to prefixed typed IDs ## 9.0 From a48a2b110aaac2190aa54700ea2fbee4e27edbbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Tue, 23 Dec 2025 13:15:12 +0100 Subject: [PATCH 8/9] Enhance source generated ID documentation --- docs/domain/id/index.md | 51 +++++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/docs/domain/id/index.md b/docs/domain/id/index.md index 35c133c3..4e7b542e 100644 --- a/docs/domain/id/index.md +++ b/docs/domain/id/index.md @@ -47,25 +47,58 @@ public class Employee : IAggregateRoot ### API -The generated ID supports the following operations: +The generated ID supports the following operations (example for `RawGuid`): ```cs -public readonly partial record struct ID +public readonly partial record struct ID : IEquatable, + IComparable, + ISpanFormattable, + IUtf8SpanFormattable, + ISpanParsable, + IEqualityOperators { - public static readonly TestIntId Empty; + public static readonly ID Empty; - public int Value { get; } + public Guid Value { get; } public bool IsEmpty { get; } - public static ID Parse(int? v); - public static ID? ParseNullable(int? id); - public static bool TryParse([NotNullWhen(true)] int? v, out ID id); - public static bool IsValid([NotNullWhen(true)] int? v); + // Parsing from backing type (if the ID is just a wrapper for raw backing type) + public static ID Parse(Guid v); + public static ID? ParseNullable(Guid? id); + public static bool TryParse([NotNullWhen(true)] Guid? v, out ID id); + public static bool IsValid([NotNullWhen(true)] Guid? v); public static ID New(); // Only if generation is possible } ``` +### Prefixed ID features + +Prefixed IDs (`PrefixedGuid`, `PrefixedUlid`, `PrefixedString`) provide additional APIs for accessing components: + +```cs +// PrefixedGuid +public Guid Guid { get; } +public (string prefix, Guid data) Destructure(); + +// PrefixedUlid +public Ulid Ulid { get; } +public (string prefix, Ulid data) Destructure(); + +// PrefixedString +public string ValuePart { get; } +public (string prefix, string data) Destructure(); +public static ID FromValuePart(string valuePart); +``` + +Example usage: + +```cs +var id = OrderId.Parse("order_01ARZ3NDEKTSV4RRFFQ69G5FAV"); +var (prefix, ulid) = id.Destructure(); // ("order", Ulid) +var rawUlid = id.Ulid; // Access raw Ulid directly +``` + ### Configuration The format of the ID can be configured using: @@ -80,7 +113,7 @@ The format of the ID can be configured using: - `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 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. +- `MaxValueLength` - required maximum length constraint for the value part of the string IDs. For `RawString`, this is the entire string length. For `PrefixedString`, this excludes the prefix and separator. Validation is performed in `Parse`/`IsValid` methods. Used for configuring columns in the database, so consider SQL Server's 900-byte key limit (~450 nvarchar chars) when choosing this value. Examples: From 36e357106bc334a68375b30f8ef2d261e9c57816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Draga=C5=84czuk?= Date: Tue, 23 Dec 2025 13:37:06 +0100 Subject: [PATCH 9/9] Add tests for Correct_PrefixedUlid scenarios in ValidConstructTests --- .../Ids/ValidConstructTests.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs index 2583b2e4..2a746df6 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs @@ -165,6 +165,46 @@ namespace Test; ); } + [Fact] + public void Correct_PrefixedUlid() + { + AssertCorrect( + """ + using LeanCode.DomainModels.Ids; + namespace Test; + [TypedId(TypedIdFormat.PrefixedUlid)] + public readonly partial record struct Id; + """ + ); + + AssertCorrect( + """ + using LeanCode.DomainModels.Ids; + namespace Test; + [TypedId(TypedIdFormat.PrefixedUlid, CustomPrefix = "prefix")] + public readonly partial record struct Id; + """ + ); + + AssertCorrect( + """ + using LeanCode.DomainModels.Ids; + namespace Test; + [TypedId(TypedIdFormat.PrefixedUlid, SkipRandomGenerator = true)] + public readonly partial record struct Id; + """ + ); + + AssertCorrect( + """ + using LeanCode.DomainModels.Ids; + namespace Test; + [TypedId(TypedIdFormat.PrefixedUlid, CustomPrefix = "prefix", SkipRandomGenerator = true, MaxValueLength = 50)] + public readonly partial record struct Id; + """ + ); + } + [Fact] public void Correct_RawString() {