feat(ui): Data Sources UI — enterprise browser for all 5 source databases
New "Data Sources UI" tab with per-database Browser (catalog + sample data),
Query Console (SqlWorkbench) and embedded interactive Shell (DbShell via SSH).
Backend:
- Extend sql_console.py with Cassandra (CQL) + Neo4j (Cypher) engines
- Add GET /api/sql/catalog/{engine} and GET /api/sql/sample/{engine}
- ssh_terminal: optional initial_command for auto-launching DB CLIs
Frontend:
- DataSourcesView with 5-DB rail, health dots, Browser/Console/Shell sub-tabs
- DbShell embedded xterm terminal with docker exec CLI per engine
- Deep-link topology DB nodes to Data Sources UI (no SQL dock on platform)
- WorkbenchPanel restricted to agent mode only — frees dashboard space
This commit is contained in:
+318
-38
@@ -1,4 +1,4 @@
|
||||
"""Live SQL console — PostgreSQL, MySQL, MongoDB + Trino with benchmark."""
|
||||
"""Live SQL console — PostgreSQL, MySQL, MongoDB, Cassandra, Neo4j + Trino."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,8 +10,11 @@ from typing import Any
|
||||
import httpx
|
||||
import psycopg2
|
||||
import pymysql
|
||||
from fastapi import APIRouter
|
||||
from cassandra.auth import PlainTextAuthProvider
|
||||
from cassandra.cluster import Cluster
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from neo4j import GraphDatabase
|
||||
from pydantic import BaseModel, Field
|
||||
from pymongo import MongoClient
|
||||
|
||||
@@ -30,9 +33,21 @@ MONGO_HOST = os.getenv("MONGO_HOST", DB_HOST)
|
||||
MONGO_PORT = int(os.getenv("MONGO_PORT", "27017"))
|
||||
MONGO_DB = os.getenv("MONGO_DATABASE", "supplychain")
|
||||
|
||||
CASS_PORT = int(os.getenv("CASSANDRA_PORT", "9042"))
|
||||
CASS_USER = os.getenv("CASSANDRA_USER", "cassandra")
|
||||
CASS_PASS = os.getenv("CASSANDRA_PASSWORD", "cassandra")
|
||||
CASS_KS = os.getenv("CASSANDRA_KEYSPACE", "telemetry")
|
||||
|
||||
NEO4J_URI = os.getenv("NEO4J_URI", f"bolt://{DB_HOST}:7687")
|
||||
NEO4J_USER = os.getenv("NEO4J_USER", "neo4j")
|
||||
NEO4J_PASS = os.getenv("NEO4J_PASSWORD", "testpwd")
|
||||
|
||||
TRINO_URL = os.getenv("TRINO_URL", "http://10.0.21.50:8089").rstrip("/")
|
||||
TRINO_USER = os.getenv("TRINO_USER", "atc")
|
||||
|
||||
SOURCE_ENGINES = ("postgres", "mysql", "mongodb", "cassandra", "neo4j")
|
||||
ENGINES = SOURCE_ENGINES + ("trino",)
|
||||
|
||||
router = APIRouter(prefix="/api/sql", tags=["sql"])
|
||||
|
||||
SAMPLES: dict[str, list[dict[str, str]]] = {
|
||||
@@ -45,7 +60,7 @@ SAMPLES: dict[str, list[dict[str, str]]] = {
|
||||
{"id": "pg6", "label": "Database sizes", "sql": "SELECT datname, pg_size_pretty(pg_database_size(datname)) AS size FROM pg_database ORDER BY pg_database_size(datname) DESC LIMIT 10;"},
|
||||
{"id": "pg7", "label": "Row counts (stats)", "sql": "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC NULLS LAST LIMIT 10;"},
|
||||
{"id": "pg8", "label": "Connections", "sql": "SELECT count(*) AS connections, state FROM pg_stat_activity GROUP BY state;"},
|
||||
{"id": "pg9", "label": "Explain scan 100k", "sql": "EXPLAIN ANALYZE SELECT count(*) FROM generate_series(1, 100000);"},
|
||||
{"id": "pg9", "label": "Sales orders sample", "sql": "SELECT order_id, customer_id, region, total_amount FROM sales_orders ORDER BY order_id DESC LIMIT 10;"},
|
||||
{"id": "pg10", "label": "Memory settings", "sql": "SELECT name, setting, unit FROM pg_settings WHERE name IN ('max_connections','shared_buffers','work_mem','effective_cache_size');"},
|
||||
],
|
||||
"mysql": [
|
||||
@@ -72,6 +87,30 @@ SAMPLES: dict[str, list[dict[str, str]]] = {
|
||||
{"id": "mg9", "label": "Indexes on events", "sql": "INDEXES supplychain.events"},
|
||||
{"id": "mg10", "label": "Recent events", "sql": "FIND supplychain.events SORT timestamp DESC LIMIT 5"},
|
||||
],
|
||||
"cassandra": [
|
||||
{"id": "cs1", "label": "Cluster release", "sql": "SELECT release_version FROM system.local"},
|
||||
{"id": "cs2", "label": "Keyspaces", "sql": "SELECT keyspace_name FROM system_schema.keyspaces"},
|
||||
{"id": "cs3", "label": "Telemetry tables", "sql": "SELECT table_name FROM system_schema.tables WHERE keyspace_name='telemetry'"},
|
||||
{"id": "cs4", "label": "Device metrics sample", "sql": "SELECT device_id, metric_name, metric_value, recorded_at FROM telemetry.device_metrics LIMIT 10"},
|
||||
{"id": "cs5", "label": "Metrics by device", "sql": "SELECT device_id, count(*) AS cnt FROM telemetry.device_metrics GROUP BY device_id LIMIT 10"},
|
||||
{"id": "cs6", "label": "Distinct metric names", "sql": "SELECT DISTINCT metric_name FROM telemetry.device_metrics LIMIT 20"},
|
||||
{"id": "cs7", "label": "Table columns", "sql": "SELECT column_name, type FROM system_schema.columns WHERE keyspace_name='telemetry' AND table_name='device_metrics'"},
|
||||
{"id": "cs8", "label": "Recent metrics", "sql": "SELECT device_id, metric_name, metric_value FROM telemetry.device_metrics LIMIT 15"},
|
||||
{"id": "cs9", "label": "Partition token sample", "sql": "SELECT token(device_id) AS tok, device_id FROM telemetry.device_metrics LIMIT 5"},
|
||||
{"id": "cs10", "label": "Cluster peers", "sql": "SELECT peer, data_center, rack FROM system.peers"},
|
||||
],
|
||||
"neo4j": [
|
||||
{"id": "nj1", "label": "Node labels", "sql": "CALL db.labels() YIELD label RETURN label"},
|
||||
{"id": "nj2", "label": "Relationship types", "sql": "CALL db.relationshipTypes() YIELD relationshipType RETURN relationshipType"},
|
||||
{"id": "nj3", "label": "Total nodes", "sql": "MATCH (n) RETURN count(n) AS node_count"},
|
||||
{"id": "nj4", "label": "Total relationships", "sql": "MATCH ()-[r]->() RETURN count(r) AS rel_count"},
|
||||
{"id": "nj5", "label": "Products sample", "sql": "MATCH (p:Product) RETURN p.product_id AS id, p.name AS name, p.category AS category LIMIT 10"},
|
||||
{"id": "nj6", "label": "Suppliers sample", "sql": "MATCH (s:Supplier) RETURN s.supplier_id AS id, s.name AS name, s.country AS country LIMIT 10"},
|
||||
{"id": "nj7", "label": "Supply chain links", "sql": "MATCH (s:Supplier)-[r:SUPPLIES]->(p:Product) RETURN s.name AS supplier, p.name AS product LIMIT 10"},
|
||||
{"id": "nj8", "label": "Products by category", "sql": "MATCH (p:Product) RETURN p.category AS category, count(*) AS cnt ORDER BY cnt DESC LIMIT 10"},
|
||||
{"id": "nj9", "label": "Schema visualization", "sql": "CALL db.schema.visualization()"},
|
||||
{"id": "nj10", "label": "Constraint info", "sql": "SHOW CONSTRAINTS"},
|
||||
],
|
||||
"trino": [
|
||||
{"id": "tq1", "label": "Trino version", "sql": "SELECT version()"},
|
||||
{"id": "tq2", "label": "Catalogs", "sql": "SHOW CATALOGS"},
|
||||
@@ -104,6 +143,12 @@ def _tabular(columns: list[str], rows: list[list[Any]], elapsed_ms: int, **extra
|
||||
}
|
||||
|
||||
|
||||
def _fmt(val: Any) -> Any:
|
||||
if val is None or isinstance(val, (str, int, float, bool)):
|
||||
return val
|
||||
return str(val)[:200]
|
||||
|
||||
|
||||
def _run_postgres(sql: str, limit: int = 200) -> dict[str, Any]:
|
||||
t0 = time.perf_counter()
|
||||
conn = psycopg2.connect(
|
||||
@@ -116,7 +161,7 @@ def _run_postgres(sql: str, limit: int = 200) -> dict[str, Any]:
|
||||
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||
if cur.description:
|
||||
columns = [d[0] for d in cur.description]
|
||||
rows = [list(r) for r in cur.fetchmany(limit)]
|
||||
rows = [[_fmt(r) for r in row] for row in cur.fetchmany(limit)]
|
||||
return _tabular(columns, rows, elapsed_ms, truncated=len(rows) >= limit)
|
||||
return _tabular([], [], elapsed_ms, message="OK")
|
||||
finally:
|
||||
@@ -135,7 +180,7 @@ def _run_mysql(sql: str, limit: int = 200) -> dict[str, Any]:
|
||||
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||
if cur.description:
|
||||
columns = [d[0] for d in cur.description]
|
||||
rows = [list(r) for r in cur.fetchmany(limit)]
|
||||
rows = [[_fmt(r) for r in row] for row in cur.fetchmany(limit)]
|
||||
return _tabular(columns, rows, elapsed_ms, truncated=len(rows) >= limit)
|
||||
return _tabular([], [], elapsed_ms, message="OK")
|
||||
finally:
|
||||
@@ -146,14 +191,6 @@ def _mongo_client() -> MongoClient:
|
||||
return MongoClient(f"mongodb://{MONGO_HOST}:{MONGO_PORT}/", serverSelectionTimeoutMS=8000)
|
||||
|
||||
|
||||
def _fmt_mongo_value(val: Any) -> Any:
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, (str, int, float, bool)):
|
||||
return val
|
||||
return str(val)[:200]
|
||||
|
||||
|
||||
def _run_mongo(query: str, limit: int = 200) -> dict[str, Any]:
|
||||
t0 = time.perf_counter()
|
||||
q = query.strip()
|
||||
@@ -185,12 +222,12 @@ def _run_mongo(query: str, limit: int = 200) -> dict[str, Any]:
|
||||
rows = [["(empty)"]]
|
||||
else:
|
||||
columns = sorted({k for d in docs for k in d if k != "_id"})
|
||||
rows = [[_fmt_mongo_value(d.get(c)) for c in columns] for d in docs]
|
||||
rows = [[_fmt(d.get(c)) for c in columns] for d in docs]
|
||||
elif m := re.match(r"^DISTINCT\s+(\w+)\.(\w+)\s+(\w+)$", q, re.I):
|
||||
db_name, coll, field = m.group(1), m.group(2), m.group(3)
|
||||
columns = [field]
|
||||
vals = client[db_name][coll].distinct(field)[:limit]
|
||||
rows = [[_fmt_mongo_value(v)] for v in vals]
|
||||
rows = [[_fmt(v)] for v in vals]
|
||||
elif m := re.match(r"^AGGREGATE\s+(\w+)\.(\w+)\s+GROUP\s+(\w+)\s+TOP\s+(\d+)$", q, re.I):
|
||||
db_name, coll, field, top_n = m.group(1), m.group(2), m.group(3), int(m.group(4))
|
||||
pipe = [
|
||||
@@ -222,6 +259,47 @@ def _run_mongo(query: str, limit: int = 200) -> dict[str, Any]:
|
||||
client.close()
|
||||
|
||||
|
||||
def _cass_cluster() -> Cluster:
|
||||
auth = PlainTextAuthProvider(CASS_USER, CASS_PASS) if CASS_USER else None
|
||||
return Cluster([DB_HOST], port=CASS_PORT, auth_provider=auth, connect_timeout=8)
|
||||
|
||||
|
||||
def _run_cassandra(cql: str, limit: int = 200) -> dict[str, Any]:
|
||||
t0 = time.perf_counter()
|
||||
cluster = _cass_cluster()
|
||||
session = cluster.connect()
|
||||
try:
|
||||
rows_raw = list(session.execute(cql, timeout=30))
|
||||
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||
if not rows_raw:
|
||||
return _tabular([], [], elapsed_ms, message="OK")
|
||||
columns = list(rows_raw[0]._fields)
|
||||
rows = [[_fmt(getattr(r, c)) for c in columns] for r in rows_raw[:limit]]
|
||||
return _tabular(columns, rows, elapsed_ms, truncated=len(rows_raw) > limit)
|
||||
finally:
|
||||
cluster.shutdown()
|
||||
|
||||
|
||||
def _neo4j_driver():
|
||||
return GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASS))
|
||||
|
||||
|
||||
def _run_neo4j(cypher: str, limit: int = 200) -> dict[str, Any]:
|
||||
t0 = time.perf_counter()
|
||||
driver = _neo4j_driver()
|
||||
try:
|
||||
with driver.session() as session:
|
||||
result = session.run(cypher)
|
||||
keys = result.keys()
|
||||
columns = list(keys)
|
||||
rows_raw = [record.values() for record in result]
|
||||
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||
rows = [[_fmt(v) for v in row] for row in rows_raw[:limit]]
|
||||
return _tabular(columns, rows, elapsed_ms, truncated=len(rows_raw) > limit)
|
||||
finally:
|
||||
driver.close()
|
||||
|
||||
|
||||
def _run_trino(sql: str, limit: int = 200) -> dict[str, Any]:
|
||||
t0 = time.perf_counter()
|
||||
headers = {"X-Trino-User": TRINO_USER, "Content-Type": "text/plain"}
|
||||
@@ -253,14 +331,210 @@ def _run_trino(sql: str, limit: int = 200) -> dict[str, Any]:
|
||||
return _tabular(columns, rows, elapsed_ms, engine_stats_ms=stats.get("elapsedTimeMillis"), truncated=len(rows) >= limit)
|
||||
|
||||
|
||||
ENGINES = ("postgres", "mysql", "mongodb", "trino")
|
||||
|
||||
|
||||
class SqlRequest(BaseModel):
|
||||
engine: str = Field(..., pattern="^(postgres|mysql|mongodb|trino)$")
|
||||
engine: str = Field(..., pattern="^(postgres|mysql|mongodb|trino|cassandra|neo4j)$")
|
||||
sql: str = Field(..., min_length=1, max_length=8000)
|
||||
|
||||
|
||||
def _connection_info(engine: str) -> dict[str, str]:
|
||||
if engine == "postgres":
|
||||
return {"host": DB_HOST, "port": str(PG_PORT), "database": PG_DB, "user": PG_USER}
|
||||
if engine == "mysql":
|
||||
return {"host": DB_HOST, "port": str(MYSQL_PORT), "database": MYSQL_DB, "user": MYSQL_USER}
|
||||
if engine == "mongodb":
|
||||
return {"host": MONGO_HOST, "port": str(MONGO_PORT), "database": MONGO_DB}
|
||||
if engine == "cassandra":
|
||||
return {"host": DB_HOST, "port": str(CASS_PORT), "keyspace": CASS_KS, "user": CASS_USER}
|
||||
if engine == "neo4j":
|
||||
return {"uri": NEO4J_URI, "user": NEO4J_USER}
|
||||
return {"url": TRINO_URL, "user": TRINO_USER}
|
||||
|
||||
|
||||
def _dispatch(engine: str, sql: str, limit: int = 200) -> dict[str, Any]:
|
||||
if engine == "postgres":
|
||||
return _run_postgres(sql, limit)
|
||||
if engine == "mysql":
|
||||
return _run_mysql(sql, limit)
|
||||
if engine == "mongodb":
|
||||
return _run_mongo(sql, limit)
|
||||
if engine == "cassandra":
|
||||
return _run_cassandra(sql, limit)
|
||||
if engine == "neo4j":
|
||||
return _run_neo4j(sql, limit)
|
||||
return _run_trino(sql, limit)
|
||||
|
||||
|
||||
def _catalog_postgres() -> dict[str, Any]:
|
||||
conn = psycopg2.connect(host=DB_HOST, port=PG_PORT, user=PG_USER, password=PG_PASS, dbname=PG_DB, connect_timeout=8)
|
||||
try:
|
||||
conn.set_session(readonly=True, autocommit=True)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT schemaname, relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC NULLS LAST LIMIT 50"
|
||||
)
|
||||
objects = []
|
||||
for schema, table, rows in cur.fetchall():
|
||||
objects.append({"type": "table", "schema": schema, "name": table, "fqn": f"{schema}.{table}", "row_count": int(rows or 0)})
|
||||
cur.execute("SELECT version()")
|
||||
version = cur.fetchone()[0]
|
||||
return {"engine": "postgres", "version": version, "objects": objects}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _catalog_mysql() -> dict[str, Any]:
|
||||
conn = pymysql.connect(host=DB_HOST, port=MYSQL_PORT, user=MYSQL_USER, password=MYSQL_PASS, database=MYSQL_DB, connect_timeout=8)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT table_name, table_rows, ROUND((data_length+index_length)/1024/1024,1) AS mb "
|
||||
"FROM information_schema.tables WHERE table_schema=%s ORDER BY table_rows DESC",
|
||||
(MYSQL_DB,),
|
||||
)
|
||||
objects = [{"type": "table", "schema": MYSQL_DB, "name": r[0], "fqn": f"{MYSQL_DB}.{r[0]}", "row_count": int(r[1] or 0), "size_mb": float(r[2] or 0)} for r in cur.fetchall()]
|
||||
cur.execute("SELECT VERSION()")
|
||||
version = cur.fetchone()[0]
|
||||
return {"engine": "mysql", "version": version, "objects": objects}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _catalog_mongodb() -> dict[str, Any]:
|
||||
client = _mongo_client()
|
||||
try:
|
||||
objects = []
|
||||
for db_name in client.list_database_names():
|
||||
if db_name in ("admin", "config", "local"):
|
||||
continue
|
||||
for coll in client[db_name].list_collection_names():
|
||||
try:
|
||||
cnt = client[db_name][coll].estimated_document_count()
|
||||
except Exception:
|
||||
cnt = None
|
||||
objects.append({"type": "collection", "schema": db_name, "name": coll, "fqn": f"{db_name}.{coll}", "row_count": cnt})
|
||||
build = client.admin.command("buildInfo")
|
||||
return {"engine": "mongodb", "version": build.get("version"), "objects": objects}
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def _catalog_cassandra() -> dict[str, Any]:
|
||||
cluster = _cass_cluster()
|
||||
session = cluster.connect()
|
||||
try:
|
||||
objects = []
|
||||
kss = [
|
||||
r.keyspace_name for r in session.execute("SELECT keyspace_name FROM system_schema.keyspaces")
|
||||
if not r.keyspace_name.startswith("system")
|
||||
]
|
||||
for ks in kss:
|
||||
tables = session.execute(
|
||||
"SELECT table_name FROM system_schema.tables WHERE keyspace_name=%s", (ks,)
|
||||
)
|
||||
for t in tables:
|
||||
objects.append({"type": "table", "schema": ks, "name": t.table_name, "fqn": f"{ks}.{t.table_name}", "row_count": None})
|
||||
ver = list(session.execute("SELECT release_version FROM system.local"))[0].release_version
|
||||
return {"engine": "cassandra", "version": ver, "objects": objects}
|
||||
finally:
|
||||
cluster.shutdown()
|
||||
|
||||
|
||||
def _catalog_neo4j() -> dict[str, Any]:
|
||||
driver = _neo4j_driver()
|
||||
try:
|
||||
with driver.session() as session:
|
||||
labels = [r["label"] for r in session.run("CALL db.labels() YIELD label RETURN label")]
|
||||
rels = [r["relationshipType"] for r in session.run("CALL db.relationshipTypes() YIELD relationshipType RETURN relationshipType")]
|
||||
objects = []
|
||||
for label in labels:
|
||||
cnt = session.run(f"MATCH (n:`{label}`) RETURN count(n) AS c").single()["c"]
|
||||
objects.append({"type": "node_label", "schema": "graph", "name": label, "fqn": label, "row_count": cnt})
|
||||
for rel in rels:
|
||||
cnt = session.run(f"MATCH ()-[r:`{rel}`]->() RETURN count(r) AS c").single()["c"]
|
||||
objects.append({"type": "relationship", "schema": "graph", "name": rel, "fqn": rel, "row_count": cnt})
|
||||
total = session.run("MATCH (n) RETURN count(n) AS c").single()["c"]
|
||||
return {"engine": "neo4j", "version": "4.4", "total_nodes": total, "objects": objects}
|
||||
finally:
|
||||
driver.close()
|
||||
|
||||
|
||||
def _catalog(engine: str) -> dict[str, Any]:
|
||||
if engine == "postgres":
|
||||
return _catalog_postgres()
|
||||
if engine == "mysql":
|
||||
return _catalog_mysql()
|
||||
if engine == "mongodb":
|
||||
return _catalog_mongodb()
|
||||
if engine == "cassandra":
|
||||
return _catalog_cassandra()
|
||||
if engine == "neo4j":
|
||||
return _catalog_neo4j()
|
||||
raise ValueError(f"Catalog not supported for {engine}")
|
||||
|
||||
|
||||
def _sample_postgres(object_name: str, limit: int) -> dict[str, Any]:
|
||||
if "." in object_name:
|
||||
schema, table = object_name.split(".", 1)
|
||||
sql = f'SELECT * FROM "{schema}"."{table}" LIMIT {limit}'
|
||||
else:
|
||||
sql = f'SELECT * FROM public."{object_name}" LIMIT {limit}'
|
||||
return _run_postgres(sql, limit)
|
||||
|
||||
|
||||
def _sample_mysql(object_name: str, limit: int) -> dict[str, Any]:
|
||||
table = object_name.split(".")[-1]
|
||||
return _run_mysql(f"SELECT * FROM `{table}` LIMIT {limit}", limit)
|
||||
|
||||
|
||||
def _sample_mongodb(object_name: str, limit: int) -> dict[str, Any]:
|
||||
if "." in object_name:
|
||||
db_name, coll = object_name.split(".", 1)
|
||||
else:
|
||||
db_name, coll = MONGO_DB, object_name
|
||||
return _run_mongo(f"FIND {db_name}.{coll} LIMIT {limit}", limit)
|
||||
|
||||
|
||||
def _sample_cassandra(object_name: str, limit: int) -> dict[str, Any]:
|
||||
if "." in object_name:
|
||||
ks, table = object_name.split(".", 1)
|
||||
else:
|
||||
ks, table = CASS_KS, object_name
|
||||
return _run_cassandra(f"SELECT * FROM {ks}.{table} LIMIT {limit}", limit)
|
||||
|
||||
|
||||
def _sample_neo4j(object_name: str, limit: int) -> dict[str, Any]:
|
||||
label = object_name.split(".")[-1]
|
||||
cypher = f"MATCH (n:`{label}`) RETURN n LIMIT {limit}"
|
||||
t0 = time.perf_counter()
|
||||
driver = _neo4j_driver()
|
||||
try:
|
||||
with driver.session() as session:
|
||||
result = session.run(cypher)
|
||||
docs = [dict(record["n"]) for record in result]
|
||||
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||
if not docs:
|
||||
return _tabular(["result"], [["(empty)"]], elapsed_ms)
|
||||
columns = sorted({k for d in docs for k in d})
|
||||
rows = [[_fmt(d.get(c)) for c in columns] for d in docs]
|
||||
return _tabular(columns, rows, elapsed_ms, object=object_name)
|
||||
finally:
|
||||
driver.close()
|
||||
|
||||
|
||||
def _sample(engine: str, object_name: str, limit: int) -> dict[str, Any]:
|
||||
if engine == "postgres":
|
||||
return _sample_postgres(object_name, limit)
|
||||
if engine == "mysql":
|
||||
return _sample_mysql(object_name, limit)
|
||||
if engine == "mongodb":
|
||||
return _sample_mongodb(object_name, limit)
|
||||
if engine == "cassandra":
|
||||
return _sample_cassandra(object_name, limit)
|
||||
if engine == "neo4j":
|
||||
return _sample_neo4j(object_name, limit)
|
||||
raise ValueError(f"Sample not supported for {engine}")
|
||||
|
||||
|
||||
@router.get("/samples/{engine}")
|
||||
async def get_samples(engine: str):
|
||||
if engine not in SAMPLES:
|
||||
@@ -271,7 +545,7 @@ async def get_samples(engine: str):
|
||||
@router.get("/health")
|
||||
async def sql_health():
|
||||
out: dict[str, Any] = {}
|
||||
for eng in ENGINES:
|
||||
for eng in SOURCE_ENGINES:
|
||||
try:
|
||||
if eng == "postgres":
|
||||
_run_postgres("SELECT 1")
|
||||
@@ -282,32 +556,38 @@ async def sql_health():
|
||||
elif eng == "mongodb":
|
||||
_run_mongo("SHOW DATABASES")
|
||||
out[eng] = {"ok": True, "host": MONGO_HOST, "database": MONGO_DB, "error": None}
|
||||
elif eng == "cassandra":
|
||||
_run_cassandra("SELECT release_version FROM system.local LIMIT 1")
|
||||
out[eng] = {"ok": True, "host": DB_HOST, "keyspace": CASS_KS, "error": None}
|
||||
else:
|
||||
r = _run_trino("SELECT 1")
|
||||
out[eng] = {"ok": bool(r.get("ok")), "url": TRINO_URL, "user": TRINO_USER, "error": r.get("error")}
|
||||
_run_neo4j("RETURN 1 AS n")
|
||||
out[eng] = {"ok": True, "uri": NEO4J_URI, "user": NEO4J_USER, "error": None}
|
||||
except Exception as exc:
|
||||
out[eng] = {"ok": False, "error": str(exc)[:200]}
|
||||
return out
|
||||
|
||||
|
||||
def _connection_info(engine: str) -> dict[str, str]:
|
||||
if engine == "postgres":
|
||||
return {"host": DB_HOST, "port": str(PG_PORT), "database": PG_DB, "user": PG_USER}
|
||||
if engine == "mysql":
|
||||
return {"host": DB_HOST, "port": str(MYSQL_PORT), "database": MYSQL_DB, "user": MYSQL_USER}
|
||||
if engine == "mongodb":
|
||||
return {"host": MONGO_HOST, "port": str(MONGO_PORT), "database": MONGO_DB}
|
||||
return {"url": TRINO_URL, "user": TRINO_USER}
|
||||
@router.get("/catalog/{engine}")
|
||||
async def get_catalog(engine: str):
|
||||
if engine not in SOURCE_ENGINES:
|
||||
return JSONResponse({"error": "unknown engine"}, status_code=404)
|
||||
try:
|
||||
return _catalog(engine)
|
||||
except Exception as exc:
|
||||
return JSONResponse({"error": str(exc)[:500]}, status_code=502)
|
||||
|
||||
|
||||
def _dispatch(engine: str, sql: str) -> dict[str, Any]:
|
||||
if engine == "postgres":
|
||||
return _run_postgres(sql)
|
||||
if engine == "mysql":
|
||||
return _run_mysql(sql)
|
||||
if engine == "mongodb":
|
||||
return _run_mongo(sql)
|
||||
return _run_trino(sql)
|
||||
@router.get("/sample/{engine}")
|
||||
async def get_sample(engine: str, object: str = Query(..., min_length=1), limit: int = Query(50, ge=1, le=200)):
|
||||
if engine not in SOURCE_ENGINES:
|
||||
return JSONResponse({"error": "unknown engine"}, status_code=404)
|
||||
try:
|
||||
result = _sample(engine, object, limit)
|
||||
if not result.get("ok"):
|
||||
return JSONResponse(result, status_code=422)
|
||||
return {**result, "engine": engine, "object": object}
|
||||
except Exception as exc:
|
||||
return JSONResponse({"ok": False, "error": str(exc)[:500]}, status_code=502)
|
||||
|
||||
|
||||
@router.post("/execute")
|
||||
|
||||
Reference in New Issue
Block a user