mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-09 06:23:29 +08:00
🐛 fix(sql-editor): 修复脚本执行拆分与元数据只读提示
- Oracle 匿名块:识别 BEGIN/DECLARE...END 块,避免按内部分号错误拆分 - 执行路径:PL/SQL 块跳过批量写入路径,保持单条语句语义 - SQL 文件:同步修复流式 SQL 文件拆分逻辑 - 查询结果:系统元数据表保持只读但不再弹业务表主键提示 - 测试覆盖:补充前后端拆分、执行和 information_schema 回归用例
This commit is contained in:
@@ -1676,6 +1676,42 @@ describe('QueryEditor external SQL save', () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
|
||||
it('keeps Oracle anonymous PL/SQL blocks intact when running from the editor', async () => {
|
||||
storeState.connections[0].config.type = 'oracle';
|
||||
storeState.connections[0].config.database = 'ORCLPDB1';
|
||||
backendApp.DBQueryMulti.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ columns: ['affectedRows'], rows: [{ affectedRows: 1 }] }],
|
||||
});
|
||||
const plsql = [
|
||||
'BEGIN',
|
||||
" INSERT INTO tmp_disable_trigger (table_name) VALUES ('t_memcard_reg');",
|
||||
" UPDATE t_memcard_reg SET CARDLEVEL = 1 WHERE MEMCARDNO = '8032277312';",
|
||||
" DELETE FROM tmp_disable_trigger WHERE table_name = 't_memcard_reg';",
|
||||
'END;',
|
||||
].join('\n');
|
||||
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ dbName: 'ORCLPDB1', query: plsql })} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await findButton(renderer!, '运行').props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'ORCLPDB1', plsql, 'query-1');
|
||||
expect(storeState.addSqlLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sql: plsql,
|
||||
status: 'success',
|
||||
}));
|
||||
renderer?.unmount();
|
||||
});
|
||||
|
||||
it('keeps non-Oracle query results read-only when no safe locator exists', async () => {
|
||||
backendApp.DBQueryMulti.mockResolvedValueOnce({
|
||||
success: true,
|
||||
@@ -1710,6 +1746,46 @@ describe('QueryEditor external SQL save', () => {
|
||||
expect(messageApi.warning).toHaveBeenCalledWith('查询结果保持只读:main.users 未检测到主键或可用唯一索引,无法安全提交修改。');
|
||||
});
|
||||
|
||||
it('keeps MySQL information_schema routine results read-only without a locator warning', async () => {
|
||||
const sql = [
|
||||
'SELECT ROUTINE_SCHEMA, ROUTINE_NAME, DEFINER, SECURITY_TYPE',
|
||||
'FROM information_schema.ROUTINES',
|
||||
"WHERE ROUTINE_SCHEMA = 'mkefu_location_dev_local'",
|
||||
" AND ROUTINE_NAME = 'init_orgi'",
|
||||
].join('\n');
|
||||
backendApp.DBQueryMulti.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{
|
||||
columns: ['ROUTINE_SCHEMA', 'ROUTINE_NAME', 'DEFINER', 'SECURITY_TYPE'],
|
||||
rows: [{
|
||||
ROUTINE_SCHEMA: 'mkefu_location_dev_local',
|
||||
ROUTINE_NAME: 'init_orgi',
|
||||
DEFINER: 'root@%',
|
||||
SECURITY_TYPE: 'DEFINER',
|
||||
}],
|
||||
}],
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ dbName: 'mkefu_location_dev_local', query: sql })} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await findButton(renderer!, '运行').props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(dataGridState.latestProps?.tableName).toBe('ROUTINES');
|
||||
expect(dataGridState.latestProps?.readOnly).toBe(true);
|
||||
expect(backendApp.DBGetColumns).not.toHaveBeenCalled();
|
||||
expect(backendApp.DBGetIndexes).not.toHaveBeenCalled();
|
||||
expect(messageApi.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs the SQL statement at the cursor instead of the whole editor when nothing is selected', async () => {
|
||||
backendApp.DBQueryMulti.mockResolvedValueOnce({
|
||||
success: true,
|
||||
|
||||
@@ -262,6 +262,33 @@ const stripQueryIdentifierQuotes = (part: string): string => {
|
||||
return text;
|
||||
};
|
||||
|
||||
const MYSQL_SYSTEM_METADATA_SCHEMAS = new Set(['information_schema', 'performance_schema', 'mysql', 'sys']);
|
||||
const POSTGRES_SYSTEM_METADATA_SCHEMAS = new Set(['information_schema', 'pg_catalog']);
|
||||
const SQLITE_SYSTEM_METADATA_TABLES = new Set(['sqlite_master', 'sqlite_schema', 'sqlite_temp_master', 'sqlite_temp_schema']);
|
||||
|
||||
const isSystemMetadataQueryResult = (tableRef: QueryResultTableRef, dbType: string): boolean => {
|
||||
const normalizedDbType = String(dbType || '').trim().toLowerCase();
|
||||
const metadataDbName = stripQueryIdentifierQuotes(tableRef.metadataDbName).toLowerCase();
|
||||
const metadataTableName = stripQueryIdentifierQuotes(tableRef.metadataTableName).toLowerCase();
|
||||
|
||||
if (['mysql', 'mariadb', 'oceanbase', 'diros', 'starrocks', 'sphinx', 'tidb'].includes(normalizedDbType)) {
|
||||
return MYSQL_SYSTEM_METADATA_SCHEMAS.has(metadataDbName);
|
||||
}
|
||||
if (['postgres', 'kingbase', 'highgo', 'vastbase', 'opengauss'].includes(normalizedDbType)) {
|
||||
return POSTGRES_SYSTEM_METADATA_SCHEMAS.has(metadataDbName);
|
||||
}
|
||||
if (normalizedDbType === 'sqlite' || normalizedDbType === 'duckdb') {
|
||||
return SQLITE_SYSTEM_METADATA_TABLES.has(metadataTableName) || metadataDbName === 'information_schema';
|
||||
}
|
||||
if (normalizedDbType === 'sqlserver') {
|
||||
return metadataDbName === 'information_schema' || metadataDbName === 'sys';
|
||||
}
|
||||
if (normalizedDbType === 'clickhouse') {
|
||||
return metadataDbName === 'system' || metadataDbName === 'information_schema';
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const splitTopLevelComma = (text: string): string[] => {
|
||||
const parts: string[] = [];
|
||||
let current = '';
|
||||
@@ -658,6 +685,65 @@ const areSqlStatementListsEqual = (left: string[], right: string[]): boolean =>
|
||||
&& left.every((statement, index) => normalizeExecutedSqlKey(statement) === normalizeExecutedSqlKey(right[index]))
|
||||
);
|
||||
|
||||
const isSqlIdentifierStart = (ch: string): boolean => /^[A-Za-z_]$/.test(ch);
|
||||
|
||||
const isSqlIdentifierPart = (ch: string): boolean => /^[A-Za-z0-9_$#]$/.test(ch);
|
||||
|
||||
const skipSqlWhitespaceAndComments = (text: string, position: number): number => {
|
||||
let index = position;
|
||||
while (index < text.length) {
|
||||
const ch = text[index];
|
||||
const next = index + 1 < text.length ? text[index + 1] : '';
|
||||
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f') {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '-' && next === '-') {
|
||||
index += 2;
|
||||
while (index < text.length && text[index] !== '\n') index += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '*') {
|
||||
index += 2;
|
||||
while (index + 1 < text.length && !(text[index] === '*' && text[index + 1] === '/')) {
|
||||
index += 1;
|
||||
}
|
||||
if (index + 1 < text.length) index += 2;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
const nextSqlSignificantToken = (text: string, position: number): string => {
|
||||
const index = skipSqlWhitespaceAndComments(text, position);
|
||||
if (index >= text.length || !isSqlIdentifierStart(text[index])) return '';
|
||||
let end = index + 1;
|
||||
while (end < text.length && isSqlIdentifierPart(text[end])) end += 1;
|
||||
return text.slice(index, end).toLowerCase();
|
||||
};
|
||||
|
||||
const nextSqlSignificantChar = (text: string, position: number): string => {
|
||||
const index = skipSqlWhitespaceAndComments(text, position);
|
||||
return index >= text.length ? '' : text[index];
|
||||
};
|
||||
|
||||
const shouldEnterPlsqlBeginBlock = (text: string, tokenEnd: number): boolean => {
|
||||
const nextChar = nextSqlSignificantChar(text, tokenEnd);
|
||||
if (!nextChar || nextChar === ';') return false;
|
||||
return !['transaction', 'work', 'isolation', 'read', 'write'].includes(nextSqlSignificantToken(text, tokenEnd));
|
||||
};
|
||||
|
||||
const shouldEnterPlsqlDeclareBlock = (text: string, tokenEnd: number): boolean => {
|
||||
const nextToken = nextSqlSignificantToken(text, tokenEnd);
|
||||
return Boolean(nextToken);
|
||||
};
|
||||
|
||||
const isPlsqlControlEnd = (text: string, tokenEnd: number): boolean => (
|
||||
['if', 'loop', 'case'].includes(nextSqlSignificantToken(text, tokenEnd))
|
||||
);
|
||||
|
||||
const normalizeEditorPosition = (position: any): { lineNumber: number; column: number } | null => {
|
||||
if (!position) return null;
|
||||
const lineNumber = Number(position.positionLineNumber ?? position.lineNumber ?? position.endLineNumber ?? position.startLineNumber ?? position.selectionStartLineNumber);
|
||||
@@ -1563,6 +1649,10 @@ const resolveQueryLocatorPlan = async ({
|
||||
const tableRef = extractQueryResultTableRef(statement, dbType, currentDb);
|
||||
if (!tableRef) return plan;
|
||||
plan.tableRef = tableRef;
|
||||
if (isSystemMetadataQueryResult(tableRef, dbType)) {
|
||||
plan.editLocator = buildQueryReadOnlyLocator('系统元数据查询结果保持只读。');
|
||||
return plan;
|
||||
}
|
||||
|
||||
const selectInfo = parseSimpleSelectInfo(statement);
|
||||
if (!selectInfo) {
|
||||
@@ -3604,6 +3694,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
let inLineComment = false;
|
||||
let inBlockComment = false;
|
||||
let dollarTag: string | null = null; // postgres/kingbase: $$...$$ or $tag$...$tag$
|
||||
let plsqlDepth = 0;
|
||||
let plsqlDeclareBeginSkips = 0;
|
||||
let justClosedPLSQLBlock = false;
|
||||
|
||||
const push = () => {
|
||||
const s = cur.trim();
|
||||
@@ -3705,7 +3798,45 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inSingle && !inDouble && !inBacktick && !dollarTag && isSqlIdentifierStart(ch)) {
|
||||
let end = i + 1;
|
||||
while (end < text.length && isSqlIdentifierPart(text[end])) {
|
||||
end += 1;
|
||||
}
|
||||
const token = text.slice(i, end).toLowerCase();
|
||||
if (token === 'begin' && plsqlDeclareBeginSkips > 0) {
|
||||
plsqlDeclareBeginSkips -= 1;
|
||||
justClosedPLSQLBlock = false;
|
||||
} else if (token === 'begin' && shouldEnterPlsqlBeginBlock(text, end)) {
|
||||
plsqlDepth += 1;
|
||||
justClosedPLSQLBlock = false;
|
||||
} else if (token === 'declare' && shouldEnterPlsqlDeclareBlock(text, end)) {
|
||||
plsqlDepth += 1;
|
||||
plsqlDeclareBeginSkips += 1;
|
||||
justClosedPLSQLBlock = false;
|
||||
} else if (token === 'end' && plsqlDepth > 0 && !isPlsqlControlEnd(text, end)) {
|
||||
plsqlDepth -= 1;
|
||||
if (plsqlDeclareBeginSkips > plsqlDepth) {
|
||||
plsqlDeclareBeginSkips = plsqlDepth;
|
||||
}
|
||||
justClosedPLSQLBlock = plsqlDepth === 0;
|
||||
}
|
||||
cur += text.slice(i, end);
|
||||
i = end - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inSingle && !inDouble && !inBacktick && !dollarTag && (ch === ';' || ch === ';')) {
|
||||
if (plsqlDepth > 0) {
|
||||
cur += ch;
|
||||
continue;
|
||||
}
|
||||
if (justClosedPLSQLBlock) {
|
||||
cur += ch;
|
||||
push();
|
||||
justClosedPLSQLBlock = false;
|
||||
continue;
|
||||
}
|
||||
push();
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,74 @@ describe('sqlStatementSelection', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps Oracle anonymous PL/SQL blocks as one executable statement', () => {
|
||||
const plsql = [
|
||||
'BEGIN',
|
||||
" INSERT INTO tmp_disable_trigger (table_name) VALUES ('t_memcard_reg');",
|
||||
" UPDATE t_memcard_reg SET CARDLEVEL = 1 WHERE MEMCARDNO = '8032277312';",
|
||||
" DELETE FROM tmp_disable_trigger WHERE table_name = 't_memcard_reg';",
|
||||
'END;',
|
||||
'SELECT 1 FROM dual;',
|
||||
].join('\n');
|
||||
|
||||
const ranges = findSqlStatementRanges(plsql).map((range) => range.text);
|
||||
|
||||
expect(ranges).toEqual([
|
||||
[
|
||||
'BEGIN',
|
||||
" INSERT INTO tmp_disable_trigger (table_name) VALUES ('t_memcard_reg');",
|
||||
" UPDATE t_memcard_reg SET CARDLEVEL = 1 WHERE MEMCARDNO = '8032277312';",
|
||||
" DELETE FROM tmp_disable_trigger WHERE table_name = 't_memcard_reg';",
|
||||
'END;',
|
||||
].join('\n'),
|
||||
'SELECT 1 FROM dual',
|
||||
]);
|
||||
expect(resolveExecutableSql(plsql, plsql.indexOf('UPDATE'))).toEqual({
|
||||
sql: ranges[0],
|
||||
source: 'statement',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps Oracle DECLARE blocks as one executable statement', () => {
|
||||
const sql = [
|
||||
'DECLARE',
|
||||
' v_count NUMBER;',
|
||||
'BEGIN',
|
||||
' SELECT COUNT(*) INTO v_count FROM t_memcard_reg;',
|
||||
" UPDATE t_memcard_reg SET CARDLEVEL = v_count WHERE MEMCARDNO = '8032277312';",
|
||||
'END;',
|
||||
'SELECT 1 FROM dual;',
|
||||
].join('\n');
|
||||
|
||||
const ranges = findSqlStatementRanges(sql).map((range) => range.text);
|
||||
|
||||
expect(ranges).toEqual([
|
||||
[
|
||||
'DECLARE',
|
||||
' v_count NUMBER;',
|
||||
'BEGIN',
|
||||
' SELECT COUNT(*) INTO v_count FROM t_memcard_reg;',
|
||||
" UPDATE t_memcard_reg SET CARDLEVEL = v_count WHERE MEMCARDNO = '8032277312';",
|
||||
'END;',
|
||||
].join('\n'),
|
||||
'SELECT 1 FROM dual',
|
||||
]);
|
||||
expect(resolveExecutableSql(sql, sql.indexOf('UPDATE'))).toEqual({
|
||||
sql: ranges[0],
|
||||
source: 'statement',
|
||||
});
|
||||
});
|
||||
|
||||
it('still splits transaction BEGIN statements', () => {
|
||||
const sql = 'BEGIN; UPDATE accounts SET balance = balance - 1 WHERE id = 1; COMMIT;';
|
||||
|
||||
expect(findSqlStatementRanges(sql).map((range) => range.text)).toEqual([
|
||||
'BEGIN',
|
||||
'UPDATE accounts SET balance = balance - 1 WHERE id = 1',
|
||||
'COMMIT',
|
||||
]);
|
||||
});
|
||||
|
||||
it('selects the next statement when the cursor is on whitespace before it', () => {
|
||||
const sql = 'select 1;\n\n select 2;';
|
||||
const range = resolveCurrentSqlStatementRange(sql, sql.indexOf(' select 2'));
|
||||
|
||||
@@ -12,7 +12,63 @@ export interface SqlExecutionSelection {
|
||||
}
|
||||
|
||||
const isWhitespace = (ch: string): boolean => (
|
||||
ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'
|
||||
ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f'
|
||||
);
|
||||
|
||||
const isSqlIdentifierStart = (ch: string): boolean => /^[A-Za-z_]$/.test(ch);
|
||||
|
||||
const isSqlIdentifierPart = (ch: string): boolean => /^[A-Za-z0-9_$#]$/.test(ch);
|
||||
|
||||
const skipSqlWhitespaceAndComments = (text: string, position: number): number => {
|
||||
let index = position;
|
||||
while (index < text.length) {
|
||||
const ch = text[index];
|
||||
const next = index + 1 < text.length ? text[index + 1] : '';
|
||||
if (isWhitespace(ch)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '-' && next === '-') {
|
||||
index += 2;
|
||||
while (index < text.length && text[index] !== '\n') index += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '*') {
|
||||
index += 2;
|
||||
while (index + 1 < text.length && !(text[index] === '*' && text[index + 1] === '/')) {
|
||||
index += 1;
|
||||
}
|
||||
if (index + 1 < text.length) index += 2;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
const nextSqlSignificantToken = (text: string, position: number): string => {
|
||||
const index = skipSqlWhitespaceAndComments(text, position);
|
||||
if (index >= text.length || !isSqlIdentifierStart(text[index])) return '';
|
||||
let end = index + 1;
|
||||
while (end < text.length && isSqlIdentifierPart(text[end])) end += 1;
|
||||
return text.slice(index, end).toLowerCase();
|
||||
};
|
||||
|
||||
const nextSqlSignificantChar = (text: string, position: number): string => {
|
||||
const index = skipSqlWhitespaceAndComments(text, position);
|
||||
return index >= text.length ? '' : text[index];
|
||||
};
|
||||
|
||||
const shouldEnterPlsqlBeginBlock = (text: string, tokenEnd: number): boolean => {
|
||||
const nextChar = nextSqlSignificantChar(text, tokenEnd);
|
||||
if (!nextChar || nextChar === ';') return false;
|
||||
return !['transaction', 'work', 'isolation', 'read', 'write'].includes(nextSqlSignificantToken(text, tokenEnd));
|
||||
};
|
||||
|
||||
const shouldEnterPlsqlDeclareBlock = (text: string, tokenEnd: number): boolean => Boolean(nextSqlSignificantToken(text, tokenEnd));
|
||||
|
||||
const isPlsqlControlEnd = (text: string, tokenEnd: number): boolean => (
|
||||
['if', 'loop', 'case'].includes(nextSqlSignificantToken(text, tokenEnd))
|
||||
);
|
||||
|
||||
const trimStatementRange = (sql: string, start: number, end: number): SqlStatementRange | null => {
|
||||
@@ -49,6 +105,9 @@ export const findSqlStatementRanges = (sql: string): SqlStatementRange[] => {
|
||||
let inLineComment = false;
|
||||
let inBlockComment = false;
|
||||
let dollarTag: string | null = null;
|
||||
let plsqlDepth = 0;
|
||||
let plsqlDeclareBeginSkips = 0;
|
||||
let justClosedPLSQLBlock = false;
|
||||
|
||||
const push = (end: number) => {
|
||||
const range = trimStatementRange(text, statementStart, end);
|
||||
@@ -134,9 +193,41 @@ export const findSqlStatementRanges = (sql: string): SqlStatementRange[] => {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inSingle && !inDouble && !inBacktick && !dollarTag && isSqlIdentifierStart(ch)) {
|
||||
let tokenEnd = index + 1;
|
||||
while (tokenEnd < text.length && isSqlIdentifierPart(text[tokenEnd])) {
|
||||
tokenEnd++;
|
||||
}
|
||||
const token = text.slice(index, tokenEnd).toLowerCase();
|
||||
if (token === 'begin' && plsqlDeclareBeginSkips > 0) {
|
||||
plsqlDeclareBeginSkips--;
|
||||
justClosedPLSQLBlock = false;
|
||||
} else if (token === 'begin' && shouldEnterPlsqlBeginBlock(text, tokenEnd)) {
|
||||
plsqlDepth++;
|
||||
justClosedPLSQLBlock = false;
|
||||
} else if (token === 'declare' && shouldEnterPlsqlDeclareBlock(text, tokenEnd)) {
|
||||
plsqlDepth++;
|
||||
plsqlDeclareBeginSkips++;
|
||||
justClosedPLSQLBlock = false;
|
||||
} else if (token === 'end' && plsqlDepth > 0 && !isPlsqlControlEnd(text, tokenEnd)) {
|
||||
plsqlDepth--;
|
||||
if (plsqlDeclareBeginSkips > plsqlDepth) {
|
||||
plsqlDeclareBeginSkips = plsqlDepth;
|
||||
}
|
||||
justClosedPLSQLBlock = plsqlDepth === 0;
|
||||
}
|
||||
index = tokenEnd - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inSingle && !inDouble && !inBacktick && (ch === ';' || ch === ';')) {
|
||||
push(index);
|
||||
if (plsqlDepth > 0) {
|
||||
continue;
|
||||
}
|
||||
push(justClosedPLSQLBlock ? index + 1 : index);
|
||||
statementStart = index + 1;
|
||||
justClosedPLSQLBlock = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user