pgdevkit 0.8.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.
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/PKG-INFO +1 -1
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/cli.py +42 -1
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/migrate.py +42 -5
- pgdevkit-0.10.0/pgdevkit/stats.py +148 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pyproject.toml +1 -1
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/skills/pgdevkit/SKILL.md +31 -0
- pgdevkit-0.10.0/tests/test_migrate_grants.py +91 -0
- pgdevkit-0.10.0/tests/test_stats_cli.py +75 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/uv.lock +1 -1
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/.github/workflows/auto-release.yml +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/.github/workflows/python-test.yml +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/.gitignore +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/.python-version +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/README.md +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/docs/database-layout.md +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/areas.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/backends/__init__.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/backends/base.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/backends/mssql.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/backends/postgres.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/connection.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/db/__init__.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/db/complex_types.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/db/connection.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/db/crud.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/db/mssql_crud.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/db/mssql_sql.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/dialect.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/envtag.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/fetch_missing.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/models.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/mssql_introspect.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/parser.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/schemas.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/sql_text.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/testdb/__init__.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/testdb/_docker.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/testdb/api.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/testdb/config.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/testdb/constants.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/testdb/container.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/testdb/mssql/__init__.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/testdb/mssql/api.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/testdb/mssql/constants.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/testdb/mssql/container.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/testdb/naming.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/pgdevkit/testdb/schema.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/skills/pgdevkit/references/dynamic-sql.md +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/__init__.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/conftest.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/db/__init__.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/db/test_complex_types.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/db/test_connection.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/db/test_crud.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/db/test_mssql_crud_live.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/db/test_mssql_crud_sql.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/test_areas.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/test_compare.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/test_compare_mssql_live.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/test_connection.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/test_dialect.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/test_diff_mssql.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/test_envtag.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/test_fetch_missing.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/test_fetch_missing_cli.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/test_migrate.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/test_migrate_areas.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/test_migrate_env.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/test_migrate_schemas.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/test_mssql_introspect.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/test_parser_areas.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/test_parser_mssql.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/test_parser_schemas.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/test_schemas.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/conftest.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/test_api.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/test_api_mssql.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/test_api_mssql_live.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/test_cli.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/test_config.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/test_config_mssql.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/test_container.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/test_container_mssql.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/test_docker.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/test_mssql_constants.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/test_naming.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/test_query.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/test_schema.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/test_schema_filtering.py +0 -0
- {pgdevkit-0.8.0 → pgdevkit-0.10.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,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,
|
|
@@ -303,6 +303,14 @@ def record_applied(conninfo: str, tracking_table: str, filename: str) -> bool:
|
|
|
303
303
|
return False
|
|
304
304
|
|
|
305
305
|
|
|
306
|
+
def created_table_names(sql: str) -> list[str]:
|
|
307
|
+
"""Table names any CREATE TABLE statement in this raw SQL script targets. Public
|
|
308
|
+
wrapper around the same detection `apply_migration` uses internally, for callers that
|
|
309
|
+
run a script directly (e.g. via `execute_sql_script`) instead of through a tracked
|
|
310
|
+
migration file, and still want to know what tables -- if any -- it created."""
|
|
311
|
+
return _created_table_names(_split_sql(sql))
|
|
312
|
+
|
|
313
|
+
|
|
306
314
|
def verify_created_tables(conninfo: str, stmts: list[str]) -> list[str]:
|
|
307
315
|
"""Table names from this migration's CREATE TABLE statements that do NOT exist in the
|
|
308
316
|
database. Empty means everything landed."""
|
|
@@ -318,6 +326,39 @@ def verify_created_tables(conninfo: str, stmts: list[str]) -> list[str]:
|
|
|
318
326
|
return missing
|
|
319
327
|
|
|
320
328
|
|
|
329
|
+
def missing_privileges(conninfo: str, role: str, tables: list[str], privilege: str = "select") -> list[str]:
|
|
330
|
+
"""Table names from `tables` that `role` cannot currently exercise `privilege` on
|
|
331
|
+
(checked via Postgres's own `has_table_privilege`). Empty means the role can access
|
|
332
|
+
all of them. For guarding against the classic "migration creates a table, nobody
|
|
333
|
+
grants it to the app's runtime role" gap: check the tables a migration just created
|
|
334
|
+
against the role that will actually query them at runtime."""
|
|
335
|
+
if not tables:
|
|
336
|
+
return []
|
|
337
|
+
missing = []
|
|
338
|
+
with psycopg.connect(conninfo) as con:
|
|
339
|
+
for tbl in tables:
|
|
340
|
+
row = con.execute("select has_table_privilege(%s, %s, %s)", (role, tbl, privilege)).fetchone()
|
|
341
|
+
if not (row and row[0]):
|
|
342
|
+
missing.append(tbl)
|
|
343
|
+
return missing
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _execute_stmts(conninfo: str, stmts: list[str]) -> None:
|
|
347
|
+
with psycopg.connect(conninfo) as con:
|
|
348
|
+
for stmt in stmts:
|
|
349
|
+
con.execute(cast(LiteralString, stmt))
|
|
350
|
+
con.commit()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def execute_sql_script(conninfo: str, sql: str) -> None:
|
|
354
|
+
"""Run a raw SQL script as one committed transaction, split into statements the same
|
|
355
|
+
statement-boundary-safe way apply_migration is (dollar-quoted blocks, string literals,
|
|
356
|
+
and line comments never get split mid-statement). Unlike apply_migration, this does
|
|
357
|
+
no tracking-table bookkeeping and isn't forward-only -- for scripts meant to re-run
|
|
358
|
+
every time, like an idempotent `GRANT ... ON ALL TABLES IN SCHEMA` privilege sync."""
|
|
359
|
+
_execute_stmts(conninfo, _split_sql(sql))
|
|
360
|
+
|
|
361
|
+
|
|
321
362
|
@dataclass
|
|
322
363
|
class ApplyResult:
|
|
323
364
|
filename: str
|
|
@@ -337,11 +378,7 @@ def apply_migration(
|
|
|
337
378
|
stmts = _split_sql(sql)
|
|
338
379
|
|
|
339
380
|
if not already_done:
|
|
340
|
-
|
|
341
|
-
with psycopg.connect(conninfo) as con:
|
|
342
|
-
for stmt in stmts:
|
|
343
|
-
con.execute(cast(LiteralString, stmt))
|
|
344
|
-
con.commit()
|
|
381
|
+
_execute_stmts(conninfo, stmts)
|
|
345
382
|
|
|
346
383
|
# Tracking insert is a separate connection/transaction so a missing tracking table
|
|
347
384
|
# never rolls back the DDL that was just applied.
|
|
@@ -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
|
|
@@ -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,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import psycopg
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from pgdevkit.migrate import created_table_names, execute_sql_script, missing_privileges
|
|
7
|
+
|
|
8
|
+
ROLE = "pgdevkit_test_grants_role"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def app_role(clean_db: str):
|
|
13
|
+
"""A throwaway, no-login role to check privileges against -- dropped afterwards since
|
|
14
|
+
roles are cluster-wide, not scoped to the (per-test) database like clean_db's schemas."""
|
|
15
|
+
with psycopg.connect(clean_db, autocommit=True) as con:
|
|
16
|
+
con.execute(f"DROP ROLE IF EXISTS {ROLE}")
|
|
17
|
+
con.execute(f"CREATE ROLE {ROLE} NOLOGIN")
|
|
18
|
+
yield ROLE
|
|
19
|
+
with psycopg.connect(clean_db, autocommit=True) as con:
|
|
20
|
+
# DROP OWNED BY revokes any grants left on tables the role can still see --
|
|
21
|
+
# otherwise DROP ROLE fails with "cannot be dropped because some objects depend
|
|
22
|
+
# on it" for as long as the granted-on table (e.g. widgets) still exists.
|
|
23
|
+
con.execute(f"DROP OWNED BY {ROLE}")
|
|
24
|
+
con.execute(f"DROP ROLE IF EXISTS {ROLE}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_created_table_names_finds_tables_in_a_raw_script():
|
|
28
|
+
sql = "GRANT SELECT ON foo TO some_role; CREATE TABLE public.widgets (id int primary key);"
|
|
29
|
+
assert created_table_names(sql) == ["public.widgets"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_created_table_names_empty_for_a_script_with_no_create_table():
|
|
33
|
+
assert created_table_names("GRANT SELECT ON ALL TABLES IN SCHEMA public TO some_role;") == []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_execute_sql_script_runs_multiple_statements_in_one_transaction(clean_db: str):
|
|
37
|
+
execute_sql_script(
|
|
38
|
+
clean_db,
|
|
39
|
+
"""
|
|
40
|
+
-- a leading comment shouldn't confuse statement splitting
|
|
41
|
+
CREATE TABLE public.widgets (id int primary key);
|
|
42
|
+
INSERT INTO public.widgets VALUES (1);
|
|
43
|
+
""",
|
|
44
|
+
)
|
|
45
|
+
with psycopg.connect(clean_db) as con:
|
|
46
|
+
row = con.execute("select count(*) from public.widgets").fetchone()
|
|
47
|
+
assert row == (1,)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_execute_sql_script_rolls_back_all_statements_on_any_failure(clean_db: str):
|
|
51
|
+
with pytest.raises(psycopg.Error):
|
|
52
|
+
execute_sql_script(
|
|
53
|
+
clean_db,
|
|
54
|
+
"CREATE TABLE public.widgets (id int primary key); NOT VALID SQL HERE;",
|
|
55
|
+
)
|
|
56
|
+
with psycopg.connect(clean_db) as con:
|
|
57
|
+
row = con.execute("select to_regclass('public.widgets')").fetchone()
|
|
58
|
+
assert row is not None and row[0] is None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_missing_privileges_reports_table_without_grant(clean_db: str, app_role: str):
|
|
62
|
+
with psycopg.connect(clean_db, autocommit=True) as con:
|
|
63
|
+
con.execute("CREATE TABLE public.widgets (id int primary key)")
|
|
64
|
+
assert missing_privileges(clean_db, app_role, ["public.widgets"]) == ["public.widgets"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_missing_privileges_empty_once_granted(clean_db: str, app_role: str):
|
|
68
|
+
with psycopg.connect(clean_db, autocommit=True) as con:
|
|
69
|
+
con.execute("CREATE TABLE public.widgets (id int primary key)")
|
|
70
|
+
con.execute(f"GRANT SELECT ON public.widgets TO {app_role}")
|
|
71
|
+
assert missing_privileges(clean_db, app_role, ["public.widgets"]) == []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_missing_privileges_empty_for_no_tables(clean_db: str, app_role: str):
|
|
75
|
+
assert missing_privileges(clean_db, app_role, []) == []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_execute_sql_script_grant_on_all_tables_covers_tables_created_after_the_fact(
|
|
79
|
+
clean_db: str, app_role: str
|
|
80
|
+
):
|
|
81
|
+
"""The actual use case: a re-run of `GRANT ... ON ALL TABLES IN SCHEMA` after a new
|
|
82
|
+
table appears picks it up automatically -- unlike ALTER DEFAULT PRIVILEGES, which only
|
|
83
|
+
covers tables created later by the exact role that ran the ALTER DEFAULT PRIVILEGES
|
|
84
|
+
statement itself."""
|
|
85
|
+
with psycopg.connect(clean_db, autocommit=True) as con:
|
|
86
|
+
con.execute("CREATE TABLE public.widgets (id int primary key)")
|
|
87
|
+
assert missing_privileges(clean_db, app_role, ["public.widgets"]) == ["public.widgets"]
|
|
88
|
+
|
|
89
|
+
execute_sql_script(clean_db, f"GRANT SELECT ON ALL TABLES IN SCHEMA public TO {app_role}")
|
|
90
|
+
|
|
91
|
+
assert missing_privileges(clean_db, app_role, ["public.widgets"]) == []
|
|
@@ -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}"')
|
|
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.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json
RENAMED
|
File without changes
|
{pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql
RENAMED
|
File without changes
|
{pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/widget.init.sql
RENAMED
|
File without changes
|
|
File without changes
|
{pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json
RENAMED
|
File without changes
|
{pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql
RENAMED
|
File without changes
|
{pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgdevkit-0.8.0 → pgdevkit-0.10.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.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql
RENAMED
|
File without changes
|
|
File without changes
|
{pgdevkit-0.8.0 → pgdevkit-0.10.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql
RENAMED
|
File without changes
|
{pgdevkit-0.8.0 → pgdevkit-0.10.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
|