diff --git a/app/other/apis/tools/gen_audit_columns_test.go b/app/other/apis/tools/gen_audit_columns_test.go new file mode 100644 index 00000000..4a34ac9c --- /dev/null +++ b/app/other/apis/tools/gen_audit_columns_test.go @@ -0,0 +1,334 @@ +package tools + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "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 config page reads a table's columns through SysTable.Get and saves them +// back through SysTable.Update. created_at and updated_at used to be filtered +// out of what Get returns, so no generated page could list, sort or filter by +// when a row was created unless someone edited sys_columns by hand. This +// drives that page's round trip, generates the table, and runs the result. +// genEnv is a generator pointed at the MySQL test database, writing into out +// and resolving its templates from this repository. +type genEnv struct { + db *gorm.DB + root string + out string +} + +func newGenEnv(t *testing.T) genEnv { + t.Helper() + 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) + } + if err := os.Symlink(filepath.Join(root, "template"), filepath.Join(out, "template")); err != nil { + t.Fatal(err) + } + t.Chdir(out) + return genEnv{db: db, root: root, out: out} +} + +// importTable creates table with the given column definitions and imports it +// into the generator, returning the route parameter that names it. +func (g genEnv) importTable(t *testing.T, table, columns string) gin.Params { + t.Helper() + dropFixture(t, g.db, table) + if err := g.db.Exec("CREATE TABLE " + table + " (" + columns + ")").Error; err != nil { + t.Fatalf("creating %s: %v", table, err) + } + t.Cleanup(func() { dropFixture(t, g.db, table) }) + if code := callHandler(t, g.db, SysTable{}.Insert, "/?tables="+table, nil); code != http.StatusOK { + t.Fatalf("importing %s: response code %d", table, code) + } + var row tools.SysTables + if err := g.db.Where("table_name = ?", table).First(&row).Error; err != nil { + t.Fatalf("finding %s after import: %v", table, err) + } + return gin.Params{{Key: "tableId", Value: itoa(row.TableId)}} +} + +func (g genEnv) page(t *testing.T, table string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(g.out, "ui", "views", "admin", strings.ReplaceAll(table, "_", "-"), "index.vue")) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func TestAuditTimestampsCanBeListedAndQueried(t *testing.T) { + g := newGenEnv(t) + db, root, out := g.db, g.root, g.out + const table = "gau_event" + tableID := g.importTable(t, table, "id int NOT NULL AUTO_INCREMENT PRIMARY KEY,"+auditColumns) + + // What the config page is shown. + var detail struct { + List []map[string]any `json:"list"` + Info map[string]any `json:"info"` + } + callJSON(t, db, SysTable{}.Get, http.MethodGet, "", tableID, &detail) + shown := map[string]map[string]any{} + for _, c := range detail.List { + shown[c["columnName"].(string)] = c + } + for _, name := range []string{"created_at", "updated_at"} { + if shown[name] == nil { + t.Errorf("the config page is not shown %s", name) + } + } + // Filled in by the framework on every write; a setting on them would + // either do nothing or break the generated model. + for _, name := range []string{"id", "create_by", "update_by", "deleted_at"} { + if shown[name] != nil { + t.Errorf("the config page is shown %s", name) + } + } + if t.Failed() { + return + } + + // What the page sends back once someone ticks created_at's list and query + // boxes and picks "greater than or equal". + shown["created_at"]["isList"] = "1" + shown["created_at"]["isQuery"] = "1" + shown["created_at"]["queryType"] = "GTE" + shown["updated_at"]["isList"] = "1" + // Ticked and then unticked: the checkbox's false-value. + shown["name"]["isQuery"] = "0" + detail.Info["columns"] = detail.List + body, _ := json.Marshal(detail.Info) + if code := callJSON(t, db, SysTable{}.Update, http.MethodPut, string(body), nil, nil); code != http.StatusOK { + t.Fatalf("saving the table config: response code %d", code) + } + + if code := callHandler(t, db, Gen{}.GenCode, "/", tableID); code != http.StatusOK { + t.Fatalf("generation failed with response code %d", code) + } + + vue := g.page(t, table) + for _, want := range []string{ + ``, + ``, + `v-model="table.query.createdAt"`, + } { + if !strings.Contains(vue, want) { + t.Errorf("the generated page has no %s", want) + } + } + for _, unwanted := range []string{"form.model.createdAt", "form.model.updatedAt"} { + if strings.Contains(vue, unwanted) { + t.Errorf("the generated form edits %s, which the framework fills in", unwanted) + } + } + + dto, err := os.ReadFile(filepath.Join(out, "app", "admin", "service", "dto", table+".go")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(dto), `form:"name"`) { + t.Error("name, saved with isQuery \"0\", is a search field of the generated list request") + } + + generated := backendFiles(out, root, table) + test := filepath.Join(out, "generated_audit_test.go") + f, err := os.Create(test) + if err != nil { + t.Fatal(err) + } + if err := auditQueryTest.Execute(f, table); err != nil { + t.Fatal(err) + } + f.Close() + generated[filepath.Join(root, "app/admin/apis/zz_generated_audit_test.go")] = test + 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", "^TestGeneratedAuditQuery$", "./app/admin/apis/") + if !strings.Contains(string(ran), "--- PASS: TestGeneratedAuditQuery ") { + t.Error("TestGeneratedAuditQuery did not run") + } +} + +// A table whose only columns are its key and the ones the framework fills in +// leaves the create/edit dialog nothing to show. The page used to render it +// anyway: an add button and an edit link opening an empty form. +func TestAPageWithNothingToEnterHasNoForm(t *testing.T) { + g := newGenEnv(t) + const table = "gau_stamp" + tableID := g.importTable(t, table, `id int NOT NULL AUTO_INCREMENT PRIMARY KEY, + 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`) + + if code := callHandler(t, g.db, Gen{}.GenCode, "/", tableID); code != http.StatusOK { + t.Fatalf("generation failed with response code %d", code) + } + vue := g.page(t, table) + for _, unwanted := range []string{"= %s listed %v, want only the recent row", since, got) + } +} +`)) diff --git a/app/other/apis/tools/gen_primary_key_test.go b/app/other/apis/tools/gen_primary_key_test.go index 93b51bf0..7be331fe 100644 --- a/app/other/apis/tools/gen_primary_key_test.go +++ b/app/other/apis/tools/gen_primary_key_test.go @@ -197,11 +197,13 @@ func TestGeneratorHandlesEveryPrimaryKeyShape(t *testing.T) { func dropFixture(t *testing.T, db *gorm.DB, table string) { t.Helper() + // Unscoped, as the delete endpoint is: a soft-deleted row still holds its + // business name, and the config page's uniqueness check counts it. var ids []int - db.Model(&tools.SysTables{}).Where("table_name = ?", table).Pluck("table_id", &ids) + db.Unscoped().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{}) + db.Unscoped().Where("table_id IN ?", ids).Delete(&tools.SysColumns{}) + db.Unscoped().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) diff --git a/app/other/models/tools/sys_columns.go b/app/other/models/tools/sys_columns.go index e901cbfa..758a8a56 100644 --- a/app/other/models/tools/sys_columns.go +++ b/app/other/models/tools/sys_columns.go @@ -70,13 +70,11 @@ func (e *SysColumns) GetList(tx *gorm.DB, exclude bool) ([]SysColumns, error) { table := tx.Table("sys_columns") table = table.Where("table_id = ? ", e.TableId) if exclude { - notIn := make([]string, 0, 6) - notIn = append(notIn, "id") - notIn = append(notIn, "create_by") - notIn = append(notIn, "update_by") - notIn = append(notIn, "created_at") - notIn = append(notIn, "updated_at") - notIn = append(notIn, "deleted_at") + // Columns the framework fills in on every write, which a setting on the + // config page could not change. created_at and updated_at are not + // among them: whether a page lists or filters by them is a choice the + // page makes, and the templates already keep them out of the form. + notIn := []string{"id", "create_by", "update_by", "deleted_at"} table = table.Where(" column_name not in(?)", notIn) } diff --git a/template/v4/dto.go.template b/template/v4/dto.go.template index 7619b066..10d6eb74 100644 --- a/template/v4/dto.go.template +++ b/template/v4/dto.go.template @@ -10,8 +10,8 @@ import ( {{- 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 -}} - {{- if ($z) -}} + {{- /* "1" exactly: the config page saves an unticked box as "0", which a bare if reads as true. */ -}} + {{- if eq .IsQuery "1" -}} {{- if eq .GoType "time.Time" -}}{{- $bb = true -}}{{- end -}} {{- end -}} {{- end -}} @@ -36,8 +36,7 @@ type {{.ClassName}}GetPageReq struct { dto.Pagination `search:"-"` {{- $tablename := .TBName -}} {{- range .Columns -}} - {{$z := .IsQuery}} - {{- if ($z) }} + {{- if eq .IsQuery "1" }} {{.GoField}} {{.GoType}} `form:"{{.JsonField}}" search:"type:{{if eq .QueryType "EQ"}}exact{{ else if eq .QueryType "NE"}}iexact{{ else if eq .QueryType "LIKE"}}contains{{ else if eq .QueryType "GT"}}gt{{ else if eq .QueryType "GTE"}}gte{{ else if eq .QueryType "LT"}}lt{{ else if eq .QueryType "LTE"}}lte{{- end }};column:{{.ColumnName}};table:{{$tablename}}" comment:"{{.ColumnComment}}"` {{- end }} {{- end }} diff --git a/template/v4/model.go.template b/template/v4/model.go.template index ad8f4360..589a0b9e 100644 --- a/template/v4/model.go.template +++ b/template/v4/model.go.template @@ -1,14 +1,14 @@ package models import ( + {{- /* + The model declares a time.Time field for every such column except the + three models.ModelTime supplies. Whether a column is queryable is the + DTO's business, not the model's: counting it here imported "time" for a + queryable created_at that the model never declares. + */ -}} {{- $bb := false -}} {{- range .Columns -}} - {{- $z := .IsQuery -}} - {{- if ($z) -}} - {{- if eq .GoType "time.Time" -}}{{- $bb = true -}}{{- end -}} - {{- end -}} - {{- end -}} - {{- range .Columns -}} {{- if eq .GoField "CreatedAt" -}} {{- else if eq .GoField "UpdatedAt" -}} {{- else if eq .GoField "DeletedAt" -}} diff --git a/template/v4/no_actions/apis.go.template b/template/v4/no_actions/apis.go.template index a0825963..adfc9c99 100644 --- a/template/v4/no_actions/apis.go.template +++ b/template/v4/no_actions/apis.go.template @@ -24,8 +24,7 @@ type {{.ClassName}} struct { // @Tags {{.TableComment}} {{- $tablename := .TBName -}} {{- range .Columns -}} -{{$z := .IsQuery}} -{{- if ($z) }} +{{- if eq .IsQuery "1" }} // @Param {{.JsonField}} query {{.GoType}} false "{{.ColumnComment}}" {{- end -}} {{- end }} diff --git a/template/v4/vue.go.template b/template/v4/vue.go.template index 6b72c465..cd7e208b 100644 --- a/template/v4/vue.go.template +++ b/template/v4/vue.go.template @@ -56,6 +56,7 @@ {{- $hasDatetime := false -}} {{- $hasRules := false -}} {{- $hasQuery := false -}} +{{- $hasForm := false -}} {{- $pkType := "number" -}} {{- range .Columns -}} {{- $dictUsed := and (ne .DictType "") (or (and (eq .IsQuery "1") (eq .FkTableName "")) (and (eq .IsList "1") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "select") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "radio"))) -}} @@ -66,8 +67,15 @@ {{- if and (eq .IsList "1") (eq .FkTableName "") (eq .DictType "") (eq .HtmlType "datetime") }}{{$hasDatetime = true}}{{end -}} {{- if eq .IsQuery "1" }}{{$hasQuery = true}}{{end -}} {{- if and (eq .IsInsert "1") (eq .IsRequired "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy") }}{{$hasRules = true}}{{end -}} - {{- if and .Pk (eq .GoType "string") }}{{$pkType = "string"}}{{$hasRules = true}}{{end -}} + {{- if and .Pk (eq .GoType "string") }}{{$pkType = "string"}}{{$hasRules = true}}{{$hasForm = true}}{{end -}} + {{- if and (eq .IsInsert "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy") }}{{$hasForm = true}}{{end -}} {{- end -}} +{{- /* + $hasForm: the create/edit dialog would hold at least one field - a typed-in + string key or an insertable column the framework does not fill in. Without + one, the add button, the edit link and the dialog are left out, not rendered + around an empty form that saves nothing the user chose. +*/ -}} + {{- end}} @@ -273,18 +287,14 @@ import ProTable from '@/components/ProTable/index.vue' {{- if $hasDatetime}} import DateCell from '@/components/DateCell/index.vue' {{- end}} -{{- if $hasDict}} -{{- if $hasDictList}} -import { useTable, useForm, useRemove, useDict, dictLabel } from '@/composables' -{{- else}} -import { useTable, useForm, useRemove, useDict } from '@/composables' -{{- end}} -{{- else}} -import { useTable, useForm, useRemove } from '@/composables' -{{- end}} +import { useTable{{if $hasForm}}, useForm{{end}}, useRemove{{if $hasDict}}, useDict{{if $hasDictList}}, dictLabel{{end}}{{end}} } from '@/composables' +{{- if $hasForm}} import { add{{.ClassName}}, del{{.ClassName}}, get{{.ClassName}}, list{{.ClassName}}, update{{.ClassName}} } from '@/api/{{.PackageName}}/{{.MLTBName}}' +{{- else}} +import { del{{.ClassName}}, list{{.ClassName}} } from '@/api/{{.PackageName}}/{{.MLTBName}}' +{{- end}} import type { {{.ClassName}}, {{.ClassName}}Query } from '@/api/{{.PackageName}}/{{.MLTBName}}' {{- /* Two columns pointing at the same foreign table must not import it twice -- @@ -386,6 +396,7 @@ const rules = computed(() => ({ {{$rFirst := true}} {{- end}} })) {{- end}} +{{- if $hasForm}} const form = useForm<{{.ClassName}}, {{$pkType}}>({ defaultModel: () => ({{"{"}} {{.PkJsonField}}: undefined{{range .Columns}}{{if and (eq .IsInsert "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy")}}, {{.JsonField}}: {{if eq .DefaultValue ""}}undefined{{else if eq .GoType "int"}}{{.DefaultValue}}{{else}}'{{js .DefaultValue}}'{{end}}{{end}}{{end}} {{"}"}}), @@ -396,6 +407,7 @@ const form = useForm<{{.ClassName}}, {{$pkType}}>({ api: { get: get{{.ClassName}}, add: add{{.ClassName}}, update: update{{.ClassName}} }, onSuccess: () => table.getList() }) +{{- end}} const { remove } = useRemove({ api: del{{.ClassName}},