pum 1.0.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 ADDED
@@ -0,0 +1,27 @@
1
+ from .changelog import Changelog
2
+ from .dumper import Dumper, DumpFormat
3
+ from .pum_config import PumConfig
4
+ from .hook import HookHandler, HookBase
5
+ from .parameter import ParameterDefinition, ParameterType
6
+ from .role_manager import RoleManager, Role, Permission, PermissionType
7
+ from .schema_migrations import SchemaMigrations
8
+ from .sql_content import SqlContent
9
+ from .upgrader import Upgrader
10
+
11
+ __all__ = [
12
+ "Changelog",
13
+ "Dumper",
14
+ "DumpFormat",
15
+ "HookBase",
16
+ "HookHandler",
17
+ "ParameterDefinition",
18
+ "ParameterType",
19
+ "Permission",
20
+ "PermissionType",
21
+ "PumConfig",
22
+ "Role",
23
+ "RoleManager",
24
+ "SchemaMigrations",
25
+ "SqlContent",
26
+ "Upgrader",
27
+ ]
pum/changelog.py ADDED
@@ -0,0 +1,111 @@
1
+ from os import listdir
2
+ from os.path import basename
3
+ from pathlib import Path
4
+
5
+ from packaging.version import parse as parse_version
6
+ import psycopg
7
+
8
+ from .exceptions import PumInvalidChangelog, PumSqlError
9
+ from .sql_content import SqlContent
10
+
11
+
12
+ class Changelog:
13
+ """This class represent a changelog directory.
14
+ The directory name is the version of the changelog.
15
+ """
16
+
17
+ def __init__(self, dir):
18
+ """Args:
19
+ dir (str): The directory where the changelog is located.
20
+
21
+ """
22
+ self.dir = dir
23
+ self.version = parse_version(basename(dir))
24
+
25
+ def __repr__(self):
26
+ return f"<dir: {self.dir} (v: {self.version})>"
27
+
28
+ def files(self) -> list[Path]:
29
+ """Get the ordered list of SQL files in the changelog directory.
30
+ This is not recursive, it only returns the files in the given changelog directory.
31
+
32
+ Returns:
33
+ list[Path]: A list of paths to the changelog files.
34
+
35
+ """
36
+ files = [
37
+ self.dir / f
38
+ for f in listdir(self.dir)
39
+ if (self.dir / f).is_file() and f.endswith(".sql")
40
+ ]
41
+ files.sort()
42
+ return files
43
+
44
+ def validate(self, parameters: dict | None = None) -> bool:
45
+ """Validate the changelog directory.
46
+ This is done by checking if the directory exists and if it contains at least one SQL file.
47
+
48
+ Args:
49
+ parameters: The parameters to pass to the SQL files.
50
+
51
+ Raises:
52
+ PumInvalidChangelog: If the changelog directory does not exist or does not contain any SQL files.
53
+
54
+ """
55
+ if not self.dir.is_dir():
56
+ raise PumInvalidChangelog(f"Changelog directory `{self.dir}` does not exist.")
57
+ files = self.files()
58
+ if not files:
59
+ raise PumInvalidChangelog(
60
+ f"Changelog directory `{self.dir}` does not contain any SQL files."
61
+ )
62
+ for file in files:
63
+ if not file.is_file():
64
+ raise PumInvalidChangelog(f"Changelog file `{file}` does not exist.")
65
+ if not file.suffix == ".sql":
66
+ raise PumInvalidChangelog(f"Changelog file `{file}` is not a SQL file.")
67
+ try:
68
+ SqlContent(file).validate(parameters=parameters)
69
+ except PumSqlError as e:
70
+ raise PumInvalidChangelog(
71
+ f"Changelog file `{file}` is not a valid SQL file."
72
+ ) from e
73
+
74
+ if not self.version:
75
+ raise PumInvalidChangelog(
76
+ f"Changelog directory `{self.dir}` does not have a valid version."
77
+ )
78
+ return True
79
+
80
+ def apply(
81
+ self,
82
+ connection: psycopg.Connection,
83
+ parameters: dict | None = None,
84
+ commit: bool = True,
85
+ ) -> list[Path]:
86
+ """Apply a changelog
87
+ This will execute all the files in the changelog directory.
88
+ The changelog directory is the one that contains the delta files.
89
+
90
+ Args:
91
+ connection: Connection
92
+ The connection to the database
93
+ parameters: dict
94
+ The parameters to pass to the SQL files
95
+ commit: bool
96
+ If true, the transaction is committed. The default is true.
97
+
98
+ Returns:
99
+ list[Path]
100
+ The list of changelogs that were executed
101
+
102
+ """
103
+ files = self.files()
104
+ for file in files:
105
+ try:
106
+ SqlContent(file).execute(
107
+ connection=connection, commit=commit, parameters=parameters
108
+ )
109
+ except PumSqlError as e:
110
+ raise PumSqlError(f"Error applying changelog {file}: {e}") from e
111
+ return files
pum/checker.py ADDED
@@ -0,0 +1,431 @@
1
+ import difflib
2
+
3
+ import psycopg
4
+
5
+
6
+ class Checker:
7
+ """This class is used to compare 2 Postgres databases and show the
8
+ differences.
9
+ """
10
+
11
+ def __init__(
12
+ self,
13
+ pg_service1,
14
+ pg_service2,
15
+ exclude_schema=None,
16
+ exclude_field_pattern=None,
17
+ ignore_list=None,
18
+ verbose_level=1,
19
+ ):
20
+ """Constructor
21
+
22
+ Parameters
23
+ ----------
24
+ pg_service1: str
25
+ The name of the postgres service (defined in pg_service.conf)
26
+ related to the first db to be compared
27
+ pg_service2: str
28
+ The name of the postgres service (defined in pg_service.conf)
29
+ related to the first db to be compared
30
+ ignore_list: list(str)
31
+ List of elements to be ignored in check (ex. tables, columns,
32
+ views, ...)
33
+ exclude_schema: list of strings
34
+ List of schemas to be ignored in check.
35
+ exclude_field_pattern: list of strings
36
+ List of field patterns to be ignored in check.
37
+ verbose_level: int
38
+ verbose level, 0 -> nothing, 1 -> print first 80 char of each
39
+ difference, 2 -> print all the difference details
40
+
41
+ """
42
+ self.conn1 = psycopg.connect(f"service={pg_service1}")
43
+ self.cur1 = self.conn1.cursor()
44
+
45
+ self.conn2 = psycopg.connect(f"service={pg_service2}")
46
+ self.cur2 = self.conn2.cursor()
47
+
48
+ self.ignore_list = ignore_list
49
+ self.exclude_schema = "('information_schema'"
50
+ if exclude_schema is not None:
51
+ for schema in exclude_schema:
52
+ self.exclude_schema += f", '{schema}'"
53
+ self.exclude_schema += ")"
54
+ self.exclude_field_pattern = exclude_field_pattern or []
55
+
56
+ self.verbose_level = verbose_level
57
+
58
+ def run_checks(self):
59
+ """Run all the checks functions.
60
+
61
+ Returns
62
+ -------
63
+ bool
64
+ True if all the checks are true
65
+ False otherwise
66
+ dict
67
+ Dictionary of lists of differences
68
+
69
+ """
70
+ result = True
71
+ differences_dict = {}
72
+
73
+ if "tables" not in self.ignore_list:
74
+ tmp_result, differences_dict["tables"] = self.check_tables()
75
+ result = False if not tmp_result else result
76
+ if "columns" not in self.ignore_list:
77
+ tmp_result, differences_dict["columns"] = self.check_columns(
78
+ "views" not in self.ignore_list
79
+ )
80
+ result = False if not tmp_result else result
81
+ if "constraints" not in self.ignore_list:
82
+ tmp_result, differences_dict["constraints"] = self.check_constraints()
83
+ result = False if not tmp_result else result
84
+ if "views" not in self.ignore_list:
85
+ tmp_result, differences_dict["views"] = self.check_views()
86
+ result = False if not tmp_result else result
87
+ if "sequences" not in self.ignore_list:
88
+ tmp_result, differences_dict["sequences"] = self.check_sequences()
89
+ result = False if not tmp_result else result
90
+ if "indexes" not in self.ignore_list:
91
+ tmp_result, differences_dict["indexes"] = self.check_indexes()
92
+ result = False if not tmp_result else result
93
+ if "triggers" not in self.ignore_list:
94
+ tmp_result, differences_dict["triggers"] = self.check_triggers()
95
+ result = False if not tmp_result else result
96
+ if "functions" not in self.ignore_list:
97
+ tmp_result, differences_dict["functions"] = self.check_functions()
98
+ result = False if not tmp_result else result
99
+ if "rules" not in self.ignore_list:
100
+ tmp_result, differences_dict["rules"] = self.check_rules()
101
+ result = False if not tmp_result else result
102
+ if self.verbose_level == 0:
103
+ differences_dict = None
104
+ return result, differences_dict
105
+
106
+ def check_tables(self):
107
+ """Check if the tables are equals.
108
+
109
+ Returns
110
+ -------
111
+ bool
112
+ True if the tables are the same
113
+ False otherwise
114
+ list
115
+ A list with the differences
116
+
117
+ """
118
+ query = rf"""SELECT table_schema, table_name
119
+ FROM information_schema.tables
120
+ WHERE table_schema NOT IN {self.exclude_schema}
121
+ AND table_schema NOT LIKE 'pg\_%'
122
+ AND table_type NOT LIKE 'VIEW'
123
+ ORDER BY table_schema, table_name
124
+ """
125
+
126
+ return self.__check_equals(query)
127
+
128
+ def check_columns(self, check_views=True):
129
+ """Check if the columns in all tables are equals.
130
+
131
+ Parameters
132
+ ----------
133
+ check_views: bool
134
+ if True, check the columns of all the tables and views, if
135
+ False check only the columns of the tables
136
+
137
+ Returns
138
+ -------
139
+ bool
140
+ True if the columns are the same
141
+ False otherwise
142
+ list
143
+ A list with the differences
144
+
145
+ """
146
+ with_query = None
147
+ if check_views:
148
+ with_query = rf"""WITH table_list AS (
149
+ SELECT table_schema, table_name
150
+ FROM information_schema.tables
151
+ WHERE table_schema NOT IN {self.exclude_schema}
152
+ AND table_schema NOT LIKE 'pg\_%'
153
+ ORDER BY table_schema,table_name
154
+ )"""
155
+
156
+ else:
157
+ with_query = rf"""WITH table_list AS (
158
+ SELECT table_schema, table_name
159
+ FROM information_schema.tables
160
+ WHERE table_schema NOT IN {self.exclude_schema}
161
+ AND table_schema NOT LIKE 'pg\_%'
162
+ AND table_type NOT LIKE 'VIEW'
163
+ ORDER BY table_schema,table_name
164
+ )"""
165
+
166
+ query = """{wq}
167
+ SELECT isc.table_schema, isc.table_name, column_name,
168
+ column_default, is_nullable, data_type,
169
+ character_maximum_length::text, numeric_precision::text,
170
+ numeric_precision_radix::text, datetime_precision::text
171
+ FROM information_schema.columns isc,
172
+ table_list tl
173
+ WHERE isc.table_schema = tl.table_schema
174
+ AND isc.table_name = tl.table_name
175
+ {efp}
176
+ ORDER BY isc.table_schema, isc.table_name, column_name
177
+ """.format(
178
+ wq=with_query,
179
+ efp="".join(
180
+ [f" AND column_name NOT LIKE '{pattern}'" for pattern in self.exclude_field_pattern]
181
+ ),
182
+ )
183
+
184
+ return self.__check_equals(query)
185
+
186
+ def check_constraints(self):
187
+ """Check if the constraints are equals.
188
+
189
+ Returns
190
+ -------
191
+ bool
192
+ True if the constraints are the same
193
+ False otherwise
194
+ list
195
+ A list with the differences
196
+
197
+ """
198
+ query = f""" select
199
+ tc.constraint_name,
200
+ tc.constraint_schema || '.' || tc.table_name || '.' ||
201
+ kcu.column_name as physical_full_name,
202
+ tc.constraint_schema,
203
+ tc.table_name,
204
+ kcu.column_name,
205
+ ccu.table_name as foreign_table_name,
206
+ ccu.column_name as foreign_column_name,
207
+ tc.constraint_type
208
+ from information_schema.table_constraints as tc
209
+ join information_schema.key_column_usage as kcu on
210
+ (tc.constraint_name = kcu.constraint_name and
211
+ tc.table_name = kcu.table_name)
212
+ join information_schema.constraint_column_usage as ccu on
213
+ ccu.constraint_name = tc.constraint_name
214
+ WHERE tc.constraint_schema NOT IN {self.exclude_schema}
215
+ ORDER BY tc.constraint_schema, physical_full_name,
216
+ tc.constraint_name, foreign_table_name,
217
+ foreign_column_name """
218
+
219
+ return self.__check_equals(query)
220
+
221
+ def check_views(self):
222
+ """Check if the views are equals.
223
+
224
+ Returns
225
+ -------
226
+ bool
227
+ True if the views are the same
228
+ False otherwise
229
+ list
230
+ A list with the differences
231
+
232
+ """
233
+ query = rf"""
234
+ SELECT table_name, REPLACE(view_definition,'"','')
235
+ FROM INFORMATION_SCHEMA.views
236
+ WHERE table_schema NOT IN {self.exclude_schema}
237
+ AND table_schema NOT LIKE 'pg\_%'
238
+ AND table_name not like 'vw_export_%'
239
+ ORDER BY table_schema, table_name
240
+ """
241
+
242
+ return self.__check_equals(query)
243
+
244
+ def check_sequences(self):
245
+ """Check if the sequences are equals.
246
+
247
+ Returns
248
+ -------
249
+ bool
250
+ True if the sequences are the same
251
+ False otherwise
252
+ list
253
+ A list with the differences
254
+
255
+ """
256
+ query = f"""
257
+ SELECT c.relname,
258
+ ns.nspname as schema_name
259
+ FROM pg_class c
260
+ JOIN pg_namespace ns ON c.relnamespace = ns.oid
261
+ WHERE c.relkind = 'S'
262
+ AND ns.nspname NOT IN {self.exclude_schema}
263
+ ORDER BY c.relname"""
264
+
265
+ return self.__check_equals(query)
266
+
267
+ def check_indexes(self):
268
+ """Check if the indexes are equals.
269
+
270
+ Returns
271
+ -------
272
+ bool
273
+ True if the indexes are the same
274
+ False otherwise
275
+ list
276
+ A list with the differences
277
+
278
+ """
279
+ query = rf"""
280
+ select
281
+ t.relname as table_name,
282
+ i.relname as index_name,
283
+ a.attname as column_name,
284
+ ns.nspname as schema_name
285
+ from
286
+ pg_class t,
287
+ pg_class i,
288
+ pg_index ix,
289
+ pg_attribute a,
290
+ pg_namespace ns
291
+ where
292
+ t.oid = ix.indrelid
293
+ and i.oid = ix.indexrelid
294
+ and a.attrelid = t.oid
295
+ and t.relnamespace = ns.oid
296
+ and a.attnum = ANY(ix.indkey)
297
+ and t.relkind = 'r'
298
+ AND t.relname NOT IN ('information_schema')
299
+ AND t.relname NOT LIKE 'pg\_%'
300
+ AND ns.nspname NOT IN {self.exclude_schema}
301
+ order by
302
+ t.relname,
303
+ i.relname,
304
+ a.attname
305
+ """
306
+ return self.__check_equals(query)
307
+
308
+ def check_triggers(self) -> dict:
309
+ """Check if the triggers are equals.
310
+
311
+ Returns
312
+ -------
313
+ bool
314
+ True if the triggers are the same
315
+ False otherwise
316
+ list
317
+ A list with the differences
318
+
319
+ """
320
+ query = f"""
321
+ WITH trigger_list AS (
322
+ select tgname, tgisinternal from pg_trigger
323
+ GROUP BY tgname, tgisinternal
324
+ )
325
+ select ns.nspname as schema_name, p.relname, t.tgname, pp.prosrc
326
+ from pg_trigger t, pg_proc pp, trigger_list tl, pg_class p, pg_namespace ns
327
+ where pp.oid = t.tgfoid
328
+ and t.tgname = tl.tgname
329
+ AND t.tgrelid = p.oid
330
+ AND p.relnamespace = ns.oid
331
+ AND NOT tl.tgisinternal
332
+ AND ns.nspname NOT IN {self.exclude_schema}
333
+ ORDER BY p.relname, t.tgname, pp.prosrc"""
334
+
335
+ return self.__check_equals(query)
336
+
337
+ def check_functions(self):
338
+ """Check if the functions are equals.
339
+
340
+ Returns
341
+ -------
342
+ bool
343
+ True if the functions are the same
344
+ False otherwise
345
+ list
346
+ A list with the differences
347
+
348
+ """
349
+ query = rf"""
350
+ SELECT routines.routine_schema, routines.routine_name, parameters.data_type,
351
+ routines.routine_definition
352
+ FROM information_schema.routines
353
+ LEFT JOIN information_schema.parameters
354
+ ON routines.specific_name=parameters.specific_name
355
+ WHERE routines.specific_schema NOT IN {self.exclude_schema}
356
+ AND routines.specific_schema NOT LIKE 'pg\_%'
357
+ AND routines.specific_schema <> 'information_schema'
358
+ ORDER BY routines.routine_name, parameters.data_type,
359
+ routines.routine_definition, parameters.ordinal_position
360
+ """
361
+
362
+ return self.__check_equals(query)
363
+
364
+ def check_rules(self):
365
+ """Check if the rules are equals.
366
+
367
+ Returns
368
+ -------
369
+ bool
370
+ True if the rules are the same
371
+ False otherwise
372
+ list
373
+ A list with the differences
374
+
375
+ """
376
+ query = rf"""
377
+ select n.nspname as rule_schema,
378
+ c.relname as rule_table,
379
+ r.rulename as rule_name,
380
+ case r.ev_type
381
+ when '1' then 'SELECT'
382
+ when '2' then 'UPDATE'
383
+ when '3' then 'INSERT'
384
+ when '4' then 'DELETE'
385
+ else 'UNKNOWN'
386
+ end as rule_event
387
+ from pg_rewrite r
388
+ join pg_class c on r.ev_class = c.oid
389
+ left join pg_namespace n on n.oid = c.relnamespace
390
+ left join pg_description d on r.oid = d.objoid
391
+ WHERE n.nspname NOT IN {self.exclude_schema}
392
+ AND n.nspname NOT LIKE 'pg\_%'
393
+ ORDER BY n.nspname, c.relname, r.rulename, rule_event
394
+ """
395
+
396
+ return self.__check_equals(query)
397
+
398
+ def __check_equals(self, query):
399
+ """Check if the query results on the two databases are equals.
400
+
401
+ Returns
402
+ -------
403
+ bool
404
+ True if the results are the same
405
+ False otherwise
406
+ list
407
+ A list with the differences
408
+
409
+ """
410
+ self.cur1.execute(query)
411
+ records1 = self.cur1.fetchall()
412
+
413
+ self.cur2.execute(query)
414
+ records2 = self.cur2.fetchall()
415
+
416
+ result = True
417
+ differences = []
418
+
419
+ d = difflib.Differ()
420
+ records1 = [str(x) for x in records1]
421
+ records2 = [str(x) for x in records2]
422
+
423
+ for line in d.compare(records1, records2):
424
+ if line[0] in ("-", "+"):
425
+ result = False
426
+ if self.verbose_level == 1:
427
+ differences.append(line[0:79])
428
+ elif self.verbose_level == 2:
429
+ differences.append(line)
430
+
431
+ return result, differences