Skip to content

Commit e46fe52

Browse files
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 <venkatesh.sakamuri@stayflexi.com>
1 parent 6ef0d75 commit e46fe52

9 files changed

Lines changed: 125 additions & 75 deletions

File tree

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}>

src/components/tabs/Brain/KeyColumnsPanel.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { AlertCircle, Play, Loader, ChevronDown, ChevronRight, AlertTriangle, In
55
import { useKeyColumns } from './hooks/useKeyColumns'
66
import { ActionGuard } from '@/components/ActionGuard'
77
import styles from '../Core/RagTrainingTab.module.css'
8+
import { canonicalTableReference, objectKey } from '@/lib/schemaNames'
89

910
/**
1011
* Key Columns Panel component
@@ -94,18 +95,18 @@ export function KeyColumnsPanel({ connectionId, hideCTAs = false }) {
9495
return true
9596
})
9697

97-
// Group columns by table name (case-insensitive), sorted by number of columns descending
98+
// Group columns by schema.table (case-insensitive), sorted by number of columns descending
9899
const groupedByTable = useMemo(() => {
99100
const groups = {}
100101
const tableNameMap = {} // Maps lowercase to display name
101102

102103
topColumns.forEach(column => {
103-
const rawTableName = column.tableName || 'Unknown'
104-
const tableKey = rawTableName.toLowerCase()
104+
const displayName = canonicalTableReference(column) || column.tableName || 'Unknown'
105+
const tableKey = (objectKey(column) || displayName).toLowerCase()
105106

106107
// Keep track of preferred display name (prefer UPPER_CASE version if available)
107-
if (!tableNameMap[tableKey] || rawTableName === rawTableName.toUpperCase()) {
108-
tableNameMap[tableKey] = rawTableName
108+
if (!tableNameMap[tableKey] || displayName === displayName.toUpperCase()) {
109+
tableNameMap[tableKey] = displayName
109110
}
110111

111112
if (!groups[tableKey]) {

src/components/tabs/Brain/SchemaClassificationPanel.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useState, useEffect } from 'react'
44
import { AlertCircle, Loader, Info, Database, Table2 } from 'lucide-react'
5+
import { canonicalTableReference } from '@/lib/schemaNames'
56
import { ActionGuard } from '@/components/ActionGuard'
67
import { HelpTooltip } from './components/HelpTooltip'
78
import { useSchemaClassification } from './hooks/useSchemaClassification'
@@ -320,7 +321,7 @@ function TableList({ tables, roleFilter, setRoleFilter, getRoleColors, getHealth
320321
const renderCell = (table, column) => {
321322
switch (column) {
322323
case 'Table Name':
323-
return <td className={styles.tableCellName}>{table.tableName}</td>
324+
return <td className={styles.tableCellName}>{canonicalTableReference(table) || table.tableName}</td>
324325
case 'Role':
325326
return (
326327
<td className={styles.tableCell}>

src/components/tabs/Brain/SchemaDocs/SchemaDocsPanel.js

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,18 @@ export function SchemaDocsPanel({
4242
const q = searchTerm.toLowerCase()
4343
return tables.filter(t => {
4444
const name = (t.tableName || '').toLowerCase()
45+
const ref = (t.tableReference || '').toLowerCase()
46+
const schema = (t.schemaName || '').toLowerCase()
4547
const desc = (t.note?.noteText || '').toLowerCase()
46-
return name.includes(q) || desc.includes(q)
48+
return name.includes(q) || ref.includes(q) || schema.includes(q) || desc.includes(q)
4749
})
4850
}, [tables, searchTerm])
4951

50-
const toggleTable = useCallback((tableName) => {
52+
const toggleTable = useCallback((tableKey) => {
5153
setExpandedTables(prev => {
5254
const next = new Set(prev)
53-
if (next.has(tableName)) next.delete(tableName)
54-
else next.add(tableName)
55+
if (next.has(tableKey)) next.delete(tableKey)
56+
else next.add(tableKey)
5557
return next
5658
})
5759
}, [])
@@ -63,6 +65,8 @@ export function SchemaDocsPanel({
6365
const payload = {
6466
connectionId,
6567
scopeType: 'TABLE',
68+
// Persist the qualified reference when present so multi-schema
69+
// notes never collide on bare table names.
6670
tableName,
6771
columnName: null,
6872
noteText: text,
@@ -157,18 +161,21 @@ export function SchemaDocsPanel({
157161
</div>
158162
) : (
159163
<div className={styles.tableList}>
160-
{filteredTables.map(table => (
164+
{filteredTables.map(table => {
165+
const tableKey = table.tableReference || table.tableName
166+
return (
161167
<SchemaDocsTableRow
162-
key={table.tableName}
168+
key={tableKey}
163169
table={table}
164-
expanded={expandedTables.has(table.tableName)}
165-
onToggle={() => toggleTable(table.tableName)}
170+
expanded={expandedTables.has(tableKey)}
171+
onToggle={() => toggleTable(tableKey)}
166172
onSaveTableNote={handleSaveTableNote}
167173
onSaveColumnNote={handleSaveColumnNote}
168174
savingNoteId={savingNoteId}
169175
onOpenCompanyKnowledge={onOpenCompanyKnowledge}
170176
/>
171-
))}
177+
)
178+
})}
172179
</div>
173180
)}
174181
</div>

src/components/tabs/Brain/SchemaDocs/SchemaDocsTableRow.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export function SchemaDocsTableRow({
3434
const roleClass = ROLE_STYLES[table.role] || styles.roleDefault
3535

3636
const descriptionText = table.note?.noteText || ''
37+
const displayName = table.tableReference || table.tableName
38+
const persistTableName = table.tableReference || table.tableName
3739

3840
return (
3941
<div className={styles.tableRow}>
@@ -42,7 +44,7 @@ export function SchemaDocsTableRow({
4244
size={14}
4345
className={`${styles.chevron} ${expanded ? styles.chevronExpanded : ''}`}
4446
/>
45-
<span className={styles.tableName}>{table.tableName}</span>
47+
<span className={styles.tableName} title={displayName}>{displayName}</span>
4648
<div className={styles.tableMeta}>
4749
{table.rowCount != null && (
4850
<span className={styles.rowCount}>
@@ -98,7 +100,7 @@ export function SchemaDocsTableRow({
98100
source={table.note?.source}
99101
noteId={table.note?.id}
100102
sourceFiles={table.note?.sourceFiles}
101-
onSave={(text, noteId) => onSaveTableNote(table.tableName, text, noteId)}
103+
onSave={(text, noteId) => onSaveTableNote(persistTableName, text, noteId)}
102104
saving={savingNoteId === table.note?.id}
103105
placeholder="Click to add table description"
104106
/>
@@ -107,10 +109,10 @@ export function SchemaDocsTableRow({
107109
<div className={styles.columnList}>
108110
{table.columns.map(col => (
109111
<SchemaDocsColumnRow
110-
key={`${table.tableName}.${col.columnName}`}
112+
key={`${persistTableName}.${col.columnName}`}
111113
column={col}
112114
onSave={(text, noteId) =>
113-
onSaveColumnNote(table.tableName, col.columnName, text, noteId)
115+
onSaveColumnNote(persistTableName, col.columnName, text, noteId)
114116
}
115117
saving={savingNoteId === col.note?.id}
116118
onOpenCompanyKnowledge={onOpenCompanyKnowledge}

src/components/tabs/Brain/SchemaDocs/useSchemaDocsData.js

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ import { useMemo } from "react";
44
import { useQuery } from "@tanstack/react-query";
55
import { schemaAPI, brainAPI, companyKnowledgeAPI } from "@/lib/api/client";
66
import { queryKeys } from "@/lib/queryKeys";
7+
import {
8+
canonicalTableReference,
9+
isDefaultSchema,
10+
stripIdentQuotes,
11+
} from "@/lib/schemaNames";
712

813
function normalizeIdentifier(value) {
9-
return (value || "")
10-
.trim()
11-
.replace(/[`"\[\]]/g, "")
12-
.toLowerCase();
14+
return stripIdentQuotes(value).toLowerCase();
1315
}
1416

1517
function identifierTail(value, segments = 1) {
@@ -26,16 +28,6 @@ function referenceAliases(value) {
2628
return new Set([normalized, identifierTail(normalized, 1), identifierTail(normalized, 2)].filter(Boolean))
2729
}
2830

29-
function canonicalTableReference(table) {
30-
const tableName = (table?.tableName || table?.name || "").trim().replace(/[`"\[\]]/g, "")
31-
const schemaName = (table?.schema || table?.schemaName || "").trim().replace(/[`"\[\]]/g, "")
32-
if (!tableName) return ""
33-
if (!schemaName || schemaName === "public" || schemaName === "dbo") {
34-
return tableName
35-
}
36-
return `${schemaName}.${tableName}`
37-
}
38-
3931
function tableAliases(table) {
4032
const canonical = normalizeIdentifier(canonicalTableReference(table))
4133
const bare = normalizeIdentifier(table?.tableName || table?.name || "")
@@ -140,7 +132,10 @@ export function useSchemaDocsData(connectionId) {
140132
const bareName = rawName.includes(".")
141133
? rawName.split(".").pop()
142134
: rawName;
143-
const keys = rawName === bareName ? [rawName] : [rawName, bareName];
135+
// Index qualified notes only under their full key so crm.orders and
136+
// sales.orders never share a slot. Bare notes stay under the bare key
137+
// for default-schema / legacy rows.
138+
const keys = rawName.includes(".") ? [rawName] : [bareName];
144139
if (note.scopeType === "TABLE" || (!note.scopeType && !note.columnName)) {
145140
for (const k of keys) {
146141
tableNotes[k] = considerWinner(tableNotes[k], note);
@@ -154,10 +149,16 @@ export function useSchemaDocsData(connectionId) {
154149
}
155150
}
156151

157-
// Build classification lookup (case-insensitive)
152+
// Build classification lookup (case-insensitive). Prefer schema.table keys.
158153
const roleMap = {};
159154
for (const c of classifications) {
160-
roleMap[(c.tableName || "").toLowerCase()] = c.role || c.tableRole;
155+
const bare = (c.tableName || "").toLowerCase();
156+
const schema = (c.schemaName || c.schema || "").toLowerCase();
157+
const qualified =
158+
schema && !isDefaultSchema(schema) ? `${schema}.${bare}` : bare;
159+
if (qualified) roleMap[qualified] = c.role || c.tableRole;
160+
// Bare fallback only when classification itself is unqualified.
161+
if (bare && !schema) roleMap[bare] = c.role || c.tableRole;
161162
}
162163

163164
let totalTablesDocumented = 0;
@@ -168,10 +169,17 @@ export function useSchemaDocsData(connectionId) {
168169
// Normalize: API uses `name`, plan assumed `tableName`
169170
const tableName = table.tableName || table.name || "";
170171
const tableReference = canonicalTableReference(table)
171-
const tKey = tableName.toLowerCase();
172-
const tableNote = tableNotes[tKey] || null;
173-
const colNotes = columnNotes[tKey] || {};
174-
const role = roleMap[tKey] || null;
172+
const refKey = normalizeIdentifier(tableReference);
173+
const bareKey = normalizeIdentifier(tableName);
174+
// Prefer exact reference match; only fall back to bare for default-schema tables.
175+
const tableNote =
176+
tableNotes[refKey] ||
177+
(refKey === bareKey ? tableNotes[bareKey] : null);
178+
const colNotes =
179+
columnNotes[refKey] ||
180+
(refKey === bareKey ? columnNotes[bareKey] : {}) ||
181+
{};
182+
const role = roleMap[refKey] || (refKey === bareKey ? roleMap[bareKey] : null) || null;
175183
const tableAliasSet = tableAliases(table)
176184
const linkedKnowledge = knowledgeEntries.filter((entry) => {
177185
const linkedTables = Array.isArray(entry?.linkedTables) ? entry.linkedTables : []
@@ -220,6 +228,7 @@ export function useSchemaDocsData(connectionId) {
220228
return {
221229
tableName,
222230
tableReference,
231+
schemaName: table.schema || table.schemaName || "",
223232
rowCount: table.rowCount,
224233
note: tableNote,
225234
role,
@@ -230,8 +239,10 @@ export function useSchemaDocsData(connectionId) {
230239
};
231240
});
232241

233-
// Sort tables alphabetically
234-
tables.sort((a, b) => a.tableName.localeCompare(b.tableName));
242+
// Sort by qualified reference so schemas cluster together
243+
tables.sort((a, b) =>
244+
(a.tableReference || a.tableName).localeCompare(b.tableReference || b.tableName)
245+
);
235246

236247
return {
237248
tables,

0 commit comments

Comments
 (0)