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.
Files changed (127) hide show
  1. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/PKG-INFO +29 -2
  2. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/README.md +28 -1
  3. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/docs/database-layout.md +22 -3
  4. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/cli.py +20 -3
  5. pgdevkit-0.6.1/pgdevkit/envtag.py +54 -0
  6. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/migrate.py +16 -2
  7. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/api.py +10 -3
  8. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/mssql/api.py +10 -5
  9. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/schema.py +43 -7
  10. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pyproject.toml +1 -1
  11. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/skills/pgdevkit/SKILL.md +2 -2
  12. pgdevkit-0.6.1/tests/test_envtag.py +56 -0
  13. pgdevkit-0.6.1/tests/test_migrate_env.py +44 -0
  14. pgdevkit-0.6.1/tests/testdb/fixtures/database/app/tables/event.sql +10 -0
  15. pgdevkit-0.6.1/tests/testdb/fixtures/database/app/tables/event_kind.sql +4 -0
  16. pgdevkit-0.6.1/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +3 -0
  17. pgdevkit-0.6.1/tests/testdb/fixtures/database/app/tables/widget.init.sql +1 -0
  18. pgdevkit-0.6.1/tests/testdb/fixtures/database/permissions/grants.sql +18 -0
  19. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_schema.py +65 -0
  20. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/uv.lock +1 -1
  21. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/.github/workflows/auto-release.yml +0 -0
  22. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/.github/workflows/python-publish.yml +0 -0
  23. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/.github/workflows/python-test.yml +0 -0
  24. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/.gitignore +0 -0
  25. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/.python-version +0 -0
  26. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/__init__.py +0 -0
  27. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/areas.py +0 -0
  28. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/backends/__init__.py +0 -0
  29. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/backends/base.py +0 -0
  30. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/backends/mssql.py +0 -0
  31. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/backends/postgres.py +0 -0
  32. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/connection.py +0 -0
  33. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/db/__init__.py +0 -0
  34. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/db/complex_types.py +0 -0
  35. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/db/connection.py +0 -0
  36. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/db/crud.py +0 -0
  37. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/db/loader.py +0 -0
  38. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/db/model.py +0 -0
  39. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/db/mssql_crud.py +0 -0
  40. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/db/mssql_sql.py +0 -0
  41. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/dialect.py +0 -0
  42. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/diff.py +0 -0
  43. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/fetch_missing.py +0 -0
  44. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/introspect.py +0 -0
  45. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/lakebase.py +0 -0
  46. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/models.py +0 -0
  47. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/mssql_introspect.py +0 -0
  48. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/parser.py +0 -0
  49. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/schemas.py +0 -0
  50. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/sql_text.py +0 -0
  51. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/__init__.py +0 -0
  52. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/_docker.py +0 -0
  53. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/config.py +0 -0
  54. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/constants.py +0 -0
  55. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/container.py +0 -0
  56. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/mssql/__init__.py +0 -0
  57. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/mssql/constants.py +0 -0
  58. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/mssql/container.py +0 -0
  59. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/naming.py +0 -0
  60. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/pgdevkit/testdb/query.py +0 -0
  61. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/skills/pgdevkit/references/dynamic-sql.md +0 -0
  62. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/skills/pgdevkit/references/temporal-tables.md +0 -0
  63. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/__init__.py +0 -0
  64. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/conftest.py +0 -0
  65. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/db/__init__.py +0 -0
  66. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/db/test_complex_types.py +0 -0
  67. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/db/test_connection.py +0 -0
  68. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/db/test_crud.py +0 -0
  69. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/db/test_loader.py +0 -0
  70. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/db/test_mssql_crud_live.py +0 -0
  71. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/db/test_mssql_crud_sql.py +0 -0
  72. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/fixtures/01_schema.sql +0 -0
  73. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/fixtures/02_types.sql +0 -0
  74. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/fixtures/03_tables.sql +0 -0
  75. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/fixtures/04_views.sql +0 -0
  76. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/fixtures/05_functions.sql +0 -0
  77. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/fixtures/06_indexes.sql +0 -0
  78. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_areas.py +0 -0
  79. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_cli_compare.py +0 -0
  80. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_compare.py +0 -0
  81. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_compare_mssql_live.py +0 -0
  82. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_connection.py +0 -0
  83. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_dialect.py +0 -0
  84. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_diff_mssql.py +0 -0
  85. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_fetch_missing.py +0 -0
  86. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_fetch_missing_cli.py +0 -0
  87. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_lakebase.py +0 -0
  88. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_migrate.py +0 -0
  89. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_migrate_areas.py +0 -0
  90. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_migrate_schemas.py +0 -0
  91. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_mssql_introspect.py +0 -0
  92. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_parser_areas.py +0 -0
  93. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_parser_mssql.py +0 -0
  94. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_parser_schemas.py +0 -0
  95. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/test_schemas.py +0 -0
  96. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/__init__.py +0 -0
  97. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/conftest.py +0 -0
  98. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  99. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  100. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  101. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  102. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  103. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  104. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  105. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  106. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  107. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  108. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  109. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  110. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
  111. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
  112. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
  113. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
  114. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
  115. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_api.py +0 -0
  116. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_api_mssql.py +0 -0
  117. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_api_mssql_live.py +0 -0
  118. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_cli.py +0 -0
  119. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_config.py +0 -0
  120. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_config_mssql.py +0 -0
  121. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_constants.py +0 -0
  122. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_container.py +0 -0
  123. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_mssql_constants.py +0 -0
  124. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_naming.py +0 -0
  125. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_query.py +0 -0
  126. {pgdevkit-0.5.0 → pgdevkit-0.6.1}/tests/testdb/test_schema_filtering.py +0 -0
  127. {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.5.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>.prod.sql` / `.prod` anywhere in the name | Production-only (real permission grants, real user accounts) — skipped by `pgdb testdb` |
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
- - [ ] `.prod` files are production-only and skipped by `pgdb testdb`
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
- return filter_by_schema(files, only=schemas, exclude=exclude_schemas)
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, areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas
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
- json_file = file.with_suffix(".test_data.json")
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
- table_stem = _strip_layer_prefix(file.stem)
136
- await _insert_test_data(json_file, f"{schema_name}.{table_stem}", force_reset, conn)
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.name).removeprefix("_").removesuffix(".sql")
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
- if file.endswith(".sql") and ".prod" not in file:
146
- files.append(Path(root) / file)
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.stem)}"
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
- json_file = file.with_suffix(".test_data.json")
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
- table_stem = _strip_layer_prefix(file.stem)
293
- await _insert_test_data(json_file, f"{schema_name}.{table_stem}", force_reset, con, complex_helper)
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,
@@ -11,7 +11,7 @@ packages = ["pgdevkit"]
11
11
 
12
12
  [project]
13
13
  name = "pgdevkit"
14
- version = "0.5.0"
14
+ version = "0.6.1"
15
15
  description = "A helper for developing with Postgres"
16
16
  readme = "README.md"
17
17
  requires-python = ">=3.14"
@@ -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`, `.prod`), and how migrations are organised.
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
- - [ ] `.prod` files are production-only and skipped by `pgdb testdb`
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"