fix(GOV-012): 兼容 MySQL 5.7 视图目录并封存 v0.0.9 基线
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
|||||||
Reference in New Issue
Block a user