feat(GOV-012): 实现完整 SHOW CREATE 结构导出

This commit is contained in:
2026-08-18 02:02:41 +08:00
parent 7e9d9065bb
commit dc8ad7d4b6
2 changed files with 586 additions and 0 deletions
+246
View File
@@ -1,5 +1,8 @@
import { createHash } from 'node:crypto';
import { createRequire } from 'node:module';
import mysql from 'mysql2/promise';
const require = createRequire(import.meta.url);
const mysql2Version = require('mysql2/package.json').version;
const DEVELOPMENT_DATABASE = 'a_share_analysis_dev';
@@ -30,6 +33,21 @@ WHERE EVENT_SCHEMA = ?
ORDER BY EVENT_NAME`,
});
const SERVER_METADATA_QUERY = `SELECT VERSION() AS SERVER_VERSION,
DEFAULT_CHARACTER_SET_NAME AS DATABASE_CHARSET,
DEFAULT_COLLATION_NAME AS DATABASE_COLLATION
FROM information_schema.SCHEMATA
WHERE SCHEMA_NAME = ?`;
const CREATE_RESULT_COLUMNS = Object.freeze({
TABLE: 'Create Table',
VIEW: 'Create View',
PROCEDURE: 'Create Procedure',
FUNCTION: 'Create Function',
TRIGGER: 'SQL Original Statement',
EVENT: 'Create Event',
});
export const MYSQL2_EXPORTER_METADATA = Object.freeze({
name: 'mysql2-show-create',
version: '1.0.0',
@@ -256,3 +274,231 @@ export function orderViewNames(catalog) {
}
return ordered;
}
function sha256(content) {
return createHash('sha256').update(content, 'utf8').digest('hex');
}
/** 只屏蔽 SQL 字符串字面量,保留标识符参与 DEFINER 和凭据扫描。 */
function maskStringLiterals(sql) {
return String(sql).replace(/'(?:''|\\.|[^'\\])*'/gs, "''");
}
/**
* SHOW CREATE 的格式由服务端决定,这里只移除与结构无关的账号绑定、当前表自增值、
* 行尾分号和换行差异,不重排对象体,也不删除字符串字面量中的业务文本。
*/
export function normalizeCreateDefinition(definition) {
const normalized = String(definition)
.replace(/\r\n?/g, '\n')
.replace(
/^(CREATE\s+(?:ALGORITHM=\S+\s+)?)DEFINER=`(?:``|[^`])+`@`(?:`(?:``|[^`])+`|'(?:''|[^'])+'|\S+)\s+/i,
'$1',
)
.replace(/\sAUTO_INCREMENT=\d+(?=\s(?:DEFAULT\s+)?CHARSET=)/i, '')
.trim()
.replace(/;+\s*$/, '');
const inspectable = maskStringLiterals(normalized);
if (
/\bDEFINER\s*=/i.test(inspectable) ||
/\b(?:password|passwd|pwd|api[_-]?key|api[_-]?token|access[_-]?token|client[_-]?secret|secret)\s*[:=]/i.test(
inspectable,
) ||
/\b(?:mysql|mariadb):\/\/[^\s'"`]+/i.test(inspectable)
) {
throw new SchemaExportError('UNSAFE_DEFINITION', 'Schema 对象定义包含禁止内容');
}
return normalized;
}
export async function readMysql57ServerMetadata(
connection,
database,
limits = DEFAULT_EXPORT_LIMITS,
) {
const [rows] = await connection.query({
sql: SERVER_METADATA_QUERY,
values: [database],
timeout: limits.queryTimeoutMs,
});
if (!Array.isArray(rows) || rows.length !== 1) {
throw new SchemaExportError('DATABASE_METADATA_INVALID', '开发库元数据查询结果无效');
}
const metadata = {
serverVersion: rows[0].SERVER_VERSION,
databaseCharset: rows[0].DATABASE_CHARSET,
databaseCollation: rows[0].DATABASE_COLLATION,
};
assertMysql57ServerMetadata(metadata);
return Object.freeze(metadata);
}
function getCreateDescriptors(catalog) {
const viewOrder = orderViewNames(catalog);
const viewDefinitions = new Map((catalog.views ?? []).map((name) => [name, { name, type: 'VIEW' }]));
return [
...(catalog.tables ?? []).map((name) => ({ name, type: 'TABLE' })),
...(catalog.routines ?? []).map(({ name, type }) => ({ name, type })),
...viewOrder.map((name) => viewDefinitions.get(name)),
...(catalog.triggers ?? []).map(({ name }) => ({ name, type: 'TRIGGER' })),
...(catalog.events ?? []).map((name) => ({ name, type: 'EVENT' })),
];
}
async function readCreateDefinition(connection, descriptor, limits) {
const sql = `SHOW CREATE ${descriptor.type} ${quoteIdentifier(DEVELOPMENT_DATABASE)}.${quoteIdentifier(descriptor.name)}`;
const [rows] = await connection.query({ sql, timeout: limits.queryTimeoutMs });
if (!Array.isArray(rows) || rows.length === 0) {
throw new SchemaExportError('SHOW_CREATE_EMPTY', 'SHOW CREATE 未返回对象定义');
}
const rawDefinition = rows[0]?.[CREATE_RESULT_COLUMNS[descriptor.type]];
if (typeof rawDefinition !== 'string' || rawDefinition.length === 0) {
throw new SchemaExportError('SHOW_CREATE_SHAPE_INVALID', 'SHOW CREATE 返回列无效');
}
if (Buffer.byteLength(rawDefinition, 'utf8') > limits.maxDefinitionBytes) {
throw new SchemaExportError('DEFINITION_LIMIT_EXCEEDED', 'Schema 对象定义超过安全上限');
}
return Object.freeze({
...descriptor,
definition: normalizeCreateDefinition(rawDefinition),
sourceSha256: sha256(rawDefinition.replace(/\r\n?/g, '\n')),
});
}
export async function readDefinitions(connection, catalog, limits = DEFAULT_EXPORT_LIMITS) {
const definitions = [];
for (const descriptor of getCreateDescriptors(catalog)) {
definitions.push(await readCreateDefinition(connection, descriptor, limits));
}
return Object.freeze(definitions);
}
function appendDefinition(parts, definition) {
if (['TABLE', 'VIEW'].includes(definition.type)) {
parts.push(`${definition.definition};`);
return;
}
parts.push(`DELIMITER ;;\n${definition.definition} ;;\nDELIMITER ;`);
}
/** 固定恢复顺序和会话保护,不重新格式化对象体。 */
export function renderSchemaSql(catalog, definitions) {
const definitionsByKey = new Map(
definitions.map((definition) => [`${definition.type}:${definition.name}`, definition]),
);
const parts = [
'SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0;',
'SET NAMES utf8mb4;',
];
for (const descriptor of getCreateDescriptors(catalog)) {
const definition = definitionsByKey.get(`${descriptor.type}:${descriptor.name}`);
if (!definition) {
throw new SchemaExportError('SHOW_CREATE_SHAPE_INVALID', 'Schema 对象定义集合不完整');
}
appendDefinition(parts, definition);
}
parts.push('SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS;');
return `${parts.join('\n\n')}\n`;
}
function assertWithinDeadline(startedAt, now, limits) {
if (now() - startedAt > limits.totalTimeoutMs) {
throw new SchemaExportError('EXPORT_TIMEOUT', 'Schema 导出超过总时长限制');
}
}
function buildObjectCounts(catalog) {
return Object.freeze({
tables: catalog.tables.length,
views: catalog.views.length,
triggers: catalog.triggers.length,
routines: catalog.routines.length,
events: catalog.events.length,
});
}
/**
* 单连接执行两轮目录和定义读取;任何漂移都失败。底层数据库异常统一脱敏,连接在
* 成功、查询失败、校验失败和超时路径均只关闭一次。
*/
export async function exportMysql57Schema({
config,
createConnection = mysql.createConnection,
now = Date.now,
limits = DEFAULT_EXPORT_LIMITS,
}) {
const startedAt = now();
let connection;
let result;
let failure;
try {
connection = await createConnection(buildConnectionOptions(config));
const guardedConnection = {
async query(options) {
assertWithinDeadline(startedAt, now, limits);
const queryResult = await connection.query(options);
assertWithinDeadline(startedAt, now, limits);
return queryResult;
},
};
const serverMetadata = await readMysql57ServerMetadata(
guardedConnection,
config.MYSQL_DATABASE,
limits,
);
const beforeCatalog = await readSchemaCatalog(
guardedConnection,
config.MYSQL_DATABASE,
limits,
);
const beforeDefinitions = await readDefinitions(guardedConnection, beforeCatalog, limits);
const afterCatalog = await readSchemaCatalog(
guardedConnection,
config.MYSQL_DATABASE,
limits,
);
if (sha256(JSON.stringify(beforeCatalog)) !== sha256(JSON.stringify(afterCatalog))) {
throw new SchemaExportError(
'CATALOG_CHANGED_DURING_EXPORT',
'Schema 对象目录在导出期间发生变化',
);
}
const afterDefinitions = await readDefinitions(guardedConnection, afterCatalog, limits);
if (sha256(JSON.stringify(beforeDefinitions)) !== sha256(JSON.stringify(afterDefinitions))) {
throw new SchemaExportError(
'DEFINITION_CHANGED_DURING_EXPORT',
'Schema 对象定义在导出期间发生变化',
);
}
assertWithinDeadline(startedAt, now, limits);
result = Object.freeze({
metadata: Object.freeze({
exporterName: MYSQL2_EXPORTER_METADATA.name,
exporterVersion: MYSQL2_EXPORTER_METADATA.version,
driverName: MYSQL2_EXPORTER_METADATA.driverName,
driverVersion: MYSQL2_EXPORTER_METADATA.driverVersion,
...serverMetadata,
}),
rawSql: renderSchemaSql(beforeCatalog, beforeDefinitions),
objectCounts: buildObjectCounts(beforeCatalog),
durationMs: now() - startedAt,
});
} catch (error) {
failure =
error instanceof SchemaExportError
? error
: new SchemaExportError('DATABASE_QUERY_FAILED', '开发库 Schema 查询失败');
} finally {
if (connection) {
try {
await connection.end();
} catch {
if (!failure) {
failure = new SchemaExportError('DATABASE_QUERY_FAILED', '开发库 Schema 连接关闭失败');
}
}
}
}
if (failure) throw failure;
return result;
}