pgdevkit 0.6.2__tar.gz → 0.7.1__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.6.2 → pgdevkit-0.7.1}/PKG-INFO +31 -3
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/README.md +30 -2
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/cli.py +23 -2
- pgdevkit-0.7.1/pgdevkit/testdb/__init__.py +23 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/api.py +64 -15
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/config.py +13 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/mssql/api.py +37 -17
- pgdevkit-0.7.1/pgdevkit/testdb/naming.py +112 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pyproject.toml +1 -1
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/conftest.py +20 -0
- pgdevkit-0.7.1/tests/testdb/test_api.py +244 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_cli.py +24 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_config.py +23 -0
- pgdevkit-0.7.1/tests/testdb/test_naming.py +121 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/uv.lock +1 -1
- pgdevkit-0.6.2/pgdevkit/testdb/__init__.py +0 -3
- pgdevkit-0.6.2/pgdevkit/testdb/naming.py +0 -41
- pgdevkit-0.6.2/tests/testdb/test_api.py +0 -114
- pgdevkit-0.6.2/tests/testdb/test_naming.py +0 -51
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/.github/workflows/auto-release.yml +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/.github/workflows/python-test.yml +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/.gitignore +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/.python-version +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/docs/database-layout.md +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/areas.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/backends/__init__.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/backends/base.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/backends/mssql.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/backends/postgres.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/connection.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/db/__init__.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/db/complex_types.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/db/connection.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/db/crud.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/db/mssql_crud.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/db/mssql_sql.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/dialect.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/envtag.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/fetch_missing.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/migrate.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/models.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/mssql_introspect.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/parser.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/schemas.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/sql_text.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/_docker.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/constants.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/container.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/mssql/__init__.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/mssql/constants.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/mssql/container.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/schema.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/skills/pgdevkit/SKILL.md +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/skills/pgdevkit/references/dynamic-sql.md +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/__init__.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/conftest.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/db/__init__.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/db/test_complex_types.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/db/test_connection.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/db/test_crud.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/db/test_mssql_crud_live.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/db/test_mssql_crud_sql.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_areas.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_compare.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_compare_mssql_live.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_connection.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_dialect.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_diff_mssql.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_envtag.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_fetch_missing.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_fetch_missing_cli.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_migrate.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_migrate_areas.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_migrate_env.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_migrate_schemas.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_mssql_introspect.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_parser_areas.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_parser_mssql.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_parser_schemas.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_schemas.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_api_mssql.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_api_mssql_live.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_config_mssql.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_container.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_mssql_constants.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_query.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_schema.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_schema_filtering.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.1}/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.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: A helper for developing with Postgres
|
|
5
5
|
Requires-Python: >=3.14
|
|
6
6
|
Requires-Dist: docker>=7.1.0
|
|
@@ -221,8 +221,8 @@ def ensure_test_postgres():
|
|
|
221
221
|
os.environ[k] = v
|
|
222
222
|
```
|
|
223
223
|
|
|
224
|
-
CLI: `pgdb testdb up|reset|run-sql|status|shell|clean`. `up`/`reset`
|
|
225
|
-
`--env` (default `local_test`) — see "Environment-tagged files" above.
|
|
224
|
+
CLI: `pgdb testdb up|reset|run-sql|status|shell|clean|list-orphaned`. `up`/`reset`
|
|
225
|
+
accept `--env` (default `local_test`) — see "Environment-tagged files" above.
|
|
226
226
|
|
|
227
227
|
`up`/`reset` accept `--area`/`--exclude-area` and `--schema`/`--exclude-schema`
|
|
228
228
|
(see "Area and schema filtering" above) to scope which `database/` files get
|
|
@@ -231,6 +231,34 @@ applied — e.g. `pgdb testdb up --schema billing` for a test DB with only the
|
|
|
231
231
|
project's schema to apply. `ensure_testdb`/`reset_testdb` take the same
|
|
232
232
|
keyword arguments when called from Python (e.g. from a pytest fixture).
|
|
233
233
|
|
|
234
|
+
Every git worktree/branch of a project gets its own database, named after
|
|
235
|
+
`project_name` + branch (see `pgdevkit.testdb.naming.workspace_db_name`).
|
|
236
|
+
Removing a worktree (or deleting its directory without `git worktree
|
|
237
|
+
remove`) doesn't drop its database — `pgdb testdb list-orphaned` lists
|
|
238
|
+
this project's databases whose worktree no longer exists, and
|
|
239
|
+
`pgdb testdb clean --orphaned` drops them (as opposed to `--all`, which
|
|
240
|
+
drops every database of this project regardless of whether its worktree is
|
|
241
|
+
still live). The same is available from Python as
|
|
242
|
+
`pgdevkit.testdb.find_orphaned_dbs()` and
|
|
243
|
+
`pgdevkit.testdb.clean_testdb(orphaned=True)`.
|
|
244
|
+
|
|
245
|
+
If your project's own test setup also creates a sibling database per
|
|
246
|
+
worktree (e.g. `<main_db>_myservice` for a mock service used only by that
|
|
247
|
+
project's tests), add its literal suffix so orphan detection knows it
|
|
248
|
+
belongs to a live worktree too:
|
|
249
|
+
|
|
250
|
+
```toml
|
|
251
|
+
[tool.pgdevkit]
|
|
252
|
+
extra_db_suffixes = ["_myservice"]
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`pgdevkit.testdb.workspace_db_names(project_root=None)` is the
|
|
256
|
+
single-workspace analog of `find_orphaned_dbs()`: it returns the exact set
|
|
257
|
+
of DB names (main + any `extra_db_suffixes`) owned by the branch currently
|
|
258
|
+
checked out at `project_root`, without touching Postgres or diffing against
|
|
259
|
+
other worktrees. Useful for a caller that's about to remove one specific
|
|
260
|
+
worktree and wants to know exactly which DB(s) go with it.
|
|
261
|
+
|
|
234
262
|
Container connection defaults (`localhost:54322`, `postgres`/`testpwd`) can
|
|
235
263
|
be overridden with `PGDEVKIT_TESTDB_HOST`, `PGDEVKIT_TESTDB_PORT`,
|
|
236
264
|
`PGDEVKIT_TESTDB_USER`, `PGDEVKIT_TESTDB_PASSWORD`. Before touching the
|
|
@@ -200,8 +200,8 @@ def ensure_test_postgres():
|
|
|
200
200
|
os.environ[k] = v
|
|
201
201
|
```
|
|
202
202
|
|
|
203
|
-
CLI: `pgdb testdb up|reset|run-sql|status|shell|clean`. `up`/`reset`
|
|
204
|
-
`--env` (default `local_test`) — see "Environment-tagged files" above.
|
|
203
|
+
CLI: `pgdb testdb up|reset|run-sql|status|shell|clean|list-orphaned`. `up`/`reset`
|
|
204
|
+
accept `--env` (default `local_test`) — see "Environment-tagged files" above.
|
|
205
205
|
|
|
206
206
|
`up`/`reset` accept `--area`/`--exclude-area` and `--schema`/`--exclude-schema`
|
|
207
207
|
(see "Area and schema filtering" above) to scope which `database/` files get
|
|
@@ -210,6 +210,34 @@ applied — e.g. `pgdb testdb up --schema billing` for a test DB with only the
|
|
|
210
210
|
project's schema to apply. `ensure_testdb`/`reset_testdb` take the same
|
|
211
211
|
keyword arguments when called from Python (e.g. from a pytest fixture).
|
|
212
212
|
|
|
213
|
+
Every git worktree/branch of a project gets its own database, named after
|
|
214
|
+
`project_name` + branch (see `pgdevkit.testdb.naming.workspace_db_name`).
|
|
215
|
+
Removing a worktree (or deleting its directory without `git worktree
|
|
216
|
+
remove`) doesn't drop its database — `pgdb testdb list-orphaned` lists
|
|
217
|
+
this project's databases whose worktree no longer exists, and
|
|
218
|
+
`pgdb testdb clean --orphaned` drops them (as opposed to `--all`, which
|
|
219
|
+
drops every database of this project regardless of whether its worktree is
|
|
220
|
+
still live). The same is available from Python as
|
|
221
|
+
`pgdevkit.testdb.find_orphaned_dbs()` and
|
|
222
|
+
`pgdevkit.testdb.clean_testdb(orphaned=True)`.
|
|
223
|
+
|
|
224
|
+
If your project's own test setup also creates a sibling database per
|
|
225
|
+
worktree (e.g. `<main_db>_myservice` for a mock service used only by that
|
|
226
|
+
project's tests), add its literal suffix so orphan detection knows it
|
|
227
|
+
belongs to a live worktree too:
|
|
228
|
+
|
|
229
|
+
```toml
|
|
230
|
+
[tool.pgdevkit]
|
|
231
|
+
extra_db_suffixes = ["_myservice"]
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`pgdevkit.testdb.workspace_db_names(project_root=None)` is the
|
|
235
|
+
single-workspace analog of `find_orphaned_dbs()`: it returns the exact set
|
|
236
|
+
of DB names (main + any `extra_db_suffixes`) owned by the branch currently
|
|
237
|
+
checked out at `project_root`, without touching Postgres or diffing against
|
|
238
|
+
other worktrees. Useful for a caller that's about to remove one specific
|
|
239
|
+
worktree and wants to know exactly which DB(s) go with it.
|
|
240
|
+
|
|
213
241
|
Container connection defaults (`localhost:54322`, `postgres`/`testpwd`) can
|
|
214
242
|
be overridden with `PGDEVKIT_TESTDB_HOST`, `PGDEVKIT_TESTDB_PORT`,
|
|
215
243
|
`PGDEVKIT_TESTDB_USER`, `PGDEVKIT_TESTDB_PASSWORD`. Before touching the
|
|
@@ -302,12 +302,33 @@ def testdb_shell() -> None:
|
|
|
302
302
|
@testdb_app.command("clean")
|
|
303
303
|
def testdb_clean(
|
|
304
304
|
all: bool = typer.Option(False, "--all", help="Drop every database belonging to this project"),
|
|
305
|
+
orphaned: bool = typer.Option(
|
|
306
|
+
False,
|
|
307
|
+
"--orphaned",
|
|
308
|
+
help="Drop only databases whose git worktree no longer exists (see `testdb list-orphaned`)",
|
|
309
|
+
),
|
|
305
310
|
) -> None:
|
|
306
|
-
"""Drop this workspace's database (or every database of this project with --all
|
|
307
|
-
|
|
311
|
+
"""Drop this workspace's database (or every database of this project with --all,
|
|
312
|
+
or only its orphaned ones with --orphaned)."""
|
|
313
|
+
if all and orphaned:
|
|
314
|
+
err_console.print("[red]Error:[/red] pass at most one of --all, --orphaned")
|
|
315
|
+
raise typer.Exit(2)
|
|
316
|
+
testdb.clean_testdb(all=all, orphaned=orphaned)
|
|
308
317
|
console.print("[green]Cleaned.[/green]")
|
|
309
318
|
|
|
310
319
|
|
|
320
|
+
@testdb_app.command("list-orphaned")
|
|
321
|
+
def testdb_list_orphaned() -> None:
|
|
322
|
+
"""List this project's databases whose git worktree no longer exists,
|
|
323
|
+
without dropping them."""
|
|
324
|
+
names = testdb.find_orphaned_dbs()
|
|
325
|
+
if not names:
|
|
326
|
+
console.print("No orphaned databases.")
|
|
327
|
+
return
|
|
328
|
+
for name in names:
|
|
329
|
+
console.print(name)
|
|
330
|
+
|
|
331
|
+
|
|
311
332
|
@migrate_app.command("check")
|
|
312
333
|
def migrate_check(
|
|
313
334
|
migrations_dir: Path = typer.Argument(..., help="Directory of numbered .sql migration files"),
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .api import (
|
|
2
|
+
clean_testdb,
|
|
3
|
+
dsn_for,
|
|
4
|
+
ensure_testdb,
|
|
5
|
+
find_orphaned_dbs,
|
|
6
|
+
reset_testdb,
|
|
7
|
+
run_sql,
|
|
8
|
+
shell_argv,
|
|
9
|
+
status,
|
|
10
|
+
workspace_db_names,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"clean_testdb",
|
|
15
|
+
"dsn_for",
|
|
16
|
+
"ensure_testdb",
|
|
17
|
+
"find_orphaned_dbs",
|
|
18
|
+
"reset_testdb",
|
|
19
|
+
"run_sql",
|
|
20
|
+
"shell_argv",
|
|
21
|
+
"status",
|
|
22
|
+
"workspace_db_names",
|
|
23
|
+
]
|
|
@@ -9,7 +9,14 @@ from psycopg.sql import SQL, Identifier
|
|
|
9
9
|
from . import constants, query
|
|
10
10
|
from .config import ProjectConfig, load_config
|
|
11
11
|
from .container import ensure_container
|
|
12
|
-
from .naming import
|
|
12
|
+
from .naming import (
|
|
13
|
+
current_branch,
|
|
14
|
+
escape_like_prefix,
|
|
15
|
+
expected_db_names,
|
|
16
|
+
live_worktree_branches,
|
|
17
|
+
slugify,
|
|
18
|
+
workspace_db_name,
|
|
19
|
+
)
|
|
13
20
|
from .schema import apply_schema
|
|
14
21
|
|
|
15
22
|
|
|
@@ -68,6 +75,16 @@ async def _drop_database(db_name: str) -> None:
|
|
|
68
75
|
await con.execute(SQL("DROP DATABASE IF EXISTS {}").format(Identifier(db_name)))
|
|
69
76
|
|
|
70
77
|
|
|
78
|
+
async def _dbs_with_prefix(prefix: str) -> list[str]:
|
|
79
|
+
escaped_prefix = escape_like_prefix(prefix)
|
|
80
|
+
async with await psycopg.AsyncConnection.connect(_admin_dsn(), autocommit=True) as con:
|
|
81
|
+
result = await con.execute(
|
|
82
|
+
"SELECT datname FROM pg_database WHERE datname LIKE %(pattern)s ESCAPE '\\'",
|
|
83
|
+
{"pattern": f"{escaped_prefix}%"},
|
|
84
|
+
)
|
|
85
|
+
return [row[0] for row in await result.fetchall()]
|
|
86
|
+
|
|
87
|
+
|
|
71
88
|
async def _apply(
|
|
72
89
|
config: ProjectConfig,
|
|
73
90
|
db_name: str,
|
|
@@ -154,27 +171,59 @@ def reset_testdb(
|
|
|
154
171
|
)
|
|
155
172
|
|
|
156
173
|
|
|
157
|
-
def
|
|
174
|
+
async def _find_orphaned_dbs(config: ProjectConfig) -> list[str]:
|
|
175
|
+
prefix = f"{slugify(config.name)}_"
|
|
176
|
+
actual = await _dbs_with_prefix(prefix)
|
|
177
|
+
expected = expected_db_names(config, live_worktree_branches(config.root))
|
|
178
|
+
return sorted(set(actual) - expected)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def find_orphaned_dbs(project_root: Path | None = None) -> list[str]:
|
|
182
|
+
"""Databases belonging to this project (matched by its name-slug prefix)
|
|
183
|
+
that don't belong to any currently live git worktree of this repo --
|
|
184
|
+
i.e. their branch's worktree was removed (or never existed) without
|
|
185
|
+
also dropping its database."""
|
|
186
|
+
config, _ = _resolve(project_root)
|
|
187
|
+
if config.engine == "mssql":
|
|
188
|
+
return _mssql_api().find_orphaned_dbs(config)
|
|
189
|
+
return asyncio.run(_find_orphaned_dbs(config))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def workspace_db_names(project_root: Path | None = None) -> frozenset[str]:
|
|
193
|
+
"""Every DB name this exact workspace (the branch currently checked out
|
|
194
|
+
at `project_root`) owns: its main workspace DB plus one
|
|
195
|
+
`<main>{suffix}` sibling per configured `extra_db_suffixes` entry. The
|
|
196
|
+
single-workspace analog of what `find_orphaned_dbs` computes across
|
|
197
|
+
every *live* worktree -- for a caller that wants "which DBs belong to
|
|
198
|
+
this one worktree right now" (e.g. to drop them before removing the
|
|
199
|
+
worktree itself), as opposed to a whole-project orphan sweep. Engine
|
|
200
|
+
(postgres/mssql) doesn't affect naming, so this doesn't dispatch on it."""
|
|
201
|
+
config = load_config(project_root)
|
|
202
|
+
branch = current_branch(config.root)
|
|
203
|
+
return frozenset(expected_db_names(config, [branch]))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def clean_testdb(project_root: Path | None = None, all: bool = False, orphaned: bool = False) -> None:
|
|
158
207
|
"""Drop this workspace's database. With all=True, drop every database
|
|
159
208
|
belonging to this project (matched by its name-slug prefix), across
|
|
160
|
-
every worktree/branch.
|
|
209
|
+
every worktree/branch. With orphaned=True, drop only those without a
|
|
210
|
+
currently live git worktree (see `find_orphaned_dbs`). At most one of
|
|
211
|
+
all/orphaned may be set."""
|
|
212
|
+
if all and orphaned:
|
|
213
|
+
raise ValueError("clean_testdb: pass at most one of all=True, orphaned=True")
|
|
214
|
+
|
|
161
215
|
config, db_name = _resolve(project_root)
|
|
162
216
|
if config.engine == "mssql":
|
|
163
|
-
_mssql_api().clean_testdb(config, db_name, all)
|
|
217
|
+
_mssql_api().clean_testdb(config, db_name, all, orphaned)
|
|
164
218
|
return
|
|
165
219
|
|
|
166
220
|
async def _run() -> None:
|
|
167
|
-
if
|
|
168
|
-
await
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
result = await con.execute(
|
|
174
|
-
"SELECT datname FROM pg_database WHERE datname LIKE %(pattern)s ESCAPE '\\'",
|
|
175
|
-
{"pattern": f"{escaped_prefix}%"},
|
|
176
|
-
)
|
|
177
|
-
names = [row[0] for row in await result.fetchall()]
|
|
221
|
+
if orphaned:
|
|
222
|
+
names = await _find_orphaned_dbs(config)
|
|
223
|
+
elif all:
|
|
224
|
+
names = await _dbs_with_prefix(f"{slugify(config.name)}_")
|
|
225
|
+
else:
|
|
226
|
+
names = [db_name]
|
|
178
227
|
for name in names:
|
|
179
228
|
await _drop_database(name)
|
|
180
229
|
|
|
@@ -14,6 +14,7 @@ class ProjectConfig:
|
|
|
14
14
|
database_dir: str = "database"
|
|
15
15
|
env_prefix: str = ""
|
|
16
16
|
extensions: tuple[str, ...] = ()
|
|
17
|
+
extra_db_suffixes: tuple[str, ...] = ()
|
|
17
18
|
engine: str = "postgres"
|
|
18
19
|
root: Path = field(default_factory=Path)
|
|
19
20
|
|
|
@@ -48,6 +49,17 @@ def load_config(start: Path | None = None) -> ProjectConfig:
|
|
|
48
49
|
f"[tool.pgdevkit].extensions in {pyproject} must be a list, got {type(extensions).__name__}"
|
|
49
50
|
)
|
|
50
51
|
|
|
52
|
+
# Lets a repo that layers an extra, literally-suffixed sibling database
|
|
53
|
+
# on top of its main workspace DB (e.g. a mock-service DB used only by
|
|
54
|
+
# that repo's own test setup) teach `find_orphaned_dbs`/`clean_testdb`
|
|
55
|
+
# about it, without pgdevkit needing to know why that suffix exists.
|
|
56
|
+
extra_db_suffixes = section.get("extra_db_suffixes", [])
|
|
57
|
+
if not isinstance(extra_db_suffixes, list):
|
|
58
|
+
raise TypeError(
|
|
59
|
+
f"[tool.pgdevkit].extra_db_suffixes in {pyproject} must be a list, "
|
|
60
|
+
f"got {type(extra_db_suffixes).__name__}"
|
|
61
|
+
)
|
|
62
|
+
|
|
51
63
|
# PGDEVKIT_TESTDB_ENGINE lets CI/ad-hoc runs flip engines without
|
|
52
64
|
# editing pyproject.toml; the toml value is the durable, per-project
|
|
53
65
|
# default (a project's database/ tree is written in one dialect, so
|
|
@@ -59,6 +71,7 @@ def load_config(start: Path | None = None) -> ProjectConfig:
|
|
|
59
71
|
database_dir=section.get("database_dir", "database"),
|
|
60
72
|
env_prefix=section.get("env_prefix", ""),
|
|
61
73
|
extensions=tuple(extensions),
|
|
74
|
+
extra_db_suffixes=tuple(extra_db_suffixes),
|
|
62
75
|
engine=engine,
|
|
63
76
|
root=root,
|
|
64
77
|
)
|
|
@@ -168,26 +168,46 @@ def ensure_testdb(
|
|
|
168
168
|
return _env_for(config, db_name)
|
|
169
169
|
|
|
170
170
|
|
|
171
|
-
def
|
|
172
|
-
from ..naming import
|
|
171
|
+
async def _dbs_with_prefix(prefix: str) -> list[str]:
|
|
172
|
+
from ..naming import escape_like_prefix
|
|
173
173
|
|
|
174
|
-
|
|
175
|
-
if not all:
|
|
176
|
-
await _drop_database(db_name)
|
|
177
|
-
return
|
|
178
|
-
prefix = f"{slugify(config.name)}_"
|
|
179
|
-
escaped_prefix = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
174
|
+
escaped_prefix = escape_like_prefix(prefix)
|
|
180
175
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
176
|
+
def _list_names() -> list[str]:
|
|
177
|
+
conn = mssql_python.connect(_admin_dsn(), autocommit=True)
|
|
178
|
+
try:
|
|
179
|
+
cur = conn.cursor()
|
|
180
|
+
cur.execute("SELECT name FROM sys.databases WHERE name LIKE ? ESCAPE '\\'", [f"{escaped_prefix}%"])
|
|
181
|
+
return [row[0] for row in cur.fetchall()]
|
|
182
|
+
finally:
|
|
183
|
+
conn.close()
|
|
189
184
|
|
|
190
|
-
|
|
185
|
+
return await asyncio.to_thread(_list_names)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def _find_orphaned_dbs(config: ProjectConfig) -> list[str]:
|
|
189
|
+
from ..naming import expected_db_names, live_worktree_branches, slugify
|
|
190
|
+
|
|
191
|
+
prefix = f"{slugify(config.name)}_"
|
|
192
|
+
actual = await _dbs_with_prefix(prefix)
|
|
193
|
+
expected = expected_db_names(config, live_worktree_branches(config.root))
|
|
194
|
+
return sorted(set(actual) - expected)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def find_orphaned_dbs(config: ProjectConfig) -> list[str]:
|
|
198
|
+
return asyncio.run(_find_orphaned_dbs(config))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def clean_testdb(config: ProjectConfig, db_name: str, all: bool, orphaned: bool = False) -> None:
|
|
202
|
+
from ..naming import slugify
|
|
203
|
+
|
|
204
|
+
async def _run() -> None:
|
|
205
|
+
if orphaned:
|
|
206
|
+
names = await _find_orphaned_dbs(config)
|
|
207
|
+
elif all:
|
|
208
|
+
names = await _dbs_with_prefix(f"{slugify(config.name)}_")
|
|
209
|
+
else:
|
|
210
|
+
names = [db_name]
|
|
191
211
|
for name in names:
|
|
192
212
|
await _drop_database(name)
|
|
193
213
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .config import ProjectConfig
|
|
11
|
+
|
|
12
|
+
_INVALID_CHARS = re.compile(r"[^a-z0-9_]+")
|
|
13
|
+
_MAX_SLUG_LEN = 30
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def slugify(value: str) -> str:
|
|
17
|
+
"""Lowercase, replace invalid chars with '_', truncate+hash if too long."""
|
|
18
|
+
slug = _INVALID_CHARS.sub("_", value.lower()).strip("_")
|
|
19
|
+
if not slug:
|
|
20
|
+
slug = "x"
|
|
21
|
+
if len(slug) <= _MAX_SLUG_LEN:
|
|
22
|
+
return slug
|
|
23
|
+
digest = hashlib.sha256(slug.encode()).hexdigest()[:8]
|
|
24
|
+
return f"{slug[:_MAX_SLUG_LEN]}_{digest}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def current_branch(cwd: Path | None = None) -> str:
|
|
28
|
+
"""Return the branch checked out in the git worktree rooted at cwd."""
|
|
29
|
+
result = subprocess.run(
|
|
30
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
31
|
+
cwd=cwd,
|
|
32
|
+
capture_output=True,
|
|
33
|
+
text=True,
|
|
34
|
+
check=True,
|
|
35
|
+
)
|
|
36
|
+
return result.stdout.strip()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def workspace_db_name(project_name: str, branch: str) -> str:
|
|
40
|
+
"""Compute a Postgres-safe, collision-resistant database name for this
|
|
41
|
+
project+branch. A second slugify pass over the joined string guarantees
|
|
42
|
+
the result stays under Postgres's 63-byte identifier limit even when
|
|
43
|
+
both inputs are already at the per-component truncation limit."""
|
|
44
|
+
joined = f"{slugify(project_name)}_{slugify(branch)}"
|
|
45
|
+
return slugify(joined)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def live_worktree_branches(repo: Path) -> list[str]:
|
|
49
|
+
"""Branches checked out in every currently-live worktree of the repo
|
|
50
|
+
containing `repo` -- "live" meaning its filesystem path still exists and
|
|
51
|
+
it isn't a bare checkout. `git worktree list` reports every worktree of
|
|
52
|
+
a repo regardless of which one it's run from, so this works whether
|
|
53
|
+
`repo` is the main checkout or a linked worktree.
|
|
54
|
+
|
|
55
|
+
A detached-HEAD worktree (no `branch ...` porcelain line) is reported as
|
|
56
|
+
branch "HEAD" -- matching what `current_branch()` (and so
|
|
57
|
+
`workspace_db_name()`) computes for that same worktree via `git
|
|
58
|
+
rev-parse --abbrev-ref HEAD`. Without this, a detached-HEAD worktree
|
|
59
|
+
(e.g. a CI checkout, which defaults to one) would never appear "live"
|
|
60
|
+
here, and its database would look orphaned and get dropped out from
|
|
61
|
+
under it."""
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
["git", "worktree", "list", "--porcelain"],
|
|
64
|
+
cwd=repo,
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
check=True,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
branches: list[str] = []
|
|
71
|
+
path: Path | None = None
|
|
72
|
+
branch: str | None = None
|
|
73
|
+
bare = False
|
|
74
|
+
|
|
75
|
+
def _flush() -> None:
|
|
76
|
+
if path is not None and not bare and branch is not None and path.exists():
|
|
77
|
+
branches.append(branch)
|
|
78
|
+
|
|
79
|
+
for line in result.stdout.splitlines():
|
|
80
|
+
if not line:
|
|
81
|
+
_flush()
|
|
82
|
+
path, branch, bare = None, None, False
|
|
83
|
+
elif line.startswith("worktree "):
|
|
84
|
+
path = Path(line[len("worktree ") :])
|
|
85
|
+
elif line.startswith("branch "):
|
|
86
|
+
branch = line[len("branch ") :].removeprefix("refs/heads/")
|
|
87
|
+
elif line == "detached":
|
|
88
|
+
branch = "HEAD"
|
|
89
|
+
elif line == "bare":
|
|
90
|
+
bare = True
|
|
91
|
+
_flush()
|
|
92
|
+
|
|
93
|
+
return branches
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def escape_like_prefix(prefix: str) -> str:
|
|
97
|
+
"""Escape a literal string for use as a `LIKE ... ESCAPE '\\'` prefix
|
|
98
|
+
pattern (with a trailing `%` the caller adds), so a project name/branch
|
|
99
|
+
containing `%` or `_` can't widen the match to an unrelated database."""
|
|
100
|
+
return prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def expected_db_names(config: "ProjectConfig", branches: list[str]) -> set[str]:
|
|
104
|
+
"""Every DB name a currently live worktree of this project is entitled
|
|
105
|
+
to own: the main workspace DB per branch, plus one `<main>{suffix}`
|
|
106
|
+
sibling per `[tool.pgdevkit].extra_db_suffixes` entry."""
|
|
107
|
+
names: set[str] = set()
|
|
108
|
+
for branch in branches:
|
|
109
|
+
main_db = workspace_db_name(config.name, branch)
|
|
110
|
+
names.add(main_db)
|
|
111
|
+
names.update(f"{main_db}{suffix}" for suffix in config.extra_db_suffixes)
|
|
112
|
+
return names
|
|
@@ -74,3 +74,23 @@ def project_factory(tmp_path: Path) -> Callable[..., Path]:
|
|
|
74
74
|
return _make_project(tmp_path, name, branch, engine)
|
|
75
75
|
|
|
76
76
|
return _factory
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.fixture
|
|
80
|
+
def worktree_project_factory(tmp_path: Path) -> Callable[..., tuple[Path, Callable[[str], Path]]]:
|
|
81
|
+
"""Build a real project repo (with a `[tool.pgdevkit]` pyproject.toml,
|
|
82
|
+
like `project_factory`) plus a `add_worktree(branch)` helper that adds a
|
|
83
|
+
linked `git worktree` of that same repo -- for tests that need more than
|
|
84
|
+
one *live* worktree of one repo, e.g. orphaned-DB detection."""
|
|
85
|
+
|
|
86
|
+
def _factory(name: str, main_branch: str = "main", engine: str = "postgres") -> tuple[Path, Callable[[str], Path]]:
|
|
87
|
+
repo = _make_project(tmp_path, name, main_branch, engine)
|
|
88
|
+
|
|
89
|
+
def _add_worktree(branch: str) -> Path:
|
|
90
|
+
path = tmp_path / f"{name}-{branch}"
|
|
91
|
+
subprocess.run(["git", "worktree", "add", "-q", str(path), "-b", branch], cwd=repo, check=True)
|
|
92
|
+
return path
|
|
93
|
+
|
|
94
|
+
return repo, _add_worktree
|
|
95
|
+
|
|
96
|
+
return _factory
|