pgdevkit 0.5.0__tar.gz → 0.6.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.5.0 → pgdevkit-0.6.0}/PKG-INFO +29 -2
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/README.md +28 -1
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/docs/database-layout.md +11 -3
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/cli.py +20 -3
- pgdevkit-0.6.0/pgdevkit/envtag.py +54 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/migrate.py +16 -2
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/testdb/api.py +10 -3
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/testdb/mssql/api.py +10 -5
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/testdb/schema.py +20 -7
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pyproject.toml +1 -1
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/skills/pgdevkit/SKILL.md +2 -2
- pgdevkit-0.6.0/tests/test_envtag.py +56 -0
- pgdevkit-0.6.0/tests/test_migrate_env.py +44 -0
- pgdevkit-0.6.0/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +3 -0
- pgdevkit-0.6.0/tests/testdb/fixtures/database/app/tables/widget.init.sql +1 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/test_schema.py +37 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/uv.lock +1 -1
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/.github/workflows/auto-release.yml +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/.github/workflows/python-test.yml +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/.gitignore +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/.python-version +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/areas.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/backends/__init__.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/backends/base.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/backends/mssql.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/backends/postgres.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/connection.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/db/__init__.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/db/complex_types.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/db/connection.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/db/crud.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/db/mssql_crud.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/db/mssql_sql.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/dialect.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/fetch_missing.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/models.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/mssql_introspect.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/parser.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/schemas.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/sql_text.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/testdb/__init__.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/testdb/_docker.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/testdb/config.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/testdb/constants.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/testdb/container.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/testdb/mssql/__init__.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/testdb/mssql/constants.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/testdb/mssql/container.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/testdb/naming.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/skills/pgdevkit/references/dynamic-sql.md +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/__init__.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/conftest.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/db/__init__.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/db/test_complex_types.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/db/test_connection.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/db/test_crud.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/db/test_mssql_crud_live.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/db/test_mssql_crud_sql.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/test_areas.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/test_compare.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/test_compare_mssql_live.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/test_connection.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/test_dialect.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/test_diff_mssql.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/test_fetch_missing.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/test_fetch_missing_cli.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/test_migrate.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/test_migrate_areas.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/test_migrate_schemas.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/test_mssql_introspect.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/test_parser_areas.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/test_parser_mssql.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/test_parser_schemas.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/test_schemas.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/conftest.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/test_api.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/test_api_mssql.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/test_api_mssql_live.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/test_cli.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/test_config.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/test_config_mssql.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/test_container.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/test_mssql_constants.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/test_naming.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/test_query.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/test_schema_filtering.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.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.6.0
|
|
4
4
|
Summary: A helper for developing with Postgres
|
|
5
5
|
Requires-Python: >=3.14
|
|
6
6
|
Requires-Dist: docker>=7.1.0
|
|
@@ -167,6 +167,32 @@ semantics. `pgdevkit.migrate.list_migration_files`/`pending_migrations` and
|
|
|
167
167
|
`pgdevkit.fetch_missing.find_missing_objects` takes neither, for the reason
|
|
168
168
|
above.
|
|
169
169
|
|
|
170
|
+
## Environment-tagged files (`<name>.<env>.sql`)
|
|
171
|
+
|
|
172
|
+
A file whose name ends `.<env>.sql` (e.g. `grants.prod.sql`,
|
|
173
|
+
`seed.staging.sql`) is only in scope when targeting that environment; a
|
|
174
|
+
plain `<name>.sql` file is untagged and always in scope, regardless of
|
|
175
|
+
environment. `.init.sql` (see `docs/database-layout.md`) is reserved and is
|
|
176
|
+
never treated as an environment tag.
|
|
177
|
+
|
|
178
|
+
- `pgdb testdb up`/`pgdb testdb reset` accept `--env` (default
|
|
179
|
+
`local_test`) — so an untagged `grants.sql` always applies, but
|
|
180
|
+
`grants.prod.sql` is skipped unless run with `--env prod`.
|
|
181
|
+
- `pgdb migrate check`/`pgdb migrate apply` accept `--env` too, but it's
|
|
182
|
+
optional with **no** default: omit it and every file is a candidate
|
|
183
|
+
regardless of its tag (unchanged, today's behavior); pass it to restrict
|
|
184
|
+
to files tagged for that environment plus untagged ones.
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
pgdb testdb up --env prod # apply prod-tagged files too, against the local test container
|
|
188
|
+
pgdb migrate apply path/to/database/_migration_scripts --url ... --env prod
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`pgdevkit.envtag` exposes the same logic for scripting: `file_env` reads a
|
|
192
|
+
file's tag, `env_allowed` applies the filtering semantics above, and
|
|
193
|
+
`strip_env_suffix` returns a tagged file's logical name (e.g.
|
|
194
|
+
`grants.prod.sql` -> `"grants"`).
|
|
195
|
+
|
|
170
196
|
## `pgdb testdb`
|
|
171
197
|
|
|
172
198
|
Manages a single shared, Podman-backed Postgres container for local tests
|
|
@@ -195,7 +221,8 @@ def ensure_test_postgres():
|
|
|
195
221
|
os.environ[k] = v
|
|
196
222
|
```
|
|
197
223
|
|
|
198
|
-
CLI: `pgdb testdb up|reset|run-sql|status|shell|clean`.
|
|
224
|
+
CLI: `pgdb testdb up|reset|run-sql|status|shell|clean`. `up`/`reset` accept
|
|
225
|
+
`--env` (default `local_test`) — see "Environment-tagged files" above.
|
|
199
226
|
|
|
200
227
|
`up`/`reset` accept `--area`/`--exclude-area` and `--schema`/`--exclude-schema`
|
|
201
228
|
(see "Area and schema filtering" above) to scope which `database/` files get
|
|
@@ -146,6 +146,32 @@ semantics. `pgdevkit.migrate.list_migration_files`/`pending_migrations` and
|
|
|
146
146
|
`pgdevkit.fetch_missing.find_missing_objects` takes neither, for the reason
|
|
147
147
|
above.
|
|
148
148
|
|
|
149
|
+
## Environment-tagged files (`<name>.<env>.sql`)
|
|
150
|
+
|
|
151
|
+
A file whose name ends `.<env>.sql` (e.g. `grants.prod.sql`,
|
|
152
|
+
`seed.staging.sql`) is only in scope when targeting that environment; a
|
|
153
|
+
plain `<name>.sql` file is untagged and always in scope, regardless of
|
|
154
|
+
environment. `.init.sql` (see `docs/database-layout.md`) is reserved and is
|
|
155
|
+
never treated as an environment tag.
|
|
156
|
+
|
|
157
|
+
- `pgdb testdb up`/`pgdb testdb reset` accept `--env` (default
|
|
158
|
+
`local_test`) — so an untagged `grants.sql` always applies, but
|
|
159
|
+
`grants.prod.sql` is skipped unless run with `--env prod`.
|
|
160
|
+
- `pgdb migrate check`/`pgdb migrate apply` accept `--env` too, but it's
|
|
161
|
+
optional with **no** default: omit it and every file is a candidate
|
|
162
|
+
regardless of its tag (unchanged, today's behavior); pass it to restrict
|
|
163
|
+
to files tagged for that environment plus untagged ones.
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
pgdb testdb up --env prod # apply prod-tagged files too, against the local test container
|
|
167
|
+
pgdb migrate apply path/to/database/_migration_scripts --url ... --env prod
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`pgdevkit.envtag` exposes the same logic for scripting: `file_env` reads a
|
|
171
|
+
file's tag, `env_allowed` applies the filtering semantics above, and
|
|
172
|
+
`strip_env_suffix` returns a tagged file's logical name (e.g.
|
|
173
|
+
`grants.prod.sql` -> `"grants"`).
|
|
174
|
+
|
|
149
175
|
## `pgdb testdb`
|
|
150
176
|
|
|
151
177
|
Manages a single shared, Podman-backed Postgres container for local tests
|
|
@@ -174,7 +200,8 @@ def ensure_test_postgres():
|
|
|
174
200
|
os.environ[k] = v
|
|
175
201
|
```
|
|
176
202
|
|
|
177
|
-
CLI: `pgdb testdb up|reset|run-sql|status|shell|clean`.
|
|
203
|
+
CLI: `pgdb testdb up|reset|run-sql|status|shell|clean`. `up`/`reset` accept
|
|
204
|
+
`--env` (default `local_test`) — see "Environment-tagged files" above.
|
|
178
205
|
|
|
179
206
|
`up`/`reset` accept `--area`/`--exclude-area` and `--schema`/`--exclude-schema`
|
|
180
207
|
(see "Area and schema filtering" above) to scope which `database/` files get
|
|
@@ -71,10 +71,18 @@ One object per file: `tables/user.sql`, `views/all_edits.sql`,
|
|
|
71
71
|
|---|---|
|
|
72
72
|
| `<name>.sql` | The object's live definition (`CREATE TABLE`, `CREATE OR REPLACE VIEW`, ...) |
|
|
73
73
|
| `<name>.test_data.json` | Seed rows for a table — a JSON array of row objects, loaded after the table is created |
|
|
74
|
-
| `<name>.init.sql` | One-time setup for an object (e.g. a backfill), run once, kept separate from the reusable definition |
|
|
75
|
-
| `<name>.
|
|
74
|
+
| `<name>.init.sql` | One-time setup for an object (e.g. a backfill), run once, kept separate from the reusable definition — `init` is reserved and is never treated as an environment tag |
|
|
75
|
+
| `<name>.<env>.sql` | Only applied when targeting environment `<env>` (any name you like — `prod`, `staging`, ...); a file with no such suffix is untagged and always applies, regardless of environment |
|
|
76
76
|
| `all.sql` | Generated concatenation of the whole tree — not hand-edited, not committed |
|
|
77
77
|
|
|
78
|
+
`pgdb testdb up`/`pgdb testdb reset` apply the `--env` they're given (default
|
|
79
|
+
`local_test`) — so an untagged `grants.sql` always applies, but
|
|
80
|
+
`grants.prod.sql` is skipped unless you pass `--env prod`. `pgdb migrate
|
|
81
|
+
check`/`pgdb migrate apply` accept the same `--env`, but it's optional with no
|
|
82
|
+
default: omit it and every file is a candidate regardless of its tag (today's
|
|
83
|
+
behavior); pass it to restrict to files tagged for that environment plus
|
|
84
|
+
untagged ones.
|
|
85
|
+
|
|
78
86
|
---
|
|
79
87
|
|
|
80
88
|
## Migrations
|
|
@@ -156,5 +164,5 @@ leading sort number.
|
|
|
156
164
|
- [ ] Object-type folder (`tables`, `views`, ...) matches the apply-order table above — that's what governs ordering, not the layer's leading number
|
|
157
165
|
- [ ] One-off changes go in `migrations/`, dated, never edited after applying
|
|
158
166
|
- [ ] The live `.sql` file is updated in the same change as any migration touching that object
|
|
159
|
-
- [ ]
|
|
167
|
+
- [ ] `.<env>.sql` files (e.g. `.prod.sql`) are skipped by `pgdb testdb` unless it's run with a matching `--env`
|
|
160
168
|
- [ ] Every table (and non-obvious column) has a `COMMENT ON`, placed in the object's own `.sql` file
|
|
@@ -41,6 +41,17 @@ _EXCLUDE_SCHEMA_OPTION = typer.Option(
|
|
|
41
41
|
help="Skip files referencing this DB schema (repeatable); "
|
|
42
42
|
"files with no detectable schema reference are never excluded",
|
|
43
43
|
)
|
|
44
|
+
_TESTDB_ENV_OPTION = typer.Option(
|
|
45
|
+
"local_test",
|
|
46
|
+
"--env",
|
|
47
|
+
help="Environment to apply: skips any <name>.<other-env>.sql file (e.g. grants.prod.sql); untagged files always apply",
|
|
48
|
+
)
|
|
49
|
+
_MIGRATE_ENV_OPTION = typer.Option(
|
|
50
|
+
None,
|
|
51
|
+
"--env",
|
|
52
|
+
help="Restrict to files tagged for this environment (e.g. <name>.prod.sql); untagged files always apply; "
|
|
53
|
+
"omit to apply every file regardless of its env tag",
|
|
54
|
+
)
|
|
44
55
|
|
|
45
56
|
|
|
46
57
|
def _as_set(values: list[str]) -> frozenset[str] | None:
|
|
@@ -209,6 +220,7 @@ def fetch_missing(
|
|
|
209
220
|
|
|
210
221
|
@testdb_app.command("up")
|
|
211
222
|
def testdb_up(
|
|
223
|
+
env: str = _TESTDB_ENV_OPTION,
|
|
212
224
|
area: list[str] = _AREA_OPTION,
|
|
213
225
|
exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
|
|
214
226
|
schema: list[str] = _SCHEMA_OPTION,
|
|
@@ -216,7 +228,7 @@ def testdb_up(
|
|
|
216
228
|
) -> None:
|
|
217
229
|
"""Ensure the container is running, the workspace DB exists, and schema is applied."""
|
|
218
230
|
testdb.ensure_testdb(
|
|
219
|
-
areas=_as_set(area), exclude_areas=_as_set(exclude_area), schemas=_as_set(schema),
|
|
231
|
+
env=env, areas=_as_set(area), exclude_areas=_as_set(exclude_area), schemas=_as_set(schema),
|
|
220
232
|
exclude_schemas=_as_set(exclude_schema),
|
|
221
233
|
)
|
|
222
234
|
info = testdb.status()
|
|
@@ -225,6 +237,7 @@ def testdb_up(
|
|
|
225
237
|
|
|
226
238
|
@testdb_app.command("reset")
|
|
227
239
|
def testdb_reset(
|
|
240
|
+
env: str = _TESTDB_ENV_OPTION,
|
|
228
241
|
area: list[str] = _AREA_OPTION,
|
|
229
242
|
exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
|
|
230
243
|
schema: list[str] = _SCHEMA_OPTION,
|
|
@@ -232,7 +245,7 @@ def testdb_reset(
|
|
|
232
245
|
) -> None:
|
|
233
246
|
"""Drop and recreate only this workspace's database, then reapply schema + seed data."""
|
|
234
247
|
testdb.reset_testdb(
|
|
235
|
-
areas=_as_set(area), exclude_areas=_as_set(exclude_area), schemas=_as_set(schema),
|
|
248
|
+
env=env, areas=_as_set(area), exclude_areas=_as_set(exclude_area), schemas=_as_set(schema),
|
|
236
249
|
exclude_schemas=_as_set(exclude_schema),
|
|
237
250
|
)
|
|
238
251
|
info = testdb.status()
|
|
@@ -310,6 +323,7 @@ def migrate_check(
|
|
|
310
323
|
exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
|
|
311
324
|
schema: list[str] = _SCHEMA_OPTION,
|
|
312
325
|
exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
|
|
326
|
+
env: str | None = _MIGRATE_ENV_OPTION,
|
|
313
327
|
) -> None:
|
|
314
328
|
"""List which migration files under migrations_dir are applied vs. pending."""
|
|
315
329
|
if not migrations_dir.is_dir():
|
|
@@ -324,6 +338,7 @@ def migrate_check(
|
|
|
324
338
|
exclude_areas=_as_set(exclude_area),
|
|
325
339
|
schemas=_as_set(schema),
|
|
326
340
|
exclude_schemas=_as_set(exclude_schema),
|
|
341
|
+
env=env,
|
|
327
342
|
)
|
|
328
343
|
try:
|
|
329
344
|
applied = migrate.applied_migrations(conninfo, tracking_table)
|
|
@@ -367,6 +382,7 @@ def migrate_apply(
|
|
|
367
382
|
exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
|
|
368
383
|
schema: list[str] = _SCHEMA_OPTION,
|
|
369
384
|
exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
|
|
385
|
+
env: str | None = _MIGRATE_ENV_OPTION,
|
|
370
386
|
) -> None:
|
|
371
387
|
"""Apply pending migration files, in filename order, tracking each in tracking_table."""
|
|
372
388
|
if not migrations_dir.is_dir():
|
|
@@ -393,6 +409,7 @@ def migrate_apply(
|
|
|
393
409
|
exclude_areas=exclude_areas,
|
|
394
410
|
schemas=schemas,
|
|
395
411
|
exclude_schemas=exclude_schemas,
|
|
412
|
+
env=env,
|
|
396
413
|
)
|
|
397
414
|
except migrate.TrackingTableMissing:
|
|
398
415
|
err_console.print(
|
|
@@ -400,7 +417,7 @@ def migrate_apply(
|
|
|
400
417
|
)
|
|
401
418
|
targets = migrate.list_migration_files(
|
|
402
419
|
migrations_dir, areas=areas, exclude_areas=exclude_areas, schemas=schemas,
|
|
403
|
-
exclude_schemas=exclude_schemas,
|
|
420
|
+
exclude_schemas=exclude_schemas, env=env,
|
|
404
421
|
)
|
|
405
422
|
|
|
406
423
|
if not targets:
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Optional `<name>.<env>.sql` filename convention: a file whose dot-segment
|
|
2
|
+
immediately before `.sql` names a deployment environment (e.g.
|
|
3
|
+
`grants.prod.sql`, `seed.staging.sql`) is only in scope when the caller is
|
|
4
|
+
targeting that same environment. A plain `<name>.sql` file (no such segment)
|
|
5
|
+
is untagged/common and is always in scope, regardless of which environment is
|
|
6
|
+
requested — mirroring the untagged-file rule for `-- area:` tags in
|
|
7
|
+
areas.py.
|
|
8
|
+
|
|
9
|
+
This generalizes the older, hardcoded `.prod.sql` convention (still the usual
|
|
10
|
+
name for a production-only file — grants, real user accounts — that
|
|
11
|
+
`pgdb testdb` should never touch); any string can now be used as an
|
|
12
|
+
environment name.
|
|
13
|
+
|
|
14
|
+
`.init.sql` (one-time setup, see docs/database-layout.md) is reserved and is
|
|
15
|
+
never interpreted as an environment tag.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
_RESERVED_SQL_SUFFIXES = {"init"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def file_env(path: Path) -> str | None:
|
|
26
|
+
"""The environment tag from `path`'s name, or None if it's untagged (or
|
|
27
|
+
the suffix is a reserved, non-env one like `.init.sql`). Only `.sql`
|
|
28
|
+
files can carry a tag."""
|
|
29
|
+
if path.suffix != ".sql":
|
|
30
|
+
return None
|
|
31
|
+
stem = path.stem
|
|
32
|
+
base, dot, suffix = stem.rpartition(".")
|
|
33
|
+
if not dot or suffix in _RESERVED_SQL_SUFFIXES:
|
|
34
|
+
return None
|
|
35
|
+
return suffix
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def env_allowed(path: Path, env: str | None) -> bool:
|
|
39
|
+
"""Whether `path` is in scope for `env`. `env=None` means no environment
|
|
40
|
+
filtering was requested, so every file (tagged or not) is in scope."""
|
|
41
|
+
if env is None:
|
|
42
|
+
return True
|
|
43
|
+
tag = file_env(path)
|
|
44
|
+
return tag is None or tag == env
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def strip_env_suffix(path: Path) -> str:
|
|
48
|
+
"""`path.stem` with a trailing `.<tag>` removed, so a tagged file
|
|
49
|
+
resolves to the same logical name as its untagged counterpart would
|
|
50
|
+
(e.g. `grants.prod.sql` -> "grants", same as `grants.sql`)."""
|
|
51
|
+
tag = file_env(path)
|
|
52
|
+
if tag is None:
|
|
53
|
+
return path.stem
|
|
54
|
+
return path.stem[: -(len(tag) + 1)]
|
|
@@ -19,6 +19,7 @@ from psycopg import errors as pg_errors
|
|
|
19
19
|
from psycopg import sql as pg_sql
|
|
20
20
|
|
|
21
21
|
from .areas import filter_by_area
|
|
22
|
+
from .envtag import env_allowed
|
|
22
23
|
from .schemas import filter_by_schema
|
|
23
24
|
from .sql_text import strip_line_comments
|
|
24
25
|
|
|
@@ -236,10 +237,17 @@ def list_migration_files(
|
|
|
236
237
|
exclude_areas: frozenset[str] | None = None,
|
|
237
238
|
schemas: frozenset[str] | None = None,
|
|
238
239
|
exclude_schemas: frozenset[str] | None = None,
|
|
240
|
+
env: str | None = None,
|
|
239
241
|
) -> list[Path]:
|
|
242
|
+
"""Migration files under migrations_dir, restricted by area (see
|
|
243
|
+
`.areas`), by schema (see `.schemas`), and by environment tag (see
|
|
244
|
+
`.envtag`) — e.g. a `2026-07-10_backfill.prod.sql` is only included when
|
|
245
|
+
`env="prod"`. `env=None` (the default) applies no environment filtering
|
|
246
|
+
at all, so every file is a candidate regardless of its tag."""
|
|
240
247
|
files = sorted(migrations_dir.glob("*.sql"))
|
|
241
248
|
files = filter_by_area(files, only=areas, exclude=exclude_areas)
|
|
242
|
-
|
|
249
|
+
files = filter_by_schema(files, only=schemas, exclude=exclude_schemas)
|
|
250
|
+
return [f for f in files if env_allowed(f, env)]
|
|
243
251
|
|
|
244
252
|
|
|
245
253
|
def applied_migrations(conninfo: str, tracking_table: str) -> dict[str, tuple[datetime, str]]:
|
|
@@ -264,10 +272,16 @@ def pending_migrations(
|
|
|
264
272
|
exclude_areas: frozenset[str] | None = None,
|
|
265
273
|
schemas: frozenset[str] | None = None,
|
|
266
274
|
exclude_schemas: frozenset[str] | None = None,
|
|
275
|
+
env: str | None = None,
|
|
267
276
|
) -> list[Path]:
|
|
268
277
|
applied = applied_migrations(conninfo, tracking_table)
|
|
269
278
|
files = list_migration_files(
|
|
270
|
-
migrations_dir,
|
|
279
|
+
migrations_dir,
|
|
280
|
+
areas=areas,
|
|
281
|
+
exclude_areas=exclude_areas,
|
|
282
|
+
schemas=schemas,
|
|
283
|
+
exclude_schemas=exclude_schemas,
|
|
284
|
+
env=env,
|
|
271
285
|
)
|
|
272
286
|
return [p for p in files if p.name not in applied]
|
|
273
287
|
|
|
@@ -73,6 +73,7 @@ async def _apply(
|
|
|
73
73
|
db_name: str,
|
|
74
74
|
force_reset: bool,
|
|
75
75
|
*,
|
|
76
|
+
env: str = "local_test",
|
|
76
77
|
areas: frozenset[str] | None = None,
|
|
77
78
|
exclude_areas: frozenset[str] | None = None,
|
|
78
79
|
schemas: frozenset[str] | None = None,
|
|
@@ -84,6 +85,7 @@ async def _apply(
|
|
|
84
85
|
config.root / config.database_dir,
|
|
85
86
|
extensions=config.extensions,
|
|
86
87
|
force_reset=force_reset,
|
|
88
|
+
env=env,
|
|
87
89
|
areas=areas,
|
|
88
90
|
exclude_areas=exclude_areas,
|
|
89
91
|
schemas=schemas,
|
|
@@ -95,6 +97,7 @@ def ensure_testdb(
|
|
|
95
97
|
project_root: Path | None = None,
|
|
96
98
|
force_reset: bool = False,
|
|
97
99
|
*,
|
|
100
|
+
env: str = "local_test",
|
|
98
101
|
areas: frozenset[str] | None = None,
|
|
99
102
|
exclude_areas: frozenset[str] | None = None,
|
|
100
103
|
schemas: frozenset[str] | None = None,
|
|
@@ -105,6 +108,9 @@ def ensure_testdb(
|
|
|
105
108
|
vars for this workspace (or the mssql equivalent's env vars, per
|
|
106
109
|
`config.engine`).
|
|
107
110
|
|
|
111
|
+
`env` selects which environment-tagged files apply (see pgdevkit.envtag,
|
|
112
|
+
e.g. a `grants.prod.sql` is skipped unless env="prod").
|
|
113
|
+
|
|
108
114
|
`areas`/`exclude_areas` and `schemas`/`exclude_schemas` restrict which
|
|
109
115
|
database/ files get applied -- e.g. for a test DB scoped to one area or
|
|
110
116
|
schema. Neither filters what gets *dropped* by force_reset/clean, only
|
|
@@ -113,7 +119,7 @@ def ensure_testdb(
|
|
|
113
119
|
if config.engine == "mssql":
|
|
114
120
|
return _mssql_api().ensure_testdb(
|
|
115
121
|
config, db_name, force_reset,
|
|
116
|
-
areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas,
|
|
122
|
+
env=env, areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas,
|
|
117
123
|
)
|
|
118
124
|
|
|
119
125
|
ensure_container()
|
|
@@ -124,7 +130,7 @@ def ensure_testdb(
|
|
|
124
130
|
await _ensure_database(db_name)
|
|
125
131
|
await _apply(
|
|
126
132
|
config, db_name, force_reset,
|
|
127
|
-
areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas,
|
|
133
|
+
env=env, areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas,
|
|
128
134
|
)
|
|
129
135
|
|
|
130
136
|
asyncio.run(_run())
|
|
@@ -134,6 +140,7 @@ def ensure_testdb(
|
|
|
134
140
|
def reset_testdb(
|
|
135
141
|
project_root: Path | None = None,
|
|
136
142
|
*,
|
|
143
|
+
env: str = "local_test",
|
|
137
144
|
areas: frozenset[str] | None = None,
|
|
138
145
|
exclude_areas: frozenset[str] | None = None,
|
|
139
146
|
schemas: frozenset[str] | None = None,
|
|
@@ -143,7 +150,7 @@ def reset_testdb(
|
|
|
143
150
|
schema and seed data."""
|
|
144
151
|
return ensure_testdb(
|
|
145
152
|
project_root, force_reset=True,
|
|
146
|
-
areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas,
|
|
153
|
+
env=env, areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas,
|
|
147
154
|
)
|
|
148
155
|
|
|
149
156
|
|
|
@@ -9,6 +9,7 @@ import mssql_python
|
|
|
9
9
|
|
|
10
10
|
from ...db.mssql_sql import ident, json_encode_value
|
|
11
11
|
from ...dialect import MSSQL
|
|
12
|
+
from ...envtag import strip_env_suffix
|
|
12
13
|
from .. import query
|
|
13
14
|
from ..config import ProjectConfig
|
|
14
15
|
from ..schema import _iter_sql_files, _strip_layer_prefix
|
|
@@ -106,6 +107,7 @@ async def _apply(
|
|
|
106
107
|
db_name: str,
|
|
107
108
|
force_reset: bool,
|
|
108
109
|
*,
|
|
110
|
+
env: str = "local_test",
|
|
109
111
|
areas: frozenset[str] | None = None,
|
|
110
112
|
exclude_areas: frozenset[str] | None = None,
|
|
111
113
|
schemas: frozenset[str] | None = None,
|
|
@@ -121,7 +123,7 @@ async def _apply(
|
|
|
121
123
|
return
|
|
122
124
|
for file, sql in _iter_sql_files(
|
|
123
125
|
database_dir, MSSQL,
|
|
124
|
-
areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas,
|
|
126
|
+
env=env, areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas,
|
|
125
127
|
):
|
|
126
128
|
for batch in query.split_tsql_batches(sql):
|
|
127
129
|
|
|
@@ -129,11 +131,13 @@ async def _apply(
|
|
|
129
131
|
conn.cursor().execute(batch)
|
|
130
132
|
|
|
131
133
|
await asyncio.to_thread(_exec)
|
|
132
|
-
|
|
134
|
+
table_stem = strip_env_suffix(file)
|
|
135
|
+
json_file = file.parent / f"{table_stem}.test_data.json"
|
|
133
136
|
if json_file.exists():
|
|
134
137
|
schema_name = _strip_layer_prefix(file.parent.parent.name)
|
|
135
|
-
|
|
136
|
-
|
|
138
|
+
await _insert_test_data(
|
|
139
|
+
json_file, f"{schema_name}.{_strip_layer_prefix(table_stem)}", force_reset, conn
|
|
140
|
+
)
|
|
137
141
|
finally:
|
|
138
142
|
await asyncio.to_thread(conn.close)
|
|
139
143
|
|
|
@@ -143,6 +147,7 @@ def ensure_testdb(
|
|
|
143
147
|
db_name: str,
|
|
144
148
|
force_reset: bool,
|
|
145
149
|
*,
|
|
150
|
+
env: str = "local_test",
|
|
146
151
|
areas: frozenset[str] | None = None,
|
|
147
152
|
exclude_areas: frozenset[str] | None = None,
|
|
148
153
|
schemas: frozenset[str] | None = None,
|
|
@@ -156,7 +161,7 @@ def ensure_testdb(
|
|
|
156
161
|
await _ensure_database(db_name)
|
|
157
162
|
await _apply(
|
|
158
163
|
config, db_name, force_reset,
|
|
159
|
-
areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas,
|
|
164
|
+
env=env, areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas,
|
|
160
165
|
)
|
|
161
166
|
|
|
162
167
|
asyncio.run(_run())
|
|
@@ -16,6 +16,7 @@ from psycopg.sql import SQL, Identifier, Placeholder
|
|
|
16
16
|
from ..areas import area_allowed, parse_areas
|
|
17
17
|
from ..db.complex_types import ComplexHelper
|
|
18
18
|
from ..dialect import Dialect, POSTGRES, SYSTEM_SCHEMAS
|
|
19
|
+
from ..envtag import env_allowed, strip_env_suffix
|
|
19
20
|
from ..parser import IGNORED_DIR_NAMES
|
|
20
21
|
from ..schemas import schema_allowed, sql_schemas
|
|
21
22
|
|
|
@@ -40,7 +41,7 @@ _TYPE_ORDER = {
|
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
def _get_type_order(path: Path) -> int:
|
|
43
|
-
filename = re.sub(r"^\d+(\.\d+)?", "", path
|
|
44
|
+
filename = re.sub(r"^\d+(\.\d+)?", "", strip_env_suffix(path)).removeprefix("_")
|
|
44
45
|
if filename in _TYPE_ORDER:
|
|
45
46
|
return _TYPE_ORDER[filename]
|
|
46
47
|
if path.parent.name in _TYPE_ORDER:
|
|
@@ -123,6 +124,7 @@ def _iter_sql_files(
|
|
|
123
124
|
database_dir: Path,
|
|
124
125
|
dialect: Dialect = POSTGRES,
|
|
125
126
|
*,
|
|
127
|
+
env: str = "local_test",
|
|
126
128
|
areas: frozenset[str] | None = None,
|
|
127
129
|
exclude_areas: frozenset[str] | None = None,
|
|
128
130
|
schemas: frozenset[str] | None = None,
|
|
@@ -130,6 +132,9 @@ def _iter_sql_files(
|
|
|
130
132
|
):
|
|
131
133
|
"""Yield (Path, sql_content) pairs in dependency-safe execution order.
|
|
132
134
|
|
|
135
|
+
A file tagged for another environment (see pgdevkit.envtag) is dropped
|
|
136
|
+
during the initial file walk, before dependency ordering even sees it.
|
|
137
|
+
|
|
133
138
|
A file dropped by the area/schema filter is skipped entirely -- as if it
|
|
134
139
|
didn't exist -- so it never delivers a dependency another (in-scope)
|
|
135
140
|
file waits on; that's the same tradeoff `list_migration_files`/
|
|
@@ -142,8 +147,9 @@ def _iter_sql_files(
|
|
|
142
147
|
for file in dbfiles:
|
|
143
148
|
if file in ("all.sql", "100_permissions.sql"):
|
|
144
149
|
continue
|
|
145
|
-
|
|
146
|
-
|
|
150
|
+
path = Path(root) / file
|
|
151
|
+
if file.endswith(".sql") and env_allowed(path, env):
|
|
152
|
+
files.append(path)
|
|
147
153
|
|
|
148
154
|
delivered: set[str] = set()
|
|
149
155
|
delayed: list[tuple[str | None, Path, str]] = []
|
|
@@ -160,7 +166,7 @@ def _iter_sql_files(
|
|
|
160
166
|
deps = _get_sql_deps(content, dialect)
|
|
161
167
|
if file.parent.name in _SCHEMA_QUALIFIED_TYPES:
|
|
162
168
|
schema = _strip_layer_prefix(file.parent.parent.name)
|
|
163
|
-
full_name = f"{schema}.{_strip_layer_prefix(file
|
|
169
|
+
full_name = f"{schema}.{_strip_layer_prefix(strip_env_suffix(file))}"
|
|
164
170
|
deps.discard(full_name) # the file's own CREATE target is not a real dependency
|
|
165
171
|
all_declared.add(full_name)
|
|
166
172
|
if not deps or all(d in delivered for d in deps):
|
|
@@ -261,6 +267,7 @@ async def apply_schema(
|
|
|
261
267
|
force_reset: bool = False,
|
|
262
268
|
*,
|
|
263
269
|
dialect: Dialect = POSTGRES,
|
|
270
|
+
env: str = "local_test",
|
|
264
271
|
areas: frozenset[str] | None = None,
|
|
265
272
|
exclude_areas: frozenset[str] | None = None,
|
|
266
273
|
schemas: frozenset[str] | None = None,
|
|
@@ -269,6 +276,9 @@ async def apply_schema(
|
|
|
269
276
|
"""Apply every .sql file under database_dir (in dependency-safe order)
|
|
270
277
|
and seed any matching .test_data.json files. Safe to call repeatedly.
|
|
271
278
|
|
|
279
|
+
A file tagged for another environment (e.g. `grants.prod.sql` when
|
|
280
|
+
`env="local_test"`) is skipped — see pgdevkit.envtag.
|
|
281
|
+
|
|
272
282
|
`migrations/` subdirectories are never applied here — they're for
|
|
273
283
|
one-time manual application against real (already-provisioned)
|
|
274
284
|
databases, not for building a fresh schema. The base object files under
|
|
@@ -286,16 +296,19 @@ async def apply_schema(
|
|
|
286
296
|
|
|
287
297
|
async def _apply(file: Path, sql: str) -> None:
|
|
288
298
|
await con.execute(cast(Any, sql))
|
|
289
|
-
|
|
299
|
+
table_stem = strip_env_suffix(file)
|
|
300
|
+
json_file = file.parent / f"{table_stem}.test_data.json"
|
|
290
301
|
if json_file.exists():
|
|
291
302
|
schema_name = _strip_layer_prefix(file.parent.parent.name)
|
|
292
|
-
|
|
293
|
-
|
|
303
|
+
await _insert_test_data(
|
|
304
|
+
json_file, f"{schema_name}.{_strip_layer_prefix(table_stem)}", force_reset, con, complex_helper
|
|
305
|
+
)
|
|
294
306
|
|
|
295
307
|
failures: list[tuple[Path, str]] = []
|
|
296
308
|
for file, sql in _iter_sql_files(
|
|
297
309
|
database_dir,
|
|
298
310
|
dialect,
|
|
311
|
+
env=env,
|
|
299
312
|
areas=areas,
|
|
300
313
|
exclude_areas=exclude_areas,
|
|
301
314
|
schemas=schemas,
|
|
@@ -253,7 +253,7 @@ sqlfmt --check db/queries/ # CI check
|
|
|
253
253
|
|
|
254
254
|
## The `database/` folder & backfilling untracked objects
|
|
255
255
|
|
|
256
|
-
See [docs/database-layout.md](../../docs/database-layout.md) for the full convention: layer directories, object-type subfolders and their apply order, file-naming rules (`.test_data.json`, `.init.sql`,
|
|
256
|
+
See [docs/database-layout.md](../../docs/database-layout.md) for the full convention: layer directories, object-type subfolders and their apply order, file-naming rules (`.test_data.json`, `.init.sql`, `.<env>.sql`), and how migrations are organised.
|
|
257
257
|
|
|
258
258
|
If a table, view, or function was created directly on the database and never got a `.sql` file:
|
|
259
259
|
|
|
@@ -285,6 +285,6 @@ Reports drift between the `database/` `.sql` files and the actual schema — tab
|
|
|
285
285
|
- [ ] All parameters use `%(name)s` style with a dict argument
|
|
286
286
|
- [ ] Results mapped to a Pydantic model; table-mapped models extend `PostgresTableModel`
|
|
287
287
|
- [ ] No `LATERAL JOIN` — use a CTE that groups/aggregates first, then joins it
|
|
288
|
-
- [ ]
|
|
288
|
+
- [ ] `.<env>.sql` files (e.g. `.prod.sql`) are skipped by `pgdb testdb` unless it's run with a matching `--env`
|
|
289
289
|
- [ ] Every table (and non-obvious column) has a `COMMENT ON`, placed in the object's own `.sql` file
|
|
290
290
|
- [ ] Untracked DB objects backfilled via `pgdb fetch-missing`, not left undocumented
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pgdevkit.envtag import env_allowed, file_env, strip_env_suffix
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestFileEnv:
|
|
9
|
+
def test_plain_sql_file_is_untagged(self):
|
|
10
|
+
assert file_env(Path("grants.sql")) is None
|
|
11
|
+
|
|
12
|
+
def test_init_sql_is_never_a_tag(self):
|
|
13
|
+
assert file_env(Path("user.init.sql")) is None
|
|
14
|
+
|
|
15
|
+
def test_prod_suffix_is_the_prod_tag(self):
|
|
16
|
+
assert file_env(Path("grants.prod.sql")) == "prod"
|
|
17
|
+
|
|
18
|
+
def test_arbitrary_env_name_is_a_tag(self):
|
|
19
|
+
assert file_env(Path("seed.staging.sql")) == "staging"
|
|
20
|
+
assert file_env(Path("seed.local_test.sql")) == "local_test"
|
|
21
|
+
|
|
22
|
+
def test_non_sql_file_is_never_tagged(self):
|
|
23
|
+
assert file_env(Path("widget.test_data.json")) is None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestEnvAllowed:
|
|
27
|
+
def test_no_env_requested_allows_everything(self):
|
|
28
|
+
assert env_allowed(Path("grants.prod.sql"), None) is True
|
|
29
|
+
assert env_allowed(Path("grants.sql"), None) is True
|
|
30
|
+
|
|
31
|
+
def test_untagged_file_always_allowed(self):
|
|
32
|
+
assert env_allowed(Path("grants.sql"), "prod") is True
|
|
33
|
+
assert env_allowed(Path("grants.sql"), "staging") is True
|
|
34
|
+
|
|
35
|
+
def test_matching_tag_allowed(self):
|
|
36
|
+
assert env_allowed(Path("grants.prod.sql"), "prod") is True
|
|
37
|
+
|
|
38
|
+
def test_mismatched_tag_disallowed(self):
|
|
39
|
+
assert env_allowed(Path("grants.prod.sql"), "staging") is False
|
|
40
|
+
assert env_allowed(Path("grants.prod.sql"), "local_test") is False
|
|
41
|
+
|
|
42
|
+
def test_init_file_always_allowed(self):
|
|
43
|
+
assert env_allowed(Path("user.init.sql"), "prod") is True
|
|
44
|
+
assert env_allowed(Path("user.init.sql"), "staging") is True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestStripEnvSuffix:
|
|
48
|
+
def test_untagged_file_unchanged(self):
|
|
49
|
+
assert strip_env_suffix(Path("grants.sql")) == "grants"
|
|
50
|
+
|
|
51
|
+
def test_tagged_file_matches_untagged_logical_name(self):
|
|
52
|
+
assert strip_env_suffix(Path("grants.prod.sql")) == "grants"
|
|
53
|
+
assert strip_env_suffix(Path("seed.local_test.sql")) == "seed"
|
|
54
|
+
|
|
55
|
+
def test_init_file_keeps_init_in_the_stem(self):
|
|
56
|
+
assert strip_env_suffix(Path("user.init.sql")) == "user.init"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pgdevkit.migrate import list_migration_files
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _write(dir: Path, name: str, content: str = "select 1;\n") -> Path:
|
|
9
|
+
p = dir / name
|
|
10
|
+
p.write_text(content, encoding="utf-8")
|
|
11
|
+
return p
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestListMigrationFilesEnvFiltering:
|
|
15
|
+
def test_no_env_applies_every_file_regardless_of_tag(self, tmp_path: Path):
|
|
16
|
+
prod = _write(tmp_path, "001_prod.prod.sql")
|
|
17
|
+
common = _write(tmp_path, "002_common.sql")
|
|
18
|
+
|
|
19
|
+
assert list_migration_files(tmp_path) == sorted([prod, common])
|
|
20
|
+
|
|
21
|
+
def test_env_keeps_matching_tag_and_untagged(self, tmp_path: Path):
|
|
22
|
+
prod = _write(tmp_path, "001_backfill.prod.sql")
|
|
23
|
+
staging = _write(tmp_path, "002_backfill.staging.sql")
|
|
24
|
+
common = _write(tmp_path, "003_common.sql")
|
|
25
|
+
|
|
26
|
+
result = list_migration_files(tmp_path, env="prod")
|
|
27
|
+
assert set(result) == {prod, common}
|
|
28
|
+
assert staging not in result
|
|
29
|
+
|
|
30
|
+
def test_init_file_is_never_filtered_by_env(self, tmp_path: Path):
|
|
31
|
+
init = _write(tmp_path, "001_setup.init.sql")
|
|
32
|
+
|
|
33
|
+
assert list_migration_files(tmp_path, env="prod") == [init]
|
|
34
|
+
assert list_migration_files(tmp_path, env="staging") == [init]
|
|
35
|
+
|
|
36
|
+
def test_env_and_area_filters_compose(self, tmp_path: Path):
|
|
37
|
+
billing_prod = _write(tmp_path, "001_billing.prod.sql", "-- area: billing\nselect 1;\n")
|
|
38
|
+
billing_staging = _write(tmp_path, "002_billing.staging.sql", "-- area: billing\nselect 1;\n")
|
|
39
|
+
reporting_prod = _write(tmp_path, "003_reporting.prod.sql", "-- area: reporting\nselect 1;\n")
|
|
40
|
+
|
|
41
|
+
result = list_migration_files(tmp_path, areas=frozenset({"billing"}), env="prod")
|
|
42
|
+
assert result == [billing_prod]
|
|
43
|
+
assert billing_staging not in result
|
|
44
|
+
assert reporting_prod not in result
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE app.widget ADD COLUMN IF NOT EXISTS init_flag boolean NOT NULL DEFAULT true;
|
|
@@ -114,6 +114,43 @@ async def test_apply_schema_seeds_composite_enum_and_jsonb_columns(schema_test_d
|
|
|
114
114
|
assert tags == ["small", "shiny"]
|
|
115
115
|
|
|
116
116
|
|
|
117
|
+
@requires_podman
|
|
118
|
+
async def test_apply_schema_skips_env_tagged_file_for_a_different_env(schema_test_db):
|
|
119
|
+
# prod_only.prod.sql is tagged for "prod" -- with the default env
|
|
120
|
+
# ("local_test"), it must not be applied.
|
|
121
|
+
async with await psycopg.AsyncConnection.connect(_db_dsn(), autocommit=True) as con:
|
|
122
|
+
await apply_schema(con, FIXTURES)
|
|
123
|
+
async with con.cursor() as cur:
|
|
124
|
+
await cur.execute("SELECT to_regclass('app.prod_only')")
|
|
125
|
+
(regclass,) = await cur.fetchone()
|
|
126
|
+
assert regclass is None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@requires_podman
|
|
130
|
+
async def test_apply_schema_applies_env_tagged_file_for_the_matching_env(schema_test_db):
|
|
131
|
+
async with await psycopg.AsyncConnection.connect(_db_dsn(), autocommit=True) as con:
|
|
132
|
+
await apply_schema(con, FIXTURES, env="prod")
|
|
133
|
+
async with con.cursor() as cur:
|
|
134
|
+
await cur.execute("SELECT to_regclass('app.prod_only')")
|
|
135
|
+
(regclass,) = await cur.fetchone()
|
|
136
|
+
assert regclass is not None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@requires_podman
|
|
140
|
+
async def test_apply_schema_init_file_applies_regardless_of_env(schema_test_db):
|
|
141
|
+
# widget.init.sql adds init_flag to app.widget -- "init" is a reserved
|
|
142
|
+
# suffix, never an environment tag, so this must apply under any env.
|
|
143
|
+
async with await psycopg.AsyncConnection.connect(_db_dsn(), autocommit=True) as con:
|
|
144
|
+
await apply_schema(con, FIXTURES, env="staging")
|
|
145
|
+
async with con.cursor() as cur:
|
|
146
|
+
await cur.execute(
|
|
147
|
+
"SELECT column_name FROM information_schema.columns "
|
|
148
|
+
"WHERE table_schema = 'app' AND table_name = 'widget' AND column_name = 'init_flag'"
|
|
149
|
+
)
|
|
150
|
+
row = await cur.fetchone()
|
|
151
|
+
assert row is not None
|
|
152
|
+
|
|
153
|
+
|
|
117
154
|
@requires_podman
|
|
118
155
|
async def test_apply_schema_never_applies_migrations_dir(schema_test_db):
|
|
119
156
|
# migrations/ is for one-time manual application against real databases,
|
|
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.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json
RENAMED
|
File without changes
|
|
File without changes
|
{pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json
RENAMED
|
File without changes
|
|
File without changes
|
{pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql
RENAMED
|
File without changes
|
|
File without changes
|
{pgdevkit-0.5.0 → pgdevkit-0.6.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql
RENAMED
|
File without changes
|
{pgdevkit-0.5.0 → pgdevkit-0.6.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
|