Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agent/skills/dashboard-design/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,8 @@ public ResponseEntity<Map<String, Object>> executeQuery(
}
}

@GetMapping("/tables/{tableName}/indexes")
// `{tableName:.+}` keeps schema-qualified ids (`crm.orders`) as one segment.
@GetMapping("/tables/{tableName:.+}/indexes")
public ResponseEntity<Map<String, Object>> getTableIndexes(
@PathVariable String connectionId,
@PathVariable String tableName) {
Expand Down Expand Up @@ -292,7 +293,7 @@ public ResponseEntity<Map<String, Object>> getTableIndexes(
}
}

@GetMapping("/tables/{tableName}/stats")
@GetMapping("/tables/{tableName:.+}/stats")
public ResponseEntity<Map<String, Object>> getTableStats(
@PathVariable String connectionId,
@PathVariable String tableName) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,16 @@ public List<TableIndex> getTableIndexes(Connection connection, String database,
List<TableIndex> indexes = new ArrayList<>();
Map<String, TableIndex> 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
Expand All @@ -161,8 +171,8 @@ public List<TableIndex> 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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,18 @@ public List<TableIndex> getTableIndexes(Connection connection, String database,
List<TableIndex> indexes = new ArrayList<>();
Map<String, TableIndex> 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,
Expand All @@ -212,16 +224,21 @@ public List<TableIndex> 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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ private List<IndexRecommendation> 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,
Expand All @@ -296,7 +296,7 @@ private List<IndexRecommendation> 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)
Expand All @@ -308,11 +308,13 @@ private List<IndexRecommendation> 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<String> candidateColumns = getPostgresCandidateColumns(
Expand All @@ -325,7 +327,7 @@ private List<IndexRecommendation> detectPostgresMissingIndexes(String connection
.id(UUID.randomUUID().toString())
.connectionId(connectionId)
.tableName(tableName)
.schemaName("public")
.schemaName(schemaName)
.columns(candidateColumns)
.indexType("BTREE")
.priority(seqScans > 10000 ?
Expand All @@ -334,13 +336,13 @@ private List<IndexRecommendation> 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()
Expand All @@ -358,9 +360,10 @@ private List<IndexRecommendation> 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
Expand All @@ -371,11 +374,11 @@ private List<IndexRecommendation> 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 || '%'
)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
72 changes: 46 additions & 26 deletions src/components/company-knowledge/CompanyKnowledgePanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand All @@ -27,16 +28,6 @@ const EMPTY_FORM = {
const TABLE_ANNOTATION_RE = /(?<!@)@([A-Za-z_][\w$.]*)/g
const COLUMN_ANNOTATION_RE = /(?<!@)@@([A-Za-z_][\w$.]*)/g

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 normalizeValue(value) {
return (value || '').trim().toLowerCase()
}
Expand Down Expand Up @@ -68,12 +59,21 @@ function getDiagnosticTone(entry) {

function buildTableLookup(tableOptions) {
const lookup = new Map()
const bareCounts = new Map()
tableOptions.forEach((table) => {
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))
Expand All @@ -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))
Expand Down Expand Up @@ -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],
)
Expand Down
5 changes: 3 additions & 2 deletions src/components/tabs/Brain/DetailsLibrary.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;" });
Expand Down Expand Up @@ -444,7 +445,7 @@ export function DetailsLibrary({
<div className={styles.bulkUploadInfo}>
<div className={styles.bulkUploadTitle}>Bulk upload details</div>
<p className={styles.bulkUploadHelp}>
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.
</p>
<div className={styles.bulkUploadMeta}>
Expand Down
Loading
Loading