diff --git a/conceptual/EFCore.PG/misc/temporal-constraints.md b/conceptual/EFCore.PG/misc/temporal-constraints.md new file mode 100644 index 00000000..c8172964 --- /dev/null +++ b/conceptual/EFCore.PG/misc/temporal-constraints.md @@ -0,0 +1,167 @@ +# Temporal constraints + +> [!NOTE] +> Temporal constraints are only supported starting with version 11 of the EF provider, and require PostgreSQL 18. + +PostgreSQL 18 introduced temporal constraints, which allow you to enforce data integrity rules over time periods. These features are particularly valuable for applications that need to track the validity periods of data, such as employee records, pricing information, equipment assignments, or any scenario where you need to maintain a complete historical timeline without gaps or overlaps. + +Temporal constraints work with PostgreSQL's range types, such as `daterange`, `tstzrange` (timestamp with timezone range), and `tsrange` (timestamp range). + +## WITHOUT OVERLAPS + +The `WITHOUT OVERLAPS` clause can be added to primary and alternate keys to ensure that for any given set of scalar column values, the associated time ranges do not overlap. + +A temporal key combines regular columns with a range column. This allows multiple rows for the same entity (e.g., same employee ID) as long as their time periods don't overlap, enabling you to maintain a complete history of changes: + +```csharp +public class Employee +{ + public int EmployeeId { get; set; } + public string Name { get; set; } + public string Department { get; set; } + public decimal Salary { get; set; } + public NpgsqlRange ValidPeriod { get; set; } +} + +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.Entity(b => + { + // Configure the range property with a default value + b.Property(e => e.ValidPeriod) + .HasDefaultValueSql("tstzrange(now(), 'infinity', '[)')"); + + // Configure the temporal primary key + b.HasKey(e => new { e.EmployeeId, e.ValidPeriod }) + .HasWithoutOverlaps(); + }); +} +``` + +This configuration creates the following table: + +```sql +CREATE TABLE employees ( + employee_id INTEGER, + name VARCHAR(100) NOT NULL, + department VARCHAR(50) NOT NULL, + salary DECIMAL(10,2) NOT NULL, + valid_period tstzrange NOT NULL DEFAULT tstzrange(now(), 'infinity', '[)'), + PRIMARY KEY (employee_id, valid_period WITHOUT OVERLAPS) +); +``` + +With this constraint, you can insert multiple records for the same employee as long as their time periods don't overlap: + +```sql +-- Valid: Two records for the same employee with non-overlapping periods +INSERT INTO employees (employee_id, name, department, salary, valid_period) +VALUES + (1, 'Alice Johnson', 'Engineering', 75000, tstzrange('2024-01-01', '2025-01-01', '[)')), + (1, 'Alice Johnson', 'Engineering', 85000, tstzrange('2025-01-01', 'infinity', '[)')); + +-- Invalid: This would fail because it overlaps with existing data +INSERT INTO employees (employee_id, name, department, salary, valid_period) +VALUES (1, 'Alice Johnson', 'Engineering', 95000, tstzrange('2024-06-01', '2025-06-01', '[)')); +``` + +> [!IMPORTANT] +> The range column with `WITHOUT OVERLAPS` must be the last column in the primary key definition. + +## PERIOD for temporal foreign keys + +PostgreSQL 18 also introduces temporal foreign keys using the `PERIOD` clause. These constraints ensure that foreign key relationships are maintained across time periods, checking for range containment rather than simple equality. + +A temporal foreign key ensures that the referenced row exists during the entire time period of the referencing row. This is particularly useful when you need to enforce that related temporal data is valid for the same time periods. + +```csharp +public class Employee +{ + public int EmployeeId { get; set; } + public string Name { get; set; } + public NpgsqlRange ValidPeriod { get; set; } +} + +public class ProjectAssignment +{ + public int AssignmentId { get; set; } + public int EmployeeId { get; set; } + public string ProjectName { get; set; } + public NpgsqlRange AssignmentPeriod { get; set; } +} + +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.Entity(b => + { + b.Property(e => e.ValidPeriod) + .HasDefaultValueSql("tstzrange(now(), 'infinity', '[)')"); + + b.HasKey(e => new { e.EmployeeId, e.ValidPeriod }) + .HasWithoutOverlaps(); + }); + + modelBuilder.Entity(b => + { + b.HasOne() + .WithMany() + .HasForeignKey(e => new { e.EmployeeId, e.AssignmentPeriod }) + .HasPrincipalKey(e => new { e.EmployeeId, e.ValidPeriod }) + .HasPeriod(); + }); +} +``` + +This generates a foreign key constraint like: + +```sql +ALTER TABLE project_assignments +ADD CONSTRAINT fk_emp_temporal +FOREIGN KEY (employee_id, PERIOD assignment_period) +REFERENCES employees (employee_id, PERIOD valid_period); +``` + +With this constraint: + +```sql +-- Valid: Assignment period falls within the employee's validity period +INSERT INTO project_assignments (employee_id, project_name, assignment_period) +VALUES (1, 'Website Redesign', tstzrange('2024-03-01', '2024-06-01', '[)')); + +-- Invalid: Assignment period extends beyond the employee's validity period +INSERT INTO project_assignments (employee_id, project_name, assignment_period) +VALUES (1, 'Legacy Project', tstzrange('2022-01-01', '2022-06-01', '[)')); +``` + +## Querying temporal data + +When querying temporal data, PostgreSQL's range operators are particularly useful. The containment operator (`@>`) checks if a range contains a specific point in time: + +```csharp +// Find employees who were active on a specific date +var activeEmployees = context.Employees + .Where(e => e.ValidPeriod.Contains(new DateTime(2024, 6, 15))) + .ToList(); + +// Find all historical records for a specific employee +var employeeHistory = context.Employees + .Where(e => e.EmployeeId == 1) + .OrderBy(e => e.ValidPeriod) + .ToList(); +``` + +These queries translate to efficient SQL that can leverage GiST indexes: + +```sql +-- Active employees on a specific date +SELECT * FROM employees +WHERE valid_period @> '2024-06-15'::timestamptz; + +-- Employee history +SELECT * FROM employees +WHERE employee_id = 1 +ORDER BY valid_period; +``` + +> [!NOTE] +> Temporal constraints require the `btree_gist` extension to be installed in your database. The EF provider automatically installs `btree_gist` when it detects a key with `WITHOUT OVERLAPS`. diff --git a/conceptual/EFCore.PG/release-notes/11.0.md b/conceptual/EFCore.PG/release-notes/11.0.md new file mode 100644 index 00000000..2dded579 --- /dev/null +++ b/conceptual/EFCore.PG/release-notes/11.0.md @@ -0,0 +1,88 @@ +# 11.0 Release Notes + +Npgsql.EntityFrameworkCore.PostgreSQL version 11.0 is currently in development. Previews are available on [nuget.org](https://www.nuget.org/packages/Npgsql.EntityFrameworkCore.PostgreSQL). + +## Support for PostgreSQL 18 temporal constraints + +PostgreSQL 18 introduced powerful temporal constraints that allow enforcing data integrity over time periods directly at the database level. The EF Core provider now supports these features, allowing you to define temporal primary keys, unique constraints, and foreign keys. + +### WITHOUT OVERLAPS for keys + +Temporal primary and alternate keys use the `WITHOUT OVERLAPS` clause to ensure that for any given set of scalar column values, the associated time ranges do not overlap. This is useful for scenarios where you need to track historical data (e.g. employee records, pricing information, equipment assignments) while ensuring data integrity. + +For example, an employee can have multiple records in the database (reflecting changes over time), but their validity periods must never overlap: + +```csharp +public class Employee +{ + public int EmployeeId { get; set; } + public string Name { get; set; } + public string Department { get; set; } + public decimal Salary { get; set; } + public NpgsqlRange ValidPeriod { get; set; } +} + +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.Entity(b => + { + b.Property(e => e.ValidPeriod) + .HasDefaultValueSql("tstzrange(now(), 'infinity', '[)')"); + + b.HasKey(e => new { e.EmployeeId, e.ValidPeriod }) + .HasWithoutOverlaps(); + }); +} +``` + +This generates the following SQL: + +```sql +CREATE TABLE employees ( + employee_id INTEGER, + name VARCHAR(100) NOT NULL, + department VARCHAR(50) NOT NULL, + salary DECIMAL(10,2) NOT NULL, + valid_period tstzrange NOT NULL DEFAULT tstzrange(now(), 'infinity', '[)'), + PRIMARY KEY (employee_id, valid_period WITHOUT OVERLAPS) +); +``` + +### PERIOD for temporal foreign keys + +Temporal foreign keys use the `PERIOD` clause to ensure that the referenced row exists during the entire time period of the referencing row. This maintains referential integrity across temporal relationships. + +For example, when assigning employees to projects, the assignment period must fall within the employee's validity period: + +```csharp +public class ProjectAssignment +{ + public int AssignmentId { get; set; } + public int EmployeeId { get; set; } + public string ProjectName { get; set; } + public NpgsqlRange AssignmentPeriod { get; set; } +} + +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.Entity(b => + { + b.HasOne() + .WithMany() + .HasForeignKey(e => new { e.EmployeeId, e.AssignmentPeriod }) + .HasPrincipalKey(e => new { e.EmployeeId, e.ValidPeriod }) + .HasPeriod(); + }); +} +``` + +This generates the following SQL: + +```sql +ALTER TABLE project_assignments +ADD CONSTRAINT fk_emp_temporal +FOREIGN KEY (employee_id, PERIOD assignment_period) +REFERENCES employees (employee_id, PERIOD valid_period); +``` + +For more details, see the [temporal constraints documentation](../misc/temporal-constraints.md). diff --git a/conceptual/EFCore.PG/toc.yml b/conceptual/EFCore.PG/toc.yml index 0e71fa82..1c498838 100644 --- a/conceptual/EFCore.PG/toc.yml +++ b/conceptual/EFCore.PG/toc.yml @@ -2,6 +2,8 @@ href: index.md - name: Release notes items: + - name: "11.0 (preview)" + href: release-notes/11.0.md - name: "10.0" href: release-notes/10.0.md - name: "9.0" @@ -62,6 +64,8 @@ href: misc/collations-and-case-sensitivity.md - name: Database creation href: misc/database-creation.md + - name: Temporal constraints + href: misc/temporal-constraints.md - name: Other href: misc/other.md - name: API reference