229 lines
7.1 KiB
JavaScript
229 lines
7.1 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
DEFAULT_EXPORT_LIMITS,
|
|
MYSQL2_EXPORTER_METADATA,
|
|
SchemaExportError,
|
|
assertMysql57ServerMetadata,
|
|
buildConnectionOptions,
|
|
orderViewNames,
|
|
quoteIdentifier,
|
|
readSchemaCatalog,
|
|
} from './mysql2-schema-exporter.mjs';
|
|
|
|
const config = Object.freeze({
|
|
MYSQL_HOST: '127.0.0.1',
|
|
MYSQL_PORT: '3306',
|
|
MYSQL_DATABASE: 'a_share_analysis_dev',
|
|
MYSQL_USERNAME: 'schema_reader',
|
|
MYSQL_PASSWORD: 'not-for-logs',
|
|
});
|
|
|
|
test('连接配置固定单连接、utf8mb4、东八区且禁用多语句', () => {
|
|
const options = buildConnectionOptions(config);
|
|
assert.deepEqual(options, {
|
|
host: '127.0.0.1',
|
|
port: 3306,
|
|
user: 'schema_reader',
|
|
password: 'not-for-logs',
|
|
database: 'a_share_analysis_dev',
|
|
charset: 'utf8mb4',
|
|
timezone: '+08:00',
|
|
connectTimeout: 5_000,
|
|
multipleStatements: false,
|
|
enableKeepAlive: true,
|
|
});
|
|
});
|
|
|
|
test('标识符转义反引号并拒绝空值和控制字符', () => {
|
|
assert.equal(quoteIdentifier('market`view'), '`market``view`');
|
|
for (const value of ['', 'line\nbreak', 'zero\0byte']) {
|
|
assert.throws(() => quoteIdentifier(value), SchemaExportError);
|
|
}
|
|
});
|
|
|
|
test('只接受 MySQL 5.7.x 与固定字符规则', () => {
|
|
const valid = {
|
|
serverVersion: '5.7.42-log',
|
|
databaseCharset: 'utf8mb4',
|
|
databaseCollation: 'utf8mb4_general_ci',
|
|
};
|
|
assert.doesNotThrow(() => assertMysql57ServerMetadata(valid));
|
|
for (const serverVersion of [
|
|
'5.6.51',
|
|
'8.0.40',
|
|
'5.7.42-MariaDB',
|
|
'10.6.18-MariaDB',
|
|
]) {
|
|
assert.throws(() => assertMysql57ServerMetadata({ ...valid, serverVersion }));
|
|
}
|
|
});
|
|
|
|
function createCatalogConnection(overrides = {}) {
|
|
const rowsBySource = {
|
|
'information_schema.TABLES': [
|
|
{ TABLE_NAME: 'market_snapshot', TABLE_TYPE: 'BASE TABLE' },
|
|
{ TABLE_NAME: 'market_dashboard', TABLE_TYPE: 'VIEW' },
|
|
{ TABLE_NAME: 'market_summary', TABLE_TYPE: 'VIEW' },
|
|
],
|
|
'information_schema.VIEW_TABLE_USAGE': [
|
|
{
|
|
VIEW_NAME: 'market_dashboard',
|
|
REFERENCED_SCHEMA_NAME: 'a_share_analysis_dev',
|
|
REFERENCED_TABLE_NAME: 'market_summary',
|
|
},
|
|
{
|
|
VIEW_NAME: 'market_summary',
|
|
REFERENCED_SCHEMA_NAME: 'a_share_analysis_dev',
|
|
REFERENCED_TABLE_NAME: 'market_snapshot',
|
|
},
|
|
],
|
|
'information_schema.ROUTINES': [
|
|
{ ROUTINE_NAME: 'calculate_breadth', ROUTINE_TYPE: 'FUNCTION' },
|
|
{ ROUTINE_NAME: 'refresh_market_snapshot', ROUTINE_TYPE: 'PROCEDURE' },
|
|
],
|
|
'information_schema.TRIGGERS': [
|
|
{
|
|
TRIGGER_NAME: 'snapshot_before_insert',
|
|
EVENT_OBJECT_TABLE: 'market_snapshot',
|
|
ACTION_TIMING: 'BEFORE',
|
|
EVENT_MANIPULATION: 'INSERT',
|
|
ACTION_ORDER: 1,
|
|
},
|
|
],
|
|
'information_schema.EVENTS': [{ EVENT_NAME: 'purge_market_snapshot' }],
|
|
...overrides,
|
|
};
|
|
const calls = [];
|
|
return {
|
|
calls,
|
|
async query(options) {
|
|
calls.push(options);
|
|
const source = Object.keys(rowsBySource).find((candidate) => options.sql.includes(candidate));
|
|
assert.ok(source, `未识别的目录查询:${options.sql}`);
|
|
return [rowsBySource[source], []];
|
|
},
|
|
};
|
|
}
|
|
|
|
test('目录查询使用固定参数和超时并返回稳定对象', async () => {
|
|
const connection = createCatalogConnection();
|
|
const catalog = await readSchemaCatalog(connection, 'a_share_analysis_dev', DEFAULT_EXPORT_LIMITS);
|
|
|
|
assert.deepEqual(catalog, {
|
|
tables: ['market_snapshot'],
|
|
views: ['market_dashboard', 'market_summary'],
|
|
viewDependencies: [
|
|
{
|
|
view: 'market_dashboard',
|
|
referencedSchema: 'a_share_analysis_dev',
|
|
referencedObject: 'market_summary',
|
|
},
|
|
{
|
|
view: 'market_summary',
|
|
referencedSchema: 'a_share_analysis_dev',
|
|
referencedObject: 'market_snapshot',
|
|
},
|
|
],
|
|
routines: [
|
|
{ name: 'calculate_breadth', type: 'FUNCTION' },
|
|
{ name: 'refresh_market_snapshot', type: 'PROCEDURE' },
|
|
],
|
|
triggers: [
|
|
{
|
|
name: 'snapshot_before_insert',
|
|
table: 'market_snapshot',
|
|
timing: 'BEFORE',
|
|
event: 'INSERT',
|
|
order: 1,
|
|
},
|
|
],
|
|
events: ['purge_market_snapshot'],
|
|
});
|
|
assert.equal(connection.calls.length, 5);
|
|
for (const call of connection.calls) {
|
|
assert.deepEqual(call.values, ['a_share_analysis_dev']);
|
|
assert.equal(call.timeout, DEFAULT_EXPORT_LIMITS.queryTimeoutMs);
|
|
}
|
|
});
|
|
|
|
test('视图按依赖拓扑排序且同层按名称稳定排序', () => {
|
|
const catalog = {
|
|
tables: ['market_snapshot'],
|
|
views: ['z_dashboard', 'market_summary', 'a_dashboard'],
|
|
viewDependencies: [
|
|
{
|
|
view: 'z_dashboard',
|
|
referencedSchema: 'a_share_analysis_dev',
|
|
referencedObject: 'market_summary',
|
|
},
|
|
{
|
|
view: 'a_dashboard',
|
|
referencedSchema: 'a_share_analysis_dev',
|
|
referencedObject: 'market_summary',
|
|
},
|
|
{
|
|
view: 'market_summary',
|
|
referencedSchema: 'a_share_analysis_dev',
|
|
referencedObject: 'market_snapshot',
|
|
},
|
|
],
|
|
};
|
|
|
|
assert.deepEqual(orderViewNames(catalog), ['market_summary', 'a_dashboard', 'z_dashboard']);
|
|
});
|
|
|
|
test('视图排序拒绝循环、跨库和未知依赖', () => {
|
|
const base = { tables: ['market_snapshot'], views: ['first_view', 'second_view'] };
|
|
const invalidCases = [
|
|
{
|
|
code: 'VIEW_DEPENDENCY_CYCLE',
|
|
dependencies: [
|
|
{ view: 'first_view', referencedSchema: 'a_share_analysis_dev', referencedObject: 'second_view' },
|
|
{ view: 'second_view', referencedSchema: 'a_share_analysis_dev', referencedObject: 'first_view' },
|
|
],
|
|
},
|
|
{
|
|
code: 'CROSS_SCHEMA_VIEW_DEPENDENCY',
|
|
dependencies: [
|
|
{ view: 'first_view', referencedSchema: 'production', referencedObject: 'market_snapshot' },
|
|
],
|
|
},
|
|
{
|
|
code: 'UNKNOWN_VIEW_DEPENDENCY',
|
|
dependencies: [
|
|
{ view: 'first_view', referencedSchema: 'a_share_analysis_dev', referencedObject: 'missing_object' },
|
|
],
|
|
},
|
|
];
|
|
|
|
for (const invalid of invalidCases) {
|
|
assert.throws(
|
|
() => orderViewNames({ ...base, viewDependencies: invalid.dependencies }),
|
|
(error) => error instanceof SchemaExportError && error.code === invalid.code,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('目录拒绝未知表类型和超过对象上限', async () => {
|
|
const unknownType = createCatalogConnection({
|
|
'information_schema.TABLES': [{ TABLE_NAME: 'temporary_data', TABLE_TYPE: 'SYSTEM VIEW' }],
|
|
});
|
|
await assert.rejects(
|
|
() => readSchemaCatalog(unknownType, 'a_share_analysis_dev', DEFAULT_EXPORT_LIMITS),
|
|
(error) => error instanceof SchemaExportError && error.code === 'UNKNOWN_TABLE_TYPE',
|
|
);
|
|
|
|
const overLimit = createCatalogConnection({
|
|
'information_schema.TABLES': [
|
|
{ TABLE_NAME: 'first_table', TABLE_TYPE: 'BASE TABLE' },
|
|
{ TABLE_NAME: 'second_table', TABLE_TYPE: 'BASE TABLE' },
|
|
],
|
|
});
|
|
await assert.rejects(
|
|
() => readSchemaCatalog(overLimit, 'a_share_analysis_dev', { ...DEFAULT_EXPORT_LIMITS, maxObjects: 1 }),
|
|
(error) => error instanceof SchemaExportError && error.code === 'OBJECT_LIMIT_EXCEEDED',
|
|
);
|
|
});
|