Skip to content

Commit 5e08481

Browse files
venkateshsakamuri-labcursoragentgeekypunk
authored
feat(ui): multi-schema awareness across Editor, Brain, and Advisor (#55)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Summary End-to-end multi-schema UI pass so connections like **Multi Schema Shop** (`crm` / `sales`) no longer look like a single bare-name catalog. ### P0 — Authoring - Shared `src/lib/schemaNames.js` helpers (`canonicalTableReference`, `objectKey`, `qualifyForSql`, …) - **SQL Editor** + Tables overview: schema-prefixed labels, qualified autocomplete/insertText, generated SQL (`FROM crm.orders`), collision-safe React keys, schema-aware search - Clicking a table name inserts `SELECT … FROM schema.table` (chevron expands columns) - Index/stats REST paths accept `schema.table`; Postgres/MySQL introspection scopes indexes by schema ### P1 — Brain / Knowledge / ERD - Schema Docs display + note persist use `tableReference` (no cross-schema note collisions) - Knowledge `@` / `@@` suggestions show qualified labels; bare-name lookup only when unique - ERD / classification / key-column grouping use qualified ids - DetailsLibrary CSV example + NoteModal placeholder encourage `schema.table` ### P2 — Advisor / ops copy - Database advisor scans non-`public` schemas and emits schema-qualified `CREATE INDEX … ON` - Performance cards / heatmap keep schema in labels - Privileges accordion documents multi-schema `GRANT` pattern - Dashboard-design skill nudges schema-qualified SQL ## Verification On **Multi Schema Shop**: - Editor lists `crm.customers`, `sales.order_items`, `sales.orders` - Click inserts `FROM crm.customers` - Brain Schema Docs shows the same qualified names [Editor with schema-qualified tables](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fmulti_schema_editor_expanded.png) [Generated SQL FROM crm.customers](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fmulti_schema_editor_query.png) [Brain Schema Docs with crm/sales tables](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fmulti_schema_brain.png) ## Test plan - [x] Select **Multi Schema Shop** → Editor lists `crm.*` / `sales.*` - [x] Click table generates `FROM schema.table` SQL - [x] Brain Schema Docs shows `schema.table` - [ ] Regression: **Demo Shop** (public-only) still shows bare table names - [x] `node --test src/lib/schemaNames.test.js` Note: PR #53 is docs-only Cloud caveats; this PR is the multi-schema UI work. <sub>To show artifacts inline, <a href="https://cursor.com/dashboard/cloud-agents#my-pull-requests">enable</a> in settings.</sub> <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-019fe687-99b1-76fd-80fa-dd213aecc497&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Krishna Sasank Talasila <sasanktk@gmail.com>
1 parent 9bf3856 commit 5e08481

25 files changed

Lines changed: 517 additions & 189 deletions

agent/skills/dashboard-design/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ Hard rules:
5151
## Procedure
5252

5353
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.
54-
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.
54+
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.
5555
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.
5656
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.
5757
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.

backend/src/main/java/com/dbaagent/controller/SchemaController.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,8 @@ public ResponseEntity<Map<String, Object>> executeQuery(
260260
}
261261
}
262262

263-
@GetMapping("/tables/{tableName}/indexes")
263+
// `{tableName:.+}` keeps schema-qualified ids (`crm.orders`) as one segment.
264+
@GetMapping("/tables/{tableName:.+}/indexes")
264265
public ResponseEntity<Map<String, Object>> getTableIndexes(
265266
@PathVariable String connectionId,
266267
@PathVariable String tableName) {
@@ -292,7 +293,7 @@ public ResponseEntity<Map<String, Object>> getTableIndexes(
292293
}
293294
}
294295

295-
@GetMapping("/tables/{tableName}/stats")
296+
@GetMapping("/tables/{tableName:.+}/stats")
296297
public ResponseEntity<Map<String, Object>> getTableStats(
297298
@PathVariable String connectionId,
298299
@PathVariable String tableName) {

backend/src/main/java/com/dbaagent/provider/mysql/MySQLIntrospectionProvider.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,16 @@ public List<TableIndex> getTableIndexes(Connection connection, String database,
153153
List<TableIndex> indexes = new ArrayList<>();
154154
Map<String, TableIndex> indexMap = new HashMap<>();
155155

156+
String schemaName = database;
157+
String bareName = tableName;
158+
if (tableName != null) {
159+
int dot = tableName.lastIndexOf('.');
160+
if (dot > 0) {
161+
schemaName = tableName.substring(0, dot);
162+
bareName = tableName.substring(dot + 1);
163+
}
164+
}
165+
156166
String query = """
157167
SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE, INDEX_TYPE, SEQ_IN_INDEX
158168
FROM INFORMATION_SCHEMA.STATISTICS
@@ -161,8 +171,8 @@ public List<TableIndex> getTableIndexes(Connection connection, String database,
161171
""";
162172

163173
try (PreparedStatement stmt = connection.prepareStatement(query)) {
164-
stmt.setString(1, database);
165-
stmt.setString(2, tableName);
174+
stmt.setString(1, schemaName);
175+
stmt.setString(2, bareName);
166176

167177
try (ResultSet rs = stmt.executeQuery()) {
168178
while (rs.next()) {

backend/src/main/java/com/dbaagent/provider/postgres/PostgresIntrospectionProvider.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,18 @@ public List<TableIndex> getTableIndexes(Connection connection, String database,
204204
List<TableIndex> indexes = new ArrayList<>();
205205
Map<String, TableIndex> indexMap = new HashMap<>();
206206

207+
// Accept bare `orders` or qualified `crm.orders` so multi-schema UIs
208+
// don't silently merge indexes from every schema that shares the name.
209+
String schemaName = null;
210+
String bareName = tableName;
211+
if (tableName != null) {
212+
int dot = tableName.lastIndexOf('.');
213+
if (dot > 0) {
214+
schemaName = tableName.substring(0, dot);
215+
bareName = tableName.substring(dot + 1);
216+
}
217+
}
218+
207219
String query = """
208220
SELECT
209221
i.relname AS index_name,
@@ -212,16 +224,21 @@ public List<TableIndex> getTableIndexes(Connection connection, String database,
212224
ix.indisprimary AS is_primary,
213225
am.amname AS index_type
214226
FROM pg_class t
227+
JOIN pg_namespace n ON n.oid = t.relnamespace
215228
JOIN pg_index ix ON t.oid = ix.indrelid
216229
JOIN pg_class i ON i.oid = ix.indexrelid
217230
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
218231
JOIN pg_am am ON i.relam = am.oid
219-
WHERE t.relname = ?
232+
WHERE t.relkind IN ('r', 'p', 'm', 'v')
233+
AND t.relname = ?
234+
AND (?::text IS NULL OR n.nspname = ?)
220235
ORDER BY i.relname, a.attnum
221236
""";
222237

223238
try (PreparedStatement stmt = connection.prepareStatement(query)) {
224-
stmt.setString(1, tableName);
239+
stmt.setString(1, bareName);
240+
stmt.setString(2, schemaName);
241+
stmt.setString(3, schemaName);
225242

226243
try (ResultSet rs = stmt.executeQuery()) {
227244
while (rs.next()) {

backend/src/main/java/com/dbaagent/service/DatabaseAdvisorService.java

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ private List<IndexRecommendation> detectPostgresMissingIndexes(String connection
281281

282282
try (Connection connection = connectionService.getConnection(connectionId, connRequest)) {
283283

284-
// Query 1: Tables with high sequential scans
284+
// Query 1: Tables with high sequential scans (all non-system schemas)
285285
String query1 = """
286286
SELECT
287287
schemaname,
@@ -296,7 +296,7 @@ private List<IndexRecommendation> detectPostgresMissingIndexes(String connection
296296
ELSE 0
297297
END as avg_seq_tup_read
298298
FROM pg_stat_user_tables
299-
WHERE schemaname = 'public'
299+
WHERE schemaname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
300300
AND seq_scan > 1000
301301
AND n_live_tup > 10000
302302
AND (idx_scan IS NULL OR seq_scan > idx_scan * 2)
@@ -308,11 +308,13 @@ private List<IndexRecommendation> detectPostgresMissingIndexes(String connection
308308
ResultSet rs = stmt.executeQuery(query1)) {
309309

310310
while (rs.next()) {
311+
String schemaName = rs.getString("schemaname");
311312
String tableName = rs.getString("tablename");
312313
long seqScans = rs.getLong("seq_scan");
313314
long seqTupRead = rs.getLong("seq_tup_read");
314315
long liveRows = rs.getLong("n_live_tup");
315316
double avgSeqRead = rs.getDouble("avg_seq_tup_read");
317+
String qualifiedTable = "public".equals(schemaName) ? tableName : schemaName + "." + tableName;
316318

317319
// Get candidate columns
318320
List<String> candidateColumns = getPostgresCandidateColumns(
@@ -325,7 +327,7 @@ private List<IndexRecommendation> detectPostgresMissingIndexes(String connection
325327
.id(UUID.randomUUID().toString())
326328
.connectionId(connectionId)
327329
.tableName(tableName)
328-
.schemaName("public")
330+
.schemaName(schemaName)
329331
.columns(candidateColumns)
330332
.indexType("BTREE")
331333
.priority(seqScans > 10000 ?
@@ -334,13 +336,13 @@ private List<IndexRecommendation> detectPostgresMissingIndexes(String connection
334336
.reasoning(String.format(
335337
"Table '%s' has %,d sequential scans reading %,d rows (avg %.0f rows/scan). " +
336338
"Current row count: %,d. An index would significantly improve query performance.",
337-
tableName, seqScans, seqTupRead, avgSeqRead, liveRows
339+
qualifiedTable, seqScans, seqTupRead, avgSeqRead, liveRows
338340
))
339341
.suggestedSQL(String.format(
340342
"CREATE INDEX CONCURRENTLY idx_%s_%s ON %s(%s)",
341343
tableName,
342344
String.join("_", candidateColumns),
343-
tableName,
345+
qualifiedTable,
344346
String.join(", ", candidateColumns)
345347
))
346348
.metrics(IndexRecommendation.IndexRecommendationMetrics.builder()
@@ -358,9 +360,10 @@ private List<IndexRecommendation> detectPostgresMissingIndexes(String connection
358360
}
359361
}
360362

361-
// Query 2: Foreign keys without indexes
363+
// Query 2: Foreign keys without indexes (all non-system schemas)
362364
String query2 = """
363365
SELECT
366+
tc.table_schema,
364367
tc.table_name,
365368
kcu.column_name,
366369
ccu.table_name AS foreign_table_name
@@ -371,11 +374,11 @@ private List<IndexRecommendation> detectPostgresMissingIndexes(String connection
371374
JOIN information_schema.constraint_column_usage AS ccu
372375
ON ccu.constraint_name = tc.constraint_name
373376
WHERE tc.constraint_type = 'FOREIGN KEY'
374-
AND tc.table_schema = 'public'
377+
AND tc.table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
375378
AND NOT EXISTS (
376379
SELECT 1
377380
FROM pg_indexes
378-
WHERE schemaname = 'public'
381+
WHERE schemaname = tc.table_schema
379382
AND tablename = tc.table_name
380383
AND indexdef LIKE '%' || kcu.column_name || '%'
381384
)
@@ -385,15 +388,17 @@ AND NOT EXISTS (
385388
ResultSet rs = stmt.executeQuery(query2)) {
386389

387390
while (rs.next()) {
391+
String schemaName = rs.getString("table_schema");
388392
String tableName = rs.getString("table_name");
389393
String columnName = rs.getString("column_name");
390394
String foreignTable = rs.getString("foreign_table_name");
395+
String qualifiedTable = "public".equals(schemaName) ? tableName : schemaName + "." + tableName;
391396

392397
IndexRecommendation rec = IndexRecommendation.builder()
393398
.id(UUID.randomUUID().toString())
394399
.connectionId(connectionId)
395400
.tableName(tableName)
396-
.schemaName("public")
401+
.schemaName(schemaName)
397402
.columns(Collections.singletonList(columnName))
398403
.indexType("BTREE")
399404
.priority(IndexRecommendation.RecommendationPriority.HIGH)
@@ -404,7 +409,7 @@ AND NOT EXISTS (
404409
))
405410
.suggestedSQL(String.format(
406411
"CREATE INDEX CONCURRENTLY idx_%s_%s ON %s(%s)",
407-
tableName, columnName, tableName, columnName
412+
tableName, columnName, qualifiedTable, columnName
408413
))
409414
.metrics(IndexRecommendation.IndexRecommendationMetrics.builder()
410415
.estimatedImprovementPercent(70)

src/components/ConnectionWizard/components/PrivilegesAccordion.js

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,23 @@ export function PrivilegesAccordion({ dbType }) {
1919
-- Replace 'your_user' with your database username
2020
-- Replace 'your_database' with your database name
2121
22-
-- Basic read access to all tables
22+
-- Basic read access (repeat GRANT block per schema you want DeepSQL to see)
2323
GRANT SELECT ON ALL TABLES IN SCHEMA public TO your_user;
2424
GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO your_user;
25+
ALTER DEFAULT PRIVILEGES IN SCHEMA public
26+
GRANT SELECT ON TABLES TO your_user;
27+
28+
-- Multi-schema example (crm / sales / …)
29+
-- GRANT USAGE ON SCHEMA crm TO your_user;
30+
-- GRANT SELECT ON ALL TABLES IN SCHEMA crm TO your_user;
31+
-- GRANT SELECT ON ALL SEQUENCES IN SCHEMA crm TO your_user;
32+
-- ALTER DEFAULT PRIVILEGES IN SCHEMA crm GRANT SELECT ON TABLES TO your_user;
2533
2634
-- Access to system views for monitoring
2735
GRANT pg_read_all_stats TO your_user;
2836
2937
-- Enable pg_stat_statements extension (if not already enabled)
30-
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
31-
32-
-- For future tables
33-
ALTER DEFAULT PRIVILEGES IN SCHEMA public
34-
GRANT SELECT ON TABLES TO your_user;`
38+
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;`
3539
}
3640

3741
if (dbType === 'mysql') {

src/components/company-knowledge/CompanyKnowledgePanel.jsx

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import CodeSourcesTab from './CodeSourcesTab'
1818
import SuggestionsQueueTab from './SuggestionsQueueTab'
1919
import EntriesTable from './EntriesTable'
2020
import SchemaContextTab from './SchemaContextTab'
21+
import { canonicalTableReference } from '@/lib/schemaNames'
2122

2223
const EMPTY_FORM = {
2324
title: '',
@@ -27,16 +28,6 @@ const EMPTY_FORM = {
2728
const TABLE_ANNOTATION_RE = /(?<!@)@([A-Za-z_][\w$.]*)/g
2829
const COLUMN_ANNOTATION_RE = /(?<!@)@@([A-Za-z_][\w$.]*)/g
2930

30-
function canonicalTableReference(table) {
31-
const tableName = (table?.tableName || table?.name || '').trim().replace(/[`"\[\]]/g, '')
32-
const schemaName = (table?.schema || table?.schemaName || '').trim().replace(/[`"\[\]]/g, '')
33-
if (!tableName) return ''
34-
if (!schemaName || schemaName === 'public' || schemaName === 'dbo') {
35-
return tableName
36-
}
37-
return `${schemaName}.${tableName}`
38-
}
39-
4031
function normalizeValue(value) {
4132
return (value || '').trim().toLowerCase()
4233
}
@@ -68,12 +59,21 @@ function getDiagnosticTone(entry) {
6859

6960
function buildTableLookup(tableOptions) {
7061
const lookup = new Map()
62+
const bareCounts = new Map()
7163
tableOptions.forEach((table) => {
72-
const keys = [
73-
table.value,
74-
table.label,
75-
table.value.split('.').pop(),
76-
]
64+
const bare = (table.value || '').split('.').pop()
65+
if (!bare) return
66+
bareCounts.set(normalizeValue(bare), (bareCounts.get(normalizeValue(bare)) || 0) + 1)
67+
})
68+
tableOptions.forEach((table) => {
69+
const bare = (table.value || '').split('.').pop()
70+
const bareKey = normalizeValue(bare)
71+
// Always index the canonical value. Index the bare name only when unique
72+
// across schemas so @orders stays unambiguous on multi-schema DBs.
73+
const keys = [table.value, table.label]
74+
if (bare && bareCounts.get(bareKey) === 1) {
75+
keys.push(bare)
76+
}
7777
keys
7878
.filter(Boolean)
7979
.forEach((key) => lookup.set(normalizeValue(key), table.value))
@@ -83,14 +83,24 @@ function buildTableLookup(tableOptions) {
8383

8484
function buildColumnLookup(columnOptions) {
8585
const lookup = new Map()
86+
const shortCounts = new Map()
87+
columnOptions.forEach((column) => {
88+
const shortTable = column.tableValue?.split('.').pop()
89+
const shortKey = normalizeValue(`${shortTable}.${column.columnLabel}`)
90+
if (!shortKey) return
91+
shortCounts.set(shortKey, (shortCounts.get(shortKey) || 0) + 1)
92+
})
8693
columnOptions.forEach((column) => {
8794
const canonical = column.value
8895
const shortTable = column.tableValue?.split('.').pop()
96+
const shortKey = `${shortTable}.${column.columnLabel}`
8997
const keys = [
9098
canonical,
9199
`${column.tableValue}.${column.columnLabel}`,
92-
`${shortTable}.${column.columnLabel}`,
93100
]
101+
if (shortCounts.get(normalizeValue(shortKey)) === 1) {
102+
keys.push(shortKey)
103+
}
94104
keys
95105
.filter(Boolean)
96106
.forEach((key) => lookup.set(normalizeValue(key), canonical))
@@ -267,16 +277,26 @@ export default function CompanyKnowledgePanel({ connectionId }) {
267277

268278
const tableOptions = useMemo(
269279
() => (schemaQuery.data?.schema?.tables || schemaQuery.data?.tables || [])
270-
.map((table) => ({
271-
label: table.tableName || table.name,
272-
value: canonicalTableReference(table),
273-
columns: (table.columns || []).map((column) => ({
274-
label: `${table.tableName || table.name}.${column.columnName || column.name}`,
275-
value: `${canonicalTableReference(table)}.${column.columnName || column.name}`,
276-
columnLabel: column.columnName || column.name,
277-
tableValue: canonicalTableReference(table),
278-
})),
279-
}))
280+
.map((table) => {
281+
const value = canonicalTableReference(table)
282+
const bare = table.tableName || table.name || ''
283+
// When the same bare name exists in multiple schemas, force the
284+
// qualified label so @ suggestions never look ambiguous.
285+
return {
286+
label: value,
287+
bareLabel: bare,
288+
value,
289+
columns: (table.columns || []).map((column) => {
290+
const colName = column.columnName || column.name
291+
return {
292+
label: `${value}.${colName}`,
293+
value: `${value}.${colName}`,
294+
columnLabel: colName,
295+
tableValue: value,
296+
}
297+
}),
298+
}
299+
})
280300
.filter((table) => table.value),
281301
[schemaQuery.data],
282302
)

src/components/tabs/Brain/DetailsLibrary.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,8 @@ export function DetailsLibrary({
176176
const downloadTemplate = () => {
177177
const template = [
178178
"table_name,column_name,details",
179-
"orders,,Contains order-level details used in analytics dashboards.",
179+
"crm.orders,,Contains order-level details used in analytics dashboards.",
180+
"sales.orders,,Order headers for the sales schema (use schema.table when names collide).",
180181
"orders,order_total,Total order value in USD after discounts.",
181182
].join("\n");
182183
const blob = new Blob([template], { type: "text/csv;charset=utf-8;" });
@@ -444,7 +445,7 @@ export function DetailsLibrary({
444445
<div className={styles.bulkUploadInfo}>
445446
<div className={styles.bulkUploadTitle}>Bulk upload details</div>
446447
<p className={styles.bulkUploadHelp}>
447-
Upload a CSV or Excel file with columns: table_name, column_name
448+
Upload a CSV or Excel file with columns: table_name, column_name (use schema.table for non-public schemas)
448449
(optional), details. The first sheet is used for Excel.
449450
</p>
450451
<div className={styles.bulkUploadMeta}>

0 commit comments

Comments
 (0)