feat: enrich SQL explorer schema browser with PK/FK, row counts, search, collapsible tables

This commit is contained in:
Celes Renata
2026-04-17 06:22:04 +00:00
parent bbf7a6ee7b
commit cbe3fbe8b4
3 changed files with 149 additions and 26 deletions
+83 -8
View File
@@ -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<ChartType>('none');
const [xCol, setXCol] = useState(0);
const [yCol, setYCol] = useState(1);
const [schemaFilter, setSchemaFilter] = useState('');
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
const { data: schema } = useQuery<SchemaInfo>({
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 */}
<div className="w-64 shrink-0 overflow-y-auto rounded-lg border border-surface-700 bg-surface-900 p-3">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">Schema</h2>
{schema?.tables.map((t) => (
<div key={t.name} className="mb-2">
<div className="relative mb-2">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-500" />
<input
type="text"
value={schemaFilter}
onChange={(e) => 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"
/>
</div>
<p className="mb-2 text-[10px] text-gray-600">{schema?.tables.length ?? 0} tables</p>
{filteredTables.map((t) => {
const isExpanded = expandedTables.has(t.name);
return (
<div key={t.name} className="mb-1">
<div className="flex items-center gap-1">
<button
className="text-xs font-medium text-brand-300 hover:underline"
className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-200"
onClick={() => toggleTable(t.name)}
aria-expanded={isExpanded}
aria-label={`Toggle ${t.name} columns`}
>
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
<button
className="flex-1 truncate text-left text-xs font-medium text-brand-300 hover:underline"
onClick={() => handleQuickInsert(t.name)}
title={`SELECT * FROM ${t.name} LIMIT 100`}
>
{t.name}
</button>
<div className="ml-2 space-y-0.5">
{t.row_estimate != null && (
<span className="text-[10px] text-gray-600" title={`~${t.row_estimate.toLocaleString()} rows`}>
{formatRowCount(t.row_estimate)}
</span>
)}
</div>
{isExpanded && (
<div className="ml-5 mt-0.5 space-y-0.5">
{t.columns.map((c) => (
<div key={c.name} className="flex justify-between text-[10px]">
<span className="text-gray-400">{c.name}</span>
<div key={c.name} className="flex items-center justify-between text-[10px]">
<span className="flex items-center gap-1 text-gray-400">
{c.primary_key && <Key size={9} className="text-amber-500" title="Primary key" />}
{c.references && <Link size={9} className="text-blue-400" title={`FK → ${c.references}`} />}
{c.name}
</span>
<span className="text-gray-600">{c.type}</span>
</div>
))}
</div>
)}
</div>
))}
);
})}
{/* Pre-built queries */}
{preBuiltQueries.length > 0 && (
+2 -2
View File
@@ -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({
+54 -6
View File
@@ -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())}