Files
stonks-oracle/frontend/src/pages/SqlExplorer.tsx
T
Celes Renata 949324dc89 feat: SQL Explorer with PostgreSQL schema browser and pre-built queries
The SQL Explorer was querying Trino which has zero tables. Rewrote to
use PostgreSQL directly:

Backend:
- GET /api/analytics/pg-schema: returns all public tables with column
  names, types, and nullability from information_schema
- POST /api/analytics/pg-query: read-only SQL execution against
  PostgreSQL with SELECT-only enforcement, auto LIMIT, and descriptive
  error messages for syntax/table/query errors

Frontend:
- Schema browser shows all PostgreSQL tables with columns and types
- Click a table name → generates SELECT * FROM table LIMIT 100
- Pre-built Queries section with 12 seeded queries covering companies,
  recommendations, trends, market prices, documents, global events,
  trading decisions, ingestion health, reserve pool, sector exposure
- User-saved queries shown separately with delete buttons
- Chart builder, Monaco editor, and save functionality preserved

Migration 021: seeds 12 pre-built saved queries
2026-04-16 01:06:49 +00:00

300 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Editor from '@monaco-editor/react';
import { apiGet, apiPost, apiDelete } from '../api/client';
import { LoadingSpinner, Card } from '../components/ui';
import {
BarChart, Bar, LineChart, Line, ScatterChart, Scatter,
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
} from 'recharts';
interface QueryResult {
columns: Array<{ name: string; type: string }>;
rows: unknown[][];
row_count: number;
elapsed_ms: number;
}
interface SavedQuery {
id: string;
name: string;
description: string;
sql_text: string;
created_at: string;
}
interface SchemaInfo {
catalog: string;
schema: string;
tables: Array<{ name: string; columns: Array<{ name: string; type: string; nullable?: boolean }> }>;
}
type ChartType = 'none' | 'bar' | 'line' | 'scatter';
export function SqlExplorerPage() {
const qc = useQueryClient();
const [sql, setSql] = useState('SELECT 1 AS test');
const [result, setResult] = useState<QueryResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [chartType, setChartType] = useState<ChartType>('none');
const [xCol, setXCol] = useState(0);
const [yCol, setYCol] = useState(1);
const { data: schema } = useQuery<SchemaInfo>({
queryKey: ['pg-schema'],
queryFn: () => apiGet<SchemaInfo>('query', '/api/analytics/pg-schema'),
});
const { data: savedQueries } = useQuery<SavedQuery[]>({
queryKey: ['saved-queries'],
queryFn: () => apiGet<SavedQuery[]>('query', '/api/analytics/saved-queries'),
});
const executeMutation = useMutation({
mutationFn: (sqlText: string) => apiPost<QueryResult>('query', '/api/analytics/pg-query', { sql: sqlText, limit: 1000 }),
onSuccess: (data) => { setResult(data); setError(null); },
onError: (err: Error) => { setError(err.message); setResult(null); },
});
const saveMutation = useMutation({
mutationFn: (body: { name: string; sql_text: string }) => apiPost<SavedQuery>('query', '/api/analytics/saved-queries', body),
onSuccess: () => qc.invalidateQueries({ queryKey: ['saved-queries'] }),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiDelete<unknown>('query', `/api/analytics/saved-queries/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['saved-queries'] }),
});
const handleExecute = useCallback(() => {
if (sql.trim()) executeMutation.mutate(sql);
}, [sql, executeMutation]);
const handleSave = useCallback(() => {
const name = prompt('Query name:');
if (name) saveMutation.mutate({ name, sql_text: sql });
}, [sql, saveMutation]);
const handleQuickInsert = useCallback((tableName: string) => {
const query = `SELECT * FROM ${tableName} LIMIT 100`;
setSql(query);
}, []);
// Build chart data from result
const chartData = result && result.columns.length >= 2
? result.rows.map((row) => ({
x: row[xCol],
y: Number(row[yCol]) || 0,
label: String(row[xCol]),
}))
: [];
// Split saved queries into pre-built (no id-based delete) and user-saved
const preBuiltNames = new Set([
'Company Overview', 'Recent Recommendations', 'High Confidence Buys',
'Trend Windows Summary', 'Market Prices', 'Document Counts by Type',
'Global Events', 'Trading Decisions', 'Ingestion Health',
'Reserve Pool Ledger', 'Sector Exposure', 'Source Coverage Matrix',
]);
const preBuiltQueries = (savedQueries ?? []).filter((sq) => preBuiltNames.has(sq.name));
const userQueries = (savedQueries ?? []).filter((sq) => !preBuiltNames.has(sq.name));
return (
<div className="flex h-[calc(100vh-3rem)] gap-4">
{/* 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>
))}
</div>
</div>
))}
{/* Pre-built queries */}
{preBuiltQueries.length > 0 && (
<>
<h2 className="mb-2 mt-4 text-xs font-semibold uppercase tracking-wider text-gray-500">Pre-built Queries</h2>
{preBuiltQueries.map((sq) => (
<div key={sq.id} className="mb-1.5">
<button
className="block w-full truncate text-left text-xs text-brand-300 hover:text-brand-200"
onClick={() => setSql(sq.sql_text)}
title={sq.sql_text}
>
{sq.name}
</button>
{sq.description && (
<p className="ml-0 truncate text-[10px] text-gray-600" title={sq.description}>
{sq.description}
</p>
)}
</div>
))}
</>
)}
{/* User saved queries */}
<h2 className="mb-2 mt-4 text-xs font-semibold uppercase tracking-wider text-gray-500">Saved Queries</h2>
{userQueries.length === 0 && (
<p className="text-[10px] text-gray-600">No saved queries yet</p>
)}
{userQueries.map((sq) => (
<div key={sq.id} className="mb-1 flex items-center justify-between">
<button
className="truncate text-xs text-gray-300 hover:text-brand-300"
onClick={() => setSql(sq.sql_text)}
title={sq.description || sq.name}
>
{sq.name}
</button>
<button
className="text-[10px] text-gray-600 hover:text-red-400"
onClick={() => deleteMutation.mutate(sq.id)}
aria-label={`Delete ${sq.name}`}
>
×
</button>
</div>
))}
</div>
{/* Main area */}
<div className="flex flex-1 flex-col gap-3 overflow-hidden">
{/* Editor */}
<div className="h-48 shrink-0 overflow-hidden rounded-lg border border-surface-700">
<Editor
height="100%"
defaultLanguage="sql"
value={sql}
onChange={(v) => setSql(v ?? '')}
theme="vs-dark"
options={{ minimap: { enabled: false }, fontSize: 13, lineNumbers: 'on', scrollBeyondLastLine: false }}
/>
</div>
{/* Controls */}
<div className="flex items-center gap-2">
<button
onClick={handleExecute}
disabled={executeMutation.isPending}
className="rounded-md bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{executeMutation.isPending ? 'Running…' : 'Execute'}
</button>
<button onClick={handleSave} className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800">
Save
</button>
{result && (
<span className="text-xs text-gray-500">
{result.row_count} rows in {result.elapsed_ms}ms
</span>
)}
{error && <span className="text-xs text-red-400">{error}</span>}
</div>
{/* Results */}
{executeMutation.isPending && <LoadingSpinner />}
{result && (
<div className="flex-1 overflow-auto rounded-lg border border-surface-700">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-surface-900">
<tr>
{result.columns.map((col, i) => (
<th key={i} className="px-2 py-1.5 text-left font-medium text-gray-400">
{col.name} <span className="text-gray-600">({col.type})</span>
</th>
))}
</tr>
</thead>
<tbody>
{result.rows.map((row, ri) => (
<tr key={ri} className="border-t border-surface-700/30 hover:bg-surface-800/30">
{row.map((cell, ci) => (
<td key={ci} className="px-2 py-1 text-gray-300">
{cell == null ? <span className="text-gray-600">NULL</span> : String(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Chart builder */}
{result && result.columns.length >= 2 && (
<Card className="shrink-0">
<div className="mb-2 flex items-center gap-3">
<span className="text-xs text-gray-500">Chart:</span>
{(['none', 'bar', 'line', 'scatter'] as ChartType[]).map((ct) => (
<button
key={ct}
onClick={() => setChartType(ct)}
className={`rounded px-2 py-0.5 text-xs ${chartType === ct ? 'bg-brand-600 text-white' : 'text-gray-400 hover:bg-surface-800'}`}
>
{ct === 'none' ? 'Off' : ct}
</button>
))}
{chartType !== 'none' && (
<>
<select value={xCol} onChange={(e) => setXCol(Number(e.target.value))} className="rounded border border-surface-700 bg-surface-900 px-1 py-0.5 text-xs text-gray-300" aria-label="X axis column">
{result.columns.map((c, i) => <option key={i} value={i}>{c.name}</option>)}
</select>
<span className="text-xs text-gray-600"></span>
<select value={yCol} onChange={(e) => setYCol(Number(e.target.value))} className="rounded border border-surface-700 bg-surface-900 px-1 py-0.5 text-xs text-gray-300" aria-label="Y axis column">
{result.columns.map((c, i) => <option key={i} value={i}>{c.name}</option>)}
</select>
</>
)}
</div>
{chartType !== 'none' && chartData.length > 0 && (
<ResponsiveContainer width="100%" height={250}>
{chartType === 'bar' ? (
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="label" tick={{ fill: '#6b7280', fontSize: 10 }} />
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
<Bar dataKey="y" fill="#3b82f6" />
</BarChart>
) : chartType === 'line' ? (
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="label" tick={{ fill: '#6b7280', fontSize: 10 }} />
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
<Line type="monotone" dataKey="y" stroke="#3b82f6" dot={false} />
</LineChart>
) : (
<ScatterChart>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="x" tick={{ fill: '#6b7280', fontSize: 10 }} name={result.columns[xCol]?.name} />
<YAxis dataKey="y" tick={{ fill: '#6b7280', fontSize: 10 }} name={result.columns[yCol]?.name} />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
<Scatter data={chartData} fill="#3b82f6" />
</ScatterChart>
)}
</ResponsiveContainer>
)}
</Card>
)}
</div>
</div>
);
}