Skip to content

Commit 8c4c92f

Browse files
Add support for PostgreSQL temporal constraints with WITHOUT OVERLAPS
- Introduced `HasTemporalConstraint` API for PostgreSQL temporal `UNIQUE` constraints. - Added SQL migration generation for temporal constraints, including `CREATE EXTENSION btree_gist` injection. - Enhanced model diffs with detection and generation of temporal constraint operations. - Enabled suppression of auto-extension injection and added explicit `UseBtreeGist` support. - Added comprehensive tests for temporal constraints, including named multi-key support and suppression mechanisms. - Updated internal annotations and serializers for temporal constraint handling.
1 parent 0f8e165 commit 8c4c92f

7 files changed

Lines changed: 694 additions & 4 deletions

EFCore.ComplexIndexes.PostgreSQL/NpgsqlComplexIndexMigrationsModelDiffer.cs

Lines changed: 171 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using Microsoft.EntityFrameworkCore;
12
using Microsoft.EntityFrameworkCore.Metadata;
23
using Microsoft.EntityFrameworkCore.Migrations;
34
using Microsoft.EntityFrameworkCore.Migrations.Operations;
@@ -9,8 +10,9 @@ namespace EFCore.ComplexIndexes.PostgreSQL;
910
#pragma warning disable EF1001
1011

1112
/// <summary>
12-
/// Extends <see cref="CustomMigrationsModelDiffer"/> to validate that
13-
/// provider annotations on complex index operations use recognized Npgsql keys.
13+
/// Extends <see cref="CustomMigrationsModelDiffer"/> to validate that provider annotations on complex
14+
/// index operations use recognized Npgsql keys, and to emit PostgreSQL 18 temporal <c>UNIQUE</c>
15+
/// constraints (<c>WITHOUT OVERLAPS</c>) declared via <c>HasTemporalConstraint</c>.
1416
/// </summary>
1517
public class NpgsqlComplexIndexMigrationsModelDiffer(
1618
IRelationalTypeMappingSource typeMappingSource,
@@ -59,8 +61,173 @@ public override IReadOnlyList<MigrationOperation> GetDifferences(
5961
}
6062
}
6163

62-
return operations;
64+
return ApplyTemporalConstraints(operations, source, target);
65+
}
66+
67+
// Diffs the temporal UNIQUE constraints declared on entity types and emits standalone
68+
// Add/DropUniqueConstraintOperations (the period column is a plain column EF doesn't otherwise
69+
// constrain). Adds carry the WITHOUT OVERLAPS marker for the SQL generator; a CREATE EXTENSION
70+
// btree_gist is auto-injected when a temporal constraint is being created.
71+
private static IReadOnlyList<MigrationOperation> ApplyTemporalConstraints(
72+
IReadOnlyList<MigrationOperation> operations,
73+
IRelationalModel? source,
74+
IRelationalModel? target
75+
)
76+
{
77+
var sourceConstraints = BuildDescriptors(source);
78+
var targetConstraints = BuildDescriptors(target);
79+
80+
if (sourceConstraints.Count == 0 && targetConstraints.Count == 0)
81+
return operations;
82+
83+
var result = new List<MigrationOperation>(operations);
84+
var addedTemporal = false;
85+
86+
foreach (var tgt in targetConstraints)
87+
{
88+
if (sourceConstraints.Contains(tgt))
89+
continue;
90+
91+
var op = new AddUniqueConstraintOperation
92+
{
93+
Name = tgt.Name,
94+
Table = tgt.Table,
95+
Schema = tgt.Schema,
96+
Columns = [.. tgt.KeyColumns, tgt.PeriodColumn]
97+
};
98+
op.AddAnnotation(NpgsqlTemporalAnnotations.WithoutOverlaps, tgt.PeriodColumn);
99+
result.Add(op);
100+
addedTemporal = true;
101+
}
102+
103+
var droppedTables = operations
104+
.OfType<DropTableOperation>()
105+
.Select(o => (o.Name, o.Schema))
106+
.ToHashSet();
107+
108+
foreach (var src in sourceConstraints)
109+
{
110+
if (targetConstraints.Contains(src) || droppedTables.Contains((src.Table, src.Schema)))
111+
continue;
112+
113+
result.Add(new DropUniqueConstraintOperation
114+
{
115+
Name = src.Name,
116+
Table = src.Table,
117+
Schema = src.Schema
118+
});
119+
}
120+
121+
if (addedTemporal && ShouldInjectExtension(target))
122+
result.Insert(0, new SqlOperation { Sql = $"CREATE EXTENSION IF NOT EXISTS {NpgsqlTemporalAnnotations.BtreeGistExtension};" });
123+
124+
return result;
125+
}
126+
127+
private static HashSet<TemporalDescriptor> BuildDescriptors(IRelationalModel? model)
128+
{
129+
var set = new HashSet<TemporalDescriptor>();
130+
if (model is null) return set;
131+
132+
foreach (var entityType in model.Model.GetEntityTypes())
133+
{
134+
if (entityType.FindAnnotation(NpgsqlTemporalAnnotations.Constraints)?.Value is not string json
135+
|| string.IsNullOrEmpty(json))
136+
continue;
137+
138+
var table = entityType.GetTableName();
139+
if (table is null) continue;
140+
141+
var schema = entityType.GetSchema();
142+
var storeObject = StoreObjectIdentifier.Table(table, schema);
143+
144+
foreach (var def in TemporalConstraintSerializer.Deserialize(json))
145+
{
146+
var keyColumns = new List<string>(def.KeyProperties.Count);
147+
foreach (var keyProperty in def.KeyProperties)
148+
{
149+
keyColumns.Add(
150+
ResolveColumn(entityType, keyProperty, storeObject)
151+
?? throw new InvalidOperationException(
152+
$"Could not resolve temporal constraint key column '{keyProperty}' on entity {entityType.Name}.")
153+
);
154+
}
155+
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}.");
159+
160+
var name = def.Name ?? $"AK_{table}_{string.Join("_", keyColumns)}_{periodColumn}";
161+
162+
set.Add(new TemporalDescriptor(table, schema, name, keyColumns, periodColumn));
163+
}
164+
}
165+
166+
return set;
167+
}
168+
169+
private static string? ResolveColumn(IEntityType entityType, string dotPath, StoreObjectIdentifier storeObject)
170+
{
171+
var parts = dotPath.Split('.');
172+
ITypeBase current = entityType;
173+
174+
for (var i = 0; i < parts.Length; i++)
175+
{
176+
if (i == parts.Length - 1)
177+
return current.FindProperty(parts[i])?.GetColumnName(storeObject);
178+
179+
var cp = current.FindComplexProperty(parts[i]);
180+
if (cp is null) return null;
181+
current = cp.ComplexType;
182+
}
183+
184+
return null;
185+
}
186+
187+
private static bool ShouldInjectExtension(IRelationalModel? target)
188+
{
189+
if (target is null)
190+
return false;
191+
192+
if (target.Model.FindAnnotation(NpgsqlTemporalAnnotations.SuppressAutoExtension)?.Value is true)
193+
return false;
194+
195+
// If the extension is declared via HasPostgresExtension (e.g. UseBtreeGist()), Npgsql's own
196+
// differ already emits CREATE EXTENSION, so we must not duplicate it.
197+
var alreadyDeclared = target.Model
198+
.GetAnnotations()
199+
.Any(a => a.Name.StartsWith("Npgsql:PostgresExtension", StringComparison.Ordinal)
200+
&& a.Name.Contains(NpgsqlTemporalAnnotations.BtreeGistExtension, StringComparison.Ordinal));
201+
202+
return !alreadyDeclared;
203+
}
204+
205+
private sealed record TemporalDescriptor(
206+
string Table,
207+
string? Schema,
208+
string Name,
209+
IReadOnlyList<string> KeyColumns,
210+
string PeriodColumn)
211+
{
212+
public bool Equals(TemporalDescriptor? other) =>
213+
other is not null
214+
&& Table == other.Table
215+
&& Schema == other.Schema
216+
&& Name == other.Name
217+
&& PeriodColumn == other.PeriodColumn
218+
&& KeyColumns.SequenceEqual(other.KeyColumns);
219+
220+
public override int GetHashCode()
221+
{
222+
var hash = new HashCode();
223+
hash.Add(Table);
224+
hash.Add(Schema);
225+
hash.Add(Name);
226+
foreach (var column in KeyColumns) hash.Add(column);
227+
hash.Add(PeriodColumn);
228+
return hash.ToHashCode();
229+
}
63230
}
64231
}
65232

66-
#pragma warning restore EF1001
233+
#pragma warning restore EF1001

EFCore.ComplexIndexes.PostgreSQL/NpgsqlComplexIndexSqlGenerator.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,61 @@ protected override void Generate(
9595
}
9696
}
9797

98+
/// <summary>
99+
/// Renders a temporal <c>UNIQUE</c> constraint (<c>… WITHOUT OVERLAPS</c>) declared via
100+
/// <c>HasTemporalConstraint</c>; otherwise delegates to the base Npgsql generator.
101+
/// </summary>
102+
protected override void Generate(
103+
AddUniqueConstraintOperation operation,
104+
IModel? model,
105+
MigrationCommandListBuilder builder
106+
)
107+
{
108+
if (operation[NpgsqlTemporalAnnotations.WithoutOverlaps] is string period)
109+
GenerateTemporalConstraint(operation.Name, operation.Table, operation.Schema, operation.Columns, "UNIQUE", period, builder, terminate: true);
110+
else
111+
base.Generate(operation, model, builder);
112+
}
113+
114+
// Emits ALTER TABLE … ADD CONSTRAINT … <keyword> (cols…, period WITHOUT OVERLAPS). PostgreSQL
115+
// requires the range column last, so the period column is always emitted at the end regardless of
116+
// its position in the key.
117+
private void GenerateTemporalConstraint(
118+
string name,
119+
string table,
120+
string? schema,
121+
IReadOnlyList<string> columns,
122+
string keyword,
123+
string periodColumn,
124+
MigrationCommandListBuilder builder,
125+
bool terminate
126+
)
127+
{
128+
var sqlHelper = Dependencies.SqlGenerationHelper;
129+
130+
var rendered = columns.Where(c => c != periodColumn)
131+
.Select(sqlHelper.DelimitIdentifier)
132+
.ToList();
133+
rendered.Add($"{sqlHelper.DelimitIdentifier(periodColumn)} WITHOUT OVERLAPS");
134+
135+
builder
136+
.Append("ALTER TABLE ")
137+
.Append(sqlHelper.DelimitIdentifier(table, schema))
138+
.Append(" ADD CONSTRAINT ")
139+
.Append(sqlHelper.DelimitIdentifier(name))
140+
.Append(" ")
141+
.Append(keyword)
142+
.Append(" (")
143+
.Append(string.Join(", ", rendered))
144+
.Append(")");
145+
146+
if (terminate)
147+
{
148+
builder.AppendLine(sqlHelper.StatementTerminator);
149+
EndStatement(builder);
150+
}
151+
}
152+
98153
private static IReadOnlyList<string>? ToStringList(object? value) =>
99154
value switch
100155
{
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
namespace EFCore.ComplexIndexes.PostgreSQL;
2+
3+
/// <summary>
4+
/// Annotation key constants for PostgreSQL 18 temporal constraints (<c>WITHOUT OVERLAPS</c>).
5+
/// These use the <c>CustomTemporal:</c> prefix so they never collide with the <c>Npgsql:</c>
6+
/// keys validated on index operations.
7+
/// </summary>
8+
internal static class NpgsqlTemporalAnnotations
9+
{
10+
/// <summary>
11+
/// Stamped on an entity type to hold the JSON-serialized list of temporal UNIQUE constraints
12+
/// declared via <c>HasTemporalConstraint</c>. The period (range) column stays a plain mapped
13+
/// column — it is deliberately not part of any EF key, because EF forbids non-comparable range
14+
/// types in keys.
15+
/// </summary>
16+
public const string Constraints = "CustomTemporal:Constraints";
17+
18+
/// <summary>
19+
/// Stamped by the differ onto an <c>AddUniqueConstraintOperation</c> to carry the resolved column
20+
/// name that must be rendered with <c>WITHOUT OVERLAPS</c>.
21+
/// </summary>
22+
public const string WithoutOverlaps = "CustomTemporal:WithoutOverlaps";
23+
24+
/// <summary>
25+
/// Stamped on the model to opt out of automatic <c>CREATE EXTENSION btree_gist</c> injection.
26+
/// </summary>
27+
public const string SuppressAutoExtension = "CustomTemporal:SuppressAutoExtension";
28+
29+
/// <summary>The PostgreSQL extension required by temporal constraints over scalar columns.</summary>
30+
public const string BtreeGistExtension = "btree_gist";
31+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
using System.Linq.Expressions;
2+
using EFCore.ComplexIndexes;
3+
using Microsoft.EntityFrameworkCore;
4+
using Microsoft.EntityFrameworkCore.Metadata.Builders;
5+
6+
namespace EFCore.ComplexIndexes.PostgreSQL;
7+
8+
/// <summary>
9+
/// PostgreSQL 18 temporal-constraint API. Adds a temporal <c>UNIQUE</c> constraint
10+
/// (<c>UNIQUE (key…, period WITHOUT OVERLAPS)</c>) as standalone DDL, alongside the entity's normal
11+
/// EF key. The period (range) column stays a plain mapped column — it is deliberately *not* part of
12+
/// an EF key, because EF Core forbids non-comparable range types (e.g. <c>NpgsqlRange&lt;T&gt;</c>) in
13+
/// keys. Use a surrogate or scalar EF primary key for change tracking and this constraint for the
14+
/// non-overlap guarantee.
15+
/// </summary>
16+
/// <remarks>
17+
/// Temporal constraints over scalar columns require the <c>btree_gist</c> extension. The differ
18+
/// injects <c>CREATE EXTENSION IF NOT EXISTS btree_gist</c> automatically; call <c>UseBtreeGist</c>
19+
/// for explicit control or <c>SuppressTemporalExtensionAutoInjection</c> to opt out. Rendering the
20+
/// <c>WITHOUT OVERLAPS</c> clause requires runtime wiring via <c>UseNpgsqlComplexIndexes()</c>.
21+
/// </remarks>
22+
public static class NpgsqlTemporalConstraintExtensions
23+
{
24+
extension<TEntity>(EntityTypeBuilder<TEntity> builder) where TEntity : class
25+
{
26+
/// <summary>
27+
/// Adds a temporal <c>UNIQUE</c> constraint: <paramref name="keyColumns"/> (one or more scalar
28+
/// columns, e.g. <c>x =&gt; x.RoomId</c> or <c>x =&gt; new { x.RoomId, x.Floor }</c>) plus
29+
/// <paramref name="period"/> — a range column rendered last with <c>WITHOUT OVERLAPS</c>.
30+
/// </summary>
31+
public EntityTypeBuilder<TEntity> HasTemporalConstraint<TKey>(
32+
Expression<Func<TEntity, TKey>> keyColumns,
33+
Expression<Func<TEntity, object?>> period,
34+
string? name = null
35+
)
36+
{
37+
var keys = ExtractPaths(keyColumns);
38+
var periodPath = ComplexIndexExtensions.ExtractSinglePath(period.Body);
39+
40+
if (keys.Count == 0)
41+
throw new ArgumentException("A temporal constraint requires at least one key column.", nameof(keyColumns));
42+
43+
if (keys.Contains(periodPath))
44+
throw new ArgumentException(
45+
$"The period column '{periodPath}' must not also appear in the key columns.",
46+
nameof(period)
47+
);
48+
49+
var definition = new TemporalConstraintDefinition
50+
{
51+
KeyProperties = keys,
52+
PeriodProperty = periodPath,
53+
Name = name
54+
};
55+
56+
var existing = GetExisting(builder);
57+
existing.RemoveAll(d => d.KeyProperties.SequenceEqual(keys) && d.PeriodProperty == periodPath);
58+
existing.Add(definition);
59+
60+
builder.HasAnnotation(NpgsqlTemporalAnnotations.Constraints, TemporalConstraintSerializer.Serialize(existing));
61+
return builder;
62+
}
63+
64+
private static List<TemporalConstraintDefinition> GetExisting(EntityTypeBuilder<TEntity> entityTypeBuilder)
65+
{
66+
var annotation = entityTypeBuilder.Metadata.FindAnnotation(NpgsqlTemporalAnnotations.Constraints);
67+
68+
return annotation?.Value is string json && !string.IsNullOrEmpty(json)
69+
? TemporalConstraintSerializer.Deserialize(json)
70+
: [];
71+
}
72+
}
73+
74+
extension(ModelBuilder modelBuilder)
75+
{
76+
/// <summary>
77+
/// Declares the <c>btree_gist</c> extension on the model (via Npgsql's
78+
/// <c>HasPostgresExtension</c>) so it is created during migrations. Use this for explicit
79+
/// control; when present, the differ's automatic injection backs off.
80+
/// </summary>
81+
public ModelBuilder UseBtreeGist()
82+
=> modelBuilder.HasPostgresExtension(NpgsqlTemporalAnnotations.BtreeGistExtension);
83+
84+
/// <summary>
85+
/// Opts out of automatic <c>CREATE EXTENSION IF NOT EXISTS btree_gist</c> injection by the
86+
/// differ. Use when the extension is provisioned out of band.
87+
/// </summary>
88+
public ModelBuilder SuppressTemporalExtensionAutoInjection()
89+
{
90+
modelBuilder.HasAnnotation(NpgsqlTemporalAnnotations.SuppressAutoExtension, true);
91+
return modelBuilder;
92+
}
93+
}
94+
95+
// Accepts either an anonymous-type key list (x => new { x.A, x.B }) or a single member (x => x.A).
96+
private static List<string> ExtractPaths<TEntity, TKey>(Expression<Func<TEntity, TKey>> expression)
97+
=> expression.Body is NewExpression
98+
? ComplexIndexExtensions.ExtractPropertyPaths(expression)
99+
: [ComplexIndexExtensions.ExtractSinglePath(expression.Body)];
100+
}

0 commit comments

Comments
 (0)