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.
- alembic_utils_extended/__init__.py +0 -0
- alembic_utils_extended/depends.py +145 -0
- alembic_utils_extended/exceptions.py +18 -0
- alembic_utils_extended/experimental/__init__.py +6 -0
- alembic_utils_extended/experimental/_collect_instances.py +90 -0
- alembic_utils_extended/on_entity_mixin.py +51 -0
- alembic_utils_extended/pg_extension.py +83 -0
- alembic_utils_extended/pg_function.py +169 -0
- alembic_utils_extended/pg_grant_table.py +232 -0
- alembic_utils_extended/pg_materialized_view.py +141 -0
- alembic_utils_extended/pg_policy.py +114 -0
- alembic_utils_extended/pg_trigger.py +169 -0
- alembic_utils_extended/pg_view.py +107 -0
- alembic_utils_extended/py.typed +0 -0
- alembic_utils_extended/replaceable_entity.py +438 -0
- alembic_utils_extended/reversible_op.py +172 -0
- alembic_utils_extended/simulate.py +67 -0
- alembic_utils_extended/statement.py +75 -0
- alembic_utils_extended/testbase.py +49 -0
- alembic_utils_extended-0.0.0.dist-info/LICENSE +21 -0
- alembic_utils_extended-0.0.0.dist-info/METADATA +165 -0
- alembic_utils_extended-0.0.0.dist-info/RECORD +23 -0
- alembic_utils_extended-0.0.0.dist-info/WHEEL +4 -0
|
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,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
|