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:
Celes Renata
2026-04-16 01:06:49 +00:00
parent 55512ca5a8
commit 949324dc89
4 changed files with 173 additions and 8 deletions
+65
View File
@@ -1673,6 +1673,71 @@ async def analytics_schema():
return {"catalog": trino_catalog, "schema": trino_schema, "tables": []}
# ---------------------------------------------------------------------------
# Analytics: PostgreSQL Direct Query (Schema browser + read-only SQL)
# ---------------------------------------------------------------------------
@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
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
""")
tables: dict[str, dict[str, Any]] = {}
for row in rows:
tname = row["table_name"]
if tname not in tables:
tables[tname] = {"name": tname, "columns": []}
tables[tname]["columns"].append({
"name": row["column_name"],
"type": row["data_type"],
"nullable": row["is_nullable"] == "YES",
})
return {"catalog": "postgresql", "schema": "public", "tables": list(tables.values())}
@app.post("/api/analytics/pg-query")
async def pg_query(body: dict[str, Any]):
"""Run read-only SQL against PostgreSQL directly."""
sql = body.get("sql", "").strip()
if not sql:
raise HTTPException(400, "sql is required")
limit = min(int(body.get("limit", 1000)), 10000)
# Safety: only allow SELECT statements
if not sql.upper().startswith("SELECT"):
raise HTTPException(400, "Only SELECT queries are allowed")
# Add LIMIT if not present
if "LIMIT" not in sql.upper():
sql = f"{sql} LIMIT {limit}"
start = _time.monotonic()
try:
rows = await pool.fetch(sql)
elapsed_ms = round((_time.monotonic() - start) * 1000)
columns = [{"name": k, "type": "text"} for k in rows[0].keys()] if rows else []
return {
"columns": columns,
"rows": [[str(v) for v in row.values()] for row in rows],
"row_count": len(rows),
"elapsed_ms": elapsed_ms,
}
except asyncpg.PostgresSyntaxError as exc:
raise HTTPException(400, f"SQL syntax error: {exc}")
except asyncpg.UndefinedTableError as exc:
raise HTTPException(400, f"Table not found: {exc}")
except asyncpg.PostgresError as exc:
raise HTTPException(400, f"Query error: {exc}")
# ---------------------------------------------------------------------------
# Analytics: Saved Queries (Requirement 13.7)
# ---------------------------------------------------------------------------