Commit e69a73c7757f14704a6a9f20a21fee2981ac8445
1 parent
ce708e4b
db-init: parse standalone CREATE INDEX / ALTER ADD FK + harden connect path
validate-ddl now recognizes the db-init A.1 DDL form (CREATE TABLE → CREATE INDEX → ALTER TABLE ADD FK), so schemas with out-of-body indexes/ FKs no longer report false-positive index/FK diffs. Adds a second parse pass (mergeStandaloneIndexes / mergeStandaloneForeignKeys), string-literal blanking so DEFAULT/COMMENT text isn't mistaken for real DDL, backtick non-ASCII identifier support across all matchers (H3), and shared index- column normalization (strip backtick / prefix-length / ASC|DESC) on both docs and DDL sides. Chinese 主键/唯一 index labels map to PRIMARY/UNIQUE. apply-ddl resolves mysql2 from the target project dir (not the plugin), rejects 【人工填写】 credential placeholders before connecting, and exits 2 on missing config/DDL paths. setup-test-db template validates schema as an ASCII identifier before interpolating it into DROP/CREATE. db-init gains a B.1 toolchain precheck (mysql2 module + mysql client) before any DROP.
Showing
8 changed files
with
559 additions
and
35 deletions
lib/apply-ddl.mjs
| 1 | import { parseYamlConfig } from './yaml-config.mjs' | 1 | import { parseYamlConfig } from './yaml-config.mjs' |
| 2 | +import { createRequire } from 'node:module' | ||
| 3 | +import { pathToFileURL } from 'node:url' | ||
| 4 | +import { dirname, resolve as resolvePath, join } from 'node:path' | ||
| 5 | + | ||
| 6 | +/** | ||
| 7 | + * Resolve the `mysql2/promise` driver from the TARGET PROJECT directory. | ||
| 8 | + * | ||
| 9 | + * ESM resolves a bare specifier relative to the importing module — and this helper | ||
| 10 | + * lives inside the PLUGIN, not the project being scaffolded. A plain | ||
| 11 | + * `import('mysql2/promise')` here would therefore look in the plugin's own | ||
| 12 | + * node_modules and never see a target-project `npm i mysql2`. We instead build a | ||
| 13 | + * `require` rooted at the target project so the documented "install mysql2 in your | ||
| 14 | + * project" actually takes effect. Throws (MODULE_NOT_FOUND) when it is absent. | ||
| 15 | + * | ||
| 16 | + * @param {string} [baseDir] target project root (defaults to cwd) | ||
| 17 | + * @returns {string} absolute path to the resolved mysql2/promise entry | ||
| 18 | + */ | ||
| 19 | +export function resolveMysql2Path(baseDir = process.cwd()) { | ||
| 20 | + const require = createRequire(pathToFileURL(join(baseDir, 'package.json')).href) | ||
| 21 | + return require.resolve('mysql2/promise') | ||
| 22 | +} | ||
| 2 | 23 | ||
| 3 | /** | 24 | /** |
| 4 | * Apply a DDL file to a MySQL database using mysql2/promise. | 25 | * Apply a DDL file to a MySQL database using mysql2/promise. |
| @@ -16,7 +37,9 @@ export async function applyDDL({ configPath, ddlPath }) { | @@ -16,7 +37,9 @@ export async function applyDDL({ configPath, ddlPath }) { | ||
| 16 | 37 | ||
| 17 | let mysql | 38 | let mysql |
| 18 | try { | 39 | try { |
| 19 | - ;({ default: mysql } = await import('mysql2/promise')) | 40 | + // 从目标项目(config-vars.yaml 所在目录)解析 mysql2,而非插件自身目录(见 resolveMysql2Path)。 |
| 41 | + const resolved = resolveMysql2Path(dirname(resolvePath(configPath))) | ||
| 42 | + ;({ default: mysql } = await import(pathToFileURL(resolved).href)) | ||
| 20 | } catch { | 43 | } catch { |
| 21 | throw new MysqlUnavailableError() | 44 | throw new MysqlUnavailableError() |
| 22 | } | 45 | } |
| @@ -60,6 +83,13 @@ export function resolveDbConfig(config, cfgPath = 'config-vars.yaml') { | @@ -60,6 +83,13 @@ export function resolveDbConfig(config, cfgPath = 'config-vars.yaml') { | ||
| 60 | if (!Number.isInteger(port) || port <= 0 || port > 65535) { | 83 | if (!Number.isInteger(port) || port <= 0 || port > 65535) { |
| 61 | throw new Error(`apply-ddl: 端口非法 — ${cfgPath} 的 database.port 必须是 1..65535 的整数`) | 84 | throw new Error(`apply-ddl: 端口非法 — ${cfgPath} 的 database.port 必须是 1..65535 的整数`) |
| 62 | } | 85 | } |
| 86 | + // DDL-7:lib 自保护——拒绝把未填的「【人工填写】」凭据占位直连 MySQL。 | ||
| 87 | + // db-init 步骤 B 已用 LLM 文本检查把关,这里在真正建连的那一层再加一道防御(占位文本绝不应连库)。 | ||
| 88 | + for (const [k, v] of [['host', host], ['user', user], ['password', password], ['schema', database]]) { | ||
| 89 | + if (typeof v === 'string' && v.includes('【人工填写')) { | ||
| 90 | + throw new Error(`apply-ddl: ${cfgPath} 的 database.${k} 仍是「【人工填写】」占位 — 请先填真实凭据`) | ||
| 91 | + } | ||
| 92 | + } | ||
| 63 | return { host, port, user, password, database } | 93 | return { host, port, user, password, database } |
| 64 | } | 94 | } |
| 65 | 95 | ||
| @@ -72,13 +102,16 @@ export class MysqlUnavailableError extends Error { | @@ -72,13 +102,16 @@ export class MysqlUnavailableError extends Error { | ||
| 72 | } | 102 | } |
| 73 | 103 | ||
| 74 | // CLI entry guard:pathToFileURL 规范化 argv[1] 以匹配 import.meta.url(路径含空格 / 非 ASCII / Windows 反斜杠时字面比较会失配) | 104 | // CLI entry guard:pathToFileURL 规范化 argv[1] 以匹配 import.meta.url(路径含空格 / 非 ASCII / Windows 反斜杠时字面比较会失配) |
| 75 | -const { pathToFileURL } = await import('node:url') | ||
| 76 | if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { | 105 | if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { |
| 77 | const [configPath, ddlPath] = process.argv.slice(2) | 106 | const [configPath, ddlPath] = process.argv.slice(2) |
| 78 | if (!configPath || !ddlPath) { | 107 | if (!configPath || !ddlPath) { |
| 79 | console.error('usage: node lib/apply-ddl.mjs <configPath> <ddlPath>') | 108 | console.error('usage: node lib/apply-ddl.mjs <configPath> <ddlPath>') |
| 80 | process.exit(2) | 109 | process.exit(2) |
| 81 | } | 110 | } |
| 111 | + // DDL-6:缺文件属用法/路径错 → 退出码 2(与 db-init C.2 文档「2 = 用法错(路径找不到)」及 validate-ddl 对齐)。 | ||
| 112 | + const { existsSync } = await import('node:fs') | ||
| 113 | + if (!existsSync(configPath)) { console.error(`apply-ddl: 配置文件不存在: ${configPath}`); process.exit(2) } | ||
| 114 | + if (!existsSync(ddlPath)) { console.error(`apply-ddl: DDL 文件不存在: ${ddlPath}`); process.exit(2) } | ||
| 82 | try { | 115 | try { |
| 83 | await applyDDL({ configPath, ddlPath }) | 116 | await applyDDL({ configPath, ddlPath }) |
| 84 | console.log(`apply-ddl: applied ${ddlPath} using ${configPath}`) | 117 | console.log(`apply-ddl: applied ${ddlPath} using ${configPath}`) |
lib/apply-ddl.test.mjs
| 1 | import { test } from 'node:test' | 1 | import { test } from 'node:test' |
| 2 | import assert from 'node:assert/strict' | 2 | import assert from 'node:assert/strict' |
| 3 | -import { resolveDbConfig } from './apply-ddl.mjs' | 3 | +import { spawnSync } from 'node:child_process' |
| 4 | +import { fileURLToPath } from 'node:url' | ||
| 5 | +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' | ||
| 6 | +import { tmpdir } from 'node:os' | ||
| 7 | +import { join } from 'node:path' | ||
| 8 | +import { resolveDbConfig, resolveMysql2Path } from './apply-ddl.mjs' | ||
| 9 | + | ||
| 10 | +const APPLY = fileURLToPath(new URL('./apply-ddl.mjs', import.meta.url)) | ||
| 4 | 11 | ||
| 5 | // ── resolveDbConfig(直接读 config-vars.yaml 解析后的 database: 段)───────── | 12 | // ── resolveDbConfig(直接读 config-vars.yaml 解析后的 database: 段)───────── |
| 6 | test('resolveDbConfig maps the database section to mysql2 settings', () => { | 13 | test('resolveDbConfig maps the database section to mysql2 settings', () => { |
| @@ -31,3 +38,52 @@ test('resolveDbConfig rejects invalid ports', () => { | @@ -31,3 +38,52 @@ test('resolveDbConfig rejects invalid ports', () => { | ||
| 31 | assert.throws(() => resolveDbConfig({ database: { schema: 'erp_test', port: 'abc' } }), /database\.port/) | 38 | assert.throws(() => resolveDbConfig({ database: { schema: 'erp_test', port: 'abc' } }), /database\.port/) |
| 32 | assert.throws(() => resolveDbConfig({ database: { schema: 'erp_test', port: '70000' } }), /database\.port/) | 39 | assert.throws(() => resolveDbConfig({ database: { schema: 'erp_test', port: '70000' } }), /database\.port/) |
| 33 | }) | 40 | }) |
| 41 | + | ||
| 42 | +// ── DDL-7:lib 自保护——未填的 【人工填写】 凭据占位不得直连 MySQL ────────── | ||
| 43 | +test('resolveDbConfig rejects unfilled 【人工填写】 placeholders in credentials (DDL-7)', () => { | ||
| 44 | + const base = { host: 'h', port: '3306', user: 'u', password: 'p', schema: 's' } | ||
| 45 | + assert.throws(() => resolveDbConfig({ database: { ...base, schema: '【人工填写:schema 名】' } }), /人工填写/) | ||
| 46 | + assert.throws(() => resolveDbConfig({ database: { ...base, host: '【人工填写:MySQL host】' } }), /人工填写/) | ||
| 47 | + assert.throws(() => resolveDbConfig({ database: { ...base, user: '【人工填写:账号】' } }), /人工填写/) | ||
| 48 | + assert.throws(() => resolveDbConfig({ database: { ...base, password: '【人工填写:密码】' } }), /人工填写/) | ||
| 49 | +}) | ||
| 50 | + | ||
| 51 | +test('resolveDbConfig still accepts a fully-filled real config (DDL-7 no false positive)', () => { | ||
| 52 | + const c = resolveDbConfig({ database: { host: '127.0.0.1', port: '3306', user: 'root', password: 'p@ss', schema: 'erp_dev' } }) | ||
| 53 | + assert.equal(c.database, 'erp_dev') | ||
| 54 | +}) | ||
| 55 | + | ||
| 56 | +// ── DDL-6:缺文件应退出码 2(用法/路径错),与 db-init C.2 文档及 validate-ddl 对齐 ── | ||
| 57 | +test('apply-ddl CLI exits 2 when the config file does not exist (DDL-6)', () => { | ||
| 58 | + const r = spawnSync('node', [APPLY, '/no/such/config-vars.yaml', '/no/such/V1.sql'], { encoding: 'utf8' }) | ||
| 59 | + assert.equal(r.status, 2, 'missing config file should be exit 2, not 1 — stderr: ' + r.stderr) | ||
| 60 | +}) | ||
| 61 | + | ||
| 62 | +test('apply-ddl CLI exits 2 when the DDL file does not exist (DDL-6)', () => { | ||
| 63 | + // config exists (this very test file stands in as an existing path), DDL does not | ||
| 64 | + const r = spawnSync('node', [APPLY, APPLY, '/no/such/V1.sql'], { encoding: 'utf8' }) | ||
| 65 | + assert.equal(r.status, 2, 'missing DDL file should be exit 2 — stderr: ' + r.stderr) | ||
| 66 | +}) | ||
| 67 | + | ||
| 68 | +test('apply-ddl CLI exits 2 on missing arguments (existing behavior preserved)', () => { | ||
| 69 | + const r = spawnSync('node', [APPLY], { encoding: 'utf8' }) | ||
| 70 | + assert.equal(r.status, 2) | ||
| 71 | +}) | ||
| 72 | + | ||
| 73 | +// ── H2:mysql2 必须从「目标项目」解析,而非插件自身目录 ──────────────────── | ||
| 74 | +// ESM 裸说明符按 importer 解析;apply-ddl.mjs 住在插件目录,若直接 import('mysql2/promise') | ||
| 75 | +// 则永远看不到目标项目 `npm i mysql2` 的安装。resolveMysql2Path 用 createRequire(目标根) 修正。 | ||
| 76 | +test('resolveMysql2Path resolves mysql2/promise from the target project dir (H2)', () => { | ||
| 77 | + const dir = mkdtempSync(join(tmpdir(), 'erp-m2-')) | ||
| 78 | + mkdirSync(join(dir, 'node_modules', 'mysql2'), { recursive: true }) | ||
| 79 | + writeFileSync(join(dir, 'node_modules', 'mysql2', 'package.json'), | ||
| 80 | + JSON.stringify({ name: 'mysql2', version: '0.0.0', exports: { './promise': './promise.js' } })) | ||
| 81 | + writeFileSync(join(dir, 'node_modules', 'mysql2', 'promise.js'), 'export default {}') | ||
| 82 | + const p = resolveMysql2Path(dir) | ||
| 83 | + assert.match(p, /node_modules[\\/]mysql2[\\/]promise\.js$/, 'got: ' + p) | ||
| 84 | +}) | ||
| 85 | + | ||
| 86 | +test('resolveMysql2Path throws when mysql2 is absent in the target project (H2)', () => { | ||
| 87 | + const dir = mkdtempSync(join(tmpdir(), 'erp-m2-empty-')) | ||
| 88 | + assert.throws(() => resolveMysql2Path(dir)) | ||
| 89 | +}) |
lib/setup-test-db-template.test.mjs
0 → 100644
| 1 | +// lib/setup-test-db-template.test.mjs — 校验生成模板 scripts/setup-test-db.mjs 的 schema 守卫。 | ||
| 2 | +// 跑的是真实模板产物:复制到临时 scripts/ 下、写一个 ../config-vars.yaml、再 node 执行。 | ||
| 3 | +// 所有用例的 host/port 故意指向 127.0.0.1:1(必拒连),即便守卫缺失也绝不触碰真实库。 | ||
| 4 | +import { test } from 'node:test' | ||
| 5 | +import assert from 'node:assert/strict' | ||
| 6 | +import { spawnSync } from 'node:child_process' | ||
| 7 | +import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync } from 'node:fs' | ||
| 8 | +import { tmpdir } from 'node:os' | ||
| 9 | +import { join } from 'node:path' | ||
| 10 | +import { fileURLToPath } from 'node:url' | ||
| 11 | + | ||
| 12 | +const TEMPLATE = fileURLToPath(new URL('../skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs', import.meta.url)) | ||
| 13 | + | ||
| 14 | +function runWithSchema(schemaLine) { | ||
| 15 | + const dir = mkdtempSync(join(tmpdir(), 'erp-stdb-')) | ||
| 16 | + mkdirSync(join(dir, 'scripts')) | ||
| 17 | + copyFileSync(TEMPLATE, join(dir, 'scripts', 'setup-test-db.mjs')) | ||
| 18 | + writeFileSync( | ||
| 19 | + join(dir, 'config-vars.yaml'), | ||
| 20 | + ['database:', ' host: 127.0.0.1', ' port: 1', ' user: root', ' password: x', ' ' + schemaLine, ''].join('\n'), | ||
| 21 | + ) | ||
| 22 | + return spawnSync('node', [join(dir, 'scripts', 'setup-test-db.mjs')], { encoding: 'utf8' }) | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +// ROBUST-3:空 schema 不应进到 DROP DATABASE `` —— 守卫应先拦下。 | ||
| 26 | +test('setup-test-db: empty schema fails closed with a schema message (ROBUST-3)', () => { | ||
| 27 | + const r = runWithSchema('schema:') | ||
| 28 | + assert.equal(r.status, 1) | ||
| 29 | + assert.match(r.stderr, /schema/, '应是 schema 守卫报错而非连库失败 — stderr: ' + r.stderr) | ||
| 30 | +}) | ||
| 31 | + | ||
| 32 | +// ROBUST-3:未填的 【人工填写】 占位不应被当库名。 | ||
| 33 | +test('setup-test-db: 【人工填写】 placeholder schema fails closed (ROBUST-3)', () => { | ||
| 34 | + const r = runWithSchema('schema: 【人工填写:schema 名】') | ||
| 35 | + assert.equal(r.status, 1) | ||
| 36 | + assert.match(r.stderr, /schema/, 'stderr: ' + r.stderr) | ||
| 37 | +}) | ||
| 38 | + | ||
| 39 | +// DDL-8:含反引号的 schema(标识符注入)应被拒,而不是拼进 DROP/CREATE 语句。 | ||
| 40 | +test('setup-test-db: schema with a backtick is rejected (DDL-8 injection guard)', () => { | ||
| 41 | + const r = runWithSchema('schema: ev`il') | ||
| 42 | + assert.equal(r.status, 1) | ||
| 43 | + assert.match(r.stderr, /schema/, 'stderr: ' + r.stderr) | ||
| 44 | +}) | ||
| 45 | + | ||
| 46 | +// 正例:合法标识符 schema 应通过守卫并继续到连库阶段(此处连 127.0.0.1:1 必失败, | ||
| 47 | +// 但 stderr 应是连库/mysql 错误,而非 schema 守卫错误)——证明守卫不误伤合法名。 | ||
| 48 | +test('setup-test-db: a valid identifier schema passes the guard (no false positive)', () => { | ||
| 49 | + const r = runWithSchema('schema: erp_dev') | ||
| 50 | + // 连不上 127.0.0.1:1 → 非零退出;关键是错误不来自 schema 守卫。 | ||
| 51 | + assert.doesNotMatch(r.stderr, /database\.schema 非法|schema 非法或未填/, 'stderr: ' + r.stderr) | ||
| 52 | +}) |
lib/validate-ddl.mjs
| @@ -76,17 +76,16 @@ function parseIndexBullet(line, indexes) { | @@ -76,17 +76,16 @@ function parseIndexBullet(line, indexes) { | ||
| 76 | if (!name) return | 76 | if (!name) return |
| 77 | // 散文 bullet 守门:没有括号也没有冒号列段 → 不是索引项 | 77 | // 散文 bullet 守门:没有括号也没有冒号列段 → 不是索引项 |
| 78 | if (!type && !colsRaw) return | 78 | if (!type && !colsRaw) return |
| 79 | - if (/^primary$/i.test(type) || /^primary$/i.test(name)) { | 79 | + // PRIMARY:英文 primary 或恰为中文「主键」(M3:type 槽位可能写中文)。锚定匹配—— |
| 80 | + // 「主键索引」「主键候选」等含「主键」但非主键的标签不得被剔除为 PRIMARY(EFFICACY-1)。 | ||
| 81 | + if (/^primary$/i.test(type) || /^primary$/i.test(name) || /^主键$/.test(type)) { | ||
| 80 | indexes.add('PRIMARY') | 82 | indexes.add('PRIMARY') |
| 81 | return | 83 | return |
| 82 | } | 84 | } |
| 83 | - // 列与 UNIQUE/INDEX 类别一并参与等价比较(fix #10) | ||
| 84 | - const cols = colsRaw | ||
| 85 | - .split(',') | ||
| 86 | - .map(c => c.replace(/`/g, '').trim()) | ||
| 87 | - .filter(c => /^[A-Za-z0-9_]+$/.test(c)) | ||
| 88 | - .join(',') | ||
| 89 | - const kind = /^unique$/i.test(type) ? 'UNIQUE' : 'INDEX' | 85 | + // 列与 UNIQUE/INDEX 类别一并参与等价比较(fix #10);列归一化两侧共用(DDL-9)。 |
| 86 | + // UNIQUE 识别英文 unique 或恰为中文「唯一」(M3,锚定),否则视为普通 INDEX。 | ||
| 87 | + const cols = normalizeIndexCols(colsRaw) | ||
| 88 | + const kind = (/^unique$/i.test(type) || /^唯一$/.test(type)) ? 'UNIQUE' : 'INDEX' | ||
| 90 | indexes.add(`${name}:${kind}:${cols}`) | 89 | indexes.add(`${name}:${kind}:${cols}`) |
| 91 | } | 90 | } |
| 92 | 91 | ||
| @@ -95,7 +94,8 @@ function parseIndexBullet(line, indexes) { | @@ -95,7 +94,8 @@ function parseIndexBullet(line, indexes) { | ||
| 95 | function parseForeignKeyBullet(line, foreignKeys) { | 94 | function parseForeignKeyBullet(line, foreignKeys) { |
| 96 | // 1) 先把头部 `- `name`: ... → table` 抠出来,保留"目标表后剩余的尾段"用于解析目标列(可能是 | 95 | // 1) 先把头部 `- `name`: ... → table` 抠出来,保留"目标表后剩余的尾段"用于解析目标列(可能是 |
| 97 | // `.idA`、`.idA, idB`、`.(idA, idB)` 或 `.`idA`,`idB``)。 | 96 | // `.idA`、`.idA, idB`、`.(idA, idB)` 或 `.`idA`,`idB``)。 |
| 98 | - const head = line.match(/^\s*-\s+`?[^`:]+`?\s*:\s*([^→>\n]+?)\s*(?:→|->)\s*`?([A-Za-z0-9_]+)`?\s*\.\s*(.+)$/) | 97 | + // 目标表名用 [^`\s.]+(接受反引号包裹的中文表名,H3;以 `.` 与目标列分隔),与 docs headerRe 的非 ASCII 容许度对齐。 |
| 98 | + const head = line.match(/^\s*-\s+`?[^`:]+`?\s*:\s*([^→>\n]+?)\s*(?:→|->)\s*`?([^`\s.]+)`?\s*\.\s*(.+)$/) | ||
| 99 | if (!head) return | 99 | if (!head) return |
| 100 | const fromRaw = head[1] | 100 | const fromRaw = head[1] |
| 101 | const toTable = head[2] | 101 | const toTable = head[2] |
| @@ -126,28 +126,138 @@ function parseForeignKeyBullet(line, foreignKeys) { | @@ -126,28 +126,138 @@ function parseForeignKeyBullet(line, foreignKeys) { | ||
| 126 | } | 126 | } |
| 127 | 127 | ||
| 128 | // ── 解析 CREATE TABLE DDL ──────────────────────────────────────── | 128 | // ── 解析 CREATE TABLE DDL ──────────────────────────────────────── |
| 129 | +// 标识符 token:反引号包裹(任意非反引号字符,支持中文)或裸 ASCII 标识符(含 `$`)。 | ||
| 130 | +// docs 侧表名/索引名以 `[^`]+` 接受中文,DDL 侧此前仅 `[A-Za-z0-9_]+` → 中文名假阳性(H3)。 | ||
| 131 | +const IDENT = '(?:`[^`]+`|[A-Za-z0-9_$]+)' | ||
| 132 | + | ||
| 133 | +// 索引列归一化(两侧共用,消除 DDL-9 假阳性):去反引号 / 去前缀长度 `(N)` / 去 ASC|DESC 排序方向。 | ||
| 134 | +// 例:`sName`(20) → sName;a DESC → a。空 token 丢弃。 | ||
| 135 | +function normalizeIndexCols(raw) { | ||
| 136 | + return String(raw) | ||
| 137 | + .split(',') | ||
| 138 | + .map(c => c.replace(/`/g, '').trim()) | ||
| 139 | + .map(c => c.replace(/\s+(?:asc|desc)\s*$/i, '').trim()) // 先去排序方向:`col(N) DESC` → `col(N)` | ||
| 140 | + .map(c => c.replace(/\(\s*\d+\s*\)?\s*$/, '').trim()) // 再去前缀长度:`col(N)` → `col`(闭括号可缺,容忍被截断的 `(N`) | ||
| 141 | + .filter(Boolean) | ||
| 142 | + .join(',') | ||
| 143 | +} | ||
| 144 | + | ||
| 145 | +// 把 '...' / "..." 字符串字面量内部抹成等长空格(保留首尾引号与总长度),反引号标识符整段保留。 | ||
| 146 | +// 用于独立 CREATE INDEX / ALTER ADD FK 扫描前预处理:DEFAULT / COMMENT 字面量里出现的 "CREATE INDEX …" | ||
| 147 | +// "ALTER TABLE …" 文本不应被当成真实 DDL 语句(REGEX-3)。长度不变 → 偏移可直接用于平衡括号提取。 | ||
| 148 | +function blankStringLiterals(s) { | ||
| 149 | + let out = '' | ||
| 150 | + let i = 0 | ||
| 151 | + while (i < s.length) { | ||
| 152 | + const ch = s[i] | ||
| 153 | + if (ch === "'" || ch === '"') { | ||
| 154 | + const end = advanceLiteral(s, i) // end 指向闭引号之后 | ||
| 155 | + out += ch // 开引号 | ||
| 156 | + for (let k = i + 1; k < end - 1; k++) out += ' ' | ||
| 157 | + if (end - 1 > i) out += s[end - 1] // 闭引号(字面量已终止时) | ||
| 158 | + i = end | ||
| 159 | + continue | ||
| 160 | + } | ||
| 161 | + if (ch === '`') { // 反引号标识符整段保留——它们正是要匹配的标识符 | ||
| 162 | + const end = advanceLiteral(s, i) | ||
| 163 | + out += s.slice(i, end) | ||
| 164 | + i = end | ||
| 165 | + continue | ||
| 166 | + } | ||
| 167 | + out += ch | ||
| 168 | + i++ | ||
| 169 | + } | ||
| 170 | + return out | ||
| 171 | +} | ||
| 172 | + | ||
| 173 | +// 表体内联索引 / 外键的匹配器(与 IDENT 同语法,支持反引号包裹的非 ASCII 名,H3 全路径一致)。 | ||
| 174 | +const INLINE_KEY_RE = new RegExp( | ||
| 175 | + '^(?:UNIQUE\\s+(?:KEY|INDEX)|KEY|INDEX|FULLTEXT\\s+KEY|SPATIAL\\s+KEY)\\s+(' + IDENT + ')\\s*\\(', 'i') | ||
| 176 | +const INLINE_FK_RE = new RegExp( | ||
| 177 | + 'FOREIGN\\s+KEY\\s*\\(([^)]*)\\)\\s*REFERENCES\\s+(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s*\\(([^)]*)\\)' + | ||
| 178 | + '(?:\\s+ON\\s+DELETE\\s+(CASCADE|RESTRICT|SET\\s+NULL|SET\\s+DEFAULT|NO\\s+ACTION))?', 'i') | ||
| 179 | + | ||
| 129 | // 提取每个 CREATE TABLE 的:列名→类型、索引名集合、外键描述集合。 | 180 | // 提取每个 CREATE TABLE 的:列名→类型、索引名集合、外键描述集合。 |
| 181 | +// 第二遍并入 db-init A.1 强制的独立语句形态(CREATE INDEX / ALTER TABLE ADD FK,C1)。 | ||
| 130 | export function parseDDL(text) { | 182 | export function parseDDL(text) { |
| 131 | const tables = new Map() | 183 | const tables = new Map() |
| 132 | // 先剥离 SQL 注释,避免被注释掉的 CREATE TABLE 被当成真实表(幽灵表假阳性)。 | 184 | // 先剥离 SQL 注释,避免被注释掉的 CREATE TABLE 被当成真实表(幽灵表假阳性)。 |
| 133 | const src = stripSqlComments(String(text)) | 185 | const src = stripSqlComments(String(text)) |
| 134 | - // 抓取 CREATE TABLE <name> ( <body> ) ;name 可带反引号;body 到匹配的右括号。 | 186 | + // 抓取 CREATE TABLE <name> ( <body> ) ;name 反引号可含中文(H3);body 到匹配的右括号。 |
| 135 | // 支持可选 schema 限定名 `db`.`t` / db.t(取末段为表名,与 docs/03 一致)。 | 187 | // 支持可选 schema 限定名 `db`.`t` / db.t(取末段为表名,与 docs/03 一致)。 |
| 136 | - const createRe = /CREATE\s+(?:(?:GLOBAL|LOCAL)\s+)?(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:`?[A-Za-z0-9_]+`?\s*\.\s*)?`?([A-Za-z0-9_]+)`?\s*\(/gi | 188 | + const createRe = new RegExp( |
| 189 | + 'CREATE\\s+(?:(?:GLOBAL|LOCAL)\\s+)?(?:TEMPORARY\\s+)?TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?' + | ||
| 190 | + '(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s*\\(', 'gi') | ||
| 137 | let m | 191 | let m |
| 138 | while ((m = createRe.exec(src)) !== null) { | 192 | while ((m = createRe.exec(src)) !== null) { |
| 139 | - const tableName = m[1] | 193 | + const tableName = stripTicks(m[1]) |
| 140 | const bodyStart = createRe.lastIndex - 1 // 指向 '(' | 194 | const bodyStart = createRe.lastIndex - 1 // 指向 '(' |
| 141 | const body = extractBalancedParens(src, bodyStart) | 195 | const body = extractBalancedParens(src, bodyStart) |
| 142 | if (body == null) continue | 196 | if (body == null) continue |
| 143 | - const parsed = parseTableBody(body) | ||
| 144 | - tables.set(tableName, parsed) | 197 | + // 抹掉列体内字符串字面量再解析:避免 DEFAULT / COMMENT 里出现 "FOREIGN KEY …" / "KEY …" 文本被 |
| 198 | + // 内联检测误当真实约束(REGEX-3);反引号标识符整段保留,列名/类型解析不读字面量内容,故不受影响。 | ||
| 199 | + tables.set(tableName, parseTableBody(blankStringLiterals(body))) | ||
| 145 | // 继续从 body 之后扫描 | 200 | // 继续从 body 之后扫描 |
| 146 | createRe.lastIndex = bodyStart + body.length + 2 | 201 | createRe.lastIndex = bodyStart + body.length + 2 |
| 147 | } | 202 | } |
| 203 | + | ||
| 204 | + // 第二遍:db-init A.1/A.2 强制 DDL 形态为 CREATE TABLE → CREATE INDEX → ALTER TABLE ADD FK, | ||
| 205 | + // 索引 / 外键写在表体之外。把这些独立语句并回对应表,否则含索引 / 外键的 schema 首轮校验必报假阳性(C1)。 | ||
| 206 | + // 扫描前先抹掉字符串字面量内部,避免 DEFAULT / COMMENT 里的 "CREATE INDEX …" / "ALTER TABLE …" 文本被误当语句(REGEX-3)。 | ||
| 207 | + const scanSrc = blankStringLiterals(src) | ||
| 208 | + mergeStandaloneIndexes(scanSrc, tables) | ||
| 209 | + mergeStandaloneForeignKeys(scanSrc, tables) | ||
| 148 | return tables | 210 | return tables |
| 149 | } | 211 | } |
| 150 | 212 | ||
| 213 | +// 独立 `CREATE [UNIQUE] INDEX <name> [USING BTREE|HASH] ON [<db>.]<table> (<cols>)` → 并入 table.indexes(C1)。 | ||
| 214 | +// USING 子句可出现在 ON 之前(合法 MySQL),需容忍(REGEX-4)。 | ||
| 215 | +function mergeStandaloneIndexes(src, tables) { | ||
| 216 | + const re = new RegExp( | ||
| 217 | + 'CREATE\\s+(UNIQUE\\s+)?INDEX\\s+(' + IDENT + ')(?:\\s+USING\\s+(?:BTREE|HASH))?\\s+ON\\s+' + | ||
| 218 | + '(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s*\\(', 'gi') | ||
| 219 | + let m | ||
| 220 | + while ((m = re.exec(src)) !== null) { | ||
| 221 | + const kind = m[1] ? 'UNIQUE' : 'INDEX' | ||
| 222 | + const idxName = stripTicks(m[2]) | ||
| 223 | + const tbl = stripTicks(m[3]) | ||
| 224 | + const colsBody = extractBalancedParens(src, re.lastIndex - 1) // 指向 '(',平衡括号容纳前缀长度 (N) | ||
| 225 | + if (colsBody == null) continue | ||
| 226 | + const t = tables.get(tbl) | ||
| 227 | + if (!t) continue // 索引指向未声明的表 → 维度1(表集合)会另行报缺,这里不凭空造表 | ||
| 228 | + t.indexes.add(`${idxName}:${kind}:${normalizeIndexCols(colsBody)}`) | ||
| 229 | + } | ||
| 230 | +} | ||
| 231 | + | ||
| 232 | +// 独立 `ALTER TABLE <table> ADD [CONSTRAINT n] FOREIGN KEY (cols) REFERENCES [<db>.]<ref> (refcols) [ON DELETE x]` | ||
| 233 | +// → 并入 table.foreignKeys,归一化与 parseTableBody 内联 FK 同形(C1)。 | ||
| 234 | +// 先框定每条 ALTER 语句(到 `;` 或结尾),再在其体内抓所有 ADD…FOREIGN KEY 子句, | ||
| 235 | +// 支持一条 ALTER 内逗号分隔的多个 ADD(REGEX-4)。src 已抹掉字符串字面量,故 `;` 边界与匹配都安全。 | ||
| 236 | +function mergeStandaloneForeignKeys(src, tables) { | ||
| 237 | + const stmtRe = new RegExp( | ||
| 238 | + 'ALTER\\s+TABLE\\s+(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')([\\s\\S]*?)(?:;|$)', 'gi') | ||
| 239 | + const clauseRe = new RegExp( | ||
| 240 | + 'ADD\\s+(?:CONSTRAINT\\s+' + IDENT + '\\s+)?FOREIGN\\s+KEY\\s*\\(([^)]*)\\)\\s*REFERENCES\\s+' + | ||
| 241 | + '(?:' + IDENT + '\\s*\\.\\s*)?(' + IDENT + ')\\s*\\(([^)]*)\\)' + | ||
| 242 | + '(?:\\s+ON\\s+DELETE\\s+(CASCADE|RESTRICT|SET\\s+NULL|SET\\s+DEFAULT|NO\\s+ACTION))?', 'gi') | ||
| 243 | + let s | ||
| 244 | + while ((s = stmtRe.exec(src)) !== null) { | ||
| 245 | + const t = tables.get(stripTicks(s[1])) | ||
| 246 | + if (!t) continue | ||
| 247 | + const body = s[2] | ||
| 248 | + clauseRe.lastIndex = 0 | ||
| 249 | + let c | ||
| 250 | + while ((c = clauseRe.exec(body)) !== null) { | ||
| 251 | + const fromCols = c[1].replace(/`/g, '').replace(/\s+/g, '') | ||
| 252 | + const refTable = stripTicks(c[2]) | ||
| 253 | + const toCols = c[3].replace(/`/g, '').replace(/\s+/g, '') | ||
| 254 | + const onDelete = (c[4] || 'RESTRICT').toUpperCase().replace(/\s+/g, ' ') | ||
| 255 | + if (!fromCols || !refTable || !toCols) continue | ||
| 256 | + t.foreignKeys.add(`${fromCols}->${refTable}(${toCols}):${onDelete}`) | ||
| 257 | + } | ||
| 258 | + } | ||
| 259 | +} | ||
| 260 | + | ||
| 151 | function parseTableBody(body) { | 261 | function parseTableBody(body) { |
| 152 | const columns = new Map() | 262 | const columns = new Map() |
| 153 | const indexes = new Set() | 263 | const indexes = new Set() |
| @@ -159,11 +269,11 @@ function parseTableBody(body) { | @@ -159,11 +269,11 @@ function parseTableBody(body) { | ||
| 159 | 269 | ||
| 160 | // 外键约束(可带前缀 CONSTRAINT <name>) | 270 | // 外键约束(可带前缀 CONSTRAINT <name>) |
| 161 | if (/\bFOREIGN\s+KEY\b/i.test(item)) { | 271 | if (/\bFOREIGN\s+KEY\b/i.test(item)) { |
| 162 | - // REFERENCES 支持 schema 限定 `db`.`t` / db.t(取末段为表名,与 CREATE TABLE 一致)。 | ||
| 163 | - const fk = item.match(/FOREIGN\s+KEY\s*\(([^)]*)\)\s*REFERENCES\s+(?:`?[A-Za-z0-9_]+`?\s*\.\s*)?`?([A-Za-z0-9_]+)`?\s*\(([^)]*)\)(?:\s+ON\s+DELETE\s+(CASCADE|RESTRICT|SET\s+NULL|SET\s+DEFAULT|NO\s+ACTION))?/i) | 272 | + // REFERENCES 支持 schema 限定与反引号包裹的非 ASCII 目标表(IDENT,H3 全路径一致;取末段为表名)。 |
| 273 | + const fk = item.match(INLINE_FK_RE) | ||
| 164 | if (fk) { | 274 | if (fk) { |
| 165 | const fromCols = fk[1].replace(/`/g, '').replace(/\s+/g, '') | 275 | const fromCols = fk[1].replace(/`/g, '').replace(/\s+/g, '') |
| 166 | - const refTable = fk[2] | 276 | + const refTable = stripTicks(fk[2]) |
| 167 | const toCols = fk[3].replace(/`/g, '').replace(/\s+/g, '') | 277 | const toCols = fk[3].replace(/`/g, '').replace(/\s+/g, '') |
| 168 | const onDelete = (fk[4] || 'RESTRICT').toUpperCase().replace(/\s+/g, ' ') | 278 | const onDelete = (fk[4] || 'RESTRICT').toUpperCase().replace(/\s+/g, ' ') |
| 169 | foreignKeys.add(`${fromCols}->${refTable}(${toCols}):${onDelete}`) | 279 | foreignKeys.add(`${fromCols}->${refTable}(${toCols}):${onDelete}`) |
| @@ -183,17 +293,17 @@ function parseTableBody(body) { | @@ -183,17 +293,17 @@ function parseTableBody(body) { | ||
| 183 | // `key varchar(10)`),更可能是未加反引号的保留字列名 + 类型,回退到普通列解析避免漏列; | 293 | // `key varchar(10)`),更可能是未加反引号的保留字列名 + 类型,回退到普通列解析避免漏列; |
| 184 | // 但下游列正则会显式排斥以 KEY/INDEX/UNIQUE/FULLTEXT/SPATIAL 开头的整项,避免 fix #2 的幽灵列。 | 294 | // 但下游列正则会显式排斥以 KEY/INDEX/UNIQUE/FULLTEXT/SPATIAL 开头的整项,避免 fix #2 的幽灵列。 |
| 185 | if (/^(UNIQUE\s+(KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\b/i.test(item)) { | 295 | if (/^(UNIQUE\s+(KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\b/i.test(item)) { |
| 186 | - const nameMatch = item.match(/^(?:UNIQUE\s+(?:KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\s+`?([A-Za-z0-9_]+)`?\s*\(([^)]*)\)/i) | 296 | + // 名支持反引号包裹的非 ASCII(IDENT,H3);列体用平衡括号提取,避免前缀长度 `(N)` 处的 `)` 提前截断丢列(DDL-9)。 |
| 297 | + const nameMatch = item.match(INLINE_KEY_RE) | ||
| 187 | const SQL_TYPE_RE = /^(?:int|integer|bigint|smallint|tinyint|mediumint|varchar|char|text|blob|date|datetime|timestamp|time|year|decimal|numeric|float|double|real|bit|enum|set|json|binary|varbinary|longtext|longblob|mediumtext|mediumblob|tinytext|tinyblob)$/i | 298 | const SQL_TYPE_RE = /^(?:int|integer|bigint|smallint|tinyint|mediumint|varchar|char|text|blob|date|datetime|timestamp|time|year|decimal|numeric|float|double|real|bit|enum|set|json|binary|varbinary|longtext|longblob|mediumtext|mediumblob|tinytext|tinyblob)$/i |
| 188 | - if (nameMatch && !SQL_TYPE_RE.test(nameMatch[1])) { | ||
| 189 | - const kind = /^UNIQUE/i.test(item) ? 'UNIQUE' : 'INDEX' | ||
| 190 | - const cols = nameMatch[2] | ||
| 191 | - .split(',') | ||
| 192 | - .map(c => c.replace(/`/g, '').trim()) | ||
| 193 | - .filter(Boolean) | ||
| 194 | - .join(',') | ||
| 195 | - indexes.add(`${nameMatch[1]}:${kind}:${cols}`) | ||
| 196 | - continue | 299 | + if (nameMatch) { |
| 300 | + const idxName = stripTicks(nameMatch[1]) // 未加反引号的保留字(如 `key varchar`)启发式仍由 SQL_TYPE_RE 兜住 | ||
| 301 | + if (!SQL_TYPE_RE.test(idxName)) { | ||
| 302 | + const kind = /^UNIQUE/i.test(item) ? 'UNIQUE' : 'INDEX' | ||
| 303 | + const colsBody = extractBalancedParens(item, nameMatch[0].length - 1) // nameMatch[0] 以 '(' 结尾 | ||
| 304 | + indexes.add(`${idxName}:${kind}:${normalizeIndexCols(colsBody || '')}`) // 列归一化两侧共用(DDL-9) | ||
| 305 | + continue | ||
| 306 | + } | ||
| 197 | } | 307 | } |
| 198 | } | 308 | } |
| 199 | // CONSTRAINT <name> 但非外键(如 UNIQUE/CHECK 约束)→ 当索引/约束记 | 309 | // CONSTRAINT <name> 但非外键(如 UNIQUE/CHECK 约束)→ 当索引/约束记 |
lib/validate-ddl.test.mjs
| @@ -464,3 +464,246 @@ test('parseDDL: CREATE TEMPORARY TABLE 也应被解析(fix #16)', () => { | @@ -464,3 +464,246 @@ test('parseDDL: CREATE TEMPORARY TABLE 也应被解析(fix #16)', () => { | ||
| 464 | const tables = parseDDL('CREATE TEMPORARY TABLE t_tmp ( id int );') | 464 | const tables = parseDDL('CREATE TEMPORARY TABLE t_tmp ( id int );') |
| 465 | assert.deepEqual([...tables.keys()], ['t_tmp'], 'TEMPORARY 表应入 Map — got: ' + [...tables.keys()]) | 465 | assert.deepEqual([...tables.keys()], ['t_tmp'], 'TEMPORARY 表应入 Map — got: ' + [...tables.keys()]) |
| 466 | }) | 466 | }) |
| 467 | + | ||
| 468 | +// ── C1: 独立语句形态的索引 / 外键(db-init A.1 强制的 DDL 形态)────────── | ||
| 469 | +// db-init A.1/A.2 强制 DDL 形态为:CREATE TABLE → CREATE INDEX → ALTER TABLE ADD FK | ||
| 470 | +// (索引 / 外键写在表体之外的独立语句)。parseDDL 必须把这些独立语句并回对应表的 | ||
| 471 | +// indexes / foreignKeys 集合,否则任何含索引 / 外键的 schema 首轮校验必报假阳性。 | ||
| 472 | +test('parseDDL: 独立 CREATE INDEX 并入对应表的 indexes(C1)', () => { | ||
| 473 | + const ddl = [ | ||
| 474 | + 'CREATE TABLE `t_order` ( `iId` int NOT NULL, `iCustomerId` int NOT NULL, PRIMARY KEY (`iId`) );', | ||
| 475 | + 'CREATE INDEX `idx_cust` ON `t_order` (`iCustomerId`);', | ||
| 476 | + ].join('\n') | ||
| 477 | + const t = parseDDL(ddl).get('t_order') | ||
| 478 | + assert.ok(t) | ||
| 479 | + assert.ok(t.indexes.has('idx_cust:INDEX:iCustomerId'), | ||
| 480 | + '独立 CREATE INDEX 应并入表索引集 — got: ' + [...t.indexes]) | ||
| 481 | +}) | ||
| 482 | + | ||
| 483 | +test('parseDDL: 独立 CREATE UNIQUE INDEX 归一化为 UNIQUE(C1)', () => { | ||
| 484 | + const ddl = [ | ||
| 485 | + 'CREATE TABLE `t` ( `c` int NOT NULL );', | ||
| 486 | + 'CREATE UNIQUE INDEX `uk_c` ON `t` (`c`);', | ||
| 487 | + ].join('\n') | ||
| 488 | + const t = parseDDL(ddl).get('t') | ||
| 489 | + assert.ok(t.indexes.has('uk_c:UNIQUE:c'), 'got: ' + [...t.indexes]) | ||
| 490 | +}) | ||
| 491 | + | ||
| 492 | +test('parseDDL: 独立 CREATE INDEX 多列归一化(C1)', () => { | ||
| 493 | + const ddl = [ | ||
| 494 | + 'CREATE TABLE `t` ( `sBrandsId` varchar(100), `sSubsidiaryId` varchar(100) );', | ||
| 495 | + 'CREATE INDEX `idx_tenant` ON `t` (`sBrandsId`, `sSubsidiaryId`);', | ||
| 496 | + ].join('\n') | ||
| 497 | + const t = parseDDL(ddl).get('t') | ||
| 498 | + assert.ok(t.indexes.has('idx_tenant:INDEX:sBrandsId,sSubsidiaryId'), 'got: ' + [...t.indexes]) | ||
| 499 | +}) | ||
| 500 | + | ||
| 501 | +test('parseDDL: 独立 ALTER TABLE ADD CONSTRAINT FOREIGN KEY 并入对应表的 foreignKeys(C1)', () => { | ||
| 502 | + const ddl = [ | ||
| 503 | + 'CREATE TABLE `t_order` ( `iId` int NOT NULL, `iCustomerId` int NOT NULL );', | ||
| 504 | + 'ALTER TABLE `t_order` ADD CONSTRAINT `fk_cust` FOREIGN KEY (`iCustomerId`) REFERENCES `t_customer` (`iIncrement`) ON DELETE RESTRICT;', | ||
| 505 | + ].join('\n') | ||
| 506 | + const t = parseDDL(ddl).get('t_order') | ||
| 507 | + assert.ok(t) | ||
| 508 | + assert.ok(t.foreignKeys.has('iCustomerId->t_customer(iIncrement):RESTRICT'), | ||
| 509 | + '独立 ALTER ADD FK 应并入表外键集 — got: ' + [...t.foreignKeys]) | ||
| 510 | +}) | ||
| 511 | + | ||
| 512 | +test('parseDDL: 独立 ALTER TABLE ADD FOREIGN KEY(无 CONSTRAINT 名)默认 RESTRICT(C1)', () => { | ||
| 513 | + const ddl = [ | ||
| 514 | + 'CREATE TABLE `t` ( `uid` int );', | ||
| 515 | + 'ALTER TABLE `t` ADD FOREIGN KEY (`uid`) REFERENCES `users` (`id`);', | ||
| 516 | + ].join('\n') | ||
| 517 | + const t = parseDDL(ddl).get('t') | ||
| 518 | + assert.ok(t.foreignKeys.has('uid->users(id):RESTRICT'), 'got: ' + [...t.foreignKeys]) | ||
| 519 | +}) | ||
| 520 | + | ||
| 521 | +test('full chain: A.1 形态 DDL(CREATE TABLE → CREATE INDEX → ALTER ADD FK)↔ docs/03 不应有 diff(C1 头号回归)', () => { | ||
| 522 | + const docs = [ | ||
| 523 | + '## `t_customer` — 客户表', | ||
| 524 | + '### 字段', | ||
| 525 | + '| 字段 | 类型 |', | ||
| 526 | + '|---|---|', | ||
| 527 | + '| `iIncrement` | int |', | ||
| 528 | + '', | ||
| 529 | + '## `t_order` — 订单表', | ||
| 530 | + '### 字段', | ||
| 531 | + '| 字段 | 类型 |', | ||
| 532 | + '|---|---|', | ||
| 533 | + '| `iId` | int |', | ||
| 534 | + '| `iCustomerId` | int |', | ||
| 535 | + '### 索引', | ||
| 536 | + '- `idx_cust` (INDEX): iCustomerId', | ||
| 537 | + '### 外键', | ||
| 538 | + '- `fk_cust`: iCustomerId → t_customer.iIncrement (RESTRICT)', | ||
| 539 | + '', | ||
| 540 | + ].join('\n') | ||
| 541 | + const ddl = [ | ||
| 542 | + 'CREATE TABLE `t_customer` ( `iIncrement` int NOT NULL, PRIMARY KEY (`iIncrement`) );', | ||
| 543 | + 'CREATE TABLE `t_order` ( `iId` int NOT NULL, `iCustomerId` int NOT NULL, PRIMARY KEY (`iId`) );', | ||
| 544 | + 'CREATE INDEX `idx_cust` ON `t_order` (`iCustomerId`);', | ||
| 545 | + 'ALTER TABLE `t_order` ADD CONSTRAINT `fk_cust` FOREIGN KEY (`iCustomerId`) REFERENCES `t_customer` (`iIncrement`) ON DELETE RESTRICT;', | ||
| 546 | + ].join('\n') | ||
| 547 | + const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) | ||
| 548 | + assert.deepEqual(d.indexMismatches, [], '索引维度应干净 — got: ' + JSON.stringify(d.indexMismatches)) | ||
| 549 | + assert.deepEqual(d.foreignKeyMismatches, [], '外键维度应干净 — got: ' + JSON.stringify(d.foreignKeyMismatches)) | ||
| 550 | + assert.equal(d.hasDiff, false, 'A.1 形态的忠实 schema 不应报 diff') | ||
| 551 | +}) | ||
| 552 | + | ||
| 553 | +test('full chain: 独立 ALTER ADD FK 在 docs 有而 DDL 缺时仍被捕获(C1 不掩盖真实缺失)', () => { | ||
| 554 | + const docs = [ | ||
| 555 | + '## `t_order`', | ||
| 556 | + '### 字段', | ||
| 557 | + '| 列 | 类型 |', | ||
| 558 | + '|---|---|', | ||
| 559 | + '| `iCustomerId` | int |', | ||
| 560 | + '### 外键', | ||
| 561 | + '- `fk_cust`: iCustomerId → t_customer.iIncrement (RESTRICT)', | ||
| 562 | + ].join('\n') | ||
| 563 | + const ddl = 'CREATE TABLE `t_order` ( `iCustomerId` int NOT NULL );' // FK 真的缺失 | ||
| 564 | + const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) | ||
| 565 | + assert.ok(d.foreignKeyMismatches.some(m => m.side === 'docs' && m.foreignKey === 'iCustomerId->t_customer(iIncrement):RESTRICT'), | ||
| 566 | + '真实缺失的 FK 仍应报 — got: ' + JSON.stringify(d.foreignKeyMismatches)) | ||
| 567 | +}) | ||
| 568 | + | ||
| 569 | +// ── H3: 反引号包裹的非 ASCII 表名(docs 侧 [^`]+ 接受,DDL 侧需对齐)────── | ||
| 570 | +test('parseDDL: 反引号包裹的中文表名应被解析(H3 标识符语法对齐)', () => { | ||
| 571 | + const t = parseDDL('CREATE TABLE `订单表` ( `iIncrement` int NOT NULL, PRIMARY KEY (`iIncrement`) );') | ||
| 572 | + assert.ok(t.get('订单表'), '中文表名应入 Map — got: ' + [...t.keys()]) | ||
| 573 | +}) | ||
| 574 | + | ||
| 575 | +test('full chain: docs 与 DDL 同为中文表名时不应误报 missingTables(H3)', () => { | ||
| 576 | + const docs = '## `订单表`\n| 列 | 类型 |\n|---|---|\n| `iIncrement` | int |\n' | ||
| 577 | + const ddl = 'CREATE TABLE `订单表` ( `iIncrement` int NOT NULL );' | ||
| 578 | + const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) | ||
| 579 | + assert.deepEqual(d.missingTables, [], 'got: ' + JSON.stringify(d.missingTables)) | ||
| 580 | + assert.deepEqual(d.extraTables, []) | ||
| 581 | +}) | ||
| 582 | + | ||
| 583 | +test('parseDDL: 反引号包裹的 FK 目标表为中文时归一化保留中文(H3)', () => { | ||
| 584 | + const ddl = [ | ||
| 585 | + 'CREATE TABLE `t` ( `uid` int );', | ||
| 586 | + 'ALTER TABLE `t` ADD FOREIGN KEY (`uid`) REFERENCES `用户表` (`id`);', | ||
| 587 | + ].join('\n') | ||
| 588 | + const t = parseDDL(ddl).get('t') | ||
| 589 | + assert.ok(t.foreignKeys.has('uid->用户表(id):RESTRICT'), 'got: ' + [...t.foreignKeys]) | ||
| 590 | +}) | ||
| 591 | + | ||
| 592 | +// ── DDL-9: 索引列归一化两侧对齐(前缀长度 / 排序方向)──────────────────── | ||
| 593 | +test('full chain: 前缀长度索引列 sName(20) docs↔DDL 一致时不应误报(DDL-9)', () => { | ||
| 594 | + const docs = [ | ||
| 595 | + '## `t`', | ||
| 596 | + '### 字段', | ||
| 597 | + '| 列 | 类型 |', | ||
| 598 | + '|---|---|', | ||
| 599 | + '| `sName` | varchar(100) |', | ||
| 600 | + '### 索引', | ||
| 601 | + '- `idx_name` (INDEX): sName(20)', | ||
| 602 | + ].join('\n') | ||
| 603 | + const ddl = [ | ||
| 604 | + 'CREATE TABLE `t` (', | ||
| 605 | + ' `sName` varchar(100),', | ||
| 606 | + ' KEY `idx_name` (`sName`(20))', | ||
| 607 | + ') ENGINE=InnoDB;', | ||
| 608 | + ].join('\n') | ||
| 609 | + const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) | ||
| 610 | + assert.deepEqual(d.indexMismatches, [], | ||
| 611 | + '前缀长度索引两侧应归一化为同一列名 — got: ' + JSON.stringify(d.indexMismatches)) | ||
| 612 | +}) | ||
| 613 | + | ||
| 614 | +// ── M3: 索引 type 槽位的中文同义词应映射到与 DDL 一致的 kind ──────────── | ||
| 615 | +test('parseDocsTables: 索引 type 写「唯一」中文标签应归一化为 UNIQUE(M3)', () => { | ||
| 616 | + const docs = '## `t`\n### 字段\n| 列 | 类型 |\n|---|---|\n| `c` | int |\n### 索引\n- `uk_c` (唯一): c\n' | ||
| 617 | + const t = parseDocsTables(docs).get('t') | ||
| 618 | + assert.ok(t.indexes.has('uk_c:UNIQUE:c'), | ||
| 619 | + '中文「唯一」应映射为 UNIQUE 而非 INDEX — got: ' + [...t.indexes]) | ||
| 620 | +}) | ||
| 621 | + | ||
| 622 | +// ── 实现复审回归(REGEX/EFFICACY 系列)──────────────────────────────── | ||
| 623 | + | ||
| 624 | +// REGEX-2:前缀长度 + 排序方向同时出现 `col(N) DESC` 时必须完全归一化到裸列名 `col`, | ||
| 625 | +// 否则 docs 写 `sName(20) DESC`、DDL 写裸 `sName`(或反之)会假阳性。两侧故意不对称以暴露归一化顺序 bug。 | ||
| 626 | +test('full chain: 索引列 `sName(20) DESC` 应完全归一化为裸列名,与裸 `sName` 对齐(REGEX-2 前缀长度+方向)', () => { | ||
| 627 | + const docs = [ | ||
| 628 | + '## `t`', '### 字段', '| 列 | 类型 |', '|---|---|', '| `sName` | varchar(100) |', | ||
| 629 | + '### 索引', '- `idx_name` (INDEX): sName(20) DESC', | ||
| 630 | + ].join('\n') | ||
| 631 | + const ddlInline = [ | ||
| 632 | + 'CREATE TABLE `t` (', ' `sName` varchar(100),', ' KEY `idx_name` (`sName`)', ') ENGINE=InnoDB;', | ||
| 633 | + ].join('\n') | ||
| 634 | + const ddlStandalone = [ | ||
| 635 | + 'CREATE TABLE `t` ( `sName` varchar(100) );', | ||
| 636 | + 'CREATE INDEX `idx_name` ON `t` (`sName`);', | ||
| 637 | + ].join('\n') | ||
| 638 | + for (const ddl of [ddlInline, ddlStandalone]) { | ||
| 639 | + const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) | ||
| 640 | + assert.deepEqual(d.indexMismatches, [], '`sName(20) DESC` 应归一化为 sName — got: ' + JSON.stringify(d.indexMismatches)) | ||
| 641 | + } | ||
| 642 | +}) | ||
| 643 | + | ||
| 644 | +// REGEX-1 / EFFICACY-4 / PROSE-1:inline KEY 名 + inline FK 目标表为中文时也应与 docs 对齐。 | ||
| 645 | +test('full chain: inline 中文索引名 + inline 中文 FK 目标表应与 docs 对齐(REGEX-1 / H3 一致)', () => { | ||
| 646 | + const docs = [ | ||
| 647 | + '## `订单`', '### 字段', '| 列 | 类型 |', '|---|---|', '| `user_id` | int |', | ||
| 648 | + '### 索引', '- `中文索引` (INDEX): user_id', | ||
| 649 | + '### 外键', '- `fk_u`: user_id → 用户.id (RESTRICT)', | ||
| 650 | + ].join('\n') | ||
| 651 | + const ddl = [ | ||
| 652 | + 'CREATE TABLE `订单` (', ' `user_id` int,', | ||
| 653 | + ' KEY `中文索引` (`user_id`),', | ||
| 654 | + ' CONSTRAINT `fk_u` FOREIGN KEY (`user_id`) REFERENCES `用户` (`id`)', | ||
| 655 | + ') ENGINE=InnoDB;', | ||
| 656 | + ].join('\n') | ||
| 657 | + const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) | ||
| 658 | + assert.deepEqual(d.indexMismatches, [], 'inline 中文索引名应对齐 — got: ' + JSON.stringify(d.indexMismatches)) | ||
| 659 | + assert.deepEqual(d.foreignKeyMismatches, [], 'inline 中文 FK 目标表应对齐 — got: ' + JSON.stringify(d.foreignKeyMismatches)) | ||
| 660 | +}) | ||
| 661 | + | ||
| 662 | +// REGEX-3:字符串字面量里的 CREATE INDEX / ALTER ADD FK 不应被独立语句扫描误当真实定义。 | ||
| 663 | +test('parseDDL: 字符串字面量中的 CREATE INDEX 文本不应注入幽灵索引(REGEX-3)', () => { | ||
| 664 | + const ddl = "CREATE TABLE `t_order` ( `iId` int NOT NULL, `note` varchar(200) DEFAULT 'CREATE INDEX `ghost` ON `t_order` (`iId`)', PRIMARY KEY (`iId`) );" | ||
| 665 | + const t = parseDDL(ddl).get('t_order') | ||
| 666 | + assert.ok(t) | ||
| 667 | + assert.equal([...t.indexes].some(ix => ix.includes('ghost')), false, '字面量内的 CREATE INDEX 不应成为真实索引 — got: ' + [...t.indexes]) | ||
| 668 | +}) | ||
| 669 | + | ||
| 670 | +test('parseDDL: 字符串字面量中的 ALTER ADD FK 文本不应注入幽灵外键(REGEX-3)', () => { | ||
| 671 | + const ddl = "CREATE TABLE `t` ( `c` int, `doc` varchar(300) DEFAULT 'see ALTER TABLE `t` ADD FOREIGN KEY (`c`) REFERENCES `x` (`id`)' );" | ||
| 672 | + const t = parseDDL(ddl).get('t') | ||
| 673 | + assert.ok(t) | ||
| 674 | + assert.equal(t.foreignKeys.size, 0, '字面量内的 ALTER ADD FK 不应成为真实外键 — got: ' + [...t.foreignKeys]) | ||
| 675 | +}) | ||
| 676 | + | ||
| 677 | +// REGEX-4:一条 ALTER TABLE 内多个逗号分隔 ADD FK 都应被捕获;CREATE INDEX 的 USING 子句应容忍。 | ||
| 678 | +test('parseDDL: 单条 ALTER 内多个 ADD FOREIGN KEY 都应被捕获(REGEX-4 multi-ADD)', () => { | ||
| 679 | + const ddl = [ | ||
| 680 | + 'CREATE TABLE `t_order` ( `a` int, `b` int );', | ||
| 681 | + 'CREATE TABLE `t_a` ( `id` int );', | ||
| 682 | + 'CREATE TABLE `t_b` ( `id` int );', | ||
| 683 | + '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`);', | ||
| 684 | + ].join('\n') | ||
| 685 | + const t = parseDDL(ddl).get('t_order') | ||
| 686 | + assert.ok(t.foreignKeys.has('a->t_a(id):CASCADE'), '第一个 FK — got: ' + [...t.foreignKeys]) | ||
| 687 | + assert.ok(t.foreignKeys.has('b->t_b(id):RESTRICT'), '同条 ALTER 的第二个 FK — got: ' + [...t.foreignKeys]) | ||
| 688 | +}) | ||
| 689 | + | ||
| 690 | +test('parseDDL: CREATE INDEX ... USING BTREE ON ... 应被解析(REGEX-4 USING)', () => { | ||
| 691 | + const ddl = ['CREATE TABLE `t` ( `c` int );', 'CREATE INDEX `idx_c` USING BTREE ON `t` (`c`);'].join('\n') | ||
| 692 | + const t = parseDDL(ddl).get('t') | ||
| 693 | + assert.ok(t.indexes.has('idx_c:INDEX:c'), 'got: ' + [...t.indexes]) | ||
| 694 | +}) | ||
| 695 | + | ||
| 696 | +// EFFICACY-1:中文 type 映射应锚定——「主键索引」(含「主键」但不是主键) 不得被当 PRIMARY 剔除。 | ||
| 697 | +test('parseDocsTables: 「主键索引」标签不应被误当 PRIMARY(EFFICACY-1 锚定)', () => { | ||
| 698 | + const docs = '## `t`\n### 字段\n| 列 | 类型 |\n|---|---|\n| `c` | int |\n### 索引\n- `idx_c` (主键索引): c\n' | ||
| 699 | + const t = parseDocsTables(docs).get('t') | ||
| 700 | + assert.ok(t.indexes.has('idx_c:INDEX:c'), '「主键索引」应作普通 INDEX 保留 — got: ' + [...t.indexes]) | ||
| 701 | + assert.equal(t.indexes.has('PRIMARY'), false, '不应被剔除为 PRIMARY') | ||
| 702 | +}) | ||
| 703 | + | ||
| 704 | +test('parseDocsTables: 恰为「主键」/「唯一」仍正确映射(EFFICACY-1 锚定不误伤正例)', () => { | ||
| 705 | + const pk = parseDocsTables('## `t`\n### 索引\n- `pk` (主键): c\n').get('t') | ||
| 706 | + assert.ok(pk.indexes.has('PRIMARY'), '「主键」应映射 PRIMARY') | ||
| 707 | + const uk = parseDocsTables('## `t`\n### 字段\n| 列 | 类型 |\n|---|---|\n| `c` | int |\n### 索引\n- `uk` (唯一): c\n').get('t') | ||
| 708 | + assert.ok(uk.indexes.has('uk:UNIQUE:c'), '「唯一」应映射 UNIQUE — got: ' + [...uk.indexes]) | ||
| 709 | +}) |
skills/plan/db-design-gen/SKILL.md
| @@ -28,7 +28,8 @@ allowed-tools: Read Write Edit Grep Glob | @@ -28,7 +28,8 @@ allowed-tools: Read Write Edit Grep Glob | ||
| 28 | 1. 严格套用 `docs/04` 命名规范 + 匈牙利列前缀(`i`=int / `s`=varchar / `t`=datetime) | 28 | 1. 严格套用 `docs/04` 命名规范 + 匈牙利列前缀(`i`=int / `s`=varchar / `t`=datetime) |
| 29 | 2. **主键**:标准列 `iIncrement` int 主键。REQ 明确要求不同主键(复合主键 / UUID / 业务主键)时按 REQ,并在该表业务注记里注明偏离原因 | 29 | 2. **主键**:标准列 `iIncrement` int 主键。REQ 明确要求不同主键(复合主键 / UUID / 业务主键)时按 REQ,并在该表业务注记里注明偏离原因 |
| 30 | 3. **外键**:依据 REQ 中的引用关系(如「订单引用客户」),明确列出 `ON DELETE` / `ON UPDATE` 策略;不能确定时默认 `RESTRICT` | 30 | 3. **外键**:依据 REQ 中的引用关系(如「订单引用客户」),明确列出 `ON DELETE` / `ON UPDATE` 策略;不能确定时默认 `RESTRICT` |
| 31 | -4. **索引**:根据 REQ 的查询模式推导业务索引;外键列默认建索引;租户隔离列 `sBrandsId` / `sSubsidiaryId`(标准列)按业务查询模式建组合索引 | 31 | +4. **索引**:根据 REQ 的查询模式推导业务索引;外键列默认建索引;租户隔离列 `sBrandsId` / `sSubsidiaryId`(标准列)按业务查询模式建组合索引。 |
| 32 | + - 索引 bullet 的 `(类别)` 槽位**统一用 ASCII**:唯一索引写 `UNIQUE`、普通/组合索引写 `INDEX`(与 DDL 侧 `UNIQUE KEY` / `KEY` 对齐,validate-ddl 据此比对 UNIQUE\|INDEX 类别);主键不在 `### 索引` 重复列(由标准列 `iIncrement` 治理)。 | ||
| 32 | 5. **业务注记**:对每张表用一两句话说明业务用途、关键约束、与其他表的关系 | 33 | 5. **业务注记**:对每张表用一两句话说明业务用途、关键约束、与其他表的关系 |
| 33 | 34 | ||
| 34 | 如果某 REQ 表述模糊以致无法推断关键 schema 细节(如:枚举值范围 / 字段长度上限 / 必填性),先按合理默认推导并在该字段「业务含义」列加 `【人工填写:需用户审阅】` 标注,待步骤 E 用户审阅时调整;**不打断本次推导**。 | 35 | 如果某 REQ 表述模糊以致无法推断关键 schema 细节(如:枚举值范围 / 字段长度上限 / 必填性),先按合理默认推导并在该字段「业务含义」列加 `【人工填写:需用户审阅】` 标注,待步骤 E 用户审阅时调整;**不打断本次推导**。 |
skills/plan/db-init/SKILL.md
| 1 | --- | 1 | --- |
| 2 | name: db-init | 2 | name: db-init |
| 3 | -description: A4 DB 初始化——LLM 解析 docs/03-数据库设计文档.md → 生成 sql/migrations/V1__initial_schema.sql(DDL only,Flyway 初始 migration)→ 用 lib/validate-ddl.mjs 全量校验 DDL ↔ docs/03 一致性 → 校验 config-vars.yaml DB 凭据 5 项非空 → 调 scripts/setup-test-db.mjs DROP+CREATE 空库(连不上即失败)→ 用 lib/apply-ddl.mjs apply V1。 | 3 | +description: A4 DB 初始化——LLM 解析 docs/03-数据库设计文档.md → 生成 sql/migrations/V1__initial_schema.sql(DDL only,Flyway 初始 migration)→ 用 lib/validate-ddl.mjs 做 5 维校验(表/列/类型/索引/外键)DDL ↔ docs/03 一致性 → 连库前置预检(mysql2 模块 + mysql 客户端)→ 校验 config-vars.yaml DB 凭据 5 项非空 → 调 scripts/setup-test-db.mjs DROP+CREATE 空库(连不上即失败)→ 用 lib/apply-ddl.mjs apply V1。 |
| 4 | user-invocable: false | 4 | user-invocable: false |
| 5 | -allowed-tools: Read Write Edit Skill Bash(node *) | 5 | +allowed-tools: Read Write Edit Skill Bash(node *) Bash(npm i mysql2) Bash(npm install mysql2) |
| 6 | --- | 6 | --- |
| 7 | 7 | ||
| 8 | **所有输出必须使用中文。** | 8 | **所有输出必须使用中文。** |
| @@ -36,6 +36,8 @@ allowed-tools: Read Write Edit Skill Bash(node *) | @@ -36,6 +36,8 @@ allowed-tools: Read Write Edit Skill Bash(node *) | ||
| 36 | 36 | ||
| 37 | 调 `${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs` 做跨平台、纯 Node 的 5 维校验(表集合 / 列名 / 列类型 / 索引 / 外键)。**注意参数顺序:docs/03 在前,V1.sql 在后。** | 37 | 调 `${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs` 做跨平台、纯 Node 的 5 维校验(表集合 / 列名 / 列类型 / 索引 / 外键)。**注意参数顺序:docs/03 在前,V1.sql 在后。** |
| 38 | 38 | ||
| 39 | +> **机检边界(勿误解)**:5 维 = 表集合 / 列名 / 列类型 / 索引(名 + UNIQUE\|INDEX 类别 + 列)/ 外键(列 → 表(列) + ON DELETE);表体内联与独立 `CREATE INDEX` / `ALTER TABLE ... ADD FOREIGN KEY` 两种形态都识别。**A.1 要求的「字段顺序 / 可空 / 默认 / 列注释对齐」不在机检范围内**——这几项靠 A.1 翻译时忠实对齐 docs/03(docs/03 已在 A3 人工审阅过),validate-ddl 不会代为兜底,勿因校验通过就认定它们也一致。 | ||
| 40 | + | ||
| 39 | ```bash | 41 | ```bash |
| 40 | node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ | 42 | node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ |
| 41 | docs/03-数据库设计文档.md \ | 43 | docs/03-数据库设计文档.md \ |
| @@ -54,8 +56,24 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ | @@ -54,8 +56,24 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ | ||
| 54 | - ` - [ ] sql/migrations/V1__initial_schema.sql 已生成` | 56 | - ` - [ ] sql/migrations/V1__initial_schema.sql 已生成` |
| 55 | - ` - [ ] DDL ↔ docs/03 5 维一致(validate-ddl.mjs)` | 57 | - ` - [ ] DDL ↔ docs/03 5 维一致(validate-ddl.mjs)` |
| 56 | 58 | ||
| 57 | -### B. 数据库环境检查 | 59 | +### B. 数据库环境检查(连库前置——必须在步骤 C 的任何 DROP 之前完成) |
| 60 | + | ||
| 61 | +#### B.1 工具链预检 | ||
| 62 | +A4 连库用到两样东西:`apply-ddl.mjs` 依赖 `mysql2` 模块、`setup-test-db.mjs` 依赖 `mysql` 客户端。两者都在 C 阶段才被调用,而 C.1 第一步就是 `DROP DATABASE`——所以必须**在此先探测、缺则补齐**,避免"删到一半才发现缺工具"。 | ||
| 63 | + | ||
| 64 | +1. **mysql2 驱动**(`apply-ddl.mjs` 从 config-vars.yaml 所在目录解析 mysql2,而非插件目录;按 A4 既定调用 `apply-ddl.mjs config-vars.yaml …`、cwd = 项目根,该目录即项目根,故下面按 cwd 解析的 `node -e` 探测对此调用具代表性): | ||
| 65 | + ```bash | ||
| 66 | + node -e "import('mysql2/promise').then(()=>process.exit(0),()=>process.exit(1))" | ||
| 67 | + ``` | ||
| 68 | + - 退出 `0` → 已就绪。 | ||
| 69 | + - 退出非 `0` → 在项目根执行 `npm i mysql2`(首次会生成 / 更新根 `package.json` + `node_modules`;`.gitignore` 已忽略 `node_modules`),再重跑上面一行确认;仍失败 → 打印 stderr 并停下。 | ||
| 70 | +2. **mysql 客户端**: | ||
| 71 | + ```bash | ||
| 72 | + node -e "process.exit(require('node:child_process').spawnSync('mysql',['--version']).status===0?0:1)" | ||
| 73 | + ``` | ||
| 74 | + - 退出非 `0` → 打印「未找到 mysql 客户端,请安装并加入 PATH 后重跑 /plan-start」并停下(**不进入 C**)。 | ||
| 58 | 75 | ||
| 76 | +#### B.2 凭据校验 | ||
| 59 | 用 `Read` 读 `config-vars.yaml` 的 `database:` 段(文件缺失 → 提示重跑 A1 `scope-lock` 并停下),校验 `host` / `port` / `user` / `password` / `schema` 5 项均非空且非 `【人工填写` 占位——任一缺失 → 打印缺失字段并停下。 | 77 | 用 `Read` 读 `config-vars.yaml` 的 `database:` 段(文件缺失 → 提示重跑 A1 `scope-lock` 并停下),校验 `host` / `port` / `user` / `password` / `schema` 5 项均非空且非 `【人工填写` 占位——任一缺失 → 打印缺失字段并停下。 |
| 60 | 78 | ||
| 61 | 连通性无需在此单独探测:步骤 C.1 的 `setup-test-db.mjs` 会用同一份凭据连同一个 MySQL 跑 `DROP+CREATE`,连不上即报错(认证 / 主机不可达 / 端口拒接);且 `DROP DATABASE IF EXISTS` 在连不上时不破坏任何东西,由 C.1 失败即可。 | 79 | 连通性无需在此单独探测:步骤 C.1 的 `setup-test-db.mjs` 会用同一份凭据连同一个 MySQL 跑 `DROP+CREATE`,连不上即报错(认证 / 主机不可达 / 端口拒接);且 `DROP DATABASE IF EXISTS` 在连不上时不破坏任何东西,由 C.1 失败即可。 |
skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs
| 1 | #!/usr/bin/env node | 1 | #!/usr/bin/env node |
| 2 | // scripts/setup-test-db.mjs — DROP + CREATE 测试库。 | 2 | // scripts/setup-test-db.mjs — DROP + CREATE 测试库。 |
| 3 | // 由 coding.mjs 的 test-gate 调用;schema 由 Flyway 在 Spring Boot 启动时重放。 | 3 | // 由 coding.mjs 的 test-gate 调用;schema 由 Flyway 在 Spring Boot 启动时重放。 |
| 4 | -// DB 凭据从仓库根 config-vars.yaml 的 database: 段读取(host / schema 完全信任该文件,无额外校验)。 | 4 | +// DB 凭据从仓库根 config-vars.yaml 的 database: 段读取:schema 经标识符校验后才拼进 SQL(防误删 / 注入,见下方守卫); |
| 5 | +// host / user / password 信任该文件,port 仅校验范围。 | ||
| 5 | 6 | ||
| 6 | import { spawnSync } from 'node:child_process' | 7 | import { spawnSync } from 'node:child_process' |
| 7 | import { existsSync, readFileSync } from 'node:fs' | 8 | import { existsSync, readFileSync } from 'node:fs' |
| @@ -72,6 +73,16 @@ if (!/^\d+$/.test(DB_PORT) || Number(DB_PORT) <= 0 || Number(DB_PORT) > 65535) { | @@ -72,6 +73,16 @@ if (!/^\d+$/.test(DB_PORT) || Number(DB_PORT) <= 0 || Number(DB_PORT) > 65535) { | ||
| 72 | process.exit(1) | 73 | process.exit(1) |
| 73 | } | 74 | } |
| 74 | 75 | ||
| 76 | +// schema 是被无条件 DROP + CREATE 的标识符——必须严格校验后才拼进 SQL: | ||
| 77 | +// · 空值 → 避免 DROP DATABASE `` 这类无意义/误删语句 | ||
| 78 | +// · 「【人工填写】」占位 → 配置尚未填好,不应连库 | ||
| 79 | +// · 含反引号 → 防止 `erp`; DROP DATABASE `prod` 形态的标识符注入(值来自 config-vars.yaml,按 fail-closed 处理) | ||
| 80 | +// 注:仅接受 ASCII 标识符;非 ASCII schema 名一律拒绝(即便 MySQL / apply-ddl 允许),与推荐的 test/_dev 命名一致 | ||
| 81 | +if (!/^[A-Za-z0-9_$]+$/.test(DB_SCHEMA)) { | ||
| 82 | + console.error(`[setup-test-db] database.schema 非法或未填: ${JSON.stringify(DB_SCHEMA)}(需为 [A-Za-z0-9_$] 标识符;空值 / 「【人工填写】」占位 / 含反引号均拒绝)`) | ||
| 83 | + process.exit(1) | ||
| 84 | +} | ||
| 85 | + | ||
| 75 | console.log(`[setup-test-db] 即将 DROP + CREATE \`${DB_SCHEMA}\` on ${DB_HOST}:${DB_PORT}`) | 86 | console.log(`[setup-test-db] 即将 DROP + CREATE \`${DB_SCHEMA}\` on ${DB_HOST}:${DB_PORT}`) |
| 76 | 87 | ||
| 77 | const sql = | 88 | const sql = |