models_test.go 12 KB

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