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.
- pgdevkit-0.5.0/.github/workflows/auto-release.yml +83 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/PKG-INFO +122 -1
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/README.md +121 -0
- pgdevkit-0.5.0/pgdevkit/areas.py +64 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/cli.py +97 -9
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/dialect.py +10 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/fetch_missing.py +6 -1
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/migrate.py +31 -55
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/parser.py +35 -6
- pgdevkit-0.5.0/pgdevkit/schemas.py +118 -0
- pgdevkit-0.5.0/pgdevkit/sql_text.py +56 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/api.py +49 -7
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/mssql/api.py +28 -4
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/schema.py +52 -17
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pyproject.toml +1 -1
- pgdevkit-0.5.0/tests/test_areas.py +106 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_migrate.py +2 -2
- pgdevkit-0.5.0/tests/test_migrate_areas.py +41 -0
- pgdevkit-0.5.0/tests/test_migrate_schemas.py +52 -0
- pgdevkit-0.5.0/tests/test_parser_areas.py +34 -0
- pgdevkit-0.5.0/tests/test_parser_schemas.py +98 -0
- pgdevkit-0.5.0/tests/test_schemas.py +156 -0
- pgdevkit-0.5.0/tests/testdb/test_schema_filtering.py +115 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/uv.lock +1 -1
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/.github/workflows/python-test.yml +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/.gitignore +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/.python-version +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/docs/database-layout.md +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/backends/__init__.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/backends/base.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/backends/mssql.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/backends/postgres.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/connection.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/db/__init__.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/db/complex_types.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/db/connection.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/db/crud.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/db/mssql_crud.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/db/mssql_sql.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/models.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/mssql_introspect.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/__init__.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/_docker.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/config.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/constants.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/container.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/mssql/__init__.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/mssql/constants.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/mssql/container.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/naming.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/skills/pgdevkit/SKILL.md +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/skills/pgdevkit/references/dynamic-sql.md +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/__init__.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/conftest.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/db/__init__.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/db/test_complex_types.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/db/test_connection.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/db/test_crud.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/db/test_mssql_crud_live.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/db/test_mssql_crud_sql.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_compare.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_compare_mssql_live.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_connection.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_dialect.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_diff_mssql.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_fetch_missing.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_fetch_missing_cli.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_mssql_introspect.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/test_parser_mssql.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/conftest.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_api.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_api_mssql.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_api_mssql_live.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_cli.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_config.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_config_mssql.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_container.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_mssql_constants.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_naming.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_query.py +0 -0
- {pgdevkit-0.3.8 → pgdevkit-0.5.0}/tests/testdb/test_schema.py +0 -0
- {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
|
+
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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)
|