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