pgdevkit 0.6.2__tar.gz → 0.7.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.6.2 → pgdevkit-0.7.0}/PKG-INFO +24 -3
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/README.md +23 -2
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/cli.py +23 -2
- pgdevkit-0.7.0/pgdevkit/testdb/__init__.py +21 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/api.py +50 -15
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/config.py +13 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/mssql/api.py +37 -17
- pgdevkit-0.7.0/pgdevkit/testdb/naming.py +112 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pyproject.toml +1 -1
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/conftest.py +20 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_api.py +93 -1
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_cli.py +24 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_config.py +23 -0
- pgdevkit-0.7.0/tests/testdb/test_naming.py +121 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/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_naming.py +0 -51
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/.github/workflows/auto-release.yml +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/.github/workflows/python-test.yml +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/.gitignore +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/.python-version +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/docs/database-layout.md +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/areas.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/backends/__init__.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/backends/base.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/backends/mssql.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/backends/postgres.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/connection.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/db/__init__.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/db/complex_types.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/db/connection.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/db/crud.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/db/mssql_crud.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/db/mssql_sql.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/dialect.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/envtag.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/fetch_missing.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/migrate.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/models.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/mssql_introspect.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/parser.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/schemas.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/sql_text.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/_docker.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/constants.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/container.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/mssql/__init__.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/mssql/constants.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/mssql/container.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/schema.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/skills/pgdevkit/SKILL.md +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/skills/pgdevkit/references/dynamic-sql.md +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/__init__.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/conftest.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/db/__init__.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/db/test_complex_types.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/db/test_connection.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/db/test_crud.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/db/test_mssql_crud_live.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/db/test_mssql_crud_sql.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_areas.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_compare.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_compare_mssql_live.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_connection.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_dialect.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_diff_mssql.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_envtag.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_fetch_missing.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_fetch_missing_cli.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_migrate.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_migrate_areas.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_migrate_env.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_migrate_schemas.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_mssql_introspect.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_parser_areas.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_parser_mssql.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_parser_schemas.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_schemas.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_api_mssql.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_api_mssql_live.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_config_mssql.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_container.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_mssql_constants.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_query.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_schema.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_schema_filtering.py +0 -0
- {pgdevkit-0.6.2 → pgdevkit-0.7.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.
|
|
3
|
+
Version: 0.7.0
|
|
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,27 @@ 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
|
+
|
|
234
255
|
Container connection defaults (`localhost:54322`, `postgres`/`testpwd`) can
|
|
235
256
|
be overridden with `PGDEVKIT_TESTDB_HOST`, `PGDEVKIT_TESTDB_PORT`,
|
|
236
257
|
`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,27 @@ 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
|
+
|
|
213
234
|
Container connection defaults (`localhost:54322`, `postgres`/`testpwd`) can
|
|
214
235
|
be overridden with `PGDEVKIT_TESTDB_HOST`, `PGDEVKIT_TESTDB_PORT`,
|
|
215
236
|
`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,21 @@
|
|
|
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
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"clean_testdb",
|
|
14
|
+
"dsn_for",
|
|
15
|
+
"ensure_testdb",
|
|
16
|
+
"find_orphaned_dbs",
|
|
17
|
+
"reset_testdb",
|
|
18
|
+
"run_sql",
|
|
19
|
+
"shell_argv",
|
|
20
|
+
"status",
|
|
21
|
+
]
|
|
@@ -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,45 @@ 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 clean_testdb(project_root: Path | None = None, all: bool = False, orphaned: bool = False) -> None:
|
|
158
193
|
"""Drop this workspace's database. With all=True, drop every database
|
|
159
194
|
belonging to this project (matched by its name-slug prefix), across
|
|
160
|
-
every worktree/branch.
|
|
195
|
+
every worktree/branch. With orphaned=True, drop only those without a
|
|
196
|
+
currently live git worktree (see `find_orphaned_dbs`). At most one of
|
|
197
|
+
all/orphaned may be set."""
|
|
198
|
+
if all and orphaned:
|
|
199
|
+
raise ValueError("clean_testdb: pass at most one of all=True, orphaned=True")
|
|
200
|
+
|
|
161
201
|
config, db_name = _resolve(project_root)
|
|
162
202
|
if config.engine == "mssql":
|
|
163
|
-
_mssql_api().clean_testdb(config, db_name, all)
|
|
203
|
+
_mssql_api().clean_testdb(config, db_name, all, orphaned)
|
|
164
204
|
return
|
|
165
205
|
|
|
166
206
|
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()]
|
|
207
|
+
if orphaned:
|
|
208
|
+
names = await _find_orphaned_dbs(config)
|
|
209
|
+
elif all:
|
|
210
|
+
names = await _dbs_with_prefix(f"{slugify(config.name)}_")
|
|
211
|
+
else:
|
|
212
|
+
names = [db_name]
|
|
178
213
|
for name in names:
|
|
179
214
|
await _drop_database(name)
|
|
180
215
|
|
|
@@ -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
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
3
5
|
from pathlib import Path
|
|
4
6
|
from typing import Callable
|
|
5
7
|
|
|
@@ -7,7 +9,7 @@ import psycopg
|
|
|
7
9
|
import pytest
|
|
8
10
|
|
|
9
11
|
from pgdevkit.testdb import constants
|
|
10
|
-
from pgdevkit.testdb.api import clean_testdb, ensure_testdb, reset_testdb, status
|
|
12
|
+
from pgdevkit.testdb.api import clean_testdb, ensure_testdb, find_orphaned_dbs, reset_testdb, status
|
|
11
13
|
from pgdevkit.testdb.config import load_config
|
|
12
14
|
from pgdevkit.testdb.naming import slugify
|
|
13
15
|
from tests.testdb.conftest import requires_podman
|
|
@@ -112,3 +114,93 @@ def test_dsn_for_matches_status(project_factory: Callable[[str, str], Path]):
|
|
|
112
114
|
assert dsn_for(project) == status(project)["dsn"]
|
|
113
115
|
finally:
|
|
114
116
|
clean_testdb(project)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_clean_testdb_rejects_all_and_orphaned_together(project_factory: Callable[[str, str], Path]):
|
|
120
|
+
project = project_factory("apitest6", "main")
|
|
121
|
+
with pytest.raises(ValueError, match="all=True, orphaned=True"):
|
|
122
|
+
clean_testdb(project, all=True, orphaned=True)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@requires_podman
|
|
126
|
+
def test_find_orphaned_dbs_excludes_live_worktrees(
|
|
127
|
+
worktree_project_factory: Callable[..., tuple[Path, Callable[[str], Path]]],
|
|
128
|
+
):
|
|
129
|
+
repo, add_worktree = worktree_project_factory("orphtest")
|
|
130
|
+
feature = add_worktree("feature")
|
|
131
|
+
ghost = add_worktree("ghost")
|
|
132
|
+
try:
|
|
133
|
+
ensure_testdb(repo)
|
|
134
|
+
ensure_testdb(feature)
|
|
135
|
+
ghost_db = ensure_testdb(ghost)["ORPHTEST_POSTGRES_DB"]
|
|
136
|
+
subprocess.run(["git", "worktree", "remove", "--force", str(ghost)], cwd=repo, check=True)
|
|
137
|
+
|
|
138
|
+
assert find_orphaned_dbs(repo) == [ghost_db]
|
|
139
|
+
finally:
|
|
140
|
+
clean_testdb(repo, all=True)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@requires_podman
|
|
144
|
+
def test_find_orphaned_dbs_treats_manually_deleted_worktree_as_orphaned(
|
|
145
|
+
worktree_project_factory: Callable[..., tuple[Path, Callable[[str], Path]]],
|
|
146
|
+
):
|
|
147
|
+
# A worktree dir removed with plain `rm -rf` (no `git worktree remove`)
|
|
148
|
+
# still shows up in `git worktree list` as prunable -- it must still be
|
|
149
|
+
# treated as not-live.
|
|
150
|
+
repo, add_worktree = worktree_project_factory("orphtest2")
|
|
151
|
+
ghost = add_worktree("ghost")
|
|
152
|
+
try:
|
|
153
|
+
ghost_db = ensure_testdb(ghost)["ORPHTEST2_POSTGRES_DB"]
|
|
154
|
+
shutil.rmtree(ghost)
|
|
155
|
+
|
|
156
|
+
assert find_orphaned_dbs(repo) == [ghost_db]
|
|
157
|
+
finally:
|
|
158
|
+
clean_testdb(repo, all=True)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@requires_podman
|
|
162
|
+
def test_clean_orphaned_drops_only_orphaned_dbs(
|
|
163
|
+
worktree_project_factory: Callable[..., tuple[Path, Callable[[str], Path]]],
|
|
164
|
+
):
|
|
165
|
+
repo, add_worktree = worktree_project_factory("orphtest3")
|
|
166
|
+
feature = add_worktree("feature")
|
|
167
|
+
ghost = add_worktree("ghost")
|
|
168
|
+
try:
|
|
169
|
+
ensure_testdb(repo)
|
|
170
|
+
feature_db = ensure_testdb(feature)["ORPHTEST3_POSTGRES_DB"]
|
|
171
|
+
ensure_testdb(ghost)
|
|
172
|
+
subprocess.run(["git", "worktree", "remove", "--force", str(ghost)], cwd=repo, check=True)
|
|
173
|
+
|
|
174
|
+
clean_testdb(repo, orphaned=True)
|
|
175
|
+
|
|
176
|
+
assert find_orphaned_dbs(repo) == []
|
|
177
|
+
with psycopg.connect(constants.conninfo("postgres")) as con:
|
|
178
|
+
with con.cursor() as cur:
|
|
179
|
+
cur.execute("SELECT count(*) FROM pg_database WHERE datname = %s", (feature_db,))
|
|
180
|
+
(count,) = cur.fetchone()
|
|
181
|
+
assert count == 1
|
|
182
|
+
finally:
|
|
183
|
+
clean_testdb(repo, all=True)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@requires_podman
|
|
187
|
+
def test_find_orphaned_dbs_respects_extra_db_suffixes(
|
|
188
|
+
worktree_project_factory: Callable[..., tuple[Path, Callable[[str], Path]]],
|
|
189
|
+
):
|
|
190
|
+
repo, _ = worktree_project_factory("orphtest4")
|
|
191
|
+
pyproject = repo / "pyproject.toml"
|
|
192
|
+
pyproject.write_text(
|
|
193
|
+
pyproject.read_text(encoding="utf-8").rstrip("\n") + '\nextra_db_suffixes = ["_sibling"]\n',
|
|
194
|
+
encoding="utf-8",
|
|
195
|
+
)
|
|
196
|
+
try:
|
|
197
|
+
main_db = ensure_testdb(repo)["ORPHTEST4_POSTGRES_DB"]
|
|
198
|
+
sibling_db = f"{main_db}_sibling"
|
|
199
|
+
stray_db = f"{main_db}_stray"
|
|
200
|
+
with psycopg.connect(constants.conninfo("postgres"), autocommit=True) as con:
|
|
201
|
+
con.execute(f'CREATE DATABASE "{sibling_db}"')
|
|
202
|
+
con.execute(f'CREATE DATABASE "{stray_db}"')
|
|
203
|
+
|
|
204
|
+
assert find_orphaned_dbs(repo) == [stray_db]
|
|
205
|
+
finally:
|
|
206
|
+
clean_testdb(repo, all=True)
|
|
@@ -105,3 +105,27 @@ def test_testdb_clean(project_factory: Callable[[str, str], Path], monkeypatch):
|
|
|
105
105
|
runner.invoke(app, ["testdb", "up"])
|
|
106
106
|
result = runner.invoke(app, ["testdb", "clean"])
|
|
107
107
|
assert result.exit_code == 0, result.output
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@requires_podman
|
|
111
|
+
def test_testdb_list_orphaned_reports_none_for_a_single_live_worktree(
|
|
112
|
+
project_factory: Callable[[str, str], Path], monkeypatch
|
|
113
|
+
):
|
|
114
|
+
project = project_factory("clitest7", "main")
|
|
115
|
+
monkeypatch.chdir(project)
|
|
116
|
+
try:
|
|
117
|
+
runner.invoke(app, ["testdb", "up"])
|
|
118
|
+
result = runner.invoke(app, ["testdb", "list-orphaned"])
|
|
119
|
+
assert result.exit_code == 0, result.output
|
|
120
|
+
assert "No orphaned databases." in result.output
|
|
121
|
+
finally:
|
|
122
|
+
clean_testdb(project)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_testdb_clean_rejects_all_and_orphaned_together(
|
|
126
|
+
project_factory: Callable[[str, str], Path], monkeypatch
|
|
127
|
+
):
|
|
128
|
+
project = project_factory("clitest8", "main")
|
|
129
|
+
monkeypatch.chdir(project)
|
|
130
|
+
result = runner.invoke(app, ["testdb", "clean", "--all", "--orphaned"])
|
|
131
|
+
assert result.exit_code == 2
|
|
@@ -69,3 +69,26 @@ def test_extensions_must_be_a_list(tmp_path: Path):
|
|
|
69
69
|
)
|
|
70
70
|
with pytest.raises(TypeError, match="extensions"):
|
|
71
71
|
load_config(tmp_path)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_extra_db_suffixes_defaults_empty(tmp_path: Path):
|
|
75
|
+
project = tmp_path / "myproj"
|
|
76
|
+
project.mkdir()
|
|
77
|
+
config = load_config(project)
|
|
78
|
+
assert config.extra_db_suffixes == ()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_reads_extra_db_suffixes(tmp_path: Path):
|
|
82
|
+
(tmp_path / "pyproject.toml").write_text(
|
|
83
|
+
'[tool.pgdevkit]\nname = "ccmt"\nextra_db_suffixes = ["_onetrade"]\n', encoding="utf-8"
|
|
84
|
+
)
|
|
85
|
+
config = load_config(tmp_path)
|
|
86
|
+
assert config.extra_db_suffixes == ("_onetrade",)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_extra_db_suffixes_must_be_a_list(tmp_path: Path):
|
|
90
|
+
(tmp_path / "pyproject.toml").write_text(
|
|
91
|
+
'[tool.pgdevkit]\nname = "x"\nextra_db_suffixes = "_onetrade"\n', encoding="utf-8"
|
|
92
|
+
)
|
|
93
|
+
with pytest.raises(TypeError, match="extra_db_suffixes"):
|
|
94
|
+
load_config(tmp_path)
|