Skip to content

Commit 2b2eee0

Browse files
Update README with PostgreSQL temporal constraint documentation and enhance migration model differ
- Added `WITHOUT OVERLAPS` support to README, including examples and setup instructions. - Enhanced `NpgsqlComplexIndexMigrationsModelDiffer` with range type validation for temporal constraints.
1 parent 8c4c92f commit 2b2eee0

2 files changed

Lines changed: 132 additions & 13 deletions

File tree

EFCore.ComplexIndexes.PostgreSQL/NpgsqlComplexIndexMigrationsModelDiffer.cs

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ public override IReadOnlyList<MigrationOperation> GetDifferences(
6161
}
6262
}
6363

64-
return ApplyTemporalConstraints(operations, source, target);
64+
return ApplyTemporalConstraints(operations, source, target, typeMappingSource);
6565
}
6666

6767
// Diffs the temporal UNIQUE constraints declared on entity types and emits standalone
@@ -71,11 +71,12 @@ public override IReadOnlyList<MigrationOperation> GetDifferences(
7171
private static IReadOnlyList<MigrationOperation> ApplyTemporalConstraints(
7272
IReadOnlyList<MigrationOperation> operations,
7373
IRelationalModel? source,
74-
IRelationalModel? target
74+
IRelationalModel? target,
75+
IRelationalTypeMappingSource typeMappingSource
7576
)
7677
{
77-
var sourceConstraints = BuildDescriptors(source);
78-
var targetConstraints = BuildDescriptors(target);
78+
var sourceConstraints = BuildDescriptors(source, typeMappingSource);
79+
var targetConstraints = BuildDescriptors(target, typeMappingSource);
7980

8081
if (sourceConstraints.Count == 0 && targetConstraints.Count == 0)
8182
return operations;
@@ -124,7 +125,10 @@ private static IReadOnlyList<MigrationOperation> ApplyTemporalConstraints(
124125
return result;
125126
}
126127

127-
private static HashSet<TemporalDescriptor> BuildDescriptors(IRelationalModel? model)
128+
private static HashSet<TemporalDescriptor> BuildDescriptors(
129+
IRelationalModel? model,
130+
IRelationalTypeMappingSource typeMappingSource
131+
)
128132
{
129133
var set = new HashSet<TemporalDescriptor>();
130134
if (model is null) return set;
@@ -146,16 +150,26 @@ private static HashSet<TemporalDescriptor> BuildDescriptors(IRelationalModel? mo
146150
var keyColumns = new List<string>(def.KeyProperties.Count);
147151
foreach (var keyProperty in def.KeyProperties)
148152
{
153+
var property = ResolveProperty(entityType, keyProperty)
154+
?? throw new InvalidOperationException(
155+
$"Could not resolve temporal constraint key property '{keyProperty}' on entity '{entityType.Name}'.");
156+
149157
keyColumns.Add(
150-
ResolveColumn(entityType, keyProperty, storeObject)
158+
property.GetColumnName(storeObject)
151159
?? throw new InvalidOperationException(
152-
$"Could not resolve temporal constraint key column '{keyProperty}' on entity {entityType.Name}.")
160+
$"Temporal constraint key property '{keyProperty}' on entity '{entityType.Name}' has no column mapping for table '{table}'.")
153161
);
154162
}
155163

156-
var periodColumn = ResolveColumn(entityType, def.PeriodProperty, storeObject)
157-
?? throw new InvalidOperationException(
158-
$"Could not resolve temporal constraint period column '{def.PeriodProperty}' on entity {entityType.Name}.");
164+
var periodProperty = ResolveProperty(entityType, def.PeriodProperty)
165+
?? throw new InvalidOperationException(
166+
$"Could not resolve temporal constraint period column '{def.PeriodProperty}' on entity {entityType.Name}.");
167+
168+
ValidatePeriodIsRangeOrMultirangeType(periodProperty, def.PeriodProperty, entityType.Name, typeMappingSource);
169+
170+
var periodColumn = periodProperty.GetColumnName(storeObject)
171+
?? throw new InvalidOperationException(
172+
$"Temporal constraint period property '{def.PeriodProperty}' on entity '{entityType.Name}' has no column mapping for table '{table}'.");
159173

160174
var name = def.Name ?? $"AK_{table}_{string.Join("_", keyColumns)}_{periodColumn}";
161175

@@ -166,15 +180,48 @@ private static HashSet<TemporalDescriptor> BuildDescriptors(IRelationalModel? mo
166180
return set;
167181
}
168182

169-
private static string? ResolveColumn(IEntityType entityType, string dotPath, StoreObjectIdentifier storeObject)
183+
private static void ValidatePeriodIsRangeOrMultirangeType(
184+
IProperty property,
185+
string propertyName,
186+
string entityName,
187+
IRelationalTypeMappingSource typeMappingSource
188+
)
189+
{
190+
var clrType = property.ClrType;
191+
var storeType = typeMappingSource.FindMapping(property)?.StoreType ?? property.GetColumnType();
192+
193+
var isValidPeriod = IsRangeClrType(clrType)
194+
|| IsMultirangeClrType(clrType)
195+
|| (storeType is not null && storeType.EndsWith("range", StringComparison.OrdinalIgnoreCase));
196+
197+
if (!isValidPeriod)
198+
throw new InvalidOperationException(
199+
$"The temporal constraint period property '{propertyName}' on entity " +
200+
$"'{entityName}' does not appear to be a range or multirange type. " +
201+
$"Found CLR type '{clrType.Name}'" +
202+
(storeType is not null ? $" (store type: '{storeType}')" : "") +
203+
". Expected NpgsqlRange<T>, a PostgreSQL range/multirange column type, " +
204+
"or a store type ending in 'range' (e.g., daterange, int4multirange)."
205+
);
206+
}
207+
208+
private static bool IsRangeClrType(Type type)
209+
=> type.IsGenericType
210+
&& type.GetGenericTypeDefinition().FullName is "NpgsqlTypes.NpgsqlRange`1";
211+
212+
private static bool IsMultirangeClrType(Type type)
213+
=> type.Namespace is "NpgsqlTypes"
214+
&& type.Name.EndsWith("Multirange", StringComparison.Ordinal);
215+
216+
private static IProperty? ResolveProperty(ITypeBase entityType, string dotPath)
170217
{
171218
var parts = dotPath.Split('.');
172219
ITypeBase current = entityType;
173220

174221
for (var i = 0; i < parts.Length; i++)
175222
{
176223
if (i == parts.Length - 1)
177-
return current.FindProperty(parts[i])?.GetColumnName(storeObject);
224+
return current.FindProperty(parts[i]);
178225

179226
var cp = current.FindComplexProperty(parts[i]);
180227
if (cp is null) return null;

README.md

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ EF Core 8.0 introduced complex properties, but migration tooling doesn't automat
1414
- **Flexible Filtering**: Supports SQL `WHERE` clauses for filtered indexes (e.g., soft deletes)
1515
- **Composite Indexes**: Define multi-column indexes spanning both scalar and nested properties with a single, intuitive expression — with per-column `ASC`/`DESC` ordering via `DbOrder.Asc`/`DbOrder.Desc`
1616
- **Expression Indexes** *(PostgreSQL)*: Index arbitrary SQL expressions such as `lower(email)` or `to_tsvector('english', body)` — including on plain, non-complex entities
17+
- **Temporal Constraints** *(PostgreSQL 18)*: Declare `UNIQUE … WITHOUT OVERLAPS` constraints to guarantee no two rows occupy overlapping time periods — the database enforces scheduling integrity for you
1718

1819
| Package | NuGet | Description |
1920
|---|---|---|
2021
| **EFCore.ComplexIndexes** | [![nuget](https://img.shields.io/nuget/v/EFCore.ComplexIndexes.svg)](https://www.nuget.org/packages/EFCore.ComplexIndexes/) | Core library — single-column, composite, unique, and filtered indexes on complex type properties. Works with any EF Core relational provider. |
21-
| **EFCore.ComplexIndexes.PostgreSQL** | [![nuget](https://img.shields.io/nuget/v/EFCore.ComplexIndexes.PostgreSQL.svg)](https://www.nuget.org/packages/EFCore.ComplexIndexes.PostgreSQL/) | PostgreSQL extensions via [Npgsql](https://www.npgsql.org/efcore/) — adds GIN, GiST, BRIN, SP-GiST, and Hash index methods, operator classes, covering indexes (`INCLUDE`), concurrent creation, nulls-distinct control, and **expression (functional) indexes**. |
22+
| **EFCore.ComplexIndexes.PostgreSQL** | [![nuget](https://img.shields.io/nuget/v/EFCore.ComplexIndexes.PostgreSQL.svg)](https://www.nuget.org/packages/EFCore.ComplexIndexes.PostgreSQL/) | PostgreSQL extensions via [Npgsql](https://www.npgsql.org/efcore/) — adds GIN, GiST, BRIN, SP-GiST, and Hash index methods, operator classes, covering indexes (`INCLUDE`), concurrent creation, nulls-distinct control, **expression (functional) indexes**, and **temporal `UNIQUE` constraints (`WITHOUT OVERLAPS`)**. |
2223

2324
> **Which package do I need?**
2425
> Install only the **core** package if you use SQL Server, SQLite, or any provider where the default B-tree index type is sufficient.
@@ -169,6 +170,77 @@ builder.HasExpressionIndex(""" lower("Email") """.Trim());
169170

170171
> **Roadmap:** the expression API is built on an `IIndexExpression` seam. A future LINQ add-on will let you write `HasExpressionIndex(x => x.Email.ToLower())` and have it translated to SQL — flowing through the exact same pipeline.
171172
173+
### Temporal `UNIQUE` constraints (`WITHOUT OVERLAPS`) — PostgreSQL 18
174+
175+
> Requires `UseNpgsqlComplexIndexes()` (see [Getting started](#expression-indexes-postgresql--one-time-setup)).
176+
> Available as an extension on `EntityTypeBuilder<TEntity>`, so it works on any entity — complex or not.
177+
178+
PostgreSQL 18 introduced `WITHOUT OVERLAPS` for unique constraints — a long-requested feature for scheduling, booking, and versioning scenarios. Instead of only checking *"is this exact value already present?"*, the database enforces *"no two rows for the same key have overlapping time periods"*.
179+
180+
```sql
181+
ALTER TABLE bookings
182+
ADD CONSTRAINT ak_bookings_room_period
183+
UNIQUE (room_id, period WITHOUT OVERLAPS);
184+
```
185+
186+
`HasTemporalConstraint` exposes this as a first-class EF Core API. You supply scalar key columns (the "group" — e.g. a room, a resource, an employee) and a period column (a [PostgreSQL range type](https://www.postgresql.org/docs/current/rangetypes.html) such as `daterange`, `tstzrange`, or `NpgsqlRange<T>`):
187+
188+
**Single key column:**
189+
190+
```csharp
191+
builder.HasTemporalConstraint(
192+
keyColumns: b => b.RoomId,
193+
period: b => b.ValidPeriod);
194+
// ALTER TABLE "Bookings" ADD CONSTRAINT "AK_Bookings__RoomId_ValidPeriod"
195+
// UNIQUE ("RoomId", "ValidPeriod" WITHOUT OVERLAPS);
196+
```
197+
198+
**Composite key columns:**
199+
200+
```csharp
201+
builder.HasTemporalConstraint(
202+
keyColumns: b => new { b.Facility, b.RoomId },
203+
period: b => b.ValidPeriod);
204+
// UNIQUE ("Facility", "RoomId", "ValidPeriod" WITHOUT OVERLAPS)
205+
```
206+
207+
**Explicit constraint name:**
208+
209+
```csharp
210+
builder.HasTemporalConstraint(
211+
keyColumns: b => b.RoomId,
212+
period: b => b.ValidPeriod,
213+
name: "uk_room_no_overlap");
214+
```
215+
216+
#### How the period column is validated
217+
218+
The migration differ validates the period property at migration-generation time (`dotnet ef migrations add`). It must be mapped to a PostgreSQL range or multirange store type (anything ending in `range` — e.g. `daterange`, `tstzrange`, `int4multirange`) or have a CLR type of `NpgsqlRange<T>` / a multirange struct from `NpgsqlTypes`. Using an incompatible type such as `string`, `int`, or `DateOnly` throws an `InvalidOperationException` *before* any SQL is generated:
219+
220+
```
221+
The temporal constraint period property 'Start' on entity 'Booking' does not appear to be a range or multirange type. Found CLR type 'DateTime' (store type: 'timestamp with time zone'). Expected NpgsqlRange<T>, a PostgreSQL range/multirange column type, or a store type ending in 'range' (e.g., daterange, int4multirange).
222+
```
223+
224+
The period column stays a plain mapped column — it is deliberately **not** part of an EF key, because EF Core forbids non-comparable range types in primary keys. Use a surrogate or scalar EF primary key for change tracking; the temporal constraint handles the non-overlap guarantee independently.
225+
226+
#### `btree_gist` extension
227+
228+
Temporal constraints over scalar key columns require the `btree_gist` PostgreSQL extension. The differ injects `CREATE EXTENSION IF NOT EXISTS btree_gist;` automatically when a temporal constraint is first added. You can take explicit control or opt out:
229+
230+
```csharp
231+
// Explicit: declare the extension yourself (Npgsql's own differ handles it)
232+
modelBuilder.UseBtreeGist();
233+
234+
// Opt out: e.g. if the extension is provisioned out-of-band by your DBA
235+
modelBuilder.SuppressTemporalExtensionAutoInjection();
236+
```
237+
238+
When `UseBtreeGist()` is present, automatic injection backs off to avoid a duplicate `CREATE EXTENSION` statement.
239+
240+
#### Idempotency and renames
241+
242+
Re-declaring a temporal constraint on the same key + period replaces the previous one. Removing `HasTemporalConstraint` from the model causes the differ to emit a `DROP CONSTRAINT` in the next migration (unless the table itself is being dropped).
243+
172244
---
173245

174246
The package integrates seamlessly with EF Core's design-time tooling. Apart from the one-time `UseNpgsqlComplexIndexes()` call for expression indexes, there is no additional ceremony — just configure and migrate.

0 commit comments

Comments
 (0)