From 6ef0d756aaf1eb15d540db892379d81380bd0824 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 19:13:31 +0000 Subject: [PATCH 1/4] feat(editor): show and qualify multi-schema table names Add shared schemaNames helpers and wire the SQL editor / tables overview so object browser, autocomplete, generated SQL, and index lookup APIs use schema.table for non-default schemas. Co-authored-by: Venkat SF --- .../dbaagent/controller/SchemaController.java | 5 +- .../mysql/MySQLIntrospectionProvider.java | 14 +- .../PostgresIntrospectionProvider.java | 21 ++- src/components/tabs/Core/SqlRunnerTab.js | 158 +++++++++++------- src/components/tabs/Core/TablesOverviewTab.js | 30 ++-- src/lib/api/client.js | 7 +- src/lib/schemaNames.js | 126 ++++++++++++++ src/lib/schemaNames.test.js | 55 ++++++ 8 files changed, 335 insertions(+), 81 deletions(-) create mode 100644 src/lib/schemaNames.js create mode 100644 src/lib/schemaNames.test.js diff --git a/backend/src/main/java/com/dbaagent/controller/SchemaController.java b/backend/src/main/java/com/dbaagent/controller/SchemaController.java index 7244447..8bab6c9 100644 --- a/backend/src/main/java/com/dbaagent/controller/SchemaController.java +++ b/backend/src/main/java/com/dbaagent/controller/SchemaController.java @@ -260,7 +260,8 @@ public ResponseEntity> executeQuery( } } - @GetMapping("/tables/{tableName}/indexes") + // `{tableName:.+}` keeps schema-qualified ids (`crm.orders`) as one segment. + @GetMapping("/tables/{tableName:.+}/indexes") public ResponseEntity> getTableIndexes( @PathVariable String connectionId, @PathVariable String tableName) { @@ -292,7 +293,7 @@ public ResponseEntity> getTableIndexes( } } - @GetMapping("/tables/{tableName}/stats") + @GetMapping("/tables/{tableName:.+}/stats") public ResponseEntity> getTableStats( @PathVariable String connectionId, @PathVariable String tableName) { diff --git a/backend/src/main/java/com/dbaagent/provider/mysql/MySQLIntrospectionProvider.java b/backend/src/main/java/com/dbaagent/provider/mysql/MySQLIntrospectionProvider.java index 16fad76..39eb0a4 100644 --- a/backend/src/main/java/com/dbaagent/provider/mysql/MySQLIntrospectionProvider.java +++ b/backend/src/main/java/com/dbaagent/provider/mysql/MySQLIntrospectionProvider.java @@ -153,6 +153,16 @@ public List getTableIndexes(Connection connection, String database, List indexes = new ArrayList<>(); Map indexMap = new HashMap<>(); + String schemaName = database; + String bareName = tableName; + if (tableName != null) { + int dot = tableName.lastIndexOf('.'); + if (dot > 0) { + schemaName = tableName.substring(0, dot); + bareName = tableName.substring(dot + 1); + } + } + String query = """ SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE, INDEX_TYPE, SEQ_IN_INDEX FROM INFORMATION_SCHEMA.STATISTICS @@ -161,8 +171,8 @@ public List getTableIndexes(Connection connection, String database, """; try (PreparedStatement stmt = connection.prepareStatement(query)) { - stmt.setString(1, database); - stmt.setString(2, tableName); + stmt.setString(1, schemaName); + stmt.setString(2, bareName); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { diff --git a/backend/src/main/java/com/dbaagent/provider/postgres/PostgresIntrospectionProvider.java b/backend/src/main/java/com/dbaagent/provider/postgres/PostgresIntrospectionProvider.java index 63cc158..1d31aba 100644 --- a/backend/src/main/java/com/dbaagent/provider/postgres/PostgresIntrospectionProvider.java +++ b/backend/src/main/java/com/dbaagent/provider/postgres/PostgresIntrospectionProvider.java @@ -204,6 +204,18 @@ public List getTableIndexes(Connection connection, String database, List indexes = new ArrayList<>(); Map indexMap = new HashMap<>(); + // Accept bare `orders` or qualified `crm.orders` so multi-schema UIs + // don't silently merge indexes from every schema that shares the name. + String schemaName = null; + String bareName = tableName; + if (tableName != null) { + int dot = tableName.lastIndexOf('.'); + if (dot > 0) { + schemaName = tableName.substring(0, dot); + bareName = tableName.substring(dot + 1); + } + } + String query = """ SELECT i.relname AS index_name, @@ -212,16 +224,21 @@ public List getTableIndexes(Connection connection, String database, ix.indisprimary AS is_primary, am.amname AS index_type FROM pg_class t + JOIN pg_namespace n ON n.oid = t.relnamespace JOIN pg_index ix ON t.oid = ix.indrelid JOIN pg_class i ON i.oid = ix.indexrelid JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) JOIN pg_am am ON i.relam = am.oid - WHERE t.relname = ? + WHERE t.relkind IN ('r', 'p', 'm', 'v') + AND t.relname = ? + AND (?::text IS NULL OR n.nspname = ?) ORDER BY i.relname, a.attnum """; try (PreparedStatement stmt = connection.prepareStatement(query)) { - stmt.setString(1, tableName); + stmt.setString(1, bareName); + stmt.setString(2, schemaName); + stmt.setString(3, schemaName); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { diff --git a/src/components/tabs/Core/SqlRunnerTab.js b/src/components/tabs/Core/SqlRunnerTab.js index e15ae7e..bf80acf 100644 --- a/src/components/tabs/Core/SqlRunnerTab.js +++ b/src/components/tabs/Core/SqlRunnerTab.js @@ -44,6 +44,12 @@ import QueryOptimizePanel from "./QueryOptimizePanel"; import { useAuth } from "@/hooks/useAuth"; import { useConnections } from "@/lib/hooks/queries/useConnections"; import { HelpTooltip } from "../Brain/components"; +import { + canonicalTableReference, + objectKey, + qualifyForSql, + connectionHasMultipleSchemas, +} from "@/lib/schemaNames"; // Constants for diagram layout const DIAGRAM_NODE_WIDTH = 240; @@ -586,23 +592,25 @@ export default function SqlRunnerTab({ connectionId }) { // Get all table names with fuzzy matching const tables = dbObjects.filter((obj) => obj.type === "table"); tables.forEach((table) => { - const fuzzyResult = fuzzyMatch(currentWord, table.name); + const tableRef = canonicalTableReference(table); + const matchRef = fuzzyMatch(currentWord, tableRef); + const matchBare = fuzzyMatch(currentWord, table.name); + const fuzzyResult = matchRef.matches ? matchRef : matchBare; if (fuzzyResult.matches) { - // Use fuzzy score to boost sortText (100 - score to make higher scores sort first) const fuzzyBoost = String(100 - fuzzyResult.score).padStart( 3, "0", ); suggestions.push({ - label: table.name, + label: tableRef, kind: monaco.languages.CompletionItemKind.Class, - insertText: table.name, + insertText: qualifyForSql(table), range: range, - detail: "Table", + detail: table.schema && table.schema !== "public" ? `Table · ${table.schema}` : "Table", documentation: table.columns ? `${table.columns.length} columns` : "Table", - sortText: getPriority("table") + fuzzyBoost + table.name, + sortText: getPriority("table") + fuzzyBoost + tableRef, }); } }); @@ -610,27 +618,30 @@ export default function SqlRunnerTab({ connectionId }) { // Get all view names with fuzzy matching const views = dbObjects.filter((obj) => obj.type === "view"); views.forEach((view) => { - const fuzzyResult = fuzzyMatch(currentWord, view.name); + const viewRef = canonicalTableReference(view); + const matchRef = fuzzyMatch(currentWord, viewRef); + const matchBare = fuzzyMatch(currentWord, view.name); + const fuzzyResult = matchRef.matches ? matchRef : matchBare; if (fuzzyResult.matches) { const fuzzyBoost = String(100 - fuzzyResult.score).padStart( 3, "0", ); suggestions.push({ - label: view.name, + label: viewRef, kind: monaco.languages.CompletionItemKind.View, - insertText: view.name, + insertText: qualifyForSql(view), range: range, - detail: "View", + detail: view.schema && view.schema !== "public" ? `View · ${view.schema}` : "View", documentation: "Database view", - sortText: getPriority("view") + fuzzyBoost + view.name, + sortText: getPriority("view") + fuzzyBoost + viewRef, }); } }); // Detect table aliases (e.g., "FROM users u", "FROM users AS u") const aliasPattern = - /(?:FROM|JOIN)\s+(\w+)(?:\s+AS\s+(\w+)|\s+(\w+)(?=\s|,|WHERE|JOIN|GROUP|ORDER|LIMIT|$))/gi; + /(?:FROM|JOIN)\s+((?:\w+\.)?\w+)(?:\s+AS\s+(\w+)|\s+(\w+)(?=\s|,|WHERE|JOIN|GROUP|ORDER|LIMIT|$))/gi; const aliases = {}; let aliasMatch; @@ -657,9 +668,12 @@ export default function SqlRunnerTab({ connectionId }) { if (actualTableName) { // User typed an alias - suggest columns from that table - const table = tables.find( - (t) => t.name.toLowerCase() === actualTableName.toLowerCase(), - ); + const table = tables.find((t) => { + const ref = canonicalTableReference(t).toLowerCase(); + const bare = (t.name || "").toLowerCase(); + const wanted = actualTableName.toLowerCase(); + return ref === wanted || bare === wanted || qualifyForSql(t).toLowerCase() === wanted; + }); if (table && table.columns) { table.columns.forEach((column) => { const fuzzyResult = fuzzyMatch(currentWord, column.name); @@ -675,7 +689,7 @@ export default function SqlRunnerTab({ connectionId }) { : monaco.languages.CompletionItemKind.Field, insertText: column.name, range: range, - detail: `${possibleAlias}.${column.name} (${table.name})`, + detail: `${possibleAlias}.${column.name} (${canonicalTableReference(table)})`, documentation: `${column.dataType}${column.nullable ? " (nullable)" : " (not null)"}${column.primaryKey ? " [PK]" : ""}`, sortText: getPriority("column") + fuzzyBoost + column.name, @@ -684,10 +698,13 @@ export default function SqlRunnerTab({ connectionId }) { }); } } else { - // Not an alias - check if it's a direct table name - const table = tables.find( - (t) => t.name.toLowerCase() === possibleAlias.toLowerCase(), - ); + // Not an alias - check if it's a direct table / schema.table name + const table = tables.find((t) => { + const ref = canonicalTableReference(t).toLowerCase(); + const bare = (t.name || "").toLowerCase(); + const wanted = possibleAlias.toLowerCase(); + return ref === wanted || bare === wanted; + }); if (table && table.columns) { table.columns.forEach((column) => { const fuzzyResult = fuzzyMatch(currentWord, column.name); @@ -703,7 +720,7 @@ export default function SqlRunnerTab({ connectionId }) { : monaco.languages.CompletionItemKind.Field, insertText: column.name, range: range, - detail: `${table.name}.${column.name}`, + detail: `${canonicalTableReference(table)}.${column.name}`, documentation: `${column.dataType}${column.nullable ? " (nullable)" : " (not null)"}${column.primaryKey ? " [PK]" : ""}`, sortText: getPriority("column") + fuzzyBoost + column.name, @@ -717,7 +734,7 @@ export default function SqlRunnerTab({ connectionId }) { tables.forEach((table) => { if (table.columns) { table.columns.forEach((column) => { - const fullName = `${table.name}.${column.name}`; + const fullName = `${canonicalTableReference(table)}.${column.name}`; const fuzzyResult = fuzzyMatch(currentWord, fullName); if (fuzzyResult.matches) { const fuzzyBoost = String(100 - fuzzyResult.score).padStart( @@ -1492,7 +1509,8 @@ export default function SqlRunnerTab({ connectionId }) { const handleTableClick = (table) => { setSelectedTable(table); const columnList = table.columns?.map((col) => col.name).join(", ") || "*"; - const sql = `SELECT ${columnList}\nFROM ${table.name}\nLIMIT 10;`; + const from = qualifyForSql(table); + const sql = `SELECT ${columnList}\nFROM ${from}\nLIMIT 10;`; if (editorRef.current) editorRef.current.setValue(sql); setQuery(sql); }; @@ -1501,13 +1519,17 @@ export default function SqlRunnerTab({ connectionId }) { // Just select the table without overwriting the query setSelectedTable(table); // Expand the table to show columns - toggleNode(`table-${table.name}`); + toggleNode(`table-${objectKey(table)}`); }; - const handleColumnInsert = (tableName, columnName) => { + const handleColumnInsert = (tableOrName, columnName) => { // Get current value directly from the editor (not from stale React state) const current = editorRef.current ? editorRef.current.getValue() : query; - const newValue = current + (current ? "\n" : "") + `${tableName}.${columnName}`; + const tableRef = + typeof tableOrName === "string" + ? tableOrName + : canonicalTableReference(tableOrName); + const newValue = current + (current ? "\n" : "") + `${tableRef}.${columnName}`; if (editorRef.current) editorRef.current.setValue(newValue); setQuery(newValue); }; @@ -1524,7 +1546,7 @@ export default function SqlRunnerTab({ connectionId }) { const handlePreviewData = () => { if (contextMenu.table) { - const previewQuery = `SELECT *\nFROM ${contextMenu.table.name}\nLIMIT 100;`; + const previewQuery = `SELECT *\nFROM ${qualifyForSql(contextMenu.table)}\nLIMIT 100;`; if (editorRef.current) editorRef.current.setValue(previewQuery); setQuery(previewQuery); setContextMenu({ visible: false, x: 0, y: 0, table: null }); @@ -1545,7 +1567,7 @@ export default function SqlRunnerTab({ connectionId }) { try { const response = await queryAPI.getTableIndexes( connectionId, - contextMenu.table.name, + objectKey(contextMenu.table), ); if (response.success) { setTableIndexes(response.indexes || []); @@ -1573,7 +1595,7 @@ export default function SqlRunnerTab({ connectionId }) { const handleCopyName = () => { if (contextMenu.table) { - copyToClipboard(contextMenu.table.name); + copyToClipboard(canonicalTableReference(contextMenu.table)); } }; @@ -1589,7 +1611,7 @@ export default function SqlRunnerTab({ connectionId }) { const handleGenerateSQL = (type) => { if (!contextMenu.table) return; - const tableName = contextMenu.table.name; + const tableName = qualifyForSql(contextMenu.table); const columns = contextMenu.table.columns || []; const columnList = columns.map((col) => col.name).join(",\n "); @@ -1633,7 +1655,7 @@ export default function SqlRunnerTab({ connectionId }) { } const columns = contextMenu.table.columns; - const tableName = contextMenu.table.name; + const tableName = canonicalTableReference(contextMenu.table); // Create schema text with table name header let schemaText = `-- Schema for table: ${tableName}\n`; @@ -1662,7 +1684,7 @@ export default function SqlRunnerTab({ connectionId }) { const handleCopyURL = () => { if (contextMenu.table) { - const url = `${window.location.origin}/tables/${contextMenu.table.name}`; + const url = `${window.location.origin}/tables/${objectKey(contextMenu.table)}`; copyToClipboard(url); } }; @@ -1713,11 +1735,19 @@ export default function SqlRunnerTab({ connectionId }) { }; // Filter objects based on search (includes table names and column names) + const multiSchema = connectionHasMultipleSchemas(databaseObjects); + const filteredObjects = searchTerm ? databaseObjects.filter((obj) => { const searchLower = searchTerm.toLowerCase(); - // Match table/view/function/procedure name - if (obj.name.toLowerCase().includes(searchLower)) { + const ref = canonicalTableReference(obj).toLowerCase(); + const schema = (obj.schema || obj.schemaName || "").toLowerCase(); + // Match table/view/function/procedure name or schema + if ( + obj.name.toLowerCase().includes(searchLower) || + ref.includes(searchLower) || + schema.includes(searchLower) + ) { return true; } // Match columns if it's a table or view @@ -1830,19 +1860,19 @@ export default function SqlRunnerTab({ connectionId }) { {filteredObjects.length !== 1 ? "s" : ""} {filteredObjects.map((obj) => ( -
+
handleTableSelect(obj)} >
{ e.stopPropagation(); - toggleNode(`table-${obj.name}`); + toggleNode(`table-${objectKey(obj)}`); }} > - {expandedNodes[`table-${obj.name}`] ? ( + {expandedNodes[`table-${objectKey(obj)}`] ? ( ) : ( @@ -1855,7 +1885,7 @@ export default function SqlRunnerTab({ connectionId }) { {obj.type === "procedure" && ( )} - {obj.name} + {canonicalTableReference(obj)}
{hasRowCount(obj.rowCount) && ( @@ -1863,7 +1893,7 @@ export default function SqlRunnerTab({ connectionId }) { )}
- {expandedNodes[`table-${obj.name}`] && + {expandedNodes[`table-${objectKey(obj)}`] && obj.columns && (
{obj.columns.map((column) => { @@ -1874,12 +1904,12 @@ export default function SqlRunnerTab({ connectionId }) { .includes(searchTerm.toLowerCase()); return (
{ e.stopPropagation(); handleColumnInsert( - obj.name, + obj, column.name, ); }} @@ -1926,9 +1956,9 @@ export default function SqlRunnerTab({ connectionId }) { {expandedNodes.tables && (
{groupedObjects.tables.map((table) => ( -
+
handleTableClick(table)} onContextMenu={(e) => handleTableContextMenu(e, table) @@ -1938,15 +1968,19 @@ export default function SqlRunnerTab({ connectionId }) { className={styles.objectName} onClick={(e) => { e.stopPropagation(); - toggleNode(`table-${table.name}`); + toggleNode(`table-${objectKey(table)}`); }} > - {expandedNodes[`table-${table.name}`] ? ( + {expandedNodes[`table-${objectKey(table)}`] ? ( ) : ( )} - {table.name} + + {multiSchema && table.schema && table.schema !== 'public' + ? <>{table.schema}.{table.name} + : table.name} +
{hasRowCount(table.rowCount) && ( @@ -1954,7 +1988,7 @@ export default function SqlRunnerTab({ connectionId }) { )}
- {expandedNodes[`table-${table.name}`] && + {expandedNodes[`table-${objectKey(table)}`] && table.columns && (
{table.columns.map((column) => { @@ -1974,12 +2008,12 @@ export default function SqlRunnerTab({ connectionId }) { : null; return (
{ e.stopPropagation(); handleColumnInsert( - table.name, + table, column.name, ); }} @@ -2057,11 +2091,11 @@ export default function SqlRunnerTab({ connectionId }) {
{groupedObjects.views.map((view) => (
handleTableClick(view)} > - {view.name} + {canonicalTableReference(view)}
))}
@@ -2091,10 +2125,10 @@ export default function SqlRunnerTab({ connectionId }) {
{groupedObjects.functions.map((func) => (
- {func.name} + {canonicalTableReference(func)}
))}
@@ -2124,10 +2158,10 @@ export default function SqlRunnerTab({ connectionId }) {
{groupedObjects.procedures.map((proc) => (
- {proc.name} + {canonicalTableReference(proc)}
))}
@@ -2159,14 +2193,14 @@ export default function SqlRunnerTab({ connectionId }) { ) : ( groupedObjects.views.map((view) => (
handleTableClick(view)} >
- {view.name} + {canonicalTableReference(view)}
{view.columns && ( @@ -2201,14 +2235,14 @@ export default function SqlRunnerTab({ connectionId }) { ) : ( groupedObjects.functions.map((func) => (
handleTableClick(func)} >
- {func.name} + {canonicalTableReference(func)}
@@ -2238,14 +2272,14 @@ export default function SqlRunnerTab({ connectionId }) { ) : ( groupedObjects.procedures.map((proc) => (
handleTableClick(proc)} >
- {proc.name} + {canonicalTableReference(proc)}
diff --git a/src/components/tabs/Core/TablesOverviewTab.js b/src/components/tabs/Core/TablesOverviewTab.js index fd926e5..1c70f4b 100644 --- a/src/components/tabs/Core/TablesOverviewTab.js +++ b/src/components/tabs/Core/TablesOverviewTab.js @@ -6,6 +6,7 @@ import { } from 'lucide-react' import styles from './TablesOverviewTab.module.css' import { queryAPI } from '@/lib/api/client' +import { canonicalTableReference, objectKey, qualifyForSql } from '@/lib/schemaNames' export default function TablesOverviewTab({ connectionId }) { const [tables, setTables] = useState([]) @@ -24,9 +25,16 @@ export default function TablesOverviewTab({ connectionId }) { useEffect(() => { if (searchTerm) { - const filtered = tables.filter(table => - table.name.toLowerCase().includes(searchTerm.toLowerCase()) - ) + const q = searchTerm.toLowerCase() + const filtered = tables.filter(table => { + const ref = canonicalTableReference(table).toLowerCase() + const schema = (table.schema || table.schemaName || '').toLowerCase() + return ( + table.name.toLowerCase().includes(q) || + ref.includes(q) || + schema.includes(q) + ) + }) setFilteredTables(filtered) } else { setFilteredTables(tables) @@ -57,13 +65,13 @@ export default function TablesOverviewTab({ connectionId }) { const tablesWithStats = await Promise.all( tableObjects.map(async (table) => { try { - const statsResponse = await queryAPI.getTableStats(connectionId, table.name) + const statsResponse = await queryAPI.getTableStats(connectionId, objectKey(table)) return { ...table, stats: statsResponse.success ? statsResponse.stats : null } } catch (err) { - console.error(`Failed to fetch stats for ${table.name}:`, err) + console.error(`Failed to fetch stats for ${canonicalTableReference(table)}:`, err) return { ...table, stats: null } } }) @@ -98,7 +106,7 @@ export default function TablesOverviewTab({ connectionId }) { const handleCopyName = () => { if (contextMenu.table) { - copyToClipboard(contextMenu.table.name) + copyToClipboard(canonicalTableReference(contextMenu.table)) } } @@ -112,7 +120,7 @@ export default function TablesOverviewTab({ connectionId }) { const handlePreviewTableData = async () => { if (contextMenu.table) { // TODO: Open in SQL Runner tab with SELECT query - const query = `SELECT *\nFROM ${contextMenu.table.name}\nLIMIT 100;` + const query = `SELECT *\nFROM ${qualifyForSql(contextMenu.table)}\nLIMIT 100;` copyToClipboard(query) setContextMenu({ visible: false, x: 0, y: 0, table: null }) } @@ -121,7 +129,7 @@ export default function TablesOverviewTab({ connectionId }) { const handleGenerateSQL = (type) => { if (!contextMenu.table) return - const tableName = contextMenu.table.name + const tableName = qualifyForSql(contextMenu.table) const columns = contextMenu.table.columns || [] const columnNames = columns.map(col => col.name).join(', ') const columnList = columns.map(col => col.name).join(',\n ') @@ -178,7 +186,7 @@ export default function TablesOverviewTab({ connectionId }) { const handleCopyURL = () => { if (contextMenu.table) { - const url = `${window.location.origin}/tables/${contextMenu.table.name}` + const url = `${window.location.origin}/tables/${objectKey(contextMenu.table)}` copyToClipboard(url) } } @@ -250,13 +258,13 @@ export default function TablesOverviewTab({ connectionId }) { {filteredTables.map((table) => ( handleContextMenu(e, table)} className={styles.tableRow} > - {table.name} + {canonicalTableReference(table)} diff --git a/src/lib/api/client.js b/src/lib/api/client.js index fdf8b03..4e1a9d8 100644 --- a/src/lib/api/client.js +++ b/src/lib/api/client.js @@ -1403,15 +1403,18 @@ export const queryAPI = { }, getTableIndexes: async (connectionId, tableName) => { + // Encode so schema-qualified ids (`crm.orders`) survive the path segment. + const tableId = encodeURIComponent(String(tableName || "")); const response = await apiClient.get( - `/api/connections/${connectionId}/tables/${tableName}/indexes`, + `/api/connections/${connectionId}/tables/${tableId}/indexes`, ); return response.data; }, getTableStats: async (connectionId, tableName) => { + const tableId = encodeURIComponent(String(tableName || "")); const response = await apiClient.get( - `/api/connections/${connectionId}/tables/${tableName}/stats`, + `/api/connections/${connectionId}/tables/${tableId}/stats`, ); return response.data; }, diff --git a/src/lib/schemaNames.js b/src/lib/schemaNames.js new file mode 100644 index 0000000..88aa9c9 --- /dev/null +++ b/src/lib/schemaNames.js @@ -0,0 +1,126 @@ +/** + * Shared helpers for multi-schema object naming in the UI. + * + * Display / SQL rule: + * - Default schemas (`public`, `dbo`) stay bare for single-schema ergonomics. + * - All other schemas render and insert as `schema.table`. + * - React keys and expand ids always use {@link objectKey} so `crm.orders` + * and `sales.orders` never collide. + */ + +const DEFAULT_SCHEMAS = new Set(['public', 'dbo']) + +export function isDefaultSchema(schemaName) { + if (!schemaName) return true + return DEFAULT_SCHEMAS.has(String(schemaName).trim().toLowerCase()) +} + +export function stripIdentQuotes(value) { + return String(value || '').trim().replace(/[`"\[\]]/g, '') +} + +/** + * Human / SQL label: bare for default schema, otherwise `schema.table`. + */ +export function canonicalTableReference(tableOrSchema, maybeName) { + let schemaName = '' + let tableName = '' + if (typeof tableOrSchema === 'string' && maybeName !== undefined) { + schemaName = stripIdentQuotes(tableOrSchema) + tableName = stripIdentQuotes(maybeName) + } else if (typeof tableOrSchema === 'string') { + const parsed = parseQualifiedName(tableOrSchema) + schemaName = parsed.schema + tableName = parsed.name + } else { + const obj = tableOrSchema || {} + tableName = stripIdentQuotes(obj.tableName || obj.name || '') + schemaName = stripIdentQuotes(obj.schema || obj.schemaName || '') + } + if (!tableName) return '' + if (isDefaultSchema(schemaName)) return tableName + return `${schemaName}.${tableName}` +} + +/** + * Stable unique key for lists / expand state — always includes schema when known. + * Falls back to bare name only when schema is missing. + */ +export function objectKey(obj) { + if (!obj) return '' + const name = stripIdentQuotes(obj.tableName || obj.name || obj.table || '') + const schema = stripIdentQuotes(obj.schema || obj.schemaName || '') + if (!name) return '' + if (schema) return `${schema}.${name}` + return name +} + +/** Split `schema.table` (last dot) into parts. Bare names → schema ''. */ +export function parseQualifiedName(value) { + const raw = stripIdentQuotes(value) + if (!raw) return { schema: '', name: '' } + const dot = raw.lastIndexOf('.') + if (dot <= 0) return { schema: '', name: raw } + return { schema: raw.slice(0, dot), name: raw.slice(dot + 1) } +} + +/** + * True when the connection has objects in more than one user schema + * (or any non-default schema). + */ +export function connectionHasMultipleSchemas(objects = []) { + const schemas = new Set() + for (const obj of objects) { + const schema = stripIdentQuotes(obj?.schema || obj?.schemaName || '') || 'public' + schemas.add(schema.toLowerCase()) + if (schemas.size > 1) return true + } + return [...schemas].some((s) => !isDefaultSchema(s)) +} + +/** + * Group objects by schema for explorer trees. Default schema sorts first. + */ +export function groupBySchema(objects = []) { + const groups = new Map() + for (const obj of objects) { + const schema = stripIdentQuotes(obj?.schema || obj?.schemaName || '') || 'public' + if (!groups.has(schema)) groups.set(schema, []) + groups.get(schema).push(obj) + } + return [...groups.entries()].sort(([a], [b]) => { + if (isDefaultSchema(a) && !isDefaultSchema(b)) return -1 + if (!isDefaultSchema(a) && isDefaultSchema(b)) return 1 + return a.localeCompare(b) + }) +} + +/** Quote an identifier if it needs it (reserved / mixed case / non-plain). */ +export function quoteIdent(ident, dialect = 'postgres') { + const name = stripIdentQuotes(ident) + if (!name) return '' + const plain = /^[a-z_][a-z0-9_]*$/i.test(name) + if (plain) return name + if (dialect === 'mysql') return `\`${name.replace(/`/g, '``')}\`` + return `"${name.replace(/"/g, '""')}"` +} + +/** Build `schema.table` (quoted when needed) for FROM / INSERT / etc. */ +export function qualifyForSql(tableOrObj, dialect = 'postgres') { + const ref = typeof tableOrObj === 'string' + ? parseQualifiedName(tableOrObj) + : { + schema: stripIdentQuotes(tableOrObj?.schema || tableOrObj?.schemaName || ''), + name: stripIdentQuotes(tableOrObj?.tableName || tableOrObj?.name || ''), + } + if (!ref.name) return '' + const tableSql = quoteIdent(ref.name, dialect) + if (!ref.schema || isDefaultSchema(ref.schema)) return tableSql + return `${quoteIdent(ref.schema, dialect)}.${tableSql}` +} + +/** Path-safe table id for REST `/tables/{id}/…` (supports schema.table). */ +export function encodeTablePathId(tableOrObj) { + const key = typeof tableOrObj === 'string' ? stripIdentQuotes(tableOrObj) : objectKey(tableOrObj) + return encodeURIComponent(key) +} diff --git a/src/lib/schemaNames.test.js b/src/lib/schemaNames.test.js new file mode 100644 index 0000000..c063660 --- /dev/null +++ b/src/lib/schemaNames.test.js @@ -0,0 +1,55 @@ +/** + * Lightweight unit tests for multi-schema name helpers. + * Run: node --test src/lib/schemaNames.test.js + */ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { + canonicalTableReference, + objectKey, + qualifyForSql, + parseQualifiedName, + connectionHasMultipleSchemas, + isDefaultSchema, +} from './schemaNames.js' + +describe('schemaNames', () => { + it('keeps default schemas bare', () => { + assert.equal(canonicalTableReference({ schema: 'public', name: 'orders' }), 'orders') + assert.equal(canonicalTableReference({ schema: 'dbo', name: 'orders' }), 'orders') + assert.equal(isDefaultSchema('public'), true) + }) + + it('qualifies non-default schemas', () => { + assert.equal(canonicalTableReference({ schema: 'crm', name: 'orders' }), 'crm.orders') + assert.equal(objectKey({ schema: 'crm', name: 'orders' }), 'crm.orders') + assert.equal(objectKey({ schema: 'sales', name: 'orders' }), 'sales.orders') + }) + + it('builds SQL FROM targets', () => { + assert.equal(qualifyForSql({ schema: 'public', name: 'orders' }), 'orders') + assert.equal(qualifyForSql({ schema: 'crm', name: 'orders' }), 'crm.orders') + }) + + it('parses qualified names', () => { + assert.deepEqual(parseQualifiedName('crm.orders'), { schema: 'crm', name: 'orders' }) + assert.deepEqual(parseQualifiedName('orders'), { schema: '', name: 'orders' }) + }) + + it('detects multi-schema connections', () => { + assert.equal( + connectionHasMultipleSchemas([ + { schema: 'crm', name: 'a' }, + { schema: 'sales', name: 'b' }, + ]), + true, + ) + assert.equal( + connectionHasMultipleSchemas([ + { schema: 'public', name: 'a' }, + { schema: 'public', name: 'b' }, + ]), + false, + ) + }) +}) From e46fe52116c7501b438bcdb590864d35d61e9662 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 19:13:31 +0000 Subject: [PATCH 2/4] feat(brain): qualify schema docs and knowledge for multi-schema Persist/display tableReference in Schema Docs notes, show schema in knowledge @ suggestions, and use qualified ids in ERD / classification / key-column grouping so crm.orders and sales.orders do not collide. Co-authored-by: Venkat SF --- .../CompanyKnowledgePanel.jsx | 72 ++++++++++++------- src/components/tabs/Brain/DetailsLibrary.js | 5 +- src/components/tabs/Brain/KeyColumnsPanel.js | 11 +-- .../tabs/Brain/SchemaClassificationPanel.js | 3 +- .../tabs/Brain/SchemaDocs/SchemaDocsPanel.js | 25 ++++--- .../Brain/SchemaDocs/SchemaDocsTableRow.js | 10 +-- .../Brain/SchemaDocs/useSchemaDocsData.js | 57 +++++++++------ src/components/tabs/Brain/SchemaERD.js | 15 ++-- src/components/tabs/Brain/modals/NoteModal.js | 2 +- 9 files changed, 125 insertions(+), 75 deletions(-) diff --git a/src/components/company-knowledge/CompanyKnowledgePanel.jsx b/src/components/company-knowledge/CompanyKnowledgePanel.jsx index 3919c9c..d031f93 100644 --- a/src/components/company-knowledge/CompanyKnowledgePanel.jsx +++ b/src/components/company-knowledge/CompanyKnowledgePanel.jsx @@ -18,6 +18,7 @@ import CodeSourcesTab from './CodeSourcesTab' import SuggestionsQueueTab from './SuggestionsQueueTab' import EntriesTable from './EntriesTable' import SchemaContextTab from './SchemaContextTab' +import { canonicalTableReference } from '@/lib/schemaNames' const EMPTY_FORM = { title: '', @@ -27,16 +28,6 @@ const EMPTY_FORM = { const TABLE_ANNOTATION_RE = /(? { - const keys = [ - table.value, - table.label, - table.value.split('.').pop(), - ] + const bare = (table.value || '').split('.').pop() + if (!bare) return + bareCounts.set(normalizeValue(bare), (bareCounts.get(normalizeValue(bare)) || 0) + 1) + }) + tableOptions.forEach((table) => { + const bare = (table.value || '').split('.').pop() + const bareKey = normalizeValue(bare) + // Always index the canonical value. Index the bare name only when unique + // across schemas so @orders stays unambiguous on multi-schema DBs. + const keys = [table.value, table.label] + if (bare && bareCounts.get(bareKey) === 1) { + keys.push(bare) + } keys .filter(Boolean) .forEach((key) => lookup.set(normalizeValue(key), table.value)) @@ -83,14 +83,24 @@ function buildTableLookup(tableOptions) { function buildColumnLookup(columnOptions) { const lookup = new Map() + const shortCounts = new Map() + columnOptions.forEach((column) => { + const shortTable = column.tableValue?.split('.').pop() + const shortKey = normalizeValue(`${shortTable}.${column.columnLabel}`) + if (!shortKey) return + shortCounts.set(shortKey, (shortCounts.get(shortKey) || 0) + 1) + }) columnOptions.forEach((column) => { const canonical = column.value const shortTable = column.tableValue?.split('.').pop() + const shortKey = `${shortTable}.${column.columnLabel}` const keys = [ canonical, `${column.tableValue}.${column.columnLabel}`, - `${shortTable}.${column.columnLabel}`, ] + if (shortCounts.get(normalizeValue(shortKey)) === 1) { + keys.push(shortKey) + } keys .filter(Boolean) .forEach((key) => lookup.set(normalizeValue(key), canonical)) @@ -267,16 +277,26 @@ export default function CompanyKnowledgePanel({ connectionId }) { const tableOptions = useMemo( () => (schemaQuery.data?.schema?.tables || schemaQuery.data?.tables || []) - .map((table) => ({ - label: table.tableName || table.name, - value: canonicalTableReference(table), - columns: (table.columns || []).map((column) => ({ - label: `${table.tableName || table.name}.${column.columnName || column.name}`, - value: `${canonicalTableReference(table)}.${column.columnName || column.name}`, - columnLabel: column.columnName || column.name, - tableValue: canonicalTableReference(table), - })), - })) + .map((table) => { + const value = canonicalTableReference(table) + const bare = table.tableName || table.name || '' + // When the same bare name exists in multiple schemas, force the + // qualified label so @ suggestions never look ambiguous. + return { + label: value, + bareLabel: bare, + value, + columns: (table.columns || []).map((column) => { + const colName = column.columnName || column.name + return { + label: `${value}.${colName}`, + value: `${value}.${colName}`, + columnLabel: colName, + tableValue: value, + } + }), + } + }) .filter((table) => table.value), [schemaQuery.data], ) diff --git a/src/components/tabs/Brain/DetailsLibrary.js b/src/components/tabs/Brain/DetailsLibrary.js index 6bd52e7..b22e04c 100644 --- a/src/components/tabs/Brain/DetailsLibrary.js +++ b/src/components/tabs/Brain/DetailsLibrary.js @@ -176,7 +176,8 @@ export function DetailsLibrary({ const downloadTemplate = () => { const template = [ "table_name,column_name,details", - "orders,,Contains order-level details used in analytics dashboards.", + "crm.orders,,Contains order-level details used in analytics dashboards.", + "sales.orders,,Order headers for the sales schema (use schema.table when names collide).", "orders,order_total,Total order value in USD after discounts.", ].join("\n"); const blob = new Blob([template], { type: "text/csv;charset=utf-8;" }); @@ -444,7 +445,7 @@ export function DetailsLibrary({
Bulk upload details

- Upload a CSV or Excel file with columns: table_name, column_name + Upload a CSV or Excel file with columns: table_name, column_name (use schema.table for non-public schemas) (optional), details. The first sheet is used for Excel.

diff --git a/src/components/tabs/Brain/KeyColumnsPanel.js b/src/components/tabs/Brain/KeyColumnsPanel.js index 85842ed..2f2dbfd 100644 --- a/src/components/tabs/Brain/KeyColumnsPanel.js +++ b/src/components/tabs/Brain/KeyColumnsPanel.js @@ -5,6 +5,7 @@ import { AlertCircle, Play, Loader, ChevronDown, ChevronRight, AlertTriangle, In import { useKeyColumns } from './hooks/useKeyColumns' import { ActionGuard } from '@/components/ActionGuard' import styles from '../Core/RagTrainingTab.module.css' +import { canonicalTableReference, objectKey } from '@/lib/schemaNames' /** * Key Columns Panel component @@ -94,18 +95,18 @@ export function KeyColumnsPanel({ connectionId, hideCTAs = false }) { return true }) - // Group columns by table name (case-insensitive), sorted by number of columns descending + // Group columns by schema.table (case-insensitive), sorted by number of columns descending const groupedByTable = useMemo(() => { const groups = {} const tableNameMap = {} // Maps lowercase to display name topColumns.forEach(column => { - const rawTableName = column.tableName || 'Unknown' - const tableKey = rawTableName.toLowerCase() + const displayName = canonicalTableReference(column) || column.tableName || 'Unknown' + const tableKey = (objectKey(column) || displayName).toLowerCase() // Keep track of preferred display name (prefer UPPER_CASE version if available) - if (!tableNameMap[tableKey] || rawTableName === rawTableName.toUpperCase()) { - tableNameMap[tableKey] = rawTableName + if (!tableNameMap[tableKey] || displayName === displayName.toUpperCase()) { + tableNameMap[tableKey] = displayName } if (!groups[tableKey]) { diff --git a/src/components/tabs/Brain/SchemaClassificationPanel.js b/src/components/tabs/Brain/SchemaClassificationPanel.js index c028c73..d5253be 100644 --- a/src/components/tabs/Brain/SchemaClassificationPanel.js +++ b/src/components/tabs/Brain/SchemaClassificationPanel.js @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { AlertCircle, Loader, Info, Database, Table2 } from 'lucide-react' +import { canonicalTableReference } from '@/lib/schemaNames' import { ActionGuard } from '@/components/ActionGuard' import { HelpTooltip } from './components/HelpTooltip' import { useSchemaClassification } from './hooks/useSchemaClassification' @@ -320,7 +321,7 @@ function TableList({ tables, roleFilter, setRoleFilter, getRoleColors, getHealth const renderCell = (table, column) => { switch (column) { case 'Table Name': - return
+ return case 'Role': return (
{table.stats?.engine || 'InnoDB'} {table.stats?.collation || 'utf8mb3_general_ci'}{table.tableName}{canonicalTableReference(table) || table.tableName} diff --git a/src/components/tabs/Brain/SchemaDocs/SchemaDocsPanel.js b/src/components/tabs/Brain/SchemaDocs/SchemaDocsPanel.js index b06cdc3..baf085c 100644 --- a/src/components/tabs/Brain/SchemaDocs/SchemaDocsPanel.js +++ b/src/components/tabs/Brain/SchemaDocs/SchemaDocsPanel.js @@ -42,16 +42,18 @@ export function SchemaDocsPanel({ const q = searchTerm.toLowerCase() return tables.filter(t => { const name = (t.tableName || '').toLowerCase() + const ref = (t.tableReference || '').toLowerCase() + const schema = (t.schemaName || '').toLowerCase() const desc = (t.note?.noteText || '').toLowerCase() - return name.includes(q) || desc.includes(q) + return name.includes(q) || ref.includes(q) || schema.includes(q) || desc.includes(q) }) }, [tables, searchTerm]) - const toggleTable = useCallback((tableName) => { + const toggleTable = useCallback((tableKey) => { setExpandedTables(prev => { const next = new Set(prev) - if (next.has(tableName)) next.delete(tableName) - else next.add(tableName) + if (next.has(tableKey)) next.delete(tableKey) + else next.add(tableKey) return next }) }, []) @@ -63,6 +65,8 @@ export function SchemaDocsPanel({ const payload = { connectionId, scopeType: 'TABLE', + // Persist the qualified reference when present so multi-schema + // notes never collide on bare table names. tableName, columnName: null, noteText: text, @@ -157,18 +161,21 @@ export function SchemaDocsPanel({ ) : (
- {filteredTables.map(table => ( + {filteredTables.map(table => { + const tableKey = table.tableReference || table.tableName + return ( toggleTable(table.tableName)} + expanded={expandedTables.has(tableKey)} + onToggle={() => toggleTable(tableKey)} onSaveTableNote={handleSaveTableNote} onSaveColumnNote={handleSaveColumnNote} savingNoteId={savingNoteId} onOpenCompanyKnowledge={onOpenCompanyKnowledge} /> - ))} + ) + })}
)} diff --git a/src/components/tabs/Brain/SchemaDocs/SchemaDocsTableRow.js b/src/components/tabs/Brain/SchemaDocs/SchemaDocsTableRow.js index 61e380b..7f77522 100644 --- a/src/components/tabs/Brain/SchemaDocs/SchemaDocsTableRow.js +++ b/src/components/tabs/Brain/SchemaDocs/SchemaDocsTableRow.js @@ -34,6 +34,8 @@ export function SchemaDocsTableRow({ const roleClass = ROLE_STYLES[table.role] || styles.roleDefault const descriptionText = table.note?.noteText || '' + const displayName = table.tableReference || table.tableName + const persistTableName = table.tableReference || table.tableName return (
@@ -42,7 +44,7 @@ export function SchemaDocsTableRow({ size={14} className={`${styles.chevron} ${expanded ? styles.chevronExpanded : ''}`} /> - {table.tableName} + {displayName}
{table.rowCount != null && ( @@ -98,7 +100,7 @@ export function SchemaDocsTableRow({ source={table.note?.source} noteId={table.note?.id} sourceFiles={table.note?.sourceFiles} - onSave={(text, noteId) => onSaveTableNote(table.tableName, text, noteId)} + onSave={(text, noteId) => onSaveTableNote(persistTableName, text, noteId)} saving={savingNoteId === table.note?.id} placeholder="Click to add table description" /> @@ -107,10 +109,10 @@ export function SchemaDocsTableRow({
{table.columns.map(col => ( - onSaveColumnNote(table.tableName, col.columnName, text, noteId) + onSaveColumnNote(persistTableName, col.columnName, text, noteId) } saving={savingNoteId === col.note?.id} onOpenCompanyKnowledge={onOpenCompanyKnowledge} diff --git a/src/components/tabs/Brain/SchemaDocs/useSchemaDocsData.js b/src/components/tabs/Brain/SchemaDocs/useSchemaDocsData.js index 7cce88f..47b7e82 100644 --- a/src/components/tabs/Brain/SchemaDocs/useSchemaDocsData.js +++ b/src/components/tabs/Brain/SchemaDocs/useSchemaDocsData.js @@ -4,12 +4,14 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { schemaAPI, brainAPI, companyKnowledgeAPI } from "@/lib/api/client"; import { queryKeys } from "@/lib/queryKeys"; +import { + canonicalTableReference, + isDefaultSchema, + stripIdentQuotes, +} from "@/lib/schemaNames"; function normalizeIdentifier(value) { - return (value || "") - .trim() - .replace(/[`"\[\]]/g, "") - .toLowerCase(); + return stripIdentQuotes(value).toLowerCase(); } function identifierTail(value, segments = 1) { @@ -26,16 +28,6 @@ function referenceAliases(value) { return new Set([normalized, identifierTail(normalized, 1), identifierTail(normalized, 2)].filter(Boolean)) } -function canonicalTableReference(table) { - const tableName = (table?.tableName || table?.name || "").trim().replace(/[`"\[\]]/g, "") - const schemaName = (table?.schema || table?.schemaName || "").trim().replace(/[`"\[\]]/g, "") - if (!tableName) return "" - if (!schemaName || schemaName === "public" || schemaName === "dbo") { - return tableName - } - return `${schemaName}.${tableName}` -} - function tableAliases(table) { const canonical = normalizeIdentifier(canonicalTableReference(table)) const bare = normalizeIdentifier(table?.tableName || table?.name || "") @@ -140,7 +132,10 @@ export function useSchemaDocsData(connectionId) { const bareName = rawName.includes(".") ? rawName.split(".").pop() : rawName; - const keys = rawName === bareName ? [rawName] : [rawName, bareName]; + // Index qualified notes only under their full key so crm.orders and + // sales.orders never share a slot. Bare notes stay under the bare key + // for default-schema / legacy rows. + const keys = rawName.includes(".") ? [rawName] : [bareName]; if (note.scopeType === "TABLE" || (!note.scopeType && !note.columnName)) { for (const k of keys) { tableNotes[k] = considerWinner(tableNotes[k], note); @@ -154,10 +149,16 @@ export function useSchemaDocsData(connectionId) { } } - // Build classification lookup (case-insensitive) + // Build classification lookup (case-insensitive). Prefer schema.table keys. const roleMap = {}; for (const c of classifications) { - roleMap[(c.tableName || "").toLowerCase()] = c.role || c.tableRole; + const bare = (c.tableName || "").toLowerCase(); + const schema = (c.schemaName || c.schema || "").toLowerCase(); + const qualified = + schema && !isDefaultSchema(schema) ? `${schema}.${bare}` : bare; + if (qualified) roleMap[qualified] = c.role || c.tableRole; + // Bare fallback only when classification itself is unqualified. + if (bare && !schema) roleMap[bare] = c.role || c.tableRole; } let totalTablesDocumented = 0; @@ -168,10 +169,17 @@ export function useSchemaDocsData(connectionId) { // Normalize: API uses `name`, plan assumed `tableName` const tableName = table.tableName || table.name || ""; const tableReference = canonicalTableReference(table) - const tKey = tableName.toLowerCase(); - const tableNote = tableNotes[tKey] || null; - const colNotes = columnNotes[tKey] || {}; - const role = roleMap[tKey] || null; + const refKey = normalizeIdentifier(tableReference); + const bareKey = normalizeIdentifier(tableName); + // Prefer exact reference match; only fall back to bare for default-schema tables. + const tableNote = + tableNotes[refKey] || + (refKey === bareKey ? tableNotes[bareKey] : null); + const colNotes = + columnNotes[refKey] || + (refKey === bareKey ? columnNotes[bareKey] : {}) || + {}; + const role = roleMap[refKey] || (refKey === bareKey ? roleMap[bareKey] : null) || null; const tableAliasSet = tableAliases(table) const linkedKnowledge = knowledgeEntries.filter((entry) => { const linkedTables = Array.isArray(entry?.linkedTables) ? entry.linkedTables : [] @@ -220,6 +228,7 @@ export function useSchemaDocsData(connectionId) { return { tableName, tableReference, + schemaName: table.schema || table.schemaName || "", rowCount: table.rowCount, note: tableNote, role, @@ -230,8 +239,10 @@ export function useSchemaDocsData(connectionId) { }; }); - // Sort tables alphabetically - tables.sort((a, b) => a.tableName.localeCompare(b.tableName)); + // Sort by qualified reference so schemas cluster together + tables.sort((a, b) => + (a.tableReference || a.tableName).localeCompare(b.tableReference || b.tableName) + ); return { tables, diff --git a/src/components/tabs/Brain/SchemaERD.js b/src/components/tabs/Brain/SchemaERD.js index 57284fe..bd18dc9 100644 --- a/src/components/tabs/Brain/SchemaERD.js +++ b/src/components/tabs/Brain/SchemaERD.js @@ -3,6 +3,7 @@ import { useMemo, useCallback } from 'react' import ForceGraph2D from 'react-force-graph-2d' import styles from './SchemaERD.module.css' +import { canonicalTableReference, objectKey } from '@/lib/schemaNames' /** * Entity Relationship Diagram visualization @@ -16,9 +17,11 @@ export function SchemaERD({ tables = [], height = 600 }) { // Create nodes for each table tables.forEach(table => { + const id = objectKey(table) || table.tableName || table.name + const name = canonicalTableReference(table) || id nodes.push({ - id: table.tableName, - name: table.tableName, + id, + name, val: (table.columnCount || 10), // Size based on column count color: getNodeColor(table) }) @@ -27,11 +30,15 @@ export function SchemaERD({ tables = [], height = 600 }) { // Create links based on foreign keys tables.forEach(table => { const foreignKeys = table.foreignKeys || [] + const sourceId = objectKey(table) || table.tableName || table.name foreignKeys.forEach(fk => { if (fk.referencedTable) { + const target = fk.referencedSchema + ? canonicalTableReference({ schema: fk.referencedSchema, name: fk.referencedTable }) + : fk.referencedTable links.push({ - source: table.tableName, - target: fk.referencedTable, + source: sourceId, + target, label: fk.columnName }) } diff --git a/src/components/tabs/Brain/modals/NoteModal.js b/src/components/tabs/Brain/modals/NoteModal.js index cac24cb..6fd288c 100644 --- a/src/components/tabs/Brain/modals/NoteModal.js +++ b/src/components/tabs/Brain/modals/NoteModal.js @@ -122,7 +122,7 @@ export function NoteModal({ list="brain-table-options" value={noteForm.tableName} onChange={(e) => onUpdateForm({ tableName: e.target.value })} - placeholder="Table name" + placeholder="schema.table (e.g. crm.orders)" readOnly={noteData?.locked} required /> From 42fc87decdf33445e82715b940cc6c0d56997029 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 19:13:31 +0000 Subject: [PATCH 3/4] feat(advisor): surface schema in recommendations and grants Advisor scans non-public schemas, emits schema-qualified CREATE INDEX SQL, and Performance/privileges/dashboard skill copy follow suit. Co-authored-by: Venkat SF --- agent/skills/dashboard-design/SKILL.md | 2 +- .../service/DatabaseAdvisorService.java | 25 +++++++++++-------- .../components/PrivilegesAccordion.js | 16 +++++++----- .../tabs/Core/DatabaseAdvisorTab.js | 2 +- .../tabs/Performance/ExplainPlanTab.js | 4 +-- .../tabs/Performance/SlowQueryAnalysisTab.js | 2 +- .../tabs/Performance/WorkloadAnalysisPanel.js | 2 +- .../Performance/components/TableHeatmap.js | 2 +- 8 files changed, 32 insertions(+), 23 deletions(-) diff --git a/agent/skills/dashboard-design/SKILL.md b/agent/skills/dashboard-design/SKILL.md index 6a4e4e7..8d003a2 100644 --- a/agent/skills/dashboard-design/SKILL.md +++ b/agent/skills/dashboard-design/SKILL.md @@ -51,7 +51,7 @@ Hard rules: ## Procedure 1. **Ground.** `get_brain_context`, `get_schema`, `list_business_rules`, `get_relationships`. Obey business rules about which table/column/filter/currency a concept uses — quote them; don't guess a similar-looking table. -2. **Design.** Decide the KPIs, charts, tables, and controls (date range, dropdowns) the request calls for. Sketch the SQL for each — table-qualified, read-only. +2. **Design.** Decide the KPIs, charts, tables, and controls (date range, dropdowns) the request calls for. Sketch the SQL for each — **schema-qualified** (`crm.orders`, not bare `orders` when the DB has multiple schemas), table-qualified columns, read-only. 3. **Handle dates correctly.** Check the column's type in the schema. If it's a real DATE/DATETIME, filter with `BETWEEN '2026-07-01' AND '2026-07-08'`. **If it's a Unix-epoch integer** (seconds), filter on the epoch: `col >= UNIX_TIMESTAMP('2026-07-01 00:00:00') AND col < UNIX_TIMESTAMP('2026-07-09 00:00:00')`. Build these strings in JS from the picker's values. 4. **Verify.** Run every query with `execute_sql` and READ the rows: date windows bounded and inside range (never the future), KPI value types right (name = text, money = currency), totals plausible vs a `COUNT(*)`. Fix and re-run until correct. 5. **Intent checklist.** Before emitting, list every explicit ask (each chart, each metric, each control like "a date range picker defaulting to today") and confirm the HTML satisfies ALL of them. An unmet ask is a failed dashboard even if the data is perfect. diff --git a/backend/src/main/java/com/dbaagent/service/DatabaseAdvisorService.java b/backend/src/main/java/com/dbaagent/service/DatabaseAdvisorService.java index b5efc90..0ceebf5 100644 --- a/backend/src/main/java/com/dbaagent/service/DatabaseAdvisorService.java +++ b/backend/src/main/java/com/dbaagent/service/DatabaseAdvisorService.java @@ -281,7 +281,7 @@ private List detectPostgresMissingIndexes(String connection try (Connection connection = connectionService.getConnection(connectionId, connRequest)) { - // Query 1: Tables with high sequential scans + // Query 1: Tables with high sequential scans (all non-system schemas) String query1 = """ SELECT schemaname, @@ -296,7 +296,7 @@ private List detectPostgresMissingIndexes(String connection ELSE 0 END as avg_seq_tup_read FROM pg_stat_user_tables - WHERE schemaname = 'public' + WHERE schemaname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') AND seq_scan > 1000 AND n_live_tup > 10000 AND (idx_scan IS NULL OR seq_scan > idx_scan * 2) @@ -308,11 +308,13 @@ private List detectPostgresMissingIndexes(String connection ResultSet rs = stmt.executeQuery(query1)) { while (rs.next()) { + String schemaName = rs.getString("schemaname"); String tableName = rs.getString("tablename"); long seqScans = rs.getLong("seq_scan"); long seqTupRead = rs.getLong("seq_tup_read"); long liveRows = rs.getLong("n_live_tup"); double avgSeqRead = rs.getDouble("avg_seq_tup_read"); + String qualifiedTable = "public".equals(schemaName) ? tableName : schemaName + "." + tableName; // Get candidate columns List candidateColumns = getPostgresCandidateColumns( @@ -325,7 +327,7 @@ private List detectPostgresMissingIndexes(String connection .id(UUID.randomUUID().toString()) .connectionId(connectionId) .tableName(tableName) - .schemaName("public") + .schemaName(schemaName) .columns(candidateColumns) .indexType("BTREE") .priority(seqScans > 10000 ? @@ -334,13 +336,13 @@ private List detectPostgresMissingIndexes(String connection .reasoning(String.format( "Table '%s' has %,d sequential scans reading %,d rows (avg %.0f rows/scan). " + "Current row count: %,d. An index would significantly improve query performance.", - tableName, seqScans, seqTupRead, avgSeqRead, liveRows + qualifiedTable, seqScans, seqTupRead, avgSeqRead, liveRows )) .suggestedSQL(String.format( "CREATE INDEX CONCURRENTLY idx_%s_%s ON %s(%s)", tableName, String.join("_", candidateColumns), - tableName, + qualifiedTable, String.join(", ", candidateColumns) )) .metrics(IndexRecommendation.IndexRecommendationMetrics.builder() @@ -358,9 +360,10 @@ private List detectPostgresMissingIndexes(String connection } } - // Query 2: Foreign keys without indexes + // Query 2: Foreign keys without indexes (all non-system schemas) String query2 = """ SELECT + tc.table_schema, tc.table_name, kcu.column_name, ccu.table_name AS foreign_table_name @@ -371,11 +374,11 @@ private List detectPostgresMissingIndexes(String connection JOIN information_schema.constraint_column_usage AS ccu ON ccu.constraint_name = tc.constraint_name WHERE tc.constraint_type = 'FOREIGN KEY' - AND tc.table_schema = 'public' + AND tc.table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast') AND NOT EXISTS ( SELECT 1 FROM pg_indexes - WHERE schemaname = 'public' + WHERE schemaname = tc.table_schema AND tablename = tc.table_name AND indexdef LIKE '%' || kcu.column_name || '%' ) @@ -385,15 +388,17 @@ AND NOT EXISTS ( ResultSet rs = stmt.executeQuery(query2)) { while (rs.next()) { + String schemaName = rs.getString("table_schema"); String tableName = rs.getString("table_name"); String columnName = rs.getString("column_name"); String foreignTable = rs.getString("foreign_table_name"); + String qualifiedTable = "public".equals(schemaName) ? tableName : schemaName + "." + tableName; IndexRecommendation rec = IndexRecommendation.builder() .id(UUID.randomUUID().toString()) .connectionId(connectionId) .tableName(tableName) - .schemaName("public") + .schemaName(schemaName) .columns(Collections.singletonList(columnName)) .indexType("BTREE") .priority(IndexRecommendation.RecommendationPriority.HIGH) @@ -404,7 +409,7 @@ AND NOT EXISTS ( )) .suggestedSQL(String.format( "CREATE INDEX CONCURRENTLY idx_%s_%s ON %s(%s)", - tableName, columnName, tableName, columnName + tableName, columnName, qualifiedTable, columnName )) .metrics(IndexRecommendation.IndexRecommendationMetrics.builder() .estimatedImprovementPercent(70) diff --git a/src/components/ConnectionWizard/components/PrivilegesAccordion.js b/src/components/ConnectionWizard/components/PrivilegesAccordion.js index 1b0b0a5..a85091b 100644 --- a/src/components/ConnectionWizard/components/PrivilegesAccordion.js +++ b/src/components/ConnectionWizard/components/PrivilegesAccordion.js @@ -19,19 +19,23 @@ export function PrivilegesAccordion({ dbType }) { -- Replace 'your_user' with your database username -- Replace 'your_database' with your database name --- Basic read access to all tables +-- Basic read access (repeat GRANT block per schema you want DeepSQL to see) GRANT SELECT ON ALL TABLES IN SCHEMA public TO your_user; GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO your_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT ON TABLES TO your_user; + +-- Multi-schema example (crm / sales / …) +-- GRANT USAGE ON SCHEMA crm TO your_user; +-- GRANT SELECT ON ALL TABLES IN SCHEMA crm TO your_user; +-- GRANT SELECT ON ALL SEQUENCES IN SCHEMA crm TO your_user; +-- ALTER DEFAULT PRIVILEGES IN SCHEMA crm GRANT SELECT ON TABLES TO your_user; -- Access to system views for monitoring GRANT pg_read_all_stats TO your_user; -- Enable pg_stat_statements extension (if not already enabled) -CREATE EXTENSION IF NOT EXISTS pg_stat_statements; - --- For future tables -ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT SELECT ON TABLES TO your_user;` +CREATE EXTENSION IF NOT EXISTS pg_stat_statements;` } if (dbType === 'mysql') { diff --git a/src/components/tabs/Core/DatabaseAdvisorTab.js b/src/components/tabs/Core/DatabaseAdvisorTab.js index 8a84736..9d36f86 100644 --- a/src/components/tabs/Core/DatabaseAdvisorTab.js +++ b/src/components/tabs/Core/DatabaseAdvisorTab.js @@ -196,7 +196,7 @@ export default function DatabaseAdvisorTab({ connectionId }) { {rec.priority}
- {rec.tableName ? `Table: ${rec.tableName}` : rec.title} + {rec.tableName ? `Table: ${rec.schemaName ? `${rec.schemaName}.${rec.tableName}` : rec.tableName}` : rec.title}
diff --git a/src/components/tabs/Performance/ExplainPlanTab.js b/src/components/tabs/Performance/ExplainPlanTab.js index d8819d6..15f140a 100644 --- a/src/components/tabs/Performance/ExplainPlanTab.js +++ b/src/components/tabs/Performance/ExplainPlanTab.js @@ -1741,9 +1741,9 @@ export default function ExplainPlanTab({ connectionId }) {

Index Recommendations ({indexRecommendations.length})

{indexRecommendations.map((rec, idx) => ( -
+
- {rec.tableName} + {rec.schemaName ? `${rec.schemaName}.${rec.tableName}` : rec.tableName} {rec.priority && ( {rec.priority} )} diff --git a/src/components/tabs/Performance/SlowQueryAnalysisTab.js b/src/components/tabs/Performance/SlowQueryAnalysisTab.js index 2d8816d..07cd66a 100644 --- a/src/components/tabs/Performance/SlowQueryAnalysisTab.js +++ b/src/components/tabs/Performance/SlowQueryAnalysisTab.js @@ -1772,7 +1772,7 @@ export default function SlowQueryAnalysisTab({ connectionId }) { : ""; const statement = rec.suggestedSQL || - `CREATE INDEX ON ${rec.tableName} ${columns}`.trim(); + `CREATE INDEX ON ${rec.schemaName ? `${rec.schemaName}.${rec.tableName}` : rec.tableName} ${columns}`.trim(); items.push({ label: "Index", text: statement }); }); } diff --git a/src/components/tabs/Performance/WorkloadAnalysisPanel.js b/src/components/tabs/Performance/WorkloadAnalysisPanel.js index e66e0c1..b0b7c37 100644 --- a/src/components/tabs/Performance/WorkloadAnalysisPanel.js +++ b/src/components/tabs/Performance/WorkloadAnalysisPanel.js @@ -251,7 +251,7 @@ export default function WorkloadAnalysisPanel({ connectionId }) { {rec.kind === "DROP_INDEX" ? "DROP" : "CREATE"} - {rec.tableName} ({rec.columnNames}) + {rec.schemaName ? `${rec.schemaName}.${rec.tableName}` : rec.tableName} ({rec.columnNames}) {rec.priority && ( diff --git a/src/components/tabs/Performance/components/TableHeatmap.js b/src/components/tabs/Performance/components/TableHeatmap.js index d7c31c4..ead0471 100644 --- a/src/components/tabs/Performance/components/TableHeatmap.js +++ b/src/components/tabs/Performance/components/TableHeatmap.js @@ -107,7 +107,7 @@ export default function TableHeatmap({ data = [], caption = 'Based on scan frequ {data.slice(0, 10).map((item, idx) => { const widthPercent = Math.max((item.usageScore / maxScore) * 100, 8) const opacity = 0.3 + (item.usageScore / 100) * 0.7 - const displayName = item.tableName?.split('.').pop() || item.tableName + const displayName = item.tableName || '' const tooltipContent = getTableTooltipContent(displayName, item.usageScore) return ( From 92bdd1e1a760d3118fc868ffe42e5398d7f61618 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 19:17:52 +0000 Subject: [PATCH 4/4] fix(editor): generate SQL when clicking the table name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep expand/collapse on the chevron only so a normal click inserts schema-qualified SELECT … FROM schema.table. Co-authored-by: Venkat SF --- src/components/tabs/Core/SqlRunnerTab.js | 39 ++++++++++++++++-------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/components/tabs/Core/SqlRunnerTab.js b/src/components/tabs/Core/SqlRunnerTab.js index bf80acf..9eedffd 100644 --- a/src/components/tabs/Core/SqlRunnerTab.js +++ b/src/components/tabs/Core/SqlRunnerTab.js @@ -1964,18 +1964,33 @@ export default function SqlRunnerTab({ connectionId }) { handleTableContextMenu(e, table) } > -
{ - e.stopPropagation(); - toggleNode(`table-${objectKey(table)}`); - }} - > - {expandedNodes[`table-${objectKey(table)}`] ? ( - - ) : ( - - )} +
+ {multiSchema && table.schema && table.schema !== 'public' ? <>{table.schema}.{table.name}