pum 1.2.3__py3-none-any.whl → 1.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.
@@ -62,7 +75,8 @@ class SchemaMigrations:
62
75
  }
63
76
 
64
77
  cursor = SqlContent(query).execute(connection, parameters=parameters)
65
- return cursor.fetchone()[0]
78
+ result = cursor._pum_results[0] if cursor._pum_results else None
79
+ return result[0] if result else False
66
80
 
67
81
  def exists_in_other_schemas(self, connection: psycopg.Connection) -> list[str]:
68
82
  """Check if the schema_migrations information table exists in other schemas.
@@ -86,7 +100,7 @@ class SchemaMigrations:
86
100
  "schema": psycopg.sql.Literal(self.config.config.pum.migration_table_schema),
87
101
  }
88
102
  cursor = SqlContent(query).execute(connection, parameters=parameters)
89
- return [row[0] for row in cursor.fetchall()]
103
+ return [row[0] for row in (cursor._pum_results or [])]
90
104
 
91
105
  def create(
92
106
  self,
@@ -103,13 +117,13 @@ class SchemaMigrations:
103
117
  distinct schemas. Default is false.
104
118
  """
105
119
  if self.exists(connection):
106
- logger.info(
120
+ logger.debug(
107
121
  f"{self.config.config.pum.migration_table_schema}.pum_migrations table already exists."
108
122
  )
109
123
  return
110
124
 
111
125
  if not allow_multiple_schemas and len(self.exists_in_other_schemas(connection)) > 0:
112
- raise PumException(
126
+ raise PumSchemaMigrationError(
113
127
  f"Another {self.config.config.pum.migration_table_schema}.{MIGRATION_TABLE_NAME} table exists in another schema (). "
114
128
  "Please use the allow_multiple_schemas option to create a new one."
115
129
  )
@@ -130,30 +144,81 @@ class SchemaMigrations:
130
144
  (
131
145
  id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
132
146
  date_installed timestamp without time zone NOT NULL DEFAULT now(),
133
- module character varying(50), -- TODO: NOT NULL,
147
+ module character varying(50) NOT NULL,
134
148
  version character varying(50) NOT NULL,
135
149
  beta_testing boolean NOT NULL DEFAULT false,
136
150
  changelog_files text[],
137
151
  parameters jsonb,
138
- migration_table_version character varying(50) NOT NULL DEFAULT {version}
152
+ migration_table_version integer NOT NULL DEFAULT {version}
139
153
  );
140
154
  """
141
155
  )
142
156
 
143
- comment_query = psycopg.sql.SQL(
144
- "COMMENT ON TABLE {table} IS 'version: 1 -- schema_migration table version';"
145
- )
157
+ comment_query = psycopg.sql.SQL("COMMENT ON TABLE {table} IS 'migration_table_version: 2';")
146
158
 
147
159
  if create_schema_query:
148
160
  SqlContent(create_schema_query).execute(connection, parameters=parameters)
149
161
  SqlContent(create_table_query).execute(connection, parameters=parameters)
150
162
  SqlContent(comment_query).execute(connection, parameters=parameters)
151
163
 
152
- logger.info(f"Created {parameters['schema']}.{parameters['table']} table")
164
+ logger.info(f"Created migration table: {self.migration_table_identifier_str}")
153
165
 
154
166
  if commit:
155
167
  connection.commit()
156
168
 
169
+ def migration_table_version(self, connection: psycopg.Connection) -> int:
170
+ """Return the migration table version.
171
+
172
+ Args:
173
+ connection: The database connection to get the migration table version.
174
+
175
+ Returns:
176
+ int | None: The migration table version, or None if the table does not exist.
177
+
178
+ """
179
+ query = psycopg.sql.SQL(
180
+ """
181
+ SELECT migration_table_version
182
+ FROM {table}
183
+ ORDER BY migration_table_version DESC
184
+ LIMIT 1;
185
+ """
186
+ )
187
+
188
+ parameters = {
189
+ "table": self.migration_table_identifier,
190
+ }
191
+
192
+ cursor = SqlContent(query).execute(connection, parameters=parameters)
193
+ row = cursor._pum_results[0] if cursor._pum_results else None
194
+ if row is None:
195
+ raise PumSchemaMigrationError(
196
+ f"Migration table {self.migration_table_identifier_str} does not exist."
197
+ )
198
+ return row[0]
199
+
200
+ def update_migration_table_schema(self, connection: psycopg.Connection) -> None:
201
+ """Update the migration table schema to the latest version.
202
+
203
+ Args:
204
+ connection: The database connection to update the table.
205
+
206
+ """
207
+ table_version = self.migration_table_version(connection)
208
+ logger.info(
209
+ f"Updating migration table {self.migration_table_identifier_str} from version {table_version} to {MIGRATION_TABLE_VERSION}."
210
+ )
211
+ if table_version == 1:
212
+ alter_query = psycopg.sql.SQL("""
213
+ ALTER TABLE {table} ALTER COLUMN migration_table_version ALTER TYPE integer SET DEFAULT {version} USING 1;
214
+ ALTER TABLE {table} ALTER COLUMN module SET NOT NULL USING 'tww';
215
+ """)
216
+ parameters = {
217
+ "table": self.migration_table_identifier,
218
+ "version": psycopg.sql.Literal(MIGRATION_TABLE_VERSION),
219
+ }
220
+ SqlContent(alter_query).execute(connection, parameters=parameters)
221
+
157
222
  def set_baseline(
158
223
  self,
159
224
  connection: psycopg.Connection,
@@ -175,24 +240,37 @@ class SchemaMigrations:
175
240
  commit: If true, the transaction is committed. The default is False.
176
241
 
177
242
  """
178
- if isinstance(version, packaging.version.Version):
179
- version = str(version)
243
+ version_str = version
244
+ version_packaging = version
245
+ if isinstance(version_str, packaging.version.Version):
246
+ version_str = str(version_str)
247
+ if isinstance(version_packaging, str):
248
+ version_packaging = packaging.version.parse(version_packaging)
180
249
  pattern = re.compile(r"^\d+\.\d+(\.\d+)?$")
181
- if not re.match(pattern, version):
250
+ if not re.match(pattern, version_str):
182
251
  raise ValueError(f"Wrong version format: {version}. Must be x.y or x.y.z")
183
252
 
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}.")
253
+ try:
254
+ current = self.baseline(connection=connection)
255
+ except PumSchemaMigrationNoBaselineError:
256
+ current = None
257
+ if current:
258
+ self.update_migration_table_schema(connection)
259
+ if current and current >= version_packaging:
260
+ raise PumSchemaMigrationError(
261
+ f"Cannot set baseline {version_str} as it is already set at {current}."
262
+ )
187
263
 
188
264
  code = psycopg.sql.SQL("""
189
265
  INSERT INTO {table} (
266
+ module,
190
267
  version,
191
268
  beta_testing,
192
269
  migration_table_version,
193
270
  changelog_files,
194
271
  parameters
195
272
  ) VALUES (
273
+ {module},
196
274
  {version},
197
275
  {beta_testing},
198
276
  {migration_table_version},
@@ -202,7 +280,8 @@ INSERT INTO {table} (
202
280
 
203
281
  query_parameters = {
204
282
  "table": self.migration_table_identifier,
205
- "version": psycopg.sql.Literal(version),
283
+ "module": psycopg.sql.Literal(self.config.config.pum.module),
284
+ "version": psycopg.sql.Literal(version_str),
206
285
  "beta_testing": psycopg.sql.Literal(beta_testing),
207
286
  "migration_table_version": psycopg.sql.Literal(MIGRATION_TABLE_VERSION),
208
287
  "changelog_files": psycopg.sql.Literal(changelog_files or []),
@@ -210,11 +289,25 @@ INSERT INTO {table} (
210
289
  }
211
290
 
212
291
  logger.info(
213
- f"Setting baseline version {version} in {self.config.config.pum.migration_table_schema}.{MIGRATION_TABLE_NAME} table"
292
+ f"Setting baseline version {version} in {self.migration_table_identifier_str} table"
214
293
  )
215
294
  SqlContent(code).execute(connection, parameters=query_parameters, commit=commit)
216
295
 
217
- def baseline(self, connection: psycopg.Connection) -> str | None:
296
+ def has_baseline(self, connection: psycopg.Connection) -> bool:
297
+ """Check if the migration table has a baseline version.
298
+
299
+ Args:
300
+ connection: The database connection to check for the baseline version.
301
+ Returns:
302
+ bool: True if the baseline version exists, False otherwise.
303
+ """
304
+ try:
305
+ self.baseline(connection=connection)
306
+ return True
307
+ except PumSchemaMigrationError:
308
+ return False
309
+
310
+ def baseline(self, connection: psycopg.Connection) -> packaging.version.Version:
218
311
  """Return the baseline version from the migration table.
219
312
 
220
313
  Args:
@@ -222,12 +315,17 @@ INSERT INTO {table} (
222
315
  The database connection to get the baseline version.
223
316
 
224
317
  Returns:
225
- str: The baseline version.
318
+ packaging.version.Version | None: The baseline version.
226
319
 
320
+ Raises:
321
+ PumSchemaMigrationError: If the migration table does not exist or if no baseline version is found
322
+ PumSchemaMigrationNoBaselineError: If the migration table does not exist
227
323
  """
228
324
 
229
325
  if not self.exists(connection=connection):
230
- return None
326
+ raise PumSchemaMigrationError(
327
+ f"{self.migration_table_identifier_str} table does not exist."
328
+ )
231
329
 
232
330
  query = psycopg.sql.SQL(
233
331
  """
@@ -247,10 +345,12 @@ INSERT INTO {table} (
247
345
  }
248
346
 
249
347
  cursor = SqlContent(query).execute(connection, parameters=parameters)
250
- row = cursor.fetchone()
348
+ row = cursor._pum_results[0] if cursor._pum_results else None
251
349
  if row is None:
252
- return None
253
- return row[0]
350
+ raise PumSchemaMigrationNoBaselineError(
351
+ f"Baseline version not found in the {self.migration_table_identifier_str} table."
352
+ )
353
+ return packaging.version.parse(row[0])
254
354
 
255
355
  def migration_details(self, connection: psycopg.Connection, version: str | None = None) -> dict:
256
356
  """Return the migration details from the migration table.
@@ -265,6 +365,8 @@ INSERT INTO {table} (
265
365
  Returns:
266
366
  dict: The migration details.
267
367
 
368
+ Raises:
369
+ PumSchemaMigrationError: If the migration table does not exist or if no migration details are found.
268
370
  """
269
371
  query = None
270
372
  if version is None:
@@ -300,7 +402,38 @@ INSERT INTO {table} (
300
402
  }
301
403
 
302
404
  cursor = SqlContent(query).execute(connection, parameters=parameters)
303
- row = cursor.fetchone()
405
+ row = cursor._pum_results[0] if cursor._pum_results else None
304
406
  if row is None:
305
- return None
306
- return dict(zip([desc[0] for desc in cursor.description], row, strict=False))
407
+ raise PumSchemaMigrationError(
408
+ f"Migration details not found for version {version} in the {self.migration_table_identifier_str} table."
409
+ )
410
+ return dict(zip([desc[0] for desc in cursor._pum_description], row, strict=False))
411
+
412
+ def compare(self, connection: psycopg.Connection) -> int:
413
+ """Compare the migrations details in the database to the changelogs in the source.
414
+
415
+ Args:
416
+ connection: The database connection to get the baseline version.
417
+ Returns:
418
+ int: -1 if database is behind, 0 if up to date.
419
+
420
+ Raises:
421
+ PumSchemaMigrationError: If there is a mismatch between the database and the source.
422
+ """
423
+
424
+ current_version = self.baseline(connection=connection)
425
+ migration_details = self.migration_details(connection=connection)
426
+ changelogs = [str(changelog.version) for changelog in self.config.changelogs()]
427
+
428
+ # Check if the current migration version is in the changelogs
429
+ if migration_details["version"] not in changelogs:
430
+ raise PumSchemaMigrationError(
431
+ f"Changelog for version {migration_details['version']} not found in the source."
432
+ )
433
+
434
+ # Check if there are newer changelogs than current version
435
+ for changelog_version in changelogs:
436
+ if packaging.version.parse(changelog_version) > current_version:
437
+ return -1 # database is behind
438
+
439
+ return 0 # database is up to date