pgdevkit 0.9.0__tar.gz → 0.11.0__tar.gz
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.
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/PKG-INFO +1 -1
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/cli.py +48 -1
- pgdevkit-0.11.0/pgdevkit/stats.py +268 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pyproject.toml +1 -1
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/skills/pgdevkit/SKILL.md +33 -0
- pgdevkit-0.11.0/tests/test_stats_cli.py +76 -0
- pgdevkit-0.11.0/tests/test_stats_mssql_live.py +59 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/uv.lock +1 -1
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/.github/workflows/auto-release.yml +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/.github/workflows/python-test.yml +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/.gitignore +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/.python-version +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/README.md +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/docs/database-layout.md +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/areas.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/backends/__init__.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/backends/base.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/backends/mssql.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/backends/postgres.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/connection.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/db/__init__.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/db/complex_types.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/db/connection.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/db/crud.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/db/mssql_crud.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/db/mssql_sql.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/dialect.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/envtag.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/fetch_missing.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/migrate.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/models.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/mssql_introspect.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/parser.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/schemas.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/sql_text.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/testdb/__init__.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/testdb/_docker.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/testdb/api.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/testdb/config.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/testdb/constants.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/testdb/container.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/testdb/mssql/__init__.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/testdb/mssql/api.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/testdb/mssql/constants.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/testdb/mssql/container.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/testdb/naming.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/pgdevkit/testdb/schema.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/skills/pgdevkit/references/dynamic-sql.md +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/__init__.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/conftest.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/db/__init__.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/db/test_complex_types.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/db/test_connection.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/db/test_crud.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/db/test_mssql_crud_live.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/db/test_mssql_crud_sql.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_areas.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_compare.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_compare_mssql_live.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_connection.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_dialect.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_diff_mssql.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_envtag.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_fetch_missing.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_fetch_missing_cli.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_migrate.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_migrate_areas.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_migrate_env.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_migrate_grants.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_migrate_schemas.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_mssql_introspect.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_parser_areas.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_parser_mssql.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_parser_schemas.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/test_schemas.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/conftest.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/test_api.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/test_api_mssql.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/test_api_mssql_live.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/test_cli.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/test_config.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/test_config_mssql.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/test_container.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/test_container_mssql.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/test_docker.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/test_mssql_constants.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/test_naming.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/test_query.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/test_schema.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/test_schema_filtering.py +0 -0
- {pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/test_schema_mssql.py +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import os
|
|
4
5
|
import queue
|
|
5
6
|
import threading
|
|
@@ -12,7 +13,7 @@ from rich.table import Table
|
|
|
12
13
|
from rich import box
|
|
13
14
|
from tqdm import tqdm
|
|
14
15
|
|
|
15
|
-
from . import migrate, testdb
|
|
16
|
+
from . import migrate, stats, testdb
|
|
16
17
|
from .backends import get_backend
|
|
17
18
|
from .connection import build_conninfo
|
|
18
19
|
from .diff import DiffKind, compute_diff
|
|
@@ -218,6 +219,52 @@ def fetch_missing(
|
|
|
218
219
|
console.print(f"\nWrote {written} file(s).")
|
|
219
220
|
|
|
220
221
|
|
|
222
|
+
@app.command("update-stats")
|
|
223
|
+
def update_stats(
|
|
224
|
+
scripts_dir: Path = typer.Argument(..., help="The database/ folder; stats are written to <scripts_dir>/_stats/"),
|
|
225
|
+
url: str = typer.Option(..., "--url", help="PostgreSQL DSN (postgresql://user:pass@host:port/db)"),
|
|
226
|
+
entra_user: str | None = typer.Option(None, "--entra-user", help="Azure Entra user (triggers token auth)"),
|
|
227
|
+
table: list[str] = typer.Option([], "--table", help="Only update schema.name (repeatable); default is every table"),
|
|
228
|
+
exact: bool = typer.Option(False, "--exact", help="Use count(*) for row counts instead of the planner estimate"),
|
|
229
|
+
analyze: bool = typer.Option(False, "--analyze", help="Run ANALYZE first so column stats are fresh"),
|
|
230
|
+
dialect: str = typer.Option("postgres", "--dialect", help="postgres (default) or mssql"),
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Store table stats in _stats/_tables.json (keyed by schema.name, sorted)
|
|
233
|
+
and column stats in _stats/<schema.name>.json."""
|
|
234
|
+
if not scripts_dir.is_dir():
|
|
235
|
+
err_console.print(f"[red]Error:[/red] {scripts_dir} is not a directory")
|
|
236
|
+
raise typer.Exit(2)
|
|
237
|
+
try:
|
|
238
|
+
dialect = get_backend(dialect).dialect.name
|
|
239
|
+
except ValueError as e:
|
|
240
|
+
err_console.print(f"[red]Error:[/red] {e}")
|
|
241
|
+
raise typer.Exit(2)
|
|
242
|
+
conninfo = build_conninfo(url, entra_user)
|
|
243
|
+
try:
|
|
244
|
+
with console.status("Collecting stats..."):
|
|
245
|
+
tables, columns = stats.collect_stats(conninfo, set(table) or None, exact=exact, analyze=analyze, dialect=dialect)
|
|
246
|
+
except KeyError as e:
|
|
247
|
+
err_console.print(f"[red]Error:[/red] unknown table(s): {e.args[0]}")
|
|
248
|
+
raise typer.Exit(2)
|
|
249
|
+
path = stats.write_stats(scripts_dir, tables, columns, prune=not table)
|
|
250
|
+
console.print(f"Updated stats for {len(tables)} table(s) in {path.parent}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@app.command("get-stats")
|
|
254
|
+
def get_stats(
|
|
255
|
+
scripts_dir: Path = typer.Argument(..., help="The database/ folder containing _stats/"),
|
|
256
|
+
tables: list[str] = typer.Argument(None, help="schema.name of the tables (default: all)"),
|
|
257
|
+
no_columns: bool = typer.Option(False, "--no-columns", help="Omit column stats"),
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Print stored stats (read from the _stats JSON files, no DB access) as JSON."""
|
|
260
|
+
try:
|
|
261
|
+
result = stats.read_stats(scripts_dir, tables or [], columns=not no_columns)
|
|
262
|
+
except (FileNotFoundError, KeyError) as e:
|
|
263
|
+
err_console.print(f"[red]Error:[/red] {e.args[0]}")
|
|
264
|
+
raise typer.Exit(2)
|
|
265
|
+
typer.echo(json.dumps(result, indent=2, sort_keys=True))
|
|
266
|
+
|
|
267
|
+
|
|
221
268
|
@testdb_app.command("up")
|
|
222
269
|
def testdb_up(
|
|
223
270
|
env: str = _TESTDB_ENV_OPTION,
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import psycopg
|
|
8
|
+
import psycopg.sql
|
|
9
|
+
from psycopg.rows import dict_row
|
|
10
|
+
|
|
11
|
+
from .dialect import resolve_dialect
|
|
12
|
+
|
|
13
|
+
STATS_DIRNAME = "_stats"
|
|
14
|
+
TABLES_FILE = "_tables.json"
|
|
15
|
+
|
|
16
|
+
_TABLES_SQL = """
|
|
17
|
+
SELECT n.nspname AS schema, c.relname AS name,
|
|
18
|
+
c.reltuples::bigint AS estimated_rows,
|
|
19
|
+
pg_table_size(c.oid) AS table_bytes,
|
|
20
|
+
pg_indexes_size(c.oid) AS index_bytes,
|
|
21
|
+
pg_total_relation_size(c.oid) AS total_bytes
|
|
22
|
+
FROM pg_class c
|
|
23
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
24
|
+
WHERE c.relkind IN ('r', 'p')
|
|
25
|
+
AND n.nspname NOT LIKE 'pg\\_%' AND n.nspname != 'information_schema'
|
|
26
|
+
ORDER BY 1, 2
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
_COLUMNS_SQL = """
|
|
30
|
+
SELECT a.attname AS name,
|
|
31
|
+
format_type(a.atttypid, a.atttypmod) AS data_type,
|
|
32
|
+
s.null_frac, s.n_distinct, s.avg_width
|
|
33
|
+
FROM pg_attribute a
|
|
34
|
+
LEFT JOIN pg_stats s
|
|
35
|
+
ON s.schemaname = %(schema)s AND s.tablename = %(table)s AND s.attname = a.attname
|
|
36
|
+
WHERE a.attrelid = to_regclass(%(qualified)s) AND a.attnum > 0 AND NOT a.attisdropped
|
|
37
|
+
ORDER BY a.attnum, s.inherited NULLS FIRST
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def stats_dir(scripts_dir: Path) -> Path:
|
|
42
|
+
return scripts_dir / STATS_DIRNAME
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def column_stats_path(scripts_dir: Path, qualified_name: str) -> Path:
|
|
46
|
+
return stats_dir(scripts_dir) / f"{qualified_name}.json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _q(conn: Any, sql: Any, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
50
|
+
with conn.cursor(row_factory=dict_row) as cur:
|
|
51
|
+
cur.execute(sql, params)
|
|
52
|
+
return cur.fetchall()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _write_json(path: Path, data: Any) -> None:
|
|
56
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_MSSQL_TABLES_SQL = """
|
|
61
|
+
SELECT s.name AS [schema], t.name AS name,
|
|
62
|
+
SUM(CASE WHEN ps.index_id IN (0, 1) THEN ps.row_count ELSE 0 END) AS estimated_rows,
|
|
63
|
+
SUM(CASE WHEN ps.index_id IN (0, 1) THEN ps.used_page_count ELSE 0 END) * 8192 AS table_bytes,
|
|
64
|
+
SUM(CASE WHEN ps.index_id > 1 THEN ps.used_page_count ELSE 0 END) * 8192 AS index_bytes,
|
|
65
|
+
SUM(ps.used_page_count) * 8192 AS total_bytes
|
|
66
|
+
FROM sys.tables t
|
|
67
|
+
JOIN sys.schemas s ON s.schema_id = t.schema_id
|
|
68
|
+
LEFT JOIN sys.dm_db_partition_stats ps ON ps.object_id = t.object_id
|
|
69
|
+
GROUP BY s.name, t.name
|
|
70
|
+
ORDER BY 1, 2
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
_MSSQL_COLUMNS_SQL = """
|
|
74
|
+
SELECT c.name AS name, ty.name AS base_type, c.max_length AS max_length,
|
|
75
|
+
c.precision AS precision, c.scale AS scale
|
|
76
|
+
FROM sys.columns c
|
|
77
|
+
JOIN sys.types ty ON ty.user_type_id = c.user_type_id
|
|
78
|
+
WHERE c.object_id = OBJECT_ID(?)
|
|
79
|
+
ORDER BY c.column_id
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
# Types that can't be COUNT(DISTINCT)ed / measured with DATALENGTH.
|
|
83
|
+
_MSSQL_UNMEASURABLE = {"text", "ntext", "image", "xml", "geography", "geometry", "hierarchyid", "sql_variant", "json", "vector"}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _mssql_q(conn: Any, sql: str, params: tuple = ()) -> list[dict[str, Any]]:
|
|
87
|
+
cur = conn.cursor()
|
|
88
|
+
try:
|
|
89
|
+
cur.execute(sql, params)
|
|
90
|
+
names = [c[0] for c in cur.description]
|
|
91
|
+
return [dict(zip(names, row)) for row in cur.fetchall()]
|
|
92
|
+
finally:
|
|
93
|
+
cur.close()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _mssql_ident(name: str) -> str:
|
|
97
|
+
return "[" + name.replace("]", "]]") + "]"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _collect_mssql_stats(
|
|
101
|
+
conninfo: str, only: set[str] | None, exact: bool, analyze: bool
|
|
102
|
+
) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
|
|
103
|
+
"""MSSQL flavour of collect_stats. Row counts/sizes come from
|
|
104
|
+
sys.dm_db_partition_stats. SQL Server has no pg_stats equivalent, so column
|
|
105
|
+
null_fraction/n_distinct/avg_width are only filled in with exact=True
|
|
106
|
+
(computed by scanning each table); otherwise they are null.
|
|
107
|
+
analyze=True runs UPDATE STATISTICS."""
|
|
108
|
+
# Lazy: the mssql extra isn't required for postgres-only use.
|
|
109
|
+
import mssql_python
|
|
110
|
+
|
|
111
|
+
from .mssql_introspect import _format_type, _is_system_schema
|
|
112
|
+
|
|
113
|
+
tables: dict[str, dict[str, Any]] = {}
|
|
114
|
+
columns: dict[str, dict[str, Any]] = {}
|
|
115
|
+
conn = mssql_python.connect(conninfo, autocommit=True)
|
|
116
|
+
try:
|
|
117
|
+
rows = [r for r in _mssql_q(conn, _MSSQL_TABLES_SQL) if not _is_system_schema(r["schema"])]
|
|
118
|
+
if only is not None:
|
|
119
|
+
unknown = only - {f"{r['schema']}.{r['name']}" for r in rows}
|
|
120
|
+
if unknown:
|
|
121
|
+
raise KeyError(", ".join(sorted(unknown)))
|
|
122
|
+
for r in rows:
|
|
123
|
+
qn = f"{r['schema']}.{r['name']}"
|
|
124
|
+
if only is not None and qn not in only:
|
|
125
|
+
continue
|
|
126
|
+
ident = f"{_mssql_ident(r['schema'])}.{_mssql_ident(r['name'])}"
|
|
127
|
+
if analyze:
|
|
128
|
+
conn.execute(f"UPDATE STATISTICS {ident}")
|
|
129
|
+
col_defs = _mssql_q(conn, _MSSQL_COLUMNS_SQL, (ident,))
|
|
130
|
+
measured: dict[str, Any] = {}
|
|
131
|
+
if exact:
|
|
132
|
+
# One scan per table, so row and column counts share a snapshot.
|
|
133
|
+
parts = ["COUNT_BIG(*) AS n"]
|
|
134
|
+
for i, c in enumerate(col_defs):
|
|
135
|
+
if c["base_type"].lower() not in _MSSQL_UNMEASURABLE:
|
|
136
|
+
col = _mssql_ident(c["name"])
|
|
137
|
+
parts.append(
|
|
138
|
+
f"COUNT_BIG({col}) AS nn{i}, COUNT_BIG(DISTINCT {col}) AS nd{i}, "
|
|
139
|
+
f"AVG(CAST(DATALENGTH({col}) AS float)) AS w{i}"
|
|
140
|
+
)
|
|
141
|
+
measured = _mssql_q(conn, f"SELECT {', '.join(parts)} FROM {ident}")[0]
|
|
142
|
+
row_count = measured["n"]
|
|
143
|
+
else:
|
|
144
|
+
row_count = r["estimated_rows"]
|
|
145
|
+
tables[qn] = {
|
|
146
|
+
"row_count": row_count,
|
|
147
|
+
"row_count_exact": exact,
|
|
148
|
+
"table_bytes": r["table_bytes"],
|
|
149
|
+
"index_bytes": r["index_bytes"],
|
|
150
|
+
"total_bytes": r["total_bytes"],
|
|
151
|
+
}
|
|
152
|
+
col_stats: dict[str, Any] = {}
|
|
153
|
+
for i, c in enumerate(col_defs):
|
|
154
|
+
entry: dict[str, Any] = {
|
|
155
|
+
"data_type": _format_type(c["base_type"], c["max_length"], c["precision"], c["scale"]),
|
|
156
|
+
"null_fraction": None,
|
|
157
|
+
"n_distinct": None,
|
|
158
|
+
"avg_width": None,
|
|
159
|
+
}
|
|
160
|
+
if row_count and f"nn{i}" in measured:
|
|
161
|
+
entry["null_fraction"] = 1 - measured[f"nn{i}"] / row_count
|
|
162
|
+
entry["n_distinct"] = measured[f"nd{i}"]
|
|
163
|
+
w = measured[f"w{i}"]
|
|
164
|
+
entry["avg_width"] = None if w is None else round(w)
|
|
165
|
+
col_stats[c["name"]] = entry
|
|
166
|
+
columns[qn] = col_stats
|
|
167
|
+
finally:
|
|
168
|
+
conn.close()
|
|
169
|
+
return tables, columns
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def collect_stats(
|
|
173
|
+
conninfo: str,
|
|
174
|
+
only: set[str] | None = None,
|
|
175
|
+
exact: bool = False,
|
|
176
|
+
analyze: bool = False,
|
|
177
|
+
dialect: str = "postgres",
|
|
178
|
+
) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
|
|
179
|
+
"""Returns (table_stats, column_stats), both keyed by "schema.table".
|
|
180
|
+
|
|
181
|
+
Row counts come from pg_class.reltuples (null if the table was never
|
|
182
|
+
analyzed) unless exact=True, which runs count(*). analyze=True runs ANALYZE
|
|
183
|
+
first so column stats (null_frac/n_distinct/avg_width) are populated."""
|
|
184
|
+
if resolve_dialect(dialect).name == "mssql":
|
|
185
|
+
return _collect_mssql_stats(conninfo, only, exact, analyze)
|
|
186
|
+
tables: dict[str, dict[str, Any]] = {}
|
|
187
|
+
columns: dict[str, dict[str, Any]] = {}
|
|
188
|
+
with psycopg.connect(conninfo, autocommit=True) as conn:
|
|
189
|
+
rows = _q(conn, _TABLES_SQL)
|
|
190
|
+
if only is not None:
|
|
191
|
+
unknown = only - {f"{r['schema']}.{r['name']}" for r in rows}
|
|
192
|
+
if unknown:
|
|
193
|
+
raise KeyError(", ".join(sorted(unknown)))
|
|
194
|
+
for r in rows:
|
|
195
|
+
qn = f"{r['schema']}.{r['name']}"
|
|
196
|
+
if only is not None and qn not in only:
|
|
197
|
+
continue
|
|
198
|
+
ident = psycopg.sql.SQL("{}.{}").format(
|
|
199
|
+
psycopg.sql.Identifier(r["schema"]), psycopg.sql.Identifier(r["name"])
|
|
200
|
+
)
|
|
201
|
+
if analyze:
|
|
202
|
+
conn.execute(psycopg.sql.SQL("ANALYZE {}").format(ident))
|
|
203
|
+
if exact:
|
|
204
|
+
row_count = _q(conn, psycopg.sql.SQL("SELECT count(*) AS n FROM {}").format(ident))[0]["n"]
|
|
205
|
+
else:
|
|
206
|
+
row_count = r["estimated_rows"] if r["estimated_rows"] >= 0 else None
|
|
207
|
+
tables[qn] = {
|
|
208
|
+
"row_count": row_count,
|
|
209
|
+
"row_count_exact": exact,
|
|
210
|
+
"table_bytes": r["table_bytes"],
|
|
211
|
+
"index_bytes": r["index_bytes"],
|
|
212
|
+
"total_bytes": r["total_bytes"],
|
|
213
|
+
}
|
|
214
|
+
cols = _q(
|
|
215
|
+
conn,
|
|
216
|
+
_COLUMNS_SQL,
|
|
217
|
+
{"schema": r["schema"], "table": r["name"], "qualified": ident.as_string(conn)},
|
|
218
|
+
)
|
|
219
|
+
columns[qn] = {
|
|
220
|
+
c["name"]: {
|
|
221
|
+
"data_type": c["data_type"],
|
|
222
|
+
"null_fraction": c["null_frac"],
|
|
223
|
+
"n_distinct": c["n_distinct"],
|
|
224
|
+
"avg_width": c["avg_width"],
|
|
225
|
+
}
|
|
226
|
+
for c in cols
|
|
227
|
+
}
|
|
228
|
+
return tables, columns
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def write_stats(
|
|
232
|
+
scripts_dir: Path, tables: dict[str, dict[str, Any]], columns: dict[str, dict[str, Any]], prune: bool = False
|
|
233
|
+
) -> Path:
|
|
234
|
+
"""Merge into _stats/_tables.json (keys sorted; tables not in `tables` are kept,
|
|
235
|
+
unless prune=True, which drops them and their column files — use for a full run)
|
|
236
|
+
and write one _stats/<schema.table>.json per table for column stats."""
|
|
237
|
+
path = stats_dir(scripts_dir) / TABLES_FILE
|
|
238
|
+
existing: dict[str, Any] = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
|
239
|
+
if prune:
|
|
240
|
+
for gone in set(existing) - set(tables):
|
|
241
|
+
column_stats_path(scripts_dir, gone).unlink(missing_ok=True)
|
|
242
|
+
existing = {}
|
|
243
|
+
existing.update(tables)
|
|
244
|
+
_write_json(path, existing)
|
|
245
|
+
for qn, cols in columns.items():
|
|
246
|
+
_write_json(column_stats_path(scripts_dir, qn), cols)
|
|
247
|
+
return path
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def read_stats(scripts_dir: Path, names: list[str], columns: bool = True) -> dict[str, Any]:
|
|
251
|
+
"""Stats for the given "schema.table" names (all tables if empty), read
|
|
252
|
+
from the JSON files. Unknown tables raise KeyError."""
|
|
253
|
+
path = stats_dir(scripts_dir) / TABLES_FILE
|
|
254
|
+
if not path.exists():
|
|
255
|
+
raise FileNotFoundError(f"{path} not found — run `pgdb update-stats` first")
|
|
256
|
+
all_tables: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
|
257
|
+
wanted = names or sorted(all_tables)
|
|
258
|
+
missing = [n for n in wanted if n not in all_tables]
|
|
259
|
+
if missing:
|
|
260
|
+
raise KeyError(", ".join(missing))
|
|
261
|
+
result: dict[str, Any] = {}
|
|
262
|
+
for qn in wanted:
|
|
263
|
+
entry = dict(all_tables[qn])
|
|
264
|
+
cpath = column_stats_path(scripts_dir, qn)
|
|
265
|
+
if columns and cpath.exists():
|
|
266
|
+
entry["columns"] = json.loads(cpath.read_text(encoding="utf-8"))
|
|
267
|
+
result[qn] = entry
|
|
268
|
+
return result
|
|
@@ -266,6 +266,39 @@ It diffs the live schema against `database/`, reverse-engineers DDL for anything
|
|
|
266
266
|
|
|
267
267
|
---
|
|
268
268
|
|
|
269
|
+
## Table & column stats — `pgdb update-stats` / `pgdb get-stats`
|
|
270
|
+
|
|
271
|
+
Stats live next to the schema files, in `database/_stats/`, so they can be committed and read without a DB connection:
|
|
272
|
+
|
|
273
|
+
```
|
|
274
|
+
database/_stats/
|
|
275
|
+
├── _tables.json # one entry per table, keyed by "schema.table", keys sorted
|
|
276
|
+
└── public.users.json # column stats for one table, keyed by column name
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
```json
|
|
280
|
+
// _tables.json
|
|
281
|
+
{ "public.users": { "row_count": 1200, "row_count_exact": false,
|
|
282
|
+
"table_bytes": 98304, "index_bytes": 32768, "total_bytes": 131072 } }
|
|
283
|
+
// public.users.json
|
|
284
|
+
{ "email": { "data_type": "text", "null_fraction": 0.0, "n_distinct": -1, "avg_width": 24 } }
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
`row_count` is the planner estimate (`null` if the table was never analyzed) unless `--exact` was used. Column stats come from `pg_stats` (`n_distinct` < 0 means a fraction of the row count, as in Postgres) and are `null` until the table is analyzed — pass `--analyze`.
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
pgdb update-stats database/ --url postgresql://... --analyze # all tables
|
|
291
|
+
pgdb update-stats database/ --url postgresql://... --table public.users --exact # partial update, keeps other entries
|
|
292
|
+
pgdb get-stats database/ public.users public.orders # JSON to stdout, no DB needed
|
|
293
|
+
pgdb get-stats database/ --no-columns # all tables, table-level stats only
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
MSSQL: add `--dialect mssql` (needs the `mssql` extra). Row counts and sizes come from `sys.dm_db_partition_stats`; SQL Server has no `pg_stats`, so column `null_fraction`/`n_distinct`/`avg_width` are `null` unless `--exact` is given, which scans each table to compute them. `--analyze` runs `UPDATE STATISTICS`.
|
|
297
|
+
|
|
298
|
+
To read the stats from code or a script, just `json.load` `database/_stats/_tables.json` (and `database/_stats/<schema.table>.json` for columns).
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
269
302
|
## Comparing scripts to a live database
|
|
270
303
|
|
|
271
304
|
```bash
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import psycopg
|
|
7
|
+
from typer.testing import CliRunner
|
|
8
|
+
|
|
9
|
+
from pgdevkit.cli import app
|
|
10
|
+
from pgdevkit.testdb import constants
|
|
11
|
+
from pgdevkit.testdb.container import ensure_container
|
|
12
|
+
from tests.testdb.conftest import RUN_SUFFIX, requires_podman
|
|
13
|
+
|
|
14
|
+
runner = CliRunner()
|
|
15
|
+
|
|
16
|
+
TEST_DB = f"pgdevkit_stats_cli_selftest_{RUN_SUFFIX}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_get_stats_reads_json_without_db(tmp_path: Path):
|
|
20
|
+
stats = tmp_path / "_stats"
|
|
21
|
+
stats.mkdir()
|
|
22
|
+
(stats / "_tables.json").write_text(json.dumps({"public.a": {"row_count": 3}, "public.b": {"row_count": 1}}))
|
|
23
|
+
(stats / "public.a.json").write_text(json.dumps({"id": {"data_type": "integer"}}))
|
|
24
|
+
|
|
25
|
+
r = runner.invoke(app, ["get-stats", str(tmp_path), "public.a"])
|
|
26
|
+
assert r.exit_code == 0, r.output
|
|
27
|
+
assert json.loads(r.stdout) == {"public.a": {"row_count": 3, "columns": {"id": {"data_type": "integer"}}}}
|
|
28
|
+
|
|
29
|
+
r = runner.invoke(app, ["get-stats", str(tmp_path), "--no-columns"])
|
|
30
|
+
assert list(json.loads(r.stdout)) == ["public.a", "public.b"]
|
|
31
|
+
|
|
32
|
+
assert runner.invoke(app, ["get-stats", str(tmp_path), "public.nope"]).exit_code == 2
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@requires_podman
|
|
36
|
+
def test_update_stats_then_get_stats(tmp_path: Path):
|
|
37
|
+
ensure_container()
|
|
38
|
+
admin = constants.conninfo("postgres")
|
|
39
|
+
dsn = constants.conninfo(TEST_DB)
|
|
40
|
+
with psycopg.connect(admin, autocommit=True) as con:
|
|
41
|
+
con.execute(f'DROP DATABASE IF EXISTS "{TEST_DB}"')
|
|
42
|
+
con.execute(f'CREATE DATABASE "{TEST_DB}"')
|
|
43
|
+
try:
|
|
44
|
+
with psycopg.connect(dsn, autocommit=True) as con:
|
|
45
|
+
con.execute("CREATE TABLE public.zeta (id int PRIMARY KEY, note text)")
|
|
46
|
+
con.execute("CREATE TABLE public.alpha (id int PRIMARY KEY)")
|
|
47
|
+
con.execute("INSERT INTO public.zeta SELECT g, NULL FROM generate_series(1, 10) g")
|
|
48
|
+
|
|
49
|
+
r = runner.invoke(app, ["update-stats", str(tmp_path), "--url", dsn, "--exact", "--analyze"])
|
|
50
|
+
assert r.exit_code == 0, r.output
|
|
51
|
+
tables = json.loads((tmp_path / "_stats" / "_tables.json").read_text())
|
|
52
|
+
assert list(tables) == ["public.alpha", "public.zeta"]
|
|
53
|
+
assert tables["public.zeta"]["row_count"] == 10
|
|
54
|
+
cols = json.loads((tmp_path / "_stats" / "public.zeta.json").read_text())
|
|
55
|
+
assert cols["note"]["null_fraction"] == 1
|
|
56
|
+
|
|
57
|
+
# A partial update keeps other tables' entries.
|
|
58
|
+
r = runner.invoke(app, ["update-stats", str(tmp_path), "--url", dsn, "--table", "public.alpha"])
|
|
59
|
+
assert r.exit_code == 0, r.output
|
|
60
|
+
assert "public.zeta" in json.loads((tmp_path / "_stats" / "_tables.json").read_text())
|
|
61
|
+
|
|
62
|
+
assert runner.invoke(app, ["update-stats", str(tmp_path), "--url", dsn, "--table", "public.nope"]).exit_code == 2
|
|
63
|
+
|
|
64
|
+
# A full run prunes dropped tables.
|
|
65
|
+
with psycopg.connect(dsn, autocommit=True) as con:
|
|
66
|
+
con.execute("DROP TABLE public.alpha")
|
|
67
|
+
assert runner.invoke(app, ["update-stats", str(tmp_path), "--url", dsn]).exit_code == 0
|
|
68
|
+
assert list(json.loads((tmp_path / "_stats" / "_tables.json").read_text())) == ["public.zeta"]
|
|
69
|
+
assert not (tmp_path / "_stats" / "public.alpha.json").exists()
|
|
70
|
+
|
|
71
|
+
got = json.loads(runner.invoke(app, ["get-stats", str(tmp_path), "public.zeta"]).stdout)
|
|
72
|
+
assert got["public.zeta"]["columns"]["id"]["data_type"] == "integer"
|
|
73
|
+
finally:
|
|
74
|
+
with psycopg.connect(admin, autocommit=True) as con:
|
|
75
|
+
con.execute(f'DROP DATABASE IF EXISTS "{TEST_DB}"')
|
|
76
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import mssql_python
|
|
7
|
+
import pytest
|
|
8
|
+
from typer.testing import CliRunner
|
|
9
|
+
|
|
10
|
+
from pgdevkit.cli import app
|
|
11
|
+
from pgdevkit.testdb.api import clean_testdb, ensure_testdb, status
|
|
12
|
+
from tests.testdb.conftest import _make_project, requires_mssql
|
|
13
|
+
|
|
14
|
+
# Selected only by the dedicated `mssql-test` CI job; see test_compare_mssql_live.py.
|
|
15
|
+
pytestmark = pytest.mark.mssql
|
|
16
|
+
|
|
17
|
+
runner = CliRunner()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _exec(dsn: str, sql: str) -> None:
|
|
21
|
+
conn = mssql_python.connect(dsn, autocommit=True)
|
|
22
|
+
try:
|
|
23
|
+
conn.cursor().execute(sql)
|
|
24
|
+
finally:
|
|
25
|
+
conn.close()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@requires_mssql
|
|
29
|
+
def test_update_stats_mssql_live(tmp_path: Path):
|
|
30
|
+
project = _make_project(tmp_path, "mssqlstats", "main", engine="mssql")
|
|
31
|
+
out = tmp_path / "out"
|
|
32
|
+
out.mkdir()
|
|
33
|
+
try:
|
|
34
|
+
ensure_testdb(project)
|
|
35
|
+
dsn = status(project)["dsn"]
|
|
36
|
+
_exec(dsn, "INSERT INTO app.widget (id, name, tags) VALUES (2, N'gear', NULL), (3, N'cog', NULL)")
|
|
37
|
+
|
|
38
|
+
r = runner.invoke(app, ["update-stats", str(out), "--url", dsn, "--dialect", "mssql", "--exact", "--analyze"])
|
|
39
|
+
assert r.exit_code == 0, r.output
|
|
40
|
+
tables = json.loads((out / "_stats" / "_tables.json").read_text())
|
|
41
|
+
assert "app.widget" in tables and not any(k.startswith("sys.") for k in tables)
|
|
42
|
+
assert tables["app.widget"]["row_count"] == 3
|
|
43
|
+
assert tables["app.widget"]["row_count_exact"] is True
|
|
44
|
+
assert tables["app.widget"]["total_bytes"] > 0
|
|
45
|
+
cols = json.loads((out / "_stats" / "app.widget.json").read_text())
|
|
46
|
+
assert cols["name"]["data_type"] == "nvarchar(100)"
|
|
47
|
+
assert cols["id"]["n_distinct"] == 3 and cols["id"]["null_fraction"] == 0
|
|
48
|
+
assert cols["tags"]["null_fraction"] == 1
|
|
49
|
+
|
|
50
|
+
# Without --exact: engine-maintained row count, no column measurements.
|
|
51
|
+
r = runner.invoke(app, ["update-stats", str(out), "--url", dsn, "--dialect", "mssql", "--table", "app.widget"])
|
|
52
|
+
assert r.exit_code == 0, r.output
|
|
53
|
+
assert json.loads((out / "_stats" / "_tables.json").read_text())["app.widget"]["row_count"] == 3
|
|
54
|
+
assert json.loads((out / "_stats" / "app.widget.json").read_text())["id"]["n_distinct"] is None
|
|
55
|
+
|
|
56
|
+
bad = ["update-stats", str(out), "--url", dsn, "--dialect", "mssql", "--table", "app.nope"]
|
|
57
|
+
assert runner.invoke(app, bad).exit_code == 2
|
|
58
|
+
finally:
|
|
59
|
+
clean_testdb(project)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json
RENAMED
|
File without changes
|
{pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql
RENAMED
|
File without changes
|
{pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/widget.init.sql
RENAMED
|
File without changes
|
|
File without changes
|
{pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json
RENAMED
|
File without changes
|
{pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql
RENAMED
|
File without changes
|
{pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql
RENAMED
|
File without changes
|
|
File without changes
|
{pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql
RENAMED
|
File without changes
|
{pgdevkit-0.9.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|