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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions src/Olve.Utilities/Ids/Id.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;

namespace Olve.Utilities.Ids;

/// <summary>
/// Represents an opaque, type-agnostic identifier backed by a <see cref="Guid"/>.
/// </summary>
[DebuggerDisplay("{ToDisplayString()}")]
public readonly record struct Id(Guid Value) : IComparable<Id>
[JsonConverter(typeof(IdJsonConverter))]
public readonly record struct Id(Guid Value) : IComparable<Id>, IParsable<Id>
{
/// <summary>
/// Gets the underlying <see cref="Guid"/> value for this identifier.
Expand Down Expand Up @@ -90,6 +92,34 @@ public static bool TryParse<T>(string text, out Id<T> id)
return false;
}

/// <summary>
/// Parses a string into an <see cref="Id"/>.
/// </summary>
/// <param name="s">The string to parse.</param>
/// <param name="provider">An optional format provider (ignored).</param>
/// <returns>The parsed <see cref="Id"/>.</returns>
/// <exception cref="FormatException">Thrown when <paramref name="s"/> is not a valid GUID.</exception>
public static Id Parse(string s, IFormatProvider? provider)
{
if (TryParse(s, provider, out var result))
return result;
throw new FormatException($"'{s}' is not a valid Id.");
}

/// <summary>
/// Attempts to parse a string into an <see cref="Id"/>.
/// </summary>
/// <param name="s">The string to parse.</param>
/// <param name="provider">An optional format provider (ignored).</param>
/// <param name="result">When the method returns, contains the parsed <see cref="Id"/> if parsing succeeded; otherwise the default value.</param>
/// <returns><c>true</c> if <paramref name="s"/> was parsed successfully; otherwise <c>false</c>.</returns>
public static bool TryParse(string? s, IFormatProvider? provider, out Id result)
{
result = default;
if (s is null) return false;
return TryParse(s, out result);
}

/// <summary>
/// Returns the canonical string representation of the underlying <see cref="Guid"/>.
/// </summary>
Expand Down Expand Up @@ -173,7 +203,8 @@ private static Guid DeterministicGuidFromName(Guid namespaceGuid, string name)
/// </summary>
/// <typeparam name="T">The logical entity type represented by this identifier (used only for compile-time safety).</typeparam>
[DebuggerDisplay("{ToDisplayString()}")]
public readonly record struct Id<T> : IComparable<Id<T>>
[JsonConverter(typeof(IdOfTJsonConverterFactory))]
public readonly record struct Id<T> : IComparable<Id<T>>, IParsable<Id<T>>
{
/// <summary>
/// Gets the underlying untyped <see cref="Id"/> value held by this typed identifier.
Expand All @@ -186,6 +217,34 @@ private static Guid DeterministicGuidFromName(Guid namespaceGuid, string name)
/// <param name="value">The underlying <see cref="Id"/> value.</param>
public Id(Id value) => Value = value;

/// <summary>
/// Parses a string into an <see cref="Id{T}"/>.
/// </summary>
/// <param name="s">The string to parse.</param>
/// <param name="provider">An optional format provider (ignored).</param>
/// <returns>The parsed <see cref="Id{T}"/>.</returns>
/// <exception cref="FormatException">Thrown when <paramref name="s"/> is not a valid GUID.</exception>
public static Id<T> Parse(string s, IFormatProvider? provider)
{
if (TryParse(s, provider, out var result))
return result;
throw new FormatException($"'{s}' is not a valid Id<{typeof(T).Name}>.");
}

/// <summary>
/// Attempts to parse a string into an <see cref="Id{T}"/>.
/// </summary>
/// <param name="s">The string to parse.</param>
/// <param name="provider">An optional format provider (ignored).</param>
/// <param name="result">When the method returns, contains the parsed <see cref="Id{T}"/> if parsing succeeded; otherwise the default value.</param>
/// <returns><c>true</c> if <paramref name="s"/> was parsed successfully; otherwise <c>false</c>.</returns>
public static bool TryParse(string? s, IFormatProvider? provider, out Id<T> result)
{
result = default;
if (s is null) return false;
return Id.TryParse(s, out result);
}

/// <summary>
/// Returns a human-readable representation of this typed identifier, including the logical type name and value.
/// </summary>
Expand Down
23 changes: 23 additions & 0 deletions src/Olve.Utilities/Ids/IdJsonConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Olve.Utilities.Ids;

/// <summary>
/// AOT-safe JSON converter for <see cref="Id"/> that serializes as a GUID string.
/// </summary>
public sealed class IdJsonConverter : JsonConverter<Id>
{
/// <inheritdoc />
public override Id Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var guid = reader.GetGuid();
return new Id(guid);
}

/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, Id value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.Value);
}
}
24 changes: 24 additions & 0 deletions src/Olve.Utilities/Ids/IdOfTJsonConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Olve.Utilities.Ids;

/// <summary>
/// AOT-safe JSON converter for <see cref="Id{T}"/> that serializes as a GUID string.
/// </summary>
/// <typeparam name="T">The logical entity type.</typeparam>
public sealed class IdOfTJsonConverter<T> : JsonConverter<Id<T>>
{
/// <inheritdoc />
public override Id<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var guid = reader.GetGuid();
return new Id<T>(new Id(guid));
}

/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, Id<T> value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.Value.Value);
}
}
29 changes: 29 additions & 0 deletions src/Olve.Utilities/Ids/IdOfTJsonConverterFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Olve.Utilities.Ids;

/// <summary>
/// Factory that creates <see cref="IdOfTJsonConverter{T}"/> instances for any <see cref="Id{T}"/>.
/// In AOT contexts, the source generator bypasses this factory entirely when consumers
/// declare <c>[JsonSerializable(typeof(Id&lt;MyEntity&gt;))]</c> on their <see cref="JsonSerializerContext"/>.
/// </summary>
public sealed class IdOfTJsonConverterFactory : JsonConverterFactory
{
/// <inheritdoc />
public override bool CanConvert(Type typeToConvert)
=> typeToConvert.IsGenericType
&& typeToConvert.GetGenericTypeDefinition() == typeof(Id<>);

/// <inheritdoc />
[UnconditionalSuppressMessage("AOT", "IL3050",
Justification = "In AOT contexts, the source generator bypasses this factory. " +
"MakeGenericType is only called in JIT scenarios.")]
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
var typeArg = typeToConvert.GetGenericArguments()[0];
var converterType = typeof(IdOfTJsonConverter<>).MakeGenericType(typeArg);
return (JsonConverter)Activator.CreateInstance(converterType)!;
}
}
1 change: 1 addition & 0 deletions src/Olve.Utilities/Olve.Utilities.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<PackageRequireLicenseAcceptance>true</PackageRequireLicenseAcceptance>
<PackageReadmeFile>README.md</PackageReadmeFile>
<IsTrimmable>true</IsTrimmable>
<IsAotCompatible>true</IsAotCompatible>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
Expand Down
88 changes: 88 additions & 0 deletions tests/Olve.Utilities.Tests/Ids/IdJsonSerializationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Olve.Utilities.Ids;
using Assert = TUnit.Assertions.Assert;

namespace Olve.Utilities.Tests.Ids;

public partial class IdJsonSerializationTests
{
private readonly record struct Pipeline;

[JsonSerializable(typeof(Id))]
[JsonSerializable(typeof(Id<Pipeline>))]
private partial class TestJsonContext : JsonSerializerContext;

[Test]
public async Task Id_RoundTrips_ThroughJson()
{
var original = Id.New();
var json = JsonSerializer.Serialize(original);
var deserialized = JsonSerializer.Deserialize<Id>(json);

await Assert.That(deserialized).IsEqualTo(original);
}

[Test]
public async Task IdOfT_RoundTrips_ThroughJson()
{
var original = Id.New<Pipeline>();
var json = JsonSerializer.Serialize(original);
var deserialized = JsonSerializer.Deserialize<Id<Pipeline>>(json);

await Assert.That(deserialized).IsEqualTo(original);
}

[Test]
public async Task Id_SerializesAsBareGuidString()
{
var id = Id.New();
var json = JsonSerializer.Serialize(id);

await Assert.That(json).IsEqualTo($"\"{id.Value}\"");
}

[Test]
public async Task IdOfT_SerializesAsBareGuidString()
{
var id = Id.New<Pipeline>();
var json = JsonSerializer.Serialize(id);

await Assert.That(json).IsEqualTo($"\"{id.Value.Value}\"");
}

[Test]
public async Task Id_RoundTrips_ThroughAotContext()
{
var original = Id.New();
var json = JsonSerializer.Serialize(original, TestJsonContext.Default.Id);
var deserialized = JsonSerializer.Deserialize(json, TestJsonContext.Default.Id);

await Assert.That(deserialized).IsEqualTo(original);
}

[Test]
public async Task IdOfT_RoundTrips_ThroughAotContext()
{
var original = Id.New<Pipeline>();
var json = JsonSerializer.Serialize(original, TestJsonContext.Default.IdPipeline);
var deserialized = JsonSerializer.Deserialize(json, TestJsonContext.Default.IdPipeline);

await Assert.That(deserialized).IsEqualTo(original);
}

[Test]
public void Id_DeserializeInvalidString_ThrowsJsonException()
{
var json = "\"not-a-guid\"";

try
{
JsonSerializer.Deserialize<Id>(json);
Assert.Fail("Should have thrown");
}
catch (JsonException)
{
}
}
}
75 changes: 75 additions & 0 deletions tests/Olve.Utilities.Tests/Ids/IdParsableTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using Olve.Utilities.Ids;
using Assert = TUnit.Assertions.Assert;

namespace Olve.Utilities.Tests.Ids;

public class IdParsableTests
{
private readonly record struct Pipeline;

[Test]
public async Task Id_Parse_ValidString_ReturnsParsedId()
{
var original = Id.New();
var text = original.ToString();

var parsed = Id.Parse(text, null);

await Assert.That(parsed).IsEqualTo(original);
}

[Test]
public void Id_Parse_InvalidString_ThrowsFormatException()
{
try
{
Id.Parse("bad", null);
Assert.Fail("Should have thrown");
}
catch (FormatException)
{
}
}

[Test]
public async Task Id_TryParse_NullString_ReturnsFalse()
{
var result = Id.TryParse(null, null, out var id);

await Assert.That(result).IsFalse();
await Assert.That(id).IsEqualTo(default(Id));
}

[Test]
public async Task IdOfT_Parse_ValidString_ReturnsParsedId()
{
var original = Id.New<Pipeline>();
var text = original.ToString();

var parsed = Id<Pipeline>.Parse(text, null);

await Assert.That(parsed).IsEqualTo(original);
}

[Test]
public void IdOfT_Parse_InvalidString_ThrowsFormatException()
{
try
{
Id<Pipeline>.Parse("bad", null);
Assert.Fail("Should have thrown");
}
catch (FormatException)
{
}
}

[Test]
public async Task IdOfT_TryParse_NullString_ReturnsFalse()
{
var result = Id<Pipeline>.TryParse(null, null, out var id);

await Assert.That(result).IsFalse();
await Assert.That(id).IsEqualTo(default(Id<Pipeline>));
}
}
Loading