Skip to content

Commit

Permalink
Add a drop_multicolumn_constraint operation (#487)
Browse files Browse the repository at this point in the history
Add a new `drop_multicolumn_constraint` operation type. The new
operation is used to drop multi-column (and single-column) `CHECK`,
`UNIQUE`, and `FOREIGN KEY` constraints:

The following migration drops a multi-column constraint called
`check_zip_name`, and defines `up` and `down` SQL data migrations for
each column covered by the constraint:

```json
{
  "name": "48_drop_tickets_check",
  "operations": [
    {
      "drop_multicolumn_constraint": {
        "table": "tickets",
        "name": "check_zip_name",
        "up": {
          "sellers_name": "sellers_name",
          "sellers_zip": "sellers_zip"
        },
        "down": {
          "sellers_name": "sellers_name",
          "sellers_zip": "sellers_zip"
        }
      }
    }
  ]
}
```

* On operation `Start` each column covered by the constraint is
duplicated, ignoring the constraint to be dropped. Triggers for data
migrations are created for each column covered by the constraint using
the SQL for each column from the migration file.
* On `Complete`, the duplicated column is renamed to the original name
and triggers are removed.
* `Validate` ensures that all columns covered by the constraint have
data migrations defined, and only columns covered by the constraint have
data migrations defined.

The new operation is in addition to the existing `drop_constraint`
operation which only drops single column constraints. This old operation
type is preserved for backwards compatibility but will be removed as a
breaking change before a v1 release.

#489 tracks the removal of the
`drop_constraint` operation.
  • Loading branch information
andrew-farries authored Nov 26, 2024
1 parent 74a6fb5 commit 9c9518b
Show file tree
Hide file tree
Showing 9 changed files with 1,008 additions and 30 deletions.
33 changes: 32 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
* [Create constraint](#create-constraint)
* [Drop column](#drop-column)
* [Drop constraint](#drop-constraint)
* [Drop multi-column constraint](#drop-multi-column-constraint)
* [Drop index](#drop-index)
* [Drop table](#drop-table)
* [Raw SQL](#raw-sql)
Expand Down Expand Up @@ -793,6 +794,7 @@ See the [examples](../examples) directory for examples of each kind of operation
* [Create constraint](#create-constraint)
* [Drop column](#drop-column)
* [Drop constraint](#drop-constraint)
* [Drop multi-column constraint](#drop-multi-column-constraint)
* [Drop index](#drop-index)
* [Drop table](#drop-table)
* [Raw SQL](#raw-sql)
Expand Down Expand Up @@ -1208,7 +1210,7 @@ Example **drop column** migrations:

### Drop constraint

A drop constraint operation drops a constraint from an existing table.
A drop constraint operation drops a single-column constraint from an existing table.

Only `CHECK`, `FOREIGN KEY`, and `UNIQUE` constraints can be dropped.

Expand All @@ -1231,6 +1233,35 @@ Example **drop constraint** migrations:
* [24_drop_foreign_key_constraint.json](../examples/24_drop_foreign_key_constraint.json)
* [27_drop_unique_constraint.json](../examples/27_drop_unique_constraint.json)

### Drop multi-column constraint

A drop constraint operation drops a multi-column constraint from an existing table.

Only `CHECK`, `FOREIGN KEY`, and `UNIQUE` constraints can be dropped.

**drop multi-column constraint** operations have this structure:

```json
{
"drop_multicolumn_constraint": {
"table": "name of table",
"name": "name of constraint to drop",
"up": {
"column1": "up SQL expressions for each column covered by the constraint",
...
},
"down": {
"column1": "down SQL expressions for each column covered by the constraint",
...
}
}
}
```

Example **drop multi-column constraint** migrations:

* [48_drop_tickets_check.json](../examples/48_drop_tickets_check.json)

### Drop index

A drop index operation drops an index from a table.
Expand Down
1 change: 1 addition & 0 deletions examples/.ledger
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@
45_add_table_check_constraint.json
46_alter_column_drop_default.json
47_add_table_foreign_key_constraint.json
48_drop_tickets_check.json
19 changes: 19 additions & 0 deletions examples/48_drop_tickets_check.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "48_drop_tickets_check",
"operations": [
{
"drop_multicolumn_constraint": {
"table": "tickets",
"name": "check_zip_name",
"up": {
"sellers_name": "sellers_name",
"sellers_zip": "sellers_zip"
},
"down": {
"sellers_name": "sellers_name",
"sellers_zip": "sellers_zip"
}
}
}
]
}
9 changes: 9 additions & 0 deletions pkg/migrations/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ func (e ColumnMigrationMissingError) Error() string {
return fmt.Sprintf("migration for column %q in %q is missing", e.Name, e.Table)
}

type ColumnMigrationRedundantError struct {
Table string
Name string
}

func (e ColumnMigrationRedundantError) Error() string {
return fmt.Sprintf("migration for column %q in %q is redundant", e.Name, e.Table)
}

type ColumnIsNotNullableError struct {
Table string
Name string
Expand Down
33 changes: 20 additions & 13 deletions pkg/migrations/op_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,20 @@ import (
type OpName string

const (
OpNameCreateTable OpName = "create_table"
OpNameRenameTable OpName = "rename_table"
OpNameDropTable OpName = "drop_table"
OpNameAddColumn OpName = "add_column"
OpNameDropColumn OpName = "drop_column"
OpNameAlterColumn OpName = "alter_column"
OpNameCreateIndex OpName = "create_index"
OpNameDropIndex OpName = "drop_index"
OpNameRenameConstraint OpName = "rename_constraint"
OpNameDropConstraint OpName = "drop_constraint"
OpNameSetReplicaIdentity OpName = "set_replica_identity"
OpRawSQLName OpName = "sql"
OpCreateConstraintName OpName = "create_constraint"
OpNameCreateTable OpName = "create_table"
OpNameRenameTable OpName = "rename_table"
OpNameDropTable OpName = "drop_table"
OpNameAddColumn OpName = "add_column"
OpNameDropColumn OpName = "drop_column"
OpNameAlterColumn OpName = "alter_column"
OpNameCreateIndex OpName = "create_index"
OpNameDropIndex OpName = "drop_index"
OpNameRenameConstraint OpName = "rename_constraint"
OpNameDropConstraint OpName = "drop_constraint"
OpNameSetReplicaIdentity OpName = "set_replica_identity"
OpNameDropMultiColumnConstraint OpName = "drop_multicolumn_constraint"
OpRawSQLName OpName = "sql"
OpCreateConstraintName OpName = "create_constraint"

// Internal operation types used by `alter_column`

Expand Down Expand Up @@ -129,6 +130,9 @@ func (v *Operations) UnmarshalJSON(data []byte) error {
case OpCreateConstraintName:
item = &OpCreateConstraint{}

case OpNameDropMultiColumnConstraint:
item = &OpDropMultiColumnConstraint{}

default:
return fmt.Errorf("unknown migration type: %v", opName)
}
Expand Down Expand Up @@ -218,6 +222,9 @@ func OperationName(op Operation) OpName {
case *OpCreateConstraint:
return OpCreateConstraintName

case *OpDropMultiColumnConstraint:
return OpNameDropMultiColumnConstraint

}

panic(fmt.Errorf("unknown operation for %T", op))
Expand Down
196 changes: 196 additions & 0 deletions pkg/migrations/op_drop_multicolumn_constraint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// SPDX-License-Identifier: Apache-2.0

package migrations

import (
"context"
"fmt"
"slices"

"github.com/lib/pq"
"github.com/xataio/pgroll/pkg/db"
"github.com/xataio/pgroll/pkg/schema"
)

var _ Operation = (*OpDropMultiColumnConstraint)(nil)

func (o *OpDropMultiColumnConstraint) Start(ctx context.Context, conn db.DB, latestSchema string, tr SQLTransformer, s *schema.Schema, cbs ...CallbackFn) (*schema.Table, error) {
table := s.GetTable(o.Table)

// Get all columns covered by the constraint to be dropped
constraintColumns := table.GetConstraintColumns(o.Name)
columns := make([]*schema.Column, len(constraintColumns))
for i, c := range constraintColumns {
columns[i] = table.GetColumn(c)
}

// Duplicate each of the columns covered by the constraint to be dropped
d := NewColumnDuplicator(conn, table, columns...).WithoutConstraint(o.Name)
if err := d.Duplicate(ctx); err != nil {
return nil, fmt.Errorf("failed to duplicate column: %w", err)
}

// Create triggers for each column covered by the constraint to be dropped
for _, columnName := range table.GetConstraintColumns(o.Name) {
// Add a trigger to copy values from the old column to the new, rewriting values using the `up` SQL.
err := createTrigger(ctx, conn, tr, triggerConfig{
Name: TriggerName(o.Table, columnName),
Direction: TriggerDirectionUp,
Columns: table.Columns,
SchemaName: s.Name,
LatestSchema: latestSchema,
TableName: o.Table,
PhysicalColumn: TemporaryName(columnName),
SQL: o.upSQL(columnName),
})
if err != nil {
return nil, fmt.Errorf("failed to create up trigger: %w", err)
}

// Add the new column to the internal schema representation. This is done
// here, before creation of the down trigger, so that the trigger can declare
// a variable for the new column.
table.AddColumn(columnName, schema.Column{
Name: TemporaryName(columnName),
})

// Add a trigger to copy values from the new column to the old, rewriting values using the `down` SQL.
err = createTrigger(ctx, conn, tr, triggerConfig{
Name: TriggerName(o.Table, TemporaryName(columnName)),
Direction: TriggerDirectionDown,
Columns: table.Columns,
SchemaName: s.Name,
LatestSchema: latestSchema,
TableName: o.Table,
PhysicalColumn: columnName,
SQL: o.Down[columnName],
})
if err != nil {
return nil, fmt.Errorf("failed to create down trigger: %w", err)
}
}

return table, nil
}

func (o *OpDropMultiColumnConstraint) Complete(ctx context.Context, conn db.DB, tr SQLTransformer, s *schema.Schema) error {
table := s.GetTable(o.Table)

for _, columnName := range table.GetConstraintColumns(o.Name) {
// Remove the up function and trigger
_, err := conn.ExecContext(ctx, fmt.Sprintf("DROP FUNCTION IF EXISTS %s CASCADE",
pq.QuoteIdentifier(TriggerFunctionName(o.Table, columnName))))
if err != nil {
return err
}

// Remove the down function and trigger
_, err = conn.ExecContext(ctx, fmt.Sprintf("DROP FUNCTION IF EXISTS %s CASCADE",
pq.QuoteIdentifier(TriggerFunctionName(o.Table, TemporaryName(columnName)))))
if err != nil {
return err
}

// Drop the old column
_, err = conn.ExecContext(ctx, fmt.Sprintf("ALTER TABLE IF EXISTS %s DROP COLUMN IF EXISTS %s",
pq.QuoteIdentifier(o.Table),
pq.QuoteIdentifier(columnName)))
if err != nil {
return err
}

// Rename the new column to the old column name
column := table.GetColumn(columnName)
if err := RenameDuplicatedColumn(ctx, conn, table, column); err != nil {
return err
}
}

return nil
}

func (o *OpDropMultiColumnConstraint) Rollback(ctx context.Context, conn db.DB, tr SQLTransformer, s *schema.Schema) error {
table := s.GetTable(o.Table)

for _, columnName := range table.GetConstraintColumns(o.Name) {
// Drop the new column
_, err := conn.ExecContext(ctx, fmt.Sprintf("ALTER TABLE %s DROP COLUMN IF EXISTS %s",
pq.QuoteIdentifier(o.Table),
pq.QuoteIdentifier(TemporaryName(columnName)),
))
if err != nil {
return err
}

// Remove the up function and trigger
_, err = conn.ExecContext(ctx, fmt.Sprintf("DROP FUNCTION IF EXISTS %s CASCADE",
pq.QuoteIdentifier(TriggerFunctionName(o.Table, columnName)),
))
if err != nil {
return err
}

// Remove the down function and trigger
_, err = conn.ExecContext(ctx, fmt.Sprintf("DROP FUNCTION IF EXISTS %s CASCADE",
pq.QuoteIdentifier(TriggerFunctionName(o.Table, TemporaryName(columnName))),
))
if err != nil {
return err
}
}

return nil
}

func (o *OpDropMultiColumnConstraint) Validate(ctx context.Context, s *schema.Schema) error {
table := s.GetTable(o.Table)
if table == nil {
return TableDoesNotExistError{Name: o.Table}
}

if o.Name == "" {
return FieldRequiredError{Name: "name"}
}

if !table.ConstraintExists(o.Name) {
return ConstraintDoesNotExistError{Table: o.Table, Constraint: o.Name}
}

if o.Down == nil {
return FieldRequiredError{Name: "down"}
}

// Ensure that `down` migrations are present for all columns covered by the
// constraint to be dropped.
for _, columnName := range table.GetConstraintColumns(o.Name) {
if _, ok := o.Down[columnName]; !ok {
return ColumnMigrationMissingError{
Table: o.Table,
Name: columnName,
}
}
}

// Ensure that only columns covered by the constraint are present in the
// `up` and `down` migrations.
for _, m := range []map[string]string{o.Down, o.Up} {
for columnName := range m {
if !slices.Contains(table.GetConstraintColumns(o.Name), columnName) {
return ColumnMigrationRedundantError{
Table: o.Table,
Name: columnName,
}
}
}
}

return nil
}

func (o *OpDropMultiColumnConstraint) upSQL(column string) string {
if o.Up[column] != "" {
return o.Up[column]
}

return pq.QuoteIdentifier(column)
}
Loading

0 comments on commit 9c9518b

Please sign in to comment.