]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Add raise/raiseload relationship loading strategy
authorAdrian Moennich <adrian@planetcoding.net>
Tue, 12 Apr 2016 03:15:15 +0000 (23:15 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Tue, 19 Apr 2016 17:04:41 +0000 (13:04 -0400)
Fixes: #3512
Co-Authored-By: Mike Bayer <mike_mp@zzzcomputing.com>
Change-Id: Ibd126c50eda621e2f4120ee378f7313af2d7ec3c
Pull-request: https://github.com/zzzeek/sqlalchemy/pull/193

doc/build/changelog/changelog_11.rst
doc/build/changelog/migration_11.rst
doc/build/orm/collections.rst
doc/build/orm/loading_relationships.rst
lib/sqlalchemy/orm/__init__.py
lib/sqlalchemy/orm/relationships.py
lib/sqlalchemy/orm/strategies.py
lib/sqlalchemy/orm/strategy_options.py
test/orm/test_mapper.py

index 53bd38a98e94a0873405db0701eec5b5ecb8c870..613f76beb9383e4935615398c576ab7b7f7b0df2 100644 (file)
 
             :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
index 6f0da37809c48b46a4c391dc0933808e5f20198f..12fa42ea7d32c16d87b7ccfdd76653497e580c10 100644 (file)
@@ -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
 ====================================
 
index 577cd233ec4ad12bf9cef6b8bd040b25858af443..f37a36b405c7fae944f4e9377d8e86aebb6b909e 100644 (file)
@@ -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:
 
index 3a0026bbe52526641247727095bf71244aadd4b8..941edce2c41f5c998504c0cbfd173b7187e7296b 100644 (file)
@@ -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
index 7425737cefb74ba781a5eb0ca9ebe58d1a9cdbd8..d822c83cbcec774775ead06fd8a685ba9262292a 100644 (file)
@@ -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
index 17f94d4af0ec26f9cc543044a8fbe61034b1a2c1..4d5e5d29dbc5c7aa4da17bb8f526b58a53fdf15a 100644 (file)
@@ -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.
 
index 370cb974b52861c9dae660f44b675f000bd538fe..3c03a681dfb69cc866baa6d577487b6c33aa7864 100644 (file)
@@ -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")
index b7084cc22adff84a82d4fa2c56a90aedce77b5fb..97d2c0f2981d763179c42cc83b35b0529e18339d 100644 (file)
@@ -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.
index 6845ababb52c018b9d7ce87cc2f67fc9b0402dda..d4eed4d9246b185ce5df69e89e06fff17022a563 100644 (file)
@@ -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."""