Behavioral Changes - ORM
========================
+.. _change_1763:
+
+Eager loaders emit during unexpire operations
+---------------------------------------------
+
+A long sought behavior was that when an expired object is accessed, configured
+eager loaders will run in order to eagerly load relationships on the expired
+object when the object is refreshed or otherwise unexpired. This behavior has
+now been added, so that joinedloaders will add inline JOINs as usual, and
+selectin/subquery loaders will run an "immediateload" operation for a given
+relationship, when an expired object is unexpired or an object is refreshed::
+
+ >>> a1 = session.query(A).options(joinedload(A.bs)).first()
+ >>> a1.data = 'new data'
+ >>> session.commit()
+
+Above, the ``A`` object was loaded with a ``joinedload()`` option associated
+with it in order to eagerly load the ``bs`` collection. After the
+``session.commit()``, the state of the object is expired. Upon accessing
+the ``.data`` column attribute, the object is refreshed and this will now
+include the joinedload operation as well::
+
+ >>> a1.data
+ SELECT a.id AS a_id, a.data AS a_data, b_1.id AS b_1_id, b_1.a_id AS b_1_a_id
+ FROM a LEFT OUTER JOIN b AS b_1 ON a.id = b_1.a_id
+ WHERE a.id = ?
+
+The behavior applies both to loader strategies applied to the
+:func:`.relationship` directly, as well as with options used with
+:meth:`.Query.options`, provided that the object was originally loaded by that
+query.
+
+For the "secondary" eager loaders "selectinload" and "subqueryload", the SQL
+strategy for these loaders is not necessary in order to eagerly load attributes
+on a single object; so they will instead invoke the "immediateload" strategy in
+a refresh scenario, which resembles the query emitted by "lazyload", emitted as
+an additional query::
+
+ >>> a1 = session.query(A).options(selectinload(A.bs)).first()
+ >>> a1.data = 'new data'
+ >>> session.commit()
+ >>> a1.data
+ SELECT a.id AS a_id, a.data AS a_data
+ FROM a
+ WHERE a.id = ?
+ (1,)
+ SELECT b.id AS b_id, b.a_id AS b_a_id
+ FROM b
+ WHERE ? = b.a_id
+ (1,)
+
+Note that a loader option does not apply to an object that was introduced
+into the :class:`.Session` in a different way. That is, if the ``a1`` object
+were just persisted in this :class:`.Session`, or was loaded with a different
+query before the eager option had been applied, then the object doesn't have
+an eager load option associated with it. This is not a new concept, however
+users who are looking for the eagerload on refresh behavior may find this
+to be more noticeable.
+
+:ticket:`1763`
+
.. _change_4519:
Accessing an uninitialized collection attribute on a transient object no longer mutates __dict__
--- /dev/null
+.. change::
+ :tags: feature, orm
+ :tickets: 1763
+
+ Eager loaders, such as joined loading, SELECT IN loading, etc., when
+ configured on a mapper or via query options will now be invoked during
+ the refresh on an expired object; in the case of selectinload and
+ subqueryload, since the additional load is for a single object only,
+ the "immediateload" scheme is used in these cases which resembles the
+ single-parent query emitted by lazy loading.
+
+ .. seealso::
+
+ :ref:`change_1763`
if not passive & CALLABLES_OK:
return PASSIVE_NO_RESULT
- if key in state.expired_attributes:
+ if self.accepts_scalar_loader and \
+ key in state.expired_attributes:
value = state._load_expired(state, passive)
elif key in state.callables:
callable_ = state.callables[key]
]
)
+ @_memoized_key_collection
+ def _loader_impls(self):
+ return frozenset([attr.impl for attr in self.values()])
+
@util.memoized_property
def mapper(self):
# raises unless self.mapper has been assigned
else:
version_check = False
+ if refresh_state and refresh_state.load_options:
+ q = q._with_current_path(refresh_state.load_path.parent)
+ q = q._conditional_options(refresh_state.load_options)
+
q._get_options(
populate_existing=bool(refresh_state),
version_check=version_check,
"""
props = self._props
+ col_attribute_names = set(attribute_names).intersection(
+ state.mapper.column_attrs.keys()
+ )
tables = set(
chain(
*[
sql_util.find_tables(c, check_columns=True)
- for key in attribute_names
+ for key in col_attribute_names
for c in props[key].columns
]
)
cond = sql.and_(*allconds)
cols = []
- for key in attribute_names:
+ for key in col_attribute_names:
cols.extend(props[key].columns)
return sql.select(cols, cond, use_labels=True)
self.expired_attributes.update(
[
impl.key
- for impl in self.manager._scalar_loader_impls
+ for impl in self.manager._loader_impls
if impl.expire_missing or impl.key in dict_
]
)
if self.callables:
+ # the per state loader callables we can remove here are
+ # LoadDeferredColumns, which undefers a column at the instance
+ # level that is mapped with deferred, and LoadLazyAttribute,
+ # which lazy loads a relationship at the instance level that
+ # is mapped with "noload" or perhaps "immediateload".
+ # Before 1.4, only column-based
+ # attributes could be considered to be "expired", so here they
+ # were the only ones "unexpired", which means to make them deferred
+ # again. For the moment, as of 1.4 we also apply the same
+ # treatment relationships now, that is, an instance level lazy
+ # loader is reset in the same way as a column loader.
for k in self.expired_attributes.intersection(self.callables):
del self.callables[k]
if _none_set.issuperset(primary_key_identity):
return None
+ if self.key in state.dict:
+ return attributes.ATTR_WAS_SET
+
# look for this identity in the identity map. Delegate to the
# Query class in use, as it may have special rules for how it
# does this, including how it decides what the correct
)
lazy_clause, params = self._generate_lazy_clause(state, passive)
+ if self.key in state.dict:
+ return attributes.ATTR_WAS_SET
if pending:
if util.has_intersection(orm_util._none_set, params.values()):
return strategy._load_for_state(state, passive)
+class PostLoader(AbstractRelationshipLoader):
+ """A relationship loader that emits a second SELECT statement."""
+
+ def _immediateload_create_row_processor(
+ self, context, path, loadopt, mapper, result, adapter, populators
+ ):
+ return self.parent_property._get_strategy(
+ (("lazy", "immediate"),)
+ ).create_row_processor(
+ context, path, loadopt, mapper, result, adapter, populators
+ )
+
+
@properties.RelationshipProperty.strategy_for(lazy="immediate")
-class ImmediateLoader(AbstractRelationshipLoader):
+class ImmediateLoader(PostLoader):
__slots__ = ()
def init_class_attribute(self, mapper):
@log.class_logger
@properties.RelationshipProperty.strategy_for(lazy="subquery")
-class SubqueryLoader(AbstractRelationshipLoader):
+class SubqueryLoader(PostLoader):
__slots__ = ("join_depth",)
def __init__(self, parent, strategy_key):
**kwargs
):
- if not context.query._enable_eagerloads:
+ if not context.query._enable_eagerloads or context.refresh_state:
return
elif context.query._yield_per:
context.query._no_yield_per("subquery")
def create_row_processor(
self, context, path, loadopt, mapper, result, adapter, populators
):
+ if context.refresh_state:
+ return self._immediateload_create_row_processor(
+ context, path, loadopt, mapper, result, adapter, populators
+ )
+
if not self.parent.class_manager[self.key].impl.supports_population:
raise sa_exc.InvalidRequestError(
"'%s' does not support object "
@log.class_logger
@properties.RelationshipProperty.strategy_for(lazy="selectin")
-class SelectInLoader(AbstractRelationshipLoader, util.MemoizedSlots):
+class SelectInLoader(PostLoader, util.MemoizedSlots):
__slots__ = (
"join_depth",
"omit_join",
def create_row_processor(
self, context, path, loadopt, mapper, result, adapter, populators
):
+ if context.refresh_state:
+ return self._immediateload_create_row_processor(
+ context, path, loadopt, mapper, result, adapter, populators
+ )
+
if not self.parent.class_manager[self.key].impl.supports_population:
raise sa_exc.InvalidRequestError(
"'%s' does not support object "
sess = Session(autoflush=False)
return User, Address, sess
- def test_no_query_on_refresh(self):
+ def test_runs_query_on_refresh(self):
User, Address, sess = self._eager_config_fixture()
u1 = sess.query(User).get(8)
eq_(u1.id, 8)
self.assert_sql_count(testing.db, go, 1)
- assert "addresses" not in u1.__dict__
+
+ assert 'addresses' in u1.__dict__
def test_populate_existing_propagate(self):
# both SelectInLoader and SubqueryLoader receive the loaded collection
from sqlalchemy.orm import defer
from sqlalchemy.orm import deferred
from sqlalchemy.orm import exc as orm_exc
+from sqlalchemy.orm import joinedload
from sqlalchemy.orm import lazyload
from sqlalchemy.orm import make_transient_to_detached
from sqlalchemy.orm import mapper
assert u.addresses[0].email_address == "jack@bean.com"
assert u.name == "jack"
- # two loads, since relationship() + scalar are
- # separate right now on per-attribute load
- self.assert_sql_count(testing.db, go, 2)
+ # one load, due to #1763 allows joinedload to
+ # take over
+ self.assert_sql_count(testing.db, go, 1)
assert "name" in u.__dict__
assert "addresses" in u.__dict__
assert "name" in u.__dict__
assert len(u.addresses) == 2
- def test_joinedload_props_dontload(self):
+ def test_mapper_joinedload_props_load(self):
users, Address, addresses, User = (
self.tables.users,
self.classes.Address,
self.classes.User,
)
- # relationships currently have to load separately from scalar instances
- # the use case is: expire "addresses". then access it. lazy load
- # fires off to load "addresses", but needs foreign key or primary key
- # attributes in order to lazy load; hits those attributes, such as
- # below it hits "u.id". "u.id" triggers full unexpire operation,
- # joinedloads addresses since lazy='joined'. this is all within lazy
- # load which fires unconditionally; so an unnecessary joinedload (or
- # lazyload) was issued. would prefer not to complicate lazyloading to
- # "figure out" that the operation should be aborted right now.
+ # changed in #1763, eager loaders are run when we unexpire
mapper(
User,
u = sess.query(User).get(8)
sess.expire(u)
u.id
- assert "addresses" not in u.__dict__
+
+ assert "addresses" in u.__dict__
+ u.addresses
+ assert "addresses" in u.__dict__
+
+ def test_options_joinedload_props_load(self):
+ users, Address, addresses, User = (
+ self.tables.users,
+ self.classes.Address,
+ self.tables.addresses,
+ self.classes.User,
+ )
+
+ # changed in #1763, eager loaders are run when we unexpire
+
+ mapper(
+ User,
+ users,
+ properties={"addresses": relationship(Address, backref="user")},
+ )
+ mapper(Address, addresses)
+ sess = create_session()
+ u = sess.query(User).options(joinedload(User.addresses)).get(8)
+ sess.expire(u)
+ u.id
+ assert "addresses" in u.__dict__
u.addresses
assert "addresses" in u.__dict__
+ def test_joinedload_props_load_two(self):
+ users, Address, addresses, User = (
+ self.tables.users,
+ self.classes.Address,
+ self.tables.addresses,
+ self.classes.User,
+ )
+ mapper(
+ User,
+ users,
+ properties={
+ "addresses": relationship(
+ Address, backref="user", lazy="joined"
+ )
+ },
+ )
+ mapper(Address, addresses)
+ sess = create_session()
+ u = sess.query(User).get(8)
+ sess.expire(u)
+
+ # here, the lazy loader will encounter the attribute already
+ # loaded when it goes to get the PK, so the loader itself
+ # needs to no longer fire off.
+ def go():
+ u.addresses
+ assert "addresses" in u.__dict__
+ assert "id" in u.__dict__
+
+ self.assert_sql_count(testing.db, go, 1)
+
def test_expire_synonym(self):
User, users = self.classes.User, self.tables.users
attributes.instance_state(u1).callables["addresses"],
strategies.LoadLazyAttribute,
)
- # expire, it stays
+ # expire, it goes away from callables as of 1.4 and is considered
+ # to be expired
sess.expire(u1)
- assert (
- "addresses" not in attributes.instance_state(u1).expired_attributes
- )
- assert isinstance(
- attributes.instance_state(u1).callables["addresses"],
- strategies.LoadLazyAttribute,
- )
- # load over it. callable goes away.
+ assert "addresses" in attributes.instance_state(u1).expired_attributes
+ assert "addresses" not in attributes.instance_state(u1).callables
+
+ # load it
sess.query(User).first()
assert (
"addresses" not in attributes.instance_state(u1).expired_attributes
sess = Session(autoflush=False)
return User, Address, sess
- def test_no_query_on_refresh(self):
+ def test_runs_query_on_refresh(self):
User, Address, sess = self._eager_config_fixture()
u1 = sess.query(User).get(8)
def go():
eq_(u1.id, 8)
- self.assert_sql_count(testing.db, go, 1)
- assert "addresses" not in u1.__dict__
+ self.assert_sql_count(testing.db, go, 2)
+ assert 'addresses' in u1.__dict__
def test_no_query_on_deferred(self):
User, Address, sess = self._deferred_config_fixture()
sess = Session(autoflush=False)
return User, Address, sess
- def test_no_query_on_refresh(self):
+ def test_runs_query_on_refresh(self):
User, Address, sess = self._eager_config_fixture()
u1 = sess.query(User).get(8)
def go():
eq_(u1.id, 8)
- self.assert_sql_count(testing.db, go, 1)
- assert "addresses" not in u1.__dict__
+ self.assert_sql_count(testing.db, go, 2)
+ assert "addresses" in u1.__dict__
def test_no_query_on_deferred(self):
User, Address, sess = self._deferred_config_fixture()