validate-ddl.mjs
16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
// lib/validate-ddl.mjs — docs/03 表格 ↔ DDL(V1.sql)一致性 5 维校验
// 替换 db-init/scripts/validate.sh(跨平台、纯 Node、零外部依赖)。
//
// 用法(CLI):node lib/validate-ddl.mjs <docs03Path> <ddlPath>
// 退出码 0 = 一致;1 = 存在差异(diff 明细打印到 stderr);2 = 用法/路径错误。
// 程序内:import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs'
//
// 5 维 diff:
// 1) 表集合(missingTables / extraTables)
// 2) 列名(columnMismatches,side: 'docs'|'ddl')
// 3) 列类型(typeMismatches)
// 4) 索引(indexMismatches,side: 'docs'|'ddl')
// 5) 外键(foreignKeyMismatches,side: 'docs'|'ddl')
//
// 数据结构(解析结果):Map<tableName, {
// columns: Map<colName, type>, indexes: Set<string>, foreignKeys: Set<string> }>
// ── 解析 docs/03 markdown 表定义 ─────────────────────────────────
// 约定:每张表一节,节标题形如 ## `表名` 或 ## `表名` — 业务含义
// 节内分 ### 字段(markdown 表格,首列列名、次列类型)、### 索引、### 外键(项目符号列表)。
// 索引/外键的 bullet 形态见 db-design-gen/templates/docs-03-table-template.md:
// ### 索引 → - `name` (type): cols
// ### 外键 → - `name`: from_col → to_table.to_col (on_delete)
// 跳过表头行(列/字段/类型等标签)与分隔行(---)。
// 形如「## 一、全局约定」这类非反引号标题不视为表。
export function parseDocsTables(text) {
const tables = new Map()
const lines = String(text).split('\n')
// 反引号包裹的表名:## `name` 或 ## `name` — purpose
const headerRe = /^##\s+`([^`]+)`/
let current = null // { columns, indexes, foreignKeys }
let mode = 'col' // 当前子区块:'col'(字段表格)/ 'idx'(索引)/ 'fk'(外键)
for (const raw of lines) {
const line = raw.replace(/\r$/, '')
const h2 = line.match(headerRe)
if (h2) {
current = { columns: new Map(), indexes: new Set(), foreignKeys: new Set() }
mode = 'col'
tables.set(h2[1].trim(), current)
continue
}
// 任何其它二级(或更高)非反引号标题 → 结束当前表块(如 ## 一、全局约定)
if (/^##\s/.test(line) && !headerRe.test(line)) {
current = null
continue
}
if (!current) continue
// ### 子区块切换(### 索引 / ### 外键 / 其它如 ### 字段、### 业务注记 → col)
const h3 = line.match(/^###\s+(.+)$/)
if (h3) {
const title = h3[1].trim()
mode = /索引|index/i.test(title) ? 'idx' : /外键|foreign/i.test(title) ? 'fk' : 'col'
continue
}
if (mode === 'idx') { parseIndexBullet(line, current.indexes); continue }
if (mode === 'fk') { parseForeignKeyBullet(line, current.foreignKeys); continue }
// mode === 'col':markdown 表格行(以 | 开头)
if (!/^\s*\|/.test(line)) continue
const cells = splitMarkdownRow(line)
if (cells.length < 2) continue
const name = stripTicks(cells[0])
const type = stripTicks(cells[1])
// 跳过分隔行(---)、表头标签行、空名行
if (!name) continue
if (isSeparatorCell(name)) continue
if (isHeaderLabel(name)) continue
current.columns.set(name, type)
}
return tables
}
// 解析索引 bullet: - `name` (type): cols
// type 为 PRIMARY(不分大小写)→ 记 'PRIMARY'(匹配 parseDDL 对主键的归一化);
// 否则记索引名 name(匹配 parseDDL 对命名索引存 name)。
function parseIndexBullet(line, indexes) {
const m = line.match(/^\s*-\s+`?([^`():]+)`?\s*(?:\(([^)]*)\))?\s*:?/)
if (!m) return
const name = m[1].trim()
const type = (m[2] || '').trim()
if (!name) return
if (/^primary$/i.test(type) || /^primary$/i.test(name)) indexes.add('PRIMARY')
else indexes.add(name)
}
// 解析外键 bullet: - `name`: from_col → to_table.to_col (on_delete)
// 归一化为 parseDDL 同形的 `${fromCol}->${toTable}(${toCol})`(注意 docs 用 unicode → / DDL 用 ->)。
function parseForeignKeyBullet(line, foreignKeys) {
const m = line.match(/^\s*-\s+`?[^`:]+`?\s*:\s*`?([A-Za-z0-9_,\s]+?)`?\s*(?:→|->|>)\s*`?([A-Za-z0-9_]+)`?\.`?([A-Za-z0-9_]+)`?/)
if (!m) return
const fromCols = m[1].replace(/`/g, '').replace(/\s+/g, '')
const toTable = m[2]
const toCols = m[3].replace(/`/g, '').replace(/\s+/g, '')
if (!fromCols || !toTable || !toCols) return
foreignKeys.add(`${fromCols}->${toTable}(${toCols})`)
}
// ── 解析 CREATE TABLE DDL ────────────────────────────────────────
// 提取每个 CREATE TABLE 的:列名→类型、索引名集合、外键描述集合。
export function parseDDL(text) {
const tables = new Map()
// 先剥离 SQL 注释,避免被注释掉的 CREATE TABLE 被当成真实表(幽灵表假阳性)。
const src = stripSqlComments(String(text))
// 抓取 CREATE TABLE <name> ( <body> ) ;name 可带反引号;body 到匹配的右括号
const createRe = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([A-Za-z0-9_]+)`?\s*\(/gi
let m
while ((m = createRe.exec(src)) !== null) {
const tableName = m[1]
const bodyStart = createRe.lastIndex - 1 // 指向 '('
const body = extractBalancedParens(src, bodyStart)
if (body == null) continue
const parsed = parseTableBody(body)
tables.set(tableName, parsed)
// 继续从 body 之后扫描
createRe.lastIndex = bodyStart + body.length + 2
}
return tables
}
function parseTableBody(body) {
const columns = new Map()
const indexes = new Set()
const foreignKeys = new Set()
for (const itemRaw of splitTopLevelCommas(body)) {
const item = itemRaw.trim()
if (!item) continue
const upper = item.toUpperCase()
// 外键约束(可带前缀 CONSTRAINT <name>)
if (/\bFOREIGN\s+KEY\b/i.test(item)) {
const fk = item.match(/FOREIGN\s+KEY\s*\(([^)]*)\)\s*REFERENCES\s+`?([A-Za-z0-9_]+)`?\s*\(([^)]*)\)/i)
if (fk) {
const fromCols = fk[1].replace(/`/g, '').replace(/\s+/g, '')
const refTable = fk[2]
const toCols = fk[3].replace(/`/g, '').replace(/\s+/g, '')
foreignKeys.add(`${fromCols}->${refTable}(${toCols})`)
} else {
foreignKeys.add(item)
}
continue
}
// PRIMARY KEY (...)
if (/^PRIMARY\s+KEY/i.test(item)) {
indexes.add('PRIMARY')
continue
}
// UNIQUE [KEY|INDEX] <name> (...) / KEY <name> (...) / INDEX <name> (...)
if (/^(UNIQUE\s+(KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\b/i.test(item)) {
const nameMatch = item.match(/^(?:UNIQUE\s+(?:KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\s+`?([A-Za-z0-9_]+)`?/i)
indexes.add(nameMatch ? nameMatch[1] : item)
continue
}
// CONSTRAINT <name> 但非外键(如 UNIQUE/CHECK 约束)→ 当索引/约束记
if (/^CONSTRAINT\b/i.test(upper)) {
const cn = item.match(/^CONSTRAINT\s+`?([A-Za-z0-9_]+)`?/i)
indexes.add(cn ? cn[1] : item)
continue
}
// CHECK (...)
if (/^CHECK\b/i.test(upper)) continue
// 普通列:<name> <type> ... name 可带反引号;type 取到第一个属性关键字/逗号前
const col = item.match(/^`?([A-Za-z0-9_]+)`?\s+(.+)$/s)
if (!col) continue
const name = col[1]
const type = extractType(col[2])
columns.set(name, type)
}
return { columns, indexes, foreignKeys }
}
// 从列定义剩余部分提取类型(含括号内长度),到下一个属性关键字前停止。
function extractType(rest) {
const s = rest.trim()
// 类型形如 varchar(100) / decimal(10,2) / int unsigned / bigint
const m = s.match(/^([A-Za-z]+(?:\s+(?:unsigned|signed|zerofill))*)\s*(\([^)]*\))?/i)
if (!m) return s.split(/\s+/)[0]
let type = m[1].trim()
// 仅保留基础类型词 + 括号;丢弃 unsigned/signed 这类修饰以贴近 docs/03 写法(docs 一般只写基础类型)
const base = type.split(/\s+/)[0]
return base + (m[2] ? m[2].replace(/\s+/g, '') : '')
}
// ── 5 维 diff ────────────────────────────────────────────────────
export function diffSchema(docsTables, ddlTables) {
const diff = {
missingTables: [], // docs 有、DDL 无
extraTables: [], // DDL 有、docs 无
columnMismatches: [], // { table, column, side: 'docs'|'ddl' }
typeMismatches: [], // { table, column, docsType, ddlType }
indexMismatches: [], // { table, index, side: 'docs'|'ddl' }
foreignKeyMismatches: [],// { table, foreignKey, side: 'docs'|'ddl' }
hasDiff: false,
}
const docNames = new Set(docsTables.keys())
const ddlNames = new Set(ddlTables.keys())
for (const t of docNames) if (!ddlNames.has(t)) diff.missingTables.push(t)
for (const t of ddlNames) if (!docNames.has(t)) diff.extraTables.push(t)
diff.missingTables.sort()
diff.extraTables.sort()
// 仅对共有表做列/类型/索引/外键比对
for (const t of [...docNames].filter(n => ddlNames.has(n)).sort()) {
const d = docsTables.get(t)
const s = ddlTables.get(t)
// 维度 2/3:列名 + 列类型
for (const [col, dType] of d.columns) {
if (!s.columns.has(col)) {
diff.columnMismatches.push({ table: t, column: col, side: 'docs' })
} else {
const sType = s.columns.get(col)
if (!typesEqual(dType, sType)) {
diff.typeMismatches.push({ table: t, column: col, docsType: dType, ddlType: sType })
}
}
}
for (const col of s.columns.keys()) {
if (!d.columns.has(col)) diff.columnMismatches.push({ table: t, column: col, side: 'ddl' })
}
// 维度 4:索引。PRIMARY 由列级主键约定治理(已在列维度校验),且 docs/03 常只在 ### 字段
// 表内体现 PK、不在 ### 索引 重列 → 从两侧索引集剔除 PRIMARY,避免假阳性;命名二级索引仍比对。
const dIdx = new Set([...(d.indexes || [])].filter(ix => ix !== 'PRIMARY'))
const sIdx = new Set([...(s.indexes || [])].filter(ix => ix !== 'PRIMARY'))
for (const ix of dIdx) if (!sIdx.has(ix)) diff.indexMismatches.push({ table: t, index: ix, side: 'docs' })
for (const ix of sIdx) if (!dIdx.has(ix)) diff.indexMismatches.push({ table: t, index: ix, side: 'ddl' })
// 维度 5:外键
const dFk = d.foreignKeys || new Set()
const sFk = s.foreignKeys || new Set()
for (const fk of dFk) if (!sFk.has(fk)) diff.foreignKeyMismatches.push({ table: t, foreignKey: fk, side: 'docs' })
for (const fk of sFk) if (!dFk.has(fk)) diff.foreignKeyMismatches.push({ table: t, foreignKey: fk, side: 'ddl' })
}
diff.hasDiff = diff.missingTables.length > 0 || diff.extraTables.length > 0 ||
diff.columnMismatches.length > 0 || diff.typeMismatches.length > 0 ||
diff.indexMismatches.length > 0 || diff.foreignKeyMismatches.length > 0
return diff
}
// ── 工具函数 ─────────────────────────────────────────────────────
// 剥离 SQL 注释:-- 行注释(到行尾)、# 行注释(到行尾)、/* */ 块注释。
// 保守起见不解析字符串字面量内的注释符(DDL 极少在标识符/默认值里出现裸 -- 或 /*)。
function stripSqlComments(sql) {
return sql
.replace(/\/\*[\s\S]*?\*\//g, ' ') // 块注释
.replace(/--.*$/gm, '') // -- 行注释
.replace(/#.*$/gm, '') // # 行注释
}
function stripTicks(s) {
return String(s).replace(/`/g, '').trim()
}
function splitMarkdownRow(line) {
// 去掉首尾管道再按 | 切分
let t = line.trim()
if (t.startsWith('|')) t = t.slice(1)
if (t.endsWith('|')) t = t.slice(0, -1)
return t.split('|').map(c => c.trim())
}
function isSeparatorCell(cell) {
// 形如 --- / :--- / ---: / :---:
return /^:?-{1,}:?$/.test(cell.trim())
}
function isHeaderLabel(cell) {
// 表头标签:列 / 字段 / 字段名 / 类型 / 列名(避免把表头行当列)
return ['列', '字段', '字段名', '列名', '类型', 'name', 'type', 'column'].includes(cell.trim())
}
// 提取从 openIdx(指向 '(')开始的平衡括号内部内容(不含最外层括号)。
function extractBalancedParens(src, openIdx) {
if (src[openIdx] !== '(') return null
let depth = 0
for (let i = openIdx; i < src.length; i++) {
const ch = src[i]
if (ch === '(') depth++
else if (ch === ')') {
depth--
if (depth === 0) return src.slice(openIdx + 1, i)
}
}
return null
}
// 在顶层(括号深度 0)按逗号切分 DDL body,保护 varchar(100) / decimal(10,2) 内的逗号。
function splitTopLevelCommas(body) {
const out = []
let depth = 0
let buf = ''
for (let i = 0; i < body.length; i++) {
const ch = body[i]
if (ch === '(') { depth++; buf += ch }
else if (ch === ')') { depth--; buf += ch }
else if (ch === ',' && depth === 0) { out.push(buf); buf = '' }
else buf += ch
}
if (buf.trim()) out.push(buf)
return out
}
// 类型相等比较:大小写不敏感、忽略空白。
function typesEqual(a, b) {
const norm = (x) => String(x).toLowerCase().replace(/\s+/g, '')
return norm(a) === norm(b)
}
// ── 报告(供 CLI 与外部复用)────────────────────────────────────
export function formatDiff(diff) {
const out = []
if (diff.missingTables.length) {
out.push('=== 维度1 表集合:docs/03 有但 DDL 无 ===')
for (const t of diff.missingTables) out.push(` - ${t}`)
}
if (diff.extraTables.length) {
out.push('=== 维度1 表集合:DDL 有但 docs/03 无 ===')
for (const t of diff.extraTables) out.push(` - ${t}`)
}
if (diff.columnMismatches.length) {
out.push('=== 维度2 列名 ===')
for (const m of diff.columnMismatches) {
out.push(` - ${m.table}.${m.column} 仅在 ${m.side === 'docs' ? 'docs/03' : 'DDL'}`)
}
}
if (diff.typeMismatches.length) {
out.push('=== 维度3 列类型 ===')
for (const m of diff.typeMismatches) {
out.push(` - ${m.table}.${m.column}: docs/03=${m.docsType} ≠ DDL=${m.ddlType}`)
}
}
if (diff.indexMismatches.length) {
out.push('=== 维度4 索引 ===')
for (const m of diff.indexMismatches) {
out.push(` - ${m.table} 索引 ${m.index} 仅在 ${m.side === 'docs' ? 'docs/03' : 'DDL'}`)
}
}
if (diff.foreignKeyMismatches.length) {
out.push('=== 维度5 外键 ===')
for (const m of diff.foreignKeyMismatches) {
out.push(` - ${m.table} 外键 ${m.foreignKey} 仅在 ${m.side === 'docs' ? 'docs/03' : 'DDL'}`)
}
}
return out.join('\n')
}
// ── CLI 入口 ─────────────────────────────────────────────────────
// 用 pathToFileURL 做比较:路径含空格/非 ASCII 时 import.meta.url 是百分号编码,
// 而 process.argv[1] 是原始路径,直接 `file://${argv1}` 拼接永远不相等。
const { pathToFileURL } = await import('node:url')
const isCliEntry = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (isCliEntry) {
const { readFileSync, existsSync } = await import('node:fs')
const [docsPath, ddlPath] = process.argv.slice(2)
if (!docsPath || !ddlPath) {
console.error('用法: node lib/validate-ddl.mjs <docs/03 path> <V1.sql path>')
process.exit(2)
}
if (!existsSync(docsPath)) { console.error(`validate-ddl: docs 不存在: ${docsPath}`); process.exit(2) }
if (!existsSync(ddlPath)) { console.error(`validate-ddl: DDL 不存在: ${ddlPath}`); process.exit(2) }
const docsTables = parseDocsTables(readFileSync(docsPath, 'utf8'))
const ddlTables = parseDDL(readFileSync(ddlPath, 'utf8'))
const diff = diffSchema(docsTables, ddlTables)
if (diff.hasDiff) {
console.error(formatDiff(diff))
process.exit(1)
}
console.log('validate-ddl: ✓ docs/03 与 DDL 在 5 维(表/列/类型/索引/外键)一致')
process.exit(0)
}