postgresledger 0.0.1__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.1/README.md → postgresledger-0.0.3/PKG-INFO +112 -0
- postgresledger-0.0.1/PKG-INFO → postgresledger-0.0.3/README.md +80 -19
- {postgresledger-0.0.1 → postgresledger-0.0.3}/pyproject.toml +24 -2
- {postgresledger-0.0.1 → postgresledger-0.0.3}/pyproject.toml.orig +15 -2
- {postgresledger-0.0.1 → postgresledger-0.0.3}/src/postgresledger/cli.py +25 -2
- {postgresledger-0.0.1 → postgresledger-0.0.3}/src/postgresledger/commands.py +90 -34
- {postgresledger-0.0.1 → postgresledger-0.0.3}/src/postgresledger/compare.py +16 -0
- {postgresledger-0.0.1 → postgresledger-0.0.3}/src/postgresledger/config.py +49 -2
- {postgresledger-0.0.1 → postgresledger-0.0.3}/src/postgresledger/core.py +203 -24
- {postgresledger-0.0.1 → postgresledger-0.0.3}/LICENSE +0 -0
- {postgresledger-0.0.1 → postgresledger-0.0.3}/src/postgresledger/__init__.py +0 -0
- {postgresledger-0.0.1 → postgresledger-0.0.3}/src/postgresledger/pgschema.py +0 -0
- {postgresledger-0.0.1 → postgresledger-0.0.3}/src/postgresledger/scratch.py +0 -0
|
@@ -1,5 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: postgresledger
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: Versioned PostgreSQL migrations from plain SQL, without an ORM: generated from schema.sql, tracked per database, with drift detection
|
|
5
|
+
Keywords: postgresql,postgres,migrations,database-migrations,schema,sql,drift,pgschema
|
|
6
|
+
Author: Nebojša Mrkić
|
|
7
|
+
Author-email: Nebojša Mrkić <mrkic.nebojsa@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Programming Language :: SQL
|
|
23
|
+
Classifier: Topic :: Database
|
|
24
|
+
Classifier: Topic :: Software Development :: Version Control
|
|
25
|
+
Requires-Dist: python-dotenv>=1.2.4
|
|
26
|
+
Requires-Python: >=3.11
|
|
27
|
+
Project-URL: Homepage, https://github.com/nmrkic/postgresledger
|
|
28
|
+
Project-URL: Repository, https://github.com/nmrkic/postgresledger
|
|
29
|
+
Project-URL: Issues, https://github.com/nmrkic/postgresledger/issues
|
|
30
|
+
Project-URL: Changelog, https://github.com/nmrkic/postgresledger/releases
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
1
33
|
# postgresledger
|
|
2
34
|
|
|
35
|
+
[](https://pypi.org/project/postgresledger/)
|
|
36
|
+
[](https://pypi.org/project/postgresledger/)
|
|
37
|
+
[](https://github.com/nmrkic/postgresledger/actions/workflows/tests.yml)
|
|
38
|
+
[](https://github.com/nmrkic/postgresledger/blob/main/LICENSE)
|
|
39
|
+
|
|
3
40
|
Versioned migrations for PostgreSQL from plain SQL, without an ORM (tables,
|
|
4
41
|
views, materialized views, functions, triggers), built on
|
|
5
42
|
[pgschema](https://www.pgschema.com). The command is `pgl`.
|
|
@@ -245,6 +282,81 @@ environment; a variable set there wins over `.env`. `pgl --env-file .env.uat …
|
|
|
245
282
|
(or `PGL_ENV_FILE=.env.uat`) picks another file for one run.
|
|
246
283
|
Run `pgl` in the folder with `postgresledger.toml`, or point to it with
|
|
247
284
|
`--config PATH`. `pgl <command> -h` describes each command.
|
|
285
|
+
`baseline`, `new`, `capture` and `drift` print only what changed;
|
|
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).
|
|
248
360
|
|
|
249
361
|
## Developing postgresledger
|
|
250
362
|
|
|
@@ -1,24 +1,10 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: postgresledger
|
|
3
|
-
Version: 0.0.1
|
|
4
|
-
Summary: Versioned PostgreSQL migrations from plain SQL, without an ORM: generated from schema.sql, tracked per database, with drift detection
|
|
5
|
-
Author: Nebojša Mrkić
|
|
6
|
-
Author-email: Nebojša Mrkić <mrkic.nebojsa@gmail.com>
|
|
7
|
-
License-Expression: MIT
|
|
8
|
-
License-File: LICENSE
|
|
9
|
-
Classifier: Programming Language :: Python :: 3
|
|
10
|
-
Classifier: Topic :: Database
|
|
11
|
-
Classifier: Environment :: Console
|
|
12
|
-
Requires-Dist: python-dotenv>=1.2.4
|
|
13
|
-
Requires-Python: >=3.11
|
|
14
|
-
Project-URL: Homepage, https://github.com/nmrkic/postgresledger
|
|
15
|
-
Project-URL: Repository, https://github.com/nmrkic/postgresledger
|
|
16
|
-
Project-URL: Issues, https://github.com/nmrkic/postgresledger/issues
|
|
17
|
-
Project-URL: Changelog, https://github.com/nmrkic/postgresledger/releases
|
|
18
|
-
Description-Content-Type: text/markdown
|
|
19
|
-
|
|
20
1
|
# postgresledger
|
|
21
2
|
|
|
3
|
+
[](https://pypi.org/project/postgresledger/)
|
|
4
|
+
[](https://pypi.org/project/postgresledger/)
|
|
5
|
+
[](https://github.com/nmrkic/postgresledger/actions/workflows/tests.yml)
|
|
6
|
+
[](https://github.com/nmrkic/postgresledger/blob/main/LICENSE)
|
|
7
|
+
|
|
22
8
|
Versioned migrations for PostgreSQL from plain SQL, without an ORM (tables,
|
|
23
9
|
views, materialized views, functions, triggers), built on
|
|
24
10
|
[pgschema](https://www.pgschema.com). The command is `pgl`.
|
|
@@ -264,6 +250,81 @@ environment; a variable set there wins over `.env`. `pgl --env-file .env.uat …
|
|
|
264
250
|
(or `PGL_ENV_FILE=.env.uat`) picks another file for one run.
|
|
265
251
|
Run `pgl` in the folder with `postgresledger.toml`, or point to it with
|
|
266
252
|
`--config PATH`. `pgl <command> -h` describes each command.
|
|
253
|
+
`baseline`, `new`, `capture` and `drift` print only what changed;
|
|
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).
|
|
267
328
|
|
|
268
329
|
## Developing postgresledger
|
|
269
330
|
|
|
@@ -1,16 +1,38 @@
|
|
|
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"
|
|
7
7
|
license-files = ["LICENSE"]
|
|
8
8
|
requires-python = ">=3.11"
|
|
9
9
|
dependencies = ["python-dotenv>=1.2.4"]
|
|
10
|
+
keywords = [
|
|
11
|
+
"postgresql",
|
|
12
|
+
"postgres",
|
|
13
|
+
"migrations",
|
|
14
|
+
"database-migrations",
|
|
15
|
+
"schema",
|
|
16
|
+
"sql",
|
|
17
|
+
"drift",
|
|
18
|
+
"pgschema",
|
|
19
|
+
]
|
|
10
20
|
classifiers = [
|
|
21
|
+
"Development Status :: 3 - Alpha",
|
|
22
|
+
"Environment :: Console",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"Intended Audience :: System Administrators",
|
|
25
|
+
"Operating System :: POSIX :: Linux",
|
|
26
|
+
"Operating System :: MacOS",
|
|
11
27
|
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Programming Language :: Python :: 3.14",
|
|
33
|
+
"Programming Language :: SQL",
|
|
12
34
|
"Topic :: Database",
|
|
13
|
-
"
|
|
35
|
+
"Topic :: Software Development :: Version Control",
|
|
14
36
|
]
|
|
15
37
|
|
|
16
38
|
[[project.authors]]
|
|
@@ -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"
|
|
@@ -10,10 +10,23 @@ authors = [{ name = "Nebojša Mrkić", email = "mrkic.nebojsa@gmail.com" }]
|
|
|
10
10
|
dependencies = [
|
|
11
11
|
"python-dotenv>=1.2.4",
|
|
12
12
|
]
|
|
13
|
+
keywords = ["postgresql", "postgres", "migrations", "database-migrations", "schema", "sql", "drift", "pgschema"]
|
|
13
14
|
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Intended Audience :: System Administrators",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
"Operating System :: MacOS",
|
|
14
21
|
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Programming Language :: SQL",
|
|
15
28
|
"Topic :: Database",
|
|
16
|
-
"
|
|
29
|
+
"Topic :: Software Development :: Version Control",
|
|
17
30
|
]
|
|
18
31
|
|
|
19
32
|
[project.urls]
|
|
@@ -17,6 +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 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"}
|
|
20
23
|
|
|
21
24
|
INTRO = """\
|
|
22
25
|
Versioned PostgreSQL migrations from plain SQL, without an ORM.
|
|
@@ -60,6 +63,12 @@ def add_apply_arguments(command_parser: argparse.ArgumentParser) -> None:
|
|
|
60
63
|
command_parser.add_argument("--dry-run", action="store_true", help="only list what would be applied")
|
|
61
64
|
|
|
62
65
|
|
|
66
|
+
def add_verbose_argument(command_parser: argparse.ArgumentParser) -> None:
|
|
67
|
+
command_parser.add_argument("-v", "--verbose", action="store_true",
|
|
68
|
+
help="show pgschema's debug log while it dumps the database, "
|
|
69
|
+
"and its whole plan including the SQL")
|
|
70
|
+
|
|
71
|
+
|
|
63
72
|
def add_snapshot_arguments(command_parser: argparse.ArgumentParser) -> None:
|
|
64
73
|
command_parser.add_argument("name", help="label for the database, e.g. prod")
|
|
65
74
|
|
|
@@ -85,7 +94,8 @@ COMMANDS = [
|
|
|
85
94
|
Command(
|
|
86
95
|
"setup", "init", "[--dir D]", "create postgresledger.toml, .env and folders",
|
|
87
96
|
"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."
|
|
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).",
|
|
89
99
|
add_init_arguments),
|
|
90
100
|
Command(
|
|
91
101
|
"setup", "setup", "", "download pgschema + scratch Postgres, check psql",
|
|
@@ -95,7 +105,8 @@ COMMANDS = [
|
|
|
95
105
|
|
|
96
106
|
Command(
|
|
97
107
|
"migrations", "baseline", "", "snapshot the database as 0001_baseline",
|
|
98
|
-
"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, "
|
|
99
110
|
"recorded as 'baseline' (not executed). Migrations exist: an existing database that already has "
|
|
100
111
|
"exactly their schema gets them recorded as 'baseline' instead of applying them; if it differs, "
|
|
101
112
|
"the differences are listed and nothing is recorded."),
|
|
@@ -180,10 +191,22 @@ def parser() -> argparse.ArgumentParser:
|
|
|
180
191
|
command_parser.set_defaults(fn=command_function)
|
|
181
192
|
if command.add_arguments is not None:
|
|
182
193
|
command.add_arguments(command_parser)
|
|
194
|
+
if command.name in VERBOSE:
|
|
195
|
+
add_verbose_argument(command_parser)
|
|
183
196
|
return main_parser
|
|
184
197
|
|
|
185
198
|
|
|
186
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:
|
|
187
210
|
args = parser().parse_args(argv)
|
|
188
211
|
if args.cmd in NO_CONFIG:
|
|
189
212
|
sys.exit(args.fn(args) or 0)
|
|
@@ -4,24 +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
|
-
|
|
27
|
+
plan_schemas,
|
|
28
|
+
print_plans,
|
|
22
29
|
psql,
|
|
23
30
|
record_migration_sql,
|
|
24
31
|
require_up_to_date,
|
|
32
|
+
schema_files_in,
|
|
25
33
|
)
|
|
26
34
|
|
|
27
35
|
|
|
@@ -35,6 +43,9 @@ def config_file_text(folder: str) -> str:
|
|
|
35
43
|
[project]
|
|
36
44
|
schema = "public" # schema managed by postgresledger
|
|
37
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
|
|
38
49
|
migrations_dir = "{folder}/migrations"
|
|
39
50
|
snapshots_dir = "{folder}/snapshots" # pgl snapshot <name> writes here
|
|
40
51
|
meta_schema = "postgresledger" # tracking table: <meta_schema>.schema_migrations
|
|
@@ -53,6 +64,30 @@ DATABASE_NAME={database}
|
|
|
53
64
|
"""
|
|
54
65
|
|
|
55
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
|
+
|
|
56
91
|
def database_name_from_folder(folder_name: str) -> str:
|
|
57
92
|
"""The project folder name as a database name: lowercase letters, digits and _ ("My-App" -> "my_app")."""
|
|
58
93
|
allowed_characters = "abcdefghijklmnopqrstuvwxyz0123456789_"
|
|
@@ -82,6 +117,12 @@ def cmd_init(args) -> int:
|
|
|
82
117
|
dotenv_file.write_text(dotenv_file_text(database_name_from_folder(root.name)))
|
|
83
118
|
created.append(".env")
|
|
84
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
|
+
|
|
85
126
|
gitignore = root / ".gitignore"
|
|
86
127
|
ignored = gitignore.read_text().splitlines() if gitignore.exists() else []
|
|
87
128
|
if ".env" not in ignored:
|
|
@@ -105,14 +146,30 @@ def cmd_setup(args) -> int:
|
|
|
105
146
|
return pgschema.setup()
|
|
106
147
|
|
|
107
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
|
+
|
|
108
165
|
def cmd_baseline(cfg: Config, connection: Connection, args) -> int:
|
|
109
166
|
migrations = list_migrations(cfg)
|
|
110
167
|
with scratch.server() as scratch_server:
|
|
111
168
|
if not migrations:
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
169
|
+
print(f"- dump {connection.label} -> {schema_files_label(cfg)}")
|
|
170
|
+
dump_schemas(cfg, connection, cfg.schema_files, args.verbose)
|
|
171
|
+
baseline = generate_migration(cfg, scratch_server, "baseline", f"baseline taken from {connection.database}",
|
|
172
|
+
args.verbose)
|
|
116
173
|
if baseline is None:
|
|
117
174
|
sys.exit("the database is empty, nothing to snapshot")
|
|
118
175
|
ensure_meta(cfg, connection)
|
|
@@ -123,14 +180,10 @@ def cmd_baseline(cfg: Config, connection: Connection, args) -> int:
|
|
|
123
180
|
if applied(cfg, connection):
|
|
124
181
|
sys.exit(f"{connection.label} already tracks migrations - use `pgl apply`")
|
|
125
182
|
print(f"- building the state of {len(migrations)} migrations (scratch Postgres)")
|
|
126
|
-
|
|
127
|
-
|
|
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:
|
|
183
|
+
plans = database_plans(cfg, connection, scratch_server, migrations, args.verbose)
|
|
184
|
+
if has_changes(plans):
|
|
132
185
|
print(f"✘ {connection.label} does not match the migrations, so they cannot be recorded as already there:\n")
|
|
133
|
-
|
|
186
|
+
print_plans(plans, args.verbose)
|
|
134
187
|
print("\nempty database: `pgl apply` instead; otherwise align it first (see `pgl compare`)")
|
|
135
188
|
return 1
|
|
136
189
|
# every record in one transaction: all of them or none
|
|
@@ -142,7 +195,7 @@ def cmd_baseline(cfg: Config, connection: Connection, args) -> int:
|
|
|
142
195
|
|
|
143
196
|
def cmd_new(cfg: Config, args) -> None:
|
|
144
197
|
with scratch.server() as scratch_server:
|
|
145
|
-
generate_migration(cfg, scratch_server, args.name, args.note or "change in schema.sql")
|
|
198
|
+
generate_migration(cfg, scratch_server, args.name, args.note or "change in schema.sql", args.verbose)
|
|
146
199
|
|
|
147
200
|
|
|
148
201
|
def cmd_apply(cfg: Config, connection: Connection, args) -> None:
|
|
@@ -207,17 +260,13 @@ def cmd_drift(cfg: Config, connection: Connection, args) -> int:
|
|
|
207
260
|
|
|
208
261
|
with scratch.server() as scratch_server:
|
|
209
262
|
print(f"- building the state of the {len(has)} migrations {connection.database} has (scratch Postgres)")
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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:
|
|
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):
|
|
217
266
|
print(f"✔ {connection.label}: no drift, the database matches its migrations")
|
|
218
267
|
return 0
|
|
219
268
|
print(f"✘ {connection.label}: DRIFT - the database was changed outside of migrations:\n")
|
|
220
|
-
|
|
269
|
+
print_plans(plans, args.verbose)
|
|
221
270
|
if pending:
|
|
222
271
|
print("\nrun `pgl apply` first, then keep or drop the changes:")
|
|
223
272
|
print("\nkeep the changes: pgl capture <name>")
|
|
@@ -227,15 +276,21 @@ def cmd_drift(cfg: Config, connection: Connection, args) -> int:
|
|
|
227
276
|
|
|
228
277
|
def cmd_capture(cfg: Config, connection: Connection, args) -> None:
|
|
229
278
|
require_up_to_date(cfg, connection)
|
|
230
|
-
print(f"- dump {connection.label} -> {
|
|
231
|
-
# put back when there is nothing to capture
|
|
232
|
-
|
|
233
|
-
|
|
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)
|
|
234
285
|
note = f"manual changes captured from {connection.database} (recorded as captured there)"
|
|
235
286
|
with scratch.server() as scratch_server:
|
|
236
|
-
migration = generate_migration(cfg, scratch_server, args.name, note)
|
|
287
|
+
migration = generate_migration(cfg, scratch_server, args.name, note, args.verbose)
|
|
237
288
|
if migration is None:
|
|
238
|
-
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])
|
|
239
294
|
print(f"{connection.label}: no drift, nothing captured")
|
|
240
295
|
return
|
|
241
296
|
psql(connection, record_migration_sql(cfg, migration, "captured"))
|
|
@@ -253,8 +308,11 @@ def cmd_snapshot(cfg: Config, connection: Connection, args) -> None:
|
|
|
253
308
|
if not is_valid_snapshot_name(args.name):
|
|
254
309
|
sys.exit("snapshot name: letters, digits, _ . - only")
|
|
255
310
|
snapshot = cfg.snapshots_dir / f"{args.name}.sql"
|
|
256
|
-
|
|
257
|
-
|
|
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")
|
|
258
316
|
|
|
259
317
|
|
|
260
318
|
def snapshot_file(cfg: Config, name_or_path: str) -> Path:
|
|
@@ -275,10 +333,8 @@ def cmd_compare(cfg: Config, args) -> int:
|
|
|
275
333
|
# no second snapshot: compare with the database itself, dumped right now
|
|
276
334
|
if cfg.target is None:
|
|
277
335
|
sys.exit("comparing with the database needs DATABASE_NAME in .env - or give two snapshots")
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
pgschema_dump(cfg, cfg.target, live_dump_file)
|
|
281
|
-
second_dump = live_dump_file.read_text()
|
|
336
|
+
print(f"- dump {cfg.target.label}")
|
|
337
|
+
second_dump = dump_text(cfg, cfg.target, args.verbose)
|
|
282
338
|
second_label = cfg.target.label
|
|
283
339
|
comparison = compare(first_dump, second_dump, args.a, second_label)
|
|
284
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,28 +262,109 @@ 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
|
+
|
|
310
|
+
# where pgschema's readable plan starts repeating the whole SQL
|
|
311
|
+
DDL_HEADER = "DDL to be executed:"
|
|
312
|
+
|
|
313
|
+
|
|
215
314
|
@dataclass(frozen=True)
|
|
216
315
|
class Plan:
|
|
217
316
|
"""What pgschema would change, in two forms. Both are empty when there is nothing to change."""
|
|
218
317
|
|
|
219
318
|
sql: str # the statements, e.g. for a migration file
|
|
220
|
-
human: str # the same changes as a readable list for the terminal
|
|
319
|
+
human: str # the same changes as a readable list for the terminal, followed by the SQL
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def changes(self) -> str:
|
|
323
|
+
"""`human` without the SQL: the summary and the list of changed objects."""
|
|
324
|
+
return self.human.split(DDL_HEADER)[0].strip()
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def headline(self) -> str:
|
|
328
|
+
"""The size of the plan, e.g. "52 to add" from pgschema's first line "Plan: 52 to add."."""
|
|
329
|
+
first_line = self.human.splitlines()[0] if self.human else ""
|
|
330
|
+
return first_line.removeprefix("Plan:").strip().removesuffix(".")
|
|
331
|
+
|
|
332
|
+
|
|
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())
|
|
221
346
|
|
|
222
347
|
|
|
223
|
-
def
|
|
224
|
-
"""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`."""
|
|
225
362
|
with tempfile.TemporaryDirectory() as temporary_dir:
|
|
226
363
|
sql_file = Path(temporary_dir) / "plan.sql"
|
|
227
364
|
human_file = Path(temporary_dir) / "plan.txt"
|
|
228
365
|
command = [
|
|
229
366
|
pgschema.locate(), "plan",
|
|
230
|
-
"--schema",
|
|
367
|
+
"--schema", schema,
|
|
231
368
|
"--file", str(desired),
|
|
232
369
|
"--output-sql", str(sql_file),
|
|
233
370
|
"--output-human", str(human_file),
|
|
@@ -241,6 +378,37 @@ def pgschema_plan(cfg: Config, connection: Connection, desired: Path, scratch: C
|
|
|
241
378
|
return Plan(sql=sql.strip(), human=human.strip())
|
|
242
379
|
|
|
243
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
|
+
|
|
244
412
|
# Generating migrations
|
|
245
413
|
|
|
246
414
|
|
|
@@ -265,24 +433,35 @@ def write_migration(cfg: Config, name: str, sql: str, note: str) -> Migration:
|
|
|
265
433
|
return Migration(version, name, path)
|
|
266
434
|
|
|
267
435
|
|
|
268
|
-
def generate_migration(cfg: Config, scratch: Connection, name: str, note: str
|
|
269
|
-
|
|
436
|
+
def generate_migration(cfg: Config, scratch: Connection, name: str, note: str,
|
|
437
|
+
verbose: bool = False) -> Migration | None:
|
|
438
|
+
"""schema files vs (every migration applied) -> new migration, then check that it reproduces them.
|
|
439
|
+
|
|
440
|
+
The plan is only printed with `verbose`: it is the content of the new file anyway.
|
|
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)")
|
|
270
446
|
existing = list_migrations(cfg)
|
|
271
447
|
print(f"- building the state of {len(existing)} existing migrations (scratch Postgres)")
|
|
272
448
|
shadow = build_shadow(cfg, scratch, existing)
|
|
273
|
-
|
|
274
|
-
|
|
449
|
+
new_schemas = create_missing_schemas(cfg, shadow)
|
|
450
|
+
plans = plan_schemas(cfg, shadow, cfg.schema_files, scratch)
|
|
451
|
+
if not has_changes(plans):
|
|
275
452
|
print("no changes compared to the existing migrations")
|
|
276
453
|
return None
|
|
277
|
-
migration = write_migration(cfg, name,
|
|
278
|
-
|
|
454
|
+
migration = write_migration(cfg, name, migration_sql(cfg, plans, new_schemas), note)
|
|
455
|
+
if verbose:
|
|
456
|
+
print_plans(plans, verbose=True)
|
|
279
457
|
print(f"- verifying {migration.path.name}")
|
|
280
458
|
apply_file(cfg, shadow, migration)
|
|
281
459
|
# with the new migration applied, nothing should be left to change
|
|
282
|
-
leftover =
|
|
283
|
-
if leftover
|
|
284
|
-
|
|
460
|
+
leftover = plan_schemas(cfg, shadow, cfg.schema_files, scratch)
|
|
461
|
+
if has_changes(leftover):
|
|
462
|
+
# always in full: this is the case to debug
|
|
463
|
+
print_plans(leftover, verbose=True)
|
|
285
464
|
migration.path.unlink()
|
|
286
|
-
sys.exit("after applying the migration the result still differs from schema
|
|
287
|
-
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)})")
|
|
288
467
|
return migration
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|