From 27a3dd47f0da94530eb94d700b35ff8784a62e92 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 11 Jul 2026 22:05:15 +0200 Subject: [PATCH] feat: support SemanticVersion and SemanticVersionRange as JSON dictionary keys 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 keys. Add failing-then-passing tests for both key types. --- CHANGELOG.md | 4 +++ src/semver.tests/SemanticVersionRangeTests.cs | 20 +++++++++++++ src/semver.tests/SemanticVersionTests.cs | 19 ++++++++++++- src/semver/SemanticVersion.JsonConverter.cs | 28 +++++++++++++++++++ .../SemanticVersionRange.JsonConverter.cs | 28 +++++++++++++++++++ 5 files changed, 98 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8837c0f..c410ad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added +- `SemanticVersion` and `SemanticVersionRange` can now be used as `System.Text.Json` + dictionary keys. The custom converters override `ReadAsPropertyName`/`WriteAsPropertyName` + so `Dictionary` round-trips correctly. + ### Changed - `SemanticVersion` `==`/`Equals`/`GetHashCode` now ignore build metadata, matching precedence comparison rules. diff --git a/src/semver.tests/SemanticVersionRangeTests.cs b/src/semver.tests/SemanticVersionRangeTests.cs index ec11063..d5d92a6 100644 --- a/src/semver.tests/SemanticVersionRangeTests.cs +++ b/src/semver.tests/SemanticVersionRangeTests.cs @@ -1,6 +1,8 @@ // Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 +using System.Text.Json; + namespace Geekeey.SemVer.Tests; internal sealed class SemanticVersionRangeTests @@ -283,6 +285,24 @@ internal sealed class SemanticVersionRangeTests await Assert.That(charsWritten).IsEqualTo(0); } + [Test] + public async Task I_can_serialize_range_as_dictionary_key() + { + var dict = new Dictionary + { + [SemanticVersionRange.Parse("^1.2.3")] = 1, + [SemanticVersionRange.Parse("^2.0.0")] = 2, + }; + + var json = JsonSerializer.Serialize(dict); + var roundTripped = JsonSerializer.Deserialize>(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] public async Task I_can_treat_structurally_equal_ranges_as_equal() { diff --git a/src/semver.tests/SemanticVersionTests.cs b/src/semver.tests/SemanticVersionTests.cs index 587d643..c3f9c73 100644 --- a/src/semver.tests/SemanticVersionTests.cs +++ b/src/semver.tests/SemanticVersionTests.cs @@ -257,8 +257,25 @@ internal sealed class SemanticVersionTests public async Task I_can_deserialize_empty_string() { var json = "\"\""; - await Assert.That(() => JsonSerializer.Deserialize(json)) .Throws(); } + + [Test] + public async Task I_can_serialize_version_as_dictionary_key() + { + var dict = new Dictionary + { + [new SemanticVersion(1, 2, 3)] = 1, + [new SemanticVersion(2, 0, 0, "rc.1", "build")] = 2, + }; + + var json = JsonSerializer.Serialize(dict); + var roundTripped = JsonSerializer.Deserialize>(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); + } } diff --git a/src/semver/SemanticVersion.JsonConverter.cs b/src/semver/SemanticVersion.JsonConverter.cs index c677e3a..8eb1797 100644 --- a/src/semver/SemanticVersion.JsonConverter.cs +++ b/src/semver/SemanticVersion.JsonConverter.cs @@ -37,5 +37,33 @@ public readonly partial struct SemanticVersion { 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)); + } } } diff --git a/src/semver/SemanticVersionRange.JsonConverter.cs b/src/semver/SemanticVersionRange.JsonConverter.cs index d94d305..f3bcd40 100644 --- a/src/semver/SemanticVersionRange.JsonConverter.cs +++ b/src/semver/SemanticVersionRange.JsonConverter.cs @@ -37,5 +37,33 @@ public readonly partial struct SemanticVersionRange { 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()); + } } }