Skip to content

Commit a5fb72e

Browse files
kvchandrew-farriesryanslade
authored
New operation: create_constraint and support unique constraints with multiple columns (#459)
This PR adds a new operation named `create_constraint`. Previously, we only supported adding constraints to columns. This operation lets us define table level constraints including multiple columns. The operation supports `unique` constraints for now. In a follow-up PR I am adding `foreign_key` and `check` constraints as well. ### Unique ```json { "name": "44_add_table_unique_constraint", "operations": [ { "create_constraint": { "type": "unique", "table": "tickets", "name": "unique_zip_name", "columns": [ "sellers_name", "sellers_zip" ], "up": { "sellers_name": "sellers_name", "sellers_zip": "sellers_zip" }, "down": { "sellers_name": "sellers_name", "sellers_zip": "sellers_zip" } } } ] } ``` ### Related Created from #411 --------- Co-authored-by: Andrew Farries <andyrb@gmail.com> Co-authored-by: Ryan Slade <ryanslade@gmail.com>
1 parent 0e34f78 commit a5fb72e

File tree

10 files changed

+688
-0
lines changed

10 files changed

+688
-0
lines changed

docs/README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
* [Add unique constraint](#add-unique-constraint)
2828
* [Create index](#create-index)
2929
* [Create table](#create-table)
30+
* [Create constraint](#create-constraint)
3031
* [Drop column](#drop-column)
3132
* [Drop constraint](#drop-constraint)
3233
* [Drop index](#drop-index)
@@ -687,6 +688,7 @@ See the [examples](../examples) directory for examples of each kind of operation
687688
* [Add unique constraint](#add-unique-constraint)
688689
* [Create index](#create-index)
689690
* [Create table](#create-table)
691+
* [Create constraint](#create-constraint)
690692
* [Drop column](#drop-column)
691693
* [Drop constraint](#drop-constraint)
692694
* [Drop index](#drop-index)
@@ -1037,6 +1039,40 @@ Example **create table** migrations:
10371039
* [25_add_table_with_check_constraint.json](../examples/25_add_table_with_check_constraint.json)
10381040
* [28_different_defaults.json](../examples/28_different_defaults.json)
10391041

1042+
### Create constraint
1043+
1044+
A create constraint operation adds a new constraint to an existing table.
1045+
1046+
Only `UNIQUE` constraints are supported.
1047+
1048+
Required fields: `name`, `table`, `type`, `up`, `down`.
1049+
1050+
**create constraint** operations have this structure:
1051+
1052+
```json
1053+
{
1054+
"create_constraint": {
1055+
"table": "name of table",
1056+
"name": "my_unique_constraint",
1057+
"columns": ["col1", "col2"],
1058+
"type": "unique"
1059+
"up": {
1060+
"col1": "col1 || random()",
1061+
"col2": "col2 || random()"
1062+
},
1063+
"down": {
1064+
"col1": "col1",
1065+
"col2": "col2"
1066+
}
1067+
}
1068+
}
1069+
```
1070+
1071+
Example **create constraint** migrations:
1072+
1073+
* [44_add_table_unique_constraint.json](../examples/44_add_table_unique_constraint.json)
1074+
1075+
10401076
### Drop column
10411077

10421078
A drop column operation drops a column from an existing table.

examples/.ledger

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,5 @@
4040
40_create_enum_type.json
4141
41_add_enum_column.json
4242
42_create_unique_index.json
43+
43_create_tickets_table.json
44+
44_add_table_unique_constraint.json

examples/43_create_tickets_table.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "43_create_tickets_table",
3+
"operations": [
4+
{
5+
"create_table": {
6+
"name": "tickets",
7+
"columns": [
8+
{
9+
"name": "ticket_id",
10+
"type": "serial",
11+
"pk": true
12+
},
13+
{
14+
"name": "sellers_name",
15+
"type": "varchar(255)"
16+
},
17+
{
18+
"name": "sellers_zip",
19+
"type": "integer"
20+
}
21+
]
22+
}
23+
}
24+
]
25+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "44_add_table_unique_constraint",
3+
"operations": [
4+
{
5+
"create_constraint": {
6+
"type": "unique",
7+
"table": "tickets",
8+
"name": "unique_zip_name",
9+
"columns": [
10+
"sellers_name",
11+
"sellers_zip"
12+
],
13+
"up": {
14+
"sellers_name": "sellers_name",
15+
"sellers_zip": "sellers_zip"
16+
},
17+
"down": {
18+
"sellers_name": "sellers_name",
19+
"sellers_zip": "sellers_zip"
20+
}
21+
}
22+
}
23+
]
24+
}

pkg/migrations/errors.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ func (e ColumnDoesNotExistError) Error() string {
5454
return fmt.Sprintf("column %q does not exist on table %q", e.Name, e.Table)
5555
}
5656

57+
type ColumnMigrationMissingError struct {
58+
Table string
59+
Name string
60+
}
61+
62+
func (e ColumnMigrationMissingError) Error() string {
63+
return fmt.Sprintf("migration for column %q in %q is missing", e.Name, e.Table)
64+
}
65+
5766
type ColumnIsNotNullableError struct {
5867
Table string
5968
Name string

pkg/migrations/op_common.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const (
2424
OpNameDropConstraint OpName = "drop_constraint"
2525
OpNameSetReplicaIdentity OpName = "set_replica_identity"
2626
OpRawSQLName OpName = "sql"
27+
OpCreateConstraintName OpName = "create_constraint"
2728

2829
// Internal operation types used by `alter_column`
2930
OpNameRenameColumn OpName = "rename_column"
@@ -124,6 +125,9 @@ func (v *Operations) UnmarshalJSON(data []byte) error {
124125
case OpRawSQLName:
125126
item = &OpRawSQL{}
126127

128+
case OpCreateConstraintName:
129+
item = &OpCreateConstraint{}
130+
127131
default:
128132
return fmt.Errorf("unknown migration type: %v", opName)
129133
}
@@ -210,6 +214,9 @@ func OperationName(op Operation) OpName {
210214
case *OpRawSQL:
211215
return OpRawSQLName
212216

217+
case *OpCreateConstraint:
218+
return OpCreateConstraintName
219+
213220
}
214221

215222
panic(fmt.Errorf("unknown operation for %T", op))
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package migrations
4+
5+
import (
6+
"context"
7+
"fmt"
8+
"strings"
9+
10+
"github.com/lib/pq"
11+
12+
"github.com/xataio/pgroll/pkg/db"
13+
"github.com/xataio/pgroll/pkg/schema"
14+
)
15+
16+
var _ Operation = (*OpCreateConstraint)(nil)
17+
18+
func (o *OpCreateConstraint) Start(ctx context.Context, conn db.DB, latestSchema string, tr SQLTransformer, s *schema.Schema, cbs ...CallbackFn) (*schema.Table, error) {
19+
var err error
20+
var table *schema.Table
21+
for _, col := range o.Columns {
22+
if table, err = o.duplicateColumnBeforeStart(ctx, conn, latestSchema, tr, col, s); err != nil {
23+
return nil, err
24+
}
25+
}
26+
27+
switch o.Type { //nolint:gocritic // more cases will be added
28+
case OpCreateConstraintTypeUnique:
29+
return table, o.addUniqueIndex(ctx, conn)
30+
}
31+
32+
return table, nil
33+
}
34+
35+
func (o *OpCreateConstraint) duplicateColumnBeforeStart(ctx context.Context, conn db.DB, latestSchema string, tr SQLTransformer, colName string, s *schema.Schema) (*schema.Table, error) {
36+
table := s.GetTable(o.Table)
37+
column := table.GetColumn(colName)
38+
39+
d := NewColumnDuplicator(conn, table, column)
40+
if err := d.Duplicate(ctx); err != nil {
41+
return nil, fmt.Errorf("failed to duplicate column for new constraint: %w", err)
42+
}
43+
44+
upSQL, ok := o.Up[colName]
45+
if !ok {
46+
return nil, fmt.Errorf("up migration is missing for column %s", colName)
47+
}
48+
physicalColumnName := TemporaryName(colName)
49+
err := createTrigger(ctx, conn, tr, triggerConfig{
50+
Name: TriggerName(o.Table, colName),
51+
Direction: TriggerDirectionUp,
52+
Columns: table.Columns,
53+
SchemaName: s.Name,
54+
LatestSchema: latestSchema,
55+
TableName: o.Table,
56+
PhysicalColumn: physicalColumnName,
57+
SQL: upSQL,
58+
})
59+
if err != nil {
60+
return nil, fmt.Errorf("failed to create up trigger: %w", err)
61+
}
62+
63+
table.AddColumn(colName, schema.Column{
64+
Name: physicalColumnName,
65+
})
66+
67+
downSQL, ok := o.Down[colName]
68+
if !ok {
69+
return nil, fmt.Errorf("down migration is missing for column %s", colName)
70+
}
71+
err = createTrigger(ctx, conn, tr, triggerConfig{
72+
Name: TriggerName(o.Table, physicalColumnName),
73+
Direction: TriggerDirectionDown,
74+
Columns: table.Columns,
75+
LatestSchema: latestSchema,
76+
SchemaName: s.Name,
77+
TableName: o.Table,
78+
PhysicalColumn: colName,
79+
SQL: downSQL,
80+
})
81+
if err != nil {
82+
return nil, fmt.Errorf("failed to create down trigger: %w", err)
83+
}
84+
return table, nil
85+
}
86+
87+
func (o *OpCreateConstraint) Complete(ctx context.Context, conn db.DB, tr SQLTransformer, s *schema.Schema) error {
88+
switch o.Type { //nolint:gocritic // more cases will be added
89+
case OpCreateConstraintTypeUnique:
90+
uniqueOp := &OpSetUnique{
91+
Table: o.Table,
92+
Name: o.Name,
93+
}
94+
err := uniqueOp.Complete(ctx, conn, tr, s)
95+
if err != nil {
96+
return err
97+
}
98+
}
99+
100+
// remove old columns
101+
_, err := conn.ExecContext(ctx, fmt.Sprintf("ALTER TABLE %s %s",
102+
pq.QuoteIdentifier(o.Table),
103+
dropMultipleColumns(quoteColumnNames(o.Columns)),
104+
))
105+
if err != nil {
106+
return err
107+
}
108+
109+
// rename new columns to old name
110+
table := s.GetTable(o.Table)
111+
for _, col := range o.Columns {
112+
column := table.GetColumn(col)
113+
if err := RenameDuplicatedColumn(ctx, conn, table, column); err != nil {
114+
return err
115+
}
116+
}
117+
118+
return o.removeTriggers(ctx, conn)
119+
}
120+
121+
func (o *OpCreateConstraint) Rollback(ctx context.Context, conn db.DB, tr SQLTransformer, s *schema.Schema) error {
122+
_, err := conn.ExecContext(ctx, fmt.Sprintf("ALTER TABLE %s %s",
123+
pq.QuoteIdentifier(o.Table),
124+
dropMultipleColumns(quotedTemporaryNames(o.Columns)),
125+
))
126+
if err != nil {
127+
return err
128+
}
129+
130+
return o.removeTriggers(ctx, conn)
131+
}
132+
133+
func (o *OpCreateConstraint) removeTriggers(ctx context.Context, conn db.DB) error {
134+
dropFuncs := make([]string, len(o.Columns)*2)
135+
for i, j := 0, 0; i < len(o.Columns); i, j = i+1, j+2 {
136+
dropFuncs[j] = pq.QuoteIdentifier(TriggerFunctionName(o.Table, o.Columns[i]))
137+
dropFuncs[j+1] = pq.QuoteIdentifier(TriggerFunctionName(o.Table, TemporaryName(o.Columns[i])))
138+
}
139+
_, err := conn.ExecContext(ctx, fmt.Sprintf("DROP FUNCTION IF EXISTS %s CASCADE",
140+
strings.Join(dropFuncs, ", "),
141+
))
142+
return err
143+
}
144+
145+
func dropMultipleColumns(columns []string) string {
146+
for i, col := range columns {
147+
columns[i] = "DROP COLUMN IF EXISTS " + col
148+
}
149+
return strings.Join(columns, ", ")
150+
}
151+
152+
func (o *OpCreateConstraint) Validate(ctx context.Context, s *schema.Schema) error {
153+
table := s.GetTable(o.Table)
154+
if table == nil {
155+
return TableDoesNotExistError{Name: o.Table}
156+
}
157+
158+
if err := ValidateIdentifierLength(o.Name); err != nil {
159+
return err
160+
}
161+
162+
if table.ConstraintExists(o.Name) {
163+
return ConstraintAlreadyExistsError{
164+
Table: o.Table,
165+
Constraint: o.Name,
166+
}
167+
}
168+
169+
for _, col := range o.Columns {
170+
if table.GetColumn(col) == nil {
171+
return ColumnDoesNotExistError{
172+
Table: o.Table,
173+
Name: col,
174+
}
175+
}
176+
if _, ok := o.Up[col]; !ok {
177+
return ColumnMigrationMissingError{
178+
Table: o.Table,
179+
Name: col,
180+
}
181+
}
182+
if _, ok := o.Down[col]; !ok {
183+
return ColumnMigrationMissingError{
184+
Table: o.Table,
185+
Name: col,
186+
}
187+
}
188+
}
189+
190+
switch o.Type { //nolint:gocritic // more cases will be added
191+
case OpCreateConstraintTypeUnique:
192+
if len(o.Columns) == 0 {
193+
return FieldRequiredError{Name: "columns"}
194+
}
195+
}
196+
197+
return nil
198+
}
199+
200+
func (o *OpCreateConstraint) addUniqueIndex(ctx context.Context, conn db.DB) error {
201+
_, err := conn.ExecContext(ctx, fmt.Sprintf("CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS %s ON %s (%s)",
202+
pq.QuoteIdentifier(o.Name),
203+
pq.QuoteIdentifier(o.Table),
204+
strings.Join(quotedTemporaryNames(o.Columns), ", "),
205+
))
206+
207+
return err
208+
}
209+
210+
func quotedTemporaryNames(columns []string) []string {
211+
names := make([]string, len(columns))
212+
for i, col := range columns {
213+
names[i] = pq.QuoteIdentifier(TemporaryName(col))
214+
}
215+
return names
216+
}

0 commit comments

Comments
 (0)