]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Memoize load_path in all cases, run quick populators for path change
authorMike Bayer <mike_mp@zzzcomputing.com>
Thu, 13 Oct 2016 16:27:18 +0000 (12:27 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Mon, 17 Oct 2016 15:29:23 +0000 (11:29 -0400)
Adds a new variant to the "isnew" state within entity loading
for isnew=False, but the load path is new.  This is to address
the use case of an entity appearing in multiple places in
the row in a more generalized way than the fixes in [ticket:3431],
[ticket:3811] in that loading.py will be able to tell the
populator that this row is not "isnew" but is a "new" path
for the entity.   For the moment, the new information is only
being applied to the use of "quick" populators so that
simple column loads can take place on top of a deferred loader
from elsewhere in the row.

As part of this change, state.load_path() will now always
be populated with the "path" that was in effect when this state
was originally loaded, which for multi-path loads of the
same entity is still non-deterministic.  Ideally there'd be some
kind of "here's all the paths that loaded this state and how"
type of data structure though it's not clear if that could be
done while maintaining performance.

Fixes: #3822
Change-Id: Ib915365353dfcca09e15c24001a8581113b97d5e

doc/build/changelog/changelog_11.rst
lib/sqlalchemy/ext/baked.py
lib/sqlalchemy/orm/loading.py
test/orm/test_deferred.py
test/orm/test_expire.py
test/orm/test_merge.py

index 127227ded9e03f657cb70d9de7e2102ef3892a17..45d98aa223efd053e4963179f31b0831a1c6534c 100644 (file)
         in [ticket:1495], where the rule would fail if the parent object
         had some other lazyloader-bound query options associated with it.
 
+    .. change::
+        :tags: bug, orm
+        :tickets: 3822
+
+        Fixed self-referential entity, deferred column loading issue in a
+        similar style as that of [ticket:3431], [ticket:3811] where an entity
+        is present in multiple positions within the row due to self-referential
+        eager loading; when the deferred loader only applies to one of the
+        paths, the "present" column loader will now override the deferred non-
+        load for that entity regardless of row ordering.
+
 .. changelog::
     :version: 1.1.1
     :released: October 7, 2016
index 2f658edf3ab2e5ebeabae84d90f96d7b55115bb2..a6191f5cba5dbc1ec1c35ddbd4fe28d56c70d807 100644 (file)
@@ -441,14 +441,12 @@ class BakedLazyLoader(strategies.LazyLoader):
         if pending or passive & attributes.NO_AUTOFLUSH:
             q.add_criteria(lambda q: q.autoflush(False))
 
-        if state.load_path:
+        if state.load_options:
             q.spoil()
+            args = state.load_path[self.parent_property]
             q.add_criteria(
                 lambda q:
-                q._with_current_path(state.load_path[self.parent_property]))
-
-        if state.load_options:
-            q.spoil()
+                q._with_current_path(args), args)
             q.add_criteria(
                 lambda q: q._conditional_options(*state.load_options))
 
index d457f3c6375a254d004b6a76d7890e12dd9fb3fd..5ee8f6abe8013bade1c79f60b1ac6d4adbe9a8d3 100644 (file)
@@ -330,9 +330,8 @@ def _instance_processor(
                 context, path, mapper, result, adapter, populators)
 
     propagate_options = context.propagate_options
-    if propagate_options:
-        load_path = context.query._current_path + path \
-            if context.query._current_path.path else path
+    load_path = context.query._current_path + path \
+        if context.query._current_path.path else path
 
     session_identity_map = context.session.identity_map
 
@@ -426,12 +425,15 @@ def _instance_processor(
             # full population routines.  Objects here are either
             # just created, or we are doing a populate_existing
 
-            if isnew and propagate_options:
+            # be conservative about setting load_path when populate_existing
+            # is in effect; want to maintain options from the original
+            # load.  see test_expire->test_refresh_maintains_deferred_options
+            if isnew and (propagate_options or not populate_existing):
                 state.load_options = propagate_options
                 state.load_path = load_path
 
             _populate_full(
-                context, row, state, dict_, isnew,
+                context, row, state, dict_, isnew, load_path,
                 loaded_instance, populate_existing, populators)
 
             if isnew:
@@ -463,7 +465,7 @@ def _instance_processor(
                 # and add to the "context.partials" collection.
 
                 to_load = _populate_partial(
-                    context, row, state, dict_, isnew,
+                    context, row, state, dict_, isnew, load_path,
                     unloaded, populators)
 
                 if isnew:
@@ -486,7 +488,7 @@ def _instance_processor(
 
 
 def _populate_full(
-        context, row, state, dict_, isnew,
+        context, row, state, dict_, isnew, load_path,
         loaded_instance, populate_existing, populators):
     if isnew:
         # first time we are seeing a row with this identity.
@@ -507,15 +509,37 @@ def _populate_full(
             populator(state, dict_, row)
         for key, populator in populators["delayed"]:
             populator(state, dict_, row)
+    elif load_path != state.load_path:
+        # new load path, e.g. object is present in more than one
+        # column position in a series of rows
+        state.load_path = load_path
+
+        # if we have data, and the data isn't in the dict, OK, let's put
+        # it in.
+        for key, getter in populators["quick"]:
+            if key not in dict_:
+                dict_[key] = getter(row)
+
+        # otherwise treat like an "already seen" row
+        for key, populator in populators["existing"]:
+            populator(state, dict_, row)
+            # TODO:  allow "existing" populator to know this is
+            # a new path for the state:
+            # populator(state, dict_, row, new_path=True)
+
     else:
-        # have already seen rows with this identity.
+        # have already seen rows with this identity in this same path.
         for key, populator in populators["existing"]:
             populator(state, dict_, row)
 
+            # TODO: same path
+            # populator(state, dict_, row, new_path=False)
+
 
 def _populate_partial(
-        context, row, state, dict_, isnew,
+        context, row, state, dict_, isnew, load_path,
         unloaded, populators):
+
     if not isnew:
         to_load = context.partials[state]
         for key, populator in populators["existing"]:
index 957e1d41957baf2d7776e97bcf860a1c9e6fb647..1ae6cbd892142506da26fd8d1f2cf0c0aad45e08 100644 (file)
@@ -680,6 +680,54 @@ class DeferredOptionsTest(AssertsCompiledSQL, _fixtures.FixtureTest):
         )
 
 
+class SelfReferentialMultiPathTest(testing.fixtures.DeclarativeMappedTest):
+    """test for [ticket:3822]"""
+
+    @classmethod
+    def setup_classes(cls):
+        Base = cls.DeclarativeBasic
+
+        class Node(Base):
+            __tablename__ = 'node'
+
+            id = sa.Column(sa.Integer, primary_key=True)
+            parent_id = sa.Column(sa.ForeignKey('node.id'))
+            parent = relationship('Node', remote_side=[id])
+            name = sa.Column(sa.String(10))
+
+    @classmethod
+    def insert_data(cls):
+        Node = cls.classes.Node
+
+        session = Session()
+        session.add_all([
+            Node(id=1, name='name'),
+            Node(id=2, parent_id=1, name='name'),
+            Node(id=3, parent_id=1, name='name')
+        ])
+        session.commit()
+
+    def test_present_overrides_deferred(self):
+        Node = self.classes.Node
+
+        session = Session()
+
+        q = session.query(Node).options(
+            joinedload(Node.parent).load_only(Node.id, Node.parent_id)
+        )
+
+        # Node #1 will appear first as Node.parent and have
+        # deferred applied to Node.name.  it will then appear
+        # as Node in the last row and "name" should be populated.
+        nodes = q.order_by(Node.id.desc()).all()
+
+        def go():
+            for node in nodes:
+                eq_(node.name, 'name')
+
+        self.assert_sql_count(testing.db, go, 0)
+
+
 class InheritanceTest(_Polymorphic):
     __dialect__ = 'default'
 
index 158957ebb53bb6009081367d9af4eed4d24a005a..5399bff803167369fa28342e038a0abecc63ff9e 100644 (file)
@@ -1435,6 +1435,47 @@ class RefreshTest(_fixtures.FixtureTest):
         s.expire(u)
         assert len(u.addresses) == 3
 
+    def test_refresh_maintains_deferred_options(self):
+        # testing a behavior that may have changed with
+        # [ticket:3822]
+        User, Address, Dingaling = self.classes(
+            "User", "Address", "Dingaling")
+        users, addresses, dingalings = self.tables(
+            "users", "addresses", "dingalings")
+
+        mapper(User, users, properties={
+            'addresses': relationship(Address)
+        })
+
+        mapper(Address, addresses, properties={
+            'dingalings': relationship(Dingaling)
+        })
+
+        mapper(Dingaling, dingalings)
+
+        s = create_session()
+        q = s.query(User).filter_by(name='fred').options(
+            sa.orm.lazyload('addresses').joinedload("dingalings"))
+
+        u1 = q.one()
+
+        # "addresses" is not present on u1, but when u1.addresses
+        # lazy loads, it should also joinedload dingalings.  This is
+        # present in state.load_options and state.load_path.   The
+        # refresh operation should not reset these attributes.
+        s.refresh(u1)
+
+        def go():
+            eq_(
+                u1.addresses,
+                [Address(
+                    email_address='fred@fred.com',
+                    dingalings=[Dingaling(data="ding 2/5")]
+                )]
+            )
+
+        self.assert_sql_count(testing.db, go, 1)
+
     def test_refresh2(self):
         """test a hang condition that was occurring on expire/refresh"""
 
index 8a3419eccea49d671793ce2fda7495cb0bc6e336..2676ebc4a53cc466213bf6f15bc4be4c820fabab 100644 (file)
@@ -1124,7 +1124,7 @@ class MergeTest(_fixtures.FixtureTest):
 
         for u in s1_users:
             ustate = attributes.instance_state(u)
-            eq_(ustate.load_path, ())
+            eq_(ustate.load_path.path, (umapper, ))
             eq_(ustate.load_options, set())
 
         for u in s2_users: