pum 1.2.2__py3-none-any.whl → 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pum/__init__.py +71 -10
- pum/changelog.py +61 -1
- pum/checker.py +444 -214
- pum/cli.py +300 -137
- pum/config_model.py +57 -34
- pum/connection.py +30 -0
- pum/dependency_handler.py +69 -4
- pum/dumper.py +14 -4
- pum/exceptions.py +9 -0
- pum/feedback.py +119 -0
- pum/hook.py +95 -29
- pum/info.py +0 -2
- pum/parameter.py +4 -0
- pum/pum_config.py +103 -20
- pum/report_generator.py +1043 -0
- pum/role_manager.py +151 -23
- pum/schema_migrations.py +173 -36
- pum/sql_content.py +83 -21
- pum/upgrader.py +287 -23
- {pum-1.2.2.dist-info → pum-1.3.0.dist-info}/METADATA +6 -2
- pum-1.3.0.dist-info/RECORD +25 -0
- {pum-1.2.2.dist-info → pum-1.3.0.dist-info}/WHEEL +1 -1
- pum-1.2.2.dist-info/RECORD +0 -22
- {pum-1.2.2.dist-info → pum-1.3.0.dist-info}/entry_points.txt +0 -0
- {pum-1.2.2.dist-info → pum-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {pum-1.2.2.dist-info → pum-1.3.0.dist-info}/top_level.txt +0 -0
pum/role_manager.py
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import enum
|
|
2
|
-
from typing import Optional
|
|
2
|
+
from typing import Optional, TYPE_CHECKING
|
|
3
3
|
import copy
|
|
4
4
|
import psycopg
|
|
5
5
|
import logging
|
|
6
6
|
|
|
7
7
|
from .sql_content import SqlContent
|
|
8
|
+
from .exceptions import PumException
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .feedback import Feedback
|
|
8
12
|
|
|
9
13
|
logger = logging.getLogger(__name__)
|
|
10
14
|
|
|
@@ -40,12 +44,14 @@ class Permission:
|
|
|
40
44
|
role: str,
|
|
41
45
|
connection: psycopg.Connection,
|
|
42
46
|
commit: bool = False,
|
|
47
|
+
feedback: Optional["Feedback"] = None,
|
|
43
48
|
) -> None:
|
|
44
49
|
"""Grant the permission to the specified role.
|
|
45
50
|
Args:
|
|
46
51
|
role: The name of the role to grant the permission to.
|
|
47
52
|
connection: The database connection to execute the SQL statements.
|
|
48
53
|
commit: Whether to commit the transaction. Defaults to False.
|
|
54
|
+
feedback: Optional feedback object for progress reporting.
|
|
49
55
|
"""
|
|
50
56
|
if not isinstance(role, str):
|
|
51
57
|
raise TypeError("Role must be a string.")
|
|
@@ -54,13 +60,37 @@ class Permission:
|
|
|
54
60
|
raise ValueError("Schemas must be defined for the permission.")
|
|
55
61
|
|
|
56
62
|
for schema in self.schemas:
|
|
57
|
-
|
|
63
|
+
if feedback and feedback.is_cancelled():
|
|
64
|
+
raise PumException("Permission grant cancelled by user")
|
|
65
|
+
|
|
66
|
+
# Detect if schema exists; if not, warn and continue
|
|
67
|
+
cursor = SqlContent("SELECT 1 FROM pg_namespace WHERE nspname = {schema}").execute(
|
|
68
|
+
connection=connection,
|
|
69
|
+
commit=False,
|
|
70
|
+
parameters={"schema": psycopg.sql.Literal(schema)},
|
|
71
|
+
)
|
|
72
|
+
if not cursor._pum_results or not cursor._pum_results[0]:
|
|
73
|
+
logger.warning(
|
|
74
|
+
f"Schema {schema} does not exist; skipping grant of {self.type.value} "
|
|
75
|
+
f"permission to role {role}."
|
|
76
|
+
)
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
logger.debug(
|
|
80
|
+
f"Granting {self.type.value} permission on schema {schema} to role {role}."
|
|
81
|
+
)
|
|
58
82
|
if self.type == PermissionType.READ:
|
|
59
83
|
SqlContent("""
|
|
60
84
|
GRANT USAGE ON SCHEMA {schema} TO {role};
|
|
61
|
-
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA {schema} TO {role};
|
|
62
85
|
GRANT SELECT, REFERENCES, TRIGGER ON ALL TABLES IN SCHEMA {schema} TO {role};
|
|
86
|
+
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA {schema} TO {role};
|
|
87
|
+
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA {schema} TO {role};
|
|
88
|
+
GRANT EXECUTE ON ALL ROUTINES IN SCHEMA {schema} TO {role};
|
|
63
89
|
ALTER DEFAULT PRIVILEGES IN SCHEMA {schema} GRANT SELECT, REFERENCES, TRIGGER ON TABLES TO {role};
|
|
90
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA {schema} GRANT SELECT ON SEQUENCES TO {role};
|
|
91
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA {schema} GRANT EXECUTE ON FUNCTIONS TO {role};
|
|
92
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA {schema} GRANT EXECUTE ON ROUTINES TO {role};
|
|
93
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA {schema} GRANT USAGE ON TYPES TO {role};
|
|
64
94
|
""").execute(
|
|
65
95
|
connection=connection,
|
|
66
96
|
commit=False,
|
|
@@ -69,12 +99,20 @@ class Permission:
|
|
|
69
99
|
"role": psycopg.sql.Identifier(role),
|
|
70
100
|
},
|
|
71
101
|
)
|
|
102
|
+
# Grant permissions on existing types
|
|
103
|
+
self._grant_existing_types(connection, schema, role, "USAGE")
|
|
72
104
|
elif self.type == PermissionType.WRITE:
|
|
73
105
|
SqlContent("""
|
|
74
106
|
GRANT ALL ON SCHEMA {schema} TO {role};
|
|
75
107
|
GRANT ALL ON ALL TABLES IN SCHEMA {schema} TO {role};
|
|
76
108
|
GRANT ALL ON ALL SEQUENCES IN SCHEMA {schema} TO {role};
|
|
109
|
+
GRANT ALL ON ALL FUNCTIONS IN SCHEMA {schema} TO {role};
|
|
110
|
+
GRANT ALL ON ALL ROUTINES IN SCHEMA {schema} TO {role};
|
|
77
111
|
ALTER DEFAULT PRIVILEGES IN SCHEMA {schema} GRANT ALL ON TABLES TO {role};
|
|
112
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA {schema} GRANT ALL ON SEQUENCES TO {role};
|
|
113
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA {schema} GRANT ALL ON FUNCTIONS TO {role};
|
|
114
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA {schema} GRANT ALL ON ROUTINES TO {role};
|
|
115
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA {schema} GRANT ALL ON TYPES TO {role};
|
|
78
116
|
""").execute(
|
|
79
117
|
connection=connection,
|
|
80
118
|
commit=False,
|
|
@@ -83,15 +121,62 @@ class Permission:
|
|
|
83
121
|
"role": psycopg.sql.Identifier(role),
|
|
84
122
|
},
|
|
85
123
|
)
|
|
124
|
+
# Grant permissions on existing types
|
|
125
|
+
self._grant_existing_types(connection, schema, role, "ALL")
|
|
86
126
|
else:
|
|
87
127
|
raise ValueError(f"Unknown permission type: {self.type}")
|
|
88
128
|
|
|
89
129
|
if commit:
|
|
130
|
+
if feedback:
|
|
131
|
+
feedback.lock_cancellation()
|
|
90
132
|
connection.commit()
|
|
91
133
|
|
|
134
|
+
def _grant_existing_types(
|
|
135
|
+
self, connection: psycopg.Connection, schema: str, role: str, privilege: str
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Grant permissions on all existing types in a schema.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
connection: The database connection.
|
|
141
|
+
schema: The schema name.
|
|
142
|
+
role: The role name.
|
|
143
|
+
privilege: The privilege to grant (USAGE or ALL).
|
|
144
|
+
|
|
145
|
+
"""
|
|
146
|
+
# Query for all types in the schema (excluding array types and internal types)
|
|
147
|
+
cursor = connection.cursor()
|
|
148
|
+
cursor.execute(
|
|
149
|
+
"""
|
|
150
|
+
SELECT t.typname
|
|
151
|
+
FROM pg_type t
|
|
152
|
+
JOIN pg_namespace n ON t.typnamespace = n.oid
|
|
153
|
+
WHERE n.nspname = %s
|
|
154
|
+
AND t.typtype IN ('e', 'c', 'd', 'b', 'r') -- enum, composite, domain, base, range
|
|
155
|
+
AND t.typname NOT LIKE '_%%' -- exclude array types
|
|
156
|
+
""",
|
|
157
|
+
(schema,),
|
|
158
|
+
)
|
|
159
|
+
types = cursor.fetchall()
|
|
160
|
+
|
|
161
|
+
# Grant permissions on each type
|
|
162
|
+
for (type_name,) in types:
|
|
163
|
+
grant_sql = psycopg.sql.SQL(
|
|
164
|
+
"GRANT {privilege} ON TYPE {schema}.{type_name} TO {role}"
|
|
165
|
+
).format(
|
|
166
|
+
privilege=psycopg.sql.SQL(privilege),
|
|
167
|
+
schema=psycopg.sql.Identifier(schema),
|
|
168
|
+
type_name=psycopg.sql.Identifier(type_name),
|
|
169
|
+
role=psycopg.sql.Identifier(role),
|
|
170
|
+
)
|
|
171
|
+
cursor.execute(grant_sql)
|
|
172
|
+
|
|
173
|
+
def __repr__(self) -> str:
|
|
174
|
+
"""Return a string representation of the Permission object."""
|
|
175
|
+
return f"<Permission: {self.type.value} on {self.schemas}>"
|
|
176
|
+
|
|
92
177
|
|
|
93
178
|
class Role:
|
|
94
|
-
"""
|
|
179
|
+
"""
|
|
95
180
|
Represents a database role with associated permissions and optional inheritance.
|
|
96
181
|
The Role class encapsulates the concept of a database role, including its name,
|
|
97
182
|
permissions, optional inheritance from another role, and an optional description.
|
|
@@ -138,30 +223,36 @@ class Role:
|
|
|
138
223
|
Returns:
|
|
139
224
|
bool: True if the role exists, False otherwise.
|
|
140
225
|
"""
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
commit=False,
|
|
146
|
-
parameters={"name": psycopg.sql.Literal(self.name)},
|
|
147
|
-
)
|
|
148
|
-
.fetchone()
|
|
149
|
-
is not None
|
|
226
|
+
cursor = SqlContent("SELECT 1 FROM pg_roles WHERE rolname = {name}").execute(
|
|
227
|
+
connection=connection,
|
|
228
|
+
commit=False,
|
|
229
|
+
parameters={"name": psycopg.sql.Literal(self.name)},
|
|
150
230
|
)
|
|
231
|
+
return bool(cursor._pum_results)
|
|
151
232
|
|
|
152
233
|
def create(
|
|
153
|
-
self,
|
|
234
|
+
self,
|
|
235
|
+
connection: psycopg.Connection,
|
|
236
|
+
grant: bool = False,
|
|
237
|
+
commit: bool = False,
|
|
238
|
+
feedback: Optional["Feedback"] = None,
|
|
154
239
|
) -> None:
|
|
155
240
|
"""Create the role in the database.
|
|
156
241
|
Args:
|
|
157
242
|
connection: The database connection to execute the SQL statements.
|
|
158
243
|
grant: Whether to grant permissions to the role. Defaults to False.
|
|
159
244
|
commit: Whether to commit the transaction. Defaults to False.
|
|
245
|
+
feedback: Optional feedback object for progress reporting.
|
|
160
246
|
"""
|
|
247
|
+
if feedback and feedback.is_cancelled():
|
|
248
|
+
from .exceptions import PumException
|
|
249
|
+
|
|
250
|
+
raise PumException("Role creation cancelled by user")
|
|
251
|
+
|
|
161
252
|
if self.exists(connection):
|
|
162
|
-
logger.
|
|
253
|
+
logger.debug(f"Role {self.name} already exists, skipping creation.")
|
|
163
254
|
else:
|
|
164
|
-
logger.
|
|
255
|
+
logger.debug(f"Creating role {self.name}.")
|
|
165
256
|
SqlContent(
|
|
166
257
|
"CREATE ROLE {name} NOSUPERUSER INHERIT NOCREATEDB NOCREATEROLE NOREPLICATION"
|
|
167
258
|
).execute(
|
|
@@ -189,9 +280,13 @@ class Role:
|
|
|
189
280
|
)
|
|
190
281
|
if grant:
|
|
191
282
|
for permission in self.permissions():
|
|
192
|
-
permission.grant(
|
|
283
|
+
permission.grant(
|
|
284
|
+
role=self.name, connection=connection, commit=False, feedback=feedback
|
|
285
|
+
)
|
|
193
286
|
|
|
194
287
|
if commit:
|
|
288
|
+
if feedback:
|
|
289
|
+
feedback.lock_cancellation()
|
|
195
290
|
connection.commit()
|
|
196
291
|
|
|
197
292
|
|
|
@@ -232,28 +327,61 @@ class RoleManager:
|
|
|
232
327
|
)
|
|
233
328
|
|
|
234
329
|
def create_roles(
|
|
235
|
-
self,
|
|
330
|
+
self,
|
|
331
|
+
connection: psycopg.Connection,
|
|
332
|
+
grant: bool = False,
|
|
333
|
+
commit: bool = False,
|
|
334
|
+
feedback: Optional["Feedback"] = None,
|
|
236
335
|
) -> None:
|
|
237
336
|
"""Create roles in the database.
|
|
238
337
|
Args:
|
|
239
338
|
connection: The database connection to execute the SQL statements.
|
|
240
339
|
grant: Whether to grant permissions to the roles. Defaults to False.
|
|
241
340
|
commit: Whether to commit the transaction. Defaults to False.
|
|
341
|
+
feedback: Optional feedback object for progress reporting.
|
|
242
342
|
"""
|
|
243
|
-
|
|
244
|
-
|
|
343
|
+
roles_list = list(self.roles.values())
|
|
344
|
+
for role in roles_list:
|
|
345
|
+
if feedback and feedback.is_cancelled():
|
|
346
|
+
from .exceptions import PumException
|
|
347
|
+
|
|
348
|
+
raise PumException("Role creation cancelled by user")
|
|
349
|
+
if feedback:
|
|
350
|
+
feedback.increment_step()
|
|
351
|
+
feedback.report_progress(f"Creating role: {role.name}")
|
|
352
|
+
role.create(connection=connection, commit=False, grant=grant, feedback=feedback)
|
|
245
353
|
if commit:
|
|
354
|
+
if feedback:
|
|
355
|
+
feedback.lock_cancellation()
|
|
246
356
|
connection.commit()
|
|
247
357
|
|
|
248
|
-
def grant_permissions(
|
|
358
|
+
def grant_permissions(
|
|
359
|
+
self,
|
|
360
|
+
connection: psycopg.Connection,
|
|
361
|
+
commit: bool = False,
|
|
362
|
+
feedback: Optional["Feedback"] = None,
|
|
363
|
+
) -> None:
|
|
249
364
|
"""Grant permissions to the roles in the database.
|
|
250
365
|
Args:
|
|
251
366
|
connection: The database connection to execute the SQL statements.
|
|
252
367
|
commit: Whether to commit the transaction. Defaults to False.
|
|
368
|
+
feedback: Optional feedback object for progress reporting.
|
|
253
369
|
"""
|
|
254
|
-
|
|
370
|
+
roles_list = list(self.roles.values())
|
|
371
|
+
for role in roles_list:
|
|
372
|
+
if feedback and feedback.is_cancelled():
|
|
373
|
+
from .exceptions import PumException
|
|
374
|
+
|
|
375
|
+
raise PumException("Permission grant cancelled by user")
|
|
376
|
+
if feedback:
|
|
377
|
+
feedback.increment_step()
|
|
378
|
+
feedback.report_progress(f"Granting permissions to role: {role.name}")
|
|
255
379
|
for permission in role.permissions():
|
|
256
|
-
permission.grant(
|
|
380
|
+
permission.grant(
|
|
381
|
+
role=role.name, connection=connection, commit=False, feedback=feedback
|
|
382
|
+
)
|
|
257
383
|
logger.info("All permissions granted to roles.")
|
|
258
384
|
if commit:
|
|
385
|
+
if feedback:
|
|
386
|
+
feedback.lock_cancellation()
|
|
259
387
|
connection.commit()
|
pum/schema_migrations.py
CHANGED
|
@@ -3,18 +3,28 @@ import logging
|
|
|
3
3
|
import re
|
|
4
4
|
|
|
5
5
|
import packaging
|
|
6
|
+
import packaging.version
|
|
6
7
|
import psycopg
|
|
7
8
|
import psycopg.sql
|
|
8
9
|
|
|
9
|
-
from .
|
|
10
|
-
from .exceptions import PumException
|
|
10
|
+
from .exceptions import PumSchemaMigrationError, PumSchemaMigrationNoBaselineError
|
|
11
11
|
from .sql_content import SqlContent
|
|
12
|
+
from .pum_config import PumConfig
|
|
12
13
|
|
|
13
14
|
logger = logging.getLogger(__name__)
|
|
14
15
|
|
|
15
|
-
MIGRATION_TABLE_VERSION =
|
|
16
|
+
MIGRATION_TABLE_VERSION = 2 # Current schema version
|
|
16
17
|
MIGRATION_TABLE_NAME = "pum_migrations"
|
|
17
18
|
|
|
19
|
+
# TABLE VERSION HISTORY
|
|
20
|
+
#
|
|
21
|
+
# Version 1:
|
|
22
|
+
# Initial version with columns id, date_installed, module, version,
|
|
23
|
+
# beta_testing, changelog_files, parameters, migration_table_version
|
|
24
|
+
#
|
|
25
|
+
# Version 2:
|
|
26
|
+
# Changed migration_table_version type to integer and set module NOT NULL (version 2025.1 => 1, module 'tww')
|
|
27
|
+
|
|
18
28
|
|
|
19
29
|
class SchemaMigrations:
|
|
20
30
|
"""Manage the schema migrations in the database.
|
|
@@ -36,6 +46,9 @@ class SchemaMigrations:
|
|
|
36
46
|
psycopg.sql.Identifier(MIGRATION_TABLE_NAME),
|
|
37
47
|
]
|
|
38
48
|
)
|
|
49
|
+
self.migration_table_identifier_str = (
|
|
50
|
+
f"{self.config.config.pum.migration_table_schema}.{MIGRATION_TABLE_NAME}"
|
|
51
|
+
)
|
|
39
52
|
|
|
40
53
|
def exists(self, connection: psycopg.Connection) -> bool:
|
|
41
54
|
"""Check if the schema_migrations information table exists.
|
|
@@ -61,8 +74,10 @@ class SchemaMigrations:
|
|
|
61
74
|
"schema": psycopg.sql.Literal(self.config.config.pum.migration_table_schema),
|
|
62
75
|
}
|
|
63
76
|
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
with connection.transaction():
|
|
78
|
+
cursor = SqlContent(query).execute(connection, parameters=parameters)
|
|
79
|
+
result = cursor._pum_results[0] if cursor._pum_results else None
|
|
80
|
+
return result[0] if result else False
|
|
66
81
|
|
|
67
82
|
def exists_in_other_schemas(self, connection: psycopg.Connection) -> list[str]:
|
|
68
83
|
"""Check if the schema_migrations information table exists in other schemas.
|
|
@@ -85,8 +100,9 @@ class SchemaMigrations:
|
|
|
85
100
|
parameters = {
|
|
86
101
|
"schema": psycopg.sql.Literal(self.config.config.pum.migration_table_schema),
|
|
87
102
|
}
|
|
88
|
-
|
|
89
|
-
|
|
103
|
+
with connection.transaction():
|
|
104
|
+
cursor = SqlContent(query).execute(connection, parameters=parameters)
|
|
105
|
+
return [row[0] for row in (cursor._pum_results or [])]
|
|
90
106
|
|
|
91
107
|
def create(
|
|
92
108
|
self,
|
|
@@ -103,13 +119,13 @@ class SchemaMigrations:
|
|
|
103
119
|
distinct schemas. Default is false.
|
|
104
120
|
"""
|
|
105
121
|
if self.exists(connection):
|
|
106
|
-
logger.
|
|
122
|
+
logger.debug(
|
|
107
123
|
f"{self.config.config.pum.migration_table_schema}.pum_migrations table already exists."
|
|
108
124
|
)
|
|
109
125
|
return
|
|
110
126
|
|
|
111
127
|
if not allow_multiple_schemas and len(self.exists_in_other_schemas(connection)) > 0:
|
|
112
|
-
raise
|
|
128
|
+
raise PumSchemaMigrationError(
|
|
113
129
|
f"Another {self.config.config.pum.migration_table_schema}.{MIGRATION_TABLE_NAME} table exists in another schema (). "
|
|
114
130
|
"Please use the allow_multiple_schemas option to create a new one."
|
|
115
131
|
)
|
|
@@ -130,30 +146,81 @@ class SchemaMigrations:
|
|
|
130
146
|
(
|
|
131
147
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
|
132
148
|
date_installed timestamp without time zone NOT NULL DEFAULT now(),
|
|
133
|
-
module character varying(50)
|
|
149
|
+
module character varying(50) NOT NULL,
|
|
134
150
|
version character varying(50) NOT NULL,
|
|
135
151
|
beta_testing boolean NOT NULL DEFAULT false,
|
|
136
152
|
changelog_files text[],
|
|
137
153
|
parameters jsonb,
|
|
138
|
-
migration_table_version
|
|
154
|
+
migration_table_version integer NOT NULL DEFAULT {version}
|
|
139
155
|
);
|
|
140
156
|
"""
|
|
141
157
|
)
|
|
142
158
|
|
|
143
|
-
comment_query = psycopg.sql.SQL(
|
|
144
|
-
"COMMENT ON TABLE {table} IS 'version: 1 -- schema_migration table version';"
|
|
145
|
-
)
|
|
159
|
+
comment_query = psycopg.sql.SQL("COMMENT ON TABLE {table} IS 'migration_table_version: 2';")
|
|
146
160
|
|
|
147
161
|
if create_schema_query:
|
|
148
162
|
SqlContent(create_schema_query).execute(connection, parameters=parameters)
|
|
149
163
|
SqlContent(create_table_query).execute(connection, parameters=parameters)
|
|
150
164
|
SqlContent(comment_query).execute(connection, parameters=parameters)
|
|
151
165
|
|
|
152
|
-
logger.info(f"Created {
|
|
166
|
+
logger.info(f"Created migration table: {self.migration_table_identifier_str}")
|
|
153
167
|
|
|
154
168
|
if commit:
|
|
155
169
|
connection.commit()
|
|
156
170
|
|
|
171
|
+
def migration_table_version(self, connection: psycopg.Connection) -> int:
|
|
172
|
+
"""Return the migration table version.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
connection: The database connection to get the migration table version.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
int | None: The migration table version, or None if the table does not exist.
|
|
179
|
+
|
|
180
|
+
"""
|
|
181
|
+
query = psycopg.sql.SQL(
|
|
182
|
+
"""
|
|
183
|
+
SELECT migration_table_version
|
|
184
|
+
FROM {table}
|
|
185
|
+
ORDER BY migration_table_version DESC
|
|
186
|
+
LIMIT 1;
|
|
187
|
+
"""
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
parameters = {
|
|
191
|
+
"table": self.migration_table_identifier,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
cursor = SqlContent(query).execute(connection, parameters=parameters)
|
|
195
|
+
row = cursor._pum_results[0] if cursor._pum_results else None
|
|
196
|
+
if row is None:
|
|
197
|
+
raise PumSchemaMigrationError(
|
|
198
|
+
f"Migration table {self.migration_table_identifier_str} does not exist."
|
|
199
|
+
)
|
|
200
|
+
return row[0]
|
|
201
|
+
|
|
202
|
+
def update_migration_table_schema(self, connection: psycopg.Connection) -> None:
|
|
203
|
+
"""Update the migration table schema to the latest version.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
connection: The database connection to update the table.
|
|
207
|
+
|
|
208
|
+
"""
|
|
209
|
+
table_version = self.migration_table_version(connection)
|
|
210
|
+
logger.info(
|
|
211
|
+
f"Updating migration table {self.migration_table_identifier_str} from version {table_version} to {MIGRATION_TABLE_VERSION}."
|
|
212
|
+
)
|
|
213
|
+
if table_version == 1:
|
|
214
|
+
alter_query = psycopg.sql.SQL("""
|
|
215
|
+
ALTER TABLE {table} ALTER COLUMN migration_table_version ALTER TYPE integer SET DEFAULT {version} USING 1;
|
|
216
|
+
ALTER TABLE {table} ALTER COLUMN module SET NOT NULL USING 'tww';
|
|
217
|
+
""")
|
|
218
|
+
parameters = {
|
|
219
|
+
"table": self.migration_table_identifier,
|
|
220
|
+
"version": psycopg.sql.Literal(MIGRATION_TABLE_VERSION),
|
|
221
|
+
}
|
|
222
|
+
SqlContent(alter_query).execute(connection, parameters=parameters)
|
|
223
|
+
|
|
157
224
|
def set_baseline(
|
|
158
225
|
self,
|
|
159
226
|
connection: psycopg.Connection,
|
|
@@ -175,24 +242,37 @@ class SchemaMigrations:
|
|
|
175
242
|
commit: If true, the transaction is committed. The default is False.
|
|
176
243
|
|
|
177
244
|
"""
|
|
178
|
-
|
|
179
|
-
|
|
245
|
+
version_str = version
|
|
246
|
+
version_packaging = version
|
|
247
|
+
if isinstance(version_str, packaging.version.Version):
|
|
248
|
+
version_str = str(version_str)
|
|
249
|
+
if isinstance(version_packaging, str):
|
|
250
|
+
version_packaging = packaging.version.parse(version_packaging)
|
|
180
251
|
pattern = re.compile(r"^\d+\.\d+(\.\d+)?$")
|
|
181
|
-
if not re.match(pattern,
|
|
252
|
+
if not re.match(pattern, version_str):
|
|
182
253
|
raise ValueError(f"Wrong version format: {version}. Must be x.y or x.y.z")
|
|
183
254
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
255
|
+
try:
|
|
256
|
+
current = self.baseline(connection=connection)
|
|
257
|
+
except PumSchemaMigrationNoBaselineError:
|
|
258
|
+
current = None
|
|
259
|
+
if current:
|
|
260
|
+
self.update_migration_table_schema(connection)
|
|
261
|
+
if current and current >= version_packaging:
|
|
262
|
+
raise PumSchemaMigrationError(
|
|
263
|
+
f"Cannot set baseline {version_str} as it is already set at {current}."
|
|
264
|
+
)
|
|
187
265
|
|
|
188
266
|
code = psycopg.sql.SQL("""
|
|
189
267
|
INSERT INTO {table} (
|
|
268
|
+
module,
|
|
190
269
|
version,
|
|
191
270
|
beta_testing,
|
|
192
271
|
migration_table_version,
|
|
193
272
|
changelog_files,
|
|
194
273
|
parameters
|
|
195
274
|
) VALUES (
|
|
275
|
+
{module},
|
|
196
276
|
{version},
|
|
197
277
|
{beta_testing},
|
|
198
278
|
{migration_table_version},
|
|
@@ -202,7 +282,8 @@ INSERT INTO {table} (
|
|
|
202
282
|
|
|
203
283
|
query_parameters = {
|
|
204
284
|
"table": self.migration_table_identifier,
|
|
205
|
-
"
|
|
285
|
+
"module": psycopg.sql.Literal(self.config.config.pum.module),
|
|
286
|
+
"version": psycopg.sql.Literal(version_str),
|
|
206
287
|
"beta_testing": psycopg.sql.Literal(beta_testing),
|
|
207
288
|
"migration_table_version": psycopg.sql.Literal(MIGRATION_TABLE_VERSION),
|
|
208
289
|
"changelog_files": psycopg.sql.Literal(changelog_files or []),
|
|
@@ -210,11 +291,25 @@ INSERT INTO {table} (
|
|
|
210
291
|
}
|
|
211
292
|
|
|
212
293
|
logger.info(
|
|
213
|
-
f"Setting baseline version {version} in {self.
|
|
294
|
+
f"Setting baseline version {version} in {self.migration_table_identifier_str} table"
|
|
214
295
|
)
|
|
215
296
|
SqlContent(code).execute(connection, parameters=query_parameters, commit=commit)
|
|
216
297
|
|
|
217
|
-
def
|
|
298
|
+
def has_baseline(self, connection: psycopg.Connection) -> bool:
|
|
299
|
+
"""Check if the migration table has a baseline version.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
connection: The database connection to check for the baseline version.
|
|
303
|
+
Returns:
|
|
304
|
+
bool: True if the baseline version exists, False otherwise.
|
|
305
|
+
"""
|
|
306
|
+
try:
|
|
307
|
+
self.baseline(connection=connection)
|
|
308
|
+
return True
|
|
309
|
+
except PumSchemaMigrationError:
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
def baseline(self, connection: psycopg.Connection) -> packaging.version.Version:
|
|
218
313
|
"""Return the baseline version from the migration table.
|
|
219
314
|
|
|
220
315
|
Args:
|
|
@@ -222,12 +317,17 @@ INSERT INTO {table} (
|
|
|
222
317
|
The database connection to get the baseline version.
|
|
223
318
|
|
|
224
319
|
Returns:
|
|
225
|
-
|
|
320
|
+
packaging.version.Version | None: The baseline version.
|
|
226
321
|
|
|
322
|
+
Raises:
|
|
323
|
+
PumSchemaMigrationError: If the migration table does not exist or if no baseline version is found
|
|
324
|
+
PumSchemaMigrationNoBaselineError: If the migration table does not exist
|
|
227
325
|
"""
|
|
228
326
|
|
|
229
327
|
if not self.exists(connection=connection):
|
|
230
|
-
|
|
328
|
+
raise PumSchemaMigrationError(
|
|
329
|
+
f"{self.migration_table_identifier_str} table does not exist."
|
|
330
|
+
)
|
|
231
331
|
|
|
232
332
|
query = psycopg.sql.SQL(
|
|
233
333
|
"""
|
|
@@ -246,11 +346,14 @@ INSERT INTO {table} (
|
|
|
246
346
|
"table": self.migration_table_identifier,
|
|
247
347
|
}
|
|
248
348
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
349
|
+
with connection.transaction():
|
|
350
|
+
cursor = SqlContent(query).execute(connection, parameters=parameters)
|
|
351
|
+
row = cursor._pum_results[0] if cursor._pum_results else None
|
|
352
|
+
if row is None:
|
|
353
|
+
raise PumSchemaMigrationNoBaselineError(
|
|
354
|
+
f"Baseline version not found in the {self.migration_table_identifier_str} table."
|
|
355
|
+
)
|
|
356
|
+
return packaging.version.parse(row[0])
|
|
254
357
|
|
|
255
358
|
def migration_details(self, connection: psycopg.Connection, version: str | None = None) -> dict:
|
|
256
359
|
"""Return the migration details from the migration table.
|
|
@@ -265,6 +368,8 @@ INSERT INTO {table} (
|
|
|
265
368
|
Returns:
|
|
266
369
|
dict: The migration details.
|
|
267
370
|
|
|
371
|
+
Raises:
|
|
372
|
+
PumSchemaMigrationError: If the migration table does not exist or if no migration details are found.
|
|
268
373
|
"""
|
|
269
374
|
query = None
|
|
270
375
|
if version is None:
|
|
@@ -299,8 +404,40 @@ INSERT INTO {table} (
|
|
|
299
404
|
"version": psycopg.sql.Literal(version),
|
|
300
405
|
}
|
|
301
406
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
407
|
+
with connection.transaction():
|
|
408
|
+
cursor = SqlContent(query).execute(connection, parameters=parameters)
|
|
409
|
+
row = cursor._pum_results[0] if cursor._pum_results else None
|
|
410
|
+
if row is None:
|
|
411
|
+
raise PumSchemaMigrationError(
|
|
412
|
+
f"Migration details not found for version {version} in the {self.migration_table_identifier_str} table."
|
|
413
|
+
)
|
|
414
|
+
return dict(zip([desc[0] for desc in cursor._pum_description], row, strict=False))
|
|
415
|
+
|
|
416
|
+
def compare(self, connection: psycopg.Connection) -> int:
|
|
417
|
+
"""Compare the migrations details in the database to the changelogs in the source.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
connection: The database connection to get the baseline version.
|
|
421
|
+
Returns:
|
|
422
|
+
int: -1 if database is behind, 0 if up to date.
|
|
423
|
+
|
|
424
|
+
Raises:
|
|
425
|
+
PumSchemaMigrationError: If there is a mismatch between the database and the source.
|
|
426
|
+
"""
|
|
427
|
+
|
|
428
|
+
current_version = self.baseline(connection=connection)
|
|
429
|
+
migration_details = self.migration_details(connection=connection)
|
|
430
|
+
changelogs = [str(changelog.version) for changelog in self.config.changelogs()]
|
|
431
|
+
|
|
432
|
+
# Check if the current migration version is in the changelogs
|
|
433
|
+
if migration_details["version"] not in changelogs:
|
|
434
|
+
raise PumSchemaMigrationError(
|
|
435
|
+
f"Changelog for version {migration_details['version']} not found in the source."
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
# Check if there are newer changelogs than current version
|
|
439
|
+
for changelog_version in changelogs:
|
|
440
|
+
if packaging.version.parse(changelog_version) > current_version:
|
|
441
|
+
return -1 # database is behind
|
|
442
|
+
|
|
443
|
+
return 0 # database is up to date
|