models_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. // academic model 与 DDL 逐列对账单测(**纯单测,不连库**)。
  2. //
  3. // 权威基准:testdata/academic-model-ddl.sql(`02-data-model/academic-model-ddl.sql` 的逐字节副本,
  4. // 由 AC-01 建立时 cp 而来;DDL 变更须同步刷新该 fixture,AC-17 会再用 information_schema 做真库核对)。
  5. //
  6. // 覆盖 4 项硬要求(AC-01 验收②③④):
  7. // 1. `AllTables()` 长度 = 27、无重复、每表均有 TableName();
  8. // 2. 27 表 ↔ DDL CREATE TABLE 集合一致(无遗漏/无多余);
  9. // 3. 每表列名集合、列类型(VARCHAR(N)/DECIMAL(p,s)/BIGINT/INT/TINYINT(1)/TINYINT/SMALLINT/
  10. // DATETIME(3)/DATE/LONGTEXT)、可空性(NULL ↔ 指针)逐列一致;
  11. // 4. 每个字段都有 gorm `column:` tag + 五维中文注释(含 4 个"|"分隔符)。
  12. package model
  13. import (
  14. _ "embed"
  15. "fmt"
  16. "go/ast"
  17. "go/parser"
  18. "go/token"
  19. "os"
  20. "reflect"
  21. "strings"
  22. "sync"
  23. "testing"
  24. "gorm.io/gorm/schema"
  25. )
  26. // ddlSQL academic 目标态 DDL 全文(fixture 副本,见文件头说明)。
  27. //
  28. //go:embed testdata/academic-model-ddl.sql
  29. var ddlSQL string
  30. // expectedTableCount 目标态表数(设计 §2.1:30 旧表 → 27 建 + 3 不建)。
  31. const expectedTableCount = 27
  32. // ddlColumn DDL 单列信息(解析产物)。
  33. type ddlColumn struct {
  34. Name string // 列名(DDL 内的 snake_case)
  35. Type string // 归一化列类型(大写,如 "VARCHAR(26)" / "DECIMAL(12,2)" / "DATETIME(3)")
  36. NotNull bool // 是否 NOT NULL
  37. }
  38. // TestAllTablesShape 断言 AllTables 的形状:27 项、无重复、TableName 齐全且返回新切片。
  39. func TestAllTablesShape(t *testing.T) {
  40. tables := AllTables()
  41. if len(tables) != expectedTableCount {
  42. t.Fatalf("AllTables() 长度 = %d,期望 %d", len(tables), expectedTableCount)
  43. }
  44. seen := make(map[string]bool, len(tables))
  45. for _, m := range tables {
  46. if m == nil {
  47. t.Fatal("AllTables() 含 nil")
  48. }
  49. p, err := schema.Parse(m, &sync.Map{}, schema.NamingStrategy{})
  50. if err != nil {
  51. t.Fatalf("schema.Parse(%T) 失败: %v", m, err)
  52. }
  53. if p.Table == "" {
  54. t.Fatalf("%T 缺少 TableName()", m)
  55. }
  56. if seen[p.Table] {
  57. t.Fatalf("表名重复: %s", p.Table)
  58. }
  59. seen[p.Table] = true
  60. }
  61. // 返回新切片:改动第一次返回值不得影响第二次(backend-model-field-spec.md §1.8)
  62. first := AllTables()
  63. first[0] = nil
  64. if second := AllTables(); second[0] == nil {
  65. t.Fatal("AllTables() 返回了共享底层数组(调用方可安全 append 的前提被破坏)")
  66. }
  67. }
  68. // TestDDLTableCoverage DDL 中的 CREATE TABLE 集合与 AllTables 完全一致。
  69. func TestDDLTableCoverage(t *testing.T) {
  70. ddl := parseDDL(t, ddlSQL)
  71. if len(ddl) != expectedTableCount {
  72. t.Fatalf("DDL 解析到 %d 张表,期望 %d(fixture 是否最新?)", len(ddl), expectedTableCount)
  73. }
  74. structTables := make(map[string]bool, expectedTableCount)
  75. for _, m := range AllTables() {
  76. p, err := schema.Parse(m, &sync.Map{}, schema.NamingStrategy{})
  77. if err != nil {
  78. t.Fatalf("schema.Parse 失败: %v", err)
  79. }
  80. structTables[p.Table] = true
  81. }
  82. for table := range ddl {
  83. if !structTables[table] {
  84. t.Fatalf("DDL 表 %s 在 model.AllTables() 中缺失", table)
  85. }
  86. }
  87. for table := range structTables {
  88. if _, ok := ddl[table]; !ok {
  89. t.Fatalf("model 表 %s 在 DDL 中不存在", table)
  90. }
  91. }
  92. }
  93. // TestModelsColumnByColumn 27 表逐列比对(列名/类型/可空性)。
  94. func TestModelsColumnByColumn(t *testing.T) {
  95. ddl := parseDDL(t, ddlSQL)
  96. checkedColumns := 0
  97. for _, m := range AllTables() {
  98. p, err := schema.Parse(m, &sync.Map{}, schema.NamingStrategy{})
  99. if err != nil {
  100. t.Fatalf("schema.Parse(%T) 失败: %v", m, err)
  101. }
  102. cols, ok := ddl[p.Table]
  103. if !ok {
  104. t.Fatalf("表 %s 无 DDL 定义", p.Table)
  105. }
  106. // 结构体列索引(DBName → 字段)
  107. structCols := make(map[string]*schema.Field, len(p.Fields))
  108. for _, f := range p.Fields {
  109. structCols[f.DBName] = f
  110. }
  111. if len(structCols) != len(p.Fields) {
  112. t.Fatalf("表 %s 存在重复 column 映射(DB 列名冲突)", p.Table)
  113. }
  114. seen := make(map[string]bool, len(cols))
  115. for _, c := range cols {
  116. seen[c.Name] = true
  117. f, ok := structCols[c.Name]
  118. if !ok {
  119. t.Fatalf("表 %s 缺少列 %s(DDL 有、model 无)", p.Table, c.Name)
  120. }
  121. gotType := gormTypeSig(f)
  122. if gotType != c.Type {
  123. t.Fatalf("表 %s 列 %s 类型 = %s,DDL = %s", p.Table, c.Name, gotType, c.Type)
  124. }
  125. isPtr := f.FieldType.Kind() == reflect.Ptr
  126. if c.NotNull && isPtr {
  127. t.Fatalf("表 %s 列 %s 为 NOT NULL,model 却是指针(可空)", p.Table, c.Name)
  128. }
  129. if !c.NotNull && !isPtr {
  130. t.Fatalf("表 %s 列 %s 可空,model 必须指针化(backend-model-field-spec.md §1.5)", p.Table, c.Name)
  131. }
  132. checkedColumns++
  133. }
  134. for name := range structCols {
  135. if !seen[name] {
  136. t.Fatalf("表 %s 多出列 %s(model 有、DDL 无)", p.Table, name)
  137. }
  138. }
  139. }
  140. // 27 表合计约 280+ 列;低于 250 说明解析或映射异常(防御性下限断言)
  141. if checkedColumns < 250 {
  142. t.Fatalf("仅比对 %d 列,疑似 DDL 解析或列映射不全", checkedColumns)
  143. }
  144. }
  145. // TestFieldTagsAndComments 每个字段必须有 gorm column tag + 五维中文注释(4 个"|"分隔符)。
  146. //
  147. // 说明:注释无法经反射读取,故用 go/parser 直接解析本包源文件(同一目录下的 *.go)。
  148. func TestFieldTagsAndComments(t *testing.T) {
  149. entries, err := os.ReadDir(".")
  150. if err != nil {
  151. t.Fatalf("读取包目录失败: %v", err)
  152. }
  153. fset := token.NewFileSet()
  154. files := 0
  155. for _, e := range entries {
  156. name := e.Name()
  157. if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
  158. continue
  159. }
  160. f, err := parser.ParseFile(fset, name, nil, parser.ParseComments)
  161. if err != nil {
  162. t.Fatalf("解析 %s 失败: %v", name, err)
  163. }
  164. files++
  165. for _, decl := range f.Decls {
  166. gd, ok := decl.(*ast.GenDecl)
  167. if !ok || gd.Tok != token.TYPE {
  168. continue
  169. }
  170. for _, spec := range gd.Specs {
  171. ts, ok := spec.(*ast.TypeSpec)
  172. if !ok {
  173. continue
  174. }
  175. st, ok := ts.Type.(*ast.StructType)
  176. if !ok {
  177. continue
  178. }
  179. for _, field := range st.Fields.List {
  180. if len(field.Names) == 0 {
  181. continue // 内嵌字段(本包 model 无)
  182. }
  183. name := field.Names[0].Name
  184. tag := ""
  185. if field.Tag != nil {
  186. tag = field.Tag.Value
  187. }
  188. if !strings.Contains(tag, "column:") {
  189. t.Fatalf("%s.%s 缺少 gorm column tag", ts.Name.Name, name)
  190. }
  191. comment := ""
  192. if field.Comment != nil {
  193. comment += field.Comment.Text()
  194. }
  195. if field.Doc != nil {
  196. comment += field.Doc.Text()
  197. }
  198. if comment == "" {
  199. t.Fatalf("%s.%s 缺少五维中文注释", ts.Name.Name, name)
  200. }
  201. if got := strings.Count(comment, "|"); got < 4 {
  202. t.Fatalf("%s.%s 注释维度不足(应含 语义|枚举|写入|关联|红线 共 4 个分隔符,实得 %d):%s",
  203. ts.Name.Name, name, got, comment)
  204. }
  205. }
  206. }
  207. }
  208. }
  209. if files < 6 {
  210. t.Fatalf("仅解析到 %d 个源文件,期望 >= 6(models_*.go + tables.go)", files)
  211. }
  212. }
  213. // gormTypeSig 把 GORM schema.Field 归一为与 DDL 同形的类型签名。
  214. //
  215. // 归一规则(与 backend-model-field-spec.md §1.4 的类型映射互为逆运算):
  216. // - `type:decimal(12,2)` / `type:decimal(5,1)` → "DECIMAL(12,2)" / "DECIMAL(5,1)"(原样大写);
  217. // - `type:date` → "DATE";`type:longtext` → "LONGTEXT";
  218. // - string + size:N → "VARCHAR(N)";string 无 size → "LONGTEXT"(GORM 默认,正是历史踩坑点);
  219. // - bool → "TINYINT(1)";int8/16/32/64 → TINYINT/SMALLINT/INT/BIGINT;
  220. // - time.Time / *time.Time → "DATETIME(3)"(MySQL 驱动默认毫秒精度,与 DDL datetime(3) 一致)。
  221. func gormTypeSig(f *schema.Field) string {
  222. dt := strings.ToLower(string(f.DataType))
  223. switch {
  224. case strings.HasPrefix(dt, "decimal"):
  225. if dt == "decimal" {
  226. return fmt.Sprintf("DECIMAL(%d,%d)", f.Precision, f.Scale)
  227. }
  228. return strings.ToUpper(dt) // 已含精度,如 "decimal(12,2)"
  229. case dt == "date":
  230. return "DATE"
  231. case dt == "longtext":
  232. return "LONGTEXT"
  233. case dt == "datetime(3)":
  234. return "DATETIME(3)"
  235. }
  236. switch f.DataType {
  237. case schema.String:
  238. if f.Size > 0 {
  239. return fmt.Sprintf("VARCHAR(%d)", f.Size)
  240. }
  241. return "LONGTEXT"
  242. case schema.Bool:
  243. return "TINYINT(1)"
  244. case schema.Int, schema.Uint:
  245. switch f.Size {
  246. case 8:
  247. return "TINYINT"
  248. case 16:
  249. return "SMALLINT"
  250. case 32:
  251. return "INT"
  252. case 64:
  253. return "BIGINT"
  254. }
  255. return fmt.Sprintf("INT(%d)", f.Size)
  256. case schema.Time:
  257. if f.Precision > 0 {
  258. return fmt.Sprintf("DATETIME(%d)", f.Precision)
  259. }
  260. return "DATETIME(3)"
  261. }
  262. return strings.ToUpper(string(f.DataType))
  263. }
  264. // parseDDL 解析 DDL 全文,返回「表名 → 列定义列表」(仅列定义,索引/约束行跳过)。
  265. //
  266. // 识别规则:
  267. // - `CREATE TABLE \`t_x\` (` 行开启一张表;
  268. // - 以 ')' 开头的行结束当前表;
  269. // - 以反引号开头的行是列定义(`PRIMARY KEY` / `UNIQUE KEY` / `KEY` 开头行自动跳过)。
  270. func parseDDL(t *testing.T, src string) map[string][]ddlColumn {
  271. t.Helper()
  272. out := make(map[string][]ddlColumn)
  273. var cur string
  274. for _, raw := range strings.Split(src, "\n") {
  275. line := strings.TrimSpace(raw)
  276. if line == "" || strings.HasPrefix(line, "--") {
  277. continue
  278. }
  279. if strings.HasPrefix(line, "CREATE TABLE") {
  280. start := strings.Index(line, "`")
  281. end := strings.LastIndex(line, "`")
  282. if start < 0 || end <= start {
  283. t.Fatalf("CREATE TABLE 行缺少反引号表名: %s", line)
  284. }
  285. cur = line[start+1 : end]
  286. out[cur] = nil
  287. continue
  288. }
  289. if cur == "" {
  290. continue
  291. }
  292. if strings.HasPrefix(line, ")") {
  293. cur = ""
  294. continue
  295. }
  296. if !strings.HasPrefix(line, "`") {
  297. continue // PRIMARY KEY / UNIQUE KEY / KEY 等
  298. }
  299. rest := line[1:]
  300. idx := strings.Index(rest, "`")
  301. if idx < 0 {
  302. t.Fatalf("列定义行缺少结束反引号: %s", line)
  303. }
  304. name := rest[:idx]
  305. rest = strings.TrimSuffix(strings.TrimSpace(rest[idx+1:]), ",")
  306. parts := strings.SplitN(rest, " ", 2)
  307. typ := strings.ToUpper(parts[0])
  308. head := rest
  309. if i := strings.Index(rest, "COMMENT"); i >= 0 {
  310. head = rest[:i] // 排除 COMMENT 文案里出现 "NOT NULL" 造成的误判
  311. }
  312. out[cur] = append(out[cur], ddlColumn{
  313. Name: name,
  314. Type: typ,
  315. NotNull: strings.Contains(head, "NOT NULL"),
  316. })
  317. }
  318. return out
  319. }