Merge pull request #954 from go-admin-team/fix/gen-audit-columns-and-empty-form

fix🐛: generator config for audit timestamps, and no empty form
This commit is contained in:
wenjianzhang
2026-09-27 16:16:18 +08:00
committed by GitHub
7 changed files with 376 additions and 32 deletions
@@ -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{
`<DateCell :value="row.createdAt" />`,
`<DateCell :value="row.updatedAt" />`,
`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{"<el-dialog", "<el-form", "useForm", "openCreate", "openEdit", "addGauStamp", "updateGauStamp", "getGauStamp"} {
if strings.Contains(vue, unwanted) {
t.Errorf("the generated page contains %s", unwanted)
}
}
for _, want := range []string{"useTable", "remove(table.selectedIds)", "import { delGauStamp, listGauStamp }"} {
if !strings.Contains(vue, want) {
t.Errorf("the generated page has no %s", want)
}
}
overlay := filepath.Join(g.out, "overlay.json")
b, _ := json.Marshal(map[string]any{"Replace": backendFiles(g.out, g.root, table)})
if err := os.WriteFile(overlay, b, 0o644); err != nil {
t.Fatal(err)
}
goCmd(t, g.root, "vet", "-overlay="+overlay, "./app/admin/...")
}
// callJSON runs one handler with a JSON body and decodes the response's data
// into data when it is not nil. It returns the code the body carries.
func callJSON(t *testing.T, db *gorm.DB, h gin.HandlerFunc, method, body string, params gin.Params, data any) int {
t.Helper()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(method, "/", strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = params
c.Set("db", db)
h(c)
var res struct {
Code int `json:"code"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatalf("decoding the response: %v (body %q)", err, w.Body.String())
}
if data != nil {
if err := json.Unmarshal(res.Data, data); err != nil {
t.Fatalf("decoding the response data: %v (body %q)", err, w.Body.String())
}
}
if res.Code != http.StatusOK {
t.Logf("response: %s", w.Body.String())
}
return res.Code
}
// auditQueryTest lists the generated table filtered on created_at, sent in
// the format the generated page's date picker sends (value-format
// "YYYY-MM-DD[T]HH:mm:ssZ").
var auditQueryTest = template.Must(template.New("audit").Parse(`package apis
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"testing"
"time"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestGeneratedAuditQuery(t *testing.T) {
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() })
r.GET("/x", GauEvent{}.GetPage)
old := time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local)
recent := time.Now().Add(-time.Hour)
for name, at := range map[string]time.Time{"old": old, "recent": recent} {
if err := db.Exec("INSERT INTO {{.}} (name, created_at, updated_at) VALUES (?, ?, ?)", name, at, at).Error; err != nil {
t.Fatal(err)
}
}
list := func(query string) []map[string]any {
t.Helper()
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x?pageIndex=1&pageSize=10"+query, nil))
var res struct {
Code int
Data struct{ List []map[string]any }
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil || res.Code != 200 {
t.Fatalf("listing %q: code %d, err %v, body %s", query, res.Code, err, w.Body.String())
}
return res.Data.List
}
all := list("")
if len(all) != 2 {
t.Fatalf("unfiltered list has %d rows, want 2", len(all))
}
if all[0]["createdAt"] == nil || all[0]["updatedAt"] == nil {
t.Errorf("a listed row carries no createdAt/updatedAt: %v", all[0])
}
since := time.Date(2025, 1, 1, 0, 0, 0, 0, time.Local).Format("2006-01-02T15:04:05Z07:00")
got := list("&createdAt=" + url.QueryEscape(since))
if len(got) != 1 || got[0]["name"] != "recent" {
t.Errorf("createdAt >= %s listed %v, want only the recent row", since, got)
}
}
`))
+5 -3
View File
@@ -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)
+5 -7
View File
@@ -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)
}
+3 -4
View File
@@ -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 }}
+6 -6
View File
@@ -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" -}}
+1 -2
View File
@@ -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 }}
+22 -10
View File
@@ -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.
*/ -}}
<template>
<PageContainer>
<ProTable :table="table" selection row-key="{{.PkJsonField}}">
@@ -112,9 +120,11 @@
{{- end}}
<template #toolbar>
{{- if $hasForm}}
<el-button v-permisaction="['{{.PackageName}}:{{.BusinessName}}:add']" type="primary" @click="form.openCreate()">
{{ "{{" }} $t('common.add') {{ "}}" }}
</el-button>
{{- end}}
<el-button
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:remove']"
type="danger"
@@ -156,14 +166,17 @@
{{- end}}
<template #actions="{ row }">
{{- if $hasForm}}
<el-button v-permisaction="['{{.PackageName}}:{{.BusinessName}}:edit']" link type="primary" @click="form.openEdit(row)">
{{ "{{" }} $t('common.edit') {{ "}}" }}
</el-button>
{{- end}}
<el-button v-permisaction="['{{.PackageName}}:{{.BusinessName}}:remove']" link type="danger" @click="remove(row.{{.PkJsonField}})">
{{ "{{" }} $t('common.delete') {{ "}}" }}
</el-button>
</template>
</ProTable>
{{- if $hasForm}}
<el-dialog
v-model="form.visible"
@@ -254,6 +267,7 @@
</el-button>
</template>
</el-dialog>
{{- end}}
</PageContainer>
</template>
@@ -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<FormRules>(() => ({ {{$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}},