diff options
| -rw-r--r-- | CHANGES | 18 | ||||
| -rw-r--r-- | lib/sqlalchemy/event.py | 11 | ||||
| -rw-r--r-- | lib/sqlalchemy/orm/events.py | 131 | ||||
| -rw-r--r-- | lib/sqlalchemy/orm/instrumentation.py | 6 | ||||
| -rw-r--r-- | lib/sqlalchemy/orm/mapper.py | 5 | ||||
| -rw-r--r-- | test/orm/test_events.py | 263 |
6 files changed, 414 insertions, 20 deletions
@@ -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 diff --git a/lib/sqlalchemy/event.py b/lib/sqlalchemy/event.py index 633cb96f8..c702d9d34 100644 --- a/lib/sqlalchemy/event.py +++ b/lib/sqlalchemy/event.py @@ -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.""" diff --git a/lib/sqlalchemy/orm/events.py b/lib/sqlalchemy/orm/events.py index 9132ef8fa..174652a15 100644 --- a/lib/sqlalchemy/orm/events.py +++ b/lib/sqlalchemy/orm/events.py @@ -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. diff --git a/lib/sqlalchemy/orm/instrumentation.py b/lib/sqlalchemy/orm/instrumentation.py index 9a185c9ef..0e828ce87 100644 --- a/lib/sqlalchemy/orm/instrumentation.py +++ b/lib/sqlalchemy/orm/instrumentation.py @@ -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() diff --git a/lib/sqlalchemy/orm/mapper.py b/lib/sqlalchemy/orm/mapper.py index 4269e332f..dfd8a12b7 100644 --- a/lib/sqlalchemy/orm/mapper.py +++ b/lib/sqlalchemy/orm/mapper.py @@ -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 diff --git a/test/orm/test_events.py b/test/orm/test_events.py index d8bef66d3..6b903d9be 100644 --- a/test/orm/test_events.py +++ b/test/orm/test_events.py @@ -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 |
