alembic-utils-extended 0.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.
File without changes
@@ -0,0 +1,145 @@
1
+ import logging
2
+ from contextlib import contextmanager
3
+ from typing import Generator, List
4
+
5
+ from sqlalchemy import exc as sqla_exc
6
+ from sqlalchemy.orm import Session
7
+
8
+ from alembic_utils_extended.simulate import simulate_entity
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def solve_resolution_order(sess: Session, entities):
14
+ """Solve for an entity resolution order that increases the probability that
15
+ a migration will suceed if, for example, two new views are created and one
16
+ refers to the other
17
+
18
+ This strategy will only solve for simple cases
19
+ """
20
+
21
+ resolved = []
22
+
23
+ # Resolve the entities with 0 dependencies first (faster)
24
+ logger.info("Resolving entities with no dependencies")
25
+ for entity in entities:
26
+ try:
27
+ with simulate_entity(sess, entity):
28
+ resolved.append(entity)
29
+ except (sqla_exc.ProgrammingError, sqla_exc.InternalError) as exc:
30
+ continue
31
+
32
+ # Resolve entities with possible dependencies
33
+ logger.info("Resolving entities with dependencies. This may take a minute")
34
+ for _ in range(len(entities)):
35
+ n_resolved = len(resolved)
36
+
37
+ for entity in entities:
38
+ if entity in resolved:
39
+ continue
40
+
41
+ try:
42
+ with simulate_entity(sess, entity, dependencies=resolved):
43
+ resolved.append(entity)
44
+ except (sqla_exc.ProgrammingError, sqla_exc.InternalError):
45
+ continue
46
+
47
+ if len(resolved) == n_resolved:
48
+ # No new entities resolved in the last iteration. Exit
49
+ break
50
+
51
+ for entity in entities:
52
+ if entity not in resolved:
53
+ resolved.append(entity)
54
+
55
+ return resolved
56
+
57
+
58
+ @contextmanager
59
+ def recreate_dropped(connection) -> Generator[Session, None, None]:
60
+ """Recreate any dropped all ReplaceableEntities that were dropped within block
61
+
62
+ This is useful for making cascading updates. For example, updating a table's column type when it has dependent views.
63
+
64
+ def upgrade() -> None:
65
+
66
+ my_view = PGView(...)
67
+
68
+ with recreate_dropped(op.get_bind()) as conn:
69
+
70
+ op.drop_entity(my_view)
71
+
72
+ # change an integer column to a bigint
73
+ op.alter_column(
74
+ table_name="account",
75
+ column_name="id",
76
+ schema="public"
77
+ type_=sa.BIGINT()
78
+ existing_type=sa.Integer(),
79
+ )
80
+ """
81
+ from alembic_utils_extended.pg_function import PGFunction
82
+ from alembic_utils_extended.pg_materialized_view import PGMaterializedView
83
+ from alembic_utils_extended.pg_trigger import PGTrigger
84
+ from alembic_utils_extended.pg_view import PGView
85
+ from alembic_utils_extended.replaceable_entity import ReplaceableEntity
86
+
87
+ # Do not include permissions here e.g. PGGrantTable. If columns granted to users are dropped, it will cause an error
88
+
89
+ def collect_all_db_entities(sess: Session) -> List[ReplaceableEntity]:
90
+ """Collect all entities from the database"""
91
+
92
+ return [
93
+ *PGFunction.from_database(sess, "%"),
94
+ *PGTrigger.from_database(sess, "%"),
95
+ *PGView.from_database(sess, "%"),
96
+ *PGMaterializedView.from_database(sess, "%"),
97
+ ]
98
+
99
+ sess = Session(bind=connection)
100
+
101
+ # All existing entities, before the upgrade
102
+ before = collect_all_db_entities(sess)
103
+
104
+ # In the yield, do a
105
+ # op.drop_entity(my_mat_view, cascade=True)
106
+ # op.create_entity(my_mat_view)
107
+ try:
108
+ yield sess
109
+ except:
110
+ sess.rollback()
111
+ raise
112
+
113
+ # All existing entities, after the upgrade
114
+ after = collect_all_db_entities(sess)
115
+ after_identities = {x.identity for x in after}
116
+
117
+ # Entities that were not impacted, or that we have "recovered"
118
+ resolved = []
119
+ unresolved = []
120
+
121
+ # First, ignore the ones that were not impacted by the upgrade
122
+ for ent in before:
123
+ if ent.identity in after_identities:
124
+ resolved.append(ent)
125
+ else:
126
+ unresolved.append(ent)
127
+
128
+ # Attempt to find an acceptable order of creation for the unresolved entities
129
+ ordered_unresolved = solve_resolution_order(sess, unresolved)
130
+
131
+ # Attempt to recreate the missing entities in the specified order
132
+ for ent in ordered_unresolved:
133
+ sess.execute(ent.to_sql_statement_create())
134
+
135
+ # Sanity check that everything is now fine
136
+ sanity_check = collect_all_db_entities(sess)
137
+ # Fail and rollback if the sanity check is wrong
138
+ try:
139
+ assert len(before) == len(sanity_check)
140
+ except:
141
+ sess.rollback()
142
+ raise
143
+
144
+ # Close out the session
145
+ sess.commit()
@@ -0,0 +1,18 @@
1
+ class AlembicUtilsException(Exception):
2
+ """Base exception for AlembicUtils package"""
3
+
4
+
5
+ class SQLParseFailure(AlembicUtilsException):
6
+ """An entity could not be parsed"""
7
+
8
+
9
+ class FailedToGenerateComparable(AlembicUtilsException):
10
+ """Failed to generate a comparable entity"""
11
+
12
+
13
+ class UnreachableException(AlembicUtilsException):
14
+ """An exception no one should ever see"""
15
+
16
+
17
+ class BadInputException(AlembicUtilsException):
18
+ """Invalid user input"""
@@ -0,0 +1,6 @@
1
+ from alembic_utils_extended.experimental._collect_instances import (
2
+ collect_instances,
3
+ collect_subclasses,
4
+ )
5
+
6
+ __all__ = ["collect_instances", "collect_subclasses"]
@@ -0,0 +1,90 @@
1
+ import importlib
2
+ import os
3
+ from pathlib import Path
4
+ from types import ModuleType
5
+ from typing import Generator, List, Type, TypeVar
6
+
7
+ from flupy import walk_files
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ def walk_modules(module: ModuleType) -> Generator[ModuleType, None, None]:
13
+ """Recursively yield python import paths to submodules in *module*
14
+
15
+ Example:
16
+ module_iter = walk_modules(alembic_utils_extended)
17
+
18
+ for module_path in module_iter:
19
+ print(module_path)
20
+
21
+ # alembic_utils_extended.exceptions
22
+ # alembic_utils_extended.on_entity_mixin
23
+ # ...
24
+ """
25
+ top_module = module
26
+ top_path = Path(top_module.__path__[0])
27
+ top_path_absolute = top_path.resolve()
28
+
29
+ directories = (
30
+ walk_files(str(top_path_absolute))
31
+ .filter(lambda x: x.endswith(".py"))
32
+ .map(Path)
33
+ .group_by(lambda x: x.parent)
34
+ .map(lambda x: (x[0], x[1].collect()))
35
+ )
36
+
37
+ for base_path, files in directories:
38
+ if str(base_path / "__init__.py") in [str(x) for x in files]:
39
+ for module_path in files:
40
+ if "__init__.py" not in str(module_path):
41
+
42
+ # Example: elt.settings
43
+ module_import_path = str(module_path)[
44
+ len(str(top_path_absolute)) - len(top_module.__name__) :
45
+ ].replace(os.path.sep, ".")[:-3]
46
+
47
+ module = importlib.import_module(module_import_path)
48
+ yield module
49
+
50
+
51
+ def collect_instances(module: ModuleType, class_: Type[T]) -> List[T]:
52
+ """Collect all instances of *class_* defined in *module*
53
+
54
+ Note: Will import all submodules in *module*. Beware of import side effects
55
+ """
56
+
57
+ found: List[T] = []
58
+
59
+ for module_ in walk_modules(module):
60
+
61
+ for _, variable in module_.__dict__.items():
62
+
63
+ if isinstance(variable, class_):
64
+ # Ensure variable is not a subclass
65
+ if variable.__class__ == class_:
66
+ found.append(variable)
67
+ return found
68
+
69
+
70
+ def collect_subclasses(module: ModuleType, class_: Type[T]) -> List[Type[T]]:
71
+ """Collect all subclasses of *class_* currently imported or defined in *module*
72
+
73
+ Note: Will import all submodules in *module*. Beware of import side effects
74
+ """
75
+
76
+ found: List[Type[T]] = []
77
+
78
+ for module_ in walk_modules(module):
79
+
80
+ for _, variable in module_.__dict__.items():
81
+ try:
82
+ if issubclass(variable, class_) and not class_ == variable:
83
+ found.append(variable)
84
+ except TypeError:
85
+ # argument 2 to issubclass must be a class ....
86
+ pass
87
+
88
+ imported: List[Type[T]] = list(class_.__subclasses__()) # type: ignore
89
+
90
+ return list(set(found + imported))
@@ -0,0 +1,51 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from alembic_utils_extended.statement import coerce_to_unquoted
4
+
5
+ if TYPE_CHECKING:
6
+ from alembic_utils_extended.replaceable_entity import ReplaceableEntity
7
+
8
+ _Base = ReplaceableEntity
9
+ else:
10
+ _Base = object
11
+
12
+
13
+ class OnEntityMixin(_Base):
14
+ """Mixin to ReplaceableEntity providing setup for entity types requiring an "ON" clause"""
15
+
16
+ def __init__(self, schema: str, signature: str, definition: str, on_entity: str):
17
+ super().__init__(schema=schema, signature=signature, definition=definition)
18
+
19
+ if "." not in on_entity:
20
+ on_entity = "public." + on_entity
21
+
22
+ # Guarenteed to have a schema
23
+ self.on_entity = coerce_to_unquoted(on_entity)
24
+
25
+ @property
26
+ def identity(self) -> str:
27
+ """A string that consistently and globally identifies a function
28
+
29
+ Overriding default to add the "on table" clause
30
+ """
31
+ return f"{self.__class__.__name__}: {self.schema}.{self.signature} {self.on_entity}"
32
+
33
+ def render_self_for_migration(self, omit_definition=False) -> str:
34
+ """Render a string that is valid python code to reconstruct self in a migration"""
35
+ var_name = self.to_variable_name()
36
+ class_name = self.__class__.__name__
37
+ escaped_definition = self.definition if not omit_definition else "# not required for op"
38
+
39
+ return f"""{var_name} = {class_name}(
40
+ schema="{self.schema}",
41
+ signature="{self.signature}",
42
+ on_entity="{self.on_entity}",
43
+ definition={repr(escaped_definition)}
44
+ )\n"""
45
+
46
+ def to_variable_name(self) -> str:
47
+ """A deterministic variable name based on PGFunction's contents"""
48
+ schema_name = self.schema.lower()
49
+ object_name = self.signature.split("(")[0].strip().lower()
50
+ _, _, unqualified_entity_name = self.on_entity.lower().partition(".")
51
+ return f"{schema_name}_{unqualified_entity_name}_{object_name}"
@@ -0,0 +1,83 @@
1
+ # pylint: disable=unused-argument,invalid-name,line-too-long
2
+
3
+
4
+ from typing import Generator
5
+
6
+ from sqlalchemy import text as sql_text
7
+ from sqlalchemy.sql.elements import TextClause
8
+
9
+ from alembic_utils_extended.replaceable_entity import ReplaceableEntity
10
+ from alembic_utils_extended.statement import (
11
+ coerce_to_unquoted,
12
+ normalize_whitespace,
13
+ )
14
+
15
+
16
+ class PGExtension(ReplaceableEntity):
17
+ """A PostgreSQL Extension compatible with `alembic revision --autogenerate`
18
+
19
+ **Parameters:**
20
+
21
+ * **schema** - *str*: A SQL schema name
22
+ * **signature** - *str*: A PostgreSQL extension's name
23
+ """
24
+
25
+ type_ = "extension"
26
+
27
+ def __init__(self, schema: str, signature: str):
28
+ self.schema: str = coerce_to_unquoted(normalize_whitespace(schema))
29
+ self.signature: str = coerce_to_unquoted(normalize_whitespace(signature))
30
+ # Include schema in definition since extensions can only exist once per
31
+ # database and we want to detect schema changes and emit alter schema
32
+ self.definition: str = f"{self.__class__.__name__}: {self.schema} {self.signature}"
33
+
34
+ def to_sql_statement_create(self) -> TextClause:
35
+ """Generates a SQL "create extension" statement"""
36
+ return sql_text(f'CREATE EXTENSION "{self.signature}" WITH SCHEMA {self.literal_schema};')
37
+
38
+ def to_sql_statement_drop(self, cascade=False) -> TextClause:
39
+ """Generates a SQL "drop extension" statement"""
40
+ cascade = "CASCADE" if cascade else ""
41
+ return sql_text(f'DROP EXTENSION "{self.signature}" {cascade}')
42
+
43
+ def to_sql_statement_create_or_replace(self) -> Generator[TextClause, None, None]:
44
+ """Generates SQL equivalent to "create or replace" statement"""
45
+ raise NotImplementedError()
46
+
47
+ @property
48
+ def identity(self) -> str:
49
+ """A string that consistently and globally identifies an extension"""
50
+ # Extensions may only be installed once per db, schema is not a
51
+ # component of identity
52
+ return f"{self.__class__.__name__}: {self.signature}"
53
+
54
+ def render_self_for_migration(self, omit_definition=False) -> str:
55
+ """Render a string that is valid python code to reconstruct self in a migration"""
56
+ var_name = self.to_variable_name()
57
+ class_name = self.__class__.__name__
58
+
59
+ return f"""{var_name} = {class_name}(
60
+ schema="{self.schema}",
61
+ signature="{self.signature}"
62
+ )\n"""
63
+
64
+ @classmethod
65
+ def from_database(cls, sess, schema):
66
+ """Get a list of all extensions defined in the db"""
67
+ sql = sql_text(
68
+ f"""
69
+ select
70
+ np.nspname schema_name,
71
+ ext.extname extension_name
72
+ from
73
+ pg_extension ext
74
+ join pg_namespace np
75
+ on ext.extnamespace = np.oid
76
+ where
77
+ np.nspname not in ('pg_catalog')
78
+ and np.nspname like :schema;
79
+ """
80
+ )
81
+ rows = sess.execute(sql, {"schema": schema}).fetchall()
82
+ db_exts = [cls(x[0], x[1]) for x in rows]
83
+ return db_exts
@@ -0,0 +1,169 @@
1
+ # pylint: disable=unused-argument,invalid-name,line-too-long
2
+ from typing import List
3
+
4
+ from parse import parse
5
+ from sqlalchemy import text as sql_text
6
+
7
+ from alembic_utils_extended.exceptions import SQLParseFailure
8
+ from alembic_utils_extended.replaceable_entity import ReplaceableEntity
9
+ from alembic_utils_extended.statement import (
10
+ escape_colon_for_plpgsql,
11
+ escape_colon_for_sql,
12
+ normalize_whitespace,
13
+ strip_terminating_semicolon,
14
+ )
15
+
16
+
17
+ class PGFunction(ReplaceableEntity):
18
+ """A PostgreSQL Function compatible with `alembic revision --autogenerate`
19
+
20
+ **Parameters:**
21
+
22
+ * **schema** - *str*: A SQL schema name
23
+ * **signature** - *str*: A SQL function's call signature
24
+ * **definition** - *str*: The remainig function body and identifiers
25
+ """
26
+
27
+ type_ = "function"
28
+
29
+ def __init__(self, schema: str, signature: str, definition: str):
30
+ super().__init__(schema, signature, definition)
31
+ # Detect if function uses plpgsql and update escaping rules to not escape ":="
32
+ is_plpgsql: bool = "language plpgsql" in normalize_whitespace(definition).lower().replace(
33
+ "'", ""
34
+ )
35
+ escaping_callable = escape_colon_for_plpgsql if is_plpgsql else escape_colon_for_sql
36
+ # Override definition with correct escaping rules
37
+ self.definition: str = escaping_callable(strip_terminating_semicolon(definition))
38
+
39
+ @classmethod
40
+ def from_sql(cls, sql: str) -> "PGFunction":
41
+ """Create an instance instance from a SQL string"""
42
+ template = "create{}function{:s}{schema}.{signature}{:s}returns{:s}{definition}"
43
+ result = parse(template, sql.strip(), case_sensitive=False)
44
+ if result is not None:
45
+ # remove possible quotes from signature
46
+ raw_signature = result["signature"]
47
+ signature = (
48
+ "".join(raw_signature.split('"', 2))
49
+ if raw_signature.startswith('"')
50
+ else raw_signature
51
+ )
52
+ return cls(
53
+ schema=result["schema"],
54
+ signature=signature,
55
+ definition="returns " + result["definition"],
56
+ )
57
+ raise SQLParseFailure(f'Failed to parse SQL into PGFunction """{sql}"""')
58
+
59
+ @property
60
+ def literal_signature(self) -> str:
61
+ """Adds quoting around the functions name when emitting SQL statements
62
+
63
+ e.g.
64
+ 'toUpper(text) returns text' -> '"toUpper"(text) returns text'
65
+ """
66
+ # May already be quoted if loading from database or SQL file
67
+ name, remainder = self.signature.split("(", 1)
68
+ return '"' + name.strip() + '"(' + remainder
69
+
70
+ def to_sql_statement_create(self):
71
+ """Generates a SQL "create function" statement for PGFunction"""
72
+ return sql_text(
73
+ f"CREATE FUNCTION {self.literal_schema}.{self.literal_signature} {self.definition}"
74
+ )
75
+
76
+ def to_sql_statement_drop(self, cascade=False):
77
+ """Generates a SQL "drop function" statement for PGFunction"""
78
+ cascade = "cascade" if cascade else ""
79
+ template = "{function_name}({parameters})"
80
+ result = parse(template, self.signature, case_sensitive=False)
81
+ try:
82
+ function_name = result["function_name"].strip()
83
+ parameters_str = result["parameters"].strip()
84
+ except TypeError:
85
+ # Did not match, NoneType is not scriptable
86
+ result = parse("{function_name}()", self.signature, case_sensitive=False)
87
+ function_name = result["function_name"].strip()
88
+ parameters_str = ""
89
+
90
+ # NOTE: Will fail if a text field has a default and that deafult contains a comma...
91
+ parameters: List[str] = parameters_str.split(",")
92
+ parameters = [x[: len(x.lower().split("default")[0])] for x in parameters]
93
+ parameters = [x.strip() for x in parameters]
94
+ drop_params = ", ".join(parameters)
95
+ return sql_text(
96
+ f'DROP FUNCTION {self.literal_schema}."{function_name}"({drop_params}) {cascade}'
97
+ )
98
+
99
+ def to_sql_statement_create_or_replace(self):
100
+ """Generates a SQL "create or replace function" statement for PGFunction"""
101
+ yield sql_text(
102
+ f"CREATE OR REPLACE FUNCTION {self.literal_schema}.{self.literal_signature} {self.definition}"
103
+ )
104
+
105
+ @classmethod
106
+ def from_database(cls, sess, schema):
107
+ """Get a list of all functions defined in the db"""
108
+
109
+ # Prior to postgres 11, pg_proc had different columns
110
+ # https://github.com/candidhealth/alembic-utils-extended/issues/12
111
+ PG_GTE_11 = """
112
+ and p.prokind = 'f'
113
+ """
114
+
115
+ PG_LT_11 = """
116
+ and not p.proisagg
117
+ and not p.proiswindow
118
+ """
119
+
120
+ # Retrieve the postgres server version e.g. 90603 for 9.6.3 or 120003 for 12.3
121
+ pg_version_str = sess.execute(sql_text("show server_version_num")).fetchone()[0]
122
+ pg_version = int(pg_version_str)
123
+
124
+ sql = sql_text(
125
+ f"""
126
+ with extension_functions as (
127
+ select
128
+ objid as extension_function_oid
129
+ from
130
+ pg_depend
131
+ where
132
+ -- depends on an extension
133
+ deptype='e'
134
+ -- is a proc/function
135
+ and classid = 'pg_proc'::regclass
136
+ )
137
+
138
+ select
139
+ n.nspname as function_schema,
140
+ p.proname as function_name,
141
+ pg_get_function_arguments(p.oid) as function_arguments,
142
+ case
143
+ when l.lanname = 'internal' then p.prosrc
144
+ else pg_get_functiondef(p.oid)
145
+ end as create_statement,
146
+ t.typname as return_type,
147
+ l.lanname as function_language
148
+ from
149
+ pg_proc p
150
+ left join pg_namespace n on p.pronamespace = n.oid
151
+ left join pg_language l on p.prolang = l.oid
152
+ left join pg_type t on t.oid = p.prorettype
153
+ left join extension_functions ef on p.oid = ef.extension_function_oid
154
+ where
155
+ n.nspname not in ('pg_catalog', 'information_schema')
156
+ -- Filter out functions from extensions
157
+ and ef.extension_function_oid is null
158
+ and n.nspname = :schema
159
+ """
160
+ + (PG_GTE_11 if pg_version >= 110000 else PG_LT_11)
161
+ )
162
+
163
+ rows = sess.execute(sql, {"schema": schema}).fetchall()
164
+ db_functions = [cls.from_sql(x[3]) for x in rows]
165
+
166
+ for func in db_functions:
167
+ assert func is not None
168
+
169
+ return db_functions