--- /dev/null
+.. change::
+ :tags: usecase, orm
+ :tickets: 5129
+
+ Added a new flag :paramref:`.InstanceEvents.restore_load_context` and
+ :paramref:`.SessionEvents.restore_load_context` which apply to the
+ :meth:`.InstanceEvents.load`, :meth:`.InstanceEvents.refresh`, and
+ :meth:`.SessionEvents.loaded_as_persistent` events, which when set will
+ restore the "load context" of the object after the event hook has been
+ called. This ensures that the object remains within the "loader context"
+ of the load operation that is already ongoing, rather than the object being
+ transferred to a new load context due to refresh operations which may have
+ occurred in the event. A warning is now emitted when this condition occurs,
+ which recommends use of the flag to resolve this case. The flag is
+ "opt-in" so that there is no risk introduced to existing applications.
+
+ The change additionally adds support for the ``raw=True`` flag to
+ session lifecycle events.
\ No newline at end of file
to applicable event listener functions will be the
instance's :class:`.InstanceState` management
object, rather than the mapped instance itself.
+ :param restore_load_context=False: Applies to the
+ :meth:`.InstanceEvents.load` and :meth:`.InstanceEvents.refresh`
+ events. Restores the loader context of the object when the event
+ hook is complete, so that ongoing eager load operations continue
+ to target the object appropriately. A warning is emitted if the
+ object is moved to a new loader context from within one of these
+ events if this flag is not set.
+
+ .. versionadded:: 1.3.14
+
"""
return None
@classmethod
- def _listen(cls, event_key, raw=False, propagate=False, **kw):
+ def _listen(
+ cls,
+ event_key,
+ raw=False,
+ propagate=False,
+ restore_load_context=False,
+ **kw
+ ):
target, fn = (event_key.dispatch_target, event_key._listen_fn)
- if not raw:
+ if not raw or restore_load_context:
def wrap(state, *arg, **kw):
- return fn(state.obj(), *arg, **kw)
+ if not raw:
+ target = state.obj()
+ else:
+ target = state
+ if restore_load_context:
+ runid = state.runid
+ try:
+ return fn(target, *arg, **kw)
+ finally:
+ if restore_load_context:
+ state.runid = runid
event_key = event_key.with_wrapper(wrap)
incoming result rows, and is only called once for that
instance's lifetime.
- Note that during a result-row load, this method is called upon
- the first row received for this instance. Note that some
- attributes and collections may or may not be loaded or even
- initialized, depending on what's present in the result rows.
+ .. warning::
+
+ During a result-row load, this event is invoked when the
+ first row received for this instance is processed. When using
+ eager loading with collection-oriented attributes, the additional
+ rows that are to be loaded / processed in order to load subsequent
+ collection items have not occurred yet. This has the effect
+ both that collections will not be fully loaded, as well as that
+ if an operation occurs within this event handler that emits
+ another database load operation for the object, the "loading
+ context" for the object can change and interfere with the
+ existing eager loaders still in progress.
+
+ Examples of what can cause the "loading context" to change within
+ the event handler include, but are not necessarily limited to:
+
+ * accessing deferred attributes that weren't part of the row,
+ will trigger an "undefer" operation and refresh the object
+
+ * accessing attributes on a joined-inheritance subclass that
+ weren't part of the row, will trigger a refresh operation.
+
+ As of SQLAlchemy 1.3.14, a warning is emitted when this occurs. The
+ :paramref:`.InstanceEvents.restore_load_context` option may be
+ used on the event to prevent this warning; this will ensure that
+ the existing loading context is maintained for the object after the
+ event is called::
+
+ @event.listens_for(
+ SomeClass, "load", restore_load_context=True)
+ def on_load(instance, context):
+ instance.some_unloaded_attribute
+
+ .. versionchanged:: 1.3.14 Added
+ :paramref:`.InstanceEvents.restore_load_context`
+ and :paramref:`.SessionEvents.restore_load_context` flags which
+ apply to "on load" events, which will ensure that the loading
+ context for an object is restored when the event hook is
+ complete; a warning is emitted if the load context of the object
+ changes without this flag being set.
+
The :meth:`.InstanceEvents.load` event is also available in a
class-method decorator format called :func:`.orm.reconstructor`.
Contrast this to the :meth:`.InstanceEvents.load` method, which
is invoked when the object is first loaded from a query.
+ .. note:: This event is invoked within the loader process before
+ eager loaders may have been completed, and the object's state may
+ not be complete. Additionally, invoking row-level refresh
+ operations on the object will place the object into a new loader
+ context, interfering with the existing load context. See the note
+ on :meth:`.InstanceEvents.load` for background on making use of the
+ :paramref:`.InstanceEvents.restore_load_context` parameter, in
+ order to resolve this scenario.
+
:param target: the mapped instance. If
the event is configured with ``raw=True``, this will
instead be the :class:`.InstanceState` state-management
dispatch = event.dispatcher(HoldMapperEvents)
+_sessionevents_lifecycle_event_names = set()
+
+
class SessionEvents(event.Events):
"""Define events specific to :class:`.Session` lifecycle.
will apply listeners to all :class:`.Session` instances
globally.
+ :param raw=False: When True, the "target" argument passed
+ to applicable event listener functions that work on individual
+ objects will be the instance's :class:`.InstanceState` management
+ object, rather than the mapped instance itself.
+
+ .. versionadded:: 1.3.14
+
+ :param restore_load_context=False: Applies to the
+ :meth:`.SessionEvents.loaded_as_persistent` event. Restores the loader
+ context of the object when the event hook is complete, so that ongoing
+ eager load operations continue to target the object appropriately. A
+ warning is emitted if the object is moved to a new loader context from
+ within this event if this flag is not set.
+
+ .. versionadded:: 1.3.14
+
"""
_target_class_doc = "SomeSessionOrFactory"
_dispatch_target = Session
+ def _lifecycle_event(fn):
+ _sessionevents_lifecycle_event_names.add(fn.__name__)
+ return fn
+
@classmethod
def _accept_with(cls, target):
if isinstance(target, scoped_session):
else:
return None
+ @classmethod
+ def _listen(cls, event_key, raw=False, restore_load_context=False, **kw):
+ is_instance_event = (
+ event_key.identifier in _sessionevents_lifecycle_event_names
+ )
+
+ if is_instance_event:
+ if not raw or restore_load_context:
+
+ fn = event_key._listen_fn
+
+ def wrap(session, state, *arg, **kw):
+ if not raw:
+ target = state.obj()
+ if target is None:
+ # existing behavior is that if the object is
+ # garbage collected, no event is emitted
+ return
+ else:
+ target = state
+ if restore_load_context:
+ runid = state.runid
+ try:
+ return fn(session, target, *arg, **kw)
+ finally:
+ if restore_load_context:
+ state.runid = runid
+
+ event_key = event_key.with_wrapper(wrap)
+
+ event_key.base_listen(**kw)
+
def after_transaction_create(self, session, transaction):
"""Execute when a new :class:`.SessionTransaction` is created.
"""
+ @_lifecycle_event
def before_attach(self, session, instance):
"""Execute before an instance is attached to a session.
"""
+ @_lifecycle_event
def after_attach(self, session, instance):
"""Execute after an instance is attached to a session.
"""
+ @_lifecycle_event
def transient_to_pending(self, session, instance):
"""Intercept the "transient to pending" transition for a specific object.
"""
+ @_lifecycle_event
def pending_to_transient(self, session, instance):
"""Intercept the "pending to transient" transition for a specific object.
"""
+ @_lifecycle_event
def persistent_to_transient(self, session, instance):
"""Intercept the "persistent to transient" transition for a specific object.
"""
+ @_lifecycle_event
def pending_to_persistent(self, session, instance):
"""Intercept the "pending to persistent"" transition for a specific object.
"""
+ @_lifecycle_event
def detached_to_persistent(self, session, instance):
"""Intercept the "detached to persistent" transition for a specific object.
"""
+ @_lifecycle_event
def loaded_as_persistent(self, session, instance):
"""Intercept the "loaded as persistent" transition for a specific object.
is guaranteed to be present in the session's identity map when
this event is called.
+ .. note:: This event is invoked within the loader process before
+ eager loaders may have been completed, and the object's state may
+ not be complete. Additionally, invoking row-level refresh
+ operations on the object will place the object into a new loader
+ context, interfering with the existing load context. See the note
+ on :meth:`.InstanceEvents.load` for background on making use of the
+ :paramref:`.SessionEvents.restore_load_context` parameter, which
+ works in the same manner as that of
+ :paramref:`.InstanceEvents.restore_load_context`, in order to
+ resolve this scenario.
:param session: target :class:`.Session`
"""
+ @_lifecycle_event
def persistent_to_deleted(self, session, instance):
"""Intercept the "persistent to deleted" transition for a specific object.
"""
+ @_lifecycle_event
def deleted_to_persistent(self, session, instance):
"""Intercept the "deleted to persistent" transition for a specific object.
"""
+ @_lifecycle_event
def deleted_to_detached(self, session, instance):
"""Intercept the "deleted to detached" transition for a specific object.
"""
+ @_lifecycle_event
def persistent_to_detached(self, session, instance):
"""Intercept the "persistent to detached" transition for a specific object.
column_collection.append(pd)
+def _warn_for_runid_changed(state):
+ util.warn(
+ "Loading context for %s has changed within a load/refresh "
+ "handler, suggesting a row refresh operation took place. If this "
+ "event handler is expected to be "
+ "emitting row refresh operations within an existing load or refresh "
+ "operation, set restore_load_context=True when establishing the "
+ "listener to ensure the context remains unchanged when the event "
+ "handler completes." % (state_str(state),)
+ )
+
+
def _instance_processor(
mapper,
context,
)
if isnew:
+ # state.runid should be equal to context.runid / runid
+ # here, however for event checks we are being more conservative
+ # and checking against existing run id
+ # assert state.runid == runid
+
+ existing_runid = state.runid
+
if loaded_instance:
if load_evt:
state.manager.dispatch.load(state, context)
+ if state.runid != existing_runid:
+ _warn_for_runid_changed(state)
if persistent_evt:
- loaded_as_persistent(context.session, state.obj())
+ loaded_as_persistent(context.session, state)
+ if state.runid != existing_runid:
+ _warn_for_runid_changed(state)
elif refresh_evt:
state.manager.dispatch.refresh(
state, context, only_load_props
)
+ if state.runid != runid:
+ _warn_for_runid_changed(state)
if populate_existing or state.modified:
if refresh_state and only_load_props:
if isnew:
if refresh_evt:
+ existing_runid = state.runid
state.manager.dispatch.refresh(state, context, to_load)
+ if state.runid != existing_runid:
+ _warn_for_runid_changed(state)
state._commit(dict_, to_load)
if pending_to_persistent is not None:
for state in states.intersection(self._new):
- pending_to_persistent(self, state.obj())
+ pending_to_persistent(self, state)
# remove from new last, might be the last strong ref
for state in set(states).intersection(self._new):
if persistent_to_deleted is not None:
# get a strong reference before we pop out of
# self._deleted
- obj = state.obj()
+ obj = state.obj() # noqa
self.identity_map.safe_discard(state)
self._deleted.pop(state, None)
# is still in the transaction snapshot and needs to be
# tracked as part of that
if persistent_to_deleted is not None:
- persistent_to_deleted(self, obj)
+ persistent_to_deleted(self, state)
def add(self, instance, _warn=True):
"""Place an object in the ``Session``.
if to_attach:
self._after_attach(state, obj)
elif revert_deletion:
- self.dispatch.deleted_to_persistent(self, obj)
+ self.dispatch.deleted_to_persistent(self, state)
def _save_or_update_impl(self, state):
if state.key is None:
% (state_str(state), state.session_id, self.hash_key)
)
- self.dispatch.before_attach(self, obj)
+ self.dispatch.before_attach(self, state)
return True
state.session_id = self.hash_key
if state.modified and state._strong_obj is None:
state._strong_obj = obj
- self.dispatch.after_attach(self, obj)
+ self.dispatch.after_attach(self, state)
if state.key:
- self.dispatch.detached_to_persistent(self, obj)
+ self.dispatch.detached_to_persistent(self, state)
else:
- self.dispatch.transient_to_pending(self, obj)
+ self.dispatch.transient_to_pending(self, state)
def __contains__(self, instance):
"""Return True if the instance is associated with this session.
if persistent:
if to_transient:
if persistent_to_transient is not None:
- obj = state.obj()
- if obj is not None:
- persistent_to_transient(session, obj)
+ persistent_to_transient(session, state)
elif persistent_to_detached is not None:
- obj = state.obj()
- if obj is not None:
- persistent_to_detached(session, obj)
+ persistent_to_detached(session, state)
elif deleted and deleted_to_detached is not None:
- obj = state.obj()
- if obj is not None:
- deleted_to_detached(session, obj)
+ deleted_to_detached(session, state)
elif pending and pending_to_transient is not None:
- obj = state.obj()
- if obj is not None:
- pending_to_transient(session, obj)
+ pending_to_transient(session, state)
state._strong_obj = None
import sqlalchemy as sa
from sqlalchemy import event
+from sqlalchemy import ForeignKey
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import testing
from sqlalchemy.orm import class_mapper
from sqlalchemy.orm import configure_mappers
from sqlalchemy.orm import create_session
+from sqlalchemy.orm import deferred
from sqlalchemy.orm import events
from sqlalchemy.orm import EXT_SKIP
from sqlalchemy.orm import instrumentation
from sqlalchemy.testing import assert_raises_message
from sqlalchemy.testing import AssertsCompiledSQL
from sqlalchemy.testing import eq_
+from sqlalchemy.testing import expect_warnings
from sqlalchemy.testing import fixtures
from sqlalchemy.testing import is_not_
from sqlalchemy.testing.assertsql import CompiledSQL
probe()
+class RestoreLoadContextTest(fixtures.DeclarativeMappedTest):
+ @classmethod
+ def setup_classes(cls):
+ class A(cls.DeclarativeBasic):
+ __tablename__ = "a"
+ id = Column(Integer, primary_key=True)
+ unloaded = deferred(Column(String(50)))
+ bs = relationship("B", lazy="joined")
+
+ class B(cls.DeclarativeBasic):
+ __tablename__ = "b"
+ id = Column(Integer, primary_key=True)
+ a_id = Column(ForeignKey("a.id"))
+
+ @classmethod
+ def insert_data(cls):
+ A, B = cls.classes("A", "B")
+ s = Session(testing.db)
+ s.add(A(bs=[B(), B(), B()]))
+ s.commit()
+
+ def _combinations(fn):
+ return testing.combinations(
+ (lambda A: A, "load", lambda instance, context: instance.unloaded),
+ (
+ lambda A: A,
+ "refresh",
+ lambda instance, context, attrs: instance.unloaded,
+ ),
+ (
+ lambda session: session,
+ "loaded_as_persistent",
+ lambda session, instance: instance.unloaded
+ if instance.__class__.__name__ == "A"
+ else None,
+ ),
+ argnames="target, event_name, fn",
+ )(fn)
+
+ def teardown(self):
+ A = self.classes.A
+ A._sa_class_manager.dispatch._clear()
+
+ @_combinations
+ def test_warning(self, target, event_name, fn):
+ A = self.classes.A
+ s = Session()
+ target = testing.util.resolve_lambda(target, A=A, session=s)
+ event.listen(target, event_name, fn)
+
+ with expect_warnings(
+ r"Loading context for \<A at .*\> has changed within a "
+ r"load/refresh handler, suggesting a row refresh operation "
+ r"took place. "
+ r"If this event handler is expected to be emitting row refresh "
+ r"operations within an existing load or refresh operation, set "
+ r"restore_load_context=True when establishing the listener to "
+ r"ensure the context remains unchanged when the event handler "
+ r"completes."
+ ):
+ a1 = s.query(A).all()[0]
+ if event_name == "refresh":
+ s.refresh(a1)
+ # joined eager load didn't continue
+ eq_(len(a1.bs), 1)
+
+ @_combinations
+ def test_flag_resolves_existing(self, target, event_name, fn):
+ A = self.classes.A
+ s = Session()
+ target = testing.util.resolve_lambda(target, A=A, session=s)
+
+ a1 = s.query(A).all()[0]
+
+ s.expire(a1)
+ event.listen(target, event_name, fn, restore_load_context=True)
+ s.query(A).all()
+
+ @_combinations
+ def test_flag_resolves(self, target, event_name, fn):
+ A = self.classes.A
+ s = Session()
+ target = testing.util.resolve_lambda(target, A=A, session=s)
+ event.listen(target, event_name, fn, restore_load_context=True)
+
+ a1 = s.query(A).all()[0]
+ if event_name == "refresh":
+ s.refresh(a1)
+ # joined eager load continued
+ eq_(len(a1.bs), 3)
+
+
class DeclarativeEventListenTest(
_RemoveListeners, fixtures.DeclarativeMappedTest
):