优化
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
#!/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
|
||||
})
|
||||
Reference in New Issue
Block a user