postgresledger 0.0.1__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.
- postgresledger/__init__.py +6 -0
- postgresledger/cli.py +205 -0
- postgresledger/commands.py +285 -0
- postgresledger/compare.py +116 -0
- postgresledger/config.py +82 -0
- postgresledger/core.py +288 -0
- postgresledger/pgschema.py +112 -0
- postgresledger/scratch.py +156 -0
- postgresledger-0.0.1.dist-info/METADATA +289 -0
- postgresledger-0.0.1.dist-info/RECORD +13 -0
- postgresledger-0.0.1.dist-info/WHEEL +4 -0
- postgresledger-0.0.1.dist-info/entry_points.txt +4 -0
- postgresledger-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""postgresledger - versioned PostgreSQL migrations from plain SQL, on top of pgschema."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
# the version of the installed package: the release tag it was built from (0.0.0 in a local checkout)
|
|
6
|
+
__version__ = version("postgresledger")
|
postgresledger/cli.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""pgl - versioned PostgreSQL migrations from plain SQL, without an ORM."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
import textwrap
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import __version__, commands
|
|
13
|
+
from .config import CONFIG_NAME, Config, relative
|
|
14
|
+
|
|
15
|
+
# commands that run without a postgresledger.toml
|
|
16
|
+
NO_CONFIG = {"init", "setup"}
|
|
17
|
+
# commands that read the project but never touch a database
|
|
18
|
+
# (compare with two snapshots; compare with one checks DATABASE_NAME itself)
|
|
19
|
+
NO_DATABASE = {"new", "compare"}
|
|
20
|
+
|
|
21
|
+
INTRO = """\
|
|
22
|
+
Versioned PostgreSQL migrations from plain SQL, without an ORM.
|
|
23
|
+
|
|
24
|
+
Works on one database: DATABASE_* from .env (or the environment). schema.sql
|
|
25
|
+
is the desired state; every change to it becomes a numbered migration. The
|
|
26
|
+
database records which migrations it has, and changes made by hand (drift)
|
|
27
|
+
are found and can be captured as a migration. Scratch work happens in
|
|
28
|
+
a throwaway Postgres on this machine, deleted after each command."""
|
|
29
|
+
|
|
30
|
+
EXAMPLES = """\
|
|
31
|
+
examples:
|
|
32
|
+
pgl init start in a new project
|
|
33
|
+
pgl baseline snapshot the existing database
|
|
34
|
+
pgl new add_email after editing schema.sql
|
|
35
|
+
pgl apply bring the database up to date
|
|
36
|
+
pgl drift was the database changed by hand?
|
|
37
|
+
pgl compare uat prod how do two saved snapshots differ?
|
|
38
|
+
|
|
39
|
+
`pgl <command> -h` shows the details of one command.
|
|
40
|
+
postgresledger.toml is read from the current folder (or --config)."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def add_init_arguments(command_parser: argparse.ArgumentParser) -> None:
|
|
44
|
+
command_parser.add_argument("--dir", default="database",
|
|
45
|
+
help="folder for schema/ and migrations/ (default: database)")
|
|
46
|
+
command_parser.add_argument("--no-setup", action="store_true",
|
|
47
|
+
help="don't download pgschema and the scratch Postgres")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def add_name_argument(command_parser: argparse.ArgumentParser) -> None:
|
|
51
|
+
command_parser.add_argument("name", help="lowercase letters, digits and _")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def add_new_arguments(command_parser: argparse.ArgumentParser) -> None:
|
|
55
|
+
add_name_argument(command_parser)
|
|
56
|
+
command_parser.add_argument("--note", help="comment written into the migration header")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def add_apply_arguments(command_parser: argparse.ArgumentParser) -> None:
|
|
60
|
+
command_parser.add_argument("--dry-run", action="store_true", help="only list what would be applied")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def add_snapshot_arguments(command_parser: argparse.ArgumentParser) -> None:
|
|
64
|
+
command_parser.add_argument("name", help="label for the database, e.g. prod")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def add_compare_arguments(command_parser: argparse.ArgumentParser) -> None:
|
|
68
|
+
command_parser.add_argument("a", help="snapshot name or file")
|
|
69
|
+
command_parser.add_argument("b", nargs="?", help="snapshot name or file (default: the database)")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Command:
|
|
74
|
+
"""One `pgl` command as the help shows it. It runs commands.cmd_<name>."""
|
|
75
|
+
|
|
76
|
+
group: str # a key of GROUPS
|
|
77
|
+
name: str
|
|
78
|
+
usage: str # the arguments shown in the overview
|
|
79
|
+
summary: str
|
|
80
|
+
details: str
|
|
81
|
+
add_arguments: Callable[[argparse.ArgumentParser], None] | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
COMMANDS = [
|
|
85
|
+
Command(
|
|
86
|
+
"setup", "init", "[--dir D]", "create postgresledger.toml, .env and folders",
|
|
87
|
+
"Creates postgresledger.toml (project settings, committed) and .env with DATABASE_* (never committed, "
|
|
88
|
+
"added to .gitignore) in the current folder, plus <dir>/schema/ and <dir>/migrations/, then runs setup.",
|
|
89
|
+
add_init_arguments),
|
|
90
|
+
Command(
|
|
91
|
+
"setup", "setup", "", "download pgschema + scratch Postgres, check psql",
|
|
92
|
+
"Downloads the pinned pgschema and a pinned Postgres server into ~/.cache/postgresledger/ (once per "
|
|
93
|
+
"machine, SHA256 checked) and checks that psql is installed. The Postgres server is only ever "
|
|
94
|
+
"started as a throwaway scratch database on this machine."),
|
|
95
|
+
|
|
96
|
+
Command(
|
|
97
|
+
"migrations", "baseline", "", "snapshot the database as 0001_baseline",
|
|
98
|
+
"No migrations yet: dumps the database into schema.sql and creates 0001_baseline.sql from it, "
|
|
99
|
+
"recorded as 'baseline' (not executed). Migrations exist: an existing database that already has "
|
|
100
|
+
"exactly their schema gets them recorded as 'baseline' instead of applying them; if it differs, "
|
|
101
|
+
"the differences are listed and nothing is recorded."),
|
|
102
|
+
Command(
|
|
103
|
+
"migrations", "new", "<name> [--note T]", "new migration from the changes in schema.sql",
|
|
104
|
+
"Builds the state of every existing migration in a scratch Postgres, compares it with schema.sql "
|
|
105
|
+
"and writes the difference as NNNN_<name>.sql. The file is checked there and deleted if it does "
|
|
106
|
+
"not reproduce schema.sql. Nothing is written when there is no change. The database is not touched.",
|
|
107
|
+
add_new_arguments),
|
|
108
|
+
Command(
|
|
109
|
+
"migrations", "apply", "[--dry-run]", "apply pending migrations",
|
|
110
|
+
"Runs every migration the database does not have yet, in order, each in one transaction together "
|
|
111
|
+
"with its record. Refuses to run when an already applied file was edited.",
|
|
112
|
+
add_apply_arguments),
|
|
113
|
+
Command(
|
|
114
|
+
"migrations", "status", "", "which migrations the database has",
|
|
115
|
+
"Every migration with its state on the database: applied, baseline, captured or PENDING."),
|
|
116
|
+
|
|
117
|
+
Command(
|
|
118
|
+
"drift", "drift", "", "list manual changes (exit 1 if any)",
|
|
119
|
+
"Compares the database with the state its migrations describe (built in a scratch Postgres) and "
|
|
120
|
+
"lists every difference. The database must be up to date first (apply)."),
|
|
121
|
+
Command(
|
|
122
|
+
"drift", "capture", "<name>", "keep manual changes as a new migration",
|
|
123
|
+
"Takes the manual changes into schema.sql and creates NNNN_<name>.sql from them. It is recorded "
|
|
124
|
+
"as 'captured' on this database (not executed) and reaches the other databases with apply.",
|
|
125
|
+
add_name_argument),
|
|
126
|
+
|
|
127
|
+
Command(
|
|
128
|
+
"compare", "snapshot", "<name>", "save the database schema as snapshots/<name>.sql",
|
|
129
|
+
"Dumps the schema of the database into <snapshots_dir>/<name>.sql, e.g. `pgl snapshot prod` with "
|
|
130
|
+
"the prod .env. Commit it to compare databases later.",
|
|
131
|
+
add_snapshot_arguments),
|
|
132
|
+
Command(
|
|
133
|
+
"compare", "compare", "<a> [b]", "differences between two snapshots",
|
|
134
|
+
"Compares two snapshots (names from snapshots_dir or file paths) object by object: only in a, "
|
|
135
|
+
"only in b, different. Without b, a is compared with the database itself. Exit 1 on differences.",
|
|
136
|
+
add_compare_arguments),
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
GROUPS = {
|
|
140
|
+
"setup": "setup",
|
|
141
|
+
"migrations": "migrations",
|
|
142
|
+
"drift": "drift (changes made by hand on the database)",
|
|
143
|
+
"compare": "comparing databases",
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def overview() -> str:
|
|
148
|
+
"""Every command with its summary, grouped, for the main help."""
|
|
149
|
+
width = max(len(f"{command.name} {command.usage}".strip()) for command in COMMANDS) + 2
|
|
150
|
+
lines = []
|
|
151
|
+
for group, title in GROUPS.items():
|
|
152
|
+
lines.append(f"{title}:")
|
|
153
|
+
for command in COMMANDS:
|
|
154
|
+
if command.group == group:
|
|
155
|
+
name_and_usage = f"{command.name} {command.usage}".strip()
|
|
156
|
+
lines.append(f" {name_and_usage:<{width}}{command.summary}")
|
|
157
|
+
lines.append("")
|
|
158
|
+
return "\n".join(lines)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def parser() -> argparse.ArgumentParser:
|
|
162
|
+
main_parser = argparse.ArgumentParser(
|
|
163
|
+
prog="pgl", usage="pgl [--env-file PATH] <command> [arguments]",
|
|
164
|
+
description=f"{INTRO}\n\n{overview()}\n{EXAMPLES}",
|
|
165
|
+
formatter_class=argparse.RawDescriptionHelpFormatter, add_help=False)
|
|
166
|
+
options = main_parser.add_argument_group("options")
|
|
167
|
+
options.add_argument("-h", "--help", action="help", help="show this help and exit")
|
|
168
|
+
options.add_argument("--version", action="version", version=f"postgresledger {__version__}")
|
|
169
|
+
options.add_argument("--config", metavar="PATH",
|
|
170
|
+
help="postgresledger.toml to use (default: the one in the current folder)")
|
|
171
|
+
options.add_argument("--env-file", metavar="PATH",
|
|
172
|
+
help="read DATABASE_* from this file (default: .env next to postgresledger.toml)")
|
|
173
|
+
subparsers = main_parser.add_subparsers(dest="cmd", required=True, metavar="<command>", help=argparse.SUPPRESS)
|
|
174
|
+
for command in COMMANDS:
|
|
175
|
+
command_parser = subparsers.add_parser(
|
|
176
|
+
command.name, help=command.summary,
|
|
177
|
+
description=f"{command.summary}.\n\n{textwrap.fill(command.details, 78)}",
|
|
178
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
179
|
+
command_function = getattr(commands, "cmd_" + command.name.replace("-", "_"))
|
|
180
|
+
command_parser.set_defaults(fn=command_function)
|
|
181
|
+
if command.add_arguments is not None:
|
|
182
|
+
command.add_arguments(command_parser)
|
|
183
|
+
return main_parser
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def main(argv: list[str] | None = None) -> None:
|
|
187
|
+
args = parser().parse_args(argv)
|
|
188
|
+
if args.cmd in NO_CONFIG:
|
|
189
|
+
sys.exit(args.fn(args) or 0)
|
|
190
|
+
|
|
191
|
+
config_file = Path(args.config) if args.config else Path(CONFIG_NAME)
|
|
192
|
+
env_file = Path(args.env_file) if args.env_file else None
|
|
193
|
+
cfg = Config(config_file, env_file)
|
|
194
|
+
if args.cmd in NO_DATABASE:
|
|
195
|
+
sys.exit(args.fn(cfg, args) or 0)
|
|
196
|
+
|
|
197
|
+
# every other command works on the one database: checked here, once
|
|
198
|
+
if cfg.target is None:
|
|
199
|
+
sys.exit(f"no DATABASE_NAME - set it in {relative(cfg.env_file)} or in the environment "
|
|
200
|
+
"(with DATABASE_HOST, DATABASE_PORT, DATABASE_USER, DATABASE_PASSWORD)")
|
|
201
|
+
sys.exit(args.fn(cfg, cfg.target, args) or 0)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
main()
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""One function per `pgl` command. Every command works on one database: the DATABASE_* variables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import pgschema, scratch
|
|
10
|
+
from .compare import compare
|
|
11
|
+
from .config import CONFIG_NAME, Config, Connection, relative
|
|
12
|
+
from .core import (
|
|
13
|
+
applied,
|
|
14
|
+
apply_file,
|
|
15
|
+
build_shadow,
|
|
16
|
+
check_checksums,
|
|
17
|
+
ensure_meta,
|
|
18
|
+
generate_migration,
|
|
19
|
+
list_migrations,
|
|
20
|
+
pgschema_dump,
|
|
21
|
+
pgschema_plan,
|
|
22
|
+
psql,
|
|
23
|
+
record_migration_sql,
|
|
24
|
+
require_up_to_date,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def config_file_text(folder: str) -> str:
|
|
29
|
+
"""The postgresledger.toml `pgl init` writes, with every path inside `folder`."""
|
|
30
|
+
return f"""\
|
|
31
|
+
# postgresledger project settings - the same for every database, committed to git.
|
|
32
|
+
# Paths are relative to this file. The database itself is DATABASE_* in .env
|
|
33
|
+
# (or in the environment), one database per run.
|
|
34
|
+
|
|
35
|
+
[project]
|
|
36
|
+
schema = "public" # schema managed by postgresledger
|
|
37
|
+
schema_file = "{folder}/schema/schema.sql" # desired state, the source of truth
|
|
38
|
+
migrations_dir = "{folder}/migrations"
|
|
39
|
+
snapshots_dir = "{folder}/snapshots" # pgl snapshot <name> writes here
|
|
40
|
+
meta_schema = "postgresledger" # tracking table: <meta_schema>.schema_migrations
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def dotenv_file_text(database: str) -> str:
|
|
45
|
+
"""The .env `pgl init` writes: a local database called `database`."""
|
|
46
|
+
return f"""\
|
|
47
|
+
# The database pgl works on. Never commit this file.
|
|
48
|
+
DATABASE_HOST=localhost
|
|
49
|
+
DATABASE_PORT=5432
|
|
50
|
+
DATABASE_USER=postgres
|
|
51
|
+
DATABASE_PASSWORD=postgres
|
|
52
|
+
DATABASE_NAME={database}
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def database_name_from_folder(folder_name: str) -> str:
|
|
57
|
+
"""The project folder name as a database name: lowercase letters, digits and _ ("My-App" -> "my_app")."""
|
|
58
|
+
allowed_characters = "abcdefghijklmnopqrstuvwxyz0123456789_"
|
|
59
|
+
database = ""
|
|
60
|
+
for character in folder_name.lower():
|
|
61
|
+
if character in allowed_characters:
|
|
62
|
+
database += character
|
|
63
|
+
elif not database.endswith("_"):
|
|
64
|
+
# a run of other characters becomes one _
|
|
65
|
+
database += "_"
|
|
66
|
+
return database.strip("_") or "app"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_init(args) -> int:
|
|
70
|
+
root = Path.cwd()
|
|
71
|
+
config_file = root / CONFIG_NAME
|
|
72
|
+
if config_file.exists():
|
|
73
|
+
sys.exit(f"{CONFIG_NAME} already exists here")
|
|
74
|
+
for subfolder in ("schema", "migrations"):
|
|
75
|
+
(root / args.dir / subfolder).mkdir(parents=True, exist_ok=True)
|
|
76
|
+
(root / args.dir / "migrations" / ".gitkeep").touch()
|
|
77
|
+
config_file.write_text(config_file_text(args.dir))
|
|
78
|
+
created = [CONFIG_NAME, f"{args.dir}/schema/", f"{args.dir}/migrations/"]
|
|
79
|
+
|
|
80
|
+
dotenv_file = root / ".env"
|
|
81
|
+
if not dotenv_file.exists():
|
|
82
|
+
dotenv_file.write_text(dotenv_file_text(database_name_from_folder(root.name)))
|
|
83
|
+
created.append(".env")
|
|
84
|
+
|
|
85
|
+
gitignore = root / ".gitignore"
|
|
86
|
+
ignored = gitignore.read_text().splitlines() if gitignore.exists() else []
|
|
87
|
+
if ".env" not in ignored:
|
|
88
|
+
# start on a new line when the file does not end with one
|
|
89
|
+
needs_newline = bool(ignored) and ignored[-1] != ""
|
|
90
|
+
with gitignore.open("a") as gitignore_file:
|
|
91
|
+
gitignore_file.write(("\n" if needs_newline else "") + ".env\n")
|
|
92
|
+
created.append(".gitignore (.env)")
|
|
93
|
+
|
|
94
|
+
print(f"✔ created {', '.join(created)}")
|
|
95
|
+
exit_code = 0 if args.no_setup else pgschema.setup()
|
|
96
|
+
print(f"""
|
|
97
|
+
next:
|
|
98
|
+
1. set the DATABASE_* variables in .env to your database
|
|
99
|
+
2. pgl baseline snapshot it as the first migration
|
|
100
|
+
or write {args.dir}/schema/schema.sql and run `pgl new initial`""")
|
|
101
|
+
return exit_code
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_setup(args) -> int:
|
|
105
|
+
return pgschema.setup()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cmd_baseline(cfg: Config, connection: Connection, args) -> int:
|
|
109
|
+
migrations = list_migrations(cfg)
|
|
110
|
+
with scratch.server() as scratch_server:
|
|
111
|
+
if not migrations:
|
|
112
|
+
cfg.schema_file.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
print(f"- dump {connection.label} -> {relative(cfg.schema_file)}")
|
|
114
|
+
pgschema_dump(cfg, connection, cfg.schema_file)
|
|
115
|
+
baseline = generate_migration(cfg, scratch_server, "baseline", f"baseline taken from {connection.database}")
|
|
116
|
+
if baseline is None:
|
|
117
|
+
sys.exit("the database is empty, nothing to snapshot")
|
|
118
|
+
ensure_meta(cfg, connection)
|
|
119
|
+
psql(connection, record_migration_sql(cfg, baseline, "baseline"))
|
|
120
|
+
print(f"✔ {baseline.id} recorded on {connection.label} (baseline, not executed)")
|
|
121
|
+
return 0
|
|
122
|
+
# migrations exist: adopt a database that already has their schema
|
|
123
|
+
if applied(cfg, connection):
|
|
124
|
+
sys.exit(f"{connection.label} already tracks migrations - use `pgl apply`")
|
|
125
|
+
print(f"- building the state of {len(migrations)} migrations (scratch Postgres)")
|
|
126
|
+
shadow = build_shadow(cfg, scratch_server, migrations)
|
|
127
|
+
with tempfile.TemporaryDirectory() as temporary_dir:
|
|
128
|
+
actual = Path(temporary_dir) / "actual.sql"
|
|
129
|
+
pgschema_dump(cfg, connection, actual)
|
|
130
|
+
plan = pgschema_plan(cfg, shadow, actual, scratch_server)
|
|
131
|
+
if plan.sql:
|
|
132
|
+
print(f"✘ {connection.label} does not match the migrations, so they cannot be recorded as already there:\n")
|
|
133
|
+
print(plan.human)
|
|
134
|
+
print("\nempty database: `pgl apply` instead; otherwise align it first (see `pgl compare`)")
|
|
135
|
+
return 1
|
|
136
|
+
# every record in one transaction: all of them or none
|
|
137
|
+
records = [record_migration_sql(cfg, migration, "baseline") for migration in migrations]
|
|
138
|
+
psql(connection, "BEGIN;\n" + "\n".join(records) + "\nCOMMIT;")
|
|
139
|
+
print(f"✔ {connection.label} matches the migrations: {len(migrations)} recorded as baseline (not executed)")
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cmd_new(cfg: Config, args) -> None:
|
|
144
|
+
with scratch.server() as scratch_server:
|
|
145
|
+
generate_migration(cfg, scratch_server, args.name, args.note or "change in schema.sql")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def cmd_apply(cfg: Config, connection: Connection, args) -> None:
|
|
149
|
+
applied_by_version = applied(cfg, connection)
|
|
150
|
+
migrations = list_migrations(cfg)
|
|
151
|
+
check_checksums(connection, migrations, applied_by_version)
|
|
152
|
+
pending = [migration for migration in migrations if migration.version not in applied_by_version]
|
|
153
|
+
if not pending:
|
|
154
|
+
print(f"{connection.label}: all migrations are already applied")
|
|
155
|
+
return
|
|
156
|
+
for migration in pending:
|
|
157
|
+
if args.dry_run:
|
|
158
|
+
print(f"[dry-run] {connection.label}: would apply {migration.id}")
|
|
159
|
+
continue
|
|
160
|
+
print(f"- {connection.label}: applying {migration.id}")
|
|
161
|
+
apply_file(cfg, connection, migration)
|
|
162
|
+
if not args.dry_run:
|
|
163
|
+
print(f"✔ {connection.label}: applied {len(pending)} migration(s)")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def cmd_status(cfg: Config, connection: Connection, args) -> None:
|
|
167
|
+
migrations = list_migrations(cfg)
|
|
168
|
+
applied_by_version = applied(cfg, connection)
|
|
169
|
+
width = max([len(migration.id) for migration in migrations] + [9])
|
|
170
|
+
print(f"{connection.label}\n")
|
|
171
|
+
print(f"{'migration':<{width}} {'state':<9} applied at")
|
|
172
|
+
pending_count = 0
|
|
173
|
+
for migration in migrations:
|
|
174
|
+
applied_migration = applied_by_version.get(migration.version)
|
|
175
|
+
if applied_migration is None:
|
|
176
|
+
pending_count += 1
|
|
177
|
+
print(f"{migration.id:<{width}} {'PENDING':<9}")
|
|
178
|
+
else:
|
|
179
|
+
print(f"{migration.id:<{width}} {applied_migration.mode:<9} {applied_migration.applied_at}")
|
|
180
|
+
summary = f"\n{len(migrations) - pending_count} of {len(migrations)} applied"
|
|
181
|
+
if pending_count:
|
|
182
|
+
summary += f", {pending_count} pending - `pgl apply`"
|
|
183
|
+
print(summary)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def cmd_drift(cfg: Config, connection: Connection, args) -> int:
|
|
187
|
+
"""Changes made by hand: the database compared with the state of the migrations it has.
|
|
188
|
+
|
|
189
|
+
Migrations the database doesn't have yet are reported, not counted as drift.
|
|
190
|
+
"""
|
|
191
|
+
applied_migrations = applied(cfg, connection)
|
|
192
|
+
all_migrations = list_migrations(cfg)
|
|
193
|
+
known_versions = {migration.version for migration in all_migrations}
|
|
194
|
+
|
|
195
|
+
unknown = [record for record in applied_migrations.values() if record.version not in known_versions]
|
|
196
|
+
if unknown:
|
|
197
|
+
names = ", ".join(f"{record.version}_{record.name}" for record in unknown)
|
|
198
|
+
sys.exit(f"{connection.label} has migrations that are not in the repository ({names}) - "
|
|
199
|
+
"its expected state cannot be built")
|
|
200
|
+
|
|
201
|
+
has = [migration for migration in all_migrations if migration.version in applied_migrations]
|
|
202
|
+
pending = [migration for migration in all_migrations if migration.version not in applied_migrations]
|
|
203
|
+
if pending:
|
|
204
|
+
names = ", ".join(migration.id for migration in pending)
|
|
205
|
+
print(f"! {connection.label} does not have {len(pending)} migration(s) yet: {names} - `pgl apply`")
|
|
206
|
+
print(f" comparing with the {len(has)} it has, so these are not counted as drift")
|
|
207
|
+
|
|
208
|
+
with scratch.server() as scratch_server:
|
|
209
|
+
print(f"- building the state of the {len(has)} migrations {connection.database} has (scratch Postgres)")
|
|
210
|
+
shadow = build_shadow(cfg, scratch_server, has)
|
|
211
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
212
|
+
actual = Path(tmp_dir) / "actual.sql"
|
|
213
|
+
pgschema_dump(cfg, connection, actual)
|
|
214
|
+
# "what turns the expected state into the actual database" = the manual changes
|
|
215
|
+
plan = pgschema_plan(cfg, shadow, actual, scratch_server)
|
|
216
|
+
if not plan.sql:
|
|
217
|
+
print(f"✔ {connection.label}: no drift, the database matches its migrations")
|
|
218
|
+
return 0
|
|
219
|
+
print(f"✘ {connection.label}: DRIFT - the database was changed outside of migrations:\n")
|
|
220
|
+
print(plan.human)
|
|
221
|
+
if pending:
|
|
222
|
+
print("\nrun `pgl apply` first, then keep or drop the changes:")
|
|
223
|
+
print("\nkeep the changes: pgl capture <name>")
|
|
224
|
+
print("drop the changes: undo them by hand (or with a new migration), then `pgl drift` again")
|
|
225
|
+
return 1
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def cmd_capture(cfg: Config, connection: Connection, args) -> None:
|
|
229
|
+
require_up_to_date(cfg, connection)
|
|
230
|
+
print(f"- dump {connection.label} -> {relative(cfg.schema_file)} (manual changes become the desired state)")
|
|
231
|
+
# put back when there is nothing to capture
|
|
232
|
+
schema_before = cfg.schema_file.read_text()
|
|
233
|
+
pgschema_dump(cfg, connection, cfg.schema_file)
|
|
234
|
+
note = f"manual changes captured from {connection.database} (recorded as captured there)"
|
|
235
|
+
with scratch.server() as scratch_server:
|
|
236
|
+
migration = generate_migration(cfg, scratch_server, args.name, note)
|
|
237
|
+
if migration is None:
|
|
238
|
+
cfg.schema_file.write_text(schema_before)
|
|
239
|
+
print(f"{connection.label}: no drift, nothing captured")
|
|
240
|
+
return
|
|
241
|
+
psql(connection, record_migration_sql(cfg, migration, "captured"))
|
|
242
|
+
print(f"✔ {migration.id} recorded on {connection.label} (captured, not executed) - "
|
|
243
|
+
"other databases get it with `pgl apply`")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def is_valid_snapshot_name(name: str) -> bool:
|
|
247
|
+
"""Letters, digits, _ . and - only, so the name is safe as a file name."""
|
|
248
|
+
allowed_characters = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-")
|
|
249
|
+
return name != "" and set(name) <= allowed_characters
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def cmd_snapshot(cfg: Config, connection: Connection, args) -> None:
|
|
253
|
+
if not is_valid_snapshot_name(args.name):
|
|
254
|
+
sys.exit("snapshot name: letters, digits, _ . - only")
|
|
255
|
+
snapshot = cfg.snapshots_dir / f"{args.name}.sql"
|
|
256
|
+
pgschema_dump(cfg, connection, snapshot)
|
|
257
|
+
print(f"✔ {connection.label} -> {relative(snapshot)}")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def snapshot_file(cfg: Config, name_or_path: str) -> Path:
|
|
261
|
+
"""A snapshot given as a file path, or by name from snapshots_dir."""
|
|
262
|
+
for candidate in (Path(name_or_path), cfg.snapshots_dir / f"{name_or_path}.sql"):
|
|
263
|
+
if candidate.is_file():
|
|
264
|
+
return candidate
|
|
265
|
+
sys.exit(f"no snapshot '{name_or_path}' (neither a file nor {relative(cfg.snapshots_dir)}/{name_or_path}.sql) - "
|
|
266
|
+
f"`pgl snapshot {name_or_path}`")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def cmd_compare(cfg: Config, args) -> int:
|
|
270
|
+
first_dump = snapshot_file(cfg, args.a).read_text()
|
|
271
|
+
if args.b:
|
|
272
|
+
second_dump = snapshot_file(cfg, args.b).read_text()
|
|
273
|
+
second_label = args.b
|
|
274
|
+
else:
|
|
275
|
+
# no second snapshot: compare with the database itself, dumped right now
|
|
276
|
+
if cfg.target is None:
|
|
277
|
+
sys.exit("comparing with the database needs DATABASE_NAME in .env - or give two snapshots")
|
|
278
|
+
with tempfile.TemporaryDirectory() as temporary_dir:
|
|
279
|
+
live_dump_file = Path(temporary_dir) / "live.sql"
|
|
280
|
+
pgschema_dump(cfg, cfg.target, live_dump_file)
|
|
281
|
+
second_dump = live_dump_file.read_text()
|
|
282
|
+
second_label = cfg.target.label
|
|
283
|
+
comparison = compare(first_dump, second_dump, args.a, second_label)
|
|
284
|
+
print(comparison.report)
|
|
285
|
+
return 1 if comparison.difference_count else 0
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Differences between two schema dumps, object by object, without a database.
|
|
2
|
+
|
|
3
|
+
pgschema writes every object as its own block under a header
|
|
4
|
+
`-- Name: <name>; Type: <type>; Schema: ...`, in a stable order, so two dumps
|
|
5
|
+
can be compared block by block.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import difflib
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
HEADER_START = "-- Name: "
|
|
14
|
+
# lines around an object's SQL that are not part of it
|
|
15
|
+
SEPARATOR_LINES = ("", "--")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, order=True)
|
|
19
|
+
class SchemaObject:
|
|
20
|
+
"""One object in a dump, e.g. TABLE customer. Sorted by type, then name."""
|
|
21
|
+
|
|
22
|
+
type: str
|
|
23
|
+
name: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_header(line: str) -> SchemaObject | None:
|
|
27
|
+
"""The object a `-- Name: <name>; Type: <type>; Schema: ...` line starts; None for any other line."""
|
|
28
|
+
if not line.startswith(HEADER_START):
|
|
29
|
+
return None
|
|
30
|
+
name, _, rest = line.removeprefix(HEADER_START).partition("; Type: ")
|
|
31
|
+
object_type, _, schema = rest.partition("; Schema:")
|
|
32
|
+
if not object_type or not schema:
|
|
33
|
+
return None
|
|
34
|
+
return SchemaObject(type=object_type, name=name)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def strip_separator_lines(lines: list[str]) -> list[str]:
|
|
38
|
+
"""`lines` without the empty and `--` lines at the start and the end."""
|
|
39
|
+
start = 0
|
|
40
|
+
end = len(lines)
|
|
41
|
+
while start < end and lines[start].strip() in SEPARATOR_LINES:
|
|
42
|
+
start += 1
|
|
43
|
+
while end > start and lines[end - 1].strip() in SEPARATOR_LINES:
|
|
44
|
+
end -= 1
|
|
45
|
+
return lines[start:end]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def add_object(sql_by_object: dict[SchemaObject, str], schema_object: SchemaObject, lines: list[str]) -> None:
|
|
49
|
+
# same type and name twice (overloaded functions): the next ones become "name #2", "name #3", ...
|
|
50
|
+
unique_object = schema_object
|
|
51
|
+
number = 2
|
|
52
|
+
while unique_object in sql_by_object:
|
|
53
|
+
unique_object = SchemaObject(schema_object.type, f"{schema_object.name} #{number}")
|
|
54
|
+
number += 1
|
|
55
|
+
sql_by_object[unique_object] = "\n".join(strip_separator_lines(lines))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def objects(dump: str) -> dict[SchemaObject, str]:
|
|
59
|
+
"""The SQL of every object in a pgschema dump."""
|
|
60
|
+
sql_by_object: dict[SchemaObject, str] = {}
|
|
61
|
+
current_object = None
|
|
62
|
+
current_lines: list[str] = []
|
|
63
|
+
for line in dump.splitlines():
|
|
64
|
+
header_object = parse_header(line)
|
|
65
|
+
if header_object is not None:
|
|
66
|
+
if current_object is not None:
|
|
67
|
+
add_object(sql_by_object, current_object, current_lines)
|
|
68
|
+
current_object = header_object
|
|
69
|
+
current_lines = []
|
|
70
|
+
elif current_object is not None:
|
|
71
|
+
current_lines.append(line)
|
|
72
|
+
if current_object is not None:
|
|
73
|
+
add_object(sql_by_object, current_object, current_lines)
|
|
74
|
+
return sql_by_object
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Comparison:
|
|
79
|
+
difference_count: int # 0 when the dumps are identical
|
|
80
|
+
report: str # readable, for the terminal
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def compare(first_dump: str, second_dump: str, first_label: str, second_label: str) -> Comparison:
|
|
84
|
+
first_objects = objects(first_dump)
|
|
85
|
+
second_objects = objects(second_dump)
|
|
86
|
+
only_in_first = sorted(item for item in first_objects if item not in second_objects)
|
|
87
|
+
only_in_second = sorted(item for item in second_objects if item not in first_objects)
|
|
88
|
+
changed = sorted(item for item in first_objects
|
|
89
|
+
if item in second_objects and first_objects[item] != second_objects[item])
|
|
90
|
+
difference_count = len(only_in_first) + len(only_in_second) + len(changed)
|
|
91
|
+
# the type column is as wide as the longest type
|
|
92
|
+
width = max([len(item.type) for item in [*only_in_first, *only_in_second, *changed]] + [4])
|
|
93
|
+
|
|
94
|
+
report_lines = []
|
|
95
|
+
for title, missing_objects in [(f"only in {first_label}", only_in_first),
|
|
96
|
+
(f"only in {second_label}", only_in_second)]:
|
|
97
|
+
if missing_objects:
|
|
98
|
+
report_lines.append(f"{title} ({len(missing_objects)}):")
|
|
99
|
+
for item in missing_objects:
|
|
100
|
+
report_lines.append(f" {item.type:<{width}} {item.name}")
|
|
101
|
+
report_lines.append("")
|
|
102
|
+
if changed:
|
|
103
|
+
report_lines.append(f"different ({len(changed)}):")
|
|
104
|
+
for item in changed:
|
|
105
|
+
report_lines.append(f" {item.type:<{width}} {item.name}")
|
|
106
|
+
diff = difflib.unified_diff(first_objects[item].splitlines(), second_objects[item].splitlines(),
|
|
107
|
+
first_label, second_label, n=1, lineterm="")
|
|
108
|
+
for diff_line in diff:
|
|
109
|
+
report_lines.append(f" {diff_line}")
|
|
110
|
+
report_lines.append("")
|
|
111
|
+
if difference_count:
|
|
112
|
+
report_lines.append(f"✘ {difference_count} difference(s) between {first_label} ({len(first_objects)} objects) "
|
|
113
|
+
f"and {second_label} ({len(second_objects)} objects)")
|
|
114
|
+
else:
|
|
115
|
+
report_lines.append(f"✔ {first_label} and {second_label} are identical ({len(first_objects)} objects)")
|
|
116
|
+
return Comparison(difference_count=difference_count, report="\n".join(report_lines))
|