diff --git a/app/other/apis/tools/column_width.go b/app/other/apis/tools/column_width.go new file mode 100644 index 00000000..efdf3c83 --- /dev/null +++ b/app/other/apis/tools/column_width.go @@ -0,0 +1,126 @@ +package tools + +import ( + "regexp" + "strconv" + "strings" + + "go-admin/app/other/models/tools" +) + +// columnLengthPattern pulls the first parenthesized integer out of a MySQL +// COLUMN_TYPE string - the "(255)" in "varchar(255)", the "(10" in +// "decimal(10,2)". Works regardless of trailing modifiers such as +// "unsigned" or a charset clause, since it only looks for the first digits +// after the first '('. +var columnLengthPattern = regexp.MustCompile(`\((\d+)`) + +// InferColumnWidth backs R2's fallback path: when a column's colWidth is +// left at its 0 sentinel (unconfigured), this reads sys_columns.column_type +// - MySQL's information_schema.COLUMNS.COLUMN_TYPE, which carries length, +// e.g. "varchar(255)", "int(11)", "decimal(10,2)", "tinyint(1)" - and +// returns a px width sized to fit inside go-admin-ui's ~580px text-column +// budget for a 1280px viewport (its AGENTS.md "列宽" section). +// +// The judgment has to be columnType, not goType: sys_tables.go:323-338 +// gives every non-primary-key int/tinyint/bigint/decimal column goType +// "string" (a bare substring match on "int" that also catches "tinyint"/ +// "bigint", intentional at import time but useless for telling a boolean +// flag from a bigint), so goType alone cannot distinguish a switch column +// from a price column from a name column. This is the same judgment call +// API契约.md §1.1 made, reversing the PRD's original "GoType" reading of R2. +// GoType is not consulted anywhere in this function, including for +// datetime/timestamp columns - those are matched on columnType too. +// +// Exported and pure (string in, int out) so QA can pin an exact input/output +// table against it directly (测试用例.md §2.5's own recommendation), rather +// than only being able to assert "the rendered page happens not to overflow". +func InferColumnWidth(columnType string) int { + ct := strings.ToLower(strings.TrimSpace(columnType)) + + switch { + case strings.HasPrefix(ct, "tinyint(1)"): + // MySQL's own shape for a boolean/status flag - a tag or a switch, + // not text, so it wants less room than a general numeric column. + return 70 + + case strings.Contains(ct, "datetime"), strings.Contains(ct, "timestamp"), + strings.Contains(ct, "date"), strings.Contains(ct, "time"): + return 110 + + case strings.HasPrefix(ct, "tinyint"), strings.HasPrefix(ct, "smallint"), + strings.HasPrefix(ct, "mediumint"), strings.HasPrefix(ct, "int"), + strings.HasPrefix(ct, "bigint"), strings.HasPrefix(ct, "decimal"), + strings.HasPrefix(ct, "float"), strings.HasPrefix(ct, "double"): + // API契约.md §1.1: "decimal/bigint/int 类给数字型窄宽度" groups these + // together rather than sizing each individually - none of them need + // more than a handful of digits' worth of width. + return 90 + + case strings.HasPrefix(ct, "varchar"), strings.HasPrefix(ct, "char"): + return varcharWidth(columnLength(ct)) + + case strings.Contains(ct, "text"), strings.Contains(ct, "blob"): + // longtext/mediumtext/text/blob: no declared length to size against, + // and content here is free-form, so this errs wide rather than + // guessing a number the actual content will not respect. + return 260 + + default: + // Unrecognized column_type (an enum, a json column, a driver this + // codebase does not special-case, ...). Matches the flat fallback + // vue.go.template already used for every non-datetime column before + // this function existed, so a type this does not recognize is no + // worse off than the old blanket default. + return 120 + } +} + +// varcharWidth tiers a char/varchar column by its declared length. The +// tiers are deliberately coarse - R2 only asks for "common tables land in +// the 580px budget", not pixel-perfect sizing per character. +func varcharWidth(n int) int { + switch { + case n <= 0: + // Length did not parse (unexpected shape) - mid tier, not the + // narrowest, since an un-lengthed varchar is unlikely to be a + // short code column. + return 150 + case n <= 10: + return 90 + case n <= 20: + return 110 + case n <= 50: + return 150 + case n <= 100: + return 200 + default: + return 240 + } +} + +// columnLength extracts the first parenthesized integer, or 0 if the type +// string does not have one (already-lowercased input expected). +func columnLength(columnType string) int { + m := columnLengthPattern.FindStringSubmatch(columnType) + if m == nil { + return 0 + } + n, err := strconv.Atoi(m[1]) + if err != nil { + return 0 + } + return n +} + +// applyInferredColumnWidths fills in InferColumnWidth's result for every +// column still at the 0 "unconfigured" sentinel, in place, before the +// template that reads .ColWidth runs. A column the user (or F6's config +// page) already gave an explicit width is left untouched. +func applyInferredColumnWidths(columns []tools.SysColumns) { + for i := range columns { + if columns[i].ColWidth == 0 { + columns[i].ColWidth = InferColumnWidth(columns[i].ColumnType) + } + } +} diff --git a/app/other/apis/tools/column_width_test.go b/app/other/apis/tools/column_width_test.go new file mode 100644 index 00000000..0ebf3ea4 --- /dev/null +++ b/app/other/apis/tools/column_width_test.go @@ -0,0 +1,80 @@ +package tools + +import ( + "testing" + + "go-admin/app/other/models/tools" +) + +// Input/output pins for InferColumnWidth, per 测试用例.md §2.5's own +// recommendation ("QA 才能在阶段 4 补一张精确的输入→输出对照表断言, 而不是只测 +// 结果凑巧没溢出这种弱结论") - this is that table, kept next to the function +// it pins rather than only living in a later QA-owned suite. +func TestInferColumnWidth(t *testing.T) { + cases := []struct { + name string + columnType string + want int + }{ + {"boolean/status flag", "tinyint(1)", 70}, + {"boolean flag, case-insensitive", "TINYINT(1)", 70}, + {"datetime", "datetime", 110}, + {"timestamp", "timestamp", 110}, + {"date only", "date", 110}, + {"time only", "time", 110}, + + {"plain tinyint (not the (1) boolean shape)", "tinyint(4)", 90}, + {"smallint", "smallint(6)", 90}, + {"mediumint", "mediumint(9)", 90}, + {"int", "int(11)", 90}, + {"bigint", "bigint(20)", 90}, + {"decimal", "decimal(10,2)", 90}, + {"float", "float", 90}, + {"double", "double", 90}, + + {"varchar short code", "varchar(8)", 90}, + {"varchar at the 10 boundary", "varchar(10)", 90}, + {"varchar just past the 10 boundary", "varchar(11)", 110}, + {"varchar at the 20 boundary", "varchar(20)", 110}, + {"varchar mid length", "varchar(32)", 150}, + {"varchar at the 50 boundary", "varchar(50)", 150}, + {"varchar just past the 50 boundary", "varchar(51)", 200}, + {"varchar(255), the common default", "varchar(255)", 240}, + {"char, fixed-width", "char(2)", 90}, + {"varchar with no parsed length", "varchar", 150}, + + {"text, no length to size against", "text", 260}, + {"longtext", "longtext", 260}, + {"mediumtext", "mediumtext", 260}, + {"blob", "blob", 260}, + + {"unrecognized type falls back to the old flat default", "json", 120}, + {"empty column_type falls back to the old flat default", "", 120}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := InferColumnWidth(tc.columnType); got != tc.want { + t.Errorf("InferColumnWidth(%q) = %d, want %d", tc.columnType, got, tc.want) + } + }) + } +} + +func TestApplyInferredColumnWidths(t *testing.T) { + columns := []tools.SysColumns{ + {JsonField: "name", ColumnType: "varchar(64)", ColWidth: 0}, + {JsonField: "price", ColumnType: "decimal(10,2)", ColWidth: 300}, // already configured + } + + applyInferredColumnWidths(columns) + + if columns[0].ColWidth == 0 { + t.Error("unconfigured column: want an inferred non-zero width, still 0") + } + if want := InferColumnWidth("varchar(64)"); columns[0].ColWidth != want { + t.Errorf("unconfigured column: want %d (InferColumnWidth's own answer), got %d", want, columns[0].ColWidth) + } + if columns[1].ColWidth != 300 { + t.Errorf("already-configured column: want the user's 300 left untouched, got %d", columns[1].ColWidth) + } +} diff --git a/app/other/apis/tools/gen.go b/app/other/apis/tools/gen.go index 7fb38168..02701620 100644 --- a/app/other/apis/tools/gen.go +++ b/app/other/apis/tools/gen.go @@ -22,6 +22,29 @@ type Gen struct { api.Api } +// genLangFuncs backs the lang-zh/lang-en templates (PRD 010 F3/F9). The +// generated files are TypeScript, and go-admin-ui's eslint config requires +// single-quoted strings with no trailing comma (@stylistic/quotes, +// @stylistic/comma-dangle: never) - text/template's builtin `printf "%q"` +// only produces Go/JSON-style double-quoted output, so this supplies a +// single-quote equivalent instead of leaning on the builtin. +var genLangFuncs = template.FuncMap{ + "singleQuote": func(s string) string { + r := strings.NewReplacer(`\`, `\\`, `'`, `\'`, "\n", `\n`, "\r", `\r`) + return "'" + r.Replace(s) + "'" + }, +} + +// parseGenTemplate is template.ParseFiles plus genLangFuncs, for the two +// language-pack templates. template.New's name must match the file's base +// name - ParseFiles reuses the template already registered under that name +// instead of creating an unnamed second one, which is what makes Execute +// find the parsed content afterwards. +func parseGenTemplate(path string) (*template.Template, error) { + base := path[strings.LastIndex(path, "/")+1:] + return template.New(base).Funcs(genLangFuncs).ParseFiles(path) +} + func (e Gen) Preview(c *gin.Context) { e.Context = c log := e.GetLogger() @@ -45,10 +68,10 @@ func (e Gen) Preview(c *gin.Context) { e.Error(500, err, fmt.Sprintf("api模版读取失败!错误详情:%s", err.Error())) return } - t3, err := template.ParseFiles("template/v4/js.go.template") + t3, err := template.ParseFiles("template/v4/ts.go.template") if err != nil { log.Error(err) - e.Error(500, err, fmt.Sprintf("js模版读取失败!错误详情:%s", err.Error())) + e.Error(500, err, fmt.Sprintf("ts模版读取失败!错误详情:%s", err.Error())) return } t4, err := template.ParseFiles("template/v4/vue.go.template") @@ -75,6 +98,22 @@ func (e Gen) Preview(c *gin.Context) { e.Error(500, err, fmt.Sprintf("service模版读取失败!错误详情:%s", err.Error())) return } + // t8/t9 back F3/F9 (PRD 010): one language pack per locale, nested under + // gen/{PackageName}/{BusinessName}.ts by NOActionsGen below so go-admin-ui's + // gen-namespace.ts glob (`./*/*.ts` under each locale's gen/) picks them up. + // See docs-prd/010-代码生成器前端模板迁移Vue3/API契约.md §2.3. + t8, err := parseGenTemplate("template/v4/lang-zh.go.template") + if err != nil { + log.Error(err) + e.Error(500, err, fmt.Sprintf("zh语言包模版读取失败!错误详情:%s", err.Error())) + return + } + t9, err := parseGenTemplate("template/v4/lang-en.go.template") + if err != nil { + log.Error(err) + e.Error(500, err, fmt.Sprintf("en语言包模版读取失败!错误详情:%s", err.Error())) + return + } db, err := pkg.GetOrm(c) if err != nil { @@ -84,6 +123,17 @@ func (e Gen) Preview(c *gin.Context) { } tab, _ := table.Get(db, false) + // MLTBName (table_name with underscores turned to dashes) is a gorm:"-" + // field - table.Get never fills it in, so every template that reads it + // (the .vue/.ts import paths, e.g. "@/api/{PackageName}/{MLTBName}") + // silently rendered it empty here. NOActionsGen has set this since it + // existed (see below); Preview never did, which is why the two paths + // are not interchangeable stand-ins for each other and should not be + // assumed to be. + tab.MLTBName = strings.Replace(tab.TBName, "_", "-", -1) + // 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) var b1 bytes.Buffer err = t1.Execute(&b1, tab) var b2 bytes.Buffer @@ -98,15 +148,21 @@ func (e Gen) Preview(c *gin.Context) { err = t6.Execute(&b6, tab) var b7 bytes.Buffer err = t7.Execute(&b7, tab) + var b8 bytes.Buffer + err = t8.Execute(&b8, tab) + var b9 bytes.Buffer + err = t9.Execute(&b9, tab) mp := make(map[string]interface{}) mp["template/model.go.template"] = b1.String() mp["template/api.go.template"] = b2.String() - mp["template/js.go.template"] = b3.String() + mp["template/api.ts.template"] = b3.String() mp["template/vue.go.template"] = b4.String() mp["template/router.go.template"] = b5.String() mp["template/dto.go.template"] = b6.String() mp["template/service.go.template"] = b7.String() + mp["template/lang-zh.go.template"] = b8.String() + mp["template/lang-en.go.template"] = b9.String() e.OK(mp, "") } @@ -165,6 +221,8 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) { e.Context = c log := e.GetLogger() tab.MLTBName = strings.Replace(tab.TBName, "_", "-", -1) + // R2: see the matching call and comment in Preview above. + applyInferredColumnWidths(tab.Columns) basePath := "template/v4/" routerFile := basePath + "no_actions/router_check_role.go.template" @@ -191,10 +249,10 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) { e.Error(500, err, fmt.Sprintf("路由模版失败!错误详情:%s", err.Error())) return } - t4, err := template.ParseFiles(basePath + "js.go.template") + t4, err := template.ParseFiles(basePath + "ts.go.template") if err != nil { log.Error(err) - e.Error(500, err, fmt.Sprintf("js模版解析失败!错误详情:%s", err.Error())) + e.Error(500, err, fmt.Sprintf("ts模版解析失败!错误详情:%s", err.Error())) return } t5, err := template.ParseFiles(basePath + "vue.go.template") @@ -215,6 +273,19 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) { e.Error(500, err, fmt.Sprintf("service模版失败!错误详情:%s", err.Error())) return } + // t8/t9 back F3/F9 (PRD 010): see the matching comment in Preview above. + t8, err := parseGenTemplate(basePath + "lang-zh.go.template") + if err != nil { + log.Error(err) + e.Error(500, err, fmt.Sprintf("zh语言包模版解析失败!错误详情:%s", err.Error())) + return + } + t9, err := parseGenTemplate(basePath + "lang-en.go.template") + if err != nil { + log.Error(err) + e.Error(500, err, fmt.Sprintf("en语言包模版解析失败!错误详情:%s", err.Error())) + return + } _ = pkg.PathCreate("./app/" + tab.PackageName + "/apis/") _ = pkg.PathCreate("./app/" + tab.PackageName + "/models/") @@ -227,6 +298,23 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) { e.Error(500, err, fmt.Sprintf("views目录创建失败!错误详情:%s", err.Error())) return } + // gen/{PackageName}/ nests under each locale so go-admin-ui's + // gen-namespace.ts (`./*/*.ts` glob, one level under gen/) picks the file + // up - a flat gen/{BusinessName}.ts would let two tables in different + // packages silently overwrite each other's translations, since + // BusinessName only has a pattern check, no uniqueness check. + err = pkg.PathCreate(config.GenConfig.FrontPath + "/lang/zh-CN/gen/" + tab.PackageName + "/") + if err != nil { + log.Error(err) + e.Error(500, err, fmt.Sprintf("zh语言包目录创建失败!错误详情:%s", err.Error())) + return + } + err = pkg.PathCreate(config.GenConfig.FrontPath + "/lang/en-US/gen/" + tab.PackageName + "/") + if err != nil { + log.Error(err) + e.Error(500, err, fmt.Sprintf("en语言包目录创建失败!错误详情:%s", err.Error())) + return + } var b1 bytes.Buffer err = t1.Execute(&b1, tab) @@ -242,13 +330,19 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) { err = t6.Execute(&b6, tab) var b7 bytes.Buffer err = t7.Execute(&b7, tab) + var b8 bytes.Buffer + err = t8.Execute(&b8, tab) + var b9 bytes.Buffer + err = t9.Execute(&b9, tab) pkg.FileCreate(b1, "./app/"+tab.PackageName+"/models/"+tab.TBName+".go") pkg.FileCreate(b2, "./app/"+tab.PackageName+"/apis/"+tab.TBName+".go") pkg.FileCreate(b3, "./app/"+tab.PackageName+"/router/"+tab.TBName+".go") - pkg.FileCreate(b4, config.GenConfig.FrontPath+"/api/"+tab.PackageName+"/"+tab.MLTBName+".js") + pkg.FileCreate(b4, config.GenConfig.FrontPath+"/api/"+tab.PackageName+"/"+tab.MLTBName+".ts") pkg.FileCreate(b5, config.GenConfig.FrontPath+"/views/"+tab.PackageName+"/"+tab.MLTBName+"/index.vue") pkg.FileCreate(b6, "./app/"+tab.PackageName+"/service/dto/"+tab.TBName+".go") pkg.FileCreate(b7, "./app/"+tab.PackageName+"/service/"+tab.TBName+".go") + pkg.FileCreate(b8, config.GenConfig.FrontPath+"/lang/zh-CN/gen/"+tab.PackageName+"/"+tab.BusinessName+".ts") + pkg.FileCreate(b9, config.GenConfig.FrontPath+"/lang/en-US/gen/"+tab.PackageName+"/"+tab.BusinessName+".ts") } diff --git a/app/other/apis/tools/sys_tables.go b/app/other/apis/tools/sys_tables.go index 734f3d13..e8c84fea 100644 --- a/app/other/apis/tools/sys_tables.go +++ b/app/other/apis/tools/sys_tables.go @@ -368,6 +368,21 @@ func (e SysTable) Update(c *gin.Context) { return } + // PRD 010 F10: this bind-and-save path has no field-level validation of + // its own (API契约.md §1.2/§2.1, D6) - see sys_tables_validate.go for + // what each check guards and why colWidth is sanitized in place rather + // than rejected. + if err = validateAndSanitizeColumns(data.Columns); err != nil { + log.Errorf("validate columns error, %s", err.Error()) + e.Error(500, err, err.Error()) + return + } + if err = validateBusinessNameUnique(db, data.PackageName, data.BusinessName, data.TableId); err != nil { + log.Errorf("validate businessName error, %s", err.Error()) + e.Error(500, err, err.Error()) + return + } + data.UpdateBy = 0 result, err := data.Update(db) if err != nil { diff --git a/app/other/apis/tools/sys_tables_validate.go b/app/other/apis/tools/sys_tables_validate.go new file mode 100644 index 00000000..067f6aaa --- /dev/null +++ b/app/other/apis/tools/sys_tables_validate.go @@ -0,0 +1,112 @@ +package tools + +import ( + "fmt" + "regexp" + "strings" + + "gorm.io/gorm" + + "go-admin/app/other/models/tools" +) + +// jsonFieldPattern accepts any legal JS/TS identifier that starts with a +// lowercase letter - not businessName's rule. +// +// This used to be businessName's own pattern (^[a-z][A-Za-z]+$, requiring at +// least two letters and no digits), copied over on the theory that jsonField +// "should tighten to the same identifier shape". That theory does not hold: +// businessName is typed by a person on genInfoForm.vue, so a strict pattern +// is a reasonable guardrail on human input. jsonField is computed by the +// importer from the column name (sys_tables.go's namelist/JsonField loop) - +// nobody types it, so the same pattern only rejects names the importer +// legitimately produces. A one-letter column ("x") or a column ending in a +// digit ("address2", "a1") both import to a single camelCase word with no +// separators to re-capitalize, and both used to fail this check - meaning a +// table that merely contained such a column could never save any config +// again, unrelated columns included, since this check runs over every +// column on every Update. +// +// What still has to be rejected is a jsonField that cannot be a raw object +// key at all: empty, containing whitespace/punctuation, or leading with a +// digit (`2faEnabled: 1` is not valid JS - identifiers cannot start with a +// digit, and this is what lands as the property name in gen.go's generated +// interface / lang file, both unquoted). Hence still anchoring on a +// lowercase letter first, but no longer requiring a second character or +// forbidding digits after it. +var jsonFieldPattern = regexp.MustCompile(`^[a-z][A-Za-z0-9]*$`) + +// colWidthMin/colWidthMax are API契约.md §2.1's suggested range for colWidth. +const ( + colWidthMin = 40 + colWidthMax = 800 +) + +// expressionMarkers flags the "meant to be evaluated" shapes API契约.md §2.1 +// says defaultValue must not carry: it is spliced into the generated +// defaultModel() as a literal and never evaluated, so anything that looks +// like a function call or a block is rejected outright rather than +// generating code that silently does nothing. +var expressionMarkers = []string{"(", ")", "{", "}", "`", ";", "=>"} + +// validateAndSanitizeColumns enforces PRD 010 F10 on the columns carried by +// a table update (sys_tables.go:357's Update handler, the one bind-and-save +// path with no field-level validation at all - see API契约.md §1.2/§2.1, +// decision D6). +// +// jsonField and defaultValue problems reject the request outright: letting +// either through would corrupt the generated i18n file silently (a +// duplicate or malformed jsonField becomes a duplicate or invalid key in +// gen/{PackageName}/{BusinessName}.ts, see the lang-zh/lang-en templates). +// An out-of-range colWidth does not reject - §2.1 says it "falls back to +// the inferred value", so this resets it to the 0 sentinel in place and lets +// R2's inference take over, the same as if the field had never been set. +func validateAndSanitizeColumns(columns []tools.SysColumns) error { + seen := make(map[string]bool, len(columns)) + for i := range columns { + col := &columns[i] + + if !jsonFieldPattern.MatchString(col.JsonField) { + return fmt.Errorf("jsonField 格式不合法:%q,须以小写字母开头且只能包含英文字母", col.JsonField) + } + if seen[col.JsonField] { + return fmt.Errorf("jsonField 在同一张表内重复:%q", col.JsonField) + } + seen[col.JsonField] = true + + if col.ColWidth != 0 && (col.ColWidth < colWidthMin || col.ColWidth > colWidthMax) { + col.ColWidth = 0 + } + + for _, marker := range expressionMarkers { + if strings.Contains(col.DefaultValue, marker) { + return fmt.Errorf("defaultValue 不允许包含表达式或函数调用内容:%q", col.DefaultValue) + } + } + } + return nil +} + +// validateBusinessNameUnique enforces PRD 010 F10's other half: two tables +// sharing (packageName, businessName) write the same generated language +// pack path, gen/{PackageName}/{BusinessName}.ts (see gen.go's +// NOActionsGen), so the second one silently overwrites the first's +// translations. tableID excludes the row being saved, so a table updating +// its own unchanged name does not trip the check on itself. +// +// G10's other concern - colliding with the built-in admin/* i18n namespace - +// does not apply here anymore: D9 moved generated keys to their own gen/ +// namespace, so this only has to guard generated tables against each other. +func validateBusinessNameUnique(db *gorm.DB, packageName, businessName string, tableID int) error { + var count int64 + err := db.Table("sys_tables"). + Where("package_name = ? AND business_name = ? AND table_id != ?", packageName, businessName, tableID). + Count(&count).Error + if err != nil { + return err + } + if count > 0 { + return fmt.Errorf("packageName=%q 下 businessName=%q 已被其它表使用", packageName, businessName) + } + return nil +} diff --git a/app/other/apis/tools/sys_tables_validate_test.go b/app/other/apis/tools/sys_tables_validate_test.go new file mode 100644 index 00000000..0d80643e --- /dev/null +++ b/app/other/apis/tools/sys_tables_validate_test.go @@ -0,0 +1,157 @@ +package tools + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "go-admin/app/other/models/tools" +) + +func TestValidateAndSanitizeColumns_JsonFieldFormat(t *testing.T) { + cases := []struct { + name string + jsonField string + wantErr bool + }{ + {"lower camelCase", "userName", false}, + {"two-letter lowercase", "id", false}, + // The importer's own output (sys_tables.go's namelist/JsonField + // loop), not made up: a single-letter column ("x"), and a column + // whose last name segment ends in a digit ("address2", "a1") both + // produce a jsonField with no separator left to re-capitalize. + // These three used to be rejected - the whole point of this fix. + {"single letter, real importer output for a column named x", "x", false}, + {"letters then a trailing digit, real importer output for address2", "address2", false}, + {"two letters then a digit, real importer output for a1", "a1", false}, + {"leading underscore rejected", "_id", true}, + {"leading digit rejected (not a legal identifier start)", "1name", true}, + {"snake_case rejected (importer never emits an underscore)", "user_name", true}, + {"dot rejected, would break the gen/{pkg}/{biz}.ts key path", "user.name", true}, + {"empty rejected", "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateAndSanitizeColumns([]tools.SysColumns{{JsonField: tc.jsonField}}) + if tc.wantErr && err == nil { + t.Errorf("jsonField %q: want error, got nil", tc.jsonField) + } + if !tc.wantErr && err != nil { + t.Errorf("jsonField %q: want no error, got %v", tc.jsonField, err) + } + }) + } +} + +func TestValidateAndSanitizeColumns_JsonFieldUniqueWithinTable(t *testing.T) { + err := validateAndSanitizeColumns([]tools.SysColumns{ + {JsonField: "name"}, + {JsonField: "name"}, + }) + if err == nil { + t.Fatal("want error for a jsonField repeated in the same table, got nil") + } +} + +func TestValidateAndSanitizeColumns_ColWidthOutOfRangeIsSanitizedNotRejected(t *testing.T) { + cases := []struct { + name string + width int + want int + }{ + {"zero (unconfigured) is left alone", 0, 0}, + {"in range is left alone", 150, 150}, + {"lower bound is left alone", colWidthMin, colWidthMin}, + {"upper bound is left alone", colWidthMax, colWidthMax}, + {"too small falls back to the sentinel", colWidthMin - 1, 0}, + {"too large falls back to the sentinel", colWidthMax + 1, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cols := []tools.SysColumns{{JsonField: "name", ColWidth: tc.width}} + if err := validateAndSanitizeColumns(cols); err != nil { + t.Fatalf("colWidth %d: want no error (out-of-range sanitizes, it does not reject), got %v", tc.width, err) + } + if cols[0].ColWidth != tc.want { + t.Errorf("colWidth %d: want sanitized to %d, got %d", tc.width, tc.want, cols[0].ColWidth) + } + }) + } +} + +func TestValidateAndSanitizeColumns_DefaultValueExpressionRejected(t *testing.T) { + cases := []struct { + name string + defaultValue string + wantErr bool + }{ + {"plain literal", "0", false}, + {"plain string literal", "active", false}, + {"empty (unconfigured)", "", false}, + {"function call rejected", "Date.now()", true}, + {"template literal rejected", "`x`", true}, + {"arrow function rejected", "() => 1", true}, + {"statement separator rejected", "1; drop", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateAndSanitizeColumns([]tools.SysColumns{{JsonField: "name", DefaultValue: tc.defaultValue}}) + if tc.wantErr && err == nil { + t.Errorf("defaultValue %q: want error, got nil", tc.defaultValue) + } + if !tc.wantErr && err != nil { + t.Errorf("defaultValue %q: want no error, got %v", tc.defaultValue, err) + } + }) + } +} + +func newBusinessNameTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(new(tools.SysTables)); err != nil { + t.Fatalf("migrate sys_tables: %v", err) + } + return db +} + +func TestValidateBusinessNameUnique(t *testing.T) { + db := newBusinessNameTestDB(t) + + existing := tools.SysTables{TBName: "sys_widget", PackageName: "biz", BusinessName: "widget"} + if err := db.Table("sys_tables").Create(&existing).Error; err != nil { + t.Fatalf("seed: %v", err) + } + + t.Run("same package, same businessName, different table: rejected", func(t *testing.T) { + other := tools.SysTables{TBName: "sys_widget_copy", PackageName: "biz", BusinessName: "widget"} + if err := db.Table("sys_tables").Create(&other).Error; err != nil { + t.Fatalf("seed second row: %v", err) + } + // Unscoped: a plain Delete only soft-deletes (SysTables carries + // common.ModelTime), which would leave this row's businessName + // looking taken for the next subtest - production's own delete path + // (SysTables.BatchDelete) hard-deletes for the same reason. + defer db.Table("sys_tables").Unscoped().Delete(&other) + + if err := validateBusinessNameUnique(db, "biz", "widget", other.TableId); err == nil { + t.Error("want error for a businessName already used by another table in the same package, got nil") + } + }) + + t.Run("different package, same businessName: allowed", func(t *testing.T) { + if err := validateBusinessNameUnique(db, "other-pkg", "widget", 0); err != nil { + t.Errorf("want no error across different packages, got %v", err) + } + }) + + t.Run("a table checking against its own current name: allowed", func(t *testing.T) { + if err := validateBusinessNameUnique(db, "biz", "widget", existing.TableId); err != nil { + t.Errorf("want no error when the only match is the row being saved itself, got %v", err) + } + }) +} diff --git a/app/other/models/tools/sys_columns.go b/app/other/models/tools/sys_columns.go index ff6b8ddf..e901cbfa 100644 --- a/app/other/models/tools/sys_columns.go +++ b/app/other/models/tools/sys_columns.go @@ -45,6 +45,19 @@ type SysColumns struct { CreateBy int `gorm:"column:create_by;size:20;" json:"createBy"` UpdateBy int `gorm:"column:update_By;size:20;" json:"updateBy"` + // ColWidth and DefaultValue back PRD 010 F1/F2 (代码生成器前端模板迁移 Vue 3). + // Both use a sentinel default (0 / "") rather than NULL - see + // docs-prd/010-代码生成器前端模板迁移Vue3/数据库变更.md §1.1: a non-pointer + // int/string field can never read NULL back out, and NULL would give + // "unconfigured" two representations instead of one. Callers test + // ColWidth == 0 / DefaultValue == "" to detect "not configured". + // + // ColWidth deliberately has no gorm size tag: this codebase's "size:N" + // convention on numeric fields maps to a narrow SQL integer type (see + // column_width_test.go), and col_width needs to hold values up to 800. + ColWidth int `gorm:"column:col_width;not null;default:0;comment:table column width in px, 0 = not configured" json:"colWidth"` + DefaultValue string `gorm:"column:default_value;size:255;not null;default:'';comment:form field default value, empty = not configured" json:"defaultValue"` + common.ModelTime } @@ -97,5 +110,23 @@ func (e *SysColumns) Update(tx *gorm.DB) (update SysColumns, err error) { return } + // Updates(&e) above skips zero-value fields (GORM's struct-form Updates + // always does), but ColWidth/DefaultValue's own "unconfigured" sentinel + // is 0/"" (see the field comments on SysColumns) - so clearing either one + // back to its sentinel is indistinguishable, to a struct-form Updates, + // from "the caller didn't touch this field" and silently does not get + // written. A map-form Updates does not skip zero values, so it is used + // here for just these two columns rather than widening this to + // Select("*") (which would also start writing every other zero-valued + // field on this struct - Sort, the Pk/Required/... bools - and that is a + // pre-existing gap in this method affecting fields outside PRD 010's + // scope, not fixed here). + if err = tx.Table("sys_columns").Model(&update).Updates(map[string]interface{}{ + "col_width": e.ColWidth, + "default_value": e.DefaultValue, + }).Error; err != nil { + return + } + return } diff --git a/app/other/models/tools/sys_columns_test.go b/app/other/models/tools/sys_columns_test.go new file mode 100644 index 00000000..121a6cbf --- /dev/null +++ b/app/other/models/tools/sys_columns_test.go @@ -0,0 +1,46 @@ +package tools + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +// GORM's Updates(struct) skips zero-value fields, and PRD 010 F1/F2 chose 0 / +// "" as the sentinel for "unconfigured" (docs-prd/010-代码生成器前端模板迁移Vue3/ +// 数据库变更.md §1.1). Put those together and Update can set ColWidth/ +// DefaultValue but never clear them back to the sentinel: the struct-form +// Updates call silently drops the very values this feature needs to write. +func TestSysColumnsUpdateClearsSentinelFields(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(new(SysColumns)); err != nil { + t.Fatalf("migrate sys_columns: %v", err) + } + + col := SysColumns{TableId: 1, ColumnName: "status", ColWidth: 150, DefaultValue: "active"} + if _, err := col.Create(db); err != nil { + t.Fatalf("create: %v", err) + } + + // Reset back to the sentinel - the UI action for "go back to inferred + // width / no default", not merely "never configured". + update := SysColumns{ColumnId: col.ColumnId, ColWidth: 0, DefaultValue: ""} + if _, err := update.Update(db); err != nil { + t.Fatalf("update: %v", err) + } + + var got SysColumns + if err := db.Table("sys_columns").First(&got, col.ColumnId).Error; err != nil { + t.Fatalf("read back: %v", err) + } + if got.ColWidth != 0 { + t.Errorf("colWidth: want 0 (cleared), got %d - Update() did not write the sentinel back", got.ColWidth) + } + if got.DefaultValue != "" { + t.Errorf("defaultValue: want \"\" (cleared), got %q - Update() did not write the sentinel back", got.DefaultValue) + } +} diff --git a/cmd/migrate/migration/version/1786700010000_gen_column_layout_fields.go b/cmd/migrate/migration/version/1786700010000_gen_column_layout_fields.go new file mode 100644 index 00000000..28b81121 --- /dev/null +++ b/cmd/migrate/migration/version/1786700010000_gen_column_layout_fields.go @@ -0,0 +1,56 @@ +package version + +import ( + "runtime" + + "gorm.io/gorm" + + "go-admin/app/other/models/tools" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" +) + +// Add sys_columns.col_width and sys_columns.default_value for PRD 010 F1/F2 +// (代码生成器前端模板迁移 Vue 3). +// +// col_width backs R2's column-width inference fallback and default_value +// backs R1/A6's "unconfigured rows still generate a usable page" guarantee - +// see docs-prd/010-代码生成器前端模板迁移Vue3/数据库变更.md §1.1 for why both +// defaults are sentinels (0 / "") rather than NULL: a non-pointer Go int/ +// string field can never read NULL back out, and NULL would give +// "unconfigured" two representations instead of one. +// +// Ordered after 1786700003000, so this reads tools.SysColumns (the runtime +// model sys_columns's Update/GetPage/GetSysTablesInfo actually query through) +// rather than cmd/migrate/migration/models, matching every migration in this +// directory since sys_columns was converted - see +// 1786700004000_generator_tables_marker.go and schema_coverage_test.go's +// TestPostConversionMigrationsAvoidFrozenSeedModels. +// +// Hard prerequisite: tools.SysColumns must already declare ColWidth and +// DefaultValue (with the gorm tags in the doc above) by the time this file +// is compiled - AddColumn reads the column definition off the struct's own +// tag, not off anything in this file. Landing this migration without that +// model change first makes HasColumn/AddColumn silently do nothing (the +// field lookup fails and AddColumn returns an error naming the missing +// field), which fails loudly rather than silently - see the "no such field" +// error - so this is caught at migrate time, not left for a report later. +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700010000GenColumnLayoutFields) +} + +func _1786700010000GenColumnLayoutFields(db *gorm.DB, version string) error { + m := db.Migrator() + if !m.HasColumn(&tools.SysColumns{}, "ColWidth") { + if err := m.AddColumn(&tools.SysColumns{}, "ColWidth"); err != nil { + return err + } + } + if !m.HasColumn(&tools.SysColumns{}, "DefaultValue") { + if err := m.AddColumn(&tools.SysColumns{}, "DefaultValue"); err != nil { + return err + } + } + return db.Create(&common.Migration{Version: version}).Error +} diff --git a/template/v4/js.go.template b/template/v4/js.go.template deleted file mode 100644 index db3d4a27..00000000 --- a/template/v4/js.go.template +++ /dev/null @@ -1,47 +0,0 @@ -import request from '@/utils/request' - -// 查询{{.ClassName}}列表 -export function list{{.ClassName}}(query) { - return request({ - url: '/api/v1/{{.ModuleName}}', - method: 'get', - params: query - }) -} - -// 查询{{.ClassName}}详细 -export function get{{.ClassName}} ({{.PkJsonField}}) { - return request({ - url: '/api/v1/{{.ModuleName}}/' + {{.PkJsonField}}, - method: 'get' - }) -} - - -// 新增{{.ClassName}} -export function add{{.ClassName}}(data) { - return request({ - url: '/api/v1/{{.ModuleName}}', - method: 'post', - data: data - }) -} - -// 修改{{.ClassName}} -export function update{{.ClassName}}(data) { - return request({ - url: '/api/v1/{{.ModuleName}}/'+data.{{.PkJsonField}}, - method: 'put', - data: data - }) -} - -// 删除{{.ClassName}} -export function del{{.ClassName}}(data) { - return request({ - url: '/api/v1/{{.ModuleName}}', - method: 'delete', - data: data - }) -} - diff --git a/template/v4/lang-en.go.template b/template/v4/lang-en.go.template new file mode 100644 index 00000000..2d3ff67b --- /dev/null +++ b/template/v4/lang-en.go.template @@ -0,0 +1,6 @@ +export default { +{{- range $i, $col := .Columns}} +{{- if $i}},{{end}} + {{$col.JsonField}}: {{if $col.ColumnComment}}{{singleQuote $col.ColumnComment}}{{else}}{{singleQuote $col.JsonField}}{{end}} +{{- end}} +} diff --git a/template/v4/lang-zh.go.template b/template/v4/lang-zh.go.template new file mode 100644 index 00000000..2d3ff67b --- /dev/null +++ b/template/v4/lang-zh.go.template @@ -0,0 +1,6 @@ +export default { +{{- range $i, $col := .Columns}} +{{- if $i}},{{end}} + {{$col.JsonField}}: {{if $col.ColumnComment}}{{singleQuote $col.ColumnComment}}{{else}}{{singleQuote $col.JsonField}}{{end}} +{{- end}} +} diff --git a/template/v4/ts.go.template b/template/v4/ts.go.template new file mode 100644 index 00000000..d5e70a0f --- /dev/null +++ b/template/v4/ts.go.template @@ -0,0 +1,125 @@ +{{- /* + $pkType: the primary key's TS type for get{ClassName}'s parameter. + Defaults to "number" - true for every column but string primary keys + (natural keys), which do exist (sys_tables.go:323-338 gives a primary + key column GoType "string" whenever its ColumnType is not int-shaped). + Matches vue.go.template's own $pkType derivation exactly (F4) - useForm + there is typed on the same column, and a mismatch between the two is a + TS compile error at the call site, not a runtime bug. +*/ -}} +{{- $pkType := "number" -}} +{{- $hasQuery := false -}} +{{- range .Columns -}} + {{- if and .Pk (eq .GoType "string") }}{{$pkType = "string"}}{{end -}} + {{- if eq .IsQuery "1" }}{{$hasQuery = true}}{{end -}} +{{- end -}} +import request from '@/utils/request' +import type { ApiResponse, PageQuery, PageResult, Id } from '@/types/api' + +export interface {{.ClassName}} { +{{- range .Columns}} + {{.JsonField}}?: {{if eq .GoType "int" -}} + number + {{- else if eq .GoType "int64" -}} + number + {{- else if eq .GoType "float32" -}} + number + {{- else if eq .GoType "float64" -}} + number + {{- else -}} + string + {{- end}} +{{- end}} +} + +{{if $hasQuery -}} +export interface {{.ClassName}}Query { +{{- range .Columns}} +{{- if eq .IsQuery "1"}} + {{.JsonField}}?: {{if eq .GoType "int" -}} + number + {{- else if eq .GoType "int64" -}} + number + {{- else if eq .GoType "float32" -}} + number + {{- else if eq .GoType "float64" -}} + number + {{- else -}} + string + {{- end}} +{{- end}} +{{- end}} +} +{{- else -}} +{{- /* + No column is marked IsQuery - a plain display table with no search form + is a normal shape, not an edge case, so this still has to produce a type + useTable/list{ClassName}(query: Query & PageQuery) can use. + `export interface {ClassName}Query {}` is what naturally falls out of the + range above finding nothing to iterate, but an empty interface trips + @typescript-eslint/no-empty-object-type and fails pnpm lint. + + Record (this file's first attempt, and the type + useTable.ts's own `TQuery extends object = Record` default + uses) looks like the obvious match but is wrong here: it is a mapped type + over *every* string key, each mapped to never, so intersecting it with + PageQuery does not leave PageQuery alone - `pageIndex` becomes + `never & number`, i.e. never, and no value can be passed for it at all. + useTable.ts itself never hits this because its one internal use of + `TQuery & PageQuery` goes through an `as` cast rather than a structural + check (composables/useTable.ts ~line 160); code that builds the object + literal directly - such as a foreign-key column's + `list{FkClass}({ pageIndex: 1, pageSize: 100 })` call in vue.go.template - + is not casting anything and hits the real error, only when the referenced + table happens to have no query columns of its own (a plain lookup/dict + table used as a dropdown source, not a rare shape). + + Record is the type with the same intent - "no query + columns" - but the mapped-type domain is `never`, so it has no keys at + all rather than "every key, mapped to never": it behaves as the empty + object type `{}` under intersection, leaving PageQuery's own pageIndex/ + pageSize untouched, and confirmed separately not to trip + no-empty-object-type either (it is a generic instantiation, not a + literal `{}` type annotation). +*/ -}} +export type {{.ClassName}}Query = Record +{{- end}} + +export function list{{.ClassName}}(query: {{.ClassName}}Query & PageQuery) { + return request>>({ + url: '/api/v1/{{.ModuleName}}', + method: 'get', + params: query + }) +} + +export function get{{.ClassName}}({{.PkJsonField}}: {{$pkType}}) { + return request>({ + url: '/api/v1/{{.ModuleName}}/' + {{.PkJsonField}}, + method: 'get' + }) +} + +export function add{{.ClassName}}(data: {{.ClassName}}) { + return request>({ + url: '/api/v1/{{.ModuleName}}', + method: 'post', + data + }) +} + +export function update{{.ClassName}}(data: {{.ClassName}}) { + return request>({ + url: '/api/v1/{{.ModuleName}}/' + data.{{.PkJsonField}}, + method: 'put', + data + }) +} + +export function del{{.ClassName}}(ids: Id[]) { + return request>({ + url: '/api/v1/{{.ModuleName}}', + method: 'delete', + data: { ids: ids.map(Number) } + }) +} diff --git a/template/v4/vue.go.template b/template/v4/vue.go.template index 6efa4d41..2b1e43f1 100644 --- a/template/v4/vue.go.template +++ b/template/v4/vue.go.template @@ -1,479 +1,389 @@ -{{$tableComment:=.TableComment}} +{{- /* + Vue 3 + Element Plus + TypeScript list page (PRD 010, F4). + + Shape matches go-admin-ui/src/views/demo/product/index.vue, the reference + page AGENTS.md names: PageContainer + ProTable + useTable/useForm/useRemove, +