From f9531fe112d85cb14c493544b70b3fae6d40eddb Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 11 Jul 2026 22:04:15 +0200 Subject: [PATCH 1/3] fix: make SemanticVersionRange equality structural The type was a record struct whose ==/Equals/GetHashCode were synthesized from all fields, including the _sets array, so two ranges that parse to the same constraint sets compared as unequal (reference-sensitive on the array). This also broke using ranges as dictionary keys, where lookup relies on value equality. Change the type to a plain readonly struct implementing IEquatable with structural equality over the constraint sets (operation + version per comparator). The custom ToString already existed, so no display behaviour is lost. Add a failing-then-passing test. --- CHANGELOG.md | 3 + src/semver.tests/SemanticVersionRangeTests.cs | 10 +++ src/semver/SemanticVersionRange.Formatting.cs | 2 +- .../SemanticVersionRange.JsonConverter.cs | 2 +- src/semver/SemanticVersionRange.Parsing.cs | 2 +- src/semver/SemanticVersionRange.cs | 84 ++++++++++++++++++- 6 files changed, 99 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5db6072..8837c0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Changed - `SemanticVersion` `==`/`Equals`/`GetHashCode` now ignore build metadata, matching precedence comparison rules. +- `SemanticVersionRange` equality (`==`, `Equals`, `GetHashCode`) is now structural and based + on the constraint sets, so ranges that parse to the same comparators compare as equal + (previously it was reference-sensitive on the backing array). ### Fixed diff --git a/src/semver.tests/SemanticVersionRangeTests.cs b/src/semver.tests/SemanticVersionRangeTests.cs index ea392e1..ec11063 100644 --- a/src/semver.tests/SemanticVersionRangeTests.cs +++ b/src/semver.tests/SemanticVersionRangeTests.cs @@ -282,4 +282,14 @@ internal sealed class SemanticVersionRangeTests await Assert.That(success).IsFalse(); await Assert.That(charsWritten).IsEqualTo(0); } + + [Test] + public async Task I_can_treat_structurally_equal_ranges_as_equal() + { + var a = SemanticVersionRange.Parse("^1.2.3"); + var b = SemanticVersionRange.Parse("[1.2.3,2.0.0)"); + + await Assert.That(a == b).IsTrue(); + await Assert.That(a.GetHashCode()).IsEqualTo(b.GetHashCode()); + } } diff --git a/src/semver/SemanticVersionRange.Formatting.cs b/src/semver/SemanticVersionRange.Formatting.cs index 8b1c570..d35d44e 100644 --- a/src/semver/SemanticVersionRange.Formatting.cs +++ b/src/semver/SemanticVersionRange.Formatting.cs @@ -5,7 +5,7 @@ using System.Runtime.CompilerServices; namespace Geekeey.SemVer; -public readonly partial record struct SemanticVersionRange : ISpanFormattable +public readonly partial struct SemanticVersionRange : ISpanFormattable { /// public override string ToString() diff --git a/src/semver/SemanticVersionRange.JsonConverter.cs b/src/semver/SemanticVersionRange.JsonConverter.cs index ff95e73..d94d305 100644 --- a/src/semver/SemanticVersionRange.JsonConverter.cs +++ b/src/semver/SemanticVersionRange.JsonConverter.cs @@ -7,7 +7,7 @@ using System.Text.Json.Serialization; namespace Geekeey.SemVer; [JsonConverter(typeof(SemanticVersionRangeJsonConverter))] -public readonly partial record struct SemanticVersionRange +public readonly partial struct SemanticVersionRange { internal sealed class SemanticVersionRangeJsonConverter : JsonConverter { diff --git a/src/semver/SemanticVersionRange.Parsing.cs b/src/semver/SemanticVersionRange.Parsing.cs index 098f1f4..8234747 100644 --- a/src/semver/SemanticVersionRange.Parsing.cs +++ b/src/semver/SemanticVersionRange.Parsing.cs @@ -5,7 +5,7 @@ using System.Diagnostics.CodeAnalysis; namespace Geekeey.SemVer; -public readonly partial record struct SemanticVersionRange : ISpanParsable +public readonly partial struct SemanticVersionRange : ISpanParsable { #region IParsable diff --git a/src/semver/SemanticVersionRange.cs b/src/semver/SemanticVersionRange.cs index fa1ee37..3a8432b 100644 --- a/src/semver/SemanticVersionRange.cs +++ b/src/semver/SemanticVersionRange.cs @@ -1,13 +1,15 @@ // Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 +using System; + namespace Geekeey.SemVer; /// /// Represents a semantic version range, which is a set of version constraints /// used to match specific semantic versions based on defined ranges or patterns. /// -public readonly partial record struct SemanticVersionRange +public readonly partial struct SemanticVersionRange : IEquatable { // OR of AND-groups. null == empty range (matches nothing). private readonly ConstraintSet[]? _sets; @@ -31,6 +33,86 @@ public readonly partial record struct SemanticVersionRange return _sets.Any(set => set.Includes(version)); } + + /// + public bool Equals(SemanticVersionRange other) + { + if (_sets is null || other._sets is null) + { + return _sets is null && other._sets is null; + } + + if (_sets.Length != other._sets.Length) + { + return false; + } + + for (var i = 0; i < _sets.Length; i++) + { + if (!Equals(_sets[i], other._sets[i])) + { + return false; + } + } + + return true; + } + + private static bool Equals(ConstraintSet x, ConstraintSet y) + { + if (x.Constraints.Length != y.Constraints.Length) + { + return false; + } + + for (var i = 0; i < x.Constraints.Length; i++) + { + if (x.Constraints[i].Operation != y.Constraints[i].Operation || + x.Constraints[i].Version != y.Constraints[i].Version) + { + return false; + } + } + + return true; + } + + /// + public override bool Equals(object? obj) + => obj is SemanticVersionRange other && Equals(other); + + /// + public override int GetHashCode() + { + if (_sets is null) + { + return 0; + } + + var hash = new HashCode(); + foreach (var set in _sets) + { + foreach (var constraint in set.Constraints) + { + hash.Add(constraint.Operation); + hash.Add(constraint.Version); + } + } + + return hash.ToHashCode(); + } + + /// + /// Determines whether two semantic version ranges are equal. + /// + public static bool operator ==(SemanticVersionRange left, SemanticVersionRange right) + => left.Equals(right); + + /// + /// Determines whether two semantic version ranges differ. + /// + public static bool operator !=(SemanticVersionRange left, SemanticVersionRange right) + => !left.Equals(right); } internal enum Comparison { Eq, Neq, Lt, Lte, Gt, Gte } From fba1476d9c01fb271e318f298fc051f48b685e26 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 11 Jul 2026 22:05:15 +0200 Subject: [PATCH 2/3] 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()); + } } } From eb20fb8077b82574009fd84bdf572fa1a68fab90 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 11 Jul 2026 23:08:45 +0200 Subject: [PATCH 3/3] fixup! fix: make SemanticVersionRange equality structural --- src/semver/SemanticVersionRange.cs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/semver/SemanticVersionRange.cs b/src/semver/SemanticVersionRange.cs index 3a8432b..ce8efc5 100644 --- a/src/semver/SemanticVersionRange.cs +++ b/src/semver/SemanticVersionRange.cs @@ -1,8 +1,6 @@ // Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 -using System; - namespace Geekeey.SemVer; /// @@ -79,7 +77,9 @@ public readonly partial struct SemanticVersionRange : IEquatable public override bool Equals(object? obj) - => obj is SemanticVersionRange other && Equals(other); + { + return obj is SemanticVersionRange other && Equals(other); + } /// public override int GetHashCode() @@ -106,13 +106,17 @@ public readonly partial struct SemanticVersionRange : IEquatable public static bool operator ==(SemanticVersionRange left, SemanticVersionRange right) - => left.Equals(right); + { + return left.Equals(right); + } /// /// Determines whether two semantic version ranges differ. /// public static bool operator !=(SemanticVersionRange left, SemanticVersionRange right) - => !left.Equals(right); + { + return !left.Equals(right); + } } internal enum Comparison { Eq, Neq, Lt, Lte, Gt, Gte }