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.
- {postgresledger-0.0.2 → postgresledger-0.0.3}/PKG-INFO +75 -2
- {postgresledger-0.0.2 → postgresledger-0.0.3}/README.md +74 -1
- {postgresledger-0.0.2 → postgresledger-0.0.3}/pyproject.toml +1 -1
- {postgresledger-0.0.2 → postgresledger-0.0.3}/pyproject.toml.orig +1 -1
- {postgresledger-0.0.2 → postgresledger-0.0.3}/src/postgresledger/cli.py +19 -5
- {postgresledger-0.0.2 → postgresledger-0.0.3}/src/postgresledger/commands.py +86 -32
- {postgresledger-0.0.2 → postgresledger-0.0.3}/src/postgresledger/compare.py +16 -0
- {postgresledger-0.0.2 → postgresledger-0.0.3}/src/postgresledger/config.py +49 -2
- {postgresledger-0.0.2 → postgresledger-0.0.3}/src/postgresledger/core.py +178 -25
- {postgresledger-0.0.2 → postgresledger-0.0.3}/LICENSE +0 -0
- {postgresledger-0.0.2 → postgresledger-0.0.3}/src/postgresledger/__init__.py +0 -0
- {postgresledger-0.0.2 → postgresledger-0.0.3}/src/postgresledger/pgschema.py +0 -0
- {postgresledger-0.0.2 → postgresledger-0.0.3}/src/postgresledger/scratch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: postgresledger
|
|
3
|
-
Version: 0.0.
|
|
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.
|
|
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.
|
|
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
|
|
21
|
-
|
|
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="
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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} -> {
|
|
233
|
-
# put back when there is nothing to capture
|
|
234
|
-
|
|
235
|
-
|
|
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.
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
57
|
-
self.
|
|
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
|
|
27
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
|
239
|
-
"""The changed objects, and with --verbose also the SQL."""
|
|
240
|
-
|
|
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
|
|
244
|
-
"""The
|
|
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",
|
|
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
|
|
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
|
-
|
|
298
|
-
|
|
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,
|
|
454
|
+
migration = write_migration(cfg, name, migration_sql(cfg, plans, new_schemas), note)
|
|
302
455
|
if verbose:
|
|
303
|
-
|
|
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 =
|
|
308
|
-
if leftover
|
|
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
|
-
|
|
463
|
+
print_plans(leftover, verbose=True)
|
|
311
464
|
migration.path.unlink()
|
|
312
|
-
sys.exit("after applying the migration the result still differs from schema
|
|
313
|
-
print(f"✔ created {relative(migration.path)} ({
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|