| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333 |
- // academic model 与 DDL 逐列对账单测(**纯单测,不连库**)。
- //
- // 权威基准:testdata/academic-model-ddl.sql(`02-data-model/academic-model-ddl.sql` 的逐字节副本,
- // 由 AC-01 建立时 cp 而来;DDL 变更须同步刷新该 fixture,AC-17 会再用 information_schema 做真库核对)。
- //
- // 覆盖 4 项硬要求(AC-01 验收②③④):
- // 1. `AllTables()` 长度 = 27、无重复、每表均有 TableName();
- // 2. 27 表 ↔ DDL CREATE TABLE 集合一致(无遗漏/无多余);
- // 3. 每表列名集合、列类型(VARCHAR(N)/DECIMAL(p,s)/BIGINT/INT/TINYINT(1)/TINYINT/SMALLINT/
- // DATETIME(3)/DATE/LONGTEXT)、可空性(NULL ↔ 指针)逐列一致;
- // 4. 每个字段都有 gorm `column:` tag + 五维中文注释(含 4 个"|"分隔符)。
- package model
- import (
- _ "embed"
- "fmt"
- "go/ast"
- "go/parser"
- "go/token"
- "os"
- "reflect"
- "strings"
- "sync"
- "testing"
- "gorm.io/gorm/schema"
- )
- // ddlSQL academic 目标态 DDL 全文(fixture 副本,见文件头说明)。
- //
- //go:embed testdata/academic-model-ddl.sql
- var ddlSQL string
- // expectedTableCount 目标态表数(设计 §2.1:30 旧表 → 27 建 + 3 不建)。
- const expectedTableCount = 27
- // ddlColumn DDL 单列信息(解析产物)。
- type ddlColumn struct {
- Name string // 列名(DDL 内的 snake_case)
- Type string // 归一化列类型(大写,如 "VARCHAR(26)" / "DECIMAL(12,2)" / "DATETIME(3)")
- NotNull bool // 是否 NOT NULL
- }
- // TestAllTablesShape 断言 AllTables 的形状:27 项、无重复、TableName 齐全且返回新切片。
- func TestAllTablesShape(t *testing.T) {
- tables := AllTables()
- if len(tables) != expectedTableCount {
- t.Fatalf("AllTables() 长度 = %d,期望 %d", len(tables), expectedTableCount)
- }
- seen := make(map[string]bool, len(tables))
- for _, m := range tables {
- if m == nil {
- t.Fatal("AllTables() 含 nil")
- }
- p, err := schema.Parse(m, &sync.Map{}, schema.NamingStrategy{})
- if err != nil {
- t.Fatalf("schema.Parse(%T) 失败: %v", m, err)
- }
- if p.Table == "" {
- t.Fatalf("%T 缺少 TableName()", m)
- }
- if seen[p.Table] {
- t.Fatalf("表名重复: %s", p.Table)
- }
- seen[p.Table] = true
- }
- // 返回新切片:改动第一次返回值不得影响第二次(backend-model-field-spec.md §1.8)
- first := AllTables()
- first[0] = nil
- if second := AllTables(); second[0] == nil {
- t.Fatal("AllTables() 返回了共享底层数组(调用方可安全 append 的前提被破坏)")
- }
- }
- // TestDDLTableCoverage DDL 中的 CREATE TABLE 集合与 AllTables 完全一致。
- func TestDDLTableCoverage(t *testing.T) {
- ddl := parseDDL(t, ddlSQL)
- if len(ddl) != expectedTableCount {
- t.Fatalf("DDL 解析到 %d 张表,期望 %d(fixture 是否最新?)", len(ddl), expectedTableCount)
- }
- structTables := make(map[string]bool, expectedTableCount)
- for _, m := range AllTables() {
- p, err := schema.Parse(m, &sync.Map{}, schema.NamingStrategy{})
- if err != nil {
- t.Fatalf("schema.Parse 失败: %v", err)
- }
- structTables[p.Table] = true
- }
- for table := range ddl {
- if !structTables[table] {
- t.Fatalf("DDL 表 %s 在 model.AllTables() 中缺失", table)
- }
- }
- for table := range structTables {
- if _, ok := ddl[table]; !ok {
- t.Fatalf("model 表 %s 在 DDL 中不存在", table)
- }
- }
- }
- // TestModelsColumnByColumn 27 表逐列比对(列名/类型/可空性)。
- func TestModelsColumnByColumn(t *testing.T) {
- ddl := parseDDL(t, ddlSQL)
- checkedColumns := 0
- for _, m := range AllTables() {
- p, err := schema.Parse(m, &sync.Map{}, schema.NamingStrategy{})
- if err != nil {
- t.Fatalf("schema.Parse(%T) 失败: %v", m, err)
- }
- cols, ok := ddl[p.Table]
- if !ok {
- t.Fatalf("表 %s 无 DDL 定义", p.Table)
- }
- // 结构体列索引(DBName → 字段)
- structCols := make(map[string]*schema.Field, len(p.Fields))
- for _, f := range p.Fields {
- structCols[f.DBName] = f
- }
- if len(structCols) != len(p.Fields) {
- t.Fatalf("表 %s 存在重复 column 映射(DB 列名冲突)", p.Table)
- }
- seen := make(map[string]bool, len(cols))
- for _, c := range cols {
- seen[c.Name] = true
- f, ok := structCols[c.Name]
- if !ok {
- t.Fatalf("表 %s 缺少列 %s(DDL 有、model 无)", p.Table, c.Name)
- }
- gotType := gormTypeSig(f)
- if gotType != c.Type {
- t.Fatalf("表 %s 列 %s 类型 = %s,DDL = %s", p.Table, c.Name, gotType, c.Type)
- }
- isPtr := f.FieldType.Kind() == reflect.Ptr
- if c.NotNull && isPtr {
- t.Fatalf("表 %s 列 %s 为 NOT NULL,model 却是指针(可空)", p.Table, c.Name)
- }
- if !c.NotNull && !isPtr {
- t.Fatalf("表 %s 列 %s 可空,model 必须指针化(backend-model-field-spec.md §1.5)", p.Table, c.Name)
- }
- checkedColumns++
- }
- for name := range structCols {
- if !seen[name] {
- t.Fatalf("表 %s 多出列 %s(model 有、DDL 无)", p.Table, name)
- }
- }
- }
- // 27 表合计约 280+ 列;低于 250 说明解析或映射异常(防御性下限断言)
- if checkedColumns < 250 {
- t.Fatalf("仅比对 %d 列,疑似 DDL 解析或列映射不全", checkedColumns)
- }
- }
- // TestFieldTagsAndComments 每个字段必须有 gorm column tag + 五维中文注释(4 个"|"分隔符)。
- //
- // 说明:注释无法经反射读取,故用 go/parser 直接解析本包源文件(同一目录下的 *.go)。
- func TestFieldTagsAndComments(t *testing.T) {
- entries, err := os.ReadDir(".")
- if err != nil {
- t.Fatalf("读取包目录失败: %v", err)
- }
- fset := token.NewFileSet()
- files := 0
- for _, e := range entries {
- name := e.Name()
- if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
- continue
- }
- f, err := parser.ParseFile(fset, name, nil, parser.ParseComments)
- if err != nil {
- t.Fatalf("解析 %s 失败: %v", name, err)
- }
- files++
- for _, decl := range f.Decls {
- gd, ok := decl.(*ast.GenDecl)
- if !ok || gd.Tok != token.TYPE {
- continue
- }
- for _, spec := range gd.Specs {
- ts, ok := spec.(*ast.TypeSpec)
- if !ok {
- continue
- }
- st, ok := ts.Type.(*ast.StructType)
- if !ok {
- continue
- }
- for _, field := range st.Fields.List {
- if len(field.Names) == 0 {
- continue // 内嵌字段(本包 model 无)
- }
- name := field.Names[0].Name
- tag := ""
- if field.Tag != nil {
- tag = field.Tag.Value
- }
- if !strings.Contains(tag, "column:") {
- t.Fatalf("%s.%s 缺少 gorm column tag", ts.Name.Name, name)
- }
- comment := ""
- if field.Comment != nil {
- comment += field.Comment.Text()
- }
- if field.Doc != nil {
- comment += field.Doc.Text()
- }
- if comment == "" {
- t.Fatalf("%s.%s 缺少五维中文注释", ts.Name.Name, name)
- }
- if got := strings.Count(comment, "|"); got < 4 {
- t.Fatalf("%s.%s 注释维度不足(应含 语义|枚举|写入|关联|红线 共 4 个分隔符,实得 %d):%s",
- ts.Name.Name, name, got, comment)
- }
- }
- }
- }
- }
- if files < 6 {
- t.Fatalf("仅解析到 %d 个源文件,期望 >= 6(models_*.go + tables.go)", files)
- }
- }
- // gormTypeSig 把 GORM schema.Field 归一为与 DDL 同形的类型签名。
- //
- // 归一规则(与 backend-model-field-spec.md §1.4 的类型映射互为逆运算):
- // - `type:decimal(12,2)` / `type:decimal(5,1)` → "DECIMAL(12,2)" / "DECIMAL(5,1)"(原样大写);
- // - `type:date` → "DATE";`type:longtext` → "LONGTEXT";
- // - string + size:N → "VARCHAR(N)";string 无 size → "LONGTEXT"(GORM 默认,正是历史踩坑点);
- // - bool → "TINYINT(1)";int8/16/32/64 → TINYINT/SMALLINT/INT/BIGINT;
- // - time.Time / *time.Time → "DATETIME(3)"(MySQL 驱动默认毫秒精度,与 DDL datetime(3) 一致)。
- func gormTypeSig(f *schema.Field) string {
- dt := strings.ToLower(string(f.DataType))
- switch {
- case strings.HasPrefix(dt, "decimal"):
- if dt == "decimal" {
- return fmt.Sprintf("DECIMAL(%d,%d)", f.Precision, f.Scale)
- }
- return strings.ToUpper(dt) // 已含精度,如 "decimal(12,2)"
- case dt == "date":
- return "DATE"
- case dt == "longtext":
- return "LONGTEXT"
- case dt == "datetime(3)":
- return "DATETIME(3)"
- }
- switch f.DataType {
- case schema.String:
- if f.Size > 0 {
- return fmt.Sprintf("VARCHAR(%d)", f.Size)
- }
- return "LONGTEXT"
- case schema.Bool:
- return "TINYINT(1)"
- case schema.Int, schema.Uint:
- switch f.Size {
- case 8:
- return "TINYINT"
- case 16:
- return "SMALLINT"
- case 32:
- return "INT"
- case 64:
- return "BIGINT"
- }
- return fmt.Sprintf("INT(%d)", f.Size)
- case schema.Time:
- if f.Precision > 0 {
- return fmt.Sprintf("DATETIME(%d)", f.Precision)
- }
- return "DATETIME(3)"
- }
- return strings.ToUpper(string(f.DataType))
- }
- // parseDDL 解析 DDL 全文,返回「表名 → 列定义列表」(仅列定义,索引/约束行跳过)。
- //
- // 识别规则:
- // - `CREATE TABLE \`t_x\` (` 行开启一张表;
- // - 以 ')' 开头的行结束当前表;
- // - 以反引号开头的行是列定义(`PRIMARY KEY` / `UNIQUE KEY` / `KEY` 开头行自动跳过)。
- func parseDDL(t *testing.T, src string) map[string][]ddlColumn {
- t.Helper()
- out := make(map[string][]ddlColumn)
- var cur string
- for _, raw := range strings.Split(src, "\n") {
- line := strings.TrimSpace(raw)
- if line == "" || strings.HasPrefix(line, "--") {
- continue
- }
- if strings.HasPrefix(line, "CREATE TABLE") {
- start := strings.Index(line, "`")
- end := strings.LastIndex(line, "`")
- if start < 0 || end <= start {
- t.Fatalf("CREATE TABLE 行缺少反引号表名: %s", line)
- }
- cur = line[start+1 : end]
- out[cur] = nil
- continue
- }
- if cur == "" {
- continue
- }
- if strings.HasPrefix(line, ")") {
- cur = ""
- continue
- }
- if !strings.HasPrefix(line, "`") {
- continue // PRIMARY KEY / UNIQUE KEY / KEY 等
- }
- rest := line[1:]
- idx := strings.Index(rest, "`")
- if idx < 0 {
- t.Fatalf("列定义行缺少结束反引号: %s", line)
- }
- name := rest[:idx]
- rest = strings.TrimSuffix(strings.TrimSpace(rest[idx+1:]), ",")
- parts := strings.SplitN(rest, " ", 2)
- typ := strings.ToUpper(parts[0])
- head := rest
- if i := strings.Index(rest, "COMMENT"); i >= 0 {
- head = rest[:i] // 排除 COMMENT 文案里出现 "NOT NULL" 造成的误判
- }
- out[cur] = append(out[cur], ddlColumn{
- Name: name,
- Type: typ,
- NotNull: strings.Contains(head, "NOT NULL"),
- })
- }
- return out
- }
|