diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 8d99fe3..fe64c10 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -867,6 +867,30 @@ describe('QueryEditor external SQL save', () => { }); }); + it('prefers the unique schema-qualified view target when metadata also contains a bare view name', () => { + const views = [ + { dbName: 'SYSDBA', viewName: 'V_ACCOUNT', schemaName: undefined }, + { dbName: 'SYSDBA', viewName: 'SYSDBA.V_ACCOUNT', schemaName: 'SYSDBA' }, + ]; + + expect(resolveQueryEditorNavigationTarget( + 'select * from V_ACCOUNT', + 'select * from V_ACCOUNT'.length + 1, + 'SYSDBA', + ['SYSDBA'], + [], + views, + [], + [], + [], + )).toEqual({ + type: 'view', + dbName: 'SYSDBA', + viewName: 'SYSDBA.V_ACCOUNT', + schemaName: 'SYSDBA', + }); + }); + it('opens a table tab on ctrl left click inside the editor', async () => { editorState.value = 'select * from analytics.events where id = 1'; autoFetchState.visible = true; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index cb0aa0b..ee42d3d 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -1432,6 +1432,16 @@ export const resolveQueryEditorNavigationTarget = ( && meta.normalizedRawObjectName === exactQualifiedName ); if (exact) { + if (!normalizedSchemaName && !exact.normalizedSchemaName) { + const schemaQualifiedMatches = metas.filter((meta) => + meta.normalizedDbName === normalizedDbName + && meta.normalizedObjectName === normalizedObjectName + && Boolean(meta.normalizedSchemaName) + ); + if (schemaQualifiedMatches.length === 1) { + return schemaQualifiedMatches[0]; + } + } return exact; } diff --git a/frontend/src/utils/sidebarLocate.test.ts b/frontend/src/utils/sidebarLocate.test.ts index ccae775..1a1fdb3 100644 --- a/frontend/src/utils/sidebarLocate.test.ts +++ b/frontend/src/utils/sidebarLocate.test.ts @@ -414,6 +414,114 @@ describe('sidebarLocate', () => { ]); }); + it('finds a unique schema-qualified view when the locate request only has the view name', () => { + 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-SYSDBA', + children: [ + { + key: 'conn-1-SYSDBA-schema-SYSDBA-views', + children: [ + { + key: 'conn-1-SYSDBA-view-SYSDBA.V_ACCOUNT', + type: 'view', + dataRef: { + id: 'conn-1', + dbName: 'SYSDBA', + viewName: 'SYSDBA.V_ACCOUNT', + schemaName: 'SYSDBA', + }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + ]; + + 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 an unqualified locate request is ambiguous', () => { + 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-SYSDBA', + children: [ + { + key: 'conn-1-SYSDBA-schema-SYSDBA-views', + children: [ + { + key: 'conn-1-SYSDBA-view-SYSDBA.V_ACCOUNT', + type: 'view', + dataRef: { id: 'conn-1', dbName: 'SYSDBA', viewName: 'SYSDBA.V_ACCOUNT', schemaName: 'SYSDBA' }, + }, + ], + }, + ], + }, + { + 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(); + }); + it('finds external SQL file paths from loaded tree data', () => { const target = resolveSidebarLocateTarget({ filePath: 'C:\\Users\\me\\sql\\report.sql', diff --git a/frontend/src/utils/sidebarLocate.ts b/frontend/src/utils/sidebarLocate.ts index cf1574e..3aa42d2 100644 --- a/frontend/src/utils/sidebarLocate.ts +++ b/frontend/src/utils/sidebarLocate.ts @@ -249,7 +249,12 @@ export const findSidebarNodePathByKey = ( return null; }; -const matchesLocateObjectName = (target: SidebarLocateTarget, nodeObjectName: string, nodeSchemaName: string): boolean => { +const matchesLocateObjectName = ( + target: SidebarLocateTarget, + nodeObjectName: string, + nodeSchemaName: string, + options: { allowUnqualifiedSchemaMatch?: boolean } = {}, +): boolean => { const normalizedNodeName = toTrimmedString(nodeObjectName); if (!normalizedNodeName) return false; @@ -264,6 +269,9 @@ const matchesLocateObjectName = (target: SidebarLocateTarget, nodeObjectName: st if (normalize(normalizedNodeName) === normalize(target.tableName)) return true; if (!resolvedTargetSchema) { + if (options.allowUnqualifiedSchemaMatch) { + return normalize(nodeObject) === normalize(targetObject); + } return !resolvedNodeSchema && normalize(nodeObject) === normalize(targetObject); } @@ -271,7 +279,11 @@ const matchesLocateObjectName = (target: SidebarLocateTarget, nodeObjectName: st && normalize(nodeObject) === normalize(targetObject); }; -const matchesLocateObjectNode = (node: SidebarLocateTreeNodeLike, target: SidebarLocateTarget): boolean => { +const matchesLocateObjectNode = ( + node: SidebarLocateTreeNodeLike, + target: SidebarLocateTarget, + options: { allowUnqualifiedSchemaMatch?: boolean } = {}, +): boolean => { const dataRef = node.dataRef || {}; if (target.objectGroup === 'externalSqlFiles') { @@ -288,26 +300,72 @@ const matchesLocateObjectNode = (node: SidebarLocateTreeNodeLike, target: Sideba if (target.objectGroup === 'views') { if (node.type !== 'view') return false; - return matchesLocateObjectName(target, toTrimmedString(dataRef.viewName || dataRef.tableName), toTrimmedString(dataRef.schemaName)); + return matchesLocateObjectName(target, toTrimmedString(dataRef.viewName || dataRef.tableName), toTrimmedString(dataRef.schemaName), options); } if (target.objectGroup === 'materializedViews') { if (node.type !== 'materialized-view') return false; - return matchesLocateObjectName(target, toTrimmedString(dataRef.viewName || dataRef.tableName), toTrimmedString(dataRef.schemaName)); + return matchesLocateObjectName(target, toTrimmedString(dataRef.viewName || dataRef.tableName), toTrimmedString(dataRef.schemaName), options); } if (target.objectGroup === 'triggers') { if (node.type !== 'db-trigger') return false; - return matchesLocateObjectName(target, toTrimmedString(dataRef.triggerName || dataRef.tableName), toTrimmedString(dataRef.schemaName)); + return matchesLocateObjectName(target, toTrimmedString(dataRef.triggerName || dataRef.tableName), toTrimmedString(dataRef.schemaName), options); } if (target.objectGroup === 'routines') { if (node.type !== 'routine') return false; - return matchesLocateObjectName(target, toTrimmedString(dataRef.routineName || dataRef.tableName), toTrimmedString(dataRef.schemaName)); + return matchesLocateObjectName(target, toTrimmedString(dataRef.routineName || dataRef.tableName), toTrimmedString(dataRef.schemaName), options); } if (node.type !== 'table') return false; - return matchesLocateObjectName(target, toTrimmedString(dataRef.tableName), toTrimmedString(dataRef.schemaName)); + return matchesLocateObjectName(target, toTrimmedString(dataRef.tableName), toTrimmedString(dataRef.schemaName), options); +}; + +const findSidebarNodePathForLocateByObject = ( + nodes: SidebarLocateTreeNodeLike[], + target: SidebarLocateTarget, + options: { allowUnqualifiedSchemaMatch?: boolean } = {}, +): string[] | null => { + for (const node of nodes) { + const nodeKey = String(node.key); + if (matchesLocateObjectNode(node, target, options)) { + return [nodeKey]; + } + + if (node.children) { + const childPath = findSidebarNodePathForLocateByObject(node.children, target, options); + if (childPath) { + return [nodeKey, ...childPath]; + } + } + } + return null; +}; + +const collectSidebarNodePathsForLocateByObject = ( + nodes: SidebarLocateTreeNodeLike[], + target: SidebarLocateTarget, + options: { allowUnqualifiedSchemaMatch?: boolean } = {}, + ancestorPath: string[] = [], +): string[][] => { + const paths: string[][] = []; + for (const node of nodes) { + const nodeKey = String(node.key); + const path = [...ancestorPath, nodeKey]; + if (matchesLocateObjectNode(node, target, options)) { + paths.push(path); + } + if (node.children) { + paths.push(...collectSidebarNodePathsForLocateByObject(node.children, target, options, path)); + } + } + return paths; +}; + +const hasLocateTargetSchema = (target: SidebarLocateTarget): boolean => { + if (target.objectGroup === 'externalSqlFiles') return true; + return Boolean(toTrimmedString(target.schemaName) || splitSidebarQualifiedName(target.tableName).schemaName); }; export const findSidebarNodePathForLocate = ( @@ -317,18 +375,11 @@ export const findSidebarNodePathForLocate = ( const exactPath = findSidebarNodePathByKey(nodes, target.targetKey); if (exactPath) return exactPath; - for (const node of nodes) { - const nodeKey = String(node.key); - if (matchesLocateObjectNode(node, target)) { - return [nodeKey]; - } + const strictPath = findSidebarNodePathForLocateByObject(nodes, target); + if (strictPath) return strictPath; - if (node.children) { - const childPath = findSidebarNodePathForLocate(node.children, target); - if (childPath) { - return [nodeKey, ...childPath]; - } - } - } - return null; + if (hasLocateTargetSchema(target)) return null; + + const relaxedPaths = collectSidebarNodePathsForLocateByObject(nodes, target, { allowUnqualifiedSchemaMatch: true }); + return relaxedPaths.length === 1 ? relaxedPaths[0] : null; };