queryview 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- queryview/__init__.py +0 -0
- queryview/conftest.py +86 -0
- queryview/connect.py +428 -0
- queryview/dashboard_queries.py +75 -0
- queryview/dashboards.py +156 -0
- queryview/drivers/__init__.py +10 -0
- queryview/drivers/base.py +165 -0
- queryview/drivers/clickhouse.py +138 -0
- queryview/drivers/duckdb.py +153 -0
- queryview/drivers/postgres.py +166 -0
- queryview/drivers/test_base.py +49 -0
- queryview/drivers/test_clickhouse.py +53 -0
- queryview/drivers/test_contract.py +77 -0
- queryview/drivers/test_duckdb.py +80 -0
- queryview/drivers/test_postgres.py +80 -0
- queryview/gitsync.py +374 -0
- queryview/main.py +740 -0
- queryview/mcp_server.py +294 -0
- queryview/migrations/env.py +39 -0
- queryview/migrations/script.py.mako +29 -0
- queryview/migrations/versions/9a536b7c0328_initial_schema.py +89 -0
- queryview/migrations/versions/a1b2c3d4e5f6_connection_config_blob.py +59 -0
- queryview/migrations/versions/b2c3d4e5f6a7_predefined_presentation.py +32 -0
- queryview/migrations/versions/c7d8e9f0a1b2_workspaces.py +98 -0
- queryview/queries.py +159 -0
- queryview/remote.py +141 -0
- queryview/static/assets/index-CvnC_D68.js +47 -0
- queryview/static/assets/index-Qe7bhycG.css +2 -0
- queryview/static/favicon.svg +1 -0
- queryview/static/index.html +14 -0
- queryview/test_api_db.py +51 -0
- queryview/test_api_export_import.py +85 -0
- queryview/test_api_gitsync.py +83 -0
- queryview/test_api_workspaces.py +44 -0
- queryview/test_connect_flow.py +123 -0
- queryview/test_connect_store.py +34 -0
- queryview/test_dashboards.py +216 -0
- queryview/test_gitsync.py +346 -0
- queryview/test_main.py +18 -0
- queryview/test_mcp_gitsync.py +72 -0
- queryview/test_migrations.py +99 -0
- queryview/test_queries.py +170 -0
- queryview/test_remote.py +260 -0
- queryview/test_validation.py +87 -0
- queryview/test_workspaces.py +109 -0
- queryview/test_yamlio.py +198 -0
- queryview/validation.py +111 -0
- queryview/workspaces.py +167 -0
- queryview/yamlio.py +245 -0
- queryview-0.0.2.dist-info/METADATA +183 -0
- queryview-0.0.2.dist-info/RECORD +54 -0
- queryview-0.0.2.dist-info/WHEEL +4 -0
- queryview-0.0.2.dist-info/entry_points.txt +3 -0
- queryview-0.0.2.dist-info/licenses/LICENSE +21 -0
queryview/dashboards.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Dashboard store: dashboards (HTML layout + named SQL queries) keyed by name
|
|
2
|
+
within a workspace. Reuses connect.py's SQLite engine, mirroring queries.py.
|
|
3
|
+
Also hosts the shared upsert-and-push helper that both the REST endpoint and
|
|
4
|
+
the MCP tool call."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any, ClassVar
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import UniqueConstraint
|
|
12
|
+
from sqlmodel import Field, SQLModel, select
|
|
13
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
14
|
+
|
|
15
|
+
from . import remote
|
|
16
|
+
from .connect import _engine_for_db, _ensure_schema, _now_ms
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Dashboard(SQLModel, table=True):
|
|
20
|
+
__tablename__: ClassVar[str] = "dashboards"
|
|
21
|
+
__table_args__ = (UniqueConstraint("workspace_id", "name", name="uq_dashboards_ws_name"),)
|
|
22
|
+
|
|
23
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
24
|
+
name: str = Field(index=True) # unique per workspace, not globally
|
|
25
|
+
workspace_id: int # owning workspace (workspaces.id)
|
|
26
|
+
connection: str # connection name the queries run against
|
|
27
|
+
html: str # agent-authored HTML document
|
|
28
|
+
queries: str # JSON text: {query_name: SQL}
|
|
29
|
+
updated_at: int # unix ms
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def upsert_dashboards(items: list[dict[str, Any]], *, workspace_id: int) -> None:
|
|
33
|
+
"""Upsert many dashboards — each a dict with `name`, `connection`, `html`
|
|
34
|
+
and a `queries` dict — in one transaction, keyed by (workspace, name).
|
|
35
|
+
`queries` is serialized to JSON text."""
|
|
36
|
+
await _ensure_schema()
|
|
37
|
+
async with AsyncSession(_engine_for_db()) as s:
|
|
38
|
+
for d in items:
|
|
39
|
+
row = (
|
|
40
|
+
await s.exec(
|
|
41
|
+
select(Dashboard).where(Dashboard.name == d["name"], Dashboard.workspace_id == workspace_id)
|
|
42
|
+
)
|
|
43
|
+
).first()
|
|
44
|
+
if row is None:
|
|
45
|
+
row = Dashboard(
|
|
46
|
+
name=d["name"], workspace_id=workspace_id, connection="", html="", queries="", updated_at=0
|
|
47
|
+
)
|
|
48
|
+
row.connection = d["connection"]
|
|
49
|
+
row.html = d["html"]
|
|
50
|
+
row.queries = json.dumps(d["queries"])
|
|
51
|
+
row.updated_at = _now_ms()
|
|
52
|
+
s.add(row)
|
|
53
|
+
await s.commit()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def upsert_dashboard(
|
|
57
|
+
name: str, connection: str, html: str, queries: dict[str, str], *, workspace_id: int
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Upsert a dashboard by (workspace, name); `queries` is serialized to JSON text."""
|
|
60
|
+
await upsert_dashboards(
|
|
61
|
+
[{"name": name, "connection": connection, "html": html, "queries": queries}], workspace_id=workspace_id
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _payload(row: Dashboard) -> dict[str, Any]:
|
|
66
|
+
"""A row's full payload with `queries` parsed back to a dict (leniently —
|
|
67
|
+
unparsable stored text degrades to an empty map)."""
|
|
68
|
+
try:
|
|
69
|
+
queries = json.loads(row.queries)
|
|
70
|
+
except (ValueError, TypeError):
|
|
71
|
+
queries = {}
|
|
72
|
+
return {
|
|
73
|
+
"name": row.name,
|
|
74
|
+
"connection": row.connection,
|
|
75
|
+
"html": row.html,
|
|
76
|
+
"queries": queries,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def get_dashboard(name: str, workspace_id: int) -> dict[str, Any] | None:
|
|
81
|
+
"""A single dashboard with its `queries` parsed back to a dict, or None."""
|
|
82
|
+
await _ensure_schema()
|
|
83
|
+
async with AsyncSession(_engine_for_db()) as s:
|
|
84
|
+
row = (
|
|
85
|
+
await s.exec(select(Dashboard).where(Dashboard.name == name, Dashboard.workspace_id == workspace_id))
|
|
86
|
+
).first()
|
|
87
|
+
return _payload(row) if row is not None else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def list_dashboards(workspace_id: int) -> list[dict[str, Any]]:
|
|
91
|
+
"""One workspace's dashboards ordered by name, without the html/queries payload."""
|
|
92
|
+
await _ensure_schema()
|
|
93
|
+
async with AsyncSession(_engine_for_db()) as s:
|
|
94
|
+
rows = (
|
|
95
|
+
await s.exec(select(Dashboard).where(Dashboard.workspace_id == workspace_id).order_by(Dashboard.name))
|
|
96
|
+
).all()
|
|
97
|
+
return [{"name": r.name, "connection": r.connection, "updated_at": r.updated_at} for r in rows]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def list_dashboards_full(workspace_id: int) -> list[dict[str, Any]]:
|
|
101
|
+
"""One workspace's dashboards ordered by name, with the full payload in
|
|
102
|
+
get_dashboard's shape (`queries` parsed to a dict). Used by the
|
|
103
|
+
whole-workspace YAML export."""
|
|
104
|
+
await _ensure_schema()
|
|
105
|
+
async with AsyncSession(_engine_for_db()) as s:
|
|
106
|
+
rows = (
|
|
107
|
+
await s.exec(select(Dashboard).where(Dashboard.workspace_id == workspace_id).order_by(Dashboard.name))
|
|
108
|
+
).all()
|
|
109
|
+
return [_payload(r) for r in rows]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _dashboard_event(name: str, connection: str, html: str, queries: dict[str, str]) -> dict[str, Any]:
|
|
113
|
+
"""The SSE payload the browser renders for a pushed dashboard."""
|
|
114
|
+
return {
|
|
115
|
+
"type": "dashboard",
|
|
116
|
+
"name": name,
|
|
117
|
+
"connection": connection,
|
|
118
|
+
"html": html,
|
|
119
|
+
"queries": queries,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def _push_dashboard(
|
|
124
|
+
name: str,
|
|
125
|
+
connection: str,
|
|
126
|
+
html: str,
|
|
127
|
+
queries: dict[str, str],
|
|
128
|
+
session_id: str | None,
|
|
129
|
+
) -> tuple[bool, str]:
|
|
130
|
+
"""Push a dashboard to a live session as a DRAFT — no persistence. Only the
|
|
131
|
+
user's Save (POST /api/dashboards) writes it to the store, mirroring how
|
|
132
|
+
push_query drafts a query for the user to Save. Returns (pushed, message);
|
|
133
|
+
no session_id -> (False, "no session")."""
|
|
134
|
+
if not session_id:
|
|
135
|
+
return False, "no session"
|
|
136
|
+
return remote.push(session_id, _dashboard_event(name, connection, html, queries))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def _upsert_and_push(
|
|
140
|
+
name: str,
|
|
141
|
+
connection: str,
|
|
142
|
+
html: str,
|
|
143
|
+
queries: dict[str, str],
|
|
144
|
+
session_id: str | None,
|
|
145
|
+
*,
|
|
146
|
+
workspace_id: int,
|
|
147
|
+
) -> tuple[bool, bool, str]:
|
|
148
|
+
"""Persist a dashboard, then (if `session_id` given) push it to that live
|
|
149
|
+
browser session. Returns (persisted, pushed, message). Push is best-effort:
|
|
150
|
+
an unknown/inactive session leaves it saved with pushed=False, per
|
|
151
|
+
remote.push's contract. Used by the REST endpoint (the user-Save path)."""
|
|
152
|
+
await upsert_dashboard(name, connection, html, queries, workspace_id=workspace_id)
|
|
153
|
+
if session_id:
|
|
154
|
+
ok, message = remote.push(session_id, _dashboard_event(name, connection, html, queries))
|
|
155
|
+
return True, ok, message
|
|
156
|
+
return True, False, "persisted"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Driver registry: maps a connection `type` to the Driver that executes it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import Driver
|
|
6
|
+
from .clickhouse import ClickHouseDriver
|
|
7
|
+
from .duckdb import DuckDBDriver
|
|
8
|
+
from .postgres import PostgresDriver
|
|
9
|
+
|
|
10
|
+
DRIVERS: dict[str, Driver] = {d.type: d for d in (ClickHouseDriver(), PostgresDriver(), DuckDBDriver())}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""The driver contract (Protocol) plus dialect helpers and the row serializer
|
|
2
|
+
shared by row-returning drivers. No backend/storage concerns here."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import csv
|
|
7
|
+
import io
|
|
8
|
+
from typing import Any, NamedTuple, Protocol, TypeAlias, runtime_checkable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class QueryResult(NamedTuple):
|
|
12
|
+
ok: bool
|
|
13
|
+
value: str # serialized rows when ok; an error message otherwise
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# A driver's own config object (ChConfig, PgConfig, DuckConfig, …). Opaque to
|
|
17
|
+
# everything outside the driver that produced it: the storage and session layers
|
|
18
|
+
# only ever round-trip it back through the same driver's methods, so they must
|
|
19
|
+
# NOT depend on any concrete type. Named (rather than a bare `Any`) so that
|
|
20
|
+
# opacity is intentional and documented at every use site. A union of the
|
|
21
|
+
# concrete configs would instead re-couple connect.py to every driver and defeat
|
|
22
|
+
# the registry indirection.
|
|
23
|
+
DriverConfig: TypeAlias = Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@runtime_checkable
|
|
27
|
+
class Driver(Protocol):
|
|
28
|
+
# Stable identifier, also the registry key, the persisted `connections.type`
|
|
29
|
+
# column, and the API `type` field — one word across the whole stack.
|
|
30
|
+
type: str
|
|
31
|
+
# Whether queries require a database to be selected first (a non-empty
|
|
32
|
+
# picker). False for file-based drivers like DuckDB that have no picker.
|
|
33
|
+
requires_database: bool
|
|
34
|
+
# The dialect's identifier-quote character, for SQL generated server-side
|
|
35
|
+
# (select_all_sql). Same convention as the build_order_by `quote` argument.
|
|
36
|
+
ident_quote: str
|
|
37
|
+
|
|
38
|
+
def parse_config(self, body: Any) -> tuple[DriverConfig | None, str | None]: ...
|
|
39
|
+
def config_to_dict(self, config: DriverConfig) -> dict[str, Any]: ...
|
|
40
|
+
def config_from_dict(self, data: dict[str, Any]) -> DriverConfig: ...
|
|
41
|
+
async def test(self, config: DriverConfig) -> dict[str, Any]: ...
|
|
42
|
+
async def list_databases(self, config: DriverConfig) -> tuple[bool, list[str] | str]: ...
|
|
43
|
+
# Each table is {"name": str, "rows": int|None, "bytes": int|None} — rows and
|
|
44
|
+
# bytes are cheap engine estimates (never a COUNT(*) scan), None when the
|
|
45
|
+
# engine doesn't track them (e.g. views, or DuckDB's missing per-table size).
|
|
46
|
+
async def list_tables(
|
|
47
|
+
self,
|
|
48
|
+
config: DriverConfig,
|
|
49
|
+
database: str | None,
|
|
50
|
+
) -> tuple[bool, list[dict[str, Any]] | str]: ...
|
|
51
|
+
async def run_query(
|
|
52
|
+
self,
|
|
53
|
+
config: DriverConfig,
|
|
54
|
+
sql: str,
|
|
55
|
+
database: str | None,
|
|
56
|
+
limit: int,
|
|
57
|
+
offset: int,
|
|
58
|
+
order_by: list[dict[str, Any]] | None,
|
|
59
|
+
fmt: str,
|
|
60
|
+
) -> QueryResult: ...
|
|
61
|
+
async def describe_query(
|
|
62
|
+
self,
|
|
63
|
+
config: DriverConfig,
|
|
64
|
+
sql: str,
|
|
65
|
+
database: str | None,
|
|
66
|
+
) -> tuple[bool, list[dict[str, str]] | str]: ...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_port(raw: Any) -> int | None:
|
|
70
|
+
"""Coerce a raw port value to an int in [1, 65535], or None. Rejects bool
|
|
71
|
+
(a bool is an int subclass) and out-of-range / non-numeric values."""
|
|
72
|
+
if isinstance(raw, bool) or not isinstance(raw, (int, str)):
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
port = int(raw)
|
|
76
|
+
except ValueError:
|
|
77
|
+
return None
|
|
78
|
+
return port if 1 <= port <= 65535 else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_host_port_config(body: Any) -> tuple[dict[str, Any] | None, str | None]:
|
|
82
|
+
"""Validate the host/port/username/password fields shared by network drivers
|
|
83
|
+
(ClickHouse, Postgres). Returns ({host,port,username,password}, None) or
|
|
84
|
+
(None, message)."""
|
|
85
|
+
b = body if isinstance(body, dict) else {}
|
|
86
|
+
raw_host = b.get("host")
|
|
87
|
+
host = raw_host.strip() if isinstance(raw_host, str) else ""
|
|
88
|
+
port = _parse_port(b.get("port"))
|
|
89
|
+
username = b.get("username") if isinstance(b.get("username"), str) else ""
|
|
90
|
+
password = b.get("password") if isinstance(b.get("password"), str) else ""
|
|
91
|
+
if not host:
|
|
92
|
+
return None, "host required"
|
|
93
|
+
if port is None:
|
|
94
|
+
return None, "valid port required"
|
|
95
|
+
return {"host": host, "port": port, "username": username, "password": password}, None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def select_all_sql(table: str, quote: str) -> str:
|
|
99
|
+
"""`SELECT * FROM <quoted table>` — the query the explorer browses a table
|
|
100
|
+
with. The name is `quote`-quoted with embedded quotes doubled, so an odd
|
|
101
|
+
table name can't escape the identifier."""
|
|
102
|
+
escaped = table.replace(quote, quote + quote)
|
|
103
|
+
return f"SELECT * FROM {quote}{escaped}{quote}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def build_order_by(order_by: list[dict[str, Any]] | None, quote: str) -> str:
|
|
107
|
+
"""`ORDER BY` clause from `[{"name","dir"}]`. Names are `quote`-quoted (any
|
|
108
|
+
embedded quote doubled) and directions whitelisted to ASC/DESC, so malformed
|
|
109
|
+
input can't inject SQL. Empty/absent input yields no clause."""
|
|
110
|
+
if not order_by:
|
|
111
|
+
return ""
|
|
112
|
+
parts: list[str] = []
|
|
113
|
+
for col in order_by:
|
|
114
|
+
if not isinstance(col, dict):
|
|
115
|
+
continue
|
|
116
|
+
name = col.get("name")
|
|
117
|
+
if not isinstance(name, str) or not name:
|
|
118
|
+
continue
|
|
119
|
+
raw_dir = col.get("dir")
|
|
120
|
+
direction = raw_dir.upper() if isinstance(raw_dir, str) else ""
|
|
121
|
+
if direction not in ("ASC", "DESC"):
|
|
122
|
+
direction = "ASC"
|
|
123
|
+
escaped = name.replace(quote, quote + quote)
|
|
124
|
+
parts.append(f"{quote}{escaped}{quote} {direction}")
|
|
125
|
+
if not parts:
|
|
126
|
+
return ""
|
|
127
|
+
return "ORDER BY " + ", ".join(parts)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def wrap_paginated(
|
|
131
|
+
sql: str,
|
|
132
|
+
order_clause: str,
|
|
133
|
+
limit: int,
|
|
134
|
+
offset: int,
|
|
135
|
+
alias: str | None = None,
|
|
136
|
+
) -> str:
|
|
137
|
+
"""Wrap a SELECT in a paginating subselect. `alias` (e.g. `_qv`) is required
|
|
138
|
+
by Postgres/DuckDB for a derived table; ClickHouse passes alias=None to keep
|
|
139
|
+
its historical SQL byte-for-byte identical."""
|
|
140
|
+
inner = sql.rstrip().rstrip(";")
|
|
141
|
+
head = f"SELECT * FROM (\n{inner}\n)"
|
|
142
|
+
if alias:
|
|
143
|
+
head += f" AS {alias}"
|
|
144
|
+
clauses = [head]
|
|
145
|
+
if order_clause:
|
|
146
|
+
clauses.append(order_clause)
|
|
147
|
+
clauses.append(f"LIMIT {int(limit)} OFFSET {int(offset)}")
|
|
148
|
+
return " ".join(clauses)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def serialize_rows(columns: list[str], rows: list[Any], fmt: str) -> str:
|
|
152
|
+
"""Serialize rows to the text contract ClickHouse emits: TabSeparatedWithNames
|
|
153
|
+
(fmt='tsv') or CSVWithNames (fmt='csv'). None -> empty field. Non-strings are
|
|
154
|
+
str()-ified. No trailing newline (matches ClickHouse's stripped output)."""
|
|
155
|
+
if fmt == "csv":
|
|
156
|
+
buf = io.StringIO()
|
|
157
|
+
writer = csv.writer(buf, lineterminator="\n")
|
|
158
|
+
writer.writerow(columns)
|
|
159
|
+
for row in rows:
|
|
160
|
+
writer.writerow(["" if v is None else str(v) for v in row])
|
|
161
|
+
return buf.getvalue().rstrip("\n")
|
|
162
|
+
lines = ["\t".join(columns)]
|
|
163
|
+
for row in rows:
|
|
164
|
+
lines.append("\t".join("" if v is None else str(v) for v in row))
|
|
165
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""ClickHouse driver: the HTTP-interface client and a Driver implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from typing import Any, NamedTuple
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .base import QueryResult, build_order_by, parse_host_port_config, wrap_paginated
|
|
11
|
+
|
|
12
|
+
CH_TIMEOUT_SECONDS = 5.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ChConfig:
|
|
17
|
+
host: str
|
|
18
|
+
port: int
|
|
19
|
+
username: str
|
|
20
|
+
password: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ChResult(NamedTuple):
|
|
24
|
+
ok: bool
|
|
25
|
+
value: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def ch_query(c: ChConfig, query: str, database: str | None = None, fmt: str | None = None) -> ChResult:
|
|
29
|
+
"""Run a query against the ClickHouse HTTP interface (Basic auth, 5s timeout).
|
|
30
|
+
`database` scopes the query; `fmt` appends a ClickHouse `FORMAT` clause."""
|
|
31
|
+
url = f"http://{c.host}:{c.port}/"
|
|
32
|
+
q = f"{query}\nFORMAT {fmt}" if fmt else query
|
|
33
|
+
params = {"query": q}
|
|
34
|
+
if database:
|
|
35
|
+
params["database"] = database
|
|
36
|
+
try:
|
|
37
|
+
async with httpx.AsyncClient(timeout=CH_TIMEOUT_SECONDS) as client:
|
|
38
|
+
res = await client.get(url, params=params, auth=(c.username, c.password))
|
|
39
|
+
except httpx.TimeoutException:
|
|
40
|
+
return ChResult(False, "connection timed out")
|
|
41
|
+
except httpx.HTTPError as err:
|
|
42
|
+
return ChResult(False, str(err) or "connection failed")
|
|
43
|
+
text = res.text.strip()
|
|
44
|
+
if not res.is_success:
|
|
45
|
+
return ChResult(False, f"ClickHouse responded {res.status_code}: {text[:200]}")
|
|
46
|
+
return ChResult(True, text)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_ch_config(body: Any) -> tuple[ChConfig | None, str | None]:
|
|
50
|
+
"""Validate a ClickHouse config from a request body. Returns (config, None) or
|
|
51
|
+
(None, message)."""
|
|
52
|
+
fields, err = parse_host_port_config(body)
|
|
53
|
+
if err or fields is None:
|
|
54
|
+
return None, err
|
|
55
|
+
return ChConfig(**fields), None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _tsv_rows(text: str, min_cols: int):
|
|
59
|
+
"""Rows of a TabSeparated result: blank lines and rows with fewer than
|
|
60
|
+
`min_cols` columns are skipped."""
|
|
61
|
+
for line in text.split("\n"):
|
|
62
|
+
if not line.strip():
|
|
63
|
+
continue
|
|
64
|
+
cols = line.split("\t")
|
|
65
|
+
if len(cols) >= min_cols:
|
|
66
|
+
yield cols
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ClickHouseDriver:
|
|
70
|
+
type: str = "clickhouse"
|
|
71
|
+
requires_database: bool = True
|
|
72
|
+
ident_quote: str = "`"
|
|
73
|
+
|
|
74
|
+
def parse_config(self, body: Any) -> tuple[ChConfig | None, str | None]:
|
|
75
|
+
return parse_ch_config(body)
|
|
76
|
+
|
|
77
|
+
def config_to_dict(self, config: ChConfig) -> dict[str, Any]:
|
|
78
|
+
return asdict(config)
|
|
79
|
+
|
|
80
|
+
def config_from_dict(self, data: dict[str, Any]) -> ChConfig:
|
|
81
|
+
return ChConfig(**data)
|
|
82
|
+
|
|
83
|
+
async def test(self, config: ChConfig) -> dict[str, Any]:
|
|
84
|
+
r = await ch_query(config, "SELECT 1")
|
|
85
|
+
if r.ok:
|
|
86
|
+
return {"ok": True, "message": f"Connected — SELECT 1 returned {r.value}"}
|
|
87
|
+
return {"ok": False, "message": r.value}
|
|
88
|
+
|
|
89
|
+
async def list_databases(self, config: ChConfig) -> tuple[bool, list[str] | str]:
|
|
90
|
+
r = await ch_query(config, "SHOW DATABASES")
|
|
91
|
+
if not r.ok:
|
|
92
|
+
return False, r.value
|
|
93
|
+
return True, [s.strip() for s in r.value.split("\n") if s.strip()]
|
|
94
|
+
|
|
95
|
+
async def list_tables(self, config: ChConfig, database: str | None) -> tuple[bool, list[dict[str, Any]] | str]:
|
|
96
|
+
# Same set SHOW TABLES yields, plus the engine's stored row/byte counts
|
|
97
|
+
# (NULL — serialized as \N — for views and engines that don't track them).
|
|
98
|
+
r = await ch_query(
|
|
99
|
+
config,
|
|
100
|
+
"SELECT name, total_rows, total_bytes FROM system.tables WHERE database = currentDatabase() ORDER BY name",
|
|
101
|
+
database=database,
|
|
102
|
+
fmt="TabSeparated",
|
|
103
|
+
)
|
|
104
|
+
if not r.ok:
|
|
105
|
+
return False, r.value
|
|
106
|
+
return True, [
|
|
107
|
+
{
|
|
108
|
+
"name": cols[0],
|
|
109
|
+
"rows": None if cols[1] == "\\N" else int(cols[1]),
|
|
110
|
+
"bytes": None if cols[2] == "\\N" else int(cols[2]),
|
|
111
|
+
}
|
|
112
|
+
for cols in _tsv_rows(r.value, 3)
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
async def run_query(
|
|
116
|
+
self,
|
|
117
|
+
config: ChConfig,
|
|
118
|
+
sql: str,
|
|
119
|
+
database: str | None,
|
|
120
|
+
limit: int,
|
|
121
|
+
offset: int,
|
|
122
|
+
order_by: list[dict[str, Any]] | None,
|
|
123
|
+
fmt: str,
|
|
124
|
+
) -> QueryResult:
|
|
125
|
+
order_clause = build_order_by(order_by, "`")
|
|
126
|
+
paginated = wrap_paginated(sql, order_clause, limit, offset, alias=None)
|
|
127
|
+
ch_fmt = "CSVWithNames" if fmt == "csv" else "TabSeparatedWithNames"
|
|
128
|
+
r = await ch_query(config, paginated, database=database, fmt=ch_fmt)
|
|
129
|
+
return QueryResult(r.ok, r.value)
|
|
130
|
+
|
|
131
|
+
async def describe_query(
|
|
132
|
+
self, config: ChConfig, sql: str, database: str | None
|
|
133
|
+
) -> tuple[bool, list[dict[str, str]] | str]:
|
|
134
|
+
inner = sql.rstrip().rstrip(";")
|
|
135
|
+
r = await ch_query(config, f"DESCRIBE (\n{inner}\n)", database=database, fmt="TabSeparated")
|
|
136
|
+
if not r.ok:
|
|
137
|
+
return False, r.value
|
|
138
|
+
return True, [{"name": cols[0], "type": cols[1]} for cols in _tsv_rows(r.value, 2)]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""DuckDB driver: file-based, no network, no picker. The synchronous duckdb
|
|
2
|
+
library is driven in a worker thread so the event loop is never blocked."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import duckdb
|
|
11
|
+
|
|
12
|
+
from .base import QueryResult, build_order_by, serialize_rows, wrap_paginated
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class DuckConfig:
|
|
17
|
+
path: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_duck_config(body: Any) -> tuple[DuckConfig | None, str | None]:
|
|
21
|
+
b = body if isinstance(body, dict) else {}
|
|
22
|
+
raw = b.get("path")
|
|
23
|
+
path = raw.strip() if isinstance(raw, str) else ""
|
|
24
|
+
return DuckConfig(path=path or ":memory:"), None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _open(path: str):
|
|
28
|
+
# read_only avoids lock contention between concurrent describe/query opens;
|
|
29
|
+
# :memory: cannot be read_only, so open it read-write.
|
|
30
|
+
return duckdb.connect(path, read_only=(path != ":memory:"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _scalar(con: duckdb.DuckDBPyConnection, sql: str) -> Any:
|
|
34
|
+
"""First column of the single-row result; raises if the query yields no row."""
|
|
35
|
+
row = con.execute(sql).fetchone()
|
|
36
|
+
if row is None:
|
|
37
|
+
raise RuntimeError(f"query returned no row: {sql}")
|
|
38
|
+
return row[0]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DuckDBDriver:
|
|
42
|
+
type: str = "duckdb"
|
|
43
|
+
requires_database: bool = False
|
|
44
|
+
ident_quote: str = '"'
|
|
45
|
+
|
|
46
|
+
def parse_config(self, body: Any) -> tuple[DuckConfig | None, str | None]:
|
|
47
|
+
return parse_duck_config(body)
|
|
48
|
+
|
|
49
|
+
def config_to_dict(self, config: DuckConfig) -> dict[str, Any]:
|
|
50
|
+
return asdict(config)
|
|
51
|
+
|
|
52
|
+
def config_from_dict(self, data: dict[str, Any]) -> DuckConfig:
|
|
53
|
+
return DuckConfig(**data)
|
|
54
|
+
|
|
55
|
+
async def test(self, config: DuckConfig) -> dict[str, Any]:
|
|
56
|
+
def _work():
|
|
57
|
+
con = _open(config.path)
|
|
58
|
+
try:
|
|
59
|
+
return _scalar(con, "SELECT 1")
|
|
60
|
+
finally:
|
|
61
|
+
con.close()
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
val = await asyncio.to_thread(_work)
|
|
65
|
+
return {"ok": True, "message": f"Connected — SELECT 1 returned {val}"}
|
|
66
|
+
except Exception as e: # noqa: BLE001
|
|
67
|
+
return {"ok": False, "message": str(e) or "connection failed"}
|
|
68
|
+
|
|
69
|
+
async def list_databases(self, config: DuckConfig) -> tuple[bool, list[str] | str]:
|
|
70
|
+
# No picker: queries run directly against the file (schema-qualify in SQL).
|
|
71
|
+
return True, []
|
|
72
|
+
|
|
73
|
+
async def list_tables(self, config: DuckConfig, database: str | None) -> tuple[bool, list[dict[str, Any]] | str]:
|
|
74
|
+
# SHOW TABLES for the name set, joined with duckdb_tables()'s estimated
|
|
75
|
+
# row count (absent for views). Bytes are distinct storage blocks ×
|
|
76
|
+
# block size from pragma_storage_info — metadata only, no data scan;
|
|
77
|
+
# None for views and whenever the pragma has nothing (e.g. :memory:).
|
|
78
|
+
def _work():
|
|
79
|
+
con = _open(config.path)
|
|
80
|
+
try:
|
|
81
|
+
names = [r[0] for r in con.execute("SHOW TABLES").fetchall()]
|
|
82
|
+
est = dict(con.execute("SELECT table_name, estimated_size FROM duckdb_tables()").fetchall())
|
|
83
|
+
block_size = _scalar(con, "SELECT block_size FROM pragma_database_size()")
|
|
84
|
+
|
|
85
|
+
def _bytes(name: str) -> int | None:
|
|
86
|
+
if name not in est: # a view — no storage of its own
|
|
87
|
+
return None
|
|
88
|
+
quoted = name.replace("'", "''")
|
|
89
|
+
try:
|
|
90
|
+
blocks = _scalar(
|
|
91
|
+
con,
|
|
92
|
+
f"SELECT count(DISTINCT block_id) FROM pragma_storage_info('{quoted}') WHERE block_id >= 0",
|
|
93
|
+
)
|
|
94
|
+
except Exception: # noqa: BLE001
|
|
95
|
+
return None
|
|
96
|
+
return blocks * block_size if blocks else None
|
|
97
|
+
|
|
98
|
+
return [{"name": n, "rows": est.get(n), "bytes": _bytes(n)} for n in names]
|
|
99
|
+
finally:
|
|
100
|
+
con.close()
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
return True, await asyncio.to_thread(_work)
|
|
104
|
+
except Exception as e: # noqa: BLE001
|
|
105
|
+
return False, str(e)
|
|
106
|
+
|
|
107
|
+
async def run_query(
|
|
108
|
+
self,
|
|
109
|
+
config: DuckConfig,
|
|
110
|
+
sql: str,
|
|
111
|
+
database: str | None,
|
|
112
|
+
limit: int,
|
|
113
|
+
offset: int,
|
|
114
|
+
order_by: list[dict[str, Any]] | None,
|
|
115
|
+
fmt: str,
|
|
116
|
+
) -> QueryResult:
|
|
117
|
+
order_clause = build_order_by(order_by, '"')
|
|
118
|
+
paginated = wrap_paginated(sql, order_clause, limit, offset, alias="_qv")
|
|
119
|
+
|
|
120
|
+
def _work():
|
|
121
|
+
con = _open(config.path)
|
|
122
|
+
try:
|
|
123
|
+
cur = con.execute(paginated)
|
|
124
|
+
columns = [d[0] for d in cur.description] if cur.description else []
|
|
125
|
+
rows = cur.fetchall()
|
|
126
|
+
return columns, rows
|
|
127
|
+
finally:
|
|
128
|
+
con.close()
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
columns, rows = await asyncio.to_thread(_work)
|
|
132
|
+
return QueryResult(True, serialize_rows(columns, rows, fmt))
|
|
133
|
+
except Exception as e: # noqa: BLE001
|
|
134
|
+
return QueryResult(False, str(e))
|
|
135
|
+
|
|
136
|
+
async def describe_query(
|
|
137
|
+
self, config: DuckConfig, sql: str, database: str | None
|
|
138
|
+
) -> tuple[bool, list[dict[str, str]] | str]:
|
|
139
|
+
inner = sql.rstrip().rstrip(";")
|
|
140
|
+
|
|
141
|
+
def _work():
|
|
142
|
+
con = _open(config.path)
|
|
143
|
+
try:
|
|
144
|
+
# DuckDB's DESCRIBE returns (column_name, column_type, ...).
|
|
145
|
+
return con.execute(f"DESCRIBE {inner}").fetchall()
|
|
146
|
+
finally:
|
|
147
|
+
con.close()
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
rows = await asyncio.to_thread(_work)
|
|
151
|
+
return True, [{"name": r[0], "type": r[1]} for r in rows]
|
|
152
|
+
except Exception as e: # noqa: BLE001
|
|
153
|
+
return False, str(e)
|