models_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  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. //
  95. // ---------------------------------------------------------------------------
  96. // ⚠ 本用例是**精度类问题的唯一防线**,不得跳过、不得放宽为"仅名字比对"
  97. // ---------------------------------------------------------------------------
  98. //
  99. // 原因(`tech-debt-backlog.md` **T-TEST-1**):本仓单测栈是 sqlite,它的 NUMERIC 亲和性
  100. // **不强制 `DECIMAL` 的标度**——把 `decimal(6,3)` 改回 `(6,2)`,插入 `0.999` 依然原样存下,
  101. // 于是"0.999 被舍成 1.00 ⇒ 相邻区间并列命中"这类**精度导致的语义差异在单测里根本复现不出来**。
  102. //
  103. // 由此三条纪律:
  104. // 1. 精度敏感的语义差异,只能靠**真库 e2e**(MySQL)或 **D7 双跑**证明,sqlite 单测不能替代;
  105. // 2. 写"防精度回退"用例时,**断言对象必须是列类型(schema)**,不是计算结果 ——
  106. // 拿计算结果做断言等于没写(它对两种精度都绿);
  107. // 3. 因此本用例(tag ↔ DDL 逐列比对)必须**保持对 `type` 的严格比对**:
  108. // 一旦被简化成"只比列名",`decimal(6,2)`/`(6,3)` 收敛就再没有任何门禁能拦。
  109. //
  110. // 另注:本用例抓得住"只改一边"(改 DDL 忘改 tag),但**抓不住"两边一起改回"**(一致地错)。
  111. // 后者的守护是 `payroll_precision_test.go`(`TestPayrollTeacherExpPrecisionLocked`,业务口径锁)。
  112. func TestModelsColumnByColumn(t *testing.T) {
  113. ddl := parseDDL(t, ddlSQL)
  114. checkedColumns := 0
  115. for _, m := range AllTables() {
  116. p, err := schema.Parse(m, &sync.Map{}, schema.NamingStrategy{})
  117. if err != nil {
  118. t.Fatalf("schema.Parse(%T) 失败: %v", m, err)
  119. }
  120. cols, ok := ddl[p.Table]
  121. if !ok {
  122. t.Fatalf("表 %s 无 DDL 定义", p.Table)
  123. }
  124. // 结构体列索引(DBName → 字段)
  125. structCols := make(map[string]*schema.Field, len(p.Fields))
  126. for _, f := range p.Fields {
  127. structCols[f.DBName] = f
  128. }
  129. if len(structCols) != len(p.Fields) {
  130. t.Fatalf("表 %s 存在重复 column 映射(DB 列名冲突)", p.Table)
  131. }
  132. seen := make(map[string]bool, len(cols))
  133. for _, c := range cols {
  134. seen[c.Name] = true
  135. f, ok := structCols[c.Name]
  136. if !ok {
  137. t.Fatalf("表 %s 缺少列 %s(DDL 有、model 无)", p.Table, c.Name)
  138. }
  139. gotType := gormTypeSig(f)
  140. if gotType != c.Type {
  141. t.Fatalf("表 %s 列 %s 类型 = %s,DDL = %s", p.Table, c.Name, gotType, c.Type)
  142. }
  143. isPtr := f.FieldType.Kind() == reflect.Ptr
  144. if c.NotNull && isPtr {
  145. t.Fatalf("表 %s 列 %s 为 NOT NULL,model 却是指针(可空)", p.Table, c.Name)
  146. }
  147. if !c.NotNull && !isPtr {
  148. t.Fatalf("表 %s 列 %s 可空,model 必须指针化(backend-model-field-spec.md §1.5)", p.Table, c.Name)
  149. }
  150. checkedColumns++
  151. }
  152. for name := range structCols {
  153. if !seen[name] {
  154. t.Fatalf("表 %s 多出列 %s(model 有、DDL 无)", p.Table, name)
  155. }
  156. }
  157. }
  158. // 27 表合计约 280+ 列;低于 250 说明解析或映射异常(防御性下限断言)
  159. if checkedColumns < 250 {
  160. t.Fatalf("仅比对 %d 列,疑似 DDL 解析或列映射不全", checkedColumns)
  161. }
  162. }
  163. // TestFieldTagsAndComments 每个字段必须有 gorm column tag + 五维中文注释(4 个"|"分隔符)。
  164. //
  165. // 说明:注释无法经反射读取,故用 go/parser 直接解析本包源文件(同一目录下的 *.go)。
  166. func TestFieldTagsAndComments(t *testing.T) {
  167. entries, err := os.ReadDir(".")
  168. if err != nil {
  169. t.Fatalf("读取包目录失败: %v", err)
  170. }
  171. fset := token.NewFileSet()
  172. files := 0
  173. for _, e := range entries {
  174. name := e.Name()
  175. if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
  176. continue
  177. }
  178. f, err := parser.ParseFile(fset, name, nil, parser.ParseComments)
  179. if err != nil {
  180. t.Fatalf("解析 %s 失败: %v", name, err)
  181. }
  182. files++
  183. for _, decl := range f.Decls {
  184. gd, ok := decl.(*ast.GenDecl)
  185. if !ok || gd.Tok != token.TYPE {
  186. continue
  187. }
  188. for _, spec := range gd.Specs {
  189. ts, ok := spec.(*ast.TypeSpec)
  190. if !ok {
  191. continue
  192. }
  193. st, ok := ts.Type.(*ast.StructType)
  194. if !ok {
  195. continue
  196. }
  197. for _, field := range st.Fields.List {
  198. if len(field.Names) == 0 {
  199. continue // 内嵌字段(本包 model 无)
  200. }
  201. name := field.Names[0].Name
  202. tag := ""
  203. if field.Tag != nil {
  204. tag = field.Tag.Value
  205. }
  206. if !strings.Contains(tag, "column:") {
  207. t.Fatalf("%s.%s 缺少 gorm column tag", ts.Name.Name, name)
  208. }
  209. comment := ""
  210. if field.Comment != nil {
  211. comment += field.Comment.Text()
  212. }
  213. if field.Doc != nil {
  214. comment += field.Doc.Text()
  215. }
  216. if comment == "" {
  217. t.Fatalf("%s.%s 缺少五维中文注释", ts.Name.Name, name)
  218. }
  219. if got := strings.Count(comment, "|"); got < 4 {
  220. t.Fatalf("%s.%s 注释维度不足(应含 语义|枚举|写入|关联|红线 共 4 个分隔符,实得 %d):%s",
  221. ts.Name.Name, name, got, comment)
  222. }
  223. }
  224. }
  225. }
  226. }
  227. if files < 6 {
  228. t.Fatalf("仅解析到 %d 个源文件,期望 >= 6(models_*.go + tables.go)", files)
  229. }
  230. }
  231. // gormTypeSig 把 GORM schema.Field 归一为与 DDL 同形的类型签名。
  232. //
  233. // 归一规则(与 backend-model-field-spec.md §1.4 的类型映射互为逆运算):
  234. // - `type:decimal(12,2)` / `type:decimal(5,1)` → "DECIMAL(12,2)" / "DECIMAL(5,1)"(原样大写);
  235. // - `type:date` → "DATE";`type:longtext` → "LONGTEXT";
  236. // - string + size:N → "VARCHAR(N)";string 无 size → "LONGTEXT"(GORM 默认,正是历史踩坑点);
  237. // - bool → "TINYINT(1)";int8/16/32/64 → TINYINT/SMALLINT/INT/BIGINT;
  238. // - time.Time / *time.Time → "DATETIME(3)"(MySQL 驱动默认毫秒精度,与 DDL datetime(3) 一致)。
  239. func gormTypeSig(f *schema.Field) string {
  240. dt := strings.ToLower(string(f.DataType))
  241. switch {
  242. case strings.HasPrefix(dt, "decimal"):
  243. if dt == "decimal" {
  244. return fmt.Sprintf("DECIMAL(%d,%d)", f.Precision, f.Scale)
  245. }
  246. return strings.ToUpper(dt) // 已含精度,如 "decimal(12,2)"
  247. case dt == "date":
  248. return "DATE"
  249. case dt == "longtext":
  250. return "LONGTEXT"
  251. case dt == "datetime(3)":
  252. return "DATETIME(3)"
  253. }
  254. switch f.DataType {
  255. case schema.String:
  256. if f.Size > 0 {
  257. return fmt.Sprintf("VARCHAR(%d)", f.Size)
  258. }
  259. return "LONGTEXT"
  260. case schema.Bool:
  261. return "TINYINT(1)"
  262. case schema.Int, schema.Uint:
  263. switch f.Size {
  264. case 8:
  265. return "TINYINT"
  266. case 16:
  267. return "SMALLINT"
  268. case 32:
  269. return "INT"
  270. case 64:
  271. return "BIGINT"
  272. }
  273. return fmt.Sprintf("INT(%d)", f.Size)
  274. case schema.Time:
  275. if f.Precision > 0 {
  276. return fmt.Sprintf("DATETIME(%d)", f.Precision)
  277. }
  278. return "DATETIME(3)"
  279. }
  280. return strings.ToUpper(string(f.DataType))
  281. }
  282. // parseDDL 解析 DDL 全文,返回「表名 → 列定义列表」(仅列定义,索引/约束行跳过)。
  283. //
  284. // 识别规则:
  285. // - `CREATE TABLE \`t_x\` (` 行开启一张表;
  286. // - 以 ')' 开头的行结束当前表;
  287. // - 以反引号开头的行是列定义(`PRIMARY KEY` / `UNIQUE KEY` / `KEY` 开头行自动跳过)。
  288. func parseDDL(t *testing.T, src string) map[string][]ddlColumn {
  289. t.Helper()
  290. out := make(map[string][]ddlColumn)
  291. var cur string
  292. for _, raw := range strings.Split(src, "\n") {
  293. line := strings.TrimSpace(raw)
  294. if line == "" || strings.HasPrefix(line, "--") {
  295. continue
  296. }
  297. if strings.HasPrefix(line, "CREATE TABLE") {
  298. start := strings.Index(line, "`")
  299. end := strings.LastIndex(line, "`")
  300. if start < 0 || end <= start {
  301. t.Fatalf("CREATE TABLE 行缺少反引号表名: %s", line)
  302. }
  303. cur = line[start+1 : end]
  304. out[cur] = nil
  305. continue
  306. }
  307. if cur == "" {
  308. continue
  309. }
  310. if strings.HasPrefix(line, ")") {
  311. cur = ""
  312. continue
  313. }
  314. if !strings.HasPrefix(line, "`") {
  315. continue // PRIMARY KEY / UNIQUE KEY / KEY 等
  316. }
  317. rest := line[1:]
  318. idx := strings.Index(rest, "`")
  319. if idx < 0 {
  320. t.Fatalf("列定义行缺少结束反引号: %s", line)
  321. }
  322. name := rest[:idx]
  323. rest = strings.TrimSuffix(strings.TrimSpace(rest[idx+1:]), ",")
  324. parts := strings.SplitN(rest, " ", 2)
  325. typ := strings.ToUpper(parts[0])
  326. head := rest
  327. if i := strings.Index(rest, "COMMENT"); i >= 0 {
  328. head = rest[:i] // 排除 COMMENT 文案里出现 "NOT NULL" 造成的误判
  329. }
  330. out[cur] = append(out[cur], ddlColumn{
  331. Name: name,
  332. Type: typ,
  333. NotNull: strings.Contains(head, "NOT NULL"),
  334. })
  335. }
  336. return out
  337. }