summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMike Bayer <mike_mp@zzzcomputing.com>2016-10-13 12:27:18 -0400
committerMike Bayer <mike_mp@zzzcomputing.com>2016-10-17 11:29:23 -0400
commitc02675b407b8326643b559770d6d9686b880c113 (patch)
tree761ef462196141ff0fe7e92300e24459194fab8a
parentae7d2837b3c5ae3fd6e9dad6b14a26abb32cfee5 (diff)
downloadsqlalchemy-c02675b407b8326643b559770d6d9686b880c113.tar.gz
Memoize load_path in all cases, run quick populators for path change
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
-rw-r--r--doc/build/changelog/changelog_11.rst11
-rw-r--r--lib/sqlalchemy/ext/baked.py8
-rw-r--r--lib/sqlalchemy/orm/loading.py42
-rw-r--r--test/orm/test_deferred.py48
-rw-r--r--test/orm/test_expire.py41
-rw-r--r--test/orm/test_merge.py2
6 files changed, 137 insertions, 15 deletions
diff --git a/doc/build/changelog/changelog_11.rst b/doc/build/changelog/changelog_11.rst
index 127227ded..45d98aa22 100644
--- a/doc/build/changelog/changelog_11.rst
+++ b/doc/build/changelog/changelog_11.rst
@@ -40,6 +40,17 @@
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
diff --git a/lib/sqlalchemy/ext/baked.py b/lib/sqlalchemy/ext/baked.py
index 2f658edf3..a6191f5cb 100644
--- a/lib/sqlalchemy/ext/baked.py
+++ b/lib/sqlalchemy/ext/baked.py
@@ -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))
diff --git a/lib/sqlalchemy/orm/loading.py b/lib/sqlalchemy/orm/loading.py
index d457f3c63..5ee8f6abe 100644
--- a/lib/sqlalchemy/orm/loading.py
+++ b/lib/sqlalchemy/orm/loading.py
@@ -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"]:
diff --git a/test/orm/test_deferred.py b/test/orm/test_deferred.py
index 957e1d419..1ae6cbd89 100644
--- a/test/orm/test_deferred.py
+++ b/test/orm/test_deferred.py
@@ -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'
diff --git a/test/orm/test_expire.py b/test/orm/test_expire.py
index 158957ebb..5399bff80 100644
--- a/test/orm/test_expire.py
+++ b/test/orm/test_expire.py
@@ -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"""
diff --git a/test/orm/test_merge.py b/test/orm/test_merge.py
index 8a3419ecc..2676ebc4a 100644
--- a/test/orm/test_merge.py
+++ b/test/orm/test_merge.py
@@ -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: