feat: support SemanticVersion and SemanticVersionRange as JSON dictionary keys
All checks were successful
default / dotnet-default-workflow (push) Successful in 56s

System.Text.Json requires custom converters to override
ReadAsPropertyName/WriteAsPropertyName in order to serialize
types used as dictionary keys. Add those overrides to both
converters so SemanticVersion and SemanticVersionRange can round
trip as Dictionary<TKey,TValue> keys.

Add failing-then-passing tests for both key types.
This commit is contained in:
Louis Seubert 2026-07-11 22:05:15 +02:00
commit 71353363f2
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
5 changed files with 100 additions and 3 deletions

View file

@ -14,6 +14,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
### Added ### Added
- `SemanticVersion` and `SemanticVersionRange` can now be used as `System.Text.Json`
dictionary keys. The custom converters override `ReadAsPropertyName`/`WriteAsPropertyName`
so `Dictionary<TKey, TValue>` round-trips correctly.
### Changed ### Changed
- `SemanticVersion` `==`/`Equals`/`GetHashCode` now ignore build metadata, matching precedence comparison rules. - `SemanticVersion` `==`/`Equals`/`GetHashCode` now ignore build metadata, matching precedence comparison rules.

View file

@ -1,6 +1,8 @@
// Copyright (c) The Geekeey Authors // Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2 // SPDX-License-Identifier: EUPL-1.2
using System.Text.Json;
namespace Geekeey.SemVer.Tests; namespace Geekeey.SemVer.Tests;
internal sealed class SemanticVersionRangeTests internal sealed class SemanticVersionRangeTests
@ -201,7 +203,7 @@ internal sealed class SemanticVersionRangeTests
public async Task I_can_serialize_to_json() public async Task I_can_serialize_to_json()
{ {
var r = SemanticVersionRange.Parse("[1.2.3,2.0.0)"); var r = SemanticVersionRange.Parse("[1.2.3,2.0.0)");
var json = System.Text.Json.JsonSerializer.Serialize(r); var json = JsonSerializer.Serialize(r);
await Assert.That(json).IsEqualTo("\"^1.2.3\""); await Assert.That(json).IsEqualTo("\"^1.2.3\"");
} }
@ -209,7 +211,7 @@ internal sealed class SemanticVersionRangeTests
public async Task I_can_deserialize_from_json() public async Task I_can_deserialize_from_json()
{ {
var json = "\"^1.2.3\""; var json = "\"^1.2.3\"";
var r = System.Text.Json.JsonSerializer.Deserialize<SemanticVersionRange>(json); var r = JsonSerializer.Deserialize<SemanticVersionRange>(json);
await Assert.That(r.ToString()).IsEqualTo("^1.2.3"); await Assert.That(r.ToString()).IsEqualTo("^1.2.3");
await Assert.That(r.Contains(new SemanticVersion(1, 2, 4))).IsTrue(); await Assert.That(r.Contains(new SemanticVersion(1, 2, 4))).IsTrue();
} }
@ -283,6 +285,24 @@ internal sealed class SemanticVersionRangeTests
await Assert.That(charsWritten).IsEqualTo(0); await Assert.That(charsWritten).IsEqualTo(0);
} }
[Test]
public async Task I_can_serialize_range_as_dictionary_key()
{
var dict = new Dictionary<SemanticVersionRange, int>
{
[SemanticVersionRange.Parse("^1.2.3")] = 1,
[SemanticVersionRange.Parse("^2.0.0")] = 2,
};
var json = JsonSerializer.Serialize(dict);
var roundTripped = JsonSerializer.Deserialize<Dictionary<SemanticVersionRange, int>>(json);
await Assert.That(roundTripped).IsNotNull();
await Assert.That(roundTripped!.Count).IsEqualTo(2);
await Assert.That(roundTripped[SemanticVersionRange.Parse("^1.2.3")]).IsEqualTo(1);
await Assert.That(roundTripped[SemanticVersionRange.Parse("^2.0.0")]).IsEqualTo(2);
}
[Test] [Test]
public async Task I_can_treat_structurally_equal_ranges_as_equal() public async Task I_can_treat_structurally_equal_ranges_as_equal()
{ {

View file

@ -257,8 +257,25 @@ internal sealed class SemanticVersionTests
public async Task I_can_deserialize_empty_string() public async Task I_can_deserialize_empty_string()
{ {
var json = "\"\""; var json = "\"\"";
await Assert.That(() => JsonSerializer.Deserialize<SemanticVersion>(json)) await Assert.That(() => JsonSerializer.Deserialize<SemanticVersion>(json))
.Throws<JsonException>(); .Throws<JsonException>();
} }
[Test]
public async Task I_can_serialize_version_as_dictionary_key()
{
var dict = new Dictionary<SemanticVersion, int>
{
[new SemanticVersion(1, 2, 3)] = 1,
[new SemanticVersion(2, 0, 0, "rc.1", "build")] = 2,
};
var json = JsonSerializer.Serialize(dict);
var roundTripped = JsonSerializer.Deserialize<Dictionary<SemanticVersion, int>>(json);
await Assert.That(roundTripped).IsNotNull();
await Assert.That(roundTripped!.Count).IsEqualTo(2);
await Assert.That(roundTripped[new SemanticVersion(1, 2, 3)]).IsEqualTo(1);
await Assert.That(roundTripped[new SemanticVersion(2, 0, 0, "rc.1", "build")]).IsEqualTo(2);
}
} }

View file

@ -37,5 +37,33 @@ public readonly partial struct SemanticVersion
{ {
writer.WriteStringValue(value.ToString("f", null)); writer.WriteStringValue(value.ToString("f", null));
} }
public override SemanticVersion ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType is JsonTokenType.Null)
{
return default;
}
var name = reader.GetString();
if (name is null)
{
return default;
}
try
{
return Parse(name);
}
catch (FormatException exception)
{
throw new JsonException(exception.Message, exception);
}
}
public override void WriteAsPropertyName(Utf8JsonWriter writer, SemanticVersion value, JsonSerializerOptions options)
{
writer.WritePropertyName(value.ToString("f", null));
}
} }
} }

View file

@ -37,5 +37,33 @@ public readonly partial struct SemanticVersionRange
{ {
writer.WriteStringValue(value.ToString()); writer.WriteStringValue(value.ToString());
} }
public override SemanticVersionRange ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType is JsonTokenType.Null)
{
return default;
}
var name = reader.GetString();
if (name is null)
{
return default;
}
try
{
return Parse(name);
}
catch (FormatException exception)
{
throw new JsonException(exception.Message, exception);
}
}
public override void WriteAsPropertyName(Utf8JsonWriter writer, SemanticVersionRange value, JsonSerializerOptions options)
{
writer.WritePropertyName(value.ToString());
}
} }
} }