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,109 @@
1
+ """Workspace store: CRUD, name rules, encrypted-remote round trip, and the
2
+ delete-refuses-on-non-empty rule. The 'default' workspace is seeded by the
3
+ migration and is an ordinary row."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import sqlite3
9
+
10
+ import pytest
11
+
12
+ from queryview.connect import _db_path
13
+ from queryview.workspaces import (
14
+ DEFAULT_WORKSPACE,
15
+ WorkspaceError,
16
+ create_workspace,
17
+ delete_workspace,
18
+ list_workspaces,
19
+ resolve,
20
+ update_workspace,
21
+ )
22
+
23
+
24
+ def _run(coro):
25
+ return asyncio.run(coro)
26
+
27
+
28
+ def test_default_workspace_is_seeded():
29
+ rec = _run(resolve(DEFAULT_WORKSPACE))
30
+ assert rec.name == "default"
31
+ assert isinstance(rec.id, int)
32
+
33
+
34
+ def test_resolve_unknown_is_404():
35
+ with pytest.raises(WorkspaceError) as e:
36
+ _run(resolve("no-such-ws"))
37
+ assert e.value.status == 404
38
+
39
+
40
+ def test_create_list_and_encrypted_remote_round_trip():
41
+ _run(create_workspace("t2-crt", remote="https://u:tok@example.test/r.git", branch="dev"))
42
+ rec = _run(resolve("t2-crt"))
43
+ assert rec.remote == "https://u:tok@example.test/r.git" # decrypted for gitsync
44
+ assert rec.branch == "dev"
45
+
46
+ listed = {w["name"]: w for w in _run(list_workspaces())}
47
+ assert listed["t2-crt"] == {"name": "t2-crt", "branch": "dev", "configured": True}
48
+ assert "remote" not in listed["t2-crt"] # the URL (a secret) never leaves the store
49
+
50
+ # At rest the remote is ciphertext, not the URL.
51
+ con = sqlite3.connect(_db_path())
52
+ try:
53
+ stored = con.execute("SELECT remote FROM workspaces WHERE name='t2-crt'").fetchone()[0]
54
+ finally:
55
+ con.close()
56
+ assert "example.test" not in stored
57
+
58
+
59
+ def test_create_duplicate_is_409_and_bad_names_400():
60
+ _run(create_workspace("t2-dup"))
61
+ with pytest.raises(WorkspaceError) as e:
62
+ _run(create_workspace("t2-dup"))
63
+ assert e.value.status == 409
64
+ with pytest.raises(WorkspaceError) as e:
65
+ _run(create_workspace(" "))
66
+ assert e.value.status == 400
67
+ with pytest.raises(WorkspaceError) as e:
68
+ _run(create_workspace("a/b")) # names travel in URL paths
69
+ assert e.value.status == 400
70
+
71
+
72
+ def test_update_rename_set_and_clear_remote():
73
+ _run(create_workspace("t2-upd", remote="https://example.test/a.git"))
74
+ _run(update_workspace("t2-upd", new_name="t2-upd2", branch="rel"))
75
+ rec = _run(resolve("t2-upd2"))
76
+ assert rec.branch == "rel" and rec.remote == "https://example.test/a.git" # remote untouched
77
+ _run(update_workspace("t2-upd2", remote=None))
78
+ assert _run(resolve("t2-upd2")).remote is None
79
+ with pytest.raises(WorkspaceError) as e:
80
+ _run(update_workspace("t2-upd2", new_name=DEFAULT_WORKSPACE))
81
+ assert e.value.status == 409
82
+
83
+
84
+ def test_delete_refuses_non_empty_then_deletes_empty():
85
+ _run(create_workspace("t2-del"))
86
+ rec = _run(resolve("t2-del"))
87
+ con = sqlite3.connect(_db_path())
88
+ try:
89
+ con.execute(
90
+ "INSERT INTO predefined_queries (query_name, type, query, workspace_id) "
91
+ "VALUES ('held', 'clickhouse', 'SELECT 1', ?)",
92
+ (rec.id,),
93
+ )
94
+ con.commit()
95
+ finally:
96
+ con.close()
97
+ with pytest.raises(WorkspaceError) as e:
98
+ _run(delete_workspace("t2-del"))
99
+ assert e.value.status == 409
100
+
101
+ con = sqlite3.connect(_db_path())
102
+ try:
103
+ con.execute("DELETE FROM predefined_queries WHERE workspace_id=?", (rec.id,))
104
+ con.commit()
105
+ finally:
106
+ con.close()
107
+ _run(delete_workspace("t2-del"))
108
+ with pytest.raises(WorkspaceError):
109
+ _run(resolve("t2-del"))
@@ -0,0 +1,198 @@
1
+ """yamlio: export/import round trips at the module level, plus document
2
+ validation (parse_document rejects malformed files before anything writes)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+
8
+ import pytest
9
+ import yaml
10
+
11
+ from queryview.yamlio import (
12
+ YamlIOError,
13
+ export_dashboard,
14
+ export_query,
15
+ export_workspace,
16
+ import_text,
17
+ parse_document,
18
+ slug,
19
+ )
20
+
21
+
22
+ def _run(coro):
23
+ return asyncio.run(coro)
24
+
25
+
26
+ def test_slug_passthrough_and_encoding():
27
+ assert slug("top_errors") == "top_errors"
28
+ assert slug("top errors 2.0") == "top errors 2.0"
29
+ assert slug("a/b") == "a%2Fb"
30
+ assert slug(".hidden") == "%2Ehidden"
31
+ assert slug("héllo") == "h%C3%A9llo"
32
+
33
+
34
+ # --- parse_document validation ---------------------------------------------
35
+
36
+
37
+ def test_parse_rejects_non_mapping_and_unknown_kind():
38
+ with pytest.raises(YamlIOError):
39
+ parse_document("- just\n- a list\n")
40
+ with pytest.raises(YamlIOError):
41
+ parse_document("kind: nope\n")
42
+ with pytest.raises(YamlIOError):
43
+ parse_document("query_name: no kind\n")
44
+
45
+
46
+ def test_parse_rejects_invalid_yaml():
47
+ with pytest.raises(YamlIOError):
48
+ parse_document("kind: [unclosed\n")
49
+
50
+
51
+ def test_parse_query_requires_fields():
52
+ with pytest.raises(YamlIOError):
53
+ parse_document("kind: query\nquery_name: x\n") # no type/query
54
+ with pytest.raises(YamlIOError):
55
+ parse_document("kind: query\ntype: clickhouse\nquery_name: ''\nquery: SELECT 1\n")
56
+
57
+
58
+ def test_parse_query_validates_presentation():
59
+ doc = "kind: query\ntype: clickhouse\nquery_name: x\nquery: SELECT 1\norder_by: notalist\n"
60
+ with pytest.raises(YamlIOError):
61
+ parse_document(doc)
62
+
63
+
64
+ def test_parse_query_validates_cell_view_yaml():
65
+ doc = 'kind: query\ntype: clickhouse\nquery_name: x\nquery: SELECT 1\ncell_view: "col: [unclosed"\n'
66
+ with pytest.raises(YamlIOError):
67
+ parse_document(doc)
68
+
69
+
70
+ def test_parse_dashboard_requires_fields():
71
+ with pytest.raises(YamlIOError):
72
+ parse_document("kind: dashboard\nname: d\nconnection: c\n") # no html
73
+ with pytest.raises(YamlIOError):
74
+ parse_document("kind: dashboard\nname: d\nconnection: c\nhtml: <p/>\nqueries: [notamap]\n")
75
+
76
+
77
+ def test_parse_workspace_validates_entries():
78
+ doc = "kind: workspace\nqueries:\n - query_name: missing type\n query: SELECT 1\n"
79
+ with pytest.raises(YamlIOError):
80
+ parse_document(doc)
81
+ with pytest.raises(YamlIOError):
82
+ parse_document("kind: workspace\nqueries: notalist\n")
83
+
84
+
85
+ def test_parse_empty_workspace_bundle_is_valid():
86
+ kind, payload = parse_document("kind: workspace\n")
87
+ assert kind == "workspace"
88
+ assert payload == {"queries": [], "dashboards": []}
89
+
90
+
91
+ # --- Round trips ------------------------------------------------------------
92
+
93
+
94
+ def test_query_export_import_round_trip(default_ws_id):
95
+ from queryview.queries import get_predefined_query, save_predefined_query
96
+
97
+ _run(
98
+ save_predefined_query(
99
+ "yio query",
100
+ "clickhouse",
101
+ "SELECT 1\nFROM t",
102
+ "col:\n type: link\n value: https://x/{cell}\n",
103
+ '[{"name": "col", "dir": "DESC"}]',
104
+ '["col"]',
105
+ workspace_id=default_ws_id,
106
+ )
107
+ )
108
+ text = _run(export_query("clickhouse", "yio query", default_ws_id))
109
+ data = yaml.safe_load(text)
110
+ assert data["kind"] == "query" and data["type"] == "clickhouse"
111
+ assert data["query"] == "SELECT 1\nFROM t"
112
+
113
+ # Re-import under a changed name to prove the import writes the file's
114
+ # content, then compare rows field by field.
115
+ _run(import_text(text.replace("yio query", "yio query copy"), default_ws_id))
116
+ orig = _run(get_predefined_query("clickhouse", "yio query", default_ws_id))
117
+ copy = _run(get_predefined_query("clickhouse", "yio query copy", default_ws_id))
118
+ assert orig is not None and copy is not None
119
+ for key in ("query", "cell_view", "fields"):
120
+ assert copy[key] == orig[key]
121
+ import json
122
+
123
+ ob_copy, ob_orig = copy["order_by"], orig["order_by"]
124
+ assert ob_copy is not None and ob_orig is not None
125
+ assert json.loads(ob_copy) == json.loads(ob_orig)
126
+
127
+
128
+ def test_dashboard_export_import_round_trip(default_ws_id):
129
+ from queryview.dashboards import get_dashboard, upsert_dashboard
130
+
131
+ _run(
132
+ upsert_dashboard(
133
+ "yio dash",
134
+ "prod",
135
+ "<html>\n<body>v1</body>\n</html>",
136
+ {"panel": "SELECT 1"},
137
+ workspace_id=default_ws_id,
138
+ )
139
+ )
140
+ text = _run(export_dashboard("yio dash", default_ws_id))
141
+ data = yaml.safe_load(text)
142
+ assert data["kind"] == "dashboard" and data["connection"] == "prod"
143
+
144
+ # Drift the stored copy, then import the export to restore it.
145
+ _run(upsert_dashboard("yio dash", "other", "<p>v2</p>", {}, workspace_id=default_ws_id))
146
+ r = _run(import_text(text, default_ws_id))
147
+ assert r == {"kind": "dashboard", "queries": 0, "dashboards": 1}
148
+ d = _run(get_dashboard("yio dash", default_ws_id))
149
+ assert d is not None
150
+ assert d["connection"] == "prod" and "v1" in d["html"] and d["queries"] == {"panel": "SELECT 1"}
151
+
152
+
153
+ def test_workspace_export_import_round_trip(default_ws_id, wipe_workspace_entities):
154
+ """Export one workspace's whole content and import it into another."""
155
+ from queryview.dashboards import get_dashboard, upsert_dashboard
156
+ from queryview.queries import get_predefined_query, save_predefined_query
157
+ from queryview.workspaces import create_workspace, delete_workspace, resolve
158
+
159
+ _run(save_predefined_query("ws q1", "clickhouse", "SELECT 1", workspace_id=default_ws_id))
160
+ _run(save_predefined_query("ws q2", "postgres", "SELECT 2", workspace_id=default_ws_id))
161
+ _run(upsert_dashboard("ws dash", "prod", "<p>hi</p>", {"a": "SELECT 3"}, workspace_id=default_ws_id))
162
+ text = _run(export_workspace(default_ws_id))
163
+ data = yaml.safe_load(text)
164
+ assert data["kind"] == "workspace"
165
+ names = {(q["type"], q["query_name"]) for q in data["queries"]}
166
+ assert {("clickhouse", "ws q1"), ("postgres", "ws q2")} <= names
167
+
168
+ _run(create_workspace("yio target"))
169
+ try:
170
+ target = _run(resolve("yio target"))
171
+ r = _run(import_text(text, target.id))
172
+ assert r["kind"] == "workspace" and r["queries"] >= 2 and r["dashboards"] >= 1
173
+ q = _run(get_predefined_query("postgres", "ws q2", target.id))
174
+ assert q is not None and q["query"] == "SELECT 2"
175
+ d = _run(get_dashboard("ws dash", target.id))
176
+ assert d is not None and d["queries"] == {"a": "SELECT 3"}
177
+ # Clean the target so delete_workspace succeeds (it must be empty).
178
+ wipe_workspace_entities(target.id)
179
+ finally:
180
+ _run(delete_workspace("yio target"))
181
+
182
+
183
+ def test_import_validates_before_writing(default_ws_id):
184
+ """A workspace bundle with one bad entry writes nothing at all."""
185
+ from queryview.queries import get_predefined_query
186
+
187
+ doc = (
188
+ "kind: workspace\n"
189
+ "queries:\n"
190
+ " - type: clickhouse\n"
191
+ " query_name: good entry\n"
192
+ " query: SELECT 1\n"
193
+ " - type: clickhouse\n"
194
+ " query_name: bad entry\n"
195
+ )
196
+ with pytest.raises(YamlIOError):
197
+ _run(import_text(doc, default_ws_id))
198
+ assert _run(get_predefined_query("clickhouse", "good entry", default_ws_id)) is None
@@ -0,0 +1,111 @@
1
+ """Strict validation for the presentation fields carried on a push, a
2
+ predefined-query save, or a YAML import/restore: order_by/fields (structured)
3
+ and cell_view (raw YAML text, checked against the contract in
4
+ docs/query.md#cell-views). Shared by every write path so they all enforce the
5
+ same rules; reads stay lenient (the client drops entries it can't use)."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import yaml
10
+
11
+ MAX_LIMIT = 10000
12
+
13
+
14
+ def presentation_error(order_by: object, fields: object) -> str | None:
15
+ """Return an error message if order_by/fields are malformed, else None.
16
+ None inputs are valid (the field is simply absent/unchanged)."""
17
+ if order_by is not None:
18
+ if not isinstance(order_by, list):
19
+ return "invalid order_by: must be a list"
20
+ for col in order_by:
21
+ if not isinstance(col, dict):
22
+ return "invalid order_by: each entry must be an object"
23
+ name = col.get("name")
24
+ if not isinstance(name, str) or not name:
25
+ return "invalid order_by: name must be a non-empty string"
26
+ if "`" in name:
27
+ return "invalid order_by: name must not contain a backtick"
28
+ direction = col.get("dir")
29
+ if not isinstance(direction, str) or direction.upper() not in ("ASC", "DESC"):
30
+ return "invalid order_by: dir must be ASC or DESC"
31
+ if fields is not None:
32
+ if not isinstance(fields, list):
33
+ return "invalid fields: must be a list"
34
+ for f in fields:
35
+ if not isinstance(f, str) or not f:
36
+ return "invalid fields: each column must be a non-empty string"
37
+ return None
38
+
39
+
40
+ def _cell_view_params_error(raw: object) -> str | None:
41
+ """Validate the reserved `params:` selector list (see docs/query.md)."""
42
+ if not isinstance(raw, list):
43
+ return "invalid cell_view: params must be a list of selector specs"
44
+ for i, entry in enumerate(raw):
45
+ if not isinstance(entry, dict):
46
+ return f"invalid cell_view: params[{i}] must be a mapping"
47
+ name = entry.get("name")
48
+ if not isinstance(name, str) or not name:
49
+ return f"invalid cell_view: params[{i}] name must be a non-empty string"
50
+ options, sql = entry.get("options"), entry.get("options_sql")
51
+ if (options is None) == (sql is None):
52
+ return f"invalid cell_view: params[{i}] must declare exactly one of options or options_sql"
53
+ if sql is not None and (not isinstance(sql, str) or not sql.strip()):
54
+ return f"invalid cell_view: params[{i}] options_sql must be a non-empty string"
55
+ if options is not None:
56
+ if not isinstance(options, list) or not options:
57
+ return f"invalid cell_view: params[{i}] options must be a non-empty list"
58
+ if any(isinstance(v, (dict, list)) or v is None for v in options):
59
+ return f"invalid cell_view: params[{i}] options must be scalars"
60
+ return None
61
+
62
+
63
+ def cell_view_error(cell_view: object) -> str | None:
64
+ """Return an error message if a cell_view YAML text is malformed, else None.
65
+ None / empty text is valid (no custom views). The contract
66
+ (docs/query.md#cell-views): a YAML mapping whose reserved `params` key holds
67
+ selector specs and whose other entries are column -> {type: link|custom,
68
+ value: str} (extra keys are allowed)."""
69
+ if cell_view is None:
70
+ return None
71
+ if not isinstance(cell_view, str):
72
+ return "invalid cell_view: must be YAML text"
73
+ if not cell_view.strip():
74
+ return None
75
+ try:
76
+ doc = yaml.safe_load(cell_view)
77
+ except yaml.YAMLError as e:
78
+ return f"invalid cell_view: not valid YAML ({e})"
79
+ if doc is None:
80
+ return None
81
+ if not isinstance(doc, dict):
82
+ return "invalid cell_view: must be a YAML mapping of column -> {type, value}"
83
+ for key, entry in doc.items():
84
+ if not isinstance(key, str) or not key:
85
+ return "invalid cell_view: column names must be non-empty strings"
86
+ if key == "params":
87
+ perr = _cell_view_params_error(entry)
88
+ if perr is not None:
89
+ return perr
90
+ continue
91
+ if not isinstance(entry, dict):
92
+ return f"invalid cell_view: entry {key!r} must be a {{type, value}} mapping"
93
+ if entry.get("type") not in ("link", "custom"):
94
+ return f"invalid cell_view: entry {key!r} type must be 'link' or 'custom'"
95
+ value = entry.get("value")
96
+ if not isinstance(value, str) or not value:
97
+ return f"invalid cell_view: entry {key!r} value must be a non-empty string"
98
+ return None
99
+
100
+
101
+ def clamp_paging(limit: object, offset: object) -> tuple[int, int]:
102
+ """Coerce/clamp limit & offset to safe ints: 0 <= limit <= MAX_LIMIT, offset >= 0."""
103
+ try:
104
+ lim = int(limit) # type: ignore[arg-type]
105
+ except (TypeError, ValueError):
106
+ lim = 100
107
+ try:
108
+ off = int(offset) # type: ignore[arg-type]
109
+ except (TypeError, ValueError):
110
+ off = 0
111
+ return max(0, min(lim, MAX_LIMIT)), max(0, off)
@@ -0,0 +1,167 @@
1
+ """Workspace domain: named git-sync targets that own predefined queries and
2
+ dashboards. The remote URL may embed a token, so it is encrypted at rest with
3
+ connect.py's AES-GCM helpers and never returned by list_workspaces. Docs:
4
+ docs/workspace.md."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Any, ClassVar
10
+
11
+ from sqlalchemy import text
12
+ from sqlmodel import Field, SQLModel, select
13
+ from sqlmodel.ext.asyncio.session import AsyncSession
14
+
15
+ from .connect import _decrypt_str, _encrypt_str, _engine_for_db, _ensure_schema
16
+
17
+ DEFAULT_WORKSPACE = "default"
18
+
19
+
20
+ class WorkspaceError(Exception):
21
+ """Workspace failure carrying an HTTP-ish status for the API layer:
22
+ 400 invalid input, 404 unknown workspace, 409 conflict (duplicate name,
23
+ delete of a non-empty workspace)."""
24
+
25
+ def __init__(self, message: str, status: int = 400):
26
+ super().__init__(message)
27
+ self.status = status
28
+
29
+
30
+ class Workspace(SQLModel, table=True):
31
+ __tablename__: ClassVar[str] = "workspaces"
32
+
33
+ id: int | None = Field(default=None, primary_key=True)
34
+ name: str = Field(unique=True, index=True)
35
+ remote: str | None = Field(default=None) # base64(AES-GCM(url)) — never plaintext
36
+ branch: str = Field(default="main")
37
+
38
+
39
+ @dataclass
40
+ class WorkspaceRec:
41
+ """A resolved workspace with its remote decrypted, ready for gitsync."""
42
+
43
+ id: int
44
+ name: str
45
+ remote: str | None
46
+ branch: str
47
+
48
+
49
+ def _to_rec(row: Workspace) -> WorkspaceRec:
50
+ return WorkspaceRec(
51
+ id=row.id, # type: ignore[arg-type] # persisted rows always have an id
52
+ name=row.name,
53
+ remote=_decrypt_str(row.remote) if row.remote else None,
54
+ branch=row.branch,
55
+ )
56
+
57
+
58
+ def _valid_name(name: str) -> str:
59
+ name = name.strip()
60
+ if not name:
61
+ raise WorkspaceError("workspace name is required", status=400)
62
+ if "/" in name:
63
+ # Names travel in /api/workspaces/{name} URL paths.
64
+ raise WorkspaceError("workspace name must not contain '/'", status=400)
65
+ return name
66
+
67
+
68
+ async def resolve(name: str) -> WorkspaceRec:
69
+ """The named workspace, or 404. Every workspace-scoped operation starts here."""
70
+ await _ensure_schema()
71
+ async with AsyncSession(_engine_for_db()) as s:
72
+ row = (await s.exec(select(Workspace).where(Workspace.name == name))).first()
73
+ if row is None:
74
+ raise WorkspaceError(f"unknown workspace {name!r}", status=404)
75
+ return _to_rec(row)
76
+
77
+
78
+ async def list_workspaces() -> list[dict[str, Any]]:
79
+ """All workspaces ordered by name; exposes whether a remote is configured
80
+ but never the remote itself."""
81
+ await _ensure_schema()
82
+ async with AsyncSession(_engine_for_db()) as s:
83
+ rows = (await s.exec(select(Workspace).order_by(Workspace.name))).all()
84
+ return [{"name": r.name, "branch": r.branch, "configured": bool(r.remote)} for r in rows]
85
+
86
+
87
+ async def create_workspace(name: str, remote: str | None = None, branch: str = "main") -> None:
88
+ name = _valid_name(name)
89
+ await _ensure_schema()
90
+ async with AsyncSession(_engine_for_db()) as s:
91
+ dup = (await s.exec(select(Workspace).where(Workspace.name == name))).first()
92
+ if dup is not None:
93
+ raise WorkspaceError(f"workspace {name!r} already exists", status=409)
94
+ s.add(
95
+ Workspace(
96
+ name=name,
97
+ remote=_encrypt_str(remote) if remote else None,
98
+ branch=branch.strip() or "main",
99
+ )
100
+ )
101
+ await s.commit()
102
+
103
+
104
+ # Sentinel distinguishing "leave the remote as-is" (omitted) from "clear it"
105
+ # (an explicit None).
106
+ _UNSET: Any = object()
107
+
108
+
109
+ async def update_workspace(
110
+ name: str,
111
+ new_name: str | None = None,
112
+ remote: Any = _UNSET,
113
+ branch: str | None = None,
114
+ ) -> None:
115
+ """Rename and/or reconfigure a workspace. Renaming is a one-row update:
116
+ entities and clone dirs are keyed by workspace id, so nothing else moves."""
117
+ await _ensure_schema()
118
+ async with AsyncSession(_engine_for_db()) as s:
119
+ row = (await s.exec(select(Workspace).where(Workspace.name == name))).first()
120
+ if row is None:
121
+ raise WorkspaceError(f"unknown workspace {name!r}", status=404)
122
+ if new_name is not None and new_name.strip() != name:
123
+ new_name = _valid_name(new_name)
124
+ dup = (await s.exec(select(Workspace).where(Workspace.name == new_name))).first()
125
+ if dup is not None:
126
+ raise WorkspaceError(f"workspace {new_name!r} already exists", status=409)
127
+ row.name = new_name
128
+ if remote is not _UNSET:
129
+ row.remote = _encrypt_str(remote) if remote else None
130
+ if branch is not None and branch.strip():
131
+ row.branch = branch.strip()
132
+ s.add(row)
133
+ await s.commit()
134
+
135
+
136
+ async def _entity_count(workspace_id: int) -> int:
137
+ """How many entities the workspace still owns. Raw SQL so this module
138
+ doesn't import the entity modules (which import nothing from here either)."""
139
+ async with _engine_for_db().connect() as conn:
140
+ n = (
141
+ await conn.execute(
142
+ text(
143
+ "SELECT (SELECT COUNT(*) FROM predefined_queries WHERE workspace_id = :w)"
144
+ " + (SELECT COUNT(*) FROM dashboards WHERE workspace_id = :w)"
145
+ ),
146
+ {"w": workspace_id},
147
+ )
148
+ ).scalar()
149
+ return int(n or 0)
150
+
151
+
152
+ async def delete_workspace(name: str) -> None:
153
+ """Delete an empty workspace; 409 while it still owns entities. The git
154
+ remote keeps its history either way — this only removes the local row."""
155
+ await _ensure_schema()
156
+ async with AsyncSession(_engine_for_db()) as s:
157
+ row = (await s.exec(select(Workspace).where(Workspace.name == name))).first()
158
+ if row is None:
159
+ raise WorkspaceError(f"unknown workspace {name!r}", status=404)
160
+ count = await _entity_count(row.id) # type: ignore[arg-type]
161
+ if count:
162
+ raise WorkspaceError(
163
+ f"workspace {name!r} still contains {count} entities; delete them first",
164
+ status=409,
165
+ )
166
+ await s.delete(row)
167
+ await s.commit()