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,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"]