fix(GOV-012): 兼容 MySQL 5.7 视图目录并封存 v0.0.9 基线

This commit is contained in:
2026-08-18 02:57:40 +08:00
parent 874796ab65
commit afd186c8a4
4 changed files with 146 additions and 15 deletions
+1 -1
View File
@@ -29,7 +29,7 @@
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # GitNexus — Code Intelligence
This project is indexed by GitNexus as **a-share-analysis** (2373 symbols, 3222 relationships, 63 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. This project is indexed by GitNexus as **a-share-analysis** (2382 symbols, 3233 relationships, 63 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
@@ -0,0 +1,33 @@
{
"format_version": 3,
"status": "captured_empty",
"application_version": "v0.0.9",
"git_commit": "88e2fdffc8c71dd682405c148a81953a8dcee9d6",
"source_environment": "development",
"database_name": "a_share_analysis_dev",
"charset": "utf8mb4",
"collation": "utf8mb4_general_ci",
"generated_at": "2026-08-17T18:56:33.473Z",
"generator": "schema-baseline/3.0.0",
"reason": "none",
"schema_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"objects": {
"tables": false,
"views": false,
"triggers": false,
"routines": false,
"events": false
},
"object_counts": {
"tables": 0,
"views": 0,
"triggers": 0,
"routines": 0,
"events": 0
},
"exporter_name": "mysql2-show-create",
"exporter_version": "1.0.0",
"database_driver": "mysql2",
"database_driver_version": "3.23.3",
"mysql_server_version": "5.7.42-0ubuntu0.18.04.1"
}
+69 -14
View File
@@ -19,6 +19,10 @@ ORDER BY TABLE_NAME`,
FROM information_schema.VIEW_TABLE_USAGE FROM information_schema.VIEW_TABLE_USAGE
WHERE VIEW_SCHEMA = ? WHERE VIEW_SCHEMA = ?
ORDER BY VIEW_NAME, REFERENCED_SCHEMA_NAME, REFERENCED_TABLE_NAME`, ORDER BY VIEW_NAME, REFERENCED_SCHEMA_NAME, REFERENCED_TABLE_NAME`,
viewDefinitions: `SELECT TABLE_NAME AS VIEW_NAME, VIEW_DEFINITION
FROM information_schema.VIEWS
WHERE TABLE_SCHEMA = ?
ORDER BY TABLE_NAME`,
routines: `SELECT ROUTINE_NAME, ROUTINE_TYPE routines: `SELECT ROUTINE_NAME, ROUTINE_TYPE
FROM information_schema.ROUTINES FROM information_schema.ROUTINES
WHERE ROUTINE_SCHEMA = ? WHERE ROUTINE_SCHEMA = ?
@@ -128,7 +132,7 @@ function requireCatalogText(value) {
return value; return value;
} }
async function queryCatalogRows(connection, sql, database, limits) { async function queryCatalogRows(connection, sql, database, limits, options = {}) {
let result; let result;
try { try {
result = await connection.query({ result = await connection.query({
@@ -137,6 +141,9 @@ async function queryCatalogRows(connection, sql, database, limits) {
timeout: limits.queryTimeoutMs, timeout: limits.queryTimeoutMs,
}); });
} catch (error) { } catch (error) {
if (options.allowMissingTable && error?.code === 'ER_UNKNOWN_TABLE') {
throw new SchemaExportError('OPTIONAL_CATALOG_UNAVAILABLE', '可选 Schema 目录来源不可用');
}
// mysql2 错误可能包含连接 URL、用户名或密码,目录读取的公共接口一律不透传原始文本。 // mysql2 错误可能包含连接 URL、用户名或密码,目录读取的公共接口一律不透传原始文本。
sanitizeDatabaseQueryError(error); sanitizeDatabaseQueryError(error);
} }
@@ -146,6 +153,38 @@ async function queryCatalogRows(connection, sql, database, limits) {
return result[0]; return result[0];
} }
function unquoteViewIdentifier(identifier) {
const value = identifier.startsWith('`') && identifier.endsWith('`')
? identifier.slice(1, -1).replaceAll('``', '`')
: identifier;
return value;
}
function parseViewDependencies(viewRows, database) {
const dependencies = [];
for (const row of viewRows) {
const view = requireCatalogText(row.VIEW_NAME);
const definition = requireCatalogText(row.VIEW_DEFINITION);
const inspectable = maskStringLiterals(definition);
const referencePattern = /\b(?:FROM|JOIN)\s+((?:`[^`]+`|[A-Za-z0-9_$]+)(?:\s*\.\s*(?:`[^`]+`|[A-Za-z0-9_$]+))?)/gi;
for (const match of inspectable.matchAll(referencePattern)) {
const parts = match[1].split(/\s*\.\s*/).map(unquoteViewIdentifier);
dependencies.push({
view,
referencedSchema: parts.length === 2 ? parts[0] : database,
referencedObject: parts.length === 2 ? parts[1] : parts[0],
});
}
}
dependencies.sort(
(left, right) =>
left.view.localeCompare(right.view) ||
left.referencedSchema.localeCompare(right.referencedSchema) ||
left.referencedObject.localeCompare(right.referencedObject),
);
return dependencies;
}
function assertObjectLimit(count, limits) { function assertObjectLimit(count, limits) {
if (!Number.isInteger(limits.maxObjects) || limits.maxObjects < 0 || count > limits.maxObjects) { if (!Number.isInteger(limits.maxObjects) || limits.maxObjects < 0 || count > limits.maxObjects) {
throw new SchemaExportError('OBJECT_LIMIT_EXCEEDED', 'Schema 对象数量超过安全上限'); throw new SchemaExportError('OBJECT_LIMIT_EXCEEDED', 'Schema 对象数量超过安全上限');
@@ -171,17 +210,30 @@ export async function readSchemaCatalog(connection, database, limits = DEFAULT_E
let objectCount = tables.length + views.length; let objectCount = tables.length + views.length;
assertObjectLimit(objectCount, limits); assertObjectLimit(objectCount, limits);
const dependencyRows = await queryCatalogRows( let viewDependencies;
connection, try {
CATALOG_QUERIES.viewDependencies, const dependencyRows = await queryCatalogRows(
database, connection,
limits, CATALOG_QUERIES.viewDependencies,
); database,
const viewDependencies = dependencyRows.map((row) => ({ limits,
view: requireCatalogText(row.VIEW_NAME), { allowMissingTable: true },
referencedSchema: requireCatalogText(row.REFERENCED_SCHEMA_NAME), );
referencedObject: requireCatalogText(row.REFERENCED_TABLE_NAME), viewDependencies = dependencyRows.map((row) => ({
})); view: requireCatalogText(row.VIEW_NAME),
referencedSchema: requireCatalogText(row.REFERENCED_SCHEMA_NAME),
referencedObject: requireCatalogText(row.REFERENCED_TABLE_NAME),
}));
} catch (error) {
if (error.code !== 'OPTIONAL_CATALOG_UNAVAILABLE') throw error;
const viewRows = await queryCatalogRows(
connection,
CATALOG_QUERIES.viewDefinitions,
database,
limits,
);
viewDependencies = parseViewDependencies(viewRows, database);
}
const routineRows = await queryCatalogRows(connection, CATALOG_QUERIES.routines, database, limits); const routineRows = await queryCatalogRows(connection, CATALOG_QUERIES.routines, database, limits);
const routines = routineRows.map((row) => { const routines = routineRows.map((row) => {
@@ -518,6 +570,7 @@ export async function exportMysql57Schema({
); );
} }
assertWithinDeadline(startedAt, now, limits); assertWithinDeadline(startedAt, now, limits);
const objectCounts = buildObjectCounts(beforeCatalog);
result = Object.freeze({ result = Object.freeze({
metadata: Object.freeze({ metadata: Object.freeze({
exporterName: MYSQL2_EXPORTER_METADATA.name, exporterName: MYSQL2_EXPORTER_METADATA.name,
@@ -526,8 +579,10 @@ export async function exportMysql57Schema({
driverVersion: MYSQL2_EXPORTER_METADATA.driverVersion, driverVersion: MYSQL2_EXPORTER_METADATA.driverVersion,
...serverMetadata, ...serverMetadata,
}), }),
rawSql: renderSchemaSql(beforeCatalog, beforeDefinitions), rawSql: Object.values(objectCounts).every((count) => count === 0)
objectCounts: buildObjectCounts(beforeCatalog), ? ''
: renderSchemaSql(beforeCatalog, beforeDefinitions),
objectCounts,
durationMs: now() - startedAt, durationMs: now() - startedAt,
}); });
} catch (error) { } catch (error) {
@@ -153,6 +153,49 @@ test('目录查询使用固定参数和超时并返回稳定对象', async () =>
} }
}); });
test('MySQL 5.7 缺少 VIEW_TABLE_USAGE 时从 VIEWS 定义解析依赖', async () => {
const fallback = createCatalogConnection();
const connection = {
calls: fallback.calls,
async query(options) {
if (options.sql.includes('information_schema.VIEW_TABLE_USAGE')) {
throw Object.assign(new Error('unknown metadata table'), { code: 'ER_UNKNOWN_TABLE' });
}
if (options.sql.includes('information_schema.VIEWS')) {
return [
[
{
VIEW_NAME: 'market_dashboard',
VIEW_DEFINITION:
'SELECT * FROM `a_share_analysis_dev`.`market_summary`',
},
{
VIEW_NAME: 'market_summary',
VIEW_DEFINITION: 'SELECT * FROM `market_snapshot`',
},
],
[],
];
}
return fallback.query(options);
},
};
const catalog = await readSchemaCatalog(connection, 'a_share_analysis_dev', DEFAULT_EXPORT_LIMITS);
assert.deepEqual(catalog.viewDependencies, [
{
view: 'market_dashboard',
referencedSchema: 'a_share_analysis_dev',
referencedObject: 'market_summary',
},
{
view: 'market_summary',
referencedSchema: 'a_share_analysis_dev',
referencedObject: 'market_snapshot',
},
]);
});
test('视图按依赖拓扑排序且同层按名称稳定排序', () => { test('视图按依赖拓扑排序且同层按名称稳定排序', () => {
const catalog = { const catalog = {
tables: ['market_snapshot'], tables: ['market_snapshot'],