queryview 0.0.2__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.2 → queryview-0.0.4}/PKG-INFO +36 -9
  2. {queryview-0.0.2 → queryview-0.0.4}/README.md +34 -8
  3. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/connect.py +30 -5
  4. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/dashboard_queries.py +12 -31
  5. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/drivers/base.py +86 -18
  6. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/drivers/clickhouse.py +47 -8
  7. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/drivers/duckdb.py +36 -12
  8. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/drivers/postgres.py +39 -10
  9. queryview-0.0.4/backend/queryview/drivers/test_base.py +85 -0
  10. queryview-0.0.4/backend/queryview/drivers/test_clickhouse.py +97 -0
  11. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/drivers/test_duckdb.py +14 -7
  12. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/drivers/test_postgres.py +8 -2
  13. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/main.py +23 -4
  14. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/mcp_server.py +4 -1
  15. queryview-0.0.4/backend/queryview/static/assets/index-B3W6A5bg.css +2 -0
  16. queryview-0.0.4/backend/queryview/static/assets/index-BE8_u4Np.js +44 -0
  17. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/static/index.html +2 -2
  18. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_connect_flow.py +19 -6
  19. queryview-0.0.4/backend/queryview/test_dashboard_queries.py +53 -0
  20. queryview-0.0.4/backend/queryview/test_db_path.py +58 -0
  21. queryview-0.0.4/backend/queryview/test_mcp_mount.py +38 -0
  22. {queryview-0.0.2 → queryview-0.0.4}/docker/Dockerfile +4 -3
  23. {queryview-0.0.2 → queryview-0.0.4}/docs/api.md +6 -5
  24. {queryview-0.0.2 → queryview-0.0.4}/docs/connect.md +1 -1
  25. {queryview-0.0.2 → queryview-0.0.4}/docs/dashboard.md +3 -0
  26. {queryview-0.0.2 → queryview-0.0.4}/docs/query.md +25 -21
  27. {queryview-0.0.2 → queryview-0.0.4}/docs/remote.md +12 -2
  28. {queryview-0.0.2 → queryview-0.0.4}/e2e/test_drivers.py +5 -0
  29. queryview-0.0.4/frontend/README.md +20 -0
  30. queryview-0.0.4/frontend/eslint.config.js +41 -0
  31. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/App.tsx +4 -3
  32. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/DashboardView.tsx +8 -31
  33. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/ExplorerView.tsx +16 -12
  34. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/QueryView.tsx +43 -161
  35. queryview-0.0.4/frontend/src/app/connection.ts +16 -0
  36. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app/controls}/ExportImportControls.tsx +2 -2
  37. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app/controls}/GitSyncControls.tsx +0 -0
  38. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app/controls}/WorkspaceSwitcher.tsx +2 -2
  39. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/core/cells}/ComplexCell.tsx +8 -7
  40. queryview-0.0.4/frontend/src/core/cells/cellViewYaml.test.ts +73 -0
  41. queryview-0.0.4/frontend/src/core/cells/cellViewYaml.tsx +80 -0
  42. queryview-0.0.4/frontend/src/core/cells/complexCells.test.ts +103 -0
  43. queryview-0.0.4/frontend/src/core/cells/complexCells.ts +69 -0
  44. queryview-0.0.4/frontend/src/core/dashboard/DashboardFrame.tsx +27 -0
  45. queryview-0.0.4/frontend/src/core/dashboard/srcDoc.test.ts +15 -0
  46. queryview-0.0.4/frontend/src/core/dashboard/srcDoc.ts +12 -0
  47. queryview-0.0.4/frontend/src/core/index.ts +38 -0
  48. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/core/results}/ResultsTable.tsx +7 -5
  49. queryview-0.0.4/frontend/src/core/results/rows.test.ts +29 -0
  50. queryview-0.0.4/frontend/src/core/results/rows.ts +32 -0
  51. {queryview-0.0.2 → queryview-0.0.4}/frontend/src/main.tsx +1 -1
  52. {queryview-0.0.2 → queryview-0.0.4}/pyproject.toml +1 -0
  53. {queryview-0.0.2 → queryview-0.0.4}/uv.lock +2 -0
  54. queryview-0.0.2/backend/queryview/drivers/test_base.py +0 -49
  55. queryview-0.0.2/backend/queryview/drivers/test_clickhouse.py +0 -53
  56. queryview-0.0.2/backend/queryview/static/assets/index-CvnC_D68.js +0 -47
  57. queryview-0.0.2/backend/queryview/static/assets/index-Qe7bhycG.css +0 -2
  58. queryview-0.0.2/frontend/README.md +0 -73
  59. queryview-0.0.2/frontend/eslint.config.js +0 -22
  60. queryview-0.0.2/frontend/src/complexCellParsing.test.ts +0 -221
  61. queryview-0.0.2/frontend/src/complexCellParsing.ts +0 -177
  62. queryview-0.0.2/frontend/src/tsv.ts +0 -8
  63. {queryview-0.0.2 → queryview-0.0.4}/.claude/settings.json +0 -0
  64. {queryview-0.0.2 → queryview-0.0.4}/.github/actions/start-git-daemon/action.yaml +0 -0
  65. {queryview-0.0.2 → queryview-0.0.4}/.github/workflows/ci.yml +0 -0
  66. {queryview-0.0.2 → queryview-0.0.4}/.github/workflows/publish.yaml +0 -0
  67. {queryview-0.0.2 → queryview-0.0.4}/.gitignore +0 -0
  68. {queryview-0.0.2 → queryview-0.0.4}/.mcp.json +0 -0
  69. {queryview-0.0.2 → queryview-0.0.4}/.pre-commit-config.yaml +0 -0
  70. {queryview-0.0.2 → queryview-0.0.4}/CLAUDE.md +0 -0
  71. {queryview-0.0.2 → queryview-0.0.4}/LICENSE +0 -0
  72. {queryview-0.0.2 → queryview-0.0.4}/backend/alembic.ini +0 -0
  73. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/__init__.py +0 -0
  74. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/conftest.py +0 -0
  75. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/dashboards.py +0 -0
  76. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/drivers/__init__.py +0 -0
  77. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/drivers/test_contract.py +0 -0
  78. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/gitsync.py +0 -0
  79. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/migrations/env.py +0 -0
  80. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/migrations/script.py.mako +0 -0
  81. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/migrations/versions/9a536b7c0328_initial_schema.py +0 -0
  82. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/migrations/versions/a1b2c3d4e5f6_connection_config_blob.py +0 -0
  83. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/migrations/versions/b2c3d4e5f6a7_predefined_presentation.py +0 -0
  84. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/migrations/versions/c7d8e9f0a1b2_workspaces.py +0 -0
  85. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/queries.py +0 -0
  86. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/remote.py +0 -0
  87. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/static/favicon.svg +0 -0
  88. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_api_db.py +0 -0
  89. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_api_export_import.py +0 -0
  90. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_api_gitsync.py +0 -0
  91. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_api_workspaces.py +0 -0
  92. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_connect_store.py +0 -0
  93. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_dashboards.py +0 -0
  94. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_gitsync.py +0 -0
  95. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_main.py +0 -0
  96. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_mcp_gitsync.py +0 -0
  97. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_migrations.py +0 -0
  98. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_queries.py +0 -0
  99. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_remote.py +0 -0
  100. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_validation.py +0 -0
  101. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_workspaces.py +0 -0
  102. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_yamlio.py +0 -0
  103. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/validation.py +0 -0
  104. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/workspaces.py +0 -0
  105. {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/yamlio.py +0 -0
  106. {queryview-0.0.2 → queryview-0.0.4}/docs/explorer.md +0 -0
  107. {queryview-0.0.2 → queryview-0.0.4}/docs/export-import.md +0 -0
  108. {queryview-0.0.2 → queryview-0.0.4}/docs/future.md +0 -0
  109. {queryview-0.0.2 → queryview-0.0.4}/docs/gitsync.md +0 -0
  110. {queryview-0.0.2 → queryview-0.0.4}/docs/queryview.md +0 -0
  111. {queryview-0.0.2 → queryview-0.0.4}/docs/workspace.md +0 -0
  112. {queryview-0.0.2 → queryview-0.0.4}/e2e/conftest.py +0 -0
  113. {queryview-0.0.2 → queryview-0.0.4}/e2e/test_app.py +0 -0
  114. {queryview-0.0.2 → queryview-0.0.4}/e2e/test_dashboard.py +0 -0
  115. {queryview-0.0.2 → queryview-0.0.4}/e2e/test_explorer.py +0 -0
  116. {queryview-0.0.2 → queryview-0.0.4}/e2e/test_export_import.py +0 -0
  117. {queryview-0.0.2 → queryview-0.0.4}/e2e/test_gitsync.py +0 -0
  118. {queryview-0.0.2 → queryview-0.0.4}/e2e/test_query.py +0 -0
  119. {queryview-0.0.2 → queryview-0.0.4}/e2e/test_remote.py +0 -0
  120. {queryview-0.0.2 → queryview-0.0.4}/e2e/test_workspaces.py +0 -0
  121. {queryview-0.0.2 → queryview-0.0.4}/frontend/.gitignore +0 -0
  122. {queryview-0.0.2 → queryview-0.0.4}/frontend/index.html +0 -0
  123. {queryview-0.0.2 → queryview-0.0.4}/frontend/package.json +0 -0
  124. {queryview-0.0.2 → queryview-0.0.4}/frontend/public/favicon.svg +0 -0
  125. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/compactNumber.test.ts +0 -0
  126. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/compactNumber.ts +0 -0
  127. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app/controls}/Toast.tsx +0 -0
  128. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/drivers.ts +0 -0
  129. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/gitsync.test.ts +0 -0
  130. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/gitsync.ts +0 -0
  131. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/promptSuggestions.test.ts +0 -0
  132. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/promptSuggestions.ts +0 -0
  133. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/sessionLock.test.ts +0 -0
  134. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/sessionLock.ts +0 -0
  135. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/workspace.test.ts +0 -0
  136. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/workspace.ts +0 -0
  137. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/yamlio.test.ts +0 -0
  138. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/yamlio.ts +0 -0
  139. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/core/cells}/CellViewModal.tsx +0 -0
  140. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/core/cells}/cellView.test.ts +0 -0
  141. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/core/cells}/cellView.ts +0 -0
  142. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/core/params}/queryParams.test.ts +0 -0
  143. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/core/params}/queryParams.ts +0 -0
  144. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/core/presentation}/FieldPickers.tsx +0 -0
  145. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/core/presentation}/presentation.test.ts +0 -0
  146. {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/core/presentation}/presentation.ts +0 -0
  147. {queryview-0.0.2 → queryview-0.0.4}/frontend/src/index.css +0 -0
  148. {queryview-0.0.2 → queryview-0.0.4}/frontend/tsconfig.app.json +0 -0
  149. {queryview-0.0.2 → queryview-0.0.4}/frontend/tsconfig.json +0 -0
  150. {queryview-0.0.2 → queryview-0.0.4}/frontend/tsconfig.node.json +0 -0
  151. {queryview-0.0.2 → queryview-0.0.4}/frontend/vite.config.ts +0 -0
  152. {queryview-0.0.2 → queryview-0.0.4}/package-lock.json +0 -0
  153. {queryview-0.0.2 → queryview-0.0.4}/package.json +0 -0
  154. {queryview-0.0.2 → queryview-0.0.4}/pyrightconfig.json +0 -0
  155. {queryview-0.0.2 → queryview-0.0.4}/scripts/dev.sh +0 -0
  156. {queryview-0.0.2 → queryview-0.0.4}/scripts/setup.sh +0 -0
  157. {queryview-0.0.2 → queryview-0.0.4}/scripts/setup_browser.sh +0 -0
  158. {queryview-0.0.2 → queryview-0.0.4}/scripts/setup_clickhouse.sh +0 -0
  159. {queryview-0.0.2 → queryview-0.0.4}/scripts/setup_postgres.sh +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: queryview
3
- Version: 0.0.2
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
@@ -16,6 +16,7 @@ Requires-Dist: duckdb>=1.0
16
16
  Requires-Dist: fastapi>=0.110
17
17
  Requires-Dist: httpx>=0.27
18
18
  Requires-Dist: mcp<2,>=1.9
19
+ Requires-Dist: platformdirs>=4
19
20
  Requires-Dist: pyyaml>=6
20
21
  Requires-Dist: sqlalchemy[asyncio]>=2.0
21
22
  Requires-Dist: sqlmodel>=0.0.16
@@ -46,7 +47,9 @@ docker run -p 8000:8000 ghcr.io/kolodkin/queryview:latest
46
47
  ```
47
48
 
48
49
  To serve on a different host port, remap it (the container keeps listening on
49
- 8000, which its healthcheck probes): `docker run -p 9000:8000 ...`.
50
+ 8000, which its healthcheck probes): `docker run -p 9000:8000 ...`. QueryView
51
+ expects to be reached from localhost only, so prefer binding the published port
52
+ to loopback: `docker run -p 127.0.0.1:8000:8000 ...`.
50
53
 
51
54
  State (the SQLite DB and its encryption key) lives in `/home/queryview`; mount a
52
55
  volume there to persist it across containers:
@@ -154,14 +157,34 @@ An installed wheel serves the bundled UI by default — see
154
157
  ## MCP server
155
158
 
156
159
  The backend mounts a FastMCP server (Streamable HTTP) at
157
- `http://localhost:8000/mcp`. There is nothing extra to start — it runs inside
158
- the server process (`uvx queryview`, `npm run dev`, ...). Hook up an MCP client,
159
- e.g.:
160
+ `http://localhost:8000/mcp/`. There is nothing extra to start — it runs inside
161
+ the server process (`uvx queryview`, `npm run dev`, ...). Registering the client
162
+ is a separate, one-time step on the machine running the agent: an HTTP MCP
163
+ server can't install itself into someone else's client.
160
164
 
161
165
  ```bash
162
- claude mcp add --transport http queryview http://localhost:8000/mcp
166
+ claude mcp add --transport http queryview http://localhost:8000/mcp/
163
167
  ```
164
168
 
169
+ Three things to get right:
170
+
171
+ - **Prefer the trailing slash.** The mount serves `/mcp/`. The slashless
172
+ `/mcp` also works — it 307-redirects — but registering the canonical path
173
+ skips a round trip on every call.
174
+ - **Match the port.** The URL must point at the port QueryView actually
175
+ listens on — `--port 9000` means `http://localhost:9000/mcp/`, and
176
+ `docker run -p 9000:8000` means the *host* port, `9000`, not the container's
177
+ `8000`.
178
+ - **Start QueryView first.** The client dials this URL when it starts; if
179
+ nothing is listening it reports a connection error and stays failed until you
180
+ reconnect it.
181
+
182
+ QueryView is a **local, single-user tool**: it assumes it is reachable only from
183
+ localhost. `/mcp/` is unauthenticated, and its tools can query every configured
184
+ connection and rewrite workspace git state, so don't publish the port. Bind the
185
+ container to loopback — `docker run -p 127.0.0.1:8000:8000 ...` — since a plain
186
+ `-p 8000:8000` listens on all interfaces.
187
+
165
188
  Tools: `run_query` (read-only SQL, rows returned to the agent), `push_query`
166
189
  and `push_dashboard` (fill a live browser session), `list_queries` /
167
190
  `list_dashboards`, and `git_store` / `git_history` / `git_restore` (workspace
@@ -178,6 +201,10 @@ The single-page prompt UI is described in [docs/queryview.md](docs/queryview.md)
178
201
  connecting (`new <type>` / `connect <name>`), SQLite persistence, and session
179
202
  auto-connect are specified in [docs/connect.md](docs/connect.md).
180
203
 
181
- Connections are stored in SQLite (`backend/queryview.db`, override with
182
- `DB_PATH`); the backend writes that file and a local password-encryption key
183
- (`backend/queryview.db.key`, override with `DB_KEY_PATH`).
204
+ Connections are stored in SQLite. The default location is the platform's
205
+ user-data directory — `$XDG_DATA_HOME/queryview/queryview.db` (i.e.
206
+ `~/.local/share/queryview/`) on Linux, `~/Library/Application Support/queryview/`
207
+ on macOS, `%LOCALAPPDATA%\queryview\` on Windows — overridable with `DB_PATH`.
208
+ Alongside it the backend writes a local password-encryption key
209
+ (`<db>.key`, override with `DB_KEY_PATH`) and the workspace git-sync clones
210
+ (`<db>.gitsync/`, override with `GIT_SYNC_DIR`).
@@ -22,7 +22,9 @@ docker run -p 8000:8000 ghcr.io/kolodkin/queryview:latest
22
22
  ```
23
23
 
24
24
  To serve on a different host port, remap it (the container keeps listening on
25
- 8000, which its healthcheck probes): `docker run -p 9000:8000 ...`.
25
+ 8000, which its healthcheck probes): `docker run -p 9000:8000 ...`. QueryView
26
+ expects to be reached from localhost only, so prefer binding the published port
27
+ to loopback: `docker run -p 127.0.0.1:8000:8000 ...`.
26
28
 
27
29
  State (the SQLite DB and its encryption key) lives in `/home/queryview`; mount a
28
30
  volume there to persist it across containers:
@@ -130,14 +132,34 @@ An installed wheel serves the bundled UI by default — see
130
132
  ## MCP server
131
133
 
132
134
  The backend mounts a FastMCP server (Streamable HTTP) at
133
- `http://localhost:8000/mcp`. There is nothing extra to start — it runs inside
134
- the server process (`uvx queryview`, `npm run dev`, ...). Hook up an MCP client,
135
- e.g.:
135
+ `http://localhost:8000/mcp/`. There is nothing extra to start — it runs inside
136
+ the server process (`uvx queryview`, `npm run dev`, ...). Registering the client
137
+ is a separate, one-time step on the machine running the agent: an HTTP MCP
138
+ server can't install itself into someone else's client.
136
139
 
137
140
  ```bash
138
- claude mcp add --transport http queryview http://localhost:8000/mcp
141
+ claude mcp add --transport http queryview http://localhost:8000/mcp/
139
142
  ```
140
143
 
144
+ Three things to get right:
145
+
146
+ - **Prefer the trailing slash.** The mount serves `/mcp/`. The slashless
147
+ `/mcp` also works — it 307-redirects — but registering the canonical path
148
+ skips a round trip on every call.
149
+ - **Match the port.** The URL must point at the port QueryView actually
150
+ listens on — `--port 9000` means `http://localhost:9000/mcp/`, and
151
+ `docker run -p 9000:8000` means the *host* port, `9000`, not the container's
152
+ `8000`.
153
+ - **Start QueryView first.** The client dials this URL when it starts; if
154
+ nothing is listening it reports a connection error and stays failed until you
155
+ reconnect it.
156
+
157
+ QueryView is a **local, single-user tool**: it assumes it is reachable only from
158
+ localhost. `/mcp/` is unauthenticated, and its tools can query every configured
159
+ connection and rewrite workspace git state, so don't publish the port. Bind the
160
+ container to loopback — `docker run -p 127.0.0.1:8000:8000 ...` — since a plain
161
+ `-p 8000:8000` listens on all interfaces.
162
+
141
163
  Tools: `run_query` (read-only SQL, rows returned to the agent), `push_query`
142
164
  and `push_dashboard` (fill a live browser session), `list_queries` /
143
165
  `list_dashboards`, and `git_store` / `git_history` / `git_restore` (workspace
@@ -154,6 +176,10 @@ The single-page prompt UI is described in [docs/queryview.md](docs/queryview.md)
154
176
  connecting (`new <type>` / `connect <name>`), SQLite persistence, and session
155
177
  auto-connect are specified in [docs/connect.md](docs/connect.md).
156
178
 
157
- Connections are stored in SQLite (`backend/queryview.db`, override with
158
- `DB_PATH`); the backend writes that file and a local password-encryption key
159
- (`backend/queryview.db.key`, override with `DB_KEY_PATH`).
179
+ Connections are stored in SQLite. The default location is the platform's
180
+ user-data directory — `$XDG_DATA_HOME/queryview/queryview.db` (i.e.
181
+ `~/.local/share/queryview/`) on Linux, `~/Library/Application Support/queryview/`
182
+ on macOS, `%LOCALAPPDATA%\queryview\` on Windows — overridable with `DB_PATH`.
183
+ Alongside it the backend writes a local password-encryption key
184
+ (`<db>.key`, override with `DB_KEY_PATH`) and the workspace git-sync clones
185
+ (`<db>.gitsync/`, override with `GIT_SYNC_DIR`).
@@ -14,6 +14,7 @@ from dataclasses import dataclass
14
14
  from pathlib import Path
15
15
  from typing import TYPE_CHECKING, Any, ClassVar
16
16
 
17
+ import platformdirs
17
18
  from cryptography.hazmat.primitives.ciphers.aead import AESGCM
18
19
  from sqlalchemy.ext.asyncio import create_async_engine
19
20
  from sqlmodel import Field, SQLModel, col, select
@@ -40,10 +41,14 @@ class Connection(SQLModel, table=True):
40
41
 
41
42
 
42
43
  def _db_path() -> Path:
44
+ """The SQLite store's path: DB_PATH, else the platform's user-data dir
45
+ (`$XDG_DATA_HOME/queryview` on Linux, `Application Support` on macOS,
46
+ `%LOCALAPPDATA%` on Windows). Deliberately not package-relative — that put
47
+ the DB in site-packages, which is uv's disposable cache under `uvx`."""
43
48
  env = os.environ.get("DB_PATH")
44
49
  if env:
45
50
  return Path(env)
46
- return Path(__file__).resolve().parent.parent / "queryview.db"
51
+ return Path(platformdirs.user_data_dir("queryview")) / "queryview.db"
47
52
 
48
53
 
49
54
  _engine = None
@@ -80,6 +85,9 @@ async def _ensure_schema() -> None:
80
85
  return
81
86
  from alembic import command
82
87
 
88
+ # SQLite won't create a missing parent, and the user-data dir doesn't exist
89
+ # on a fresh install; the key file and git-sync clones land here too.
90
+ _db_path().parent.mkdir(parents=True, exist_ok=True)
83
91
  command.upgrade(_alembic_config(), "head")
84
92
  _schema_ready = True
85
93
 
@@ -414,15 +422,32 @@ async def run_query(
414
422
  sql: str,
415
423
  limit: int,
416
424
  offset: int,
417
- fmt: str,
418
425
  order_by: list[dict[str, Any]] | None = None,
419
426
  ) -> 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'."""
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."""
422
430
  s, err = await _gated_session(sid)
423
431
  if s is None:
424
432
  return err # type: ignore[return-value]
425
- 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)
426
451
  if not r.ok:
427
452
  return {"ok": False, "message": r.value}
428
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