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">
|
||||
<button
|
||||
className="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.columns.map((c) => (
|
||||
<div key={c.name} className="flex justify-between text-[10px]">
|
||||
<span className="text-gray-400">{c.name}</span>
|
||||
<span className="text-gray-600">{c.type}</span>
|
||||
<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="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>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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({
|
||||
|
||||
Reference in New Issue
Block a user