Skip to content

Commit bd07c34

Browse files
authored
Implement support for PG18 PERIOD (#3711)
Closes #3710
1 parent 789f55a commit bd07c34

13 files changed

Lines changed: 626 additions & 30 deletions

File tree

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
using Microsoft.EntityFrameworkCore.Metadata.Builders;
2+
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Internal;
3+
4+
// ReSharper disable once CheckNamespace
5+
namespace Microsoft.EntityFrameworkCore;
6+
7+
/// <summary>
8+
/// Npgsql specific extension methods for relationship builders.
9+
/// </summary>
10+
public static class NpgsqlForeignKeyBuilderExtensions
11+
{
12+
#region Period
13+
14+
/// <summary>
15+
/// Configures the foreign key to use the PostgreSQL PERIOD feature for temporal foreign keys.
16+
/// The last column in the foreign key must be a PostgreSQL range type, and the referenced
17+
/// principal key must have WITHOUT OVERLAPS configured.
18+
/// </summary>
19+
/// <remarks>
20+
/// See https://www.postgresql.org/docs/current/sql-createtable.html for more information.
21+
/// </remarks>
22+
/// <param name="referenceCollectionBuilder">The builder being used to configure the relationship.</param>
23+
/// <param name="withPeriod">A value indicating whether to use PERIOD.</param>
24+
/// <returns>The same builder instance so that multiple calls can be chained.</returns>
25+
public static ReferenceCollectionBuilder WithPeriod(
26+
this ReferenceCollectionBuilder referenceCollectionBuilder,
27+
bool withPeriod = true)
28+
{
29+
Check.NotNull(referenceCollectionBuilder, nameof(referenceCollectionBuilder));
30+
31+
referenceCollectionBuilder.Metadata.SetPeriod(withPeriod);
32+
33+
return referenceCollectionBuilder;
34+
}
35+
36+
/// <summary>
37+
/// Configures the foreign key to use the PostgreSQL PERIOD feature for temporal foreign keys.
38+
/// The last column in the foreign key must be a PostgreSQL range type, and the referenced
39+
/// principal key must have WITHOUT OVERLAPS configured.
40+
/// </summary>
41+
/// <remarks>
42+
/// See https://www.postgresql.org/docs/current/sql-createtable.html for more information.
43+
/// </remarks>
44+
/// <param name="referenceCollectionBuilder">The builder being used to configure the relationship.</param>
45+
/// <param name="withPeriod">A value indicating whether to use PERIOD.</param>
46+
/// <typeparam name="TEntity">The principal entity type in this relationship.</typeparam>
47+
/// <typeparam name="TRelatedEntity">The dependent entity type in this relationship.</typeparam>
48+
/// <returns>The same builder instance so that multiple calls can be chained.</returns>
49+
public static ReferenceCollectionBuilder<TEntity, TRelatedEntity> WithPeriod<TEntity, TRelatedEntity>(
50+
this ReferenceCollectionBuilder<TEntity, TRelatedEntity> referenceCollectionBuilder,
51+
bool withPeriod = true)
52+
where TEntity : class
53+
where TRelatedEntity : class
54+
=> (ReferenceCollectionBuilder<TEntity, TRelatedEntity>)WithPeriod(
55+
(ReferenceCollectionBuilder)referenceCollectionBuilder, withPeriod);
56+
57+
/// <summary>
58+
/// Configures the foreign key to use the PostgreSQL PERIOD feature for temporal foreign keys.
59+
/// The last column in the foreign key must be a PostgreSQL range type, and the referenced
60+
/// principal key must have WITHOUT OVERLAPS configured.
61+
/// </summary>
62+
/// <remarks>
63+
/// See https://www.postgresql.org/docs/current/sql-createtable.html for more information.
64+
/// </remarks>
65+
/// <param name="referenceReferenceBuilder">The builder being used to configure the relationship.</param>
66+
/// <param name="withPeriod">A value indicating whether to use PERIOD.</param>
67+
/// <returns>The same builder instance so that multiple calls can be chained.</returns>
68+
public static ReferenceReferenceBuilder WithPeriod(
69+
this ReferenceReferenceBuilder referenceReferenceBuilder,
70+
bool withPeriod = true)
71+
{
72+
Check.NotNull(referenceReferenceBuilder, nameof(referenceReferenceBuilder));
73+
74+
referenceReferenceBuilder.Metadata.SetPeriod(withPeriod);
75+
76+
return referenceReferenceBuilder;
77+
}
78+
79+
/// <summary>
80+
/// Configures the foreign key to use the PostgreSQL PERIOD feature for temporal foreign keys.
81+
/// The last column in the foreign key must be a PostgreSQL range type, and the referenced
82+
/// principal key must have WITHOUT OVERLAPS configured.
83+
/// </summary>
84+
/// <remarks>
85+
/// See https://www.postgresql.org/docs/current/sql-createtable.html for more information.
86+
/// </remarks>
87+
/// <param name="referenceReferenceBuilder">The builder being used to configure the relationship.</param>
88+
/// <param name="withPeriod">A value indicating whether to use PERIOD.</param>
89+
/// <typeparam name="TEntity">The entity type on one end of the relationship.</typeparam>
90+
/// <typeparam name="TRelatedEntity">The entity type on the other end of the relationship.</typeparam>
91+
/// <returns>The same builder instance so that multiple calls can be chained.</returns>
92+
public static ReferenceReferenceBuilder<TEntity, TRelatedEntity> WithPeriod<TEntity, TRelatedEntity>(
93+
this ReferenceReferenceBuilder<TEntity, TRelatedEntity> referenceReferenceBuilder,
94+
bool withPeriod = true)
95+
where TEntity : class
96+
where TRelatedEntity : class
97+
=> (ReferenceReferenceBuilder<TEntity, TRelatedEntity>)WithPeriod(
98+
(ReferenceReferenceBuilder)referenceReferenceBuilder, withPeriod);
99+
100+
/// <summary>
101+
/// Configures the foreign key to use the PostgreSQL PERIOD feature for temporal foreign keys.
102+
/// The last column in the foreign key must be a PostgreSQL range type, and the referenced
103+
/// principal key must have WITHOUT OVERLAPS configured.
104+
/// </summary>
105+
/// <remarks>
106+
/// See https://www.postgresql.org/docs/current/sql-createtable.html for more information.
107+
/// </remarks>
108+
/// <param name="ownershipBuilder">The builder being used to configure the relationship.</param>
109+
/// <param name="withPeriod">A value indicating whether to use PERIOD.</param>
110+
/// <returns>The same builder instance so that multiple calls can be chained.</returns>
111+
public static OwnershipBuilder WithPeriod(
112+
this OwnershipBuilder ownershipBuilder,
113+
bool withPeriod = true)
114+
{
115+
Check.NotNull(ownershipBuilder, nameof(ownershipBuilder));
116+
117+
ownershipBuilder.Metadata.SetPeriod(withPeriod);
118+
119+
return ownershipBuilder;
120+
}
121+
122+
/// <summary>
123+
/// Configures the foreign key to use the PostgreSQL PERIOD feature for temporal foreign keys.
124+
/// The last column in the foreign key must be a PostgreSQL range type, and the referenced
125+
/// principal key must have WITHOUT OVERLAPS configured.
126+
/// </summary>
127+
/// <remarks>
128+
/// See https://www.postgresql.org/docs/current/sql-createtable.html for more information.
129+
/// </remarks>
130+
/// <param name="ownershipBuilder">The builder being used to configure the relationship.</param>
131+
/// <param name="withPeriod">A value indicating whether to use PERIOD.</param>
132+
/// <typeparam name="TEntity">The entity type on one end of the relationship.</typeparam>
133+
/// <typeparam name="TDependentEntity">The entity type on the other end of the relationship.</typeparam>
134+
/// <returns>The same builder instance so that multiple calls can be chained.</returns>
135+
public static OwnershipBuilder<TEntity, TDependentEntity> WithPeriod<TEntity, TDependentEntity>(
136+
this OwnershipBuilder<TEntity, TDependentEntity> ownershipBuilder,
137+
bool withPeriod = true)
138+
where TEntity : class
139+
where TDependentEntity : class
140+
=> (OwnershipBuilder<TEntity, TDependentEntity>)WithPeriod(
141+
(OwnershipBuilder)ownershipBuilder, withPeriod);
142+
143+
/// <summary>
144+
/// Configures the foreign key to use the PostgreSQL PERIOD feature for temporal foreign keys.
145+
/// The last column in the foreign key must be a PostgreSQL range type, and the referenced
146+
/// principal key must have WITHOUT OVERLAPS configured.
147+
/// </summary>
148+
/// <remarks>
149+
/// See https://www.postgresql.org/docs/current/sql-createtable.html for more information.
150+
/// </remarks>
151+
/// <param name="foreignKeyBuilder">The builder being used to configure the relationship.</param>
152+
/// <param name="withPeriod">A value indicating whether to use PERIOD.</param>
153+
/// <param name="fromDataAnnotation">Indicates whether the configuration was specified using a data annotation.</param>
154+
/// <returns>
155+
/// The same builder instance if the configuration was applied,
156+
/// <see langword="null" /> otherwise.
157+
/// </returns>
158+
public static IConventionForeignKeyBuilder? WithPeriod(
159+
this IConventionForeignKeyBuilder foreignKeyBuilder,
160+
bool? withPeriod = true,
161+
bool fromDataAnnotation = false)
162+
{
163+
if (foreignKeyBuilder.CanSetPeriod(withPeriod, fromDataAnnotation))
164+
{
165+
foreignKeyBuilder.Metadata.SetPeriod(withPeriod, fromDataAnnotation);
166+
167+
return foreignKeyBuilder;
168+
}
169+
170+
return null;
171+
}
172+
173+
/// <summary>
174+
/// Returns a value indicating whether PERIOD can be configured.
175+
/// </summary>
176+
/// <param name="foreignKeyBuilder">The builder being used to configure the relationship.</param>
177+
/// <param name="withPeriod">A value indicating whether to use PERIOD.</param>
178+
/// <param name="fromDataAnnotation">Indicates whether the configuration was specified using a data annotation.</param>
179+
/// <returns><see langword="true" /> if the foreign key can be configured with PERIOD.</returns>
180+
public static bool CanSetPeriod(
181+
this IConventionForeignKeyBuilder foreignKeyBuilder,
182+
bool? withPeriod = true,
183+
bool fromDataAnnotation = false)
184+
{
185+
Check.NotNull(foreignKeyBuilder, nameof(foreignKeyBuilder));
186+
187+
return foreignKeyBuilder.CanSetAnnotation(NpgsqlAnnotationNames.Period, withPeriod, fromDataAnnotation);
188+
}
189+
190+
#endregion Period
191+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Internal;
2+
3+
// ReSharper disable once CheckNamespace
4+
namespace Microsoft.EntityFrameworkCore;
5+
6+
/// <summary>
7+
/// Extension methods for <see cref="IForeignKey" /> for Npgsql-specific metadata.
8+
/// </summary>
9+
public static class NpgsqlForeignKeyExtensions
10+
{
11+
#region Period
12+
13+
/// <summary>
14+
/// Returns a value indicating whether the foreign key uses the PostgreSQL PERIOD feature for temporal foreign keys.
15+
/// </summary>
16+
/// <remarks>
17+
/// See https://www.postgresql.org/docs/current/sql-createtable.html for more information.
18+
/// </remarks>
19+
/// <param name="foreignKey">The foreign key.</param>
20+
/// <returns><see langword="true" /> if the foreign key uses PERIOD.</returns>
21+
public static bool? GetPeriod(this IReadOnlyForeignKey foreignKey)
22+
=> (bool?)foreignKey[NpgsqlAnnotationNames.Period];
23+
24+
/// <summary>
25+
/// Sets a value indicating whether the foreign key uses the PostgreSQL PERIOD feature for temporal foreign keys.
26+
/// </summary>
27+
/// <remarks>
28+
/// See https://www.postgresql.org/docs/current/sql-createtable.html for more information.
29+
/// </remarks>
30+
/// <param name="foreignKey">The foreign key.</param>
31+
/// <param name="period">The value to set.</param>
32+
public static void SetPeriod(this IMutableForeignKey foreignKey, bool? period)
33+
=> foreignKey.SetOrRemoveAnnotation(NpgsqlAnnotationNames.Period, period);
34+
35+
/// <summary>
36+
/// Sets a value indicating whether the foreign key uses the PostgreSQL PERIOD feature for temporal foreign keys.
37+
/// </summary>
38+
/// <remarks>
39+
/// See https://www.postgresql.org/docs/current/sql-createtable.html for more information.
40+
/// </remarks>
41+
/// <param name="foreignKey">The foreign key.</param>
42+
/// <param name="period">The value to set.</param>
43+
/// <param name="fromDataAnnotation">Indicates whether the configuration was specified using a data annotation.</param>
44+
/// <returns>The configured value.</returns>
45+
public static bool? SetPeriod(this IConventionForeignKey foreignKey, bool? period, bool fromDataAnnotation = false)
46+
{
47+
foreignKey.SetOrRemoveAnnotation(NpgsqlAnnotationNames.Period, period, fromDataAnnotation);
48+
49+
return period;
50+
}
51+
52+
/// <summary>
53+
/// Returns the <see cref="ConfigurationSource" /> for whether the foreign key uses PERIOD.
54+
/// </summary>
55+
/// <param name="foreignKey">The foreign key.</param>
56+
/// <returns>The <see cref="ConfigurationSource" />.</returns>
57+
public static ConfigurationSource? GetPeriodConfigurationSource(this IConventionForeignKey foreignKey)
58+
=> foreignKey.FindAnnotation(NpgsqlAnnotationNames.Period)?.GetConfigurationSource();
59+
60+
#endregion Period
61+
}

src/EFCore.PG/Infrastructure/Internal/NpgsqlModelValidator.cs

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ public override void Validate(IModel model, IDiagnosticsLogger<DbLoggerCategory.
4545
ValidateIdentityVersionCompatibility(model);
4646
ValidateIndexIncludeProperties(model);
4747
ValidateWithoutOverlaps(model);
48+
ValidatePeriod(model);
4849
}
4950

5051
/// <summary>
@@ -294,7 +295,7 @@ protected virtual void ValidateWithoutOverlaps(IModel model)
294295

295296
private void ValidateWithoutOverlapsKey(IKey key)
296297
{
297-
var keyName = key.IsPrimaryKey() ? "primary key" : $"alternate key {{{string.Join(", ", key.Properties.Select(p => p.Name))}}}";
298+
var keyName = key.IsPrimaryKey() ? "primary key" : $"alternate key {key.Properties.Format()}";
298299
var entityType = key.DeclaringEntityType;
299300

300301
// Check PostgreSQL version requirement
@@ -305,7 +306,7 @@ private void ValidateWithoutOverlapsKey(IKey key)
305306
}
306307

307308
// Check that the last property is a range type
308-
var lastProperty = key.Properties.Last();
309+
var lastProperty = key.Properties[^1];
309310
var typeMapping = lastProperty.FindTypeMapping();
310311

311312
if (typeMapping is not NpgsqlRangeTypeMapping)
@@ -318,4 +319,65 @@ private void ValidateWithoutOverlapsKey(IKey key)
318319
lastProperty.ClrType.ShortDisplayName()));
319320
}
320321
}
322+
323+
/// <summary>
324+
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
325+
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
326+
/// any release. You should only use it directly in your code with extreme caution and knowing that
327+
/// doing so can result in application failures when updating to a new Entity Framework Core release.
328+
/// </summary>
329+
protected virtual void ValidatePeriod(IModel model)
330+
{
331+
foreach (var entityType in model.GetEntityTypes())
332+
{
333+
foreach (var foreignKey in entityType.GetDeclaredForeignKeys())
334+
{
335+
if (foreignKey.GetPeriod() == true)
336+
{
337+
ValidatePeriodForeignKey(foreignKey);
338+
}
339+
}
340+
}
341+
}
342+
343+
private void ValidatePeriodForeignKey(IForeignKey foreignKey)
344+
{
345+
var entityType = foreignKey.DeclaringEntityType;
346+
var fkName = foreignKey.Properties.Format();
347+
var principalKey = foreignKey.PrincipalKey;
348+
var principalEntityType = principalKey.DeclaringEntityType;
349+
350+
if (!_postgresVersion.AtLeast(18))
351+
{
352+
throw new InvalidOperationException(
353+
NpgsqlStrings.PeriodRequiresPostgres18(fkName, entityType.DisplayName()));
354+
}
355+
356+
// Check that the principal key has WITHOUT OVERLAPS (check this before range type)
357+
if (principalKey.GetWithoutOverlaps() != true)
358+
{
359+
throw new InvalidOperationException(
360+
NpgsqlStrings.PeriodRequiresWithoutOverlapsOnPrincipal(
361+
fkName,
362+
entityType.DisplayName(),
363+
principalKey.IsPrimaryKey()
364+
? "primary key"
365+
: $"alternate key {principalKey.Properties.Format()}",
366+
principalEntityType.DisplayName()));
367+
}
368+
369+
// Check that the last property is a range type
370+
var lastProperty = foreignKey.Properties[^1];
371+
var typeMapping = lastProperty.FindTypeMapping();
372+
373+
if (typeMapping is not NpgsqlRangeTypeMapping)
374+
{
375+
throw new InvalidOperationException(
376+
NpgsqlStrings.PeriodRequiresRangeType(
377+
fkName,
378+
entityType.DisplayName(),
379+
lastProperty.Name,
380+
lastProperty.ClrType.ShortDisplayName()));
381+
}
382+
}
321383
}

src/EFCore.PG/Metadata/Conventions/NpgsqlConventionSetBuilder.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ public override ConventionSet CreateConventionSet()
6464

6565
ReplaceConvention(conventionSet.ForeignKeyRemovedConventions, valueGenerationConvention);
6666

67+
conventionSet.ForeignKeyAnnotationChangedConventions.Add(
68+
new NpgsqlPeriodConvention(Dependencies, RelationalDependencies));
69+
6770
var storeGenerationConvention =
6871
new NpgsqlStoreGenerationConvention(Dependencies, RelationalDependencies);
6972
ReplaceConvention(conventionSet.PropertyAnnotationChangedConventions, storeGenerationConvention);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Internal;
2+
3+
namespace Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Conventions;
4+
5+
/// <summary>
6+
/// A convention that sets the delete behavior to <see cref="DeleteBehavior.NoAction" /> for foreign keys with PERIOD,
7+
/// since PostgreSQL does not support cascading deletes for temporal foreign keys.
8+
/// </summary>
9+
/// <param name="dependencies">Parameter object containing dependencies for this convention.</param>
10+
/// <param name="relationalDependencies">Parameter object containing relational dependencies for this convention.</param>
11+
public class NpgsqlPeriodConvention(
12+
ProviderConventionSetBuilderDependencies dependencies,
13+
RelationalConventionSetBuilderDependencies relationalDependencies)
14+
: IForeignKeyAnnotationChangedConvention
15+
{
16+
/// <summary>
17+
/// Dependencies for this service.
18+
/// </summary>
19+
protected virtual ProviderConventionSetBuilderDependencies Dependencies { get; } = dependencies;
20+
21+
/// <summary>
22+
/// Relational provider-specific dependencies for this service.
23+
/// </summary>
24+
protected virtual RelationalConventionSetBuilderDependencies RelationalDependencies { get; } = relationalDependencies;
25+
26+
/// <inheritdoc />
27+
public virtual void ProcessForeignKeyAnnotationChanged(
28+
IConventionForeignKeyBuilder relationshipBuilder,
29+
string name,
30+
IConventionAnnotation? annotation,
31+
IConventionAnnotation? oldAnnotation,
32+
IConventionContext<IConventionAnnotation> context)
33+
{
34+
if (name == NpgsqlAnnotationNames.Period && annotation?.Value is true)
35+
{
36+
relationshipBuilder.OnDelete(DeleteBehavior.NoAction);
37+
}
38+
}
39+
}

0 commit comments

Comments
 (0)