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:
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user