queryview 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- queryview/__init__.py +0 -0
- queryview/conftest.py +86 -0
- queryview/connect.py +428 -0
- queryview/dashboard_queries.py +75 -0
- queryview/dashboards.py +156 -0
- queryview/drivers/__init__.py +10 -0
- queryview/drivers/base.py +165 -0
- queryview/drivers/clickhouse.py +138 -0
- queryview/drivers/duckdb.py +153 -0
- queryview/drivers/postgres.py +166 -0
- queryview/drivers/test_base.py +49 -0
- queryview/drivers/test_clickhouse.py +53 -0
- queryview/drivers/test_contract.py +77 -0
- queryview/drivers/test_duckdb.py +80 -0
- queryview/drivers/test_postgres.py +80 -0
- queryview/gitsync.py +374 -0
- queryview/main.py +740 -0
- queryview/mcp_server.py +294 -0
- queryview/migrations/env.py +39 -0
- queryview/migrations/script.py.mako +29 -0
- queryview/migrations/versions/9a536b7c0328_initial_schema.py +89 -0
- queryview/migrations/versions/a1b2c3d4e5f6_connection_config_blob.py +59 -0
- queryview/migrations/versions/b2c3d4e5f6a7_predefined_presentation.py +32 -0
- queryview/migrations/versions/c7d8e9f0a1b2_workspaces.py +98 -0
- queryview/queries.py +159 -0
- queryview/remote.py +141 -0
- queryview/static/assets/index-CvnC_D68.js +47 -0
- queryview/static/assets/index-Qe7bhycG.css +2 -0
- queryview/static/favicon.svg +1 -0
- queryview/static/index.html +14 -0
- queryview/test_api_db.py +51 -0
- queryview/test_api_export_import.py +85 -0
- queryview/test_api_gitsync.py +83 -0
- queryview/test_api_workspaces.py +44 -0
- queryview/test_connect_flow.py +123 -0
- queryview/test_connect_store.py +34 -0
- queryview/test_dashboards.py +216 -0
- queryview/test_gitsync.py +346 -0
- queryview/test_main.py +18 -0
- queryview/test_mcp_gitsync.py +72 -0
- queryview/test_migrations.py +99 -0
- queryview/test_queries.py +170 -0
- queryview/test_remote.py +260 -0
- queryview/test_validation.py +87 -0
- queryview/test_workspaces.py +109 -0
- queryview/test_yamlio.py +198 -0
- queryview/validation.py +111 -0
- queryview/workspaces.py +167 -0
- queryview/yamlio.py +245 -0
- queryview-0.0.2.dist-info/METADATA +183 -0
- queryview-0.0.2.dist-info/RECORD +54 -0
- queryview-0.0.2.dist-info/WHEEL +4 -0
- queryview-0.0.2.dist-info/entry_points.txt +3 -0
- queryview-0.0.2.dist-info/licenses/LICENSE +21 -0
queryview/mcp_server.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""The MCP layer: a FastMCP server (mounted by main.py at /mcp) whose tools push
|
|
2
|
+
queries/dashboards to a live QueryView browser session, delegating to the
|
|
3
|
+
in-process remote.py hub."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from mcp.server.fastmcp import FastMCP
|
|
10
|
+
|
|
11
|
+
from . import gitsync, remote
|
|
12
|
+
from .dashboards import _push_dashboard
|
|
13
|
+
from .queries import list_predefined_queries_view
|
|
14
|
+
|
|
15
|
+
mcp = FastMCP("queryview", stateless_http=True)
|
|
16
|
+
mcp.settings.streamable_http_path = "/"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@mcp.tool()
|
|
20
|
+
async def push_query(
|
|
21
|
+
session_id: str,
|
|
22
|
+
query: str,
|
|
23
|
+
limit: int = 100,
|
|
24
|
+
offset: int = 0,
|
|
25
|
+
order_by: list[dict[str, Any]] | None = None,
|
|
26
|
+
fields: list[str] | None = None,
|
|
27
|
+
cell_view: str | None = None,
|
|
28
|
+
name: str | None = None,
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
"""Push a SQL query to a live QueryView browser session.
|
|
31
|
+
|
|
32
|
+
The targeted browser fills its query panel and auto-runs the query. Get the
|
|
33
|
+
`session_id` from the QueryView UI: the agent icon next to the connection
|
|
34
|
+
status pill, after enabling "Allow remote control".
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
session_id: The session id shown in the QueryView popover.
|
|
38
|
+
query: The SQL to run.
|
|
39
|
+
limit: Page size (default 100).
|
|
40
|
+
offset: Row offset (default 0).
|
|
41
|
+
order_by: Optional sort, e.g. [{"name": "id", "dir": "DESC"}].
|
|
42
|
+
fields: Optional column names to display; omit to show all columns.
|
|
43
|
+
cell_view: Optional raw cell-view YAML (column -> {type, value}) applied
|
|
44
|
+
to this pushed result only, e.g. to render a column as a custom
|
|
45
|
+
HTML token. Same format as the "Cell view" modal; see docs/query.md.
|
|
46
|
+
Unlike the modal it isn't persisted — it rides with this one push and
|
|
47
|
+
overrides any selected predefined query's saved cell_view.
|
|
48
|
+
name: Optional predefined-query name to select in the dropdown (e.g.
|
|
49
|
+
"findings sources"). Selection only — nothing is persisted. When the
|
|
50
|
+
name matches a saved query and no `cell_view` is given, the pushed
|
|
51
|
+
result renders with that query's saved cell_view.
|
|
52
|
+
"""
|
|
53
|
+
payload = {
|
|
54
|
+
"type": "query",
|
|
55
|
+
"query": query,
|
|
56
|
+
"limit": limit,
|
|
57
|
+
"offset": offset,
|
|
58
|
+
"order_by": order_by,
|
|
59
|
+
"fields": fields,
|
|
60
|
+
"cell_view": cell_view,
|
|
61
|
+
"name": name,
|
|
62
|
+
}
|
|
63
|
+
ok, message = remote.push(session_id, payload)
|
|
64
|
+
return {"ok": ok, "message": message, "database": remote.session_database(session_id)}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _columns_to_rows(cols: dict[str, list]) -> dict[str, Any]:
|
|
68
|
+
"""Turn a column-oriented map {col: [values, ...]} into
|
|
69
|
+
{"columns": [...], "rows": [[...], ...]} for agent-friendly row output."""
|
|
70
|
+
names = list(cols.keys())
|
|
71
|
+
n = len(next(iter(cols.values()), []))
|
|
72
|
+
return {"columns": names, "rows": [[cols[c][i] for c in names] for i in range(n)]}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@mcp.tool()
|
|
76
|
+
async def run_query(
|
|
77
|
+
query: str,
|
|
78
|
+
connection: str = "clickhouse",
|
|
79
|
+
limit: int = 1000,
|
|
80
|
+
offset: int = 0,
|
|
81
|
+
) -> dict[str, Any]:
|
|
82
|
+
"""Run a read-only SQL query against a saved connection and return the rows
|
|
83
|
+
to the agent (unlike push_query, which only fills a browser panel).
|
|
84
|
+
|
|
85
|
+
Use this to explore schema (system.tables / system.columns) and inspect data
|
|
86
|
+
before building a dashboard or a push_query. `limit`/`offset` page the result
|
|
87
|
+
(default 1000 / 0; limit is capped at 10000). Returns
|
|
88
|
+
{"ok": True, "connection": ..., "database": ..., "columns": [...],
|
|
89
|
+
"rows": [[...], ...]}, or {"ok": False, "message": ...}.
|
|
90
|
+
`database` is the connection's currently-selected database (the user can
|
|
91
|
+
change it from the connection pill), so check it before deciding whether to
|
|
92
|
+
fully-qualify tables as db.table.
|
|
93
|
+
"""
|
|
94
|
+
from .connect import _connection_by_name
|
|
95
|
+
from .dashboard_queries import run_queries_for_connection
|
|
96
|
+
from .validation import MAX_LIMIT
|
|
97
|
+
|
|
98
|
+
limit = max(0, min(limit, MAX_LIMIT))
|
|
99
|
+
offset = max(0, offset)
|
|
100
|
+
r = await run_queries_for_connection(connection, {"q": query}, limit=limit, offset=offset)
|
|
101
|
+
if not r["ok"]:
|
|
102
|
+
return {"ok": False, "message": r["message"]}
|
|
103
|
+
stored = await _connection_by_name(connection)
|
|
104
|
+
return {
|
|
105
|
+
"ok": True,
|
|
106
|
+
"connection": connection,
|
|
107
|
+
"database": stored.database if stored else None,
|
|
108
|
+
**_columns_to_rows(r["results"]["q"]),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def _session_workspace_rec(session_id: str | None):
|
|
113
|
+
"""The workspace the given armed session is on (reported by the browser),
|
|
114
|
+
else the default workspace. MCP is deliberately workspace-unaware: the
|
|
115
|
+
human picks the workspace in the UI; the agent works in session scope."""
|
|
116
|
+
from . import workspaces
|
|
117
|
+
|
|
118
|
+
name = remote.session_workspace(session_id) if session_id else None
|
|
119
|
+
return await workspaces.resolve(name or workspaces.DEFAULT_WORKSPACE)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@mcp.tool()
|
|
123
|
+
async def list_queries(conn_type: str = "clickhouse", session_id: str | None = None) -> dict[str, Any]:
|
|
124
|
+
"""List the saved (predefined) queries for a connection type.
|
|
125
|
+
|
|
126
|
+
These are reusable queries a human has saved, shared per connection type
|
|
127
|
+
(default "clickhouse") within a workspace. Returns
|
|
128
|
+
{"queries": [{query_name, query, cell_view, order_by, fields}]}, where
|
|
129
|
+
order_by is [{"name","dir"}] or null, fields is ["col", ...] or null, and
|
|
130
|
+
cell_view is raw YAML or null. Pass a query_name back as push_query's `name`
|
|
131
|
+
to select that query in the browser (its saved presentation then applies).
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
conn_type: Connection type to list queries for.
|
|
135
|
+
session_id: Optional armed-session id; scopes the list to that
|
|
136
|
+
session's workspace (default workspace otherwise).
|
|
137
|
+
"""
|
|
138
|
+
ws = await _session_workspace_rec(session_id)
|
|
139
|
+
return {"queries": await list_predefined_queries_view(conn_type, ws.id)}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@mcp.tool()
|
|
143
|
+
async def list_dashboards(session_id: str | None = None) -> dict[str, Any]:
|
|
144
|
+
"""List the saved dashboards (names + metadata, no HTML/queries payload).
|
|
145
|
+
|
|
146
|
+
Use this to discover dashboard names — e.g. to pick a `name` for
|
|
147
|
+
git_store/git_history/git_restore with kind="dashboard", or to avoid
|
|
148
|
+
overwriting an existing dashboard when pushing a new one.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
session_id: Optional armed-session id; scopes the list to that
|
|
152
|
+
session's workspace (default workspace otherwise).
|
|
153
|
+
|
|
154
|
+
Returns {"dashboards": [{name, connection, updated_at}]}; updated_at is
|
|
155
|
+
unix ms.
|
|
156
|
+
"""
|
|
157
|
+
from .dashboards import list_dashboards as _list_dashboards
|
|
158
|
+
|
|
159
|
+
ws = await _session_workspace_rec(session_id)
|
|
160
|
+
return {"dashboards": await _list_dashboards(ws.id)}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@mcp.tool()
|
|
164
|
+
async def push_dashboard(
|
|
165
|
+
session_id: str,
|
|
166
|
+
name: str,
|
|
167
|
+
connection: str,
|
|
168
|
+
html: str,
|
|
169
|
+
queries: dict[str, str],
|
|
170
|
+
) -> dict[str, Any]:
|
|
171
|
+
"""Push a dashboard DRAFT to a live QueryView session (does not persist).
|
|
172
|
+
|
|
173
|
+
The dashboard renders immediately in the browser, but nothing is written to
|
|
174
|
+
the store — only the user's **Save** button in the dashboard view persists
|
|
175
|
+
it, mirroring how push_query drafts a query for the user to Save. Re-push to
|
|
176
|
+
update the live draft. The dashboard's queries run against the named
|
|
177
|
+
connection.
|
|
178
|
+
|
|
179
|
+
The browser consumes the results, not the agent: the HTML reads them from a
|
|
180
|
+
`window.queries` global, a column-oriented map
|
|
181
|
+
`{query_name: {column_name: [values, …]}}` — e.g.
|
|
182
|
+
`window.queries.sales.revenue`. Load chart libraries from a CDN if needed.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
session_id: The session id shown in the QueryView popover.
|
|
186
|
+
name: Dashboard name (the name the user's Save will persist under).
|
|
187
|
+
connection: Saved connection name the queries run against.
|
|
188
|
+
html: The dashboard HTML document (renders in a sandboxed iframe).
|
|
189
|
+
queries: Map of query name to SQL.
|
|
190
|
+
|
|
191
|
+
Returns {ok, pushed, message}.
|
|
192
|
+
"""
|
|
193
|
+
pushed, message = await _push_dashboard(name, connection, html, queries, session_id or None)
|
|
194
|
+
return {
|
|
195
|
+
"ok": pushed,
|
|
196
|
+
"pushed": pushed,
|
|
197
|
+
"message": message,
|
|
198
|
+
"database": remote.session_database(session_id),
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def _git_tool(coro) -> dict[str, Any]:
|
|
203
|
+
"""Await a gitsync operation, mapping its result (or GitSyncError) to the
|
|
204
|
+
tool result — the one place the git-tool error contract lives."""
|
|
205
|
+
try:
|
|
206
|
+
return {"ok": True, **(await coro)}
|
|
207
|
+
except gitsync.GitSyncError as e:
|
|
208
|
+
return {"ok": False, "message": str(e)}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@mcp.tool()
|
|
212
|
+
async def git_store(
|
|
213
|
+
kind: str,
|
|
214
|
+
name: str,
|
|
215
|
+
conn_type: str | None = None,
|
|
216
|
+
message: str | None = None,
|
|
217
|
+
session_id: str | None = None,
|
|
218
|
+
) -> dict[str, Any]:
|
|
219
|
+
"""Back up one predefined query or dashboard to its workspace's git remote.
|
|
220
|
+
|
|
221
|
+
Exports the entity's saved DB state as files, makes one commit, and pushes.
|
|
222
|
+
Requires the target workspace to have a git remote configured. Versions are
|
|
223
|
+
git commits — use git_history to list them and git_restore to roll back.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
kind: "query" or "dashboard".
|
|
227
|
+
name: The entity's name.
|
|
228
|
+
conn_type: The query's connection type (e.g. "clickhouse"); required
|
|
229
|
+
for kind="query", ignored for dashboards.
|
|
230
|
+
message: Optional commit message (default: "store query {conn_type}/{name}"
|
|
231
|
+
for queries, "store dashboard {name}" for dashboards).
|
|
232
|
+
session_id: Optional armed-session id; the operation runs in that
|
|
233
|
+
session's workspace (default workspace otherwise).
|
|
234
|
+
|
|
235
|
+
Returns {ok, committed, sha, message}; committed=False with "no changes"
|
|
236
|
+
when the repo already matches the DB.
|
|
237
|
+
"""
|
|
238
|
+
ws = await _session_workspace_rec(session_id)
|
|
239
|
+
return await _git_tool(gitsync.store(ws, kind, name, conn_type, message))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@mcp.tool()
|
|
243
|
+
async def git_history(
|
|
244
|
+
kind: str,
|
|
245
|
+
name: str,
|
|
246
|
+
conn_type: str | None = None,
|
|
247
|
+
before: str | None = None,
|
|
248
|
+
limit: int = 10,
|
|
249
|
+
session_id: str | None = None,
|
|
250
|
+
) -> dict[str, Any]:
|
|
251
|
+
"""List an entity's backup revisions (commits touching it), newest first.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
kind: "query" or "dashboard".
|
|
255
|
+
name: The entity's name.
|
|
256
|
+
conn_type: The query's connection type; required for kind="query".
|
|
257
|
+
before: A sha from a previous page; returns strictly older revisions.
|
|
258
|
+
limit: Page size (default 10).
|
|
259
|
+
session_id: Optional armed-session id; the operation runs in that
|
|
260
|
+
session's workspace (default workspace otherwise).
|
|
261
|
+
|
|
262
|
+
Returns {ok, revisions: [{sha, date, message}], has_more}; date is unix ms.
|
|
263
|
+
Pass a sha to git_restore's `ref` to restore that revision.
|
|
264
|
+
"""
|
|
265
|
+
ws = await _session_workspace_rec(session_id)
|
|
266
|
+
return await _git_tool(gitsync.history(ws, kind, name, conn_type, before, limit))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@mcp.tool()
|
|
270
|
+
async def git_restore(
|
|
271
|
+
kind: str,
|
|
272
|
+
name: str,
|
|
273
|
+
conn_type: str | None = None,
|
|
274
|
+
ref: str | None = None,
|
|
275
|
+
session_id: str | None = None,
|
|
276
|
+
) -> dict[str, Any]:
|
|
277
|
+
"""Overwrite the local DB copy of one query/dashboard with a git revision.
|
|
278
|
+
|
|
279
|
+
Reads the entity's files at `ref` (default: the remote branch head) and
|
|
280
|
+
upserts them into the store. Git history is untouched — HEAD never moves;
|
|
281
|
+
a later git_store simply commits on top.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
kind: "query" or "dashboard".
|
|
285
|
+
name: The entity's name.
|
|
286
|
+
conn_type: The query's connection type; required for kind="query".
|
|
287
|
+
ref: A sha from git_history (default: latest backup).
|
|
288
|
+
session_id: Optional armed-session id; the operation runs in that
|
|
289
|
+
session's workspace (default workspace otherwise).
|
|
290
|
+
|
|
291
|
+
Returns {ok, restored, sha} or {ok: False, message}.
|
|
292
|
+
"""
|
|
293
|
+
ws = await _session_workspace_rec(session_id)
|
|
294
|
+
return await _git_tool(gitsync.restore(ws, kind, name, conn_type, ref))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Alembic environment. Uses a *sync* SQLite engine (separate from the app's
|
|
2
|
+
async aiosqlite runtime, to avoid async-greenlet complexity). Importing the model
|
|
3
|
+
modules populates SQLModel.metadata so autogenerate sees every table;
|
|
4
|
+
render_as_batch=True is required for SQLite's limited ALTER TABLE.
|
|
5
|
+
|
|
6
|
+
Online migrations only — the app and dev CLI both run `alembic upgrade` against a
|
|
7
|
+
real connection; offline (`--sql`) mode isn't used."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# Import for side effects: register all tables on SQLModel.metadata.
|
|
12
|
+
import queryview.connect # noqa: F401
|
|
13
|
+
import queryview.dashboards # noqa: F401
|
|
14
|
+
import queryview.queries # noqa: F401
|
|
15
|
+
from alembic import context
|
|
16
|
+
from sqlalchemy import create_engine, pool
|
|
17
|
+
from sqlmodel import SQLModel
|
|
18
|
+
|
|
19
|
+
config = context.config
|
|
20
|
+
target_metadata = SQLModel.metadata
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _url() -> str:
|
|
24
|
+
"""Prefer the URL the app injects; fall back to DB_PATH so the dev CLI
|
|
25
|
+
(`alembic ...`) targets the same SQLite file the app would."""
|
|
26
|
+
from queryview.connect import _db_path
|
|
27
|
+
|
|
28
|
+
return config.get_main_option("sqlalchemy.url") or f"sqlite:///{_db_path()}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
connectable = create_engine(_url(), poolclass=pool.NullPool)
|
|
32
|
+
with connectable.connect() as connection:
|
|
33
|
+
context.configure(
|
|
34
|
+
connection=connection,
|
|
35
|
+
target_metadata=target_metadata,
|
|
36
|
+
render_as_batch=True,
|
|
37
|
+
)
|
|
38
|
+
with context.begin_transaction():
|
|
39
|
+
context.run_migrations()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
Create Date: ${create_date}
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Sequence, Union
|
|
11
|
+
|
|
12
|
+
from alembic import op
|
|
13
|
+
import sqlalchemy as sa
|
|
14
|
+
import sqlmodel
|
|
15
|
+
${imports if imports else ""}
|
|
16
|
+
|
|
17
|
+
# revision identifiers, used by Alembic.
|
|
18
|
+
revision: str = ${repr(up_revision)}
|
|
19
|
+
down_revision: Union[str, None] = ${repr(down_revision)}
|
|
20
|
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
|
21
|
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def upgrade() -> None:
|
|
25
|
+
${upgrades if upgrades else "pass"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def downgrade() -> None:
|
|
29
|
+
${downgrades if downgrades else "pass"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""initial schema
|
|
2
|
+
|
|
3
|
+
Revision ID: 9a536b7c0328
|
|
4
|
+
Revises:
|
|
5
|
+
Create Date: 2026-06-03 15:30:03.750791
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
import sqlalchemy as sa
|
|
14
|
+
import sqlmodel
|
|
15
|
+
from alembic import op
|
|
16
|
+
|
|
17
|
+
# revision identifiers, used by Alembic.
|
|
18
|
+
revision: str = "9a536b7c0328"
|
|
19
|
+
down_revision: str | None = None
|
|
20
|
+
branch_labels: str | Sequence[str] | None = None
|
|
21
|
+
depends_on: str | Sequence[str] | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def upgrade() -> None:
|
|
25
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
26
|
+
op.create_table(
|
|
27
|
+
"connections",
|
|
28
|
+
sa.Column("id", sa.Integer(), nullable=False),
|
|
29
|
+
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
|
30
|
+
sa.Column("type", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
|
31
|
+
sa.Column("host", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
|
32
|
+
sa.Column("port", sa.Integer(), nullable=False),
|
|
33
|
+
sa.Column("username", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
|
34
|
+
sa.Column("password", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
|
35
|
+
sa.Column("database", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
|
36
|
+
sa.Column("last_active_at", sa.Integer(), nullable=False),
|
|
37
|
+
sa.PrimaryKeyConstraint("id"),
|
|
38
|
+
)
|
|
39
|
+
with op.batch_alter_table("connections", schema=None) as batch_op:
|
|
40
|
+
batch_op.create_index(batch_op.f("ix_connections_name"), ["name"], unique=True)
|
|
41
|
+
batch_op.create_index(batch_op.f("ix_connections_type"), ["type"], unique=False)
|
|
42
|
+
|
|
43
|
+
op.create_table(
|
|
44
|
+
"dashboards",
|
|
45
|
+
sa.Column("id", sa.Integer(), nullable=False),
|
|
46
|
+
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
|
47
|
+
sa.Column("connection", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
|
48
|
+
sa.Column("html", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
|
49
|
+
sa.Column("queries", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
|
50
|
+
sa.Column("updated_at", sa.Integer(), nullable=False),
|
|
51
|
+
sa.PrimaryKeyConstraint("id"),
|
|
52
|
+
)
|
|
53
|
+
with op.batch_alter_table("dashboards", schema=None) as batch_op:
|
|
54
|
+
batch_op.create_index(batch_op.f("ix_dashboards_name"), ["name"], unique=True)
|
|
55
|
+
|
|
56
|
+
op.create_table(
|
|
57
|
+
"predefined_queries",
|
|
58
|
+
sa.Column("id", sa.Integer(), nullable=False),
|
|
59
|
+
sa.Column("query_name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
|
60
|
+
sa.Column("type", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
|
61
|
+
sa.Column("query", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
|
62
|
+
sa.Column("cell_view", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
|
63
|
+
sa.PrimaryKeyConstraint("id"),
|
|
64
|
+
sa.UniqueConstraint("type", "query_name", name="uq_predefined_type_name"),
|
|
65
|
+
)
|
|
66
|
+
with op.batch_alter_table("predefined_queries", schema=None) as batch_op:
|
|
67
|
+
batch_op.create_index(batch_op.f("ix_predefined_queries_query_name"), ["query_name"], unique=False)
|
|
68
|
+
batch_op.create_index(batch_op.f("ix_predefined_queries_type"), ["type"], unique=False)
|
|
69
|
+
|
|
70
|
+
# ### end Alembic commands ###
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def downgrade() -> None:
|
|
74
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
75
|
+
with op.batch_alter_table("predefined_queries", schema=None) as batch_op:
|
|
76
|
+
batch_op.drop_index(batch_op.f("ix_predefined_queries_type"))
|
|
77
|
+
batch_op.drop_index(batch_op.f("ix_predefined_queries_query_name"))
|
|
78
|
+
|
|
79
|
+
op.drop_table("predefined_queries")
|
|
80
|
+
with op.batch_alter_table("dashboards", schema=None) as batch_op:
|
|
81
|
+
batch_op.drop_index(batch_op.f("ix_dashboards_name"))
|
|
82
|
+
|
|
83
|
+
op.drop_table("dashboards")
|
|
84
|
+
with op.batch_alter_table("connections", schema=None) as batch_op:
|
|
85
|
+
batch_op.drop_index(batch_op.f("ix_connections_type"))
|
|
86
|
+
batch_op.drop_index(batch_op.f("ix_connections_name"))
|
|
87
|
+
|
|
88
|
+
op.drop_table("connections")
|
|
89
|
+
# ### end Alembic commands ###
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""connections: per-driver columns -> one encrypted JSON config blob
|
|
2
|
+
|
|
3
|
+
Revision ID: a1b2c3d4e5f6
|
|
4
|
+
Revises: 9a536b7c0328
|
|
5
|
+
Create Date: 2026-06-20
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
|
|
14
|
+
import sqlalchemy as sa
|
|
15
|
+
import sqlmodel
|
|
16
|
+
from alembic import op
|
|
17
|
+
|
|
18
|
+
# revision identifiers, used by Alembic.
|
|
19
|
+
revision: str = "a1b2c3d4e5f6"
|
|
20
|
+
down_revision: str | None = "9a536b7c0328"
|
|
21
|
+
branch_labels: str | Sequence[str] | None = None
|
|
22
|
+
depends_on: str | Sequence[str] | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def upgrade() -> None:
|
|
26
|
+
# 1. Add the new blob column (nullable while we backfill).
|
|
27
|
+
with op.batch_alter_table("connections", schema=None) as batch_op:
|
|
28
|
+
batch_op.add_column(sa.Column("config", sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
|
29
|
+
|
|
30
|
+
# 2. Backfill each existing row: decrypt the old password, build the config
|
|
31
|
+
# dict, re-encrypt the whole JSON. The app's key loader is imported here.
|
|
32
|
+
from queryview.connect import _decrypt_str, _encrypt_str
|
|
33
|
+
|
|
34
|
+
conn = op.get_bind()
|
|
35
|
+
rows = conn.execute(sa.text("SELECT id, host, port, username, password FROM connections")).fetchall()
|
|
36
|
+
for rid, host, port, username, password in rows:
|
|
37
|
+
plain = _decrypt_str(password) if password else ""
|
|
38
|
+
blob = _encrypt_str(json.dumps({"host": host, "port": port, "username": username, "password": plain}))
|
|
39
|
+
conn.execute(
|
|
40
|
+
sa.text("UPDATE connections SET config = :c WHERE id = :i"),
|
|
41
|
+
{"c": blob, "i": rid},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# 3. Enforce NOT NULL and drop the per-driver columns.
|
|
45
|
+
with op.batch_alter_table("connections", schema=None) as batch_op:
|
|
46
|
+
batch_op.alter_column("config", existing_type=sqlmodel.sql.sqltypes.AutoString(), nullable=False)
|
|
47
|
+
batch_op.drop_column("host")
|
|
48
|
+
batch_op.drop_column("port")
|
|
49
|
+
batch_op.drop_column("username")
|
|
50
|
+
batch_op.drop_column("password")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def downgrade() -> None:
|
|
54
|
+
with op.batch_alter_table("connections", schema=None) as batch_op:
|
|
55
|
+
batch_op.add_column(sa.Column("host", sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
|
56
|
+
batch_op.add_column(sa.Column("port", sa.Integer(), nullable=True))
|
|
57
|
+
batch_op.add_column(sa.Column("username", sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
|
58
|
+
batch_op.add_column(sa.Column("password", sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
|
59
|
+
batch_op.drop_column("config")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""predefined_queries: add order_by + fields presentation columns
|
|
2
|
+
|
|
3
|
+
Revision ID: b2c3d4e5f6a7
|
|
4
|
+
Revises: a1b2c3d4e5f6
|
|
5
|
+
Create Date: 2026-07-05
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
import sqlalchemy as sa
|
|
14
|
+
import sqlmodel
|
|
15
|
+
from alembic import op
|
|
16
|
+
|
|
17
|
+
revision: str = "b2c3d4e5f6a7"
|
|
18
|
+
down_revision: str | None = "a1b2c3d4e5f6"
|
|
19
|
+
branch_labels: str | Sequence[str] | None = None
|
|
20
|
+
depends_on: str | Sequence[str] | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def upgrade() -> None:
|
|
24
|
+
with op.batch_alter_table("predefined_queries", schema=None) as batch_op:
|
|
25
|
+
batch_op.add_column(sa.Column("order_by", sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
|
26
|
+
batch_op.add_column(sa.Column("fields", sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def downgrade() -> None:
|
|
30
|
+
with op.batch_alter_table("predefined_queries", schema=None) as batch_op:
|
|
31
|
+
batch_op.drop_column("fields")
|
|
32
|
+
batch_op.drop_column("order_by")
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""workspaces: per-workspace git sync — workspaces table, entity workspace_id
|
|
2
|
+
|
|
3
|
+
Seeds a 'default' workspace from GIT_SYNC_REMOTE/GIT_SYNC_BRANCH (read once
|
|
4
|
+
here; runtime config lives in the table from now on) and backfills all
|
|
5
|
+
existing predefined queries and dashboards into it. Name uniqueness becomes
|
|
6
|
+
per-workspace.
|
|
7
|
+
|
|
8
|
+
Revision ID: c7d8e9f0a1b2
|
|
9
|
+
Revises: b2c3d4e5f6a7
|
|
10
|
+
Create Date: 2026-07-10
|
|
11
|
+
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
|
|
19
|
+
import sqlalchemy as sa
|
|
20
|
+
import sqlmodel
|
|
21
|
+
from alembic import op
|
|
22
|
+
|
|
23
|
+
revision: str = "c7d8e9f0a1b2"
|
|
24
|
+
down_revision: str | None = "b2c3d4e5f6a7"
|
|
25
|
+
branch_labels: str | Sequence[str] | None = None
|
|
26
|
+
depends_on: str | Sequence[str] | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def upgrade() -> None:
|
|
30
|
+
op.create_table(
|
|
31
|
+
"workspaces",
|
|
32
|
+
sa.Column("id", sa.Integer(), nullable=False),
|
|
33
|
+
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
|
34
|
+
sa.Column("remote", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
|
35
|
+
sa.Column("branch", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
|
36
|
+
sa.PrimaryKeyConstraint("id"),
|
|
37
|
+
)
|
|
38
|
+
op.create_index(op.f("ix_workspaces_name"), "workspaces", ["name"], unique=True)
|
|
39
|
+
|
|
40
|
+
# Seed 'default' from the legacy env config. The app's key loader is
|
|
41
|
+
# imported here, mirroring the connection-config-blob migration.
|
|
42
|
+
from queryview.connect import _encrypt_str
|
|
43
|
+
|
|
44
|
+
env_remote = os.environ.get("GIT_SYNC_REMOTE")
|
|
45
|
+
remote = _encrypt_str(env_remote) if env_remote else None
|
|
46
|
+
branch = os.environ.get("GIT_SYNC_BRANCH") or "main"
|
|
47
|
+
conn = op.get_bind()
|
|
48
|
+
conn.execute(
|
|
49
|
+
sa.text("INSERT INTO workspaces (name, remote, branch) VALUES (:n, :r, :b)"),
|
|
50
|
+
{"n": "default", "r": remote, "b": branch},
|
|
51
|
+
)
|
|
52
|
+
default_id = conn.execute(sa.text("SELECT id FROM workspaces WHERE name = 'default'")).scalar()
|
|
53
|
+
|
|
54
|
+
# server_default backfills existing rows during the batch table rewrite and
|
|
55
|
+
# keeps pre-workspace INSERT paths working mid-upgrade; application code
|
|
56
|
+
# always passes workspace_id explicitly.
|
|
57
|
+
with op.batch_alter_table("predefined_queries", schema=None) as batch_op:
|
|
58
|
+
batch_op.add_column(
|
|
59
|
+
sa.Column(
|
|
60
|
+
"workspace_id",
|
|
61
|
+
sa.Integer(),
|
|
62
|
+
nullable=False,
|
|
63
|
+
server_default=str(default_id),
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
batch_op.drop_constraint("uq_predefined_type_name", type_="unique")
|
|
67
|
+
batch_op.create_unique_constraint("uq_predefined_ws_type_name", ["workspace_id", "type", "query_name"])
|
|
68
|
+
|
|
69
|
+
with op.batch_alter_table("dashboards", schema=None) as batch_op:
|
|
70
|
+
batch_op.add_column(
|
|
71
|
+
sa.Column(
|
|
72
|
+
"workspace_id",
|
|
73
|
+
sa.Integer(),
|
|
74
|
+
nullable=False,
|
|
75
|
+
server_default=str(default_id),
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
batch_op.create_unique_constraint("uq_dashboards_ws_name", ["workspace_id", "name"])
|
|
79
|
+
|
|
80
|
+
# Dashboard names are now unique per workspace, not globally.
|
|
81
|
+
op.drop_index("ix_dashboards_name", table_name="dashboards")
|
|
82
|
+
op.create_index("ix_dashboards_name", "dashboards", ["name"], unique=False)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def downgrade() -> None:
|
|
86
|
+
with op.batch_alter_table("dashboards", schema=None) as batch_op:
|
|
87
|
+
batch_op.drop_constraint("uq_dashboards_ws_name", type_="unique")
|
|
88
|
+
batch_op.drop_column("workspace_id")
|
|
89
|
+
op.drop_index("ix_dashboards_name", table_name="dashboards")
|
|
90
|
+
op.create_index("ix_dashboards_name", "dashboards", ["name"], unique=True)
|
|
91
|
+
|
|
92
|
+
with op.batch_alter_table("predefined_queries", schema=None) as batch_op:
|
|
93
|
+
batch_op.drop_constraint("uq_predefined_ws_type_name", type_="unique")
|
|
94
|
+
batch_op.create_unique_constraint("uq_predefined_type_name", ["type", "query_name"])
|
|
95
|
+
batch_op.drop_column("workspace_id")
|
|
96
|
+
|
|
97
|
+
op.drop_index(op.f("ix_workspaces_name"), table_name="workspaces")
|
|
98
|
+
op.drop_table("workspaces")
|