]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
consult allow_partial_pks for NULL check in lazyload
authorMike Bayer <mike_mp@zzzcomputing.com>
Sun, 13 Oct 2024 14:04:23 +0000 (10:04 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Sun, 13 Oct 2024 14:21:42 +0000 (10:21 -0400)
Refined the check which the ORM lazy loader uses to detect "this would be
loading by primary key and the primary key is NULL, skip loading" to take
into account the current setting for the
:paramref:`.orm.Mapper.allow_partial_pks` parameter. If this parameter is
False, then a composite PK value that has partial NULL elements should also
be skipped.   This can apply to some composite overlapping foreign key
configurations.

Fixes: #11995
Change-Id: Icf9a52b7405d7400d46bfa944edcbff1a89225a3
(cherry picked from commit 830debc30896203bfd21fea18d323c5d849068d1)

doc/build/changelog/unreleased_20/11995.rst [new file with mode: 0644]
lib/sqlalchemy/orm/base.py
lib/sqlalchemy/orm/mapper.py
lib/sqlalchemy/orm/strategies.py
lib/sqlalchemy/orm/util.py
test/orm/test_lazy_relations.py

diff --git a/doc/build/changelog/unreleased_20/11995.rst b/doc/build/changelog/unreleased_20/11995.rst
new file mode 100644 (file)
index 0000000..a748a1c
--- /dev/null
@@ -0,0 +1,12 @@
+.. change::
+    :tags: bug, orm
+    :tickets: 11995
+
+    Refined the check which the ORM lazy loader uses to detect "this would be
+    loading by primary key and the primary key is NULL, skip loading" to take
+    into account the current setting for the
+    :paramref:`.orm.Mapper.allow_partial_pks` parameter. If this parameter is
+    False, then a composite PK value that has partial NULL elements should also
+    be skipped.   This can apply to some composite overlapping foreign key
+    configurations.
+
index c9005298d827e34e3d9e84147d8e43ba3e0d71ef..b5f7dbbafb05650dc681557fa3f4c9a47d7716b0 100644 (file)
@@ -283,6 +283,8 @@ _never_set = frozenset([NEVER_SET])
 
 _none_set = frozenset([None, NEVER_SET, PASSIVE_NO_RESULT])
 
+_none_only_set = frozenset([None])
+
 _SET_DEFERRED_EXPIRED = util.symbol("SET_DEFERRED_EXPIRED")
 
 _DEFER_FOR_STATE = util.symbol("DEFER_FOR_STATE")
index 5bb203b3a928aac1ee292581fcbdfed88cc0bcc7..29659e6e8e26021fb8f393d7ab019556f2b5ce6a 100644 (file)
@@ -296,6 +296,17 @@ class Mapper(
            particular primary key value. A "partial primary key" can occur if
            one has mapped to an OUTER JOIN, for example.
 
+           The :paramref:`.orm.Mapper.allow_partial_pks` parameter also
+           indicates to the ORM relationship lazy loader, when loading a
+           many-to-one related object, if a composite primary key that has
+           partial NULL values should result in an attempt to load from the
+           database, or if a load attempt is not necessary.
+
+           .. versionadded:: 2.0.36 :paramref:`.orm.Mapper.allow_partial_pks`
+              is consulted by the relationship lazy loader strategy, such that
+              when set to False, a SELECT for a composite primary key that
+              has partial NULL values will not be emitted.
+
         :param batch: Defaults to ``True``, indicating that save operations
            of multiple entities can be batched together for efficiency.
            Setting to False indicates
index 3f947a8d7438f2d51bb73f3211425fa79cd80903..c89a12efd667b1db1f648d7715c9135d37eab779 100644 (file)
@@ -47,7 +47,7 @@ from .interfaces import StrategizedProperty
 from .session import _state_session
 from .state import InstanceState
 from .strategy_options import Load
-from .util import _none_set
+from .util import _none_only_set
 from .util import AliasedClass
 from .. import event
 from .. import exc as sa_exc
@@ -936,8 +936,15 @@ class LazyLoader(
             elif LoaderCallableStatus.NEVER_SET in primary_key_identity:
                 return LoaderCallableStatus.NEVER_SET
 
-            if _none_set.issuperset(primary_key_identity):
-                return None
+            # test for None alone in primary_key_identity based on
+            # allow_partial_pks preference.   PASSIVE_NO_RESULT and NEVER_SET
+            # have already been tested above
+            if not self.mapper.allow_partial_pks:
+                if _none_only_set.intersection(primary_key_identity):
+                    return None
+            else:
+                if _none_only_set.issuperset(primary_key_identity):
+                    return None
 
             if (
                 self.key in state.dict
index c9fa79c804bfc52e5420fb3f32cfa846698d06eb..69556751a767bf55b2d148295b75a5a9ba810f2a 100644 (file)
@@ -42,6 +42,7 @@ from ._typing import prop_is_relationship
 from .base import _class_to_mapper as _class_to_mapper
 from .base import _MappedAnnotationBase
 from .base import _never_set as _never_set  # noqa: F401
+from .base import _none_only_set as _none_only_set  # noqa: F401
 from .base import _none_set as _none_set  # noqa: F401
 from .base import attribute_str as attribute_str  # noqa: F401
 from .base import class_mapper as class_mapper
index 64c86853d27019a4b4a79485e68b659a7a6d34a3..9bb8071984dbefc02118ffe3fd1ec7e4a8c1f579 100644 (file)
@@ -21,7 +21,9 @@ from sqlalchemy.orm import aliased
 from sqlalchemy.orm import attributes
 from sqlalchemy.orm import configure_mappers
 from sqlalchemy.orm import exc as orm_exc
+from sqlalchemy.orm import foreign
 from sqlalchemy.orm import relationship
+from sqlalchemy.orm import remote
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import with_parent
 from sqlalchemy.testing import assert_raises
@@ -1270,6 +1272,54 @@ class M2OGetTest(_fixtures.FixtureTest):
 
         self.assert_sql_count(testing.db, go, 1)
 
+    @testing.fixture()
+    def composite_overlapping_fixture(self, decl_base, connection):
+        def go(allow_partial_pks):
+
+            class Section(decl_base):
+                __tablename__ = "sections"
+                year = Column(Integer, primary_key=True)
+                idx = Column(Integer, primary_key=True)
+                parent_idx = Column(Integer)
+
+                if not allow_partial_pks:
+                    __mapper_args__ = {"allow_partial_pks": False}
+
+                ForeignKeyConstraint((year, parent_idx), (year, idx))
+
+                parent = relationship(
+                    "Section",
+                    primaryjoin=and_(
+                        year == remote(year),
+                        foreign(parent_idx) == remote(idx),
+                    ),
+                )
+
+            decl_base.metadata.create_all(connection)
+            connection.commit()
+
+            with Session(connection) as sess:
+                sess.add(Section(year=5, idx=1, parent_idx=None))
+                sess.commit()
+
+            return Section
+
+        return go
+
+    @testing.variation("allow_partial_pks", [True, False])
+    def test_composite_m2o_load_partial_pks(
+        self, allow_partial_pks, composite_overlapping_fixture
+    ):
+        Section = composite_overlapping_fixture(allow_partial_pks)
+
+        session = fixture_session()
+        section = session.get(Section, (5, 1))
+
+        with self.assert_statement_count(
+            testing.db, 1 if allow_partial_pks else 0
+        ):
+            testing.is_none(section.parent)
+
 
 class CorrelatedTest(fixtures.MappedTest):
     @classmethod