import { splitQualifiedNameLast } from './qualifiedName'; export type SidebarLocateObjectGroup = 'tables' | 'views' | 'materializedViews' | 'triggers' | 'routines' | 'externalSqlFiles'; export type SidebarLocateDatabaseObjectGroup = Exclude; export interface SidebarLocateDatabaseObjectRequest { tabId?: string; connectionId: string; dbName: string; tableName: string; schemaName?: string; objectGroup: SidebarLocateDatabaseObjectGroup; } export interface SidebarLocateExternalSQLFileRequest { tabId?: string; connectionId?: string; dbName?: string; filePath: string; fileName?: string; objectGroup: 'externalSqlFiles'; } export type SidebarLocateObjectRequest = SidebarLocateDatabaseObjectRequest | SidebarLocateExternalSQLFileRequest; export interface SidebarLocateTarget { connectionKey: string; databaseKey: string; targetKey: string; objectGroup: SidebarLocateObjectGroup; objectGroupKey: string; schemaKey?: string; expectedAncestorKeys: string[]; connectionId: string; dbName: string; tableName: string; schemaName: string; filePath?: string; } export interface SidebarLocateTreeNodeLike { key: string | number; title?: unknown; type?: string; dataRef?: Record; children?: SidebarLocateTreeNodeLike[]; } export interface SidebarLocateTabLike { id?: string; type?: string; connectionId?: string; dbName?: string; tableName?: string; viewName?: string; viewKind?: string; triggerName?: string; triggerTableName?: string; routineName?: string; schemaName?: string; sidebarLocateKey?: string; filePath?: string; objectType?: string; } 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, '/'); export const splitSidebarQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => { const raw = toTrimmedString(qualifiedName); if (!raw) return { schemaName: '', objectName: '' }; const parsed = splitQualifiedNameLast(raw); return { schemaName: parsed.parentPath, objectName: parsed.objectName, }; }; const inferObjectGroup = (detail: Record, connectionId: string, dbName: string): SidebarLocateDatabaseObjectGroup => { const explicitGroup = toTrimmedString(detail.objectGroup); if (explicitGroup === 'views' || explicitGroup === 'view') return 'views'; if (explicitGroup === 'materializedViews' || explicitGroup === 'materialized-view') return 'materializedViews'; if (explicitGroup === 'triggers' || explicitGroup === 'trigger') return 'triggers'; if (explicitGroup === 'routines' || explicitGroup === 'routine') return 'routines'; const explicitType = toTrimmedString(detail.objectType); if (explicitType === 'view' || explicitType === 'views') return 'views'; if (explicitType === 'materialized' || explicitType === 'materialized-view') return 'materializedViews'; if (explicitType === 'trigger' || explicitType === 'triggers') return 'triggers'; if (explicitType === 'routine' || explicitType === 'routines') return 'routines'; const tabId = toTrimmedString(detail.tabId); const dbNodeKey = `${connectionId}-${dbName}`; if (tabId.startsWith(`${dbNodeKey}-materialized-view-`)) return 'materializedViews'; if (tabId.startsWith(`${dbNodeKey}-view-`)) return 'views'; if (tabId.startsWith(`${dbNodeKey}-trigger-`)) return 'triggers'; if (tabId.startsWith(`${dbNodeKey}-routine-`) || tabId.startsWith(`routine-def-${connectionId}-${dbName}-`)) return 'routines'; return 'tables'; }; export const normalizeSidebarLocateObjectRequest = (detail: unknown): SidebarLocateObjectRequest | null => { const raw = (detail || {}) as Record; const filePath = normalizeExternalSQLLocatePath(raw.filePath); if (filePath) { return { tabId: toTrimmedString(raw.tabId) || undefined, connectionId: toTrimmedString(raw.connectionId) || undefined, dbName: toTrimmedString(raw.dbName) || undefined, filePath, fileName: toTrimmedString(raw.fileName || raw.title) || undefined, objectGroup: 'externalSqlFiles', }; } const connectionId = toTrimmedString(raw.connectionId); const dbName = toTrimmedString(raw.dbName); const tableName = toTrimmedString(raw.tableName || raw.objectName || raw.viewName || raw.triggerName || raw.routineName); if (!connectionId || !dbName || !tableName) { return null; } const parsed = splitSidebarQualifiedName(tableName); const schemaName = toTrimmedString(raw.schemaName) || parsed.schemaName; return { tabId: toTrimmedString(raw.tabId) || undefined, connectionId, dbName, tableName, schemaName, objectGroup: inferObjectGroup(raw, connectionId, dbName), }; }; export const normalizeSidebarLocateObjectRequestFromTab = (tab: SidebarLocateTabLike | null | undefined): SidebarLocateObjectRequest | null => { if (!tab) return null; const filePath = normalizeExternalSQLLocatePath(tab.filePath); if (tab.type === 'query' && filePath) { return normalizeSidebarLocateObjectRequest({ tabId: tab.id, connectionId: tab.connectionId, dbName: tab.dbName, filePath, fileName: tab.id, }); } const objectName = tab.type === 'view-def' ? toTrimmedString(tab.viewName || tab.tableName) : tab.type === 'trigger' ? toTrimmedString(tab.triggerName || tab.tableName) : tab.type === 'routine-def' ? toTrimmedString(tab.routineName || tab.tableName) : toTrimmedString(tab.tableName || tab.viewName); if (tab.type !== 'table' && tab.type !== 'view-def' && tab.type !== 'trigger' && tab.type !== 'routine-def') { return null; } return normalizeSidebarLocateObjectRequest({ tabId: toTrimmedString(tab.sidebarLocateKey || tab.id) || undefined, connectionId: tab.connectionId, dbName: tab.dbName, tableName: objectName, schemaName: tab.schemaName, objectGroup: tab.type === 'view-def' ? (tab.viewKind === 'materialized' ? 'materializedViews' : 'views') : (tab.type === 'trigger' ? 'triggers' : (tab.type === 'routine-def' ? 'routines' : (tab.objectType === 'materialized-view' ? 'materializedViews' : (tab.objectType === 'view' ? 'views' : undefined)))), }); }; export const resolveSidebarLocateTarget = ( request: SidebarLocateObjectRequest, options: { groupBySchema: boolean }, ): SidebarLocateTarget => { if (request.objectGroup === 'externalSqlFiles') { const filePath = normalizeExternalSQLLocatePath(request.filePath); return { connectionKey: toTrimmedString(request.connectionId), databaseKey: request.connectionId && request.dbName ? `${request.connectionId}-${request.dbName}` : '', targetKey: request.tabId || filePath, objectGroup: 'externalSqlFiles', objectGroupKey: 'external-sql-root', expectedAncestorKeys: ['external-sql-root'], connectionId: toTrimmedString(request.connectionId), dbName: toTrimmedString(request.dbName), tableName: request.fileName || filePath.split('/').filter(Boolean).pop() || filePath, schemaName: '', filePath, }; } const connectionKey = request.connectionId; const databaseKey = `${request.connectionId}-${request.dbName}`; const fallbackTargetKey = request.objectGroup === 'materializedViews' ? `${databaseKey}-materialized-view-${request.tableName}` : request.objectGroup === 'views' ? `${databaseKey}-view-${request.tableName}` : request.objectGroup === 'triggers' ? `${databaseKey}-trigger-${request.tableName}` : request.objectGroup === 'routines' ? `${databaseKey}-routine-${request.tableName}` : `${databaseKey}-${request.tableName}`; const targetKey = request.tabId || fallbackTargetKey; const schemaSegment = request.schemaName || 'default'; const schemaKey = options.groupBySchema ? `${databaseKey}-schema-${schemaSegment}` : undefined; const objectGroupKey = options.groupBySchema ? `${schemaKey}-${request.objectGroup}` : `${databaseKey}-${request.objectGroup}`; const expectedAncestorKeys = [ connectionKey, databaseKey, ...(schemaKey ? [schemaKey] : []), objectGroupKey, ]; return { connectionKey, databaseKey, targetKey, objectGroup: request.objectGroup, objectGroupKey, schemaKey, expectedAncestorKeys, connectionId: request.connectionId, dbName: request.dbName, tableName: request.tableName, schemaName: request.schemaName || '', }; }; export const findSidebarNodePathByKey = ( nodes: SidebarLocateTreeNodeLike[], targetKey: string, ): string[] | null => { for (const node of nodes) { const nodeKey = String(node.key); if (nodeKey === targetKey) { return [nodeKey]; } if (node.children) { const childPath = findSidebarNodePathByKey(node.children, targetKey); if (childPath) { return [nodeKey, ...childPath]; } } } return null; }; const matchesLocateObjectName = ( target: SidebarLocateTarget, nodeObjectName: string, nodeSchemaName: string, options: { allowUnqualifiedSchemaMatch?: boolean } = {}, ): boolean => { const normalizedNodeName = toTrimmedString(nodeObjectName); if (!normalizedNodeName) return false; const nodeParsed = splitSidebarQualifiedName(normalizedNodeName); const targetParsed = splitSidebarQualifiedName(target.tableName); const nodeObject = nodeParsed.objectName || normalizedNodeName; const targetObject = targetParsed.objectName || target.tableName; const resolvedNodeSchema = toTrimmedString(nodeSchemaName) || nodeParsed.schemaName; const resolvedTargetSchema = toTrimmedString(target.schemaName) || targetParsed.schemaName; if ( resolvedTargetSchema && !resolvedNodeSchema && normalizeLocateName(resolvedTargetSchema) === normalizeLocateName(target.dbName) && normalizeLocateName(nodeObject) === normalizeLocateName(targetObject) ) { return true; } if ( options.allowUnqualifiedSchemaMatch && !resolvedNodeSchema && normalizeLocateName(nodeObject) === normalizeLocateName(targetObject) ) { return true; } if (!resolvedTargetSchema) { if (options.allowUnqualifiedSchemaMatch) { return normalizeLocateName(nodeObject) === normalizeLocateName(targetObject); } return !resolvedNodeSchema && normalizeLocateName(nodeObject) === normalizeLocateName(targetObject); } return normalizeLocateName(resolvedNodeSchema) === normalizeLocateName(resolvedTargetSchema) && normalizeLocateName(nodeObject) === normalizeLocateName(targetObject); }; const matchesLocateObjectNode = ( node: SidebarLocateTreeNodeLike, target: SidebarLocateTarget, options: { allowUnqualifiedSchemaMatch?: boolean } = {}, ): boolean => { const dataRef = node.dataRef || {}; const nodeObjectType = normalizeLocateName(toTrimmedString(dataRef.objectType || dataRef.objectKind)); if (target.objectGroup === 'externalSqlFiles') { return node.type === 'external-sql-file' && normalizeExternalSQLLocatePath(dataRef.path) === normalizeExternalSQLLocatePath(target.filePath); } const nodeConnectionId = toTrimmedString(dataRef.id || dataRef.connectionId); const nodeDbName = toTrimmedString(dataRef.dbName); if (nodeConnectionId !== target.connectionId || nodeDbName !== target.dbName) { return false; } if (target.objectGroup === 'views') { if (node.type !== 'view' && nodeObjectType !== 'view' && nodeObjectType !== 'views') return false; return matchesLocateObjectName(target, toTrimmedString(dataRef.viewName || dataRef.tableName), toTrimmedString(dataRef.schemaName), options); } if (target.objectGroup === 'materializedViews') { if (node.type !== 'materialized-view' && nodeObjectType !== 'materialized-view' && nodeObjectType !== 'materializedviews') return false; 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), options); } if (target.objectGroup === 'routines') { if (node.type !== 'routine') return false; 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), 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 getVisualNodeObjectName = ( node: SidebarLocateTreeNodeLike, target: SidebarLocateTarget, ): string => { const title = toTrimmedString(node.title); if (title && title !== '[object Object]') return title; const nodeKey = toTrimmedString(node.key); const keyPrefixes = target.objectGroup === 'materializedViews' ? [`${target.databaseKey}-materialized-view-`] : target.objectGroup === 'views' ? [`${target.databaseKey}-view-`] : target.objectGroup === 'triggers' ? [`${target.databaseKey}-trigger-`] : target.objectGroup === 'routines' ? [`${target.databaseKey}-routine-`] : [`${target.databaseKey}-table-`, `${target.databaseKey}-`]; const matchedPrefix = keyPrefixes.find((prefix) => nodeKey.startsWith(prefix)); return matchedPrefix ? nodeKey.slice(matchedPrefix.length) : ''; }; const getLocateObjectGroupPathSuffix = (objectGroup: SidebarLocateObjectGroup): string => { if (objectGroup === 'externalSqlFiles') return 'external-sql-root'; return objectGroup.toLowerCase(); }; const isPathInsideLocateObjectGroup = ( path: string[], target: SidebarLocateTarget, ): boolean => { if (target.objectGroup === 'externalSqlFiles') return false; const normalizedObjectGroupKey = normalizeLocateName(target.objectGroupKey); const groupSuffix = getLocateObjectGroupPathSuffix(target.objectGroup); return path.some((key) => { const normalizedKey = normalizeLocateName(key); return normalizedKey === normalizedObjectGroupKey || normalizedKey.endsWith(`-${groupSuffix}`); }); }; const matchesLocateObjectNodeByVisualIdentity = ( node: SidebarLocateTreeNodeLike, target: SidebarLocateTarget, path: string[], ): boolean => { if (!path.includes(target.databaseKey)) return false; const nodeObjectType = normalizeLocateName(toTrimmedString(node.dataRef?.objectType || node.dataRef?.objectKind)); const insideExpectedGroup = isPathInsideLocateObjectGroup(path, target); if (target.objectGroup === 'views' && node.type !== 'view' && nodeObjectType !== 'view' && nodeObjectType !== 'views' && !insideExpectedGroup) return false; if (target.objectGroup === 'materializedViews' && node.type !== 'materialized-view' && nodeObjectType !== 'materialized-view' && nodeObjectType !== 'materializedviews' && !insideExpectedGroup) return false; if (target.objectGroup === 'triggers' && node.type !== 'db-trigger' && !insideExpectedGroup) return false; if (target.objectGroup === 'routines' && node.type !== 'routine' && !insideExpectedGroup) return false; if (target.objectGroup === 'tables' && node.type !== 'table' && !insideExpectedGroup) return false; if (target.objectGroup === 'externalSqlFiles') return false; const schemaName = toTrimmedString(node.dataRef?.schemaName); return matchesLocateObjectName(target, getVisualNodeObjectName(node, target), schemaName, { allowUnqualifiedSchemaMatch: true }); }; const collectSidebarNodePathsForLocateByVisualIdentity = ( nodes: SidebarLocateTreeNodeLike[], target: SidebarLocateTarget, ancestorPath: string[] = [], ): string[][] => { const paths: string[][] = []; for (const node of nodes) { const nodeKey = String(node.key); const path = [...ancestorPath, nodeKey]; if (matchesLocateObjectNodeByVisualIdentity(node, target, path)) { paths.push(path); } if (node.children) { paths.push(...collectSidebarNodePathsForLocateByVisualIdentity(node.children, target, path)); } } return paths; }; const hasLocateTargetSchema = (target: SidebarLocateTarget): boolean => { if (target.objectGroup === 'externalSqlFiles') return true; return Boolean(toTrimmedString(target.schemaName) || splitSidebarQualifiedName(target.tableName).schemaName); }; const shouldFallbackViewLocateToTableNode = (target: SidebarLocateTarget): boolean => ( 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, ): string[] | null => { const exactPath = findSidebarNodePathByKey(nodes, target.targetKey); if (exactPath) return exactPath; const strictPath = findSidebarNodePathForLocateByObject(nodes, target); if (strictPath) return strictPath; const visualIdentityPaths = collectSidebarNodePathsForLocateByVisualIdentity(nodes, target); const visualIdentityPath = selectPreferredSidebarLocatePath(visualIdentityPaths, target); if (visualIdentityPath) return visualIdentityPath; if (shouldFallbackViewLocateToTableNode(target)) { const tableLikeTarget = { ...target, objectGroup: 'tables' as const }; const tableLikePaths = collectSidebarNodePathsForLocateByObject(nodes, tableLikeTarget); const tableLikePath = selectPreferredSidebarLocatePath(tableLikePaths, target); if (tableLikePath) return tableLikePath; const visualTableLikePaths = collectSidebarNodePathsForLocateByVisualIdentity(nodes, tableLikeTarget); const visualTableLikePath = selectPreferredSidebarLocatePath(visualTableLikePaths, target); if (visualTableLikePath) return visualTableLikePath; if (!hasLocateTargetSchema(target)) { const relaxedTableLikePaths = collectSidebarNodePathsForLocateByObject( nodes, tableLikeTarget, { allowUnqualifiedSchemaMatch: true }, ); const relaxedTableLikePath = selectPreferredSidebarLocatePath(relaxedTableLikePaths, target); if (relaxedTableLikePath) return relaxedTableLikePath; } } const relaxedPaths = collectSidebarNodePathsForLocateByObject( nodes, target, { allowUnqualifiedSchemaMatch: true }, ); const relaxedPath = selectPreferredSidebarLocatePath(relaxedPaths, target); if (relaxedPath) return relaxedPath; if (hasLocateTargetSchema(target)) return null; return null; };