From 33921261f8ebfd710ffa6e855d90c142ceb3303c Mon Sep 17 00:00:00 2001 From: Adrian Moennich Date: Mon, 11 Apr 2016 23:15:15 -0400 Subject: [PATCH] Add raise/raiseload relationship loading strategy Fixes: #3512 Co-Authored-By: Mike Bayer Change-Id: Ibd126c50eda621e2f4120ee378f7313af2d7ec3c Pull-request: https://github.com/zzzeek/sqlalchemy/pull/193 --- doc/build/changelog/changelog_11.rst | 14 +++ doc/build/changelog/migration_11.rst | 20 +++++ doc/build/orm/collections.rst | 32 ++++++- doc/build/orm/loading_relationships.rst | 6 +- lib/sqlalchemy/orm/__init__.py | 1 + lib/sqlalchemy/orm/relationships.py | 8 ++ lib/sqlalchemy/orm/strategies.py | 27 ++++++ lib/sqlalchemy/orm/strategy_options.py | 29 +++++++ test/orm/test_mapper.py | 109 ++++++++++++++++++++++++ 9 files changed, 241 insertions(+), 5 deletions(-) diff --git a/doc/build/changelog/changelog_11.rst b/doc/build/changelog/changelog_11.rst index 53bd38a98e..613f76beb9 100644 --- a/doc/build/changelog/changelog_11.rst +++ b/doc/build/changelog/changelog_11.rst @@ -840,6 +840,20 @@ :ref:`change_2528` + .. change:: + :tags: feature, orm + :tickets: 3512 + :pullreq: github:193 + + Added new relationship loading strategy :func:`.orm.raiseload` (also + accessible via ``lazy='raise'``). This strategy behaves almost like + :func:`.orm.noload` but instead of returning ``None`` it raises an + InvalidRequestError. Pull request courtesy Adrian Moennich. + + .. seealso:: + + :ref:`change_3512` + .. change:: :tags: bug, mssql :tickets: 3504 diff --git a/doc/build/changelog/migration_11.rst b/doc/build/changelog/migration_11.rst index 6f0da37809..12fa42ea7d 100644 --- a/doc/build/changelog/migration_11.rst +++ b/doc/build/changelog/migration_11.rst @@ -820,6 +820,26 @@ added to the :ref:`mutable_toplevel` extension, to complement the existing :ticket:`3297` +.. _change_3512: + +New "raise" loader strategy +--------------------------- + +To assist with the use case of preventing unwanted lazy loads from occurring +after a series of objects are loaded, the new "lazy='raise'" strategy and +corresponding loader option :func:`.orm.raiseload` may be applied to a +relationship attribute which will cause it to raise ``InvalidRequestError`` +when a non-eagerly-loaded attribute is accessed for read:: + + >>> from sqlalchemy.orm import raiseload + >>> a1 = s.query(A).options(raiseload(A.bs)).first() + >>> a1.bs + Traceback (most recent call last): + ... + sqlalchemy.exc.InvalidRequestError: 'A.bs' is not available due to lazy='raise' + +:ticket:`3512` + New Features and Improvements - Core ==================================== diff --git a/doc/build/orm/collections.rst b/doc/build/orm/collections.rst index 577cd233ec..f37a36b405 100644 --- a/doc/build/orm/collections.rst +++ b/doc/build/orm/collections.rst @@ -91,8 +91,10 @@ Note that eager/lazy loading options cannot be used in conjunction dynamic relat relationships. Newer versions of SQLAlchemy emit warnings or exceptions in these cases. -Setting Noload ---------------- +.. _collections_noload_raiseload: + +Setting Noload, RaiseLoad +------------------------- A "noload" relationship never loads from the database, even when accessed. It is configured using ``lazy='noload'``:: @@ -105,7 +107,31 @@ accessed. It is configured using ``lazy='noload'``:: Above, the ``children`` collection is fully writeable, and changes to it will be persisted to the database as well as locally available for reading at the time they are added. However when instances of ``MyClass`` are freshly loaded -from the database, the ``children`` collection stays empty. +from the database, the ``children`` collection stays empty. The noload +strategy is also available on a query option basis using the +:func:`.orm.noload` loader option. + +Alternatively, a "raise"-loaded relationship will raise an +:exc:`~sqlalchemy.exc.InvalidRequestError` where the attribute would normally +emit a lazy load:: + + class MyClass(Base): + __tablename__ = 'some_table' + + children = relationship(MyOtherClass, lazy='raise') + +Above, attribute access on the ``children`` collection will raise an exception +if it was not previously eagerloaded. This includes read access but for +collections will also affect write access, as collections can't be mutated +without first loading them. The rationale for this is to ensure that an +application is not emitting any unexpected lazy loads within a certain context. +Rather than having to read through SQL logs to determine that all necessary +attributes were eager loaded, the "raise" strategy will cause unloaded +attributes to raise immediately if accessed. The raise strategy is +also available on a query option basis using the :func:`.orm.raiseload` +loader option. + +.. versionadded:: 1.1 added the "raise" loader strategy. .. _passive_deletes: diff --git a/doc/build/orm/loading_relationships.rst b/doc/build/orm/loading_relationships.rst index 3a0026bbe5..941edce2c4 100644 --- a/doc/build/orm/loading_relationships.rst +++ b/doc/build/orm/loading_relationships.rst @@ -185,8 +185,8 @@ Default Loading Strategies Default loader strategies as a new feature. Each of :func:`.joinedload`, :func:`.subqueryload`, :func:`.lazyload`, -and :func:`.noload` can be used to set the default style of -:func:`.relationship` loading +:func:`.noload`, and :func:`.raiseload` can be used to set the default +style of :func:`.relationship` loading for a particular query, affecting all :func:`.relationship` -mapped attributes not otherwise specified in the :class:`.Query`. This feature is available by passing @@ -661,6 +661,8 @@ Relationship Loader API .. autofunction:: noload +.. autofunction:: raiseload + .. autofunction:: subqueryload .. autofunction:: subqueryload_all diff --git a/lib/sqlalchemy/orm/__init__.py b/lib/sqlalchemy/orm/__init__.py index 7425737cef..d822c83cbc 100644 --- a/lib/sqlalchemy/orm/__init__.py +++ b/lib/sqlalchemy/orm/__init__.py @@ -242,6 +242,7 @@ subqueryload = strategy_options.subqueryload._unbound_fn subqueryload_all = strategy_options.subqueryload_all._unbound_all_fn immediateload = strategy_options.immediateload._unbound_fn noload = strategy_options.noload._unbound_fn +raiseload = strategy_options.raiseload._unbound_fn defaultload = strategy_options.defaultload._unbound_fn from .strategy_options import Load diff --git a/lib/sqlalchemy/orm/relationships.py b/lib/sqlalchemy/orm/relationships.py index 17f94d4af0..4d5e5d29db 100644 --- a/lib/sqlalchemy/orm/relationships.py +++ b/lib/sqlalchemy/orm/relationships.py @@ -540,6 +540,12 @@ class RelationshipProperty(StrategizedProperty): support "write-only" attributes, or attributes which are populated in some manner specific to the application. + * ``raise`` - lazy loading is disallowed; accessing + the attribute, if its value were not already loaded via eager + loading, will raise an :exc:`~sqlalchemy.exc.InvalidRequestError`. + + .. versionadded:: 1.1 + * ``dynamic`` - the attribute will return a pre-configured :class:`.Query` object for all read operations, onto which further filtering operations can be @@ -559,6 +565,8 @@ class RelationshipProperty(StrategizedProperty): :ref:`dynamic_relationship` - detail on the ``dynamic`` option. + :ref:`collections_noload_raiseload` - notes on "noload" and "raise" + :param load_on_pending=False: Indicates loading behavior for transient or pending parent objects. diff --git a/lib/sqlalchemy/orm/strategies.py b/lib/sqlalchemy/orm/strategies.py index 370cb974b5..3c03a681df 100644 --- a/lib/sqlalchemy/orm/strategies.py +++ b/lib/sqlalchemy/orm/strategies.py @@ -353,6 +353,33 @@ class NoLoader(AbstractRelationshipLoader): populators["new"].append((self.key, invoke_no_load)) +@log.class_logger +@properties.RelationshipProperty.strategy_for(lazy="raise") +class RaiseLoader(NoLoader): + """Provide loading behavior for a :class:`.RelationshipProperty` + with "lazy='raise'". + + """ + + __slots__ = () + + def create_row_processor( + self, context, path, loadopt, mapper, + result, adapter, populators): + + def invoke_raise_load(state, passive): + raise sa_exc.InvalidRequestError( + "'%s' is not available due to lazy='raise'" % self + ) + + set_lazy_callable = InstanceState._instance_level_callable_processor( + mapper.class_manager, + invoke_raise_load, + self.key + ) + populators["new"].append((self.key, set_lazy_callable)) + + @log.class_logger @properties.RelationshipProperty.strategy_for(lazy=True) @properties.RelationshipProperty.strategy_for(lazy="select") diff --git a/lib/sqlalchemy/orm/strategy_options.py b/lib/sqlalchemy/orm/strategy_options.py index b7084cc22a..97d2c0f298 100644 --- a/lib/sqlalchemy/orm/strategy_options.py +++ b/lib/sqlalchemy/orm/strategy_options.py @@ -878,6 +878,35 @@ def noload(*keys): return _UnboundLoad._from_keys(_UnboundLoad.noload, keys, False, {}) +@loader_option() +def raiseload(loadopt, attr): + """Indicate that the given relationship attribute should disallow lazy loads. + + A relationship attribute configured with :func:`.orm.raiseload` will + raise an :exc:`~sqlalchemy.exc.InvalidRequestError` upon access. The + typical way this is useful is when an application is attempting to ensure + that all relationship attributes that are accessed in a particular context + would have been already loaded via eager loading. Instead of having + to read through SQL logs to ensure lazy loads aren't occurring, this + strategy will cause them to raise immediately. + + This function is part of the :class:`.Load` interface and supports + both method-chained and standalone operation. + + :func:`.orm.raiseload` applies to :func:`.relationship` attributes only. + + .. versionadded:: 1.1 + + """ + + return loadopt.set_relationship_strategy(attr, {"lazy": "raise"}) + + +@raiseload._add_unbound_fn +def raiseload(*keys): + return _UnboundLoad._from_keys(_UnboundLoad.raiseload, keys, False, {}) + + @loader_option() def defaultload(loadopt, attr): """Indicate an attribute should load using its default loader style. diff --git a/test/orm/test_mapper.py b/test/orm/test_mapper.py index 6845ababb5..d4eed4d924 100644 --- a/test/orm/test_mapper.py +++ b/test/orm/test_mapper.py @@ -2717,6 +2717,115 @@ class NoLoadTest(_fixtures.FixtureTest): self.sql_count_(0, go) +class RaiseLoadTest(_fixtures.FixtureTest): + run_inserts = 'once' + run_deletes = None + + def test_o2m_raiseload_mapper(self): + Address, addresses, users, User = ( + self.classes.Address, + self.tables.addresses, + self.tables.users, + self.classes.User) + + mapper(Address, addresses) + mapper(User, users, properties=dict( + addresses=relationship(Address, lazy='raise') + )) + q = create_session().query(User) + l = [None] + + def go(): + x = q.filter(User.id == 7).all() + assert_raises_message( + sa.exc.InvalidRequestError, + "'User.addresses' is not available due to lazy='raise'", + lambda: x[0].addresses) + l[0] = x + self.assert_sql_count(testing.db, go, 1) + + self.assert_result( + l[0], User, + {'id': 7}, + ) + + def test_o2m_raiseload_option(self): + Address, addresses, users, User = ( + self.classes.Address, + self.tables.addresses, + self.tables.users, + self.classes.User) + + mapper(Address, addresses) + mapper(User, users, properties=dict( + addresses=relationship(Address) + )) + q = create_session().query(User) + l = [None] + + def go(): + x = q.options( + sa.orm.raiseload(User.addresses)).filter(User.id == 7).all() + assert_raises_message( + sa.exc.InvalidRequestError, + "'User.addresses' is not available due to lazy='raise'", + lambda: x[0].addresses) + l[0] = x + self.assert_sql_count(testing.db, go, 1) + + self.assert_result( + l[0], User, + {'id': 7}, + ) + + def test_o2m_raiseload_lazyload_option(self): + Address, addresses, users, User = ( + self.classes.Address, + self.tables.addresses, + self.tables.users, + self.classes.User) + + mapper(Address, addresses) + mapper(User, users, properties=dict( + addresses=relationship(Address, lazy='raise') + )) + q = create_session().query(User).options(sa.orm.lazyload('addresses')) + l = [None] + + def go(): + x = q.filter(User.id == 7).all() + x[0].addresses + l[0] = x + self.sql_count_(2, go) + + self.assert_result( + l[0], User, + {'id': 7, 'addresses': (Address, [{'id': 1}])}, + ) + + def test_m2o_raiseload_option(self): + Address, addresses, users, User = ( + self.classes.Address, + self.tables.addresses, + self.tables.users, + self.classes.User) + mapper(Address, addresses, properties={ + 'user': relationship(User) + }) + mapper(User, users) + s = Session() + a1 = s.query(Address).filter_by(id=1).options( + sa.orm.raiseload('user')).first() + + def go(): + assert_raises_message( + sa.exc.InvalidRequestError, + "'Address.user' is not available due to lazy='raise'", + lambda: a1.user) + + self.sql_count_(0, go) + + class RequirementsTest(fixtures.MappedTest): """Tests the contract for user classes.""" -- 2.47.2