Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions ddlmod.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ var (
sqliteColumnQuote = "`"
uniqueRegexp = regexp.MustCompile(fmt.Sprintf(`^(?:CONSTRAINT [%v]?[\w-]+[%v]? )?UNIQUE (.*)$`, sqliteSeparator, sqliteSeparator))
indexRegexp = regexp.MustCompile(fmt.Sprintf(`(?is)CREATE(?: UNIQUE)? INDEX [%v]?[\w\d-]+[%v]?(?s:.*?)ON (.*)$`, sqliteSeparator, sqliteSeparator))
tableRegexp = regexp.MustCompile(fmt.Sprintf(`(?is)(CREATE TABLE [%v]?[\w\d-]+[%v]?)(?:\s*\((.*)\))?`, sqliteSeparator, sqliteSeparator))
tableRegexp = regexp.MustCompile(fmt.Sprintf(`(?is)(CREATE TABLE [%v]?[\w\d-]+[%v]?)(?:\s*\((.*)\))?(.*)$`, sqliteSeparator, sqliteSeparator))
checkRegexp = regexp.MustCompile(`^(?i)CHECK[\s]*\(`)
constraintRegexp = regexp.MustCompile(fmt.Sprintf(`^(?i)CONSTRAINT\s+%[1]s[\w\d_]+%[1]s[\s]+`, sqliteColumnQuote))
separatorRegexp = regexp.MustCompile(fmt.Sprintf("[%v]", sqliteSeparator))
Expand All @@ -28,6 +28,7 @@ var (
type ddl struct {
head string
fields []string
suffix string // table options after the column list, e.g. WITHOUT ROWID, STRICT
columns []migrator.ColumnType
}

Expand All @@ -45,6 +46,7 @@ func parseDDL(strs ...string) (*ddl, error) {
ddlBodyRunesLen := len(ddlBodyRunes)

result.head = sections[1]
result.suffix = sections[3]

for idx := 0; idx < ddlBodyRunesLen; idx++ {
var (
Expand Down Expand Up @@ -193,10 +195,10 @@ func (d *ddl) clone() *ddl {

func (d *ddl) compile() string {
if len(d.fields) == 0 {
return d.head
return d.head + d.suffix
}

return fmt.Sprintf("%s (%s)", d.head, strings.Join(d.fields, ","))
return fmt.Sprintf("%s (%s)%s", d.head, strings.Join(d.fields, ","), d.suffix)
}

func (d *ddl) renameTable(dst, src string) error {
Expand Down
37 changes: 36 additions & 1 deletion migrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,16 @@ func (m Migrator) recreateTable(
columns := createDDL.getColumns()
createSQL := createDDL.compile()

// indexes and triggers are dropped together with the old table; save
// their DDL so they can be recreated on the rebuilt table.
var auxDDLs []string
if err := m.DB.Raw(
"SELECT sql FROM sqlite_master WHERE tbl_name = ? AND type IN (?, ?) AND sql IS NOT NULL",
table, "index", "trigger",
).Scan(&auxDDLs).Error; err != nil {
return err
}

return m.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec(createSQL, sqlArgs...).Error; err != nil {
return err
Expand All @@ -419,13 +429,38 @@ func (m Migrator) recreateTable(
queries := []string{
fmt.Sprintf("INSERT INTO `%v`(%v) SELECT %v FROM `%v`", newTableName, strings.Join(columns, ","), strings.Join(columns, ","), table),
fmt.Sprintf("DROP TABLE `%v`", table),
fmt.Sprintf("ALTER TABLE `%v` RENAME TO `%v`", newTableName, table),
}
for _, query := range queries {
if err := tx.Exec(query).Error; err != nil {
return err
}
}

// legacy_alter_table keeps RENAME from re-resolving views that
// reference the table; they point at the original name and become
// valid again right after the rename.
if err := tx.Exec("PRAGMA legacy_alter_table = ON").Error; err != nil {
return err
}
renameErr := tx.Exec(fmt.Sprintf("ALTER TABLE `%v` RENAME TO `%v`", newTableName, table)).Error
if err := tx.Exec("PRAGMA legacy_alter_table = OFF").Error; renameErr == nil {
renameErr = err
}
if renameErr != nil {
return renameErr
}

// recreate the saved indexes and triggers; ones referencing a
// column that no longer exists cannot apply anymore and are
// skipped, any other failure aborts the migration
for _, aux := range auxDDLs {
if err := tx.Exec(aux).Error; err != nil {
if strings.Contains(err.Error(), "no such column") {
continue
}
return err
}
}
return nil
})
})
Expand Down
140 changes: 140 additions & 0 deletions migrator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package sqlite

import (
"strings"
"testing"

"gorm.io/gorm"
)

func openRecreateTestDB(t *testing.T, name string) *gorm.DB {
t.Helper()
db, err := gorm.Open(Open("file:"+name+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("gorm.Open: %v", err)
}
// close the pool so the shared in-memory database is torn down and
// repeated runs (go test -count=N) start from a clean state
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
return db
}

type RebuildIdxTable struct {
ID int
A string
B string
}

func (RebuildIdxTable) TableName() string { return "rebuild_idx_table" }

// recreateTable drops the old table together with its indexes and triggers;
// they must be recreated on the rebuilt table. Indexes on a dropped column
// are the exception: they can no longer apply.
func TestRecreateTablePreservesIndexesAndTriggers(t *testing.T) {
db := openRecreateTestDB(t, "recreate_idx")
stmts := []string{
"CREATE TABLE `rebuild_idx_table` (`id` integer, `a` text, `b` text)",
"CREATE INDEX `idx_keep` ON `rebuild_idx_table`(`a`)",
"CREATE INDEX `idx_gone` ON `rebuild_idx_table`(`b`)",
"CREATE TABLE `rebuild_audit` (`msg` text)",
"CREATE TRIGGER `trg_keep` AFTER INSERT ON `rebuild_idx_table` BEGIN INSERT INTO `rebuild_audit`(`msg`) VALUES ('x'); END",
}
for _, s := range stmts {
if err := db.Exec(s).Error; err != nil {
t.Fatal(err)
}
}

if err := db.Migrator().DropColumn(&RebuildIdxTable{}, "b"); err != nil {
t.Fatalf("DropColumn: %v", err)
}

var names []string
if err := db.Raw("SELECT name FROM sqlite_master WHERE tbl_name = 'rebuild_idx_table' AND type IN ('index','trigger')").Scan(&names).Error; err != nil {
t.Fatalf("querying sqlite_master: %v", err)
}
got := strings.Join(names, ",")
if !strings.Contains(got, "idx_keep") {
t.Errorf("index idx_keep lost after DropColumn, remaining: %v", names)
}
if !strings.Contains(got, "trg_keep") {
t.Errorf("trigger trg_keep lost after DropColumn, remaining: %v", names)
}
if strings.Contains(got, "idx_gone") {
t.Errorf("index idx_gone references the dropped column and must not survive, remaining: %v", names)
}

// the trigger still works on the rebuilt table
if err := db.Exec("INSERT INTO `rebuild_idx_table`(`id`,`a`) VALUES (1,'v')").Error; err != nil {
t.Fatal(err)
}
var auditCount int
if err := db.Raw("SELECT count(*) FROM rebuild_audit").Scan(&auditCount).Error; err != nil {
t.Fatalf("querying rebuild_audit: %v", err)
}
if auditCount != 1 {
t.Errorf("trigger did not fire after rebuild, audit rows = %d", auditCount)
}
}

type RebuildOptsTable struct {
ID string `gorm:"primaryKey"`
A string
B string
}

func (RebuildOptsTable) TableName() string { return "rebuild_opts_table" }

// Table options after the column list (WITHOUT ROWID, STRICT) must survive a
// table rebuild instead of silently turning the table into a plain rowid one.
func TestRecreateTablePreservesTableOptions(t *testing.T) {
db := openRecreateTestDB(t, "recreate_opts")
if err := db.Exec("CREATE TABLE `rebuild_opts_table` (`id` text PRIMARY KEY, `a` text, `b` text) WITHOUT ROWID").Error; err != nil {
t.Fatal(err)
}
if err := db.Migrator().DropColumn(&RebuildOptsTable{}, "b"); err != nil {
t.Fatalf("DropColumn: %v", err)
}

var ddl string
if err := db.Raw("SELECT sql FROM sqlite_master WHERE type='table' AND name='rebuild_opts_table'").Scan(&ddl).Error; err != nil {
t.Fatalf("querying sqlite_master: %v", err)
}
if !strings.Contains(ddl, "WITHOUT ROWID") {
t.Errorf("WITHOUT ROWID lost after rebuild: %s", ddl)
}
}

type RebuildViewedTable struct {
ID int
A string
B string
}

func (RebuildViewedTable) TableName() string { return "rebuild_viewed" }

// Rebuilding a table that a view references used to fail with "error in view
// ...: no such table" because the RENAME step re-resolves the view while the
// original table name doesn't exist yet (issue #225).
func TestRecreateTableWithView(t *testing.T) {
db := openRecreateTestDB(t, "recreate_view")
if err := db.Exec("CREATE TABLE `rebuild_viewed` (`id` integer, `a` text, `b` text)").Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("CREATE VIEW `rebuild_viewed_v` AS SELECT `a` FROM `rebuild_viewed`").Error; err != nil {
t.Fatal(err)
}

if err := db.Migrator().DropColumn(&RebuildViewedTable{}, "b"); err != nil {
t.Fatalf("DropColumn with a referencing view: %v", err)
}

var n int
if err := db.Raw("SELECT count(*) FROM `rebuild_viewed_v`").Scan(&n).Error; err != nil {
t.Errorf("view is broken after rebuild: %v", err)
}
}
Loading