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 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 _as_area_set(values: list[str]) -> frozenset[str] | None:
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 report every DB object "
71
- "outside the filtered area(s) as \"missing in scripts\", since the live database has no concept "
72
- "of areas — only the scripts side is filtered."
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, dialect=backend.dialect, areas=_as_area_set(area), exclude_areas=_as_area_set(exclude_area)
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() -> None:
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() -> None:
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, areas=_as_area_set(area), exclude_areas=_as_area_set(exclude_area)
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 = _as_area_set(area), _as_area_set(exclude_area)
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, conninfo, tracking_table, areas=areas, exclude_areas=exclude_areas
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(migrations_dir, areas=areas, exclude_areas=exclude_areas)
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
@@ -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>.prod.sql` / `.prod` anywhere in the name | Production-only (real permission grants, real user accounts) — skipped by `pgdb testdb` |
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
- - [ ] `.prod` files are production-only and skipped by `pgdb testdb`
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 = _strip_line_comments(stmt)
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 = _strip_line_comments(stmt).strip()
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
- return filter_by_area(files, only=areas, exclude=exclude_areas)
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(migrations_dir, areas=areas, exclude_areas=exclude_areas)
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 both the area check and parsing, rather than
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`, `.prod`), and how migrations are organised.
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
- - [ ] `.prod` files are production-only and skipped by `pgdb testdb`
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(config: ProjectConfig, db_name: str, force_reset: bool) -> None:
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(project_root: Path | None = None, force_reset: bool = False) -> dict[str, str]:
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(config, db_name, force_reset)
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(config, db_name, force_reset)
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(project_root: Path | None = None) -> dict[str, str]:
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(project_root, force_reset=True)
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:
@@ -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(config: ProjectConfig, db_name: str, force_reset: bool) -> None:
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(database_dir, MSSQL):
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
- json_file = file.with_suffix(".test_data.json")
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
- table_stem = _strip_layer_prefix(file.stem)
124
- await _insert_test_data(json_file, f"{schema_name}.{table_stem}", force_reset, conn)
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(config: ProjectConfig, db_name: str, force_reset: bool) -> dict[str, str]:
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(config, db_name, force_reset)
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.name).removeprefix("_").removesuffix(".sql")
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
- # Schemas that hold system catalog views/tables, never a file this project
69
- # manages -- a reference to one is never a real cross-file dependency to
70
- # wait for. Matters most for T-SQL, where "IF NOT EXISTS (SELECT ... FROM
71
- # sys.schemas/sys.tables/sys.objects ...) BEGIN CREATE ... END" is the
72
- # idiomatic idempotency-guard pattern (T-SQL has no native "CREATE TABLE IF
73
- # NOT EXISTS"/"CREATE SCHEMA IF NOT EXISTS"), so without this exclusion
74
- # nearly every T-SQL file would pick up a spurious, never-resolvable
75
- # dependency on "sys.*" and get shuffled into the delayed-retry path, whose
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 _SYSTEM_SCHEMAS}
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 _SYSTEM_SCHEMAS:
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(database_dir: Path, dialect: Dialect = POSTGRES):
123
- """Yield (Path, sql_content) pairs in dependency-safe execution order."""
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
- if file.endswith(".sql") and ".prod" not in file:
133
- files.append(Path(root) / file)
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.stem)}"
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
- json_file = file.with_suffix(".test_data.json")
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
- table_stem = _strip_layer_prefix(file.stem)
265
- await _insert_test_data(json_file, f"{schema_name}.{table_stem}", force_reset, con, complex_helper)
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(database_dir, dialect):
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.4.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 tagging and filtering
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
- `pgdb compare`, `pgdb migrate check`, and `pgdb migrate apply` all accept:
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
- Both can be combined; a file matching both an included and an excluded area
108
- is excluded. Passing neither option applies no filtering (the default,
109
- unchanged behavior).
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 an area filter also
119
- reports every DB object outside the filtered area(s) as "missing in
120
- scripts" — since the live database has no concept of areas, only the
121
- scripts side is filtered — so treat that combination's "missing in scripts"
122
- results with that in mind (the CLI prints a warning when you combine them).
123
-
124
- `pgdb fetch-missing` deliberately has **no** `--area`/`--exclude-area`: it
125
- diffs the full database against scripts to find genuinely untracked
126
- objects, so narrowing the scripts side by area would make every object
127
- tracked only under a different area look "missing" too — and `--write`
128
- would then reconstruct a duplicate file for something that already exists.
129
-
130
- `pgdevkit.areas` exposes the same logic for scripting:
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.migrate.list_migration_files`/`pending_migrations` and
134
- `pgdevkit.parser.parse_directory` take the same `areas`/`exclude_areas`
135
- keyword arguments (`pgdevkit.fetch_missing.find_missing_objects` doesn't,
136
- for the reason above).
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=gZt4BlEuw4hhMq0dyMRCe1a3-Ci23yUDg-6Sc_H-OSo,19193
3
+ pgdevkit/cli.py,sha256=vCxea9c4PxfkpKyUJoMGdUaiLD1bwGtRVDgXbT4XAZc,21826
4
4
  pgdevkit/connection.py,sha256=x49aXKsJfyOsDTlqURs9idQUWaD-RO-XdtAqsG3guBI,4000
5
- pgdevkit/dialect.py,sha256=PL5Y8QPZNnoHIvAPomczGQX7IjZwxMgKGuREX80SwFA,3105
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=_8U-TG4MRZQasdm0UticdbM4rhthoZ9QSF9L4NX3Xq8,14057
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=RWtCDx98OU3T7fmEA7ZZN82Fqo6wlbv03O_BEKwZGQo,16561
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=l1hqX7lLLLzk3hMgbtg7EpbZ7SdwPVPwupBCPu3xRRg,6038
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=0N0Q_BQGh2kuAqa_j-DdbjYlTUOOZSEtaoUOApxQg_Y,12049
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=42RAuJeiZlK1WJaTIYoj6l8GEAbEyt6LgAoF3G2jxnw,7537
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=4GBwv4PA2cKHl2BxwLd-awLOe9he9ie4X6CpQWjIEyI,6820
40
- pgdevkit/skills/pgdevkit/SKILL.md,sha256=6uxe6BQwOLexGiBpf00VTFTHOiWJo5k2Qqzu172rb4I,12156
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.4.0.dist-info/METADATA,sha256=KgPFnAsptfih47V8mmLRNJ43aKBq013EWMH2M3EgDhs,15524
44
- pgdevkit-0.4.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
45
- pgdevkit-0.4.0.dist-info/entry_points.txt,sha256=FK2P55lf0WaGlOZJIINOGgCt8WB7ep0OnPsgnrp80bg,42
46
- pgdevkit-0.4.0.dist-info/RECORD,,
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,,