diff --git a/ddlmod.go b/ddlmod.go index d00f3ef..9301848 100644 --- a/ddlmod.go +++ b/ddlmod.go @@ -14,7 +14,7 @@ import ( var ( sqliteSeparator = "`|\"|'" sqliteColumnQuote = "`" - uniqueRegexp = regexp.MustCompile(fmt.Sprintf(`^(?:CONSTRAINT [%v]?[\w-]+[%v]? )?UNIQUE (.*)$`, sqliteSeparator, sqliteSeparator)) + uniqueRegexp = regexp.MustCompile(fmt.Sprintf(`^(?i)(?:CONSTRAINT [%v]?[\w-]+[%v]? )?UNIQUE\s*(\(.*)$`, 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)) checkRegexp = regexp.MustCompile(`^(?i)CHECK[\s]*\(`) @@ -217,7 +217,9 @@ func (d *ddl) renameTable(dst, src string) error { } func compileConstraintRegexp(name string) *regexp.Regexp { - return regexp.MustCompile("^(?i:CONSTRAINT)\\s+[\"`]?" + regexp.QuoteMeta(name) + "[\"`\\s]") + // the name may be quoted with backquotes, double quotes, single quotes + // or brackets, or not at all + return regexp.MustCompile("^(?i:CONSTRAINT)\\s+[\"'`\\[]?" + regexp.QuoteMeta(name) + "[\"'`\\]\\s]") } func (d *ddl) addConstraint(name string, sql string) { @@ -282,7 +284,7 @@ func (d *ddl) getColumns() []string { } func (d *ddl) removeColumn(name string) bool { - reg := regexp.MustCompile("^(`|'|\"| )" + regexp.QuoteMeta(name) + "(`|'|\"| ) .*?$") + reg := regexp.MustCompile("^[`'\" ]?" + regexp.QuoteMeta(name) + "[`'\" ]") for i := 0; i < len(d.fields); i++ { if reg.MatchString(d.fields[i]) { diff --git a/ddlmod_test.go b/ddlmod_test.go index 7d4e1a2..e13db5a 100644 --- a/ddlmod_test.go +++ b/ddlmod_test.go @@ -475,3 +475,43 @@ func TestGetColumns(t *testing.T) { }) } } + +func TestParseDDL_LowercaseUnique(t *testing.T) { + d, err := parseDDL("CREATE TABLE `t` (`a` text, unique(`a`))") + if err != nil { + t.Fatal(err) + } + found := false + for _, c := range d.columns { + if c.NameValue.String == "a" { + found = true + if uniq, _ := c.Unique(); !uniq { + t.Error("lowercase table-level unique(...) not recognized") + } + } + } + if !found { + t.Fatal("column a was not parsed at all") + } +} + +func TestConstraintNameQuoting(t *testing.T) { + forms := map[string]string{ + "backquotes": "CREATE TABLE `t` (`a` integer, CONSTRAINT `chk_a` CHECK (`a` > 0))", + "double quotes": `CREATE TABLE "t" ("a" integer, CONSTRAINT "chk_a" CHECK ("a" > 0))`, + "brackets": "CREATE TABLE t (a integer, CONSTRAINT [chk_a] CHECK (a > 0))", + "unquoted": "CREATE TABLE t (a integer, CONSTRAINT chk_a CHECK (a > 0))", + } + for form, ddlSQL := range forms { + d, err := parseDDL(ddlSQL) + if err != nil { + t.Fatalf("%s: %v", form, err) + } + if !d.hasConstraint("chk_a") { + t.Errorf("%s: constraint chk_a not matched", form) + } + if d.hasConstraint("chk") { + t.Errorf("%s: prefix chk must not match", form) + } + } +} diff --git a/migrator.go b/migrator.go index ae15d4a..e00dd34 100644 --- a/migrator.go +++ b/migrator.go @@ -2,6 +2,7 @@ package sqlite import ( "database/sql" + "errors" "fmt" "strings" @@ -52,7 +53,9 @@ func (m Migrator) DropTable(values ...interface{}) error { } func (m Migrator) GetTables() (tableList []string, err error) { - return tableList, m.DB.Raw("SELECT name FROM sqlite_master where type=?", "table").Scan(&tableList).Error + return tableList, m.DB.Raw( + "SELECT name FROM sqlite_master WHERE type = ? AND name NOT LIKE ?", "table", "sqlite_%", + ).Scan(&tableList).Error } func (m Migrator) HasColumn(value interface{}, name string) bool { @@ -161,7 +164,9 @@ func (m Migrator) DropColumn(value interface{}, name string) error { name = field.DBName } - ddl.removeColumn(name) + if !ddl.removeColumn(name) { + return nil, nil, fmt.Errorf("failed to drop column %v: not found in the DDL of table %v", name, stmt.Table) + } return ddl, nil, nil }) }) @@ -208,22 +213,26 @@ func (m Migrator) DropConstraint(value interface{}, name string) error { } func (m Migrator) HasConstraint(value interface{}, name string) bool { - var count int64 + var has bool m.RunWithValue(value, func(stmt *gorm.Statement) error { constraint, table := m.GuessConstraintInterfaceAndTable(stmt, name) if constraint != nil { name = constraint.GetName() } - m.DB.Raw( - "SELECT count(*) FROM sqlite_master WHERE type = ? AND tbl_name = ? AND (sql LIKE ? OR sql LIKE ? OR sql LIKE ? OR sql LIKE ? OR sql LIKE ?)", - "table", table, `%CONSTRAINT "`+name+`" %`, `%CONSTRAINT `+name+` %`, "%CONSTRAINT `"+name+"`%", "%CONSTRAINT ["+name+"]%", "%CONSTRAINT \t"+name+"\t%", - ).Row().Scan(&count) - + rawDDL, err := m.getRawDDL(table) + if err != nil { + return err + } + parsed, err := parseDDL(rawDDL) + if err != nil { + return err + } + has = parsed.hasConstraint(name) return nil }) - return count > 0 + return has } func (m Migrator) CurrentDatabase() (name string) { @@ -367,10 +376,13 @@ func (m Migrator) GetIndexes(value interface{}) ([]gorm.Index, error) { func (m Migrator) getRawDDL(table string) (string, error) { var createSQL string - m.DB.Raw("SELECT sql FROM sqlite_master WHERE type = ? AND tbl_name = ? AND name = ?", "table", table, table).Row().Scan(&createSQL) + err := m.DB.Raw("SELECT sql FROM sqlite_master WHERE type = ? AND tbl_name = ? AND name = ?", "table", table, table).Row().Scan(&createSQL) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return "", err + } - if m.DB.Error != nil { - return "", m.DB.Error + if createSQL == "" { + return "", fmt.Errorf("failed to get DDL of table %q: table not found", table) } return createSQL, nil } diff --git a/migrator_test.go b/migrator_test.go index 301d3bc..6cf4e8f 100644 --- a/migrator_test.go +++ b/migrator_test.go @@ -178,3 +178,101 @@ func openRecreateTestDB(t *testing.T, name string) *gorm.DB { }) return db } + +// GetTables must not report SQLite internal tables such as sqlite_sequence, +// which appears as soon as any table uses AUTOINCREMENT. +func TestGetTablesExcludesInternal(t *testing.T) { + db, err := gorm.Open(Open("file:internal_tables?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("gorm.Open: %v", err) + } + t.Cleanup(func() { + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + }) + + if err := db.Exec("CREATE TABLE `seq_table` (`id` integer PRIMARY KEY AUTOINCREMENT)").Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("INSERT INTO `seq_table` DEFAULT VALUES").Error; err != nil { + t.Fatal(err) + } + + tables, err := db.Migrator().GetTables() + if err != nil { + t.Fatal(err) + } + for _, tb := range tables { + if strings.HasPrefix(tb, "sqlite_") { + t.Errorf("GetTables returned internal table %q", tb) + } + } + if len(tables) != 1 || tables[0] != "seq_table" { + t.Errorf("GetTables = %v, want [seq_table]", tables) + } +} + +type checkedModel struct { + ID int + Age int `gorm:"check:age_positive,age > 0"` +} + +func (checkedModel) TableName() string { return "checked_models" } + +// HasConstraint must match the exact constraint name instead of a LIKE +// substring, and DropColumn must fail for a column that is not in the DDL +// instead of silently rebuilding an identical table. +func TestConstraintAndColumnMatching(t *testing.T) { + db, err := gorm.Open(Open("file:constraint_matching?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("gorm.Open: %v", err) + } + t.Cleanup(func() { + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + }) + + if err := db.AutoMigrate(&checkedModel{}); err != nil { + t.Fatal(err) + } + if !db.Migrator().HasConstraint(&checkedModel{}, "age_positive") { + t.Error("HasConstraint(age_positive) = false, want true") + } + if db.Migrator().HasConstraint(&checkedModel{}, "age_pos") { + t.Error("HasConstraint(age_pos) matched by prefix, want false") + } + + // unquoted DDL: the column must still be dropped + if err := db.Exec("CREATE TABLE plain_cols (id integer, name text)").Error; err != nil { + t.Fatal(err) + } + if err := db.Migrator().DropColumn(&plainColModel{}, "name"); err != nil { + t.Fatalf("DropColumn on unquoted DDL: %v", err) + } + if db.Migrator().HasColumn(&plainColModel{}, "name") { + t.Error("column still present after DropColumn on unquoted DDL") + } + // a missing column must be an error, not a silent no-op + if err := db.Migrator().DropColumn(&plainColModel{}, "missing"); err == nil { + t.Error("DropColumn(missing) = nil, want error") + } + // a missing table must be reported clearly + if err := db.Migrator().DropColumn(&noSuchTableModel{}, "col"); err == nil || !strings.Contains(err.Error(), "table not found") { + t.Errorf("DropColumn on a missing table = %v, want a table-not-found error", err) + } +} + +type plainColModel struct { + ID int + Name string +} + +func (plainColModel) TableName() string { return "plain_cols" } + +type noSuchTableModel struct { + ID int +} + +func (noSuchTableModel) TableName() string { return "no_such_table" }