From d53c8abc6c963d5e18d38d4fadc13e9509f8c4e2 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Fri, 25 Sep 2026 19:59:59 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix=F0=9F=90=9B:=20generate=20a=20model=20a?= =?UTF-8?q?nd=20DTOs=20for=20any=20single-column=20primary=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model and DTO templates assumed every table's key is an int column named id. For any other key the generated backend did not compile - the model embedded models.Model, which supplies Id, skipped the real key column, and GetId then returned a field that did not exist ("e.Code undefined"). Past the compile error there was more of the same assumption: - GetReq and UpdateReq bound the key with uri:"", while every generated router registers it as :id, so any other name bound nothing; - InsertReq tagged the key json:"-", so a key the database does not assign, such as a string, could never be supplied; - DeleteReq took Ids []int, so a string key could not be deleted. models.Model now stands in for the key only when the key is exactly an int named Id; anything else is declared as its own primaryKey field. The key is bound from :id, accepted from the request body when it is not an int, and deleted by a list of its own type. A table keyed on an int id renders byte-for-byte as before. --- template/v4/dto.go.template | 21 +++++++++++++++++---- template/v4/model.go.template | 13 +++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/template/v4/dto.go.template b/template/v4/dto.go.template index 428b89ae..7619b066 100644 --- a/template/v4/dto.go.template +++ b/template/v4/dto.go.template @@ -1,6 +1,13 @@ package dto import ( + {{- /* + $embed: the key is models.Model's own Id, so the model embeds it. + $pkType: the key's Go type, for the delete request's id list. + */ -}} + {{- $embed := false -}} + {{- $pkType := "int" -}} + {{- range .Columns -}}{{- if .Pk -}}{{- $pkType = .GoType -}}{{- if and (eq .GoField "Id") (eq .GoType "int") -}}{{- $embed = true -}}{{- end -}}{{- end -}}{{- end -}} {{- $bb := false -}} {{- range .Columns -}} {{- $z := .IsQuery -}} @@ -52,7 +59,7 @@ type {{.ClassName}}InsertReq struct { {{- range .Columns -}} {{$x := .Pk}} {{- if ($x) }} - {{.GoField}} {{.GoType}} `json:"-" comment:"{{.ColumnComment}}"` // {{.ColumnComment}} + {{.GoField}} {{.GoType}} `json:"{{if eq .GoType "int"}}-{{else}}{{.JsonField}}{{end}}" comment:"{{.ColumnComment}}"` // {{.ColumnComment}} {{- else if eq .GoField "CreatedAt" -}} {{- else if eq .GoField "UpdatedAt" -}} {{- else if eq .GoField "DeletedAt" -}} @@ -69,9 +76,13 @@ func (s *{{.ClassName}}InsertReq) Generate(model *models.{{.ClassName}}) { {{- range .Columns -}} {{$x := .Pk}} {{- if ($x) }} + {{- if $embed }} if s.{{.GoField}} == 0 { model.Model = common.Model{ {{.GoField}}: s.{{.GoField}} } } + {{- else }} + model.{{.GoField}} = s.{{.GoField}} + {{- end }} {{- else if eq .GoField "CreatedAt" -}} {{- else if eq .GoField "UpdatedAt" -}} {{- else if eq .GoField "DeletedAt" -}} @@ -92,7 +103,7 @@ type {{.ClassName}}UpdateReq struct { {{- range .Columns -}} {{$x := .Pk}} {{- if ($x) }} - {{.GoField}} {{.GoType}} `uri:"{{.JsonField}}" comment:"{{.ColumnComment}}"` // {{.ColumnComment}} + {{.GoField}} {{.GoType}} `uri:"id" comment:"{{.ColumnComment}}"` // {{.ColumnComment}} {{- else if eq .GoField "CreatedAt" -}} {{- else if eq .GoField "UpdatedAt" -}} {{- else if eq .GoField "DeletedAt" -}} @@ -109,9 +120,11 @@ func (s *{{.ClassName}}UpdateReq) Generate(model *models.{{.ClassName}}) { {{- range .Columns -}} {{$x := .Pk}} {{- if ($x) }} + {{- if $embed }} if s.{{.GoField}} == 0 { model.Model = common.Model{ {{.GoField}}: s.{{.GoField}} } } + {{- end }} {{- else if eq .GoField "CreatedAt" -}} {{- else if eq .GoField "UpdatedAt" -}} {{- else if eq .GoField "DeletedAt" -}} @@ -133,7 +146,7 @@ type {{.ClassName}}GetReq struct { {{- range .Columns -}} {{$x := .Pk}} {{- if ($x) }} - {{.GoField}} {{.GoType}} `uri:"{{.JsonField}}"` + {{.GoField}} {{.GoType}} `uri:"id"` {{- end }} {{- end }} } @@ -143,7 +156,7 @@ func (s *{{.ClassName}}GetReq) GetId() interface{} { // {{.ClassName}}DeleteReq 功能删除请求参数 type {{.ClassName}}DeleteReq struct { - Ids []int `json:"ids"` + Ids []{{$pkType}} `json:"ids"` } func (s *{{.ClassName}}DeleteReq) GetId() interface{} { diff --git a/template/v4/model.go.template b/template/v4/model.go.template index 2f56123d..ad8f4360 100644 --- a/template/v4/model.go.template +++ b/template/v4/model.go.template @@ -24,11 +24,24 @@ import ( ) +{{/* + models.Model supplies an int primary key named Id, so it stands in for the + key column only when that is exactly the key the table has. Any other key - + a different name, or a string - is declared as its own field, or GetId below + would name a field that does not exist. +*/ -}} +{{- $embed := false -}} +{{- range .Columns -}}{{- if and .Pk (eq .GoField "Id") (eq .GoType "int") -}}{{- $embed = true -}}{{- end -}}{{- end -}} type {{.ClassName}} struct { + {{- if $embed }} models.Model + {{- end }} {{ range .Columns -}} {{$x := .Pk}} {{- if ($x) }} + {{- if not $embed }} + {{.GoField}} {{.GoType}} `json:"{{.JsonField}}" gorm:"primaryKey;type:{{.ColumnType}};comment:{{- if eq .ColumnComment "" -}}{{.GoField}}{{- else -}}{{.ColumnComment}}{{end -}}"` + {{- end }} {{- else if eq .GoField "CreatedAt" -}} {{- else if eq .GoField "UpdatedAt" -}} {{- else if eq .GoField "DeletedAt" -}} From bf8a1b337465d77f630636493441efe1b295a23f Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Fri, 25 Sep 2026 20:00:13 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix=F0=9F=90=9B:=20bind=20the=20generated?= =?UTF-8?q?=20service's=20key=20as=20a=20query=20value?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Get and Update found their row with First(&model, id). GORM reads a string passed that way as a SQL condition rather than a key, so with a string primary key the path parameter went into the WHERE clause as written: "c-1" failed as the expression c - 1, and GET /…/1=1 returned a row. Both now filter on clause.PrimaryColumn with the id as a bound value, which GORM resolves to the model's own quoted key column whatever it is called. Delete keeps Delete(&model, ids): a slice is always taken as key values. --- template/v4/no_actions/service.go.template | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/template/v4/no_actions/service.go.template b/template/v4/no_actions/service.go.template index 9f2a1af7..3be6f66c 100644 --- a/template/v4/no_actions/service.go.template +++ b/template/v4/no_actions/service.go.template @@ -5,6 +5,7 @@ import ( "github.com/go-admin-team/go-admin-core/v2/sdk/service" "gorm.io/gorm" + "gorm.io/gorm/clause" "go-admin/app/{{.PackageName}}/models" "go-admin/app/{{.PackageName}}/service/dto" @@ -37,6 +38,9 @@ func (e *{{.ClassName}}) GetPage(c *dto.{{.ClassName}}GetPageReq, p *actions.Dat } // Get 获取{{.ClassName}}对象 +// +// The key is matched as a value of the model's primary-key column. Handed to +// First on its own, a string key would be read as a SQL condition. func (e *{{.ClassName}}) Get(d *dto.{{.ClassName}}GetReq, p *actions.DataPermission, model *models.{{.ClassName}}) error { var data models.{{.ClassName}} @@ -44,7 +48,8 @@ func (e *{{.ClassName}}) Get(d *dto.{{.ClassName}}GetReq, p *actions.DataPermiss Scopes( actions.Permission(data.TableName(), p), ). - First(model, d.GetId()).Error + Where(clause.Eq{Column: clause.PrimaryColumn, Value: d.GetId()}). + First(model).Error if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { err = errors.New("查看对象不存在或无权查看") e.Log.Errorf("Service Get{{.ClassName}} error:%s \r\n", err) @@ -76,7 +81,9 @@ func (e *{{.ClassName}}) Update(c *dto.{{.ClassName}}UpdateReq, p *actions.DataP var data = models.{{.ClassName}}{} e.Orm.Scopes( actions.Permission(data.TableName(), p), - ).First(&data, c.GetId()) + ). + Where(clause.Eq{Column: clause.PrimaryColumn, Value: c.GetId()}). + First(&data) c.Generate(&data) db := e.Orm.Save(&data) From 3112896a83ff32230db06b6ae411a6e03a00d2a5 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Fri, 25 Sep 2026 20:00:30 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix=F0=9F=90=9B:=20stop=20a=20generated=20U?= =?UTF-8?q?pdate=20when=20its=20row=20is=20not=20found?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update looked its row up and ignored the result. When nothing matched - because the key does not exist, or because the caller's data scope filters the row out - the model stayed at its zero value, Generate copied the request onto it, and Save, finding a zero key, inserted a new row. The update of a row the caller may not see became a row they now own, and the RowsAffected check meant to refuse it never fired, because the insert did affect one. A missing row now ends the update with the same "无权更新该数据" the RowsAffected check returns, and any other lookup error is returned as is. This applies to every generated table, an int id included. --- template/v4/no_actions/service.go.template | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/template/v4/no_actions/service.go.template b/template/v4/no_actions/service.go.template index 3be6f66c..5dc5c4f7 100644 --- a/template/v4/no_actions/service.go.template +++ b/template/v4/no_actions/service.go.template @@ -76,14 +76,25 @@ func (e *{{.ClassName}}) Insert(c *dto.{{.ClassName}}InsertReq) error { } // Update 修改{{.ClassName}}对象 +// +// The row has to be found first. Save on a model whose key is still zero +// inserts, so a row that is missing - or that p filters out - must stop here +// rather than turn the update into a new row. func (e *{{.ClassName}}) Update(c *dto.{{.ClassName}}UpdateReq, p *actions.DataPermission) error { var err error var data = models.{{.ClassName}}{} - e.Orm.Scopes( + err = e.Orm.Scopes( actions.Permission(data.TableName(), p), ). Where(clause.Eq{Column: clause.PrimaryColumn, Value: c.GetId()}). - First(&data) + First(&data).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("无权更新该数据") + } + if err != nil { + e.Log.Errorf("{{.ClassName}}Service Update error:%s \r\n", err) + return err + } c.Generate(&data) db := e.Orm.Save(&data) From 99d22249efae4955ecf676cace631d6c7aee70ee Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Fri, 25 Sep 2026 20:00:30 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix=F0=9F=90=9B:=20refuse=20to=20generate?= =?UTF-8?q?=20a=20table=20whose=20key=20is=20not=20one=20column?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The templates address a row by a single key: one field on the model, one path parameter, one value per row in a delete. A table with no primary key rendered a model whose GetId returned an empty field name, and a composite key was silently narrowed to whichever of its columns the importer read last. Both wrote files that break the build of the whole module. Preview and NOActionsGen now check first and return an error naming the table and, for a composite key, its columns. Nothing is written. --- app/other/apis/tools/gen.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/app/other/apis/tools/gen.go b/app/other/apis/tools/gen.go index 02701620..cc673b78 100644 --- a/app/other/apis/tools/gen.go +++ b/app/other/apis/tools/gen.go @@ -131,6 +131,10 @@ func (e Gen) Preview(c *gin.Context) { // are not interchangeable stand-ins for each other and should not be // assumed to be. tab.MLTBName = strings.Replace(tab.TBName, "_", "-", -1) + if err := requireSinglePrimaryKey(tab); err != nil { + e.Error(500, err, err.Error()) + return + } // R2: infer a width for any column the config page left at colWidth's 0 // sentinel, before vue.go.template reads .ColWidth - see column_width.go. applyInferredColumnWidths(tab.Columns) @@ -221,6 +225,10 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) { e.Context = c log := e.GetLogger() tab.MLTBName = strings.Replace(tab.TBName, "_", "-", -1) + if err := requireSinglePrimaryKey(tab); err != nil { + e.Error(500, err, err.Error()) + return + } // R2: see the matching call and comment in Preview above. applyInferredColumnWidths(tab.Columns) @@ -502,3 +510,26 @@ func (e Gen) GenMenuAndApi(c *gin.Context) { e.OK("", "数据生成成功!") } + +// requireSinglePrimaryKey refuses a table whose primary key is not exactly one +// column. The templates address a row by a single key - one field on the +// model, one path parameter, one value per row in a delete - so a table with +// no primary key, or a composite one, has no shape they can render: generating +// it anyway writes a model whose GetId names nothing, or silently keys the +// whole table on whichever key column happened to be imported last. +func requireSinglePrimaryKey(tab tools.SysTables) error { + var keys []string + for _, c := range tab.Columns { + if c.Pk { + keys = append(keys, c.ColumnName) + } + } + switch len(keys) { + case 1: + return nil + case 0: + return fmt.Errorf("表 %s 没有主键,代码生成需要恰好一个主键列", tab.TBName) + default: + return fmt.Errorf("表 %s 是联合主键(%s),代码生成需要恰好一个主键列", tab.TBName, strings.Join(keys, ", ")) + } +} From 0ff5f1a027823399e3b21e4818655694cb035df6 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Fri, 25 Sep 2026 20:00:40 +0800 Subject: [PATCH 5/5] =?UTF-8?q?test=E2=9C=85:=20import=20and=20generate=20?= =?UTF-8?q?every=20primary-key=20shape,=20then=20drive=20the=20result?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generator had no test that compiled what it writes; test/gen_test.go is commented out. This one goes through the real path end to end: fixture tables in MySQL, the import handler, GenCode writing its files, the files compiled into this module's own packages through a -overlay, and a CRUD test run over HTTP against the generated handlers. The shapes are an int key named id, an int key named otherwise, a string key, no key and a composite key. The last two must be refused with nothing written. For the rest: read and update through the path parameter, a missing key neither found nor created by an update, a key of "1=1" read as a value, and delete by key. The importer only reads MySQL, so the test needs GO_ADMIN_TEST_MYSQL_DSN; without it the test skips locally and fails under CI. It also asserts each generated CRUD test actually ran, since a -run pattern that matches nothing exits 0. --- app/other/apis/tools/gen_primary_key_test.go | 385 +++++++++++++++++++ 1 file changed, 385 insertions(+) create mode 100644 app/other/apis/tools/gen_primary_key_test.go diff --git a/app/other/apis/tools/gen_primary_key_test.go b/app/other/apis/tools/gen_primary_key_test.go new file mode 100644 index 00000000..93b51bf0 --- /dev/null +++ b/app/other/apis/tools/gen_primary_key_test.go @@ -0,0 +1,385 @@ +package tools + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "text/template" + + "github.com/gin-gonic/gin" + "github.com/go-admin-team/go-admin-core/v2/sdk/config" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "go-admin/app/other/models/tools" +) + +// The importer reads tables from information_schema, which only MySQL is +// supported for, so this runs only when a MySQL DSN is set - and must run in +// CI, where one is. A suite that skipped there would report the generator +// fine for every primary key it has never been shown. +const genMySQLDSNEnv = "GO_ADMIN_TEST_MYSQL_DSN" + +// auditColumns are the columns every generated model maps through +// models.ModelTime and models.ControlBy, typed the way those structs store +// them. A fixture without them would describe a table the generated model +// cannot read or write. +const auditColumns = `name varchar(64) NOT NULL COMMENT 'name', + create_by bigint NULL, + update_by bigint NULL, + created_at datetime(3) NULL, + updated_at datetime(3) NULL, + deleted_at bigint NOT NULL DEFAULT 0` + +// pkShape is one table the importer can be pointed at. The shapes are the +// primary keys a real table has, not the one the templates were written +// for. +type pkShape struct { + Table string + Class string + Key string // key column definition, before the audit columns + Tail string // table constraints, after them + Refused bool // generation must refuse the table outright + PkColumn string + // Assigned is true when the database assigns the key; otherwise Create + // carries it and ID is the value it carries. + Assigned bool + Create string + ID string + IDJSON string + Missing string +} + +var pkShapes = []pkShape{{ + Table: "gpk_serial", Class: "GpkSerial", PkColumn: "id", + Key: "id int NOT NULL AUTO_INCREMENT PRIMARY KEY,", + Assigned: true, Create: `{"name":"alpha"}`, Missing: "987654", +}, { + Table: "gpk_order", Class: "GpkOrder", PkColumn: "order_no", + Key: "order_no int NOT NULL AUTO_INCREMENT PRIMARY KEY,", + Assigned: true, Create: `{"name":"alpha"}`, Missing: "987654", +}, { + Table: "gpk_code", Class: "GpkCode", PkColumn: "code", + Key: "code varchar(32) NOT NULL PRIMARY KEY,", + Create: `{"code":"c-1","name":"alpha"}`, ID: "c-1", IDJSON: `"c-1"`, Missing: "no-such-code", +}, { + Table: "gpk_nokey", Key: "ref int NOT NULL,", Refused: true, +}, { + Table: "gpk_pair", Key: "a int NOT NULL, b int NOT NULL,", Tail: ", PRIMARY KEY (a, b)", Refused: true, +}} + +func TestGeneratorHandlesEveryPrimaryKeyShape(t *testing.T) { + dsn := os.Getenv(genMySQLDSNEnv) + if dsn == "" { + if os.Getenv("CI") != "" { + t.Fatalf("%s is not set while CI is: the generator must not go untested", genMySQLDSNEnv) + } + t.Skipf("%s is not set; the importer only reads MySQL", genMySQLDSNEnv) + } + gin.SetMode(gin.TestMode) + root, err := filepath.Abs("../../../..") + if err != nil { + t.Fatal(err) + } + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("connecting to %s: %v", genMySQLDSNEnv, err) + } + var dbName string + if err := db.Raw("SELECT DATABASE()").Scan(&dbName).Error; err != nil || dbName == "" { + t.Fatalf("reading the database name: %v", err) + } + + out := t.TempDir() + restore := [3]string{config.DatabaseConfig.Driver, config.GenConfig.DBName, config.GenConfig.FrontPath} + t.Cleanup(func() { + config.DatabaseConfig.Driver, config.GenConfig.DBName, config.GenConfig.FrontPath = restore[0], restore[1], restore[2] + }) + config.DatabaseConfig.Driver = "mysql" + config.GenConfig.DBName = dbName + config.GenConfig.FrontPath = filepath.Join(out, "ui") + + if err := db.AutoMigrate(&tools.SysTables{}, &tools.SysColumns{}); err != nil { + t.Fatalf("migrating the generator's own tables: %v", err) + } + for _, s := range pkShapes { + dropFixture(t, db, s.Table) + ddl := "CREATE TABLE " + s.Table + " (" + s.Key + auditColumns + s.Tail + ")" + if err := db.Exec(ddl).Error; err != nil { + t.Fatalf("creating %s: %v", s.Table, err) + } + t.Cleanup(func() { dropFixture(t, db, s.Table) }) + } + + // The generator resolves its templates, and writes its output, relative to + // the working directory, exactly as it does in a running server. + if err := os.Symlink(filepath.Join(root, "template"), filepath.Join(out, "template")); err != nil { + t.Fatal(err) + } + t.Chdir(out) + + generated := map[string]string{} + var runnable []pkShape + genFailed := false + for _, s := range pkShapes { + if code := callHandler(t, db, SysTable{}.Insert, "/?tables="+s.Table, nil); code != http.StatusOK { + t.Fatalf("importing %s: response code %d", s.Table, code) + } + var row tools.SysTables + if err := db.Where("table_name = ?", s.Table).First(&row).Error; err != nil { + t.Fatalf("finding %s after import: %v", s.Table, err) + } + code := callHandler(t, db, Gen{}.GenCode, "/", gin.Params{{Key: "tableId", Value: itoa(row.TableId)}}) + files := backendFiles(out, root, s.Table) + + if s.Refused { + if code == http.StatusOK { + t.Errorf("%s: generation succeeded; a table without exactly one primary key column must be refused", s.Table) + } + for _, generatedFile := range files { + if _, err := os.Stat(generatedFile); err == nil { + t.Errorf("%s: refused, but %s was written anyway", s.Table, generatedFile) + } + } + continue + } + if code != http.StatusOK { + t.Errorf("%s: generation failed with response code %d", s.Table, code) + genFailed = true + continue + } + for dst, src := range files { + generated[dst] = src + } + runnable = append(runnable, s) + } + if genFailed { + return + } + + // Compile the generated files in place, inside this module, through an + // overlay: nothing is written into the working tree, and the generated + // code is checked against the real packages it will live in. + crud := filepath.Join(out, "generated_crud_test.go") + f, err := os.Create(crud) + if err != nil { + t.Fatal(err) + } + if err := crudTest.Execute(f, runnable); err != nil { + t.Fatal(err) + } + f.Close() + generated[filepath.Join(root, "app/admin/apis/zz_generated_crud_test.go")] = crud + + overlay := filepath.Join(out, "overlay.json") + b, _ := json.Marshal(map[string]any{"Replace": generated}) + if err := os.WriteFile(overlay, b, 0o644); err != nil { + t.Fatal(err) + } + + goCmd(t, root, "vet", "-overlay="+overlay, "./app/admin/...") + ran := goCmd(t, root, "test", "-overlay="+overlay, "-count=1", "-v", "-run", "^TestGeneratedCRUD_", "./app/admin/apis/") + // A -run pattern that matches nothing also exits 0. Each table has to + // have been driven, not merely compiled. + for _, s := range runnable { + if !strings.Contains(string(ran), "--- PASS: TestGeneratedCRUD_"+s.Class+" ") { + t.Errorf("%s: TestGeneratedCRUD_%s did not run", s.Table, s.Class) + } + } +} + +func dropFixture(t *testing.T, db *gorm.DB, table string) { + t.Helper() + var ids []int + db.Model(&tools.SysTables{}).Where("table_name = ?", table).Pluck("table_id", &ids) + if len(ids) > 0 { + db.Where("table_id IN ?", ids).Delete(&tools.SysColumns{}) + db.Where("table_id IN ?", ids).Delete(&tools.SysTables{}) + } + if err := db.Exec("DROP TABLE IF EXISTS " + table).Error; err != nil { + t.Fatalf("dropping %s: %v", table, err) + } +} + +// callHandler runs one handler the way a request would reach it and returns +// the code its first response body carries. GenCode can write a second body +// after an error body, so only the first is the verdict. +func callHandler(t *testing.T, db *gorm.DB, h gin.HandlerFunc, target string, params gin.Params) int { + t.Helper() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, target, nil) + c.Params = params + c.Set("db", db) + h(c) + var body struct { + Code int `json:"code"` + } + if err := json.NewDecoder(strings.NewReader(w.Body.String())).Decode(&body); err != nil { + t.Fatalf("decoding the response to %s: %v (body %q)", target, err, w.Body.String()) + } + return body.Code +} + +// backendFiles maps where each generated backend file belongs in this module +// to where the generator wrote it. +func backendFiles(out, root, table string) map[string]string { + m := map[string]string{} + for _, dir := range []string{"models", "apis", "router", "service", "service/dto"} { + rel := filepath.Join("app/admin", dir, table+".go") + m[filepath.Join(root, rel)] = filepath.Join(out, rel) + } + return m +} + +func goCmd(t *testing.T, dir string, args ...string) []byte { + t.Helper() + cmd := exec.Command("go", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + var exit *exec.ExitError + if errors.As(err, &exit) { + t.Fatalf("go %s failed:\n%s", strings.Join(args, " "), out) + } + if err != nil { + t.Fatalf("running go %s: %v", args[0], err) + } + t.Logf("go %s:\n%s", args[0], out) + return out +} + +func itoa(n int) string { + b, _ := json.Marshal(n) + return string(b) +} + +// crudTest drives each generated table through its generated handlers, over +// HTTP, against the fixture table. The routes use the same "/:id" pattern the +// generated routers register. +var crudTest = template.Must(template.New("crud").Parse(`package apis + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type generatedResp struct { + Code int ` + "`json:\"code\"`" + ` + Data json.RawMessage ` + "`json:\"data\"`" + ` +} + +func generatedEngine(t *testing.T) (*gin.Engine, *gorm.DB) { + t.Helper() + db, err := gorm.Open(mysql.Open(os.Getenv("GO_ADMIN_TEST_MYSQL_DSN")), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { c.Set("db", db.WithContext(c)); c.Next() }) + return r, db +} + +func generatedDo(r *gin.Engine, method, path, body string) generatedResp { + w := httptest.NewRecorder() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + var res generatedResp + _ = json.NewDecoder(strings.NewReader(w.Body.String())).Decode(&res) + return res +} + +func generatedLive(db *gorm.DB, table string) int64 { + var n int64 + db.Table(table).Where("deleted_at = 0").Count(&n) + return n +} +{{range .}} +func TestGeneratedCRUD_{{.Class}}(t *testing.T) { + r, db := generatedEngine(t) + a := {{.Class}}{} + r.POST("/x", a.Insert) + r.GET("/x/:id", a.Get) + r.PUT("/x/:id", a.Update) + r.DELETE("/x", a.Delete) + + if res := generatedDo(r, http.MethodPost, "/x", ` + "`{{.Create}}`" + `); res.Code != 200 { + t.Fatalf("insert: code %d", res.Code) + } +{{- if .Assigned}} + var n int + db.Table("{{.Table}}").Select("MAX({{.PkColumn}})").Scan(&n) + id, idJSON := itoaGenerated(n), itoaGenerated(n) +{{- else}} + id, idJSON := "{{.ID}}", ` + "`{{.IDJSON}}`" + ` +{{- end}} + + t.Run("get by the path parameter", func(t *testing.T) { + res := generatedDo(r, http.MethodGet, "/x/"+url.PathEscape(id), "") + var row map[string]any + _ = json.Unmarshal(res.Data, &row) + if res.Code != 200 || row["name"] != "alpha" { + t.Errorf("code %d, data %s", res.Code, res.Data) + } + }) + t.Run("update by the path parameter updates, not inserts", func(t *testing.T) { + live := generatedLive(db, "{{.Table}}") + res := generatedDo(r, http.MethodPut, "/x/"+url.PathEscape(id), ` + "`{\"name\":\"beta\"}`" + `) + var name string + db.Table("{{.Table}}").Where("{{.PkColumn}} = ?", id).Select("name").Scan(&name) + if res.Code != 200 || name != "beta" || generatedLive(db, "{{.Table}}") != live { + t.Errorf("code %d, name %q, live rows %d want %d", res.Code, name, generatedLive(db, "{{.Table}}"), live) + } + }) + t.Run("a missing key is not found", func(t *testing.T) { + if res := generatedDo(r, http.MethodGet, "/x/{{.Missing}}", ""); res.Code == 200 { + t.Errorf("found: %s", res.Data) + } + }) + t.Run("updating a missing key does not create a row", func(t *testing.T) { + live := generatedLive(db, "{{.Table}}") + res := generatedDo(r, http.MethodPut, "/x/{{.Missing}}", ` + "`{\"name\":\"ghost\"}`" + `) + if got := generatedLive(db, "{{.Table}}"); got != live || res.Code == 200 { + t.Errorf("code %d, live rows %d want %d", res.Code, got, live) + } + }) + // "1=1" rather than a quote-breaking payload: a key handed to GORM as a + // bare string becomes the WHERE clause itself, and this one is valid SQL + // that matches every row, so the failure it exposes is a successful read. + t.Run("a key carrying SQL is a value, not a condition", func(t *testing.T) { + if res := generatedDo(r, http.MethodGet, "/x/"+url.PathEscape("1=1"), ""); res.Code == 200 { + t.Errorf("found: %s", res.Data) + } + }) + t.Run("delete by key", func(t *testing.T) { + live := generatedLive(db, "{{.Table}}") + res := generatedDo(r, http.MethodDelete, "/x", ` + "`{\"ids\":[`+idJSON+`]}`" + `) + if res.Code != 200 || generatedLive(db, "{{.Table}}") != live-1 { + t.Errorf("code %d, live rows %d want %d", res.Code, generatedLive(db, "{{.Table}}"), live-1) + } + }) +} +{{end}} +func itoaGenerated(n int) string { + b, _ := json.Marshal(n) + return string(b) +} +`))