queryview 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- queryview/__init__.py +0 -0
- queryview/conftest.py +86 -0
- queryview/connect.py +428 -0
- queryview/dashboard_queries.py +75 -0
- queryview/dashboards.py +156 -0
- queryview/drivers/__init__.py +10 -0
- queryview/drivers/base.py +165 -0
- queryview/drivers/clickhouse.py +138 -0
- queryview/drivers/duckdb.py +153 -0
- queryview/drivers/postgres.py +166 -0
- queryview/drivers/test_base.py +49 -0
- queryview/drivers/test_clickhouse.py +53 -0
- queryview/drivers/test_contract.py +77 -0
- queryview/drivers/test_duckdb.py +80 -0
- queryview/drivers/test_postgres.py +80 -0
- queryview/gitsync.py +374 -0
- queryview/main.py +740 -0
- queryview/mcp_server.py +294 -0
- queryview/migrations/env.py +39 -0
- queryview/migrations/script.py.mako +29 -0
- queryview/migrations/versions/9a536b7c0328_initial_schema.py +89 -0
- queryview/migrations/versions/a1b2c3d4e5f6_connection_config_blob.py +59 -0
- queryview/migrations/versions/b2c3d4e5f6a7_predefined_presentation.py +32 -0
- queryview/migrations/versions/c7d8e9f0a1b2_workspaces.py +98 -0
- queryview/queries.py +159 -0
- queryview/remote.py +141 -0
- queryview/static/assets/index-CvnC_D68.js +47 -0
- queryview/static/assets/index-Qe7bhycG.css +2 -0
- queryview/static/favicon.svg +1 -0
- queryview/static/index.html +14 -0
- queryview/test_api_db.py +51 -0
- queryview/test_api_export_import.py +85 -0
- queryview/test_api_gitsync.py +83 -0
- queryview/test_api_workspaces.py +44 -0
- queryview/test_connect_flow.py +123 -0
- queryview/test_connect_store.py +34 -0
- queryview/test_dashboards.py +216 -0
- queryview/test_gitsync.py +346 -0
- queryview/test_main.py +18 -0
- queryview/test_mcp_gitsync.py +72 -0
- queryview/test_migrations.py +99 -0
- queryview/test_queries.py +170 -0
- queryview/test_remote.py +260 -0
- queryview/test_validation.py +87 -0
- queryview/test_workspaces.py +109 -0
- queryview/test_yamlio.py +198 -0
- queryview/validation.py +111 -0
- queryview/workspaces.py +167 -0
- queryview/yamlio.py +245 -0
- queryview-0.0.2.dist-info/METADATA +183 -0
- queryview-0.0.2.dist-info/RECORD +54 -0
- queryview-0.0.2.dist-info/WHEEL +4 -0
- queryview-0.0.2.dist-info/entry_points.txt +3 -0
- queryview-0.0.2.dist-info/licenses/LICENSE +21 -0
queryview/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
|