pgdevkit 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/PKG-INFO +1 -1
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/testdb/schema.py +7 -52
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pyproject.toml +1 -1
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/skills/pgdevkit/SKILL.md +2 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/test_schema.py +7 -42
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/uv.lock +1 -1
- pgdevkit-0.2.0/tests/testdb/fixtures/database/app/migrations/002_unsafe_drop_gadget_note.sql +0 -3
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/.github/workflows/python-test.yml +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/.gitignore +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/.python-version +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/README.md +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/docs/database-layout.md +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/cli.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/connection.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/db/__init__.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/db/complex_types.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/db/connection.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/db/crud.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/fetch_missing.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/models.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/parser.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/testdb/__init__.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/testdb/api.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/testdb/config.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/testdb/constants.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/testdb/container.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/testdb/naming.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/skills/pgdevkit/references/dynamic-sql.md +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/__init__.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/conftest.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/db/__init__.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/db/test_complex_types.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/db/test_connection.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/db/test_crud.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/test_compare.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/test_connection.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/test_fetch_missing.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/test_fetch_missing_cli.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/conftest.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/test_api.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/test_cli.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/test_config.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/test_container.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/test_naming.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/test_query.py +0 -0
|
@@ -172,54 +172,19 @@ async def _insert_test_data(
|
|
|
172
172
|
await cur.executemany(insert_sql, rows)
|
|
173
173
|
|
|
174
174
|
|
|
175
|
-
_UNSAFE_MIGRATION_PATTERNS = (
|
|
176
|
-
"DROP COLUMN",
|
|
177
|
-
"DROP TABLE",
|
|
178
|
-
"TRUNCATE",
|
|
179
|
-
"RENAME COLUMN",
|
|
180
|
-
"RENAME TO",
|
|
181
|
-
"DELETE FROM",
|
|
182
|
-
"OWNER TO",
|
|
183
|
-
)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
def _is_additive_migration(sql: str) -> bool:
|
|
187
|
-
"""Only pure-additive migrations (ADD COLUMN/CREATE ... IF NOT EXISTS,
|
|
188
|
-
etc.) are safe to (re-)apply against a schema that already reflects
|
|
189
|
-
later migrations — skip anything that could drop, rename, truncate, or
|
|
190
|
-
delete existing structure/data, alter an existing column, or change
|
|
191
|
-
ownership. This is a substring heuristic, not a SQL parser, so it errs
|
|
192
|
-
towards skipping (a false negative here means real data loss replayed
|
|
193
|
-
on every apply_schema() call) rather than trying to be exhaustive."""
|
|
194
|
-
normalized = sql.upper()
|
|
195
|
-
has_alter_column = "ALTER COLUMN" in normalized and "ADD COLUMN" not in normalized
|
|
196
|
-
has_unsafe_pattern = any(pattern in normalized for pattern in _UNSAFE_MIGRATION_PATTERNS)
|
|
197
|
-
return not (has_alter_column or has_unsafe_pattern)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def _iter_migration_files(database_dir: Path):
|
|
201
|
-
"""Yield every `*.sql` file in any `migrations/` subdirectory, in
|
|
202
|
-
filename order (the project's naming convention — numeric or date
|
|
203
|
-
prefixes — sorts chronologically)."""
|
|
204
|
-
for root, _, files in os.walk(database_dir):
|
|
205
|
-
if Path(root).name != "migrations":
|
|
206
|
-
continue
|
|
207
|
-
for file in sorted(files):
|
|
208
|
-
if file.endswith(".sql"):
|
|
209
|
-
yield Path(root) / file
|
|
210
|
-
|
|
211
|
-
|
|
212
175
|
async def apply_schema(
|
|
213
176
|
con: psycopg.AsyncConnection,
|
|
214
177
|
database_dir: Path,
|
|
215
178
|
extensions: tuple[str, ...] = (),
|
|
216
179
|
force_reset: bool = False,
|
|
217
180
|
) -> None:
|
|
218
|
-
"""Apply every .sql file under database_dir (in dependency-safe order)
|
|
219
|
-
seed any matching .test_data.json files
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
181
|
+
"""Apply every .sql file under database_dir (in dependency-safe order)
|
|
182
|
+
and seed any matching .test_data.json files. Safe to call repeatedly.
|
|
183
|
+
|
|
184
|
+
`migrations/` subdirectories are never applied here — they're for
|
|
185
|
+
one-time manual application against real (already-provisioned)
|
|
186
|
+
databases, not for building a fresh schema. The base object files under
|
|
187
|
+
`database_dir` must reflect the current, final schema on their own."""
|
|
223
188
|
await con.set_autocommit(True)
|
|
224
189
|
for extension in extensions:
|
|
225
190
|
await con.execute(SQL("CREATE EXTENSION IF NOT EXISTS {e}").format(e=Identifier(extension)))
|
|
@@ -243,13 +208,3 @@ async def apply_schema(
|
|
|
243
208
|
|
|
244
209
|
for file, sql in failures:
|
|
245
210
|
await _apply(file, sql)
|
|
246
|
-
|
|
247
|
-
for migration_file in _iter_migration_files(database_dir):
|
|
248
|
-
content = migration_file.read_text(encoding="utf-8")
|
|
249
|
-
if not _is_additive_migration(content):
|
|
250
|
-
continue
|
|
251
|
-
try:
|
|
252
|
-
await con.execute(cast(Any, content))
|
|
253
|
-
logger.info("Applied migration %s", migration_file.name)
|
|
254
|
-
except Exception as e: # noqa: BLE001
|
|
255
|
-
logger.debug("Migration %s skipped (likely already applied): %s", migration_file.name, e)
|
|
@@ -75,6 +75,8 @@ def _testdb_env():
|
|
|
75
75
|
|
|
76
76
|
`ensure_testdb()` starts the shared `pgdevkit-postgres` container if needed (via `podman`), creates a database scoped to this project+branch (so different worktrees/branches never collide), and applies every `.sql` file under `database_dir` in dependency order, seeding any `.test_data.json` sidecar files.
|
|
77
77
|
|
|
78
|
+
**`migrations/` is never applied here, on purpose.** If the test schema is missing something, that's a sign the base `tables/`/`views`/... file has drifted behind a migration that was only ever run manually against a real database — fix the base file, don't add migration-replay to `apply_schema()` (tried once, reverted: a migration can't be judged "safe to re-run" from its SQL text alone — see `docs/database-layout.md`'s Migrations section).
|
|
79
|
+
|
|
78
80
|
| What do you need? | Command |
|
|
79
81
|
|---|---|
|
|
80
82
|
| First-time setup / apply new files | `pgdb testdb up` |
|
|
@@ -7,34 +7,13 @@ import pytest
|
|
|
7
7
|
|
|
8
8
|
from pgdevkit.testdb import constants
|
|
9
9
|
from pgdevkit.testdb.container import ensure_container
|
|
10
|
-
from pgdevkit.testdb.schema import
|
|
10
|
+
from pgdevkit.testdb.schema import apply_schema
|
|
11
11
|
from tests.testdb.conftest import RUN_SUFFIX, requires_podman
|
|
12
12
|
|
|
13
13
|
FIXTURES = Path(__file__).parent / "fixtures" / "database"
|
|
14
14
|
TEST_DB = f"pgdevkit_schema_selftest_{RUN_SUFFIX}"
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
@pytest.mark.parametrize(
|
|
18
|
-
"sql,expected",
|
|
19
|
-
[
|
|
20
|
-
("ALTER TABLE t ADD COLUMN IF NOT EXISTS x text;", True),
|
|
21
|
-
("CREATE TABLE IF NOT EXISTS t (id serial primary key);", True),
|
|
22
|
-
("CREATE INDEX IF NOT EXISTS idx ON t(x);", True),
|
|
23
|
-
("ALTER TABLE t ALTER COLUMN x TYPE text;", False),
|
|
24
|
-
("ALTER TABLE t DROP COLUMN x;", False),
|
|
25
|
-
("DROP TABLE t;", False),
|
|
26
|
-
("DROP TABLE IF EXISTS t;", False),
|
|
27
|
-
("TRUNCATE t;", False),
|
|
28
|
-
("ALTER TABLE t RENAME COLUMN x TO y;", False),
|
|
29
|
-
("ALTER TABLE t RENAME TO t2;", False),
|
|
30
|
-
("DELETE FROM t WHERE id = 1;", False),
|
|
31
|
-
("ALTER TABLE t OWNER TO someone;", False),
|
|
32
|
-
],
|
|
33
|
-
)
|
|
34
|
-
def test_is_additive_migration(sql, expected):
|
|
35
|
-
assert _is_additive_migration(sql) is expected
|
|
36
|
-
|
|
37
|
-
|
|
38
17
|
def _admin_dsn() -> str:
|
|
39
18
|
return constants.conninfo("postgres")
|
|
40
19
|
|
|
@@ -136,25 +115,11 @@ async def test_apply_schema_seeds_composite_enum_and_jsonb_columns(schema_test_d
|
|
|
136
115
|
|
|
137
116
|
|
|
138
117
|
@requires_podman
|
|
139
|
-
async def
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
async with con.cursor() as cur:
|
|
145
|
-
await cur.execute(
|
|
146
|
-
"SELECT column_name FROM information_schema.columns "
|
|
147
|
-
"WHERE table_schema = 'app' AND table_name = 'gadget' AND column_name = 'note'"
|
|
148
|
-
)
|
|
149
|
-
row = await cur.fetchone()
|
|
150
|
-
assert row is not None
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
@requires_podman
|
|
154
|
-
async def test_apply_schema_skips_unsafe_migrations(schema_test_db):
|
|
155
|
-
# app/migrations/002_unsafe_drop_gadget_note.sql drops the column that
|
|
156
|
-
# 001 added — apply_schema must skip it (not execute it), so the column
|
|
157
|
-
# added by 001 must still be present afterwards.
|
|
118
|
+
async def test_apply_schema_never_applies_migrations_dir(schema_test_db):
|
|
119
|
+
# migrations/ is for one-time manual application against real databases,
|
|
120
|
+
# not for building a fresh schema — the base table file is the only
|
|
121
|
+
# source of truth apply_schema uses. app/migrations/001_add_gadget_note.sql
|
|
122
|
+
# adds a column that gadget.sql itself doesn't have; it must NOT appear.
|
|
158
123
|
async with await psycopg.AsyncConnection.connect(_db_dsn(), autocommit=True) as con:
|
|
159
124
|
await apply_schema(con, FIXTURES)
|
|
160
125
|
async with con.cursor() as cur:
|
|
@@ -163,4 +128,4 @@ async def test_apply_schema_skips_unsafe_migrations(schema_test_db):
|
|
|
163
128
|
"WHERE table_schema = 'app' AND table_name = 'gadget' AND column_name = 'note'"
|
|
164
129
|
)
|
|
165
130
|
row = await cur.fetchone()
|
|
166
|
-
assert row is
|
|
131
|
+
assert row is None
|
|
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.2.0 → pgdevkit-0.2.1}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json
RENAMED
|
File without changes
|
|
File without changes
|
{pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/fixtures/database/app/tables/widget.test_data.json
RENAMED
|
File without changes
|
|
File without changes
|
{pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgdevkit-0.2.0 → pgdevkit-0.2.1}/tests/testdb/fixtures/database/app/views/a_wrapper_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
|