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/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
- logger.info(f"Granting {self.type.value} permission on schema {schema} to role {role}.")
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
- return (
142
- SqlContent("SELECT 1 FROM pg_roles WHERE rolname = {name}")
143
- .execute(
144
- connection=connection,
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, connection: psycopg.Connection, grant: bool = False, commit: bool = False
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.info(f"Role {self.name} already exists, skipping creation.")
253
+ logger.debug(f"Role {self.name} already exists, skipping creation.")
163
254
  else:
164
- logger.info(f"Creating role {self.name}.")
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(role=self.name, connection=connection, commit=commit)
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, connection: psycopg.Connection, grant: bool = False, commit: bool = False
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
- for role in self.roles.values():
244
- role.create(connection=connection, commit=False, grant=grant)
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(self, connection: psycopg.Connection, commit: bool = False) -> None:
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
- for role in self.roles.values():
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(role=role.name, connection=connection, commit=False)
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 .pum_config import PumConfig
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 = "2025.0"
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
- cursor = SqlContent(query).execute(connection, parameters=parameters)
65
- return cursor.fetchone()[0]
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
- cursor = SqlContent(query).execute(connection, parameters=parameters)
89
- return [row[0] for row in cursor.fetchall()]
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.info(
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 PumException(
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), -- TODO: NOT NULL,
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 character varying(50) NOT NULL DEFAULT {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 {parameters['schema']}.{parameters['table']} table")
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
- if isinstance(version, packaging.version.Version):
179
- version = str(version)
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, version):
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
- current = self.baseline(connection=connection)
185
- if current and current >= version:
186
- raise PumException(f"Cannot set baseline {version} as it is already set at {current}.")
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
- "version": psycopg.sql.Literal(version),
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.config.config.pum.migration_table_schema}.{MIGRATION_TABLE_NAME} table"
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 baseline(self, connection: psycopg.Connection) -> str | None:
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
- str: The baseline version.
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
- return None
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
- cursor = SqlContent(query).execute(connection, parameters=parameters)
250
- row = cursor.fetchone()
251
- if row is None:
252
- return None
253
- return row[0]
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
- cursor = SqlContent(query).execute(connection, parameters=parameters)
303
- row = cursor.fetchone()
304
- if row is None:
305
- return None
306
- return dict(zip([desc[0] for desc in cursor.description], row, strict=False))
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