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
This commit is contained in:
@@ -26,7 +26,7 @@ interface SavedQuery {
|
||||
interface SchemaInfo {
|
||||
catalog: string;
|
||||
schema: string;
|
||||
tables: Array<{ name: string; columns: Array<{ name: string; type: string }> }>;
|
||||
tables: Array<{ name: string; columns: Array<{ name: string; type: string; nullable?: boolean }> }>;
|
||||
}
|
||||
|
||||
type ChartType = 'none' | 'bar' | 'line' | 'scatter';
|
||||
@@ -41,8 +41,8 @@ export function SqlExplorerPage() {
|
||||
const [yCol, setYCol] = useState(1);
|
||||
|
||||
const { data: schema } = useQuery<SchemaInfo>({
|
||||
queryKey: ['trino-schema'],
|
||||
queryFn: () => apiGet<SchemaInfo>('query', '/api/analytics/schema'),
|
||||
queryKey: ['pg-schema'],
|
||||
queryFn: () => apiGet<SchemaInfo>('query', '/api/analytics/pg-schema'),
|
||||
});
|
||||
|
||||
const { data: savedQueries } = useQuery<SavedQuery[]>({
|
||||
@@ -51,7 +51,7 @@ export function SqlExplorerPage() {
|
||||
});
|
||||
|
||||
const executeMutation = useMutation({
|
||||
mutationFn: (sqlText: string) => apiPost<QueryResult>('query', '/api/analytics/query', { sql: sqlText, limit: 1000 }),
|
||||
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); },
|
||||
});
|
||||
@@ -75,6 +75,11 @@ export function SqlExplorerPage() {
|
||||
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) => ({
|
||||
@@ -84,16 +89,27 @@ export function SqlExplorerPage() {
|
||||
}))
|
||||
: [];
|
||||
|
||||
// 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-56 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>
|
||||
{schema?.tables.map((t) => (
|
||||
<div key={t.name} className="mb-2">
|
||||
<button
|
||||
className="text-xs font-medium text-brand-300 hover:underline"
|
||||
onClick={() => setSql((prev) => prev + ` ${t.name}`)}
|
||||
onClick={() => handleQuickInsert(t.name)}
|
||||
title={`SELECT * FROM ${t.name} LIMIT 100`}
|
||||
>
|
||||
{t.name}
|
||||
</button>
|
||||
@@ -108,9 +124,35 @@ export function SqlExplorerPage() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Saved queries */}
|
||||
{/* 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>
|
||||
{(savedQueries ?? []).map((sq) => (
|
||||
{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"
|
||||
|
||||
@@ -95,6 +95,26 @@ export const handlers = [
|
||||
return HttpResponse.json({ id: 'w1', name: body.name, description: body.description ?? null, active: true }, { status: 201 });
|
||||
}),
|
||||
|
||||
// Analytics: PostgreSQL schema + query
|
||||
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 }] },
|
||||
],
|
||||
})),
|
||||
http.post('/api/analytics/pg-query', () => HttpResponse.json({
|
||||
columns: [{ name: 'test', type: 'text' }], rows: [['1']], row_count: 1, elapsed_ms: 5,
|
||||
})),
|
||||
http.get('/api/analytics/saved-queries', () => HttpResponse.json([
|
||||
{ id: 'sq1', name: 'Company Overview', description: 'All tracked companies with sector and status', sql_text: 'SELECT ticker, legal_name, sector, active, created_at FROM companies ORDER BY ticker', created_at: '2026-04-10T12:00:00Z' },
|
||||
])),
|
||||
http.post('/api/analytics/saved-queries', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, string>;
|
||||
return HttpResponse.json({ id: 'sq-new', name: body.name, description: '', sql_text: body.sql_text, created_at: '2026-04-10T12:00:00Z' }, { status: 201 });
|
||||
}),
|
||||
http.delete('/api/analytics/saved-queries/:id', () => HttpResponse.json({ status: 'deleted' })),
|
||||
|
||||
// Health
|
||||
http.get('/api/health', () => HttpResponse.json({ status: 'ok' })),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user