Files
baiyue-firstleg/scripts/database-schema-diff.mjs
T
2026-09-20 18:51:10 +08:00

162 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { readFile } from 'node:fs/promises'
import { spawnSync } from 'node:child_process'
import path from 'node:path'
import process from 'node:process'
const DEFAULT_SCHEMAS = [
'baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_warehouse',
'baiyue_firstleg', 'baiyue_file', 'baiyue_audit', 'baiyue_event_bridge',
'baiyue_finance', 'baiyue_lastleg', 'baiyue_lastlag_warehouse',
'baiyue_event_bridge_overseas',
]
function usage() {
console.log('用法:node scripts/database-schema-diff.mjs --left-env <env文件> --right-env <env文件> [--schemas schema1,schema2] [--json]')
console.log('env 键:CHONGCHONG_ENV、CHONGCHONG_DB_HOST、CHONGCHONG_DB_PORT、CHONGCHONG_DB_USERNAME、CHONGCHONG_DB_PASSWORD')
console.log('只查询 information_schema;不读取业务数据,不执行 DDL/DML。差异退出码为 1,执行错误退出码为 2。')
}
function argument(argv, name) {
const index = argv.indexOf(name)
return index >= 0 ? argv[index + 1] : undefined
}
function parseArguments(argv) {
if (argv.includes('--help') || argv.includes('-h')) return { help: true }
const leftEnv = argument(argv, '--left-env')
const rightEnv = argument(argv, '--right-env')
if (!leftEnv || !rightEnv) throw new Error('必须同时指定 --left-env 和 --right-env')
const schemasValue = argument(argv, '--schemas')
const schemas = schemasValue ? schemasValue.split(',').map((item) => item.trim()).filter(Boolean) : DEFAULT_SCHEMAS
if (!schemas.length || schemas.some((schema) => !/^[a-z][a-z0-9_]*$/i.test(schema))) throw new Error('--schemas 包含无效 schema 名称')
return { leftEnv: path.resolve(leftEnv), rightEnv: path.resolve(rightEnv), schemas: [...new Set(schemas)], json: argv.includes('--json') }
}
function unquote(value) {
const trimmed = value.trim()
if (trimmed.length >= 2 && trimmed[0] === trimmed.at(-1) && ['"', "'"].includes(trimmed[0])) return trimmed.slice(1, -1)
return trimmed
}
async function readConfig(file) {
const values = {}
const source = await readFile(file, 'utf8')
for (const line of source.split(/\r?\n/)) {
const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/)
if (match) values[match[1]] = unquote(match[2])
}
const required = ['CHONGCHONG_DB_HOST', 'CHONGCHONG_DB_PORT', 'CHONGCHONG_DB_USERNAME', 'CHONGCHONG_DB_PASSWORD']
const missing = required.filter((key) => !values[key])
if (missing.length) throw new Error(`${path.basename(file)} 缺少:${missing.join(', ')}`)
return { ...values, label: values.CHONGCHONG_ENV || path.basename(file) }
}
function sqlStrings(values) {
return values.map((value) => `'${value.replaceAll("'", "''")}'`).join(',')
}
function runMysql(config, sql) {
const executable = process.env.MYSQL_BIN || 'mysql'
const result = spawnSync(executable, [
'--protocol=TCP', '-h', config.CHONGCHONG_DB_HOST, '-P', config.CHONGCHONG_DB_PORT,
'-u', config.CHONGCHONG_DB_USERNAME, '--batch', '--raw', '--skip-column-names',
'--default-character-set=utf8mb4', '-e', sql,
], {
encoding: 'utf8',
env: { ...process.env, MYSQL_PWD: config.CHONGCHONG_DB_PASSWORD },
maxBuffer: 32 * 1024 * 1024,
})
if (result.error) throw new Error(`${config.label} mysql 客户端执行失败:${result.error.message}`)
if (result.status !== 0) throw new Error(`${config.label} 元数据查询失败:${(result.stderr || result.stdout).trim()}`)
return result.stdout.trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line))
}
function snapshot(config, schemas) {
const selected = sqlStrings(schemas)
const schemaRows = runMysql(config, `SELECT JSON_OBJECT('schema', SCHEMA_NAME) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME IN (${selected}) ORDER BY SCHEMA_NAME`)
const tableRows = runMysql(config, `SELECT JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'type', TABLE_TYPE, 'engine', COALESCE(ENGINE, ''), 'collation', COALESCE(TABLE_COLLATION, '')) FROM information_schema.TABLES WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME`)
const columnRows = runMysql(config, `SELECT JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'column', COLUMN_NAME, 'ordinal', ORDINAL_POSITION, 'type', COLUMN_TYPE, 'nullable', IS_NULLABLE, 'defaultValue', COLUMN_DEFAULT, 'extra', EXTRA, 'charset', CHARACTER_SET_NAME, 'collation', COLLATION_NAME, 'generation', GENERATION_EXPRESSION) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION`)
return { schemas: schemaRows, tables: tableRows, columns: columnRows }
}
function keyed(rows, keyOf, normalized) {
return new Map(rows.map((row) => [keyOf(row), normalized(row)]))
}
function compareMaps(left, right) {
const leftOnly = [...left.keys()].filter((key) => !right.has(key)).sort()
const rightOnly = [...right.keys()].filter((key) => !left.has(key)).sort()
const changed = [...left.keys()].filter((key) => right.has(key) && JSON.stringify(left.get(key)) !== JSON.stringify(right.get(key))).sort()
.map((key) => ({ key, left: left.get(key), right: right.get(key) }))
return { leftOnly, rightOnly, changed }
}
export function compareSnapshots(left, right) {
const schemaKey = (row) => row.schema
const tableKey = (row) => `${row.schema}.${row.table}`
const columnKey = (row) => `${row.schema}.${row.table}.${row.column}`
const schemas = compareMaps(keyed(left.schemas, schemaKey, () => true), keyed(right.schemas, schemaKey, () => true))
const tables = compareMaps(
keyed(left.tables, tableKey, ({ type, engine, collation }) => ({ type, engine, collation })),
keyed(right.tables, tableKey, ({ type, engine, collation }) => ({ type, engine, collation })),
)
const normalizeColumn = ({ type, nullable, defaultValue, extra, charset, collation, generation }) => (
{ type, nullable, defaultValue, extra, charset, collation, generation }
)
const columns = compareMaps(keyed(left.columns, columnKey, normalizeColumn), keyed(right.columns, columnKey, normalizeColumn))
return { schemas, tables, columns }
}
function count(diff) {
return diff.leftOnly.length + diff.rightOnly.length + diff.changed.length
}
function printSection(title, diff, leftLabel, rightLabel) {
if (!count(diff)) return
console.log(`\n${title}`)
for (const key of diff.leftOnly) console.log(` 仅 ${leftLabel}: ${key}`)
for (const key of diff.rightOnly) console.log(` 仅 ${rightLabel}: ${key}`)
for (const item of diff.changed) {
console.log(` 定义不同: ${item.key}`)
console.log(` ${leftLabel}: ${JSON.stringify(item.left)}`)
console.log(` ${rightLabel}: ${JSON.stringify(item.right)}`)
}
}
async function main() {
const options = parseArguments(process.argv.slice(2))
if (options.help) return usage()
const [leftConfig, rightConfig] = await Promise.all([readConfig(options.leftEnv), readConfig(options.rightEnv)])
if (leftConfig.label === rightConfig.label) throw new Error(`两侧环境标签相同:${leftConfig.label}`)
const [left, right] = [snapshot(leftConfig, options.schemas), snapshot(rightConfig, options.schemas)]
const differences = compareSnapshots(left, right)
const result = {
left: leftConfig.label,
right: rightConfig.label,
checkedSchemas: options.schemas,
summary: {
schemas: count(differences.schemas),
tables: count(differences.tables),
columns: count(differences.columns),
},
differences,
}
if (options.json) console.log(JSON.stringify(result, null, 2))
else {
console.log(`数据库结构比较:${result.left} <-> ${result.right}`)
console.log(`检查 schema=${options.schemas.length};schema 差异=${result.summary.schemas},表差异=${result.summary.tables},字段差异=${result.summary.columns}`)
printSection('SCHEMA 差异', differences.schemas, result.left, result.right)
printSection('表差异', differences.tables, result.left, result.right)
printSection('字段差异', differences.columns, result.left, result.right)
}
if (Object.values(result.summary).some((value) => value > 0)) process.exitCode = 1
}
const isEntryPoint = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname.replace(/^\/(?:[A-Za-z]:)/, (value) => value.slice(1)))
if (isEntryPoint) main().catch((error) => {
console.error(`DATABASE_SCHEMA_DIFF_ERROR: ${error.message}`)
process.exitCode = 2
})