]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Run autoflush for column attribute load operations
authorMike Bayer <mike_mp@zzzcomputing.com>
Fri, 3 Apr 2020 00:45:44 +0000 (20:45 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Fri, 3 Apr 2020 17:47:57 +0000 (13:47 -0400)
The "autoflush" behavior of :class:`.Query` will now trigger for nearly
all ORM level attribute load operations, including when a deferred
column is loaded as well as when an expired column is loaded.   Previously,
autoflush on load of expired or unloaded attributes was limited to
relationship-bound attributes only.   However, this led to the issue
where column-based attributes that also depended on other rows, or even
other columns in the same row, in order to express the correct value,
would show an effectively stale value when accessed as there could be
pending changes in the session left to be flushed.    Autoflush
is now disabled only in some cases where attributes are being unexpired in
the context of a history operation.

Fixes: #5226
Change-Id: Ibd965b30918cd273ae020411a704bf2bb1891f59

13 files changed:
doc/build/changelog/unreleased_14/5226.rst [new file with mode: 0644]
lib/sqlalchemy/orm/loading.py
lib/sqlalchemy/orm/query.py
lib/sqlalchemy/orm/session.py
lib/sqlalchemy/orm/state.py
test/ext/test_extendedattr.py
test/orm/test_attributes.py
test/orm/test_cascade.py
test/orm/test_deferred.py
test/orm/test_dynamic.py
test/orm/test_expire.py
test/orm/test_load_on_fks.py
test/orm/test_versioning.py

diff --git a/doc/build/changelog/unreleased_14/5226.rst b/doc/build/changelog/unreleased_14/5226.rst
new file mode 100644 (file)
index 0000000..1436d7b
--- /dev/null
@@ -0,0 +1,17 @@
+.. change::
+    :tags: bug, orm
+    :tickets: 5226
+
+    The refresh of an expired object will now trigger an autoflush if the list
+    of expired attributes include one or more attributes that were explicitly
+    expired or refreshed using the :meth:`.Session.expire` or
+    :meth:`.Session.refresh` methods.   This is an attempt to find a middle
+    ground between the normal unexpiry of attributes that can happen in many
+    cases where autoflush is not desirable, vs. the case where attributes are
+    being explicitly expired or refreshed and it is possible that these
+    attributes depend upon other pending state within the session that needs to
+    be flushed.   The two methods now also gain a new flag
+    :paramref:`.Session.expire.autoflush` and
+    :paramref:`.Session.refresh.autoflush`, defaulting to True; when set to
+    False, this will disable the autoflush that occurs on unexpire for these
+    attributes.
index 49c71e5b2c8c15fc0d60ee6566e7e63fe3352965..b7ef96e1a4a35d008e807682d129ef703951acf4 100644 (file)
@@ -194,7 +194,12 @@ def get_from_identity(session, mapper, key, passive):
 
 
 def load_on_ident(
-    query, key, refresh_state=None, with_for_update=None, only_load_props=None
+    query,
+    key,
+    refresh_state=None,
+    with_for_update=None,
+    only_load_props=None,
+    no_autoflush=False,
 ):
     """Load the given identity key from the database."""
     if key is not None:
@@ -203,6 +208,9 @@ def load_on_ident(
     else:
         ident = identity_token = None
 
+    if no_autoflush:
+        query = query.autoflush(False)
+
     return load_on_pk_identity(
         query,
         ident,
@@ -992,7 +1000,7 @@ class PostLoad(object):
         pl.loaders[token] = (token, limit_to_mapper, loader_callable, arg, kw)
 
 
-def load_scalar_attributes(mapper, state, attribute_names):
+def load_scalar_attributes(mapper, state, attribute_names, passive):
     """initiate a column-based attribute refresh operation."""
 
     # assert mapper is _state_mapper(state)
@@ -1007,6 +1015,8 @@ def load_scalar_attributes(mapper, state, attribute_names):
 
     result = False
 
+    no_autoflush = passive & attributes.NO_AUTOFLUSH
+
     # in the case of inheritance, particularly concrete and abstract
     # concrete inheritance, the class manager might have some keys
     # of attributes on the superclass that we didn't actually map.
@@ -1031,6 +1041,7 @@ def load_scalar_attributes(mapper, state, attribute_names):
                 None,
                 only_load_props=attribute_names,
                 refresh_state=state,
+                no_autoflush=no_autoflush,
             )
 
     if result is False:
@@ -1068,6 +1079,7 @@ def load_scalar_attributes(mapper, state, attribute_names):
             identity_key,
             refresh_state=state,
             only_load_props=attribute_names,
+            no_autoflush=no_autoflush,
         )
 
     # if instance is pending, a refresh operation
index 4ba82d1e88b0cd43eccb668e722af0741e504318..d001ab983e5e3aeaa3dee9d0c9d44bcae44d2546 100644 (file)
@@ -3323,7 +3323,7 @@ class Query(Generative):
     def __iter__(self):
         context = self._compile_context()
         context.statement.label_style = LABEL_STYLE_TABLENAME_PLUS_COL
-        if self._autoflush and not self._populate_existing:
+        if self._autoflush:
             self.session._autoflush()
         return self._execute_and_instances(context)
 
index c773aeb081297b58d498299b6f7fe268d94b787b..aa55fab58f92840e05c6150f8c4cae74eb2a978c 100644 (file)
@@ -1663,9 +1663,7 @@ class Session(_SessionClassMethods):
                 )
                 util.raise_(e, with_traceback=sys.exc_info()[2])
 
-    def refresh(
-        self, instance, attribute_names=None, with_for_update=None,
-    ):
+    def refresh(self, instance, attribute_names=None, with_for_update=None):
         """Expire and refresh the attributes on the given instance.
 
         A query will be issued to the database and all attributes will be
@@ -1834,7 +1832,7 @@ class Session(_SessionClassMethods):
             for o, m, st_, dct_ in cascaded:
                 self._conditional_expire(st_)
 
-    def _conditional_expire(self, state):
+    def _conditional_expire(self, state, autoflush=None):
         """Expire a state if persistent, else expunge if pending"""
 
         if state.key:
index 91bf57ab920dae1f4843ab8619c899a371df5d73..5a885b118d6125ac137eae4f29ea493abe887a47 100644 (file)
@@ -570,7 +570,6 @@ class InstanceState(interfaces.InspectionAttrInfo):
 
     def _expire(self, dict_, modified_set):
         self.expired = True
-
         if self.modified:
             modified_set.discard(self)
             self.committed_state.clear()
@@ -665,7 +664,7 @@ class InstanceState(interfaces.InspectionAttrInfo):
             if not self.manager[attr].impl.load_on_unexpire
         )
 
-        self.manager.expired_attribute_loader(self, toload)
+        self.manager.expired_attribute_loader(self, toload, passive)
 
         # if the loader failed, or this
         # instance state didn't have an identity,
index df9d2f9d56dd84564c3cf8a1c6b13361a975fe9a..ad9bf0bc05005085b4a7f0f59572befd68f4656d 100644 (file)
@@ -223,7 +223,7 @@ class UserDefinedExtensionTest(_ExtBase, fixtures.ORMTest):
 
             data = {"a": "this is a", "b": 12}
 
-            def loader(state, keys):
+            def loader(state, keys, passive):
                 for k in keys:
                     state.dict[k] = data[k]
                 return attributes.ATTR_WAS_SET
index 347dd4e46d46ebaa014a9893d18939d96d3696cf..bb3399a5a7ead1e10200687816ad558bcce670a7 100644 (file)
@@ -442,7 +442,7 @@ class AttributesTest(fixtures.ORMTest):
 
         data = {"a": "this is a", "b": 12}
 
-        def loader(state, keys):
+        def loader(state, keys, passive):
             for k in keys:
                 state.dict[k] = data[k]
             return attributes.ATTR_WAS_SET
@@ -488,7 +488,7 @@ class AttributesTest(fixtures.ORMTest):
     def test_deferred_pickleable(self):
         data = {"a": "this is a", "b": 12}
 
-        def loader(state, keys):
+        def loader(state, keys, passive):
             for k in keys:
                 state.dict[k] = data[k]
             return attributes.ATTR_WAS_SET
@@ -2242,7 +2242,7 @@ class HistoryTest(fixtures.TestBase):
         state.dict.pop("someattr", None)
         state.expired_attributes.add("someattr")
 
-        def scalar_loader(state, toload):
+        def scalar_loader(state, toload, passive):
             state.dict["someattr"] = "one"
 
         state.manager.expired_attribute_loader = scalar_loader
index cfc3ad38f8a876608fbfc30a322590bca1b81b18..815df36203998049ced8c3b03cbaa582aaaaa3f2 100644 (file)
@@ -842,6 +842,17 @@ class O2OSingleParentNoFlushTest(fixtures.MappedTest):
         sess.add(u1)
         sess.commit()
 
+        # in this case, u1.address has active history set, because
+        # this operation necessarily replaces the old object which must be
+        # loaded.
+        # the set operation requires that "u1" is unexpired, because the
+        # replace operation wants to load the
+        # previous value.  The original test case for #2921 only included
+        # that the lazyload operation passed a no autoflush flag through
+        # to the operation, however in #5226 this has been enhanced to pass
+        # the no autoflush flag down through to the unexpire of the attributes
+        # as well, so that attribute unexpire can otherwise invoke autoflush.
+        assert "id" not in u1.__dict__
         a2 = Address(email_address="asdf")
         sess.add(a2)
         u1.address = a2
index a7957ec2810d914431411c62914ea384ec9e2c84..a02ee250ca28bfebe0e5830267fb949108d73c99 100644 (file)
@@ -1,6 +1,8 @@
 import sqlalchemy as sa
 from sqlalchemy import ForeignKey
+from sqlalchemy import func
 from sqlalchemy import Integer
+from sqlalchemy import select
 from sqlalchemy import testing
 from sqlalchemy import util
 from sqlalchemy.orm import aliased
@@ -2023,3 +2025,46 @@ class RaiseLoadTest(fixtures.DeclarativeMappedTest):
         eq_(a1.id, 1)
 
         assert "x" in a1.__dict__
+
+
+class AutoflushTest(fixtures.DeclarativeMappedTest):
+    @classmethod
+    def setup_classes(cls):
+        Base = cls.DeclarativeBasic
+
+        class A(Base):
+            __tablename__ = "a"
+
+            id = Column(Integer, primary_key=True)
+            bs = relationship("B")
+
+        class B(Base):
+            __tablename__ = "b"
+            id = Column(Integer, primary_key=True)
+            a_id = Column(ForeignKey("a.id"))
+
+        A.b_count = deferred(
+            select([func.count(1)]).where(A.id == B.a_id).scalar_subquery()
+        )
+
+    def test_deferred_autoflushes(self):
+        A, B = self.classes("A", "B")
+
+        s = Session()
+
+        a1 = A(id=1, bs=[B()])
+        s.add(a1)
+        s.commit()
+
+        eq_(a1.b_count, 1)
+        s.close()
+
+        a1 = s.query(A).first()
+        assert "b_count" not in a1.__dict__
+
+        b1 = B(a_id=1)
+        s.add(b1)
+
+        eq_(a1.b_count, 2)
+
+        assert b1 in s
index 94ecf4ee2983351df81c20d0a72a319d9cce93c2..1ca1bec03184e4fbc6c133b0d4e47d36629f588e 100644 (file)
@@ -2,6 +2,7 @@ from sqlalchemy import cast
 from sqlalchemy import desc
 from sqlalchemy import exc
 from sqlalchemy import func
+from sqlalchemy import inspect
 from sqlalchemy import Integer
 from sqlalchemy import select
 from sqlalchemy import testing
@@ -969,17 +970,25 @@ class HistoryTest(_DynamicFixture, _fixtures.FixtureTest):
         elif isinstance(obj, self.classes.Order):
             attrname = "items"
 
-        eq_(attributes.get_history(obj, attrname), compare)
+        sess = inspect(obj).session
 
-        if compare_passive is None:
-            compare_passive = compare
+        if sess:
+            sess.autoflush = False
+        try:
+            eq_(attributes.get_history(obj, attrname), compare)
 
-        eq_(
-            attributes.get_history(
-                obj, attrname, attributes.LOAD_AGAINST_COMMITTED
-            ),
-            compare_passive,
-        )
+            if compare_passive is None:
+                compare_passive = compare
+
+            eq_(
+                attributes.get_history(
+                    obj, attrname, attributes.LOAD_AGAINST_COMMITTED
+                ),
+                compare_passive,
+            )
+        finally:
+            if sess:
+                sess.autoflush = True
 
     def test_append_transient(self):
         u1, a1 = self._transient_fixture()
index 083c2f4651f3fadb2745631632b21ec9ea6d7cd5..127380fad678e8528e30494bbebcbdc9221edc4b 100644 (file)
@@ -82,6 +82,24 @@ class ExpireTest(_fixtures.FixtureTest):
 
         self.assert_sql_count(testing.db, go, 0)
 
+    def test_expire_autoflush(self):
+        User, users = self.classes.User, self.tables.users
+        Address, addresses = self.classes.Address, self.tables.addresses
+
+        mapper(User, users)
+        mapper(Address, addresses, properties={"user": relationship(User)})
+
+        s = Session()
+
+        a1 = s.query(Address).get(2)
+        u1 = s.query(User).get(7)
+        a1.user = u1
+
+        s.expire(a1, ["user_id"])
+
+        # autoflushes
+        eq_(a1.user_id, 7)
+
     def test_persistence_check(self):
         users, User = self.tables.users, self.classes.User
 
@@ -1748,6 +1766,24 @@ class RefreshTest(_fixtures.FixtureTest):
             lambda: s.refresh(u),
         )
 
+    def test_refresh_autoflush(self):
+        User, users = self.classes.User, self.tables.users
+        Address, addresses = self.classes.Address, self.tables.addresses
+
+        mapper(User, users)
+        mapper(Address, addresses, properties={"user": relationship(User)})
+
+        s = Session()
+
+        a1 = s.query(Address).get(2)
+        u1 = s.query(User).get(7)
+        a1.user = u1
+
+        s.refresh(a1, ["user_id"])
+
+        # autoflushes
+        eq_(a1.user_id, 7)
+
     def test_refresh_expired(self):
         User, users = self.classes.User, self.tables.users
 
index 6e9dde16b0dce828cf153f2abe2219ae14f65b19..0e8ac97e3e061245c44697c4438c05ca800d7703 100644 (file)
@@ -176,6 +176,9 @@ class LoadOnFKsTest(AssertsExecutionResults, fixtures.TestBase):
         assert c3 in p1.children
 
     def test_autoflush_on_pending(self):
+        # ensure p1.id is not expired
+        p1.id
+
         c3 = Child()
         sess.add(c3)
         c3.parent_id = p1.id
@@ -184,6 +187,9 @@ class LoadOnFKsTest(AssertsExecutionResults, fixtures.TestBase):
         assert c3.parent is None
 
     def test_autoflush_load_on_pending_on_pending(self):
+        # ensure p1.id is not expired
+        p1.id
+
         Child.parent.property.load_on_pending = True
         c3 = Child()
         sess.add(c3)
@@ -305,6 +311,10 @@ class LoadOnFKsTest(AssertsExecutionResults, fixtures.TestBase):
                 for manualflush in (False, True):
                     Child.parent.property.load_on_pending = loadonpending
                     sess.autoflush = autoflush
+
+                    # ensure p2.id not expired
+                    p2.id
+
                     c2 = Child()
                     sess.add(c2)
                     c2.parent_id = p2.id
index c6418745ddc0b13c0dd7cca287e32ef6ab9f46c9..1c540145bab80bdf74de522dc0936f267ed0e611 100644 (file)
@@ -139,11 +139,11 @@ class NullVersionIdTest(fixtures.MappedTest):
         # you should get a FlushError on update.
 
         f1.value = "f1rev2"
-        f1.version_id = None
 
         with conditional_sane_rowcount_warnings(
             update=True, only_returning=True
         ):
+            f1.version_id = None
             assert_raises_message(
                 sa.orm.exc.FlushError,
                 "Instance does not contain a non-NULL version value",
@@ -1973,10 +1973,9 @@ class VersioningMappedSelectTest(fixtures.MappedTest):
 
         s1.expire_all()
 
-        f1.value = "f2"
-        f1.version_id = 2
-
         with conditional_sane_rowcount_warnings(
             update=True, only_returning=True
         ):
+            f1.value = "f2"
+            f1.version_id = 2
             s1.flush()