]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
- [feature] Improvements to event listening for
authorMike Bayer <mike_mp@zzzcomputing.com>
Fri, 12 Oct 2012 21:21:08 +0000 (17:21 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Fri, 12 Oct 2012 21:21:08 +0000 (17:21 -0400)
    mapped classes allows that unmapped classes
    can be specified for instance- and mapper-events.
    The established events will be automatically
    set up on subclasses of that class when the
    propagate=True flag is passed, and the
    events will be set up for that class itself
    if and when it is ultimately mapped.
    [ticket:2585]

  - [bug] The instrumentation events class_instrument(),
    class_uninstrument(), and attribute_instrument()
    will now fire off only for descendant classes
    of the class assigned to listen().  Previously,
    an event listener would be assigned to listen
    for all classes in all cases regardless of the
    "target" argument passed.  [ticket:2590]

CHANGES
lib/sqlalchemy/event.py
lib/sqlalchemy/orm/events.py
lib/sqlalchemy/orm/instrumentation.py
lib/sqlalchemy/orm/mapper.py
test/orm/test_events.py

diff --git a/CHANGES b/CHANGES
index af4d1127e4173e94c1ec76e4c28ff80609ab2093..a376bbc3ccdd591ebbfeb4f1ed0a1a846336d13b 100644 (file)
--- a/CHANGES
+++ b/CHANGES
@@ -81,6 +81,24 @@ underneath "0.7.xx".
     contains_eager()
     [ticket:2438] [ticket:1106]
 
+  - [feature] Improvements to event listening for
+    mapped classes allows that unmapped classes
+    can be specified for instance- and mapper-events.
+    The established events will be automatically
+    set up on subclasses of that class when the
+    propagate=True flag is passed, and the
+    events will be set up for that class itself
+    if and when it is ultimately mapped.
+    [ticket:2585]
+
+  - [bug] The instrumentation events class_instrument(),
+    class_uninstrument(), and attribute_instrument()
+    will now fire off only for descendant classes
+    of the class assigned to listen().  Previously,
+    an event listener would be assigned to listen
+    for all classes in all cases regardless of the
+    "target" argument passed.  [ticket:2590]
+
   - [bug] with_polymorphic() produces JOINs
     in the correct order and with correct inheriting
     tables in the case of sending multi-level
index 633cb96f86330b1f605bff19531ffad968e6798a..c702d9d34aa79c478525089ba46712442afada52 100644 (file)
@@ -148,6 +148,12 @@ class _Dispatch(object):
             getattr(self, ls.name).\
                 for_modify(self)._update(ls, only_propagate=only_propagate)
 
+    @util.hybridmethod
+    def _clear(self):
+        for attr in dir(self):
+            if _is_event_name(attr):
+                getattr(self, attr).for_modify(self).clear()
+
 def _event_descriptors(target):
     return [getattr(target, k) for k in dir(target) if _is_event_name(k)]
 
@@ -170,7 +176,6 @@ def _create_dispatcher_class(cls, classname, bases, dict_):
     cls.dispatch = dispatch_cls = type("%sDispatch" % classname,
                                         (dispatch_base, ), {})
     dispatch_cls._listen = cls._listen
-    dispatch_cls._clear = cls._clear
 
     for k in dict_:
         if _is_event_name(k):
@@ -218,9 +223,7 @@ class Events(object):
 
     @classmethod
     def _clear(cls):
-        for attr in dir(cls.dispatch):
-            if _is_event_name(attr):
-                getattr(cls.dispatch, attr).clear()
+        cls.dispatch._clear()
 
 class _DispatchDescriptor(object):
     """Class-level attributes on :class:`._Dispatch` classes."""
index 9132ef8fa7bcb972ffc04b1fa0cd228f5529a087..174652a15a2b2774fcf2b9542492e80322c09fb3 100644 (file)
@@ -10,6 +10,8 @@
 from .. import event, exc, util
 orm = util.importlater("sqlalchemy", "orm")
 import inspect
+import weakref
+
 
 class InstrumentationEvents(event.Events):
     """Events related to class instrumentation events.
@@ -17,9 +19,20 @@ class InstrumentationEvents(event.Events):
     The listeners here support being established against
     any new style class, that is any object that is a subclass
     of 'type'.  Events will then be fired off for events
-    against that class as well as all subclasses.
-    'type' itself is also accepted as a target
-    in which case the events fire for all classes.
+    against that class.  If the "propagate=True" flag is passed
+    to event.listen(), the event will fire off for subclasses
+    of that class as well.
+
+    The Python ``type`` builtin is also accepted as a target,
+    which when used has the effect of events being emitted
+    for all classes.
+
+    .. versionchanged:: 0.8 - events here will emit based
+       on comparing the incoming class to the type of class
+       passed to :func:`.event.listen`.  Previously, the
+       event would fire for any class unconditionally regardless
+       of what class was sent for listening, despite
+       documentation which stated the contrary.
 
     """
 
@@ -27,19 +40,38 @@ class InstrumentationEvents(event.Events):
     def _accept_with(cls, target):
         # TODO: there's no coverage for this
         if isinstance(target, type):
-            return orm.instrumentation._instrumentation_factory
+            return _InstrumentationEventsHold(target)
         else:
             return None
 
     @classmethod
     def _listen(cls, target, identifier, fn, propagate=False):
-        event.Events._listen(target, identifier, fn, propagate=propagate)
+
+        def listen(target_cls, *arg):
+            listen_cls = target()
+            if propagate and issubclass(target_cls, listen_cls):
+                return fn(target_cls, *arg)
+            elif not propagate and target_cls is listen_cls:
+                return fn(target_cls, *arg)
+
+        def remove(ref):
+            event.Events._remove(orm.instrumentation._instrumentation_factory,
+                                            identifier, listen)
+
+        target = weakref.ref(target.class_, remove)
+        event.Events._listen(orm.instrumentation._instrumentation_factory,
+                        identifier, listen)
 
     @classmethod
     def _remove(cls, identifier, target, fn):
         raise NotImplementedError("Removal of instrumentation events "
                                     "not yet implemented")
 
+    @classmethod
+    def _clear(cls):
+        super(InstrumentationEvents, cls)._clear()
+        orm.instrumentation._instrumentation_factory.dispatch._clear()
+
     def class_instrument(self, cls):
         """Called after the given class is instrumented.
 
@@ -60,6 +92,17 @@ class InstrumentationEvents(event.Events):
     def attribute_instrument(self, cls, key, inst):
         """Called when an attribute is instrumented."""
 
+class _InstrumentationEventsHold(object):
+    """temporary marker object used to transfer from _accept_with() to _listen()
+    on the InstrumentationEvents class.
+
+    """
+    def __init__(self, class_):
+        self.class_ = class_
+
+    dispatch = event.dispatcher(InstrumentationEvents)
+
+
 class InstanceEvents(event.Events):
     """Define events specific to object lifecycle.
 
@@ -94,8 +137,8 @@ class InstanceEvents(event.Events):
     available to the :func:`.event.listen` function.
 
     :param propagate=False: When True, the event listener should
-       be applied to all inheriting mappers as well as the
-       mapper which is the target of this listener.
+       be applied to all inheriting classes as well as the
+       class which is the target of this listener.
     :param raw=False: When True, the "target" argument passed
        to applicable event listener functions will be the
        instance's :class:`.InstanceState` management
@@ -117,6 +160,8 @@ class InstanceEvents(event.Events):
                 manager = orm.instrumentation.manager_of_class(target)
                 if manager:
                     return manager
+                else:
+                    return _InstanceEventsHold(target)
         return None
 
     @classmethod
@@ -136,6 +181,11 @@ class InstanceEvents(event.Events):
     def _remove(cls, identifier, target, fn):
         raise NotImplementedError("Removal of instance events not yet implemented")
 
+    @classmethod
+    def _clear(cls):
+        super(InstanceEvents, cls)._clear()
+        _InstanceEventsHold._clear()
+
     def first_init(self, manager, cls):
         """Called when the first instance of a particular mapping is called.
 
@@ -258,6 +308,51 @@ class InstanceEvents(event.Events):
 
         """
 
+class _EventsHold(object):
+    """Hold onto listeners against unmapped, uninstrumented classes.
+
+    Establish _listen() for that class' mapper/instrumentation when
+    those objects are created for that class.
+
+    """
+    def __init__(self, class_):
+        self.class_ = class_
+
+    @classmethod
+    def _clear(cls):
+        cls.all_holds.clear()
+
+    class HoldEvents(object):
+        @classmethod
+        def _listen(cls, target, identifier, fn, raw=False, propagate=False):
+            if target.class_ in target.all_holds:
+                collection = target.all_holds[target.class_]
+            else:
+                collection = target.all_holds[target.class_] = []
+
+            collection.append((target.class_, identifier, fn, raw, propagate))
+
+    @classmethod
+    def populate(cls, class_, subject):
+        for subclass in class_.__mro__:
+            if subclass in cls.all_holds:
+                if subclass is class_:
+                    collection = cls.all_holds.pop(subclass)
+                else:
+                    collection = cls.all_holds[subclass]
+                for target, ident, fn, raw, propagate in collection:
+                    if propagate or subclass is class_:
+                        subject.dispatch._listen(subject, ident, fn, raw, propagate)
+
+class _InstanceEventsHold(_EventsHold):
+    all_holds = weakref.WeakKeyDictionary()
+
+    class HoldInstanceEvents(_EventsHold.HoldEvents, InstanceEvents):
+        pass
+
+    dispatch = event.dispatcher(HoldInstanceEvents)
+
+
 class MapperEvents(event.Events):
     """Define events specific to mappings.
 
@@ -307,7 +402,8 @@ class MapperEvents(event.Events):
     available to the :func:`.event.listen` function.
 
     :param propagate=False: When True, the event listener should
-       be applied to all inheriting mappers as well as the
+       be applied to all inheriting mappers and/or the mappers of
+       inheriting classes, as well as any
        mapper which is the target of this listener.
     :param raw=False: When True, the "target" argument passed
        to applicable event listener functions will be the
@@ -337,7 +433,11 @@ class MapperEvents(event.Events):
             if issubclass(target, orm.Mapper):
                 return target
             else:
-                return orm.class_mapper(target, configure=False)
+                mapper = orm.util._mapper_or_none(target)
+                if mapper is not None:
+                    return mapper
+                else:
+                    return _MapperEventsHold(target)
         else:
             return target
 
@@ -371,6 +471,11 @@ class MapperEvents(event.Events):
         else:
             event.Events._listen(target, identifier, fn)
 
+    @classmethod
+    def _clear(cls):
+        super(MapperEvents, cls)._clear()
+        _MapperEventsHold._clear()
+
     def instrument_class(self, mapper, class_):
         """Receive a class when the mapper is first constructed,
         before instrumentation is applied to the mapped class.
@@ -906,6 +1011,14 @@ class MapperEvents(event.Events):
     def _remove(cls, identifier, target, fn):
         raise NotImplementedError("Removal of mapper events not yet implemented")
 
+class _MapperEventsHold(_EventsHold):
+    all_holds = weakref.WeakKeyDictionary()
+
+    class HoldMapperEvents(_EventsHold.HoldEvents, MapperEvents):
+        pass
+
+    dispatch = event.dispatcher(HoldMapperEvents)
+
 class SessionEvents(event.Events):
     """Define events specific to :class:`.Session` lifecycle.
 
index 9a185c9ef0ba04e8a7a5fc89688103873d84f85c..0e828ce87601a74170f83c993a28d7ab182a8182 100644 (file)
@@ -63,6 +63,12 @@ class ClassManager(dict):
         for base in self._bases:
             self.update(base)
 
+        events._InstanceEventsHold.populate(class_, self)
+
+        for basecls in class_.__mro__:
+            mgr = manager_of_class(basecls)
+            if mgr is not None:
+                self.dispatch._update(mgr.dispatch)
         self.manage()
         self._instrument_init()
 
index 4269e332faac9be712a50dc9cce2c3dac81df3c1..dfd8a12b7c12d0cc7eab2a99c418c42b89013450 100644 (file)
@@ -208,6 +208,7 @@ class Mapper(_InspectionAttr):
         # configure_mappers() until construction succeeds)
         _CONFIGURE_MUTEX.acquire()
         try:
+            events._MapperEventsHold.populate(class_, self)
             self._configure_inheritance()
             self._configure_legacy_instrument_class()
             self._configure_class_instrumentation()
@@ -659,10 +660,6 @@ class Mapper(_InspectionAttr):
             if ext not in super_extensions:
                 ext._adapt_listener(self, ext)
 
-        if self.inherits:
-            self.class_manager.dispatch._update(
-                        self.inherits.class_manager.dispatch)
-
     def _configure_class_instrumentation(self):
         """If this mapper is to be a primary mapper (i.e. the
         non_primary flag is not set), associate this Mapper with the
index d8bef66d3c242958e0b9144109f53b8e06b67a6a..6b903d9bebc97d6c1074a9f30f451f893acd233d 100644 (file)
@@ -8,8 +8,10 @@ from sqlalchemy.orm import mapper, relationship, \
     Mapper, column_property, \
     Session, sessionmaker, attributes
 from sqlalchemy.orm.instrumentation import ClassManager
+from sqlalchemy.orm import instrumentation, events
 from sqlalchemy.testing import eq_
 from sqlalchemy.testing import fixtures
+from sqlalchemy.testing.util import gc_collect
 from test.orm import _fixtures
 from sqlalchemy import event
 
@@ -18,9 +20,10 @@ class _RemoveListeners(object):
     def teardown(self):
         # TODO: need to get remove() functionality
         # going
-        Mapper.dispatch._clear()
-        ClassManager.dispatch._clear()
-        Session.dispatch._clear()
+        events.MapperEvents._clear()
+        events.InstanceEvents._clear()
+        events.SessionEvents._clear()
+        events.InstrumentationEvents._clear()
         super(_RemoveListeners, self).teardown()
 
 
@@ -198,6 +201,47 @@ class MapperEventsTest(_RemoveListeners, _fixtures.FixtureTest):
             'before_update', 'after_update', 'before_delete',
             'after_delete'])
 
+    def test_inheritance_subclass_deferred(self):
+        users, addresses, User = (self.tables.users,
+                                self.tables.addresses,
+                                self.classes.User)
+
+
+        mapper(User, users)
+
+        canary1 = self.listen_all(User, propagate=True)
+        canary2 = self.listen_all(User)
+
+        class AdminUser(User):
+            pass
+        mapper(AdminUser, addresses, inherits=User)
+        canary3 = self.listen_all(AdminUser)
+
+        sess = create_session()
+        am = AdminUser(name='au1', email_address='au1@e1')
+        sess.add(am)
+        sess.flush()
+        am = sess.query(AdminUser).populate_existing().get(am.id)
+        sess.expunge_all()
+        am = sess.query(AdminUser).get(am.id)
+        am.name = 'au1 changed'
+        sess.flush()
+        sess.delete(am)
+        sess.flush()
+        eq_(canary1, ['init', 'before_insert', 'after_insert',
+            'translate_row', 'populate_instance','refresh',
+            'append_result', 'translate_row', 'create_instance'
+            , 'populate_instance', 'load', 'append_result',
+            'before_update', 'after_update', 'before_delete',
+            'after_delete'])
+        eq_(canary2, [])
+        eq_(canary3, ['init', 'before_insert', 'after_insert',
+            'translate_row', 'populate_instance','refresh',
+            'append_result', 'translate_row', 'create_instance'
+            , 'populate_instance', 'load', 'append_result',
+            'before_update', 'after_update', 'before_delete',
+            'after_delete'])
+
     def test_before_after_only_collection(self):
         """before_update is called on parent for collection modifications,
         after_update is called even if no columns were updated.
@@ -277,6 +321,219 @@ class MapperEventsTest(_RemoveListeners, _fixtures.FixtureTest):
         mapper(Address, addresses)
         eq_(canary, [User, Address])
 
+class DeferredMapperEventsTest(_RemoveListeners, _fixtures.FixtureTest):
+    """"test event listeners against unmapped classes.
+
+    This incurs special logic.  Note if we ever do the "remove" case,
+    it has to get all of these, too.
+
+    """
+    run_inserts = None
+
+    def test_deferred_map_event(self):
+        users, User = (self.tables.users,
+                                self.classes.User)
+
+        canary = []
+        def evt(x):
+            canary.append(x)
+        event.listen(User, "before_insert", evt, raw=True)
+
+        m = mapper(User, users)
+        m.dispatch.before_insert(5)
+        eq_(canary, [5])
+
+    def test_deferred_map_event_subclass_propagate(self):
+        users, User = (self.tables.users,
+                                self.classes.User)
+
+        class SubUser(User):
+            pass
+
+        canary = []
+        def evt(x):
+            canary.append(x)
+        event.listen(User, "before_insert", evt, propagate=True, raw=True)
+
+        m = mapper(SubUser, users)
+        m.dispatch.before_insert(5)
+        eq_(canary, [5])
+
+    def test_deferred_map_event_subclass_no_propagate(self):
+        users, User = (self.tables.users,
+                                self.classes.User)
+
+        class SubUser(User):
+            pass
+
+        canary = []
+        def evt(x):
+            canary.append(x)
+        event.listen(User, "before_insert", evt, propagate=False)
+
+        m = mapper(SubUser, users)
+        m.dispatch.before_insert(5)
+        eq_(canary, [])
+
+    def test_deferred_instance_event_plain(self):
+        users, User = (self.tables.users,
+                                self.classes.User)
+
+        canary = []
+        def evt(x):
+            canary.append(x)
+        event.listen(User, "load", evt, raw=True)
+
+        m = mapper(User, users)
+        m.class_manager.dispatch.load(5)
+        eq_(canary, [5])
+
+    def test_deferred_instance_event_subclass_propagate_subclass_only(self):
+        users, User = (self.tables.users,
+                                self.classes.User)
+
+        class SubUser(User):
+            pass
+
+        class SubUser2(User):
+            pass
+
+        canary = []
+        def evt(x):
+            canary.append(x)
+        event.listen(User, "load", evt, propagate=True, raw=True)
+
+        m = mapper(SubUser, users)
+        m2 = mapper(SubUser2, users)
+
+        m.class_manager.dispatch.load(5)
+        eq_(canary, [5])
+
+        m2.class_manager.dispatch.load(5)
+        eq_(canary, [5, 5])
+
+    def test_deferred_instance_event_subclass_propagate_baseclass(self):
+        users, User = (self.tables.users,
+                                self.classes.User)
+
+        class SubUser(User):
+            pass
+
+        class SubUser2(User):
+            pass
+
+        canary = []
+        def evt(x):
+            canary.append(x)
+        event.listen(User, "load", evt, propagate=True, raw=True)
+
+        m = mapper(SubUser, users)
+        m2 = mapper(User, users)
+
+        m.class_manager.dispatch.load(5)
+        eq_(canary, [5])
+
+        m2.class_manager.dispatch.load(5)
+        eq_(canary, [5, 5])
+
+        m3 = mapper(SubUser2, users)
+        m3.class_manager.dispatch.load(5)
+        eq_(canary, [5, 5, 5])
+
+    def test_deferred_instance_event_subclass_no_propagate(self):
+        users, User = (self.tables.users,
+                                self.classes.User)
+
+        class SubUser(User):
+            pass
+
+        canary = []
+        def evt(x):
+            canary.append(x)
+        event.listen(User, "load", evt, propagate=False)
+
+        m = mapper(SubUser, users)
+        m.class_manager.dispatch.load(5)
+        eq_(canary, [])
+
+    def test_deferred_instrument_event(self):
+        users, User = (self.tables.users,
+                                self.classes.User)
+
+        canary = []
+        def evt(x):
+            canary.append(x)
+        event.listen(User, "attribute_instrument", evt)
+
+        instrumentation._instrumentation_factory.dispatch.attribute_instrument(User)
+        eq_(canary, [User])
+
+    def test_isolation_instrument_event(self):
+        users, User = (self.tables.users,
+                                self.classes.User)
+        class Bar(object):
+            pass
+
+        canary = []
+        def evt(x):
+            canary.append(x)
+        event.listen(Bar, "attribute_instrument", evt)
+
+        instrumentation._instrumentation_factory.dispatch.attribute_instrument(User)
+        eq_(canary, [])
+
+    @testing.requires.predictable_gc
+    def test_instrument_event_auto_remove(self):
+        class Bar(object):
+            pass
+
+        listeners = instrumentation._instrumentation_factory.dispatch.\
+                            attribute_instrument.listeners
+        assert not listeners
+
+        canary = []
+        def evt(x):
+            canary.append(x)
+        event.listen(Bar, "attribute_instrument", evt)
+
+        eq_(len(listeners), 1)
+
+        del Bar
+        gc_collect()
+
+        assert not listeners
+
+
+    def test_deferred_instrument_event_subclass_propagate(self):
+        users, User = (self.tables.users,
+                                self.classes.User)
+        class SubUser(User):
+            pass
+
+        canary = []
+        def evt(x):
+            canary.append(x)
+        event.listen(User, "attribute_instrument", evt, propagate=True)
+
+        instrumentation._instrumentation_factory.dispatch.\
+                            attribute_instrument(SubUser)
+        eq_(canary, [SubUser])
+
+    def test_deferred_instrument_event_subclass_no_propagate(self):
+        users, User = (self.tables.users,
+                                self.classes.User)
+        class SubUser(User):
+            pass
+
+        canary = []
+        def evt(x):
+            canary.append(x)
+        event.listen(User, "attribute_instrument", evt, propagate=False)
+
+        mapper(SubUser, users)
+        instrumentation._instrumentation_factory.dispatch.attribute_instrument(5)
+        eq_(canary, [])
+
 
 class LoadTest(_fixtures.FixtureTest):
     run_inserts = None