postgresledger 0.0.2__tar.gz → 0.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: postgresledger
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: Versioned PostgreSQL migrations from plain SQL, without an ORM: generated from schema.sql, tracked per database, with drift detection
5
5
  Keywords: postgresql,postgres,migrations,database-migrations,schema,sql,drift,pgschema
6
6
  Author: Nebojša Mrkić
@@ -283,7 +283,80 @@ environment; a variable set there wins over `.env`. `pgl --env-file .env.uat …
283
283
  Run `pgl` in the folder with `postgresledger.toml`, or point to it with
284
284
  `--config PATH`. `pgl <command> -h` describes each command.
285
285
  `baseline`, `new`, `capture` and `drift` print only what changed;
286
- `--verbose` (`-v`) prints pgschema's whole plan, SQL included.
286
+ `--verbose` (`-v`) prints pgschema's whole plan, SQL included. On every
287
+ command that dumps a database (also `snapshot` and `compare`), `--verbose`
288
+ shows pgschema's log as it connects: when a run hangs, it tells whether the
289
+ connection or the dump is slow.
290
+
291
+ ## Several schemas
292
+
293
+ By default `pgl` manages one schema, `public`, with its desired state in
294
+ `schema.sql`. To manage several schemas of the same database, list them in
295
+ `postgresledger.toml` instead of `schema` and `schema_file`:
296
+
297
+ ```toml
298
+ [project]
299
+ schemas = ["public", "billing"]
300
+ schema_dir = "database/schema" # public.sql, billing.sql (the default)
301
+ ```
302
+
303
+ Each schema has its own file of desired state, and every command works on
304
+ all of them: one `pgl new`, one `pgl apply`, one `pgl drift`. A migration
305
+ covers every schema that changed, one part per schema in the order of
306
+ `schemas`:
307
+
308
+ ```sql
309
+ -- schema: public
310
+ SET search_path TO public;
311
+ ...
312
+ -- schema: billing
313
+ CREATE SCHEMA IF NOT EXISTS billing; -- only in the first migration that has billing
314
+ SET search_path TO billing;
315
+ ...
316
+ ```
317
+
318
+ pgschema writes a schema's own objects without the schema name and other
319
+ schemas' objects with it (`REFERENCES public.customer (id)`), which is why
320
+ each part sets `search_path`. Snapshots hold every schema too, and `compare`
321
+ names the objects `<schema>.<name>`.
322
+
323
+ List a schema after the schemas it refers to: a view in `billing` on
324
+ `public.customer` needs `public` first. References in both directions
325
+ between two schemas are not supported.
326
+
327
+ A schema added to `schemas` that already exists in the databases with
328
+ objects in it: `pgl capture <name>` on one of them takes it into the schema
329
+ files and records the migration as captured there. The other databases that
330
+ already have the schema cannot run that migration, and `pgl` has no command
331
+ yet to record it there without running it.
332
+
333
+ ## Roles and grants
334
+
335
+ Roles (database users and groups) belong to the whole Postgres server and
336
+ usually differ between databases: other users on dev and prod, the roles a
337
+ managed database adds. pgschema never creates them. If grants were part of
338
+ the migrations, `pgl apply` would fail on a database without one of the
339
+ roles, and grants that differ on purpose would show up in `drift` and
340
+ `compare`.
341
+
342
+ So `pgl init` writes a `.pgschemaignore` next to `postgresledger.toml` that
343
+ leaves every grant and default privilege out of `schema.sql`, the migrations
344
+ and the snapshots:
345
+
346
+ ```toml
347
+ [privileges]
348
+ patterns = ["*"]
349
+
350
+ [default_privileges]
351
+ patterns = ["*"]
352
+ ```
353
+
354
+ The patterns are the names of the roles that receive the grants:
355
+ `["*", "!app_reader"]` keeps only the grants to `app_reader`, `["cloudsql*"]`
356
+ leaves out only the grants to `cloudsql*` roles. Delete the file to keep
357
+ every grant in the migrations. pgschema reads it from the folder `pgl` runs in,
358
+ on every command; more in the
359
+ [pgschema docs](https://www.pgschema.com/cli/ignore).
287
360
 
288
361
  ## Developing postgresledger
289
362
 
@@ -251,7 +251,80 @@ environment; a variable set there wins over `.env`. `pgl --env-file .env.uat …
251
251
  Run `pgl` in the folder with `postgresledger.toml`, or point to it with
252
252
  `--config PATH`. `pgl <command> -h` describes each command.
253
253
  `baseline`, `new`, `capture` and `drift` print only what changed;
254
- `--verbose` (`-v`) prints pgschema's whole plan, SQL included.
254
+ `--verbose` (`-v`) prints pgschema's whole plan, SQL included. On every
255
+ command that dumps a database (also `snapshot` and `compare`), `--verbose`
256
+ shows pgschema's log as it connects: when a run hangs, it tells whether the
257
+ connection or the dump is slow.
258
+
259
+ ## Several schemas
260
+
261
+ By default `pgl` manages one schema, `public`, with its desired state in
262
+ `schema.sql`. To manage several schemas of the same database, list them in
263
+ `postgresledger.toml` instead of `schema` and `schema_file`:
264
+
265
+ ```toml
266
+ [project]
267
+ schemas = ["public", "billing"]
268
+ schema_dir = "database/schema" # public.sql, billing.sql (the default)
269
+ ```
270
+
271
+ Each schema has its own file of desired state, and every command works on
272
+ all of them: one `pgl new`, one `pgl apply`, one `pgl drift`. A migration
273
+ covers every schema that changed, one part per schema in the order of
274
+ `schemas`:
275
+
276
+ ```sql
277
+ -- schema: public
278
+ SET search_path TO public;
279
+ ...
280
+ -- schema: billing
281
+ CREATE SCHEMA IF NOT EXISTS billing; -- only in the first migration that has billing
282
+ SET search_path TO billing;
283
+ ...
284
+ ```
285
+
286
+ pgschema writes a schema's own objects without the schema name and other
287
+ schemas' objects with it (`REFERENCES public.customer (id)`), which is why
288
+ each part sets `search_path`. Snapshots hold every schema too, and `compare`
289
+ names the objects `<schema>.<name>`.
290
+
291
+ List a schema after the schemas it refers to: a view in `billing` on
292
+ `public.customer` needs `public` first. References in both directions
293
+ between two schemas are not supported.
294
+
295
+ A schema added to `schemas` that already exists in the databases with
296
+ objects in it: `pgl capture <name>` on one of them takes it into the schema
297
+ files and records the migration as captured there. The other databases that
298
+ already have the schema cannot run that migration, and `pgl` has no command
299
+ yet to record it there without running it.
300
+
301
+ ## Roles and grants
302
+
303
+ Roles (database users and groups) belong to the whole Postgres server and
304
+ usually differ between databases: other users on dev and prod, the roles a
305
+ managed database adds. pgschema never creates them. If grants were part of
306
+ the migrations, `pgl apply` would fail on a database without one of the
307
+ roles, and grants that differ on purpose would show up in `drift` and
308
+ `compare`.
309
+
310
+ So `pgl init` writes a `.pgschemaignore` next to `postgresledger.toml` that
311
+ leaves every grant and default privilege out of `schema.sql`, the migrations
312
+ and the snapshots:
313
+
314
+ ```toml
315
+ [privileges]
316
+ patterns = ["*"]
317
+
318
+ [default_privileges]
319
+ patterns = ["*"]
320
+ ```
321
+
322
+ The patterns are the names of the roles that receive the grants:
323
+ `["*", "!app_reader"]` keeps only the grants to `app_reader`, `["cloudsql*"]`
324
+ leaves out only the grants to `cloudsql*` roles. Delete the file to keep
325
+ every grant in the migrations. pgschema reads it from the folder `pgl` runs in,
326
+ on every command; more in the
327
+ [pgschema docs](https://www.pgschema.com/cli/ignore).
255
328
 
256
329
  ## Developing postgresledger
257
330
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "postgresledger"
3
- version = "0.0.2"
3
+ version = "0.0.3"
4
4
  description = "Versioned PostgreSQL migrations from plain SQL, without an ORM: generated from schema.sql, tracked per database, with drift detection"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "postgresledger"
3
- version = "0.0.2" # set from the release tag by .github/workflows/publish.yml, not here
3
+ version = "0.0.3" # set from the release tag by .github/workflows/publish.yml, not here
4
4
  description = "Versioned PostgreSQL migrations from plain SQL, without an ORM: generated from schema.sql, tracked per database, with drift detection"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -17,8 +17,9 @@ NO_CONFIG = {"init", "setup"}
17
17
  # commands that read the project but never touch a database
18
18
  # (compare with two snapshots; compare with one checks DATABASE_NAME itself)
19
19
  NO_DATABASE = {"new", "compare"}
20
- # commands that make a pgschema plan; --verbose prints all of it, SQL included
21
- VERBOSE = {"baseline", "new", "drift", "capture"}
20
+ # commands that dump a database or make a pgschema plan; --verbose shows pgschema's
21
+ # debug log while it dumps and the whole plan, SQL included
22
+ VERBOSE = {"baseline", "new", "drift", "capture", "snapshot", "compare"}
22
23
 
23
24
  INTRO = """\
24
25
  Versioned PostgreSQL migrations from plain SQL, without an ORM.
@@ -64,7 +65,8 @@ def add_apply_arguments(command_parser: argparse.ArgumentParser) -> None:
64
65
 
65
66
  def add_verbose_argument(command_parser: argparse.ArgumentParser) -> None:
66
67
  command_parser.add_argument("-v", "--verbose", action="store_true",
67
- help="print pgschema's whole plan, including the SQL")
68
+ help="show pgschema's debug log while it dumps the database, "
69
+ "and its whole plan including the SQL")
68
70
 
69
71
 
70
72
  def add_snapshot_arguments(command_parser: argparse.ArgumentParser) -> None:
@@ -92,7 +94,8 @@ COMMANDS = [
92
94
  Command(
93
95
  "setup", "init", "[--dir D]", "create postgresledger.toml, .env and folders",
94
96
  "Creates postgresledger.toml (project settings, committed) and .env with DATABASE_* (never committed, "
95
- "added to .gitignore) in the current folder, plus <dir>/schema/ and <dir>/migrations/, then runs setup.",
97
+ "added to .gitignore) in the current folder, plus <dir>/schema/ and <dir>/migrations/, then runs setup. "
98
+ "Also .pgschemaignore, which keeps grants out of the migrations (delete it to keep them).",
96
99
  add_init_arguments),
97
100
  Command(
98
101
  "setup", "setup", "", "download pgschema + scratch Postgres, check psql",
@@ -102,7 +105,8 @@ COMMANDS = [
102
105
 
103
106
  Command(
104
107
  "migrations", "baseline", "", "snapshot the database as 0001_baseline",
105
- "No migrations yet: dumps the database into schema.sql and creates 0001_baseline.sql from it, "
108
+ "No migrations yet: dumps the database into schema.sql (one file per schema with `schemas`) and "
109
+ "creates 0001_baseline.sql from it, "
106
110
  "recorded as 'baseline' (not executed). Migrations exist: an existing database that already has "
107
111
  "exactly their schema gets them recorded as 'baseline' instead of applying them; if it differs, "
108
112
  "the differences are listed and nothing is recorded."),
@@ -193,6 +197,16 @@ def parser() -> argparse.ArgumentParser:
193
197
 
194
198
 
195
199
  def main(argv: list[str] | None = None) -> None:
200
+ try:
201
+ run_command(argv)
202
+ except KeyboardInterrupt:
203
+ # Ctrl+C: psql / pgschema got it as well and stopped, the scratch Postgres is cleaned up;
204
+ # a Python traceback would only hide that
205
+ print("\ninterrupted", file=sys.stderr)
206
+ sys.exit(130)
207
+
208
+
209
+ def run_command(argv: list[str] | None) -> None:
196
210
  args = parser().parse_args(argv)
197
211
  if args.cmd in NO_CONFIG:
198
212
  sys.exit(args.fn(args) or 0)
@@ -4,25 +4,32 @@ from __future__ import annotations
4
4
 
5
5
  import sys
6
6
  import tempfile
7
+ import time
7
8
  from pathlib import Path
8
9
 
9
10
  from . import pgschema, scratch
10
11
  from .compare import compare
11
12
  from .config import CONFIG_NAME, Config, Connection, relative
12
13
  from .core import (
14
+ Migration,
15
+ Plan,
13
16
  applied,
14
17
  apply_file,
15
18
  build_shadow,
16
19
  check_checksums,
20
+ create_missing_schemas,
21
+ dump_schemas,
22
+ dump_text,
17
23
  ensure_meta,
18
24
  generate_migration,
25
+ has_changes,
19
26
  list_migrations,
20
- pgschema_dump,
21
- pgschema_plan,
22
- print_plan,
27
+ plan_schemas,
28
+ print_plans,
23
29
  psql,
24
30
  record_migration_sql,
25
31
  require_up_to_date,
32
+ schema_files_in,
26
33
  )
27
34
 
28
35
 
@@ -36,6 +43,9 @@ def config_file_text(folder: str) -> str:
36
43
  [project]
37
44
  schema = "public" # schema managed by postgresledger
38
45
  schema_file = "{folder}/schema/schema.sql" # desired state, the source of truth
46
+ # several schemas: instead of schema and schema_file
47
+ # schemas = ["public", "billing"] # in this order inside every migration
48
+ # schema_dir = "{folder}/schema" # one <schema>.sql per schema
39
49
  migrations_dir = "{folder}/migrations"
40
50
  snapshots_dir = "{folder}/snapshots" # pgl snapshot <name> writes here
41
51
  meta_schema = "postgresledger" # tracking table: <meta_schema>.schema_migrations
@@ -54,6 +64,30 @@ DATABASE_NAME={database}
54
64
  """
55
65
 
56
66
 
67
+ PGSCHEMA_IGNORE_NAME = ".pgschemaignore"
68
+
69
+ # what `pgl init` writes to .pgschemaignore
70
+ PGSCHEMA_IGNORE_TEXT = """\
71
+ # Read by pgschema on every pgl command run from this folder. Committed to git.
72
+ #
73
+ # Grants and default privileges are left out of schema.sql, the migrations and
74
+ # the snapshots: roles usually differ between databases (and pgschema never
75
+ # creates them), so a GRANT to a missing role would make `pgl apply` fail, and
76
+ # per-database grants would show up as drift. Manage them outside migrations.
77
+ #
78
+ # The patterns are names of the roles that receive the grants:
79
+ # ["*", "!app_reader"] keep only the grants to app_reader
80
+ # ["cloudsql*"] leave out only the grants to cloudsql* roles
81
+ # Delete this file to keep every grant in the migrations.
82
+
83
+ [privileges]
84
+ patterns = ["*"]
85
+
86
+ [default_privileges]
87
+ patterns = ["*"]
88
+ """
89
+
90
+
57
91
  def database_name_from_folder(folder_name: str) -> str:
58
92
  """The project folder name as a database name: lowercase letters, digits and _ ("My-App" -> "my_app")."""
59
93
  allowed_characters = "abcdefghijklmnopqrstuvwxyz0123456789_"
@@ -83,6 +117,12 @@ def cmd_init(args) -> int:
83
117
  dotenv_file.write_text(dotenv_file_text(database_name_from_folder(root.name)))
84
118
  created.append(".env")
85
119
 
120
+ # an existing file is the project's own choice: keep it
121
+ pgschema_ignore = root / PGSCHEMA_IGNORE_NAME
122
+ if not pgschema_ignore.exists():
123
+ pgschema_ignore.write_text(PGSCHEMA_IGNORE_TEXT)
124
+ created.append(f"{PGSCHEMA_IGNORE_NAME} (no grants)")
125
+
86
126
  gitignore = root / ".gitignore"
87
127
  ignored = gitignore.read_text().splitlines() if gitignore.exists() else []
88
128
  if ".env" not in ignored:
@@ -106,13 +146,28 @@ def cmd_setup(args) -> int:
106
146
  return pgschema.setup()
107
147
 
108
148
 
149
+ def schema_files_label(cfg: Config) -> str:
150
+ """The schema files as the user would type them, e.g. for "dump ... -> <files>"."""
151
+ return ", ".join(relative(schema_file) for schema_file in cfg.schema_files.values())
152
+
153
+
154
+ def database_plans(cfg: Config, connection: Connection, scratch_server: Connection, migrations: list[Migration],
155
+ verbose: bool) -> dict[str, Plan]:
156
+ """What turns the state of `migrations` into the database as it is, per schema."""
157
+ shadow = build_shadow(cfg, scratch_server, migrations)
158
+ create_missing_schemas(cfg, shadow)
159
+ with tempfile.TemporaryDirectory() as temporary_dir:
160
+ actual_files = schema_files_in(cfg, Path(temporary_dir))
161
+ dump_schemas(cfg, connection, actual_files, verbose)
162
+ return plan_schemas(cfg, shadow, actual_files, scratch_server)
163
+
164
+
109
165
  def cmd_baseline(cfg: Config, connection: Connection, args) -> int:
110
166
  migrations = list_migrations(cfg)
111
167
  with scratch.server() as scratch_server:
112
168
  if not migrations:
113
- cfg.schema_file.parent.mkdir(parents=True, exist_ok=True)
114
- print(f"- dump {connection.label} -> {relative(cfg.schema_file)}")
115
- pgschema_dump(cfg, connection, cfg.schema_file)
169
+ print(f"- dump {connection.label} -> {schema_files_label(cfg)}")
170
+ dump_schemas(cfg, connection, cfg.schema_files, args.verbose)
116
171
  baseline = generate_migration(cfg, scratch_server, "baseline", f"baseline taken from {connection.database}",
117
172
  args.verbose)
118
173
  if baseline is None:
@@ -125,14 +180,10 @@ def cmd_baseline(cfg: Config, connection: Connection, args) -> int:
125
180
  if applied(cfg, connection):
126
181
  sys.exit(f"{connection.label} already tracks migrations - use `pgl apply`")
127
182
  print(f"- building the state of {len(migrations)} migrations (scratch Postgres)")
128
- shadow = build_shadow(cfg, scratch_server, migrations)
129
- with tempfile.TemporaryDirectory() as temporary_dir:
130
- actual = Path(temporary_dir) / "actual.sql"
131
- pgschema_dump(cfg, connection, actual)
132
- plan = pgschema_plan(cfg, shadow, actual, scratch_server)
133
- if plan.sql:
183
+ plans = database_plans(cfg, connection, scratch_server, migrations, args.verbose)
184
+ if has_changes(plans):
134
185
  print(f"✘ {connection.label} does not match the migrations, so they cannot be recorded as already there:\n")
135
- print_plan(plan, args.verbose)
186
+ print_plans(plans, args.verbose)
136
187
  print("\nempty database: `pgl apply` instead; otherwise align it first (see `pgl compare`)")
137
188
  return 1
138
189
  # every record in one transaction: all of them or none
@@ -209,17 +260,13 @@ def cmd_drift(cfg: Config, connection: Connection, args) -> int:
209
260
 
210
261
  with scratch.server() as scratch_server:
211
262
  print(f"- building the state of the {len(has)} migrations {connection.database} has (scratch Postgres)")
212
- shadow = build_shadow(cfg, scratch_server, has)
213
- with tempfile.TemporaryDirectory() as tmp_dir:
214
- actual = Path(tmp_dir) / "actual.sql"
215
- pgschema_dump(cfg, connection, actual)
216
- # "what turns the expected state into the actual database" = the manual changes
217
- plan = pgschema_plan(cfg, shadow, actual, scratch_server)
218
- if not plan.sql:
263
+ # "what turns the expected state into the actual database" = the manual changes
264
+ plans = database_plans(cfg, connection, scratch_server, has, args.verbose)
265
+ if not has_changes(plans):
219
266
  print(f"✔ {connection.label}: no drift, the database matches its migrations")
220
267
  return 0
221
268
  print(f"✘ {connection.label}: DRIFT - the database was changed outside of migrations:\n")
222
- print_plan(plan, args.verbose)
269
+ print_plans(plans, args.verbose)
223
270
  if pending:
224
271
  print("\nrun `pgl apply` first, then keep or drop the changes:")
225
272
  print("\nkeep the changes: pgl capture <name>")
@@ -229,15 +276,21 @@ def cmd_drift(cfg: Config, connection: Connection, args) -> int:
229
276
 
230
277
  def cmd_capture(cfg: Config, connection: Connection, args) -> None:
231
278
  require_up_to_date(cfg, connection)
232
- print(f"- dump {connection.label} -> {relative(cfg.schema_file)} (manual changes become the desired state)")
233
- # put back when there is nothing to capture
234
- schema_before = cfg.schema_file.read_text()
235
- pgschema_dump(cfg, connection, cfg.schema_file)
279
+ print(f"- dump {connection.label} -> {schema_files_label(cfg)} (manual changes become the desired state)")
280
+ # put back when there is nothing to capture; None for a schema file that isn't there yet
281
+ files_before = {}
282
+ for schema, schema_file in cfg.schema_files.items():
283
+ files_before[schema] = schema_file.read_text() if schema_file.exists() else None
284
+ dump_schemas(cfg, connection, cfg.schema_files, args.verbose)
236
285
  note = f"manual changes captured from {connection.database} (recorded as captured there)"
237
286
  with scratch.server() as scratch_server:
238
287
  migration = generate_migration(cfg, scratch_server, args.name, note, args.verbose)
239
288
  if migration is None:
240
- cfg.schema_file.write_text(schema_before)
289
+ for schema, schema_file in cfg.schema_files.items():
290
+ if files_before[schema] is None:
291
+ schema_file.unlink()
292
+ else:
293
+ schema_file.write_text(files_before[schema])
241
294
  print(f"{connection.label}: no drift, nothing captured")
242
295
  return
243
296
  psql(connection, record_migration_sql(cfg, migration, "captured"))
@@ -255,8 +308,11 @@ def cmd_snapshot(cfg: Config, connection: Connection, args) -> None:
255
308
  if not is_valid_snapshot_name(args.name):
256
309
  sys.exit("snapshot name: letters, digits, _ . - only")
257
310
  snapshot = cfg.snapshots_dir / f"{args.name}.sql"
258
- pgschema_dump(cfg, connection, snapshot)
259
- print(f"✔ {connection.label} -> {relative(snapshot)}")
311
+ print(f"- dump {connection.label} -> {relative(snapshot)}")
312
+ started = time.monotonic()
313
+ snapshot.parent.mkdir(parents=True, exist_ok=True)
314
+ snapshot.write_text(dump_text(cfg, connection, args.verbose))
315
+ print(f"✔ {relative(snapshot)} written in {time.monotonic() - started:.1f} s")
260
316
 
261
317
 
262
318
  def snapshot_file(cfg: Config, name_or_path: str) -> Path:
@@ -277,10 +333,8 @@ def cmd_compare(cfg: Config, args) -> int:
277
333
  # no second snapshot: compare with the database itself, dumped right now
278
334
  if cfg.target is None:
279
335
  sys.exit("comparing with the database needs DATABASE_NAME in .env - or give two snapshots")
280
- with tempfile.TemporaryDirectory() as temporary_dir:
281
- live_dump_file = Path(temporary_dir) / "live.sql"
282
- pgschema_dump(cfg, cfg.target, live_dump_file)
283
- second_dump = live_dump_file.read_text()
336
+ print(f"- dump {cfg.target.label}")
337
+ second_dump = dump_text(cfg, cfg.target, args.verbose)
284
338
  second_label = cfg.target.label
285
339
  comparison = compare(first_dump, second_dump, args.a, second_label)
286
340
  print(comparison.report)
@@ -3,6 +3,9 @@
3
3
  pgschema writes every object as its own block under a header
4
4
  `-- Name: <name>; Type: <type>; Schema: ...`, in a stable order, so two dumps
5
5
  can be compared block by block.
6
+
7
+ With several schemas, a snapshot is one dump per schema, each under a
8
+ `-- schema: <name>` line, and objects are named <schema>.<name>.
6
9
  """
7
10
 
8
11
  from __future__ import annotations
@@ -11,6 +14,8 @@ import difflib
11
14
  from dataclasses import dataclass
12
15
 
13
16
  HEADER_START = "-- Name: "
17
+ # starts each schema's part in a snapshot or a migration of several schemas
18
+ SCHEMA_MARKER = "-- schema: "
14
19
  # lines around an object's SQL that are not part of it
15
20
  SEPARATOR_LINES = ("", "--")
16
21
 
@@ -60,11 +65,22 @@ def objects(dump: str) -> dict[SchemaObject, str]:
60
65
  sql_by_object: dict[SchemaObject, str] = {}
61
66
  current_object = None
62
67
  current_lines: list[str] = []
68
+ # set by a `-- schema: <name>` line; a dump of one schema has none
69
+ current_schema = ""
63
70
  for line in dump.splitlines():
71
+ if line.startswith(SCHEMA_MARKER):
72
+ # the previous object ends where the next schema starts
73
+ if current_object is not None:
74
+ add_object(sql_by_object, current_object, current_lines)
75
+ current_object = None
76
+ current_schema = line.removeprefix(SCHEMA_MARKER).strip()
77
+ continue
64
78
  header_object = parse_header(line)
65
79
  if header_object is not None:
66
80
  if current_object is not None:
67
81
  add_object(sql_by_object, current_object, current_lines)
82
+ if current_schema:
83
+ header_object = SchemaObject(header_object.type, f"{current_schema}.{header_object.name}")
68
84
  current_object = header_object
69
85
  current_lines = []
70
86
  elif current_object is not None:
@@ -53,12 +53,16 @@ class Config:
53
53
  self.path = path.resolve()
54
54
  self.root = self.path.parent # paths in the config file are relative to it
55
55
  project = tomllib.loads(self.path.read_text()).get("project", {})
56
- self.schema = project.get("schema", "public") # the schema pgschema manages
57
- self.schema_file = self.root / project.get("schema_file", "database/schema/schema.sql")
56
+ # every schema pgschema manages, with the file of its desired state, in the order of the config
57
+ self.schema_files = schema_files(self.root, project)
58
+ self.schemas = list(self.schema_files)
58
59
  self.migrations_dir = self.root / project.get("migrations_dir", "database/migrations")
59
60
  self.snapshots_dir = self.root / project.get("snapshots_dir", "database/snapshots")
60
61
  self.meta_schema = project.get("meta_schema", DEFAULT_META_SCHEMA)
61
62
  self.meta_table = f"{self.meta_schema}.schema_migrations"
63
+ if self.meta_schema in self.schemas:
64
+ sys.exit(f"{CONFIG_NAME}: meta_schema '{self.meta_schema}' is for the tracking table only, "
65
+ "it cannot be a managed schema")
62
66
  self.env_file = env_file or Path(os.environ.get("PGL_ENV_FILE") or self.root / ".env")
63
67
 
64
68
  # the one database this run works on; None when DATABASE_NAME is not set.
@@ -76,6 +80,49 @@ class Config:
76
80
  sslmode=os.environ.get("DATABASE_SSLMODE", "prefer"),
77
81
  )
78
82
 
83
+ @property
84
+ def names_schemas(self) -> bool:
85
+ """Whether migrations and snapshots name the schema of each part.
86
+
87
+ A project with only `public` keeps the plain pgschema output; any other schema needs its
88
+ name, because pgschema writes the objects of a schema without it (CREATE TABLE invoice).
89
+ """
90
+ return self.schemas != ["public"]
91
+
92
+
93
+ def is_valid_schema_name(name: str) -> bool:
94
+ """Lowercase letters, digits and _, not starting with a digit: safe unquoted in SQL and as a file name."""
95
+ allowed_characters = set("abcdefghijklmnopqrstuvwxyz0123456789_")
96
+ return isinstance(name, str) and name != "" and not name[0].isdigit() and set(name) <= allowed_characters
97
+
98
+
99
+ def schema_files(root: Path, project: dict) -> dict[str, Path]:
100
+ """Every managed schema with the file of its desired state, in the order of the config.
101
+
102
+ `schemas = [...]` keeps one <schema_dir>/<schema>.sql per schema. The older `schema` and
103
+ `schema_file` are one schema with its own file.
104
+ """
105
+ if "schemas" not in project:
106
+ schema = project.get("schema", "public")
107
+ if not is_valid_schema_name(schema):
108
+ sys.exit(f"{CONFIG_NAME}: schema '{schema}' - lowercase letters, digits and _ only")
109
+ return {schema: root / project.get("schema_file", "database/schema/schema.sql")}
110
+
111
+ if "schema" in project or "schema_file" in project:
112
+ sys.exit(f"{CONFIG_NAME}: use either `schemas` and `schema_dir`, or `schema` and `schema_file`, not both")
113
+ schemas = project["schemas"]
114
+ if not isinstance(schemas, list) or not schemas:
115
+ sys.exit(f'{CONFIG_NAME}: schemas must be a list of names, e.g. ["public", "billing"]')
116
+ schema_dir = root / project.get("schema_dir", "database/schema")
117
+ files = {}
118
+ for schema in schemas:
119
+ if not is_valid_schema_name(schema):
120
+ sys.exit(f"{CONFIG_NAME}: schema '{schema}' - lowercase letters, digits and _ only")
121
+ if schema in files:
122
+ sys.exit(f"{CONFIG_NAME}: schema '{schema}' is listed twice")
123
+ files[schema] = schema_dir / f"{schema}.sql"
124
+ return files
125
+
79
126
 
80
127
  def relative(path: Path) -> str:
81
128
  """The path as the user would type it from the current folder."""
@@ -14,6 +14,7 @@ from dataclasses import dataclass, replace
14
14
  from pathlib import Path
15
15
 
16
16
  from . import pgschema
17
+ from .compare import SCHEMA_MARKER
17
18
  from .config import Config, Connection, relative
18
19
 
19
20
  MIGRATION_RE = re.compile(r"^(\d{4})_([a-z0-9_]+)\.sql$")
@@ -23,16 +24,26 @@ SHADOW_DB = "shadow"
23
24
  PLAN_DB = "plan"
24
25
 
25
26
 
26
- def run(command: list[str], connection: Connection, *, stdin: str | None = None) -> subprocess.CompletedProcess:
27
- """Run psql or pgschema against `connection`; if it fails, show its output and stop pgl."""
27
+ def run(command: list[str], connection: Connection, *, stdin: str | None = None,
28
+ show_stderr: bool = False) -> subprocess.CompletedProcess:
29
+ """Run psql or pgschema against `connection`; if it fails, show its output and stop pgl.
30
+
31
+ With `show_stderr` the program's log goes straight to the terminal while it runs
32
+ (only stdout is captured), e.g. pgschema --debug.
33
+ """
28
34
  # the shell environment (PATH, HOME, ...) plus the PG* variables of this connection
29
35
  environment = {**os.environ, **connection.environment()}
30
36
  program = Path(command[0]).name
37
+ stderr = None if show_stderr else subprocess.PIPE
38
+ if show_stderr:
39
+ # pgl's own lines first: when stdout is a pipe (CI, | less) Python would print them later
40
+ sys.stdout.flush()
31
41
  try:
32
- return subprocess.run(command, input=stdin, env=environment, text=True, capture_output=True, check=True)
42
+ return subprocess.run(command, input=stdin, env=environment, text=True,
43
+ stdout=subprocess.PIPE, stderr=stderr, check=True)
33
44
  except subprocess.CalledProcessError as error:
34
- # the program ran and failed: its own output says why
35
- program_output = (error.stdout + error.stderr).strip()
45
+ # the program ran and failed: its own output says why (stderr is None when already shown)
46
+ program_output = (error.stdout + (error.stderr or "")).strip()
36
47
  sys.exit(f"{program} failed on {connection.label}:\n{program_output}")
37
48
  except OSError as error:
38
49
  # the program could not run at all, e.g. psql is not installed
@@ -193,10 +204,55 @@ def build_shadow(cfg: Config, scratch: Connection, migrations: list[Migration])
193
204
  return shadow
194
205
 
195
206
 
207
+ def existing_schemas(cfg: Config, connection: Connection) -> list[str]:
208
+ """The managed schemas `connection` has, in the order of the config."""
209
+ # schema names are checked in config.py: lowercase letters, digits and _ only
210
+ quoted_names = ", ".join(f"'{schema}'" for schema in cfg.schemas)
211
+ found = psql(connection, f"SELECT nspname FROM pg_namespace WHERE nspname IN ({quoted_names})").splitlines()
212
+ return [schema for schema in cfg.schemas if schema in found]
213
+
214
+
215
+ def create_missing_schemas(cfg: Config, scratch_database: Connection) -> list[str]:
216
+ """Create the managed schemas a scratch database doesn't have yet and return them.
217
+
218
+ pgschema plans only inside a schema that exists; the schemas created here are the ones
219
+ no migration has created yet.
220
+ """
221
+ present = existing_schemas(cfg, scratch_database)
222
+ missing = [schema for schema in cfg.schemas if schema not in present]
223
+ for schema in missing:
224
+ psql(scratch_database, f"CREATE SCHEMA {schema}")
225
+ return missing
226
+
227
+
228
+ def schema_files_in(cfg: Config, folder: Path) -> dict[str, Path]:
229
+ """One <schema>.sql in `folder` for every managed schema, e.g. for dumps that are thrown away."""
230
+ files = {}
231
+ for schema in cfg.schemas:
232
+ files[schema] = folder / f"{schema}.sql"
233
+ return files
234
+
235
+
236
+ def load_plan_database(cfg: Config, scratch: Connection, desired_files: dict[str, Path]) -> None:
237
+ """Recreate the plan database; with several schemas, load the desired state of all of them into it.
238
+
239
+ pgschema loads the desired state of one schema into a temporary schema of the plan database.
240
+ References to another schema (REFERENCES public.customer) must find that object there.
241
+ """
242
+ plan_database = reset_database(scratch, PLAN_DB)
243
+ if len(cfg.schemas) == 1:
244
+ return
245
+ # a function body may use a schema that is loaded after it
246
+ script = ["SET check_function_bodies = off;"]
247
+ for schema, desired in desired_files.items():
248
+ script.append(f"CREATE SCHEMA IF NOT EXISTS {schema};")
249
+ script.append(f"SET search_path TO {schema};")
250
+ script.append(f"\\i {desired}")
251
+ psql(plan_database, "\n".join(script))
252
+
253
+
196
254
  def plan_arguments(scratch: Connection) -> list[str]:
197
255
  """pgschema validates the desired state in a database on the scratch server (not its own embedded one)."""
198
- if not psql(scratch, f"SELECT 1 FROM pg_database WHERE datname = '{PLAN_DB}'"):
199
- psql(scratch, f'CREATE DATABASE "{PLAN_DB}"')
200
256
  return [
201
257
  "--plan-host", scratch.host,
202
258
  "--plan-port", str(scratch.port),
@@ -206,12 +262,51 @@ def plan_arguments(scratch: Connection) -> list[str]:
206
262
  ]
207
263
 
208
264
 
209
- def pgschema_dump(cfg: Config, connection: Connection, output_file: Path) -> None:
265
+ def pgschema_dump(schema: str, connection: Connection, output_file: Path, verbose: bool = False) -> None:
266
+ """One schema of `connection` into `output_file`.
267
+
268
+ With `verbose`, pgschema's debug log is shown as it happens: it says when the connection
269
+ is being made and when it is up, so a slow connection and a slow dump can be told apart.
270
+ """
210
271
  output_file.parent.mkdir(parents=True, exist_ok=True)
211
- dump = run([pgschema.locate(), "dump", "--schema", cfg.schema], connection).stdout
272
+ command = [pgschema.locate(), "dump", "--schema", schema]
273
+ if verbose:
274
+ command.append("--debug")
275
+ dump = run(command, connection, show_stderr=verbose).stdout
212
276
  output_file.write_text(dump)
213
277
 
214
278
 
279
+ def dump_schemas(cfg: Config, connection: Connection, output_files: dict[str, Path], verbose: bool = False) -> None:
280
+ """Every managed schema of `connection` into its file in `output_files`.
281
+
282
+ A schema the database doesn't have gets an empty file: an empty schema, as pgschema
283
+ cannot dump a schema that doesn't exist.
284
+ """
285
+ present = existing_schemas(cfg, connection)
286
+ for schema, output_file in output_files.items():
287
+ if schema in present:
288
+ pgschema_dump(schema, connection, output_file, verbose)
289
+ else:
290
+ output_file.parent.mkdir(parents=True, exist_ok=True)
291
+ output_file.write_text("")
292
+
293
+
294
+ def dump_text(cfg: Config, connection: Connection, verbose: bool = False) -> str:
295
+ """The managed schemas of `connection` as one text, for snapshots and compare.
296
+
297
+ Only `public`: the plain pgschema dump. Otherwise every schema's dump under `-- schema: <name>`.
298
+ """
299
+ with tempfile.TemporaryDirectory() as temporary_dir:
300
+ dump_files = schema_files_in(cfg, Path(temporary_dir))
301
+ dump_schemas(cfg, connection, dump_files, verbose)
302
+ if not cfg.names_schemas:
303
+ return dump_files["public"].read_text()
304
+ parts = []
305
+ for schema, dump_file in dump_files.items():
306
+ parts.append(f"{SCHEMA_MARKER}{schema}\n{dump_file.read_text()}")
307
+ return "\n".join(parts)
308
+
309
+
215
310
  # where pgschema's readable plan starts repeating the whole SQL
216
311
  DDL_HEADER = "DDL to be executed:"
217
312
 
@@ -235,19 +330,41 @@ class Plan:
235
330
  return first_line.removeprefix("Plan:").strip().removesuffix(".")
236
331
 
237
332
 
238
- def print_plan(plan: Plan, verbose: bool) -> None:
239
- """The changed objects, and with --verbose also the SQL."""
240
- print(plan.human if verbose else plan.changes)
333
+ def print_plans(plans: dict[str, Plan], verbose: bool) -> None:
334
+ """The changed objects of every schema that changes, and with --verbose also the SQL."""
335
+ for schema, plan in plans.items():
336
+ if not plan.sql:
337
+ continue
338
+ if len(plans) > 1:
339
+ print(f"schema {schema}:")
340
+ print(plan.human if verbose else plan.changes)
341
+
342
+
343
+ def has_changes(plans: dict[str, Plan]) -> bool:
344
+ """Whether any schema's plan changes something."""
345
+ return any(plan.sql for plan in plans.values())
241
346
 
242
347
 
243
- def pgschema_plan(cfg: Config, connection: Connection, desired: Path, scratch: Connection) -> Plan:
244
- """The plan that turns `connection` into the state from `desired`."""
348
+ def plans_headline(plans: dict[str, Plan]) -> str:
349
+ """The size of the plans, e.g. "1 to add", or with several schemas "public: 1 to add, billing: 2 to add"."""
350
+ if len(plans) == 1:
351
+ only_plan = next(iter(plans.values()))
352
+ return only_plan.headline
353
+ headlines = []
354
+ for schema, plan in plans.items():
355
+ if plan.sql:
356
+ headlines.append(f"{schema}: {plan.headline}")
357
+ return ", ".join(headlines)
358
+
359
+
360
+ def pgschema_plan(schema: str, connection: Connection, desired: Path, scratch: Connection) -> Plan:
361
+ """The plan that turns `schema` on `connection` into the state from `desired`."""
245
362
  with tempfile.TemporaryDirectory() as temporary_dir:
246
363
  sql_file = Path(temporary_dir) / "plan.sql"
247
364
  human_file = Path(temporary_dir) / "plan.txt"
248
365
  command = [
249
366
  pgschema.locate(), "plan",
250
- "--schema", cfg.schema,
367
+ "--schema", schema,
251
368
  "--file", str(desired),
252
369
  "--output-sql", str(sql_file),
253
370
  "--output-human", str(human_file),
@@ -261,6 +378,37 @@ def pgschema_plan(cfg: Config, connection: Connection, desired: Path, scratch: C
261
378
  return Plan(sql=sql.strip(), human=human.strip())
262
379
 
263
380
 
381
+ def plan_schemas(cfg: Config, scratch_database: Connection, desired_files: dict[str, Path],
382
+ scratch: Connection) -> dict[str, Plan]:
383
+ """For every managed schema, the plan that turns it on `scratch_database` into its desired file."""
384
+ load_plan_database(cfg, scratch, desired_files)
385
+ plans = {}
386
+ for schema, desired in desired_files.items():
387
+ plans[schema] = pgschema_plan(schema, scratch_database, desired, scratch)
388
+ return plans
389
+
390
+
391
+ def migration_sql(cfg: Config, plans: dict[str, Plan], new_schemas: list[str]) -> str:
392
+ """The plans of every schema as the SQL of one migration, in the order of the config.
393
+
394
+ pgschema writes a schema's own objects without the schema name, so each part starts with
395
+ SET search_path; a schema no earlier migration has is created first.
396
+ """
397
+ if not cfg.names_schemas:
398
+ return plans["public"].sql
399
+ parts = []
400
+ for schema, plan in plans.items():
401
+ if not plan.sql:
402
+ continue
403
+ lines = [f"{SCHEMA_MARKER}{schema}"]
404
+ if schema in new_schemas:
405
+ lines.append(f"CREATE SCHEMA IF NOT EXISTS {schema};")
406
+ lines.append(f"SET search_path TO {schema};\n")
407
+ lines.append(plan.sql)
408
+ parts.append("\n".join(lines))
409
+ return "\n\n".join(parts)
410
+
411
+
264
412
  # Generating migrations
265
413
 
266
414
 
@@ -287,28 +435,33 @@ def write_migration(cfg: Config, name: str, sql: str, note: str) -> Migration:
287
435
 
288
436
  def generate_migration(cfg: Config, scratch: Connection, name: str, note: str,
289
437
  verbose: bool = False) -> Migration | None:
290
- """schema.sql vs (every migration applied) -> new migration, then check that it reproduces schema.sql.
438
+ """schema files vs (every migration applied) -> new migration, then check that it reproduces them.
291
439
 
292
440
  The plan is only printed with `verbose`: it is the content of the new file anyway.
293
441
  """
442
+ for schema, desired in cfg.schema_files.items():
443
+ if not desired.is_file():
444
+ sys.exit(f"no {relative(desired)} - the desired state of schema {schema} "
445
+ "(an empty file is an empty schema)")
294
446
  existing = list_migrations(cfg)
295
447
  print(f"- building the state of {len(existing)} existing migrations (scratch Postgres)")
296
448
  shadow = build_shadow(cfg, scratch, existing)
297
- plan = pgschema_plan(cfg, shadow, cfg.schema_file, scratch)
298
- if not plan.sql:
449
+ new_schemas = create_missing_schemas(cfg, shadow)
450
+ plans = plan_schemas(cfg, shadow, cfg.schema_files, scratch)
451
+ if not has_changes(plans):
299
452
  print("no changes compared to the existing migrations")
300
453
  return None
301
- migration = write_migration(cfg, name, plan.sql, note)
454
+ migration = write_migration(cfg, name, migration_sql(cfg, plans, new_schemas), note)
302
455
  if verbose:
303
- print(plan.human)
456
+ print_plans(plans, verbose=True)
304
457
  print(f"- verifying {migration.path.name}")
305
458
  apply_file(cfg, shadow, migration)
306
459
  # with the new migration applied, nothing should be left to change
307
- leftover = pgschema_plan(cfg, shadow, cfg.schema_file, scratch)
308
- if leftover.sql:
460
+ leftover = plan_schemas(cfg, shadow, cfg.schema_files, scratch)
461
+ if has_changes(leftover):
309
462
  # always in full: this is the case to debug
310
- print(leftover.human)
463
+ print_plans(leftover, verbose=True)
311
464
  migration.path.unlink()
312
- sys.exit("after applying the migration the result still differs from schema.sql - migration deleted")
313
- print(f"✔ created {relative(migration.path)} ({plan.headline})")
465
+ sys.exit("after applying the migration the result still differs from the schema files - migration deleted")
466
+ print(f"✔ created {relative(migration.path)} ({plans_headline(plans)})")
314
467
  return migration
File without changes