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
12 changes: 12 additions & 0 deletions docs/efcore.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ Good to know:
- **Scaffolding gives you `NpgsqlRange<T>`.** `dotnet ef dbcontext scaffold` maps range columns to
`NpgsqlRange<T>`, 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<int>` 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<Int32Range, int>` for `int4multirange`. With
`UseValueRangesNodaTime`, which turns on Npgsql's NodaTime plugin, that plugin takes `daterange`,
`tsrange` and `tstzrange` first (`DateInterval`, `NpgsqlRange<LocalDateTime>`, `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<string>` property, so a value set maps only from a property typed as one.

## Query ranges with LINQ

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// With <c>UseValueRangesNodaTime</c>, which turns on Npgsql's NodaTime plugin, that plugin takes the
/// <c>daterange</c>, <c>tsrange</c> and <c>tstzrange</c> names first, and the other names, the multiranges
/// included, still resolve to the core types (<c>docs/efcore.md</c>, #56). A NodaTime range property is
/// mapped by its CLR type, so this concerns the name alone.
/// </summary>
[TestClass]
public sealed class NodaStoreTypeNameResolutionTests
{
public static IEnumerable<object[]> Names =>
[
["daterange", typeof(DateInterval)], ["tsrange", typeof(NpgsqlRange<LocalDateTime>)], ["tstzrange", typeof(Interval)],
["int4range", typeof(Int32Range)], ["timerange", typeof(TimeRange)],
["datemultirange", typeof(RangeSet<DateRange, DateOnly>)], ["tsmultirange", typeof(RangeSet<DateTimeRange, DateTime>)],
["tstzmultirange", typeof(RangeSet<DateTimeOffsetRange, DateTimeOffset>)]
];

[TestMethod]
[DynamicData(nameof(Names))]
public void StoreTypeName_WithNodaTime_ResolvesAsDocumented(string storeType, Type expected)
{
using var context = new NodaTestDbContext();

Assert.AreEqual(expected, context.GetService<IRelationalTypeMappingSource>().FindMapping(storeType)?.ClrType, storeType);
}

[TestMethod]
public void NodaTimeRangeProperty_MapsByItsClrType()
{
var source = new NodaTestDbContext().GetService<IRelationalTypeMappingSource>();

Assert.AreEqual("daterange", source.FindMapping(typeof(LocalDateRange))?.StoreType);
Assert.AreEqual("tsrange", source.FindMapping(typeof(LocalDateTimeRange))?.StoreType);
Assert.AreEqual("tstzrange", source.FindMapping(typeof(InstantRange))?.StoreType);
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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 (<c>docs/efcore.md</c>, #56). A
/// property's CLR type decides before any name: an <see cref="NpgsqlRange{T}"/> property keeps Npgsql's
/// mapping, and a range property given another range's column type is not mapped at all.
/// </summary>
[TestClass]
public sealed class StoreTypeNameResolutionTests
{
public static IEnumerable<object[]> 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<Int32Range, int>)], ["int8multirange", typeof(RangeSet<Int64Range, long>)],
["nummultirange", typeof(RangeSet<DecimalRange, decimal>)], ["datemultirange", typeof(RangeSet<DateRange, DateOnly>)],
["tsmultirange", typeof(RangeSet<DateTimeRange, DateTime>)], ["tstzmultirange", typeof(RangeSet<DateTimeOffsetRange, DateTimeOffset>)],
["timemultirange", typeof(RangeSet<TimeRange, TimeOnly>)], ["float8multirange", typeof(RangeSet<Float64Range, double>)],
["float4multirange", typeof(RangeSet<Float32Range, float>)]
];

[TestMethod]
[DynamicData(nameof(RangeNames))]
public void RangeStoreTypeName_ResolvesToThisPackagesType(string storeType, Type expected)
{
using var context = new TestDbContext();

Assert.AreEqual(expected, context.GetService<IRelationalTypeMappingSource>().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<IRelationalTypeMappingSource>().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<int>), raw.GetTypeMapping().ClrType);
}

[TestMethod]
public void RangeProperty_WithAnotherRangesColumnType_FailsModelBuilding()
{
using var context = new ColumnTypeContext(seatsColumnType: "int8range");

var error = Assert.ThrowsExactly<InvalidOperationException>(() => 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<int> 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<IModelCacheKeyFactory, ColumnTypeCacheKeyFactory>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ColumnTypeRow>().Property(r => r.Raw).HasColumnType("int4range");
if (seatsColumnType is not null) modelBuilder.Entity<ColumnTypeRow>().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);
}
}
15 changes: 15 additions & 0 deletions test/oracles/claims.json
Original file line number Diff line number Diff line change
Expand Up @@ -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<LocalDateTime>, Interval) and the multiranges still resolve to the core RangeSet types; an array store type name never resolves to a value set; an NpgsqlRange<int> 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",
Expand Down
Loading