From 4003bb450e2185ed488e66663c5f9a3f2c76ee7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sun, 25 Oct 2026 20:17:02 +0100 Subject: [PATCH] docs: say what a store type name maps to on its own (#56) docs/efcore.md (Map ranges to PostgreSQL columns) gains three Good-to-know bullets: - a property's CLR type picks its mapping before any column type: an NpgsqlRange with HasColumnType("int4range") keeps Npgsql's, and an Int32Range with HasColumnType("int8range") fails at model building; - a range or multirange name on its own maps to this package's types, except that with UseValueRangesNodaTime Npgsql's NodaTime plugin takes daterange, tsrange and tstzrange first; - an array name stays Npgsql's, because text[] also belongs to string[]; a value set maps only from a property typed as one. The asymmetry is intended. StoreTypeNameResolutionTests and NodaStoreTypeNameResolutionTests hold all of it (claim E77). Seeded both ways: a name-blind range plugin fails the 18 name rows, and a set plugin that takes text[] fails the array row. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit fd32789900a418d8050235fba30123b3a149d4fc) --- docs/efcore.md | 12 +++ .../NodaStoreTypeNameResolutionTests.cs | 45 ++++++++ .../StoreTypeNameResolutionTests.cs | 100 ++++++++++++++++++ test/oracles/claims.json | 15 +++ 4 files changed, 172 insertions(+) create mode 100644 test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaStoreTypeNameResolutionTests.cs create mode 100644 test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/StoreTypeNameResolutionTests.cs diff --git a/docs/efcore.md b/docs/efcore.md index 9988e65..53c2bf5 100644 --- a/docs/efcore.md +++ b/docs/efcore.md @@ -38,6 +38,18 @@ Good to know: - **Scaffolding gives you `NpgsqlRange`.** `dotnet ef dbcontext scaffold` maps range columns to `NpgsqlRange`, not to these types, because the package provides no design-time services. Change the property types by hand after scaffolding. +- **A property's type picks its mapping, before any column type.** `HasColumnType("int4range")` on + an `NpgsqlRange` property keeps Npgsql's own mapping. On an `Int32Range` property, the column + type of another range, such as `int8range`, fails when EF Core builds the model + (`InvalidOperationException`). +- **A range or multirange column type on its own maps to these types.** Ask EF Core's + `IRelationalTypeMappingSource` for `FindMapping("int4range")` with no property type, and you get + the `Int32Range` mapping, or `RangeSet` for `int4multirange`. With + `UseValueRangesNodaTime`, which turns on Npgsql's NodaTime plugin, that plugin takes `daterange`, + `tsrange` and `tstzrange` first (`DateInterval`, `NpgsqlRange`, `Interval`). The + multiranges still map to the core types. +- **An array column type on its own stays Npgsql's.** `text[]` is also the column type of every + `string[]` and `List` property, so a value set maps only from a property typed as one. ## Query ranges with LINQ diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaStoreTypeNameResolutionTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaStoreTypeNameResolutionTests.cs new file mode 100644 index 0000000..8a65e75 --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaStoreTypeNameResolutionTests.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using NodaTime; +using NpgsqlTypes; + +namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests; + +using CodoMetis.ValueRanges; + +/// +/// With UseValueRangesNodaTime, which turns on Npgsql's NodaTime plugin, that plugin takes the +/// daterange, tsrange and tstzrange names first, and the other names, the multiranges +/// included, still resolve to the core types (docs/efcore.md, #56). A NodaTime range property is +/// mapped by its CLR type, so this concerns the name alone. +/// +[TestClass] +public sealed class NodaStoreTypeNameResolutionTests +{ + public static IEnumerable Names => + [ + ["daterange", typeof(DateInterval)], ["tsrange", typeof(NpgsqlRange)], ["tstzrange", typeof(Interval)], + ["int4range", typeof(Int32Range)], ["timerange", typeof(TimeRange)], + ["datemultirange", typeof(RangeSet)], ["tsmultirange", typeof(RangeSet)], + ["tstzmultirange", typeof(RangeSet)] + ]; + + [TestMethod] + [DynamicData(nameof(Names))] + public void StoreTypeName_WithNodaTime_ResolvesAsDocumented(string storeType, Type expected) + { + using var context = new NodaTestDbContext(); + + Assert.AreEqual(expected, context.GetService().FindMapping(storeType)?.ClrType, storeType); + } + + [TestMethod] + public void NodaTimeRangeProperty_MapsByItsClrType() + { + var source = new NodaTestDbContext().GetService(); + + Assert.AreEqual("daterange", source.FindMapping(typeof(LocalDateRange))?.StoreType); + Assert.AreEqual("tsrange", source.FindMapping(typeof(LocalDateTimeRange))?.StoreType); + Assert.AreEqual("tstzrange", source.FindMapping(typeof(InstantRange))?.StoreType); + } +} diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/StoreTypeNameResolutionTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/StoreTypeNameResolutionTests.cs new file mode 100644 index 0000000..e857a51 --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/StoreTypeNameResolutionTests.cs @@ -0,0 +1,100 @@ +using CodoMetis.ValueRanges.Core; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using NpgsqlTypes; + +namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests; + +/// +/// A range or multirange store type name on its own resolves to this package's type, and a value set never +/// takes an array name, which belongs to Npgsql's own array mapping (docs/efcore.md, #56). A +/// property's CLR type decides before any name: an property keeps Npgsql's +/// mapping, and a range property given another range's column type is not mapped at all. +/// +[TestClass] +public sealed class StoreTypeNameResolutionTests +{ + public static IEnumerable RangeNames => + [ + ["int4range", typeof(Int32Range)], ["int8range", typeof(Int64Range)], ["numrange", typeof(DecimalRange)], + ["daterange", typeof(DateRange)], ["tsrange", typeof(DateTimeRange)], ["tstzrange", typeof(DateTimeOffsetRange)], + ["timerange", typeof(TimeRange)], ["float8range", typeof(Float64Range)], ["float4range", typeof(Float32Range)], + ["int4multirange", typeof(RangeSet)], ["int8multirange", typeof(RangeSet)], + ["nummultirange", typeof(RangeSet)], ["datemultirange", typeof(RangeSet)], + ["tsmultirange", typeof(RangeSet)], ["tstzmultirange", typeof(RangeSet)], + ["timemultirange", typeof(RangeSet)], ["float8multirange", typeof(RangeSet)], + ["float4multirange", typeof(RangeSet)] + ]; + + [TestMethod] + [DynamicData(nameof(RangeNames))] + public void RangeStoreTypeName_ResolvesToThisPackagesType(string storeType, Type expected) + { + using var context = new TestDbContext(); + + Assert.AreEqual(expected, context.GetService().FindMapping(storeType)?.ClrType, storeType); + } + + [TestMethod] + [DataRow("text[]"), DataRow("uuid[]"), DataRow("smallint[]"), DataRow("integer[]"), DataRow("bigint[]"), DataRow("numeric[]")] + [DataRow("date[]"), DataRow("time without time zone[]"), DataRow("timestamp without time zone[]"), DataRow("timestamp with time zone[]")] + [DataRow("double precision[]"), DataRow("real[]")] + public void ArrayStoreTypeName_KeepsNpgsqlsMapping(string storeType) + { + using var context = new TestDbContext(); + + var clrType = context.GetService().FindMapping(storeType)?.ClrType; + Assert.IsNotNull(clrType, storeType); + Assert.IsFalse(clrType.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IValueSet<>)), + $"{storeType} resolved to the value set {clrType}"); + } + + [TestMethod] + public void NpgsqlRangeProperty_WithARangeColumnType_KeepsNpgsqlsMapping() + { + using var context = new ColumnTypeContext(seatsColumnType: null); + + var raw = context.Model.FindEntityType(typeof(ColumnTypeRow))!.FindProperty(nameof(ColumnTypeRow.Raw))!; + Assert.AreEqual("int4range", raw.GetColumnType()); + Assert.AreEqual(typeof(NpgsqlRange), raw.GetTypeMapping().ClrType); + } + + [TestMethod] + public void RangeProperty_WithAnotherRangesColumnType_FailsModelBuilding() + { + using var context = new ColumnTypeContext(seatsColumnType: "int8range"); + + var error = Assert.ThrowsExactly(() => context.Model); + StringAssert.Contains(error.Message, "'int8range'"); + } + + public sealed class ColumnTypeRow + { + public int Id { get; set; } + + public Int32Range Seats { get; set; } = Int32Range.Empty; + + public NpgsqlRange Raw { get; set; } + } + + private sealed class ColumnTypeContext(string? seatsColumnType) : DbContext + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=valueranges_tests;Username=postgres", npgsql => npgsql.UseValueRanges()) + .ReplaceService(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().Property(r => r.Raw).HasColumnType("int4range"); + if (seatsColumnType is not null) modelBuilder.Entity().Property(r => r.Seats).HasColumnType(seatsColumnType); + } + + public string? SeatsColumnType => seatsColumnType; + } + + private sealed class ColumnTypeCacheKeyFactory : IModelCacheKeyFactory + { + public object Create(DbContext context, bool designTime) => (context.GetType(), ((ColumnTypeContext)context).SeatsColumnType, designTime); + } +} diff --git a/test/oracles/claims.json b/test/oracles/claims.json index f288716..d68e98d 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1244,6 +1244,21 @@ "TranslationSupportProbeTests" ] }, + { + "id": "E77-store-type-names-resolve-to-range-types", + "source": "docs/efcore.md#map-ranges-to-postgresql-columns", + "quote": "**A range or multirange column type on its own maps to these types.**", + "claim": "With UseValueRanges, FindMapping on a range or multirange store type name with no CLR type returns this package's range or RangeSet type for every core range type; with UseValueRangesNodaTime, Npgsql's NodaTime plugin answers daterange, tsrange and tstzrange first (DateInterval, NpgsqlRange, Interval) and the multiranges still resolve to the core RangeSet types; an array store type name never resolves to a value set; an NpgsqlRange property with HasColumnType(\"int4range\") keeps Npgsql's mapping, and an Int32Range property with HasColumnType(\"int8range\") throws InvalidOperationException at model building.", + "oracle": "claim", + "surfaces": [ + "ValueRangesTypeMappingSourcePlugin.FindMapping", + "ValueSetsTypeMappingSourcePlugin.FindMapping" + ], + "heldBy": [ + "StoreTypeNameResolutionTests", + "NodaStoreTypeNameResolutionTests" + ] + }, { "id": "J01-tostring-is-postgres-literal", "source": "docs/serialization.md#print-a-range-as-a-literal",