From cbe3fbe8b4db6849f37b25436475bb219999f895 Mon Sep 17 00:00:00 2001 From: Celes Renata Date: Fri, 17 Apr 2026 06:22:04 +0000 Subject: [PATCH] feat: enrich SQL explorer schema browser with PK/FK, row counts, search, collapsible tables --- frontend/src/pages/SqlExplorer.tsx | 111 +++++++++++++++++++++++----- frontend/src/test/mocks/handlers.ts | 4 +- services/api/app.py | 60 +++++++++++++-- 3 files changed, 149 insertions(+), 26 deletions(-) diff --git a/frontend/src/pages/SqlExplorer.tsx b/frontend/src/pages/SqlExplorer.tsx index 241b7c7..e2861f1 100644 --- a/frontend/src/pages/SqlExplorer.tsx +++ b/frontend/src/pages/SqlExplorer.tsx @@ -7,6 +7,7 @@ import { BarChart, Bar, LineChart, Line, ScatterChart, Scatter, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, } from 'recharts'; +import { ChevronDown, ChevronRight, Key, Link, Search } from 'lucide-react'; interface QueryResult { columns: Array<{ name: string; type: string }>; @@ -23,10 +24,25 @@ interface SavedQuery { created_at: string; } +interface SchemaColumn { + name: string; + type: string; + nullable?: boolean; + primary_key?: boolean; + references?: string; + has_default?: boolean; +} + +interface SchemaTable { + name: string; + row_estimate?: number; + columns: SchemaColumn[]; +} + interface SchemaInfo { catalog: string; schema: string; - tables: Array<{ name: string; columns: Array<{ name: string; type: string; nullable?: boolean }> }>; + tables: SchemaTable[]; } type ChartType = 'none' | 'auto' | 'bar' | 'line' | 'scatter'; @@ -39,6 +55,8 @@ export function SqlExplorerPage() { const [chartType, setChartType] = useState('none'); const [xCol, setXCol] = useState(0); const [yCol, setYCol] = useState(1); + const [schemaFilter, setSchemaFilter] = useState(''); + const [expandedTables, setExpandedTables] = useState>(new Set()); const { data: schema } = useQuery({ queryKey: ['pg-schema'], @@ -84,6 +102,28 @@ export function SqlExplorerPage() { setSql(query); }, []); + const toggleTable = useCallback((name: string) => { + setExpandedTables((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + }, []); + + const formatRowCount = (n: number) => { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); + }; + + const filteredTables = (schema?.tables ?? []).filter((t) => { + if (!schemaFilter) return true; + const q = schemaFilter.toLowerCase(); + return t.name.toLowerCase().includes(q) || + t.columns.some((c) => c.name.toLowerCase().includes(q)); + }); + // Build chart data from result // Auto-detect best chart type and columns when chartType === 'auto' const autoChart = (() => { @@ -200,25 +240,60 @@ export function SqlExplorerPage() { {/* Schema browser sidebar */}

Schema

- {schema?.tables.map((t) => ( -
- -
- {t.columns.map((c) => ( -
- {c.name} - {c.type} +
+ + setSchemaFilter(e.target.value)} + placeholder="Filter tables…" + className="w-full rounded border border-surface-700 bg-surface-800 py-1 pl-6 pr-2 text-xs text-gray-300 placeholder-gray-600 focus:border-brand-500 focus:outline-none" + /> +
+

{schema?.tables.length ?? 0} tables

+ {filteredTables.map((t) => { + const isExpanded = expandedTables.has(t.name); + return ( +
+
+ + + {t.row_estimate != null && ( + + {formatRowCount(t.row_estimate)} + + )} +
+ {isExpanded && ( +
+ {t.columns.map((c) => ( +
+ + {c.primary_key && } + {c.references && } + {c.name} + + {c.type} +
+ ))}
- ))} + )}
-
- ))} + ); + })} {/* Pre-built queries */} {preBuiltQueries.length > 0 && ( diff --git a/frontend/src/test/mocks/handlers.ts b/frontend/src/test/mocks/handlers.ts index e3a1e69..6e12349 100644 --- a/frontend/src/test/mocks/handlers.ts +++ b/frontend/src/test/mocks/handlers.ts @@ -128,8 +128,8 @@ export const handlers = [ http.get('/api/analytics/pg-schema', () => HttpResponse.json({ catalog: 'postgresql', schema: 'public', tables: [ - { name: 'companies', columns: [{ name: 'id', type: 'uuid', nullable: false }, { name: 'ticker', type: 'character varying', nullable: false }, { name: 'legal_name', type: 'text', nullable: false }] }, - { name: 'recommendations', columns: [{ name: 'id', type: 'uuid', nullable: false }, { name: 'ticker', type: 'character varying', nullable: false }, { name: 'action', type: 'character varying', nullable: false }] }, + { name: 'companies', row_estimate: 50, columns: [{ name: 'id', type: 'uuid', nullable: false, primary_key: true }, { name: 'ticker', type: 'character varying', nullable: false }, { name: 'legal_name', type: 'text', nullable: false }] }, + { name: 'recommendations', row_estimate: 120, columns: [{ name: 'id', type: 'uuid', nullable: false, primary_key: true }, { name: 'ticker', type: 'character varying', nullable: false }, { name: 'action', type: 'character varying', nullable: false }, { name: 'company_id', type: 'uuid', nullable: false, references: 'companies' }] }, ], })), http.post('/api/analytics/pg-query', () => HttpResponse.json({ diff --git a/services/api/app.py b/services/api/app.py index 9cf4db5..958a770 100644 --- a/services/api/app.py +++ b/services/api/app.py @@ -2121,24 +2121,72 @@ async def analytics_schema(): @app.get("/api/analytics/pg-schema") async def pg_schema(): """Return PostgreSQL table/column metadata for the schema browser.""" - rows = await pool.fetch(""" - SELECT t.table_name, c.column_name, c.data_type, c.is_nullable + # Columns with ordinal position + col_rows = await pool.fetch(""" + SELECT t.table_name, c.column_name, c.data_type, c.is_nullable, + c.column_default FROM information_schema.tables t JOIN information_schema.columns c ON t.table_name = c.table_name AND t.table_schema = c.table_schema WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE' ORDER BY t.table_name, c.ordinal_position """) + + # Primary key columns + pk_rows = await pool.fetch(""" + SELECT kcu.table_name, kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.table_schema = 'public' AND tc.constraint_type = 'PRIMARY KEY' + """) + pk_set: set[tuple[str, str]] = {(r["table_name"], r["column_name"]) for r in pk_rows} + + # Foreign key columns with referenced table + fk_rows = await pool.fetch(""" + SELECT kcu.table_name, kcu.column_name, + ccu.table_name AS foreign_table + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage ccu + ON tc.constraint_name = ccu.constraint_name + AND tc.table_schema = ccu.table_schema + WHERE tc.table_schema = 'public' AND tc.constraint_type = 'FOREIGN KEY' + """) + fk_map: dict[tuple[str, str], str] = { + (r["table_name"], r["column_name"]): r["foreign_table"] for r in fk_rows + } + + # Approximate row counts from pg_stat + count_rows = await pool.fetch(""" + SELECT relname AS table_name, reltuples::bigint AS row_estimate + FROM pg_class + WHERE relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public') + AND relkind = 'r' + """) + row_counts: dict[str, int] = {r["table_name"]: max(0, r["row_estimate"]) for r in count_rows} + tables: dict[str, dict[str, Any]] = {} - for row in rows: + for row in col_rows: tname = row["table_name"] if tname not in tables: - tables[tname] = {"name": tname, "columns": []} - tables[tname]["columns"].append({ + tables[tname] = {"name": tname, "row_estimate": row_counts.get(tname, 0), "columns": []} + col_info: dict[str, Any] = { "name": row["column_name"], "type": row["data_type"], "nullable": row["is_nullable"] == "YES", - }) + } + if (tname, row["column_name"]) in pk_set: + col_info["primary_key"] = True + fk_ref = fk_map.get((tname, row["column_name"])) + if fk_ref: + col_info["references"] = fk_ref + if row["column_default"] is not None: + col_info["has_default"] = True + tables[tname]["columns"].append(col_info) return {"catalog": "postgresql", "schema": "public", "tables": list(tables.values())}