Skip to content

Commit 74c54dd

Browse files
justanothercatgirlzzzeek
authored andcommitted
Add operation implementation replacement
Added :paramref:`.Operations.implementation_for.replace` parameter to :meth:`.Operations.implementation_for`, allowing replacement of existing operation implementations. This allows for existing operations such as :class:`.CreateTableOp` to be extended directly. Pull request courtesy justanothercatgirl. Fixes: #1750 Closes: #1751 Pull-request: #1751 Pull-request-sha: 5eef47a Change-Id: I69526c3111d41640264f226f6655dc61f83595e5
1 parent dcba644 commit 74c54dd

File tree

6 files changed

+213
-5
lines changed

6 files changed

+213
-5
lines changed

alembic/operations/base.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,19 +202,32 @@ def %(name)s%(args)s:
202202
return register
203203

204204
@classmethod
205-
def implementation_for(cls, op_cls: Any) -> Callable[[_C], _C]:
205+
def implementation_for(
206+
cls, op_cls: Any, replace: bool = False
207+
) -> Callable[[_C], _C]:
206208
"""Register an implementation for a given :class:`.MigrateOperation`.
207209
210+
:param replace: when True, allows replacement of an already
211+
registered implementation for the given operation class. This
212+
enables customization of built-in operations such as
213+
:class:`.CreateTableOp` by providing an alternate implementation
214+
that can augment, modify, or conditionally invoke the default
215+
behavior.
216+
217+
.. versionadded:: 1.17.2
218+
208219
This is part of the operation extensibility API.
209220
210221
.. seealso::
211222
212-
:ref:`operation_plugins` - example of use
223+
:ref:`operation_plugins`
224+
225+
:ref:`operations_extending_builtin`
213226
214227
"""
215228

216229
def decorate(fn: _C) -> _C:
217-
cls._to_impl.dispatch_for(op_cls)(fn)
230+
cls._to_impl.dispatch_for(op_cls, replace=replace)(fn)
218231
return fn
219232

220233
return decorate

alembic/testing/fixtures.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,23 @@ def connection(self):
9696

9797
_connection_fixture_connection = None
9898

99+
@testing.fixture
100+
def restore_operations(self):
101+
"""Restore runners for modified operations"""
102+
103+
saved_impls = None
104+
op_cls = None
105+
106+
def _save_attrs(_op_cls):
107+
nonlocal saved_impls, op_cls
108+
saved_impls = _op_cls._to_impl._registry.copy()
109+
op_cls = _op_cls
110+
111+
yield _save_attrs
112+
113+
if op_cls is not None and saved_impls is not None:
114+
op_cls._to_impl._registry = saved_impls
115+
99116
@config.fixture()
100117
def metadata(self, request):
101118
"""Provide bound MetaData for a single test, dropping afterwards."""

alembic/util/langhelpers.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,13 +270,18 @@ def __init__(self, uselist: bool = False) -> None:
270270
self.uselist = uselist
271271

272272
def dispatch_for(
273-
self, target: Any, qualifier: str = "default"
273+
self, target: Any, qualifier: str = "default", replace: bool = False
274274
) -> Callable[[_C], _C]:
275275
def decorate(fn: _C) -> _C:
276276
if self.uselist:
277277
self._registry.setdefault((target, qualifier), []).append(fn)
278278
else:
279-
assert (target, qualifier) not in self._registry
279+
if (target, qualifier) in self._registry and not replace:
280+
raise ValueError(
281+
"Can not set dispatch function for object "
282+
f"{target!r}: key already exists. To replace "
283+
"existing function, use replace=True."
284+
)
280285
self._registry[(target, qualifier)] = fn
281286
return fn
282287

docs/build/api/operations.rst

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,79 @@ The built-in operation objects are listed below.
163163

164164
.. automodule:: alembic.operations.ops
165165
:members:
166+
167+
.. _operations_extending_builtin:
168+
169+
Extending Existing Operations
170+
==============================
171+
172+
.. versionadded:: 1.17.2
173+
174+
The :paramref:`.Operations.implementation_for.replace` parameter allows
175+
replacement of existing operation implementations, including built-in
176+
operations such as :class:`.CreateTableOp`. This enables customization of
177+
migration execution for purposes such as logging operations, running
178+
integrity checks, conditionally canceling operations, or adapting
179+
operations with dialect-specific options.
180+
181+
The example below illustrates replacing the implementation of
182+
:class:`.CreateTableOp` to log each table creation to a separate metadata
183+
table::
184+
185+
from alembic import op
186+
from alembic.operations import Operations
187+
from alembic.operations.ops import CreateTableOp
188+
from alembic.operations.toimpl import create_table as _create_table
189+
from sqlalchemy import MetaData, Table, Column, String
190+
191+
# Define a metadata table to track table operations
192+
log_table = Table(
193+
"table_metadata_log",
194+
MetaData(),
195+
Column("operation", String),
196+
Column("table_name", String),
197+
)
198+
199+
@Operations.implementation_for(CreateTableOp, replace=True)
200+
def create_table_with_logging(operations, operation):
201+
# First, run the original CREATE TABLE implementation
202+
_create_table(operations, operation)
203+
204+
# Then, log the operation to the metadata table
205+
operations.execute(
206+
log_table.insert().values(
207+
operation="create",
208+
table_name=operation.table_name
209+
)
210+
)
211+
212+
The above code can be placed in the ``env.py`` file to ensure it is loaded
213+
before migrations run. Once registered, all ``op.create_table()`` calls
214+
within migration scripts will use the augmented implementation.
215+
216+
The original implementation is imported from :mod:`alembic.operations.toimpl`
217+
and invoked within the replacement implementation. The ``replace`` parameter
218+
also enables conditional execution or complete replacement of operation
219+
behavior. The example below demonstrates skipping a :class:`.CreateTableOp`
220+
based on custom logic::
221+
222+
from alembic.operations import Operations
223+
from alembic.operations.ops import CreateTableOp
224+
from alembic.operations.toimpl import create_table as _create_table
225+
226+
@Operations.implementation_for(CreateTableOp, replace=True)
227+
def create_table_conditional(operations, operation):
228+
# Check if the table should be created based on custom logic
229+
if should_create_table(operation.table_name):
230+
_create_table(operations, operation)
231+
else:
232+
# Skip creation and optionally log
233+
operations.execute(
234+
"-- Skipped creation of table %s" % operation.table_name
235+
)
236+
237+
def should_create_table(table_name):
238+
# Custom logic to determine if table should be created
239+
# For example, check a configuration or metadata table
240+
return table_name not in get_ignored_tables()
241+

docs/build/unreleased/1750.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
.. change::
2+
:tags: feature, operations
3+
:tickets: 1750
4+
5+
Added :paramref:`.Operations.implementation_for.replace` parameter to
6+
:meth:`.Operations.implementation_for`, allowing replacement of existing
7+
operation implementations. This allows for existing operations such as
8+
:class:`.CreateTableOp` to be extended directly. Pull request courtesy
9+
justanothercatgirl.
10+
11+
.. seealso::
12+
13+
:ref:`operations_extending_builtin`

tests/test_op.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
from alembic.operations import Operations
2929
from alembic.operations import ops
3030
from alembic.operations import schemaobj
31+
from alembic.operations.toimpl import create_table as _create_table
32+
from alembic.operations.toimpl import drop_table as _drop_table
3133
from alembic.testing import assert_raises_message
3234
from alembic.testing import combinations
3335
from alembic.testing import config
@@ -1362,6 +1364,7 @@ def test_create_table_literal_binds(self):
13621364

13631365
class CustomOpTest(TestBase):
13641366
def test_custom_op(self):
1367+
13651368
@Operations.register_operation("create_sequence")
13661369
class CreateSequenceOp(MigrateOperation):
13671370
"""Create a SEQUENCE."""
@@ -1385,6 +1388,87 @@ def create_sequence(operations, operation):
13851388
op.create_sequence("foob")
13861389
context.assert_("CREATE SEQUENCE foob")
13871390

1391+
def test_replace_op(self, restore_operations):
1392+
restore_operations(Operations)
1393+
context = op_fixture()
1394+
1395+
log_table = Table(
1396+
"log_table",
1397+
MetaData(),
1398+
Column("action", String),
1399+
Column("table_name", String),
1400+
)
1401+
1402+
@Operations.implementation_for(ops.CreateTableOp, replace=True)
1403+
def create_table_proxy_log(operations, operation):
1404+
_create_table(operations, operation)
1405+
operations.execute(
1406+
log_table.insert().values(["create", operation.table_name])
1407+
)
1408+
1409+
op.create_table("some_table", Column("colname", Integer))
1410+
1411+
@Operations.implementation_for(ops.CreateTableOp, replace=True)
1412+
def create_table_proxy_invert(operations, operation):
1413+
_drop_table(operations, ops.DropTableOp(operation.table_name))
1414+
operations.execute(
1415+
log_table.insert().values(["delete", operation.table_name])
1416+
)
1417+
1418+
op.create_table("some_table")
1419+
1420+
context.assert_(
1421+
"CREATE TABLE some_table (colname INTEGER)",
1422+
"INSERT INTO log_table (action, table_name) "
1423+
"VALUES (:action, :table_name)",
1424+
"DROP TABLE some_table",
1425+
"INSERT INTO log_table (action, table_name) "
1426+
"VALUES (:action, :table_name)",
1427+
)
1428+
1429+
def test_replace_error(self):
1430+
with expect_raises_message(
1431+
ValueError,
1432+
"Can not set dispatch function for object "
1433+
"<class 'alembic.operations.ops.CreateTableOp'>: "
1434+
"key already exists. To replace existing function, use "
1435+
"replace=True.",
1436+
):
1437+
1438+
@Operations.implementation_for(ops.CreateTableOp)
1439+
def create_table(operations, operation):
1440+
pass
1441+
1442+
def test_replace_custom_op(self, restore_operations):
1443+
restore_operations(Operations)
1444+
context = op_fixture()
1445+
1446+
@Operations.register_operation("create_user")
1447+
class CreateUserOp(MigrateOperation):
1448+
def __init__(self, user_name):
1449+
self.user_name = user_name
1450+
1451+
@classmethod
1452+
def create_user(cls, operations, user_name):
1453+
op = CreateUserOp(user_name)
1454+
return operations.invoke(op)
1455+
1456+
@Operations.implementation_for(CreateUserOp, replace=True)
1457+
def create_user(operations, operation):
1458+
operations.execute("CREATE USER %s" % operation.user_name)
1459+
1460+
op.create_user("bob")
1461+
1462+
@Operations.implementation_for(CreateUserOp, replace=True)
1463+
def create_user_alternative(operations, operation):
1464+
operations.execute(
1465+
"CREATE ROLE %s WITH LOGIN" % operation.user_name
1466+
)
1467+
1468+
op.create_user("bob")
1469+
1470+
context.assert_("CREATE USER bob", "CREATE ROLE bob WITH LOGIN")
1471+
13881472

13891473
class ObjectFromToTest(TestBase):
13901474
"""Test operation round trips for to_obj() / from_obj().

0 commit comments

Comments
 (0)