pgdevkit 0.4.0__py3-none-any.whl → 0.6.0__py3-none-any.whl
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/cli.py +85 -14
- pgdevkit/dialect.py +10 -0
- pgdevkit/docs/database-layout.md +11 -3
- pgdevkit/envtag.py +54 -0
- pgdevkit/migrate.py +27 -53
- pgdevkit/parser.py +9 -2
- pgdevkit/schemas.py +118 -0
- pgdevkit/skills/pgdevkit/SKILL.md +2 -2
- pgdevkit/sql_text.py +56 -0
- pgdevkit/testdb/api.py +56 -7
- pgdevkit/testdb/mssql/api.py +36 -7
- pgdevkit/testdb/schema.py +72 -24
- {pgdevkit-0.4.0.dist-info → pgdevkit-0.6.0.dist-info}/METADATA +90 -24
- {pgdevkit-0.4.0.dist-info → pgdevkit-0.6.0.dist-info}/RECORD +16 -13
- {pgdevkit-0.4.0.dist-info → pgdevkit-0.6.0.dist-info}/WHEEL +0 -0
- {pgdevkit-0.4.0.dist-info → pgdevkit-0.6.0.dist-info}/entry_points.txt +0 -0
pgdevkit/cli.py
CHANGED
|
@@ -29,9 +29,32 @@ _AREA_OPTION = typer.Option(
|
|
|
29
29
|
_EXCLUDE_AREA_OPTION = typer.Option(
|
|
30
30
|
[], "--exclude-area", help="Skip files declaring this area (repeatable); untagged files are never excluded"
|
|
31
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
|
+
_TESTDB_ENV_OPTION = typer.Option(
|
|
45
|
+
"local_test",
|
|
46
|
+
"--env",
|
|
47
|
+
help="Environment to apply: skips any <name>.<other-env>.sql file (e.g. grants.prod.sql); untagged files always apply",
|
|
48
|
+
)
|
|
49
|
+
_MIGRATE_ENV_OPTION = typer.Option(
|
|
50
|
+
None,
|
|
51
|
+
"--env",
|
|
52
|
+
help="Restrict to files tagged for this environment (e.g. <name>.prod.sql); untagged files always apply; "
|
|
53
|
+
"omit to apply every file regardless of its env tag",
|
|
54
|
+
)
|
|
32
55
|
|
|
33
56
|
|
|
34
|
-
def
|
|
57
|
+
def _as_set(values: list[str]) -> frozenset[str] | None:
|
|
35
58
|
return frozenset(values) if values else None
|
|
36
59
|
|
|
37
60
|
testdb_app = typer.Typer(name="testdb", help="Manage the shared local Postgres test container")
|
|
@@ -59,17 +82,20 @@ def compare(
|
|
|
59
82
|
dialect: str = typer.Option("postgres", "--dialect", help="postgres (default) or mssql"),
|
|
60
83
|
area: list[str] = _AREA_OPTION,
|
|
61
84
|
exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
|
|
85
|
+
schema: list[str] = _SCHEMA_OPTION,
|
|
86
|
+
exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
|
|
62
87
|
scripts_dir: Path = typer.Argument(..., help="Directory containing SQL scripts"),
|
|
63
88
|
) -> None:
|
|
64
89
|
"""Compare SQL scripts to a live database and report differences."""
|
|
65
90
|
if not scripts_dir.is_dir():
|
|
66
91
|
err_console.print(f"[red]Error:[/red] {scripts_dir} is not a directory")
|
|
67
92
|
raise typer.Exit(2)
|
|
68
|
-
if report_extra_db and (area or exclude_area):
|
|
93
|
+
if report_extra_db and (area or exclude_area or schema or exclude_schema):
|
|
69
94
|
console.print(
|
|
70
|
-
"[yellow]⚠[/yellow] --report-extra-db with --area/--exclude-area will
|
|
71
|
-
"outside the filtered area(s) as \"missing in scripts\", since the
|
|
72
|
-
"of areas —
|
|
95
|
+
"[yellow]⚠[/yellow] --report-extra-db with --area/--exclude-area/--schema/--exclude-schema will "
|
|
96
|
+
"report every DB object outside the filtered area(s)/schema(s) as \"missing in scripts\", since the "
|
|
97
|
+
"live database has no concept of areas — and isn't itself filtered by --schema either — only the "
|
|
98
|
+
"scripts side is filtered."
|
|
73
99
|
)
|
|
74
100
|
|
|
75
101
|
try:
|
|
@@ -91,7 +117,12 @@ def compare(
|
|
|
91
117
|
|
|
92
118
|
with console.status("Parsing SQL scripts..."):
|
|
93
119
|
scripts_schema = parse_directory(
|
|
94
|
-
scripts_dir,
|
|
120
|
+
scripts_dir,
|
|
121
|
+
dialect=backend.dialect,
|
|
122
|
+
areas=_as_set(area),
|
|
123
|
+
exclude_areas=_as_set(exclude_area),
|
|
124
|
+
schemas=_as_set(schema),
|
|
125
|
+
exclude_schemas=_as_set(exclude_schema),
|
|
95
126
|
)
|
|
96
127
|
|
|
97
128
|
with console.status("Introspecting database..."):
|
|
@@ -188,17 +219,35 @@ def fetch_missing(
|
|
|
188
219
|
|
|
189
220
|
|
|
190
221
|
@testdb_app.command("up")
|
|
191
|
-
def testdb_up(
|
|
222
|
+
def testdb_up(
|
|
223
|
+
env: str = _TESTDB_ENV_OPTION,
|
|
224
|
+
area: list[str] = _AREA_OPTION,
|
|
225
|
+
exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
|
|
226
|
+
schema: list[str] = _SCHEMA_OPTION,
|
|
227
|
+
exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
|
|
228
|
+
) -> None:
|
|
192
229
|
"""Ensure the container is running, the workspace DB exists, and schema is applied."""
|
|
193
|
-
testdb.ensure_testdb(
|
|
230
|
+
testdb.ensure_testdb(
|
|
231
|
+
env=env, areas=_as_set(area), exclude_areas=_as_set(exclude_area), schemas=_as_set(schema),
|
|
232
|
+
exclude_schemas=_as_set(exclude_schema),
|
|
233
|
+
)
|
|
194
234
|
info = testdb.status()
|
|
195
235
|
console.print(f"[green]Test DB ready:[/green] {info['database']} ({info['dsn']})")
|
|
196
236
|
|
|
197
237
|
|
|
198
238
|
@testdb_app.command("reset")
|
|
199
|
-
def testdb_reset(
|
|
239
|
+
def testdb_reset(
|
|
240
|
+
env: str = _TESTDB_ENV_OPTION,
|
|
241
|
+
area: list[str] = _AREA_OPTION,
|
|
242
|
+
exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
|
|
243
|
+
schema: list[str] = _SCHEMA_OPTION,
|
|
244
|
+
exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
|
|
245
|
+
) -> None:
|
|
200
246
|
"""Drop and recreate only this workspace's database, then reapply schema + seed data."""
|
|
201
|
-
testdb.reset_testdb(
|
|
247
|
+
testdb.reset_testdb(
|
|
248
|
+
env=env, areas=_as_set(area), exclude_areas=_as_set(exclude_area), schemas=_as_set(schema),
|
|
249
|
+
exclude_schemas=_as_set(exclude_schema),
|
|
250
|
+
)
|
|
202
251
|
info = testdb.status()
|
|
203
252
|
console.print(f"[green]Test DB reset:[/green] {info['database']}")
|
|
204
253
|
|
|
@@ -272,6 +321,9 @@ def migrate_check(
|
|
|
272
321
|
),
|
|
273
322
|
area: list[str] = _AREA_OPTION,
|
|
274
323
|
exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
|
|
324
|
+
schema: list[str] = _SCHEMA_OPTION,
|
|
325
|
+
exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
|
|
326
|
+
env: str | None = _MIGRATE_ENV_OPTION,
|
|
275
327
|
) -> None:
|
|
276
328
|
"""List which migration files under migrations_dir are applied vs. pending."""
|
|
277
329
|
if not migrations_dir.is_dir():
|
|
@@ -281,7 +333,12 @@ def migrate_check(
|
|
|
281
333
|
conninfo = build_conninfo(url, entra_user)
|
|
282
334
|
tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir)
|
|
283
335
|
local_files = migrate.list_migration_files(
|
|
284
|
-
migrations_dir,
|
|
336
|
+
migrations_dir,
|
|
337
|
+
areas=_as_set(area),
|
|
338
|
+
exclude_areas=_as_set(exclude_area),
|
|
339
|
+
schemas=_as_set(schema),
|
|
340
|
+
exclude_schemas=_as_set(exclude_schema),
|
|
341
|
+
env=env,
|
|
285
342
|
)
|
|
286
343
|
try:
|
|
287
344
|
applied = migrate.applied_migrations(conninfo, tracking_table)
|
|
@@ -323,6 +380,9 @@ def migrate_apply(
|
|
|
323
380
|
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirm-target prompt"),
|
|
324
381
|
area: list[str] = _AREA_OPTION,
|
|
325
382
|
exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
|
|
383
|
+
schema: list[str] = _SCHEMA_OPTION,
|
|
384
|
+
exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
|
|
385
|
+
env: str | None = _MIGRATE_ENV_OPTION,
|
|
326
386
|
) -> None:
|
|
327
387
|
"""Apply pending migration files, in filename order, tracking each in tracking_table."""
|
|
328
388
|
if not migrations_dir.is_dir():
|
|
@@ -331,7 +391,8 @@ def migrate_apply(
|
|
|
331
391
|
|
|
332
392
|
conninfo = build_conninfo(url, entra_user)
|
|
333
393
|
tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir)
|
|
334
|
-
areas, exclude_areas =
|
|
394
|
+
areas, exclude_areas = _as_set(area), _as_set(exclude_area)
|
|
395
|
+
schemas, exclude_schemas = _as_set(schema), _as_set(exclude_schema)
|
|
335
396
|
target_desc = url.rsplit("@", 1)[-1] if "@" in url else url
|
|
336
397
|
if not yes:
|
|
337
398
|
typer.confirm(f"About to run migrations against {target_desc}. Continue?", abort=True)
|
|
@@ -341,13 +402,23 @@ def migrate_apply(
|
|
|
341
402
|
else:
|
|
342
403
|
try:
|
|
343
404
|
targets = migrate.pending_migrations(
|
|
344
|
-
migrations_dir,
|
|
405
|
+
migrations_dir,
|
|
406
|
+
conninfo,
|
|
407
|
+
tracking_table,
|
|
408
|
+
areas=areas,
|
|
409
|
+
exclude_areas=exclude_areas,
|
|
410
|
+
schemas=schemas,
|
|
411
|
+
exclude_schemas=exclude_schemas,
|
|
412
|
+
env=env,
|
|
345
413
|
)
|
|
346
414
|
except migrate.TrackingTableMissing:
|
|
347
415
|
err_console.print(
|
|
348
416
|
f"[yellow]⚠[/yellow] {tracking_table} not found — treating every migration as pending"
|
|
349
417
|
)
|
|
350
|
-
targets = migrate.list_migration_files(
|
|
418
|
+
targets = migrate.list_migration_files(
|
|
419
|
+
migrations_dir, areas=areas, exclude_areas=exclude_areas, schemas=schemas,
|
|
420
|
+
exclude_schemas=exclude_schemas, env=env,
|
|
421
|
+
)
|
|
351
422
|
|
|
352
423
|
if not targets:
|
|
353
424
|
console.print("No pending migrations.")
|
pgdevkit/dialect.py
CHANGED
|
@@ -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
|
pgdevkit/docs/database-layout.md
CHANGED
|
@@ -71,10 +71,18 @@ One object per file: `tables/user.sql`, `views/all_edits.sql`,
|
|
|
71
71
|
|---|---|
|
|
72
72
|
| `<name>.sql` | The object's live definition (`CREATE TABLE`, `CREATE OR REPLACE VIEW`, ...) |
|
|
73
73
|
| `<name>.test_data.json` | Seed rows for a table — a JSON array of row objects, loaded after the table is created |
|
|
74
|
-
| `<name>.init.sql` | One-time setup for an object (e.g. a backfill), run once, kept separate from the reusable definition |
|
|
75
|
-
| `<name>.
|
|
74
|
+
| `<name>.init.sql` | One-time setup for an object (e.g. a backfill), run once, kept separate from the reusable definition — `init` is reserved and is never treated as an environment tag |
|
|
75
|
+
| `<name>.<env>.sql` | Only applied when targeting environment `<env>` (any name you like — `prod`, `staging`, ...); a file with no such suffix is untagged and always applies, regardless of environment |
|
|
76
76
|
| `all.sql` | Generated concatenation of the whole tree — not hand-edited, not committed |
|
|
77
77
|
|
|
78
|
+
`pgdb testdb up`/`pgdb testdb reset` apply the `--env` they're given (default
|
|
79
|
+
`local_test`) — so an untagged `grants.sql` always applies, but
|
|
80
|
+
`grants.prod.sql` is skipped unless you pass `--env prod`. `pgdb migrate
|
|
81
|
+
check`/`pgdb migrate apply` accept the same `--env`, but it's optional with no
|
|
82
|
+
default: omit it and every file is a candidate regardless of its tag (today's
|
|
83
|
+
behavior); pass it to restrict to files tagged for that environment plus
|
|
84
|
+
untagged ones.
|
|
85
|
+
|
|
78
86
|
---
|
|
79
87
|
|
|
80
88
|
## Migrations
|
|
@@ -156,5 +164,5 @@ leading sort number.
|
|
|
156
164
|
- [ ] Object-type folder (`tables`, `views`, ...) matches the apply-order table above — that's what governs ordering, not the layer's leading number
|
|
157
165
|
- [ ] One-off changes go in `migrations/`, dated, never edited after applying
|
|
158
166
|
- [ ] The live `.sql` file is updated in the same change as any migration touching that object
|
|
159
|
-
- [ ]
|
|
167
|
+
- [ ] `.<env>.sql` files (e.g. `.prod.sql`) are skipped by `pgdb testdb` unless it's run with a matching `--env`
|
|
160
168
|
- [ ] Every table (and non-obvious column) has a `COMMENT ON`, placed in the object's own `.sql` file
|
pgdevkit/envtag.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Optional `<name>.<env>.sql` filename convention: a file whose dot-segment
|
|
2
|
+
immediately before `.sql` names a deployment environment (e.g.
|
|
3
|
+
`grants.prod.sql`, `seed.staging.sql`) is only in scope when the caller is
|
|
4
|
+
targeting that same environment. A plain `<name>.sql` file (no such segment)
|
|
5
|
+
is untagged/common and is always in scope, regardless of which environment is
|
|
6
|
+
requested — mirroring the untagged-file rule for `-- area:` tags in
|
|
7
|
+
areas.py.
|
|
8
|
+
|
|
9
|
+
This generalizes the older, hardcoded `.prod.sql` convention (still the usual
|
|
10
|
+
name for a production-only file — grants, real user accounts — that
|
|
11
|
+
`pgdb testdb` should never touch); any string can now be used as an
|
|
12
|
+
environment name.
|
|
13
|
+
|
|
14
|
+
`.init.sql` (one-time setup, see docs/database-layout.md) is reserved and is
|
|
15
|
+
never interpreted as an environment tag.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
_RESERVED_SQL_SUFFIXES = {"init"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def file_env(path: Path) -> str | None:
|
|
26
|
+
"""The environment tag from `path`'s name, or None if it's untagged (or
|
|
27
|
+
the suffix is a reserved, non-env one like `.init.sql`). Only `.sql`
|
|
28
|
+
files can carry a tag."""
|
|
29
|
+
if path.suffix != ".sql":
|
|
30
|
+
return None
|
|
31
|
+
stem = path.stem
|
|
32
|
+
base, dot, suffix = stem.rpartition(".")
|
|
33
|
+
if not dot or suffix in _RESERVED_SQL_SUFFIXES:
|
|
34
|
+
return None
|
|
35
|
+
return suffix
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def env_allowed(path: Path, env: str | None) -> bool:
|
|
39
|
+
"""Whether `path` is in scope for `env`. `env=None` means no environment
|
|
40
|
+
filtering was requested, so every file (tagged or not) is in scope."""
|
|
41
|
+
if env is None:
|
|
42
|
+
return True
|
|
43
|
+
tag = file_env(path)
|
|
44
|
+
return tag is None or tag == env
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def strip_env_suffix(path: Path) -> str:
|
|
48
|
+
"""`path.stem` with a trailing `.<tag>` removed, so a tagged file
|
|
49
|
+
resolves to the same logical name as its untagged counterpart would
|
|
50
|
+
(e.g. `grants.prod.sql` -> "grants", same as `grants.sql`)."""
|
|
51
|
+
tag = file_env(path)
|
|
52
|
+
if tag is None:
|
|
53
|
+
return path.stem
|
|
54
|
+
return path.stem[: -(len(tag) + 1)]
|
pgdevkit/migrate.py
CHANGED
|
@@ -19,6 +19,9 @@ from psycopg import errors as pg_errors
|
|
|
19
19
|
from psycopg import sql as pg_sql
|
|
20
20
|
|
|
21
21
|
from .areas import filter_by_area
|
|
22
|
+
from .envtag import env_allowed
|
|
23
|
+
from .schemas import filter_by_schema
|
|
24
|
+
from .sql_text import strip_line_comments
|
|
22
25
|
|
|
23
26
|
_IDENTIFIER = r"[A-Za-z_][A-Za-z0-9_]*"
|
|
24
27
|
_DEFAULT_TRACKING_TABLE = "public.schema_migrations"
|
|
@@ -124,61 +127,12 @@ def _split_sql(sql: str) -> list[str]:
|
|
|
124
127
|
return stmts
|
|
125
128
|
|
|
126
129
|
|
|
127
|
-
def _strip_line_comments(sql: str) -> str:
|
|
128
|
-
"""Drop '--' line comments, respecting string literals and $$...$$ blocks."""
|
|
129
|
-
buf: list[str] = []
|
|
130
|
-
i = 0
|
|
131
|
-
in_string = False
|
|
132
|
-
in_line_comment = False
|
|
133
|
-
dollar_tag: str | None = None
|
|
134
|
-
|
|
135
|
-
while i < len(sql):
|
|
136
|
-
c = sql[i]
|
|
137
|
-
if in_line_comment:
|
|
138
|
-
if c == "\n":
|
|
139
|
-
in_line_comment = False
|
|
140
|
-
buf.append(c)
|
|
141
|
-
elif dollar_tag is not None:
|
|
142
|
-
buf.append(c)
|
|
143
|
-
if c == "$" and sql[i:i + len(dollar_tag)] == dollar_tag:
|
|
144
|
-
buf.extend(list(dollar_tag[1:]))
|
|
145
|
-
i += len(dollar_tag)
|
|
146
|
-
dollar_tag = None
|
|
147
|
-
continue
|
|
148
|
-
elif in_string:
|
|
149
|
-
if c == "'" and i + 1 < len(sql) and sql[i + 1] == "'":
|
|
150
|
-
buf.append(c)
|
|
151
|
-
buf.append(sql[i + 1])
|
|
152
|
-
i += 2
|
|
153
|
-
continue
|
|
154
|
-
elif c == "'":
|
|
155
|
-
in_string = False
|
|
156
|
-
buf.append(c)
|
|
157
|
-
elif c == "-" and i + 1 < len(sql) and sql[i + 1] == "-":
|
|
158
|
-
in_line_comment = True
|
|
159
|
-
elif c == "$":
|
|
160
|
-
m = re.match(r"\$([A-Za-z0-9_]*)\$", sql[i:])
|
|
161
|
-
if m:
|
|
162
|
-
dollar_tag = m.group(0)
|
|
163
|
-
buf.extend(list(dollar_tag))
|
|
164
|
-
i += len(dollar_tag)
|
|
165
|
-
continue
|
|
166
|
-
buf.append(c)
|
|
167
|
-
elif c == "'":
|
|
168
|
-
in_string = True
|
|
169
|
-
buf.append(c)
|
|
170
|
-
else:
|
|
171
|
-
buf.append(c)
|
|
172
|
-
i += 1
|
|
173
|
-
return "".join(buf)
|
|
174
|
-
|
|
175
|
-
|
|
176
130
|
def _created_table_names(stmts: list[str]) -> list[str]:
|
|
177
131
|
"""Names of tables any CREATE TABLE statement targets, parsed via sqlglot (falls back to
|
|
178
132
|
regex on comment-stripped text for statements sqlglot's postgres dialect can't parse)."""
|
|
179
133
|
names: list[str] = []
|
|
180
134
|
for stmt in stmts:
|
|
181
|
-
stripped =
|
|
135
|
+
stripped = strip_line_comments(stmt)
|
|
182
136
|
if not re.search(r"CREATE\s+TABLE", stripped, re.IGNORECASE):
|
|
183
137
|
continue # skip sqlglot entirely for statements that can't be a CREATE TABLE
|
|
184
138
|
try:
|
|
@@ -220,7 +174,7 @@ def _idempotent_target(stmt: str) -> tuple[str, ...] | None:
|
|
|
220
174
|
COLUMN. None if the statement isn't one of these shapes — including any CREATE OR
|
|
221
175
|
REPLACE, which is never safe to treat as a no-op just because the object exists, since
|
|
222
176
|
the migration could be replacing it with different content."""
|
|
223
|
-
stripped =
|
|
177
|
+
stripped = strip_line_comments(stmt).strip()
|
|
224
178
|
if re.search(r"\bOR\s+REPLACE\b", stripped, re.IGNORECASE):
|
|
225
179
|
return None
|
|
226
180
|
|
|
@@ -281,9 +235,19 @@ def list_migration_files(
|
|
|
281
235
|
*,
|
|
282
236
|
areas: frozenset[str] | None = None,
|
|
283
237
|
exclude_areas: frozenset[str] | None = None,
|
|
238
|
+
schemas: frozenset[str] | None = None,
|
|
239
|
+
exclude_schemas: frozenset[str] | None = None,
|
|
240
|
+
env: str | None = None,
|
|
284
241
|
) -> list[Path]:
|
|
242
|
+
"""Migration files under migrations_dir, restricted by area (see
|
|
243
|
+
`.areas`), by schema (see `.schemas`), and by environment tag (see
|
|
244
|
+
`.envtag`) — e.g. a `2026-07-10_backfill.prod.sql` is only included when
|
|
245
|
+
`env="prod"`. `env=None` (the default) applies no environment filtering
|
|
246
|
+
at all, so every file is a candidate regardless of its tag."""
|
|
285
247
|
files = sorted(migrations_dir.glob("*.sql"))
|
|
286
|
-
|
|
248
|
+
files = filter_by_area(files, only=areas, exclude=exclude_areas)
|
|
249
|
+
files = filter_by_schema(files, only=schemas, exclude=exclude_schemas)
|
|
250
|
+
return [f for f in files if env_allowed(f, env)]
|
|
287
251
|
|
|
288
252
|
|
|
289
253
|
def applied_migrations(conninfo: str, tracking_table: str) -> dict[str, tuple[datetime, str]]:
|
|
@@ -306,9 +270,19 @@ def pending_migrations(
|
|
|
306
270
|
*,
|
|
307
271
|
areas: frozenset[str] | None = None,
|
|
308
272
|
exclude_areas: frozenset[str] | None = None,
|
|
273
|
+
schemas: frozenset[str] | None = None,
|
|
274
|
+
exclude_schemas: frozenset[str] | None = None,
|
|
275
|
+
env: str | None = None,
|
|
309
276
|
) -> list[Path]:
|
|
310
277
|
applied = applied_migrations(conninfo, tracking_table)
|
|
311
|
-
files = list_migration_files(
|
|
278
|
+
files = list_migration_files(
|
|
279
|
+
migrations_dir,
|
|
280
|
+
areas=areas,
|
|
281
|
+
exclude_areas=exclude_areas,
|
|
282
|
+
schemas=schemas,
|
|
283
|
+
exclude_schemas=exclude_schemas,
|
|
284
|
+
env=env,
|
|
285
|
+
)
|
|
312
286
|
return [p for p in files if p.name not in applied]
|
|
313
287
|
|
|
314
288
|
|
pgdevkit/parser.py
CHANGED
|
@@ -10,6 +10,7 @@ import sqlglot.expressions as exp
|
|
|
10
10
|
|
|
11
11
|
from .areas import area_allowed, parse_areas
|
|
12
12
|
from .dialect import Dialect, POSTGRES, resolve_dialect
|
|
13
|
+
from .schemas import schema_allowed, sql_schemas
|
|
13
14
|
from .models import (
|
|
14
15
|
ColumnDef, ConstraintDef, CompositeTypeDef, DatabaseSchema,
|
|
15
16
|
EnumDef, FunctionDef, IndexDef, TableDef, ViewDef,
|
|
@@ -55,15 +56,21 @@ def parse_directory(
|
|
|
55
56
|
dialect: str | Dialect = "postgres",
|
|
56
57
|
areas: frozenset[str] | None = None,
|
|
57
58
|
exclude_areas: frozenset[str] | None = None,
|
|
59
|
+
schemas: frozenset[str] | None = None,
|
|
60
|
+
exclude_schemas: frozenset[str] | None = None,
|
|
58
61
|
) -> DatabaseSchema:
|
|
59
62
|
resolved = resolve_dialect(dialect)
|
|
60
63
|
db_schema = DatabaseSchema()
|
|
61
64
|
for sql_file in sorted(_iter_sql_files(scripts_dir)):
|
|
62
|
-
# Read once and reuse for
|
|
63
|
-
# filtering the file list up front (which would need its own read).
|
|
65
|
+
# Read once and reuse for the area/schema checks and parsing, rather
|
|
66
|
+
# than filtering the file list up front (which would need its own read).
|
|
64
67
|
content = sql_file.read_text(encoding="utf-8")
|
|
65
68
|
if (areas or exclude_areas) and not area_allowed(parse_areas(content), only=areas, exclude=exclude_areas):
|
|
66
69
|
continue
|
|
70
|
+
if (schemas or exclude_schemas) and not schema_allowed(
|
|
71
|
+
sql_schemas(content, resolved), only=schemas, exclude=exclude_schemas
|
|
72
|
+
):
|
|
73
|
+
continue
|
|
67
74
|
_parse_file(sql_file, db_schema, resolved, content=content)
|
|
68
75
|
return db_schema
|
|
69
76
|
|
pgdevkit/schemas.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Schema-membership filtering, composable with (but independent of) the
|
|
2
|
+
`-- area:` tag filtering in `areas.py`.
|
|
3
|
+
|
|
4
|
+
Unlike area, which is an explicit opt-in tag, a file's schema membership is
|
|
5
|
+
derived by parsing its SQL: every schema-qualified (or default-schema, when
|
|
6
|
+
unqualified) table/view/function/index/schema reference across every
|
|
7
|
+
statement in the file, DDL or DML alike.
|
|
8
|
+
|
|
9
|
+
A file whose schema(s) can't be determined -- content sqlglot can't parse at
|
|
10
|
+
all, or with no table/schema reference in it (e.g. a DO block touching no
|
|
11
|
+
table) -- is treated the same as an untagged file for `-- area:`: `only`
|
|
12
|
+
filters always keep it, `exclude` filters never drop it. Being unable to
|
|
13
|
+
prove a file belongs to an excluded schema is not the same as proving it
|
|
14
|
+
doesn't, so this errs toward keeping the file in scope rather than silently
|
|
15
|
+
dropping it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import sqlglot
|
|
24
|
+
import sqlglot.expressions as exp
|
|
25
|
+
|
|
26
|
+
from .dialect import Dialect, POSTGRES, SYSTEM_SCHEMAS
|
|
27
|
+
from .sql_text import strip_line_comments
|
|
28
|
+
|
|
29
|
+
_CREATE_SCHEMA_RE = re.compile(
|
|
30
|
+
r"CREATE\s+SCHEMA\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:AUTHORIZATION\s+)?\"?(\w+)\"?", re.IGNORECASE
|
|
31
|
+
)
|
|
32
|
+
_QUALIFIED_REF_RE = re.compile(r"\b(\w+)\.\w+")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _regex_fallback(sql: str) -> frozenset[str]:
|
|
36
|
+
"""Crude schema scan used when sqlglot can't parse `sql` at all, or found
|
|
37
|
+
no reference. Errs toward over-matching (any `schema.name`-shaped token)
|
|
38
|
+
rather than under-matching -- a false positive here only keeps a file in
|
|
39
|
+
scope for one extra schema filter, where a false negative would silently
|
|
40
|
+
drop it from an `only` filter. Comments are stripped first so a schema
|
|
41
|
+
merely *mentioned* in one (e.g. "-- see billing.invoice for context")
|
|
42
|
+
isn't mistaken for a real reference; string literals are left as-is
|
|
43
|
+
(matching one inside a literal is the same over-matching tradeoff as
|
|
44
|
+
above, not worth the complexity of also blanking them out)."""
|
|
45
|
+
stripped = strip_line_comments(sql)
|
|
46
|
+
schemas = set(_CREATE_SCHEMA_RE.findall(stripped))
|
|
47
|
+
schemas.update(m.group(1) for m in _QUALIFIED_REF_RE.finditer(stripped))
|
|
48
|
+
return frozenset(s for s in schemas if s.lower() not in SYSTEM_SCHEMAS)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def sql_schemas(content: str, dialect: Dialect = POSTGRES) -> frozenset[str]:
|
|
52
|
+
"""Schema names referenced anywhere in `content`: every table/view/
|
|
53
|
+
function/index reference's schema (its `dialect.default_schema` when
|
|
54
|
+
unqualified), plus any `CREATE SCHEMA name`. Falls back to a regex scan
|
|
55
|
+
when sqlglot can't parse the content, or finds no reference at all."""
|
|
56
|
+
try:
|
|
57
|
+
exprs = sqlglot.parse(content, dialect=dialect.sqlglot_name, error_level=sqlglot.ErrorLevel.IGNORE)
|
|
58
|
+
except Exception: # noqa: BLE001
|
|
59
|
+
return _regex_fallback(content)
|
|
60
|
+
|
|
61
|
+
schemas: set[str] = set()
|
|
62
|
+
for e in exprs:
|
|
63
|
+
if e is None:
|
|
64
|
+
continue
|
|
65
|
+
for t in e.find_all(exp.Table):
|
|
66
|
+
# T-SQL's `EXEC('...dynamic sql...')` parses as a Table subquery
|
|
67
|
+
# whose "name" is the whole literal string, not a real
|
|
68
|
+
# identifier -- e.g. a quoted "CREATE SCHEMA app". Guard against
|
|
69
|
+
# treating that as a schema-qualified (or default-schema) table
|
|
70
|
+
# reference by rejecting a name containing whitespace -- a real
|
|
71
|
+
# identifier, quoted or not (even one with hyphens or other
|
|
72
|
+
# punctuation), never has any, while embedded SQL text always
|
|
73
|
+
# does. Still accepts an empty name, the legitimate shape
|
|
74
|
+
# sqlglot gives a bare `CREATE SCHEMA x` (whose schema name ends
|
|
75
|
+
# up in `db`, not `this`).
|
|
76
|
+
if t.name and re.search(r"\s", t.name):
|
|
77
|
+
continue
|
|
78
|
+
db_node = t.args.get("db")
|
|
79
|
+
name = db_node.name if db_node else dialect.default_schema
|
|
80
|
+
if name.lower() not in SYSTEM_SCHEMAS:
|
|
81
|
+
schemas.add(name)
|
|
82
|
+
|
|
83
|
+
if not schemas:
|
|
84
|
+
return _regex_fallback(content)
|
|
85
|
+
return frozenset(schemas)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def file_schemas(path: Path, dialect: Dialect = POSTGRES) -> frozenset[str]:
|
|
89
|
+
"""Schema names referenced in the file at `path`."""
|
|
90
|
+
return sql_schemas(path.read_text(encoding="utf-8"), dialect)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def schema_allowed(
|
|
94
|
+
schemas: frozenset[str],
|
|
95
|
+
*,
|
|
96
|
+
only: frozenset[str] | None = None,
|
|
97
|
+
exclude: frozenset[str] | None = None,
|
|
98
|
+
) -> bool:
|
|
99
|
+
"""Whether a file that references `schemas` passes an `only`/`exclude` filter."""
|
|
100
|
+
if exclude and schemas & exclude:
|
|
101
|
+
return False
|
|
102
|
+
if only and schemas and not (schemas & only):
|
|
103
|
+
return False
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def filter_by_schema(
|
|
108
|
+
paths: list[Path],
|
|
109
|
+
*,
|
|
110
|
+
only: frozenset[str] | None = None,
|
|
111
|
+
exclude: frozenset[str] | None = None,
|
|
112
|
+
dialect: Dialect = POSTGRES,
|
|
113
|
+
) -> list[Path]:
|
|
114
|
+
"""`paths` restricted by an `only`/`exclude` schema filter. Returns `paths`
|
|
115
|
+
unchanged (no file reads) when neither filter is set."""
|
|
116
|
+
if not only and not exclude:
|
|
117
|
+
return paths
|
|
118
|
+
return [p for p in paths if schema_allowed(file_schemas(p, dialect), only=only, exclude=exclude)]
|
|
@@ -253,7 +253,7 @@ sqlfmt --check db/queries/ # CI check
|
|
|
253
253
|
|
|
254
254
|
## The `database/` folder & backfilling untracked objects
|
|
255
255
|
|
|
256
|
-
See [docs/database-layout.md](../../docs/database-layout.md) for the full convention: layer directories, object-type subfolders and their apply order, file-naming rules (`.test_data.json`, `.init.sql`,
|
|
256
|
+
See [docs/database-layout.md](../../docs/database-layout.md) for the full convention: layer directories, object-type subfolders and their apply order, file-naming rules (`.test_data.json`, `.init.sql`, `.<env>.sql`), and how migrations are organised.
|
|
257
257
|
|
|
258
258
|
If a table, view, or function was created directly on the database and never got a `.sql` file:
|
|
259
259
|
|
|
@@ -285,6 +285,6 @@ Reports drift between the `database/` `.sql` files and the actual schema — tab
|
|
|
285
285
|
- [ ] All parameters use `%(name)s` style with a dict argument
|
|
286
286
|
- [ ] Results mapped to a Pydantic model; table-mapped models extend `PostgresTableModel`
|
|
287
287
|
- [ ] No `LATERAL JOIN` — use a CTE that groups/aggregates first, then joins it
|
|
288
|
-
- [ ]
|
|
288
|
+
- [ ] `.<env>.sql` files (e.g. `.prod.sql`) are skipped by `pgdb testdb` unless it's run with a matching `--env`
|
|
289
289
|
- [ ] Every table (and non-obvious column) has a `COMMENT ON`, placed in the object's own `.sql` file
|
|
290
290
|
- [ ] Untracked DB objects backfilled via `pgdb fetch-missing`, not left undocumented
|
pgdevkit/sql_text.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Small text-level SQL helpers shared by modules that can't import each
|
|
2
|
+
other directly (`migrate.py` imports `schemas.py`, so `schemas.py` can't
|
|
3
|
+
import back from `migrate.py`)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def strip_line_comments(sql: str) -> str:
|
|
11
|
+
"""Drop '--' line comments, respecting string literals and $$...$$ blocks."""
|
|
12
|
+
buf: list[str] = []
|
|
13
|
+
i = 0
|
|
14
|
+
in_string = False
|
|
15
|
+
in_line_comment = False
|
|
16
|
+
dollar_tag: str | None = None
|
|
17
|
+
|
|
18
|
+
while i < len(sql):
|
|
19
|
+
c = sql[i]
|
|
20
|
+
if in_line_comment:
|
|
21
|
+
if c == "\n":
|
|
22
|
+
in_line_comment = False
|
|
23
|
+
buf.append(c)
|
|
24
|
+
elif dollar_tag is not None:
|
|
25
|
+
buf.append(c)
|
|
26
|
+
if c == "$" and sql[i:i + len(dollar_tag)] == dollar_tag:
|
|
27
|
+
buf.extend(list(dollar_tag[1:]))
|
|
28
|
+
i += len(dollar_tag)
|
|
29
|
+
dollar_tag = None
|
|
30
|
+
continue
|
|
31
|
+
elif in_string:
|
|
32
|
+
if c == "'" and i + 1 < len(sql) and sql[i + 1] == "'":
|
|
33
|
+
buf.append(c)
|
|
34
|
+
buf.append(sql[i + 1])
|
|
35
|
+
i += 2
|
|
36
|
+
continue
|
|
37
|
+
elif c == "'":
|
|
38
|
+
in_string = False
|
|
39
|
+
buf.append(c)
|
|
40
|
+
elif c == "-" and i + 1 < len(sql) and sql[i + 1] == "-":
|
|
41
|
+
in_line_comment = True
|
|
42
|
+
elif c == "$":
|
|
43
|
+
m = re.match(r"\$([A-Za-z0-9_]*)\$", sql[i:])
|
|
44
|
+
if m:
|
|
45
|
+
dollar_tag = m.group(0)
|
|
46
|
+
buf.extend(list(dollar_tag))
|
|
47
|
+
i += len(dollar_tag)
|
|
48
|
+
continue
|
|
49
|
+
buf.append(c)
|
|
50
|
+
elif c == "'":
|
|
51
|
+
in_string = True
|
|
52
|
+
buf.append(c)
|
|
53
|
+
else:
|
|
54
|
+
buf.append(c)
|
|
55
|
+
i += 1
|
|
56
|
+
return "".join(buf)
|
pgdevkit/testdb/api.py
CHANGED
|
@@ -68,24 +68,59 @@ async def _drop_database(db_name: str) -> None:
|
|
|
68
68
|
await con.execute(SQL("DROP DATABASE IF EXISTS {}").format(Identifier(db_name)))
|
|
69
69
|
|
|
70
70
|
|
|
71
|
-
async def _apply(
|
|
71
|
+
async def _apply(
|
|
72
|
+
config: ProjectConfig,
|
|
73
|
+
db_name: str,
|
|
74
|
+
force_reset: bool,
|
|
75
|
+
*,
|
|
76
|
+
env: str = "local_test",
|
|
77
|
+
areas: frozenset[str] | None = None,
|
|
78
|
+
exclude_areas: frozenset[str] | None = None,
|
|
79
|
+
schemas: frozenset[str] | None = None,
|
|
80
|
+
exclude_schemas: frozenset[str] | None = None,
|
|
81
|
+
) -> None:
|
|
72
82
|
async with await psycopg.AsyncConnection.connect(_db_dsn(db_name), autocommit=True) as con:
|
|
73
83
|
await apply_schema(
|
|
74
84
|
con,
|
|
75
85
|
config.root / config.database_dir,
|
|
76
86
|
extensions=config.extensions,
|
|
77
87
|
force_reset=force_reset,
|
|
88
|
+
env=env,
|
|
89
|
+
areas=areas,
|
|
90
|
+
exclude_areas=exclude_areas,
|
|
91
|
+
schemas=schemas,
|
|
92
|
+
exclude_schemas=exclude_schemas,
|
|
78
93
|
)
|
|
79
94
|
|
|
80
95
|
|
|
81
|
-
def ensure_testdb(
|
|
96
|
+
def ensure_testdb(
|
|
97
|
+
project_root: Path | None = None,
|
|
98
|
+
force_reset: bool = False,
|
|
99
|
+
*,
|
|
100
|
+
env: str = "local_test",
|
|
101
|
+
areas: frozenset[str] | None = None,
|
|
102
|
+
exclude_areas: frozenset[str] | None = None,
|
|
103
|
+
schemas: frozenset[str] | None = None,
|
|
104
|
+
exclude_schemas: frozenset[str] | None = None,
|
|
105
|
+
) -> dict[str, str]:
|
|
82
106
|
"""Ensure the shared container is running, this workspace's database
|
|
83
107
|
exists, and its schema is applied. Returns the {PREFIX}POSTGRES_* env
|
|
84
108
|
vars for this workspace (or the mssql equivalent's env vars, per
|
|
85
|
-
`config.engine`).
|
|
109
|
+
`config.engine`).
|
|
110
|
+
|
|
111
|
+
`env` selects which environment-tagged files apply (see pgdevkit.envtag,
|
|
112
|
+
e.g. a `grants.prod.sql` is skipped unless env="prod").
|
|
113
|
+
|
|
114
|
+
`areas`/`exclude_areas` and `schemas`/`exclude_schemas` restrict which
|
|
115
|
+
database/ files get applied -- e.g. for a test DB scoped to one area or
|
|
116
|
+
schema. Neither filters what gets *dropped* by force_reset/clean, only
|
|
117
|
+
what gets (re)applied."""
|
|
86
118
|
config, db_name = _resolve(project_root)
|
|
87
119
|
if config.engine == "mssql":
|
|
88
|
-
return _mssql_api().ensure_testdb(
|
|
120
|
+
return _mssql_api().ensure_testdb(
|
|
121
|
+
config, db_name, force_reset,
|
|
122
|
+
env=env, areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas,
|
|
123
|
+
)
|
|
89
124
|
|
|
90
125
|
ensure_container()
|
|
91
126
|
|
|
@@ -93,16 +128,30 @@ def ensure_testdb(project_root: Path | None = None, force_reset: bool = False) -
|
|
|
93
128
|
if force_reset:
|
|
94
129
|
await _drop_database(db_name)
|
|
95
130
|
await _ensure_database(db_name)
|
|
96
|
-
await _apply(
|
|
131
|
+
await _apply(
|
|
132
|
+
config, db_name, force_reset,
|
|
133
|
+
env=env, areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas,
|
|
134
|
+
)
|
|
97
135
|
|
|
98
136
|
asyncio.run(_run())
|
|
99
137
|
return _env_for(config, db_name)
|
|
100
138
|
|
|
101
139
|
|
|
102
|
-
def reset_testdb(
|
|
140
|
+
def reset_testdb(
|
|
141
|
+
project_root: Path | None = None,
|
|
142
|
+
*,
|
|
143
|
+
env: str = "local_test",
|
|
144
|
+
areas: frozenset[str] | None = None,
|
|
145
|
+
exclude_areas: frozenset[str] | None = None,
|
|
146
|
+
schemas: frozenset[str] | None = None,
|
|
147
|
+
exclude_schemas: frozenset[str] | None = None,
|
|
148
|
+
) -> dict[str, str]:
|
|
103
149
|
"""Drop and recreate only this workspace's database, then reapply
|
|
104
150
|
schema and seed data."""
|
|
105
|
-
return ensure_testdb(
|
|
151
|
+
return ensure_testdb(
|
|
152
|
+
project_root, force_reset=True,
|
|
153
|
+
env=env, areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas,
|
|
154
|
+
)
|
|
106
155
|
|
|
107
156
|
|
|
108
157
|
def clean_testdb(project_root: Path | None = None, all: bool = False) -> None:
|
pgdevkit/testdb/mssql/api.py
CHANGED
|
@@ -9,6 +9,7 @@ import mssql_python
|
|
|
9
9
|
|
|
10
10
|
from ...db.mssql_sql import ident, json_encode_value
|
|
11
11
|
from ...dialect import MSSQL
|
|
12
|
+
from ...envtag import strip_env_suffix
|
|
12
13
|
from .. import query
|
|
13
14
|
from ..config import ProjectConfig
|
|
14
15
|
from ..schema import _iter_sql_files, _strip_layer_prefix
|
|
@@ -101,7 +102,17 @@ async def _insert_test_data(json_file: Path, table: str, force_reset: bool, conn
|
|
|
101
102
|
await asyncio.to_thread(_run)
|
|
102
103
|
|
|
103
104
|
|
|
104
|
-
async def _apply(
|
|
105
|
+
async def _apply(
|
|
106
|
+
config: ProjectConfig,
|
|
107
|
+
db_name: str,
|
|
108
|
+
force_reset: bool,
|
|
109
|
+
*,
|
|
110
|
+
env: str = "local_test",
|
|
111
|
+
areas: frozenset[str] | None = None,
|
|
112
|
+
exclude_areas: frozenset[str] | None = None,
|
|
113
|
+
schemas: frozenset[str] | None = None,
|
|
114
|
+
exclude_schemas: frozenset[str] | None = None,
|
|
115
|
+
) -> None:
|
|
105
116
|
def _connect() -> Any:
|
|
106
117
|
return mssql_python.connect(_db_dsn(db_name), autocommit=True)
|
|
107
118
|
|
|
@@ -110,30 +121,48 @@ async def _apply(config: ProjectConfig, db_name: str, force_reset: bool) -> None
|
|
|
110
121
|
database_dir = config.root / config.database_dir
|
|
111
122
|
if not database_dir.is_dir():
|
|
112
123
|
return
|
|
113
|
-
for file, sql in _iter_sql_files(
|
|
124
|
+
for file, sql in _iter_sql_files(
|
|
125
|
+
database_dir, MSSQL,
|
|
126
|
+
env=env, areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas,
|
|
127
|
+
):
|
|
114
128
|
for batch in query.split_tsql_batches(sql):
|
|
115
129
|
|
|
116
130
|
def _exec(batch: str = batch) -> None:
|
|
117
131
|
conn.cursor().execute(batch)
|
|
118
132
|
|
|
119
133
|
await asyncio.to_thread(_exec)
|
|
120
|
-
|
|
134
|
+
table_stem = strip_env_suffix(file)
|
|
135
|
+
json_file = file.parent / f"{table_stem}.test_data.json"
|
|
121
136
|
if json_file.exists():
|
|
122
137
|
schema_name = _strip_layer_prefix(file.parent.parent.name)
|
|
123
|
-
|
|
124
|
-
|
|
138
|
+
await _insert_test_data(
|
|
139
|
+
json_file, f"{schema_name}.{_strip_layer_prefix(table_stem)}", force_reset, conn
|
|
140
|
+
)
|
|
125
141
|
finally:
|
|
126
142
|
await asyncio.to_thread(conn.close)
|
|
127
143
|
|
|
128
144
|
|
|
129
|
-
def ensure_testdb(
|
|
145
|
+
def ensure_testdb(
|
|
146
|
+
config: ProjectConfig,
|
|
147
|
+
db_name: str,
|
|
148
|
+
force_reset: bool,
|
|
149
|
+
*,
|
|
150
|
+
env: str = "local_test",
|
|
151
|
+
areas: frozenset[str] | None = None,
|
|
152
|
+
exclude_areas: frozenset[str] | None = None,
|
|
153
|
+
schemas: frozenset[str] | None = None,
|
|
154
|
+
exclude_schemas: frozenset[str] | None = None,
|
|
155
|
+
) -> dict[str, str]:
|
|
130
156
|
ensure_mssql_container()
|
|
131
157
|
|
|
132
158
|
async def _run() -> None:
|
|
133
159
|
if force_reset:
|
|
134
160
|
await _drop_database(db_name)
|
|
135
161
|
await _ensure_database(db_name)
|
|
136
|
-
await _apply(
|
|
162
|
+
await _apply(
|
|
163
|
+
config, db_name, force_reset,
|
|
164
|
+
env=env, areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas,
|
|
165
|
+
)
|
|
137
166
|
|
|
138
167
|
asyncio.run(_run())
|
|
139
168
|
return _env_for(config, db_name)
|
pgdevkit/testdb/schema.py
CHANGED
|
@@ -13,9 +13,12 @@ import sqlglot.expressions as exp
|
|
|
13
13
|
from psycopg.rows import dict_row
|
|
14
14
|
from psycopg.sql import SQL, Identifier, Placeholder
|
|
15
15
|
|
|
16
|
+
from ..areas import area_allowed, parse_areas
|
|
16
17
|
from ..db.complex_types import ComplexHelper
|
|
17
|
-
from ..dialect import Dialect, POSTGRES
|
|
18
|
+
from ..dialect import Dialect, POSTGRES, SYSTEM_SCHEMAS
|
|
19
|
+
from ..envtag import env_allowed, strip_env_suffix
|
|
18
20
|
from ..parser import IGNORED_DIR_NAMES
|
|
21
|
+
from ..schemas import schema_allowed, sql_schemas
|
|
19
22
|
|
|
20
23
|
logger = logging.getLogger(__name__)
|
|
21
24
|
logging.getLogger("sqlglot").setLevel(logging.ERROR)
|
|
@@ -38,7 +41,7 @@ _TYPE_ORDER = {
|
|
|
38
41
|
|
|
39
42
|
|
|
40
43
|
def _get_type_order(path: Path) -> int:
|
|
41
|
-
filename = re.sub(r"^\d+(\.\d+)?", "", path
|
|
44
|
+
filename = re.sub(r"^\d+(\.\d+)?", "", strip_env_suffix(path)).removeprefix("_")
|
|
42
45
|
if filename in _TYPE_ORDER:
|
|
43
46
|
return _TYPE_ORDER[filename]
|
|
44
47
|
if path.parent.name in _TYPE_ORDER:
|
|
@@ -65,16 +68,14 @@ _SCHEMA_QUALIFIED_TYPES = {
|
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
# reverse-order resolution can then apply files out of their intended order.
|
|
77
|
-
_SYSTEM_SCHEMAS = {"pg_catalog", "information_schema", "sys"}
|
|
71
|
+
# SYSTEM_SCHEMAS (see dialect.py) matters most here for T-SQL, where
|
|
72
|
+
# "IF NOT EXISTS (SELECT ... FROM sys.schemas/sys.tables/sys.objects ...)
|
|
73
|
+
# BEGIN CREATE ... END" is the idiomatic idempotency-guard pattern (T-SQL has
|
|
74
|
+
# no native "CREATE TABLE IF NOT EXISTS"/"CREATE SCHEMA IF NOT EXISTS"), so
|
|
75
|
+
# without this exclusion nearly every T-SQL file would pick up a spurious,
|
|
76
|
+
# never-resolvable dependency on "sys.*" and get shuffled into the
|
|
77
|
+
# delayed-retry path, whose reverse-order resolution can then apply files
|
|
78
|
+
# out of their intended order.
|
|
78
79
|
|
|
79
80
|
_DECLARE_RE = re.compile(
|
|
80
81
|
r"CREATE\s+(?:OR\s+REPLACE\s+)?(?:TABLE|VIEW|FUNCTION|PROCEDURE|TYPE|SCHEMA)\s+(\w+\.\w+)", re.IGNORECASE
|
|
@@ -89,7 +90,7 @@ def _get_sql_deps_regex_fallback(sql: str) -> set[str]:
|
|
|
89
90
|
is fine."""
|
|
90
91
|
declares = set(_DECLARE_RE.findall(sql))
|
|
91
92
|
deps = set(_DEPEND_RE.findall(sql))
|
|
92
|
-
deps = {d for d in deps if d.split(".", 1)[0].lower() not in
|
|
93
|
+
deps = {d for d in deps if d.split(".", 1)[0].lower() not in SYSTEM_SCHEMAS}
|
|
93
94
|
return deps - declares
|
|
94
95
|
|
|
95
96
|
|
|
@@ -109,7 +110,7 @@ def _get_sql_deps(sql: str, dialect: Dialect = POSTGRES) -> set[str]:
|
|
|
109
110
|
for t in e.find_all(exp.Table):
|
|
110
111
|
db_node = t.args.get("db")
|
|
111
112
|
if t.args.get("this") is not None and db_node is not None:
|
|
112
|
-
if db_node.name.lower() in
|
|
113
|
+
if db_node.name.lower() in SYSTEM_SCHEMAS:
|
|
113
114
|
continue
|
|
114
115
|
# exp.table_name(), not str(t): str() includes " AS alias" for
|
|
115
116
|
# an aliased reference (e.g. "FROM editing.visit v"), which
|
|
@@ -119,8 +120,25 @@ def _get_sql_deps(sql: str, dialect: Dialect = POSTGRES) -> set[str]:
|
|
|
119
120
|
return deps
|
|
120
121
|
|
|
121
122
|
|
|
122
|
-
def _iter_sql_files(
|
|
123
|
-
|
|
123
|
+
def _iter_sql_files(
|
|
124
|
+
database_dir: Path,
|
|
125
|
+
dialect: Dialect = POSTGRES,
|
|
126
|
+
*,
|
|
127
|
+
env: str = "local_test",
|
|
128
|
+
areas: frozenset[str] | None = None,
|
|
129
|
+
exclude_areas: frozenset[str] | None = None,
|
|
130
|
+
schemas: frozenset[str] | None = None,
|
|
131
|
+
exclude_schemas: frozenset[str] | None = None,
|
|
132
|
+
):
|
|
133
|
+
"""Yield (Path, sql_content) pairs in dependency-safe execution order.
|
|
134
|
+
|
|
135
|
+
A file tagged for another environment (see pgdevkit.envtag) is dropped
|
|
136
|
+
during the initial file walk, before dependency ordering even sees it.
|
|
137
|
+
|
|
138
|
+
A file dropped by the area/schema filter is skipped entirely -- as if it
|
|
139
|
+
didn't exist -- so it never delivers a dependency another (in-scope)
|
|
140
|
+
file waits on; that's the same tradeoff `list_migration_files`/
|
|
141
|
+
`parse_directory` make for migrations/database code files."""
|
|
124
142
|
files: list[Path] = []
|
|
125
143
|
for root, dirs, dbfiles in os.walk(database_dir):
|
|
126
144
|
dirs[:] = [d for d in dirs if d not in IGNORED_DIR_NAMES]
|
|
@@ -129,8 +147,9 @@ def _iter_sql_files(database_dir: Path, dialect: Dialect = POSTGRES):
|
|
|
129
147
|
for file in dbfiles:
|
|
130
148
|
if file in ("all.sql", "100_permissions.sql"):
|
|
131
149
|
continue
|
|
132
|
-
|
|
133
|
-
|
|
150
|
+
path = Path(root) / file
|
|
151
|
+
if file.endswith(".sql") and env_allowed(path, env):
|
|
152
|
+
files.append(path)
|
|
134
153
|
|
|
135
154
|
delivered: set[str] = set()
|
|
136
155
|
delayed: list[tuple[str | None, Path, str]] = []
|
|
@@ -138,10 +157,16 @@ def _iter_sql_files(database_dir: Path, dialect: Dialect = POSTGRES):
|
|
|
138
157
|
|
|
139
158
|
for file in sorted(files, key=lambda p: (_get_type_order(p), p.name)):
|
|
140
159
|
content = file.read_text(encoding="utf-8")
|
|
160
|
+
if (areas or exclude_areas) and not area_allowed(parse_areas(content), only=areas, exclude=exclude_areas):
|
|
161
|
+
continue
|
|
162
|
+
if (schemas or exclude_schemas) and not schema_allowed(
|
|
163
|
+
sql_schemas(content, dialect), only=schemas, exclude=exclude_schemas
|
|
164
|
+
):
|
|
165
|
+
continue
|
|
141
166
|
deps = _get_sql_deps(content, dialect)
|
|
142
167
|
if file.parent.name in _SCHEMA_QUALIFIED_TYPES:
|
|
143
168
|
schema = _strip_layer_prefix(file.parent.parent.name)
|
|
144
|
-
full_name = f"{schema}.{_strip_layer_prefix(file
|
|
169
|
+
full_name = f"{schema}.{_strip_layer_prefix(strip_env_suffix(file))}"
|
|
145
170
|
deps.discard(full_name) # the file's own CREATE target is not a real dependency
|
|
146
171
|
all_declared.add(full_name)
|
|
147
172
|
if not deps or all(d in delivered for d in deps):
|
|
@@ -242,14 +267,27 @@ async def apply_schema(
|
|
|
242
267
|
force_reset: bool = False,
|
|
243
268
|
*,
|
|
244
269
|
dialect: Dialect = POSTGRES,
|
|
270
|
+
env: str = "local_test",
|
|
271
|
+
areas: frozenset[str] | None = None,
|
|
272
|
+
exclude_areas: frozenset[str] | None = None,
|
|
273
|
+
schemas: frozenset[str] | None = None,
|
|
274
|
+
exclude_schemas: frozenset[str] | None = None,
|
|
245
275
|
) -> None:
|
|
246
276
|
"""Apply every .sql file under database_dir (in dependency-safe order)
|
|
247
277
|
and seed any matching .test_data.json files. Safe to call repeatedly.
|
|
248
278
|
|
|
279
|
+
A file tagged for another environment (e.g. `grants.prod.sql` when
|
|
280
|
+
`env="local_test"`) is skipped — see pgdevkit.envtag.
|
|
281
|
+
|
|
249
282
|
`migrations/` subdirectories are never applied here — they're for
|
|
250
283
|
one-time manual application against real (already-provisioned)
|
|
251
284
|
databases, not for building a fresh schema. The base object files under
|
|
252
|
-
`database_dir` must reflect the current, final schema on their own.
|
|
285
|
+
`database_dir` must reflect the current, final schema on their own.
|
|
286
|
+
|
|
287
|
+
`areas`/`exclude_areas` and `schemas`/`exclude_schemas` restrict which
|
|
288
|
+
files get applied, the same as `pgdevkit migrate`/`compare` — handy for
|
|
289
|
+
standing up a test DB scoped to one area or schema instead of the whole
|
|
290
|
+
project."""
|
|
253
291
|
await con.set_autocommit(True)
|
|
254
292
|
for extension in extensions:
|
|
255
293
|
await con.execute(SQL("CREATE EXTENSION IF NOT EXISTS {e}").format(e=Identifier(extension)))
|
|
@@ -258,14 +296,24 @@ async def apply_schema(
|
|
|
258
296
|
|
|
259
297
|
async def _apply(file: Path, sql: str) -> None:
|
|
260
298
|
await con.execute(cast(Any, sql))
|
|
261
|
-
|
|
299
|
+
table_stem = strip_env_suffix(file)
|
|
300
|
+
json_file = file.parent / f"{table_stem}.test_data.json"
|
|
262
301
|
if json_file.exists():
|
|
263
302
|
schema_name = _strip_layer_prefix(file.parent.parent.name)
|
|
264
|
-
|
|
265
|
-
|
|
303
|
+
await _insert_test_data(
|
|
304
|
+
json_file, f"{schema_name}.{_strip_layer_prefix(table_stem)}", force_reset, con, complex_helper
|
|
305
|
+
)
|
|
266
306
|
|
|
267
307
|
failures: list[tuple[Path, str]] = []
|
|
268
|
-
for file, sql in _iter_sql_files(
|
|
308
|
+
for file, sql in _iter_sql_files(
|
|
309
|
+
database_dir,
|
|
310
|
+
dialect,
|
|
311
|
+
env=env,
|
|
312
|
+
areas=areas,
|
|
313
|
+
exclude_areas=exclude_areas,
|
|
314
|
+
schemas=schemas,
|
|
315
|
+
exclude_schemas=exclude_schemas,
|
|
316
|
+
):
|
|
269
317
|
try:
|
|
270
318
|
await _apply(file, sql)
|
|
271
319
|
except Exception as e: # noqa: BLE001
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pgdevkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: A helper for developing with Postgres
|
|
5
5
|
Requires-Python: >=3.14
|
|
6
6
|
Requires-Dist: docker>=7.1.0
|
|
@@ -79,7 +79,13 @@ 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
|
|
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
|
|
83
89
|
|
|
84
90
|
Any migration file or `database/` code file can declare one or more areas by
|
|
85
91
|
starting with a `-- area:` comment:
|
|
@@ -97,43 +103,95 @@ first real statement); a `-- area:` comment later in the file doesn't count.
|
|
|
97
103
|
A file with no directive is untagged, and untagged files are treated as
|
|
98
104
|
shared/common.
|
|
99
105
|
|
|
100
|
-
|
|
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:
|
|
101
119
|
|
|
102
120
|
- `--area NAME` (repeatable) — restrict to files declaring one of the given
|
|
103
121
|
areas, **plus every untagged file** (untagged files always stay in scope).
|
|
104
122
|
- `--exclude-area NAME` (repeatable) — drop files declaring one of the given
|
|
105
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.
|
|
106
128
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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).
|
|
110
134
|
|
|
111
135
|
```bash
|
|
112
136
|
pgdb migrate apply path/to/database/_migration_scripts --url ... --area billing
|
|
113
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
|
|
114
140
|
```
|
|
115
141
|
|
|
116
142
|
`compare`'s default report (no `--report-extra-db`) only checks that the
|
|
117
143
|
filtered scripts exist correctly in the DB, so it composes safely with area
|
|
118
|
-
filtering. Passing `--report-extra-db` together with
|
|
119
|
-
reports every DB object outside the filtered area(s)
|
|
120
|
-
scripts" — since the live database has no concept
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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:
|
|
131
159
|
`parse_areas`/`file_areas` read a file's declared areas, and
|
|
132
160
|
`area_allowed`/`filter_by_area` apply the `only`/`exclude` semantics above.
|
|
133
|
-
`pgdevkit.
|
|
134
|
-
`
|
|
135
|
-
|
|
136
|
-
|
|
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
|
+
|
|
170
|
+
## Environment-tagged files (`<name>.<env>.sql`)
|
|
171
|
+
|
|
172
|
+
A file whose name ends `.<env>.sql` (e.g. `grants.prod.sql`,
|
|
173
|
+
`seed.staging.sql`) is only in scope when targeting that environment; a
|
|
174
|
+
plain `<name>.sql` file is untagged and always in scope, regardless of
|
|
175
|
+
environment. `.init.sql` (see `docs/database-layout.md`) is reserved and is
|
|
176
|
+
never treated as an environment tag.
|
|
177
|
+
|
|
178
|
+
- `pgdb testdb up`/`pgdb testdb reset` accept `--env` (default
|
|
179
|
+
`local_test`) — so an untagged `grants.sql` always applies, but
|
|
180
|
+
`grants.prod.sql` is skipped unless run with `--env prod`.
|
|
181
|
+
- `pgdb migrate check`/`pgdb migrate apply` accept `--env` too, but it's
|
|
182
|
+
optional with **no** default: omit it and every file is a candidate
|
|
183
|
+
regardless of its tag (unchanged, today's behavior); pass it to restrict
|
|
184
|
+
to files tagged for that environment plus untagged ones.
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
pgdb testdb up --env prod # apply prod-tagged files too, against the local test container
|
|
188
|
+
pgdb migrate apply path/to/database/_migration_scripts --url ... --env prod
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`pgdevkit.envtag` exposes the same logic for scripting: `file_env` reads a
|
|
192
|
+
file's tag, `env_allowed` applies the filtering semantics above, and
|
|
193
|
+
`strip_env_suffix` returns a tagged file's logical name (e.g.
|
|
194
|
+
`grants.prod.sql` -> `"grants"`).
|
|
137
195
|
|
|
138
196
|
## `pgdb testdb`
|
|
139
197
|
|
|
@@ -163,7 +221,15 @@ def ensure_test_postgres():
|
|
|
163
221
|
os.environ[k] = v
|
|
164
222
|
```
|
|
165
223
|
|
|
166
|
-
CLI: `pgdb testdb up|reset|run-sql|status|shell|clean`.
|
|
224
|
+
CLI: `pgdb testdb up|reset|run-sql|status|shell|clean`. `up`/`reset` accept
|
|
225
|
+
`--env` (default `local_test`) — see "Environment-tagged files" above.
|
|
226
|
+
|
|
227
|
+
`up`/`reset` accept `--area`/`--exclude-area` and `--schema`/`--exclude-schema`
|
|
228
|
+
(see "Area and schema filtering" above) to scope which `database/` files get
|
|
229
|
+
applied — e.g. `pgdb testdb up --schema billing` for a test DB with only the
|
|
230
|
+
`billing` schema's tables/views/functions, without waiting on the rest of the
|
|
231
|
+
project's schema to apply. `ensure_testdb`/`reset_testdb` take the same
|
|
232
|
+
keyword arguments when called from Python (e.g. from a pytest fixture).
|
|
167
233
|
|
|
168
234
|
Container connection defaults (`localhost:54322`, `postgres`/`testpwd`) can
|
|
169
235
|
be overridden with `PGDEVKIT_TESTDB_HOST`, `PGDEVKIT_TESTDB_PORT`,
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
pgdevkit/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
2
|
pgdevkit/areas.py,sha256=iEmOObYxhZyDFl2iQVf1OQJN4JtZ-H3EL-qMfxYZdSQ,2203
|
|
3
|
-
pgdevkit/cli.py,sha256=
|
|
3
|
+
pgdevkit/cli.py,sha256=vCxea9c4PxfkpKyUJoMGdUaiLD1bwGtRVDgXbT4XAZc,21826
|
|
4
4
|
pgdevkit/connection.py,sha256=x49aXKsJfyOsDTlqURs9idQUWaD-RO-XdtAqsG3guBI,4000
|
|
5
|
-
pgdevkit/dialect.py,sha256=
|
|
5
|
+
pgdevkit/dialect.py,sha256=gZwn4UNZ8PYBPC4CnYnfA95C7ppv7sWmpZSF8pjSPVw,3659
|
|
6
6
|
pgdevkit/diff.py,sha256=Y2TbjwpFHYik0wZDhoI--9EAlIfp4AUg8BUubkOXyUg,9295
|
|
7
|
+
pgdevkit/envtag.py,sha256=FdRqjLRdS9O9LSvoEzrRsFNeqiyZTLLzFgEkIirJyZI,2006
|
|
7
8
|
pgdevkit/fetch_missing.py,sha256=vb8hXWBKMWgqkxHso5SuhX9l6rxNXfaybAEsajPLYow,8495
|
|
8
9
|
pgdevkit/introspect.py,sha256=QYIlRuZk4lBHGxaW7zb_kFRIuNfi3AxH0Q2F_GKzlVI,7870
|
|
9
10
|
pgdevkit/lakebase.py,sha256=0PABOk4p-2klLTY8QAUibQf9QeyNHKywUWEWGdkUY8k,3165
|
|
10
|
-
pgdevkit/migrate.py,sha256=
|
|
11
|
+
pgdevkit/migrate.py,sha256=AnlxOy6Sk0iQlGq5lHYIaIBDwljO9EhCVwDkyRZmvQQ,13499
|
|
11
12
|
pgdevkit/models.py,sha256=dAZ8f7pgDsxoLVrDWsM4h0qwJ0pOzXDdWMGsnjrW80E,2103
|
|
12
13
|
pgdevkit/mssql_introspect.py,sha256=Fhv1N4zHYEDZ0PEy0OIpawUmoIFeoi-Lm97pmEyDmYY,12031
|
|
13
|
-
pgdevkit/parser.py,sha256=
|
|
14
|
+
pgdevkit/parser.py,sha256=FmXL6-eCFzP_0FNyTrg4wF3aPzPeq7re2i0xvrSJIcs,16885
|
|
15
|
+
pgdevkit/schemas.py,sha256=zZsg3m9FtI7IUG8e8HfRruCh6-O6bdpbA7Kjnk3AYJg,5053
|
|
16
|
+
pgdevkit/sql_text.py,sha256=ednfdfxGBTWYqQ5Q3l3Zv9vrjjFmLiRlGKyq3tcozco,1744
|
|
14
17
|
pgdevkit/backends/__init__.py,sha256=ys4VBglMImRtnudJBlTPVBNevDMRippuwAJOz0yRKiY,610
|
|
15
18
|
pgdevkit/backends/base.py,sha256=0i0KLCYVWlpVFnUoq2t0LCRE_abyWPPIhw1bMxmW9KQ,1048
|
|
16
19
|
pgdevkit/backends/mssql.py,sha256=pocBdoQ14ZnYLutXEOStVmJvmpclXGs3gaxHN0uJCMk,1068
|
|
@@ -25,22 +28,22 @@ pgdevkit/db/mssql_crud.py,sha256=yN4izZ0fsFwAs14008JECHH4dqjNbdHUHGEG8kidyNs,113
|
|
|
25
28
|
pgdevkit/db/mssql_sql.py,sha256=DO3UMsTFN9pg0tNdaPGb0wf6lWlHGcqwqzkrGVhJF2g,2292
|
|
26
29
|
pgdevkit/testdb/__init__.py,sha256=LpdC2XMBxqn9JrGmXhIho3sK8O1aNo_aD7leKyBr-Lo,204
|
|
27
30
|
pgdevkit/testdb/_docker.py,sha256=kj_0xrGO_mxHlK83ilsB-yE0ELX1kpH22ruYrS-N3iU,1467
|
|
28
|
-
pgdevkit/testdb/api.py,sha256=
|
|
31
|
+
pgdevkit/testdb/api.py,sha256=K-y4O6nC3z_gr9LtVHe-v-T-1UjhmFMw1j-dLa0FcnY,7680
|
|
29
32
|
pgdevkit/testdb/config.py,sha256=vjcDr3jH3-5VakNAEMqQu_ZHPXiYIvM1_BSETSJW5QU,2164
|
|
30
33
|
pgdevkit/testdb/constants.py,sha256=xi8uIoEgN9LrRkr41j3mmWB9PqqU0r8aCUb2JJXBAs0,1165
|
|
31
34
|
pgdevkit/testdb/container.py,sha256=kELTb6qaECAO9a5Bwvx1btv6i5QKRFAa9PX6auWymNo,2807
|
|
32
35
|
pgdevkit/testdb/naming.py,sha256=Kyk5qEfxUkbggUG5bD7U7CJ_kAET332VPQNB-O5kvTY,1318
|
|
33
36
|
pgdevkit/testdb/query.py,sha256=iYpy_EoYIq5USfwr6pNktmDjcGi_yQx0vHvjHZNGRr0,3854
|
|
34
|
-
pgdevkit/testdb/schema.py,sha256=
|
|
37
|
+
pgdevkit/testdb/schema.py,sha256=tLd5ovQSXdFoOOl9KWBI35wZHqhD_XIgWfwBVDIZJj0,13838
|
|
35
38
|
pgdevkit/testdb/mssql/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
|
|
36
|
-
pgdevkit/testdb/mssql/api.py,sha256=
|
|
39
|
+
pgdevkit/testdb/mssql/api.py,sha256=sFVSSQbN3Jk62YRQkT6atLSsl1-8hJVjCko5goOQkio,8374
|
|
37
40
|
pgdevkit/testdb/mssql/constants.py,sha256=7n_NcYuhabbtb18CqG_H_rRbI7Wb_qu_l4nKk3281vI,2847
|
|
38
41
|
pgdevkit/testdb/mssql/container.py,sha256=_rG0AMDXrNwok_E60Xai0DZdRiZ6Ba1j3GaLUnSAueg,3364
|
|
39
|
-
pgdevkit/docs/database-layout.md,sha256=
|
|
40
|
-
pgdevkit/skills/pgdevkit/SKILL.md,sha256=
|
|
42
|
+
pgdevkit/docs/database-layout.md,sha256=eqsEWuA0Vuqj5jRCXx24J45D1EjnDe3KNUQLxpdsW9M,7454
|
|
43
|
+
pgdevkit/skills/pgdevkit/SKILL.md,sha256=ADqEaQtMJPUUAiieqokzUUmIf27bWWJNB1ZvsfgiKAE,12205
|
|
41
44
|
pgdevkit/skills/pgdevkit/references/dynamic-sql.md,sha256=aTR1sx81moiTwaMaiOecaqk-9rT8y5Zxz7W1BoC3vK0,2774
|
|
42
45
|
pgdevkit/skills/pgdevkit/references/temporal-tables.md,sha256=LwZnItvQWI7bB0oRHxIfUc0ynnq7N0V5Mnv-QHvFN3Q,1364
|
|
43
|
-
pgdevkit-0.
|
|
44
|
-
pgdevkit-0.
|
|
45
|
-
pgdevkit-0.
|
|
46
|
-
pgdevkit-0.
|
|
46
|
+
pgdevkit-0.6.0.dist-info/METADATA,sha256=_aDm11tWJ9KphnjydC8Wq2nAKNkHd_xprRYiztnlPmc,18982
|
|
47
|
+
pgdevkit-0.6.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
48
|
+
pgdevkit-0.6.0.dist-info/entry_points.txt,sha256=FK2P55lf0WaGlOZJIINOGgCt8WB7ep0OnPsgnrp80bg,42
|
|
49
|
+
pgdevkit-0.6.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|