259 lines
9.7 KiB
JavaScript
259 lines
9.7 KiB
JavaScript
import { createRequire } from 'node:module';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const mysql2Version = require('mysql2/package.json').version;
|
|
const DEVELOPMENT_DATABASE = 'a_share_analysis_dev';
|
|
|
|
const CATALOG_QUERIES = Object.freeze({
|
|
tables: `SELECT TABLE_NAME, TABLE_TYPE
|
|
FROM information_schema.TABLES
|
|
WHERE TABLE_SCHEMA = ?
|
|
ORDER BY TABLE_NAME`,
|
|
viewDependencies: `SELECT VIEW_NAME, TABLE_SCHEMA AS REFERENCED_SCHEMA_NAME,
|
|
TABLE_NAME AS REFERENCED_TABLE_NAME
|
|
FROM information_schema.VIEW_TABLE_USAGE
|
|
WHERE VIEW_SCHEMA = ?
|
|
ORDER BY VIEW_NAME, REFERENCED_SCHEMA_NAME, REFERENCED_TABLE_NAME`,
|
|
routines: `SELECT ROUTINE_NAME, ROUTINE_TYPE
|
|
FROM information_schema.ROUTINES
|
|
WHERE ROUTINE_SCHEMA = ?
|
|
ORDER BY ROUTINE_TYPE, ROUTINE_NAME`,
|
|
triggers: `SELECT TRIGGER_NAME, EVENT_OBJECT_TABLE, ACTION_TIMING,
|
|
EVENT_MANIPULATION, ACTION_ORDER
|
|
FROM information_schema.TRIGGERS
|
|
WHERE TRIGGER_SCHEMA = ?
|
|
ORDER BY EVENT_OBJECT_TABLE, ACTION_TIMING, EVENT_MANIPULATION,
|
|
ACTION_ORDER, TRIGGER_NAME`,
|
|
events: `SELECT EVENT_NAME
|
|
FROM information_schema.EVENTS
|
|
WHERE EVENT_SCHEMA = ?
|
|
ORDER BY EVENT_NAME`,
|
|
});
|
|
|
|
export const MYSQL2_EXPORTER_METADATA = Object.freeze({
|
|
name: 'mysql2-show-create',
|
|
version: '1.0.0',
|
|
driverName: 'mysql2',
|
|
driverVersion: mysql2Version,
|
|
});
|
|
|
|
export const DEFAULT_EXPORT_LIMITS = Object.freeze({
|
|
connectTimeoutMs: 5_000,
|
|
queryTimeoutMs: 5_000,
|
|
totalTimeoutMs: 30_000,
|
|
maxObjects: 5_000,
|
|
maxDefinitionBytes: 1_048_576,
|
|
});
|
|
|
|
export class SchemaExportError extends Error {
|
|
constructor(code, message) {
|
|
super(message);
|
|
this.name = 'SchemaExportError';
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 配置在 Schema 治理入口已完成 development、主机白名单和固定库名校验;这里仅映射
|
|
* 连接所需字段,固定禁用多语句以防止未来元数据查询被拼接为任意 SQL。
|
|
*/
|
|
export function buildConnectionOptions(config) {
|
|
return {
|
|
host: config.MYSQL_HOST,
|
|
port: Number(config.MYSQL_PORT),
|
|
user: config.MYSQL_USERNAME,
|
|
password: config.MYSQL_PASSWORD,
|
|
database: config.MYSQL_DATABASE,
|
|
charset: 'utf8mb4',
|
|
timezone: '+08:00',
|
|
connectTimeout: DEFAULT_EXPORT_LIMITS.connectTimeoutMs,
|
|
multipleStatements: false,
|
|
enableKeepAlive: true,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 元数据对象名仍需防御性转义,避免控制字符破坏 `SHOW CREATE` 查询边界;名称不合法时
|
|
* 不回显输入,确保异常路径不会把潜在敏感值写入日志。
|
|
*/
|
|
export function quoteIdentifier(identifier) {
|
|
if (typeof identifier !== 'string' || identifier.length === 0 || /[\u0000-\u001F\u007F]/.test(identifier)) {
|
|
throw new SchemaExportError('INVALID_IDENTIFIER', 'Schema 标识符不能为空且不得包含控制字符');
|
|
}
|
|
|
|
return `\`${identifier.replaceAll('`', '``')}\``;
|
|
}
|
|
|
|
/**
|
|
* 导出器只接受项目数据库兼容基线,拒绝 MariaDB 和 MySQL 5.7 之外的服务器,避免相同
|
|
* `SHOW CREATE` 文本在不兼容语义下被误认为可恢复的 Schema 快照。
|
|
*/
|
|
export function assertMysql57ServerMetadata(metadata) {
|
|
const serverVersion = metadata?.serverVersion ?? '';
|
|
if (!/^5\.7\.\d+(?:[-+].*)?$/.test(serverVersion) || /mariadb/i.test(serverVersion)) {
|
|
throw new SchemaExportError('UNSUPPORTED_SERVER_VERSION', 'MySQL 服务端必须为 5.7.x');
|
|
}
|
|
if (metadata.databaseCharset !== 'utf8mb4') {
|
|
throw new SchemaExportError('INVALID_DATABASE_CHARSET', '数据库字符集必须为 utf8mb4');
|
|
}
|
|
if (metadata.databaseCollation !== 'utf8mb4_general_ci') {
|
|
throw new SchemaExportError('INVALID_DATABASE_COLLATION', '数据库排序规则必须为 utf8mb4_general_ci');
|
|
}
|
|
}
|
|
|
|
function requireCatalogText(value) {
|
|
if (typeof value !== 'string' || value.length === 0) {
|
|
throw new SchemaExportError('CATALOG_SHAPE_INVALID', 'Schema 对象目录字段无效');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function queryCatalogRows(connection, sql, database, limits) {
|
|
const result = await connection.query({
|
|
sql,
|
|
values: [database],
|
|
timeout: limits.queryTimeoutMs,
|
|
});
|
|
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
|
throw new SchemaExportError('CATALOG_SHAPE_INVALID', 'Schema 对象目录查询结果无效');
|
|
}
|
|
return result[0];
|
|
}
|
|
|
|
function assertObjectLimit(count, limits) {
|
|
if (!Number.isInteger(limits.maxObjects) || limits.maxObjects < 0 || count > limits.maxObjects) {
|
|
throw new SchemaExportError('OBJECT_LIMIT_EXCEEDED', 'Schema 对象数量超过安全上限');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 目录查询只从 information_schema 读取固定列,库名始终作为参数传入。每类对象
|
|
* 在进入后续 SHOW CREATE 前先转换成稳定的小对象,并在累计数量越界时立即停止。
|
|
*/
|
|
export async function readSchemaCatalog(connection, database, limits = DEFAULT_EXPORT_LIMITS) {
|
|
const tableRows = await queryCatalogRows(connection, CATALOG_QUERIES.tables, database, limits);
|
|
const tables = [];
|
|
const views = [];
|
|
for (const row of tableRows) {
|
|
const name = requireCatalogText(row.TABLE_NAME);
|
|
if (row.TABLE_TYPE === 'BASE TABLE') tables.push(name);
|
|
else if (row.TABLE_TYPE === 'VIEW') views.push(name);
|
|
else throw new SchemaExportError('UNKNOWN_TABLE_TYPE', 'Schema 目录包含未知表类型');
|
|
}
|
|
tables.sort();
|
|
views.sort();
|
|
let objectCount = tables.length + views.length;
|
|
assertObjectLimit(objectCount, limits);
|
|
|
|
const dependencyRows = await queryCatalogRows(
|
|
connection,
|
|
CATALOG_QUERIES.viewDependencies,
|
|
database,
|
|
limits,
|
|
);
|
|
const viewDependencies = dependencyRows.map((row) => ({
|
|
view: requireCatalogText(row.VIEW_NAME),
|
|
referencedSchema: requireCatalogText(row.REFERENCED_SCHEMA_NAME),
|
|
referencedObject: requireCatalogText(row.REFERENCED_TABLE_NAME),
|
|
}));
|
|
|
|
const routineRows = await queryCatalogRows(connection, CATALOG_QUERIES.routines, database, limits);
|
|
const routines = routineRows.map((row) => {
|
|
const type = requireCatalogText(row.ROUTINE_TYPE);
|
|
if (!['FUNCTION', 'PROCEDURE'].includes(type)) {
|
|
throw new SchemaExportError('CATALOG_SHAPE_INVALID', 'Schema 目录包含未知例程类型');
|
|
}
|
|
return { name: requireCatalogText(row.ROUTINE_NAME), type };
|
|
});
|
|
routines.sort((left, right) => left.type.localeCompare(right.type) || left.name.localeCompare(right.name));
|
|
objectCount += routines.length;
|
|
assertObjectLimit(objectCount, limits);
|
|
|
|
const triggerRows = await queryCatalogRows(connection, CATALOG_QUERIES.triggers, database, limits);
|
|
const triggers = triggerRows.map((row) => {
|
|
const order = Number(row.ACTION_ORDER);
|
|
if (!Number.isInteger(order) || order < 0) {
|
|
throw new SchemaExportError('CATALOG_SHAPE_INVALID', 'Schema 触发器执行顺序无效');
|
|
}
|
|
return {
|
|
name: requireCatalogText(row.TRIGGER_NAME),
|
|
table: requireCatalogText(row.EVENT_OBJECT_TABLE),
|
|
timing: requireCatalogText(row.ACTION_TIMING),
|
|
event: requireCatalogText(row.EVENT_MANIPULATION),
|
|
order,
|
|
};
|
|
});
|
|
triggers.sort(
|
|
(left, right) =>
|
|
left.table.localeCompare(right.table) ||
|
|
left.timing.localeCompare(right.timing) ||
|
|
left.event.localeCompare(right.event) ||
|
|
left.order - right.order ||
|
|
left.name.localeCompare(right.name),
|
|
);
|
|
objectCount += triggers.length;
|
|
assertObjectLimit(objectCount, limits);
|
|
|
|
const eventRows = await queryCatalogRows(connection, CATALOG_QUERIES.events, database, limits);
|
|
const events = eventRows.map((row) => requireCatalogText(row.EVENT_NAME)).sort();
|
|
objectCount += events.length;
|
|
assertObjectLimit(objectCount, limits);
|
|
|
|
return Object.freeze({
|
|
tables: Object.freeze(tables),
|
|
views: Object.freeze(views),
|
|
viewDependencies: Object.freeze(viewDependencies.map((dependency) => Object.freeze(dependency))),
|
|
routines: Object.freeze(routines.map((routine) => Object.freeze(routine))),
|
|
triggers: Object.freeze(triggers.map((trigger) => Object.freeze(trigger))),
|
|
events: Object.freeze(events),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 视图恢复顺序使用稳定 Kahn 拓扑排序。跨库或未知依赖不能被默默忽略,否则恢复
|
|
* 出来的 Schema 可能引用未捕获对象;同层节点始终按名称排序以保证逐字节确定性。
|
|
*/
|
|
export function orderViewNames(catalog) {
|
|
const tables = new Set(catalog.tables ?? []);
|
|
const views = new Set(catalog.views ?? []);
|
|
const incoming = new Map([...views].map((view) => [view, 0]));
|
|
const dependents = new Map([...views].map((view) => [view, []]));
|
|
|
|
for (const dependency of catalog.viewDependencies ?? []) {
|
|
if (dependency.referencedSchema !== DEVELOPMENT_DATABASE) {
|
|
throw new SchemaExportError(
|
|
'CROSS_SCHEMA_VIEW_DEPENDENCY',
|
|
'Schema 视图包含跨库依赖',
|
|
);
|
|
}
|
|
if (!views.has(dependency.view)) {
|
|
throw new SchemaExportError('UNKNOWN_VIEW_DEPENDENCY', 'Schema 视图依赖来源未知');
|
|
}
|
|
if (tables.has(dependency.referencedObject)) continue;
|
|
if (!views.has(dependency.referencedObject)) {
|
|
throw new SchemaExportError('UNKNOWN_VIEW_DEPENDENCY', 'Schema 视图依赖对象未知');
|
|
}
|
|
incoming.set(dependency.view, incoming.get(dependency.view) + 1);
|
|
dependents.get(dependency.referencedObject).push(dependency.view);
|
|
}
|
|
|
|
for (const names of dependents.values()) names.sort();
|
|
const ready = [...views].filter((view) => incoming.get(view) === 0).sort();
|
|
const ordered = [];
|
|
while (ready.length > 0) {
|
|
const view = ready.shift();
|
|
ordered.push(view);
|
|
for (const dependent of dependents.get(view)) {
|
|
incoming.set(dependent, incoming.get(dependent) - 1);
|
|
if (incoming.get(dependent) === 0) {
|
|
ready.push(dependent);
|
|
ready.sort();
|
|
}
|
|
}
|
|
}
|
|
if (ordered.length !== views.size) {
|
|
throw new SchemaExportError('VIEW_DEPENDENCY_CYCLE', 'Schema 视图依赖存在循环');
|
|
}
|
|
return ordered;
|
|
}
|