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.
Files changed (54) hide show
  1. queryview/__init__.py +0 -0
  2. queryview/conftest.py +86 -0
  3. queryview/connect.py +428 -0
  4. queryview/dashboard_queries.py +75 -0
  5. queryview/dashboards.py +156 -0
  6. queryview/drivers/__init__.py +10 -0
  7. queryview/drivers/base.py +165 -0
  8. queryview/drivers/clickhouse.py +138 -0
  9. queryview/drivers/duckdb.py +153 -0
  10. queryview/drivers/postgres.py +166 -0
  11. queryview/drivers/test_base.py +49 -0
  12. queryview/drivers/test_clickhouse.py +53 -0
  13. queryview/drivers/test_contract.py +77 -0
  14. queryview/drivers/test_duckdb.py +80 -0
  15. queryview/drivers/test_postgres.py +80 -0
  16. queryview/gitsync.py +374 -0
  17. queryview/main.py +740 -0
  18. queryview/mcp_server.py +294 -0
  19. queryview/migrations/env.py +39 -0
  20. queryview/migrations/script.py.mako +29 -0
  21. queryview/migrations/versions/9a536b7c0328_initial_schema.py +89 -0
  22. queryview/migrations/versions/a1b2c3d4e5f6_connection_config_blob.py +59 -0
  23. queryview/migrations/versions/b2c3d4e5f6a7_predefined_presentation.py +32 -0
  24. queryview/migrations/versions/c7d8e9f0a1b2_workspaces.py +98 -0
  25. queryview/queries.py +159 -0
  26. queryview/remote.py +141 -0
  27. queryview/static/assets/index-CvnC_D68.js +47 -0
  28. queryview/static/assets/index-Qe7bhycG.css +2 -0
  29. queryview/static/favicon.svg +1 -0
  30. queryview/static/index.html +14 -0
  31. queryview/test_api_db.py +51 -0
  32. queryview/test_api_export_import.py +85 -0
  33. queryview/test_api_gitsync.py +83 -0
  34. queryview/test_api_workspaces.py +44 -0
  35. queryview/test_connect_flow.py +123 -0
  36. queryview/test_connect_store.py +34 -0
  37. queryview/test_dashboards.py +216 -0
  38. queryview/test_gitsync.py +346 -0
  39. queryview/test_main.py +18 -0
  40. queryview/test_mcp_gitsync.py +72 -0
  41. queryview/test_migrations.py +99 -0
  42. queryview/test_queries.py +170 -0
  43. queryview/test_remote.py +260 -0
  44. queryview/test_validation.py +87 -0
  45. queryview/test_workspaces.py +109 -0
  46. queryview/test_yamlio.py +198 -0
  47. queryview/validation.py +111 -0
  48. queryview/workspaces.py +167 -0
  49. queryview/yamlio.py +245 -0
  50. queryview-0.0.2.dist-info/METADATA +183 -0
  51. queryview-0.0.2.dist-info/RECORD +54 -0
  52. queryview-0.0.2.dist-info/WHEEL +4 -0
  53. queryview-0.0.2.dist-info/entry_points.txt +3 -0
  54. queryview-0.0.2.dist-info/licenses/LICENSE +21 -0
@@ -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")