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
queryview/queries.py ADDED
@@ -0,0 +1,159 @@
1
+ """Predefined query store: reusable SQL keyed by connection type within a
2
+ workspace (names are unique per workspace). Reuses the SQLite engine owned by
3
+ connect.py."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, ClassVar
8
+
9
+ from sqlalchemy import UniqueConstraint
10
+ from sqlmodel import Field, SQLModel, select
11
+ from sqlmodel.ext.asyncio.session import AsyncSession
12
+
13
+ from .connect import _engine_for_db, _ensure_schema
14
+
15
+
16
+ class PredefinedQuery(SQLModel, table=True):
17
+ __tablename__: ClassVar[str] = "predefined_queries"
18
+ __table_args__ = (UniqueConstraint("workspace_id", "type", "query_name", name="uq_predefined_ws_type_name"),)
19
+
20
+ id: int | None = Field(default=None, primary_key=True)
21
+ query_name: str = Field(index=True)
22
+ type: str = Field(index=True)
23
+ workspace_id: int # owning workspace (workspaces.id); names unique per workspace
24
+ query: str
25
+ # Raw YAML (column_name -> {type, value}) controlling cell rendering; NULL =
26
+ # none. Never parsed here — interpreted client-side, matched to columns by name.
27
+ cell_view: str | None = Field(default=None)
28
+ # Raw JSON text (or NULL) for saved presentation, stored verbatim like
29
+ # cell_view: order_by is [{"name","dir"}], fields is ["col", ...].
30
+ order_by: str | None = Field(default=None)
31
+ fields: str | None = Field(default=None)
32
+
33
+
34
+ def _row_dict(r: PredefinedQuery) -> dict[str, str | None]:
35
+ """The row shape every accessor here returns (order_by/fields stay the
36
+ stored JSON text)."""
37
+ return {
38
+ "query_name": r.query_name,
39
+ "query": r.query,
40
+ "cell_view": r.cell_view,
41
+ "order_by": r.order_by,
42
+ "fields": r.fields,
43
+ }
44
+
45
+
46
+ async def list_predefined_queries(conn_type: str, workspace_id: int) -> list[dict[str, str | None]]:
47
+ """Saved queries for a connection type within one workspace, ordered by name."""
48
+ await _ensure_schema()
49
+ async with AsyncSession(_engine_for_db()) as s:
50
+ rows = (
51
+ await s.exec(
52
+ select(PredefinedQuery)
53
+ .where(
54
+ PredefinedQuery.type == conn_type,
55
+ PredefinedQuery.workspace_id == workspace_id,
56
+ )
57
+ .order_by(PredefinedQuery.query_name)
58
+ )
59
+ ).all()
60
+ return [_row_dict(r) for r in rows]
61
+
62
+
63
+ async def list_all_predefined_queries(workspace_id: int) -> list[dict[str, str | None]]:
64
+ """Every saved query in one workspace regardless of connection type,
65
+ ordered by (type, name) — the row shape of list_predefined_queries plus
66
+ `type`. Used by the whole-workspace YAML export."""
67
+ await _ensure_schema()
68
+ async with AsyncSession(_engine_for_db()) as s:
69
+ rows = (
70
+ await s.exec(
71
+ select(PredefinedQuery)
72
+ .where(PredefinedQuery.workspace_id == workspace_id)
73
+ .order_by(PredefinedQuery.type, PredefinedQuery.query_name)
74
+ )
75
+ ).all()
76
+ return [{"type": r.type, **_row_dict(r)} for r in rows]
77
+
78
+
79
+ async def get_predefined_query(conn_type: str, query_name: str, workspace_id: int) -> dict[str, str | None] | None:
80
+ """One saved query by (workspace, type, name) — the unique key — in the same
81
+ row shape as list_predefined_queries items, or None."""
82
+ await _ensure_schema()
83
+ async with AsyncSession(_engine_for_db()) as s:
84
+ row = (
85
+ await s.exec(
86
+ select(PredefinedQuery).where(
87
+ PredefinedQuery.type == conn_type,
88
+ PredefinedQuery.query_name == query_name,
89
+ PredefinedQuery.workspace_id == workspace_id,
90
+ )
91
+ )
92
+ ).first()
93
+ return _row_dict(row) if row is not None else None
94
+
95
+
96
+ async def list_predefined_queries_view(conn_type: str, workspace_id: int) -> list[dict]:
97
+ """Like list_predefined_queries but with order_by/fields parsed from their
98
+ stored JSON text into values (cell_view stays raw YAML). Shared by the HTTP
99
+ list endpoint and the MCP list_queries tool so both present the same shape."""
100
+ import json
101
+
102
+ rows = await list_predefined_queries(conn_type, workspace_id)
103
+ for r in rows:
104
+ ob, fl = r.get("order_by"), r.get("fields")
105
+ r["order_by"] = json.loads(ob) if ob else None
106
+ r["fields"] = json.loads(fl) if fl else None
107
+ return rows
108
+
109
+
110
+ async def save_predefined_queries(rows: list[dict[str, Any]], *, workspace_id: int) -> None:
111
+ """Upsert many predefined queries — each a dict with string `type`,
112
+ `query_name`, `query` and optional `cell_view`/`order_by`/`fields` (JSON
113
+ text or None) — in one transaction, keyed by (workspace, type, query_name)."""
114
+ await _ensure_schema()
115
+ async with AsyncSession(_engine_for_db()) as s:
116
+ for r in rows:
117
+ row = (
118
+ await s.exec(
119
+ select(PredefinedQuery).where(
120
+ PredefinedQuery.type == r["type"],
121
+ PredefinedQuery.query_name == r["query_name"],
122
+ PredefinedQuery.workspace_id == workspace_id,
123
+ )
124
+ )
125
+ ).first()
126
+ if row is None:
127
+ row = PredefinedQuery(query_name=r["query_name"], type=r["type"], workspace_id=workspace_id, query="")
128
+ row.query = r["query"] or ""
129
+ row.cell_view = r.get("cell_view")
130
+ row.order_by = r.get("order_by")
131
+ row.fields = r.get("fields")
132
+ s.add(row)
133
+ await s.commit()
134
+
135
+
136
+ async def save_predefined_query(
137
+ query_name: str,
138
+ conn_type: str,
139
+ query: str,
140
+ cell_view: str | None = None,
141
+ order_by: str | None = None,
142
+ fields: str | None = None,
143
+ *,
144
+ workspace_id: int,
145
+ ) -> None:
146
+ """Upsert a predefined query by (workspace, type, query_name)."""
147
+ await save_predefined_queries(
148
+ [
149
+ {
150
+ "type": conn_type,
151
+ "query_name": query_name,
152
+ "query": query,
153
+ "cell_view": cell_view,
154
+ "order_by": order_by,
155
+ "fields": fields,
156
+ }
157
+ ],
158
+ workspace_id=workspace_id,
159
+ )
queryview/remote.py ADDED
@@ -0,0 +1,141 @@
1
+ """In-memory push hub for remote control: one message queue per armed browser
2
+ channel, keyed by a random public id. SSE framing and disconnect handling live
3
+ in main.py."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import secrets
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+ from .validation import cell_view_error, presentation_error
14
+
15
+ LOCK_TTL_SECONDS = 30.0
16
+
17
+
18
+ @dataclass
19
+ class _Channel:
20
+ queue: asyncio.Queue[dict[str, Any]] = field(default_factory=asyncio.Queue)
21
+ # Advisory edit lock: "human" | "agent" | None. lock_touched is a monotonic
22
+ # timestamp refreshed on acquire; a human lock older than LOCK_TTL_SECONDS is
23
+ # treated as released (heartbeat lapsed / tab froze).
24
+ lock_owner: str | None = None
25
+ lock_touched: float = 0.0
26
+ # The database this browser session currently targets, reported by the UI so
27
+ # push_query/push_dashboard can echo it back to the agent.
28
+ database: str | None = None
29
+ # The workspace this browser session is on, reported by the UI like
30
+ # `database`, so session-scoped MCP tools resolve against it.
31
+ workspace: str | None = None
32
+
33
+
34
+ # remote_id -> channel. Module-level, like connect.py's _sessions.
35
+ _channels: dict[str, _Channel] = {}
36
+
37
+
38
+ def _human_holds(channel: _Channel) -> bool:
39
+ return channel.lock_owner == "human" and (time.monotonic() - channel.lock_touched) < LOCK_TTL_SECONDS
40
+
41
+
42
+ def acquire(remote_id: str, owner: str) -> tuple[bool, str]:
43
+ """Take (or refresh) the edit lock for `owner`. Succeeds if free, already
44
+ yours, or the current holder's TTL has lapsed. Otherwise returns the
45
+ owner-named block reason."""
46
+ channel = _channels.get(remote_id)
47
+ if channel is None:
48
+ return False, "unknown or inactive session"
49
+ now = time.monotonic()
50
+ expired = (now - channel.lock_touched) >= LOCK_TTL_SECONDS
51
+ if channel.lock_owner in (None, owner) or expired:
52
+ channel.lock_owner = owner
53
+ channel.lock_touched = now
54
+ return True, "acquired"
55
+ who = "user" if channel.lock_owner == "human" else "agent"
56
+ return False, f"blocked, {who} editing"
57
+
58
+
59
+ def release(remote_id: str, owner: str) -> tuple[bool, str]:
60
+ """Release the lock only if `owner` holds it (idempotent)."""
61
+ channel = _channels.get(remote_id)
62
+ if channel is None:
63
+ return False, "unknown or inactive session"
64
+ if channel.lock_owner == owner:
65
+ channel.lock_owner = None
66
+ return True, "released"
67
+
68
+
69
+ def register() -> str:
70
+ """Create a channel for a newly-armed browser session; return its public id.
71
+ The id is random and unrelated to the qv_session cookie, so the session secret
72
+ is never exposed to the agent."""
73
+ remote_id = secrets.token_hex(8)
74
+ _channels[remote_id] = _Channel()
75
+ return remote_id
76
+
77
+
78
+ def unregister(remote_id: str) -> None:
79
+ """Drop a channel (idempotent)."""
80
+ _channels.pop(remote_id, None)
81
+
82
+
83
+ def set_session_database(remote_id: str, database: str | None) -> bool:
84
+ """Record the database a live session targets (reported by the UI). Returns
85
+ False for an unknown/inactive session."""
86
+ channel = _channels.get(remote_id)
87
+ if channel is None:
88
+ return False
89
+ channel.database = database
90
+ return True
91
+
92
+
93
+ def session_database(remote_id: str) -> str | None:
94
+ """The database a live session targets, or None if unknown/unreported."""
95
+ channel = _channels.get(remote_id)
96
+ return channel.database if channel else None
97
+
98
+
99
+ def set_session_workspace(remote_id: str, workspace: str | None) -> bool:
100
+ """Record the workspace a live session is on (reported by the UI). Returns
101
+ False for an unknown/inactive session."""
102
+ channel = _channels.get(remote_id)
103
+ if channel is None:
104
+ return False
105
+ channel.workspace = workspace
106
+ return True
107
+
108
+
109
+ def session_workspace(remote_id: str) -> str | None:
110
+ """The workspace a live session is on, or None if unknown/unreported."""
111
+ channel = _channels.get(remote_id)
112
+ return channel.workspace if channel else None
113
+
114
+
115
+ def push(remote_id: str, payload: dict[str, Any]) -> tuple[bool, str]:
116
+ """Validate + lock-check + enqueue a payload for a channel. Ordered:
117
+ unknown session -> invalid presentation -> human holds lock -> deliver."""
118
+ channel = _channels.get(remote_id)
119
+ if channel is None:
120
+ return False, "unknown or inactive session"
121
+ err = presentation_error(payload.get("order_by"), payload.get("fields")) or cell_view_error(
122
+ payload.get("cell_view")
123
+ )
124
+ if err is not None:
125
+ return False, err
126
+ if _human_holds(channel):
127
+ return False, "blocked, user editing"
128
+ channel.queue.put_nowait(payload)
129
+ return True, "delivered"
130
+
131
+
132
+ async def next_message(remote_id: str, timeout: float) -> dict[str, Any] | None:
133
+ """Wait up to `timeout` seconds for the next payload on a channel. Returns
134
+ None on timeout or if the channel is gone."""
135
+ channel = _channels.get(remote_id)
136
+ if channel is None:
137
+ return None
138
+ try:
139
+ return await asyncio.wait_for(channel.queue.get(), timeout)
140
+ except TimeoutError:
141
+ return None