diff --git a/CHANGELOG.md b/CHANGELOG.md index 919875c3d..42156415b 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 diff --git a/docs/domain/id/index.md b/docs/domain/id/index.md index 35c133c3f..4e7b542e1 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: diff --git a/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs b/src/Domain/LeanCode.DomainModels.EF/PropertiesConfigurationBuilderExtensions.cs index 9495eafb5..14dd15ec0 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 90338b661..108cbef08 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 deleted file mode 100644 index 834becf9a..000000000 --- a/src/Domain/LeanCode.DomainModels.Generators/IdSource.cs +++ /dev/null @@ -1,653 +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.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) - { - var prefix = data.CustomPrefix?.ToLowerInvariant() ?? GetDefaultPrefix(data.TypeName); - var valueLength = 32; - - var randomFactory = !data.SkipRandomGenerator - ? $"public static {data.TypeName} New() => new(Guid.NewGuid());" - : ""; - - // 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; - - 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 {{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}(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) - { - 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; - } - else - { - var span = v.AsSpan(); - return span.Length == MaxLength - && span.StartsWith(TypePrefix) - && span[{{prefix.Length}}] == Separator - && Guid.TryParseExact(span[{{prefix.Length + 1}}..], "N", out _); - } - } - - 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 {{data.TypeName}} Parse(string v) - { - if (TryDeconstruct(v.AsSpan(), out var ulid)) - { - return new {{data.TypeName}}(ulid); - } - 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) - { - if (TryDeconstruct(v, out var ulid)) - { - id = new {{data.TypeName}}(ulid); - return true; - } - else - { - id = 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 static bool IsValid([NotNullWhen(true)] string? v) - { - return TryDeconstruct(v.AsSpan(), out _); - } - - 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); - - 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, - 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 {{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 bool IsValid([NotNullWhen(true)] {{backingType}}? v) - { - return v is not null; - } - - 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 {{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(); - - 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 000000000..98794da9d --- /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 000000000..b2c0ddcf8 --- /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 000000000..1f5646d11 --- /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 => IsEmpty ? string.Empty : 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 000000000..97f1cad2d --- /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 000000000..43cf817c2 --- /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 000000000..dcb8f9d7c --- /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 5ff8d8a66..e8b80dd3e 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; diff --git a/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs b/src/Domain/LeanCode.DomainModels/Ids/ITypedId.cs index 9d390b281..bf1aacc7d 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 501021f5e..a27e11a7e 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 cc60a68ae..a76f68fe0 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 976d8defd..4acbbee52 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 e7d82e9a5..f248a47cf 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/GuidTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/GuidTests.cs index cce8dfea4..c5c5c5861 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 e897c57d7..ef873d984 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 518eb4703..43882ccaa 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 dbd975920..7b1421bfb 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); @@ -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))); } } @@ -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() { @@ -253,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 8f9c73e72..762814ba4 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); @@ -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] @@ -233,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))); } } @@ -276,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() { @@ -315,4 +326,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 d8e5c3c58..3813716bf 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(); @@ -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(); } } @@ -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() { @@ -290,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 3042f1206..5197cd09e 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); @@ -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))); } } @@ -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)); + } } diff --git a/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs b/test/Domain/LeanCode.DomainModels.Tests/Ids/ValidConstructTests.cs index 2583b2e45..2a746df64 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() { diff --git a/test/Domain/LeanCode.DomainModels.Tests/LeanCode.DomainModels.Tests.csproj b/test/Domain/LeanCode.DomainModels.Tests/LeanCode.DomainModels.Tests.csproj index 898639395..a934da6ae 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