mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-10-03 13:59:52 +00:00
feat✨: field-level validation for sys_tables.go:357's Update (PRD 010 F10)
That bind-and-save path had zero field-level validation (API契约.md
§1.2/§2.1, decision D6), which left four ways to silently corrupt the
generator's own metadata or the language packs it writes:
- jsonField had no format rule at all, unlike tableName/className/
businessName, which all have a pattern check. A jsonField with a
dot, a leading digit, or anything not matching lower camelCase
still saved, and would land verbatim as a key segment in
gen/{PackageName}/{BusinessName}.ts.
- jsonField had no per-table uniqueness check. Two columns retitled
to the same jsonField overwrite each other's generated i18n key
with no warning.
- businessName had no cross-table uniqueness check within a
packageName. Two tables sharing one write the same language pack
path and the second's generation silently clobbers the first's.
- defaultValue is spliced into the generated defaultModel() as a
literal, never evaluated (API契约.md §2.1) - so content shaped
like a function call or block does not do what it looks like it
does, and previously saved without complaint either way.
colWidth is handled differently on purpose: API契约.md §2.1 says an
out-of-range value should fall back to the inferred width rather than
be rejected, so it is reset to the 0 sentinel in place instead of
failing the request - same outcome as if it had never been set.
G10 (colliding with the built-in admin/* i18n namespace) is not one of
the four: D9 already moved generated keys into their own gen/
namespace, so that collision no longer exists.
All four checks and colWidth's sanitize-in-place path are covered by
sys_tables_validate_test.go. Confirmed red first: swapped in a no-op
stand-in for both validators and reran - 13 sub-tests that should now
be rejected or sanitized passed straight through instead (jsonField
format x5, jsonField uniqueness x1, colWidth range x2, defaultValue
expression x4, businessName uniqueness x1). Restored the real
implementation and reran - all green.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/other/models/tools"
|
||||
)
|
||||
|
||||
// jsonFieldPattern mirrors genInfoForm.vue's businessName rule
|
||||
// (`/^[a-z][A-Za-z]+$/`) - jsonField has never had a format rule of its own,
|
||||
// unlike businessName/tableName/className, and API契约.md §1.2 recommends
|
||||
// tightening it to the same identifier shape the other three already use.
|
||||
var jsonFieldPattern = regexp.MustCompile(`^[a-z][A-Za-z]+$`)
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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},
|
||||
{"leading underscore rejected", "_id", true},
|
||||
{"leading digit rejected", "1name", true},
|
||||
{"snake_case rejected", "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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user