queryview 0.0.3__tar.gz → 0.0.4__tar.gz
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-0.0.3 → queryview-0.0.4}/PKG-INFO +1 -1
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/connect.py +21 -4
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/dashboard_queries.py +12 -31
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/drivers/base.py +86 -18
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/drivers/clickhouse.py +47 -8
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/drivers/duckdb.py +36 -12
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/drivers/postgres.py +39 -10
- queryview-0.0.4/backend/queryview/drivers/test_base.py +85 -0
- queryview-0.0.4/backend/queryview/drivers/test_clickhouse.py +97 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/drivers/test_duckdb.py +14 -7
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/drivers/test_postgres.py +8 -2
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/main.py +8 -3
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/mcp_server.py +4 -1
- queryview-0.0.4/backend/queryview/static/assets/index-B3W6A5bg.css +2 -0
- queryview-0.0.4/backend/queryview/static/assets/index-BE8_u4Np.js +44 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/static/index.html +2 -2
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_connect_flow.py +19 -6
- queryview-0.0.4/backend/queryview/test_dashboard_queries.py +53 -0
- {queryview-0.0.3 → queryview-0.0.4}/docs/api.md +2 -2
- {queryview-0.0.3 → queryview-0.0.4}/docs/connect.md +1 -1
- {queryview-0.0.3 → queryview-0.0.4}/docs/dashboard.md +3 -0
- {queryview-0.0.3 → queryview-0.0.4}/docs/query.md +25 -21
- {queryview-0.0.3 → queryview-0.0.4}/docs/remote.md +3 -1
- {queryview-0.0.3 → queryview-0.0.4}/e2e/test_drivers.py +5 -0
- queryview-0.0.4/frontend/README.md +20 -0
- queryview-0.0.4/frontend/eslint.config.js +41 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/App.tsx +4 -3
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/DashboardView.tsx +8 -31
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/ExplorerView.tsx +16 -12
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/QueryView.tsx +43 -161
- queryview-0.0.4/frontend/src/app/connection.ts +16 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app/controls}/ExportImportControls.tsx +2 -2
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app/controls}/GitSyncControls.tsx +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app/controls}/WorkspaceSwitcher.tsx +2 -2
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/core/cells}/ComplexCell.tsx +8 -7
- queryview-0.0.4/frontend/src/core/cells/cellViewYaml.test.ts +73 -0
- queryview-0.0.4/frontend/src/core/cells/cellViewYaml.tsx +80 -0
- queryview-0.0.4/frontend/src/core/cells/complexCells.test.ts +103 -0
- queryview-0.0.4/frontend/src/core/cells/complexCells.ts +69 -0
- queryview-0.0.4/frontend/src/core/dashboard/DashboardFrame.tsx +27 -0
- queryview-0.0.4/frontend/src/core/dashboard/srcDoc.test.ts +15 -0
- queryview-0.0.4/frontend/src/core/dashboard/srcDoc.ts +12 -0
- queryview-0.0.4/frontend/src/core/index.ts +38 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/core/results}/ResultsTable.tsx +7 -5
- queryview-0.0.4/frontend/src/core/results/rows.test.ts +29 -0
- queryview-0.0.4/frontend/src/core/results/rows.ts +32 -0
- {queryview-0.0.3 → queryview-0.0.4}/frontend/src/main.tsx +1 -1
- queryview-0.0.3/backend/queryview/drivers/test_base.py +0 -49
- queryview-0.0.3/backend/queryview/drivers/test_clickhouse.py +0 -53
- queryview-0.0.3/backend/queryview/static/assets/index-CvnC_D68.js +0 -47
- queryview-0.0.3/backend/queryview/static/assets/index-Qe7bhycG.css +0 -2
- queryview-0.0.3/frontend/README.md +0 -73
- queryview-0.0.3/frontend/eslint.config.js +0 -22
- queryview-0.0.3/frontend/src/complexCellParsing.test.ts +0 -221
- queryview-0.0.3/frontend/src/complexCellParsing.ts +0 -177
- queryview-0.0.3/frontend/src/tsv.ts +0 -8
- {queryview-0.0.3 → queryview-0.0.4}/.claude/settings.json +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/.github/actions/start-git-daemon/action.yaml +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/.github/workflows/ci.yml +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/.github/workflows/publish.yaml +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/.gitignore +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/.mcp.json +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/.pre-commit-config.yaml +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/CLAUDE.md +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/LICENSE +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/README.md +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/alembic.ini +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/__init__.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/conftest.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/dashboards.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/drivers/__init__.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/drivers/test_contract.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/gitsync.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/migrations/env.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/migrations/script.py.mako +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/migrations/versions/9a536b7c0328_initial_schema.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/migrations/versions/a1b2c3d4e5f6_connection_config_blob.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/migrations/versions/b2c3d4e5f6a7_predefined_presentation.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/migrations/versions/c7d8e9f0a1b2_workspaces.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/queries.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/remote.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/static/favicon.svg +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_api_db.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_api_export_import.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_api_gitsync.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_api_workspaces.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_connect_store.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_dashboards.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_db_path.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_gitsync.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_main.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_mcp_gitsync.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_mcp_mount.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_migrations.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_queries.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_remote.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_validation.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_workspaces.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_yamlio.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/validation.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/workspaces.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/yamlio.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/docker/Dockerfile +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/docs/explorer.md +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/docs/export-import.md +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/docs/future.md +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/docs/gitsync.md +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/docs/queryview.md +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/docs/workspace.md +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/e2e/conftest.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/e2e/test_app.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/e2e/test_dashboard.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/e2e/test_explorer.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/e2e/test_export_import.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/e2e/test_gitsync.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/e2e/test_query.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/e2e/test_remote.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/e2e/test_workspaces.py +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/frontend/.gitignore +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/frontend/index.html +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/frontend/package.json +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/frontend/public/favicon.svg +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/compactNumber.test.ts +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/compactNumber.ts +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app/controls}/Toast.tsx +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/drivers.ts +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/gitsync.test.ts +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/gitsync.ts +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/promptSuggestions.test.ts +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/promptSuggestions.ts +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/sessionLock.test.ts +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/sessionLock.ts +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/workspace.test.ts +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/workspace.ts +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/yamlio.test.ts +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/yamlio.ts +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/core/cells}/CellViewModal.tsx +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/core/cells}/cellView.test.ts +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/core/cells}/cellView.ts +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/core/params}/queryParams.test.ts +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/core/params}/queryParams.ts +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/core/presentation}/FieldPickers.tsx +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/core/presentation}/presentation.test.ts +0 -0
- {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/core/presentation}/presentation.ts +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/frontend/src/index.css +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/frontend/tsconfig.app.json +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/frontend/tsconfig.json +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/frontend/tsconfig.node.json +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/frontend/vite.config.ts +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/package-lock.json +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/package.json +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/pyproject.toml +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/pyrightconfig.json +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/scripts/dev.sh +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/scripts/setup.sh +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/scripts/setup_browser.sh +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/scripts/setup_clickhouse.sh +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/scripts/setup_postgres.sh +0 -0
- {queryview-0.0.3 → queryview-0.0.4}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: queryview
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
4
4
|
Summary: QueryView: FastAPI + SQLModel backend and React SPA over the ClickHouse HTTP interface
|
|
5
5
|
Project-URL: Homepage, https://github.com/kolodkin/queryview
|
|
6
6
|
Project-URL: Repository, https://github.com/kolodkin/queryview
|
|
@@ -422,15 +422,32 @@ async def run_query(
|
|
|
422
422
|
sql: str,
|
|
423
423
|
limit: int,
|
|
424
424
|
offset: int,
|
|
425
|
-
fmt: str,
|
|
426
425
|
order_by: list[dict[str, Any]] | None = None,
|
|
427
426
|
) -> dict[str, Any]:
|
|
428
|
-
"""Run a paginated SQL query against this session's selected database
|
|
429
|
-
|
|
427
|
+
"""Run a paginated SQL query against this session's selected database and
|
|
428
|
+
return `{ok, meta: [{name, type}], data: [[…]]}`. The driver owns
|
|
429
|
+
pagination/quoting."""
|
|
430
430
|
s, err = await _gated_session(sid)
|
|
431
431
|
if s is None:
|
|
432
432
|
return err # type: ignore[return-value]
|
|
433
|
-
r = await DRIVERS[s.type].run_query(s.config, sql, s.database, limit, offset, order_by
|
|
433
|
+
r = await DRIVERS[s.type].run_query(s.config, sql, s.database, limit, offset, order_by)
|
|
434
|
+
if not r.ok or r.rows is None:
|
|
435
|
+
return {"ok": False, "message": r.message}
|
|
436
|
+
return {"ok": True, "meta": [c._asdict() for c in r.rows.meta], "data": r.rows.data}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
async def export_csv(
|
|
440
|
+
sid: str,
|
|
441
|
+
sql: str,
|
|
442
|
+
limit: int,
|
|
443
|
+
offset: int,
|
|
444
|
+
order_by: list[dict[str, Any]] | None = None,
|
|
445
|
+
) -> dict[str, Any]:
|
|
446
|
+
"""The same page as `run_query`, as CSVWithNames text in `output`."""
|
|
447
|
+
s, err = await _gated_session(sid)
|
|
448
|
+
if s is None:
|
|
449
|
+
return err # type: ignore[return-value]
|
|
450
|
+
r = await DRIVERS[s.type].export_csv(s.config, sql, s.database, limit, offset, order_by)
|
|
434
451
|
if not r.ok:
|
|
435
452
|
return {"ok": False, "message": r.value}
|
|
436
453
|
return {"ok": True, "output": r.value}
|
|
@@ -9,27 +9,13 @@ from typing import Any
|
|
|
9
9
|
|
|
10
10
|
from .connect import _connection_by_name
|
|
11
11
|
from .drivers import DRIVERS
|
|
12
|
+
from .drivers.base import rows_to_columns
|
|
12
13
|
|
|
13
14
|
# Row cap per dashboard query (matches /api/clickhouse/query's ceiling), applied
|
|
14
15
|
# as the LIMIT of the subselect wrapping each query.
|
|
15
16
|
DASHBOARD_ROW_CAP = 1000
|
|
16
17
|
|
|
17
18
|
|
|
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
19
|
async def run_queries_for_connection(
|
|
34
20
|
name: str,
|
|
35
21
|
queries: dict[str, str],
|
|
@@ -39,8 +25,9 @@ async def run_queries_for_connection(
|
|
|
39
25
|
"""Run a dashboard's named queries against a saved connection by name.
|
|
40
26
|
Fail-fast: an unknown connection, no selected database, or the first failing
|
|
41
27
|
query aborts the call. On full success returns {"ok": True, "results": {name:
|
|
42
|
-
{col: [values, …]}}} — column-oriented,
|
|
43
|
-
|
|
28
|
+
{col: [values, …]}}, "meta": {name: [{name, type}, …]}} — column-oriented,
|
|
29
|
+
ready for window.queries, values typed as the driver returned them.
|
|
30
|
+
`limit`/`offset` page each query (default: the dashboard row cap, from row 0)."""
|
|
44
31
|
stored = await _connection_by_name(name)
|
|
45
32
|
if stored is None:
|
|
46
33
|
return {
|
|
@@ -58,18 +45,12 @@ async def run_queries_for_connection(
|
|
|
58
45
|
"or fully-qualify table names as db.table"
|
|
59
46
|
),
|
|
60
47
|
}
|
|
61
|
-
results: dict[str, dict[str, list[
|
|
48
|
+
results: dict[str, dict[str, list[Any]]] = {}
|
|
49
|
+
meta: dict[str, list[dict[str, str]]] = {}
|
|
62
50
|
for qname, sql in queries.items():
|
|
63
|
-
r = await driver.run_query(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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}
|
|
51
|
+
r = await driver.run_query(stored.config, sql, stored.database, limit=limit, offset=offset, order_by=None)
|
|
52
|
+
if not r.ok or r.rows is None:
|
|
53
|
+
return {"ok": False, "reason": "query", "message": f"{qname}: {r.message}"}
|
|
54
|
+
results[qname] = rows_to_columns(r.rows)
|
|
55
|
+
meta[qname] = [c._asdict() for c in r.rows.meta]
|
|
56
|
+
return {"ok": True, "results": results, "meta": meta}
|
|
@@ -1,16 +1,40 @@
|
|
|
1
|
-
"""The driver contract (Protocol) plus dialect helpers and the
|
|
2
|
-
|
|
1
|
+
"""The driver contract (Protocol) plus dialect helpers and the shared result
|
|
2
|
+
contract: typed, JSON-safe rows for the UI and CSV text for download. No
|
|
3
|
+
backend/storage concerns here."""
|
|
3
4
|
|
|
4
5
|
from __future__ import annotations
|
|
5
6
|
|
|
6
7
|
import csv
|
|
8
|
+
import datetime as dt
|
|
7
9
|
import io
|
|
10
|
+
import math
|
|
11
|
+
import uuid
|
|
12
|
+
from decimal import Decimal
|
|
8
13
|
from typing import Any, NamedTuple, Protocol, TypeAlias, runtime_checkable
|
|
9
14
|
|
|
10
15
|
|
|
16
|
+
class Column(NamedTuple):
|
|
17
|
+
name: str
|
|
18
|
+
type: str # the dialect's own type name (ClickHouse `UInt64`, Postgres `text`, …)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class QueryRows(NamedTuple):
|
|
22
|
+
"""A query result in ClickHouse's JSONCompact shape: column metadata plus
|
|
23
|
+
row-major values that are JSON-safe as-is (see `to_json_value`)."""
|
|
24
|
+
|
|
25
|
+
meta: list[Column]
|
|
26
|
+
data: list[list[Any]]
|
|
27
|
+
|
|
28
|
+
|
|
11
29
|
class QueryResult(NamedTuple):
|
|
12
30
|
ok: bool
|
|
13
|
-
|
|
31
|
+
rows: QueryRows | None = None # set when ok
|
|
32
|
+
message: str = "" # set when not ok
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TextResult(NamedTuple):
|
|
36
|
+
ok: bool
|
|
37
|
+
value: str # the text when ok; an error message otherwise
|
|
14
38
|
|
|
15
39
|
|
|
16
40
|
# A driver's own config object (ChConfig, PgConfig, DuckConfig, …). Opaque to
|
|
@@ -56,8 +80,17 @@ class Driver(Protocol):
|
|
|
56
80
|
limit: int,
|
|
57
81
|
offset: int,
|
|
58
82
|
order_by: list[dict[str, Any]] | None,
|
|
59
|
-
fmt: str,
|
|
60
83
|
) -> QueryResult: ...
|
|
84
|
+
# The same page as run_query, as CSVWithNames text for download.
|
|
85
|
+
async def export_csv(
|
|
86
|
+
self,
|
|
87
|
+
config: DriverConfig,
|
|
88
|
+
sql: str,
|
|
89
|
+
database: str | None,
|
|
90
|
+
limit: int,
|
|
91
|
+
offset: int,
|
|
92
|
+
order_by: list[dict[str, Any]] | None,
|
|
93
|
+
) -> TextResult: ...
|
|
61
94
|
async def describe_query(
|
|
62
95
|
self,
|
|
63
96
|
config: DriverConfig,
|
|
@@ -148,18 +181,53 @@ def wrap_paginated(
|
|
|
148
181
|
return " ".join(clauses)
|
|
149
182
|
|
|
150
183
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
return
|
|
162
|
-
|
|
184
|
+
# The largest integer a JS number holds exactly; beyond it values travel as strings.
|
|
185
|
+
_JS_SAFE_INT = 2**53 - 1
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def to_json_value(v: Any) -> Any:
|
|
189
|
+
"""A JSON-safe value that survives the browser: ints beyond 2^53, Decimals,
|
|
190
|
+
NaN/Inf, datetimes, UUIDs, and bytes become strings; containers recurse;
|
|
191
|
+
JSON scalars pass through. Mirrors what ClickHouse's JSONCompact emits
|
|
192
|
+
with 64-bit integers and decimals quoted, so every driver looks the same."""
|
|
193
|
+
if v is None or isinstance(v, (bool, str)):
|
|
194
|
+
return v
|
|
195
|
+
if isinstance(v, int):
|
|
196
|
+
return v if -_JS_SAFE_INT <= v <= _JS_SAFE_INT else str(v)
|
|
197
|
+
if isinstance(v, float):
|
|
198
|
+
return str(v) if math.isnan(v) or math.isinf(v) else v
|
|
199
|
+
if isinstance(v, Decimal):
|
|
200
|
+
return str(v)
|
|
201
|
+
if isinstance(v, (dt.datetime, dt.date, dt.time)):
|
|
202
|
+
return v.isoformat()
|
|
203
|
+
if isinstance(v, uuid.UUID):
|
|
204
|
+
return str(v)
|
|
205
|
+
if isinstance(v, (bytes, bytearray)):
|
|
206
|
+
return bytes(v).decode("utf-8", "replace")
|
|
207
|
+
if isinstance(v, (list, tuple, set, frozenset)):
|
|
208
|
+
return [to_json_value(x) for x in v]
|
|
209
|
+
if isinstance(v, dict):
|
|
210
|
+
return {str(k): to_json_value(x) for k, x in v.items()}
|
|
211
|
+
return str(v)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def to_csv(columns: list[str], rows: list[Any]) -> str:
|
|
215
|
+
"""CSVWithNames text as ClickHouse emits it: header row, LF line ends, None
|
|
216
|
+
as an empty field, no trailing newline."""
|
|
217
|
+
buf = io.StringIO()
|
|
218
|
+
writer = csv.writer(buf, lineterminator="\n")
|
|
219
|
+
writer.writerow(columns)
|
|
163
220
|
for row in rows:
|
|
164
|
-
|
|
165
|
-
return "\n"
|
|
221
|
+
writer.writerow(["" if v is None else str(v) for v in row])
|
|
222
|
+
return buf.getvalue().rstrip("\n")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def rows_to_columns(rows: QueryRows) -> dict[str, list[Any]]:
|
|
226
|
+
"""Column-oriented, insertion-ordered `{name: [values, …]}` — the dashboard
|
|
227
|
+
`window.queries` shape."""
|
|
228
|
+
cols: dict[str, list[Any]] = {c.name: [] for c in rows.meta}
|
|
229
|
+
names = [c.name for c in rows.meta]
|
|
230
|
+
for row in rows.data:
|
|
231
|
+
for i, name in enumerate(names):
|
|
232
|
+
cols[name].append(row[i] if i < len(row) else None)
|
|
233
|
+
return cols
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
5
6
|
from dataclasses import asdict, dataclass
|
|
6
7
|
from typing import Any, NamedTuple
|
|
7
8
|
|
|
8
9
|
import httpx
|
|
9
10
|
|
|
10
|
-
from .base import QueryResult, build_order_by, parse_host_port_config, wrap_paginated
|
|
11
|
+
from .base import Column, QueryResult, QueryRows, TextResult, build_order_by, parse_host_port_config, wrap_paginated
|
|
11
12
|
|
|
12
13
|
CH_TIMEOUT_SECONDS = 5.0
|
|
13
14
|
|
|
@@ -25,12 +26,30 @@ class ChResult(NamedTuple):
|
|
|
25
26
|
value: str
|
|
26
27
|
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
# JSONCompact output settings: values a JS number would mangle are quoted by
|
|
30
|
+
# ClickHouse itself, and named tuples arrive as objects so the UI can label
|
|
31
|
+
# their fields.
|
|
32
|
+
JSON_COMPACT_SETTINGS = {
|
|
33
|
+
"output_format_json_quote_64bit_integers": "1",
|
|
34
|
+
"output_format_json_quote_decimals": "1",
|
|
35
|
+
"output_format_json_quote_denormals": "1",
|
|
36
|
+
"output_format_json_named_tuples_as_objects": "1",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def ch_query(
|
|
41
|
+
c: ChConfig,
|
|
42
|
+
query: str,
|
|
43
|
+
database: str | None = None,
|
|
44
|
+
fmt: str | None = None,
|
|
45
|
+
settings: dict[str, str] | None = None,
|
|
46
|
+
) -> ChResult:
|
|
29
47
|
"""Run a query against the ClickHouse HTTP interface (Basic auth, 5s timeout).
|
|
30
|
-
`database` scopes the query; `fmt` appends a ClickHouse `FORMAT` clause
|
|
48
|
+
`database` scopes the query; `fmt` appends a ClickHouse `FORMAT` clause;
|
|
49
|
+
`settings` are passed as URL parameters."""
|
|
31
50
|
url = f"http://{c.host}:{c.port}/"
|
|
32
51
|
q = f"{query}\nFORMAT {fmt}" if fmt else query
|
|
33
|
-
params = {"query": q}
|
|
52
|
+
params = {"query": q, **(settings or {})}
|
|
34
53
|
if database:
|
|
35
54
|
params["database"] = database
|
|
36
55
|
try:
|
|
@@ -120,13 +139,33 @@ class ClickHouseDriver:
|
|
|
120
139
|
limit: int,
|
|
121
140
|
offset: int,
|
|
122
141
|
order_by: list[dict[str, Any]] | None,
|
|
123
|
-
fmt: str,
|
|
124
142
|
) -> QueryResult:
|
|
125
143
|
order_clause = build_order_by(order_by, "`")
|
|
126
144
|
paginated = wrap_paginated(sql, order_clause, limit, offset, alias=None)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
145
|
+
r = await ch_query(config, paginated, database=database, fmt="JSONCompact", settings=JSON_COMPACT_SETTINGS)
|
|
146
|
+
if not r.ok:
|
|
147
|
+
return QueryResult(False, None, r.value)
|
|
148
|
+
try:
|
|
149
|
+
doc = json.loads(r.value)
|
|
150
|
+
meta = [Column(str(m["name"]), str(m["type"])) for m in doc["meta"]]
|
|
151
|
+
data = [list(row) for row in doc["data"]]
|
|
152
|
+
except (ValueError, KeyError, TypeError) as err:
|
|
153
|
+
return QueryResult(False, None, f"unexpected JSON from ClickHouse: {err}")
|
|
154
|
+
return QueryResult(True, QueryRows(meta, data))
|
|
155
|
+
|
|
156
|
+
async def export_csv(
|
|
157
|
+
self,
|
|
158
|
+
config: ChConfig,
|
|
159
|
+
sql: str,
|
|
160
|
+
database: str | None,
|
|
161
|
+
limit: int,
|
|
162
|
+
offset: int,
|
|
163
|
+
order_by: list[dict[str, Any]] | None,
|
|
164
|
+
) -> TextResult:
|
|
165
|
+
order_clause = build_order_by(order_by, "`")
|
|
166
|
+
paginated = wrap_paginated(sql, order_clause, limit, offset, alias=None)
|
|
167
|
+
r = await ch_query(config, paginated, database=database, fmt="CSVWithNames")
|
|
168
|
+
return TextResult(r.ok, r.value)
|
|
130
169
|
|
|
131
170
|
async def describe_query(
|
|
132
171
|
self, config: ChConfig, sql: str, database: str | None
|
|
@@ -9,7 +9,7 @@ from typing import Any
|
|
|
9
9
|
|
|
10
10
|
import duckdb
|
|
11
11
|
|
|
12
|
-
from .base import QueryResult, build_order_by,
|
|
12
|
+
from .base import Column, QueryResult, QueryRows, TextResult, build_order_by, to_csv, to_json_value, wrap_paginated
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
@dataclass(frozen=True)
|
|
@@ -104,7 +104,7 @@ class DuckDBDriver:
|
|
|
104
104
|
except Exception as e: # noqa: BLE001
|
|
105
105
|
return False, str(e)
|
|
106
106
|
|
|
107
|
-
async def
|
|
107
|
+
async def _fetch_page(
|
|
108
108
|
self,
|
|
109
109
|
config: DuckConfig,
|
|
110
110
|
sql: str,
|
|
@@ -112,26 +112,50 @@ class DuckDBDriver:
|
|
|
112
112
|
limit: int,
|
|
113
113
|
offset: int,
|
|
114
114
|
order_by: list[dict[str, Any]] | None,
|
|
115
|
-
|
|
116
|
-
) -> QueryResult:
|
|
115
|
+
) -> QueryRows:
|
|
117
116
|
order_clause = build_order_by(order_by, '"')
|
|
118
117
|
paginated = wrap_paginated(sql, order_clause, limit, offset, alias="_qv")
|
|
119
118
|
|
|
120
|
-
def _work():
|
|
119
|
+
def _work() -> QueryRows:
|
|
121
120
|
con = _open(config.path)
|
|
122
121
|
try:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return
|
|
122
|
+
# A relation exposes DuckDB's own type names alongside the rows.
|
|
123
|
+
rel = con.sql(paginated)
|
|
124
|
+
meta = [Column(name, str(t)) for name, t in zip(rel.columns, rel.types, strict=True)]
|
|
125
|
+
return QueryRows(meta, [[to_json_value(v) for v in row] for row in rel.fetchall()])
|
|
127
126
|
finally:
|
|
128
127
|
con.close()
|
|
129
128
|
|
|
129
|
+
return await asyncio.to_thread(_work)
|
|
130
|
+
|
|
131
|
+
async def run_query(
|
|
132
|
+
self,
|
|
133
|
+
config: DuckConfig,
|
|
134
|
+
sql: str,
|
|
135
|
+
database: str | None,
|
|
136
|
+
limit: int,
|
|
137
|
+
offset: int,
|
|
138
|
+
order_by: list[dict[str, Any]] | None,
|
|
139
|
+
) -> QueryResult:
|
|
140
|
+
try:
|
|
141
|
+
return QueryResult(True, await self._fetch_page(config, sql, database, limit, offset, order_by))
|
|
142
|
+
except Exception as e: # noqa: BLE001
|
|
143
|
+
return QueryResult(False, None, str(e))
|
|
144
|
+
|
|
145
|
+
async def export_csv(
|
|
146
|
+
self,
|
|
147
|
+
config: DuckConfig,
|
|
148
|
+
sql: str,
|
|
149
|
+
database: str | None,
|
|
150
|
+
limit: int,
|
|
151
|
+
offset: int,
|
|
152
|
+
order_by: list[dict[str, Any]] | None,
|
|
153
|
+
) -> TextResult:
|
|
130
154
|
try:
|
|
131
|
-
|
|
132
|
-
return QueryResult(True, serialize_rows(columns, rows, fmt))
|
|
155
|
+
rows = await self._fetch_page(config, sql, database, limit, offset, order_by)
|
|
133
156
|
except Exception as e: # noqa: BLE001
|
|
134
|
-
return
|
|
157
|
+
return TextResult(False, str(e))
|
|
158
|
+
return TextResult(True, to_csv([c.name for c in rows.meta], rows.data))
|
|
135
159
|
|
|
136
160
|
async def describe_query(
|
|
137
161
|
self, config: DuckConfig, sql: str, database: str | None
|
|
@@ -10,10 +10,14 @@ from typing import Any
|
|
|
10
10
|
import asyncpg
|
|
11
11
|
|
|
12
12
|
from .base import (
|
|
13
|
+
Column,
|
|
13
14
|
QueryResult,
|
|
15
|
+
QueryRows,
|
|
16
|
+
TextResult,
|
|
14
17
|
build_order_by,
|
|
15
18
|
parse_host_port_config,
|
|
16
|
-
|
|
19
|
+
to_csv,
|
|
20
|
+
to_json_value,
|
|
17
21
|
wrap_paginated,
|
|
18
22
|
)
|
|
19
23
|
|
|
@@ -133,7 +137,7 @@ class PostgresDriver:
|
|
|
133
137
|
except Exception as e: # noqa: BLE001
|
|
134
138
|
return False, str(e) or "connection failed"
|
|
135
139
|
|
|
136
|
-
async def
|
|
140
|
+
async def _fetch_page(
|
|
137
141
|
self,
|
|
138
142
|
config: PgConfig,
|
|
139
143
|
sql: str,
|
|
@@ -141,18 +145,43 @@ class PostgresDriver:
|
|
|
141
145
|
limit: int,
|
|
142
146
|
offset: int,
|
|
143
147
|
order_by: list[dict[str, Any]] | None,
|
|
144
|
-
|
|
145
|
-
) -> QueryResult:
|
|
148
|
+
) -> QueryRows:
|
|
146
149
|
order_clause = build_order_by(order_by, '"')
|
|
147
150
|
paginated = wrap_paginated(sql, order_clause, limit, offset, alias="_qv")
|
|
151
|
+
async with _connect(config, database) as conn:
|
|
152
|
+
stmt = await conn.prepare(paginated)
|
|
153
|
+
meta = [Column(a.name, a.type.name) for a in stmt.get_attributes()]
|
|
154
|
+
records = await stmt.fetch()
|
|
155
|
+
return QueryRows(meta, [[to_json_value(v) for v in r] for r in records])
|
|
156
|
+
|
|
157
|
+
async def run_query(
|
|
158
|
+
self,
|
|
159
|
+
config: PgConfig,
|
|
160
|
+
sql: str,
|
|
161
|
+
database: str | None,
|
|
162
|
+
limit: int,
|
|
163
|
+
offset: int,
|
|
164
|
+
order_by: list[dict[str, Any]] | None,
|
|
165
|
+
) -> QueryResult:
|
|
148
166
|
try:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
167
|
+
return QueryResult(True, await self._fetch_page(config, sql, database, limit, offset, order_by))
|
|
168
|
+
except Exception as e: # noqa: BLE001
|
|
169
|
+
return QueryResult(False, None, str(e) or "connection failed")
|
|
170
|
+
|
|
171
|
+
async def export_csv(
|
|
172
|
+
self,
|
|
173
|
+
config: PgConfig,
|
|
174
|
+
sql: str,
|
|
175
|
+
database: str | None,
|
|
176
|
+
limit: int,
|
|
177
|
+
offset: int,
|
|
178
|
+
order_by: list[dict[str, Any]] | None,
|
|
179
|
+
) -> TextResult:
|
|
180
|
+
try:
|
|
181
|
+
rows = await self._fetch_page(config, sql, database, limit, offset, order_by)
|
|
154
182
|
except Exception as e: # noqa: BLE001
|
|
155
|
-
return
|
|
183
|
+
return TextResult(False, str(e) or "connection failed")
|
|
184
|
+
return TextResult(True, to_csv([c.name for c in rows.meta], rows.data))
|
|
156
185
|
|
|
157
186
|
async def describe_query(
|
|
158
187
|
self, config: PgConfig, sql: str, database: str | None
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Driver dialect helpers and the shared result contract (JSON-safe rows, CSV)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import uuid
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
|
|
9
|
+
from queryview.drivers.base import (
|
|
10
|
+
Column,
|
|
11
|
+
QueryRows,
|
|
12
|
+
build_order_by,
|
|
13
|
+
rows_to_columns,
|
|
14
|
+
select_all_sql,
|
|
15
|
+
to_csv,
|
|
16
|
+
to_json_value,
|
|
17
|
+
wrap_paginated,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_select_all_sql_quotes_and_doubles_embedded_quotes():
|
|
22
|
+
assert select_all_sql("items", '"') == 'SELECT * FROM "items"'
|
|
23
|
+
assert select_all_sql('we"ird', '"') == 'SELECT * FROM "we""ird"'
|
|
24
|
+
assert select_all_sql("a`b", "`") == "SELECT * FROM `a``b`"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_build_order_by_quotes_and_whitelists_direction():
|
|
28
|
+
assert build_order_by([{"name": "a", "dir": "desc"}], "`") == "ORDER BY `a` DESC"
|
|
29
|
+
# Unknown direction falls back to ASC; quote chars in the name are doubled.
|
|
30
|
+
assert build_order_by([{"name": "a`b", "dir": "x"}], "`") == "ORDER BY `a``b` ASC"
|
|
31
|
+
assert build_order_by([{"name": "a"}], '"') == 'ORDER BY "a" ASC'
|
|
32
|
+
assert build_order_by(None, "`") == ""
|
|
33
|
+
assert build_order_by([{"bad": 1}], "`") == ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_wrap_paginated_matches_clickhouse_shape_without_alias():
|
|
37
|
+
out = wrap_paginated("SELECT 1;", "", 100, 0, alias=None)
|
|
38
|
+
assert out == "SELECT * FROM (\nSELECT 1\n) LIMIT 100 OFFSET 0"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_wrap_paginated_adds_alias_and_order():
|
|
42
|
+
out = wrap_paginated("SELECT 1", 'ORDER BY "a" ASC', 10, 5, alias="_qv")
|
|
43
|
+
assert out == 'SELECT * FROM (\nSELECT 1\n) AS _qv ORDER BY "a" ASC LIMIT 10 OFFSET 5'
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_to_json_value_keeps_json_scalars():
|
|
47
|
+
assert [to_json_value(v) for v in (None, True, 1, 1.5, "s")] == [None, True, 1, 1.5, "s"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_to_json_value_stringifies_values_json_would_mangle():
|
|
51
|
+
# Beyond 2^53 a JS number rounds; Decimal has no JSON type; NaN/Inf aren't JSON.
|
|
52
|
+
assert to_json_value(2**53) == "9007199254740992"
|
|
53
|
+
assert to_json_value(-(2**53)) == "-9007199254740992"
|
|
54
|
+
assert to_json_value(2**53 - 1) == 2**53 - 1
|
|
55
|
+
assert to_json_value(Decimal("1.10")) == "1.10"
|
|
56
|
+
assert to_json_value(float("nan")) == "nan"
|
|
57
|
+
assert to_json_value(float("-inf")) == "-inf"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_to_json_value_formats_temporals_and_ids_as_strings():
|
|
61
|
+
assert to_json_value(dt.datetime(2024, 1, 2, 3, 4, 5)) == "2024-01-02T03:04:05"
|
|
62
|
+
assert to_json_value(dt.date(2024, 1, 2)) == "2024-01-02"
|
|
63
|
+
u = uuid.UUID("12345678-1234-5678-1234-567812345678")
|
|
64
|
+
assert to_json_value(u) == str(u)
|
|
65
|
+
assert to_json_value(b"\x00hi") == "\x00hi"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_to_json_value_recurses_containers():
|
|
69
|
+
assert to_json_value((1, [Decimal("2"), None], {"k": (3,)})) == [1, ["2", None], {"k": [3]}]
|
|
70
|
+
assert to_json_value({1: "a"}) == {"1": "a"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_to_csv_quotes_and_uses_lf():
|
|
74
|
+
assert to_csv(["a", "b"], [["x,y", "z"]]) == 'a,b\n"x,y",z'
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_to_csv_nulls_are_empty_fields_and_empty_is_just_header():
|
|
78
|
+
assert to_csv(["id", "name"], [[1, None]]) == "id,name\n1,"
|
|
79
|
+
assert to_csv(["a"], []) == "a"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_rows_to_columns_is_column_oriented_and_ordered():
|
|
83
|
+
rows = QueryRows([Column("a", "Int32"), Column("b", "String")], [[1, "x"], [2, "y"]])
|
|
84
|
+
assert rows_to_columns(rows) == {"a": [1, 2], "b": ["x", "y"]}
|
|
85
|
+
assert rows_to_columns(QueryRows([Column("a", "Int32")], [])) == {"a": []}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""ClickHouse-specific behavior: run_query builds the historical paginated SQL
|
|
2
|
+
(backtick-quoted, no subquery alias) and asks ClickHouse for JSONCompact. Registry conformance,
|
|
3
|
+
config round-trip, and validation are covered by test_driver_contract."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
|
|
9
|
+
from queryview.drivers.base import Column
|
|
10
|
+
from queryview.drivers.clickhouse import ChConfig, ClickHouseDriver
|
|
11
|
+
|
|
12
|
+
JSON_COMPACT = (
|
|
13
|
+
'{"meta":[{"name":"id","type":"UInt64"},{"name":"tags","type":"Array(String)"}],"data":[["1",["a","b"]]],"rows":1}'
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _capture(monkeypatch, seen, text):
|
|
18
|
+
async def fake_ch_query(c, query, database=None, fmt=None, settings=None):
|
|
19
|
+
from queryview.drivers.clickhouse import ChResult
|
|
20
|
+
|
|
21
|
+
seen.update(query=query, fmt=fmt, database=database, settings=settings)
|
|
22
|
+
return ChResult(True, text)
|
|
23
|
+
|
|
24
|
+
monkeypatch.setattr("queryview.drivers.clickhouse.ch_query", fake_ch_query)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_run_query_builds_clickhouse_sql_and_parses_json_compact(monkeypatch):
|
|
28
|
+
d = ClickHouseDriver()
|
|
29
|
+
seen = {}
|
|
30
|
+
_capture(monkeypatch, seen, JSON_COMPACT)
|
|
31
|
+
r = asyncio.run(d.run_query(ChConfig("h", 1, "u", ""), "SELECT 1;", "db", 100, 0, [{"name": "a", "dir": "DESC"}]))
|
|
32
|
+
assert r.ok and r.rows is not None
|
|
33
|
+
assert r.rows.meta == [Column("id", "UInt64"), Column("tags", "Array(String)")]
|
|
34
|
+
assert r.rows.data == [["1", ["a", "b"]]]
|
|
35
|
+
assert seen["query"] == "SELECT * FROM (\nSELECT 1\n) ORDER BY `a` DESC LIMIT 100 OFFSET 0"
|
|
36
|
+
assert seen["fmt"] == "JSONCompact"
|
|
37
|
+
assert seen["database"] == "db"
|
|
38
|
+
# Values a JS number would mangle are quoted by ClickHouse itself; named
|
|
39
|
+
# tuples arrive as objects so the UI can label their fields.
|
|
40
|
+
assert seen["settings"] == {
|
|
41
|
+
"output_format_json_quote_64bit_integers": "1",
|
|
42
|
+
"output_format_json_quote_decimals": "1",
|
|
43
|
+
"output_format_json_quote_denormals": "1",
|
|
44
|
+
"output_format_json_named_tuples_as_objects": "1",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_run_query_reports_driver_error(monkeypatch):
|
|
49
|
+
d = ClickHouseDriver()
|
|
50
|
+
|
|
51
|
+
async def fake_ch_query(c, query, database=None, fmt=None, settings=None):
|
|
52
|
+
from queryview.drivers.clickhouse import ChResult
|
|
53
|
+
|
|
54
|
+
return ChResult(False, "ClickHouse responded 400: boom")
|
|
55
|
+
|
|
56
|
+
monkeypatch.setattr("queryview.drivers.clickhouse.ch_query", fake_ch_query)
|
|
57
|
+
r = asyncio.run(d.run_query(ChConfig("h", 1, "u", ""), "SELECT 1", None, 10, 0, None))
|
|
58
|
+
assert r.ok is False and r.rows is None and "boom" in r.message
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_run_query_rejects_unparseable_body(monkeypatch):
|
|
62
|
+
d = ClickHouseDriver()
|
|
63
|
+
_capture(monkeypatch, {}, "not json")
|
|
64
|
+
r = asyncio.run(d.run_query(ChConfig("h", 1, "u", ""), "SELECT 1", None, 10, 0, None))
|
|
65
|
+
assert r.ok is False and "JSON" in r.message
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_export_csv_requests_csv_with_names(monkeypatch):
|
|
69
|
+
d = ClickHouseDriver()
|
|
70
|
+
seen = {}
|
|
71
|
+
_capture(monkeypatch, seen, "id,name\n1,a")
|
|
72
|
+
r = asyncio.run(d.export_csv(ChConfig("h", 1, "u", ""), "SELECT 1", "db", 5, 0, None))
|
|
73
|
+
assert r.ok and r.value == "id,name\n1,a"
|
|
74
|
+
assert seen["fmt"] == "CSVWithNames"
|
|
75
|
+
assert seen["query"] == "SELECT * FROM (\nSELECT 1\n) LIMIT 5 OFFSET 0"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_list_tables_parses_rows_and_bytes_with_nulls(monkeypatch):
|
|
79
|
+
d = ClickHouseDriver()
|
|
80
|
+
seen = {}
|
|
81
|
+
|
|
82
|
+
async def fake_ch_query(c, query, database=None, fmt=None):
|
|
83
|
+
from queryview.drivers.clickhouse import ChResult
|
|
84
|
+
|
|
85
|
+
seen["query"] = query
|
|
86
|
+
seen["database"] = database
|
|
87
|
+
# A MergeTree table with stats and a view (\N for both counters).
|
|
88
|
+
return ChResult(True, "items\t3\t245\nv_items\t\\N\t\\N")
|
|
89
|
+
|
|
90
|
+
monkeypatch.setattr("queryview.drivers.clickhouse.ch_query", fake_ch_query)
|
|
91
|
+
ok, tables = asyncio.run(d.list_tables(ChConfig("h", 1, "u", ""), "db"))
|
|
92
|
+
assert ok and tables == [
|
|
93
|
+
{"name": "items", "rows": 3, "bytes": 245},
|
|
94
|
+
{"name": "v_items", "rows": None, "bytes": None},
|
|
95
|
+
]
|
|
96
|
+
assert "system.tables" in seen["query"]
|
|
97
|
+
assert seen["database"] == "db"
|