diff --git a/CHANGELOG.md b/CHANGELOG.md index c90c61f29..919875c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ but this project DOES NOT adhere to [Semantic Versioning](http://semver.org/). * Upgrade to .NET 10 * Response serialization moved from `CQRSMiddleware` to individual CQRS middlewares to support output caching * Add CQRS output caching support with new `LeanCode.CQRS.OutputCaching` package -* Test infrastructure moved to Microsoft Testing Platform v2 and xunit v3 +* Migrate Test infrastructure to Microsoft Testing Platform v2 and xunit v3 +* Add `RawString` and `PrefixedString` source generated typed IDs support ## 9.0 diff --git a/docs/domain/id/index.md b/docs/domain/id/index.md index 6fa07fc0a..35c133c3f 100644 --- a/docs/domain/id/index.md +++ b/docs/domain/id/index.md @@ -74,11 +74,15 @@ The format of the ID can be configured using: - `RawInt` - uses `int` as the underlying type; works as a wrapper over `int`; does not support generating new IDs at runtime by default. - `RawLong` - uses `long` as the underlying type; works as a wrapper over `long`; does not support generating new IDs at runtime by default. - `RawGuid` - uses `Guid` as the underlying type; works as a wrapper over `Guid`; can generate new ID at runtime using `Guid.NewGuid`. - - `PrefixedGuid` - uses `string` as the underlying type; it is represented as a `(prefix)_(guid)` string that can be generated at runtime; by default `(prefix)` is a lowercase class name with `id` at the end removed. + - `RawString` - uses `string` as the underlying type; works as a wrapper over arbitrary strings; does not support generating new IDs at runtime. + - `PrefixedGuid` - uses `string` as the underlying type; it is represented as a `(prefix)_(guid)` string that can be generated at runtime; by default `(prefix)` is a lowercase class name with `Id` suffix removed. + - `PrefixedUlid` - uses `string` as the underlying type; it is represented as a `(prefix)_(ulid)` string that can be generated at runtime; by default `(prefix)` is a lowercase class name with `Id` suffix removed. + - `PrefixedString` - uses `string` as the underlying type; it is represented as a `(prefix)_(value)` string where `(value)` is an arbitrary string; does not support generating new IDs at runtime. - `CustomPrefix` - for `Prefixed*` formats, you can configure what prefix it uses (if you e.g. want to use a shorter one). -- `SkipRandomGenerator` - setting this to `true` will skip generating `New` factory method (for `Prefixed` types only). +- `SkipRandomGenerator` - setting this to `true` will skip generating `New` factory method (for formats that support generation). +- `MaxValueLength` - optional maximum length constraint for the value part. For `RawString`, this is the entire string length. For `PrefixedString`, this excludes the prefix and separator. When set, validation is performed in `Parse`/`IsValid` methods. Consider SQL Server's 900-byte key limit (~450 nvarchar chars) when choosing this value. -Example: +Examples: ```cs [TypedId(TypedIdFormat.PrefixedGuid, CustomPrefix = "employee")] @@ -87,6 +91,21 @@ public readonly partial record struct VeryLongEmployeeId; // The `VeryLongEmployeeId` will have format `employee_(guid)`, with `New` using `Guid.NewGuid` as random source. ``` +```cs +[TypedId(TypedIdFormat.RawString, MaxValueLength = 100)] +public readonly partial record struct ExternalId; + +// The `ExternalId` wraps any string up to 100 characters. Exposes `MaxLength` static property. +``` + +```cs +[TypedId(TypedIdFormat.PrefixedString, CustomPrefix = "ext", MaxValueLength = 50)] +public readonly partial record struct ExternalRefId; + +// The `ExternalRefId` has format `ext_(value)` where value can be up to 50 characters. +// Exposes `MaxValueLength` (50) and `MaxLength` (54 = 3 + 1 + 50) static properties. +``` + ## Generic type wrappers The domain part of the library supports a set of generic IDs: diff --git a/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs b/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs index ea34570f9..9495eafb5 100644 --- a/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs +++ b/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs @@ -54,6 +54,26 @@ this PropertiesConfigurationBuilder builder return builder.AreRawTypedId(); } + public static PropertiesConfigurationBuilder AreStringTypedId( + this PropertiesConfigurationBuilder builder + ) + where TId : struct, IRawStringTypedId + { + return builder + .HaveConversion, RawStringTypedIdComparer>() + .ConfigureMaxLength(); + } + + public static PropertiesConfigurationBuilder AreStringTypedId( + this PropertiesConfigurationBuilder builder + ) + where TId : struct, IRawStringTypedId + { + return builder + .HaveConversion, RawStringTypedIdComparer>() + .ConfigureMaxLength(); + } + public static PropertiesConfigurationBuilder ArePrefixedTypedId( this PropertiesConfigurationBuilder builder ) @@ -61,8 +81,7 @@ this PropertiesConfigurationBuilder builder { return builder .HaveConversion, PrefixedTypedIdComparer>() - .HaveMaxLength(TId.RawLength) - .AreFixedLength(); + .ConfigureMaxLength(); } public static PropertiesConfigurationBuilder ArePrefixedTypedId( @@ -72,8 +91,23 @@ this PropertiesConfigurationBuilder builder { return builder .HaveConversion, PrefixedTypedIdComparer>() - .HaveMaxLength(TId.RawLength) - .AreFixedLength(); + .ConfigureMaxLength(); + } + + private static PropertiesConfigurationBuilder ConfigureMaxLength( + this PropertiesConfigurationBuilder builder + ) + where TId : struct, IMaxLengthTypedId + { + return builder.HaveMaxLength(TId.MaxLength).AreFixedLength(); + } + + private static PropertiesConfigurationBuilder ConfigureMaxLength( + this PropertiesConfigurationBuilder builder + ) + where TId : struct, IMaxLengthTypedId + { + return builder.HaveMaxLength(TId.MaxLength).AreFixedLength(); } private static PropertiesConfigurationBuilder AreRawTypedId( diff --git a/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs b/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs index 1301085fe..7d9b6937f 100644 --- a/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs +++ b/src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs @@ -74,56 +74,82 @@ public static PropertyBuilder> IsTypedId(this PropertyBuilder> public static PropertyBuilder IsIntTypedId(this PropertyBuilder builder) where TId : struct, IRawTypedId { - return builder.HasConversion(RawTypedIdConverter.Instance); + return builder.HasConversion(RawTypedIdConverter.Instance, RawTypedIdComparer.Instance); } public static PropertyBuilder IsIntTypedId(this PropertyBuilder builder) where TId : struct, IRawTypedId { - return builder.HasConversion(RawTypedIdConverter.Instance); + return builder.HasConversion(RawTypedIdConverter.Instance, RawTypedIdComparer.Instance); } public static PropertyBuilder IsLongTypedId(this PropertyBuilder builder) where TId : struct, IRawTypedId { - return builder.HasConversion(RawTypedIdConverter.Instance); + return builder.HasConversion(RawTypedIdConverter.Instance, RawTypedIdComparer.Instance); } public static PropertyBuilder IsLongTypedId(this PropertyBuilder builder) where TId : struct, IRawTypedId { - return builder.HasConversion(RawTypedIdConverter.Instance); + return builder.HasConversion(RawTypedIdConverter.Instance, RawTypedIdComparer.Instance); } public static PropertyBuilder IsGuidTypedId(this PropertyBuilder builder) where TId : struct, IRawTypedId { - return builder.HasConversion(RawTypedIdConverter.Instance); + return builder.HasConversion(RawTypedIdConverter.Instance, RawTypedIdComparer.Instance); } public static PropertyBuilder IsGuidTypedId(this PropertyBuilder builder) where TId : struct, IRawTypedId { - return builder.HasConversion(RawTypedIdConverter.Instance); + return builder.HasConversion(RawTypedIdConverter.Instance, RawTypedIdComparer.Instance); + } + + public static PropertyBuilder IsStringTypedId(this PropertyBuilder builder) + where TId : struct, IRawStringTypedId + { + return builder + .HasConversion(RawStringTypedIdConverter.Instance, RawStringTypedIdComparer.Instance) + .ConfigureMaxLength(); + } + + public static PropertyBuilder IsStringTypedId(this PropertyBuilder builder) + where TId : struct, IRawStringTypedId + { + return builder + .HasConversion(RawStringTypedIdConverter.Instance, RawStringTypedIdComparer.Instance) + .ConfigureMaxLength(); } public static PropertyBuilder IsPrefixedTypedId(this PropertyBuilder builder) where TId : struct, IPrefixedTypedId { return builder - .HasConversion(PrefixedTypedIdConverter.Instance) - .HasMaxLength(TId.RawLength) - .IsFixedLength() - .ValueGeneratedNever(); + .HasConversion(PrefixedTypedIdConverter.Instance, PrefixedTypedIdComparer.Instance) + .ValueGeneratedNever() + .ConfigureMaxLength(); } public static PropertyBuilder IsPrefixedTypedId(this PropertyBuilder builder) where TId : struct, IPrefixedTypedId { return builder - .HasConversion(PrefixedTypedIdConverter.Instance) - .HasMaxLength(TId.RawLength) - .IsFixedLength() - .ValueGeneratedNever(); + .HasConversion(PrefixedTypedIdConverter.Instance, PrefixedTypedIdComparer.Instance) + .ValueGeneratedNever() + .ConfigureMaxLength(); + } + + private static PropertyBuilder ConfigureMaxLength(this PropertyBuilder builder) + where TId : struct, IMaxLengthTypedId + { + return builder.HasMaxLength(TId.MaxLength).IsFixedLength(); + } + + private static PropertyBuilder ConfigureMaxLength(this PropertyBuilder builder) + where TId : struct, IMaxLengthTypedId + { + return builder.HasMaxLength(TId.MaxLength).IsFixedLength(); } } diff --git a/src/Domain/LeanCode.DomainModels.EF/TypedIdConverter.cs b/src/Domain/LeanCode.DomainModels.EF/TypedIdConverter.cs index 2a9191312..90338b661 100644 --- a/src/Domain/LeanCode.DomainModels.EF/TypedIdConverter.cs +++ b/src/Domain/LeanCode.DomainModels.EF/TypedIdConverter.cs @@ -25,10 +25,22 @@ public RawTypedIdConverter() : base(d => d.Value, TId.FromDatabase, mappingHints: null) { } } +[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] +public class RawStringTypedIdConverter : ValueConverter + where TId : struct, IRawStringTypedId +{ + public static readonly RawStringTypedIdConverter Instance = new(); + + public RawStringTypedIdConverter() + : base(d => d.Value, TId.FromDatabase, mappingHints: null) { } +} + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] public class PrefixedTypedIdComparer : ValueComparer where TId : struct, IPrefixedTypedId { + public static readonly PrefixedTypedIdComparer Instance = new(); + public PrefixedTypedIdComparer() : base(TId.DatabaseEquals, d => d.GetHashCode()) { } } @@ -38,6 +50,18 @@ public class RawTypedIdComparer : ValueComparer where TBacking : struct where TId : struct, IRawTypedId { + public static readonly RawTypedIdComparer Instance = new(); + public RawTypedIdComparer() : base(TId.DatabaseEquals, d => d.GetHashCode()) { } } + +[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] +public class RawStringTypedIdComparer : ValueComparer + where TId : struct, IRawStringTypedId +{ + public static readonly RawStringTypedIdComparer Instance = new(); + + public RawStringTypedIdComparer() + : base(TId.DatabaseEquals, d => d.GetHashCode()) { } +} diff --git a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs index 85acc4b42..834becf9a 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs @@ -4,42 +4,33 @@ internal static class IdSource { public static string Build(TypedIdData data) { - switch (data.Format) + return data.Format switch { - case TypedIdFormat.RawInt: - return BuildRaw( - data, - "int", - "Int", - null, - "0", - "CultureInfo.InvariantCulture", - "string.Empty, CultureInfo.InvariantCulture" - ); - - case TypedIdFormat.RawLong: - return BuildRaw( - data, - "long", - "Long", - null, - "0", - "CultureInfo.InvariantCulture", - "string.Empty, CultureInfo.InvariantCulture" - ); - - case TypedIdFormat.RawGuid: - return BuildRaw(data, "Guid", "Guid", "Guid.NewGuid()", "Guid.Empty", "", "string.Empty"); - - case TypedIdFormat.PrefixedGuid: - return BuildPrefixedGuid(data); - - case TypedIdFormat.PrefixedUlid: - return BuildPrefixedUlid(data); - - default: - throw new ArgumentException("Unsupported ID format."); - } + TypedIdFormat.RawInt => BuildRaw( + data, + "int", + "Int", + null, + "0", + "CultureInfo.InvariantCulture", + "string.Empty, CultureInfo.InvariantCulture" + ), + TypedIdFormat.RawLong => BuildRaw( + data, + "long", + "Long", + null, + "0", + "CultureInfo.InvariantCulture", + "string.Empty, CultureInfo.InvariantCulture" + ), + TypedIdFormat.RawGuid => BuildRaw(data, "Guid", "Guid", "Guid.NewGuid()", "Guid.Empty", "", "string.Empty"), + TypedIdFormat.RawString => BuildRawString(data), + TypedIdFormat.PrefixedGuid => BuildPrefixedGuid(data), + TypedIdFormat.PrefixedUlid => BuildPrefixedUlid(data), + TypedIdFormat.PrefixedString => BuildPrefixedString(data), + _ => throw new ArgumentException("Unsupported ID format."), + }; } private static string BuildPrefixedGuid(TypedIdData data) @@ -50,6 +41,8 @@ private static string BuildPrefixedGuid(TypedIdData data) var randomFactory = !data.SkipRandomGenerator ? $"public static {data.TypeName} New() => new(Guid.NewGuid());" : ""; + + // language=C# return $$""" // #nullable enable @@ -75,8 +68,8 @@ namespace {{data.Namespace}} private const char Separator = '_'; private const string TypePrefix = "{{prefix}}"; - public static int RawLength { get; } = {{valueLength + 1 + prefix.Length}}; - public static readonly {{data.TypeName}} Empty = new(Guid.Empty); + public static int MaxLength { get; } = {{valueLength + 1 + prefix.Length}}; + public static {{data.TypeName}} Empty { get; } = new(Guid.Empty); private readonly string? value; @@ -84,7 +77,7 @@ namespace {{data.Namespace}} public bool IsEmpty => value is null || value == Empty; private {{data.TypeName}}(string v) => value = v; - public {{data.TypeName}}(Guid v) => value = string.Create(null, stackalloc char[RawLength], $"{TypePrefix}{Separator}{v:N}"); + public {{data.TypeName}}(Guid v) => value = string.Create(null, stackalloc char[MaxLength], $"{TypePrefix}{Separator}{v:N}"); {{randomFactory}} public static {{data.TypeName}} Parse(string v) @@ -127,7 +120,7 @@ public static bool IsValid([NotNullWhen(true)] string? v) else { var span = v.AsSpan(); - return span.Length == RawLength + return span.Length == MaxLength && span.StartsWith(TypePrefix) && span[{{prefix.Length}}] == Separator && Guid.TryParseExact(span[{{prefix.Length + 1}}..], "N", out _); @@ -176,6 +169,7 @@ private static string BuildPrefixedUlid(TypedIdData data) var prefix = data.CustomPrefix?.ToLowerInvariant() ?? GetDefaultPrefix(data.TypeName); var valueLength = 26; + // language=C# return $$""" // #nullable enable @@ -202,8 +196,8 @@ namespace {{data.Namespace}} private const char Separator = '_'; private const string TypePrefix = "{{prefix}}"; - public static int RawLength { get; } = {{valueLength + 1 + prefix.Length}}; - public static readonly {{data.TypeName}} Empty = new(Ulid.Empty); + public static int MaxLength { get; } = {{valueLength + 1 + prefix.Length}}; + public static {{data.TypeName}} Empty { get; } = new(Ulid.Empty); private readonly string? value; @@ -213,7 +207,7 @@ namespace {{data.Namespace}} private {{data.TypeName}}(string v) => value = v; - public {{data.TypeName}}(Ulid v) => value = string.Create(null, stackalloc char[RawLength], $"{TypePrefix}{Separator}{v}"); + public {{data.TypeName}}(Ulid v) => value = string.Create(null, stackalloc char[MaxLength], $"{TypePrefix}{Separator}{v}"); public static {{data.TypeName}} New() => new(Ulid.NewUlid()); @@ -252,7 +246,7 @@ public static bool TryDeconstruct(ReadOnlySpan span, out Ulid rawUlid) { rawUlid = Ulid.Empty; - return span.Length == RawLength + return span.Length == MaxLength && span.StartsWith(TypePrefix) && span[{{prefix.Length}}] == Separator && Ulid.TryParse(span[{{prefix.Length + 1}}..], out rawUlid); @@ -300,6 +294,146 @@ public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnly """; } + 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); + + 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 {{data.TypeName}} Parse(string v) + { + if (IsValid(v)) + { + return new {{data.TypeName}}(v); + } + else + { + throw new FormatException( + $"The ID has invalid format. It should look like {TypePrefix}{Separator}(value)." + ); + } + } + + [return: NotNullIfNotNull("id")] + public static {{data.TypeName}}? ParseNullable(string? id) => id is string v ? Parse(v) : ({{data.TypeName}}?)null; + + public static bool TryParse([NotNullWhen(true)] string? v, out {{data.TypeName}} id) + { + if (IsValid(v)) + { + id = new {{data.TypeName}}(v); + return true; + } + else + { + id = default; + return false; + } + } + + public static bool IsValid([NotNullWhen(true)] string? v) + { + if (v is null) + { + return false; + } + + var span = v.AsSpan(); + return span.Length >= {{prefix.Length + + 1}} && span.Length <= MaxLength && span.StartsWith(TypePrefix) && span[{{prefix.Length}}] == Separator; + } + + public ReadOnlySpan GetValuePart() => IsEmpty + ? ReadOnlySpan.Empty + : Value.AsSpan()[{{prefix.Length + 1}}..]; + + public bool Equals({{data.TypeName}} other) => Value.Equals(other.Value, StringComparison.Ordinal); + public int CompareTo({{data.TypeName}} other) => string.Compare(Value, other.Value, StringComparison.Ordinal); + 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, @@ -314,6 +448,8 @@ string tryFormatParams !data.SkipRandomGenerator && randomValueGenerator is not null ? $"public static {data.TypeName} New() => new({randomValueGenerator});" : ""; + + // language=C# return $$""" // #nullable enable @@ -335,7 +471,7 @@ namespace {{data.Namespace}} [ExcludeFromCodeCoverage] public readonly partial record struct {{data.TypeName}} : IRawTypedId<{{backingType}}, {{data.TypeName}}> { - public static readonly {{data.TypeName}} Empty = new({{defaultValue}}); + public static {{data.TypeName}} Empty { get; } = new({{defaultValue}}); public {{backingType}} Value {get;} public bool IsEmpty => Value == Empty; @@ -395,6 +531,114 @@ public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnly """; } + 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 {{data.TypeName}} Parse(string v) + { + if (IsValid(v)) + { + return new {{data.TypeName}}(v); + } + 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) + { + if (IsValid(v)) + { + id = new {{data.TypeName}}(v); + return true; + } + else + { + id = default; + return false; + } + } + + public static bool IsValid([NotNullWhen(true)] string? v) + { + return v is not null && v.Length <= MaxLength; + } + + public bool Equals({{data.TypeName}} other) => Value.Equals(other.Value, StringComparison.Ordinal); + public int CompareTo({{data.TypeName}} other) => string.Compare(Value, other.Value, StringComparison.Ordinal); + public override int GetHashCode() => Value.GetHashCode(StringComparison.Ordinal); + public static implicit operator string({{data.TypeName}} id) => id.Value; + + public static bool operator <({{data.TypeName}} a, {{data.TypeName}} b) => a.CompareTo(b) < 0; + public static bool operator <=({{data.TypeName}} a, {{data.TypeName}} b) => a.CompareTo(b) <= 0; + public static bool operator >({{data.TypeName}} a, {{data.TypeName}} b) => a.CompareTo(b) > 0; + public static bool operator >=({{data.TypeName}} a, {{data.TypeName}} b) => a.CompareTo(b) >= 0; + + static Expression> IRawStringTypedId<{{data.TypeName}}>.FromDatabase { get; } = d => Parse(d); + static Expression> IRawStringTypedId<{{data.TypeName}}>.DatabaseEquals { get; } = (a, b) => a == b; + + public override string ToString() => Value; + public string ToString(string? format, IFormatProvider? formatProvider) => Value; + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + if (destination.Length >= Value.Length) + { + Value.CopyTo(destination); + charsWritten = Value.Length; + return true; + } + else + { + charsWritten = 0; + return false; + } + } + + public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) + => Encoding.UTF8.TryGetBytes(Value, utf8Destination, out bytesWritten); + } +} +"""; + } + private static string GetDefaultPrefix(string typeName) { typeName = typeName.ToLowerInvariant(); diff --git a/src/Domain/LeanCode.DomainModels.Generators/TypedIdData.cs b/src/Domain/LeanCode.DomainModels.Generators/TypedIdData.cs index e60efbe4b..0f3db8a86 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/TypedIdData.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/TypedIdData.cs @@ -9,6 +9,7 @@ public sealed class TypedIdData public string TypeName { get; } public string? CustomPrefix { get; } public bool SkipRandomGenerator { get; } + public int? MaxValueLength { get; } public bool IsValid { get; } public Location? Location { get; } @@ -16,8 +17,9 @@ public TypedIdData( TypedIdFormat format, string @namespace, string typeName, - string? customSlug, + string? customPrefix, bool skipRandomGenerator, + int? maxValueLength, bool isValid, Location? location ) @@ -25,8 +27,9 @@ public TypedIdData( Format = format; Namespace = @namespace; TypeName = typeName; - CustomPrefix = customSlug; + CustomPrefix = customPrefix; SkipRandomGenerator = skipRandomGenerator; + MaxValueLength = maxValueLength; IsValid = isValid; Location = location; } diff --git a/src/Domain/LeanCode.DomainModels.Generators/TypedIdFormat.cs b/src/Domain/LeanCode.DomainModels.Generators/TypedIdFormat.cs index ad04b0218..6b48d6c59 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/TypedIdFormat.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/TypedIdFormat.cs @@ -5,6 +5,8 @@ public enum TypedIdFormat RawInt = 0, RawLong = 1, RawGuid = 2, - PrefixedGuid = 3, - PrefixedUlid = 4, + RawString = 3, + PrefixedGuid = 4, + PrefixedUlid = 5, + PrefixedString = 6, } diff --git a/src/Domain/LeanCode.DomainModels.Generators/TypedIdGenerator.cs b/src/Domain/LeanCode.DomainModels.Generators/TypedIdGenerator.cs index ec3203955..5ff8d8a66 100644 --- a/src/Domain/LeanCode.DomainModels.Generators/TypedIdGenerator.cs +++ b/src/Domain/LeanCode.DomainModels.Generators/TypedIdGenerator.cs @@ -10,11 +10,21 @@ public sealed class TypedIdGenerator : IIncrementalGenerator private const string AttributeName = "LeanCode.DomainModels.Ids.TypedIdAttribute"; private const string CustomPrefixField = "CustomPrefix"; private const string SkipRandomGeneratorField = "SkipRandomGenerator"; + private const string MaxValueLengthField = "MaxValueLength"; private static readonly DiagnosticDescriptor InvalidTypeRule = new( "LNCD0005", "Typed id must be `readonly partial record struct`", - @"`{0}` is invalid. For typed ids to work, the type must be `readonly partial record struct`.", + "`{0}` is invalid. For typed ids to work, the type must be `readonly partial record struct`.", + "Domain", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor MaxLengthRequiredForStringIdRule = new( + "LNCD0012", + "String typed id must provide positive `MaxValueLength` in attribute", + "`{0}` is invalid string ID. For string typed ids to work, they must provide positive `MaxValueLength` in the `TypedId` attribute.", "Domain", DiagnosticSeverity.Error, isEnabledByDefault: true @@ -36,6 +46,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var skipRandomGenerator = attribute .NamedArguments.FirstOrDefault(a => a.Key == SkipRandomGeneratorField) .Value.Value; + var maxValueLength = attribute + .NamedArguments.FirstOrDefault(a => a.Key == MaxValueLengthField) + .Value.Value; var isValid = IsValidSyntaxNode(n.TargetNode); return new TypedIdData( (TypedIdFormat)idFormat, @@ -43,6 +56,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) n.TargetSymbol.Name, (string?)customPrefix, skipRandomGenerator is true, + maxValueLength is int mvl && mvl >= 0 ? mvl : null, isValid, !isValid ? n.TargetNode.GetLocation() : null ); @@ -53,13 +67,22 @@ public void Initialize(IncrementalGeneratorInitializationContext context) src, static (sources, data) => { - if (data.IsValid) + if (!data.IsValid) { - sources.AddSource($"{data.TypeName}.g.cs", IdSource.Build(data)); + sources.ReportDiagnostic(Diagnostic.Create(InvalidTypeRule, data.Location, data.TypeName)); + } + else if ( + data.Format is TypedIdFormat.RawString or TypedIdFormat.PrefixedString + && data.MaxValueLength is null + ) + { + sources.ReportDiagnostic( + Diagnostic.Create(MaxLengthRequiredForStringIdRule, data.Location, data.TypeName) + ); } else { - sources.ReportDiagnostic(Diagnostic.Create(InvalidTypeRule, data.Location, data.TypeName)); + sources.AddSource($"{data.TypeName}.g.cs", IdSource.Build(data)); } } ); diff --git a/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs b/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs index c01ee4caf..9d390b281 100644 --- a/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs +++ b/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs @@ -12,11 +12,12 @@ public interface IPrefixedTypedId IComparable, ISpanFormattable, IUtf8SpanFormattable, - IEqualityOperators + IEqualityOperators, + IMaxLengthTypedId, + IHasEmptyId where TSelf : struct, IPrefixedTypedId { string Value { get; } - static abstract int RawLength { get; } static abstract TSelf Parse(string v); static abstract bool IsValid(string? v); @@ -34,7 +35,8 @@ public interface IRawTypedId IComparable, ISpanFormattable, IUtf8SpanFormattable, - IEqualityOperators + IEqualityOperators, + IHasEmptyId where TBacking : struct where TSelf : struct, IRawTypedId { @@ -47,3 +49,39 @@ public interface IRawTypedId [EditorBrowsable(EditorBrowsableState.Never)] static abstract Expression> DatabaseEquals { get; } } + +[SuppressMessage("?", "CA1000", Justification = "Roslyn bug.")] +[EditorBrowsable(EditorBrowsableState.Never)] +public interface IRawStringTypedId + : IEquatable, + IComparable, + ISpanFormattable, + IUtf8SpanFormattable, + IEqualityOperators, + IMaxLengthTypedId, + IHasEmptyId + where TSelf : struct, IRawStringTypedId +{ + string Value { get; } + static abstract TSelf Parse(string v); + static abstract bool IsValid(string? v); + + [EditorBrowsable(EditorBrowsableState.Never)] + static abstract Expression> FromDatabase { get; } + + [EditorBrowsable(EditorBrowsableState.Never)] + static abstract Expression> DatabaseEquals { get; } +} + +[EditorBrowsable(EditorBrowsableState.Never)] +public interface IHasEmptyId + where TSelf : struct, IHasEmptyId +{ + static abstract TSelf Empty { get; } +} + +[EditorBrowsable(EditorBrowsableState.Never)] +public interface IMaxLengthTypedId +{ + static abstract int MaxLength { get; } +} diff --git a/src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs b/src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs index bc3f05ef1..c5e2f1f75 100644 --- a/src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs +++ b/src/Domain/LeanCode.DomainModels/Ids/TypedIdAttribute.cs @@ -23,17 +23,28 @@ public enum TypedIdFormat /// RawGuid = 2, + /// + /// Raw , without prefix. It's backing type is . + /// + RawString = 3, + /// /// prefixed with the class name or . It's backing /// type is . /// - PrefixedGuid = 3, + PrefixedGuid = 4, /// /// prefixed with the class name or . It's backing /// type is . /// - PrefixedUlid = 4, + PrefixedUlid = 5, + + /// + /// Arbitrary prefixed with the class name or . + /// It's backing type is . + /// + PrefixedString = 6, } /// @@ -66,6 +77,19 @@ public sealed class TypedIdAttribute : Attribute /// public bool SkipRandomGenerator { get; set; } + /// + /// Maximum length of the value part. + /// Only applies to and + /// formats; ignored for other formats. + /// For , this constrains the entire string length. + /// For , this constrains only the value part (excludes prefix and separator). + /// Required for string-based IDs. + /// + /// + /// Consider SQL Server's 900-byte key limit (~450 nvarchar chars) when choosing this value. + /// + public int MaxValueLength { get; set; } = -1; + public TypedIdAttribute(TypedIdFormat format) { Format = format; diff --git a/src/Domain/LeanCode.DomainModels/Ids/TypedIdConverter.cs b/src/Domain/LeanCode.DomainModels/Ids/TypedIdConverter.cs index 44e802339..501021f5e 100644 --- a/src/Domain/LeanCode.DomainModels/Ids/TypedIdConverter.cs +++ b/src/Domain/LeanCode.DomainModels/Ids/TypedIdConverter.cs @@ -27,6 +27,26 @@ public override void WriteAsPropertyName(Utf8JsonWriter writer, TId value, JsonS writer.WritePropertyName(value.Value); } +[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] +public class RawStringTypedIdConverter : JsonConverter + where TId : struct, IRawStringTypedId +{ + public override TId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + TId.Parse(reader.GetString() ?? throw new JsonException("Expected an id string")); + + public override void Write(Utf8JsonWriter writer, TId value, JsonSerializerOptions options) => + writer.WriteStringValue(value.Value); + + public override TId ReadAsPropertyName( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) => Read(ref reader, typeToConvert, options); + + public override void WriteAsPropertyName(Utf8JsonWriter writer, TId value, JsonSerializerOptions options) => + writer.WritePropertyName(value.Value); +} + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] public class IntTypedIdConverter : JsonConverter where TId : struct, IRawTypedId diff --git a/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdConverterTests.cs b/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdConverterTests.cs index 02dc59a43..0ab7036ca 100644 --- a/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdConverterTests.cs +++ b/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdConverterTests.cs @@ -29,6 +29,14 @@ public void RawGuid_conversion_to_guid_and_back_works() AssertConvertsGuid(new(Guid.NewGuid())); } + [Fact] + public void RawString_conversion_to_string_and_back_works() + { + AssertConvertsRawString(new("")); + AssertConvertsRawString(new("test-id")); + AssertConvertsRawString(new("another-value")); + } + [Fact] public void PrefixedGuid_conversion_to_guid_and_back_works() { @@ -37,6 +45,14 @@ public void PrefixedGuid_conversion_to_guid_and_back_works() AssertConvertsPrefixedGuid(PrefixedGuidId.New()); } + [Fact] + public void PrefixedString_conversion_to_string_and_back_works() + { + AssertConvertsPrefixedString(PrefixedStringId.FromValuePart("")); + AssertConvertsPrefixedString(PrefixedStringId.FromValuePart("test-id")); + AssertConvertsPrefixedString(PrefixedStringId.FromValuePart("another-value")); + } + [Fact] public void RawInt_convention_is_registered_properly() { @@ -82,6 +98,22 @@ public void RawGuid_convention_is_registered_properly() Assert.Null(mapping["Relational:ColumnType"]); } + [Fact] + public void RawString_convention_is_registered_properly() + { + var builder = new ModelConfigurationBuilderWrapper(); + builder.Properties().AreStringTypedId(); + var model = builder.Build(); + + var mapping = model.FindProperty(typeof(StringId)); + Assert.NotNull(mapping); + Assert.IsType>(mapping.GetValueConverter()); + Assert.Equal(typeof(RawStringTypedIdComparer), mapping["ValueComparerType"]); + Assert.Equal(typeof(StringId), mapping.ClrType); + Assert.Equal(StringId.MaxLength, mapping.GetMaxLength()); + Assert.Null(mapping["Relational:ColumnType"]); + } + [Fact] public void PrefixedGuid_convention_is_registered_properly() { @@ -94,8 +126,23 @@ public void PrefixedGuid_convention_is_registered_properly() Assert.IsType>(mapping.GetValueConverter()); Assert.Equal(typeof(PrefixedTypedIdComparer), mapping["ValueComparerType"]); Assert.Equal(typeof(PrefixedGuidId), mapping.ClrType); - Assert.Equal(mapping.GetMaxLength(), PrefixedGuidId.RawLength); - Assert.Equal(mapping["Relational:IsFixedLength"], true); + Assert.Equal(PrefixedGuidId.MaxLength, mapping.GetMaxLength()); + Assert.Null(mapping["Relational:ColumnType"]); + } + + [Fact] + public void PrefixedString_convention_is_registered_properly() + { + var builder = new ModelConfigurationBuilderWrapper(); + builder.Properties().ArePrefixedTypedId(); + var model = builder.Build(); + + var mapping = model.FindProperty(typeof(PrefixedStringId)); + Assert.NotNull(mapping); + Assert.IsType>(mapping.GetValueConverter()); + Assert.Equal(typeof(PrefixedTypedIdComparer), mapping["ValueComparerType"]); + Assert.Equal(typeof(PrefixedStringId), mapping.ClrType); + Assert.Equal(PrefixedStringId.MaxLength, mapping.GetMaxLength()); Assert.Null(mapping["Relational:ColumnType"]); } @@ -144,6 +191,22 @@ public void OptionalRawGuid_convention_is_registered_properly() Assert.Null(mapping["Relational:ColumnType"]); } + [Fact] + public void OptionalRawString_convention_is_registered_properly() + { + var builder = new ModelConfigurationBuilderWrapper(); + builder.Properties().AreStringTypedId(); + var model = builder.Build(); + + var mapping = model.FindProperty(typeof(StringId?)); + Assert.NotNull(mapping); + Assert.IsType>(mapping.GetValueConverter()); + Assert.Equal(typeof(RawStringTypedIdComparer), mapping["ValueComparerType"]); + Assert.Equal(typeof(StringId?), mapping.ClrType); + Assert.Equal(StringId.MaxLength, mapping.GetMaxLength()); + Assert.Null(mapping["Relational:ColumnType"]); + } + [Fact] public void OptionalPrefixedGuid_convention_is_registered_properly() { @@ -156,8 +219,23 @@ public void OptionalPrefixedGuid_convention_is_registered_properly() Assert.IsType>(mapping.GetValueConverter()); Assert.Equal(typeof(PrefixedTypedIdComparer), mapping["ValueComparerType"]); Assert.Equal(typeof(PrefixedGuidId?), mapping.ClrType); - Assert.Equal(mapping.GetMaxLength(), PrefixedGuidId.RawLength); - Assert.Equal(mapping["Relational:IsFixedLength"], true); + Assert.Equal(PrefixedGuidId.MaxLength, mapping.GetMaxLength()); + Assert.Null(mapping["Relational:ColumnType"]); + } + + [Fact] + public void OptionalPrefixedString_convention_is_registered_properly() + { + var builder = new ModelConfigurationBuilderWrapper(); + builder.Properties().ArePrefixedTypedId(); + var model = builder.Build(); + + var mapping = model.FindProperty(typeof(PrefixedStringId?)); + Assert.NotNull(mapping); + Assert.IsType>(mapping.GetValueConverter()); + Assert.Equal(typeof(PrefixedTypedIdComparer), mapping["ValueComparerType"]); + Assert.Equal(typeof(PrefixedStringId?), mapping.ClrType); + Assert.Equal(PrefixedStringId.MaxLength, mapping.GetMaxLength()); Assert.Null(mapping["Relational:ColumnType"]); } @@ -185,6 +263,14 @@ private static void AssertConvertsGuid(GuidId id) Assert.Equal(id, fromResult); } + private static void AssertConvertsRawString(StringId id) + { + var toResult = RawStringTypedIdConverter.Instance.ConvertToProvider(id); + var fromResult = RawStringTypedIdConverter.Instance.ConvertFromProvider(id.Value); + Assert.Equal(id.Value, toResult); + Assert.Equal(id, fromResult); + } + private static void AssertConvertsPrefixedGuid(PrefixedGuidId id) { var toResult = PrefixedTypedIdConverter.Instance.ConvertToProvider(id); @@ -192,4 +278,12 @@ private static void AssertConvertsPrefixedGuid(PrefixedGuidId id) Assert.Equal(id.Value, toResult); Assert.Equal(id, fromResult); } + + private static void AssertConvertsPrefixedString(PrefixedStringId id) + { + var toResult = PrefixedTypedIdConverter.Instance.ConvertToProvider(id); + var fromResult = PrefixedTypedIdConverter.Instance.ConvertFromProvider(id.Value); + Assert.Equal(id.Value, toResult); + Assert.Equal(id, fromResult); + } } diff --git a/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdDatabaseIntegrationTests.cs b/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdDatabaseIntegrationTests.cs index 0a859df96..98dfec804 100644 --- a/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdDatabaseIntegrationTests.cs +++ b/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIdDatabaseIntegrationTests.cs @@ -1,3 +1,4 @@ +using LeanCode.DomainModels.Ulids; using Microsoft.EntityFrameworkCore; using Xunit; @@ -43,6 +44,12 @@ private static void AssertEqual(Entity a, Entity b) Assert.Equal(a.F, b.F); Assert.Equal(a.G, b.G); Assert.Equal(a.H, b.H); + Assert.Equal(a.I, b.I); + Assert.Equal(a.J, b.J); + Assert.Equal(a.K, b.K); + Assert.Equal(a.L, b.L); + Assert.Equal(a.M, b.M); + Assert.Equal(a.N, b.N); } private sealed class TestDbContext : DbContext @@ -73,12 +80,18 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura configurationBuilder.Properties().AreIntTypedId(); configurationBuilder.Properties().AreLongTypedId(); configurationBuilder.Properties().AreGuidTypedId(); + configurationBuilder.Properties().AreStringTypedId(); configurationBuilder.Properties().ArePrefixedTypedId(); + configurationBuilder.Properties().ArePrefixedTypedId(); + configurationBuilder.Properties().ArePrefixedTypedId(); configurationBuilder.Properties().AreIntTypedId(); configurationBuilder.Properties().AreLongTypedId(); configurationBuilder.Properties().AreGuidTypedId(); + configurationBuilder.Properties().AreStringTypedId(); configurationBuilder.Properties().ArePrefixedTypedId(); + configurationBuilder.Properties().ArePrefixedTypedId(); + configurationBuilder.Properties().ArePrefixedTypedId(); } } @@ -91,11 +104,17 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) cfg.Property(e => e.A).IsIntTypedId(); cfg.Property(e => e.B).IsLongTypedId(); cfg.Property(e => e.C).IsGuidTypedId(); - cfg.Property(e => e.D).IsPrefixedTypedId(); - cfg.Property(e => e.E).IsIntTypedId(); - cfg.Property(e => e.F).IsLongTypedId(); - cfg.Property(e => e.G).IsGuidTypedId(); - cfg.Property(e => e.H).IsPrefixedTypedId(); + cfg.Property(e => e.D).IsStringTypedId(); + cfg.Property(e => e.E).IsPrefixedTypedId(); + cfg.Property(e => e.F).IsPrefixedTypedId(); + cfg.Property(e => e.G).IsPrefixedTypedId(); + cfg.Property(e => e.H).IsIntTypedId(); + cfg.Property(e => e.I).IsLongTypedId(); + cfg.Property(e => e.J).IsGuidTypedId(); + cfg.Property(e => e.K).IsStringTypedId(); + cfg.Property(e => e.L).IsPrefixedTypedId(); + cfg.Property(e => e.M).IsPrefixedTypedId(); + cfg.Property(e => e.N).IsPrefixedTypedId(); } cfg.HasKey(e => e.A); @@ -108,36 +127,51 @@ private sealed record Entity public IntId A { get; set; } public LongId B { get; set; } public GuidId C { get; set; } - public PrefixedGuidId D { get; set; } - - public IntId? E { get; set; } - public LongId? F { get; set; } - public GuidId? G { get; set; } - public PrefixedGuidId? H { get; set; } + public StringId D { get; set; } + public PrefixedGuidId E { get; set; } + public PrefixedUlidId F { get; set; } + public PrefixedStringId G { get; set; } + + public IntId? H { get; set; } + public LongId? I { get; set; } + public GuidId? J { get; set; } + public StringId? K { get; set; } + public PrefixedGuidId? L { get; set; } + public PrefixedUlidId? M { get; set; } + public PrefixedStringId? N { get; set; } public static Entity CreateFull() { - return new Entity + return new() { A = new(1), B = new(2), C = new(Guid.NewGuid()), - D = new(Guid.NewGuid()), - E = new(3), - F = new(4), - G = new(Guid.NewGuid()), - H = new(Guid.NewGuid()), + D = new("a"), + E = new(Guid.NewGuid()), + F = new(Ulid.NewUlid()), + G = PrefixedStringId.FromValuePart("b"), + H = new(3), + I = new(4), + J = new(Guid.NewGuid()), + K = new("c"), + L = new(Guid.NewGuid()), + M = new(Ulid.NewUlid()), + N = PrefixedStringId.FromValuePart("d"), }; } public static Entity CreatePartial() { - return new Entity + return new() { A = new(5), B = new(6), C = new(Guid.NewGuid()), - D = new(Guid.NewGuid()), + D = new("e"), + E = new(Guid.NewGuid()), + F = new(Ulid.NewUlid()), + G = PrefixedStringId.FromValuePart("f"), }; } } diff --git a/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIds.cs b/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIds.cs index 949ca7e4d..ae8b715df 100644 --- a/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIds.cs +++ b/test/Domain/LeanCode.DomainModels.EF.Tests/TypedIds.cs @@ -11,5 +11,14 @@ namespace LeanCode.DomainModels.EF.Tests; [TypedId(TypedIdFormat.RawGuid)] public readonly partial record struct GuidId; +[TypedId(TypedIdFormat.RawString, MaxValueLength = 100)] +public readonly partial record struct StringId; + [TypedId(TypedIdFormat.PrefixedGuid)] public readonly partial record struct PrefixedGuidId; + +[TypedId(TypedIdFormat.PrefixedUlid)] +public readonly partial record struct PrefixedUlidId; + +[TypedId(TypedIdFormat.PrefixedString, MaxValueLength = 100)] +public readonly partial record struct PrefixedStringId; diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/GeneratorRunner.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/GeneratorRunner.cs index b816a4cca..32c05f210 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/GeneratorRunner.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/GeneratorRunner.cs @@ -8,15 +8,15 @@ namespace LeanCode.DomainModels.Tests.Ids; public static class GeneratorRunner { - private static readonly IReadOnlyList DefaultAssemblies = new[] - { + private static readonly IReadOnlyList DefaultAssemblies = + [ LoadRefLib("System.Linq"), LoadRefLib("System.Linq.Expressions"), LoadRefLib("System.Memory"), LoadRefLib("System.Runtime"), LoadRefLib("System.Text.Json"), MetadataReference.CreateFromFile(typeof(TypedIdAttribute).Assembly.Location), - }; + ]; private static PortableExecutableReference LoadRefLib(string name) { @@ -29,7 +29,7 @@ public static ImmutableArray RunDiagnostics(string source) var syntaxTree = CSharpSyntaxTree.ParseText(source); var compilation = CSharpCompilation.Create( assemblyName: "Tests", - syntaxTrees: new[] { syntaxTree }, + syntaxTrees: [syntaxTree], references: DefaultAssemblies, options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) ); diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/InvalidConstructTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/InvalidConstructTests.cs index be190dae1..cca6136db 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/InvalidConstructTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/InvalidConstructTests.cs @@ -33,4 +33,24 @@ public void No_readonly() var diag = GeneratorRunner.RunDiagnostics(Source); Assert.Single(diag, d => d.Id == "LNCD0005"); } + + [Fact] + public void No_max_value_length_for_raw_string_id() + { + const string Source = + "using LeanCode.DomainModels.Ids; [TypedId(TypedIdFormat.RawString)] public readonly partial record struct Id;"; + + var diag = GeneratorRunner.RunDiagnostics(Source); + Assert.Single(diag, d => d.Id == "LNCD0012"); + } + + [Fact] + public void No_max_value_length_for_prefixed_string_id() + { + const string Source = + "using LeanCode.DomainModels.Ids; [TypedId(TypedIdFormat.PrefixedString)] public readonly partial record struct Id;"; + + var diag = GeneratorRunner.RunDiagnostics(Source); + Assert.Single(diag, d => d.Id == "LNCD0012"); + } } diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedGuidTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedGuidTests.cs index f0f45bc8f..dbd975920 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedGuidTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedGuidTests.cs @@ -218,9 +218,9 @@ static void DatabaseExpressionsWork() } [Fact] - public void RawLength_is_correct() + public void MaxLength_is_correct() { - Assert.Equal(TestPrefixedGuidId.RawLength, TPG1.Length); + Assert.Equal(TestPrefixedGuidId.MaxLength, TPG1.Length); } [Fact] @@ -233,9 +233,9 @@ public void TryFormatChar_is_correct() charsWritten.Should().Be(0); id.TryFormat(buffer, out charsWritten, "", null).Should().BeTrue(); - charsWritten.Should().Be(TestPrefixedGuidId.RawLength); - new string(buffer[..TestPrefixedGuidId.RawLength]).Should().Be(TPG1); - buffer[TestPrefixedGuidId.RawLength..].Should().AllBeEquivalentTo(default(char)); + charsWritten.Should().Be(TestPrefixedGuidId.MaxLength); + new string(buffer[..TestPrefixedGuidId.MaxLength]).Should().Be(TPG1); + buffer[TestPrefixedGuidId.MaxLength..].Should().AllBeEquivalentTo(default(char)); } [Fact] @@ -249,8 +249,8 @@ public void TryFormatUtf8Byte_is_correct() bytesWritten.Should().Be(0); id.TryFormat(buffer, out bytesWritten, "", null).Should().BeTrue(); - bytesWritten.Should().Be(TestPrefixedGuidId.RawLength); - buffer[..TestPrefixedGuidId.RawLength].Should().BeEquivalentTo(expectedBytes); - buffer[TestPrefixedGuidId.RawLength..].Should().AllBeEquivalentTo(default(byte)); + bytesWritten.Should().Be(TestPrefixedGuidId.MaxLength); + buffer[..TestPrefixedGuidId.MaxLength].Should().BeEquivalentTo(expectedBytes); + buffer[TestPrefixedGuidId.MaxLength..].Should().AllBeEquivalentTo(default(byte)); } } diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs new file mode 100644 index 000000000..8f9c73e72 --- /dev/null +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringTests.cs @@ -0,0 +1,318 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using LeanCode.DomainModels.Ids; +using Xunit; + +namespace LeanCode.DomainModels.Tests.Ids; + +[TypedId(TypedIdFormat.PrefixedString, CustomPrefix = "tps", MaxValueLength = 10)] +public readonly partial record struct TestPrefixedStringId; + +public class PrefixedStringIdTests +{ + private const string TPSEmpty = ""; + private const string TPS1 = "tps_abc123"; + private const string TPS2 = "tps_def456"; + private const string TPS3 = "tps_xyz789"; + + [Fact] + [SuppressMessage("?", "xUnit2007", Justification = "Cannot use `IPrefixedTypedId` as generic parameter.")] + public void Generated_class_implements_ITypedId() + { + Assert.IsAssignableFrom(typeof(IPrefixedTypedId), new TestPrefixedStringId()); + } + + [Fact] + public void Default_and_empty_are_equal() + { + Assert.Equal(TestPrefixedStringId.Empty, default); + } + + [Fact] + public void Default_value_has_empty_string_value() + { + Assert.Equal(TPSEmpty, TestPrefixedStringId.Empty.Value); + } + + [Fact] + public void From_null_behaves_correctly() + { + Assert.False(TestPrefixedStringId.IsValid(null)); + + Assert.Throws(() => TestPrefixedStringId.Parse(null!)); + Assert.Throws(() => TestPrefixedStringId.ParseNullable("invalid")); + Assert.False(TestPrefixedStringId.TryParse(null, out var value)); + Assert.Equal(value, default); + } + + [Fact] + public void From_malformed_value_behaves_correctly() + { + Assert.False(TestPrefixedStringId.IsValid("invalid")); + + Assert.Throws(() => TestPrefixedStringId.Parse("invalid")); + Assert.Throws(() => TestPrefixedStringId.ParseNullable("invalid")); + Assert.False(TestPrefixedStringId.TryParse("invalid", out var value)); + Assert.Equal(value, default); + } + + [Fact] + public void From_value_with_invalid_prefix_behaves_correctly() + { + Assert.False(TestPrefixedStringId.IsValid("tps2_abc123")); + + Assert.Throws(() => TestPrefixedStringId.Parse("tps2_abc123")); + Assert.Throws(() => TestPrefixedStringId.ParseNullable("tps2_abc123")); + Assert.False(TestPrefixedStringId.TryParse("tps2_abc123", out var value)); + Assert.Equal(value, default); + } + + [Fact] + public void From_value_without_separator_behaves_correctly() + { + Assert.False(TestPrefixedStringId.IsValid("tpsabc123")); + + Assert.Throws(() => TestPrefixedStringId.Parse("tpsabc123")); + Assert.False(TestPrefixedStringId.TryParse("tpsabc123", out _)); + } + + [Fact] + public void From_value_with_empty_value_part_behaves_correctly() + { + Assert.True(TestPrefixedStringId.IsValid("tps_")); + + var id = TestPrefixedStringId.Parse("tps_"); + Assert.Equal("tps_", id.Value); + Assert.True(id.GetValuePart().IsEmpty); + + Assert.True(TestPrefixedStringId.TryParse("tps_", out var parsed)); + Assert.Equal("tps_", parsed.Value); + } + + [Fact] + public void From_valid_value_behaves_correctly() + { + Assert.True(TestPrefixedStringId.IsValid(TPS1)); + + Assert.Equal(TPS1, TestPrefixedStringId.Parse(TPS1).Value); + Assert.Equal(TPS1, TestPrefixedStringId.ParseNullable(TPS1)!.Value.Value); + Assert.True(TestPrefixedStringId.TryParse(TPS1, out var value)); + Assert.Equal(TPS1, value.Value); + } + + [Fact] + public void FromValuePart_creates_prefixed_id() + { + var id = TestPrefixedStringId.FromValuePart("abc123"); + Assert.Equal(TPS1, id.Value); + } + + [Fact] + public void GetValuePart_extracts_value_part() + { + var id = TestPrefixedStringId.Parse(TPS1); + Assert.Equal("abc123", id.GetValuePart().ToString()); + } + + [Fact] + public void GetValuePart_returns_empty_span_for_Empty_instance() + { + var empty = TestPrefixedStringId.Empty; + Assert.True(empty.GetValuePart().IsEmpty); + } + + [Fact] + public void Equals_behaves_correctly() + { + Assert.True(TestPrefixedStringId.Parse(TPS1).Equals(TestPrefixedStringId.Parse(TPS1))); + Assert.False(TestPrefixedStringId.Parse(TPS1).Equals(TestPrefixedStringId.Parse(TPS2))); + Assert.False(TestPrefixedStringId.Parse(TPS1).Equals(null)); + } + + [Fact] + public void CompareTo_behaves_correctly() + { + Assert.Equal(0, TestPrefixedStringId.Parse(TPS1).CompareTo(TestPrefixedStringId.Parse(TPS1))); + Assert.True(TestPrefixedStringId.Parse(TPS1).CompareTo(TestPrefixedStringId.Parse(TPS2)) < 0); + Assert.True(TestPrefixedStringId.Parse(TPS2).CompareTo(TestPrefixedStringId.Parse(TPS1)) > 0); + } + + [Fact] + public void Comparisons_behave_correctly() + { + Assert.True(TestPrefixedStringId.Parse(TPS1) < TestPrefixedStringId.Parse(TPS2)); + Assert.False(TestPrefixedStringId.Parse(TPS2) < TestPrefixedStringId.Parse(TPS2)); + Assert.False(TestPrefixedStringId.Parse(TPS3) < TestPrefixedStringId.Parse(TPS2)); + + Assert.True(TestPrefixedStringId.Parse(TPS1) <= TestPrefixedStringId.Parse(TPS2)); + Assert.True(TestPrefixedStringId.Parse(TPS2) <= TestPrefixedStringId.Parse(TPS2)); + Assert.False(TestPrefixedStringId.Parse(TPS3) <= TestPrefixedStringId.Parse(TPS2)); + + Assert.False(TestPrefixedStringId.Parse(TPS1) > TestPrefixedStringId.Parse(TPS2)); + Assert.False(TestPrefixedStringId.Parse(TPS2) > TestPrefixedStringId.Parse(TPS2)); + Assert.True(TestPrefixedStringId.Parse(TPS3) > TestPrefixedStringId.Parse(TPS2)); + + Assert.False(TestPrefixedStringId.Parse(TPS1) >= TestPrefixedStringId.Parse(TPS2)); + Assert.True(TestPrefixedStringId.Parse(TPS2) >= TestPrefixedStringId.Parse(TPS2)); + Assert.True(TestPrefixedStringId.Parse(TPS3) >= TestPrefixedStringId.Parse(TPS2)); + + Assert.True(TestPrefixedStringId.Parse(TPS1) == TestPrefixedStringId.Parse(TPS1)); + Assert.False(TestPrefixedStringId.Parse(TPS1) == TestPrefixedStringId.Parse(TPS2)); + Assert.True(TestPrefixedStringId.Parse(TPS1) != TestPrefixedStringId.Parse(TPS2)); + Assert.False(TestPrefixedStringId.Parse(TPS1) != TestPrefixedStringId.Parse(TPS1)); + } + + [Fact] + public void The_hash_code_is_equal_to_underlying_value() + { + Assert.Equal(TPS1.GetHashCode(StringComparison.Ordinal), TestPrefixedStringId.Parse(TPS1).GetHashCode()); + } + + [Fact] + public void Casts_to_underlying_type_extract_the_value() + { + string implicitValue = TestPrefixedStringId.Parse(TPS1); + var explicitValue = (string)TestPrefixedStringId.Parse(TPS2); + + Assert.Equal(TPS1, implicitValue); + Assert.Equal(TPS2, explicitValue); + } + + [Fact] + public void ToString_returns_string_representation_of_the_underlying_value() + { + Assert.Equal(TPS1, TestPrefixedStringId.Parse(TPS1).ToString()); + } + + [Fact] + public void ToString_from_FromValuePart_returns_the_prefixed_value() + { + Assert.Equal(TPS1, TestPrefixedStringId.FromValuePart("abc123").ToString()); + } + + [Fact] + public void The_type_can_be_serialized_and_deserialized_to_from_JSON() + { + var value = TestPrefixedStringId.Parse(TPS1); + + var json = JsonSerializer.Serialize(value); + var deserialized = JsonSerializer.Deserialize(json); + + Assert.Equal(value, deserialized); + } + + [Fact] + public void The_type_serializes_as_raw_underlying_type() + { + var value = TestPrefixedStringId.Parse(TPS1); + + var json = JsonSerializer.Serialize(value); + + Assert.Equal("\"" + TPS1 + "\"", json); + } + + [Fact] + public void The_type_can_be_serialized_and_deserialized_as_dictionary_key_from_JSON() + { + var value = TestPrefixedStringId.Parse(TPS1); + var dict = new Dictionary { [value] = 1 }; + + var json = JsonSerializer.Serialize(dict); + var deserialized = JsonSerializer.Deserialize>(json); + + Assert.Equal(dict, deserialized); + } + + [Fact] + public void Database_expressions_work() + { + DatabaseExpressionsWork(); + + static void DatabaseExpressionsWork() + where T : struct, IPrefixedTypedId + { + Assert.Equal(T.FromDatabase.Compile().Invoke(TPS1), T.Parse(TPS1)); + Assert.True(T.DatabaseEquals.Compile().Invoke(T.Parse(TPS1), T.Parse(TPS1))); + } + } + + [Fact] + public void TryFormatChar_is_correct() + { + var id = TestPrefixedStringId.Parse(TPS1); + var buffer = new char[50]; + + id.TryFormat(buffer.AsSpan(0, 5), out var charsWritten, "", null).Should().BeFalse(); + charsWritten.Should().Be(0); + + id.TryFormat(buffer, out charsWritten, "", null).Should().BeTrue(); + charsWritten.Should().Be(TPS1.Length); + new string(buffer[..TPS1.Length]).Should().Be(TPS1); + buffer[TPS1.Length..].Should().AllBeEquivalentTo(default(char)); + } + + [Fact] + public void TryFormatUtf8Byte_is_correct() + { + var id = TestPrefixedStringId.Parse(TPS1); + var buffer = new byte[50]; + var expectedBytes = Encoding.UTF8.GetBytes(TPS1); + + id.TryFormat(buffer.AsSpan(0, 5), out var bytesWritten, "", null).Should().BeFalse(); + bytesWritten.Should().Be(0); + + id.TryFormat(buffer, out bytesWritten, "", null).Should().BeTrue(); + bytesWritten.Should().Be(TPS1.Length); + buffer[..TPS1.Length].Should().BeEquivalentTo(expectedBytes); + buffer[TPS1.Length..].Should().AllBeEquivalentTo(default(byte)); + } + + [Fact] + public void IsEmpty_works_correctly() + { + Assert.True(TestPrefixedStringId.Empty.IsEmpty); + Assert.False(TestPrefixedStringId.Parse(TPS1).IsEmpty); + } + + [Fact] + public void MaxValueLength_is_exposed() + { + Assert.Equal(10, TestPrefixedStringId.MaxValueLength); + } + + [Fact] + public void MaxLength_is_calculated_correctly() + { + // prefix "tpm" (3) + separator "_" (1) + max value part (10) = 14 + Assert.Equal(14, TestPrefixedStringId.MaxLength); + } + + [Fact] + public void String_within_max_length_is_valid() + { + Assert.True(TestPrefixedStringId.IsValid("tps_1234567890")); // value part = 10 chars + Assert.True(TestPrefixedStringId.IsValid("tps_short")); + } + + [Fact] + public void String_exceeding_max_length_is_invalid() + { + Assert.False(TestPrefixedStringId.IsValid("tps_12345678901")); // value part = 11 chars + + Assert.Throws(() => TestPrefixedStringId.Parse("tps_12345678901")); + Assert.False(TestPrefixedStringId.TryParse("tps_this_is_too_long", out _)); + } + + [Fact] + public void FromValuePart_validates_max_length() + { + // Within limit - should work + var id = TestPrefixedStringId.FromValuePart("1234567890"); + Assert.Equal("tps_1234567890", id.Value); + + // Exceeds limit - should throw + Assert.Throws(() => TestPrefixedStringId.FromValuePart("12345678901")); + } +} diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringVariationsTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringVariationsTests.cs new file mode 100644 index 000000000..d67f4432d --- /dev/null +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedStringVariationsTests.cs @@ -0,0 +1,43 @@ +using FluentAssertions; +using LeanCode.DomainModels.Ids; +using Xunit; + +namespace LeanCode.DomainModels.Tests.Ids; + +[TypedId(TypedIdFormat.PrefixedString, CustomPrefix = "cp", MaxValueLength = 50)] +public readonly partial record struct CustomPrefixStringId; + +[TypedId(TypedIdFormat.PrefixedString, MaxValueLength = 50)] +public readonly partial record struct NormalStringPrefixWithId; + +[TypedId(TypedIdFormat.PrefixedString, MaxValueLength = 50)] +public readonly partial record struct NormalStringPrefixWithoutIdAtTheEnd; + +public class PrefixedStringVariationsTests +{ + [Fact] + public void CustomPrefix_starts_with_custom_prefix() + { + CustomPrefixStringId.FromValuePart("test").Value.Should().StartWith("cp_"); + } + + [Fact] + public void NormalPrefixWithId_starts_with_class_name_without_id() + { + Assert.StartsWith( + "normalstringprefixwith_", + NormalStringPrefixWithId.FromValuePart("test").Value, + StringComparison.Ordinal + ); + } + + [Fact] + public void NormalPrefixWithoutIdAtTheEnd_starts_with_class_name_as_is() + { + Assert.StartsWith( + "normalstringprefixwithoutidattheend_", + NormalStringPrefixWithoutIdAtTheEnd.FromValuePart("test").Value, + StringComparison.Ordinal + ); + } +} diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedUlidTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedUlidTests.cs index 8733a4191..d8e5c3c58 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedUlidTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/PrefixedUlidTests.cs @@ -237,9 +237,9 @@ static void DatabaseExpressionsWork() } [Fact] - public void RawLength_is_correct() + public void MaxLength_is_correct() { - TestPrefixedUlidId.RawLength.Should().Be(TPU1.Length); + TestPrefixedUlidId.MaxLength.Should().Be(TPU1.Length); } [Fact] @@ -270,9 +270,9 @@ public void TryFormatChar_is_correct() charsWritten.Should().Be(0); id.TryFormat(buffer, out charsWritten, "", null).Should().BeTrue(); - charsWritten.Should().Be(TestPrefixedUlidId.RawLength); - new string(buffer[..TestPrefixedUlidId.RawLength]).Should().Be(TPU1); - buffer[TestPrefixedUlidId.RawLength..].Should().AllBeEquivalentTo(default(char)); + charsWritten.Should().Be(TestPrefixedUlidId.MaxLength); + new string(buffer[..TestPrefixedUlidId.MaxLength]).Should().Be(TPU1); + buffer[TestPrefixedUlidId.MaxLength..].Should().AllBeEquivalentTo(default(char)); } [Fact] @@ -286,8 +286,8 @@ public void TryFormatUtf8Byte_is_correct() bytesWritten.Should().Be(0); id.TryFormat(buffer, out bytesWritten, "", null).Should().BeTrue(); - bytesWritten.Should().Be(TestPrefixedUlidId.RawLength); - buffer[..TestPrefixedUlidId.RawLength].Should().BeEquivalentTo(expectedBytes); - buffer[TestPrefixedUlidId.RawLength..].Should().AllBeEquivalentTo(default(byte)); + bytesWritten.Should().Be(TestPrefixedUlidId.MaxLength); + buffer[..TestPrefixedUlidId.MaxLength].Should().BeEquivalentTo(expectedBytes); + buffer[TestPrefixedUlidId.MaxLength..].Should().AllBeEquivalentTo(default(byte)); } } diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs new file mode 100644 index 000000000..3042f1206 --- /dev/null +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/RawStringTests.cs @@ -0,0 +1,249 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using LeanCode.DomainModels.Ids; +using Xunit; + +namespace LeanCode.DomainModels.Tests.Ids; + +[TypedId(TypedIdFormat.RawString, MaxValueLength = 10)] +public readonly partial record struct TestRawStringId; + +public class RawStringIdTests +{ + private const string String1 = "abc123"; + private const string String2 = "def456"; + private const string String3 = "xyz789"; + + [Fact] + [SuppressMessage("?", "xUnit2007", Justification = "Cannot use `IRawStringTypedId` as generic parameter.")] + public void Generated_class_implements_ITypedId() + { + Assert.IsAssignableFrom(typeof(IRawStringTypedId), new TestRawStringId()); + } + + [Fact] + public void Default_and_empty_are_equal() + { + Assert.Equal(TestRawStringId.Empty, default); + } + + [Fact] + public void Default_value_has_empty_string_value() + { + Assert.Equal(string.Empty, TestRawStringId.Empty.Value); + } + + [Fact] + public void Creating_value_out_of_string_works() + { + Assert.Equal(String1, new TestRawStringId(String1).Value); + } + + [Fact] + public void From_null_behaves_correctly() + { + Assert.False(TestRawStringId.IsValid(null)); + + Assert.Throws(() => TestRawStringId.Parse(null!)); + Assert.Null(TestRawStringId.ParseNullable(null)); + Assert.False(TestRawStringId.TryParse(null, out var value)); + Assert.Equal(value, default); + } + + [Fact] + public void From_string_behaves_correctly() + { + Assert.True(TestRawStringId.IsValid(String1)); + + Assert.Equal(new TestRawStringId(String1), TestRawStringId.Parse(String1)); + Assert.Equal(new TestRawStringId(String1), TestRawStringId.ParseNullable(String1)); + Assert.True(TestRawStringId.TryParse(String1, out var value)); + Assert.Equal(new TestRawStringId(String1), value); + } + + [Fact] + public void Empty_string_is_valid() + { + Assert.True(TestRawStringId.IsValid(string.Empty)); + Assert.Equal(string.Empty, TestRawStringId.Parse(string.Empty).Value); + } + + [Fact] + public void Equals_behaves_correctly() + { + Assert.True(new TestRawStringId(String1).Equals(new TestRawStringId(String1))); + Assert.False(new TestRawStringId(String1).Equals(new TestRawStringId(String2))); + Assert.False(new TestRawStringId(String1).Equals(null)); + } + + [Fact] + public void CompareTo_behaves_correctly() + { + Assert.Equal(0, new TestRawStringId(String1).CompareTo(new TestRawStringId(String1))); + Assert.True(new TestRawStringId(String1).CompareTo(new TestRawStringId(String2)) < 0); + Assert.True(new TestRawStringId(String2).CompareTo(new TestRawStringId(String1)) > 0); + } + + [Fact] + public void Comparisons_behave_correctly() + { + Assert.True(new TestRawStringId(String1) < new TestRawStringId(String2)); + Assert.False(new TestRawStringId(String2) < new TestRawStringId(String2)); + Assert.False(new TestRawStringId(String3) < new TestRawStringId(String2)); + + Assert.True(new TestRawStringId(String1) <= new TestRawStringId(String2)); + Assert.True(new TestRawStringId(String2) <= new TestRawStringId(String2)); + Assert.False(new TestRawStringId(String3) <= new TestRawStringId(String2)); + + Assert.False(new TestRawStringId(String1) > new TestRawStringId(String2)); + Assert.False(new TestRawStringId(String2) > new TestRawStringId(String2)); + Assert.True(new TestRawStringId(String3) > new TestRawStringId(String2)); + + Assert.False(new TestRawStringId(String1) >= new TestRawStringId(String2)); + Assert.True(new TestRawStringId(String2) >= new TestRawStringId(String2)); + Assert.True(new TestRawStringId(String3) >= new TestRawStringId(String2)); + + Assert.True(new TestRawStringId(String1) == new TestRawStringId(String1)); + Assert.False(new TestRawStringId(String1) == new TestRawStringId(String2)); + Assert.True(new TestRawStringId(String1) != new TestRawStringId(String2)); + Assert.False(new TestRawStringId(String1) != new TestRawStringId(String1)); + } + + [Fact] + public void The_hash_code_is_equal_to_underlying_value() + { + Assert.Equal(String1.GetHashCode(StringComparison.Ordinal), new TestRawStringId(String1).GetHashCode()); + } + + [Fact] + public void Casts_to_underlying_type_extract_the_value() + { + string implicitValue = new TestRawStringId(String1); + var explicitValue = (string)new TestRawStringId(String2); + + Assert.Equal(String1, implicitValue); + Assert.Equal(String2, explicitValue); + } + + [Fact] + public void ToString_returns_string_representation_of_the_underlying_value() + { + Assert.Equal(String1, new TestRawStringId(String1).ToString()); + } + + [Fact] + public void The_type_can_be_serialized_and_deserialized_to_from_JSON() + { + var value = new TestRawStringId(String1); + + var json = JsonSerializer.Serialize(value); + var deserialized = JsonSerializer.Deserialize(json); + + Assert.Equal(value, deserialized); + } + + [Fact] + public void The_type_serializes_as_raw_underlying_type() + { + var value = new TestRawStringId(String1); + + var json = JsonSerializer.Serialize(value); + + Assert.Equal("\"" + String1 + "\"", json); + } + + [Fact] + public void The_type_can_be_serialized_and_deserialized_as_dictionary_key_from_JSON() + { + var value = new TestRawStringId(String1); + var dict = new Dictionary { [value] = 1 }; + + var json = JsonSerializer.Serialize(dict); + var deserialized = JsonSerializer.Deserialize>(json); + + Assert.Equal(dict, deserialized); + } + + [Fact] + public void Database_expressions_work() + { + DatabaseExpressionsWork(); + + static void DatabaseExpressionsWork() + where T : struct, IRawStringTypedId + { + var str = "test_value"; + Assert.Equal(T.FromDatabase.Compile().Invoke(str), T.Parse(str)); + Assert.True(T.DatabaseEquals.Compile().Invoke(T.Parse(str), T.Parse(str))); + } + } + + [Fact] + public void TryFormatChar_is_correct() + { + var id = TestRawStringId.Parse(String1); + var buffer = new char[50]; + + id.TryFormat(buffer.AsSpan(0, 3), out var charsWritten, "", null).Should().BeFalse(); + charsWritten.Should().Be(0); + + id.TryFormat(buffer, out charsWritten, "", null).Should().BeTrue(); + charsWritten.Should().Be(String1.Length); + new string(buffer[..String1.Length]).Should().Be(String1); + buffer[String1.Length..].Should().AllBeEquivalentTo(default(char)); + } + + [Fact] + public void TryFormatUtf8Byte_is_correct() + { + var id = TestRawStringId.Parse(String1); + var buffer = new byte[50]; + var expectedBytes = Encoding.UTF8.GetBytes(String1); + + id.TryFormat(buffer.AsSpan(0, 3), out var bytesWritten, "", null).Should().BeFalse(); + bytesWritten.Should().Be(0); + + id.TryFormat(buffer, out bytesWritten, "", null).Should().BeTrue(); + bytesWritten.Should().Be(String1.Length); + buffer[..String1.Length].Should().BeEquivalentTo(expectedBytes); + buffer[String1.Length..].Should().AllBeEquivalentTo(default(byte)); + } + + [Fact] + public void IsEmpty_works_correctly() + { + Assert.True(TestRawStringId.Empty.IsEmpty); + Assert.True(new TestRawStringId(string.Empty).IsEmpty); + Assert.False(new TestRawStringId(String1).IsEmpty); + } + + [Fact] + public void MaxLength_is_exposed() + { + Assert.Equal(10, TestRawStringId.MaxLength); + } + + [Fact] + public void String_within_max_length_is_valid() + { + Assert.True(TestRawStringId.IsValid("1234567890")); + Assert.True(TestRawStringId.IsValid("short")); + } + + [Fact] + public void String_exceeding_max_length_is_invalid() + { + Assert.False(TestRawStringId.IsValid("12345678901")); // 11 chars + + Assert.Throws(() => TestRawStringId.Parse("12345678901")); + Assert.False(TestRawStringId.TryParse("this_is_too_long", out _)); + } + + [Fact] + public void Empty_string_is_valid_with_max_length() + { + Assert.True(TestRawStringId.IsValid(string.Empty)); + } +} diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs index 465fc771d..2583b2e45 100644 --- a/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs +++ b/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Xunit; namespace LeanCode.DomainModels.Tests.Ids; @@ -38,7 +39,7 @@ namespace Test; """ using LeanCode.DomainModels.Ids; namespace Test; - [TypedId(TypedIdFormat.RawInt, CustomPrefix = "ignored", SkipRandomGenerator = true)] + [TypedId(TypedIdFormat.RawInt, CustomPrefix = "ignored", SkipRandomGenerator = true, MaxValueLength = 50)] public readonly partial record struct Id; """ ); @@ -78,7 +79,7 @@ namespace Test; """ using LeanCode.DomainModels.Ids; namespace Test; - [TypedId(TypedIdFormat.RawLong, CustomPrefix = "ignored", SkipRandomGenerator = true)] + [TypedId(TypedIdFormat.RawLong, CustomPrefix = "ignored", SkipRandomGenerator = true, MaxValueLength = 50)] public readonly partial record struct Id; """ ); @@ -118,7 +119,7 @@ namespace Test; """ using LeanCode.DomainModels.Ids; namespace Test; - [TypedId(TypedIdFormat.RawGuid, CustomPrefix = "ignored", SkipRandomGenerator = true)] + [TypedId(TypedIdFormat.RawGuid, CustomPrefix = "ignored", SkipRandomGenerator = true, MaxValueLength = 50)] public readonly partial record struct Id; """ ); @@ -158,13 +159,57 @@ namespace Test; """ using LeanCode.DomainModels.Ids; namespace Test; - [TypedId(TypedIdFormat.PrefixedGuid, CustomPrefix = "prefix", SkipRandomGenerator = true)] + [TypedId(TypedIdFormat.PrefixedGuid, CustomPrefix = "prefix", SkipRandomGenerator = true, MaxValueLength = 50)] public readonly partial record struct Id; """ ); } - private static void AssertCorrect(string source) + [Fact] + public void Correct_RawString() + { + AssertCorrect( + """ + using LeanCode.DomainModels.Ids; + namespace Test; + [TypedId(TypedIdFormat.RawString, MaxValueLength = 100)] + public readonly partial record struct Id; + """ + ); + + AssertCorrect( + """ + using LeanCode.DomainModels.Ids; + namespace Test; + [TypedId(TypedIdFormat.RawString, CustomPrefix = "ignored", SkipRandomGenerator = true, MaxValueLength = 50)] + public readonly partial record struct Id; + """ + ); + } + + [Fact] + public void Correct_PrefixedString() + { + AssertCorrect( + """ + using LeanCode.DomainModels.Ids; + namespace Test; + [TypedId(TypedIdFormat.PrefixedString, MaxValueLength = 100)] + public readonly partial record struct Id; + """ + ); + + AssertCorrect( + """ + using LeanCode.DomainModels.Ids; + namespace Test; + [TypedId(TypedIdFormat.PrefixedString, CustomPrefix = "prefix", SkipRandomGenerator = true, MaxValueLength = 50)] + public readonly partial record struct Id; + """ + ); + } + + private static void AssertCorrect([StringSyntax("C#")] string source) { var diag = GeneratorRunner.RunDiagnostics(source); Assert.Empty(diag); diff --git a/test/LeanCode.IntegrationTests/App/CQRS.cs b/test/LeanCode.IntegrationTests/App/CQRS.cs index b3dc3a191..b8bede75c 100644 --- a/test/LeanCode.IntegrationTests/App/CQRS.cs +++ b/test/LeanCode.IntegrationTests/App/CQRS.cs @@ -29,7 +29,7 @@ public class ListEntities : IQuery> { } public class EntityDTO { - public Guid Id { get; set; } + public string Id { get; set; } = default!; public string Value { get; set; } = default!; } @@ -69,7 +69,7 @@ public AddEntityCH(TestDbContext dbContext) public Task ExecuteAsync(HttpContext context, AddEntity command) { - var entity = new Entity { Id = Guid.NewGuid(), Value = command.Value }; + var entity = new Entity { Id = EntityId.New(), Value = command.Value }; DomainEvents.Raise(new EntityAdded(entity)); dbContext.Entities.Add(entity); @@ -90,7 +90,7 @@ public EntityAddedConsumer(TestDbContext dbContext) public Task Consume(ConsumeContext context) { - var entity = new Entity { Id = Guid.NewGuid(), Value = $"{context.Message.Value}-consumer" }; + var entity = new Entity { Id = EntityId.New(), Value = $"{context.Message.Value}-consumer" }; dbContext.Entities.Add(entity); // No dbContext.SaveChanges - infrastructure will be handling this diff --git a/test/LeanCode.IntegrationTests/App/Entity.cs b/test/LeanCode.IntegrationTests/App/Entity.cs index 0951c6a86..6babf0926 100644 --- a/test/LeanCode.IntegrationTests/App/Entity.cs +++ b/test/LeanCode.IntegrationTests/App/Entity.cs @@ -1,12 +1,16 @@ using System.Text.Json.Serialization; +using LeanCode.DomainModels.Ids; using LeanCode.DomainModels.Model; using LeanCode.TimeProvider; namespace LeanCode.IntegrationTests.App; -public class Entity : IAggregateRootWithoutOptimisticConcurrency +[TypedId(TypedIdFormat.PrefixedGuid)] +public readonly partial record struct EntityId; + +public class Entity : IAggregateRootWithoutOptimisticConcurrency { - public Guid Id { get; set; } + public EntityId Id { get; set; } public string Value { get; set; } = null!; } @@ -16,10 +20,10 @@ public class EntityAdded : IDomainEvent public DateTime DateOccurred { get; private init; } public string Value { get; private init; } - public Guid EntityId { get; private init; } + public EntityId EntityId { get; private init; } [JsonConstructor] - public EntityAdded(Guid id, DateTime dateOccurred, string value, Guid entityId) + public EntityAdded(Guid id, DateTime dateOccurred, string value, EntityId entityId) { Id = id; DateOccurred = dateOccurred; diff --git a/test/LeanCode.IntegrationTests/App/Meeting.cs b/test/LeanCode.IntegrationTests/App/Meeting.cs index d0f553f66..06d4d83ee 100644 --- a/test/LeanCode.IntegrationTests/App/Meeting.cs +++ b/test/LeanCode.IntegrationTests/App/Meeting.cs @@ -1,10 +1,14 @@ +using LeanCode.DomainModels.Ids; using LeanCode.DomainModels.Model; namespace LeanCode.IntegrationTests.App; +[TypedId(TypedIdFormat.PrefixedGuid)] +public readonly partial record struct MeetingId; + public class Meeting { - public Guid Id { get; set; } + public MeetingId Id { get; set; } public string Name { get; set; } = default!; public TimestampTz StartTime { get; set; } = default!; } diff --git a/test/LeanCode.IntegrationTests/App/TestDbContext.cs b/test/LeanCode.IntegrationTests/App/TestDbContext.cs index bfc6d7cae..d2285d33e 100644 --- a/test/LeanCode.IntegrationTests/App/TestDbContext.cs +++ b/test/LeanCode.IntegrationTests/App/TestDbContext.cs @@ -1,3 +1,4 @@ +using LeanCode.DomainModels.EF; using LeanCode.Firebase.FCM; using MassTransit; using Microsoft.EntityFrameworkCore; @@ -33,4 +34,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ConfigurePushNotificationTokenEntity(setTokenColumnMaxLength: true); } + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + base.ConfigureConventions(configurationBuilder); + configurationBuilder.Properties().ArePrefixedTypedId(); + configurationBuilder.Properties().ArePrefixedTypedId(); + } } diff --git a/test/LeanCode.IntegrationTests/CQRSTests.cs b/test/LeanCode.IntegrationTests/CQRSTests.cs index d7d894c5c..dd7b09029 100644 --- a/test/LeanCode.IntegrationTests/CQRSTests.cs +++ b/test/LeanCode.IntegrationTests/CQRSTests.cs @@ -65,8 +65,8 @@ private async Task AddEntityAndVerifyResultsAsync() entities .Should() .Satisfy( - e1 => e1.Value == "test-entity" && e1.Id != Guid.Empty, - e2 => e2.Value == "test-entity-consumer" && e2.Id != Guid.Empty + e1 => e1.Value == "test-entity" && e1.Id != EntityId.Empty, + e2 => e2.Value == "test-entity-consumer" && e2.Id != EntityId.Empty ); } diff --git a/test/LeanCode.IntegrationTests/EFRepositoryTests.cs b/test/LeanCode.IntegrationTests/EFRepositoryTests.cs index e22f5d166..e7ed9f791 100644 --- a/test/LeanCode.IntegrationTests/EFRepositoryTests.cs +++ b/test/LeanCode.IntegrationTests/EFRepositoryTests.cs @@ -15,7 +15,7 @@ public class EFRepositoryTests : IAsyncLifetime [IntegrationFact] public async Task Default_implementation_of_EFRepository_works() { - var entity = new Entity { Id = Guid.NewGuid(), Value = "test value" }; + var entity = new Entity { Id = EntityId.New(), Value = "test value" }; await EnsureEntityDoesNotExistAsync(entity); await AddEntityAsync(entity); @@ -55,7 +55,7 @@ private async Task EnsureEntityDoesNotExistAsync(Entity entity) public ValueTask DisposeAsync() => app.DisposeAsync(); - private sealed class EntityRepository : EFRepository + private sealed class EntityRepository : EFRepository { public EntityRepository(TestDbContext dbContext) : base(dbContext) { } diff --git a/test/LeanCode.IntegrationTests/LeanCode.IntegrationTests.csproj b/test/LeanCode.IntegrationTests/LeanCode.IntegrationTests.csproj index b726c5eb6..f947ae543 100644 --- a/test/LeanCode.IntegrationTests/LeanCode.IntegrationTests.csproj +++ b/test/LeanCode.IntegrationTests/LeanCode.IntegrationTests.csproj @@ -10,6 +10,10 @@ + diff --git a/test/LeanCode.IntegrationTests/TimestampTzTests.cs b/test/LeanCode.IntegrationTests/TimestampTzTests.cs index 7d9e2c2ef..ea95e01ac 100644 --- a/test/LeanCode.IntegrationTests/TimestampTzTests.cs +++ b/test/LeanCode.IntegrationTests/TimestampTzTests.cs @@ -13,14 +13,14 @@ public class TimestampTzTests : IAsyncLifetime private readonly Meeting meeting1 = new() { - Id = Guid.NewGuid(), + Id = MeetingId.New(), Name = "First", StartTime = new(Date.ToDateTime(new(10, 0), DateTimeKind.Utc), "Asia/Tokyo"), }; private readonly Meeting meeting2 = new() { - Id = Guid.NewGuid(), + Id = MeetingId.New(), Name = "Second", StartTime = new(Date.ToDateTime(new(14, 0), DateTimeKind.Utc), "America/Los_Angeles"), }; diff --git a/test/LeanCode.IntegrationTests/docker/docker-compose.yml b/test/LeanCode.IntegrationTests/docker/docker-compose.yml index 32cdecab0..9ad141e03 100755 --- a/test/LeanCode.IntegrationTests/docker/docker-compose.yml +++ b/test/LeanCode.IntegrationTests/docker/docker-compose.yml @@ -33,7 +33,7 @@ services: #### Infrastructure sqlserver: - image: mcr.microsoft.com/mssql/server:2022-latest + image: mcr.microsoft.com/mssql/server:2025-latest environment: ACCEPT_EULA: Y MSSQL_SA_PASSWORD: Passw12# @@ -41,7 +41,7 @@ services: - "1433:1433" postgres: - image: postgres:15 + image: postgres:17 environment: POSTGRES_PASSWORD: Passw12# ports: