Skip to content

Commit 1aa147f

Browse files
feat: add EF Core PostgreSQL integration for range support
Introduced `CodoMetis.ValueRanges.EFCore.PostgreSQL` package to enable seamless mapping of range types to PostgreSQL range and multirange columns. Implemented LINQ-to-SQL query translation for range operations, including `Contains`, `Overlaps`, and `Union`. Added support for `RangeSet` translation and query expression rewriting. Comprehensive unit tests and documentation updates included.
1 parent 99105cd commit 1aa147f

26 files changed

Lines changed: 1869 additions & 10 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<IsPackable>false</IsPackable>
8+
<NoWarn>$(NoWarn);1591</NoWarn>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<PackageReference Include="MSTest" Version="4.2.3" />
13+
</ItemGroup>
14+
15+
<ItemGroup>
16+
<Using Include="Microsoft.VisualStudio.TestTools.UnitTesting"/>
17+
</ItemGroup>
18+
19+
<ItemGroup>
20+
<ProjectReference Include="..\CodoMetis.ValueRanges.EFCore.PostgreSQL\CodoMetis.ValueRanges.EFCore.PostgreSQL.csproj" />
21+
</ItemGroup>
22+
23+
</Project>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using Microsoft.EntityFrameworkCore.Infrastructure;
3+
using Microsoft.EntityFrameworkCore.Storage;
4+
5+
namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests;
6+
7+
[TestClass]
8+
public sealed class ModelMappingTests
9+
{
10+
private static string ColumnTypeOf(string propertyName)
11+
{
12+
using var context = new TestDbContext();
13+
var property = context.Model.FindEntityType(typeof(Booking))!.FindProperty(propertyName)!;
14+
return property.GetColumnType();
15+
}
16+
17+
[TestMethod]
18+
public void Int32Range_MapsTo_Int4Range() => Assert.AreEqual("int4range", ColumnTypeOf(nameof(Booking.Seats)));
19+
20+
[TestMethod]
21+
public void Int64Range_MapsTo_Int8Range() => Assert.AreEqual("int8range", ColumnTypeOf(nameof(Booking.Tickets)));
22+
23+
[TestMethod]
24+
public void DecimalRange_MapsTo_NumRange() => Assert.AreEqual("numrange", ColumnTypeOf(nameof(Booking.Price)));
25+
26+
[TestMethod]
27+
public void DateRange_MapsTo_DateRange() => Assert.AreEqual("daterange", ColumnTypeOf(nameof(Booking.Period)));
28+
29+
[TestMethod]
30+
public void DateTimeRange_MapsTo_TsRange() => Assert.AreEqual("tsrange", ColumnTypeOf(nameof(Booking.LocalTime)));
31+
32+
[TestMethod]
33+
public void DateTimeOffsetRange_MapsTo_TstzRange() =>
34+
Assert.AreEqual("tstzrange", ColumnTypeOf(nameof(Booking.InstantTime)));
35+
36+
[TestMethod]
37+
public void DateRangeSet_MapsTo_DateMultirange() =>
38+
Assert.AreEqual("datemultirange", ColumnTypeOf(nameof(Booking.BlockedDays)));
39+
40+
[TestMethod]
41+
public void Int32RangeSet_MapsTo_Int4Multirange() =>
42+
Assert.AreEqual("int4multirange", ColumnTypeOf(nameof(Booking.SeatBlocks)));
43+
44+
[TestMethod]
45+
public void StoreTypeName_ResolvesMapping()
46+
{
47+
using var context = new TestDbContext();
48+
var mappingSource = context.GetService<IRelationalTypeMappingSource>();
49+
50+
Assert.AreEqual(typeof(DateRange), mappingSource.FindMapping("daterange")?.ClrType);
51+
Assert.AreEqual(typeof(RangeSet<DateRange, DateOnly>), mappingSource.FindMapping("datemultirange")?.ClrType);
52+
Assert.AreEqual(typeof(Int64Range), mappingSource.FindMapping("int8range")?.ClrType);
53+
}
54+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
using CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Internal;
2+
using NpgsqlTypes;
3+
4+
namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests;
5+
6+
[TestClass]
7+
public sealed class ProviderConversionTests
8+
{
9+
private static TRange RoundTrip<TRange, T>(TRange range)
10+
where TRange : class, Core.IRangeFactory<TRange, T>, Core.IRange<T>
11+
where T : struct, IComparable<T>, IEquatable<T>
12+
=> RangeProviderConversion.FromProvider<TRange, T>(RangeProviderConversion.ToProvider(range, null));
13+
14+
// -------------------------------------------------------------------------
15+
// Shape round-trips
16+
// -------------------------------------------------------------------------
17+
18+
[TestMethod]
19+
public void RoundTrip_Empty() =>
20+
Assert.AreEqual(DateRange.Empty, RoundTrip<DateRange, DateOnly>(DateRange.Empty));
21+
22+
[TestMethod]
23+
public void RoundTrip_Infinity() =>
24+
Assert.AreEqual(DateRange.Infinite, RoundTrip<DateRange, DateOnly>(DateRange.Infinite));
25+
26+
[TestMethod]
27+
public void RoundTrip_Finite_Discrete()
28+
{
29+
var range = DateRange.CreateFinite(new DateOnly(2024, 1, 1), new DateOnly(2024, 3, 31));
30+
Assert.AreEqual(range, RoundTrip<DateRange, DateOnly>(range));
31+
}
32+
33+
[TestMethod]
34+
public void RoundTrip_UnboundedStart()
35+
{
36+
var range = DateRange.CreateUnboundedStart(new DateOnly(2024, 3, 31), endInclusive: true);
37+
Assert.AreEqual(range, RoundTrip<DateRange, DateOnly>(range));
38+
}
39+
40+
[TestMethod]
41+
public void RoundTrip_UnboundedEnd()
42+
{
43+
var range = DateRange.CreateUnboundedEnd(new DateOnly(2024, 1, 1));
44+
Assert.AreEqual(range, RoundTrip<DateRange, DateOnly>(range));
45+
}
46+
47+
[TestMethod]
48+
public void RoundTrip_Continuous_HalfOpen()
49+
{
50+
var range = DecimalRange.CreateFinite(1.5m, 9.75m);
51+
Assert.AreEqual(range, RoundTrip<DecimalRange, decimal>(range));
52+
}
53+
54+
[TestMethod]
55+
public void RoundTrip_Continuous_ExclusiveStart_InclusiveEnd()
56+
{
57+
var range = DecimalRange.CreateFinite(1.5m, 9.75m, startInclusive: false, endInclusive: true);
58+
Assert.AreEqual(range, RoundTrip<DecimalRange, decimal>(range));
59+
}
60+
61+
[TestMethod]
62+
public void RoundTrip_Int32_Finite()
63+
{
64+
var range = Int32Range.CreateFinite(1, 10);
65+
Assert.AreEqual(range, RoundTrip<Int32Range, int>(range));
66+
}
67+
68+
[TestMethod]
69+
public void RoundTrip_Int64_Finite()
70+
{
71+
var range = Int64Range.CreateFinite(1L, 10_000_000_000L);
72+
Assert.AreEqual(range, RoundTrip<Int64Range, long>(range));
73+
}
74+
75+
// -------------------------------------------------------------------------
76+
// Provider shape details
77+
// -------------------------------------------------------------------------
78+
79+
[TestMethod]
80+
public void ToProvider_Empty_MapsToNpgsqlEmpty()
81+
{
82+
var provider = RangeProviderConversion.ToProvider<DateOnly>(DateRange.Empty, null);
83+
Assert.IsTrue(provider.IsEmpty);
84+
}
85+
86+
[TestMethod]
87+
public void ToProvider_Infinity_MapsToDoublyInfinite()
88+
{
89+
var provider = RangeProviderConversion.ToProvider<DateOnly>(DateRange.Infinite, null);
90+
Assert.IsTrue(provider.LowerBoundInfinite);
91+
Assert.IsTrue(provider.UpperBoundInfinite);
92+
}
93+
94+
[TestMethod]
95+
public void ToProvider_DiscreteFinite_IsFullyClosed()
96+
{
97+
var provider = RangeProviderConversion.ToProvider<DateOnly>(
98+
DateRange.CreateFinite(new DateOnly(2024, 1, 1), new DateOnly(2024, 3, 31)), null);
99+
Assert.IsTrue(provider.LowerBoundIsInclusive);
100+
Assert.IsTrue(provider.UpperBoundIsInclusive);
101+
}
102+
103+
[TestMethod]
104+
public void FromProvider_HalfOpenDiscrete_Canonicalizes()
105+
{
106+
// PostgreSQL returns discrete ranges canonicalized to [lower, upper); the model
107+
// type re-canonicalizes to its closed form.
108+
var provider = new NpgsqlRange<DateOnly>(
109+
new DateOnly(2024, 1, 1), true, false, new DateOnly(2024, 4, 1), false, false);
110+
111+
var expected = DateRange.CreateFinite(new DateOnly(2024, 1, 1), new DateOnly(2024, 3, 31));
112+
Assert.AreEqual(expected, RangeProviderConversion.FromProvider<DateRange, DateOnly>(provider));
113+
}
114+
115+
// -------------------------------------------------------------------------
116+
// Element normalization
117+
// -------------------------------------------------------------------------
118+
119+
[TestMethod]
120+
public void ToProvider_NormalizesDateTimeKind()
121+
{
122+
var range = DateTimeRange.CreateFinite(
123+
new DateTime(2024, 1, 1, 10, 0, 0, DateTimeKind.Utc),
124+
new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Local));
125+
126+
var provider = RangeProviderConversion.ToProvider<DateTime>(
127+
range, value => DateTime.SpecifyKind(value, DateTimeKind.Unspecified));
128+
129+
Assert.AreEqual(DateTimeKind.Unspecified, provider.LowerBound.Kind);
130+
Assert.AreEqual(DateTimeKind.Unspecified, provider.UpperBound.Kind);
131+
}
132+
133+
[TestMethod]
134+
public void ToProvider_NormalizesDateTimeOffsetToUtc()
135+
{
136+
var range = DateTimeOffsetRange.CreateFinite(
137+
new DateTimeOffset(2024, 1, 1, 10, 0, 0, TimeSpan.FromHours(2)),
138+
new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.FromHours(2)));
139+
140+
var provider = RangeProviderConversion.ToProvider<DateTimeOffset>(range, value => value.ToUniversalTime());
141+
142+
Assert.AreEqual(TimeSpan.Zero, provider.LowerBound.Offset);
143+
Assert.AreEqual(new DateTimeOffset(2024, 1, 1, 8, 0, 0, TimeSpan.Zero), provider.LowerBound);
144+
}
145+
146+
// -------------------------------------------------------------------------
147+
// RangeSet (multirange) round-trips
148+
// -------------------------------------------------------------------------
149+
150+
[TestMethod]
151+
public void RoundTrip_RangeSet()
152+
{
153+
var set = RangeSet<DateRange, DateOnly>.From(
154+
[
155+
DateRange.CreateFinite(new DateOnly(2024, 1, 1), new DateOnly(2024, 1, 31)),
156+
DateRange.CreateFinite(new DateOnly(2024, 3, 1), new DateOnly(2024, 3, 31))
157+
]);
158+
159+
var provider = RangeProviderConversion.ToProvider(set, null);
160+
Assert.HasCount(2, provider);
161+
162+
Assert.AreEqual(set, RangeProviderConversion.SetFromProvider<DateRange, DateOnly>(provider));
163+
}
164+
165+
[TestMethod]
166+
public void RoundTrip_EmptyRangeSet()
167+
{
168+
var provider = RangeProviderConversion.ToProvider(RangeSet<Int32Range, int>.Empty, null);
169+
Assert.IsEmpty(provider);
170+
Assert.AreEqual(RangeSet<Int32Range, int>.Empty, RangeProviderConversion.SetFromProvider<Int32Range, int>(provider));
171+
}
172+
173+
[TestMethod]
174+
public void SetFromProvider_Normalizes()
175+
{
176+
// Adjacent and overlapping provider elements are merged by RangeSet.From on the way in.
177+
NpgsqlRange<int>[] provider =
178+
[
179+
new(1, true, false, 5, true, false),
180+
new(6, true, false, 10, true, false)
181+
];
182+
183+
var set = RangeProviderConversion.SetFromProvider<Int32Range, int>(provider);
184+
Assert.HasCount(1, set);
185+
Assert.AreEqual(Int32Range.CreateFinite(1, 10), set[0]);
186+
}
187+
}

0 commit comments

Comments
 (0)