]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Add AttributeEvents.modified
authorMike Bayer <mike_mp@zzzcomputing.com>
Mon, 22 May 2017 19:42:59 +0000 (15:42 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Tue, 23 May 2017 19:14:04 +0000 (15:14 -0400)
Added new event handler :meth:`.AttributeEvents.modified` which is
triggered when the func:`.attributes.flag_modified` function is
invoked, which is common when using the :mod:`sqlalchemy.ext.mutable`
extension module.

Change-Id: Ic152f1d5c53087d780b24ed7f1f1571527b9e8fc
Fixes: #3303
doc/build/changelog/changelog_12.rst
doc/build/changelog/migration_12.rst
lib/sqlalchemy/ext/mutable.py
lib/sqlalchemy/orm/attributes.py
lib/sqlalchemy/orm/events.py
test/ext/test_mutable.py
test/orm/test_attributes.py

index 10219e2bf59e7a264febefa68f7a5aab251a1575..1797a14adf0f8a7a983a0fe7f638e323432e2736 100644 (file)
         assuming it would match the dialect-level paramstyle, causing
         mismatches to occur.
 
+    .. change:: 3303
+        :tags: feature, orm
+        :tickets: 3303
+
+        Added new event handler :meth:`.AttributeEvents.modified` which is
+        triggered when the func:`.attributes.flag_modified` function is
+        invoked, which is common when using the :mod:`sqlalchemy.ext.mutable`
+        extension module.
+
+        .. seealso::
+
+            :ref:`change_3303`
+
     .. change:: 3918
         :tags: bug, ext
         :tickets: 3918
index d321af871d80b516a6586189e9aaa4a0e3dcc337..bb863ccca1b062dce73a0dc1c89bd7f98ea7190e 100644 (file)
@@ -305,7 +305,35 @@ if this "append" event is the second part of a bulk replace::
 
 :ticket:`3896`
 
+.. _change_3303:
 
+New "modified" event handler for sqlalchemy.ext.mutable
+-------------------------------------------------------
+
+A new event handler :meth:`.AttributeEvents.modified` is added, which is
+triggered corresponding to calls to the :func:`.attributes.flag_modified`
+method, which is normally called from the :mod:`sqlalchemy.ext.mutable`
+extension::
+
+    from sqlalchemy.ext.declarative import declarative_base
+    from sqlalchemy.ext.mutable import MutableDict
+    from sqlalchemy import event
+
+    Base = declarative_base()
+
+    class MyDataClass(Base):
+        __tablename__ = 'my_data'
+        id = Column(Integer, primary_key=True)
+        data = Column(MutableDict.as_mutable(JSONEncodedDict))
+
+    @event.listens_for(MyDataClass.data, "modified")
+    def modified_json(instance):
+        print("json value modified:", instance.data)
+
+Above, the event handler will be triggered when an in-place change to the
+``.data`` dictionary occurs.
+
+:ticket:`3303`
 
 New Features and Improvements - Core
 ====================================
index e721397b3e67866919d5911e319c944d1269ead8..ccaeb6aa3254c30c5d10db9b1350e11ffd488c5b 100644 (file)
@@ -204,6 +204,28 @@ or more parent objects that are also part of the pickle, the :class:`.Mutable`
 mixin will re-establish the :attr:`.Mutable._parents` collection on each value
 object as the owning parents themselves are unpickled.
 
+Receiving Events
+----------------
+
+The :meth:`.AttributeEvents.modified` event handler may be used to receive
+an event when a mutable scalar emits a change event.  This event handler
+is called when the :func:`.attributes.flag_modified` function is called
+from within the mutable extension::
+
+    from sqlalchemy.ext.declarative import declarative_base
+    from sqlalchemy import event
+
+    Base = declarative_base()
+
+    class MyDataClass(Base):
+        __tablename__ = 'my_data'
+        id = Column(Integer, primary_key=True)
+        data = Column(MutableDict.as_mutable(JSONEncodedDict))
+
+    @event.listens_for(MyDataClass.data, "modified")
+    def modified_json(instance):
+        print("json value modified:", instance.data)
+
 .. _mutable_composites:
 
 Establishing Mutability on Composites
index 23a9f1a8cc2f60888ef9e0b81c79622f8d5452ef..f3a5c4735397a64ed0f3497f4bd6f0c0e8156b47 100644 (file)
@@ -330,6 +330,7 @@ OP_REMOVE = util.symbol("REMOVE")
 OP_APPEND = util.symbol("APPEND")
 OP_REPLACE = util.symbol("REPLACE")
 OP_BULK_REPLACE = util.symbol("BULK_REPLACE")
+OP_MODIFIED = util.symbol("MODIFIED")
 
 
 class Event(object):
@@ -341,9 +342,9 @@ class Event(object):
     operations.
 
     The :class:`.Event` object is sent as the ``initiator`` argument
-    when dealing with the :meth:`.AttributeEvents.append`,
+    when dealing with events such as :meth:`.AttributeEvents.append`,
     :meth:`.AttributeEvents.set`,
-    and :meth:`.AttributeEvents.remove` events.
+    and :meth:`.AttributeEvents.remove`.
 
     The :class:`.Event` object is currently interpreted by the backref
     event handlers, and is used to control the propagation of operations
@@ -459,12 +460,18 @@ class AttributeImpl(object):
             self.dispatch._active_history = True
 
         self.expire_missing = expire_missing
+        self._modified_token = None
 
     __slots__ = (
         'class_', 'key', 'callable_', 'dispatch', 'trackparent',
-        'parent_token', 'send_modified_events', 'is_equal', 'expire_missing'
+        'parent_token', 'send_modified_events', 'is_equal', 'expire_missing',
+        '_modified_token'
     )
 
+    def _init_modified_token(self):
+        self._modified_token = Event(self, OP_MODIFIED)
+        return self._modified_token
+
     def __str__(self):
         return "%s.%s" % (self.class_.__name__, self.key)
 
@@ -1636,6 +1643,8 @@ def flag_modified(instance, key):
     """
     state, dict_ = instance_state(instance), instance_dict(instance)
     impl = state.manager[key].impl
+    impl.dispatch.modified(
+        state, impl._modified_token or impl._init_modified_token())
     state._modified_event(dict_, impl, NO_VALUE, is_userland=True)
 
 
index 1ec898f8c87f4412addf2fc7c4859ca90954e33f..1d1c347b1b9a88d6a9d013c519133a91333a32a6 100644 (file)
@@ -1877,14 +1877,18 @@ class AttributeEvents(event.Events):
             target.dispatch._active_history = True
 
         if not raw or not retval:
-            def wrap(target, value, *arg):
+            def wrap(target, *arg):
                 if not raw:
                     target = target.obj()
                 if not retval:
-                    fn(target, value, *arg)
+                    if arg:
+                        value = arg[0]
+                    else:
+                        value = None
+                    fn(target, *arg)
                     return value
                 else:
-                    return fn(target, value, *arg)
+                    return fn(target, *arg)
             event_key = event_key.with_wrapper(wrap)
 
         event_key.base_listen(propagate=propagate)
@@ -2188,6 +2192,24 @@ class AttributeEvents(event.Events):
 
         """
 
+    def modified(self, target, initiator):
+        """Receive a 'modified' event.
+
+        This event is triggered when the :func:`.attributes.flag_modified`
+        function is used to trigger a modify event on an attribute without
+        any specific value being set.
+
+        .. versionadded:: 1.2
+
+        :param target: the object instance receiving the event.
+          If the listener is registered with ``raw=True``, this will
+          be the :class:`.InstanceState` object.
+
+        :param initiator: An instance of :class:`.attributes.Event`
+          representing the initiation of the event.
+
+        """
+
 
 class QueryEvents(event.Events):
     """Represent events within the construction of a :class:`.Query` object.
index 23bccafe9298fcf1602ab2cc8de5b8b1fdba0f7a..366b2006fb220ba5af08d867b6fc2373335e9038 100644 (file)
@@ -2,6 +2,7 @@ from sqlalchemy import Integer, ForeignKey, String, func
 from sqlalchemy.types import PickleType, TypeDecorator, VARCHAR
 from sqlalchemy.orm import mapper, Session, composite, column_property
 from sqlalchemy.orm.mapper import Mapper
+from sqlalchemy.orm import attributes
 from sqlalchemy.orm.instrumentation import ClassManager
 from sqlalchemy.testing.schema import Table, Column
 from sqlalchemy.testing import eq_, assert_raises_message, assert_raises
@@ -9,6 +10,8 @@ from sqlalchemy.testing.util import picklers
 from sqlalchemy.testing import fixtures
 from sqlalchemy.ext.mutable import MutableComposite
 from sqlalchemy.ext.mutable import MutableDict, MutableList, MutableSet
+from sqlalchemy.testing import mock
+from sqlalchemy import event
 
 
 class Foo(fixtures.BasicEntity):
@@ -112,6 +115,19 @@ class _MutableDictTestBase(_MutableDictTestFixture):
 
         eq_(f1.data, {'a': 'c'})
 
+    def test_modified_event(self):
+        canary = mock.Mock()
+        event.listen(Foo.data, "modified", canary)
+
+        f1 = Foo(data={"a": "b"})
+        f1.data["a"] = "c"
+
+        eq_(
+            canary.mock_calls,
+            [mock.call(
+                f1, attributes.Event(Foo.data.impl, attributes.OP_MODIFIED))]
+        )
+
     def test_clear(self):
         sess = Session()
 
index 6dcba1f3f54bb089673fe2fe28f22deddcb7522b..4646a010ada0cea7ad198f636504e3d35745cc8f 100644 (file)
@@ -2779,6 +2779,23 @@ class ListenerTest(fixtures.ORMTest):
         f1.barlist.remove(None)
         eq_(canary, [(f1, b1), (f1, None), (f1, b2), (f1, None)])
 
+    def test_flag_modified(self):
+        canary = Mock()
+
+        class Foo(object):
+            pass
+        instrumentation.register_class(Foo)
+        attributes.register_attribute(Foo, 'bar')
+
+        event.listen(Foo.bar, "modified", canary)
+        f1 = Foo()
+        f1.bar = 'hi'
+        attributes.flag_modified(f1, "bar")
+        eq_(
+            canary.mock_calls,
+            [call(f1, attributes.Event(Foo.bar.impl, attributes.OP_MODIFIED))]
+        )
+
     def test_none_init_scalar(self):
         canary = Mock()