From: Mike Bayer Date: Wed, 8 Jun 2022 17:05:20 +0000 (-0400) Subject: suppport with_loader_criteria pickling w/ fixed callable X-Git-Tag: rel_2_0_0b1~256^2 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=293b0e3dd8205185b84cd3baf2f078348437d245;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git suppport with_loader_criteria pickling w/ fixed callable 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 --- diff --git a/doc/build/changelog/unreleased_14/8109.rst b/doc/build/changelog/unreleased_14/8109.rst new file mode 100644 index 0000000000..cf64d21ac4 --- /dev/null +++ b/doc/build/changelog/unreleased_14/8109.rst @@ -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. + diff --git a/lib/sqlalchemy/orm/_orm_constructors.py b/lib/sqlalchemy/orm/_orm_constructors.py index 6ef0a647f2..63fd3b76ed 100644 --- a/lib/sqlalchemy/orm/_orm_constructors.py +++ b/lib/sqlalchemy/orm/_orm_constructors.py @@ -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. diff --git a/lib/sqlalchemy/orm/util.py b/lib/sqlalchemy/orm/util.py index e1e78c99da..14a6bccb01 100644 --- a/lib/sqlalchemy/orm/util.py +++ b/lib/sqlalchemy/orm/util.py @@ -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: diff --git a/test/orm/test_pickled.py b/test/orm/test_pickled.py index c006babc84..fa3fbfa2ca 100644 --- a/test/orm/test_pickled.py +++ b/test/orm/test_pickled.py @@ -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)