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/__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}
|