Skip to content

Commit

Permalink
Support create_table, drop_table, create_table sequence of oper…
Browse files Browse the repository at this point in the history
…ations (#589)

Ensure that the following migrations work by changing how `OpDropTable`
works:

<details>
<summary><strong>Create table in one migration, then drop and recreate a
table of the same name in the same migration</strong></summary>

```json
{
  "name": "01_create_table",
  "operations": [
    {
      "create_table": {
        "name": "items",
        "columns": [
          {
            "name": "id",
            "type": "serial",
            "pk": true
          },
          {
            "name": "name",
            "type": "text",
            "nullable": true
          }
        ]
      }
    }
  ]
}
```

```json
{
  "name": "02_drop_table_create_table",
  "operations": [
    {
      "drop_table": {
        "name": "items"
      }
    },
    {
      "create_table": {
        "name": "items",
        "columns": [
          {
            "name": "id",
            "type": "serial",
            "pk": true
          },
          {
            "name": "name",
            "type": "text",
            "nullable": true
          },
          {
            "name": "description",
            "type": "text",
            "nullable": true
          }
        ]
      }
    }
  ]
}
```
</details>

<details>
<summary><strong>Create a table, drop and recreate a table all in the
same migration</strong></summary>

```json
{
  "name": "06_drop_table_create_table",
  "operations": [
    {
      "create_table": {
        "name": "items",
        "columns": [
          {
            "name": "id",
            "type": "uuid",
            "default": "gen_random_uuid()",
            "pk": true
          },
          {
            "name": "name",
            "type": "text",
            "nullable": true
          }
        ]
      }
    },
    {
      "drop_table": {
        "name": "items"
      }
    },
    {
      "create_table": {
        "name": "items",
        "columns": [
          {
            "name": "id",
            "type": "uuid",
            "default": "gen_random_uuid()",
            "pk": true
          },
          {
            "name": "name",
            "type": "text",
            "nullable": true
          },
          {
            "name": "description",
            "type": "text",
            "nullable": true
          }
        ]
      }
    }
  ]
}
```
</details>

`OpDropTable` now 'soft-deletes' tables on migration start by renaming
them with a constistent prefix (`pgroll_del_`) to allow subsequent
`OpCreateTable` operations in the same migration to create tables with
the original name.


Part of #239
  • Loading branch information
andrew-farries authored Jan 14, 2025
1 parent 5a6f508 commit 5ec28da
Show file tree
Hide file tree
Showing 4 changed files with 189 additions and 8 deletions.
1 change: 1 addition & 0 deletions internal/testutils/error_codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ const (
FKViolationErrorCode string = "foreign_key_violation"
NotNullViolationErrorCode string = "not_null_violation"
UniqueViolationErrorCode string = "unique_violation"
UndefinedColumnErrorCode string = "undefined_column"
)
10 changes: 9 additions & 1 deletion pkg/migrations/op_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,21 @@ const (
OpCreateConstraintName OpName = "create_constraint"
)

const temporaryPrefix = "_pgroll_new_"
const (
temporaryPrefix = "_pgroll_new_"
deletedPrefix = "_pgroll_del_"
)

// TemporaryName returns a temporary name for a given name.
func TemporaryName(name string) string {
return temporaryPrefix + name
}

// DeletionName returns the deleted name for a given name.
func DeletionName(name string) string {
return deletedPrefix + name
}

// ReadMigration reads a migration from an io.Reader, like a file.
func ReadMigration(r io.Reader) (*Migration, error) {
byteValue, err := io.ReadAll(r)
Expand Down
26 changes: 25 additions & 1 deletion pkg/migrations/op_drop_table.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,26 @@ import (
var _ Operation = (*OpDropTable)(nil)

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

// Soft-delete the table in order that a create table operation in the same
// migration can create a table with the same name
_, err := conn.ExecContext(ctx, fmt.Sprintf("ALTER TABLE IF EXISTS %s RENAME TO %s",
table.Name,
DeletionName(table.Name)))
if err != nil {
return nil, fmt.Errorf("failed to rename table %s: %w", o.Name, err)
}

s.RemoveTable(o.Name)
return nil, nil
}

func (o *OpDropTable) Complete(ctx context.Context, conn db.DB, tr SQLTransformer, s *schema.Schema) error {
_, err := conn.ExecContext(ctx, fmt.Sprintf("DROP TABLE IF EXISTS %s", pq.QuoteIdentifier(o.Name)))
deletionName := DeletionName(o.Name)

// Perform the actual deletion of the soft-deleted table
_, err := conn.ExecContext(ctx, fmt.Sprintf("DROP TABLE IF EXISTS %s", pq.QuoteIdentifier(deletionName)))

return err
}
Expand All @@ -28,6 +42,16 @@ func (o *OpDropTable) Rollback(ctx context.Context, conn db.DB, tr SQLTransforme
// Mark the table as no longer deleted so that it is visible to preceding
// Rollbacks in the same migration
s.UnRemoveTable(o.Name)

// Rename the table back to its original name from its soft-deleted name
table := s.GetTable(o.Name)
_, err := conn.ExecContext(ctx, fmt.Sprintf("ALTER TABLE IF EXISTS %s RENAME TO %s",
DeletionName(table.Name),
table.Name))
if err != nil {
return fmt.Errorf("failed to rename table %s: %w", o.Name, err)
}

return nil
}

Expand Down
160 changes: 154 additions & 6 deletions pkg/migrations/op_drop_table_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"database/sql"
"testing"

"github.com/xataio/pgroll/internal/testutils"
"github.com/xataio/pgroll/pkg/migrations"
)

Expand Down Expand Up @@ -49,8 +50,8 @@ func TestDropTable(t *testing.T) {
// The view for the deleted table does not exist in the new version schema.
ViewMustNotExist(t, db, schema, "02_drop_table", "users")

// But the underlying table has not been deleted.
TableMustExist(t, db, schema, "users")
// The underlying table has been soft-deleted (renamed).
TableMustExist(t, db, schema, migrations.DeletionName("users"))
},
afterRollback: func(t *testing.T, db *sql.DB, schema string) {
// Rollback is a no-op.
Expand Down Expand Up @@ -95,8 +96,9 @@ func TestDropTableInMultiOperationMigrations(t *testing.T) {
},
afterStart: func(t *testing.T, db *sql.DB, schema string) {
// OpDropTable drops tables on migration completion, so the table
// created by OpCreateTable is present after migration start.
TableMustExist(t, db, schema, "items")
// created by OpCreateTable is present after migration start but has
// been soft-deleted (renamed).
TableMustExist(t, db, schema, migrations.DeletionName("items"))

// There is no view for the "items" table in the new schema
ViewMustNotExist(t, db, schema, "01_multi_operation", "items")
Expand Down Expand Up @@ -145,8 +147,9 @@ func TestDropTableInMultiOperationMigrations(t *testing.T) {
},
afterStart: func(t *testing.T, db *sql.DB, schema string) {
// OpDropTable drops tables on migration completion, so the table
// created by OpCreateTable is present after migration start.
TableMustExist(t, db, schema, "items")
// created by OpCreateTable is present after migration start but has
// been soft-deleted (renamed).
TableMustExist(t, db, schema, migrations.DeletionName("items"))

// There is no view for the "items" table in the new schema
ViewMustNotExist(t, db, schema, "01_multi_operation", "items")
Expand All @@ -169,6 +172,151 @@ func TestDropTableInMultiOperationMigrations(t *testing.T) {
ViewMustNotExist(t, db, schema, "01_multi_operation", "products")
},
},
{
name: "create table, drop table, create table",
migrations: []migrations.Migration{
{
Name: "01_multi_operation",
Operations: migrations.Operations{
&migrations.OpCreateTable{
Name: "items",
Columns: []migrations.Column{
{
Name: "id",
Type: "serial",
Pk: true,
},
{
Name: "name",
Type: "varchar(255)",
},
},
},
&migrations.OpDropTable{
Name: "items",
},
&migrations.OpCreateTable{
Name: "items",
Columns: []migrations.Column{
{
Name: "id",
Type: "serial",
Pk: true,
},
{
Name: "name",
Type: "varchar(255)",
},
{
Name: "description",
Type: "varchar(255)",
},
},
},
},
},
},
afterStart: func(t *testing.T, db *sql.DB, schema string) {
// Can insert into the items table, and it has a description column
MustInsert(t, db, schema, "01_multi_operation", "items", map[string]string{
"name": "apples",
"description": "amazing",
})
},
afterRollback: func(t *testing.T, db *sql.DB, schema string) {
// There are no tables, either original or soft-deleted
TableMustNotExist(t, db, schema, "items")
TableMustNotExist(t, db, schema, migrations.DeletionName("items"))
},
afterComplete: func(t *testing.T, db *sql.DB, schema string) {
// Can insert into the items table, and it has a description column
MustInsert(t, db, schema, "01_multi_operation", "items", map[string]string{
"name": "bananas",
"description": "brilliant",
})
},
},
{
name: "drop table, create table",
migrations: []migrations.Migration{
{
Name: "01_create_table",
Operations: migrations.Operations{
&migrations.OpCreateTable{
Name: "items",
Columns: []migrations.Column{
{
Name: "id",
Type: "serial",
Pk: true,
},
{
Name: "name",
Type: "varchar(255)",
},
},
},
},
},
{
Name: "02_multi_operation",
Operations: migrations.Operations{
&migrations.OpDropTable{
Name: "items",
},
&migrations.OpCreateTable{
Name: "items",
Columns: []migrations.Column{
{
Name: "id",
Type: "serial",
Pk: true,
},
{
Name: "name",
Type: "varchar(255)",
},
{
Name: "description",
Type: "varchar(255)",
},
},
},
},
},
},
afterStart: func(t *testing.T, db *sql.DB, schema string) {
// Can insert into the items table, and it has a description column
MustInsert(t, db, schema, "02_multi_operation", "items", map[string]string{
"name": "apples",
"description": "amazing",
})
},
afterRollback: func(t *testing.T, db *sql.DB, schema string) {
// The table from the second migration has been dropped (the one
// without the description column)
MustNotInsert(t, db, schema, "01_create_table", "items", map[string]string{
"name": "apples",
"description": "amazing",
}, testutils.UndefinedColumnErrorCode)

// The table from the first migration remains (the one with the
// description column)
MustInsert(t, db, schema, "01_create_table", "items", map[string]string{
"name": "apples",
})

// There is no soft-deleted version of thte items table
TableMustNotExist(t, db, schema, migrations.DeletionName("items"))
},
afterComplete: func(t *testing.T, db *sql.DB, schema string) {
// Can insert into the items table, and it has a description column
MustInsert(t, db, schema, "02_multi_operation", "items", map[string]string{
"name": "bananas",
"description": "brilliant",
})
},
},
})
}

Expand Down

0 comments on commit 5ec28da

Please sign in to comment.