Skip to content

Commit 43381e9

Browse files
authored
Add constraints option to create_table and unique constraint support (#585)
This PR adds support for a new option of `create_table` operation named `constraints`. It expects a list of `constraints` that is defined on the table when the table is created. At the moment the only constraint we support is `unique`. But it includes support for all options of unique constraints including `NULLS NOT DISTINCT`, index configuration settings, constraint deference, etc. Once I am done with these table constraints, I will open a follow-up PR to extend the constraint options for column constraints and `create_constraint` operation. Example migration: ```json { "name": "50_create_table_with_table_constraint", "operations": [ { "create_table": { "name": "phonebook", "columns": [ { "name": "id", "type": "serial", "pk": true }, { "name": "name", "type": "varchar(255)" }, { "name": "city", "type": "varchar(255)" }, { "name": "phone", "type": "varchar(255)" } ], "constraints": [ { "name": "unique_numbers", "type": "unique", "columns": ["phone"], "index_parameters": { "include_columns": ["name"] } } ] } } ] } ``` The table definition above turns into this table in PostgreSQL: ``` postgres=# \d phonebook Table "public.phonebook" Column | Type | Collation | Nullable | Default --------+------------------------+-----------+----------+--------------------------------------- id | integer | | not null | nextval('phonebook_id_seq'::regclass) name | character varying(255) | | not null | city | character varying(255) | | not null | phone | character varying(255) | | not null | Indexes: "phonebook_pkey" PRIMARY KEY, btree (id) "unique_numbers" UNIQUE CONSTRAINT, btree (phone) INCLUDE (name) ``` Part of #580
1 parent 6c5ee62 commit 43381e9

File tree

10 files changed

+535
-10
lines changed

10 files changed

+535
-10
lines changed

docs/operations/create_table.mdx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ description: A create table operation creates a new table in the database.
99
{
1010
"create_table": {
1111
"name": "name of new table",
12-
"columns": [...]
12+
"columns": [...],
13+
"constraints": [...]
1314
}
1415
}
1516
```
@@ -40,6 +41,26 @@ Each `column` is defined as:
4041

4142
Default values are subject to the usual rules for quoting SQL expressions. In particular, string literals should be surrounded with single quotes.
4243

44+
Each `constraint` is defined as:
45+
46+
```json
47+
{
48+
"name": "constraint name",
49+
"type": "constraint type",
50+
"columns": ["list", "of", "columns"],
51+
"nulls_not_distinct": true|false,
52+
"deferrable": true|false,
53+
"initially_deferred": true|false,
54+
"index_parameters": {
55+
"tablespace": "index_tablespace",
56+
"storage_parameters": "parameter=value",
57+
"include_columns": ["list", "of", "columns", "included in index"]
58+
},
59+
},
60+
```
61+
62+
Supported constraint types: `unique`.
63+
4364
## Examples
4465

4566
### Create multiple tables
@@ -98,3 +119,9 @@ Create a table with a `CHECK` constraint on one column:
98119
Create a table with different `DEFAULT` values:
99120

100121
<ExampleSnippet example="28_different_defaults.json" language="json" />
122+
123+
### Create a table with table level unique constraint
124+
125+
Create a table with table level constraints:
126+
127+
<ExampleSnippet example="50_create_table_with_table_constraint.json" language="json" />

examples/.ledger

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,4 @@
4747
47_add_table_foreign_key_constraint.json
4848
48_drop_tickets_check.json
4949
49_unset_not_null_on_indexed_column.json
50+
50_create_table_with_table_constraint.json
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"name": "50_create_table_with_table_constraint",
3+
"operations": [
4+
{
5+
"create_table": {
6+
"name": "phonebook",
7+
"columns": [
8+
{
9+
"name": "id",
10+
"type": "serial",
11+
"pk": true
12+
},
13+
{
14+
"name": "name",
15+
"type": "varchar(255)"
16+
},
17+
{
18+
"name": "city",
19+
"type": "varchar(255)"
20+
},
21+
{
22+
"name": "phone",
23+
"type": "varchar(255)"
24+
}
25+
],
26+
"constraints": [
27+
{
28+
"name": "unique_numbers",
29+
"type": "unique",
30+
"columns": [
31+
"phone"
32+
],
33+
"index_parameters": {
34+
"include_columns": [
35+
"name"
36+
]
37+
}
38+
}
39+
]
40+
}
41+
}
42+
]
43+
}

pkg/migrations/op_create_table.go

Lines changed: 125 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,16 @@ func (o *OpCreateTable) Start(ctx context.Context, conn db.DB, latestSchema stri
2222
return nil, fmt.Errorf("failed to create columns SQL: %w", err)
2323
}
2424

25+
constraintsSQL, err := constraintsToSQL(o.Constraints)
26+
if err != nil {
27+
return nil, fmt.Errorf("failed to create constraints SQL: %w", err)
28+
}
29+
2530
// Create the table
26-
_, err = conn.ExecContext(ctx, fmt.Sprintf("CREATE TABLE %s (%s)",
31+
_, err = conn.ExecContext(ctx, fmt.Sprintf("CREATE TABLE %s (%s %s)",
2732
pq.QuoteIdentifier(o.Name),
28-
columnsSQL))
33+
columnsSQL,
34+
constraintsSQL))
2935
if err != nil {
3036
return nil, err
3137
}
@@ -102,6 +108,22 @@ func (o *OpCreateTable) Validate(ctx context.Context, s *schema.Schema) error {
102108
}
103109
}
104110

111+
for _, c := range o.Constraints {
112+
if c.Name == "" {
113+
return FieldRequiredError{Name: "name"}
114+
}
115+
if err := ValidateIdentifierLength(c.Name); err != nil {
116+
return fmt.Errorf("invalid constraint: %w", err)
117+
}
118+
119+
switch c.Type { //nolint:gocritic // more cases are coming soon
120+
case ConstraintTypeUnique:
121+
if len(c.Columns) == 0 {
122+
return FieldRequiredError{Name: "columns"}
123+
}
124+
}
125+
}
126+
105127
// Update the schema to ensure that the new table is visible to validation of
106128
// subsequent operations.
107129
o.updateSchema(s)
@@ -118,9 +140,23 @@ func (o *OpCreateTable) updateSchema(s *schema.Schema) *schema.Schema {
118140
Name: col.Name,
119141
}
120142
}
143+
var uniqueConstraints map[string]*schema.UniqueConstraint
144+
for _, c := range o.Constraints {
145+
switch c.Type { //nolint:gocritic // more cases are coming soon
146+
case ConstraintTypeUnique:
147+
if uniqueConstraints == nil {
148+
uniqueConstraints = make(map[string]*schema.UniqueConstraint)
149+
}
150+
uniqueConstraints[c.Name] = &schema.UniqueConstraint{
151+
Name: c.Name,
152+
Columns: c.Columns,
153+
}
154+
}
155+
}
121156
s.AddTable(o.Name, &schema.Table{
122-
Name: o.Name,
123-
Columns: columns,
157+
Name: o.Name,
158+
Columns: columns,
159+
UniqueConstraints: uniqueConstraints,
124160
})
125161

126162
return s
@@ -150,3 +186,88 @@ func columnsToSQL(cols []Column, tr SQLTransformer) (string, error) {
150186
}
151187
return sql, nil
152188
}
189+
190+
func constraintsToSQL(constraints []Constraint) (string, error) {
191+
constraintsSQL := make([]string, len(constraints))
192+
for i, c := range constraints {
193+
writer := &ConstraintSQLWriter{
194+
Name: c.Name,
195+
Columns: c.Columns,
196+
InitiallyDeferred: c.InitiallyDeferred,
197+
Deferrable: c.Deferrable,
198+
}
199+
if c.IndexParameters != nil {
200+
writer.IncludeColumns = c.IndexParameters.IncludeColumns
201+
writer.StorageParameters = c.IndexParameters.StorageParameters
202+
writer.Tablespace = c.IndexParameters.Tablespace
203+
}
204+
205+
switch c.Type { //nolint:gocritic // more cases are coming soon
206+
case ConstraintTypeUnique:
207+
constraintsSQL[i] = writer.WriteUnique(c.NullsNotDistinct)
208+
}
209+
}
210+
if len(constraintsSQL) == 0 {
211+
return "", nil
212+
}
213+
return ", " + strings.Join(constraintsSQL, ", "), nil
214+
}
215+
216+
type ConstraintSQLWriter struct {
217+
Name string
218+
Columns []string
219+
InitiallyDeferred bool
220+
Deferrable bool
221+
222+
// unique, exclude, primary key constraints support the following options
223+
IncludeColumns []string
224+
StorageParameters string
225+
Tablespace string
226+
}
227+
228+
func (w *ConstraintSQLWriter) WriteUnique(nullsNotDistinct bool) string {
229+
var constraint string
230+
if w.Name != "" {
231+
constraint = fmt.Sprintf("CONSTRAINT %s ", pq.QuoteIdentifier(w.Name))
232+
}
233+
nullsDistinct := ""
234+
if nullsNotDistinct {
235+
nullsDistinct = "NULLS NOT DISTINCT"
236+
}
237+
constraint += fmt.Sprintf("UNIQUE %s (%s)", nullsDistinct, strings.Join(quoteColumnNames(w.Columns), ", "))
238+
constraint += w.addIndexParameters()
239+
constraint += w.addDeferrable()
240+
return constraint
241+
}
242+
243+
func (w *ConstraintSQLWriter) addIndexParameters() string {
244+
constraint := ""
245+
if len(w.IncludeColumns) != 0 {
246+
constraint += fmt.Sprintf(" INCLUDE (%s)", strings.Join(quoteColumnNames(w.IncludeColumns), ", "))
247+
}
248+
if w.StorageParameters != "" {
249+
constraint += fmt.Sprintf(" WITH (%s)", w.StorageParameters)
250+
}
251+
if w.Tablespace != "" {
252+
constraint += fmt.Sprintf(" USING INDEX TABLESPACE %s", w.Tablespace)
253+
}
254+
return constraint
255+
}
256+
257+
func (w *ConstraintSQLWriter) addDeferrable() string {
258+
if !w.InitiallyDeferred && !w.Deferrable {
259+
return ""
260+
}
261+
deferrable := ""
262+
if w.Deferrable {
263+
deferrable += " DEFERRABLE"
264+
} else {
265+
deferrable += " NOT DEFERRABLE"
266+
}
267+
if w.InitiallyDeferred {
268+
deferrable += " INITIALLY DEFERRED"
269+
} else {
270+
deferrable += " INITIALLY IMMEDIATE"
271+
}
272+
return deferrable
273+
}

pkg/migrations/op_create_table_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,70 @@ func TestCreateTable(t *testing.T) {
483483
ColumnMustHaveComment(t, db, schema, "users", "name", "the username")
484484
},
485485
},
486+
{
487+
name: "create table with a unique table constraint",
488+
migrations: []migrations.Migration{
489+
{
490+
Name: "01_create_table",
491+
Operations: migrations.Operations{
492+
&migrations.OpCreateTable{
493+
Name: "users",
494+
Columns: []migrations.Column{
495+
{
496+
Name: "id",
497+
Type: "serial",
498+
Pk: true,
499+
},
500+
{
501+
Name: "name",
502+
Type: "text",
503+
},
504+
},
505+
Constraints: []migrations.Constraint{
506+
{
507+
Name: "unique_name",
508+
Type: migrations.ConstraintTypeUnique,
509+
Columns: []string{
510+
"name",
511+
},
512+
},
513+
},
514+
},
515+
},
516+
},
517+
},
518+
afterStart: func(t *testing.T, db *sql.DB, schema string) {
519+
// The unique constraint exists on the new table.
520+
UniqueConstraintMustExist(t, db, schema, "users", "unique_name")
521+
522+
// Inserting a row into the table succeeds when the unique constraint is satisfied.
523+
MustInsert(t, db, schema, "01_create_table", "users", map[string]string{
524+
"name": "alice",
525+
})
526+
527+
// Inserting a row into the table fails when the unique constraint is not satisfied.
528+
MustNotInsert(t, db, schema, "01_create_table", "users", map[string]string{
529+
"name": "alice",
530+
}, testutils.UniqueViolationErrorCode)
531+
},
532+
afterRollback: func(t *testing.T, db *sql.DB, schema string) {
533+
// The table has been dropped, so the unique constraint is gone.
534+
},
535+
afterComplete: func(t *testing.T, db *sql.DB, schema string) {
536+
// The check constraint exists on the new table.
537+
UniqueConstraintMustExist(t, db, schema, "users", "unique_name")
538+
539+
// Inserting a row into the table succeeds when the unique constraint is satisfied.
540+
MustInsert(t, db, schema, "01_create_table", "users", map[string]string{
541+
"name": "bobby",
542+
})
543+
544+
// Inserting a row into the table fails when the unique constraint is not satisfied.
545+
MustNotInsert(t, db, schema, "01_create_table", "users", map[string]string{
546+
"name": "bobby",
547+
}, testutils.UniqueViolationErrorCode)
548+
},
549+
},
486550
})
487551
}
488552

@@ -601,6 +665,38 @@ func TestCreateTableValidation(t *testing.T) {
601665
},
602666
wantStartErr: migrations.InvalidIdentifierLengthError{Name: invalidName},
603667
},
668+
{
669+
name: "missing column list in unique constraint",
670+
migrations: []migrations.Migration{
671+
{
672+
Name: "01_create_table",
673+
Operations: migrations.Operations{
674+
&migrations.OpCreateTable{
675+
Name: "table1",
676+
Columns: []migrations.Column{
677+
{
678+
Name: "id",
679+
Type: "serial",
680+
Pk: true,
681+
},
682+
{
683+
Name: "name",
684+
Type: "varchar(255)",
685+
Unique: true,
686+
},
687+
},
688+
Constraints: []migrations.Constraint{
689+
{
690+
Name: "unique_name",
691+
Type: migrations.ConstraintTypeUnique,
692+
},
693+
},
694+
},
695+
},
696+
},
697+
},
698+
wantStartErr: migrations.FieldRequiredError{Name: "columns"},
699+
},
604700
})
605701
}
606702

0 commit comments

Comments
 (0)