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,166 @@
|
|
|
1
|
+
"""Postgres driver (asyncpg). Short-lived connections per request; the picker
|
|
2
|
+
lists real databases and the selected one is where queries run."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import asyncpg
|
|
11
|
+
|
|
12
|
+
from .base import (
|
|
13
|
+
QueryResult,
|
|
14
|
+
build_order_by,
|
|
15
|
+
parse_host_port_config,
|
|
16
|
+
serialize_rows,
|
|
17
|
+
wrap_paginated,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
PG_TIMEOUT_SECONDS = 5
|
|
21
|
+
_BOOTSTRAP_DBS = ("postgres", "template1")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class PgConfig:
|
|
26
|
+
host: str
|
|
27
|
+
port: int
|
|
28
|
+
username: str
|
|
29
|
+
password: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_pg_config(body: Any) -> tuple[PgConfig | None, str | None]:
|
|
33
|
+
fields, err = parse_host_port_config(body)
|
|
34
|
+
if err or fields is None:
|
|
35
|
+
return None, err
|
|
36
|
+
return PgConfig(**fields), None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _raw_connect(c: PgConfig, database: str | None):
|
|
40
|
+
return await asyncpg.connect(
|
|
41
|
+
host=c.host,
|
|
42
|
+
port=c.port,
|
|
43
|
+
user=c.username or None,
|
|
44
|
+
password=c.password or None,
|
|
45
|
+
database=database,
|
|
46
|
+
timeout=PG_TIMEOUT_SECONDS,
|
|
47
|
+
command_timeout=PG_TIMEOUT_SECONDS,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def _raw_connect_bootstrap(c: PgConfig):
|
|
52
|
+
"""Connect to *some* database so we can enumerate the rest."""
|
|
53
|
+
candidates = ([c.username] if c.username else []) + list(_BOOTSTRAP_DBS)
|
|
54
|
+
last: Exception | None = None
|
|
55
|
+
for db in candidates:
|
|
56
|
+
try:
|
|
57
|
+
return await _raw_connect(c, db)
|
|
58
|
+
except Exception as e: # noqa: BLE001
|
|
59
|
+
last = e
|
|
60
|
+
raise last if last is not None else RuntimeError("could not connect")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@asynccontextmanager
|
|
64
|
+
async def _connect(c: PgConfig, database: str | None):
|
|
65
|
+
"""Short-lived connection to a specific database, closed on exit."""
|
|
66
|
+
conn = await _raw_connect(c, database)
|
|
67
|
+
try:
|
|
68
|
+
yield conn
|
|
69
|
+
finally:
|
|
70
|
+
await conn.close()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@asynccontextmanager
|
|
74
|
+
async def _connect_bootstrap(c: PgConfig):
|
|
75
|
+
"""Short-lived connection to a maintenance database, closed on exit."""
|
|
76
|
+
conn = await _raw_connect_bootstrap(c)
|
|
77
|
+
try:
|
|
78
|
+
yield conn
|
|
79
|
+
finally:
|
|
80
|
+
await conn.close()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class PostgresDriver:
|
|
84
|
+
type: str = "postgres"
|
|
85
|
+
requires_database: bool = True
|
|
86
|
+
ident_quote: str = '"'
|
|
87
|
+
|
|
88
|
+
def parse_config(self, body: Any) -> tuple[PgConfig | None, str | None]:
|
|
89
|
+
return parse_pg_config(body)
|
|
90
|
+
|
|
91
|
+
def config_to_dict(self, config: PgConfig) -> dict[str, Any]:
|
|
92
|
+
return asdict(config)
|
|
93
|
+
|
|
94
|
+
def config_from_dict(self, data: dict[str, Any]) -> PgConfig:
|
|
95
|
+
return PgConfig(**data)
|
|
96
|
+
|
|
97
|
+
async def test(self, config: PgConfig) -> dict[str, Any]:
|
|
98
|
+
try:
|
|
99
|
+
async with _connect_bootstrap(config) as conn:
|
|
100
|
+
val = await conn.fetchval("SELECT 1")
|
|
101
|
+
return {"ok": True, "message": f"Connected — SELECT 1 returned {val}"}
|
|
102
|
+
except Exception as e: # noqa: BLE001
|
|
103
|
+
return {"ok": False, "message": str(e) or "connection failed"}
|
|
104
|
+
|
|
105
|
+
async def list_databases(self, config: PgConfig) -> tuple[bool, list[str] | str]:
|
|
106
|
+
try:
|
|
107
|
+
async with _connect_bootstrap(config) as conn:
|
|
108
|
+
rows = await conn.fetch(
|
|
109
|
+
"SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate ORDER BY datname"
|
|
110
|
+
)
|
|
111
|
+
return True, [r["datname"] for r in rows]
|
|
112
|
+
except Exception as e: # noqa: BLE001
|
|
113
|
+
return False, str(e) or "connection failed"
|
|
114
|
+
|
|
115
|
+
async def list_tables(self, config: PgConfig, database: str | None) -> tuple[bool, list[dict[str, Any]] | str]:
|
|
116
|
+
# Tables and views of the public schema — what an unqualified name in
|
|
117
|
+
# the explorer's generated SELECT resolves to under the default
|
|
118
|
+
# search_path. Other schemas need explicit SQL on the query page.
|
|
119
|
+
# Row counts are the planner's reltuples estimate (-1 = never analyzed
|
|
120
|
+
# -> NULL, e.g. a freshly created table); size is on-disk relation size.
|
|
121
|
+
try:
|
|
122
|
+
async with _connect(config, database) as conn:
|
|
123
|
+
rows = await conn.fetch(
|
|
124
|
+
"SELECT c.relname AS name, "
|
|
125
|
+
"CASE WHEN c.reltuples < 0 THEN NULL "
|
|
126
|
+
"ELSE c.reltuples::bigint END AS rows, "
|
|
127
|
+
"pg_total_relation_size(c.oid) AS bytes "
|
|
128
|
+
"FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
|
|
129
|
+
"WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p', 'v', 'm') "
|
|
130
|
+
"ORDER BY c.relname"
|
|
131
|
+
)
|
|
132
|
+
return True, [{"name": r["name"], "rows": r["rows"], "bytes": r["bytes"]} for r in rows]
|
|
133
|
+
except Exception as e: # noqa: BLE001
|
|
134
|
+
return False, str(e) or "connection failed"
|
|
135
|
+
|
|
136
|
+
async def run_query(
|
|
137
|
+
self,
|
|
138
|
+
config: PgConfig,
|
|
139
|
+
sql: str,
|
|
140
|
+
database: str | None,
|
|
141
|
+
limit: int,
|
|
142
|
+
offset: int,
|
|
143
|
+
order_by: list[dict[str, Any]] | None,
|
|
144
|
+
fmt: str,
|
|
145
|
+
) -> QueryResult:
|
|
146
|
+
order_clause = build_order_by(order_by, '"')
|
|
147
|
+
paginated = wrap_paginated(sql, order_clause, limit, offset, alias="_qv")
|
|
148
|
+
try:
|
|
149
|
+
async with _connect(config, database) as conn:
|
|
150
|
+
stmt = await conn.prepare(paginated)
|
|
151
|
+
columns = [a.name for a in stmt.get_attributes()]
|
|
152
|
+
records = await stmt.fetch()
|
|
153
|
+
return QueryResult(True, serialize_rows(columns, [list(r) for r in records], fmt))
|
|
154
|
+
except Exception as e: # noqa: BLE001
|
|
155
|
+
return QueryResult(False, str(e) or "connection failed")
|
|
156
|
+
|
|
157
|
+
async def describe_query(
|
|
158
|
+
self, config: PgConfig, sql: str, database: str | None
|
|
159
|
+
) -> tuple[bool, list[dict[str, str]] | str]:
|
|
160
|
+
inner = sql.rstrip().rstrip(";")
|
|
161
|
+
try:
|
|
162
|
+
async with _connect(config, database) as conn:
|
|
163
|
+
stmt = await conn.prepare(inner)
|
|
164
|
+
return True, [{"name": a.name, "type": a.type.name} for a in stmt.get_attributes()]
|
|
165
|
+
except Exception as e: # noqa: BLE001
|
|
166
|
+
return False, str(e) or "connection failed"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Driver dialect helpers and the row serializer (the shared output contract)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from queryview.drivers.base import (
|
|
6
|
+
build_order_by,
|
|
7
|
+
select_all_sql,
|
|
8
|
+
serialize_rows,
|
|
9
|
+
wrap_paginated,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_select_all_sql_quotes_and_doubles_embedded_quotes():
|
|
14
|
+
assert select_all_sql("items", '"') == 'SELECT * FROM "items"'
|
|
15
|
+
assert select_all_sql('we"ird', '"') == 'SELECT * FROM "we""ird"'
|
|
16
|
+
assert select_all_sql("a`b", "`") == "SELECT * FROM `a``b`"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_build_order_by_quotes_and_whitelists_direction():
|
|
20
|
+
assert build_order_by([{"name": "a", "dir": "desc"}], "`") == "ORDER BY `a` DESC"
|
|
21
|
+
# Unknown direction falls back to ASC; quote chars in the name are doubled.
|
|
22
|
+
assert build_order_by([{"name": "a`b", "dir": "x"}], "`") == "ORDER BY `a``b` ASC"
|
|
23
|
+
assert build_order_by([{"name": "a"}], '"') == 'ORDER BY "a" ASC'
|
|
24
|
+
assert build_order_by(None, "`") == ""
|
|
25
|
+
assert build_order_by([{"bad": 1}], "`") == ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_wrap_paginated_matches_clickhouse_shape_without_alias():
|
|
29
|
+
out = wrap_paginated("SELECT 1;", "", 100, 0, alias=None)
|
|
30
|
+
assert out == "SELECT * FROM (\nSELECT 1\n) LIMIT 100 OFFSET 0"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_wrap_paginated_adds_alias_and_order():
|
|
34
|
+
out = wrap_paginated("SELECT 1", 'ORDER BY "a" ASC', 10, 5, alias="_qv")
|
|
35
|
+
assert out == 'SELECT * FROM (\nSELECT 1\n) AS _qv ORDER BY "a" ASC LIMIT 10 OFFSET 5'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_serialize_rows_tsv_with_names_and_nulls():
|
|
39
|
+
out = serialize_rows(["id", "name"], [[1, "a"], [2, None]], "tsv")
|
|
40
|
+
assert out == "id\tname\n1\ta\n2\t"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_serialize_rows_csv_quotes_and_uses_lf():
|
|
44
|
+
out = serialize_rows(["a", "b"], [["x,y", "z"]], "csv")
|
|
45
|
+
assert out == 'a,b\n"x,y",z'
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_serialize_rows_empty_is_just_header():
|
|
49
|
+
assert serialize_rows(["a"], [], "tsv") == "a"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""ClickHouse-specific behavior: run_query builds the historical paginated SQL
|
|
2
|
+
(backtick-quoted, no subquery alias, FORMAT clause). Registry conformance,
|
|
3
|
+
config round-trip, and validation are covered by test_driver_contract."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
|
|
9
|
+
from queryview.drivers.clickhouse import ChConfig, ClickHouseDriver
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_run_query_builds_clickhouse_sql(monkeypatch):
|
|
13
|
+
d = ClickHouseDriver()
|
|
14
|
+
seen = {}
|
|
15
|
+
|
|
16
|
+
async def fake_ch_query(c, query, database=None, fmt=None):
|
|
17
|
+
from queryview.drivers.clickhouse import ChResult
|
|
18
|
+
|
|
19
|
+
seen["query"] = query
|
|
20
|
+
seen["fmt"] = fmt
|
|
21
|
+
seen["database"] = database
|
|
22
|
+
return ChResult(True, "ok")
|
|
23
|
+
|
|
24
|
+
monkeypatch.setattr("queryview.drivers.clickhouse.ch_query", fake_ch_query)
|
|
25
|
+
r = asyncio.run(
|
|
26
|
+
d.run_query(ChConfig("h", 1, "u", ""), "SELECT 1;", "db", 100, 0, [{"name": "a", "dir": "DESC"}], "tsv")
|
|
27
|
+
)
|
|
28
|
+
assert r.ok and r.value == "ok"
|
|
29
|
+
assert seen["query"] == "SELECT * FROM (\nSELECT 1\n) ORDER BY `a` DESC LIMIT 100 OFFSET 0"
|
|
30
|
+
assert seen["fmt"] == "TabSeparatedWithNames"
|
|
31
|
+
assert seen["database"] == "db"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_list_tables_parses_rows_and_bytes_with_nulls(monkeypatch):
|
|
35
|
+
d = ClickHouseDriver()
|
|
36
|
+
seen = {}
|
|
37
|
+
|
|
38
|
+
async def fake_ch_query(c, query, database=None, fmt=None):
|
|
39
|
+
from queryview.drivers.clickhouse import ChResult
|
|
40
|
+
|
|
41
|
+
seen["query"] = query
|
|
42
|
+
seen["database"] = database
|
|
43
|
+
# A MergeTree table with stats and a view (\N for both counters).
|
|
44
|
+
return ChResult(True, "items\t3\t245\nv_items\t\\N\t\\N")
|
|
45
|
+
|
|
46
|
+
monkeypatch.setattr("queryview.drivers.clickhouse.ch_query", fake_ch_query)
|
|
47
|
+
ok, tables = asyncio.run(d.list_tables(ChConfig("h", 1, "u", ""), "db"))
|
|
48
|
+
assert ok and tables == [
|
|
49
|
+
{"name": "items", "rows": 3, "bytes": 245},
|
|
50
|
+
{"name": "v_items", "rows": None, "bytes": None},
|
|
51
|
+
]
|
|
52
|
+
assert "system.tables" in seen["query"]
|
|
53
|
+
assert seen["database"] == "db"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Driver contract, parameterized across every registered driver. Adding a
|
|
2
|
+
driver to the registry (with a SAMPLES entry) automatically extends these
|
|
3
|
+
checks. Driver-specific behavior (dialect SQL, real-DB queries) lives in the
|
|
4
|
+
per-driver test modules."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from queryview.drivers import DRIVERS, Driver
|
|
14
|
+
from queryview.drivers.clickhouse import ChConfig
|
|
15
|
+
from queryview.drivers.duckdb import DuckConfig
|
|
16
|
+
from queryview.drivers.postgres import PgConfig
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class DriverSample:
|
|
21
|
+
config: Any # a representative config object
|
|
22
|
+
valid_body: dict # a request body that parses to `config`
|
|
23
|
+
requires_database: bool
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# One sample per driver `type`. A network driver is identified by a `host` field
|
|
27
|
+
# in its valid body (so host/port validation checks auto-apply to it).
|
|
28
|
+
SAMPLES: dict[str, DriverSample] = {
|
|
29
|
+
"clickhouse": DriverSample(
|
|
30
|
+
ChConfig("h", 8123, "u", "p"),
|
|
31
|
+
{"host": "h", "port": "8123", "username": "u", "password": "p"},
|
|
32
|
+
requires_database=True,
|
|
33
|
+
),
|
|
34
|
+
"postgres": DriverSample(
|
|
35
|
+
PgConfig("h", 5432, "u", "p"),
|
|
36
|
+
{"host": "h", "port": "5432", "username": "u", "password": "p"},
|
|
37
|
+
requires_database=True,
|
|
38
|
+
),
|
|
39
|
+
"duckdb": DriverSample(
|
|
40
|
+
DuckConfig("/tmp/x.duckdb"),
|
|
41
|
+
{"path": "/tmp/x.duckdb"},
|
|
42
|
+
requires_database=False,
|
|
43
|
+
),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
ALL = sorted(DRIVERS)
|
|
47
|
+
NETWORK = sorted(t for t, s in SAMPLES.items() if "host" in s.valid_body)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_samples_cover_every_registered_driver():
|
|
51
|
+
assert set(SAMPLES) == set(DRIVERS), "add a SAMPLES entry for each driver"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.parametrize("type_", ALL)
|
|
55
|
+
def test_protocol_type_and_requires_database(type_):
|
|
56
|
+
d = DRIVERS[type_]
|
|
57
|
+
assert isinstance(d, Driver)
|
|
58
|
+
assert d.type == type_
|
|
59
|
+
assert d.requires_database is SAMPLES[type_].requires_database
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.mark.parametrize("type_", ALL)
|
|
63
|
+
def test_parse_valid_body_and_config_round_trip(type_):
|
|
64
|
+
d = DRIVERS[type_]
|
|
65
|
+
sample = SAMPLES[type_]
|
|
66
|
+
parsed, err = d.parse_config(sample.valid_body)
|
|
67
|
+
assert err is None and parsed == sample.config
|
|
68
|
+
assert d.config_from_dict(d.config_to_dict(sample.config)) == sample.config
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.parametrize("type_", NETWORK)
|
|
72
|
+
def test_network_driver_rejects_missing_host_and_bad_port(type_):
|
|
73
|
+
d = DRIVERS[type_]
|
|
74
|
+
assert d.parse_config({"port": 5432})[0] is None # missing host
|
|
75
|
+
assert d.parse_config({"host": "h", "port": 0})[0] is None # port too low
|
|
76
|
+
assert d.parse_config({"host": "h", "port": 99999})[0] is None # too high
|
|
77
|
+
assert d.parse_config({"host": "h", "port": "x"})[0] is None # non-numeric
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""DuckDB-specific behavior against a real temp-file database: the :memory:
|
|
2
|
+
default, the empty (no-picker) database list, paginated+serialized queries,
|
|
3
|
+
describe, and error reporting. Registry conformance, config round-trip, and
|
|
4
|
+
validation are covered by test_driver_contract."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
|
|
10
|
+
import duckdb
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from queryview.drivers.duckdb import DuckConfig, DuckDBDriver
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _run(coro):
|
|
17
|
+
return asyncio.run(coro)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def duck_path(tmp_path):
|
|
22
|
+
path = tmp_path / "qv.duckdb"
|
|
23
|
+
con = duckdb.connect(str(path))
|
|
24
|
+
con.execute("CREATE TABLE items (id INTEGER, name TEXT)")
|
|
25
|
+
con.execute("INSERT INTO items VALUES (1,'alpha'),(2,'beta'),(3,'gamma')")
|
|
26
|
+
con.close()
|
|
27
|
+
return str(path)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_parse_config_defaults_blank_path_to_memory():
|
|
31
|
+
d = DuckDBDriver()
|
|
32
|
+
assert d.parse_config({"path": ""})[0] == DuckConfig(":memory:")
|
|
33
|
+
assert d.parse_config({"path": "/tmp/x.duckdb"})[0] == DuckConfig("/tmp/x.duckdb")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_list_databases_is_empty(duck_path):
|
|
37
|
+
d = DuckDBDriver()
|
|
38
|
+
assert _run(d.list_databases(DuckConfig(duck_path))) == (True, [])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_list_tables_names_seeded_table_with_estimates(duck_path):
|
|
42
|
+
d = DuckDBDriver()
|
|
43
|
+
# rows is duckdb_tables()'s estimated_size; bytes is storage blocks × block
|
|
44
|
+
# size (a small positive multiple of the 256KiB block for a tiny table).
|
|
45
|
+
ok, tables = _run(d.list_tables(DuckConfig(duck_path), None))
|
|
46
|
+
assert ok and isinstance(tables, list) and len(tables) == 1
|
|
47
|
+
t = tables[0]
|
|
48
|
+
assert t["name"] == "items" and t["rows"] == 3
|
|
49
|
+
assert isinstance(t["bytes"], int) and t["bytes"] > 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_run_query_paginates_and_serializes(duck_path):
|
|
53
|
+
d = DuckDBDriver()
|
|
54
|
+
r = _run(
|
|
55
|
+
d.run_query(
|
|
56
|
+
DuckConfig(duck_path),
|
|
57
|
+
"SELECT id, name FROM items ORDER BY id",
|
|
58
|
+
None,
|
|
59
|
+
2,
|
|
60
|
+
0,
|
|
61
|
+
[{"name": "name", "dir": "ASC"}],
|
|
62
|
+
"tsv",
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
assert r.ok
|
|
66
|
+
assert r.value == "id\tname\n1\talpha\n2\tbeta"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_describe_query_returns_columns(duck_path):
|
|
70
|
+
d = DuckDBDriver()
|
|
71
|
+
ok, fields = _run(d.describe_query(DuckConfig(duck_path), "SELECT id, name FROM items", None))
|
|
72
|
+
assert ok and isinstance(fields, list)
|
|
73
|
+
names = [f["name"] for f in fields]
|
|
74
|
+
assert names == ["id", "name"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_run_query_error_is_reported(duck_path):
|
|
78
|
+
d = DuckDBDriver()
|
|
79
|
+
r = _run(d.run_query(DuckConfig(duck_path), "SELECT * FROM no_such", None, 10, 0, None, "tsv"))
|
|
80
|
+
assert r.ok is False and "no_such" in r.value
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Postgres-specific behavior: run_query builds double-quoted, `_qv`-aliased
|
|
2
|
+
paginated SQL (asyncpg is monkeypatched — no server). Registry conformance,
|
|
3
|
+
config round-trip, and validation are covered by test_driver_contract; live
|
|
4
|
+
connect/query/describe by e2e."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
|
|
10
|
+
from queryview.drivers.postgres import PgConfig, PostgresDriver
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_run_query_builds_aliased_double_quoted_sql(monkeypatch):
|
|
14
|
+
d = PostgresDriver()
|
|
15
|
+
captured = {}
|
|
16
|
+
|
|
17
|
+
class _Stmt:
|
|
18
|
+
def get_attributes(self):
|
|
19
|
+
class _A:
|
|
20
|
+
name = "name"
|
|
21
|
+
|
|
22
|
+
class type: # noqa: N801
|
|
23
|
+
name = "text"
|
|
24
|
+
|
|
25
|
+
return (_A(),)
|
|
26
|
+
|
|
27
|
+
async def fetch(self):
|
|
28
|
+
return [["alpha"]]
|
|
29
|
+
|
|
30
|
+
class _Conn:
|
|
31
|
+
async def prepare(self, sql):
|
|
32
|
+
captured["sql"] = sql
|
|
33
|
+
return _Stmt()
|
|
34
|
+
|
|
35
|
+
async def close(self):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
async def fake_connect(c, database):
|
|
39
|
+
captured["database"] = database
|
|
40
|
+
return _Conn()
|
|
41
|
+
|
|
42
|
+
monkeypatch.setattr("queryview.drivers.postgres._raw_connect", fake_connect)
|
|
43
|
+
r = asyncio.run(
|
|
44
|
+
d.run_query(
|
|
45
|
+
PgConfig("h", 5432, "u", ""), "SELECT name FROM t;", "mydb", 50, 10, [{"name": "name", "dir": "ASC"}], "tsv"
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
assert r.ok and r.value == "name\nalpha"
|
|
49
|
+
assert captured["database"] == "mydb"
|
|
50
|
+
assert captured["sql"] == ('SELECT * FROM (\nSELECT name FROM t\n) AS _qv ORDER BY "name" ASC LIMIT 50 OFFSET 10')
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_list_tables_queries_public_schema_with_estimates(monkeypatch):
|
|
54
|
+
d = PostgresDriver()
|
|
55
|
+
captured = {}
|
|
56
|
+
|
|
57
|
+
class _Conn:
|
|
58
|
+
async def fetch(self, sql):
|
|
59
|
+
captured["sql"] = sql
|
|
60
|
+
return [
|
|
61
|
+
{"name": "items", "rows": 3, "bytes": 16384},
|
|
62
|
+
{"name": "fresh", "rows": None, "bytes": 8192}, # never analyzed
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
async def close(self):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
async def fake_connect(c, database):
|
|
69
|
+
captured["database"] = database
|
|
70
|
+
return _Conn()
|
|
71
|
+
|
|
72
|
+
monkeypatch.setattr("queryview.drivers.postgres._raw_connect", fake_connect)
|
|
73
|
+
ok, tables = asyncio.run(d.list_tables(PgConfig("h", 5432, "u", ""), "mydb"))
|
|
74
|
+
assert ok and tables == [
|
|
75
|
+
{"name": "items", "rows": 3, "bytes": 16384},
|
|
76
|
+
{"name": "fresh", "rows": None, "bytes": 8192},
|
|
77
|
+
]
|
|
78
|
+
assert captured["database"] == "mydb"
|
|
79
|
+
assert "nspname = 'public'" in captured["sql"]
|
|
80
|
+
assert "reltuples" in captured["sql"] and "pg_total_relation_size" in captured["sql"]
|