pgdevkit 0.3.8__py3-none-any.whl → 0.5.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/areas.py ADDED
@@ -0,0 +1,64 @@
1
+ """Optional `-- area: NAME[, NAME...]` tag recognized in the leading comment
2
+ block of a migration file or a `database/` code file (blank lines and `--`
3
+ comments at the very top, stopping at the first real statement — like a file
4
+ header). A file may declare more than one area, either as a comma-separated
5
+ list on one line or across several `-- area:` lines (the areas union).
6
+
7
+ A file with no such directive is "untagged" and is treated as shared/common:
8
+ `only` filters always keep untagged files, and `exclude` filters never drop
9
+ them — only a file that explicitly declares an excluded area is dropped.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from pathlib import Path
16
+
17
+ _AREA_LINE = re.compile(r"^\s*--\s*area\s*:\s*(.+?)\s*$", re.IGNORECASE)
18
+
19
+
20
+ def parse_areas(content: str) -> frozenset[str]:
21
+ """Area names declared in `content`'s leading comment block."""
22
+ areas: set[str] = set()
23
+ for line in content.splitlines():
24
+ stripped = line.strip()
25
+ if not stripped:
26
+ continue
27
+ if not stripped.startswith("--"):
28
+ break
29
+ m = _AREA_LINE.match(stripped)
30
+ if m:
31
+ areas.update(a.strip() for a in m.group(1).split(",") if a.strip())
32
+ return frozenset(areas)
33
+
34
+
35
+ def file_areas(path: Path) -> frozenset[str]:
36
+ """Area names declared in the file at `path`."""
37
+ return parse_areas(path.read_text(encoding="utf-8"))
38
+
39
+
40
+ def area_allowed(
41
+ areas: frozenset[str],
42
+ *,
43
+ only: frozenset[str] | None = None,
44
+ exclude: frozenset[str] | None = None,
45
+ ) -> bool:
46
+ """Whether a file that declares `areas` passes an `only`/`exclude` filter."""
47
+ if exclude and areas & exclude:
48
+ return False
49
+ if only and areas and not (areas & only):
50
+ return False
51
+ return True
52
+
53
+
54
+ def filter_by_area(
55
+ paths: list[Path],
56
+ *,
57
+ only: frozenset[str] | None = None,
58
+ exclude: frozenset[str] | None = None,
59
+ ) -> list[Path]:
60
+ """`paths` restricted by an `only`/`exclude` area filter. Returns `paths`
61
+ unchanged (no file reads) when neither filter is set."""
62
+ if not only and not exclude:
63
+ return paths
64
+ return [p for p in paths if area_allowed(file_areas(p), only=only, exclude=exclude)]
pgdevkit/cli.py CHANGED
@@ -23,6 +23,29 @@ app = typer.Typer(name="pgdb", help="PostgreSQL database schema tools")
23
23
  console = Console()
24
24
  err_console = Console(stderr=True)
25
25
 
26
+ _AREA_OPTION = typer.Option(
27
+ [], "--area", help="Restrict to files declaring this area (repeatable); untagged files always stay in scope"
28
+ )
29
+ _EXCLUDE_AREA_OPTION = typer.Option(
30
+ [], "--exclude-area", help="Skip files declaring this area (repeatable); untagged files are never excluded"
31
+ )
32
+ _SCHEMA_OPTION = typer.Option(
33
+ [],
34
+ "--schema",
35
+ help="Restrict to files referencing this DB schema (repeatable); "
36
+ "files with no detectable schema reference always stay in scope",
37
+ )
38
+ _EXCLUDE_SCHEMA_OPTION = typer.Option(
39
+ [],
40
+ "--exclude-schema",
41
+ help="Skip files referencing this DB schema (repeatable); "
42
+ "files with no detectable schema reference are never excluded",
43
+ )
44
+
45
+
46
+ def _as_set(values: list[str]) -> frozenset[str] | None:
47
+ return frozenset(values) if values else None
48
+
26
49
  testdb_app = typer.Typer(name="testdb", help="Manage the shared local Postgres test container")
27
50
  app.add_typer(testdb_app, name="testdb")
28
51
 
@@ -46,12 +69,23 @@ def compare(
46
69
  ),
47
70
  report_extra_db: bool = typer.Option(False, "--report-extra-db", help="Report objects in DB but not in scripts"),
48
71
  dialect: str = typer.Option("postgres", "--dialect", help="postgres (default) or mssql"),
72
+ area: list[str] = _AREA_OPTION,
73
+ exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
74
+ schema: list[str] = _SCHEMA_OPTION,
75
+ exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
49
76
  scripts_dir: Path = typer.Argument(..., help="Directory containing SQL scripts"),
50
77
  ) -> None:
51
78
  """Compare SQL scripts to a live database and report differences."""
52
79
  if not scripts_dir.is_dir():
53
80
  err_console.print(f"[red]Error:[/red] {scripts_dir} is not a directory")
54
81
  raise typer.Exit(2)
82
+ if report_extra_db and (area or exclude_area or schema or exclude_schema):
83
+ console.print(
84
+ "[yellow]⚠[/yellow] --report-extra-db with --area/--exclude-area/--schema/--exclude-schema will "
85
+ "report every DB object outside the filtered area(s)/schema(s) as \"missing in scripts\", since the "
86
+ "live database has no concept of areas — and isn't itself filtered by --schema either — only the "
87
+ "scripts side is filtered."
88
+ )
55
89
 
56
90
  try:
57
91
  conninfo = build_conninfo(
@@ -71,7 +105,14 @@ def compare(
71
105
  raise typer.Exit(2)
72
106
 
73
107
  with console.status("Parsing SQL scripts..."):
74
- scripts_schema = parse_directory(scripts_dir, dialect=backend.dialect)
108
+ scripts_schema = parse_directory(
109
+ scripts_dir,
110
+ dialect=backend.dialect,
111
+ areas=_as_set(area),
112
+ exclude_areas=_as_set(exclude_area),
113
+ schemas=_as_set(schema),
114
+ exclude_schemas=_as_set(exclude_schema),
115
+ )
75
116
 
76
117
  with console.status("Introspecting database..."):
77
118
  db_schema = backend.introspect(conninfo)
@@ -110,7 +151,11 @@ def fetch_missing(
110
151
  only: list[str] = typer.Option([], "--only", help="Only fetch schema.name (repeatable); default is everything"),
111
152
  ) -> None:
112
153
  """Find tables/views/functions that exist in the database but aren't
113
- tracked under scripts_dir, and reverse-engineer their DDL into new files."""
154
+ tracked under scripts_dir, and reverse-engineer their DDL into new files.
155
+
156
+ No --area/--exclude-area here (unlike compare/migrate): this diffs the
157
+ full live database against scripts, so filtering the scripts side by
158
+ area would misreport objects tracked under other areas as missing."""
114
159
  if not scripts_dir.is_dir():
115
160
  err_console.print(f"[red]Error:[/red] {scripts_dir} is not a directory")
116
161
  raise typer.Exit(2)
@@ -163,17 +208,33 @@ def fetch_missing(
163
208
 
164
209
 
165
210
  @testdb_app.command("up")
166
- def testdb_up() -> None:
211
+ def testdb_up(
212
+ area: list[str] = _AREA_OPTION,
213
+ exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
214
+ schema: list[str] = _SCHEMA_OPTION,
215
+ exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
216
+ ) -> None:
167
217
  """Ensure the container is running, the workspace DB exists, and schema is applied."""
168
- testdb.ensure_testdb()
218
+ testdb.ensure_testdb(
219
+ areas=_as_set(area), exclude_areas=_as_set(exclude_area), schemas=_as_set(schema),
220
+ exclude_schemas=_as_set(exclude_schema),
221
+ )
169
222
  info = testdb.status()
170
223
  console.print(f"[green]Test DB ready:[/green] {info['database']} ({info['dsn']})")
171
224
 
172
225
 
173
226
  @testdb_app.command("reset")
174
- def testdb_reset() -> None:
227
+ def testdb_reset(
228
+ area: list[str] = _AREA_OPTION,
229
+ exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
230
+ schema: list[str] = _SCHEMA_OPTION,
231
+ exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
232
+ ) -> None:
175
233
  """Drop and recreate only this workspace's database, then reapply schema + seed data."""
176
- testdb.reset_testdb()
234
+ testdb.reset_testdb(
235
+ areas=_as_set(area), exclude_areas=_as_set(exclude_area), schemas=_as_set(schema),
236
+ exclude_schemas=_as_set(exclude_schema),
237
+ )
177
238
  info = testdb.status()
178
239
  console.print(f"[green]Test DB reset:[/green] {info['database']}")
179
240
 
@@ -245,6 +306,10 @@ def migrate_check(
245
306
  help="schema.table recording applied migrations "
246
307
  "(default: tool.pgdevkit.migrations_table in pyproject.toml, else public.schema_migrations)",
247
308
  ),
309
+ area: list[str] = _AREA_OPTION,
310
+ exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
311
+ schema: list[str] = _SCHEMA_OPTION,
312
+ exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
248
313
  ) -> None:
249
314
  """List which migration files under migrations_dir are applied vs. pending."""
250
315
  if not migrations_dir.is_dir():
@@ -253,7 +318,13 @@ def migrate_check(
253
318
 
254
319
  conninfo = build_conninfo(url, entra_user)
255
320
  tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir)
256
- local_files = migrate.list_migration_files(migrations_dir)
321
+ local_files = migrate.list_migration_files(
322
+ migrations_dir,
323
+ areas=_as_set(area),
324
+ exclude_areas=_as_set(exclude_area),
325
+ schemas=_as_set(schema),
326
+ exclude_schemas=_as_set(exclude_schema),
327
+ )
257
328
  try:
258
329
  applied = migrate.applied_migrations(conninfo, tracking_table)
259
330
  except migrate.TrackingTableMissing:
@@ -292,6 +363,10 @@ def migrate_apply(
292
363
  ),
293
364
  ask: bool = typer.Option(False, "--ask", help="Show and confirm each migration before running it"),
294
365
  yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirm-target prompt"),
366
+ area: list[str] = _AREA_OPTION,
367
+ exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
368
+ schema: list[str] = _SCHEMA_OPTION,
369
+ exclude_schema: list[str] = _EXCLUDE_SCHEMA_OPTION,
295
370
  ) -> None:
296
371
  """Apply pending migration files, in filename order, tracking each in tracking_table."""
297
372
  if not migrations_dir.is_dir():
@@ -300,6 +375,8 @@ def migrate_apply(
300
375
 
301
376
  conninfo = build_conninfo(url, entra_user)
302
377
  tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir)
378
+ areas, exclude_areas = _as_set(area), _as_set(exclude_area)
379
+ schemas, exclude_schemas = _as_set(schema), _as_set(exclude_schema)
303
380
  target_desc = url.rsplit("@", 1)[-1] if "@" in url else url
304
381
  if not yes:
305
382
  typer.confirm(f"About to run migrations against {target_desc}. Continue?", abort=True)
@@ -308,12 +385,23 @@ def migrate_apply(
308
385
  targets = [migrations_dir / file]
309
386
  else:
310
387
  try:
311
- targets = migrate.pending_migrations(migrations_dir, conninfo, tracking_table)
388
+ targets = migrate.pending_migrations(
389
+ migrations_dir,
390
+ conninfo,
391
+ tracking_table,
392
+ areas=areas,
393
+ exclude_areas=exclude_areas,
394
+ schemas=schemas,
395
+ exclude_schemas=exclude_schemas,
396
+ )
312
397
  except migrate.TrackingTableMissing:
313
398
  err_console.print(
314
399
  f"[yellow]⚠[/yellow] {tracking_table} not found — treating every migration as pending"
315
400
  )
316
- targets = migrate.list_migration_files(migrations_dir)
401
+ targets = migrate.list_migration_files(
402
+ migrations_dir, areas=areas, exclude_areas=exclude_areas, schemas=schemas,
403
+ exclude_schemas=exclude_schemas,
404
+ )
317
405
 
318
406
  if not targets:
319
407
  console.print("No pending migrations.")
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/fetch_missing.py CHANGED
@@ -50,7 +50,12 @@ def layer_folder_for(scripts_dir: Path, schema: str) -> Path:
50
50
 
51
51
  def find_missing_objects(scripts_dir: Path, conninfo: str) -> list[MissingObject]:
52
52
  """Tables, views, and functions that exist in the live database but
53
- aren't tracked as .sql files under scripts_dir."""
53
+ aren't tracked as .sql files under scripts_dir.
54
+
55
+ Deliberately not area-filterable: this diffs the *full* database against
56
+ scripts with report_extra_db=True, so narrowing the scripts side to one
57
+ area would make every object tracked only under a different area look
58
+ "missing" too — and --write would reconstruct a duplicate file for it."""
54
59
  scripts = parse_directory(scripts_dir)
55
60
  db = introspect_db(conninfo)
56
61
  diffs = compute_diff(scripts, db, report_extra_db=True)
pgdevkit/migrate.py CHANGED
@@ -18,6 +18,10 @@ import sqlglot
18
18
  from psycopg import errors as pg_errors
19
19
  from psycopg import sql as pg_sql
20
20
 
21
+ from .areas import filter_by_area
22
+ from .schemas import filter_by_schema
23
+ from .sql_text import strip_line_comments
24
+
21
25
  _IDENTIFIER = r"[A-Za-z_][A-Za-z0-9_]*"
22
26
  _DEFAULT_TRACKING_TABLE = "public.schema_migrations"
23
27
 
@@ -122,61 +126,12 @@ def _split_sql(sql: str) -> list[str]:
122
126
  return stmts
123
127
 
124
128
 
125
- def _strip_line_comments(sql: str) -> str:
126
- """Drop '--' line comments, respecting string literals and $$...$$ blocks."""
127
- buf: list[str] = []
128
- i = 0
129
- in_string = False
130
- in_line_comment = False
131
- dollar_tag: str | None = None
132
-
133
- while i < len(sql):
134
- c = sql[i]
135
- if in_line_comment:
136
- if c == "\n":
137
- in_line_comment = False
138
- buf.append(c)
139
- elif dollar_tag is not None:
140
- buf.append(c)
141
- if c == "$" and sql[i:i + len(dollar_tag)] == dollar_tag:
142
- buf.extend(list(dollar_tag[1:]))
143
- i += len(dollar_tag)
144
- dollar_tag = None
145
- continue
146
- elif in_string:
147
- if c == "'" and i + 1 < len(sql) and sql[i + 1] == "'":
148
- buf.append(c)
149
- buf.append(sql[i + 1])
150
- i += 2
151
- continue
152
- elif c == "'":
153
- in_string = False
154
- buf.append(c)
155
- elif c == "-" and i + 1 < len(sql) and sql[i + 1] == "-":
156
- in_line_comment = True
157
- elif c == "$":
158
- m = re.match(r"\$([A-Za-z0-9_]*)\$", sql[i:])
159
- if m:
160
- dollar_tag = m.group(0)
161
- buf.extend(list(dollar_tag))
162
- i += len(dollar_tag)
163
- continue
164
- buf.append(c)
165
- elif c == "'":
166
- in_string = True
167
- buf.append(c)
168
- else:
169
- buf.append(c)
170
- i += 1
171
- return "".join(buf)
172
-
173
-
174
129
  def _created_table_names(stmts: list[str]) -> list[str]:
175
130
  """Names of tables any CREATE TABLE statement targets, parsed via sqlglot (falls back to
176
131
  regex on comment-stripped text for statements sqlglot's postgres dialect can't parse)."""
177
132
  names: list[str] = []
178
133
  for stmt in stmts:
179
- stripped = _strip_line_comments(stmt)
134
+ stripped = strip_line_comments(stmt)
180
135
  if not re.search(r"CREATE\s+TABLE", stripped, re.IGNORECASE):
181
136
  continue # skip sqlglot entirely for statements that can't be a CREATE TABLE
182
137
  try:
@@ -218,7 +173,7 @@ def _idempotent_target(stmt: str) -> tuple[str, ...] | None:
218
173
  COLUMN. None if the statement isn't one of these shapes — including any CREATE OR
219
174
  REPLACE, which is never safe to treat as a no-op just because the object exists, since
220
175
  the migration could be replacing it with different content."""
221
- stripped = _strip_line_comments(stmt).strip()
176
+ stripped = strip_line_comments(stmt).strip()
222
177
  if re.search(r"\bOR\s+REPLACE\b", stripped, re.IGNORECASE):
223
178
  return None
224
179
 
@@ -274,8 +229,17 @@ def already_fully_applied(conninfo: str, path: Path) -> bool:
274
229
  return all(_target_exists(con, cast(tuple[str, ...], t)) for t in targets)
275
230
 
276
231
 
277
- def list_migration_files(migrations_dir: Path) -> list[Path]:
278
- return sorted(migrations_dir.glob("*.sql"))
232
+ def list_migration_files(
233
+ migrations_dir: Path,
234
+ *,
235
+ areas: frozenset[str] | None = None,
236
+ exclude_areas: frozenset[str] | None = None,
237
+ schemas: frozenset[str] | None = None,
238
+ exclude_schemas: frozenset[str] | None = None,
239
+ ) -> list[Path]:
240
+ files = sorted(migrations_dir.glob("*.sql"))
241
+ files = filter_by_area(files, only=areas, exclude=exclude_areas)
242
+ return filter_by_schema(files, only=schemas, exclude=exclude_schemas)
279
243
 
280
244
 
281
245
  def applied_migrations(conninfo: str, tracking_table: str) -> dict[str, tuple[datetime, str]]:
@@ -291,9 +255,21 @@ def applied_migrations(conninfo: str, tracking_table: str) -> dict[str, tuple[da
291
255
  return {r[0]: (r[1], r[2]) for r in rows}
292
256
 
293
257
 
294
- def pending_migrations(migrations_dir: Path, conninfo: str, tracking_table: str) -> list[Path]:
258
+ def pending_migrations(
259
+ migrations_dir: Path,
260
+ conninfo: str,
261
+ tracking_table: str,
262
+ *,
263
+ areas: frozenset[str] | None = None,
264
+ exclude_areas: frozenset[str] | None = None,
265
+ schemas: frozenset[str] | None = None,
266
+ exclude_schemas: frozenset[str] | None = None,
267
+ ) -> list[Path]:
295
268
  applied = applied_migrations(conninfo, tracking_table)
296
- return [p for p in list_migration_files(migrations_dir) if p.name not in applied]
269
+ files = list_migration_files(
270
+ migrations_dir, areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas
271
+ )
272
+ return [p for p in files if p.name not in applied]
297
273
 
298
274
 
299
275
  def record_applied(conninfo: str, tracking_table: str, filename: str) -> bool:
pgdevkit/parser.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import re
4
5
  import logging
5
6
  from pathlib import Path
@@ -7,7 +8,9 @@ from pathlib import Path
7
8
  import sqlglot
8
9
  import sqlglot.expressions as exp
9
10
 
11
+ from .areas import area_allowed, parse_areas
10
12
  from .dialect import Dialect, POSTGRES, resolve_dialect
13
+ from .schemas import schema_allowed, sql_schemas
11
14
  from .models import (
12
15
  ColumnDef, ConstraintDef, CompositeTypeDef, DatabaseSchema,
13
16
  EnumDef, FunctionDef, IndexDef, TableDef, ViewDef,
@@ -39,18 +42,44 @@ _DO_COMPOSITE = re.compile(
39
42
  )
40
43
 
41
44
 
42
- def parse_directory(scripts_dir: Path, *, dialect: str | Dialect = "postgres") -> DatabaseSchema:
45
+ def _iter_sql_files(scripts_dir: Path):
46
+ for root, dirs, files in os.walk(scripts_dir):
47
+ dirs[:] = [d for d in dirs if d not in IGNORED_DIR_NAMES]
48
+ for name in files:
49
+ if name.endswith(".sql"):
50
+ yield Path(root) / name
51
+
52
+
53
+ def parse_directory(
54
+ scripts_dir: Path,
55
+ *,
56
+ dialect: str | Dialect = "postgres",
57
+ areas: frozenset[str] | None = None,
58
+ exclude_areas: frozenset[str] | None = None,
59
+ schemas: frozenset[str] | None = None,
60
+ exclude_schemas: frozenset[str] | None = None,
61
+ ) -> DatabaseSchema:
43
62
  resolved = resolve_dialect(dialect)
44
63
  db_schema = DatabaseSchema()
45
- for sql_file in sorted(scripts_dir.rglob("*.sql")):
46
- if IGNORED_DIR_NAMES.intersection(sql_file.relative_to(scripts_dir).parts[:-1]):
64
+ for sql_file in sorted(_iter_sql_files(scripts_dir)):
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).
67
+ content = sql_file.read_text(encoding="utf-8")
68
+ if (areas or exclude_areas) and not area_allowed(parse_areas(content), only=areas, exclude=exclude_areas):
69
+ continue
70
+ if (schemas or exclude_schemas) and not schema_allowed(
71
+ sql_schemas(content, resolved), only=schemas, exclude=exclude_schemas
72
+ ):
47
73
  continue
48
- _parse_file(sql_file, db_schema, resolved)
74
+ _parse_file(sql_file, db_schema, resolved, content=content)
49
75
  return db_schema
50
76
 
51
77
 
52
- def _parse_file(path: Path, db_schema: DatabaseSchema, dialect: Dialect = POSTGRES) -> None:
53
- content = path.read_text(encoding="utf-8")
78
+ def _parse_file(
79
+ path: Path, db_schema: DatabaseSchema, dialect: Dialect = POSTGRES, content: str | None = None
80
+ ) -> None:
81
+ if content is None:
82
+ content = path.read_text(encoding="utf-8")
54
83
  try:
55
84
  exprs = sqlglot.parse(content, dialect=dialect.sqlglot_name, error_level=sqlglot.ErrorLevel.WARN)
56
85
  except Exception as e:
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)]
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,53 @@ 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
+ areas: frozenset[str] | None = None,
77
+ exclude_areas: frozenset[str] | None = None,
78
+ schemas: frozenset[str] | None = None,
79
+ exclude_schemas: frozenset[str] | None = None,
80
+ ) -> None:
72
81
  async with await psycopg.AsyncConnection.connect(_db_dsn(db_name), autocommit=True) as con:
73
82
  await apply_schema(
74
83
  con,
75
84
  config.root / config.database_dir,
76
85
  extensions=config.extensions,
77
86
  force_reset=force_reset,
87
+ areas=areas,
88
+ exclude_areas=exclude_areas,
89
+ schemas=schemas,
90
+ exclude_schemas=exclude_schemas,
78
91
  )
79
92
 
80
93
 
81
- def ensure_testdb(project_root: Path | None = None, force_reset: bool = False) -> dict[str, str]:
94
+ def ensure_testdb(
95
+ project_root: Path | None = None,
96
+ force_reset: bool = False,
97
+ *,
98
+ areas: frozenset[str] | None = None,
99
+ exclude_areas: frozenset[str] | None = None,
100
+ schemas: frozenset[str] | None = None,
101
+ exclude_schemas: frozenset[str] | None = None,
102
+ ) -> dict[str, str]:
82
103
  """Ensure the shared container is running, this workspace's database
83
104
  exists, and its schema is applied. Returns the {PREFIX}POSTGRES_* env
84
105
  vars for this workspace (or the mssql equivalent's env vars, per
85
- `config.engine`)."""
106
+ `config.engine`).
107
+
108
+ `areas`/`exclude_areas` and `schemas`/`exclude_schemas` restrict which
109
+ database/ files get applied -- e.g. for a test DB scoped to one area or
110
+ schema. Neither filters what gets *dropped* by force_reset/clean, only
111
+ what gets (re)applied."""
86
112
  config, db_name = _resolve(project_root)
87
113
  if config.engine == "mssql":
88
- return _mssql_api().ensure_testdb(config, db_name, force_reset)
114
+ return _mssql_api().ensure_testdb(
115
+ config, db_name, force_reset,
116
+ areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas,
117
+ )
89
118
 
90
119
  ensure_container()
91
120
 
@@ -93,16 +122,29 @@ def ensure_testdb(project_root: Path | None = None, force_reset: bool = False) -
93
122
  if force_reset:
94
123
  await _drop_database(db_name)
95
124
  await _ensure_database(db_name)
96
- await _apply(config, db_name, force_reset)
125
+ await _apply(
126
+ config, db_name, force_reset,
127
+ areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas,
128
+ )
97
129
 
98
130
  asyncio.run(_run())
99
131
  return _env_for(config, db_name)
100
132
 
101
133
 
102
- def reset_testdb(project_root: Path | None = None) -> dict[str, str]:
134
+ def reset_testdb(
135
+ project_root: Path | None = None,
136
+ *,
137
+ areas: frozenset[str] | None = None,
138
+ exclude_areas: frozenset[str] | None = None,
139
+ schemas: frozenset[str] | None = None,
140
+ exclude_schemas: frozenset[str] | None = None,
141
+ ) -> dict[str, str]:
103
142
  """Drop and recreate only this workspace's database, then reapply
104
143
  schema and seed data."""
105
- return ensure_testdb(project_root, force_reset=True)
144
+ return ensure_testdb(
145
+ project_root, force_reset=True,
146
+ areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas,
147
+ )
106
148
 
107
149
 
108
150
  def clean_testdb(project_root: Path | None = None, all: bool = False) -> None:
@@ -101,7 +101,16 @@ async def _insert_test_data(json_file: Path, table: str, force_reset: bool, conn
101
101
  await asyncio.to_thread(_run)
102
102
 
103
103
 
104
- async def _apply(config: ProjectConfig, db_name: str, force_reset: bool) -> None:
104
+ async def _apply(
105
+ config: ProjectConfig,
106
+ db_name: str,
107
+ force_reset: bool,
108
+ *,
109
+ areas: frozenset[str] | None = None,
110
+ exclude_areas: frozenset[str] | None = None,
111
+ schemas: frozenset[str] | None = None,
112
+ exclude_schemas: frozenset[str] | None = None,
113
+ ) -> None:
105
114
  def _connect() -> Any:
106
115
  return mssql_python.connect(_db_dsn(db_name), autocommit=True)
107
116
 
@@ -110,7 +119,10 @@ async def _apply(config: ProjectConfig, db_name: str, force_reset: bool) -> None
110
119
  database_dir = config.root / config.database_dir
111
120
  if not database_dir.is_dir():
112
121
  return
113
- for file, sql in _iter_sql_files(database_dir, MSSQL):
122
+ for file, sql in _iter_sql_files(
123
+ database_dir, MSSQL,
124
+ areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas,
125
+ ):
114
126
  for batch in query.split_tsql_batches(sql):
115
127
 
116
128
  def _exec(batch: str = batch) -> None:
@@ -126,14 +138,26 @@ async def _apply(config: ProjectConfig, db_name: str, force_reset: bool) -> None
126
138
  await asyncio.to_thread(conn.close)
127
139
 
128
140
 
129
- def ensure_testdb(config: ProjectConfig, db_name: str, force_reset: bool) -> dict[str, str]:
141
+ def ensure_testdb(
142
+ config: ProjectConfig,
143
+ db_name: str,
144
+ force_reset: bool,
145
+ *,
146
+ areas: frozenset[str] | None = None,
147
+ exclude_areas: frozenset[str] | None = None,
148
+ schemas: frozenset[str] | None = None,
149
+ exclude_schemas: frozenset[str] | None = None,
150
+ ) -> dict[str, str]:
130
151
  ensure_mssql_container()
131
152
 
132
153
  async def _run() -> None:
133
154
  if force_reset:
134
155
  await _drop_database(db_name)
135
156
  await _ensure_database(db_name)
136
- await _apply(config, db_name, force_reset)
157
+ await _apply(
158
+ config, db_name, force_reset,
159
+ areas=areas, exclude_areas=exclude_areas, schemas=schemas, exclude_schemas=exclude_schemas,
160
+ )
137
161
 
138
162
  asyncio.run(_run())
139
163
  return _env_for(config, db_name)
pgdevkit/testdb/schema.py CHANGED
@@ -13,9 +13,11 @@ 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
18
19
  from ..parser import IGNORED_DIR_NAMES
20
+ from ..schemas import schema_allowed, sql_schemas
19
21
 
20
22
  logger = logging.getLogger(__name__)
21
23
  logging.getLogger("sqlglot").setLevel(logging.ERROR)
@@ -65,16 +67,14 @@ _SCHEMA_QUALIFIED_TYPES = {
65
67
  }
66
68
 
67
69
 
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"}
70
+ # SYSTEM_SCHEMAS (see dialect.py) matters most here for T-SQL, where
71
+ # "IF NOT EXISTS (SELECT ... FROM sys.schemas/sys.tables/sys.objects ...)
72
+ # BEGIN CREATE ... END" is the idiomatic idempotency-guard pattern (T-SQL has
73
+ # no native "CREATE TABLE IF NOT EXISTS"/"CREATE SCHEMA IF NOT EXISTS"), so
74
+ # without this exclusion nearly every T-SQL file would pick up a spurious,
75
+ # never-resolvable dependency on "sys.*" and get shuffled into the
76
+ # delayed-retry path, whose reverse-order resolution can then apply files
77
+ # out of their intended order.
78
78
 
79
79
  _DECLARE_RE = re.compile(
80
80
  r"CREATE\s+(?:OR\s+REPLACE\s+)?(?:TABLE|VIEW|FUNCTION|PROCEDURE|TYPE|SCHEMA)\s+(\w+\.\w+)", re.IGNORECASE
@@ -89,7 +89,7 @@ def _get_sql_deps_regex_fallback(sql: str) -> set[str]:
89
89
  is fine."""
90
90
  declares = set(_DECLARE_RE.findall(sql))
91
91
  deps = set(_DEPEND_RE.findall(sql))
92
- deps = {d for d in deps if d.split(".", 1)[0].lower() not in _SYSTEM_SCHEMAS}
92
+ deps = {d for d in deps if d.split(".", 1)[0].lower() not in SYSTEM_SCHEMAS}
93
93
  return deps - declares
94
94
 
95
95
 
@@ -109,7 +109,7 @@ def _get_sql_deps(sql: str, dialect: Dialect = POSTGRES) -> set[str]:
109
109
  for t in e.find_all(exp.Table):
110
110
  db_node = t.args.get("db")
111
111
  if t.args.get("this") is not None and db_node is not None:
112
- if db_node.name.lower() in _SYSTEM_SCHEMAS:
112
+ if db_node.name.lower() in SYSTEM_SCHEMAS:
113
113
  continue
114
114
  # exp.table_name(), not str(t): str() includes " AS alias" for
115
115
  # an aliased reference (e.g. "FROM editing.visit v"), which
@@ -119,8 +119,21 @@ def _get_sql_deps(sql: str, dialect: Dialect = POSTGRES) -> set[str]:
119
119
  return deps
120
120
 
121
121
 
122
- def _iter_sql_files(database_dir: Path, dialect: Dialect = POSTGRES):
123
- """Yield (Path, sql_content) pairs in dependency-safe execution order."""
122
+ def _iter_sql_files(
123
+ database_dir: Path,
124
+ dialect: Dialect = POSTGRES,
125
+ *,
126
+ areas: frozenset[str] | None = None,
127
+ exclude_areas: frozenset[str] | None = None,
128
+ schemas: frozenset[str] | None = None,
129
+ exclude_schemas: frozenset[str] | None = None,
130
+ ):
131
+ """Yield (Path, sql_content) pairs in dependency-safe execution order.
132
+
133
+ A file dropped by the area/schema filter is skipped entirely -- as if it
134
+ didn't exist -- so it never delivers a dependency another (in-scope)
135
+ file waits on; that's the same tradeoff `list_migration_files`/
136
+ `parse_directory` make for migrations/database code files."""
124
137
  files: list[Path] = []
125
138
  for root, dirs, dbfiles in os.walk(database_dir):
126
139
  dirs[:] = [d for d in dirs if d not in IGNORED_DIR_NAMES]
@@ -138,6 +151,12 @@ def _iter_sql_files(database_dir: Path, dialect: Dialect = POSTGRES):
138
151
 
139
152
  for file in sorted(files, key=lambda p: (_get_type_order(p), p.name)):
140
153
  content = file.read_text(encoding="utf-8")
154
+ if (areas or exclude_areas) and not area_allowed(parse_areas(content), only=areas, exclude=exclude_areas):
155
+ continue
156
+ if (schemas or exclude_schemas) and not schema_allowed(
157
+ sql_schemas(content, dialect), only=schemas, exclude=exclude_schemas
158
+ ):
159
+ continue
141
160
  deps = _get_sql_deps(content, dialect)
142
161
  if file.parent.name in _SCHEMA_QUALIFIED_TYPES:
143
162
  schema = _strip_layer_prefix(file.parent.parent.name)
@@ -242,6 +261,10 @@ async def apply_schema(
242
261
  force_reset: bool = False,
243
262
  *,
244
263
  dialect: Dialect = POSTGRES,
264
+ areas: frozenset[str] | None = None,
265
+ exclude_areas: frozenset[str] | None = None,
266
+ schemas: frozenset[str] | None = None,
267
+ exclude_schemas: frozenset[str] | None = None,
245
268
  ) -> None:
246
269
  """Apply every .sql file under database_dir (in dependency-safe order)
247
270
  and seed any matching .test_data.json files. Safe to call repeatedly.
@@ -249,7 +272,12 @@ async def apply_schema(
249
272
  `migrations/` subdirectories are never applied here — they're for
250
273
  one-time manual application against real (already-provisioned)
251
274
  databases, not for building a fresh schema. The base object files under
252
- `database_dir` must reflect the current, final schema on their own."""
275
+ `database_dir` must reflect the current, final schema on their own.
276
+
277
+ `areas`/`exclude_areas` and `schemas`/`exclude_schemas` restrict which
278
+ files get applied, the same as `pgdevkit migrate`/`compare` — handy for
279
+ standing up a test DB scoped to one area or schema instead of the whole
280
+ project."""
253
281
  await con.set_autocommit(True)
254
282
  for extension in extensions:
255
283
  await con.execute(SQL("CREATE EXTENSION IF NOT EXISTS {e}").format(e=Identifier(extension)))
@@ -265,7 +293,14 @@ async def apply_schema(
265
293
  await _insert_test_data(json_file, f"{schema_name}.{table_stem}", force_reset, con, complex_helper)
266
294
 
267
295
  failures: list[tuple[Path, str]] = []
268
- for file, sql in _iter_sql_files(database_dir, dialect):
296
+ for file, sql in _iter_sql_files(
297
+ database_dir,
298
+ dialect,
299
+ areas=areas,
300
+ exclude_areas=exclude_areas,
301
+ schemas=schemas,
302
+ exclude_schemas=exclude_schemas,
303
+ ):
269
304
  try:
270
305
  await _apply(file, sql)
271
306
  except Exception as e: # noqa: BLE001
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pgdevkit
3
- Version: 0.3.8
3
+ Version: 0.5.0
4
4
  Summary: A helper for developing with Postgres
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: docker>=7.1.0
@@ -79,6 +79,94 @@ which parses/introspects/diffs like any other column type; see
79
79
  handled on the CRUD side (write-side serialization only, no auto-parsing on
80
80
  read — `mssql-python` doesn't distinguish `json` columns from `nvarchar`).
81
81
 
82
+ ### Area and schema filtering
83
+
84
+ Two independent, composable ways to narrow which files a command touches:
85
+ **area** is an explicit opt-in tag; **schema** is derived automatically from
86
+ each file's own SQL.
87
+
88
+ #### Area tagging
89
+
90
+ Any migration file or `database/` code file can declare one or more areas by
91
+ starting with a `-- area:` comment:
92
+
93
+ ```sql
94
+ -- area: billing
95
+ CREATE TABLE billing.invoices (id int primary key);
96
+ ```
97
+
98
+ A file can declare more than one area, either comma-separated on one line
99
+ (`-- area: billing, reporting`) or across several `-- area:` lines — the
100
+ declared areas union. The directive is only recognized in the file's leading
101
+ comment block (blank lines and `--` comments at the very top, stopping at the
102
+ first real statement); a `-- area:` comment later in the file doesn't count.
103
+ A file with no directive is untagged, and untagged files are treated as
104
+ shared/common.
105
+
106
+ #### Schema filtering
107
+
108
+ No tag needed — schema membership is parsed straight out of the SQL itself:
109
+ every schema-qualified (or default-schema, when unqualified) table/view/
110
+ function/index reference across every statement in the file, DDL or DML
111
+ alike, plus any `CREATE SCHEMA name`. A file whose schema(s) can't be
112
+ determined (unparseable content, or no table/schema reference in it at all)
113
+ is treated the same as an untagged file — always kept.
114
+
115
+ #### Options
116
+
117
+ `pgdb compare`, `pgdb migrate check`, `pgdb migrate apply`, `pgdb testdb up`,
118
+ and `pgdb testdb reset` all accept:
119
+
120
+ - `--area NAME` (repeatable) — restrict to files declaring one of the given
121
+ areas, **plus every untagged file** (untagged files always stay in scope).
122
+ - `--exclude-area NAME` (repeatable) — drop files declaring one of the given
123
+ areas; untagged files are never dropped by this.
124
+ - `--schema NAME` (repeatable) — restrict to files referencing one of the
125
+ given schemas, **plus every file with no detectable schema reference**.
126
+ - `--exclude-schema NAME` (repeatable) — drop files referencing one of the
127
+ given schemas; files with no detectable reference are never dropped.
128
+
129
+ All four can be combined — a file must pass every filter it's subject to (an
130
+ area match doesn't excuse a schema mismatch, and vice versa), and a file
131
+ matching both an included and an excluded value on the same axis is
132
+ excluded. Passing none of them applies no filtering (the default, unchanged
133
+ behavior).
134
+
135
+ ```bash
136
+ pgdb migrate apply path/to/database/_migration_scripts --url ... --area billing
137
+ pgdb compare path/to/database/ --url ... --exclude-area reporting
138
+ pgdb migrate check path/to/database/_migration_scripts --url ... --schema billing --exclude-schema reporting
139
+ pgdb testdb up --schema billing
140
+ ```
141
+
142
+ `compare`'s default report (no `--report-extra-db`) only checks that the
143
+ filtered scripts exist correctly in the DB, so it composes safely with area
144
+ and schema filtering. Passing `--report-extra-db` together with either kind
145
+ of filter also reports every DB object outside the filtered area(s)/
146
+ schema(s) as "missing in scripts" — since the live database has no concept
147
+ of areas, and isn't itself filtered by `--schema` either — only the scripts
148
+ side is filtered — so treat that combination's "missing in scripts" results
149
+ with that in mind (the CLI prints a warning when you combine them).
150
+
151
+ `pgdb fetch-missing` deliberately has **no** `--area`/`--exclude-area` (or
152
+ `--schema`/`--exclude-schema`): it diffs the full database against scripts
153
+ to find genuinely untracked objects, so narrowing the scripts side would
154
+ make every object tracked under a different area/schema look "missing" too
155
+ — and `--write` would then reconstruct a duplicate file for something that
156
+ already exists.
157
+
158
+ `pgdevkit.areas` exposes the tag-filtering logic for scripting:
159
+ `parse_areas`/`file_areas` read a file's declared areas, and
160
+ `area_allowed`/`filter_by_area` apply the `only`/`exclude` semantics above.
161
+ `pgdevkit.schemas` exposes the equivalent for schema filtering:
162
+ `sql_schemas`/`file_schemas` detect a file's referenced schemas, and
163
+ `schema_allowed`/`filter_by_schema` apply the same `only`/`exclude`
164
+ semantics. `pgdevkit.migrate.list_migration_files`/`pending_migrations` and
165
+ `pgdevkit.parser.parse_directory` take both pairs of keyword arguments
166
+ (`areas`/`exclude_areas` and `schemas`/`exclude_schemas`);
167
+ `pgdevkit.fetch_missing.find_missing_objects` takes neither, for the reason
168
+ above.
169
+
82
170
  ## `pgdb testdb`
83
171
 
84
172
  Manages a single shared, Podman-backed Postgres container for local tests
@@ -109,6 +197,13 @@ def ensure_test_postgres():
109
197
 
110
198
  CLI: `pgdb testdb up|reset|run-sql|status|shell|clean`.
111
199
 
200
+ `up`/`reset` accept `--area`/`--exclude-area` and `--schema`/`--exclude-schema`
201
+ (see "Area and schema filtering" above) to scope which `database/` files get
202
+ applied — e.g. `pgdb testdb up --schema billing` for a test DB with only the
203
+ `billing` schema's tables/views/functions, without waiting on the rest of the
204
+ project's schema to apply. `ensure_testdb`/`reset_testdb` take the same
205
+ keyword arguments when called from Python (e.g. from a pytest fixture).
206
+
112
207
  Container connection defaults (`localhost:54322`, `postgres`/`testpwd`) can
113
208
  be overridden with `PGDEVKIT_TESTDB_HOST`, `PGDEVKIT_TESTDB_PORT`,
114
209
  `PGDEVKIT_TESTDB_USER`, `PGDEVKIT_TESTDB_PASSWORD`. Before touching the
@@ -259,3 +354,29 @@ async with pool.connection() as con:
259
354
  widget = await pg_retrieve(con, Widget, {"id": 1})
260
355
  await pg_upsert(con, Widget(id=1, name="thing"), Widget)
261
356
  ```
357
+
358
+ ## Releasing
359
+
360
+ Bump `version` in `pyproject.toml` as part of your PR, same as any other
361
+ change. Once that PR merges to `main` and the `Python Test` workflow passes
362
+ for that commit, `.github/workflows/auto-release.yml` automatically tags it
363
+ `vX.Y.Z`, cuts a GitHub Release (skipping if that version was already
364
+ released, e.g. a merge that didn't touch the version), and dispatches
365
+ `python-publish.yml` to publish it to PyPI — no manual release step, and no
366
+ extra secret to configure. Two non-obvious GitHub Actions quirks shaped
367
+ this (see the comments at the top of `auto-release.yml` for the full
368
+ reasoning, since both were hit and confirmed the hard way):
369
+
370
+ - A release created with the default `GITHUB_TOKEN` does **not** trigger
371
+ other workflows' `release: published` listeners (an anti-recursion
372
+ safeguard) — `workflow_dispatch` is the documented exception, so
373
+ `auto-release.yml` dispatches `python-publish.yml` directly (`gh workflow
374
+ run`) instead of relying on the release to cascade into it.
375
+ - `python-publish.yml` deliberately stays a plain, directly-triggered
376
+ top-level workflow rather than something `auto-release.yml` calls via
377
+ `workflow_call`: PyPI's OIDC trusted publishing does not support
378
+ reusable/called workflows and silently rejects the token in that shape.
379
+
380
+ `workflow_dispatch` (or an actual GitHub UI release) on `python-publish.yml`
381
+ still works as a manual fallback if you ever need to re-publish a version
382
+ without going through `auto-release.yml`.
@@ -1,15 +1,18 @@
1
1
  pgdevkit/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- pgdevkit/cli.py,sha256=xP4fZB0Dg3wivhb00DcP2w2Vnp8BRyaR2o1wD7jopFc,17574
2
+ pgdevkit/areas.py,sha256=iEmOObYxhZyDFl2iQVf1OQJN4JtZ-H3EL-qMfxYZdSQ,2203
3
+ pgdevkit/cli.py,sha256=vVUg4Feln7Mc0DbZIFGPNkOVRsl2AL3ZOXM30hRoWLc,21179
3
4
  pgdevkit/connection.py,sha256=x49aXKsJfyOsDTlqURs9idQUWaD-RO-XdtAqsG3guBI,4000
4
- pgdevkit/dialect.py,sha256=PL5Y8QPZNnoHIvAPomczGQX7IjZwxMgKGuREX80SwFA,3105
5
+ pgdevkit/dialect.py,sha256=gZwn4UNZ8PYBPC4CnYnfA95C7ppv7sWmpZSF8pjSPVw,3659
5
6
  pgdevkit/diff.py,sha256=Y2TbjwpFHYik0wZDhoI--9EAlIfp4AUg8BUubkOXyUg,9295
6
- pgdevkit/fetch_missing.py,sha256=soSNqnbA06A0-b_OI7plf772eurFVfYNz20EGO6BxO8,8190
7
+ pgdevkit/fetch_missing.py,sha256=vb8hXWBKMWgqkxHso5SuhX9l6rxNXfaybAEsajPLYow,8495
7
8
  pgdevkit/introspect.py,sha256=QYIlRuZk4lBHGxaW7zb_kFRIuNfi3AxH0Q2F_GKzlVI,7870
8
9
  pgdevkit/lakebase.py,sha256=0PABOk4p-2klLTY8QAUibQf9QeyNHKywUWEWGdkUY8k,3165
9
- pgdevkit/migrate.py,sha256=pU7xVssJiAHfFFU60DP2cwpjQ1Tf8Hscc_Q6wIqeSfM,13677
10
+ pgdevkit/migrate.py,sha256=UvR5hrkkM7yO65eUL3ZPwJ5405e6nJWMJDyIwu78ugA,12945
10
11
  pgdevkit/models.py,sha256=dAZ8f7pgDsxoLVrDWsM4h0qwJ0pOzXDdWMGsnjrW80E,2103
11
12
  pgdevkit/mssql_introspect.py,sha256=Fhv1N4zHYEDZ0PEy0OIpawUmoIFeoi-Lm97pmEyDmYY,12031
12
- pgdevkit/parser.py,sha256=gbXqp5vlCD-JxlvENuJtGIuFvL3U6ndeBOySgkwWPzw,15820
13
+ pgdevkit/parser.py,sha256=FmXL6-eCFzP_0FNyTrg4wF3aPzPeq7re2i0xvrSJIcs,16885
14
+ pgdevkit/schemas.py,sha256=zZsg3m9FtI7IUG8e8HfRruCh6-O6bdpbA7Kjnk3AYJg,5053
15
+ pgdevkit/sql_text.py,sha256=ednfdfxGBTWYqQ5Q3l3Zv9vrjjFmLiRlGKyq3tcozco,1744
13
16
  pgdevkit/backends/__init__.py,sha256=ys4VBglMImRtnudJBlTPVBNevDMRippuwAJOz0yRKiY,610
14
17
  pgdevkit/backends/base.py,sha256=0i0KLCYVWlpVFnUoq2t0LCRE_abyWPPIhw1bMxmW9KQ,1048
15
18
  pgdevkit/backends/mssql.py,sha256=pocBdoQ14ZnYLutXEOStVmJvmpclXGs3gaxHN0uJCMk,1068
@@ -24,22 +27,22 @@ pgdevkit/db/mssql_crud.py,sha256=yN4izZ0fsFwAs14008JECHH4dqjNbdHUHGEG8kidyNs,113
24
27
  pgdevkit/db/mssql_sql.py,sha256=DO3UMsTFN9pg0tNdaPGb0wf6lWlHGcqwqzkrGVhJF2g,2292
25
28
  pgdevkit/testdb/__init__.py,sha256=LpdC2XMBxqn9JrGmXhIho3sK8O1aNo_aD7leKyBr-Lo,204
26
29
  pgdevkit/testdb/_docker.py,sha256=kj_0xrGO_mxHlK83ilsB-yE0ELX1kpH22ruYrS-N3iU,1467
27
- pgdevkit/testdb/api.py,sha256=l1hqX7lLLLzk3hMgbtg7EpbZ7SdwPVPwupBCPu3xRRg,6038
30
+ pgdevkit/testdb/api.py,sha256=QE0UmtUNI1dc1MG4MdyatSbswHj4mAc-aWSjtL35x0w,7407
28
31
  pgdevkit/testdb/config.py,sha256=vjcDr3jH3-5VakNAEMqQu_ZHPXiYIvM1_BSETSJW5QU,2164
29
32
  pgdevkit/testdb/constants.py,sha256=xi8uIoEgN9LrRkr41j3mmWB9PqqU0r8aCUb2JJXBAs0,1165
30
33
  pgdevkit/testdb/container.py,sha256=kELTb6qaECAO9a5Bwvx1btv6i5QKRFAa9PX6auWymNo,2807
31
34
  pgdevkit/testdb/naming.py,sha256=Kyk5qEfxUkbggUG5bD7U7CJ_kAET332VPQNB-O5kvTY,1318
32
35
  pgdevkit/testdb/query.py,sha256=iYpy_EoYIq5USfwr6pNktmDjcGi_yQx0vHvjHZNGRr0,3854
33
- pgdevkit/testdb/schema.py,sha256=0N0Q_BQGh2kuAqa_j-DdbjYlTUOOZSEtaoUOApxQg_Y,12049
36
+ pgdevkit/testdb/schema.py,sha256=fuW5juQWoMW9J2mzPQxLZDlF4ZExEPCVhbQ5DCUMhNg,13349
34
37
  pgdevkit/testdb/mssql/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
35
- pgdevkit/testdb/mssql/api.py,sha256=42RAuJeiZlK1WJaTIYoj6l8GEAbEyt6LgAoF3G2jxnw,7537
38
+ pgdevkit/testdb/mssql/api.py,sha256=Q5Gt8t4LbDtDkMXXSQdd2_Ud7nhelF2UpD8KoUjI3qg,8203
36
39
  pgdevkit/testdb/mssql/constants.py,sha256=7n_NcYuhabbtb18CqG_H_rRbI7Wb_qu_l4nKk3281vI,2847
37
40
  pgdevkit/testdb/mssql/container.py,sha256=_rG0AMDXrNwok_E60Xai0DZdRiZ6Ba1j3GaLUnSAueg,3364
38
41
  pgdevkit/docs/database-layout.md,sha256=4GBwv4PA2cKHl2BxwLd-awLOe9he9ie4X6CpQWjIEyI,6820
39
42
  pgdevkit/skills/pgdevkit/SKILL.md,sha256=6uxe6BQwOLexGiBpf00VTFTHOiWJo5k2Qqzu172rb4I,12156
40
43
  pgdevkit/skills/pgdevkit/references/dynamic-sql.md,sha256=aTR1sx81moiTwaMaiOecaqk-9rT8y5Zxz7W1BoC3vK0,2774
41
44
  pgdevkit/skills/pgdevkit/references/temporal-tables.md,sha256=LwZnItvQWI7bB0oRHxIfUc0ynnq7N0V5Mnv-QHvFN3Q,1364
42
- pgdevkit-0.3.8.dist-info/METADATA,sha256=jQlfYkvzEolSPiofscelGtur9rzWQClSvOMQxIuc7Uo,11346
43
- pgdevkit-0.3.8.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
44
- pgdevkit-0.3.8.dist-info/entry_points.txt,sha256=FK2P55lf0WaGlOZJIINOGgCt8WB7ep0OnPsgnrp80bg,42
45
- pgdevkit-0.3.8.dist-info/RECORD,,
45
+ pgdevkit-0.5.0.dist-info/METADATA,sha256=1RXl13YLU2NmB8E4YXpLGc3pNr4EUp1C4INsK0pLJ-s,17629
46
+ pgdevkit-0.5.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
47
+ pgdevkit-0.5.0.dist-info/entry_points.txt,sha256=FK2P55lf0WaGlOZJIINOGgCt8WB7ep0OnPsgnrp80bg,42
48
+ pgdevkit-0.5.0.dist-info/RECORD,,