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,
|
BarChart, Bar, LineChart, Line, ScatterChart, Scatter,
|
||||||
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
import { ChevronDown, ChevronRight, Key, Link, Search } from 'lucide-react';
|
||||||
|
|
||||||
interface QueryResult {
|
interface QueryResult {
|
||||||
columns: Array<{ name: string; type: string }>;
|
columns: Array<{ name: string; type: string }>;
|
||||||
@@ -23,10 +24,25 @@ interface SavedQuery {
|
|||||||
created_at: string;
|
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 {
|
interface SchemaInfo {
|
||||||
catalog: string;
|
catalog: string;
|
||||||
schema: string;
|
schema: string;
|
||||||
tables: Array<{ name: string; columns: Array<{ name: string; type: string; nullable?: boolean }> }>;
|
tables: SchemaTable[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChartType = 'none' | 'auto' | 'bar' | 'line' | 'scatter';
|
type ChartType = 'none' | 'auto' | 'bar' | 'line' | 'scatter';
|
||||||
@@ -39,6 +55,8 @@ export function SqlExplorerPage() {
|
|||||||
const [chartType, setChartType] = useState<ChartType>('none');
|
const [chartType, setChartType] = useState<ChartType>('none');
|
||||||
const [xCol, setXCol] = useState(0);
|
const [xCol, setXCol] = useState(0);
|
||||||
const [yCol, setYCol] = useState(1);
|
const [yCol, setYCol] = useState(1);
|
||||||
|
const [schemaFilter, setSchemaFilter] = useState('');
|
||||||
|
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const { data: schema } = useQuery<SchemaInfo>({
|
const { data: schema } = useQuery<SchemaInfo>({
|
||||||
queryKey: ['pg-schema'],
|
queryKey: ['pg-schema'],
|
||||||
@@ -84,6 +102,28 @@ export function SqlExplorerPage() {
|
|||||||
setSql(query);
|
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
|
// Build chart data from result
|
||||||
// Auto-detect best chart type and columns when chartType === 'auto'
|
// Auto-detect best chart type and columns when chartType === 'auto'
|
||||||
const autoChart = (() => {
|
const autoChart = (() => {
|
||||||
@@ -200,25 +240,60 @@ export function SqlExplorerPage() {
|
|||||||
{/* Schema browser sidebar */}
|
{/* Schema browser sidebar */}
|
||||||
<div className="w-64 shrink-0 overflow-y-auto rounded-lg border border-surface-700 bg-surface-900 p-3">
|
<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>
|
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">Schema</h2>
|
||||||
{schema?.tables.map((t) => (
|
<div className="relative mb-2">
|
||||||
<div key={t.name} className="mb-2">
|
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||||
<button
|
<input
|
||||||
className="text-xs font-medium text-brand-300 hover:underline"
|
type="text"
|
||||||
onClick={() => handleQuickInsert(t.name)}
|
value={schemaFilter}
|
||||||
title={`SELECT * FROM ${t.name} LIMIT 100`}
|
onChange={(e) => setSchemaFilter(e.target.value)}
|
||||||
>
|
placeholder="Filter tables…"
|
||||||
{t.name}
|
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"
|
||||||
</button>
|
/>
|
||||||
<div className="ml-2 space-y-0.5">
|
</div>
|
||||||
{t.columns.map((c) => (
|
<p className="mb-2 text-[10px] text-gray-600">{schema?.tables.length ?? 0} tables</p>
|
||||||
<div key={c.name} className="flex justify-between text-[10px]">
|
{filteredTables.map((t) => {
|
||||||
<span className="text-gray-400">{c.name}</span>
|
const isExpanded = expandedTables.has(t.name);
|
||||||
<span className="text-gray-600">{c.type}</span>
|
return (
|
||||||
|
<div key={t.name} className="mb-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
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>
|
||||||
|
{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 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>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
|
|
||||||
{/* Pre-built queries */}
|
{/* Pre-built queries */}
|
||||||
{preBuiltQueries.length > 0 && (
|
{preBuiltQueries.length > 0 && (
|
||||||
|
|||||||
@@ -128,8 +128,8 @@ export const handlers = [
|
|||||||
http.get('/api/analytics/pg-schema', () => HttpResponse.json({
|
http.get('/api/analytics/pg-schema', () => HttpResponse.json({
|
||||||
catalog: 'postgresql', schema: 'public',
|
catalog: 'postgresql', schema: 'public',
|
||||||
tables: [
|
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: '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', columns: [{ name: 'id', type: 'uuid', nullable: false }, { name: 'ticker', type: 'character varying', nullable: false }, { name: 'action', type: 'character varying', 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({
|
http.post('/api/analytics/pg-query', () => HttpResponse.json({
|
||||||
|
|||||||
+54
-6
@@ -2121,24 +2121,72 @@ async def analytics_schema():
|
|||||||
@app.get("/api/analytics/pg-schema")
|
@app.get("/api/analytics/pg-schema")
|
||||||
async def pg_schema():
|
async def pg_schema():
|
||||||
"""Return PostgreSQL table/column metadata for the schema browser."""
|
"""Return PostgreSQL table/column metadata for the schema browser."""
|
||||||
rows = await pool.fetch("""
|
# Columns with ordinal position
|
||||||
SELECT t.table_name, c.column_name, c.data_type, c.is_nullable
|
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
|
FROM information_schema.tables t
|
||||||
JOIN information_schema.columns c
|
JOIN information_schema.columns c
|
||||||
ON t.table_name = c.table_name AND t.table_schema = c.table_schema
|
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'
|
WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE'
|
||||||
ORDER BY t.table_name, c.ordinal_position
|
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]] = {}
|
tables: dict[str, dict[str, Any]] = {}
|
||||||
for row in rows:
|
for row in col_rows:
|
||||||
tname = row["table_name"]
|
tname = row["table_name"]
|
||||||
if tname not in tables:
|
if tname not in tables:
|
||||||
tables[tname] = {"name": tname, "columns": []}
|
tables[tname] = {"name": tname, "row_estimate": row_counts.get(tname, 0), "columns": []}
|
||||||
tables[tname]["columns"].append({
|
col_info: dict[str, Any] = {
|
||||||
"name": row["column_name"],
|
"name": row["column_name"],
|
||||||
"type": row["data_type"],
|
"type": row["data_type"],
|
||||||
"nullable": row["is_nullable"] == "YES",
|
"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())}
|
return {"catalog": "postgresql", "schema": "public", "tables": list(tables.values())}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user