feat(GOV-012): 实现完整 SHOW CREATE 结构导出
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user