Skip to content

Commit 35f4f83

Browse files
authored
PGExtension (olirice#53)
* PGExtension entity with schema alter conflict issue * PGExtension create/drop * PGExtension docs
1 parent e6befae commit 35f4f83

File tree

5 files changed

+225
-1
lines changed

5 files changed

+225
-1
lines changed

docs/api.md

+13
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,19 @@ trigger = PGTrigger(
6363
)
6464
```
6565

66+
::: alembic_utils.pg_extension.PGExtension
67+
:docstring:
68+
69+
70+
```python
71+
from alembic_utils.pg_extension import PGExtension
72+
73+
extension = PGExtension(
74+
schema="public",
75+
signature="uuid-ossp",
76+
)
77+
```
78+
6679

6780
::: alembic_utils.pg_policy.PGPolicy
6881
:docstring:

mkdocs.yml

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ extra_css:
99

1010
repo_name: olirice/alembic_utils
1111
repo_url: https://github.com/olirice/alembic_utils
12+
site_url: https://olirice.github.io/alembic_utils
1213

1314
nav:
1415
- Introduction: 'index.md'

src/alembic_utils/pg_extension.py

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# pylint: disable=unused-argument,invalid-name,line-too-long
2+
3+
4+
from sqlalchemy import text as sql_text
5+
from sqlalchemy.sql.elements import TextClause
6+
7+
from alembic_utils.replaceable_entity import ReplaceableEntity
8+
from alembic_utils.statement import (
9+
coerce_to_quoted,
10+
coerce_to_unquoted,
11+
escape_colon_for_sql,
12+
normalize_whitespace,
13+
strip_terminating_semicolon,
14+
)
15+
16+
17+
class PGExtension(ReplaceableEntity):
18+
"""A PostgreSQL Extension compatible with `alembic revision --autogenerate`
19+
20+
**Parameters:**
21+
22+
* **schema** - *str*: A SQL schema name
23+
* **signature** - *str*: A PostgreSQL extension's name
24+
"""
25+
26+
type_ = "extension"
27+
28+
def __init__(self, schema: str, signature: str):
29+
self.schema: str = coerce_to_unquoted(normalize_whitespace(schema))
30+
self.signature: str = coerce_to_unquoted(normalize_whitespace(signature))
31+
# Include schema in definition since extensions can only exist once per
32+
# database and we want to detect schema changes and emit alter schema
33+
self.definition: str = f"{self.__class__.__name__}: {self.schema} {self.signature}"
34+
35+
def to_sql_statement_create(self) -> TextClause:
36+
"""Generates a SQL "create extension" statement"""
37+
return sql_text(f'CREATE EXTENSION "{self.signature}" WITH SCHEMA {self.literal_schema};')
38+
39+
def to_sql_statement_drop(self, cascade=False) -> TextClause:
40+
"""Generates a SQL "drop extension" statement"""
41+
cascade = "CASCADE" if cascade else ""
42+
return sql_text(f'DROP EXTENSION "{self.signature}" {cascade}')
43+
44+
def to_sql_statement_create_or_replace(self) -> TextClause:
45+
"""Generates SQL equivalent to "create or replace" statement"""
46+
raise NotImplementedError()
47+
48+
@property
49+
def identity(self) -> str:
50+
"""A string that consistently and globally identifies an extension"""
51+
# Extensions may only be installed once per db, schema is not a
52+
# component of identity
53+
return f"{self.__class__.__name__}: {self.signature}"
54+
55+
def render_self_for_migration(self, omit_definition=False) -> str:
56+
"""Render a string that is valid python code to reconstruct self in a migration"""
57+
var_name = self.to_variable_name()
58+
class_name = self.__class__.__name__
59+
60+
return f"""{var_name} = {class_name}(
61+
schema="{self.schema}",
62+
signature="{self.signature}"
63+
)\n"""
64+
65+
@classmethod
66+
def from_database(cls, sess, schema):
67+
"""Get a list of all extensions defined in the db"""
68+
sql = sql_text(
69+
f"""
70+
select
71+
np.nspname schema_name,
72+
ext.extname extension_name
73+
from
74+
pg_extension ext
75+
join pg_namespace np
76+
on ext.extnamespace = np.oid
77+
where
78+
np.nspname not in ('pg_catalog')
79+
and np.nspname like :schema;
80+
"""
81+
)
82+
rows = sess.execute(sql, {"schema": schema}).fetchall()
83+
db_exts = [PGExtension(x[0], x[1]) for x in rows]
84+
return db_exts

src/alembic_utils/replaceable_entity.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ def identity(self) -> str:
143143
def to_variable_name(self) -> str:
144144
"""A deterministic variable name based on PGFunction's contents """
145145
schema_name = self.schema.lower()
146-
object_name = self.signature.split("(")[0].strip().lower()
146+
object_name = self.signature.split("(")[0].strip().lower().replace("-", "_")
147147
return f"{schema_name}_{object_name}"
148148

149149
def get_required_migration_op(

src/test/test_pg_extension.py

+126
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import pytest
2+
3+
from alembic_utils.pg_extension import PGExtension
4+
from alembic_utils.replaceable_entity import register_entities
5+
from alembic_utils.testbase import TEST_VERSIONS_ROOT, run_alembic_command
6+
7+
TEST_EXT = PGExtension(schema="public", signature="uuid-ossp")
8+
9+
10+
def test_create_revision(engine) -> None:
11+
register_entities([TEST_EXT], entity_types=[PGExtension])
12+
13+
output = run_alembic_command(
14+
engine=engine,
15+
command="revision",
16+
command_kwargs={"autogenerate": True, "rev_id": "1", "message": "create"},
17+
)
18+
19+
migration_create_path = TEST_VERSIONS_ROOT / "1_create.py"
20+
21+
with migration_create_path.open() as migration_file:
22+
migration_contents = migration_file.read()
23+
24+
assert "op.create_entity" in migration_contents
25+
assert "op.drop_entity" in migration_contents
26+
assert "op.replace_entity" not in migration_contents
27+
assert "from alembic_utils.pg_extension import PGExtension" in migration_contents
28+
29+
# Execute upgrade
30+
run_alembic_command(engine=engine, command="upgrade", command_kwargs={"revision": "head"})
31+
# Execute Downgrade
32+
run_alembic_command(engine=engine, command="downgrade", command_kwargs={"revision": "base"})
33+
34+
35+
def test_create_or_replace_raises():
36+
with pytest.raises(NotImplementedError):
37+
TEST_EXT.to_sql_statement_create_or_replace()
38+
39+
40+
def test_update_is_unreachable(engine) -> None:
41+
# Updates are not possible. The only parameter that may change is
42+
# schema, and that will result in a drop and create due to schema
43+
# scoping assumptions made for all other entities
44+
45+
# Create the view outside of a revision
46+
engine.execute(TEST_EXT.to_sql_statement_create())
47+
48+
UPDATED_TEST_EXT = PGExtension("DEV", TEST_EXT.signature)
49+
50+
register_entities([UPDATED_TEST_EXT], schemas=["public", "DEV"], entity_types=[PGExtension])
51+
52+
# Autogenerate a new migration
53+
# It should detect the change we made and produce a "replace_function" statement
54+
output = run_alembic_command(
55+
engine=engine,
56+
command="revision",
57+
command_kwargs={"autogenerate": True, "rev_id": "2", "message": "replace"},
58+
)
59+
60+
migration_replace_path = TEST_VERSIONS_ROOT / "2_replace.py"
61+
62+
with migration_replace_path.open() as migration_file:
63+
migration_contents = migration_file.read()
64+
65+
assert "op.replace_entity" not in migration_contents
66+
assert "from alembic_utils.pg_extension import PGExtension" in migration_contents
67+
68+
69+
def test_noop_revision(engine) -> None:
70+
# Create the view outside of a revision
71+
engine.execute(TEST_EXT.to_sql_statement_create())
72+
73+
register_entities([TEST_EXT], entity_types=[PGExtension])
74+
75+
# Create a third migration without making changes.
76+
# This should result in no create, drop or replace statements
77+
run_alembic_command(engine=engine, command="upgrade", command_kwargs={"revision": "head"})
78+
79+
output = run_alembic_command(
80+
engine=engine,
81+
command="revision",
82+
command_kwargs={"autogenerate": True, "rev_id": "3", "message": "do_nothing"},
83+
)
84+
migration_do_nothing_path = TEST_VERSIONS_ROOT / "3_do_nothing.py"
85+
86+
with migration_do_nothing_path.open() as migration_file:
87+
migration_contents = migration_file.read()
88+
89+
assert "op.create_entity" not in migration_contents
90+
assert "op.drop_entity" not in migration_contents
91+
assert "op.replace_entity" not in migration_contents
92+
assert "from alembic_utils" not in migration_contents
93+
94+
# Execute upgrade
95+
run_alembic_command(engine=engine, command="upgrade", command_kwargs={"revision": "head"})
96+
# Execute Downgrade
97+
run_alembic_command(engine=engine, command="downgrade", command_kwargs={"revision": "base"})
98+
99+
100+
def test_drop_revision(engine) -> None:
101+
# Register no functions locally
102+
register_entities([], entity_types=[PGExtension])
103+
104+
# Manually create a SQL function
105+
engine.execute(TEST_EXT.to_sql_statement_create())
106+
107+
output = run_alembic_command(
108+
engine=engine,
109+
command="revision",
110+
command_kwargs={"autogenerate": True, "rev_id": "1", "message": "drop"},
111+
)
112+
113+
migration_create_path = TEST_VERSIONS_ROOT / "1_drop.py"
114+
115+
with migration_create_path.open() as migration_file:
116+
migration_contents = migration_file.read()
117+
118+
assert "op.drop_entity" in migration_contents
119+
assert "op.create_entity" in migration_contents
120+
assert "from alembic_utils" in migration_contents
121+
assert migration_contents.index("op.drop_entity") < migration_contents.index("op.create_entity")
122+
123+
# Execute upgrade
124+
run_alembic_command(engine=engine, command="upgrade", command_kwargs={"revision": "head"})
125+
# Execute Downgrade
126+
run_alembic_command(engine=engine, command="downgrade", command_kwargs={"revision": "base"})

0 commit comments

Comments
 (0)