From c5a4dfafb38f4e7a85944cb49e864395f706c54b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Fri, 31 Jul 2026 17:33:26 +0800 Subject: [PATCH 1/2] fix: quote string literals with single quotes in Explain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explain wrapped string values in double quotes, which are identifier quoting in SQLite — SQL copied from the logs misparses the value as a column reference. Use standard single-quoted string literals instead. GORM also embeds string default values into CREATE TABLE through Explain, so the DDL parser now strips single quotes from parsed default values as well (double quotes are still stripped for tables created by older driver versions); without that, migrations would stop being idempotent. Covered by a default-value round-trip test. Fixes #197 --- ddlmod.go | 4 +++- sqlite.go | 3 ++- sqlite_test.go | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/ddlmod.go b/ddlmod.go index d00f3efe..2d8a3c84 100644 --- a/ddlmod.go +++ b/ddlmod.go @@ -156,7 +156,9 @@ func parseDDL(strs ...string) (*ddl, error) { } if defaultMatches := defaultValueRegexp.FindStringSubmatch(matches[3]); len(defaultMatches) > 1 { if strings.ToLower(defaultMatches[1]) != "null" { - columnType.DefaultValueValue = sql.NullString{String: strings.Trim(defaultMatches[1], `"`), Valid: true} + // single quotes are standard SQL string literals, + // double quotes come from tables created by older versions + columnType.DefaultValueValue = sql.NullString{String: strings.Trim(defaultMatches[1], `'"`), Valid: true} } } diff --git a/sqlite.go b/sqlite.go index b3c8e31a..15899545 100644 --- a/sqlite.go +++ b/sqlite.go @@ -203,7 +203,8 @@ func (dialector Dialector) QuoteTo(writer clause.Writer, str string) { } func (dialector Dialector) Explain(sql string, vars ...interface{}) string { - return logger.ExplainSQL(sql, nil, `"`, vars...) + // single quotes: double quotes are identifier quoting in SQLite + return logger.ExplainSQL(sql, nil, `'`, vars...) } func (dialector Dialector) DataTypeOf(field *schema.Field) string { diff --git a/sqlite_test.go b/sqlite_test.go index d33ea025..3eb814d6 100644 --- a/sqlite_test.go +++ b/sqlite_test.go @@ -3,6 +3,7 @@ package sqlite import ( "database/sql" "fmt" + "strings" "testing" "github.com/mattn/go-sqlite3" @@ -130,3 +131,67 @@ func TestDialector(t *testing.T) { }) } } + +func TestExplainQuotesStrings(t *testing.T) { + out := Dialector{}.Explain("SELECT * FROM t WHERE name = ?", "hello") + if !strings.Contains(out, "'hello'") { + t.Errorf("Explain must quote string literals with single quotes, got: %s", out) + } +} + +type explainDefaultModel struct { + ID int + Code string `gorm:"default:hello"` +} + +func (explainDefaultModel) TableName() string { return "explain_defaults" } + +// GORM embeds string default values into the DDL via Dialector.Explain; +// parseDDL must strip the single quotes (and double quotes from tables +// created by older versions) so migrations stay idempotent. +func TestDefaultValueRoundTrip(t *testing.T) { + db, err := gorm.Open(Open("file:explain_defaults?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(&explainDefaultModel{}); err != nil { + t.Fatal(err) + } + cols, err := db.Migrator().ColumnTypes(&explainDefaultModel{}) + if err != nil { + t.Fatal(err) + } + for _, c := range cols { + if c.Name() == "code" { + if dv, ok := c.DefaultValue(); !ok || dv != "hello" { + t.Errorf("DefaultValue = (%q,%v), want (hello,true)", dv, ok) + } + } + } + // second AutoMigrate must not rebuild the table + var before string + db.Raw("SELECT sql FROM sqlite_master WHERE type='table' AND name='explain_defaults'").Scan(&before) + if err := db.AutoMigrate(&explainDefaultModel{}); err != nil { + t.Fatal(err) + } + var after string + db.Raw("SELECT sql FROM sqlite_master WHERE type='table' AND name='explain_defaults'").Scan(&after) + if before != after { + t.Errorf("DDL changed after second AutoMigrate:\n before: %s\n after: %s", before, after) + } + + // double quotes from tables created by older driver versions still parse + d, err := parseDDL("CREATE TABLE `legacy` (`code` text DEFAULT \"hi\")") + if err != nil { + t.Fatal(err) + } + if dv, ok := d.columns[0].DefaultValue(); !ok || dv != "hi" { + t.Errorf("legacy DefaultValue = (%q,%v), want (hi,true)", dv, ok) + } +} From ea8f186c21cf2157a3416049e8ec915ea4b09af1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Fri, 31 Jul 2026 17:48:47 +0800 Subject: [PATCH 2/2] fix(ddlmod): strip only one matching outer quote pair from defaults Address review feedback: strings.Trim removes every leading/trailing character from the set, so DEFAULT '"x"' parsed to x instead of "x". Strip a single matching pair instead, and fail the round-trip test on query errors. --- ddlmod.go | 13 ++++++++++++- sqlite_test.go | 17 +++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/ddlmod.go b/ddlmod.go index 2d8a3c84..54d6c78a 100644 --- a/ddlmod.go +++ b/ddlmod.go @@ -158,7 +158,7 @@ func parseDDL(strs ...string) (*ddl, error) { if strings.ToLower(defaultMatches[1]) != "null" { // single quotes are standard SQL string literals, // double quotes come from tables created by older versions - columnType.DefaultValueValue = sql.NullString{String: strings.Trim(defaultMatches[1], `'"`), Valid: true} + columnType.DefaultValueValue = sql.NullString{String: trimQuote(defaultMatches[1]), Valid: true} } } @@ -295,3 +295,14 @@ func (d *ddl) removeColumn(name string) bool { return false } + +// trimQuote removes one pair of matching outer quotes from a default value +// literal, so inner quotes of values like '"x"' survive. +func trimQuote(s string) string { + if len(s) >= 2 { + if (s[0] == '\'' && s[len(s)-1] == '\'') || (s[0] == '"' && s[len(s)-1] == '"') { + return s[1 : len(s)-1] + } + } + return s +} diff --git a/sqlite_test.go b/sqlite_test.go index 3eb814d6..a694d301 100644 --- a/sqlite_test.go +++ b/sqlite_test.go @@ -176,12 +176,16 @@ func TestDefaultValueRoundTrip(t *testing.T) { } // second AutoMigrate must not rebuild the table var before string - db.Raw("SELECT sql FROM sqlite_master WHERE type='table' AND name='explain_defaults'").Scan(&before) + if err := db.Raw("SELECT sql FROM sqlite_master WHERE type='table' AND name='explain_defaults'").Scan(&before).Error; err != nil { + t.Fatal(err) + } if err := db.AutoMigrate(&explainDefaultModel{}); err != nil { t.Fatal(err) } var after string - db.Raw("SELECT sql FROM sqlite_master WHERE type='table' AND name='explain_defaults'").Scan(&after) + if err := db.Raw("SELECT sql FROM sqlite_master WHERE type='table' AND name='explain_defaults'").Scan(&after).Error; err != nil { + t.Fatal(err) + } if before != after { t.Errorf("DDL changed after second AutoMigrate:\n before: %s\n after: %s", before, after) } @@ -194,4 +198,13 @@ func TestDefaultValueRoundTrip(t *testing.T) { if dv, ok := d.columns[0].DefaultValue(); !ok || dv != "hi" { t.Errorf("legacy DefaultValue = (%q,%v), want (hi,true)", dv, ok) } + + // only one outer quote pair is stripped; inner quotes survive + d, err = parseDDL("CREATE TABLE `q` (`code` text DEFAULT '\"x\"')") + if err != nil { + t.Fatal(err) + } + if dv, ok := d.columns[0].DefaultValue(); !ok || dv != `"x"` { + t.Errorf(`quoted DefaultValue = (%q,%v), want ("x",true)`, dv, ok) + } }