pgdevkit 0.3.8__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pgdevkit-0.4.0/.github/workflows/auto-release.yml +83 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/PKG-INFO +83 -1
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/README.md +82 -0
- pgdevkit-0.4.0/pgdevkit/areas.py +64 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/cli.py +39 -5
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/fetch_missing.py +6 -1
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/migrate.py +20 -4
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/parser.py +28 -6
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pyproject.toml +1 -1
- pgdevkit-0.4.0/tests/test_areas.py +106 -0
- pgdevkit-0.4.0/tests/test_migrate_areas.py +41 -0
- pgdevkit-0.4.0/tests/test_parser_areas.py +34 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/uv.lock +1 -1
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/.github/workflows/python-test.yml +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/.gitignore +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/.python-version +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/docs/database-layout.md +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/backends/__init__.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/backends/base.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/backends/mssql.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/backends/postgres.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/connection.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/db/__init__.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/db/complex_types.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/db/connection.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/db/crud.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/db/mssql_crud.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/db/mssql_sql.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/dialect.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/models.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/mssql_introspect.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/__init__.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/_docker.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/api.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/config.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/constants.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/container.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/mssql/__init__.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/mssql/api.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/mssql/constants.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/mssql/container.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/naming.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/schema.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/skills/pgdevkit/SKILL.md +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/skills/pgdevkit/references/dynamic-sql.md +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/__init__.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/conftest.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/db/__init__.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/db/test_complex_types.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/db/test_connection.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/db/test_crud.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/db/test_mssql_crud_live.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/db/test_mssql_crud_sql.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_compare.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_compare_mssql_live.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_connection.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_dialect.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_diff_mssql.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_fetch_missing.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_fetch_missing_cli.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_migrate.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_mssql_introspect.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_parser_mssql.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/conftest.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_api.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_api_mssql.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_api_mssql_live.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_cli.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_config.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_config_mssql.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_container.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_mssql_constants.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_naming.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_query.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_schema.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.4.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
|
+
Version: 0.4.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,62 @@ 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 tagging and filtering
|
|
83
|
+
|
|
84
|
+
Any migration file or `database/` code file can declare one or more areas by
|
|
85
|
+
starting with a `-- area:` comment:
|
|
86
|
+
|
|
87
|
+
```sql
|
|
88
|
+
-- area: billing
|
|
89
|
+
CREATE TABLE billing.invoices (id int primary key);
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
A file can declare more than one area, either comma-separated on one line
|
|
93
|
+
(`-- area: billing, reporting`) or across several `-- area:` lines — the
|
|
94
|
+
declared areas union. The directive is only recognized in the file's leading
|
|
95
|
+
comment block (blank lines and `--` comments at the very top, stopping at the
|
|
96
|
+
first real statement); a `-- area:` comment later in the file doesn't count.
|
|
97
|
+
A file with no directive is untagged, and untagged files are treated as
|
|
98
|
+
shared/common.
|
|
99
|
+
|
|
100
|
+
`pgdb compare`, `pgdb migrate check`, and `pgdb migrate apply` all accept:
|
|
101
|
+
|
|
102
|
+
- `--area NAME` (repeatable) — restrict to files declaring one of the given
|
|
103
|
+
areas, **plus every untagged file** (untagged files always stay in scope).
|
|
104
|
+
- `--exclude-area NAME` (repeatable) — drop files declaring one of the given
|
|
105
|
+
areas; untagged files are never dropped by this.
|
|
106
|
+
|
|
107
|
+
Both can be combined; a file matching both an included and an excluded area
|
|
108
|
+
is excluded. Passing neither option applies no filtering (the default,
|
|
109
|
+
unchanged behavior).
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
pgdb migrate apply path/to/database/_migration_scripts --url ... --area billing
|
|
113
|
+
pgdb compare path/to/database/ --url ... --exclude-area reporting
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`compare`'s default report (no `--report-extra-db`) only checks that the
|
|
117
|
+
filtered scripts exist correctly in the DB, so it composes safely with area
|
|
118
|
+
filtering. Passing `--report-extra-db` together with an area filter also
|
|
119
|
+
reports every DB object outside the filtered area(s) as "missing in
|
|
120
|
+
scripts" — since the live database has no concept of areas, only the
|
|
121
|
+
scripts side is filtered — so treat that combination's "missing in scripts"
|
|
122
|
+
results with that in mind (the CLI prints a warning when you combine them).
|
|
123
|
+
|
|
124
|
+
`pgdb fetch-missing` deliberately has **no** `--area`/`--exclude-area`: it
|
|
125
|
+
diffs the full database against scripts to find genuinely untracked
|
|
126
|
+
objects, so narrowing the scripts side by area would make every object
|
|
127
|
+
tracked only under a different area look "missing" too — and `--write`
|
|
128
|
+
would then reconstruct a duplicate file for something that already exists.
|
|
129
|
+
|
|
130
|
+
`pgdevkit.areas` exposes the same logic for scripting:
|
|
131
|
+
`parse_areas`/`file_areas` read a file's declared areas, and
|
|
132
|
+
`area_allowed`/`filter_by_area` apply the `only`/`exclude` semantics above.
|
|
133
|
+
`pgdevkit.migrate.list_migration_files`/`pending_migrations` and
|
|
134
|
+
`pgdevkit.parser.parse_directory` take the same `areas`/`exclude_areas`
|
|
135
|
+
keyword arguments (`pgdevkit.fetch_missing.find_missing_objects` doesn't,
|
|
136
|
+
for the reason above).
|
|
137
|
+
|
|
82
138
|
## `pgdb testdb`
|
|
83
139
|
|
|
84
140
|
Manages a single shared, Podman-backed Postgres container for local tests
|
|
@@ -259,3 +315,29 @@ async with pool.connection() as con:
|
|
|
259
315
|
widget = await pg_retrieve(con, Widget, {"id": 1})
|
|
260
316
|
await pg_upsert(con, Widget(id=1, name="thing"), Widget)
|
|
261
317
|
```
|
|
318
|
+
|
|
319
|
+
## Releasing
|
|
320
|
+
|
|
321
|
+
Bump `version` in `pyproject.toml` as part of your PR, same as any other
|
|
322
|
+
change. Once that PR merges to `main` and the `Python Test` workflow passes
|
|
323
|
+
for that commit, `.github/workflows/auto-release.yml` automatically tags it
|
|
324
|
+
`vX.Y.Z`, cuts a GitHub Release (skipping if that version was already
|
|
325
|
+
released, e.g. a merge that didn't touch the version), and dispatches
|
|
326
|
+
`python-publish.yml` to publish it to PyPI — no manual release step, and no
|
|
327
|
+
extra secret to configure. Two non-obvious GitHub Actions quirks shaped
|
|
328
|
+
this (see the comments at the top of `auto-release.yml` for the full
|
|
329
|
+
reasoning, since both were hit and confirmed the hard way):
|
|
330
|
+
|
|
331
|
+
- A release created with the default `GITHUB_TOKEN` does **not** trigger
|
|
332
|
+
other workflows' `release: published` listeners (an anti-recursion
|
|
333
|
+
safeguard) — `workflow_dispatch` is the documented exception, so
|
|
334
|
+
`auto-release.yml` dispatches `python-publish.yml` directly (`gh workflow
|
|
335
|
+
run`) instead of relying on the release to cascade into it.
|
|
336
|
+
- `python-publish.yml` deliberately stays a plain, directly-triggered
|
|
337
|
+
top-level workflow rather than something `auto-release.yml` calls via
|
|
338
|
+
`workflow_call`: PyPI's OIDC trusted publishing does not support
|
|
339
|
+
reusable/called workflows and silently rejects the token in that shape.
|
|
340
|
+
|
|
341
|
+
`workflow_dispatch` (or an actual GitHub UI release) on `python-publish.yml`
|
|
342
|
+
still works as a manual fallback if you ever need to re-publish a version
|
|
343
|
+
without going through `auto-release.yml`.
|
|
@@ -58,6 +58,62 @@ 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 tagging and filtering
|
|
62
|
+
|
|
63
|
+
Any migration file or `database/` code file can declare one or more areas by
|
|
64
|
+
starting with a `-- area:` comment:
|
|
65
|
+
|
|
66
|
+
```sql
|
|
67
|
+
-- area: billing
|
|
68
|
+
CREATE TABLE billing.invoices (id int primary key);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A file can declare more than one area, either comma-separated on one line
|
|
72
|
+
(`-- area: billing, reporting`) or across several `-- area:` lines — the
|
|
73
|
+
declared areas union. The directive is only recognized in the file's leading
|
|
74
|
+
comment block (blank lines and `--` comments at the very top, stopping at the
|
|
75
|
+
first real statement); a `-- area:` comment later in the file doesn't count.
|
|
76
|
+
A file with no directive is untagged, and untagged files are treated as
|
|
77
|
+
shared/common.
|
|
78
|
+
|
|
79
|
+
`pgdb compare`, `pgdb migrate check`, and `pgdb migrate apply` all accept:
|
|
80
|
+
|
|
81
|
+
- `--area NAME` (repeatable) — restrict to files declaring one of the given
|
|
82
|
+
areas, **plus every untagged file** (untagged files always stay in scope).
|
|
83
|
+
- `--exclude-area NAME` (repeatable) — drop files declaring one of the given
|
|
84
|
+
areas; untagged files are never dropped by this.
|
|
85
|
+
|
|
86
|
+
Both can be combined; a file matching both an included and an excluded area
|
|
87
|
+
is excluded. Passing neither option applies no filtering (the default,
|
|
88
|
+
unchanged behavior).
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pgdb migrate apply path/to/database/_migration_scripts --url ... --area billing
|
|
92
|
+
pgdb compare path/to/database/ --url ... --exclude-area reporting
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`compare`'s default report (no `--report-extra-db`) only checks that the
|
|
96
|
+
filtered scripts exist correctly in the DB, so it composes safely with area
|
|
97
|
+
filtering. Passing `--report-extra-db` together with an area filter also
|
|
98
|
+
reports every DB object outside the filtered area(s) as "missing in
|
|
99
|
+
scripts" — since the live database has no concept of areas, only the
|
|
100
|
+
scripts side is filtered — so treat that combination's "missing in scripts"
|
|
101
|
+
results with that in mind (the CLI prints a warning when you combine them).
|
|
102
|
+
|
|
103
|
+
`pgdb fetch-missing` deliberately has **no** `--area`/`--exclude-area`: it
|
|
104
|
+
diffs the full database against scripts to find genuinely untracked
|
|
105
|
+
objects, so narrowing the scripts side by area would make every object
|
|
106
|
+
tracked only under a different area look "missing" too — and `--write`
|
|
107
|
+
would then reconstruct a duplicate file for something that already exists.
|
|
108
|
+
|
|
109
|
+
`pgdevkit.areas` exposes the same logic for scripting:
|
|
110
|
+
`parse_areas`/`file_areas` read a file's declared areas, and
|
|
111
|
+
`area_allowed`/`filter_by_area` apply the `only`/`exclude` semantics above.
|
|
112
|
+
`pgdevkit.migrate.list_migration_files`/`pending_migrations` and
|
|
113
|
+
`pgdevkit.parser.parse_directory` take the same `areas`/`exclude_areas`
|
|
114
|
+
keyword arguments (`pgdevkit.fetch_missing.find_missing_objects` doesn't,
|
|
115
|
+
for the reason above).
|
|
116
|
+
|
|
61
117
|
## `pgdb testdb`
|
|
62
118
|
|
|
63
119
|
Manages a single shared, Podman-backed Postgres container for local tests
|
|
@@ -238,3 +294,29 @@ async with pool.connection() as con:
|
|
|
238
294
|
widget = await pg_retrieve(con, Widget, {"id": 1})
|
|
239
295
|
await pg_upsert(con, Widget(id=1, name="thing"), Widget)
|
|
240
296
|
```
|
|
297
|
+
|
|
298
|
+
## Releasing
|
|
299
|
+
|
|
300
|
+
Bump `version` in `pyproject.toml` as part of your PR, same as any other
|
|
301
|
+
change. Once that PR merges to `main` and the `Python Test` workflow passes
|
|
302
|
+
for that commit, `.github/workflows/auto-release.yml` automatically tags it
|
|
303
|
+
`vX.Y.Z`, cuts a GitHub Release (skipping if that version was already
|
|
304
|
+
released, e.g. a merge that didn't touch the version), and dispatches
|
|
305
|
+
`python-publish.yml` to publish it to PyPI — no manual release step, and no
|
|
306
|
+
extra secret to configure. Two non-obvious GitHub Actions quirks shaped
|
|
307
|
+
this (see the comments at the top of `auto-release.yml` for the full
|
|
308
|
+
reasoning, since both were hit and confirmed the hard way):
|
|
309
|
+
|
|
310
|
+
- A release created with the default `GITHUB_TOKEN` does **not** trigger
|
|
311
|
+
other workflows' `release: published` listeners (an anti-recursion
|
|
312
|
+
safeguard) — `workflow_dispatch` is the documented exception, so
|
|
313
|
+
`auto-release.yml` dispatches `python-publish.yml` directly (`gh workflow
|
|
314
|
+
run`) instead of relying on the release to cascade into it.
|
|
315
|
+
- `python-publish.yml` deliberately stays a plain, directly-triggered
|
|
316
|
+
top-level workflow rather than something `auto-release.yml` calls via
|
|
317
|
+
`workflow_call`: PyPI's OIDC trusted publishing does not support
|
|
318
|
+
reusable/called workflows and silently rejects the token in that shape.
|
|
319
|
+
|
|
320
|
+
`workflow_dispatch` (or an actual GitHub UI release) on `python-publish.yml`
|
|
321
|
+
still works as a manual fallback if you ever need to re-publish a version
|
|
322
|
+
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,17 @@ 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
|
+
|
|
33
|
+
|
|
34
|
+
def _as_area_set(values: list[str]) -> frozenset[str] | None:
|
|
35
|
+
return frozenset(values) if values else None
|
|
36
|
+
|
|
26
37
|
testdb_app = typer.Typer(name="testdb", help="Manage the shared local Postgres test container")
|
|
27
38
|
app.add_typer(testdb_app, name="testdb")
|
|
28
39
|
|
|
@@ -46,12 +57,20 @@ def compare(
|
|
|
46
57
|
),
|
|
47
58
|
report_extra_db: bool = typer.Option(False, "--report-extra-db", help="Report objects in DB but not in scripts"),
|
|
48
59
|
dialect: str = typer.Option("postgres", "--dialect", help="postgres (default) or mssql"),
|
|
60
|
+
area: list[str] = _AREA_OPTION,
|
|
61
|
+
exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
|
|
49
62
|
scripts_dir: Path = typer.Argument(..., help="Directory containing SQL scripts"),
|
|
50
63
|
) -> None:
|
|
51
64
|
"""Compare SQL scripts to a live database and report differences."""
|
|
52
65
|
if not scripts_dir.is_dir():
|
|
53
66
|
err_console.print(f"[red]Error:[/red] {scripts_dir} is not a directory")
|
|
54
67
|
raise typer.Exit(2)
|
|
68
|
+
if report_extra_db and (area or exclude_area):
|
|
69
|
+
console.print(
|
|
70
|
+
"[yellow]⚠[/yellow] --report-extra-db with --area/--exclude-area will report every DB object "
|
|
71
|
+
"outside the filtered area(s) as \"missing in scripts\", since the live database has no concept "
|
|
72
|
+
"of areas — only the scripts side is filtered."
|
|
73
|
+
)
|
|
55
74
|
|
|
56
75
|
try:
|
|
57
76
|
conninfo = build_conninfo(
|
|
@@ -71,7 +90,9 @@ def compare(
|
|
|
71
90
|
raise typer.Exit(2)
|
|
72
91
|
|
|
73
92
|
with console.status("Parsing SQL scripts..."):
|
|
74
|
-
scripts_schema = parse_directory(
|
|
93
|
+
scripts_schema = parse_directory(
|
|
94
|
+
scripts_dir, dialect=backend.dialect, areas=_as_area_set(area), exclude_areas=_as_area_set(exclude_area)
|
|
95
|
+
)
|
|
75
96
|
|
|
76
97
|
with console.status("Introspecting database..."):
|
|
77
98
|
db_schema = backend.introspect(conninfo)
|
|
@@ -110,7 +131,11 @@ def fetch_missing(
|
|
|
110
131
|
only: list[str] = typer.Option([], "--only", help="Only fetch schema.name (repeatable); default is everything"),
|
|
111
132
|
) -> None:
|
|
112
133
|
"""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.
|
|
134
|
+
tracked under scripts_dir, and reverse-engineer their DDL into new files.
|
|
135
|
+
|
|
136
|
+
No --area/--exclude-area here (unlike compare/migrate): this diffs the
|
|
137
|
+
full live database against scripts, so filtering the scripts side by
|
|
138
|
+
area would misreport objects tracked under other areas as missing."""
|
|
114
139
|
if not scripts_dir.is_dir():
|
|
115
140
|
err_console.print(f"[red]Error:[/red] {scripts_dir} is not a directory")
|
|
116
141
|
raise typer.Exit(2)
|
|
@@ -245,6 +270,8 @@ def migrate_check(
|
|
|
245
270
|
help="schema.table recording applied migrations "
|
|
246
271
|
"(default: tool.pgdevkit.migrations_table in pyproject.toml, else public.schema_migrations)",
|
|
247
272
|
),
|
|
273
|
+
area: list[str] = _AREA_OPTION,
|
|
274
|
+
exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
|
|
248
275
|
) -> None:
|
|
249
276
|
"""List which migration files under migrations_dir are applied vs. pending."""
|
|
250
277
|
if not migrations_dir.is_dir():
|
|
@@ -253,7 +280,9 @@ def migrate_check(
|
|
|
253
280
|
|
|
254
281
|
conninfo = build_conninfo(url, entra_user)
|
|
255
282
|
tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir)
|
|
256
|
-
local_files = migrate.list_migration_files(
|
|
283
|
+
local_files = migrate.list_migration_files(
|
|
284
|
+
migrations_dir, areas=_as_area_set(area), exclude_areas=_as_area_set(exclude_area)
|
|
285
|
+
)
|
|
257
286
|
try:
|
|
258
287
|
applied = migrate.applied_migrations(conninfo, tracking_table)
|
|
259
288
|
except migrate.TrackingTableMissing:
|
|
@@ -292,6 +321,8 @@ def migrate_apply(
|
|
|
292
321
|
),
|
|
293
322
|
ask: bool = typer.Option(False, "--ask", help="Show and confirm each migration before running it"),
|
|
294
323
|
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirm-target prompt"),
|
|
324
|
+
area: list[str] = _AREA_OPTION,
|
|
325
|
+
exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
|
|
295
326
|
) -> None:
|
|
296
327
|
"""Apply pending migration files, in filename order, tracking each in tracking_table."""
|
|
297
328
|
if not migrations_dir.is_dir():
|
|
@@ -300,6 +331,7 @@ def migrate_apply(
|
|
|
300
331
|
|
|
301
332
|
conninfo = build_conninfo(url, entra_user)
|
|
302
333
|
tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir)
|
|
334
|
+
areas, exclude_areas = _as_area_set(area), _as_area_set(exclude_area)
|
|
303
335
|
target_desc = url.rsplit("@", 1)[-1] if "@" in url else url
|
|
304
336
|
if not yes:
|
|
305
337
|
typer.confirm(f"About to run migrations against {target_desc}. Continue?", abort=True)
|
|
@@ -308,12 +340,14 @@ def migrate_apply(
|
|
|
308
340
|
targets = [migrations_dir / file]
|
|
309
341
|
else:
|
|
310
342
|
try:
|
|
311
|
-
targets = migrate.pending_migrations(
|
|
343
|
+
targets = migrate.pending_migrations(
|
|
344
|
+
migrations_dir, conninfo, tracking_table, areas=areas, exclude_areas=exclude_areas
|
|
345
|
+
)
|
|
312
346
|
except migrate.TrackingTableMissing:
|
|
313
347
|
err_console.print(
|
|
314
348
|
f"[yellow]⚠[/yellow] {tracking_table} not found — treating every migration as pending"
|
|
315
349
|
)
|
|
316
|
-
targets = migrate.list_migration_files(migrations_dir)
|
|
350
|
+
targets = migrate.list_migration_files(migrations_dir, areas=areas, exclude_areas=exclude_areas)
|
|
317
351
|
|
|
318
352
|
if not targets:
|
|
319
353
|
console.print("No pending migrations.")
|
|
@@ -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)
|
|
@@ -18,6 +18,8 @@ import sqlglot
|
|
|
18
18
|
from psycopg import errors as pg_errors
|
|
19
19
|
from psycopg import sql as pg_sql
|
|
20
20
|
|
|
21
|
+
from .areas import filter_by_area
|
|
22
|
+
|
|
21
23
|
_IDENTIFIER = r"[A-Za-z_][A-Za-z0-9_]*"
|
|
22
24
|
_DEFAULT_TRACKING_TABLE = "public.schema_migrations"
|
|
23
25
|
|
|
@@ -274,8 +276,14 @@ def already_fully_applied(conninfo: str, path: Path) -> bool:
|
|
|
274
276
|
return all(_target_exists(con, cast(tuple[str, ...], t)) for t in targets)
|
|
275
277
|
|
|
276
278
|
|
|
277
|
-
def list_migration_files(
|
|
278
|
-
|
|
279
|
+
def list_migration_files(
|
|
280
|
+
migrations_dir: Path,
|
|
281
|
+
*,
|
|
282
|
+
areas: frozenset[str] | None = None,
|
|
283
|
+
exclude_areas: frozenset[str] | None = None,
|
|
284
|
+
) -> list[Path]:
|
|
285
|
+
files = sorted(migrations_dir.glob("*.sql"))
|
|
286
|
+
return filter_by_area(files, only=areas, exclude=exclude_areas)
|
|
279
287
|
|
|
280
288
|
|
|
281
289
|
def applied_migrations(conninfo: str, tracking_table: str) -> dict[str, tuple[datetime, str]]:
|
|
@@ -291,9 +299,17 @@ def applied_migrations(conninfo: str, tracking_table: str) -> dict[str, tuple[da
|
|
|
291
299
|
return {r[0]: (r[1], r[2]) for r in rows}
|
|
292
300
|
|
|
293
301
|
|
|
294
|
-
def pending_migrations(
|
|
302
|
+
def pending_migrations(
|
|
303
|
+
migrations_dir: Path,
|
|
304
|
+
conninfo: str,
|
|
305
|
+
tracking_table: str,
|
|
306
|
+
*,
|
|
307
|
+
areas: frozenset[str] | None = None,
|
|
308
|
+
exclude_areas: frozenset[str] | None = None,
|
|
309
|
+
) -> list[Path]:
|
|
295
310
|
applied = applied_migrations(conninfo, tracking_table)
|
|
296
|
-
|
|
311
|
+
files = list_migration_files(migrations_dir, areas=areas, exclude_areas=exclude_areas)
|
|
312
|
+
return [p for p in files if p.name not in applied]
|
|
297
313
|
|
|
298
314
|
|
|
299
315
|
def record_applied(conninfo: str, tracking_table: str, filename: str) -> bool:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import re
|
|
4
5
|
import logging
|
|
5
6
|
from pathlib import Path
|
|
@@ -7,6 +8,7 @@ from pathlib import Path
|
|
|
7
8
|
import sqlglot
|
|
8
9
|
import sqlglot.expressions as exp
|
|
9
10
|
|
|
11
|
+
from .areas import area_allowed, parse_areas
|
|
10
12
|
from .dialect import Dialect, POSTGRES, resolve_dialect
|
|
11
13
|
from .models import (
|
|
12
14
|
ColumnDef, ConstraintDef, CompositeTypeDef, DatabaseSchema,
|
|
@@ -39,18 +41,38 @@ _DO_COMPOSITE = re.compile(
|
|
|
39
41
|
)
|
|
40
42
|
|
|
41
43
|
|
|
42
|
-
def
|
|
44
|
+
def _iter_sql_files(scripts_dir: Path):
|
|
45
|
+
for root, dirs, files in os.walk(scripts_dir):
|
|
46
|
+
dirs[:] = [d for d in dirs if d not in IGNORED_DIR_NAMES]
|
|
47
|
+
for name in files:
|
|
48
|
+
if name.endswith(".sql"):
|
|
49
|
+
yield Path(root) / name
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_directory(
|
|
53
|
+
scripts_dir: Path,
|
|
54
|
+
*,
|
|
55
|
+
dialect: str | Dialect = "postgres",
|
|
56
|
+
areas: frozenset[str] | None = None,
|
|
57
|
+
exclude_areas: frozenset[str] | None = None,
|
|
58
|
+
) -> DatabaseSchema:
|
|
43
59
|
resolved = resolve_dialect(dialect)
|
|
44
60
|
db_schema = DatabaseSchema()
|
|
45
|
-
for sql_file in sorted(scripts_dir
|
|
46
|
-
|
|
61
|
+
for sql_file in sorted(_iter_sql_files(scripts_dir)):
|
|
62
|
+
# Read once and reuse for both the area check and parsing, rather than
|
|
63
|
+
# filtering the file list up front (which would need its own read).
|
|
64
|
+
content = sql_file.read_text(encoding="utf-8")
|
|
65
|
+
if (areas or exclude_areas) and not area_allowed(parse_areas(content), only=areas, exclude=exclude_areas):
|
|
47
66
|
continue
|
|
48
|
-
_parse_file(sql_file, db_schema, resolved)
|
|
67
|
+
_parse_file(sql_file, db_schema, resolved, content=content)
|
|
49
68
|
return db_schema
|
|
50
69
|
|
|
51
70
|
|
|
52
|
-
def _parse_file(
|
|
53
|
-
content =
|
|
71
|
+
def _parse_file(
|
|
72
|
+
path: Path, db_schema: DatabaseSchema, dialect: Dialect = POSTGRES, content: str | None = None
|
|
73
|
+
) -> None:
|
|
74
|
+
if content is None:
|
|
75
|
+
content = path.read_text(encoding="utf-8")
|
|
54
76
|
try:
|
|
55
77
|
exprs = sqlglot.parse(content, dialect=dialect.sqlglot_name, error_level=sqlglot.ErrorLevel.WARN)
|
|
56
78
|
except Exception as e:
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pgdevkit.areas import area_allowed, file_areas, filter_by_area, parse_areas
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestParseAreas:
|
|
9
|
+
def test_no_directive_is_untagged(self):
|
|
10
|
+
assert parse_areas("CREATE TABLE t (id int);\n") == frozenset()
|
|
11
|
+
|
|
12
|
+
def test_single_area(self):
|
|
13
|
+
sql = "-- area: billing\nCREATE TABLE t (id int);\n"
|
|
14
|
+
assert parse_areas(sql) == frozenset({"billing"})
|
|
15
|
+
|
|
16
|
+
def test_comma_separated_areas(self):
|
|
17
|
+
sql = "-- area: billing, reporting\nCREATE TABLE t (id int);\n"
|
|
18
|
+
assert parse_areas(sql) == frozenset({"billing", "reporting"})
|
|
19
|
+
|
|
20
|
+
def test_repeated_lines_union(self):
|
|
21
|
+
sql = "-- area: billing\n-- area: reporting\nCREATE TABLE t (id int);\n"
|
|
22
|
+
assert parse_areas(sql) == frozenset({"billing", "reporting"})
|
|
23
|
+
|
|
24
|
+
def test_case_insensitive_directive(self):
|
|
25
|
+
sql = "-- Area: billing\nCREATE TABLE t (id int);\n"
|
|
26
|
+
assert parse_areas(sql) == frozenset({"billing"})
|
|
27
|
+
|
|
28
|
+
def test_blank_lines_before_directive_are_skipped(self):
|
|
29
|
+
sql = "\n\n-- area: billing\nCREATE TABLE t (id int);\n"
|
|
30
|
+
assert parse_areas(sql) == frozenset({"billing"})
|
|
31
|
+
|
|
32
|
+
def test_directive_after_other_leading_comments_still_counts(self):
|
|
33
|
+
sql = "-- Copyright 2026\n-- area: billing\nCREATE TABLE t (id int);\n"
|
|
34
|
+
assert parse_areas(sql) == frozenset({"billing"})
|
|
35
|
+
|
|
36
|
+
def test_directive_past_leading_comment_block_is_ignored(self):
|
|
37
|
+
sql = "CREATE TABLE t (id int);\n-- area: billing\n"
|
|
38
|
+
assert parse_areas(sql) == frozenset()
|
|
39
|
+
|
|
40
|
+
def test_directive_stops_at_first_blank_then_statement(self):
|
|
41
|
+
# A directive after a blank line inside the header still counts (blank
|
|
42
|
+
# lines don't end the header), but nothing after the first real
|
|
43
|
+
# statement does, even inside a later comment.
|
|
44
|
+
sql = "-- area: billing\n\nCREATE TABLE t (id int);\n-- area: reporting\n"
|
|
45
|
+
assert parse_areas(sql) == frozenset({"billing"})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TestFileAreas:
|
|
49
|
+
def test_reads_from_disk(self, tmp_path: Path):
|
|
50
|
+
f = tmp_path / "001_thing.sql"
|
|
51
|
+
f.write_text("-- area: billing\nCREATE TABLE t (id int);\n", encoding="utf-8")
|
|
52
|
+
assert file_areas(f) == frozenset({"billing"})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TestAreaAllowed:
|
|
56
|
+
def test_no_filters_always_allowed(self):
|
|
57
|
+
assert area_allowed(frozenset({"billing"})) is True
|
|
58
|
+
assert area_allowed(frozenset()) is True
|
|
59
|
+
|
|
60
|
+
def test_only_filter_untagged_always_passes(self):
|
|
61
|
+
assert area_allowed(frozenset(), only=frozenset({"billing"})) is True
|
|
62
|
+
|
|
63
|
+
def test_only_filter_matching_area_passes(self):
|
|
64
|
+
assert area_allowed(frozenset({"billing"}), only=frozenset({"billing"})) is True
|
|
65
|
+
|
|
66
|
+
def test_only_filter_non_matching_area_fails(self):
|
|
67
|
+
assert area_allowed(frozenset({"reporting"}), only=frozenset({"billing"})) is False
|
|
68
|
+
|
|
69
|
+
def test_exclude_filter_untagged_never_dropped(self):
|
|
70
|
+
assert area_allowed(frozenset(), exclude=frozenset({"billing"})) is True
|
|
71
|
+
|
|
72
|
+
def test_exclude_filter_matching_area_dropped(self):
|
|
73
|
+
assert area_allowed(frozenset({"billing"}), exclude=frozenset({"billing"})) is False
|
|
74
|
+
|
|
75
|
+
def test_exclude_filter_non_matching_area_passes(self):
|
|
76
|
+
assert area_allowed(frozenset({"reporting"}), exclude=frozenset({"billing"})) is True
|
|
77
|
+
|
|
78
|
+
def test_only_and_exclude_combined_exclude_wins(self):
|
|
79
|
+
areas = frozenset({"billing"})
|
|
80
|
+
assert area_allowed(areas, only=frozenset({"billing"}), exclude=frozenset({"billing"})) is False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TestFilterByArea:
|
|
84
|
+
def test_no_filters_returns_paths_unchanged(self, tmp_path: Path):
|
|
85
|
+
paths = [tmp_path / "a.sql", tmp_path / "b.sql"]
|
|
86
|
+
assert filter_by_area(paths) == paths
|
|
87
|
+
|
|
88
|
+
def test_only_keeps_matching_and_untagged(self, tmp_path: Path):
|
|
89
|
+
tagged = tmp_path / "billing.sql"
|
|
90
|
+
tagged.write_text("-- area: billing\nselect 1;\n", encoding="utf-8")
|
|
91
|
+
other = tmp_path / "reporting.sql"
|
|
92
|
+
other.write_text("-- area: reporting\nselect 1;\n", encoding="utf-8")
|
|
93
|
+
untagged = tmp_path / "common.sql"
|
|
94
|
+
untagged.write_text("select 1;\n", encoding="utf-8")
|
|
95
|
+
|
|
96
|
+
result = filter_by_area([tagged, other, untagged], only=frozenset({"billing"}))
|
|
97
|
+
assert set(result) == {tagged, untagged}
|
|
98
|
+
|
|
99
|
+
def test_exclude_drops_matching_but_keeps_untagged(self, tmp_path: Path):
|
|
100
|
+
tagged = tmp_path / "billing.sql"
|
|
101
|
+
tagged.write_text("-- area: billing\nselect 1;\n", encoding="utf-8")
|
|
102
|
+
untagged = tmp_path / "common.sql"
|
|
103
|
+
untagged.write_text("select 1;\n", encoding="utf-8")
|
|
104
|
+
|
|
105
|
+
result = filter_by_area([tagged, untagged], exclude=frozenset({"billing"}))
|
|
106
|
+
assert result == [untagged]
|
|
@@ -0,0 +1,41 @@
|
|
|
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) -> Path:
|
|
9
|
+
p = dir / name
|
|
10
|
+
p.write_text(content, encoding="utf-8")
|
|
11
|
+
return p
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestListMigrationFilesAreaFiltering:
|
|
15
|
+
def test_no_filters_lists_everything(self, tmp_path: Path):
|
|
16
|
+
a = _write(tmp_path, "001_a.sql", "-- area: billing\nselect 1;\n")
|
|
17
|
+
b = _write(tmp_path, "002_b.sql", "select 1;\n")
|
|
18
|
+
assert list_migration_files(tmp_path) == sorted([a, b])
|
|
19
|
+
|
|
20
|
+
def test_areas_filter_keeps_matching_and_untagged(self, tmp_path: Path):
|
|
21
|
+
billing = _write(tmp_path, "001_billing.sql", "-- area: billing\nselect 1;\n")
|
|
22
|
+
reporting = _write(tmp_path, "002_reporting.sql", "-- area: reporting\nselect 1;\n")
|
|
23
|
+
common = _write(tmp_path, "003_common.sql", "select 1;\n")
|
|
24
|
+
|
|
25
|
+
result = list_migration_files(tmp_path, areas=frozenset({"billing"}))
|
|
26
|
+
assert set(result) == {billing, common}
|
|
27
|
+
assert reporting not in result
|
|
28
|
+
|
|
29
|
+
def test_exclude_areas_drops_matching_but_keeps_untagged(self, tmp_path: Path):
|
|
30
|
+
billing = _write(tmp_path, "001_billing.sql", "-- area: billing\nselect 1;\n")
|
|
31
|
+
common = _write(tmp_path, "002_common.sql", "select 1;\n")
|
|
32
|
+
|
|
33
|
+
result = list_migration_files(tmp_path, exclude_areas=frozenset({"billing"}))
|
|
34
|
+
assert result == [common]
|
|
35
|
+
assert billing not in result
|
|
36
|
+
|
|
37
|
+
def test_multiple_areas_on_one_file(self, tmp_path: Path):
|
|
38
|
+
multi = _write(tmp_path, "001_multi.sql", "-- area: billing, reporting\nselect 1;\n")
|
|
39
|
+
|
|
40
|
+
assert list_migration_files(tmp_path, areas=frozenset({"reporting"})) == [multi]
|
|
41
|
+
assert list_migration_files(tmp_path, exclude_areas=frozenset({"billing"})) == []
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pgdevkit.parser import parse_directory
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _write(dir: Path, name: str, content: str) -> Path:
|
|
9
|
+
p = dir / name
|
|
10
|
+
p.write_text(content, encoding="utf-8")
|
|
11
|
+
return p
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestParseDirectoryAreaFiltering:
|
|
15
|
+
def test_no_filters_parses_everything(self, tmp_path: Path):
|
|
16
|
+
_write(tmp_path, "billing.sql", "-- area: billing\nCREATE TABLE public.a (id int);\n")
|
|
17
|
+
_write(tmp_path, "common.sql", "CREATE TABLE public.b (id int);\n")
|
|
18
|
+
schema = parse_directory(tmp_path)
|
|
19
|
+
assert set(schema.tables) == {"public.a", "public.b"}
|
|
20
|
+
|
|
21
|
+
def test_areas_filter_keeps_matching_and_untagged(self, tmp_path: Path):
|
|
22
|
+
_write(tmp_path, "billing.sql", "-- area: billing\nCREATE TABLE public.a (id int);\n")
|
|
23
|
+
_write(tmp_path, "reporting.sql", "-- area: reporting\nCREATE TABLE public.b (id int);\n")
|
|
24
|
+
_write(tmp_path, "common.sql", "CREATE TABLE public.c (id int);\n")
|
|
25
|
+
|
|
26
|
+
schema = parse_directory(tmp_path, areas=frozenset({"billing"}))
|
|
27
|
+
assert set(schema.tables) == {"public.a", "public.c"}
|
|
28
|
+
|
|
29
|
+
def test_exclude_areas_drops_matching_but_keeps_untagged(self, tmp_path: Path):
|
|
30
|
+
_write(tmp_path, "billing.sql", "-- area: billing\nCREATE TABLE public.a (id int);\n")
|
|
31
|
+
_write(tmp_path, "common.sql", "CREATE TABLE public.c (id int);\n")
|
|
32
|
+
|
|
33
|
+
schema = parse_directory(tmp_path, exclude_areas=frozenset({"billing"}))
|
|
34
|
+
assert set(schema.tables) == {"public.c"}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json
RENAMED
|
File without changes
|
|
File without changes
|
{pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json
RENAMED
|
File without changes
|
|
File without changes
|
{pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql
RENAMED
|
File without changes
|
|
File without changes
|
{pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql
RENAMED
|
File without changes
|
{pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|