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.
Files changed (54) hide show
  1. queryview/__init__.py +0 -0
  2. queryview/conftest.py +86 -0
  3. queryview/connect.py +428 -0
  4. queryview/dashboard_queries.py +75 -0
  5. queryview/dashboards.py +156 -0
  6. queryview/drivers/__init__.py +10 -0
  7. queryview/drivers/base.py +165 -0
  8. queryview/drivers/clickhouse.py +138 -0
  9. queryview/drivers/duckdb.py +153 -0
  10. queryview/drivers/postgres.py +166 -0
  11. queryview/drivers/test_base.py +49 -0
  12. queryview/drivers/test_clickhouse.py +53 -0
  13. queryview/drivers/test_contract.py +77 -0
  14. queryview/drivers/test_duckdb.py +80 -0
  15. queryview/drivers/test_postgres.py +80 -0
  16. queryview/gitsync.py +374 -0
  17. queryview/main.py +740 -0
  18. queryview/mcp_server.py +294 -0
  19. queryview/migrations/env.py +39 -0
  20. queryview/migrations/script.py.mako +29 -0
  21. queryview/migrations/versions/9a536b7c0328_initial_schema.py +89 -0
  22. queryview/migrations/versions/a1b2c3d4e5f6_connection_config_blob.py +59 -0
  23. queryview/migrations/versions/b2c3d4e5f6a7_predefined_presentation.py +32 -0
  24. queryview/migrations/versions/c7d8e9f0a1b2_workspaces.py +98 -0
  25. queryview/queries.py +159 -0
  26. queryview/remote.py +141 -0
  27. queryview/static/assets/index-CvnC_D68.js +47 -0
  28. queryview/static/assets/index-Qe7bhycG.css +2 -0
  29. queryview/static/favicon.svg +1 -0
  30. queryview/static/index.html +14 -0
  31. queryview/test_api_db.py +51 -0
  32. queryview/test_api_export_import.py +85 -0
  33. queryview/test_api_gitsync.py +83 -0
  34. queryview/test_api_workspaces.py +44 -0
  35. queryview/test_connect_flow.py +123 -0
  36. queryview/test_connect_store.py +34 -0
  37. queryview/test_dashboards.py +216 -0
  38. queryview/test_gitsync.py +346 -0
  39. queryview/test_main.py +18 -0
  40. queryview/test_mcp_gitsync.py +72 -0
  41. queryview/test_migrations.py +99 -0
  42. queryview/test_queries.py +170 -0
  43. queryview/test_remote.py +260 -0
  44. queryview/test_validation.py +87 -0
  45. queryview/test_workspaces.py +109 -0
  46. queryview/test_yamlio.py +198 -0
  47. queryview/validation.py +111 -0
  48. queryview/workspaces.py +167 -0
  49. queryview/yamlio.py +245 -0
  50. queryview-0.0.2.dist-info/METADATA +183 -0
  51. queryview-0.0.2.dist-info/RECORD +54 -0
  52. queryview-0.0.2.dist-info/WHEEL +4 -0
  53. queryview-0.0.2.dist-info/entry_points.txt +3 -0
  54. queryview-0.0.2.dist-info/licenses/LICENSE +21 -0
@@ -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)