pgdevkit 0.10.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.
Files changed (133) hide show
  1. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/PKG-INFO +1 -1
  2. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/cli.py +7 -1
  3. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/stats.py +121 -1
  4. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pyproject.toml +1 -1
  5. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/skills/pgdevkit/SKILL.md +2 -0
  6. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_stats_cli.py +1 -0
  7. pgdevkit-0.11.0/tests/test_stats_mssql_live.py +59 -0
  8. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/uv.lock +1 -1
  9. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/.github/workflows/auto-release.yml +0 -0
  10. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/.github/workflows/python-publish.yml +0 -0
  11. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/.github/workflows/python-test.yml +0 -0
  12. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/.gitignore +0 -0
  13. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/.python-version +0 -0
  14. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/README.md +0 -0
  15. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/docs/database-layout.md +0 -0
  16. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/__init__.py +0 -0
  17. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/areas.py +0 -0
  18. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/backends/__init__.py +0 -0
  19. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/backends/base.py +0 -0
  20. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/backends/mssql.py +0 -0
  21. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/backends/postgres.py +0 -0
  22. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/connection.py +0 -0
  23. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/db/__init__.py +0 -0
  24. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/db/complex_types.py +0 -0
  25. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/db/connection.py +0 -0
  26. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/db/crud.py +0 -0
  27. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/db/loader.py +0 -0
  28. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/db/model.py +0 -0
  29. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/db/mssql_crud.py +0 -0
  30. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/db/mssql_sql.py +0 -0
  31. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/dialect.py +0 -0
  32. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/diff.py +0 -0
  33. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/envtag.py +0 -0
  34. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/fetch_missing.py +0 -0
  35. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/introspect.py +0 -0
  36. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/lakebase.py +0 -0
  37. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/migrate.py +0 -0
  38. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/models.py +0 -0
  39. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/mssql_introspect.py +0 -0
  40. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/parser.py +0 -0
  41. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/schemas.py +0 -0
  42. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/sql_text.py +0 -0
  43. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/testdb/__init__.py +0 -0
  44. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/testdb/_docker.py +0 -0
  45. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/testdb/api.py +0 -0
  46. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/testdb/config.py +0 -0
  47. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/testdb/constants.py +0 -0
  48. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/testdb/container.py +0 -0
  49. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/testdb/mssql/__init__.py +0 -0
  50. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/testdb/mssql/api.py +0 -0
  51. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/testdb/mssql/constants.py +0 -0
  52. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/testdb/mssql/container.py +0 -0
  53. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/testdb/naming.py +0 -0
  54. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/testdb/query.py +0 -0
  55. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/pgdevkit/testdb/schema.py +0 -0
  56. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/skills/pgdevkit/references/dynamic-sql.md +0 -0
  57. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
  58. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/__init__.py +0 -0
  59. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/conftest.py +0 -0
  60. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/db/__init__.py +0 -0
  61. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/db/test_complex_types.py +0 -0
  62. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/db/test_connection.py +0 -0
  63. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/db/test_crud.py +0 -0
  64. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/db/test_loader.py +0 -0
  65. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/db/test_mssql_crud_live.py +0 -0
  66. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/db/test_mssql_crud_sql.py +0 -0
  67. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/fixtures/01_schema.sql +0 -0
  68. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/fixtures/02_types.sql +0 -0
  69. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/fixtures/03_tables.sql +0 -0
  70. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/fixtures/04_views.sql +0 -0
  71. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/fixtures/05_functions.sql +0 -0
  72. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/fixtures/06_indexes.sql +0 -0
  73. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_areas.py +0 -0
  74. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_cli_compare.py +0 -0
  75. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_compare.py +0 -0
  76. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_compare_mssql_live.py +0 -0
  77. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_connection.py +0 -0
  78. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_dialect.py +0 -0
  79. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_diff_mssql.py +0 -0
  80. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_envtag.py +0 -0
  81. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_fetch_missing.py +0 -0
  82. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_fetch_missing_cli.py +0 -0
  83. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_lakebase.py +0 -0
  84. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_migrate.py +0 -0
  85. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_migrate_areas.py +0 -0
  86. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_migrate_env.py +0 -0
  87. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_migrate_grants.py +0 -0
  88. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_migrate_schemas.py +0 -0
  89. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_mssql_introspect.py +0 -0
  90. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_parser_areas.py +0 -0
  91. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_parser_mssql.py +0 -0
  92. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_parser_schemas.py +0 -0
  93. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/test_schemas.py +0 -0
  94. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/__init__.py +0 -0
  95. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/conftest.py +0 -0
  96. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  97. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
  98. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
  99. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  100. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  101. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
  102. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
  103. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  104. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  105. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  106. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  107. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  108. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  109. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  110. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  111. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
  112. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  113. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
  114. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
  115. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
  116. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
  117. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
  118. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/test_api.py +0 -0
  119. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/test_api_mssql.py +0 -0
  120. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/test_api_mssql_live.py +0 -0
  121. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/test_cli.py +0 -0
  122. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/test_config.py +0 -0
  123. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/test_config_mssql.py +0 -0
  124. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/test_constants.py +0 -0
  125. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/test_container.py +0 -0
  126. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/test_container_mssql.py +0 -0
  127. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/test_docker.py +0 -0
  128. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/test_mssql_constants.py +0 -0
  129. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/test_naming.py +0 -0
  130. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/test_query.py +0 -0
  131. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/test_schema.py +0 -0
  132. {pgdevkit-0.10.0 → pgdevkit-0.11.0}/tests/testdb/test_schema_filtering.py +0 -0
  133. {pgdevkit-0.10.0 → pgdevkit-0.11.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.10.0
3
+ Version: 0.11.0
4
4
  Summary: A helper for developing with Postgres
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: docker>=7.1.0
@@ -227,16 +227,22 @@ def update_stats(
227
227
  table: list[str] = typer.Option([], "--table", help="Only update schema.name (repeatable); default is every table"),
228
228
  exact: bool = typer.Option(False, "--exact", help="Use count(*) for row counts instead of the planner estimate"),
229
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"),
230
231
  ) -> None:
231
232
  """Store table stats in _stats/_tables.json (keyed by schema.name, sorted)
232
233
  and column stats in _stats/<schema.name>.json."""
233
234
  if not scripts_dir.is_dir():
234
235
  err_console.print(f"[red]Error:[/red] {scripts_dir} is not a directory")
235
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)
236
242
  conninfo = build_conninfo(url, entra_user)
237
243
  try:
238
244
  with console.status("Collecting stats..."):
239
- tables, columns = stats.collect_stats(conninfo, set(table) or None, exact=exact, analyze=analyze)
245
+ tables, columns = stats.collect_stats(conninfo, set(table) or None, exact=exact, analyze=analyze, dialect=dialect)
240
246
  except KeyError as e:
241
247
  err_console.print(f"[red]Error:[/red] unknown table(s): {e.args[0]}")
242
248
  raise typer.Exit(2)
@@ -8,6 +8,8 @@ import psycopg
8
8
  import psycopg.sql
9
9
  from psycopg.rows import dict_row
10
10
 
11
+ from .dialect import resolve_dialect
12
+
11
13
  STATS_DIRNAME = "_stats"
12
14
  TABLES_FILE = "_tables.json"
13
15
 
@@ -55,14 +57,132 @@ def _write_json(path: Path, data: Any) -> None:
55
57
  path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
56
58
 
57
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
+
58
172
  def collect_stats(
59
- conninfo: str, only: set[str] | None = None, exact: bool = False, analyze: bool = False
173
+ conninfo: str,
174
+ only: set[str] | None = None,
175
+ exact: bool = False,
176
+ analyze: bool = False,
177
+ dialect: str = "postgres",
60
178
  ) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
61
179
  """Returns (table_stats, column_stats), both keyed by "schema.table".
62
180
 
63
181
  Row counts come from pg_class.reltuples (null if the table was never
64
182
  analyzed) unless exact=True, which runs count(*). analyze=True runs ANALYZE
65
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)
66
186
  tables: dict[str, dict[str, Any]] = {}
67
187
  columns: dict[str, dict[str, Any]] = {}
68
188
  with psycopg.connect(conninfo, autocommit=True) as conn:
@@ -11,7 +11,7 @@ packages = ["pgdevkit"]
11
11
 
12
12
  [project]
13
13
  name = "pgdevkit"
14
- version = "0.10.0"
14
+ version = "0.11.0"
15
15
  description = "A helper for developing with Postgres"
16
16
  readme = "README.md"
17
17
  requires-python = ">=3.14"
@@ -293,6 +293,8 @@ pgdb get-stats database/ public.users public.orders # JSON t
293
293
  pgdb get-stats database/ --no-columns # all tables, table-level stats only
294
294
  ```
295
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
+
296
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).
297
299
 
298
300
  ---
@@ -73,3 +73,4 @@ def test_update_stats_then_get_stats(tmp_path: Path):
73
73
  finally:
74
74
  with psycopg.connect(admin, autocommit=True) as con:
75
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)
@@ -313,7 +313,7 @@ wheels = [
313
313
 
314
314
  [[package]]
315
315
  name = "pgdevkit"
316
- version = "0.10.0"
316
+ version = "0.11.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