feat(ai-tools): 新增 SQL 风险预检并优化视图定位

- 新增 inspect_sql_risk 内置工具,识别多语句、写入、DDL、无 WHERE 和安全策略风险

- 在 AI 设置内置工具目录和系统提示中补充 SQL 风险预检链路

- 修复同名视图定位时优先当前数据库 schema 的匹配逻辑
This commit is contained in:
Syngnat
2026-06-09 21:56:28 +08:00
parent f0afff68c4
commit 48de0b83c4
13 changed files with 577 additions and 14 deletions

View File

@@ -64,6 +64,9 @@ describe('AIBuiltinToolsCatalog', () => {
expect(markup).toContain('inspect_recent_sql_logs');
expect(markup).toContain('总结最近 SQL 活动');
expect(markup).toContain('inspect_recent_sql_activity');
expect(markup).toContain('SQL 风险预检');
expect(markup).toContain('inspect_sql_risk');
expect(markup).toContain('WHERE 条件');
expect(markup).toContain('排查应用日志');
expect(markup).toContain('inspect_app_logs');
expect(markup).toContain('排查连接失败与冷却');

View File

@@ -135,6 +135,11 @@ const BUILTIN_TOOL_FLOWS = [
steps: 'inspect_recent_sql_activity → inspect_recent_sql_logs → inspect_current_connection',
description: '适合先看最近到底以读还是写为主、有没有 DDL 或删除、哪个库最近报错最多,再决定继续下钻哪条日志或哪个连接。',
},
{
title: 'SQL 风险预检',
steps: 'inspect_sql_risk → inspect_ai_safety → execute_sql',
description: '适合用户要求执行、删除、更新、DDL 或批量 SQL 前,先检查语句数量、写入/DDL 风险、WHERE 条件和当前安全策略,再决定是否需要用户确认。',
},
{
title: '排查应用日志',
steps: 'inspect_app_logs → inspect_mcp_setup / inspect_saved_connections / inspect_current_connection',

View File

@@ -794,6 +794,56 @@ describe('aiLocalToolExecutor', () => {
expect(query).not.toHaveBeenCalled();
});
it('inspects SQL risk from the active query tab and applies the AI safety check', async () => {
const checkSQL = vi.fn().mockResolvedValue({
allowed: false,
operationType: 'UPDATE',
});
const result = await executeLocalAIToolCall({
toolCall: buildToolCall('inspect_sql_risk', {}),
connections: [buildConnection()],
tabs: [{
id: 'tab-risk-1',
title: '批量更新',
type: 'query',
connectionId: 'conn-1',
dbName: 'crm',
query: 'UPDATE users SET status = 0',
}],
activeTabId: 'tab-risk-1',
mcpTools: [],
toolContextMap: new Map(),
runtime: {
getDatabases: vi.fn(),
getTables: vi.fn(),
checkSQL,
},
});
const payload = JSON.parse(result.content);
expect(result.success).toBe(true);
expect(checkSQL).toHaveBeenCalledWith('UPDATE users SET status = 0');
expect(payload).toMatchObject({
hasSql: true,
source: 'active_tab',
riskLevel: 'critical',
requiresUserConfirmation: true,
safetyCheck: {
allowed: false,
operationType: 'UPDATE',
},
activeTab: {
id: 'tab-risk-1',
connectionName: '主库',
dbName: 'crm',
},
});
expect(payload.activityKinds).toContain('write');
expect(payload.warnings).toContain('UPDATE 缺少 WHERE 条件,可能更新整表数据');
expect(payload.warnings).toContain('当前 AI 安全策略不允许执行 UPDATE 类型 SQL');
});
it('returns a cross-table column summary for get_all_columns', async () => {
const result = await executeLocalAIToolCall({
toolCall: buildToolCall('get_all_columns', {

View File

@@ -28,6 +28,7 @@ import {
buildRecentSqlActivitySnapshot,
buildRecentSqlLogsSnapshot,
} from './aiSqlLogInsights';
import { buildSqlRiskSnapshot } from './aiSqlRiskInsights';
import {
buildActiveTabSnapshot,
buildWorkspaceTabsSnapshot,
@@ -251,6 +252,26 @@ export async function executeSnapshotInspectionToolCall(
})),
success: true,
};
case 'inspect_sql_risk': {
const candidateSql = String(args.sql || '').trim();
const activeTab = tabs.find((tab) => tab.id === activeTabId);
const activeTabSql = activeTab?.type === 'query' ? String(activeTab.query || '').trim() : '';
const sqlForCheck = candidateSql || activeTabSql;
const safetyCheck = sqlForCheck && typeof runtime?.checkSQL === 'function'
? await runtime.checkSQL(sqlForCheck)
: undefined;
return {
content: JSON.stringify(buildSqlRiskSnapshot({
sql: candidateSql,
previewCharLimit: args.previewCharLimit,
tabs,
activeTabId,
connections,
safetyCheck,
})),
success: true,
};
}
case 'inspect_app_logs': {
const readResult = typeof runtime?.readAppLogTail === 'function'
? await runtime.readAppLogTail(Number(args.lineLimit) || 80, String(args.keyword || ''))
@@ -354,6 +375,7 @@ export async function executeSnapshotInspectionToolCall(
inspect_ai_context: '读取当前 AI 上下文失败',
inspect_recent_sql_logs: '获取最近 SQL 日志失败',
inspect_recent_sql_activity: '汇总最近 SQL 活动失败',
inspect_sql_risk: '检查 SQL 风险失败',
inspect_app_logs: '读取 GoNavi 应用日志失败',
inspect_recent_connection_failures: '汇总最近连接失败记录失败',
inspect_ai_last_render_error: '读取最近一次 AI 渲染异常失败',

View File

@@ -24,6 +24,7 @@ export interface AISnapshotInspectionRuntime {
getShortcutPlatform?: () => Promise<ShortcutPlatform | undefined>;
readAppLogTail?: (lineLimit: number, keyword: string) => Promise<any>;
readSQLFile?: (filePath: string) => Promise<any>;
checkSQL?: (sql: string) => Promise<{ allowed?: boolean; operationType?: string } | undefined>;
}
export interface SnapshotInspectionResult {

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { buildSqlRiskSnapshot } from './aiSqlRiskInsights';
const connections = [{
id: 'conn-1',
name: '主库',
config: {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
user: 'root',
},
}];
describe('aiSqlRiskInsights', () => {
it('classifies multi-statement destructive SQL as critical', () => {
const snapshot = buildSqlRiskSnapshot({
sql: 'DELETE FROM accounts; SELECT * FROM accounts;',
connections,
});
expect(snapshot.hasSql).toBe(true);
expect(snapshot.source).toBe('argument');
expect(snapshot.statementCount).toBe(2);
expect(snapshot.riskLevel).toBe('critical');
expect(snapshot.requiresUserConfirmation).toBe(true);
expect(snapshot.activityKinds).toContain('write');
expect(snapshot.activityKinds).toContain('read');
expect(snapshot.warnings).toContain('DELETE 缺少 WHERE 条件,可能删除整表数据');
expect(snapshot.warnings.join('\n')).toContain('批量执行前应逐条确认影响范围');
});
it('reads SQL from the active query tab and includes safety check result', () => {
const snapshot = buildSqlRiskSnapshot({
tabs: [{
id: 'tab-1',
title: '用户更新',
type: 'query',
connectionId: 'conn-1',
dbName: 'crm',
query: 'UPDATE users SET status = 0',
}],
activeTabId: 'tab-1',
connections,
safetyCheck: {
allowed: false,
operationType: 'UPDATE',
},
});
expect(snapshot.hasSql).toBe(true);
expect(snapshot.source).toBe('active_tab');
expect(snapshot.activeTab).toMatchObject({
id: 'tab-1',
title: '用户更新',
connectionName: '主库',
connectionType: 'mysql',
dbName: 'crm',
});
expect(snapshot.riskLevel).toBe('critical');
expect(snapshot.safetyCheck).toMatchObject({ allowed: false, operationType: 'UPDATE' });
expect(snapshot.warnings).toContain('UPDATE 缺少 WHERE 条件,可能更新整表数据');
expect(snapshot.warnings).toContain('当前 AI 安全策略不允许执行 UPDATE 类型 SQL');
});
});

View File

@@ -0,0 +1,283 @@
import type { SavedConnection, TabData } from '../../types';
import { findSqlStatementRanges } from '../../utils/sqlStatementSelection';
type SqlRiskLevel = 'none' | 'low' | 'medium' | 'high' | 'critical';
type SqlActivityKind = 'read' | 'write' | 'ddl' | 'transaction' | 'session' | 'routine' | 'other';
interface SqlSafetyCheckResult {
allowed?: boolean;
operationType?: string;
}
const SQL_PREVIEW_LIMIT = 12000;
const READ_TOKENS = new Set(['select', 'show', 'describe', 'desc', 'explain', 'with']);
const WRITE_TOKENS = new Set(['insert', 'update', 'delete', 'merge', 'replace', 'upsert']);
const DDL_TOKENS = new Set(['create', 'alter', 'drop', 'truncate', 'rename']);
const TRANSACTION_TOKENS = new Set(['begin', 'start', 'commit', 'rollback', 'savepoint', 'release']);
const SESSION_TOKENS = new Set(['set', 'use', 'reset']);
const ROUTINE_TOKENS = new Set(['call', 'exec', 'execute']);
const stripCommentsAndLiterals = (sql: string): string => {
const text = String(sql || '');
let result = '';
let inSingle = false;
let inDouble = false;
let inBacktick = false;
let inLineComment = false;
let inBlockComment = false;
for (let index = 0; index < text.length; index += 1) {
const ch = text[index];
const next = index + 1 < text.length ? text[index + 1] : '';
if (inLineComment) {
if (ch === '\n') {
inLineComment = false;
result += '\n';
} else {
result += ' ';
}
continue;
}
if (inBlockComment) {
if (ch === '*' && next === '/') {
index += 1;
inBlockComment = false;
}
result += ' ';
continue;
}
if (!inSingle && !inDouble && !inBacktick && ch === '-' && next === '-') {
inLineComment = true;
result += ' ';
index += 1;
continue;
}
if (!inSingle && !inDouble && !inBacktick && ch === '/' && next === '*') {
inBlockComment = true;
result += ' ';
index += 1;
continue;
}
if (!inDouble && !inBacktick && ch === "'") {
inSingle = !inSingle;
result += ' ';
continue;
}
if (!inSingle && !inBacktick && ch === '"') {
inDouble = !inDouble;
result += ' ';
continue;
}
if (!inSingle && !inDouble && ch === '`') {
inBacktick = !inBacktick;
result += ' ';
continue;
}
result += (inSingle || inDouble || inBacktick) ? ' ' : ch;
}
return result;
};
const resolveFirstToken = (sql: string): string => {
const stripped = stripCommentsAndLiterals(sql).trim();
const match = stripped.match(/^[A-Za-z_][A-Za-z0-9_$#]*/);
return match?.[0]?.toLowerCase() || '';
};
const classifySqlActivity = (token: string): SqlActivityKind => {
if (READ_TOKENS.has(token)) return 'read';
if (WRITE_TOKENS.has(token)) return 'write';
if (DDL_TOKENS.has(token)) return 'ddl';
if (TRANSACTION_TOKENS.has(token)) return 'transaction';
if (SESSION_TOKENS.has(token)) return 'session';
if (ROUTINE_TOKENS.has(token)) return 'routine';
return 'other';
};
const escalateRisk = (current: SqlRiskLevel, next: SqlRiskLevel): SqlRiskLevel => {
const order: SqlRiskLevel[] = ['none', 'low', 'medium', 'high', 'critical'];
return order.indexOf(next) > order.indexOf(current) ? next : current;
};
const hasWhereClause = (statement: string): boolean =>
/\bwhere\b/i.test(stripCommentsAndLiterals(statement));
const normalizeLimit = (limit: unknown): number => {
const value = Math.floor(Number(limit) || SQL_PREVIEW_LIMIT);
if (value < 200) return 200;
if (value > 40000) return 40000;
return value;
};
const buildStatementRisk = (statement: string) => {
const token = resolveFirstToken(statement);
const activityKind = classifySqlActivity(token);
const normalized = stripCommentsAndLiterals(statement);
const warnings: string[] = [];
let riskLevel: SqlRiskLevel = 'low';
if (!token) {
riskLevel = 'none';
warnings.push('未识别到有效 SQL 操作关键字');
}
if (activityKind === 'write') {
riskLevel = escalateRisk(riskLevel, 'high');
warnings.push('该语句会修改数据,执行前应确认目标库、条件和影响范围');
}
if (activityKind === 'ddl') {
riskLevel = escalateRisk(riskLevel, 'high');
warnings.push('该语句会修改数据库结构或对象,建议先备份并确认回滚方案');
}
if (activityKind === 'routine') {
riskLevel = escalateRisk(riskLevel, 'medium');
warnings.push('该语句会调用例程或过程,可能存在隐式写入或副作用');
}
if (/^\s*delete\b/i.test(normalized) && !hasWhereClause(statement)) {
riskLevel = escalateRisk(riskLevel, 'critical');
warnings.push('DELETE 缺少 WHERE 条件,可能删除整表数据');
}
if (/^\s*update\b/i.test(normalized) && !hasWhereClause(statement)) {
riskLevel = escalateRisk(riskLevel, 'critical');
warnings.push('UPDATE 缺少 WHERE 条件,可能更新整表数据');
}
if (/\btruncate\s+(?:table\s+)?[A-Za-z0-9_`"[\].]+/i.test(normalized)) {
riskLevel = escalateRisk(riskLevel, 'critical');
warnings.push('TRUNCATE 会快速清空表数据,通常不可按行回滚');
}
if (/\bdrop\s+(database|schema|table|view|materialized\s+view)\b/i.test(normalized)) {
riskLevel = escalateRisk(riskLevel, 'critical');
warnings.push('DROP 会删除数据库对象,执行前必须确认对象和备份');
}
if (/\bgrant\b|\brevoke\b/i.test(normalized)) {
riskLevel = escalateRisk(riskLevel, 'high');
warnings.push('GRANT / REVOKE 会改变权限边界,应确认授权对象和范围');
}
return {
token,
activityKind,
riskLevel,
warnings,
preview: statement.trim().slice(0, 1000),
charCount: statement.trim().length,
};
};
const resolveActiveSqlSource = (params: {
sql?: string;
tabs?: TabData[];
activeTabId?: string | null;
}) => {
const explicitSql = String(params.sql || '').trim();
if (explicitSql) {
return {
source: 'argument' as const,
sql: explicitSql,
activeTab: null as TabData | null,
};
}
const activeTab = (params.tabs || []).find((tab) => tab.id === params.activeTabId) || null;
const tabSql = activeTab?.type === 'query' ? String(activeTab.query || '').trim() : '';
return {
source: activeTab ? 'active_tab' as const : 'none' as const,
sql: tabSql,
activeTab,
};
};
export const buildSqlRiskSnapshot = (params: {
sql?: string;
previewCharLimit?: unknown;
tabs?: TabData[];
activeTabId?: string | null;
connections: SavedConnection[];
safetyCheck?: SqlSafetyCheckResult;
}) => {
const { source, sql, activeTab } = resolveActiveSqlSource({
sql: params.sql,
tabs: params.tabs,
activeTabId: params.activeTabId,
});
const previewLimit = normalizeLimit(params.previewCharLimit);
const connection = activeTab
? params.connections.find((item) => item.id === activeTab.connectionId)
: undefined;
if (!sql) {
return {
hasSql: false,
source,
message: activeTab
? '当前活动页签不是 SQL 查询页签,或编辑区没有 SQL 内容'
: '未传入 SQL且当前没有可读取的活动 SQL 查询页签',
activeTab: activeTab ? {
id: activeTab.id,
title: activeTab.title,
type: activeTab.type,
} : null,
safetyCheck: params.safetyCheck || null,
riskLevel: 'none' as SqlRiskLevel,
warnings: [],
nextActions: ['先传入 sql 参数,或切换到包含 SQL 草稿的查询页签'],
};
}
const statements = findSqlStatementRanges(sql).map((range) => range.text.trim()).filter(Boolean);
const statementRisks = statements.map(buildStatementRisk);
let riskLevel: SqlRiskLevel = statements.length > 0 ? 'low' : 'none';
const warnings: string[] = [];
if (statements.length > 1) {
riskLevel = escalateRisk(riskLevel, 'medium');
warnings.push(`检测到 ${statements.length} 条 SQL 语句,批量执行前应逐条确认影响范围`);
}
for (const statementRisk of statementRisks) {
riskLevel = escalateRisk(riskLevel, statementRisk.riskLevel);
for (const warning of statementRisk.warnings) {
if (!warnings.includes(warning)) warnings.push(warning);
}
}
if (params.safetyCheck?.allowed === false) {
riskLevel = escalateRisk(riskLevel, 'high');
warnings.push(`当前 AI 安全策略不允许执行 ${params.safetyCheck.operationType || '该'} 类型 SQL`);
}
const activityKinds = Array.from(new Set(statementRisks.map((item) => item.activityKind)));
const requiresUserConfirmation = activityKinds.some((kind) => kind === 'write' || kind === 'ddl' || kind === 'routine')
|| riskLevel === 'critical';
return {
hasSql: true,
source,
sqlPreview: sql.slice(0, previewLimit),
sqlCharCount: sql.length,
sqlTruncated: sql.length > previewLimit,
statementCount: statements.length,
activityKinds,
riskLevel,
requiresUserConfirmation,
safetyCheck: params.safetyCheck || null,
activeTab: activeTab ? {
id: activeTab.id,
title: activeTab.title,
type: activeTab.type,
connectionId: activeTab.connectionId,
connectionName: connection?.name || '',
connectionType: connection?.config?.type || '',
dbName: activeTab.dbName || '',
filePath: activeTab.filePath || '',
readOnly: activeTab.readOnly === true,
} : null,
statements: statementRisks,
warnings,
nextActions: warnings.length > 0
? ['先向用户说明风险点,再要求用户确认是否继续', '写入或 DDL 语句应先确认 WHERE、备份、目标库和影响范围']
: ['只读查询风险较低,仍建议先核对目标连接和库名'],
};
};

View File

@@ -68,7 +68,7 @@ describe('buildAISystemContextMessages', () => {
connections: [connections[0]],
tabs: [],
activeTabId: null,
availableToolNames: ['inspect_workspace_tabs', 'inspect_ai_setup_health', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_mcp_setup', 'inspect_mcp_authoring_guide', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_connection_capabilities', 'inspect_saved_connections', 'inspect_external_sql_directories', 'inspect_external_sql_file', 'inspect_recent_sql_activity', 'inspect_recent_connection_failures', 'inspect_app_logs', 'inspect_ai_last_render_error', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'inspect_shortcuts', 'get_columns'],
availableToolNames: ['inspect_workspace_tabs', 'inspect_ai_setup_health', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_mcp_setup', 'inspect_mcp_authoring_guide', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_connection_capabilities', 'inspect_saved_connections', 'inspect_external_sql_directories', 'inspect_external_sql_file', 'inspect_recent_sql_activity', 'inspect_sql_risk', 'inspect_recent_connection_failures', 'inspect_app_logs', 'inspect_ai_last_render_error', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'inspect_shortcuts', 'get_columns'],
skills,
userPromptSettings,
});
@@ -90,6 +90,7 @@ describe('buildAISystemContextMessages', () => {
expect(joined).toContain('inspect_external_sql_directories');
expect(joined).toContain('inspect_external_sql_file');
expect(joined).toContain('inspect_recent_sql_activity');
expect(joined).toContain('inspect_sql_risk');
expect(joined).toContain('inspect_recent_connection_failures 读取真实连接失败总结');
expect(joined).toContain('inspect_app_logs 读取真实应用日志尾部');
expect(joined).toContain('inspect_ai_last_render_error 读取最近一次被隔离的前端渲染异常记录');

View File

@@ -152,6 +152,12 @@ export const appendDatabaseInspectionGuidanceMessages = (
'inspect_recent_sql_activity',
'如果用户提到“最近都执行了什么”“是不是刚删过数据”“最近主要在查还是在改”“哪个库最近报错最多”,优先调用 inspect_recent_sql_activity 先读最近 SQL 活动总结,再决定是否继续下钻 inspect_recent_sql_logs 看具体语句。',
);
appendGuidanceIfToolAvailable(
messages,
availableToolNames,
'inspect_sql_risk',
'如果用户要求你执行、删除、更新、DDL、批量 SQL或问“这条 SQL 能不能跑/危险不危险”,优先调用 inspect_sql_risk 检查当前编辑区或传入 SQL 的语句数量、写入/DDL 风险、WHERE 条件和安全策略结果;发现 high/critical 风险时先解释风险并让用户确认,不要直接推进执行。',
);
appendGuidanceIfToolAvailable(
messages,
availableToolNames,

View File

@@ -378,6 +378,29 @@ export const BUILTIN_AI_INSPECTION_TOOL_INFO: AIBuiltinToolInfo[] = [
},
},
},
{
name: "inspect_sql_risk",
icon: "🛑",
desc: "检查当前或指定 SQL 的执行风险",
detail:
"读取传入 SQL 或当前活动查询页签内容识别多语句、写入、DDL、DELETE/UPDATE 无 WHERE、DROP/TRUNCATE 等风险,并结合当前 AI 安全策略返回是否允许执行。适合用户让 AI 执行、解释风险、确认能不能跑某条 SQL 前先做一次安全体检。",
params: "sql?(默认读取当前活动查询页签), previewCharLimit?",
tool: {
type: "function",
function: {
name: "inspect_sql_risk",
description:
"检查传入 SQL 或当前活动查询页签 SQL 的执行风险,返回语句数量、活动类型、风险级别、危险点、是否需要用户确认,以及当前 AI 安全策略检查结果。适用于用户要求执行、删除、更新、DDL、批量 SQL、或询问某条 SQL 能不能跑时,先读取这份风险快照再回答或继续执行。",
parameters: {
type: "object",
properties: {
sql: { type: "string", description: "可选,要检查的 SQL不传时默认读取当前活动查询页签的 SQL 草稿" },
previewCharLimit: { type: "number", description: "可选SQL 预览最多返回多少字符,默认 12000最大 40000" },
},
},
},
},
},
{
name: "inspect_app_logs",
icon: "🪵",

View File

@@ -124,6 +124,7 @@ describe('aiToolRegistry', () => {
it('registers the recent-sql-activity, saved-query, and sql-snippet inspectors as builtin tools', () => {
const recentActivityTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_recent_sql_activity');
const sqlRiskTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_sql_risk');
const appLogTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_app_logs');
const connectionFailureTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_recent_connection_failures');
const renderErrorTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_last_render_error');
@@ -133,6 +134,8 @@ describe('aiToolRegistry', () => {
expect(recentActivityTool?.desc).toContain('最近 SQL 活动');
expect(recentActivityTool?.tool.function.description).toContain('最近 SQL 活动');
expect(sqlRiskTool?.desc).toContain('SQL 的执行风险');
expect(sqlRiskTool?.tool.function.description).toContain('危险点');
expect(appLogTool?.desc).toContain('GoNavi 应用日志');
expect(appLogTool?.tool.function.description).toContain('应用日志');
expect(connectionFailureTool?.desc).toContain('连接失败');
@@ -177,6 +180,7 @@ describe('aiToolRegistry', () => {
expect(tools.some((item) => item.function.name === 'inspect_external_sql_directories')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_external_sql_file')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_recent_sql_activity')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_sql_risk')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_app_logs')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_recent_connection_failures')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_ai_last_render_error')).toBe(true);

View File

@@ -700,7 +700,7 @@ describe('sidebarLocate', () => {
]);
});
it('does not guess a schema-qualified view when an unqualified locate request is ambiguous', () => {
it('prefers the current database schema when an unqualified view request matches multiple schemas', () => {
const target = resolveSidebarLocateTarget({
tabId: 'conn-1-SYSDBA-view-V_ACCOUNT',
connectionId: 'conn-1',
@@ -753,6 +753,68 @@ describe('sidebarLocate', () => {
},
];
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
'conn-1',
'conn-1-SYSDBA',
'conn-1-SYSDBA-schema-SYSDBA',
'conn-1-SYSDBA-schema-SYSDBA-views',
'conn-1-SYSDBA-view-SYSDBA.V_ACCOUNT',
]);
});
it('does not guess a schema-qualified view when no current-schema preference resolves ambiguity', () => {
const target = resolveSidebarLocateTarget({
tabId: 'conn-1-SYSDBA-view-V_ACCOUNT',
connectionId: 'conn-1',
dbName: 'SYSDBA',
tableName: 'V_ACCOUNT',
objectGroup: 'views',
}, { groupBySchema: true });
const tree = [
{
key: 'conn-1',
children: [
{
key: 'conn-1-SYSDBA',
dataRef: { id: 'conn-1', dbName: 'SYSDBA' },
children: [
{
key: 'conn-1-SYSDBA-schema-APP',
children: [
{
key: 'conn-1-SYSDBA-schema-APP-views',
children: [
{
key: 'conn-1-SYSDBA-view-APP.V_ACCOUNT',
type: 'view',
dataRef: { id: 'conn-1', dbName: 'SYSDBA', viewName: 'APP.V_ACCOUNT', schemaName: 'APP' },
},
],
},
],
},
{
key: 'conn-1-SYSDBA-schema-REPORT',
children: [
{
key: 'conn-1-SYSDBA-schema-REPORT-views',
children: [
{
key: 'conn-1-SYSDBA-view-REPORT.V_ACCOUNT',
type: 'view',
dataRef: { id: 'conn-1', dbName: 'SYSDBA', viewName: 'REPORT.V_ACCOUNT', schemaName: 'REPORT' },
},
],
},
],
},
],
},
],
},
];
expect(findSidebarNodePathForLocate(tree, target)).toBeNull();
});

View File

@@ -63,6 +63,7 @@ export interface SidebarLocateTabLike {
}
const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
const normalizeLocateName = (value: string): string => toTrimmedString(value).toLowerCase();
const normalizeExternalSQLLocatePath = (value: unknown): string => toTrimmedString(value).replace(/\\/g, '/');
@@ -265,28 +266,27 @@ const matchesLocateObjectName = (
const targetObject = targetParsed.objectName || target.tableName;
const resolvedNodeSchema = toTrimmedString(nodeSchemaName) || nodeParsed.schemaName;
const resolvedTargetSchema = toTrimmedString(target.schemaName) || targetParsed.schemaName;
const normalize = (value: string): string => toTrimmedString(value).toLowerCase();
if (normalize(normalizedNodeName) === normalize(target.tableName)) return true;
if (normalizeLocateName(normalizedNodeName) === normalizeLocateName(target.tableName)) return true;
if (
resolvedTargetSchema
&& !resolvedNodeSchema
&& normalize(resolvedTargetSchema) === normalize(target.dbName)
&& normalize(nodeObject) === normalize(targetObject)
&& normalizeLocateName(resolvedTargetSchema) === normalizeLocateName(target.dbName)
&& normalizeLocateName(nodeObject) === normalizeLocateName(targetObject)
) {
return true;
}
if (!resolvedTargetSchema) {
if (options.allowUnqualifiedSchemaMatch) {
return normalize(nodeObject) === normalize(targetObject);
return normalizeLocateName(nodeObject) === normalizeLocateName(targetObject);
}
return !resolvedNodeSchema && normalize(nodeObject) === normalize(targetObject);
return !resolvedNodeSchema && normalizeLocateName(nodeObject) === normalizeLocateName(targetObject);
}
return normalize(resolvedNodeSchema) === normalize(resolvedTargetSchema)
&& normalize(nodeObject) === normalize(targetObject);
return normalizeLocateName(resolvedNodeSchema) === normalizeLocateName(resolvedTargetSchema)
&& normalizeLocateName(nodeObject) === normalizeLocateName(targetObject);
};
const matchesLocateObjectNode = (
@@ -441,6 +441,40 @@ const shouldFallbackViewLocateToTableNode = (target: SidebarLocateTarget): boole
target.objectGroup === 'views' || target.objectGroup === 'materializedViews'
);
const selectPreferredSidebarLocatePath = (
paths: string[][],
target: SidebarLocateTarget,
): string[] | null => {
if (paths.length === 1) return paths[0];
if (paths.length === 0 || target.objectGroup === 'externalSqlFiles') return null;
const targetParsed = splitSidebarQualifiedName(target.tableName);
const targetObjectName = normalizeLocateName(targetParsed.objectName || target.tableName);
const schemaCandidates = [
toTrimmedString(target.schemaName),
targetParsed.schemaName,
target.dbName,
].filter(Boolean);
const normalizedSchemas = Array.from(new Set(schemaCandidates.map(normalizeLocateName)));
for (const normalizedSchema of normalizedSchemas) {
const preferredSchemaKey = `${normalizeLocateName(target.databaseKey)}-schema-${normalizedSchema}`;
const bySchemaGroup = paths.filter((path) =>
path.some((key) => normalizeLocateName(key) === preferredSchemaKey),
);
if (bySchemaGroup.length === 1) return bySchemaGroup[0];
const qualifiedSuffix = `${normalizedSchema}.${targetObjectName}`;
const byQualifiedLeafKey = paths.filter((path) => {
const leafKey = normalizeLocateName(path[path.length - 1] || '');
return leafKey.endsWith(qualifiedSuffix);
});
if (byQualifiedLeafKey.length === 1) return byQualifiedLeafKey[0];
}
return null;
};
export const findSidebarNodePathForLocate = (
nodes: SidebarLocateTreeNodeLike[],
target: SidebarLocateTarget,
@@ -452,24 +486,27 @@ export const findSidebarNodePathForLocate = (
if (strictPath) return strictPath;
const visualIdentityPaths = collectSidebarNodePathsForLocateByVisualIdentity(nodes, target);
if (visualIdentityPaths.length === 1) return visualIdentityPaths[0];
const visualIdentityPath = selectPreferredSidebarLocatePath(visualIdentityPaths, target);
if (visualIdentityPath) return visualIdentityPath;
if (shouldFallbackViewLocateToTableNode(target)) {
const tableLikeTarget = { ...target, objectGroup: 'tables' as const };
const tableLikePaths = collectSidebarNodePathsForLocateByObject(nodes, tableLikeTarget);
if (tableLikePaths.length === 1) return tableLikePaths[0];
const tableLikePath = selectPreferredSidebarLocatePath(tableLikePaths, target);
if (tableLikePath) return tableLikePath;
if (!hasLocateTargetSchema(target)) {
const relaxedTableLikePaths = collectSidebarNodePathsForLocateByObject(
nodes,
tableLikeTarget,
{ allowUnqualifiedSchemaMatch: true },
);
if (relaxedTableLikePaths.length === 1) return relaxedTableLikePaths[0];
const relaxedTableLikePath = selectPreferredSidebarLocatePath(relaxedTableLikePaths, target);
if (relaxedTableLikePath) return relaxedTableLikePath;
}
}
if (hasLocateTargetSchema(target)) return null;
const relaxedPaths = collectSidebarNodePathsForLocateByObject(nodes, target, { allowUnqualifiedSchemaMatch: true });
return relaxedPaths.length === 1 ? relaxedPaths[0] : null;
return selectPreferredSidebarLocatePath(relaxedPaths, target);
};