A warning is emitted for a condition in which the :class:`.Session` may
implicitly swap an object out of the identity map for another one with the
same primary key, detaching the old one, which can be an observed result of
load operations which occur within the :meth:`.SessionEvents.after_flush`
hook. The warning is intended to notify the user that some special
condition has caused this to happen and that the previous object may not be
in the expected state.
Fixes: #4890
Change-Id: Ide11c6b9f21ca67ff5a96266c521d0c56fd6af8d
(cherry picked from commit
4aa43ecbd78e5a7dd3d983ca46a377af4e01877e)
--- /dev/null
+.. change::
+ :tags: bug, orm
+ :tickets: 4890
+
+ A warning is emitted for a condition in which the :class:`.Session` may
+ implicitly swap an object out of the identity map for another one with the
+ same primary key, detaching the old one, which can be an observed result of
+ load operations which occur within the :meth:`.SessionEvents.after_flush`
+ hook. The warning is intended to notify the user that some special
+ condition has caused this to happen and that the previous object may not be
+ in the expected state.
'dirty', and 'deleted' lists still show pre-flush state as well
as the history settings on instance attributes.
+ .. warning:: This event runs after the :class:`.Session` has emitted
+ SQL to modify the database, but **before** it has altered its
+ internal state to reflect those changes, including that newly
+ inserted objects are placed into the identity map. ORM operations
+ emitted within this event such as loads of related items
+ may produce new identity map entries that will immediately
+ be replaced, sometimes causing confusing results. SQLAlchemy will
+ emit a warning for this condition as of version 1.3.9.
+
:param session: The target :class:`.Session`.
:param flush_context: Internal :class:`.UOWTransaction` object
which handles the details of the flush.
if existing is not state:
self._manage_removed_state(existing)
else:
- return
+ return None
+ else:
+ existing = None
self._dict[state.key] = state
self._manage_incoming_state(state)
+ return existing
def add(self, state):
key = state.key
self._manage_removed_state(existing)
else:
return
+ else:
+ existing = None
self._dict[state.key] = state.obj()
self._manage_incoming_state(state)
+ return existing
def add(self, state):
if state.key in self:
multiparams = [rec[2] for rec in records]
c = cached_connections[connection].execute(statement, multiparams)
-
if bookkeeping:
for (
(
# there can be an existing state in the identity map
# that is replaced when the primary keys of two instances
# are swapped; see test/orm/test_naturalpks.py -> test_reverse
- self.identity_map.replace(state)
+ old = self.identity_map.replace(state)
+ if (
+ old is not None
+ and mapper._identity_key_from_state(old) == instance_key
+ and old.obj() is not None
+ ):
+ util.warn(
+ "Identity map already had an identity for %s, "
+ "replacing it with newly flushed object. Are there "
+ "load operations occurring inside of an event handler "
+ "within the flush?" % (instance_key,)
+ )
state._orphaned_outside_of_session = False
statelib.InstanceState._commit_all_states(
)
self.session.add(lp)
+ # ensure we flush the tables in a specific order as the replayable
+ # session assumes this
+ self.session.flush()
+
# Animals
leopard = Animal(Species="Leopard", Lifespan=73.5)
session.add(a_editable)
session.commit()
+ # see also much more recent issue #4890 where we add a warning
+ # for almost this same case
+
# do the switch in both directions -
# one or the other should raise the error
# based on platform dictionary ordering
import sqlalchemy as sa
from sqlalchemy import event
from sqlalchemy import ForeignKey
+from sqlalchemy import inspect
from sqlalchemy import Integer
from sqlalchemy import Sequence
from sqlalchemy import String
sa.orm.attributes.instance_state(u2),
)
+ def test_internal_identity_conflict_warning_weak(self):
+ self._test_internal_identity_conflict_warning(True)
+
+ def test_internal_identity_conflict_warning_strong(self):
+ self._test_internal_identity_conflict_warning(False)
+
+ def _test_internal_identity_conflict_warning(self, weak_identity_map):
+ # test for issue #4890
+ # see also test_naturalpks::ReversePKsTest::test_reverse
+ users, User = self.tables.users, self.classes.User
+ addresses, Address = self.tables.addresses, self.classes.Address
+
+ mapper(
+ User,
+ users,
+ properties={"addresses": relationship(Address, backref="user")},
+ )
+ mapper(Address, addresses)
+
+ with testing.expect_deprecated():
+ session = Session(weak_identity_map=weak_identity_map)
+
+ @event.listens_for(session, "after_flush")
+ def load_collections(session, flush_context):
+ for target in set(session.new).union(session.dirty):
+ if isinstance(target, User):
+ target.addresses
+
+ u1 = User(name="u1")
+ a1 = Address(email_address="e1", user=u1)
+ session.add_all([u1, a1])
+ session.flush()
+
+ session.expire_all()
+
+ # create new Address via backref, so that u1.addresses remains
+ # expired and a2 is in pending mutations
+ a2 = Address(email_address="e2", user=u1)
+ assert "addresses" not in inspect(u1).dict
+ assert a2 in inspect(u1)._pending_mutations["addresses"].added_items
+
+ with assertions.expect_warnings(
+ r"Identity map already had an identity "
+ r"for \(.*Address.*\), replacing"
+ ):
+ session.flush()
+
def test_pickled_update(self):
users, User = self.tables.users, pickleable.User