validate-ddl.mjs
13.1 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
// 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 表格首列是列名(可含反引号),次列是类型。
// 跳过表头行(列/字段/类型等标签)与分隔行(---)。
// 形如「## 一、全局约定」这类非反引号标题不视为表。
export function parseDocsTables(text) {
const tables = new Map()
const lines = String(text).split('\n')
// 反引号包裹的表名:## `name` 或 ## `name` — purpose
const headerRe = /^##\s+`([^`]+)`/
let current = null // { columns: Map }
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() }
tables.set(h2[1].trim(), current)
continue
}
// 任何其它二级(或更高)非反引号标题 → 结束当前表块(如 ## 一、全局约定)
if (/^##\s/.test(line) && !headerRe.test(line)) {
current = null
continue
}
if (!current) continue
// 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
}
// ── 解析 CREATE TABLE DDL ────────────────────────────────────────
// 提取每个 CREATE TABLE 的:列名→类型、索引名集合、外键描述集合。
export function parseDDL(text) {
const tables = new Map()
const src = 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:索引
const dIdx = d.indexes || new Set()
const sIdx = s.indexes || new Set()
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
}
// ── 工具函数 ─────────────────────────────────────────────────────
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)
}