// lib/validate-ddl.test.mjs — 单测:docs/03 表格 ↔ DDL 5 维 diff import { test } from 'node:test' import assert from 'node:assert/strict' import { parseDocsTables, parseDDL, diffSchema } from './validate-ddl.mjs' const DOCS = `## \`t_user\`\n| 列 | 类型 |\n|---|---|\n| iId | bigint |\n| sName | varchar(50) |\n` const DDL = `CREATE TABLE t_user ( iId bigint PRIMARY KEY, sName varchar(50) );` test('matching schema yields empty diff', () => { const d = diffSchema(parseDocsTables(DOCS), parseDDL(DDL)) assert.deepEqual(d.missingTables, []) assert.deepEqual(d.columnMismatches, []) }) test('missing column is reported', () => { const ddl2 = `CREATE TABLE t_user ( iId bigint );` const d = diffSchema(parseDocsTables(DOCS), parseDDL(ddl2)) assert.ok(d.columnMismatches.some(m => m.table === 't_user' && m.column === 'sName')) }) // ── parseDocsTables ────────────────────────────────────────────── test('parseDocsTables: 列名/类型 from markdown rows under ## `table` header', () => { const tables = parseDocsTables(DOCS) assert.equal(tables.size, 1) const t = tables.get('t_user') assert.ok(t, 'table t_user parsed') assert.deepEqual([...t.columns.keys()], ['iId', 'sName']) assert.equal(t.columns.get('iId'), 'bigint') assert.equal(t.columns.get('sName'), 'varchar(50)') }) test('parseDocsTables: real docs/03 format — ## `t` — purpose + ### 字段 + backtick cols', () => { const docs = [ '## `t_order` — 订单主表', '', '### 字段', '', '| 字段 | 类型 | Nullable | 默认 | 业务含义 |', '|---|---|---|---|---|', '| `iIncrement` | int | 否 | 自增 | 主键 |', '| `sId` | varchar(100) | 是 | uuid | 业务ID |', '', '### 索引', '- `pk` (PRIMARY): iIncrement', '', '## `t_item` — 明细', '', '| 列 | 类型 |', '|---|---|', '| iId | bigint |', '', ].join('\n') const tables = parseDocsTables(docs) assert.deepEqual([...tables.keys()].sort(), ['t_item', 't_order']) const order = tables.get('t_order') assert.deepEqual([...order.columns.keys()], ['iIncrement', 'sId']) assert.equal(order.columns.get('iIncrement'), 'int') assert.equal(order.columns.get('sId'), 'varchar(100)') // header separator row and header label row must be skipped assert.equal(order.columns.has('字段'), false) assert.equal(order.columns.has('---'), false) }) // 全链路:模板格式 docs/03(### 字段 + ### 索引 + ### 外键 bullet)→ parseDocsTables 必须 // 把索引/外键解析进 Set(回归 C2:此前 parseDocsTables 从不写 indexes/foreignKeys)。 const DOCS_FULL = [ '## `t_order` — 订单主表', '', '### 字段', '| 字段 | 类型 | Nullable | 默认 | 业务含义 |', '|---|---|---|---|---|', '| `iId` | bigint | 否 | 自增 | 主键 |', '| `sUserId` | varchar(100) | 否 | — | 用户ID |', '', '### 索引', '- `pk` (PRIMARY): iId', '- `idx_user` (index): sUserId', '', '### 外键', '- `fk_user`: sUserId → t_user.sId (CASCADE)', '', ].join('\n') const DDL_FULL = [ 'CREATE TABLE `t_order` (', ' `iId` bigint NOT NULL AUTO_INCREMENT,', ' `sUserId` varchar(100) NOT NULL,', ' PRIMARY KEY (`iId`),', ' KEY `idx_user` (`sUserId`),', ' CONSTRAINT `fk_user` FOREIGN KEY (`sUserId`) REFERENCES `t_user` (`sId`) ON DELETE CASCADE', ') ENGINE=InnoDB;', ].join('\n') test('parseDocsTables: parses ### 索引 / ### 外键 bullets into sets (C2 regression)', () => { const t = parseDocsTables(DOCS_FULL).get('t_order') assert.ok(t) assert.ok(t.indexes.has('PRIMARY'), 'PRIMARY index normalized') assert.ok(t.indexes.has('idx_user:INDEX:sUserId'), 'named index normalized to name:kind:cols — got: ' + [...t.indexes]) assert.ok(t.foreignKeys.has('sUserId->t_user(sId):CASCADE'), 'FK normalized to parseDDL form with on-delete — got: ' + [...t.foreignKeys]) }) test('full chain: matching docs/03 (with indexes+FK) ↔ DDL yields no diff (C2 regression)', () => { const d = diffSchema(parseDocsTables(DOCS_FULL), parseDDL(DDL_FULL)) assert.deepEqual(d.indexMismatches, [], 'index dimension clean') assert.deepEqual(d.foreignKeyMismatches, [], 'FK dimension clean') assert.equal(d.hasDiff, false, 'no spurious diff on a faithful schema') }) test('full chain: a real FK present in docs but missing from DDL is caught', () => { const ddlNoFk = [ 'CREATE TABLE `t_order` (', ' `iId` bigint NOT NULL AUTO_INCREMENT,', ' `sUserId` varchar(100) NOT NULL,', ' PRIMARY KEY (`iId`),', ' KEY `idx_user` (`sUserId`)', ') ENGINE=InnoDB;', ].join('\n') const d = diffSchema(parseDocsTables(DOCS_FULL), parseDDL(ddlNoFk)) assert.ok(d.foreignKeyMismatches.some(m => m.side === 'docs' && m.foreignKey === 'sUserId->t_user(sId):CASCADE')) assert.equal(d.hasDiff, true) }) test('parseDDL: CREATE TABLE inside a comment is NOT counted as a table (L4)', () => { const ddl = [ '-- CREATE TABLE ghost_line ( x int );', '/* CREATE TABLE ghost_block ( y int ); */', '# CREATE TABLE ghost_hash ( z int );', 'CREATE TABLE real_one ( a int );', ].join('\n') const tables = parseDDL(ddl) assert.deepEqual([...tables.keys()], ['real_one']) }) test('parseDocsTables: top-level ## headers like "## 一、全局约定" are NOT tables', () => { const docs = [ '## 一、全局约定(人工填)', '- 数据库名: erp', '', '## `t_user`', '| 列 | 类型 |', '|---|---|', '| iId | bigint |', '', ].join('\n') const tables = parseDocsTables(docs) assert.deepEqual([...tables.keys()], ['t_user']) }) // ── parseDDL ───────────────────────────────────────────────────── test('parseDDL: columns, types, indexes, foreign keys (backtick-quoted)', () => { const ddl = [ 'CREATE TABLE `t_order` (', ' `iIncrement` int NOT NULL AUTO_INCREMENT,', ' `sId` varchar(100) DEFAULT NULL,', ' `sUserId` varchar(100) DEFAULT NULL,', ' PRIMARY KEY (`iIncrement`),', ' UNIQUE KEY `uk_sid` (`sId`),', ' KEY `idx_user` (`sUserId`),', ' CONSTRAINT `fk_user` FOREIGN KEY (`sUserId`) REFERENCES `t_user` (`sId`)', ') ENGINE=InnoDB;', ].join('\n') const tables = parseDDL(ddl) const t = tables.get('t_order') assert.ok(t) assert.deepEqual([...t.columns.keys()], ['iIncrement', 'sId', 'sUserId']) assert.equal(t.columns.get('sId'), 'varchar(100)') // index keys (named) collected; PRIMARY collected too assert.ok(t.indexes.has('uk_sid:UNIQUE:sId'), 'unique index normalized — got: ' + [...t.indexes]) assert.ok(t.indexes.has('idx_user:INDEX:sUserId'), 'named index normalized — got: ' + [...t.indexes]) assert.ok([...t.indexes].some(i => i.toUpperCase().includes('PRIMARY'))) // foreign key collected assert.ok([...t.foreignKeys].some(fk => fk.includes('sUserId') && fk.includes('t_user'))) }) test('parseDDL: unquoted identifiers and inline PRIMARY KEY', () => { const tables = parseDDL(DDL) const t = tables.get('t_user') assert.ok(t) assert.deepEqual([...t.columns.keys()], ['iId', 'sName']) assert.equal(t.columns.get('iId'), 'bigint') }) test('parseDDL: multiple tables', () => { const ddl = 'CREATE TABLE a (x int); CREATE TABLE b (y bigint);' const tables = parseDDL(ddl) assert.deepEqual([...tables.keys()].sort(), ['a', 'b']) }) // ── diffSchema 5 dimensions ────────────────────────────────────── test('diffSchema: missing table (in docs, not in DDL) reported', () => { const docs = parseDocsTables('## `t_user`\n| 列 | 类型 |\n|---|---|\n| iId | bigint |\n') const ddl = parseDDL('CREATE TABLE other ( z int );') const d = diffSchema(docs, ddl) assert.ok(d.missingTables.includes('t_user')) assert.ok(d.extraTables.includes('other')) }) test('diffSchema: type mismatch reported', () => { const docs = parseDocsTables('## `t_user`\n| 列 | 类型 |\n|---|---|\n| iId | bigint |\n') const ddl = parseDDL('CREATE TABLE t_user ( iId int );') const d = diffSchema(docs, ddl) assert.ok(d.typeMismatches.some(m => m.table === 't_user' && m.column === 'iId' && m.docsType === 'bigint' && m.ddlType === 'int')) }) test('diffSchema: extra column in DDL reported as columnMismatch', () => { const docs = parseDocsTables('## `t_user`\n| 列 | 类型 |\n|---|---|\n| iId | bigint |\n') const ddl = parseDDL('CREATE TABLE t_user ( iId bigint, extra varchar(10) );') const d = diffSchema(docs, ddl) assert.ok(d.columnMismatches.some(m => m.table === 't_user' && m.column === 'extra' && m.side === 'ddl')) }) test('diffSchema: index dimension diff reported', () => { const docs = new Map([['t', { columns: new Map([['c', 'int']]), indexes: new Set(['idx_c:INDEX:c']), foreignKeys: new Set() }]]) const ddl = parseDDL('CREATE TABLE t ( c int );') // no indexes const d = diffSchema(docs, ddl) assert.ok(d.indexMismatches.some(m => m.table === 't' && m.index === 'idx_c:INDEX:c')) }) test('diffSchema: foreign-key dimension diff reported', () => { const docs = new Map([['t', { columns: new Map([['c', 'int']]), indexes: new Set(), foreignKeys: new Set(['c->other']) }]]) const ddl = parseDDL('CREATE TABLE t ( c int );') // no FKs const d = diffSchema(docs, ddl) assert.ok(d.foreignKeyMismatches.some(m => m.table === 't' && m.foreignKey === 'c->other')) }) test('diffSchema: hasDiff is false when everything matches, true otherwise', () => { const ok = diffSchema(parseDocsTables(DOCS), parseDDL(DDL)) assert.equal(ok.hasDiff, false) const bad = diffSchema(parseDocsTables(DOCS), parseDDL('CREATE TABLE t_user ( iId bigint );')) assert.equal(bad.hasDiff, true) }) // ── 字符串字面量感知(回归)────────────────────────────────────── test('parseDDL: DEFAULT \'a--b\' 字面量中的 -- 不应被当行注释剥离', () => { const ddl = "CREATE TABLE t ( s varchar(10) DEFAULT 'a--b', x int );" const t = parseDDL(ddl).get('t') assert.ok(t) assert.deepEqual([...t.columns.keys()], ['s', 'x'], '字面量内 -- 不应吞掉后续列 x') }) test('parseDDL: DEFAULT \'#tag\' 字面量中的 # 不应被当行注释剥离', () => { const ddl = "CREATE TABLE t ( s varchar(10) DEFAULT '#tag', x int );" const t = parseDDL(ddl).get('t') assert.ok(t) assert.deepEqual([...t.columns.keys()], ['s', 'x'], '字面量内 # 不应吞掉后续列 x') }) test('parseDDL: DEFAULT \')\' 字面量中的右括号不应提前截断表体', () => { const ddl = "CREATE TABLE t ( s varchar(10) DEFAULT ')', x int );" const t = parseDDL(ddl).get('t') assert.ok(t) assert.deepEqual([...t.columns.keys()], ['s', 'x'], '字面量内 ) 不应让 depth 提前归零截断表体') }) test('parseDDL: DEFAULT \'(a,b)\' 字面量中的逗号不应被当顶层分隔', () => { const ddl = "CREATE TABLE t ( s varchar(10) DEFAULT '(a,b)', x int );" const t = parseDDL(ddl).get('t') assert.ok(t) assert.deepEqual([...t.columns.keys()], ['s', 'x']) }) // ── schema 限定表名(回归)─────────────────────────────────────── test('parseDDL: CREATE TABLE db.t 与 `db`.`t` 都应解析(取末段为表名)', () => { const tables1 = parseDDL('CREATE TABLE mydb.t_user ( id int );') assert.deepEqual([...tables1.keys()], ['t_user']) const tables2 = parseDDL('CREATE TABLE `mydb`.`t_user` ( id int );') assert.deepEqual([...tables2.keys()], ['t_user']) }) // ── 复合外键 docs↔DDL 对称(回归)──────────────────────────────── test('parseDocsTables: 复合外键 - colA, colB → other.idA, idB 应平铺成 colA,colB->other(idA,idB)', () => { const docs = [ '## `t_link`', '### 字段', '| 列 | 类型 |', '|---|---|', '| `colA` | int |', '| `colB` | int |', '### 外键', '- `fk_x`: colA, colB → other.idA, idB (CASCADE)', ].join('\n') const t = parseDocsTables(docs).get('t_link') assert.ok(t) assert.ok(t.foreignKeys.has('colA,colB->other(idA,idB):CASCADE'), 'docs-side composite FK should normalize the same way as parseDDL — got: ' + [...t.foreignKeys]) }) test('full chain: 复合外键 docs ↔ DDL 一致时不应误报双向 mismatch', () => { const docs = [ '## `t_link`', '### 字段', '| 列 | 类型 |', '|---|---|', '| `colA` | int |', '| `colB` | int |', '### 外键', '- `fk_x`: colA, colB → other.(idA, idB)', ].join('\n') const ddl = [ 'CREATE TABLE `t_link` (', ' `colA` int NOT NULL,', ' `colB` int NOT NULL,', ' CONSTRAINT `fk_x` FOREIGN KEY (`colA`, `colB`) REFERENCES `other` (`idA`, `idB`)', ') ENGINE=InnoDB;', ].join('\n') const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) assert.deepEqual(d.foreignKeyMismatches, [], '复合 FK 一致时不应误报 — got: ' + JSON.stringify(d.foreignKeyMismatches)) }) // ── 未加引号的保留字列名(回归)───────────────────────────────── test('parseDDL: 未加引号的保留字列名 `key varchar(...)` 不应被误判为索引也不应制造幽灵列(fix #2)', () => { // 列名 key 未加反引号,且后面跟的是 `varchar(`(一个类型而非 `key (`)。 // 新策略:未加反引号的保留字列名一律被跳过;用户需用反引号包裹保留字列名。 const ddl = 'CREATE TABLE t ( id int, key varchar(10) );' const t = parseDDL(ddl).get('t') assert.ok(t) assert.equal(t.columns.has('key'), false, '未加反引号的 key 应被跳过,不入 columns') assert.equal(t.indexes.size, 0, '也不应被当索引') }) test('parseDDL: 反引号包裹的保留字列名应正常解析(fix #2)', () => { const ddl = 'CREATE TABLE t ( id int, `key` varchar(10) );' const t = parseDDL(ddl).get('t') assert.ok(t) assert.ok(t.columns.has('key'), '加了反引号的 key 应被解析为普通列') assert.equal(t.columns.get('key'), 'varchar(10)') }) // ── #2 KEY/INDEX 启发式 fallthrough 不应制造幽灵列 ────────────── test('parseDDL: `KEY varchar (id)` 不应制造名为 `KEY` 的幽灵列(fix #2)', () => { const ddl = 'CREATE TABLE t ( id int, KEY varchar (id) );' const t = parseDDL(ddl).get('t') assert.ok(t) assert.deepEqual([...t.columns.keys()], ['id'], '不应出现 KEY 列') // varchar 是类型关键字,启发式跳过该项 → 既不入列也不入索引 assert.equal(t.indexes.size, 0, '保留字 + 类型名时该项应被跳过') }) test('parseDDL: `UNIQUE KEY double (c)` 不应被解析为列(fix #2/#20)', () => { const ddl = 'CREATE TABLE t ( c int, UNIQUE KEY double (c) );' const t = parseDDL(ddl).get('t') assert.ok(t) assert.deepEqual([...t.columns.keys()], ['c'], '不应出现 UNIQUE/KEY 列') }) test('parseDDL: `KEY decimal (c)` 不应被解析为列(fix #2/#20)', () => { const ddl = 'CREATE TABLE t ( c int, KEY decimal (c) );' const t = parseDDL(ddl).get('t') assert.ok(t) assert.deepEqual([...t.columns.keys()], ['c']) }) // ── #3 REFERENCES schema-qualified table ───────────────────────── test('parseDDL: FK REFERENCES mydb.users(id) 归一化为 uid->users(id)(fix #3)', () => { const ddl = [ 'CREATE TABLE t (', ' uid int NOT NULL,', ' FOREIGN KEY (uid) REFERENCES mydb.users(id)', ');', ].join('\n') const t = parseDDL(ddl).get('t') assert.ok(t) assert.ok(t.foreignKeys.has('uid->users(id):RESTRICT'), 'FK 表名应取末段 users 并附默认 on-delete — got: ' + [...t.foreignKeys]) }) // ── #4 extractType 保留 unsigned/signed 修饰 ───────────────────── test('extractType: `int unsigned` vs `int unsigned` 匹配,`int` vs `int unsigned` 报 mismatch(fix #4)', () => { const docsOk = parseDocsTables('## `t`\n| 列 | 类型 |\n|---|---|\n| id | int unsigned |\n') const ddlOk = parseDDL('CREATE TABLE t ( id int unsigned );') const ok = diffSchema(docsOk, ddlOk) assert.deepEqual(ok.typeMismatches, [], 'unsigned 两侧一致不应报错 — got: ' + JSON.stringify(ok.typeMismatches)) const docsMix = parseDocsTables('## `t`\n| 列 | 类型 |\n|---|---|\n| id | int unsigned |\n') const ddlMix = parseDDL('CREATE TABLE t ( id int );') const bad = diffSchema(docsMix, ddlMix) assert.ok(bad.typeMismatches.some(m => m.column === 'id' && m.docsType === 'int unsigned' && m.ddlType === 'int'), '一侧带 unsigned 一侧不带应报 mismatch — got: ' + JSON.stringify(bad.typeMismatches)) }) // ── #9 散文 bullet 不应被当 FK / 索引 ──────────────────────────── test('parseDocsTables: ### 外键 下的散文 bullet (含 `>`) 不应被当外键(fix #9)', () => { const docs = '## `t`\n### 外键\n- note: a > users.id\n' const t = parseDocsTables(docs).get('t') assert.ok(t) assert.equal(t.foreignKeys.size, 0, 'bare `>` 不再作为外键箭头 — got: ' + [...t.foreignKeys]) }) test('parseDocsTables: ### 索引 下纯散文 bullet 不应被当索引(fix #9)', () => { const docs = '## `t`\n### 索引\n- This bullet is not an index entry\n' const t = parseDocsTables(docs).get('t') assert.ok(t) assert.equal(t.indexes.size, 0, '散文 bullet 不再制造幽灵索引 — got: ' + [...t.indexes]) }) // ── #10 索引比较包含列与 UNIQUE-ness ──────────────────────────── test('diffSchema: 同名索引列不同应报 mismatch(fix #10)', () => { const docs = parseDocsTables([ '## `t`', '### 字段', '| 列 | 类型 |', '|---|---|', '| user_id | int |', '| wrong_col | int |', '### 索引', '- `idx_user` (index): user_id', ].join('\n')) const ddl = parseDDL([ 'CREATE TABLE `t` (', ' `user_id` int,', ' `wrong_col` int,', ' KEY `idx_user` (`wrong_col`)', ') ENGINE=InnoDB;', ].join('\n')) const d = diffSchema(docs, ddl) assert.ok(d.indexMismatches.length > 0, '同名但列不同应报 — got: ' + JSON.stringify(d.indexMismatches)) }) test('diffSchema: 同名索引 UNIQUE vs 非 UNIQUE 应报 mismatch(fix #10)', () => { const docs = parseDocsTables([ '## `t`', '### 字段', '| 列 | 类型 |', '|---|---|', '| c | int |', '### 索引', '- `uk_c` (unique): c', ].join('\n')) const ddl = parseDDL([ 'CREATE TABLE `t` (', ' `c` int,', ' KEY `uk_c` (`c`)', ') ENGINE=InnoDB;', ].join('\n')) const d = diffSchema(docs, ddl) assert.ok(d.indexMismatches.length > 0, 'UNIQUE vs INDEX 应报 — got: ' + JSON.stringify(d.indexMismatches)) }) // ── #11 ON DELETE actions differentiated ───────────────────────── test('diffSchema: FK ON DELETE CASCADE vs 缺省 RESTRICT 应报 mismatch(fix #11)', () => { const docs = parseDocsTables([ '## `t`', '### 字段', '| 列 | 类型 |', '|---|---|', '| `uid` | int |', '### 外键', '- `fk_uid`: uid → users.id (CASCADE)', ].join('\n')) const ddl = parseDDL([ 'CREATE TABLE `t` (', ' `uid` int,', ' FOREIGN KEY (`uid`) REFERENCES `users`(`id`)', ') ENGINE=InnoDB;', ].join('\n')) const d = diffSchema(docs, ddl) assert.ok(d.foreignKeyMismatches.length > 0, 'CASCADE vs RESTRICT 应报 — got: ' + JSON.stringify(d.foreignKeyMismatches)) }) // ── #16 CREATE TEMPORARY TABLE 也应被识别 ───────────────────────── test('parseDDL: CREATE TEMPORARY TABLE 也应被解析(fix #16)', () => { const tables = parseDDL('CREATE TEMPORARY TABLE t_tmp ( id int );') assert.deepEqual([...tables.keys()], ['t_tmp'], 'TEMPORARY 表应入 Map — got: ' + [...tables.keys()]) }) // ── C1: 独立语句形态的索引 / 外键(db-init A.1 强制的 DDL 形态)────────── // db-init A.1/A.2 强制 DDL 形态为:CREATE TABLE → CREATE INDEX → ALTER TABLE ADD FK // (索引 / 外键写在表体之外的独立语句)。parseDDL 必须把这些独立语句并回对应表的 // indexes / foreignKeys 集合,否则任何含索引 / 外键的 schema 首轮校验必报假阳性。 test('parseDDL: 独立 CREATE INDEX 并入对应表的 indexes(C1)', () => { const ddl = [ 'CREATE TABLE `t_order` ( `iId` int NOT NULL, `iCustomerId` int NOT NULL, PRIMARY KEY (`iId`) );', 'CREATE INDEX `idx_cust` ON `t_order` (`iCustomerId`);', ].join('\n') const t = parseDDL(ddl).get('t_order') assert.ok(t) assert.ok(t.indexes.has('idx_cust:INDEX:iCustomerId'), '独立 CREATE INDEX 应并入表索引集 — got: ' + [...t.indexes]) }) test('parseDDL: 独立 CREATE UNIQUE INDEX 归一化为 UNIQUE(C1)', () => { const ddl = [ 'CREATE TABLE `t` ( `c` int NOT NULL );', 'CREATE UNIQUE INDEX `uk_c` ON `t` (`c`);', ].join('\n') const t = parseDDL(ddl).get('t') assert.ok(t.indexes.has('uk_c:UNIQUE:c'), 'got: ' + [...t.indexes]) }) test('parseDDL: 独立 CREATE INDEX 多列归一化(C1)', () => { const ddl = [ 'CREATE TABLE `t` ( `sBrandsId` varchar(100), `sSubsidiaryId` varchar(100) );', 'CREATE INDEX `idx_tenant` ON `t` (`sBrandsId`, `sSubsidiaryId`);', ].join('\n') const t = parseDDL(ddl).get('t') assert.ok(t.indexes.has('idx_tenant:INDEX:sBrandsId,sSubsidiaryId'), 'got: ' + [...t.indexes]) }) test('parseDDL: 独立 ALTER TABLE ADD CONSTRAINT FOREIGN KEY 并入对应表的 foreignKeys(C1)', () => { const ddl = [ 'CREATE TABLE `t_order` ( `iId` int NOT NULL, `iCustomerId` int NOT NULL );', 'ALTER TABLE `t_order` ADD CONSTRAINT `fk_cust` FOREIGN KEY (`iCustomerId`) REFERENCES `t_customer` (`iIncrement`) ON DELETE RESTRICT;', ].join('\n') const t = parseDDL(ddl).get('t_order') assert.ok(t) assert.ok(t.foreignKeys.has('iCustomerId->t_customer(iIncrement):RESTRICT'), '独立 ALTER ADD FK 应并入表外键集 — got: ' + [...t.foreignKeys]) }) test('parseDDL: 独立 ALTER TABLE ADD FOREIGN KEY(无 CONSTRAINT 名)默认 RESTRICT(C1)', () => { const ddl = [ 'CREATE TABLE `t` ( `uid` int );', 'ALTER TABLE `t` ADD FOREIGN KEY (`uid`) REFERENCES `users` (`id`);', ].join('\n') const t = parseDDL(ddl).get('t') assert.ok(t.foreignKeys.has('uid->users(id):RESTRICT'), 'got: ' + [...t.foreignKeys]) }) test('full chain: A.1 形态 DDL(CREATE TABLE → CREATE INDEX → ALTER ADD FK)↔ docs/03 不应有 diff(C1 头号回归)', () => { const docs = [ '## `t_customer` — 客户表', '### 字段', '| 字段 | 类型 |', '|---|---|', '| `iIncrement` | int |', '', '## `t_order` — 订单表', '### 字段', '| 字段 | 类型 |', '|---|---|', '| `iId` | int |', '| `iCustomerId` | int |', '### 索引', '- `idx_cust` (INDEX): iCustomerId', '### 外键', '- `fk_cust`: iCustomerId → t_customer.iIncrement (RESTRICT)', '', ].join('\n') const ddl = [ 'CREATE TABLE `t_customer` ( `iIncrement` int NOT NULL, PRIMARY KEY (`iIncrement`) );', 'CREATE TABLE `t_order` ( `iId` int NOT NULL, `iCustomerId` int NOT NULL, PRIMARY KEY (`iId`) );', 'CREATE INDEX `idx_cust` ON `t_order` (`iCustomerId`);', 'ALTER TABLE `t_order` ADD CONSTRAINT `fk_cust` FOREIGN KEY (`iCustomerId`) REFERENCES `t_customer` (`iIncrement`) ON DELETE RESTRICT;', ].join('\n') const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) assert.deepEqual(d.indexMismatches, [], '索引维度应干净 — got: ' + JSON.stringify(d.indexMismatches)) assert.deepEqual(d.foreignKeyMismatches, [], '外键维度应干净 — got: ' + JSON.stringify(d.foreignKeyMismatches)) assert.equal(d.hasDiff, false, 'A.1 形态的忠实 schema 不应报 diff') }) test('full chain: 独立 ALTER ADD FK 在 docs 有而 DDL 缺时仍被捕获(C1 不掩盖真实缺失)', () => { const docs = [ '## `t_order`', '### 字段', '| 列 | 类型 |', '|---|---|', '| `iCustomerId` | int |', '### 外键', '- `fk_cust`: iCustomerId → t_customer.iIncrement (RESTRICT)', ].join('\n') const ddl = 'CREATE TABLE `t_order` ( `iCustomerId` int NOT NULL );' // FK 真的缺失 const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) assert.ok(d.foreignKeyMismatches.some(m => m.side === 'docs' && m.foreignKey === 'iCustomerId->t_customer(iIncrement):RESTRICT'), '真实缺失的 FK 仍应报 — got: ' + JSON.stringify(d.foreignKeyMismatches)) }) // ── H3: 反引号包裹的非 ASCII 表名(docs 侧 [^`]+ 接受,DDL 侧需对齐)────── test('parseDDL: 反引号包裹的中文表名应被解析(H3 标识符语法对齐)', () => { const t = parseDDL('CREATE TABLE `订单表` ( `iIncrement` int NOT NULL, PRIMARY KEY (`iIncrement`) );') assert.ok(t.get('订单表'), '中文表名应入 Map — got: ' + [...t.keys()]) }) test('full chain: docs 与 DDL 同为中文表名时不应误报 missingTables(H3)', () => { const docs = '## `订单表`\n| 列 | 类型 |\n|---|---|\n| `iIncrement` | int |\n' const ddl = 'CREATE TABLE `订单表` ( `iIncrement` int NOT NULL );' const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) assert.deepEqual(d.missingTables, [], 'got: ' + JSON.stringify(d.missingTables)) assert.deepEqual(d.extraTables, []) }) test('parseDDL: 反引号包裹的 FK 目标表为中文时归一化保留中文(H3)', () => { const ddl = [ 'CREATE TABLE `t` ( `uid` int );', 'ALTER TABLE `t` ADD FOREIGN KEY (`uid`) REFERENCES `用户表` (`id`);', ].join('\n') const t = parseDDL(ddl).get('t') assert.ok(t.foreignKeys.has('uid->用户表(id):RESTRICT'), 'got: ' + [...t.foreignKeys]) }) // ── DDL-9: 索引列归一化两侧对齐(前缀长度 / 排序方向)──────────────────── test('full chain: 前缀长度索引列 sName(20) docs↔DDL 一致时不应误报(DDL-9)', () => { const docs = [ '## `t`', '### 字段', '| 列 | 类型 |', '|---|---|', '| `sName` | varchar(100) |', '### 索引', '- `idx_name` (INDEX): sName(20)', ].join('\n') const ddl = [ 'CREATE TABLE `t` (', ' `sName` varchar(100),', ' KEY `idx_name` (`sName`(20))', ') ENGINE=InnoDB;', ].join('\n') const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) assert.deepEqual(d.indexMismatches, [], '前缀长度索引两侧应归一化为同一列名 — got: ' + JSON.stringify(d.indexMismatches)) }) // ── M3: 索引 type 槽位的中文同义词应映射到与 DDL 一致的 kind ──────────── test('parseDocsTables: 索引 type 写「唯一」中文标签应归一化为 UNIQUE(M3)', () => { const docs = '## `t`\n### 字段\n| 列 | 类型 |\n|---|---|\n| `c` | int |\n### 索引\n- `uk_c` (唯一): c\n' const t = parseDocsTables(docs).get('t') assert.ok(t.indexes.has('uk_c:UNIQUE:c'), '中文「唯一」应映射为 UNIQUE 而非 INDEX — got: ' + [...t.indexes]) }) // ── 实现复审回归(REGEX/EFFICACY 系列)──────────────────────────────── // REGEX-2:前缀长度 + 排序方向同时出现 `col(N) DESC` 时必须完全归一化到裸列名 `col`, // 否则 docs 写 `sName(20) DESC`、DDL 写裸 `sName`(或反之)会假阳性。两侧故意不对称以暴露归一化顺序 bug。 test('full chain: 索引列 `sName(20) DESC` 应完全归一化为裸列名,与裸 `sName` 对齐(REGEX-2 前缀长度+方向)', () => { const docs = [ '## `t`', '### 字段', '| 列 | 类型 |', '|---|---|', '| `sName` | varchar(100) |', '### 索引', '- `idx_name` (INDEX): sName(20) DESC', ].join('\n') const ddlInline = [ 'CREATE TABLE `t` (', ' `sName` varchar(100),', ' KEY `idx_name` (`sName`)', ') ENGINE=InnoDB;', ].join('\n') const ddlStandalone = [ 'CREATE TABLE `t` ( `sName` varchar(100) );', 'CREATE INDEX `idx_name` ON `t` (`sName`);', ].join('\n') for (const ddl of [ddlInline, ddlStandalone]) { const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) assert.deepEqual(d.indexMismatches, [], '`sName(20) DESC` 应归一化为 sName — got: ' + JSON.stringify(d.indexMismatches)) } }) // REGEX-1 / EFFICACY-4 / PROSE-1:inline KEY 名 + inline FK 目标表为中文时也应与 docs 对齐。 test('full chain: inline 中文索引名 + inline 中文 FK 目标表应与 docs 对齐(REGEX-1 / H3 一致)', () => { const docs = [ '## `订单`', '### 字段', '| 列 | 类型 |', '|---|---|', '| `user_id` | int |', '### 索引', '- `中文索引` (INDEX): user_id', '### 外键', '- `fk_u`: user_id → 用户.id (RESTRICT)', ].join('\n') const ddl = [ 'CREATE TABLE `订单` (', ' `user_id` int,', ' KEY `中文索引` (`user_id`),', ' CONSTRAINT `fk_u` FOREIGN KEY (`user_id`) REFERENCES `用户` (`id`)', ') ENGINE=InnoDB;', ].join('\n') const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) assert.deepEqual(d.indexMismatches, [], 'inline 中文索引名应对齐 — got: ' + JSON.stringify(d.indexMismatches)) assert.deepEqual(d.foreignKeyMismatches, [], 'inline 中文 FK 目标表应对齐 — got: ' + JSON.stringify(d.foreignKeyMismatches)) }) // REGEX-3:字符串字面量里的 CREATE INDEX / ALTER ADD FK 不应被独立语句扫描误当真实定义。 test('parseDDL: 字符串字面量中的 CREATE INDEX 文本不应注入幽灵索引(REGEX-3)', () => { const ddl = "CREATE TABLE `t_order` ( `iId` int NOT NULL, `note` varchar(200) DEFAULT 'CREATE INDEX `ghost` ON `t_order` (`iId`)', PRIMARY KEY (`iId`) );" const t = parseDDL(ddl).get('t_order') assert.ok(t) assert.equal([...t.indexes].some(ix => ix.includes('ghost')), false, '字面量内的 CREATE INDEX 不应成为真实索引 — got: ' + [...t.indexes]) }) test('parseDDL: 字符串字面量中的 ALTER ADD FK 文本不应注入幽灵外键(REGEX-3)', () => { const ddl = "CREATE TABLE `t` ( `c` int, `doc` varchar(300) DEFAULT 'see ALTER TABLE `t` ADD FOREIGN KEY (`c`) REFERENCES `x` (`id`)' );" const t = parseDDL(ddl).get('t') assert.ok(t) assert.equal(t.foreignKeys.size, 0, '字面量内的 ALTER ADD FK 不应成为真实外键 — got: ' + [...t.foreignKeys]) }) // REGEX-4:一条 ALTER TABLE 内多个逗号分隔 ADD FK 都应被捕获;CREATE INDEX 的 USING 子句应容忍。 test('parseDDL: 单条 ALTER 内多个 ADD FOREIGN KEY 都应被捕获(REGEX-4 multi-ADD)', () => { const ddl = [ 'CREATE TABLE `t_order` ( `a` int, `b` int );', 'CREATE TABLE `t_a` ( `id` int );', 'CREATE TABLE `t_b` ( `id` int );', 'ALTER TABLE `t_order` ADD CONSTRAINT `fk_a` FOREIGN KEY (`a`) REFERENCES `t_a` (`id`) ON DELETE CASCADE, ADD CONSTRAINT `fk_b` FOREIGN KEY (`b`) REFERENCES `t_b` (`id`);', ].join('\n') const t = parseDDL(ddl).get('t_order') assert.ok(t.foreignKeys.has('a->t_a(id):CASCADE'), '第一个 FK — got: ' + [...t.foreignKeys]) assert.ok(t.foreignKeys.has('b->t_b(id):RESTRICT'), '同条 ALTER 的第二个 FK — got: ' + [...t.foreignKeys]) }) test('parseDDL: CREATE INDEX ... USING BTREE ON ... 应被解析(REGEX-4 USING)', () => { const ddl = ['CREATE TABLE `t` ( `c` int );', 'CREATE INDEX `idx_c` USING BTREE ON `t` (`c`);'].join('\n') const t = parseDDL(ddl).get('t') assert.ok(t.indexes.has('idx_c:INDEX:c'), 'got: ' + [...t.indexes]) }) // EFFICACY-1:中文 type 映射应锚定——「主键索引」(含「主键」但不是主键) 不得被当 PRIMARY 剔除。 test('parseDocsTables: 「主键索引」标签不应被误当 PRIMARY(EFFICACY-1 锚定)', () => { const docs = '## `t`\n### 字段\n| 列 | 类型 |\n|---|---|\n| `c` | int |\n### 索引\n- `idx_c` (主键索引): c\n' const t = parseDocsTables(docs).get('t') assert.ok(t.indexes.has('idx_c:INDEX:c'), '「主键索引」应作普通 INDEX 保留 — got: ' + [...t.indexes]) assert.equal(t.indexes.has('PRIMARY'), false, '不应被剔除为 PRIMARY') }) test('parseDocsTables: 恰为「主键」/「唯一」仍正确映射(EFFICACY-1 锚定不误伤正例)', () => { const pk = parseDocsTables('## `t`\n### 索引\n- `pk` (主键): c\n').get('t') assert.ok(pk.indexes.has('PRIMARY'), '「主键」应映射 PRIMARY') const uk = parseDocsTables('## `t`\n### 字段\n| 列 | 类型 |\n|---|---|\n| `c` | int |\n### 索引\n- `uk` (唯一): c\n').get('t') assert.ok(uk.indexes.has('uk:UNIQUE:c'), '「唯一」应映射 UNIQUE — got: ' + [...uk.indexes]) })