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.
Files changed (159) hide show
  1. {queryview-0.0.3 → queryview-0.0.4}/PKG-INFO +1 -1
  2. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/connect.py +21 -4
  3. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/dashboard_queries.py +12 -31
  4. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/drivers/base.py +86 -18
  5. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/drivers/clickhouse.py +47 -8
  6. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/drivers/duckdb.py +36 -12
  7. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/drivers/postgres.py +39 -10
  8. queryview-0.0.4/backend/queryview/drivers/test_base.py +85 -0
  9. queryview-0.0.4/backend/queryview/drivers/test_clickhouse.py +97 -0
  10. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/drivers/test_duckdb.py +14 -7
  11. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/drivers/test_postgres.py +8 -2
  12. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/main.py +8 -3
  13. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/mcp_server.py +4 -1
  14. queryview-0.0.4/backend/queryview/static/assets/index-B3W6A5bg.css +2 -0
  15. queryview-0.0.4/backend/queryview/static/assets/index-BE8_u4Np.js +44 -0
  16. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/static/index.html +2 -2
  17. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_connect_flow.py +19 -6
  18. queryview-0.0.4/backend/queryview/test_dashboard_queries.py +53 -0
  19. {queryview-0.0.3 → queryview-0.0.4}/docs/api.md +2 -2
  20. {queryview-0.0.3 → queryview-0.0.4}/docs/connect.md +1 -1
  21. {queryview-0.0.3 → queryview-0.0.4}/docs/dashboard.md +3 -0
  22. {queryview-0.0.3 → queryview-0.0.4}/docs/query.md +25 -21
  23. {queryview-0.0.3 → queryview-0.0.4}/docs/remote.md +3 -1
  24. {queryview-0.0.3 → queryview-0.0.4}/e2e/test_drivers.py +5 -0
  25. queryview-0.0.4/frontend/README.md +20 -0
  26. queryview-0.0.4/frontend/eslint.config.js +41 -0
  27. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/App.tsx +4 -3
  28. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/DashboardView.tsx +8 -31
  29. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/ExplorerView.tsx +16 -12
  30. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/QueryView.tsx +43 -161
  31. queryview-0.0.4/frontend/src/app/connection.ts +16 -0
  32. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app/controls}/ExportImportControls.tsx +2 -2
  33. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app/controls}/GitSyncControls.tsx +0 -0
  34. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app/controls}/WorkspaceSwitcher.tsx +2 -2
  35. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/core/cells}/ComplexCell.tsx +8 -7
  36. queryview-0.0.4/frontend/src/core/cells/cellViewYaml.test.ts +73 -0
  37. queryview-0.0.4/frontend/src/core/cells/cellViewYaml.tsx +80 -0
  38. queryview-0.0.4/frontend/src/core/cells/complexCells.test.ts +103 -0
  39. queryview-0.0.4/frontend/src/core/cells/complexCells.ts +69 -0
  40. queryview-0.0.4/frontend/src/core/dashboard/DashboardFrame.tsx +27 -0
  41. queryview-0.0.4/frontend/src/core/dashboard/srcDoc.test.ts +15 -0
  42. queryview-0.0.4/frontend/src/core/dashboard/srcDoc.ts +12 -0
  43. queryview-0.0.4/frontend/src/core/index.ts +38 -0
  44. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/core/results}/ResultsTable.tsx +7 -5
  45. queryview-0.0.4/frontend/src/core/results/rows.test.ts +29 -0
  46. queryview-0.0.4/frontend/src/core/results/rows.ts +32 -0
  47. {queryview-0.0.3 → queryview-0.0.4}/frontend/src/main.tsx +1 -1
  48. queryview-0.0.3/backend/queryview/drivers/test_base.py +0 -49
  49. queryview-0.0.3/backend/queryview/drivers/test_clickhouse.py +0 -53
  50. queryview-0.0.3/backend/queryview/static/assets/index-CvnC_D68.js +0 -47
  51. queryview-0.0.3/backend/queryview/static/assets/index-Qe7bhycG.css +0 -2
  52. queryview-0.0.3/frontend/README.md +0 -73
  53. queryview-0.0.3/frontend/eslint.config.js +0 -22
  54. queryview-0.0.3/frontend/src/complexCellParsing.test.ts +0 -221
  55. queryview-0.0.3/frontend/src/complexCellParsing.ts +0 -177
  56. queryview-0.0.3/frontend/src/tsv.ts +0 -8
  57. {queryview-0.0.3 → queryview-0.0.4}/.claude/settings.json +0 -0
  58. {queryview-0.0.3 → queryview-0.0.4}/.github/actions/start-git-daemon/action.yaml +0 -0
  59. {queryview-0.0.3 → queryview-0.0.4}/.github/workflows/ci.yml +0 -0
  60. {queryview-0.0.3 → queryview-0.0.4}/.github/workflows/publish.yaml +0 -0
  61. {queryview-0.0.3 → queryview-0.0.4}/.gitignore +0 -0
  62. {queryview-0.0.3 → queryview-0.0.4}/.mcp.json +0 -0
  63. {queryview-0.0.3 → queryview-0.0.4}/.pre-commit-config.yaml +0 -0
  64. {queryview-0.0.3 → queryview-0.0.4}/CLAUDE.md +0 -0
  65. {queryview-0.0.3 → queryview-0.0.4}/LICENSE +0 -0
  66. {queryview-0.0.3 → queryview-0.0.4}/README.md +0 -0
  67. {queryview-0.0.3 → queryview-0.0.4}/backend/alembic.ini +0 -0
  68. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/__init__.py +0 -0
  69. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/conftest.py +0 -0
  70. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/dashboards.py +0 -0
  71. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/drivers/__init__.py +0 -0
  72. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/drivers/test_contract.py +0 -0
  73. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/gitsync.py +0 -0
  74. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/migrations/env.py +0 -0
  75. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/migrations/script.py.mako +0 -0
  76. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/migrations/versions/9a536b7c0328_initial_schema.py +0 -0
  77. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/migrations/versions/a1b2c3d4e5f6_connection_config_blob.py +0 -0
  78. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/migrations/versions/b2c3d4e5f6a7_predefined_presentation.py +0 -0
  79. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/migrations/versions/c7d8e9f0a1b2_workspaces.py +0 -0
  80. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/queries.py +0 -0
  81. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/remote.py +0 -0
  82. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/static/favicon.svg +0 -0
  83. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_api_db.py +0 -0
  84. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_api_export_import.py +0 -0
  85. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_api_gitsync.py +0 -0
  86. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_api_workspaces.py +0 -0
  87. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_connect_store.py +0 -0
  88. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_dashboards.py +0 -0
  89. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_db_path.py +0 -0
  90. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_gitsync.py +0 -0
  91. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_main.py +0 -0
  92. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_mcp_gitsync.py +0 -0
  93. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_mcp_mount.py +0 -0
  94. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_migrations.py +0 -0
  95. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_queries.py +0 -0
  96. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_remote.py +0 -0
  97. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_validation.py +0 -0
  98. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_workspaces.py +0 -0
  99. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/test_yamlio.py +0 -0
  100. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/validation.py +0 -0
  101. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/workspaces.py +0 -0
  102. {queryview-0.0.3 → queryview-0.0.4}/backend/queryview/yamlio.py +0 -0
  103. {queryview-0.0.3 → queryview-0.0.4}/docker/Dockerfile +0 -0
  104. {queryview-0.0.3 → queryview-0.0.4}/docs/explorer.md +0 -0
  105. {queryview-0.0.3 → queryview-0.0.4}/docs/export-import.md +0 -0
  106. {queryview-0.0.3 → queryview-0.0.4}/docs/future.md +0 -0
  107. {queryview-0.0.3 → queryview-0.0.4}/docs/gitsync.md +0 -0
  108. {queryview-0.0.3 → queryview-0.0.4}/docs/queryview.md +0 -0
  109. {queryview-0.0.3 → queryview-0.0.4}/docs/workspace.md +0 -0
  110. {queryview-0.0.3 → queryview-0.0.4}/e2e/conftest.py +0 -0
  111. {queryview-0.0.3 → queryview-0.0.4}/e2e/test_app.py +0 -0
  112. {queryview-0.0.3 → queryview-0.0.4}/e2e/test_dashboard.py +0 -0
  113. {queryview-0.0.3 → queryview-0.0.4}/e2e/test_explorer.py +0 -0
  114. {queryview-0.0.3 → queryview-0.0.4}/e2e/test_export_import.py +0 -0
  115. {queryview-0.0.3 → queryview-0.0.4}/e2e/test_gitsync.py +0 -0
  116. {queryview-0.0.3 → queryview-0.0.4}/e2e/test_query.py +0 -0
  117. {queryview-0.0.3 → queryview-0.0.4}/e2e/test_remote.py +0 -0
  118. {queryview-0.0.3 → queryview-0.0.4}/e2e/test_workspaces.py +0 -0
  119. {queryview-0.0.3 → queryview-0.0.4}/frontend/.gitignore +0 -0
  120. {queryview-0.0.3 → queryview-0.0.4}/frontend/index.html +0 -0
  121. {queryview-0.0.3 → queryview-0.0.4}/frontend/package.json +0 -0
  122. {queryview-0.0.3 → queryview-0.0.4}/frontend/public/favicon.svg +0 -0
  123. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/compactNumber.test.ts +0 -0
  124. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/compactNumber.ts +0 -0
  125. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app/controls}/Toast.tsx +0 -0
  126. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/drivers.ts +0 -0
  127. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/gitsync.test.ts +0 -0
  128. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/gitsync.ts +0 -0
  129. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/promptSuggestions.test.ts +0 -0
  130. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/promptSuggestions.ts +0 -0
  131. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/sessionLock.test.ts +0 -0
  132. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/sessionLock.ts +0 -0
  133. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/workspace.test.ts +0 -0
  134. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/workspace.ts +0 -0
  135. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/yamlio.test.ts +0 -0
  136. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/app}/yamlio.ts +0 -0
  137. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/core/cells}/CellViewModal.tsx +0 -0
  138. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/core/cells}/cellView.test.ts +0 -0
  139. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/core/cells}/cellView.ts +0 -0
  140. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/core/params}/queryParams.test.ts +0 -0
  141. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/core/params}/queryParams.ts +0 -0
  142. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/core/presentation}/FieldPickers.tsx +0 -0
  143. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/core/presentation}/presentation.test.ts +0 -0
  144. {queryview-0.0.3/frontend/src → queryview-0.0.4/frontend/src/core/presentation}/presentation.ts +0 -0
  145. {queryview-0.0.3 → queryview-0.0.4}/frontend/src/index.css +0 -0
  146. {queryview-0.0.3 → queryview-0.0.4}/frontend/tsconfig.app.json +0 -0
  147. {queryview-0.0.3 → queryview-0.0.4}/frontend/tsconfig.json +0 -0
  148. {queryview-0.0.3 → queryview-0.0.4}/frontend/tsconfig.node.json +0 -0
  149. {queryview-0.0.3 → queryview-0.0.4}/frontend/vite.config.ts +0 -0
  150. {queryview-0.0.3 → queryview-0.0.4}/package-lock.json +0 -0
  151. {queryview-0.0.3 → queryview-0.0.4}/package.json +0 -0
  152. {queryview-0.0.3 → queryview-0.0.4}/pyproject.toml +0 -0
  153. {queryview-0.0.3 → queryview-0.0.4}/pyrightconfig.json +0 -0
  154. {queryview-0.0.3 → queryview-0.0.4}/scripts/dev.sh +0 -0
  155. {queryview-0.0.3 → queryview-0.0.4}/scripts/setup.sh +0 -0
  156. {queryview-0.0.3 → queryview-0.0.4}/scripts/setup_browser.sh +0 -0
  157. {queryview-0.0.3 → queryview-0.0.4}/scripts/setup_clickhouse.sh +0 -0
  158. {queryview-0.0.3 → queryview-0.0.4}/scripts/setup_postgres.sh +0 -0
  159. {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
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. The
429
- driver owns pagination/quoting; `fmt` is the logical 'tsv'/'csv'."""
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, fmt)
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, ready for window.queries. `limit`/
43
- `offset` page each query (default: the dashboard row cap, from row 0)."""
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[str]]] = {}
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
- 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}
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 row serializer
2
- shared by row-returning drivers. No backend/storage concerns here."""
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
- value: str # serialized rows when ok; an error message otherwise
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
- def serialize_rows(columns: list[str], rows: list[Any], fmt: str) -> str:
152
- """Serialize rows to the text contract ClickHouse emits: TabSeparatedWithNames
153
- (fmt='tsv') or CSVWithNames (fmt='csv'). None -> empty field. Non-strings are
154
- str()-ified. No trailing newline (matches ClickHouse's stripped output)."""
155
- if fmt == "csv":
156
- buf = io.StringIO()
157
- writer = csv.writer(buf, lineterminator="\n")
158
- writer.writerow(columns)
159
- for row in rows:
160
- writer.writerow(["" if v is None else str(v) for v in row])
161
- return buf.getvalue().rstrip("\n")
162
- lines = ["\t".join(columns)]
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
- lines.append("\t".join("" if v is None else str(v) for v in row))
165
- return "\n".join(lines)
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
- async def ch_query(c: ChConfig, query: str, database: str | None = None, fmt: str | None = None) -> ChResult:
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
- ch_fmt = "CSVWithNames" if fmt == "csv" else "TabSeparatedWithNames"
128
- r = await ch_query(config, paginated, database=database, fmt=ch_fmt)
129
- return QueryResult(r.ok, r.value)
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, serialize_rows, wrap_paginated
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 run_query(
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
- fmt: str,
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
- cur = con.execute(paginated)
124
- columns = [d[0] for d in cur.description] if cur.description else []
125
- rows = cur.fetchall()
126
- return columns, rows
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
- columns, rows = await asyncio.to_thread(_work)
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 QueryResult(False, str(e))
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
- serialize_rows,
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 run_query(
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
- fmt: str,
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
- async with _connect(config, database) as conn:
150
- stmt = await conn.prepare(paginated)
151
- columns = [a.name for a in stmt.get_attributes()]
152
- records = await stmt.fetch()
153
- return QueryResult(True, serialize_rows(columns, [list(r) for r in records], fmt))
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 QueryResult(False, str(e) or "connection failed")
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"