pgdevkit 0.5.0__tar.gz → 0.6.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.5.0 → pgdevkit-0.6.1}/PKG-INFO +29 -2
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/README.md +28 -1
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/docs/database-layout.md +22 -3
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/cli.py +20 -3
- pgdevkit-0.6.1/pgdevkit/envtag.py +54 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/migrate.py +16 -2
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/api.py +10 -3
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/mssql/api.py +10 -5
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/schema.py +43 -7
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pyproject.toml +1 -1
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/skills/pgdevkit/SKILL.md +2 -2
- pgdevkit-0.6.1/tests/test_envtag.py +56 -0
- pgdevkit-0.6.1/tests/test_migrate_env.py +44 -0
- pgdevkit-0.6.1/tests/testdb/fixtures/database/app/tables/event.sql +10 -0
- pgdevkit-0.6.1/tests/testdb/fixtures/database/app/tables/event_kind.sql +4 -0
- pgdevkit-0.6.1/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +3 -0
- pgdevkit-0.6.1/tests/testdb/fixtures/database/app/tables/widget.init.sql +1 -0
- pgdevkit-0.6.1/tests/testdb/fixtures/database/permissions/grants.sql +18 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_schema.py +65 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/uv.lock +1 -1
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/.github/workflows/auto-release.yml +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/.github/workflows/python-test.yml +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/.gitignore +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/.python-version +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/areas.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/backends/__init__.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/backends/base.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/backends/mssql.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/backends/postgres.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/connection.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/db/__init__.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/db/complex_types.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/db/connection.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/db/crud.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/db/mssql_crud.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/db/mssql_sql.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/dialect.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/fetch_missing.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/models.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/mssql_introspect.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/parser.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/schemas.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/sql_text.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/__init__.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/_docker.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/config.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/constants.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/container.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/mssql/__init__.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/mssql/constants.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/mssql/container.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/naming.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/skills/pgdevkit/references/dynamic-sql.md +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/__init__.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/conftest.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/db/__init__.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/db/test_complex_types.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/db/test_connection.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/db/test_crud.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/db/test_mssql_crud_live.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/db/test_mssql_crud_sql.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_areas.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_compare.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_compare_mssql_live.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_connection.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_dialect.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_diff_mssql.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_fetch_missing.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_fetch_missing_cli.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_migrate.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_migrate_areas.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_migrate_schemas.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_mssql_introspect.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_parser_areas.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_parser_mssql.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_parser_schemas.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_schemas.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/conftest.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_api.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_api_mssql.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_api_mssql_live.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_cli.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_config.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_config_mssql.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_container.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_mssql_constants.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_naming.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_query.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_schema_filtering.py +0 -0
- {pgdevkit-0.5.0 → pgdevkit-0.6.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.6.1
|
|
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
|
|
@@ -63,6 +63,17 @@ both together.
|
|
|
63
63
|
One object per file: `tables/user.sql`, `views/all_edits.sql`,
|
|
64
64
|
`types/measurement_unit.sql`.
|
|
65
65
|
|
|
66
|
+
**`permissions` files always apply last, genuinely.** A blanket `GRANT ...
|
|
67
|
+
ON ALL TABLES IN SCHEMA x TO role;` only covers what exists at the moment it
|
|
68
|
+
runs — so if a table with a cross-file dependency that isn't resolved on the
|
|
69
|
+
first pass (e.g. an FK to a table in another layer directory whose filename
|
|
70
|
+
happens to sort later) got created *after* a `permissions` file, that grant
|
|
71
|
+
would silently never cover it. `pgdb testdb` holds every `permissions` file
|
|
72
|
+
back unconditionally and applies it only once the whole rest of the tree
|
|
73
|
+
(delayed-retry resolution included) has actually finished — so write
|
|
74
|
+
`permissions` files as if every table/view they reference is guaranteed to
|
|
75
|
+
already exist, because it is.
|
|
76
|
+
|
|
66
77
|
---
|
|
67
78
|
|
|
68
79
|
## File-naming conventions
|
|
@@ -71,10 +82,18 @@ One object per file: `tables/user.sql`, `views/all_edits.sql`,
|
|
|
71
82
|
|---|---|
|
|
72
83
|
| `<name>.sql` | The object's live definition (`CREATE TABLE`, `CREATE OR REPLACE VIEW`, ...) |
|
|
73
84
|
| `<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>.
|
|
85
|
+
| `<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 |
|
|
86
|
+
| `<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
87
|
| `all.sql` | Generated concatenation of the whole tree — not hand-edited, not committed |
|
|
77
88
|
|
|
89
|
+
`pgdb testdb up`/`pgdb testdb reset` apply the `--env` they're given (default
|
|
90
|
+
`local_test`) — so an untagged `grants.sql` always applies, but
|
|
91
|
+
`grants.prod.sql` is skipped unless you pass `--env prod`. `pgdb migrate
|
|
92
|
+
check`/`pgdb migrate apply` accept the same `--env`, but it's optional with no
|
|
93
|
+
default: omit it and every file is a candidate regardless of its tag (today's
|
|
94
|
+
behavior); pass it to restrict to files tagged for that environment plus
|
|
95
|
+
untagged ones.
|
|
96
|
+
|
|
78
97
|
---
|
|
79
98
|
|
|
80
99
|
## Migrations
|
|
@@ -156,5 +175,5 @@ leading sort number.
|
|
|
156
175
|
- [ ] Object-type folder (`tables`, `views`, ...) matches the apply-order table above — that's what governs ordering, not the layer's leading number
|
|
157
176
|
- [ ] One-off changes go in `migrations/`, dated, never edited after applying
|
|
158
177
|
- [ ] The live `.sql` file is updated in the same change as any migration touching that object
|
|
159
|
-
- [ ]
|
|
178
|
+
- [ ] `.<env>.sql` files (e.g. `.prod.sql`) are skipped by `pgdb testdb` unless it's run with a matching `--env`
|
|
160
179
|
- [ ] 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,22 @@ def _iter_sql_files(
|
|
|
130
132
|
):
|
|
131
133
|
"""Yield (Path, sql_content) pairs in dependency-safe execution order.
|
|
132
134
|
|
|
135
|
+
Every `permissions`-type file (see `_TYPE_ORDER`) is held back
|
|
136
|
+
unconditionally and yielded only after every other file -- including
|
|
137
|
+
anything resolved via the delayed-retry loop below -- has actually been
|
|
138
|
+
delivered. A blanket "grant ... on all tables in schema X" has no real
|
|
139
|
+
per-object dependency of its own to track, but it still must apply
|
|
140
|
+
*after* every table/view it's meant to cover; a table whose own creation
|
|
141
|
+
got deferred to the retry loop (e.g. an FK to a same-type file that
|
|
142
|
+
happens to sort later) would otherwise silently miss that grant, since
|
|
143
|
+
a schema-wide GRANT is a one-time snapshot of whatever exists when it
|
|
144
|
+
runs. Nothing ever legitimately depends on a permissions file (it
|
|
145
|
+
creates no table/view/schema of its own), so holding every one back is
|
|
146
|
+
always safe.
|
|
147
|
+
|
|
148
|
+
A file tagged for another environment (see pgdevkit.envtag) is dropped
|
|
149
|
+
during the initial file walk, before dependency ordering even sees it.
|
|
150
|
+
|
|
133
151
|
A file dropped by the area/schema filter is skipped entirely -- as if it
|
|
134
152
|
didn't exist -- so it never delivers a dependency another (in-scope)
|
|
135
153
|
file waits on; that's the same tradeoff `list_migration_files`/
|
|
@@ -142,12 +160,14 @@ def _iter_sql_files(
|
|
|
142
160
|
for file in dbfiles:
|
|
143
161
|
if file in ("all.sql", "100_permissions.sql"):
|
|
144
162
|
continue
|
|
145
|
-
|
|
146
|
-
|
|
163
|
+
path = Path(root) / file
|
|
164
|
+
if file.endswith(".sql") and env_allowed(path, env):
|
|
165
|
+
files.append(path)
|
|
147
166
|
|
|
148
167
|
delivered: set[str] = set()
|
|
149
168
|
delayed: list[tuple[str | None, Path, str]] = []
|
|
150
169
|
all_declared: set[str] = set()
|
|
170
|
+
permissions_files: list[tuple[Path, str]] = []
|
|
151
171
|
|
|
152
172
|
for file in sorted(files, key=lambda p: (_get_type_order(p), p.name)):
|
|
153
173
|
content = file.read_text(encoding="utf-8")
|
|
@@ -157,10 +177,13 @@ def _iter_sql_files(
|
|
|
157
177
|
sql_schemas(content, dialect), only=schemas, exclude=exclude_schemas
|
|
158
178
|
):
|
|
159
179
|
continue
|
|
180
|
+
if _get_type_order(file) == _TYPE_ORDER["permissions"]:
|
|
181
|
+
permissions_files.append((file, content))
|
|
182
|
+
continue
|
|
160
183
|
deps = _get_sql_deps(content, dialect)
|
|
161
184
|
if file.parent.name in _SCHEMA_QUALIFIED_TYPES:
|
|
162
185
|
schema = _strip_layer_prefix(file.parent.parent.name)
|
|
163
|
-
full_name = f"{schema}.{_strip_layer_prefix(file
|
|
186
|
+
full_name = f"{schema}.{_strip_layer_prefix(strip_env_suffix(file))}"
|
|
164
187
|
deps.discard(full_name) # the file's own CREATE target is not a real dependency
|
|
165
188
|
all_declared.add(full_name)
|
|
166
189
|
if not deps or all(d in delivered for d in deps):
|
|
@@ -190,6 +213,8 @@ def _iter_sql_files(
|
|
|
190
213
|
if not progressed:
|
|
191
214
|
raise ValueError(f"Circular or missing SQL dependencies: {[f[1] for f in delayed]}")
|
|
192
215
|
|
|
216
|
+
yield from permissions_files
|
|
217
|
+
|
|
193
218
|
|
|
194
219
|
async def _insert_test_data(
|
|
195
220
|
json_file: Path,
|
|
@@ -261,6 +286,7 @@ async def apply_schema(
|
|
|
261
286
|
force_reset: bool = False,
|
|
262
287
|
*,
|
|
263
288
|
dialect: Dialect = POSTGRES,
|
|
289
|
+
env: str = "local_test",
|
|
264
290
|
areas: frozenset[str] | None = None,
|
|
265
291
|
exclude_areas: frozenset[str] | None = None,
|
|
266
292
|
schemas: frozenset[str] | None = None,
|
|
@@ -269,6 +295,13 @@ async def apply_schema(
|
|
|
269
295
|
"""Apply every .sql file under database_dir (in dependency-safe order)
|
|
270
296
|
and seed any matching .test_data.json files. Safe to call repeatedly.
|
|
271
297
|
|
|
298
|
+
Every `permissions`-type file (see `_TYPE_ORDER`) is guaranteed to apply
|
|
299
|
+
after every table/view it's meant to cover, including anything resolved
|
|
300
|
+
via delayed-retry -- see `_iter_sql_files()`'s docstring.
|
|
301
|
+
|
|
302
|
+
A file tagged for another environment (e.g. `grants.prod.sql` when
|
|
303
|
+
`env="local_test"`) is skipped — see pgdevkit.envtag.
|
|
304
|
+
|
|
272
305
|
`migrations/` subdirectories are never applied here — they're for
|
|
273
306
|
one-time manual application against real (already-provisioned)
|
|
274
307
|
databases, not for building a fresh schema. The base object files under
|
|
@@ -286,16 +319,19 @@ async def apply_schema(
|
|
|
286
319
|
|
|
287
320
|
async def _apply(file: Path, sql: str) -> None:
|
|
288
321
|
await con.execute(cast(Any, sql))
|
|
289
|
-
|
|
322
|
+
table_stem = strip_env_suffix(file)
|
|
323
|
+
json_file = file.parent / f"{table_stem}.test_data.json"
|
|
290
324
|
if json_file.exists():
|
|
291
325
|
schema_name = _strip_layer_prefix(file.parent.parent.name)
|
|
292
|
-
|
|
293
|
-
|
|
326
|
+
await _insert_test_data(
|
|
327
|
+
json_file, f"{schema_name}.{_strip_layer_prefix(table_stem)}", force_reset, con, complex_helper
|
|
328
|
+
)
|
|
294
329
|
|
|
295
330
|
failures: list[tuple[Path, str]] = []
|
|
296
331
|
for file, sql in _iter_sql_files(
|
|
297
332
|
database_dir,
|
|
298
333
|
dialect,
|
|
334
|
+
env=env,
|
|
299
335
|
areas=areas,
|
|
300
336
|
exclude_areas=exclude_areas,
|
|
301
337
|
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"
|