feat: enrich SQL explorer schema browser with PK/FK, row counts, search, collapsible tables
This commit is contained in:
@@ -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 && (
|
||||
|
||||
@@ -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
@@ -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())}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user