]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
suppport with_loader_criteria pickling w/ fixed callable
authorMike Bayer <mike_mp@zzzcomputing.com>
Wed, 8 Jun 2022 17:05:20 +0000 (13:05 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Wed, 8 Jun 2022 17:07:06 +0000 (13:07 -0400)
Fixed issue where a :func:`_orm.with_loader_criteria` option could not be
pickled, as is necessary when it is carried along for propagation to lazy
loaders in conjunction with a caching scheme. Currently, the only form that
is supported as picklable is to pass the "where criteria" as a fixed
module-level callable function that produces a SQL expression. An ad-hoc
"lambda" can't be pickled, and a SQL expression object is usually not fully
picklable directly.

Fixes: #8109
Change-Id: I49fe69088b0c7e58a0f22c67d2ea4e33752a5a73

doc/build/changelog/unreleased_14/8109.rst [new file with mode: 0644]
lib/sqlalchemy/orm/_orm_constructors.py
lib/sqlalchemy/orm/util.py
test/orm/test_pickled.py

diff --git a/doc/build/changelog/unreleased_14/8109.rst b/doc/build/changelog/unreleased_14/8109.rst
new file mode 100644 (file)
index 0000000..cf64d21
--- /dev/null
@@ -0,0 +1,12 @@
+.. change::
+    :tags: bug, orm
+    :tickets: 8109
+
+    Fixed issue where a :func:`_orm.with_loader_criteria` option could not be
+    pickled, as is necessary when it is carried along for propagation to lazy
+    loaders in conjunction with a caching scheme. Currently, the only form that
+    is supported as picklable is to pass the "where criteria" as a fixed
+    module-level callable function that produces a SQL expression. An ad-hoc
+    "lambda" can't be pickled, and a SQL expression object is usually not fully
+    picklable directly.
+
index 6ef0a647f23ef13dd886b8ea82cf1a943f9ef751..63fd3b76ed4332fe5e322624fa41f62bf8a8c743 100644 (file)
@@ -724,10 +724,9 @@ def with_loader_criteria(
      accepts a target class as an argument, when the given class is
      a base with many different mapped subclasses.
 
-     .. note:: when the SQL expression is a lambda, **pickling is not
-        supported**.  Set
-        :paramref:`_orm.with_loader_criteria.propagate_to_loaders`
-        to ``False`` to prevent the object from being applied to instances.
+     .. note:: To support pickling, use a module-level Python function to
+        produce the SQL expression instead of a lambda or a fixed SQL
+        expression, which tend to not be picklable.
 
     :param include_aliases: if True, apply the rule to :func:`_orm.aliased`
      constructs as well.
index e1e78c99da94e735e36a8b084aa74ad914c4cdc4..14a6bccb01e1133d4e56ba29e5e0cea7543f3c25 100644 (file)
@@ -1138,6 +1138,8 @@ class _WrapUserEntity:
 
     """
 
+    __slots__ = ("subject",)
+
     def __init__(self, subject):
         self.subject = subject
 
@@ -1171,6 +1173,7 @@ class LoaderCriteriaOption(CriteriaOption):
         "entity",
         "deferred_where_criteria",
         "where_criteria",
+        "_where_crit_orig",
         "include_aliases",
         "propagate_to_loaders",
     )
@@ -1190,6 +1193,8 @@ class LoaderCriteriaOption(CriteriaOption):
     include_aliases: bool
     propagate_to_loaders: bool
 
+    _where_crit_orig: Any
+
     def __init__(
         self,
         entity_or_base: _EntityType[Any],
@@ -1210,6 +1215,7 @@ class LoaderCriteriaOption(CriteriaOption):
             self.root_entity = None
             self.entity = entity
 
+        self._where_crit_orig = where_criteria
         if callable(where_criteria):
             if self.root_entity is not None:
                 wrap_entity = self.root_entity
@@ -1235,6 +1241,28 @@ class LoaderCriteriaOption(CriteriaOption):
         self.include_aliases = include_aliases
         self.propagate_to_loaders = propagate_to_loaders
 
+    @classmethod
+    def _unreduce(
+        cls, entity, where_criteria, include_aliases, propagate_to_loaders
+    ):
+        return LoaderCriteriaOption(
+            entity,
+            where_criteria,
+            include_aliases=include_aliases,
+            propagate_to_loaders=propagate_to_loaders,
+        )
+
+    def __reduce__(self):
+        return (
+            LoaderCriteriaOption._unreduce,
+            (
+                self.entity.class_ if self.entity else None,
+                self._where_crit_orig,
+                self.include_aliases,
+                self.propagate_to_loaders,
+            ),
+        )
+
     def _all_mappers(self) -> Iterator[Mapper[Any]]:
 
         if self.entity:
index c006babc841c0e6e0008127b47f8ddf0bdb45b1e..fa3fbfa2ca707ce42acad1b5ee7e72abb97b692f 100644 (file)
@@ -15,6 +15,7 @@ from sqlalchemy.orm import lazyload
 from sqlalchemy.orm import relationship
 from sqlalchemy.orm import state as sa_state
 from sqlalchemy.orm import subqueryload
+from sqlalchemy.orm import with_loader_criteria
 from sqlalchemy.orm import with_polymorphic
 from sqlalchemy.orm.collections import attribute_mapped_collection
 from sqlalchemy.orm.collections import column_mapped_collection
@@ -42,6 +43,10 @@ from .inheritance._poly_fixtures import Manager
 from .inheritance._poly_fixtures import Person
 
 
+def no_ed_foo(cls):
+    return cls.email_address != "ed@foo.com"
+
+
 class PickleTest(fixtures.MappedTest):
     @classmethod
     def define_tables(cls, metadata):
@@ -322,6 +327,45 @@ class PickleTest(fixtures.MappedTest):
         u2.addresses.append(Address())
         eq_(len(u2.addresses), 2)
 
+    @testing.combinations(True, False, argnames="pickle_it")
+    def test_loader_criteria(self, pickle_it):
+        """test #8109"""
+
+        users, addresses = (self.tables.users, self.tables.addresses)
+
+        self.mapper_registry.map_imperatively(
+            User,
+            users,
+            properties={"addresses": relationship(Address)},
+        )
+        self.mapper_registry.map_imperatively(Address, addresses)
+
+        with fixture_session(expire_on_commit=False) as sess:
+            u1 = User(name="ed")
+            u1.addresses = [
+                Address(email_address="ed@bar.com"),
+                Address(email_address="ed@foo.com"),
+            ]
+            sess.add(u1)
+            sess.commit()
+
+        with fixture_session(expire_on_commit=False) as sess:
+            # note that non-lambda is not picklable right now as
+            # SQL expressions usually can't be pickled.
+            opt = with_loader_criteria(
+                Address,
+                no_ed_foo,
+            )
+
+            u1 = sess.query(User).options(opt).first()
+
+            if pickle_it:
+                u1 = pickle.loads(pickle.dumps(u1))
+                sess.close()
+                sess.add(u1)
+
+            eq_([ad.email_address for ad in u1.addresses], ["ed@bar.com"])
+
     def test_instance_deferred_cols(self):
         users, addresses = (self.tables.users, self.tables.addresses)