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
queryview/__init__.py ADDED
File without changes
queryview/conftest.py ADDED
@@ -0,0 +1,86 @@
1
+ """Shared fixtures for backend (non-e2e) tests.
2
+
3
+ Redirects the SQLite store and encryption-key file to a per-session tempdir so
4
+ tests don't touch the real `backend/queryview.db`, and resets the lazy
5
+ module-level engine/schema state in `queryview.connect` before tests run."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import subprocess
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+
16
+ @pytest.fixture
17
+ def git_env(tmp_path, monkeypatch):
18
+ """A local bare repo as the default workspace's git-sync remote + a fresh
19
+ per-workspace clone base dir. Resets the default workspace to 'no remote'
20
+ on teardown so unconfigured-state tests stay valid."""
21
+ import asyncio
22
+
23
+ from queryview.workspaces import DEFAULT_WORKSPACE, update_workspace
24
+
25
+ remote = tmp_path / "remote.git"
26
+ subprocess.run(
27
+ ["git", "init", "--bare", "-b", "main", str(remote)],
28
+ check=True,
29
+ capture_output=True,
30
+ )
31
+ monkeypatch.setenv("GIT_SYNC_DIR", str(tmp_path / "clones"))
32
+ asyncio.run(update_workspace(DEFAULT_WORKSPACE, remote=str(remote)))
33
+ yield remote
34
+ asyncio.run(update_workspace(DEFAULT_WORKSPACE, remote=None))
35
+
36
+
37
+ @pytest.fixture
38
+ def wipe_workspace_entities():
39
+ """A callable that deletes every entity a workspace owns — teardown for
40
+ import tests that must leave a non-default workspace empty so
41
+ delete_workspace accepts it."""
42
+ import asyncio
43
+
44
+ from sqlalchemy import text
45
+
46
+ def _wipe(workspace_id: int) -> None:
47
+ import queryview.connect as c
48
+
49
+ async def go():
50
+ async with c._engine_for_db().begin() as conn:
51
+ await conn.execute(text("DELETE FROM predefined_queries WHERE workspace_id = :w"), {"w": workspace_id})
52
+ await conn.execute(text("DELETE FROM dashboards WHERE workspace_id = :w"), {"w": workspace_id})
53
+
54
+ asyncio.run(go())
55
+
56
+ return _wipe
57
+
58
+
59
+ @pytest.fixture
60
+ def default_ws_id() -> int:
61
+ """The seeded default workspace's id, for store-level calls in tests."""
62
+ import asyncio
63
+
64
+ from queryview.workspaces import DEFAULT_WORKSPACE, resolve
65
+
66
+ return asyncio.run(resolve(DEFAULT_WORKSPACE)).id
67
+
68
+
69
+ @pytest.fixture(scope="session", autouse=True)
70
+ def _isolated_db(tmp_path_factory: pytest.TempPathFactory):
71
+ tmp: Path = tmp_path_factory.mktemp("qv_backend_tests")
72
+ os.environ["DB_PATH"] = str(tmp / "test.db")
73
+ os.environ["DB_KEY_PATH"] = str(tmp / "test.db.key")
74
+
75
+ # The workspaces migration seeds the default workspace from GIT_SYNC_*;
76
+ # tests control that per-test (monkeypatch), never from ambient env.
77
+ os.environ.pop("GIT_SYNC_REMOTE", None)
78
+ os.environ.pop("GIT_SYNC_BRANCH", None)
79
+
80
+ # Reset the lazy globals so the next DB touch picks up the new paths.
81
+ import queryview.connect as _c
82
+
83
+ _c._engine = None # type: ignore[attr-defined]
84
+ _c._schema_ready = False # type: ignore[attr-defined]
85
+ _c._key = None # type: ignore[attr-defined]
86
+ yield
queryview/connect.py ADDED
@@ -0,0 +1,428 @@
1
+ """Connection domain: the SQLModel/SQLite connection store (configs encrypted
2
+ at rest) and per-session active connections. No HTTP concerns here — operations
3
+ return plain results that main.py maps to responses. Per-backend execution is
4
+ delegated to the driver registry; nothing here is ClickHouse-specific."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import json
10
+ import os
11
+ import time
12
+ from collections import OrderedDict
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any, ClassVar
16
+
17
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
18
+ from sqlalchemy.ext.asyncio import create_async_engine
19
+ from sqlmodel import Field, SQLModel, col, select
20
+ from sqlmodel.ext.asyncio.session import AsyncSession
21
+
22
+ if TYPE_CHECKING:
23
+ from alembic.config import Config
24
+
25
+ from .drivers import DRIVERS
26
+ from .drivers.base import DriverConfig, select_all_sql
27
+
28
+ # --- Storage (SQLite, lazily opened) --------------------------------------
29
+
30
+
31
+ class Connection(SQLModel, table=True):
32
+ __tablename__: ClassVar[str] = "connections"
33
+
34
+ id: int | None = Field(default=None, primary_key=True)
35
+ name: str = Field(unique=True, index=True)
36
+ type: str = Field(default="clickhouse", index=True)
37
+ config: str # base64(AES-GCM(json.dumps(driver config))) — never plaintext
38
+ database: str | None = Field(default=None)
39
+ last_active_at: int # unix ms; the max is the "latest active"
40
+
41
+
42
+ def _db_path() -> Path:
43
+ env = os.environ.get("DB_PATH")
44
+ if env:
45
+ return Path(env)
46
+ return Path(__file__).resolve().parent.parent / "queryview.db"
47
+
48
+
49
+ _engine = None
50
+ _schema_ready = False
51
+
52
+
53
+ def _engine_for_db():
54
+ """The async SQLAlchemy engine (aiosqlite), memoized and lazy — no file is
55
+ touched until the first query, so importing this module is side-effect-free."""
56
+ global _engine
57
+ if _engine is None:
58
+ _engine = create_async_engine(f"sqlite+aiosqlite:///{_db_path()}")
59
+ return _engine
60
+
61
+
62
+ def _alembic_config() -> Config:
63
+ """Alembic Config built in code (not a cwd alembic.ini) so migrations run from
64
+ any directory and from the packaged wheel. Points at the package's migrations
65
+ dir and injects a *sync* SQLite URL for the current DB_PATH."""
66
+ from alembic.config import Config
67
+
68
+ cfg = Config()
69
+ cfg.set_main_option("script_location", str(Path(__file__).resolve().parent / "migrations"))
70
+ cfg.set_main_option("sqlalchemy.url", f"sqlite:///{_db_path()}")
71
+ return cfg
72
+
73
+
74
+ async def _ensure_schema() -> None:
75
+ """Migrate the DB to head on first use (idempotent). Single-process by design
76
+ (SQLite is single-writer), so no cross-process lock is needed. Runs the sync
77
+ Alembic upgrade inline — blocking is intended for this startup step."""
78
+ global _schema_ready
79
+ if _schema_ready:
80
+ return
81
+ from alembic import command
82
+
83
+ command.upgrade(_alembic_config(), "head")
84
+ _schema_ready = True
85
+
86
+
87
+ # --- Config encryption at rest (AES-256-GCM) ------------------------------
88
+ # Key from DB_ENCRYPTION_KEY (base64, 32 bytes) or a generated local key file
89
+ # next to the DB (gitignored). Stored value is base64(iv ‖ ciphertext); AES-GCM
90
+ # appends its 16-byte tag to the ciphertext.
91
+
92
+
93
+ def _key_path() -> Path:
94
+ env = os.environ.get("DB_KEY_PATH")
95
+ return Path(env) if env else Path(f"{_db_path()}.key")
96
+
97
+
98
+ _key: bytes | None = None
99
+
100
+
101
+ def _load_or_create_key() -> bytes:
102
+ env_key = os.environ.get("DB_ENCRYPTION_KEY")
103
+ if env_key:
104
+ return base64.b64decode(env_key)
105
+ path = _key_path()
106
+ try:
107
+ return path.read_bytes()
108
+ except FileNotFoundError:
109
+ raw = os.urandom(32)
110
+ path.write_bytes(raw)
111
+ os.chmod(path, 0o600)
112
+ return raw
113
+
114
+
115
+ def _key_bytes() -> bytes:
116
+ global _key
117
+ if _key is None:
118
+ _key = _load_or_create_key()
119
+ return _key
120
+
121
+
122
+ def _encrypt_str(plain: str) -> str:
123
+ iv = os.urandom(12)
124
+ ct = AESGCM(_key_bytes()).encrypt(iv, plain.encode("utf-8"), None)
125
+ return base64.b64encode(iv + ct).decode("ascii")
126
+
127
+
128
+ def _decrypt_str(stored: str) -> str:
129
+ combined = base64.b64decode(stored)
130
+ iv, ct = combined[:12], combined[12:]
131
+ return AESGCM(_key_bytes()).decrypt(iv, ct, None).decode("utf-8")
132
+
133
+
134
+ @dataclass
135
+ class StoredConnection:
136
+ name: str
137
+ type: str
138
+ config: DriverConfig
139
+ database: str | None
140
+
141
+
142
+ async def _save_active_connection(name: str, config: DriverConfig, conn_type: str) -> None:
143
+ blob = _encrypt_str(json.dumps(DRIVERS[conn_type].config_to_dict(config)))
144
+ now = _now_ms()
145
+ await _ensure_schema()
146
+ async with AsyncSession(_engine_for_db()) as s:
147
+ row = (await s.exec(select(Connection).where(Connection.name == name))).first()
148
+ if row is None:
149
+ row = Connection(name=name, type=conn_type, config=blob, last_active_at=now)
150
+ else:
151
+ # Upsert by name; the selected database is intentionally left as-is.
152
+ row.type = conn_type
153
+ row.config = blob
154
+ row.last_active_at = now
155
+ s.add(row)
156
+ await s.commit()
157
+
158
+
159
+ async def _save_selected_database(name: str, database: str) -> None:
160
+ await _ensure_schema()
161
+ async with AsyncSession(_engine_for_db()) as s:
162
+ row = (await s.exec(select(Connection).where(Connection.name == name))).first()
163
+ if row is not None:
164
+ row.database = database
165
+ s.add(row)
166
+ await s.commit()
167
+
168
+
169
+ def _row_to_stored(row: Connection | None) -> StoredConnection | None:
170
+ if row is None:
171
+ return None
172
+ try:
173
+ data = json.loads(_decrypt_str(row.config))
174
+ config = DRIVERS[row.type].config_from_dict(data)
175
+ except Exception:
176
+ # Unreadable (key changed / legacy) or unknown type — treat as unavailable.
177
+ return None
178
+ return StoredConnection(
179
+ name=row.name,
180
+ type=row.type,
181
+ config=config,
182
+ database=row.database,
183
+ )
184
+
185
+
186
+ async def _latest_active_connection() -> StoredConnection | None:
187
+ await _ensure_schema()
188
+ async with AsyncSession(_engine_for_db()) as s:
189
+ row = (await s.exec(select(Connection).order_by(col(Connection.last_active_at).desc()).limit(1))).first()
190
+ return _row_to_stored(row)
191
+
192
+
193
+ async def list_connection_names() -> list[str]:
194
+ """All saved connection names, most-recently-active first (for `connect`
195
+ autocomplete)."""
196
+ await _ensure_schema()
197
+ async with AsyncSession(_engine_for_db()) as s:
198
+ rows = await s.exec(select(Connection.name).order_by(col(Connection.last_active_at).desc()))
199
+ return list(rows.all())
200
+
201
+
202
+ async def _connection_by_name(name: str) -> StoredConnection | None:
203
+ await _ensure_schema()
204
+ async with AsyncSession(_engine_for_db()) as s:
205
+ row = (await s.exec(select(Connection).where(Connection.name == name))).first()
206
+ return _row_to_stored(row)
207
+
208
+
209
+ async def _touch_connection(name: str) -> None:
210
+ await _ensure_schema()
211
+ async with AsyncSession(_engine_for_db()) as s:
212
+ row = (await s.exec(select(Connection).where(Connection.name == name))).first()
213
+ if row is not None:
214
+ row.last_active_at = _now_ms()
215
+ s.add(row)
216
+ await s.commit()
217
+
218
+
219
+ def _now_ms() -> int:
220
+ return int(time.time() * 1000)
221
+
222
+
223
+ # --- Sessions (one active connection per session, keyed by a cookie) ------
224
+
225
+
226
+ @dataclass
227
+ class _SessionState:
228
+ name: str
229
+ type: str
230
+ config: DriverConfig
231
+ databases: list[str]
232
+ database: str | None
233
+
234
+
235
+ # LRU-capped so the map can't grow unbounded (every fresh cookie adds one). An
236
+ # evicted session transparently rebuilds on its next request via _ensure_session;
237
+ # OrderedDict + move_to_end tracks recency.
238
+ _sessions: OrderedDict[str, _SessionState] = OrderedDict()
239
+ MAX_SESSIONS = int(os.environ.get("MAX_SESSIONS", "1000"))
240
+
241
+
242
+ # Cookies that explicitly disconnected. Kept distinct from "never seen" so
243
+ # _ensure_session can suppress auto-reconnect for them; bounded like _sessions.
244
+ _disconnected: OrderedDict[str, None] = OrderedDict()
245
+
246
+
247
+ def _mark_disconnected(sid: str) -> None:
248
+ _disconnected[sid] = None
249
+ _disconnected.move_to_end(sid)
250
+ while len(_disconnected) > MAX_SESSIONS:
251
+ _disconnected.popitem(last=False)
252
+
253
+
254
+ def _get_session_entry(sid: str) -> _SessionState | None:
255
+ s = _sessions.get(sid)
256
+ if s is not None:
257
+ _sessions.move_to_end(sid)
258
+ return s
259
+
260
+
261
+ def _set_session_entry(sid: str, state: _SessionState) -> None:
262
+ _sessions[sid] = state
263
+ _sessions.move_to_end(sid)
264
+ while len(_sessions) > MAX_SESSIONS:
265
+ _sessions.popitem(last=False)
266
+
267
+
268
+ async def _build_session(
269
+ name: str, config: DriverConfig, database: str | None, conn_type: str = "clickhouse"
270
+ ) -> tuple[_SessionState | None, str | None]:
271
+ """List a connection's databases and build a session object."""
272
+ ok, result = await DRIVERS[conn_type].list_databases(config)
273
+ if not ok:
274
+ return None, result # type: ignore[return-value]
275
+ databases: list[str] = result # type: ignore[assignment]
276
+ return (
277
+ _SessionState(
278
+ name=name,
279
+ type=conn_type,
280
+ config=config,
281
+ databases=databases,
282
+ database=database if database and database in databases else None,
283
+ ),
284
+ None,
285
+ )
286
+
287
+
288
+ async def _ensure_session(sid: str) -> None:
289
+ """At session start (a cookie we haven't seen), reconnect the latest active
290
+ connection so a fresh session resumes where the last one left off."""
291
+ if _get_session_entry(sid):
292
+ return
293
+ if sid in _disconnected:
294
+ return
295
+ stored = await _latest_active_connection()
296
+ if stored is None:
297
+ return
298
+ state, _ = await _build_session(stored.name, stored.config, stored.database, stored.type)
299
+ if state is not None:
300
+ _set_session_entry(sid, state)
301
+
302
+
303
+ async def get_session(sid: str) -> dict[str, Any]:
304
+ """This session's state; auto-connects the latest active for an unseen cookie."""
305
+ await _ensure_session(sid)
306
+ s = _get_session_entry(sid)
307
+ if s is None:
308
+ return {"connected": False}
309
+ return {
310
+ "connected": True,
311
+ "name": s.name,
312
+ "type": s.type,
313
+ "databases": s.databases,
314
+ "database": s.database,
315
+ }
316
+
317
+
318
+ async def connect_new(sid: str, name: str, config: DriverConfig, conn_type: str) -> dict[str, Any]:
319
+ """Create: open a config, save + activate it for this session."""
320
+ state, message = await _build_session(name, config, None, conn_type)
321
+ if state is None:
322
+ return {"ok": False, "message": message}
323
+ _disconnected.pop(sid, None)
324
+ _set_session_entry(sid, state)
325
+ await _save_active_connection(name, config, conn_type)
326
+ return {"ok": True, "name": name, "type": state.type, "databases": state.databases}
327
+
328
+
329
+ async def open_saved(sid: str, name: str) -> dict[str, Any]:
330
+ """Open a saved connection by name for this session."""
331
+ stored = await _connection_by_name(name)
332
+ if stored is None:
333
+ return {
334
+ "ok": False,
335
+ "message": f'no connection named "{name}"',
336
+ "not_found": True,
337
+ }
338
+ # Reset the database so `connect <name>` always lands on the picker.
339
+ state, message = await _build_session(stored.name, stored.config, None, stored.type)
340
+ if state is None:
341
+ return {"ok": False, "message": message}
342
+ _disconnected.pop(sid, None)
343
+ _set_session_entry(sid, state)
344
+ await _touch_connection(name)
345
+ return {"ok": True, "name": name, "type": state.type, "databases": state.databases}
346
+
347
+
348
+ async def disconnect(sid: str) -> dict[str, Any]:
349
+ """Drop this session's active connection and suppress auto-reconnect until
350
+ it connects again. Saved connections are left intact — `connect <name>`
351
+ still reopens them."""
352
+ _sessions.pop(sid, None)
353
+ _mark_disconnected(sid)
354
+ return {"ok": True}
355
+
356
+
357
+ async def select_database(sid: str, database: str) -> dict[str, Any]:
358
+ """Select this session's active connection's database."""
359
+ s = _get_session_entry(sid)
360
+ if s is None:
361
+ return {"ok": False, "message": "not connected", "reason": "no-session"}
362
+ if not database or database not in s.databases:
363
+ return {"ok": False, "message": "unknown database", "reason": "unknown"}
364
+ s.database = database
365
+ await _save_selected_database(s.name, database)
366
+ return {"ok": True}
367
+
368
+
369
+ async def _gated_session(sid: str) -> tuple[_SessionState | None, dict[str, Any] | None]:
370
+ """This session's state, or the error dict shared by every query-shaped
371
+ operation (query/describe/tables): not connected, or — for drivers with a
372
+ picker — no database selected yet."""
373
+ await _ensure_session(sid)
374
+ s = _get_session_entry(sid)
375
+ if s is None:
376
+ return None, {"ok": False, "message": "not connected", "reason": "no-session"}
377
+ if DRIVERS[s.type].requires_database and not s.database:
378
+ return None, {
379
+ "ok": False,
380
+ "message": "select a database first",
381
+ "reason": "no-database",
382
+ }
383
+ return s, None
384
+
385
+
386
+ async def describe_query(sid: str, sql: str) -> dict[str, Any]:
387
+ """Describe a query's output columns against this session's selected database."""
388
+ s, err = await _gated_session(sid)
389
+ if s is None:
390
+ return err # type: ignore[return-value]
391
+ ok, result = await DRIVERS[s.type].describe_query(s.config, sql, s.database)
392
+ if not ok:
393
+ return {"ok": False, "message": result}
394
+ return {"ok": True, "fields": result}
395
+
396
+
397
+ async def list_tables(sid: str) -> dict[str, Any]:
398
+ """List the tables of this session's selected database (the Explorer page's
399
+ sidebar), each with the ready-to-run browse SELECT — built here with the
400
+ driver's identifier quote, so the frontend never guesses dialect quoting."""
401
+ s, err = await _gated_session(sid)
402
+ if s is None:
403
+ return err # type: ignore[return-value]
404
+ driver = DRIVERS[s.type]
405
+ _ok, result = await driver.list_tables(s.config, s.database)
406
+ if isinstance(result, str):
407
+ return {"ok": False, "message": result}
408
+ tables = [{**t, "query": select_all_sql(t["name"], driver.ident_quote)} for t in result]
409
+ return {"ok": True, "tables": tables}
410
+
411
+
412
+ async def run_query(
413
+ sid: str,
414
+ sql: str,
415
+ limit: int,
416
+ offset: int,
417
+ fmt: str,
418
+ order_by: list[dict[str, Any]] | None = None,
419
+ ) -> dict[str, Any]:
420
+ """Run a paginated SQL query against this session's selected database. The
421
+ driver owns pagination/quoting; `fmt` is the logical 'tsv'/'csv'."""
422
+ s, err = await _gated_session(sid)
423
+ if s is None:
424
+ return err # type: ignore[return-value]
425
+ r = await DRIVERS[s.type].run_query(s.config, sql, s.database, limit, offset, order_by, fmt)
426
+ if not r.ok:
427
+ return {"ok": False, "message": r.value}
428
+ return {"ok": True, "output": r.value}
@@ -0,0 +1,75 @@
1
+ """Run a dashboard's named SQL against a connection by name, decoupled from any
2
+ session/cookie. Reads the saved connection (and its stored database) via
3
+ connect.py, queries via the driver registry; dashboard persistence lives in
4
+ dashboards.py."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from .connect import _connection_by_name
11
+ from .drivers import DRIVERS
12
+
13
+ # Row cap per dashboard query (matches /api/clickhouse/query's ceiling), applied
14
+ # as the LIMIT of the subselect wrapping each query.
15
+ DASHBOARD_ROW_CAP = 1000
16
+
17
+
18
+ def _parse_tsv_columns(text: str) -> dict[str, list[str]]:
19
+ """Parse TabSeparatedWithNames into a column-oriented, insertion-ordered dict
20
+ `{column_name: [values, …]}` (first line = names, rest = rows). Empty -> {}."""
21
+ if text == "":
22
+ return {}
23
+ lines = text.split("\n")
24
+ names = lines[0].split("\t")
25
+ cols: dict[str, list[str]] = {name: [] for name in names}
26
+ for line in lines[1:]:
27
+ values = line.split("\t")
28
+ for i, name in enumerate(names):
29
+ cols[name].append(values[i] if i < len(values) else "")
30
+ return cols
31
+
32
+
33
+ async def run_queries_for_connection(
34
+ name: str,
35
+ queries: dict[str, str],
36
+ limit: int = DASHBOARD_ROW_CAP,
37
+ offset: int = 0,
38
+ ) -> dict[str, Any]:
39
+ """Run a dashboard's named queries against a saved connection by name.
40
+ Fail-fast: an unknown connection, no selected database, or the first failing
41
+ query aborts the call. On full success returns {"ok": True, "results": {name:
42
+ {col: [values, …]}}} — column-oriented, ready for window.queries. `limit`/
43
+ `offset` page each query (default: the dashboard row cap, from row 0)."""
44
+ stored = await _connection_by_name(name)
45
+ if stored is None:
46
+ return {
47
+ "ok": False,
48
+ "reason": "no-connection",
49
+ "message": f'no connection named "{name}"',
50
+ }
51
+ driver = DRIVERS[stored.type]
52
+ if driver.requires_database and not stored.database:
53
+ return {
54
+ "ok": False,
55
+ "reason": "no-database",
56
+ "message": (
57
+ f'connection "{name}" has no selected database — select one for it '
58
+ "or fully-qualify table names as db.table"
59
+ ),
60
+ }
61
+ results: dict[str, dict[str, list[str]]] = {}
62
+ for qname, sql in queries.items():
63
+ r = await driver.run_query(
64
+ stored.config,
65
+ sql,
66
+ stored.database,
67
+ limit=limit,
68
+ offset=offset,
69
+ order_by=None,
70
+ fmt="tsv",
71
+ )
72
+ if not r.ok:
73
+ return {"ok": False, "reason": "query", "message": f"{qname}: {r.value}"}
74
+ results[qname] = _parse_tsv_columns(r.value)
75
+ return {"ok": True, "results": results}