Skip to content

Commit 1520db7

Browse files
authored
Document temporal constraints (#444)
See npgsql/efcore.pg#2097
1 parent b9aab27 commit 1520db7

3 files changed

Lines changed: 259 additions & 0 deletions

File tree

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# Temporal constraints
2+
3+
> [!NOTE]
4+
> Temporal constraints are only supported starting with version 11 of the EF provider, and require PostgreSQL 18.
5+
6+
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.
7+
8+
Temporal constraints work with PostgreSQL's range types, such as `daterange`, `tstzrange` (timestamp with timezone range), and `tsrange` (timestamp range).
9+
10+
## WITHOUT OVERLAPS
11+
12+
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.
13+
14+
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:
15+
16+
```csharp
17+
public class Employee
18+
{
19+
public int EmployeeId { get; set; }
20+
public string Name { get; set; }
21+
public string Department { get; set; }
22+
public decimal Salary { get; set; }
23+
public NpgsqlRange<DateTime> ValidPeriod { get; set; }
24+
}
25+
26+
protected override void OnModelCreating(ModelBuilder modelBuilder)
27+
{
28+
modelBuilder.Entity<Employee>(b =>
29+
{
30+
// Configure the range property with a default value
31+
b.Property(e => e.ValidPeriod)
32+
.HasDefaultValueSql("tstzrange(now(), 'infinity', '[)')");
33+
34+
// Configure the temporal primary key
35+
b.HasKey(e => new { e.EmployeeId, e.ValidPeriod })
36+
.HasWithoutOverlaps();
37+
});
38+
}
39+
```
40+
41+
This configuration creates the following table:
42+
43+
```sql
44+
CREATE TABLE employees (
45+
employee_id INTEGER,
46+
name VARCHAR(100) NOT NULL,
47+
department VARCHAR(50) NOT NULL,
48+
salary DECIMAL(10,2) NOT NULL,
49+
valid_period tstzrange NOT NULL DEFAULT tstzrange(now(), 'infinity', '[)'),
50+
PRIMARY KEY (employee_id, valid_period WITHOUT OVERLAPS)
51+
);
52+
```
53+
54+
With this constraint, you can insert multiple records for the same employee as long as their time periods don't overlap:
55+
56+
```sql
57+
-- Valid: Two records for the same employee with non-overlapping periods
58+
INSERT INTO employees (employee_id, name, department, salary, valid_period)
59+
VALUES
60+
(1, 'Alice Johnson', 'Engineering', 75000, tstzrange('2024-01-01', '2025-01-01', '[)')),
61+
(1, 'Alice Johnson', 'Engineering', 85000, tstzrange('2025-01-01', 'infinity', '[)'));
62+
63+
-- Invalid: This would fail because it overlaps with existing data
64+
INSERT INTO employees (employee_id, name, department, salary, valid_period)
65+
VALUES (1, 'Alice Johnson', 'Engineering', 95000, tstzrange('2024-06-01', '2025-06-01', '[)'));
66+
```
67+
68+
> [!IMPORTANT]
69+
> The range column with `WITHOUT OVERLAPS` must be the last column in the primary key definition.
70+
71+
## PERIOD for temporal foreign keys
72+
73+
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.
74+
75+
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.
76+
77+
```csharp
78+
public class Employee
79+
{
80+
public int EmployeeId { get; set; }
81+
public string Name { get; set; }
82+
public NpgsqlRange<DateTime> ValidPeriod { get; set; }
83+
}
84+
85+
public class ProjectAssignment
86+
{
87+
public int AssignmentId { get; set; }
88+
public int EmployeeId { get; set; }
89+
public string ProjectName { get; set; }
90+
public NpgsqlRange<DateTime> AssignmentPeriod { get; set; }
91+
}
92+
93+
protected override void OnModelCreating(ModelBuilder modelBuilder)
94+
{
95+
modelBuilder.Entity<Employee>(b =>
96+
{
97+
b.Property(e => e.ValidPeriod)
98+
.HasDefaultValueSql("tstzrange(now(), 'infinity', '[)')");
99+
100+
b.HasKey(e => new { e.EmployeeId, e.ValidPeriod })
101+
.HasWithoutOverlaps();
102+
});
103+
104+
modelBuilder.Entity<ProjectAssignment>(b =>
105+
{
106+
b.HasOne<Employee>()
107+
.WithMany()
108+
.HasForeignKey(e => new { e.EmployeeId, e.AssignmentPeriod })
109+
.HasPrincipalKey(e => new { e.EmployeeId, e.ValidPeriod })
110+
.HasPeriod();
111+
});
112+
}
113+
```
114+
115+
This generates a foreign key constraint like:
116+
117+
```sql
118+
ALTER TABLE project_assignments
119+
ADD CONSTRAINT fk_emp_temporal
120+
FOREIGN KEY (employee_id, PERIOD assignment_period)
121+
REFERENCES employees (employee_id, PERIOD valid_period);
122+
```
123+
124+
With this constraint:
125+
126+
```sql
127+
-- Valid: Assignment period falls within the employee's validity period
128+
INSERT INTO project_assignments (employee_id, project_name, assignment_period)
129+
VALUES (1, 'Website Redesign', tstzrange('2024-03-01', '2024-06-01', '[)'));
130+
131+
-- Invalid: Assignment period extends beyond the employee's validity period
132+
INSERT INTO project_assignments (employee_id, project_name, assignment_period)
133+
VALUES (1, 'Legacy Project', tstzrange('2022-01-01', '2022-06-01', '[)'));
134+
```
135+
136+
## Querying temporal data
137+
138+
When querying temporal data, PostgreSQL's range operators are particularly useful. The containment operator (`@>`) checks if a range contains a specific point in time:
139+
140+
```csharp
141+
// Find employees who were active on a specific date
142+
var activeEmployees = context.Employees
143+
.Where(e => e.ValidPeriod.Contains(new DateTime(2024, 6, 15)))
144+
.ToList();
145+
146+
// Find all historical records for a specific employee
147+
var employeeHistory = context.Employees
148+
.Where(e => e.EmployeeId == 1)
149+
.OrderBy(e => e.ValidPeriod)
150+
.ToList();
151+
```
152+
153+
These queries translate to efficient SQL that can leverage GiST indexes:
154+
155+
```sql
156+
-- Active employees on a specific date
157+
SELECT * FROM employees
158+
WHERE valid_period @> '2024-06-15'::timestamptz;
159+
160+
-- Employee history
161+
SELECT * FROM employees
162+
WHERE employee_id = 1
163+
ORDER BY valid_period;
164+
```
165+
166+
> [!NOTE]
167+
> 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`.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# 11.0 Release Notes
2+
3+
Npgsql.EntityFrameworkCore.PostgreSQL version 11.0 is currently in development. Previews are available on [nuget.org](https://www.nuget.org/packages/Npgsql.EntityFrameworkCore.PostgreSQL).
4+
5+
## Support for PostgreSQL 18 temporal constraints
6+
7+
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.
8+
9+
### WITHOUT OVERLAPS for keys
10+
11+
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.
12+
13+
For example, an employee can have multiple records in the database (reflecting changes over time), but their validity periods must never overlap:
14+
15+
```csharp
16+
public class Employee
17+
{
18+
public int EmployeeId { get; set; }
19+
public string Name { get; set; }
20+
public string Department { get; set; }
21+
public decimal Salary { get; set; }
22+
public NpgsqlRange<DateTime> ValidPeriod { get; set; }
23+
}
24+
25+
protected override void OnModelCreating(ModelBuilder modelBuilder)
26+
{
27+
modelBuilder.Entity<Employee>(b =>
28+
{
29+
b.Property(e => e.ValidPeriod)
30+
.HasDefaultValueSql("tstzrange(now(), 'infinity', '[)')");
31+
32+
b.HasKey(e => new { e.EmployeeId, e.ValidPeriod })
33+
.HasWithoutOverlaps();
34+
});
35+
}
36+
```
37+
38+
This generates the following SQL:
39+
40+
```sql
41+
CREATE TABLE employees (
42+
employee_id INTEGER,
43+
name VARCHAR(100) NOT NULL,
44+
department VARCHAR(50) NOT NULL,
45+
salary DECIMAL(10,2) NOT NULL,
46+
valid_period tstzrange NOT NULL DEFAULT tstzrange(now(), 'infinity', '[)'),
47+
PRIMARY KEY (employee_id, valid_period WITHOUT OVERLAPS)
48+
);
49+
```
50+
51+
### PERIOD for temporal foreign keys
52+
53+
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.
54+
55+
For example, when assigning employees to projects, the assignment period must fall within the employee's validity period:
56+
57+
```csharp
58+
public class ProjectAssignment
59+
{
60+
public int AssignmentId { get; set; }
61+
public int EmployeeId { get; set; }
62+
public string ProjectName { get; set; }
63+
public NpgsqlRange<DateTime> AssignmentPeriod { get; set; }
64+
}
65+
66+
protected override void OnModelCreating(ModelBuilder modelBuilder)
67+
{
68+
modelBuilder.Entity<ProjectAssignment>(b =>
69+
{
70+
b.HasOne<Employee>()
71+
.WithMany()
72+
.HasForeignKey(e => new { e.EmployeeId, e.AssignmentPeriod })
73+
.HasPrincipalKey(e => new { e.EmployeeId, e.ValidPeriod })
74+
.HasPeriod();
75+
});
76+
}
77+
```
78+
79+
This generates the following SQL:
80+
81+
```sql
82+
ALTER TABLE project_assignments
83+
ADD CONSTRAINT fk_emp_temporal
84+
FOREIGN KEY (employee_id, PERIOD assignment_period)
85+
REFERENCES employees (employee_id, PERIOD valid_period);
86+
```
87+
88+
For more details, see the [temporal constraints documentation](../misc/temporal-constraints.md).

conceptual/EFCore.PG/toc.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
href: index.md
33
- name: Release notes
44
items:
5+
- name: "11.0 (preview)"
6+
href: release-notes/11.0.md
57
- name: "10.0"
68
href: release-notes/10.0.md
79
- name: "9.0"
@@ -62,6 +64,8 @@
6264
href: misc/collations-and-case-sensitivity.md
6365
- name: Database creation
6466
href: misc/database-creation.md
67+
- name: Temporal constraints
68+
href: misc/temporal-constraints.md
6569
- name: Other
6670
href: misc/other.md
6771
- name: API reference

0 commit comments

Comments
 (0)