Skip to content

Commit e3bc7c4

Browse files
Add support for PostgreSQL temporal foreign keys and enhance migration differ
- Introduced `HasTemporalForeignKey` API for PostgreSQL 18 temporal foreign keys (`PERIOD` syntax). - Enhanced `NpgsqlComplexIndexMigrationsModelDiffer` with temporal foreign key operations and validation. - Updated internal annotations to handle temporal foreign keys (`DependentPeriod`, `PrincipalPeriod`, etc.). - Added comprehensive tests for various temporal foreign key scenarios. - Updated README with examples, restrictions, and usage of temporal FKs. - Bumped package version to 4.0.0.
1 parent 2b2eee0 commit e3bc7c4

10 files changed

Lines changed: 1309 additions & 29 deletions

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<Project>
22
<PropertyGroup>
3-
<Version>3.1.5</Version>
3+
<Version>4.0.0</Version>
44
<Authors>CaffeinatedCoder</Authors>
55
<PackageLicenseExpression>MIT</PackageLicenseExpression>
66
<GenerateDocumentationFile>true</GenerateDocumentationFile>

EFCore.ComplexIndexes.PostgreSQL/NpgsqlComplexIndexMigrationsModelDiffer.cs

Lines changed: 262 additions & 22 deletions
Large diffs are not rendered by default.

EFCore.ComplexIndexes.PostgreSQL/NpgsqlComplexIndexSqlGenerator.cs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,74 @@ MigrationCommandListBuilder builder
111111
base.Generate(operation, model, builder);
112112
}
113113

114+
/// <summary>
115+
/// Renders a PostgreSQL 18 temporal foreign key (<c>FOREIGN KEY (..., PERIOD period)</c>);
116+
/// otherwise delegates to the base Npgsql generator.
117+
/// </summary>
118+
protected override void Generate(
119+
AddForeignKeyOperation operation,
120+
IModel? model,
121+
MigrationCommandListBuilder builder,
122+
bool terminate = true
123+
)
124+
{
125+
if (operation[NpgsqlTemporalAnnotations.ForeignKeyDependentPeriod] is string dependentPeriod
126+
&& operation[NpgsqlTemporalAnnotations.ForeignKeyPrincipalPeriod] is string principalPeriod)
127+
{
128+
GenerateTemporalForeignKey(operation, dependentPeriod, principalPeriod, builder, terminate);
129+
return;
130+
}
131+
132+
base.Generate(operation, model, builder, terminate);
133+
}
134+
135+
// Emits ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY (cols…, PERIOD period)
136+
// REFERENCES principal (cols…, PERIOD period). PostgreSQL requires the period column last.
137+
private void GenerateTemporalForeignKey(
138+
AddForeignKeyOperation operation,
139+
string dependentPeriodColumn,
140+
string principalPeriodColumn,
141+
MigrationCommandListBuilder builder,
142+
bool terminate
143+
)
144+
{
145+
if (operation.OnDelete != ReferentialAction.NoAction || operation.OnUpdate != ReferentialAction.NoAction)
146+
throw new InvalidOperationException("PostgreSQL temporal foreign keys only support NO ACTION referential actions.");
147+
148+
var sqlHelper = Dependencies.SqlGenerationHelper;
149+
150+
var dependentColumns = operation.Columns
151+
.Where(c => c != dependentPeriodColumn)
152+
.Select(sqlHelper.DelimitIdentifier)
153+
.ToList();
154+
dependentColumns.Add($"PERIOD {sqlHelper.DelimitIdentifier(dependentPeriodColumn)}");
155+
156+
var principalColumns = (operation.PrincipalColumns ?? [])
157+
.Where(c => c != principalPeriodColumn)
158+
.Select(sqlHelper.DelimitIdentifier)
159+
.ToList();
160+
principalColumns.Add($"PERIOD {sqlHelper.DelimitIdentifier(principalPeriodColumn)}");
161+
162+
builder
163+
.Append("ALTER TABLE ")
164+
.Append(sqlHelper.DelimitIdentifier(operation.Table, operation.Schema))
165+
.Append(" ADD CONSTRAINT ")
166+
.Append(sqlHelper.DelimitIdentifier(operation.Name))
167+
.Append(" FOREIGN KEY (")
168+
.Append(string.Join(", ", dependentColumns))
169+
.Append(") REFERENCES ")
170+
.Append(sqlHelper.DelimitIdentifier(operation.PrincipalTable, operation.PrincipalSchema))
171+
.Append(" (")
172+
.Append(string.Join(", ", principalColumns))
173+
.Append(")");
174+
175+
if (terminate)
176+
{
177+
builder.AppendLine(sqlHelper.StatementTerminator);
178+
EndStatement(builder);
179+
}
180+
}
181+
114182
// Emits ALTER TABLE … ADD CONSTRAINT … <keyword> (cols…, period WITHOUT OVERLAPS). PostgreSQL
115183
// requires the range column last, so the period column is always emitted at the end regardless of
116184
// its position in the key.

EFCore.ComplexIndexes.PostgreSQL/NpgsqlTemporalAnnotations.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,24 @@ internal static class NpgsqlTemporalAnnotations
2121
/// </summary>
2222
public const string WithoutOverlaps = "CustomTemporal:WithoutOverlaps";
2323

24+
/// <summary>
25+
/// Stamped on a dependent entity type to hold the JSON-serialized list of temporal foreign keys
26+
/// declared via <c>HasTemporalForeignKey</c>.
27+
/// </summary>
28+
public const string ForeignKeys = "CustomTemporal:ForeignKeys";
29+
30+
/// <summary>
31+
/// Stamped by the differ onto an <c>AddForeignKeyOperation</c> to carry the dependent range column
32+
/// rendered as <c>PERIOD dependent_period</c>.
33+
/// </summary>
34+
public const string ForeignKeyDependentPeriod = "CustomTemporal:ForeignKeyDependentPeriod";
35+
36+
/// <summary>
37+
/// Stamped by the differ onto an <c>AddForeignKeyOperation</c> to carry the principal range column
38+
/// rendered as <c>PERIOD principal_period</c>.
39+
/// </summary>
40+
public const string ForeignKeyPrincipalPeriod = "CustomTemporal:ForeignKeyPrincipalPeriod";
41+
2442
/// <summary>
2543
/// Stamped on the model to opt out of automatic <c>CREATE EXTENSION btree_gist</c> injection.
2644
/// </summary>

EFCore.ComplexIndexes.PostgreSQL/NpgsqlTemporalConstraintExtensions.cs

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System.Linq.Expressions;
2-
using EFCore.ComplexIndexes;
32
using Microsoft.EntityFrameworkCore;
43
using Microsoft.EntityFrameworkCore.Metadata.Builders;
54

@@ -61,6 +60,69 @@ public EntityTypeBuilder<TEntity> HasTemporalConstraint<TKey>(
6160
return builder;
6261
}
6362

63+
/// <summary>
64+
/// Adds a PostgreSQL 18 temporal foreign key. The scalar key columns are compared by equality,
65+
/// and the dependent period must be covered by matching principal periods.
66+
/// </summary>
67+
public EntityTypeBuilder<TEntity> HasTemporalForeignKey<TPrincipal>(
68+
Expression<Func<TEntity, object?>> dependentKeyColumns,
69+
Expression<Func<TEntity, object?>> dependentPeriod,
70+
Expression<Func<TPrincipal, object?>> principalKeyColumns,
71+
Expression<Func<TPrincipal, object?>> principalPeriod,
72+
string? name = null
73+
) where TPrincipal : class
74+
{
75+
var dependentKeys = ExtractPaths(dependentKeyColumns);
76+
var principalKeys = ExtractPaths(principalKeyColumns);
77+
var dependentPeriodPath = ComplexIndexExtensions.ExtractSinglePath(dependentPeriod.Body);
78+
var principalPeriodPath = ComplexIndexExtensions.ExtractSinglePath(principalPeriod.Body);
79+
80+
if (dependentKeys.Count == 0)
81+
throw new ArgumentException("A temporal foreign key requires at least one dependent key column.", nameof(dependentKeyColumns));
82+
83+
if (principalKeys.Count == 0)
84+
throw new ArgumentException("A temporal foreign key requires at least one principal key column.", nameof(principalKeyColumns));
85+
86+
if (dependentKeys.Count != principalKeys.Count)
87+
throw new ArgumentException(
88+
$"Temporal foreign key key-count mismatch: dependent has {dependentKeys.Count} key column(s), principal has {principalKeys.Count}.",
89+
nameof(principalKeyColumns)
90+
);
91+
92+
if (dependentKeys.Contains(dependentPeriodPath))
93+
throw new ArgumentException(
94+
$"The dependent period column '{dependentPeriodPath}' must not also appear in the dependent key columns.",
95+
nameof(dependentPeriod)
96+
);
97+
98+
if (principalKeys.Contains(principalPeriodPath))
99+
throw new ArgumentException(
100+
$"The principal period column '{principalPeriodPath}' must not also appear in the principal key columns.",
101+
nameof(principalPeriod)
102+
);
103+
104+
var definition = new TemporalForeignKeyDefinition
105+
{
106+
DependentKeyProperties = dependentKeys,
107+
DependentPeriodProperty = dependentPeriodPath,
108+
PrincipalEntityType = typeof(TPrincipal).FullName ?? typeof(TPrincipal).Name,
109+
PrincipalKeyProperties = principalKeys,
110+
PrincipalPeriodProperty = principalPeriodPath,
111+
Name = name
112+
};
113+
114+
var existing = GetExistingForeignKeys(builder);
115+
existing.RemoveAll(d => d.DependentKeyProperties.SequenceEqual(dependentKeys)
116+
&& d.DependentPeriodProperty == dependentPeriodPath
117+
&& d.PrincipalEntityType == definition.PrincipalEntityType
118+
&& d.PrincipalKeyProperties.SequenceEqual(principalKeys)
119+
&& d.PrincipalPeriodProperty == principalPeriodPath);
120+
existing.Add(definition);
121+
122+
builder.HasAnnotation(NpgsqlTemporalAnnotations.ForeignKeys, TemporalForeignKeySerializer.Serialize(existing));
123+
return builder;
124+
}
125+
64126
private static List<TemporalConstraintDefinition> GetExisting(EntityTypeBuilder<TEntity> entityTypeBuilder)
65127
{
66128
var annotation = entityTypeBuilder.Metadata.FindAnnotation(NpgsqlTemporalAnnotations.Constraints);
@@ -69,6 +131,15 @@ private static List<TemporalConstraintDefinition> GetExisting(EntityTypeBuilder<
69131
? TemporalConstraintSerializer.Deserialize(json)
70132
: [];
71133
}
134+
135+
private static List<TemporalForeignKeyDefinition> GetExistingForeignKeys(EntityTypeBuilder<TEntity> entityTypeBuilder)
136+
{
137+
var annotation = entityTypeBuilder.Metadata.FindAnnotation(NpgsqlTemporalAnnotations.ForeignKeys);
138+
139+
return annotation?.Value is string json && !string.IsNullOrEmpty(json)
140+
? TemporalForeignKeySerializer.Deserialize(json)
141+
: [];
142+
}
72143
}
73144

74145
extension(ModelBuilder modelBuilder)
@@ -94,7 +165,13 @@ public ModelBuilder SuppressTemporalExtensionAutoInjection()
94165

95166
// Accepts either an anonymous-type key list (x => new { x.A, x.B }) or a single member (x => x.A).
96167
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-
}
168+
{
169+
var body = expression.Body;
170+
while (body is UnaryExpression { NodeType: ExpressionType.Convert } unary)
171+
body = unary.Operand;
172+
173+
return body is NewExpression newExpression
174+
? [.. newExpression.Arguments.Select(ComplexIndexExtensions.ExtractSinglePath)]
175+
: [ComplexIndexExtensions.ExtractSinglePath(body)];
176+
}
177+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization;
3+
4+
namespace EFCore.ComplexIndexes.PostgreSQL;
5+
6+
/// <summary>
7+
/// A temporal FOREIGN KEY declared via <c>HasTemporalForeignKey</c>: matching scalar key columns
8+
/// plus dependent/principal range (period) columns rendered with PostgreSQL's <c>PERIOD</c> marker.
9+
/// Stored as JSON on the dependent entity type.
10+
/// </summary>
11+
internal sealed class TemporalForeignKeyDefinition : IEquatable<TemporalForeignKeyDefinition>
12+
{
13+
/// <summary>Dotted property paths of the dependent scalar key columns, in order.</summary>
14+
[JsonPropertyName("dependentKeys")] public List<string> DependentKeyProperties { get; init; } = [];
15+
16+
/// <summary>Dotted property path of the dependent range (period) column.</summary>
17+
[JsonPropertyName("dependentPeriod")] public string DependentPeriodProperty { get; init; } = "";
18+
19+
/// <summary>EF entity-type name of the principal entity.</summary>
20+
[JsonPropertyName("principalEntity")] public string PrincipalEntityType { get; init; } = "";
21+
22+
/// <summary>Dotted property paths of the principal scalar key columns, in order.</summary>
23+
[JsonPropertyName("principalKeys")] public List<string> PrincipalKeyProperties { get; init; } = [];
24+
25+
/// <summary>Dotted property path of the principal range (period) column.</summary>
26+
[JsonPropertyName("principalPeriod")] public string PrincipalPeriodProperty { get; init; } = "";
27+
28+
/// <summary>Optional explicit constraint name.</summary>
29+
[JsonPropertyName("name")] public string? Name { get; init; }
30+
31+
public bool Equals(TemporalForeignKeyDefinition? other) =>
32+
other is not null
33+
&& DependentPeriodProperty == other.DependentPeriodProperty
34+
&& PrincipalEntityType == other.PrincipalEntityType
35+
&& PrincipalPeriodProperty == other.PrincipalPeriodProperty
36+
&& Name == other.Name
37+
&& DependentKeyProperties.SequenceEqual(other.DependentKeyProperties)
38+
&& PrincipalKeyProperties.SequenceEqual(other.PrincipalKeyProperties);
39+
40+
public override bool Equals(object? obj) => Equals(obj as TemporalForeignKeyDefinition);
41+
42+
public override int GetHashCode()
43+
{
44+
var hash = new HashCode();
45+
foreach (var key in DependentKeyProperties) hash.Add(key);
46+
hash.Add(DependentPeriodProperty);
47+
hash.Add(PrincipalEntityType);
48+
foreach (var key in PrincipalKeyProperties) hash.Add(key);
49+
hash.Add(PrincipalPeriodProperty);
50+
hash.Add(Name);
51+
return hash.ToHashCode();
52+
}
53+
}
54+
55+
internal static class TemporalForeignKeySerializer
56+
{
57+
private static readonly JsonSerializerOptions Options = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
58+
59+
public static string Serialize(IReadOnlyList<TemporalForeignKeyDefinition> definitions)
60+
=> JsonSerializer.Serialize(definitions, Options);
61+
62+
public static List<TemporalForeignKeyDefinition> Deserialize(string json)
63+
=> JsonSerializer.Deserialize<List<TemporalForeignKeyDefinition>>(json, Options) ?? [];
64+
}

EFCore.ComplexIndexes.Tests/EFCore.ComplexIndexes.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
<ImplicitUsings>enable</ImplicitUsings>
77
<Nullable>enable</Nullable>
88
<IsPackable>false</IsPackable>
9+
<NoWarn>$(NoWarn);1591</NoWarn>
910
</PropertyGroup>
1011

1112
<ItemGroup>

0 commit comments

Comments
 (0)