From 2628ab8e3e2c705e1e793d835297b4953e5b4d37 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Thu, 27 Aug 2026 12:12:00 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix=F0=9F=90=9B:=20a=20seeded=20menu=20over?= =?UTF-8?q?flowed=20its=20column=20and=20stopped=20the=20migration=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sort is gorm:"size:4", which MySQL builds as a tinyint holding -128..127. The demo menu seeded Sort: 900, so on MySQL the run stopped at 1786700001000 with Error 1264, and every migration after it - including the soft-delete conversion - never ran. deleted_at therefore stayed NULL while the code queries deleted_at = 0, and the login returned 'incorrect Username or Password' on a database whose password hash was correct all along. sqlite ignores the declared width, so a fresh install there passed and the fault only appeared on MySQL. --- .../version/1786700001000_demo_menu.go | 5 +- .../migration/version/column_width_test.go | 82 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 cmd/migrate/migration/version/column_width_test.go diff --git a/cmd/migrate/migration/version/1786700001000_demo_menu.go b/cmd/migrate/migration/version/1786700001000_demo_menu.go index 193fdbcb..4e00a477 100644 --- a/cmd/migrate/migration/version/1786700001000_demo_menu.go +++ b/cmd/migrate/migration/version/1786700001000_demo_menu.go @@ -56,7 +56,10 @@ func _1786700001000DemoMenu(db *gorm.DB, version string) error { dir := models.SysMenu{ MenuId: demoMenuId, MenuName: "Demo", Title: "示例模块", Icon: "example", Path: "/demo", Paths: "/0/9000", MenuType: "M", ParentId: 0, - Component: "Layout", Sort: 900, Visible: "0", IsFrame: "1", + // sort is `gorm:"size:4"`, which MySQL builds as a tinyint - anything + // over 127 is rejected outright. The seeded menus run to 100, so 110 + // still puts this last. + Component: "Layout", Sort: 110, Visible: "0", IsFrame: "1", } if err := upsert(tx, &models.SysMenu{}, "menu_id = ?", dir.MenuId, &dir); err != nil { return err diff --git a/cmd/migrate/migration/version/column_width_test.go b/cmd/migrate/migration/version/column_width_test.go new file mode 100644 index 00000000..99bd4623 --- /dev/null +++ b/cmd/migrate/migration/version/column_width_test.go @@ -0,0 +1,82 @@ +package version + +import ( + "go/ast" + "go/parser" + "go/token" + "math" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// Fields tagged gorm:"size:4" become a tinyint on MySQL, which holds -128..127. +// sqlite ignores the width, so a value that overflows passes every local test +// and fails on a real install - and because the migration is not transactional, +// it fails partway, leaving later migrations unapplied. +// +// That is what happened: a seeded menu with Sort: 900 stopped the run at +// 1786700001000, so the soft-delete conversion never ran, deleted_at stayed +// NULL, and nobody could log in. +var narrowColumns = map[string]struct{ min, max int64 }{ + "Sort": {math.MinInt8, math.MaxInt8}, + "Status": {math.MinInt8, math.MaxInt8}, +} + +func TestSeededValuesFitTheirColumns(t *testing.T) { + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + files, err := filepath.Glob(filepath.Join(dir, "*.go")) + if err != nil { + t.Fatal(err) + } + + checked := 0 + for _, path := range files { + if strings.HasSuffix(path, "_test.go") { + continue + } + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + ast.Inspect(f, func(n ast.Node) bool { + kv, ok := n.(*ast.KeyValueExpr) + if !ok { + return true + } + key, ok := kv.Key.(*ast.Ident) + if !ok { + return true + } + limits, watched := narrowColumns[key.Name] + if !watched { + return true + } + lit, ok := kv.Value.(*ast.BasicLit) + if !ok || lit.Kind != token.INT { + return true + } + v, err := strconv.ParseInt(lit.Value, 10, 64) + if err != nil { + return true + } + checked++ + if v < limits.min || v > limits.max { + t.Errorf("%s:%d: %s: %d does not fit a tinyint (%d..%d);\n"+ + " MySQL rejects it with Error 1264 and the migration stops there", + filepath.Base(path), fset.Position(lit.Pos()).Line, key.Name, v, limits.min, limits.max) + } + return true + }) + } + + if checked == 0 { + t.Fatal("no seeded values were examined; the scan is broken, not the code") + } +} From 8e141ff8a0124bb16e37a0f3d8abab55f72b6fee Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Thu, 27 Aug 2026 12:12:13 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix=F0=9F=90=9B:=20the=20code=20generator?= =?UTF-8?q?=20listed=20no=20tables=20at=20all?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sys_columns and sys_tables were left out of the soft-delete conversion in 1786700003000. Their runtime models embed common.ModelTime, which is the millisecond marker, so GORM queries them with deleted_at = 0 - against a nullable datetime column holding NULL. Every row was invisible. The repository carries two ModelTime types: the one under cmd/migrate/migration/models still has a nullable gorm.DeletedAt and is what builds the tables, while common/models has the marker and is what queries them. Nothing connected the two, so a table could be built one way and read the other with no signal at all. The test now walks app/ for models embedding the marker and requires a migration to cover each. tb_demo is exempt and says why: nothing reads it at runtime. --- .../1786700004000_generator_tables_marker.go | 40 ++++ .../migration/version/schema_coverage_test.go | 173 ++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 cmd/migrate/migration/version/1786700004000_generator_tables_marker.go create mode 100644 cmd/migrate/migration/version/schema_coverage_test.go diff --git a/cmd/migrate/migration/version/1786700004000_generator_tables_marker.go b/cmd/migrate/migration/version/1786700004000_generator_tables_marker.go new file mode 100644 index 00000000..ce960bf1 --- /dev/null +++ b/cmd/migrate/migration/version/1786700004000_generator_tables_marker.go @@ -0,0 +1,40 @@ +package version + +import ( + "fmt" + "runtime" + + "gorm.io/gorm" + + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" +) + +// Convert the two generator tables to the same delete marker every other table +// got in 1786700003000. +// +// They were left out of that list, and the mismatch is invisible until it is +// not: the tables are built from cmd/migrate/migration/models, whose ModelTime +// still carries a nullable gorm.DeletedAt, while the runtime models in +// app/other/models/tools embed common.ModelTime, which is the millisecond +// marker. GORM therefore queries them with deleted_at = 0 against a datetime +// column holding NULL, and every row is invisible - so the code generator +// listed no tables at all. +// +// tb_demo has the same shape and is deliberately not here: nothing reads it at +// runtime, so there is no mismatch to fix. +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700004000GeneratorTablesMarker) +} + +var generatorTables = []string{"sys_columns", "sys_tables"} + +func _1786700004000GeneratorTablesMarker(db *gorm.DB, version string) error { + for _, table := range generatorTables { + if err := convertDeletedAt(db, table); err != nil { + return fmt.Errorf("%s: %w", table, err) + } + } + return db.Create(&common.Migration{Version: version}).Error +} diff --git a/cmd/migrate/migration/version/schema_coverage_test.go b/cmd/migrate/migration/version/schema_coverage_test.go new file mode 100644 index 00000000..7aa8f856 --- /dev/null +++ b/cmd/migrate/migration/version/schema_coverage_test.go @@ -0,0 +1,173 @@ +package version + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// The repository carries two ModelTime types. The one in +// cmd/migrate/migration/models still has a nullable gorm.DeletedAt and is what +// builds the tables; the one in common/models is the millisecond marker and is +// what queries them. A table whose runtime model embeds the second but which no +// migration converts is queried with deleted_at = 0 against a datetime column, +// and every row is invisible - silently, and only in production. +// +// sys_columns and sys_tables were in exactly that state: the code generator +// listed no tables at all. +func TestEveryRuntimeSoftDeleteTableIsConverted(t *testing.T) { + converted := map[string]bool{} + for _, name := range append(append([]string{}, softDeleteTables...), generatorTables...) { + converted[name] = true + } + + // tb_demo is built with the marker-less model and has no runtime model at + // all, so nothing ever queries it with deleted_at = 0. + const noRuntimeModel = "tb_demo" + + for table, file := range runtimeSoftDeleteTables(t) { + if table == noRuntimeModel { + continue + } + if !converted[table] { + t.Errorf("%s (%s) embeds common.ModelTime but no migration converts its deleted_at;\n"+ + " it will be queried with deleted_at = 0 against a nullable datetime and return nothing", + table, file) + } + } +} + +// runtimeSoftDeleteTables maps table name to the file declaring it, for every +// model under app/ that embeds the marker-carrying ModelTime. +func runtimeSoftDeleteTables(t *testing.T) map[string]string { + t.Helper() + + root := repoRoot(t) + found := map[string]string{} + + err := filepath.Walk(filepath.Join(root, "app"), func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return err + } + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + return nil // not this test's business + } + // Only files importing the runtime models package can embed its ModelTime. + if !importsRuntimeModels(f) { + return nil + } + for name, table := range tablesWithModelTime(f) { + _ = name + found[table] = strings.TrimPrefix(path, root+"/") + } + return nil + }) + if err != nil { + t.Fatalf("walk app/: %v", err) + } + if len(found) == 0 { + t.Fatal("found no runtime models at all; the scan is broken, not the code") + } + return found +} + +func importsRuntimeModels(f *ast.File) bool { + for _, imp := range f.Imports { + p, err := strconv.Unquote(imp.Path.Value) + if err == nil && p == "go-admin/common/models" { + return true + } + } + return false +} + +// tablesWithModelTime returns struct name -> table name for structs that embed +// ModelTime and declare a TableName. +func tablesWithModelTime(f *ast.File) map[string]string { + embeds := map[string]bool{} + ast.Inspect(f, func(n ast.Node) bool { + ts, ok := n.(*ast.TypeSpec) + if !ok { + return true + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + return true + } + for _, field := range st.Fields.List { + if len(field.Names) != 0 { + continue // named field, not an embed + } + if sel, ok := field.Type.(*ast.SelectorExpr); ok && sel.Sel.Name == "ModelTime" { + embeds[ts.Name.Name] = true + } + } + return true + }) + + out := map[string]string{} + for name := range embeds { + if table := tableNameOf(f, name); table != "" { + out[name] = table + } + } + return out +} + +// tableNameOf finds the string returned by func (T) TableName() string. +func tableNameOf(f *ast.File, structName string) string { + var table string + ast.Inspect(f, func(n ast.Node) bool { + fn, ok := n.(*ast.FuncDecl) + if !ok || fn.Name.Name != "TableName" || fn.Recv == nil || len(fn.Recv.List) != 1 { + return true + } + if receiverName(fn.Recv.List[0].Type) != structName { + return true + } + ast.Inspect(fn.Body, func(n ast.Node) bool { + lit, ok := n.(*ast.BasicLit) + if ok && lit.Kind == token.STRING { + if s, err := strconv.Unquote(lit.Value); err == nil && table == "" { + table = s + } + } + return true + }) + return true + }) + return table +} + +func receiverName(expr ast.Expr) string { + switch t := expr.(type) { + case *ast.Ident: + return t.Name + case *ast.StarExpr: + return receiverName(t.X) + } + return "" +} + +func repoRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for i := 0; i < 8; i++ { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + dir = filepath.Dir(dir) + } + t.Fatal("go.mod not found above the test directory") + return "" +}