Skip to content

Commit 05ab88c

Browse files
authored
Add support for exclusion constraints in create_table operation (#624)
This PR adds a new type of constraint to `create_table` operation called `exclude` to support [exclusion constraints](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-EXCLUSION). New options: * `exclude.index_method`: name of the index method (e.g. btree, gist, etc.) * `exclude.elements`: exlusion elements * `exclude.predicate`: WHERE clause for partial indices Example constraint forbidding overlap in room reservations: ```json { "name": "52_create_table_with_table_exclusion_constraint", "operations": [ { "create_table": { "name": "room_reservations", "columns": [ { "name": "id", "type": "serial" }, { "name": "start", "type": "timestamp" }, { "name": "end", "type": "timestamp" }, { "name": "canceled", "type": "boolean", "default": "false" } ], "constraints": [ { "name": "forbid_double_booking", "type": "exclude", "exclude": { "index_method": "gist", "elements": "id WITH =, tsrange(\"start\", \"end\") WITH &&", "predicate": "NOT canceled" } } ] } } ] } ``` The tests I added require the extension `btree_gist`. So I added it for `make examples` and running `go test`.
1 parent 5e8c6db commit 05ab88c

17 files changed

+466
-15
lines changed

docs/operations/create_table.mdx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,15 @@ Each `constraint` is defined as:
7676
"storage_parameters": "parameter=value",
7777
"include_columns": ["list", "of", "columns", "included in index"]
7878
},
79+
"exclude": {
80+
"index_method": "name of the index method, e.g. btree",
81+
"elements": "exclude elements",
82+
"predicate": "WHERE clause of the exclude constraint"
83+
}
7984
},
8085
```
8186

82-
Supported constraint types: `unique`, `check`, `primary_key`, `foreign_key`.
87+
Supported constraint types: `unique`, `check`, `primary_key`, `foreign_key`, `exclude`.
8388

8489
Please note that you can only configure primary keys in `columns` list or `constraints` list, but
8590
not in both places.
@@ -154,3 +159,9 @@ Create a table with table level constraints:
154159
Create a table with table level foreign key constraints:
155160

156161
<ExampleSnippet example="51_create_table_with_table_foreign_key_constraint.json" language="json" />
162+
163+
### Create a table with exclusion constraint
164+
165+
Create a table with an exclusion:
166+
167+
<ExampleSnippet example="52_create_table_with_table_exclusion_constraint.json" language="json" />

examples/.ledger

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,4 @@
4949
49_unset_not_null_on_indexed_column.json
5050
50_create_table_with_table_constraint.json
5151
51_create_table_with_table_foreign_key_constraint.json
52+
52_create_table_with_exclusion_constraint.json
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"name": "52_create_table_with_table_exclusion_constraint",
3+
"operations": [
4+
{
5+
"create_table": {
6+
"name": "library",
7+
"columns": [
8+
{
9+
"name": "id",
10+
"type": "serial"
11+
},
12+
{
13+
"name": "returned",
14+
"type": "timestamp"
15+
},
16+
{
17+
"name": "title",
18+
"type": "text"
19+
},
20+
{
21+
"name": "summary",
22+
"type": "text"
23+
}
24+
],
25+
"constraints": [
26+
{
27+
"name": "rooms_pk",
28+
"type": "primary_key",
29+
"columns": [
30+
"id"
31+
]
32+
},
33+
{
34+
"name": "forbid_duplicated_titles",
35+
"type": "exclude",
36+
"exclude": {
37+
"index_method": "btree",
38+
"elements": "title WITH =",
39+
"predicate": "title IS NOT NULL"
40+
}
41+
}
42+
]
43+
}
44+
}
45+
]
46+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ require (
1414
github.com/stretchr/testify v1.10.0
1515
github.com/testcontainers/testcontainers-go v0.35.0
1616
github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0
17-
github.com/xataio/pg_query_go/v6 v6.0.0-20250122133641-54118c062181
17+
github.com/xataio/pg_query_go/v6 v6.0.0-20250124115938-4fa82ad6d036
1818
golang.org/x/tools v0.29.0
1919
)
2020

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,10 @@ github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYg
223223
github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE=
224224
github.com/xataio/pg_query_go/v6 v6.0.0-20250122133641-54118c062181 h1:iLOHgul20WFUhO4eFpJ/lZRkHzZICF2ghzncxtOcD0E=
225225
github.com/xataio/pg_query_go/v6 v6.0.0-20250122133641-54118c062181/go.mod h1:GK6bpfAhPtZb7wG/IccqvnH+cz3cmvvRTkC+MosESGo=
226+
github.com/xataio/pg_query_go/v6 v6.0.0-20250123182324-526a22cbe0c0 h1:/fXLU7NusFv8TSEAM7n+vtsN5Rr2La9tXWEunrJOWrI=
227+
github.com/xataio/pg_query_go/v6 v6.0.0-20250123182324-526a22cbe0c0/go.mod h1:GK6bpfAhPtZb7wG/IccqvnH+cz3cmvvRTkC+MosESGo=
228+
github.com/xataio/pg_query_go/v6 v6.0.0-20250124115938-4fa82ad6d036 h1:QMjBW1XIFUDzyUs/zlYmUo8lNO+hQ4VVt0msFv2AYpw=
229+
github.com/xataio/pg_query_go/v6 v6.0.0-20250124115938-4fa82ad6d036/go.mod h1:GK6bpfAhPtZb7wG/IccqvnH+cz3cmvvRTkC+MosESGo=
226230
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
227231
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
228232
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
This is an invalid 'create_table' migration.
2+
Exclusion constraints must have exclude configured
3+
4+
-- create_table.json --
5+
{
6+
"name": "migration_name",
7+
"operations": [
8+
{
9+
"create_table": {
10+
"name": "posts",
11+
"columns": [
12+
{
13+
"name": "title",
14+
"type": "varchar(255)"
15+
},
16+
{
17+
"name": "user_id",
18+
"type": "integer",
19+
"nullable": true
20+
}
21+
],
22+
"constraints": [
23+
{
24+
"name": "my_invalid_fk",
25+
"type": "exclude"
26+
}
27+
]
28+
}
29+
}
30+
]
31+
}
32+
33+
-- valid --
34+
false
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
This is an invalid 'create_table' migration.
2+
Exclusion constraints mustn't have columns configured
3+
4+
-- create_table.json --
5+
{
6+
"name": "migration_name",
7+
"operations": [
8+
{
9+
"create_table": {
10+
"name": "posts",
11+
"columns": [
12+
{
13+
"name": "title",
14+
"type": "varchar(255)"
15+
},
16+
{
17+
"name": "user_id",
18+
"type": "integer",
19+
"nullable": true
20+
}
21+
],
22+
"constraints": [
23+
{
24+
"name": "my_invalid_exclusion",
25+
"type": "exclude",
26+
"columns": ["invalid"],
27+
"exclude": {
28+
"index_method": "btree",
29+
"elements": "title WITH ="
30+
}
31+
}
32+
]
33+
}
34+
}
35+
]
36+
}
37+
38+
-- valid --
39+
false

internal/testutils/error_codes.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
package testutils
44

55
const (
6-
CheckViolationErrorCode string = "check_violation"
7-
FKViolationErrorCode string = "foreign_key_violation"
8-
NotNullViolationErrorCode string = "not_null_violation"
9-
UniqueViolationErrorCode string = "unique_violation"
10-
UndefinedColumnErrorCode string = "undefined_column"
11-
UndefinedTableErrorCode string = "undefined_table"
6+
CheckViolationErrorCode string = "check_violation"
7+
ExclusionViolationErrorCode string = "exclusion_violation"
8+
FKViolationErrorCode string = "foreign_key_violation"
9+
NotNullViolationErrorCode string = "not_null_violation"
10+
UndefinedColumnErrorCode string = "undefined_column"
11+
UndefinedTableErrorCode string = "undefined_table"
12+
UniqueViolationErrorCode string = "unique_violation"
1213
)

pkg/migrations/op_common_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,13 @@ func PrimaryKeyConstraintMustExist(t *testing.T, db *sql.DB, schema, table, cons
272272
}
273273
}
274274

275+
func ExcludeConstraintMustExist(t *testing.T, db *sql.DB, schema, table, constraint string) {
276+
t.Helper()
277+
if !excludeConstraintExists(t, db, schema, table, constraint) {
278+
t.Fatalf("Expected constraint %q to exist", constraint)
279+
}
280+
}
281+
275282
func IndexMustExist(t *testing.T, db *sql.DB, schema, table, index string) {
276283
t.Helper()
277284
if !indexExists(t, db, schema, table, index) {
@@ -477,6 +484,26 @@ func primaryKeyConstraintExists(t *testing.T, db *sql.DB, schema, table, constra
477484
return exists
478485
}
479486

487+
func excludeConstraintExists(t *testing.T, db *sql.DB, schema, table, constraint string) bool {
488+
t.Helper()
489+
490+
var exists bool
491+
err := db.QueryRow(`
492+
SELECT EXISTS (
493+
SELECT 1
494+
FROM pg_catalog.pg_constraint
495+
WHERE conrelid = $1::regclass
496+
AND conname = $2
497+
AND contype = 'x'
498+
)`,
499+
fmt.Sprintf("%s.%s", schema, table), constraint).Scan(&exists)
500+
if err != nil {
501+
t.Fatal(err)
502+
}
503+
504+
return exists
505+
}
506+
480507
func triggerExists(t *testing.T, db *sql.DB, schema, table, trigger string) bool {
481508
t.Helper()
482509

pkg/migrations/op_create_table.go

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ func (o *OpCreateTable) updateSchema(s *schema.Schema) *schema.Schema {
216216
uniqueConstraints := make(map[string]*schema.UniqueConstraint, 0)
217217
checkConstraints := make(map[string]*schema.CheckConstraint, 0)
218218
foreignKeys := make(map[string]*schema.ForeignKey, 0)
219+
excludeConstraints := make(map[string]*schema.ExcludeConstraint, 0)
219220
for _, c := range o.Constraints {
220221
switch c.Type {
221222
case ConstraintTypeUnique:
@@ -241,16 +242,24 @@ func (o *OpCreateTable) updateSchema(s *schema.Schema) *schema.Schema {
241242
OnUpdate: string(c.References.OnUpdate),
242243
MatchType: string(c.References.MatchType),
243244
}
245+
case ConstraintTypeExclude:
246+
excludeConstraints[c.Name] = &schema.ExcludeConstraint{
247+
Name: c.Name,
248+
IndexMethod: c.Exclude.IndexMethod,
249+
Elements: c.Exclude.Elements,
250+
Predicate: c.Exclude.Predicate,
251+
}
244252
}
245253
}
246254

247255
s.AddTable(o.Name, &schema.Table{
248-
Name: o.Name,
249-
Columns: columns,
250-
UniqueConstraints: uniqueConstraints,
251-
CheckConstraints: checkConstraints,
252-
PrimaryKey: primaryKeys,
253-
ForeignKeys: foreignKeys,
256+
Name: o.Name,
257+
Columns: columns,
258+
UniqueConstraints: uniqueConstraints,
259+
CheckConstraints: checkConstraints,
260+
PrimaryKey: primaryKeys,
261+
ForeignKeys: foreignKeys,
262+
ExcludeConstraints: excludeConstraints,
254263
})
255264

256265
return s
@@ -312,6 +321,8 @@ func constraintsToSQL(constraints []Constraint) (string, error) {
312321
constraintsSQL[i] = writer.WritePrimaryKey()
313322
case ConstraintTypeForeignKey:
314323
constraintsSQL[i] = writer.WriteForeignKey(c.References.Table, c.References.Columns, c.References.OnDelete, c.References.OnUpdate, c.References.OnDeleteSetColumns)
324+
case ConstraintTypeExclude:
325+
constraintsSQL[i] = writer.WriteExclude(c.Exclude.IndexMethod, c.Exclude.Elements, c.Exclude.Predicate)
315326
}
316327
}
317328
if len(constraintsSQL) == 0 {
@@ -398,6 +409,20 @@ func (w *ConstraintSQLWriter) WriteForeignKey(referencedTable string, referenced
398409
return constraint
399410
}
400411

412+
func (w *ConstraintSQLWriter) WriteExclude(indexMethod, elements, predicate string) string {
413+
constraint := ""
414+
if w.Name != "" {
415+
constraint = fmt.Sprintf("CONSTRAINT %s ", pq.QuoteIdentifier(w.Name))
416+
}
417+
constraint += fmt.Sprintf("EXCLUDE USING %s (%s)", indexMethod, elements)
418+
constraint += w.addIndexParameters()
419+
if predicate != "" {
420+
constraint += fmt.Sprintf(" WHERE (%s)", predicate)
421+
}
422+
constraint += w.addDeferrable()
423+
return constraint
424+
}
425+
401426
func (w *ConstraintSQLWriter) addIndexParameters() string {
402427
constraint := ""
403428
if len(w.IncludeColumns) != 0 {

0 commit comments

Comments
 (0)