pgdevkit 0.3.8__tar.gz → 0.5.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 (119) hide show
  1. pgdevkit-0.5.0/.github/workflows/auto-release.yml +83 -0
  2. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/PKG-INFO +122 -1
  3. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/README.md +121 -0
  4. pgdevkit-0.5.0/pgdevkit/areas.py +64 -0
  5. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/cli.py +97 -9
  6. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/dialect.py +10 -0
  7. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/fetch_missing.py +6 -1
  8. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/migrate.py +31 -55
  9. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/parser.py +35 -6
  10. pgdevkit-0.5.0/pgdevkit/schemas.py +118 -0
  11. pgdevkit-0.5.0/pgdevkit/sql_text.py +56 -0
  12. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/api.py +49 -7
  13. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/mssql/api.py +28 -4
  14. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/schema.py +52 -17
  15. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pyproject.toml +1 -1
  16. pgdevkit-0.5.0/tests/test_areas.py +106 -0
  17. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_migrate.py +2 -2
  18. pgdevkit-0.5.0/tests/test_migrate_areas.py +41 -0
  19. pgdevkit-0.5.0/tests/test_migrate_schemas.py +52 -0
  20. pgdevkit-0.5.0/tests/test_parser_areas.py +34 -0
  21. pgdevkit-0.5.0/tests/test_parser_schemas.py +98 -0
  22. pgdevkit-0.5.0/tests/test_schemas.py +156 -0
  23. pgdevkit-0.5.0/tests/testdb/test_schema_filtering.py +115 -0
  24. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/uv.lock +1 -1
  25. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/.github/workflows/python-publish.yml +0 -0
  26. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/.github/workflows/python-test.yml +0 -0
  27. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/.gitignore +0 -0
  28. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/.python-version +0 -0
  29. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/docs/database-layout.md +0 -0
  30. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/__init__.py +0 -0
  31. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/backends/__init__.py +0 -0
  32. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/backends/base.py +0 -0
  33. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/backends/mssql.py +0 -0
  34. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/backends/postgres.py +0 -0
  35. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/connection.py +0 -0
  36. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/db/__init__.py +0 -0
  37. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/db/complex_types.py +0 -0
  38. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/db/connection.py +0 -0
  39. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/db/crud.py +0 -0
  40. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/db/loader.py +0 -0
  41. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/db/model.py +0 -0
  42. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/db/mssql_crud.py +0 -0
  43. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/db/mssql_sql.py +0 -0
  44. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/diff.py +0 -0
  45. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/introspect.py +0 -0
  46. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/lakebase.py +0 -0
  47. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/models.py +0 -0
  48. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/mssql_introspect.py +0 -0
  49. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/__init__.py +0 -0
  50. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/_docker.py +0 -0
  51. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/config.py +0 -0
  52. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/constants.py +0 -0
  53. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/container.py +0 -0
  54. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/mssql/__init__.py +0 -0
  55. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/mssql/constants.py +0 -0
  56. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/mssql/container.py +0 -0
  57. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/naming.py +0 -0
  58. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/query.py +0 -0
  59. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/skills/pgdevkit/SKILL.md +0 -0
  60. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/skills/pgdevkit/references/dynamic-sql.md +0 -0
  61. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
  62. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/__init__.py +0 -0
  63. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/conftest.py +0 -0
  64. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/db/__init__.py +0 -0
  65. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/db/test_complex_types.py +0 -0
  66. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/db/test_connection.py +0 -0
  67. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/db/test_crud.py +0 -0
  68. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/db/test_loader.py +0 -0
  69. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/db/test_mssql_crud_live.py +0 -0
  70. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/db/test_mssql_crud_sql.py +0 -0
  71. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/fixtures/01_schema.sql +0 -0
  72. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/fixtures/02_types.sql +0 -0
  73. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/fixtures/03_tables.sql +0 -0
  74. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/fixtures/04_views.sql +0 -0
  75. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/fixtures/05_functions.sql +0 -0
  76. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/fixtures/06_indexes.sql +0 -0
  77. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_cli_compare.py +0 -0
  78. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_compare.py +0 -0
  79. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_compare_mssql_live.py +0 -0
  80. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_connection.py +0 -0
  81. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_dialect.py +0 -0
  82. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_diff_mssql.py +0 -0
  83. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_fetch_missing.py +0 -0
  84. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_fetch_missing_cli.py +0 -0
  85. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_lakebase.py +0 -0
  86. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_mssql_introspect.py +0 -0
  87. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_parser_mssql.py +0 -0
  88. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/__init__.py +0 -0
  89. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/conftest.py +0 -0
  90. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  91. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  92. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  93. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  94. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  95. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  96. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  97. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  98. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  99. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  100. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  101. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  102. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
  103. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
  104. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
  105. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
  106. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
  107. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_api.py +0 -0
  108. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_api_mssql.py +0 -0
  109. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_api_mssql_live.py +0 -0
  110. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_cli.py +0 -0
  111. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_config.py +0 -0
  112. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_config_mssql.py +0 -0
  113. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_constants.py +0 -0
  114. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_container.py +0 -0
  115. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_mssql_constants.py +0 -0
  116. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_naming.py +0 -0
  117. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_query.py +0 -0
  118. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_schema.py +0 -0
  119. {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_schema_mssql.py +0 -0
@@ -0,0 +1,83 @@
1
+ # Auto-tags, cuts a GitHub Release, and dispatches a PyPI publish whenever
2
+ # pyproject.toml's version changes on main and the Python Test workflow has
3
+ # passed for that commit. No manual "cut a release" step needed.
4
+ #
5
+ # Two GitHub Actions quirks shaped this design -- both hit and confirmed the
6
+ # hard way, so read before "simplifying" this:
7
+ #
8
+ # 1. GitHub Actions doesn't fire other workflows' triggers (release:
9
+ # published included) for events performed with the automatic
10
+ # GITHUB_TOKEN, to prevent recursive runs. So creating the release here
11
+ # would NOT, on its own, trigger python-publish.yml's `release: published`
12
+ # listener. workflow_dispatch is the documented exception to that rule --
13
+ # an API-triggered workflow_dispatch DOES start a new run even when
14
+ # triggered by GITHUB_TOKEN -- so this dispatches python-publish.yml
15
+ # directly instead of relying on the release event to cascade.
16
+ #
17
+ # 2. The obvious alternative -- calling python-publish.yml's job directly via
18
+ # workflow_call instead of dispatching it as a separate run -- avoids (1)
19
+ # entirely, but breaks PyPI's OIDC trusted publishing: PyPI explicitly
20
+ # does not support trusted publishing from reusable/called workflows (the
21
+ # token's claims show a "reusable workflow" parent chain that PyPI's
22
+ # trusted-publisher matching rejects). So python-publish.yml must stay a
23
+ # plain, directly-triggered top-level workflow -- no workflow_call trigger
24
+ # on it, and nothing here should invoke it as `uses:`.
25
+
26
+ name: Auto Release
27
+
28
+ on:
29
+ workflow_run:
30
+ workflows: ["Python Test"]
31
+ types: [completed]
32
+
33
+ permissions:
34
+ contents: read
35
+
36
+ jobs:
37
+ release:
38
+ if: >
39
+ github.event.workflow_run.conclusion == 'success' &&
40
+ github.event.workflow_run.event == 'push' &&
41
+ github.event.workflow_run.head_branch == 'main'
42
+ runs-on: ubuntu-latest
43
+ permissions:
44
+ contents: write
45
+ actions: write
46
+ steps:
47
+ - uses: actions/checkout@v4
48
+ with:
49
+ ref: ${{ github.event.workflow_run.head_sha }}
50
+
51
+ - name: Read version from pyproject.toml
52
+ id: version
53
+ run: |
54
+ version=$(grep -m1 '^version = ' pyproject.toml | sed -E 's/version = "([^"]+)"/\1/')
55
+ echo "version=$version" >> "$GITHUB_OUTPUT"
56
+
57
+ - name: Skip if this version is already on PyPI
58
+ id: check
59
+ run: |
60
+ version="${{ steps.version.outputs.version }}"
61
+ if curl -fsS https://pypi.org/pypi/pgdevkit/json | jq -e --arg v "$version" '.releases[$v] != null' >/dev/null; then
62
+ echo "published=true" >> "$GITHUB_OUTPUT"
63
+ else
64
+ echo "published=false" >> "$GITHUB_OUTPUT"
65
+ fi
66
+
67
+ - name: Create tag and GitHub release, if missing
68
+ if: steps.check.outputs.published == 'false'
69
+ env:
70
+ GH_TOKEN: ${{ github.token }}
71
+ run: |
72
+ if ! gh release view "v${{ steps.version.outputs.version }}" >/dev/null 2>&1; then
73
+ gh release create "v${{ steps.version.outputs.version }}" \
74
+ --title "v${{ steps.version.outputs.version }}" \
75
+ --target "${{ github.event.workflow_run.head_sha }}" \
76
+ --generate-notes
77
+ fi
78
+
79
+ - name: Dispatch PyPI publish
80
+ if: steps.check.outputs.published == 'false'
81
+ env:
82
+ GH_TOKEN: ${{ github.token }}
83
+ run: gh workflow run python-publish.yml --ref main
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pgdevkit
3
- Version: 0.3.8
3
+ Version: 0.5.0
4
4
  Summary: A helper for developing with Postgres
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: docker>=7.1.0
@@ -79,6 +79,94 @@ which parses/introspects/diffs like any other column type; see
79
79
  handled on the CRUD side (write-side serialization only, no auto-parsing on
80
80
  read — `mssql-python` doesn't distinguish `json` columns from `nvarchar`).
81
81
 
82
+ ### Area and schema filtering
83
+
84
+ Two independent, composable ways to narrow which files a command touches:
85
+ **area** is an explicit opt-in tag; **schema** is derived automatically from
86
+ each file's own SQL.
87
+
88
+ #### Area tagging
89
+
90
+ Any migration file or `database/` code file can declare one or more areas by
91
+ starting with a `-- area:` comment:
92
+
93
+ ```sql
94
+ -- area: billing
95
+ CREATE TABLE billing.invoices (id int primary key);
96
+ ```
97
+
98
+ A file can declare more than one area, either comma-separated on one line
99
+ (`-- area: billing, reporting`) or across several `-- area:` lines — the
100
+ declared areas union. The directive is only recognized in the file's leading
101
+ comment block (blank lines and `--` comments at the very top, stopping at the
102
+ first real statement); a `-- area:` comment later in the file doesn't count.
103
+ A file with no directive is untagged, and untagged files are treated as
104
+ shared/common.
105
+
106
+ #### Schema filtering
107
+
108
+ No tag needed — schema membership is parsed straight out of the SQL itself:
109
+ every schema-qualified (or default-schema, when unqualified) table/view/
110
+ function/index reference across every statement in the file, DDL or DML
111
+ alike, plus any `CREATE SCHEMA name`. A file whose schema(s) can't be
112
+ determined (unparseable content, or no table/schema reference in it at all)
113
+ is treated the same as an untagged file — always kept.
114
+
115
+ #### Options
116
+
117
+ `pgdb compare`, `pgdb migrate check`, `pgdb migrate apply`, `pgdb testdb up`,
118
+ and `pgdb testdb reset` all accept:
119
+
120
+ - `--area NAME` (repeatable) — restrict to files declaring one of the given
121
+ areas, **plus every untagged file** (untagged files always stay in scope).
122
+ - `--exclude-area NAME` (repeatable) — drop files declaring one of the given
123
+ areas; untagged files are never dropped by this.
124
+ - `--schema NAME` (repeatable) — restrict to files referencing one of the
125
+ given schemas, **plus every file with no detectable schema reference**.
126
+ - `--exclude-schema NAME` (repeatable) — drop files referencing one of the
127
+ given schemas; files with no detectable reference are never dropped.
128
+
129
+ All four can be combined — a file must pass every filter it's subject to (an
130
+ area match doesn't excuse a schema mismatch, and vice versa), and a file
131
+ matching both an included and an excluded value on the same axis is
132
+ excluded. Passing none of them applies no filtering (the default, unchanged
133
+ behavior).
134
+
135
+ ```bash
136
+ pgdb migrate apply path/to/database/_migration_scripts --url ... --area billing
137
+ pgdb compare path/to/database/ --url ... --exclude-area reporting
138
+ pgdb migrate check path/to/database/_migration_scripts --url ... --schema billing --exclude-schema reporting
139
+ pgdb testdb up --schema billing
140
+ ```
141
+
142
+ `compare`'s default report (no `--report-extra-db`) only checks that the
143
+ filtered scripts exist correctly in the DB, so it composes safely with area
144
+ and schema filtering. Passing `--report-extra-db` together with either kind
145
+ of filter also reports every DB object outside the filtered area(s)/
146
+ schema(s) as "missing in scripts" — since the live database has no concept
147
+ of areas, and isn't itself filtered by `--schema` either — only the scripts
148
+ side is filtered — so treat that combination's "missing in scripts" results
149
+ with that in mind (the CLI prints a warning when you combine them).
150
+
151
+ `pgdb fetch-missing` deliberately has **no** `--area`/`--exclude-area` (or
152
+ `--schema`/`--exclude-schema`): it diffs the full database against scripts
153
+ to find genuinely untracked objects, so narrowing the scripts side would
154
+ make every object tracked under a different area/schema look "missing" too
155
+ — and `--write` would then reconstruct a duplicate file for something that
156
+ already exists.
157
+
158
+ `pgdevkit.areas` exposes the tag-filtering logic for scripting:
159
+ `parse_areas`/`file_areas` read a file's declared areas, and
160
+ `area_allowed`/`filter_by_area` apply the `only`/`exclude` semantics above.
161
+ `pgdevkit.schemas` exposes the equivalent for schema filtering:
162
+ `sql_schemas`/`file_schemas` detect a file's referenced schemas, and
163
+ `schema_allowed`/`filter_by_schema` apply the same `only`/`exclude`
164
+ semantics. `pgdevkit.migrate.list_migration_files`/`pending_migrations` and
165
+ `pgdevkit.parser.parse_directory` take both pairs of keyword arguments
166
+ (`areas`/`exclude_areas` and `schemas`/`exclude_schemas`);
167
+ `pgdevkit.fetch_missing.find_missing_objects` takes neither, for the reason
168
+ above.
169
+
82
170
  ## `pgdb testdb`
83
171
 
84
172
  Manages a single shared, Podman-backed Postgres container for local tests
@@ -109,6 +197,13 @@ def ensure_test_postgres():
109
197
 
110
198
  CLI: `pgdb testdb up|reset|run-sql|status|shell|clean`.
111
199
 
200
+ `up`/`reset` accept `--area`/`--exclude-area` and `--schema`/`--exclude-schema`
201
+ (see "Area and schema filtering" above) to scope which `database/` files get
202
+ applied — e.g. `pgdb testdb up --schema billing` for a test DB with only the
203
+ `billing` schema's tables/views/functions, without waiting on the rest of the
204
+ project's schema to apply. `ensure_testdb`/`reset_testdb` take the same
205
+ keyword arguments when called from Python (e.g. from a pytest fixture).
206
+
112
207
  Container connection defaults (`localhost:54322`, `postgres`/`testpwd`) can
113
208
  be overridden with `PGDEVKIT_TESTDB_HOST`, `PGDEVKIT_TESTDB_PORT`,
114
209
  `PGDEVKIT_TESTDB_USER`, `PGDEVKIT_TESTDB_PASSWORD`. Before touching the
@@ -259,3 +354,29 @@ async with pool.connection() as con:
259
354
  widget = await pg_retrieve(con, Widget, {"id": 1})
260
355
  await pg_upsert(con, Widget(id=1, name="thing"), Widget)
261
356
  ```
357
+
358
+ ## Releasing
359
+
360
+ Bump `version` in `pyproject.toml` as part of your PR, same as any other
361
+ change. Once that PR merges to `main` and the `Python Test` workflow passes
362
+ for that commit, `.github/workflows/auto-release.yml` automatically tags it
363
+ `vX.Y.Z`, cuts a GitHub Release (skipping if that version was already
364
+ released, e.g. a merge that didn't touch the version), and dispatches
365
+ `python-publish.yml` to publish it to PyPI — no manual release step, and no
366
+ extra secret to configure. Two non-obvious GitHub Actions quirks shaped
367
+ this (see the comments at the top of `auto-release.yml` for the full
368
+ reasoning, since both were hit and confirmed the hard way):
369
+
370
+ - A release created with the default `GITHUB_TOKEN` does **not** trigger
371
+ other workflows' `release: published` listeners (an anti-recursion
372
+ safeguard) — `workflow_dispatch` is the documented exception, so
373
+ `auto-release.yml` dispatches `python-publish.yml` directly (`gh workflow
374
+ run`) instead of relying on the release to cascade into it.
375
+ - `python-publish.yml` deliberately stays a plain, directly-triggered
376
+ top-level workflow rather than something `auto-release.yml` calls via
377
+ `workflow_call`: PyPI's OIDC trusted publishing does not support
378
+ reusable/called workflows and silently rejects the token in that shape.
379
+
380
+ `workflow_dispatch` (or an actual GitHub UI release) on `python-publish.yml`
381
+ still works as a manual fallback if you ever need to re-publish a version
382
+ without going through `auto-release.yml`.
@@ -58,6 +58,94 @@ which parses/introspects/diffs like any other column type; see
58
58
  handled on the CRUD side (write-side serialization only, no auto-parsing on
59
59
  read — `mssql-python` doesn't distinguish `json` columns from `nvarchar`).
60
60
 
61
+ ### Area and schema filtering
62
+
63
+ Two independent, composable ways to narrow which files a command touches:
64
+ **area** is an explicit opt-in tag; **schema** is derived automatically from
65
+ each file's own SQL.
66
+
67
+ #### Area tagging
68
+
69
+ Any migration file or `database/` code file can declare one or more areas by
70
+ starting with a `-- area:` comment:
71
+
72
+ ```sql
73
+ -- area: billing
74
+ CREATE TABLE billing.invoices (id int primary key);
75
+ ```
76
+
77
+ A file can declare more than one area, either comma-separated on one line
78
+ (`-- area: billing, reporting`) or across several `-- area:` lines — the
79
+ declared areas union. The directive is only recognized in the file's leading
80
+ comment block (blank lines and `--` comments at the very top, stopping at the
81
+ first real statement); a `-- area:` comment later in the file doesn't count.
82
+ A file with no directive is untagged, and untagged files are treated as
83
+ shared/common.
84
+
85
+ #### Schema filtering
86
+
87
+ No tag needed — schema membership is parsed straight out of the SQL itself:
88
+ every schema-qualified (or default-schema, when unqualified) table/view/
89
+ function/index reference across every statement in the file, DDL or DML
90
+ alike, plus any `CREATE SCHEMA name`. A file whose schema(s) can't be
91
+ determined (unparseable content, or no table/schema reference in it at all)
92
+ is treated the same as an untagged file — always kept.
93
+
94
+ #### Options
95
+
96
+ `pgdb compare`, `pgdb migrate check`, `pgdb migrate apply`, `pgdb testdb up`,
97
+ and `pgdb testdb reset` all accept:
98
+
99
+ - `--area NAME` (repeatable) — restrict to files declaring one of the given
100
+ areas, **plus every untagged file** (untagged files always stay in scope).
101
+ - `--exclude-area NAME` (repeatable) — drop files declaring one of the given
102
+ areas; untagged files are never dropped by this.
103
+ - `--schema NAME` (repeatable) — restrict to files referencing one of the
104
+ given schemas, **plus every file with no detectable schema reference**.
105
+ - `--exclude-schema NAME` (repeatable) — drop files referencing one of the
106
+ given schemas; files with no detectable reference are never dropped.
107
+
108
+ All four can be combined — a file must pass every filter it's subject to (an
109
+ area match doesn't excuse a schema mismatch, and vice versa), and a file
110
+ matching both an included and an excluded value on the same axis is
111
+ excluded. Passing none of them applies no filtering (the default, unchanged
112
+ behavior).
113
+
114
+ ```bash
115
+ pgdb migrate apply path/to/database/_migration_scripts --url ... --area billing
116
+ pgdb compare path/to/database/ --url ... --exclude-area reporting
117
+ pgdb migrate check path/to/database/_migration_scripts --url ... --schema billing --exclude-schema reporting
118
+ pgdb testdb up --schema billing
119
+ ```
120
+
121
+ `compare`'s default report (no `--report-extra-db`) only checks that the
122
+ filtered scripts exist correctly in the DB, so it composes safely with area
123
+ and schema filtering. Passing `--report-extra-db` together with either kind
124
+ of filter also reports every DB object outside the filtered area(s)/
125
+ schema(s) as "missing in scripts" — since the live database has no concept
126
+ of areas, and isn't itself filtered by `--schema` either — only the scripts
127
+ side is filtered — so treat that combination's "missing in scripts" results
128
+ with that in mind (the CLI prints a warning when you combine them).
129
+
130
+ `pgdb fetch-missing` deliberately has **no** `--area`/`--exclude-area` (or
131
+ `--schema`/`--exclude-schema`): it diffs the full database against scripts
132
+ to find genuinely untracked objects, so narrowing the scripts side would
133
+ make every object tracked under a different area/schema look "missing" too
134
+ — and `--write` would then reconstruct a duplicate file for something that
135
+ already exists.
136
+
137
+ `pgdevkit.areas` exposes the tag-filtering logic for scripting:
138
+ `parse_areas`/`file_areas` read a file's declared areas, and
139
+ `area_allowed`/`filter_by_area` apply the `only`/`exclude` semantics above.
140
+ `pgdevkit.schemas` exposes the equivalent for schema filtering:
141
+ `sql_schemas`/`file_schemas` detect a file's referenced schemas, and
142
+ `schema_allowed`/`filter_by_schema` apply the same `only`/`exclude`
143
+ semantics. `pgdevkit.migrate.list_migration_files`/`pending_migrations` and
144
+ `pgdevkit.parser.parse_directory` take both pairs of keyword arguments
145
+ (`areas`/`exclude_areas` and `schemas`/`exclude_schemas`);
146
+ `pgdevkit.fetch_missing.find_missing_objects` takes neither, for the reason
147
+ above.
148
+
61
149
  ## `pgdb testdb`
62
150
 
63
151
  Manages a single shared, Podman-backed Postgres container for local tests
@@ -88,6 +176,13 @@ def ensure_test_postgres():
88
176
 
89
177
  CLI: `pgdb testdb up|reset|run-sql|status|shell|clean`.
90
178
 
179
+ `up`/`reset` accept `--area`/`--exclude-area` and `--schema`/`--exclude-schema`
180
+ (see "Area and schema filtering" above) to scope which `database/` files get
181
+ applied — e.g. `pgdb testdb up --schema billing` for a test DB with only the
182
+ `billing` schema's tables/views/functions, without waiting on the rest of the
183
+ project's schema to apply. `ensure_testdb`/`reset_testdb` take the same
184
+ keyword arguments when called from Python (e.g. from a pytest fixture).
185
+
91
186
  Container connection defaults (`localhost:54322`, `postgres`/`testpwd`) can
92
187
  be overridden with `PGDEVKIT_TESTDB_HOST`, `PGDEVKIT_TESTDB_PORT`,
93
188
  `PGDEVKIT_TESTDB_USER`, `PGDEVKIT_TESTDB_PASSWORD`. Before touching the
@@ -238,3 +333,29 @@ async with pool.connection() as con:
238
333
  widget = await pg_retrieve(con, Widget, {"id": 1})
239
334
  await pg_upsert(con, Widget(id=1, name="thing"), Widget)
240
335
  ```
336
+
337
+ ## Releasing
338
+
339
+ Bump `version` in `pyproject.toml` as part of your PR, same as any other
340
+ change. Once that PR merges to `main` and the `Python Test` workflow passes
341
+ for that commit, `.github/workflows/auto-release.yml` automatically tags it
342
+ `vX.Y.Z`, cuts a GitHub Release (skipping if that version was already
343
+ released, e.g. a merge that didn't touch the version), and dispatches
344
+ `python-publish.yml` to publish it to PyPI — no manual release step, and no
345
+ extra secret to configure. Two non-obvious GitHub Actions quirks shaped
346
+ this (see the comments at the top of `auto-release.yml` for the full
347
+ reasoning, since both were hit and confirmed the hard way):
348
+
349
+ - A release created with the default `GITHUB_TOKEN` does **not** trigger
350
+ other workflows' `release: published` listeners (an anti-recursion
351
+ safeguard) — `workflow_dispatch` is the documented exception, so
352
+ `auto-release.yml` dispatches `python-publish.yml` directly (`gh workflow
353
+ run`) instead of relying on the release to cascade into it.
354
+ - `python-publish.yml` deliberately stays a plain, directly-triggered
355
+ top-level workflow rather than something `auto-release.yml` calls via
356
+ `workflow_call`: PyPI's OIDC trusted publishing does not support
357
+ reusable/called workflows and silently rejects the token in that shape.
358
+
359
+ `workflow_dispatch` (or an actual GitHub UI release) on `python-publish.yml`
360
+ still works as a manual fallback if you ever need to re-publish a version
361
+ without going through `auto-release.yml`.
@@ -0,0 +1,64 @@
1
+ """Optional `-- area: NAME[, NAME...]` tag recognized in the leading comment
2
+ block of a migration file or a `database/` code file (blank lines and `--`
3
+ comments at the very top, stopping at the first real statement — like a file
4
+ header). A file may declare more than one area, either as a comma-separated
5
+ list on one line or across several `-- area:` lines (the areas union).
6
+
7
+ A file with no such directive is "untagged" and is treated as shared/common:
8
+ `only` filters always keep untagged files, and `exclude` filters never drop
9
+ them — only a file that explicitly declares an excluded area is dropped.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from pathlib import Path
16
+
17
+ _AREA_LINE = re.compile(r"^\s*--\s*area\s*:\s*(.+?)\s*$", re.IGNORECASE)
18
+
19
+
20
+ def parse_areas(content: str) -> frozenset[str]:
21
+ """Area names declared in `content`'s leading comment block."""
22
+ areas: set[str] = set()
23
+ for line in content.splitlines():
24
+ stripped = line.strip()
25
+ if not stripped:
26
+ continue
27
+ if not stripped.startswith("--"):
28
+ break
29
+ m = _AREA_LINE.match(stripped)
30
+ if m:
31
+ areas.update(a.strip() for a in m.group(1).split(",") if a.strip())
32
+ return frozenset(areas)
33
+
34
+
35
+ def file_areas(path: Path) -> frozenset[str]:
36
+ """Area names declared in the file at `path`."""
37
+ return parse_areas(path.read_text(encoding="utf-8"))
38
+
39
+
40
+ def area_allowed(
41
+ areas: frozenset[str],
42
+ *,
43
+ only: frozenset[str] | None = None,
44
+ exclude: frozenset[str] | None = None,
45
+ ) -> bool:
46
+ """Whether a file that declares `areas` passes an `only`/`exclude` filter."""
47
+ if exclude and areas & exclude:
48
+ return False
49
+ if only and areas and not (areas & only):
50
+ return False
51
+ return True
52
+
53
+
54
+ def filter_by_area(
55
+ paths: list[Path],
56
+ *,
57
+ only: frozenset[str] | None = None,
58
+ exclude: frozenset[str] | None = None,
59
+ ) -> list[Path]:
60
+ """`paths` restricted by an `only`/`exclude` area filter. Returns `paths`
61
+ unchanged (no file reads) when neither filter is set."""
62
+ if not only and not exclude:
63
+ return paths
64
+ return [p for p in paths if area_allowed(file_areas(p), only=only, exclude=exclude)]
@@ -23,6 +23,29 @@ app = typer.Typer(name="pgdb", help="PostgreSQL database schema tools")
23
23
  console = Console()
24
24
  err_console = Console(stderr=True)
25
25
 
26
+ _AREA_OPTION = typer.Option(
27
+ [], "--area", help="Restrict to files declaring this area (repeatable); untagged files always stay in scope"
28
+ )
29
+ _EXCLUDE_AREA_OPTION = typer.Option(
30
+ [], "--exclude-area", help="Skip files declaring this area (repeatable); untagged files are never excluded"
31
+ )
32
+ _SCHEMA_OPTION = typer.Option(
33
+ [],
34
+ "--schema",
35
+ help="Restrict to files referencing this DB schema (repeatable); "
36
+ "files with no detectable schema reference always stay in scope",
37
+ )
38
+ _EXCLUDE_SCHEMA_OPTION = typer.Option(
39
+ [],
40
+ "--exclude-schema",
41
+ help="Skip files referencing this DB schema (repeatable); "
42
+ "files with no detectable schema reference are never excluded",
43
+ )
44
+
45
+
46
+ def _as_set(values: list[str]) -> frozenset[str] | None:
47
+ return frozenset(values) if values else None
48
+
26
49
  testdb_app = typer.Typer(name="testdb", help="Manage the shared local Postgres test container")
27
50
  app.add_typer(testdb_app, name="testdb")
28
51
 
@@ -46,12 +69,23 @@ def compare(
46
69
  ),
47
70
  report_extra_db: bool = typer.Option(False, "--report-extra-db", help="Report objects in DB but not in scripts"),
48
71
  dialect: str = typer.Option("postgres", "--dialect", help="postgres (default) or mssql"),
72
+ area: list[str] = _AREA_OPTION,
73
+ exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
74
+ schema: list[str] = _SCHEMA_OPTION,
75
+ exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
49
76
  scripts_dir: Path = typer.Argument(..., help="Directory containing SQL scripts"),
50
77
  ) -> None:
51
78
  """Compare SQL scripts to a live database and report differences."""
52
79
  if not scripts_dir.is_dir():
53
80
  err_console.print(f"[red]Error:[/red] {scripts_dir} is not a directory")
54
81
  raise typer.Exit(2)
82
+ if report_extra_db and (area or exclude_area or schema or exclude_schema):
83
+ console.print(
84
+ "[yellow]⚠[/yellow] --report-extra-db with --area/--exclude-area/--schema/--exclude-schema will "
85
+ "report every DB object outside the filtered area(s)/schema(s) as \"missing in scripts\", since the "
86
+ "live database has no concept of areas — and isn't itself filtered by --schema either — only the "
87
+ "scripts side is filtered."
88
+ )
55
89
 
56
90
  try:
57
91
  conninfo = build_conninfo(
@@ -71,7 +105,14 @@ def compare(
71
105
  raise typer.Exit(2)
72
106
 
73
107
  with console.status("Parsing SQL scripts..."):
74
- scripts_schema = parse_directory(scripts_dir, dialect=backend.dialect)
108
+ scripts_schema = parse_directory(
109
+ scripts_dir,
110
+ dialect=backend.dialect,
111
+ areas=_as_set(area),
112
+ exclude_areas=_as_set(exclude_area),
113
+ schemas=_as_set(schema),
114
+ exclude_schemas=_as_set(exclude_schema),
115
+ )
75
116
 
76
117
  with console.status("Introspecting database..."):
77
118
  db_schema = backend.introspect(conninfo)
@@ -110,7 +151,11 @@ def fetch_missing(
110
151
  only: list[str] = typer.Option([], "--only", help="Only fetch schema.name (repeatable); default is everything"),
111
152
  ) -> None:
112
153
  """Find tables/views/functions that exist in the database but aren't
113
- tracked under scripts_dir, and reverse-engineer their DDL into new files."""
154
+ tracked under scripts_dir, and reverse-engineer their DDL into new files.
155
+
156
+ No --area/--exclude-area here (unlike compare/migrate): this diffs the
157
+ full live database against scripts, so filtering the scripts side by
158
+ area would misreport objects tracked under other areas as missing."""
114
159
  if not scripts_dir.is_dir():
115
160
  err_console.print(f"[red]Error:[/red] {scripts_dir} is not a directory")
116
161
  raise typer.Exit(2)
@@ -163,17 +208,33 @@ def fetch_missing(
163
208
 
164
209
 
165
210
  @testdb_app.command("up")
166
- def testdb_up() -> None:
211
+ def testdb_up(
212
+ area: list[str] = _AREA_OPTION,
213
+ exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
214
+ schema: list[str] = _SCHEMA_OPTION,
215
+ exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
216
+ ) -> None:
167
217
  """Ensure the container is running, the workspace DB exists, and schema is applied."""
168
- testdb.ensure_testdb()
218
+ testdb.ensure_testdb(
219
+ areas=_as_set(area), exclude_areas=_as_set(exclude_area), schemas=_as_set(schema),
220
+ exclude_schemas=_as_set(exclude_schema),
221
+ )
169
222
  info = testdb.status()
170
223
  console.print(f"[green]Test DB ready:[/green] {info['database']} ({info['dsn']})")
171
224
 
172
225
 
173
226
  @testdb_app.command("reset")
174
- def testdb_reset() -> None:
227
+ def testdb_reset(
228
+ area: list[str] = _AREA_OPTION,
229
+ exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
230
+ schema: list[str] = _SCHEMA_OPTION,
231
+ exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
232
+ ) -> None:
175
233
  """Drop and recreate only this workspace's database, then reapply schema + seed data."""
176
- testdb.reset_testdb()
234
+ testdb.reset_testdb(
235
+ areas=_as_set(area), exclude_areas=_as_set(exclude_area), schemas=_as_set(schema),
236
+ exclude_schemas=_as_set(exclude_schema),
237
+ )
177
238
  info = testdb.status()
178
239
  console.print(f"[green]Test DB reset:[/green] {info['database']}")
179
240
 
@@ -245,6 +306,10 @@ def migrate_check(
245
306
  help="schema.table recording applied migrations "
246
307
  "(default: tool.pgdevkit.migrations_table in pyproject.toml, else public.schema_migrations)",
247
308
  ),
309
+ area: list[str] = _AREA_OPTION,
310
+ exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
311
+ schema: list[str] = _SCHEMA_OPTION,
312
+ exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
248
313
  ) -> None:
249
314
  """List which migration files under migrations_dir are applied vs. pending."""
250
315
  if not migrations_dir.is_dir():
@@ -253,7 +318,13 @@ def migrate_check(
253
318
 
254
319
  conninfo = build_conninfo(url, entra_user)
255
320
  tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir)
256
- local_files = migrate.list_migration_files(migrations_dir)
321
+ local_files = migrate.list_migration_files(
322
+ migrations_dir,
323
+ areas=_as_set(area),
324
+ exclude_areas=_as_set(exclude_area),
325
+ schemas=_as_set(schema),
326
+ exclude_schemas=_as_set(exclude_schema),
327
+ )
257
328
  try:
258
329
  applied = migrate.applied_migrations(conninfo, tracking_table)
259
330
  except migrate.TrackingTableMissing:
@@ -292,6 +363,10 @@ def migrate_apply(
292
363
  ),
293
364
  ask: bool = typer.Option(False, "--ask", help="Show and confirm each migration before running it"),
294
365
  yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirm-target prompt"),
366
+ area: list[str] = _AREA_OPTION,
367
+ exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
368
+ schema: list[str] = _SCHEMA_OPTION,
369
+ exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
295
370
  ) -> None:
296
371
  """Apply pending migration files, in filename order, tracking each in tracking_table."""
297
372
  if not migrations_dir.is_dir():
@@ -300,6 +375,8 @@ def migrate_apply(
300
375
 
301
376
  conninfo = build_conninfo(url, entra_user)
302
377
  tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir)
378
+ areas, exclude_areas = _as_set(area), _as_set(exclude_area)
379
+ schemas, exclude_schemas = _as_set(schema), _as_set(exclude_schema)
303
380
  target_desc = url.rsplit("@", 1)[-1] if "@" in url else url
304
381
  if not yes:
305
382
  typer.confirm(f"About to run migrations against {target_desc}. Continue?", abort=True)
@@ -308,12 +385,23 @@ def migrate_apply(
308
385
  targets = [migrations_dir / file]
309
386
  else:
310
387
  try:
311
- targets = migrate.pending_migrations(migrations_dir, conninfo, tracking_table)
388
+ targets = migrate.pending_migrations(
389
+ migrations_dir,
390
+ conninfo,
391
+ tracking_table,
392
+ areas=areas,
393
+ exclude_areas=exclude_areas,
394
+ schemas=schemas,
395
+ exclude_schemas=exclude_schemas,
396
+ )
312
397
  except migrate.TrackingTableMissing:
313
398
  err_console.print(
314
399
  f"[yellow]⚠[/yellow] {tracking_table} not found — treating every migration as pending"
315
400
  )
316
- targets = migrate.list_migration_files(migrations_dir)
401
+ targets = migrate.list_migration_files(
402
+ migrations_dir, areas=areas, exclude_areas=exclude_areas, schemas=schemas,
403
+ exclude_schemas=exclude_schemas,
404
+ )
317
405
 
318
406
  if not targets:
319
407
  console.print("No pending migrations.")
@@ -43,6 +43,16 @@ _MSSQL_TYPE_SYNONYMS = {
43
43
  }
44
44
 
45
45
 
46
+ # Schemas that hold system catalog views/tables, never a file any project
47
+ # using pgdevkit manages -- a reference to one (e.g. an idempotency guard
48
+ # querying it, or a `SELECT ... FROM information_schema/pg_catalog/sys ...`)
49
+ # is never a real schema-membership or cross-file-dependency signal. Shared
50
+ # by `schemas.py` (schema-reference filtering) and `testdb/schema.py`
51
+ # (dependency-safe apply ordering), which both walk the same sqlglot Table
52
+ # nodes for a related-but-different purpose.
53
+ SYSTEM_SCHEMAS = {"pg_catalog", "information_schema", "sys"}
54
+
55
+
46
56
  @dataclass(frozen=True)
47
57
  class Dialect:
48
58
  """A thin wrapper around a sqlglot dialect name plus the handful of
@@ -50,7 +50,12 @@ def layer_folder_for(scripts_dir: Path, schema: str) -> Path:
50
50
 
51
51
  def find_missing_objects(scripts_dir: Path, conninfo: str) -> list[MissingObject]:
52
52
  """Tables, views, and functions that exist in the live database but
53
- aren't tracked as .sql files under scripts_dir."""
53
+ aren't tracked as .sql files under scripts_dir.
54
+
55
+ Deliberately not area-filterable: this diffs the *full* database against
56
+ scripts with report_extra_db=True, so narrowing the scripts side to one
57
+ area would make every object tracked only under a different area look
58
+ "missing" too — and --write would reconstruct a duplicate file for it."""
54
59
  scripts = parse_directory(scripts_dir)
55
60
  db = introspect_db(conninfo)
56
61
  diffs = compute_diff(scripts, db, report_extra_db=True)