pgdevkit 0.9.0__tar.gz → 0.10.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.
Files changed (132) hide show
  1. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/PKG-INFO +1 -1
  2. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/cli.py +42 -1
  3. pgdevkit-0.10.0/pgdevkit/stats.py +148 -0
  4. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pyproject.toml +1 -1
  5. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/skills/pgdevkit/SKILL.md +31 -0
  6. pgdevkit-0.10.0/tests/test_stats_cli.py +75 -0
  7. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/uv.lock +1 -1
  8. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/.github/workflows/auto-release.yml +0 -0
  9. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/.github/workflows/python-publish.yml +0 -0
  10. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/.github/workflows/python-test.yml +0 -0
  11. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/.gitignore +0 -0
  12. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/.python-version +0 -0
  13. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/README.md +0 -0
  14. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/docs/database-layout.md +0 -0
  15. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/__init__.py +0 -0
  16. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/areas.py +0 -0
  17. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/backends/__init__.py +0 -0
  18. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/backends/base.py +0 -0
  19. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/backends/mssql.py +0 -0
  20. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/backends/postgres.py +0 -0
  21. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/connection.py +0 -0
  22. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/db/__init__.py +0 -0
  23. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/db/complex_types.py +0 -0
  24. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/db/connection.py +0 -0
  25. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/db/crud.py +0 -0
  26. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/db/loader.py +0 -0
  27. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/db/model.py +0 -0
  28. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/db/mssql_crud.py +0 -0
  29. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/db/mssql_sql.py +0 -0
  30. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/dialect.py +0 -0
  31. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/diff.py +0 -0
  32. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/envtag.py +0 -0
  33. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/fetch_missing.py +0 -0
  34. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/introspect.py +0 -0
  35. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/lakebase.py +0 -0
  36. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/migrate.py +0 -0
  37. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/models.py +0 -0
  38. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/mssql_introspect.py +0 -0
  39. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/parser.py +0 -0
  40. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/schemas.py +0 -0
  41. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/sql_text.py +0 -0
  42. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/testdb/__init__.py +0 -0
  43. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/testdb/_docker.py +0 -0
  44. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/testdb/api.py +0 -0
  45. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/testdb/config.py +0 -0
  46. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/testdb/constants.py +0 -0
  47. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/testdb/container.py +0 -0
  48. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/testdb/mssql/__init__.py +0 -0
  49. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/testdb/mssql/api.py +0 -0
  50. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/testdb/mssql/constants.py +0 -0
  51. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/testdb/mssql/container.py +0 -0
  52. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/testdb/naming.py +0 -0
  53. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/testdb/query.py +0 -0
  54. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/pgdevkit/testdb/schema.py +0 -0
  55. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/skills/pgdevkit/references/dynamic-sql.md +0 -0
  56. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
  57. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/__init__.py +0 -0
  58. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/conftest.py +0 -0
  59. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/db/__init__.py +0 -0
  60. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/db/test_complex_types.py +0 -0
  61. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/db/test_connection.py +0 -0
  62. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/db/test_crud.py +0 -0
  63. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/db/test_loader.py +0 -0
  64. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/db/test_mssql_crud_live.py +0 -0
  65. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/db/test_mssql_crud_sql.py +0 -0
  66. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/fixtures/01_schema.sql +0 -0
  67. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/fixtures/02_types.sql +0 -0
  68. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/fixtures/03_tables.sql +0 -0
  69. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/fixtures/04_views.sql +0 -0
  70. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/fixtures/05_functions.sql +0 -0
  71. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/fixtures/06_indexes.sql +0 -0
  72. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_areas.py +0 -0
  73. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_cli_compare.py +0 -0
  74. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_compare.py +0 -0
  75. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_compare_mssql_live.py +0 -0
  76. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_connection.py +0 -0
  77. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_dialect.py +0 -0
  78. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_diff_mssql.py +0 -0
  79. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_envtag.py +0 -0
  80. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_fetch_missing.py +0 -0
  81. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_fetch_missing_cli.py +0 -0
  82. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_lakebase.py +0 -0
  83. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_migrate.py +0 -0
  84. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_migrate_areas.py +0 -0
  85. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_migrate_env.py +0 -0
  86. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_migrate_grants.py +0 -0
  87. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_migrate_schemas.py +0 -0
  88. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_mssql_introspect.py +0 -0
  89. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_parser_areas.py +0 -0
  90. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_parser_mssql.py +0 -0
  91. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_parser_schemas.py +0 -0
  92. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/test_schemas.py +0 -0
  93. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/__init__.py +0 -0
  94. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/conftest.py +0 -0
  95. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  96. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
  97. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
  98. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  99. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  100. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
  101. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
  102. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  103. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  104. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  105. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  106. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  107. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  108. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  109. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  110. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
  111. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  112. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
  113. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
  114. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
  115. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
  116. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
  117. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/test_api.py +0 -0
  118. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/test_api_mssql.py +0 -0
  119. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/test_api_mssql_live.py +0 -0
  120. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/test_cli.py +0 -0
  121. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/test_config.py +0 -0
  122. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/test_config_mssql.py +0 -0
  123. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/test_constants.py +0 -0
  124. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/test_container.py +0 -0
  125. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/test_container_mssql.py +0 -0
  126. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/test_docker.py +0 -0
  127. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/test_mssql_constants.py +0 -0
  128. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/test_naming.py +0 -0
  129. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/test_query.py +0 -0
  130. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/test_schema.py +0 -0
  131. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/test_schema_filtering.py +0 -0
  132. {pgdevkit-0.9.0 → pgdevkit-0.10.0}/tests/testdb/test_schema_mssql.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pgdevkit
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: A helper for developing with Postgres
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: docker>=7.1.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,46 @@ 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
+ ) -> None:
231
+ """Store table stats in _stats/_tables.json (keyed by schema.name, sorted)
232
+ and column stats in _stats/<schema.name>.json."""
233
+ if not scripts_dir.is_dir():
234
+ err_console.print(f"[red]Error:[/red] {scripts_dir} is not a directory")
235
+ raise typer.Exit(2)
236
+ conninfo = build_conninfo(url, entra_user)
237
+ try:
238
+ with console.status("Collecting stats..."):
239
+ tables, columns = stats.collect_stats(conninfo, set(table) or None, exact=exact, analyze=analyze)
240
+ except KeyError as e:
241
+ err_console.print(f"[red]Error:[/red] unknown table(s): {e.args[0]}")
242
+ raise typer.Exit(2)
243
+ path = stats.write_stats(scripts_dir, tables, columns, prune=not table)
244
+ console.print(f"Updated stats for {len(tables)} table(s) in {path.parent}")
245
+
246
+
247
+ @app.command("get-stats")
248
+ def get_stats(
249
+ scripts_dir: Path = typer.Argument(..., help="The database/ folder containing _stats/"),
250
+ tables: list[str] = typer.Argument(None, help="schema.name of the tables (default: all)"),
251
+ no_columns: bool = typer.Option(False, "--no-columns", help="Omit column stats"),
252
+ ) -> None:
253
+ """Print stored stats (read from the _stats JSON files, no DB access) as JSON."""
254
+ try:
255
+ result = stats.read_stats(scripts_dir, tables or [], columns=not no_columns)
256
+ except (FileNotFoundError, KeyError) as e:
257
+ err_console.print(f"[red]Error:[/red] {e.args[0]}")
258
+ raise typer.Exit(2)
259
+ typer.echo(json.dumps(result, indent=2, sort_keys=True))
260
+
261
+
221
262
  @testdb_app.command("up")
222
263
  def testdb_up(
223
264
  env: str = _TESTDB_ENV_OPTION,
@@ -0,0 +1,148 @@
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
+ STATS_DIRNAME = "_stats"
12
+ TABLES_FILE = "_tables.json"
13
+
14
+ _TABLES_SQL = """
15
+ SELECT n.nspname AS schema, c.relname AS name,
16
+ c.reltuples::bigint AS estimated_rows,
17
+ pg_table_size(c.oid) AS table_bytes,
18
+ pg_indexes_size(c.oid) AS index_bytes,
19
+ pg_total_relation_size(c.oid) AS total_bytes
20
+ FROM pg_class c
21
+ JOIN pg_namespace n ON n.oid = c.relnamespace
22
+ WHERE c.relkind IN ('r', 'p')
23
+ AND n.nspname NOT LIKE 'pg\\_%' AND n.nspname != 'information_schema'
24
+ ORDER BY 1, 2
25
+ """
26
+
27
+ _COLUMNS_SQL = """
28
+ SELECT a.attname AS name,
29
+ format_type(a.atttypid, a.atttypmod) AS data_type,
30
+ s.null_frac, s.n_distinct, s.avg_width
31
+ FROM pg_attribute a
32
+ LEFT JOIN pg_stats s
33
+ ON s.schemaname = %(schema)s AND s.tablename = %(table)s AND s.attname = a.attname
34
+ WHERE a.attrelid = to_regclass(%(qualified)s) AND a.attnum > 0 AND NOT a.attisdropped
35
+ ORDER BY a.attnum, s.inherited NULLS FIRST
36
+ """
37
+
38
+
39
+ def stats_dir(scripts_dir: Path) -> Path:
40
+ return scripts_dir / STATS_DIRNAME
41
+
42
+
43
+ def column_stats_path(scripts_dir: Path, qualified_name: str) -> Path:
44
+ return stats_dir(scripts_dir) / f"{qualified_name}.json"
45
+
46
+
47
+ def _q(conn: Any, sql: Any, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
48
+ with conn.cursor(row_factory=dict_row) as cur:
49
+ cur.execute(sql, params)
50
+ return cur.fetchall()
51
+
52
+
53
+ def _write_json(path: Path, data: Any) -> None:
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+ path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
56
+
57
+
58
+ def collect_stats(
59
+ conninfo: str, only: set[str] | None = None, exact: bool = False, analyze: bool = False
60
+ ) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
61
+ """Returns (table_stats, column_stats), both keyed by "schema.table".
62
+
63
+ Row counts come from pg_class.reltuples (null if the table was never
64
+ analyzed) unless exact=True, which runs count(*). analyze=True runs ANALYZE
65
+ first so column stats (null_frac/n_distinct/avg_width) are populated."""
66
+ tables: dict[str, dict[str, Any]] = {}
67
+ columns: dict[str, dict[str, Any]] = {}
68
+ with psycopg.connect(conninfo, autocommit=True) as conn:
69
+ rows = _q(conn, _TABLES_SQL)
70
+ if only is not None:
71
+ unknown = only - {f"{r['schema']}.{r['name']}" for r in rows}
72
+ if unknown:
73
+ raise KeyError(", ".join(sorted(unknown)))
74
+ for r in rows:
75
+ qn = f"{r['schema']}.{r['name']}"
76
+ if only is not None and qn not in only:
77
+ continue
78
+ ident = psycopg.sql.SQL("{}.{}").format(
79
+ psycopg.sql.Identifier(r["schema"]), psycopg.sql.Identifier(r["name"])
80
+ )
81
+ if analyze:
82
+ conn.execute(psycopg.sql.SQL("ANALYZE {}").format(ident))
83
+ if exact:
84
+ row_count = _q(conn, psycopg.sql.SQL("SELECT count(*) AS n FROM {}").format(ident))[0]["n"]
85
+ else:
86
+ row_count = r["estimated_rows"] if r["estimated_rows"] >= 0 else None
87
+ tables[qn] = {
88
+ "row_count": row_count,
89
+ "row_count_exact": exact,
90
+ "table_bytes": r["table_bytes"],
91
+ "index_bytes": r["index_bytes"],
92
+ "total_bytes": r["total_bytes"],
93
+ }
94
+ cols = _q(
95
+ conn,
96
+ _COLUMNS_SQL,
97
+ {"schema": r["schema"], "table": r["name"], "qualified": ident.as_string(conn)},
98
+ )
99
+ columns[qn] = {
100
+ c["name"]: {
101
+ "data_type": c["data_type"],
102
+ "null_fraction": c["null_frac"],
103
+ "n_distinct": c["n_distinct"],
104
+ "avg_width": c["avg_width"],
105
+ }
106
+ for c in cols
107
+ }
108
+ return tables, columns
109
+
110
+
111
+ def write_stats(
112
+ scripts_dir: Path, tables: dict[str, dict[str, Any]], columns: dict[str, dict[str, Any]], prune: bool = False
113
+ ) -> Path:
114
+ """Merge into _stats/_tables.json (keys sorted; tables not in `tables` are kept,
115
+ unless prune=True, which drops them and their column files — use for a full run)
116
+ and write one _stats/<schema.table>.json per table for column stats."""
117
+ path = stats_dir(scripts_dir) / TABLES_FILE
118
+ existing: dict[str, Any] = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
119
+ if prune:
120
+ for gone in set(existing) - set(tables):
121
+ column_stats_path(scripts_dir, gone).unlink(missing_ok=True)
122
+ existing = {}
123
+ existing.update(tables)
124
+ _write_json(path, existing)
125
+ for qn, cols in columns.items():
126
+ _write_json(column_stats_path(scripts_dir, qn), cols)
127
+ return path
128
+
129
+
130
+ def read_stats(scripts_dir: Path, names: list[str], columns: bool = True) -> dict[str, Any]:
131
+ """Stats for the given "schema.table" names (all tables if empty), read
132
+ from the JSON files. Unknown tables raise KeyError."""
133
+ path = stats_dir(scripts_dir) / TABLES_FILE
134
+ if not path.exists():
135
+ raise FileNotFoundError(f"{path} not found — run `pgdb update-stats` first")
136
+ all_tables: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
137
+ wanted = names or sorted(all_tables)
138
+ missing = [n for n in wanted if n not in all_tables]
139
+ if missing:
140
+ raise KeyError(", ".join(missing))
141
+ result: dict[str, Any] = {}
142
+ for qn in wanted:
143
+ entry = dict(all_tables[qn])
144
+ cpath = column_stats_path(scripts_dir, qn)
145
+ if columns and cpath.exists():
146
+ entry["columns"] = json.loads(cpath.read_text(encoding="utf-8"))
147
+ result[qn] = entry
148
+ return result
@@ -11,7 +11,7 @@ packages = ["pgdevkit"]
11
11
 
12
12
  [project]
13
13
  name = "pgdevkit"
14
- version = "0.9.0"
14
+ version = "0.10.0"
15
15
  description = "A helper for developing with Postgres"
16
16
  readme = "README.md"
17
17
  requires-python = ">=3.14"
@@ -266,6 +266,37 @@ 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
+ To read the stats from code or a script, just `json.load` `database/_stats/_tables.json` (and `database/_stats/<schema.table>.json` for columns).
297
+
298
+ ---
299
+
269
300
  ## Comparing scripts to a live database
270
301
 
271
302
  ```bash
@@ -0,0 +1,75 @@
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}"')
@@ -313,7 +313,7 @@ wheels = [
313
313
 
314
314
  [[package]]
315
315
  name = "pgdevkit"
316
- version = "0.9.0"
316
+ version = "0.10.0"
317
317
  source = { editable = "." }
318
318
  dependencies = [
319
319
  { name = "docker" },
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