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
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Tests for the dashboard store and its REST surface. These don't need a real
|
|
2
|
+
ClickHouse: the store is pure SQLite, and route tests exercise validation plus
|
|
3
|
+
the persist/push path (the runner's happy/bad ClickHouse paths live in e2e)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
|
|
9
|
+
from fastapi.testclient import TestClient
|
|
10
|
+
|
|
11
|
+
from queryview import remote
|
|
12
|
+
from queryview.dashboards import (
|
|
13
|
+
_upsert_and_push,
|
|
14
|
+
get_dashboard,
|
|
15
|
+
list_dashboards,
|
|
16
|
+
upsert_dashboard,
|
|
17
|
+
)
|
|
18
|
+
from queryview.main import app
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run(coro):
|
|
22
|
+
return asyncio.run(coro)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_upsert_creates_then_updates_by_name(default_ws_id):
|
|
26
|
+
_run(upsert_dashboard("d1", "conn-a", "<h1>v1</h1>", {"q": "SELECT 1"}, workspace_id=default_ws_id))
|
|
27
|
+
got = _run(get_dashboard("d1", default_ws_id))
|
|
28
|
+
assert got is not None
|
|
29
|
+
assert got["connection"] == "conn-a"
|
|
30
|
+
assert got["html"] == "<h1>v1</h1>"
|
|
31
|
+
assert got["queries"] == {"q": "SELECT 1"}
|
|
32
|
+
|
|
33
|
+
_run(upsert_dashboard("d1", "conn-b", "<h1>v2</h1>", {"q": "SELECT 2"}, workspace_id=default_ws_id))
|
|
34
|
+
got = _run(get_dashboard("d1", default_ws_id))
|
|
35
|
+
assert got is not None
|
|
36
|
+
assert got["connection"] == "conn-b"
|
|
37
|
+
assert got["html"] == "<h1>v2</h1>"
|
|
38
|
+
assert got["queries"] == {"q": "SELECT 2"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_get_dashboard_round_trips_queries_dict(default_ws_id):
|
|
42
|
+
queries = {"sales": "SELECT * FROM sales", "users": "SELECT count() FROM users"}
|
|
43
|
+
_run(upsert_dashboard("multi", "c", "<div></div>", queries, workspace_id=default_ws_id))
|
|
44
|
+
got = _run(get_dashboard("multi", default_ws_id))
|
|
45
|
+
assert got is not None
|
|
46
|
+
assert got["queries"] == queries
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_get_missing_dashboard_is_none(default_ws_id):
|
|
50
|
+
assert _run(get_dashboard("does-not-exist", default_ws_id)) is None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_list_dashboards_orders_by_name_and_omits_payload(default_ws_id):
|
|
54
|
+
_run(upsert_dashboard("zeta", "c", "<i></i>", {"q": "SELECT 1"}, workspace_id=default_ws_id))
|
|
55
|
+
_run(upsert_dashboard("alpha", "c", "<i></i>", {"q": "SELECT 1"}, workspace_id=default_ws_id))
|
|
56
|
+
names = [d["name"] for d in _run(list_dashboards(default_ws_id))]
|
|
57
|
+
assert names.index("alpha") < names.index("zeta")
|
|
58
|
+
row = next(d for d in _run(list_dashboards(default_ws_id)) if d["name"] == "alpha")
|
|
59
|
+
assert set(row) == {"name", "connection", "updated_at"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_upsert_and_push_persists_without_session(default_ws_id):
|
|
63
|
+
persisted, pushed, _ = _run(
|
|
64
|
+
_upsert_and_push("np", "c", "<p></p>", {"q": "SELECT 1"}, None, workspace_id=default_ws_id)
|
|
65
|
+
)
|
|
66
|
+
assert persisted is True and pushed is False
|
|
67
|
+
assert _run(get_dashboard("np", default_ws_id)) is not None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_upsert_and_push_delivers_to_registered_session(default_ws_id):
|
|
71
|
+
rid = remote.register()
|
|
72
|
+
try:
|
|
73
|
+
persisted, pushed, _ = _run(
|
|
74
|
+
_upsert_and_push("pushed", "c", "<p>x</p>", {"q": "SELECT 1"}, rid, workspace_id=default_ws_id)
|
|
75
|
+
)
|
|
76
|
+
assert persisted is True and pushed is True
|
|
77
|
+
msg = _run(remote.next_message(rid, 1.0))
|
|
78
|
+
assert msg is not None
|
|
79
|
+
assert msg["type"] == "dashboard"
|
|
80
|
+
assert msg["name"] == "pushed"
|
|
81
|
+
assert msg["connection"] == "c"
|
|
82
|
+
assert msg["html"] == "<p>x</p>"
|
|
83
|
+
assert msg["queries"] == {"q": "SELECT 1"}
|
|
84
|
+
finally:
|
|
85
|
+
remote.unregister(rid)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --- REST surface ---------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_runqueries_requires_connection_and_queries():
|
|
92
|
+
client = TestClient(app)
|
|
93
|
+
assert client.post("/api/runqueries", json={"queries": {"q": "SELECT 1"}}).status_code == 400
|
|
94
|
+
assert client.post("/api/runqueries", json={"connection": "c"}).status_code == 400
|
|
95
|
+
assert client.post("/api/runqueries", json={"connection": "c", "queries": {}}).status_code == 400
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_runqueries_unknown_connection_returns_404():
|
|
99
|
+
client = TestClient(app)
|
|
100
|
+
r = client.post(
|
|
101
|
+
"/api/runqueries",
|
|
102
|
+
json={"connection": "no-such-connection", "queries": {"q": "SELECT 1"}},
|
|
103
|
+
)
|
|
104
|
+
assert r.status_code == 404
|
|
105
|
+
assert r.json()["ok"] is False
|
|
106
|
+
assert "no connection" in r.json()["message"].lower()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_dashboards_upsert_requires_fields():
|
|
110
|
+
client = TestClient(app)
|
|
111
|
+
r = client.post("/api/dashboards", json={"name": "x", "connection": "c"})
|
|
112
|
+
assert r.status_code == 400
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_dashboards_upsert_persists_and_lists_and_gets():
|
|
116
|
+
client = TestClient(app)
|
|
117
|
+
r = client.post(
|
|
118
|
+
"/api/dashboards",
|
|
119
|
+
json={
|
|
120
|
+
"name": "rest-dash",
|
|
121
|
+
"connection": "c",
|
|
122
|
+
"html": "<h1>hi</h1>",
|
|
123
|
+
"queries": {"q": "SELECT 1"},
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
assert r.status_code == 200
|
|
127
|
+
body = r.json()
|
|
128
|
+
assert body["persisted"] is True and body["pushed"] is False
|
|
129
|
+
|
|
130
|
+
listed = client.get("/api/dashboards").json()["dashboards"]
|
|
131
|
+
assert any(d["name"] == "rest-dash" for d in listed)
|
|
132
|
+
|
|
133
|
+
got = client.get("/api/dashboards/rest-dash").json()
|
|
134
|
+
assert got["html"] == "<h1>hi</h1>"
|
|
135
|
+
assert got["queries"] == {"q": "SELECT 1"}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_dashboards_get_missing_returns_404():
|
|
139
|
+
client = TestClient(app)
|
|
140
|
+
assert client.get("/api/dashboards/missing-one").status_code == 404
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_dashboards_upsert_pushes_to_registered_session():
|
|
144
|
+
rid = remote.register()
|
|
145
|
+
try:
|
|
146
|
+
client = TestClient(app)
|
|
147
|
+
r = client.post(
|
|
148
|
+
"/api/dashboards",
|
|
149
|
+
json={
|
|
150
|
+
"session_id": rid,
|
|
151
|
+
"name": "live-dash",
|
|
152
|
+
"connection": "c",
|
|
153
|
+
"html": "<p></p>",
|
|
154
|
+
"queries": {"q": "SELECT 1"},
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
assert r.json()["pushed"] is True
|
|
158
|
+
msg = _run(remote.next_message(rid, 1.0))
|
|
159
|
+
assert msg is not None
|
|
160
|
+
assert msg["type"] == "dashboard" and msg["name"] == "live-dash"
|
|
161
|
+
finally:
|
|
162
|
+
remote.unregister(rid)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_mcp_upsert_dashboard_pushes_draft_without_persisting(default_ws_id):
|
|
166
|
+
from queryview.mcp_server import push_dashboard as mcp_push
|
|
167
|
+
|
|
168
|
+
rid = remote.register()
|
|
169
|
+
try:
|
|
170
|
+
out = _run(mcp_push(rid, "draftdash", "c", "<p>d</p>", {"q": "SELECT 1"}))
|
|
171
|
+
assert out["pushed"] is True
|
|
172
|
+
# Draft: the agent push must NOT persist — only the user's Save does.
|
|
173
|
+
assert _run(get_dashboard("draftdash", default_ws_id)) is None
|
|
174
|
+
msg = _run(remote.next_message(rid, 1.0))
|
|
175
|
+
assert msg is not None
|
|
176
|
+
assert msg["type"] == "dashboard" and msg["name"] == "draftdash"
|
|
177
|
+
finally:
|
|
178
|
+
remote.unregister(rid)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_dashboard_names_distinct_per_workspace(default_ws_id):
|
|
182
|
+
from queryview.workspaces import create_workspace, resolve
|
|
183
|
+
|
|
184
|
+
_run(create_workspace("t4-iso"))
|
|
185
|
+
other = _run(resolve("t4-iso")).id
|
|
186
|
+
_run(upsert_dashboard("iso d", "prod", "<html>1</html>", {"q": "SELECT 1"}, workspace_id=default_ws_id))
|
|
187
|
+
_run(upsert_dashboard("iso d", "prod", "<html>2</html>", {"q": "SELECT 2"}, workspace_id=other))
|
|
188
|
+
|
|
189
|
+
d_default = _run(get_dashboard("iso d", default_ws_id))
|
|
190
|
+
d_other = _run(get_dashboard("iso d", other))
|
|
191
|
+
assert d_default is not None and d_default["html"] == "<html>1</html>"
|
|
192
|
+
assert d_other is not None and d_other["html"] == "<html>2</html>"
|
|
193
|
+
assert "iso d" in [d["name"] for d in _run(list_dashboards(default_ws_id))]
|
|
194
|
+
assert "iso d" in [d["name"] for d in _run(list_dashboards(other))]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_mcp_list_dashboards_scopes_by_session_workspace(default_ws_id):
|
|
198
|
+
from queryview.mcp_server import list_dashboards as mcp_list
|
|
199
|
+
from queryview.workspaces import create_workspace, resolve
|
|
200
|
+
|
|
201
|
+
_run(create_workspace("t-mcp-ld"))
|
|
202
|
+
other = _run(resolve("t-mcp-ld")).id
|
|
203
|
+
_run(upsert_dashboard("mcp ld", "c", "<i></i>", {}, workspace_id=other))
|
|
204
|
+
|
|
205
|
+
# No session_id -> default workspace: the other workspace's dashboard is absent.
|
|
206
|
+
names = [d["name"] for d in _run(mcp_list())["dashboards"]]
|
|
207
|
+
assert "mcp ld" not in names
|
|
208
|
+
|
|
209
|
+
# A session reporting the other workspace sees it.
|
|
210
|
+
rid = remote.register()
|
|
211
|
+
try:
|
|
212
|
+
remote.set_session_workspace(rid, "t-mcp-ld")
|
|
213
|
+
names = [d["name"] for d in _run(mcp_list(session_id=rid))["dashboards"]]
|
|
214
|
+
assert names == ["mcp ld"]
|
|
215
|
+
finally:
|
|
216
|
+
remote.unregister(rid)
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Git sync: serializer round-trips (queries/dashboards <-> repo files) and,
|
|
2
|
+
in later tests, store/history/restore against a local bare repo (no network)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from queryview import gitsync
|
|
12
|
+
from queryview.gitsync import (
|
|
13
|
+
GitSyncError,
|
|
14
|
+
dashboard_from_files,
|
|
15
|
+
dashboard_reldir,
|
|
16
|
+
dashboard_to_files,
|
|
17
|
+
query_from_yaml,
|
|
18
|
+
query_relpath,
|
|
19
|
+
query_to_yaml,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run(coro):
|
|
24
|
+
return asyncio.run(coro)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_relpaths():
|
|
28
|
+
assert query_relpath("clickhouse", "top errors") == "queries/clickhouse/top errors.yaml"
|
|
29
|
+
assert dashboard_reldir("sales/eu") == "dashboards/sales%2Feu"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_query_yaml_round_trip():
|
|
33
|
+
row = {
|
|
34
|
+
"query_name": "top errors",
|
|
35
|
+
"query": "SELECT *\nFROM errors\nORDER BY n DESC",
|
|
36
|
+
"cell_view": "cve_id:\n type: link\n value: https://x/{cell}\n",
|
|
37
|
+
"order_by": '[{"name": "n", "dir": "DESC"}]',
|
|
38
|
+
"fields": '["cve_id", "n"]',
|
|
39
|
+
}
|
|
40
|
+
text = query_to_yaml(row)
|
|
41
|
+
assert "SELECT *" in text # multiline SQL is a readable block, not \n escapes
|
|
42
|
+
assert query_from_yaml(text) == row
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_query_yaml_omits_and_restores_none_fields():
|
|
46
|
+
row = {
|
|
47
|
+
"query_name": "plain",
|
|
48
|
+
"query": "SELECT 1",
|
|
49
|
+
"cell_view": None,
|
|
50
|
+
"order_by": None,
|
|
51
|
+
"fields": None,
|
|
52
|
+
}
|
|
53
|
+
text = query_to_yaml(row)
|
|
54
|
+
assert "cell_view" not in text
|
|
55
|
+
assert query_from_yaml(text) == row
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_query_from_yaml_rejects_malformed():
|
|
59
|
+
with pytest.raises(GitSyncError):
|
|
60
|
+
query_from_yaml("just a scalar")
|
|
61
|
+
with pytest.raises(GitSyncError):
|
|
62
|
+
query_from_yaml("query_name: x\n") # no query
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_dashboard_files_round_trip():
|
|
66
|
+
d = {
|
|
67
|
+
"name": "sales",
|
|
68
|
+
"connection": "prod",
|
|
69
|
+
"html": "<html>\n<body>hi — ünicode</body>\n</html>",
|
|
70
|
+
"queries": {"revenue": "SELECT 1", "multi": "SELECT a\nFROM b"},
|
|
71
|
+
}
|
|
72
|
+
files = dashboard_to_files(d)
|
|
73
|
+
assert set(files) == {"meta.yaml", "dashboard.html", "queries.yaml"}
|
|
74
|
+
assert files["dashboard.html"] == d["html"] # verbatim
|
|
75
|
+
assert dashboard_from_files(files) == d
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_dashboard_from_files_rejects_malformed_meta():
|
|
79
|
+
with pytest.raises(GitSyncError):
|
|
80
|
+
dashboard_from_files({"meta.yaml": "connection: x", "dashboard.html": "", "queries.yaml": ""})
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# The git_env fixture (bare repo + GIT_SYNC_* env vars) lives in conftest.py.
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _default_ws_id() -> int:
|
|
87
|
+
from queryview.workspaces import DEFAULT_WORKSPACE, resolve
|
|
88
|
+
|
|
89
|
+
return _run(resolve(DEFAULT_WORKSPACE)).id
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _default_ws():
|
|
93
|
+
"""The default workspace, re-resolved so per-test remote changes are seen."""
|
|
94
|
+
from queryview.workspaces import DEFAULT_WORKSPACE, resolve
|
|
95
|
+
|
|
96
|
+
return _run(resolve(DEFAULT_WORKSPACE))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _remote_log(remote) -> str:
|
|
100
|
+
return subprocess.run(
|
|
101
|
+
["git", "log", "--format=%H %s", "main"],
|
|
102
|
+
cwd=remote,
|
|
103
|
+
check=True,
|
|
104
|
+
capture_output=True,
|
|
105
|
+
text=True,
|
|
106
|
+
).stdout
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_store_unconfigured_is_409():
|
|
110
|
+
# Outside git_env the default workspace has no remote configured.
|
|
111
|
+
with pytest.raises(GitSyncError) as e:
|
|
112
|
+
_run(gitsync.store(_default_ws(), "query", "anything", "clickhouse"))
|
|
113
|
+
assert e.value.status == 409
|
|
114
|
+
assert "no git remote" in str(e.value)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_unknown_kind_is_400():
|
|
118
|
+
with pytest.raises(GitSyncError) as e:
|
|
119
|
+
_run(gitsync.store(_default_ws(), "quert", "x", "clickhouse"))
|
|
120
|
+
assert e.value.status == 400
|
|
121
|
+
assert "kind" in str(e.value)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_query_without_conn_type_is_400():
|
|
125
|
+
with pytest.raises(GitSyncError) as e:
|
|
126
|
+
_run(gitsync.history(_default_ws(), "query", "x"))
|
|
127
|
+
assert e.value.status == 400
|
|
128
|
+
assert "conn_type" in str(e.value)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_store_missing_entity_is_404(git_env):
|
|
132
|
+
with pytest.raises(GitSyncError) as e:
|
|
133
|
+
_run(gitsync.store(_default_ws(), "query", "no such query xyz", "clickhouse"))
|
|
134
|
+
assert e.value.status == 404
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_store_query_commits_and_pushes(git_env):
|
|
138
|
+
from queryview.queries import save_predefined_query
|
|
139
|
+
|
|
140
|
+
_run(save_predefined_query("gs top", "clickhouse", "SELECT 1", workspace_id=_default_ws_id()))
|
|
141
|
+
r = _run(gitsync.store(_default_ws(), "query", "gs top", "clickhouse"))
|
|
142
|
+
assert r["committed"] is True and r["sha"]
|
|
143
|
+
log = _remote_log(git_env)
|
|
144
|
+
assert r["sha"] in log
|
|
145
|
+
assert "store query clickhouse/gs top" in log
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_store_no_change_makes_no_commit(git_env):
|
|
149
|
+
from queryview.queries import save_predefined_query
|
|
150
|
+
|
|
151
|
+
_run(save_predefined_query("gs same", "clickhouse", "SELECT 2", workspace_id=_default_ws_id()))
|
|
152
|
+
r1 = _run(gitsync.store(_default_ws(), "query", "gs same", "clickhouse"))
|
|
153
|
+
r2 = _run(gitsync.store(_default_ws(), "query", "gs same", "clickhouse"))
|
|
154
|
+
assert r1["committed"] is True
|
|
155
|
+
assert r2 == {"committed": False, "sha": None, "message": "no changes"}
|
|
156
|
+
assert _remote_log(git_env).count("\n") == 1 # exactly one commit
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_store_dashboard_touches_only_its_dir(git_env):
|
|
160
|
+
from queryview.dashboards import upsert_dashboard
|
|
161
|
+
|
|
162
|
+
_run(upsert_dashboard("gs dash", "prod", "<html>v1</html>", {"q": "SELECT 1"}, workspace_id=_default_ws_id()))
|
|
163
|
+
r = _run(gitsync.store(_default_ws(), "dashboard", "gs dash"))
|
|
164
|
+
assert r["committed"] is True
|
|
165
|
+
out = subprocess.run(
|
|
166
|
+
["git", "show", "--name-only", "--format=", r["sha"]],
|
|
167
|
+
cwd=git_env,
|
|
168
|
+
check=True,
|
|
169
|
+
capture_output=True,
|
|
170
|
+
text=True,
|
|
171
|
+
).stdout
|
|
172
|
+
files = [line for line in out.splitlines() if line.strip()]
|
|
173
|
+
assert files and all(f.startswith("dashboards/gs dash/") for f in files)
|
|
174
|
+
assert set(files) == {
|
|
175
|
+
"dashboards/gs dash/meta.yaml",
|
|
176
|
+
"dashboards/gs dash/dashboard.html",
|
|
177
|
+
"dashboards/gs dash/queries.yaml",
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_store_custom_message(git_env):
|
|
182
|
+
from queryview.queries import save_predefined_query
|
|
183
|
+
|
|
184
|
+
_run(save_predefined_query("gs msg", "clickhouse", "SELECT 3", workspace_id=_default_ws_id()))
|
|
185
|
+
_run(gitsync.store(_default_ws(), "query", "gs msg", "clickhouse", message="before migration"))
|
|
186
|
+
assert "before migration" in _remote_log(git_env)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_store_unreachable_remote_raises_without_init(tmp_path, monkeypatch):
|
|
190
|
+
from queryview.queries import save_predefined_query
|
|
191
|
+
from queryview.workspaces import DEFAULT_WORKSPACE, update_workspace
|
|
192
|
+
|
|
193
|
+
monkeypatch.setenv("GIT_SYNC_DIR", str(tmp_path / "clones"))
|
|
194
|
+
_run(update_workspace(DEFAULT_WORKSPACE, remote=str(tmp_path / "does-not-exist.git")))
|
|
195
|
+
try:
|
|
196
|
+
_run(save_predefined_query("gs unreachable", "clickhouse", "SELECT 1", workspace_id=_default_ws_id()))
|
|
197
|
+
ws = _default_ws()
|
|
198
|
+
with pytest.raises(GitSyncError) as e:
|
|
199
|
+
_run(gitsync.store(ws, "query", "gs unreachable", "clickhouse"))
|
|
200
|
+
assert e.value.status == 502
|
|
201
|
+
# No spurious local repo in this workspace's clone dir.
|
|
202
|
+
assert not (tmp_path / "clones" / str(ws.id) / ".git").exists()
|
|
203
|
+
finally:
|
|
204
|
+
_run(update_workspace(DEFAULT_WORKSPACE, remote=None))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _clone_head() -> str:
|
|
208
|
+
import os
|
|
209
|
+
|
|
210
|
+
wd = os.path.join(os.environ["GIT_SYNC_DIR"], str(_default_ws_id()))
|
|
211
|
+
return subprocess.run(
|
|
212
|
+
["git", "rev-parse", "HEAD"],
|
|
213
|
+
cwd=wd,
|
|
214
|
+
check=True,
|
|
215
|
+
capture_output=True,
|
|
216
|
+
text=True,
|
|
217
|
+
).stdout.strip()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _store_query_versions(name: str, sqls: list[str]) -> list[str]:
|
|
221
|
+
"""Save+store the query once per SQL; returns the commit shas in order."""
|
|
222
|
+
from queryview.queries import save_predefined_query
|
|
223
|
+
|
|
224
|
+
shas = []
|
|
225
|
+
for sql in sqls:
|
|
226
|
+
_run(save_predefined_query(name, "clickhouse", sql, workspace_id=_default_ws_id()))
|
|
227
|
+
shas.append(_run(gitsync.store(_default_ws(), "query", name, "clickhouse"))["sha"])
|
|
228
|
+
return shas
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_history_pages_newest_first(git_env):
|
|
232
|
+
shas = _store_query_versions("gs hist", ["SELECT 1", "SELECT 2", "SELECT 3"])
|
|
233
|
+
page1 = _run(gitsync.history(_default_ws(), "query", "gs hist", "clickhouse", limit=2))
|
|
234
|
+
assert [r["sha"] for r in page1["revisions"]] == [shas[2], shas[1]]
|
|
235
|
+
assert page1["has_more"] is True
|
|
236
|
+
assert all(isinstance(r["date"], int) and r["message"] for r in page1["revisions"])
|
|
237
|
+
page2 = _run(gitsync.history(_default_ws(), "query", "gs hist", "clickhouse", before=shas[1], limit=2))
|
|
238
|
+
assert [r["sha"] for r in page2["revisions"]] == [shas[0]]
|
|
239
|
+
assert page2["has_more"] is False
|
|
240
|
+
# Paging past the oldest commit yields an empty page, not an error.
|
|
241
|
+
page3 = _run(gitsync.history(_default_ws(), "query", "gs hist", "clickhouse", before=shas[0], limit=2))
|
|
242
|
+
assert page3 == {"revisions": [], "has_more": False}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_history_garbage_before_is_404(git_env):
|
|
246
|
+
_store_query_versions("gs badbefore", ["SELECT 1"])
|
|
247
|
+
with pytest.raises(GitSyncError) as e:
|
|
248
|
+
_run(gitsync.history(_default_ws(), "query", "gs badbefore", "clickhouse", before="not-a-sha"))
|
|
249
|
+
assert e.value.status == 404
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_history_only_sees_own_entity(git_env):
|
|
253
|
+
_store_query_versions("gs mine", ["SELECT 1"])
|
|
254
|
+
_store_query_versions("gs other", ["SELECT 9"])
|
|
255
|
+
h = _run(gitsync.history(_default_ws(), "query", "gs mine", "clickhouse"))
|
|
256
|
+
assert len(h["revisions"]) == 1
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_restore_old_version_overwrites_db_without_moving_head(git_env):
|
|
260
|
+
from queryview.queries import list_predefined_queries
|
|
261
|
+
|
|
262
|
+
shas = _store_query_versions("gs restore", ["SELECT 1", "SELECT 2"])
|
|
263
|
+
head_before = _clone_head()
|
|
264
|
+
r = _run(gitsync.restore(_default_ws(), "query", "gs restore", "clickhouse", ref=shas[0]))
|
|
265
|
+
assert r["restored"] is True
|
|
266
|
+
rows = _run(list_predefined_queries("clickhouse", _default_ws_id()))
|
|
267
|
+
row = next(x for x in rows if x["query_name"] == "gs restore")
|
|
268
|
+
assert row["query"] == "SELECT 1"
|
|
269
|
+
assert _clone_head() == head_before # HEAD never moves
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_restore_default_ref_is_remote_head(git_env):
|
|
273
|
+
from queryview.queries import list_predefined_queries, save_predefined_query
|
|
274
|
+
|
|
275
|
+
_store_query_versions("gs latest", ["SELECT 1", "SELECT 2"])
|
|
276
|
+
_run(save_predefined_query("gs latest", "clickhouse", "SELECT 999", workspace_id=_default_ws_id())) # local drift
|
|
277
|
+
_run(gitsync.restore(_default_ws(), "query", "gs latest", "clickhouse"))
|
|
278
|
+
rows = _run(list_predefined_queries("clickhouse", _default_ws_id()))
|
|
279
|
+
row = next(x for x in rows if x["query_name"] == "gs latest")
|
|
280
|
+
assert row["query"] == "SELECT 2"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_restore_dashboard_round_trip(git_env):
|
|
284
|
+
from queryview.dashboards import get_dashboard, upsert_dashboard
|
|
285
|
+
|
|
286
|
+
_run(upsert_dashboard("gs rdash", "prod", "<html>v1</html>", {"q": "SELECT 1"}, workspace_id=_default_ws_id()))
|
|
287
|
+
_run(gitsync.store(_default_ws(), "dashboard", "gs rdash"))
|
|
288
|
+
_run(upsert_dashboard("gs rdash", "other", "<html>v2</html>", {"q": "SELECT 2"}, workspace_id=_default_ws_id()))
|
|
289
|
+
_run(gitsync.restore(_default_ws(), "dashboard", "gs rdash"))
|
|
290
|
+
d = _run(get_dashboard("gs rdash", _default_ws_id()))
|
|
291
|
+
assert d == {
|
|
292
|
+
"name": "gs rdash",
|
|
293
|
+
"connection": "prod",
|
|
294
|
+
"html": "<html>v1</html>",
|
|
295
|
+
"queries": {"q": "SELECT 1"},
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_restore_missing_at_ref_is_404(git_env):
|
|
300
|
+
_store_query_versions("gs exists", ["SELECT 1"])
|
|
301
|
+
with pytest.raises(GitSyncError) as e:
|
|
302
|
+
_run(gitsync.restore(_default_ws(), "query", "never stored", "clickhouse"))
|
|
303
|
+
assert e.value.status == 404
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _bare(tmp_path, name):
|
|
307
|
+
remote = tmp_path / name
|
|
308
|
+
subprocess.run(
|
|
309
|
+
["git", "init", "--bare", "-b", "main", str(remote)],
|
|
310
|
+
check=True,
|
|
311
|
+
capture_output=True,
|
|
312
|
+
)
|
|
313
|
+
return remote
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def test_store_is_isolated_per_workspace(tmp_path, monkeypatch):
|
|
317
|
+
"""A commit in workspace A lands only in A's remote; B's stays empty; a
|
|
318
|
+
workspace without a remote is 409."""
|
|
319
|
+
from queryview.queries import save_predefined_query
|
|
320
|
+
from queryview.workspaces import create_workspace, resolve
|
|
321
|
+
|
|
322
|
+
monkeypatch.setenv("GIT_SYNC_DIR", str(tmp_path / "clones"))
|
|
323
|
+
ra, rb = _bare(tmp_path, "a.git"), _bare(tmp_path, "b.git")
|
|
324
|
+
_run(create_workspace("t5-a", remote=str(ra)))
|
|
325
|
+
_run(create_workspace("t5-b", remote=str(rb)))
|
|
326
|
+
_run(create_workspace("t5-none"))
|
|
327
|
+
wa, wb = _run(resolve("t5-a")), _run(resolve("t5-b"))
|
|
328
|
+
|
|
329
|
+
_run(save_predefined_query("iso", "clickhouse", "SELECT 1", workspace_id=wa.id))
|
|
330
|
+
r = _run(gitsync.store(wa, "query", "iso", "clickhouse"))
|
|
331
|
+
assert r["committed"] is True
|
|
332
|
+
assert "store query clickhouse/iso" in _remote_log(ra)
|
|
333
|
+
# B's remote has no commits at all (git log fails on an empty bare repo).
|
|
334
|
+
p = subprocess.run(["git", "log", "--format=%H", "main"], cwd=rb, capture_output=True, text=True)
|
|
335
|
+
assert p.returncode != 0
|
|
336
|
+
|
|
337
|
+
# And B's history for the same entity name is empty, not A's history.
|
|
338
|
+
_run(save_predefined_query("iso", "clickhouse", "SELECT 2", workspace_id=wb.id))
|
|
339
|
+
h = _run(gitsync.history(wb, "query", "iso", "clickhouse"))
|
|
340
|
+
assert h["revisions"] == []
|
|
341
|
+
|
|
342
|
+
wn = _run(resolve("t5-none"))
|
|
343
|
+
with pytest.raises(GitSyncError) as e:
|
|
344
|
+
_run(gitsync.store(wn, "query", "iso", "clickhouse"))
|
|
345
|
+
assert e.value.status == 409
|
|
346
|
+
assert "no git remote" in str(e.value)
|
queryview/test_main.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Tests for the console-script entry point (port resolution)."""
|
|
2
|
+
|
|
3
|
+
from queryview import main
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_port_flag_beats_env(monkeypatch):
|
|
7
|
+
monkeypatch.setenv("PORT", "7777")
|
|
8
|
+
assert main._resolve_port(["--port", "9000"]) == 9000
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_port_env_used_without_flag(monkeypatch):
|
|
12
|
+
monkeypatch.setenv("PORT", "7777")
|
|
13
|
+
assert main._resolve_port([]) == 7777
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_port_defaults_to_8000(monkeypatch):
|
|
17
|
+
monkeypatch.delenv("PORT", raising=False)
|
|
18
|
+
assert main._resolve_port([]) == 8000
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""The git-sync MCP tools: registration and delegation to gitsync (errors come
|
|
2
|
+
back as {ok: False} rather than raising, matching the other tools)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _run(coro):
|
|
10
|
+
return asyncio.run(coro)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_git_tools_are_registered():
|
|
14
|
+
from queryview.mcp_server import mcp
|
|
15
|
+
|
|
16
|
+
names = {t.name for t in _run(mcp.list_tools())}
|
|
17
|
+
assert {"git_store", "git_history", "git_restore"} <= names
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_git_restore_tool_reports_unconfigured(monkeypatch):
|
|
21
|
+
monkeypatch.delenv("GIT_SYNC_REMOTE", raising=False)
|
|
22
|
+
from queryview.mcp_server import git_restore
|
|
23
|
+
|
|
24
|
+
r = _run(git_restore("dashboard", "x"))
|
|
25
|
+
assert r["ok"] is False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_git_store_resolves_workspace_from_session(tmp_path, monkeypatch):
|
|
29
|
+
"""An armed session on workspace B routes git_store to B's remote; no
|
|
30
|
+
session_id falls back to the default workspace."""
|
|
31
|
+
import subprocess
|
|
32
|
+
|
|
33
|
+
from queryview import remote
|
|
34
|
+
from queryview.mcp_server import git_store
|
|
35
|
+
from queryview.queries import save_predefined_query
|
|
36
|
+
from queryview.workspaces import create_workspace, resolve
|
|
37
|
+
|
|
38
|
+
monkeypatch.setenv("GIT_SYNC_DIR", str(tmp_path / "clones"))
|
|
39
|
+
rb = tmp_path / "b.git"
|
|
40
|
+
subprocess.run(
|
|
41
|
+
["git", "init", "--bare", "-b", "main", str(rb)],
|
|
42
|
+
check=True,
|
|
43
|
+
capture_output=True,
|
|
44
|
+
)
|
|
45
|
+
_run(create_workspace("t7-b", remote=str(rb)))
|
|
46
|
+
wid = _run(resolve("t7-b")).id
|
|
47
|
+
_run(save_predefined_query("sess q", "clickhouse", "SELECT 1", workspace_id=wid))
|
|
48
|
+
|
|
49
|
+
rid = remote.register()
|
|
50
|
+
try:
|
|
51
|
+
remote.set_session_workspace(rid, "t7-b")
|
|
52
|
+
r = _run(git_store("query", "sess q", "clickhouse", session_id=rid))
|
|
53
|
+
assert r["ok"] is True and r["committed"] is True
|
|
54
|
+
log = subprocess.run(
|
|
55
|
+
["git", "log", "--format=%s", "main"],
|
|
56
|
+
cwd=rb,
|
|
57
|
+
check=True,
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
).stdout
|
|
61
|
+
assert "sess q" in log
|
|
62
|
+
finally:
|
|
63
|
+
remote.unregister(rid)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_git_store_without_session_uses_default_workspace():
|
|
67
|
+
from queryview.mcp_server import git_store
|
|
68
|
+
|
|
69
|
+
# Outside git_env the default workspace has no remote -> unconfigured.
|
|
70
|
+
r = _run(git_store("query", "anything", "clickhouse"))
|
|
71
|
+
assert r["ok"] is False
|
|
72
|
+
assert "no git remote" in r["message"]
|