mirror of
https://github.com/xataio/pgroll.git
synced 2024-10-26 13:52:56 +03:00
Add support for adding a foreign key constraint to an existing column (#82)
Add support for adding a foreign key constraint to an existing column. Such a migration looks like: ```json { "name": "21_add_foreign_key_constraint", "operations": [ { "set_foreign_key": { "table": "posts", "column": "user_id", "references": { "table": "users", "column": "id" }, "up": "(SELECT CASE WHEN EXISTS (SELECT 1 FROM users WHERE users.id = user_id) THEN user_id ELSE NULL END)", "down": "user_id" } } ] } ``` This migration adds a foreign key constraint to the `user_id` column in the `posts` table, referencing the `id` column in the `users` table. The implementation is similar to the **set not null** and **change column type** operations: * On `Start`: * Create a new column, duplicating the one to which the FK constraint should be added. * The new column has the foreign key constraint added as `NOT VALID` to avoid taking a long lived `SHARE ROW EXCLUSIVE` lock (see [here](https://medium.com/paypal-tech/postgresql-at-scale-database-schema-changes-without-downtime-20d3749ed680#00dc)). * Backfill the new column with values from the existing column, rewriting values using the `up` SQL. * Create a trigger to populate the new column when values are written to the old column, converting values with `up`. * Create a trigger to populate the old column when values are written to the new column, converting values with `down`. * On `Complete` * Validate the foreign key constraint. * Remove triggers * Drop the old column * Rename the new column to the old column name. * Rename the foreign key constraint to be consistent with the new name of the column. * On `Rollback` * Remove the new column and both triggers. Removing the new column also removes the foreign key constraint on it. The `up` SQL in this operation is critical. The old column does not have a foreign key constraint imposed on it after `Start` as that would violate the guarantee that `pg-roll` does not make changes to the existing schema. The `up` SQL therefore needs to take into account that not all rows inserted into the old schema will have a valid foreign key. In the example `json` above, the `up` SQL ensures that values for which there is no corresponding user in the `users` table result in `NULL` values in the new column. Failure to do this would result in the old schema failing to insert rows without a valid `user_id`. An alternative would be to implement data quarantining for these values, as discussed last week @exekias .
This commit is contained in:
parent
425118423c
commit
c76ea9ce48
26
examples/20_create_posts_table.json
Normal file
26
examples/20_create_posts_table.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "20_create_posts_table",
|
||||||
|
"operations": [
|
||||||
|
{
|
||||||
|
"create_table": {
|
||||||
|
"name": "posts",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"pk": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"type": "varchar(255)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
17
examples/21_add_foreign_key_constraint.json
Normal file
17
examples/21_add_foreign_key_constraint.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "21_add_foreign_key_constraint",
|
||||||
|
"operations": [
|
||||||
|
{
|
||||||
|
"set_foreign_key": {
|
||||||
|
"table": "posts",
|
||||||
|
"column": "user_id",
|
||||||
|
"references": {
|
||||||
|
"table": "users",
|
||||||
|
"column": "id"
|
||||||
|
},
|
||||||
|
"up": "(SELECT CASE WHEN EXISTS (SELECT 1 FROM users WHERE users.id = user_id) THEN user_id ELSE NULL END)",
|
||||||
|
"down": "user_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -21,6 +21,7 @@ const (
|
|||||||
OpNameRenameColumn OpName = "rename_column"
|
OpNameRenameColumn OpName = "rename_column"
|
||||||
OpNameSetUnique OpName = "set_unique"
|
OpNameSetUnique OpName = "set_unique"
|
||||||
OpNameSetNotNull OpName = "set_not_null"
|
OpNameSetNotNull OpName = "set_not_null"
|
||||||
|
OpNameSetForeignKey OpName = "set_foreign_key"
|
||||||
OpNameChangeType OpName = "change_type"
|
OpNameChangeType OpName = "change_type"
|
||||||
OpRawSQLName OpName = "sql"
|
OpRawSQLName OpName = "sql"
|
||||||
)
|
)
|
||||||
@ -108,6 +109,9 @@ func (v *Operations) UnmarshalJSON(data []byte) error {
|
|||||||
case OpNameSetNotNull:
|
case OpNameSetNotNull:
|
||||||
item = &OpSetNotNull{}
|
item = &OpSetNotNull{}
|
||||||
|
|
||||||
|
case OpNameSetForeignKey:
|
||||||
|
item = &OpSetForeignKey{}
|
||||||
|
|
||||||
case OpNameChangeType:
|
case OpNameChangeType:
|
||||||
item = &OpChangeType{}
|
item = &OpChangeType{}
|
||||||
|
|
||||||
@ -188,6 +192,9 @@ func OperationName(op Operation) OpName {
|
|||||||
case *OpSetNotNull:
|
case *OpSetNotNull:
|
||||||
return OpNameSetNotNull
|
return OpNameSetNotNull
|
||||||
|
|
||||||
|
case *OpSetForeignKey:
|
||||||
|
return OpNameSetForeignKey
|
||||||
|
|
||||||
case *OpChangeType:
|
case *OpChangeType:
|
||||||
return OpNameChangeType
|
return OpNameChangeType
|
||||||
|
|
||||||
|
216
pkg/migrations/op_set_fk.go
Normal file
216
pkg/migrations/op_set_fk.go
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/lib/pq"
|
||||||
|
"github.com/xataio/pg-roll/pkg/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OpSetForeignKey struct {
|
||||||
|
Table string `json:"table"`
|
||||||
|
Column string `json:"column"`
|
||||||
|
References ColumnReference `json:"references"`
|
||||||
|
Up string `json:"up"`
|
||||||
|
Down string `json:"down"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Operation = (*OpSetForeignKey)(nil)
|
||||||
|
|
||||||
|
func (o *OpSetForeignKey) Start(ctx context.Context, conn *sql.DB, stateSchema string, s *schema.Schema) error {
|
||||||
|
table := s.GetTable(o.Table)
|
||||||
|
column := table.GetColumn(o.Column)
|
||||||
|
|
||||||
|
// Create a copy of the column on the underlying table.
|
||||||
|
if err := duplicateColumn(ctx, conn, table, *column); err != nil {
|
||||||
|
return fmt.Errorf("failed to duplicate column: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a NOT VALID foreign key constraint on the new column.
|
||||||
|
if err := o.addForeignKeyConstraint(ctx, conn); err != nil {
|
||||||
|
return fmt.Errorf("failed to add foreign key constraint: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a trigger to copy values from the old column to the new, rewriting values using the `up` SQL.
|
||||||
|
err := createTrigger(ctx, conn, triggerConfig{
|
||||||
|
Name: TriggerName(o.Table, o.Column),
|
||||||
|
Direction: TriggerDirectionUp,
|
||||||
|
Columns: table.Columns,
|
||||||
|
SchemaName: s.Name,
|
||||||
|
TableName: o.Table,
|
||||||
|
PhysicalColumn: TemporaryName(o.Column),
|
||||||
|
StateSchema: stateSchema,
|
||||||
|
SQL: o.Up,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create up trigger: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backfill the new column with values from the old column.
|
||||||
|
if err := backFill(ctx, conn, o.Table, TemporaryName(o.Column)); err != nil {
|
||||||
|
return fmt.Errorf("failed to backfill column: %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(o.Column, schema.Column{
|
||||||
|
Name: TemporaryName(o.Column),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add a trigger to copy values from the new column to the old, rewriting values using the `down` SQL.
|
||||||
|
err = createTrigger(ctx, conn, triggerConfig{
|
||||||
|
Name: TriggerName(o.Table, TemporaryName(o.Column)),
|
||||||
|
Direction: TriggerDirectionDown,
|
||||||
|
Columns: table.Columns,
|
||||||
|
SchemaName: s.Name,
|
||||||
|
TableName: o.Table,
|
||||||
|
PhysicalColumn: o.Column,
|
||||||
|
StateSchema: stateSchema,
|
||||||
|
SQL: o.Down,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create down trigger: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OpSetForeignKey) Complete(ctx context.Context, conn *sql.DB) error {
|
||||||
|
tempName := TemporaryName(o.Column)
|
||||||
|
tableRef := o.References.Table
|
||||||
|
columnRef := o.References.Column
|
||||||
|
|
||||||
|
// Validate the foreign key constraint
|
||||||
|
_, err := conn.ExecContext(ctx, fmt.Sprintf("ALTER TABLE IF EXISTS %s VALIDATE CONSTRAINT %s",
|
||||||
|
pq.QuoteIdentifier(o.Table),
|
||||||
|
pq.QuoteIdentifier(ForeignKeyConstraintName(tempName, tableRef, columnRef))))
|
||||||
|
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, o.Column))))
|
||||||
|
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(o.Column)))))
|
||||||
|
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(o.Column)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename the new column to the old column name
|
||||||
|
_, err = conn.ExecContext(ctx, fmt.Sprintf("ALTER TABLE IF EXISTS %s RENAME COLUMN %s TO %s",
|
||||||
|
pq.QuoteIdentifier(o.Table),
|
||||||
|
pq.QuoteIdentifier(TemporaryName(o.Column)),
|
||||||
|
pq.QuoteIdentifier(o.Column)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename the foreign key constraint to use the final (non-temporary) column name.
|
||||||
|
_, err = conn.ExecContext(ctx, fmt.Sprintf("ALTER TABLE IF EXISTS %s RENAME CONSTRAINT %s TO %s",
|
||||||
|
pq.QuoteIdentifier(o.Table),
|
||||||
|
pq.QuoteIdentifier(ForeignKeyConstraintName(tempName, tableRef, columnRef)),
|
||||||
|
pq.QuoteIdentifier(ForeignKeyConstraintName(o.Column, tableRef, columnRef)),
|
||||||
|
))
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OpSetForeignKey) Rollback(ctx context.Context, conn *sql.DB) error {
|
||||||
|
// Drop the new column, taking the constraint on the column with it
|
||||||
|
_, err := conn.ExecContext(ctx, fmt.Sprintf("ALTER TABLE %s DROP COLUMN IF EXISTS %s",
|
||||||
|
pq.QuoteIdentifier(o.Table),
|
||||||
|
pq.QuoteIdentifier(TemporaryName(o.Column)),
|
||||||
|
))
|
||||||
|
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, o.Column)),
|
||||||
|
))
|
||||||
|
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(o.Column))),
|
||||||
|
))
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OpSetForeignKey) Validate(ctx context.Context, s *schema.Schema) error {
|
||||||
|
table := s.GetTable(o.Table)
|
||||||
|
if table == nil {
|
||||||
|
return TableDoesNotExistError{Name: o.Table}
|
||||||
|
}
|
||||||
|
|
||||||
|
column := table.GetColumn(o.Column)
|
||||||
|
if column == nil {
|
||||||
|
return ColumnDoesNotExistError{Table: o.Table, Name: o.Column}
|
||||||
|
}
|
||||||
|
|
||||||
|
refTable := s.GetTable(o.References.Table)
|
||||||
|
if refTable == nil {
|
||||||
|
return ColumnReferenceError{
|
||||||
|
Table: o.Table,
|
||||||
|
Column: o.Column,
|
||||||
|
Err: TableDoesNotExistError{Name: o.References.Table},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refColumn := refTable.GetColumn(o.References.Column)
|
||||||
|
if refColumn == nil {
|
||||||
|
return ColumnReferenceError{
|
||||||
|
Table: o.Table,
|
||||||
|
Column: o.Column,
|
||||||
|
Err: ColumnDoesNotExistError{
|
||||||
|
Table: o.References.Table,
|
||||||
|
Name: o.References.Column,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.Up == "" {
|
||||||
|
return FieldRequiredError{Name: "up"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.Down == "" {
|
||||||
|
return FieldRequiredError{Name: "down"}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OpSetForeignKey) addForeignKeyConstraint(ctx context.Context, conn *sql.DB) error {
|
||||||
|
tempColumnName := TemporaryName(o.Column)
|
||||||
|
|
||||||
|
_, err := conn.ExecContext(ctx, fmt.Sprintf("ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s) NOT VALID",
|
||||||
|
pq.QuoteIdentifier(o.Table),
|
||||||
|
pq.QuoteIdentifier(ForeignKeyConstraintName(tempColumnName, o.References.Table, o.References.Column)),
|
||||||
|
pq.QuoteIdentifier(tempColumnName),
|
||||||
|
pq.QuoteIdentifier(o.References.Table),
|
||||||
|
pq.QuoteIdentifier(o.References.Column),
|
||||||
|
))
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
312
pkg/migrations/op_set_fk_test.go
Normal file
312
pkg/migrations/op_set_fk_test.go
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
package migrations_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/xataio/pg-roll/pkg/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetForeignKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ExecuteTests(t, TestCases{{
|
||||||
|
name: "add foreign key constraint",
|
||||||
|
migrations: []migrations.Migration{
|
||||||
|
{
|
||||||
|
Name: "01_add_tables",
|
||||||
|
Operations: migrations.Operations{
|
||||||
|
&migrations.OpCreateTable{
|
||||||
|
Name: "users",
|
||||||
|
Columns: []migrations.Column{
|
||||||
|
{
|
||||||
|
Name: "id",
|
||||||
|
Type: "serial",
|
||||||
|
PrimaryKey: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "name",
|
||||||
|
Type: "text",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&migrations.OpCreateTable{
|
||||||
|
Name: "posts",
|
||||||
|
Columns: []migrations.Column{
|
||||||
|
{
|
||||||
|
Name: "id",
|
||||||
|
Type: "serial",
|
||||||
|
PrimaryKey: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "title",
|
||||||
|
Type: "text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "user_id",
|
||||||
|
Type: "integer",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "02_add_fk_constraint",
|
||||||
|
Operations: migrations.Operations{
|
||||||
|
&migrations.OpSetForeignKey{
|
||||||
|
Table: "posts",
|
||||||
|
Column: "user_id",
|
||||||
|
References: migrations.ColumnReference{
|
||||||
|
Table: "users",
|
||||||
|
Column: "id",
|
||||||
|
},
|
||||||
|
Up: "(SELECT CASE WHEN EXISTS (SELECT 1 FROM users WHERE users.id = user_id) THEN user_id ELSE NULL END)",
|
||||||
|
Down: "user_id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
afterStart: func(t *testing.T, db *sql.DB) {
|
||||||
|
// The new (temporary) `user_id` column should exist on the underlying table.
|
||||||
|
ColumnMustExist(t, db, "public", "posts", migrations.TemporaryName("user_id"))
|
||||||
|
|
||||||
|
// Inserting some data into the `users` table works.
|
||||||
|
MustInsert(t, db, "public", "02_add_fk_constraint", "users", map[string]string{
|
||||||
|
"name": "alice",
|
||||||
|
})
|
||||||
|
MustInsert(t, db, "public", "02_add_fk_constraint", "users", map[string]string{
|
||||||
|
"name": "bob",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Inserting data into the new `posts` view with a valid user reference works.
|
||||||
|
MustInsert(t, db, "public", "02_add_fk_constraint", "posts", map[string]string{
|
||||||
|
"title": "post by alice",
|
||||||
|
"user_id": "1",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Inserting data into the new `posts` view with an invalid user reference fails.
|
||||||
|
MustNotInsert(t, db, "public", "02_add_fk_constraint", "posts", map[string]string{
|
||||||
|
"title": "post by unknown user",
|
||||||
|
"user_id": "3",
|
||||||
|
})
|
||||||
|
|
||||||
|
// The post that was inserted successfully has been backfilled into the old view.
|
||||||
|
rows := MustSelect(t, db, "public", "01_add_tables", "posts")
|
||||||
|
assert.Equal(t, []map[string]any{
|
||||||
|
{"id": 1, "title": "post by alice", "user_id": 1},
|
||||||
|
}, rows)
|
||||||
|
|
||||||
|
// Inserting data into the old `posts` view with a valid user reference works.
|
||||||
|
MustInsert(t, db, "public", "01_add_tables", "posts", map[string]string{
|
||||||
|
"title": "post by bob",
|
||||||
|
"user_id": "2",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Inserting data into the old `posts` view with an invalid user reference also works.
|
||||||
|
MustInsert(t, db, "public", "01_add_tables", "posts", map[string]string{
|
||||||
|
"title": "post by unknown user",
|
||||||
|
"user_id": "3",
|
||||||
|
})
|
||||||
|
|
||||||
|
// The post that was inserted successfully has been backfilled into the new view.
|
||||||
|
// The post by an unknown user has been backfilled with a NULL user_id.
|
||||||
|
rows = MustSelect(t, db, "public", "02_add_fk_constraint", "posts")
|
||||||
|
assert.Equal(t, []map[string]any{
|
||||||
|
{"id": 1, "title": "post by alice", "user_id": 1},
|
||||||
|
{"id": 3, "title": "post by bob", "user_id": 2},
|
||||||
|
{"id": 4, "title": "post by unknown user", "user_id": nil},
|
||||||
|
}, rows)
|
||||||
|
},
|
||||||
|
afterRollback: func(t *testing.T, db *sql.DB) {
|
||||||
|
// The new (temporary) `user_id` column should not exist on the underlying table.
|
||||||
|
ColumnMustNotExist(t, db, "public", "posts", migrations.TemporaryName("user_id"))
|
||||||
|
|
||||||
|
// The up function no longer exists.
|
||||||
|
FunctionMustNotExist(t, db, "public", migrations.TriggerFunctionName("posts", "user_id"))
|
||||||
|
// The down function no longer exists.
|
||||||
|
FunctionMustNotExist(t, db, "public", migrations.TriggerFunctionName("posts", migrations.TemporaryName("user_id")))
|
||||||
|
|
||||||
|
// The up trigger no longer exists.
|
||||||
|
TriggerMustNotExist(t, db, "public", "posts", migrations.TriggerName("posts", "user_id"))
|
||||||
|
// The down trigger no longer exists.
|
||||||
|
TriggerMustNotExist(t, db, "public", "posts", migrations.TriggerName("posts", migrations.TemporaryName("user_id")))
|
||||||
|
},
|
||||||
|
afterComplete: func(t *testing.T, db *sql.DB) {
|
||||||
|
// The new (temporary) `user_id` column should not exist on the underlying table.
|
||||||
|
ColumnMustNotExist(t, db, "public", "posts", migrations.TemporaryName("user_id"))
|
||||||
|
|
||||||
|
// Inserting data into the new `posts` view with a valid user reference works.
|
||||||
|
MustInsert(t, db, "public", "02_add_fk_constraint", "posts", map[string]string{
|
||||||
|
"title": "another post by alice",
|
||||||
|
"user_id": "1",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Inserting data into the new `posts` view with an invalid user reference fails.
|
||||||
|
MustNotInsert(t, db, "public", "02_add_fk_constraint", "posts", map[string]string{
|
||||||
|
"title": "post by unknown user",
|
||||||
|
"user_id": "3",
|
||||||
|
})
|
||||||
|
|
||||||
|
// The data in the new `posts` view is as expected.
|
||||||
|
rows := MustSelect(t, db, "public", "02_add_fk_constraint", "posts")
|
||||||
|
assert.Equal(t, []map[string]any{
|
||||||
|
{"id": 1, "title": "post by alice", "user_id": 1},
|
||||||
|
{"id": 3, "title": "post by bob", "user_id": 2},
|
||||||
|
{"id": 4, "title": "post by unknown user", "user_id": nil},
|
||||||
|
{"id": 5, "title": "another post by alice", "user_id": 1},
|
||||||
|
}, rows)
|
||||||
|
|
||||||
|
// The up function no longer exists.
|
||||||
|
FunctionMustNotExist(t, db, "public", migrations.TriggerFunctionName("posts", "user_id"))
|
||||||
|
// The down function no longer exists.
|
||||||
|
FunctionMustNotExist(t, db, "public", migrations.TriggerFunctionName("posts", migrations.TemporaryName("user_id")))
|
||||||
|
|
||||||
|
// The up trigger no longer exists.
|
||||||
|
TriggerMustNotExist(t, db, "public", "posts", migrations.TriggerName("posts", "user_id"))
|
||||||
|
// The down trigger no longer exists.
|
||||||
|
TriggerMustNotExist(t, db, "public", "posts", migrations.TriggerName("posts", migrations.TemporaryName("user_id")))
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetForeignKeyValidation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
createTablesMigration := migrations.Migration{
|
||||||
|
Name: "01_add_tables",
|
||||||
|
Operations: migrations.Operations{
|
||||||
|
&migrations.OpCreateTable{
|
||||||
|
Name: "users",
|
||||||
|
Columns: []migrations.Column{
|
||||||
|
{
|
||||||
|
Name: "id",
|
||||||
|
Type: "serial",
|
||||||
|
PrimaryKey: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "name",
|
||||||
|
Type: "text",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&migrations.OpCreateTable{
|
||||||
|
Name: "posts",
|
||||||
|
Columns: []migrations.Column{
|
||||||
|
{
|
||||||
|
Name: "id",
|
||||||
|
Type: "serial",
|
||||||
|
PrimaryKey: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "title",
|
||||||
|
Type: "text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "user_id",
|
||||||
|
Type: "integer",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ExecuteTests(t, TestCases{
|
||||||
|
{
|
||||||
|
name: "table must exist",
|
||||||
|
migrations: []migrations.Migration{
|
||||||
|
createTablesMigration,
|
||||||
|
{
|
||||||
|
Name: "02_add_fk_constraint",
|
||||||
|
Operations: migrations.Operations{
|
||||||
|
&migrations.OpSetForeignKey{
|
||||||
|
Table: "doesntexist",
|
||||||
|
Column: "user_id",
|
||||||
|
References: migrations.ColumnReference{
|
||||||
|
Table: "users",
|
||||||
|
Column: "id",
|
||||||
|
},
|
||||||
|
Up: "(SELECT CASE WHEN EXISTS (SELECT 1 FROM users WHERE users.id = user_id) THEN user_id ELSE NULL END)",
|
||||||
|
Down: "user_id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStartErr: migrations.TableDoesNotExistError{Name: "doesntexist"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "column must exist",
|
||||||
|
migrations: []migrations.Migration{
|
||||||
|
createTablesMigration,
|
||||||
|
{
|
||||||
|
Name: "02_add_fk_constraint",
|
||||||
|
Operations: migrations.Operations{
|
||||||
|
&migrations.OpSetForeignKey{
|
||||||
|
Table: "posts",
|
||||||
|
Column: "doesntexist",
|
||||||
|
References: migrations.ColumnReference{
|
||||||
|
Table: "users",
|
||||||
|
Column: "id",
|
||||||
|
},
|
||||||
|
Up: "(SELECT CASE WHEN EXISTS (SELECT 1 FROM users WHERE users.id = user_id) THEN user_id ELSE NULL END)",
|
||||||
|
Down: "user_id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStartErr: migrations.ColumnDoesNotExistError{Table: "posts", Name: "doesntexist"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "referenced table must exist",
|
||||||
|
migrations: []migrations.Migration{
|
||||||
|
createTablesMigration,
|
||||||
|
{
|
||||||
|
Name: "02_add_fk_constraint",
|
||||||
|
Operations: migrations.Operations{
|
||||||
|
&migrations.OpSetForeignKey{
|
||||||
|
Table: "posts",
|
||||||
|
Column: "user_id",
|
||||||
|
References: migrations.ColumnReference{
|
||||||
|
Table: "doesntexist",
|
||||||
|
Column: "id",
|
||||||
|
},
|
||||||
|
Up: "(SELECT CASE WHEN EXISTS (SELECT 1 FROM users WHERE users.id = user_id) THEN user_id ELSE NULL END)",
|
||||||
|
Down: "user_id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStartErr: migrations.ColumnReferenceError{
|
||||||
|
Table: "posts",
|
||||||
|
Column: "user_id",
|
||||||
|
Err: migrations.TableDoesNotExistError{Name: "doesntexist"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "referenced column must exist",
|
||||||
|
migrations: []migrations.Migration{
|
||||||
|
createTablesMigration,
|
||||||
|
{
|
||||||
|
Name: "02_add_fk_constraint",
|
||||||
|
Operations: migrations.Operations{
|
||||||
|
&migrations.OpSetForeignKey{
|
||||||
|
Table: "posts",
|
||||||
|
Column: "user_id",
|
||||||
|
References: migrations.ColumnReference{
|
||||||
|
Table: "users",
|
||||||
|
Column: "doesntexist",
|
||||||
|
},
|
||||||
|
Up: "(SELECT CASE WHEN EXISTS (SELECT 1 FROM users WHERE users.id = user_id) THEN user_id ELSE NULL END)",
|
||||||
|
Down: "user_id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStartErr: migrations.ColumnReferenceError{
|
||||||
|
Table: "posts",
|
||||||
|
Column: "user_id",
|
||||||
|
Err: migrations.ColumnDoesNotExistError{Table: "users", Name: "doesntexist"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user