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.
- {queryview-0.0.2 → queryview-0.0.4}/PKG-INFO +36 -9
- {queryview-0.0.2 → queryview-0.0.4}/README.md +34 -8
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/connect.py +30 -5
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/dashboard_queries.py +12 -31
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/drivers/base.py +86 -18
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/drivers/clickhouse.py +47 -8
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/drivers/duckdb.py +36 -12
- {queryview-0.0.2 → 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.2 → queryview-0.0.4}/backend/queryview/drivers/test_duckdb.py +14 -7
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/drivers/test_postgres.py +8 -2
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/main.py +23 -4
- {queryview-0.0.2 → 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.2 → queryview-0.0.4}/backend/queryview/static/index.html +2 -2
- {queryview-0.0.2 → 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.4/backend/queryview/test_db_path.py +58 -0
- queryview-0.0.4/backend/queryview/test_mcp_mount.py +38 -0
- {queryview-0.0.2 → queryview-0.0.4}/docker/Dockerfile +4 -3
- {queryview-0.0.2 → queryview-0.0.4}/docs/api.md +6 -5
- {queryview-0.0.2 → queryview-0.0.4}/docs/connect.md +1 -1
- {queryview-0.0.2 → queryview-0.0.4}/docs/dashboard.md +3 -0
- {queryview-0.0.2 → queryview-0.0.4}/docs/query.md +25 -21
- {queryview-0.0.2 → queryview-0.0.4}/docs/remote.md +12 -2
- {queryview-0.0.2 → 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.2/frontend/src → queryview-0.0.4/frontend/src/app}/App.tsx +4 -3
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/DashboardView.tsx +8 -31
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/ExplorerView.tsx +16 -12
- {queryview-0.0.2/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.2/frontend/src → queryview-0.0.4/frontend/src/app/controls}/ExportImportControls.tsx +2 -2
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app/controls}/GitSyncControls.tsx +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app/controls}/WorkspaceSwitcher.tsx +2 -2
- {queryview-0.0.2/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.2/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.2 → queryview-0.0.4}/frontend/src/main.tsx +1 -1
- {queryview-0.0.2 → queryview-0.0.4}/pyproject.toml +1 -0
- {queryview-0.0.2 → queryview-0.0.4}/uv.lock +2 -0
- queryview-0.0.2/backend/queryview/drivers/test_base.py +0 -49
- queryview-0.0.2/backend/queryview/drivers/test_clickhouse.py +0 -53
- queryview-0.0.2/backend/queryview/static/assets/index-CvnC_D68.js +0 -47
- queryview-0.0.2/backend/queryview/static/assets/index-Qe7bhycG.css +0 -2
- queryview-0.0.2/frontend/README.md +0 -73
- queryview-0.0.2/frontend/eslint.config.js +0 -22
- queryview-0.0.2/frontend/src/complexCellParsing.test.ts +0 -221
- queryview-0.0.2/frontend/src/complexCellParsing.ts +0 -177
- queryview-0.0.2/frontend/src/tsv.ts +0 -8
- {queryview-0.0.2 → queryview-0.0.4}/.claude/settings.json +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/.github/actions/start-git-daemon/action.yaml +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/.github/workflows/ci.yml +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/.github/workflows/publish.yaml +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/.gitignore +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/.mcp.json +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/.pre-commit-config.yaml +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/CLAUDE.md +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/LICENSE +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/alembic.ini +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/__init__.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/conftest.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/dashboards.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/drivers/__init__.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/drivers/test_contract.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/gitsync.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/migrations/env.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/migrations/script.py.mako +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/migrations/versions/9a536b7c0328_initial_schema.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/migrations/versions/a1b2c3d4e5f6_connection_config_blob.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/migrations/versions/b2c3d4e5f6a7_predefined_presentation.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/migrations/versions/c7d8e9f0a1b2_workspaces.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/queries.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/remote.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/static/favicon.svg +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_api_db.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_api_export_import.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_api_gitsync.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_api_workspaces.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_connect_store.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_dashboards.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_gitsync.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_main.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_mcp_gitsync.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_migrations.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_queries.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_remote.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_validation.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_workspaces.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/test_yamlio.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/validation.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/workspaces.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/backend/queryview/yamlio.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/docs/explorer.md +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/docs/export-import.md +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/docs/future.md +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/docs/gitsync.md +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/docs/queryview.md +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/docs/workspace.md +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/e2e/conftest.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/e2e/test_app.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/e2e/test_dashboard.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/e2e/test_explorer.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/e2e/test_export_import.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/e2e/test_gitsync.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/e2e/test_query.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/e2e/test_remote.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/e2e/test_workspaces.py +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/frontend/.gitignore +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/frontend/index.html +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/frontend/package.json +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/frontend/public/favicon.svg +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/compactNumber.test.ts +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/compactNumber.ts +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app/controls}/Toast.tsx +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/drivers.ts +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/gitsync.test.ts +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/gitsync.ts +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/promptSuggestions.test.ts +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/promptSuggestions.ts +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/sessionLock.test.ts +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/sessionLock.ts +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/workspace.test.ts +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/workspace.ts +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/yamlio.test.ts +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/app}/yamlio.ts +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/core/cells}/CellViewModal.tsx +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/core/cells}/cellView.test.ts +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/core/cells}/cellView.ts +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/core/params}/queryParams.test.ts +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/core/params}/queryParams.ts +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/core/presentation}/FieldPickers.tsx +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/core/presentation}/presentation.test.ts +0 -0
- {queryview-0.0.2/frontend/src → queryview-0.0.4/frontend/src/core/presentation}/presentation.ts +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/frontend/src/index.css +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/frontend/tsconfig.app.json +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/frontend/tsconfig.json +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/frontend/tsconfig.node.json +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/frontend/vite.config.ts +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/package-lock.json +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/package.json +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/pyrightconfig.json +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/scripts/dev.sh +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/scripts/setup.sh +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/scripts/setup_browser.sh +0 -0
- {queryview-0.0.2 → queryview-0.0.4}/scripts/setup_clickhouse.sh +0 -0
- {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.
|
|
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
|
|
158
|
-
the server process (`uvx queryview`, `npm run dev`, ...).
|
|
159
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
134
|
-
the server process (`uvx queryview`, `npm run dev`, ...).
|
|
135
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
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(
|
|
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
|
|
421
|
-
|
|
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
|
|
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,
|
|
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
|