From: Mike Bayer Date: Tue, 23 Oct 2018 23:38:46 +0000 (-0400) Subject: Create object- and column-oriented versions of AssociationProxyInstance X-Git-Tag: rel_1_3_0b1~38^2 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=90574aabef36fd59841b7df7d8ac30e2030e9854;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git Create object- and column-oriented versions of AssociationProxyInstance The :class:`.AssociationProxy` now has standard column comparison operations such as :meth:`.ColumnOperators.like` and :meth:`.ColumnOperators.startswith` available when the target attribute is a plain column - the EXISTS expression that joins to the target table is rendered as usual, but the column expression is then use within the WHERE criteria of the EXISTS. Note that this alters the behavior of the ``.contains()`` method on the association proxy to make use of :meth:`.ColumnOperators.contains` when used on a column-based attribute. Fixes: #4351 Change-Id: I310941f4e8f778c200f8144a26a89e5364cd4dfb --- diff --git a/doc/build/changelog/migration_13.rst b/doc/build/changelog/migration_13.rst index d1ebe846c9..585a9c3a5a 100644 --- a/doc/build/changelog/migration_13.rst +++ b/doc/build/changelog/migration_13.rst @@ -234,6 +234,121 @@ specific to the ``User.keywords`` proxy, such as ``target_class``:: :ticket:`3423` +.. _change_4351: + +AssociationProxy now provides standard column operators for a column-oriented target +------------------------------------------------------------------------------------ + +Given an :class:`.AssociationProxy` where the target is a database column, +as opposed to an object reference:: + + class User(Base): + # ... + + elements = relationship("Element") + + # column-based association proxy + values = association_proxy("elements", "value") + + class Element(Base): + # ... + + value = Column(String) + +The ``User.values`` association proxy refers to the ``Element.value`` column. +Standard column operations are now available, such as ``like``:: + + >>> print(s.query(User).filter(User.values.like('%foo%'))) + SELECT "user".id AS user_id + FROM "user" + WHERE EXISTS (SELECT 1 + FROM element + WHERE "user".id = element.user_id AND element.value LIKE :value_1) + +``equals``:: + + >>> print(s.query(User).filter(User.values == 'foo')) + SELECT "user".id AS user_id + FROM "user" + WHERE EXISTS (SELECT 1 + FROM element + WHERE "user".id = element.user_id AND element.value = :value_1) + +When comparing to ``None``, the ``IS NULL`` expression is augmented with +a test that the related row does not exist at all; this is the same +behavior as before:: + + >>> print(s.query(User).filter(User.values == None)) + SELECT "user".id AS user_id + FROM "user" + WHERE (EXISTS (SELECT 1 + FROM element + WHERE "user".id = element.user_id AND element.value IS NULL)) OR NOT (EXISTS (SELECT 1 + FROM element + WHERE "user".id = element.user_id)) + +Note that the :meth:`.ColumnOperators.contains` operator is in fact a string +comparison operator; **this is a change in behavior** in that previously, +the association proxy used ``.contains`` as a list containment operator only. +With a column-oriented comparison, it now behaves like a "like":: + + >>> print(s.query(User).filter(User.values.contains('foo'))) + SELECT "user".id AS user_id + FROM "user" + WHERE EXISTS (SELECT 1 + FROM element + WHERE "user".id = element.user_id AND (element.value LIKE '%' || :value_1 || '%')) + +In order to test the ``User.values`` collection for simple membership of the value +``"foo"``, the equals operator (e.g. ``User.values == 'foo'``) should be used; +this works in previous versions as well. + +When using an object-based association proxy with a collection, the behavior is +as before, that of testing for collection membership, e.g. given a mapping:: + + class User(Base): + __tablename__ = 'user' + + id = Column(Integer, primary_key=True) + user_elements = relationship("UserElement") + + # object-based association proxy + elements = association_proxy("user_elements", "element") + + + class UserElement(Base): + __tablename__ = 'user_element' + + id = Column(Integer, primary_key=True) + user_id = Column(ForeignKey("user.id")) + element_id = Column(ForeignKey("element.id")) + element = relationship("Element") + + + class Element(Base): + __tablename__ = 'element' + + id = Column(Integer, primary_key=True) + value = Column(String) + +The ``.contains()`` method produces the same expression as before, testing +the list of ``User.elements`` for the presence of an ``Element`` object:: + + >>> print(s.query(User).filter(User.elements.contains(Element(id=1)))) + SELECT "user".id AS user_id + FROM "user" + WHERE EXISTS (SELECT 1 + FROM user_element + WHERE "user".id = user_element.user_id AND :param_1 = user_element.element_id) + +Overall, the change is enabled based on the architectural change that is +part of :ref:`change_3423`; as the proxy now spins off additional state when +an expression is generated, there is both an object-target and a column-target +version of the :class:`.AssociationProxyInstance` class. + +:ticket:`4351` + + .. _change_4246: FOR UPDATE clause is rendered within the joined eager load subquery as well as outside diff --git a/doc/build/changelog/unreleased_13/4351.rst b/doc/build/changelog/unreleased_13/4351.rst new file mode 100644 index 0000000000..51f9f603e8 --- /dev/null +++ b/doc/build/changelog/unreleased_13/4351.rst @@ -0,0 +1,17 @@ +.. change:: + :tags: feature, ext + :tickets: 4351 + + The :class:`.AssociationProxy` now has standard column comparison operations + such as :meth:`.ColumnOperators.like` and + :meth:`.ColumnOperators.startswith` available when the target attribute is a + plain column - the EXISTS expression that joins to the target table is + rendered as usual, but the column expression is then use within the WHERE + criteria of the EXISTS. Note that this alters the behavior of the + ``.contains()`` method on the association proxy to make use of + :meth:`.ColumnOperators.contains` when used on a column-based attribute. + + .. seealso:: + + :ref:`change_4351` + diff --git a/doc/build/orm/extensions/associationproxy.rst b/doc/build/orm/extensions/associationproxy.rst index 62b24c9284..0ad919c08a 100644 --- a/doc/build/orm/extensions/associationproxy.rst +++ b/doc/build/orm/extensions/associationproxy.rst @@ -516,4 +516,12 @@ API Documentation :undoc-members: :inherited-members: +.. autoclass:: ObjectAssociationProxyInstance + :members: + :inherited-members: + +.. autoclass:: ColumnAssociationProxyInstance + :members: + :inherited-members: + .. autodata:: ASSOCIATION_PROXY diff --git a/lib/sqlalchemy/ext/associationproxy.py b/lib/sqlalchemy/ext/associationproxy.py index 1c28b10a17..629b4ac649 100644 --- a/lib/sqlalchemy/ext/associationproxy.py +++ b/lib/sqlalchemy/ext/associationproxy.py @@ -17,6 +17,7 @@ import operator from .. import exc, orm, util from ..orm import collections, interfaces from ..sql import or_ +from ..sql.operators import ColumnOperators from .. import inspect @@ -217,7 +218,7 @@ class AssociationProxy(interfaces.InspectionAttrInfo): except KeyError: owner = self._calc_owner(class_) if owner is not None: - result = AssociationProxyInstance(self, owner) + result = AssociationProxyInstance.for_proxy(self, owner) setattr(class_, self.key + "_inst", result) return result else: @@ -283,13 +284,49 @@ class AssociationProxyInstance(object): """ - def __init__(self, parent, owning_class): + def __init__(self, parent, owning_class, target_class, value_attr): self.parent = parent self.key = parent.key self.owning_class = owning_class self.target_collection = parent.target_collection self.value_attr = parent.value_attr self.collection_class = None + self.target_class = target_class + self.value_attr = value_attr + + target_class = None + """The intermediary class handled by this + :class:`.AssociationProxyInstance`. + + Intercepted append/set/assignment events will result + in the generation of new instances of this class. + + """ + + @classmethod + def for_proxy(cls, parent, owning_class): + target_collection = parent.target_collection + value_attr = parent.value_attr + prop = orm.class_mapper(owning_class).\ + get_property(target_collection) + target_class = prop.mapper.class_ + + target_assoc = cls._cls_unwrap_target_assoc_proxy( + target_class, value_attr) + if target_assoc is not None: + return ObjectAssociationProxyInstance( + parent, owning_class, target_class, value_attr + ) + + is_object = getattr(target_class, value_attr).impl.uses_objects + if is_object: + return ObjectAssociationProxyInstance( + parent, owning_class, target_class, value_attr + ) + else: + return ColumnAssociationProxyInstance( + parent, owning_class, target_class, value_attr + ) def _get_property(self): return orm.class_mapper(self.owning_class).\ @@ -299,13 +336,18 @@ class AssociationProxyInstance(object): def _comparator(self): return self._get_property().comparator - @util.memoized_property - def _unwrap_target_assoc_proxy(self): - attr = getattr(self.target_class, self.value_attr) + @classmethod + def _cls_unwrap_target_assoc_proxy(cls, target_class, value_attr): + attr = getattr(target_class, value_attr) if isinstance(attr, (AssociationProxy, AssociationProxyInstance)): return attr return None + @util.memoized_property + def _unwrap_target_assoc_proxy(self): + return self._cls_unwrap_target_assoc_proxy( + self.target_class, self.value_attr) + @property def remote_attr(self): """The 'remote' :class:`.MapperProperty` referenced by this @@ -352,17 +394,6 @@ class AssociationProxyInstance(object): """ return (self.local_attr, self.remote_attr) - @util.memoized_property - def target_class(self): - """The intermediary class handled by this - :class:`.AssociationProxyInstance`. - - Intercepted append/set/assignment events will result - in the generation of new instances of this class. - - """ - return self._get_property().mapper.class_ - @util.memoized_property def scalar(self): """Return ``True`` if this :class:`.AssociationProxyInstance` @@ -378,9 +409,9 @@ class AssociationProxyInstance(object): return not self._get_property().\ mapper.get_property(self.value_attr).uselist - @util.memoized_property + @property def _target_is_object(self): - return getattr(self.target_class, self.value_attr).impl.uses_objects + raise NotImplementedError() def _initialize_scalar_accessors(self): if self.parent.getset_factory: @@ -587,6 +618,12 @@ class AssociationProxyInstance(object): return self._criterion_exists( criterion=criterion, is_has=True, **kwargs) + +class ObjectAssociationProxyInstance(AssociationProxyInstance): + """an :class:`.AssociationProxyInstance` that has an object as a target. + """ + _target_is_object = True + def contains(self, obj): """Produce a proxied 'contains' expression using EXISTS. @@ -611,7 +648,7 @@ class AssociationProxyInstance(object): elif self._target_is_object and self.scalar and \ self._value_is_scalar: raise exc.InvalidRequestError( - "contains() doesn't apply to a scalar endpoint; use ==") + "contains() doesn't apply to a scalar object endpoint; use ==") else: return self._comparator._criterion_exists(**{self.value_attr: obj}) @@ -634,6 +671,31 @@ class AssociationProxyInstance(object): getattr(self.target_class, self.value_attr) != obj) +class ColumnAssociationProxyInstance( + ColumnOperators, AssociationProxyInstance): + """an :class:`.AssociationProxyInstance` that has a database column as a + target. + """ + _target_is_object = False + + def __eq__(self, other): + # special case "is None" to check for no related row as well + expr = self._criterion_exists( + self.remote_attr.operate(operator.eq, other) + ) + if other is None: + return or_( + expr, self._comparator == None + ) + else: + return expr + + def operate(self, op, *other, **kwargs): + return self._criterion_exists( + self.remote_attr.operate(op, *other, **kwargs) + ) + + class _lazy_collection(object): def __init__(self, obj, target): self.parent = obj diff --git a/test/ext/test_associationproxy.py b/test/ext/test_associationproxy.py index 2204f13c17..c661c9e013 100644 --- a/test/ext/test_associationproxy.py +++ b/test/ext/test_associationproxy.py @@ -18,7 +18,7 @@ from sqlalchemy.testing.mock import Mock, call from sqlalchemy.testing.assertions import expect_warnings from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declared_attr - +from sqlalchemy.engine import default class DictCollection(dict): @collection.appender @@ -1374,6 +1374,10 @@ class ComparatorTest(fixtures.MappedTest, AssertsCompiledSQL): cls.session = session def _equivalent(self, q_proxy, q_direct): + proxy_sql = q_proxy.statement.compile(dialect=default.DefaultDialect()) + direct_sql = q_direct.statement.compile( + dialect=default.DefaultDialect()) + eq_(str(proxy_sql), str(direct_sql)) eq_(q_proxy.all(), q_direct.all()) def test_filter_any_criterion_ul_scalar(self): @@ -1495,11 +1499,12 @@ class ComparatorTest(fixtures.MappedTest, AssertsCompiledSQL): def test_filter_ne_nul_nul(self): Keyword = self.classes.Keyword + UserKeyword = self.classes.UserKeyword self._equivalent( self.session.query(Keyword).filter(Keyword.user != self.u), self.session.query(Keyword).filter( - Keyword.user_keyword.has(Keyword.user != self.u))) + Keyword.user_keyword.has(UserKeyword.user != self.u))) def test_filter_eq_null_nul_nul(self): UserKeyword, Keyword = self.classes.UserKeyword, self.classes.Keyword @@ -1519,7 +1524,20 @@ class ComparatorTest(fixtures.MappedTest, AssertsCompiledSQL): self.session.query(Keyword).filter( Keyword.user_keyword.has(UserKeyword.user != None))) - def test_filter_eq_None_nul(self): + def test_filter_object_eq_None_nul(self): + UserKeyword = self.classes.UserKeyword + User = self.classes.User + + self._equivalent( + self.session.query(UserKeyword).filter( + UserKeyword.singular == None), # noqa + self.session.query(UserKeyword).filter(or_( + UserKeyword.user.has(User.singular == None), + UserKeyword.user_id == None) + ) + ) + + def test_filter_column_eq_None_nul(self): User = self.classes.User Singular = self.classes.Singular @@ -1528,9 +1546,23 @@ class ComparatorTest(fixtures.MappedTest, AssertsCompiledSQL): User.singular_value == None), # noqa self.session.query(User).filter(or_( User.singular.has(Singular.value == None), - User.singular == None))) + User.singular == None) + ) + ) - def test_filter_ne_value_nul(self): + def test_filter_object_ne_value_nul(self): + UserKeyword = self.classes.UserKeyword + User = self.classes.User + Singular = self.classes.Singular + + s4 = self.session.query(Singular).filter_by(value="singular4").one() + self._equivalent( + self.session.query(UserKeyword).filter( + UserKeyword.singular != s4), + self.session.query(UserKeyword).filter( + UserKeyword.user.has(User.singular != s4))) + + def test_filter_column_ne_value_nul(self): User = self.classes.User Singular = self.classes.Singular @@ -1663,13 +1695,13 @@ class ComparatorTest(fixtures.MappedTest, AssertsCompiledSQL): User.singular_keyword.has, keyword="brown" ) - def test_filter_contains_chained_has_to_any(self): + def test_filter_eq_chained_has_to_any(self): User = self.classes.User Keyword = self.classes.Keyword Singular = self.classes.Singular q1 = self.session.query(User).filter( - User.singular_keyword.contains("brown") + User.singular_keyword == "brown" ) self.assert_compile( q1, @@ -1780,18 +1812,74 @@ class ComparatorTest(fixtures.MappedTest, AssertsCompiledSQL): User.singular_value.has, singular_value="singular4" ) - def test_filter_scalar_contains_fails_nul_nul(self): + def test_filter_scalar_object_contains_fails_nul_nul(self): Keyword = self.classes.Keyword assert_raises(exc.InvalidRequestError, lambda: Keyword.user.contains(self.u)) - def test_filter_scalar_any_fails_nul_nul(self): + def test_filter_scalar_object_any_fails_nul_nul(self): Keyword = self.classes.Keyword assert_raises(exc.InvalidRequestError, lambda: Keyword.user.any(name='user2')) + def test_filter_scalar_column_like(self): + User = self.classes.User + Singular = self.classes.Singular + + self._equivalent( + self.session.query(User).filter(User.singular_value.like('foo')), + self.session.query(User).filter( + User.singular.has(Singular.value.like('foo')), + ) + ) + + def test_filter_scalar_column_contains(self): + User = self.classes.User + Singular = self.classes.Singular + + self._equivalent( + self.session.query(User).filter(User.singular_value.contains('foo')), + self.session.query(User).filter( + User.singular.has(Singular.value.contains('foo')), + ) + ) + + def test_filter_scalar_column_eq(self): + User = self.classes.User + Singular = self.classes.Singular + + self._equivalent( + self.session.query(User).filter(User.singular_value == 'foo'), + self.session.query(User).filter( + User.singular.has(Singular.value == 'foo'), + ) + ) + + def test_filter_scalar_column_ne(self): + User = self.classes.User + Singular = self.classes.Singular + + self._equivalent( + self.session.query(User).filter(User.singular_value != 'foo'), + self.session.query(User).filter( + User.singular.has(Singular.value != 'foo'), + ) + ) + + def test_filter_scalar_column_eq_nul(self): + User = self.classes.User + Singular = self.classes.Singular + + self._equivalent( + self.session.query(User).filter(User.singular_value == None), + self.session.query(User).filter(or_( + User.singular.has(Singular.value == None), + User.singular == None + )) + ) + def test_filter_collection_has_fails_ul_nul(self): User = self.classes.User @@ -1849,7 +1937,8 @@ class DictOfTupleUpdateTest(fixtures.TestBase): m = MetaData() a = Table('a', m, Column('id', Integer, primary_key=True)) b = Table('b', m, Column('id', Integer, primary_key=True), - Column('aid', Integer, ForeignKey('a.id'))) + Column('aid', Integer, ForeignKey('a.id')), + Column('elem', String)) mapper(A, a, properties={ 'orig': relationship( B, @@ -1947,6 +2036,7 @@ class AttributeAccessTest(fixtures.TestBase): __tablename__ = 'child' parent_id = Column( Integer, ForeignKey(Parent.id), primary_key=True) + value = Column(String) # 2. declarative builds up SubParent, scans through all attributes # over all classes. Hits Mixin, hits "children", accesses "children" @@ -1976,6 +2066,7 @@ class AttributeAccessTest(fixtures.TestBase): __tablename__ = 'child' parent_id = Column( Integer, ForeignKey(Parent.id), primary_key=True) + value = Column(String) class SubParent(Parent): __tablename__ = 'subparent' @@ -1996,6 +2087,7 @@ class AttributeAccessTest(fixtures.TestBase): __tablename__ = 'child' parent_id = Column( Integer, ForeignKey(Parent.id), primary_key=True) + value = Column(String) class SubParent(Parent): __tablename__ = 'subparent' @@ -2016,6 +2108,7 @@ class AttributeAccessTest(fixtures.TestBase): __tablename__ = 'child' parent_id = Column( Integer, ForeignKey(Parent.id), primary_key=True) + value = Column(String) class SubParent(Parent): __tablename__ = 'subparent' @@ -2411,27 +2504,28 @@ class MultiOwnerTest(fixtures.DeclarativeMappedTest, c_id = Column(ForeignKey('c.id')) c2_id = Column(ForeignKey('c2.id')) - def test_any_has(self): + def test_column_collection_expressions(self): B, C, C2 = self.classes("B", "C", "C2") self.assert_compile( B.d_values.contains('b1'), "EXISTS (SELECT 1 FROM d, b WHERE d.b_id = b.id " - "AND d.value = :value_1)" + "AND (d.value LIKE '%' || :value_1 || '%'))" ) self.assert_compile( C2.d_values.contains("c2"), "EXISTS (SELECT 1 FROM d, c2 WHERE d.c2_id = c2.id " - "AND d.value = :value_1)" + "AND (d.value LIKE '%' || :value_1 || '%'))" ) self.assert_compile( C.d_values.contains('c1'), "EXISTS (SELECT 1 FROM d, c WHERE d.c_id = c.id " - "AND d.value = :value_1)" + "AND (d.value LIKE '%' || :value_1 || '%'))" ) + class ScopeBehaviorTest(fixtures.DeclarativeMappedTest): # test some GC scenarios, including issue #4268 diff --git a/test/orm/test_inspect.py b/test/orm/test_inspect.py index 0eaca3136a..8353102081 100644 --- a/test/orm/test_inspect.py +++ b/test/orm/test_inspect.py @@ -4,7 +4,9 @@ from sqlalchemy.testing import eq_, assert_raises_message, is_ from sqlalchemy import exc, util from sqlalchemy import inspect from test.orm import _fixtures -from sqlalchemy.orm import class_mapper, synonym, Session, aliased +from sqlalchemy.orm import class_mapper, synonym, Session, aliased,\ + relationship +from sqlalchemy import ForeignKey from sqlalchemy.orm.attributes import instance_state, NO_VALUE from sqlalchemy import testing from sqlalchemy.orm.util import identity_key @@ -260,6 +262,9 @@ class TestORMInspection(_fixtures.FixtureTest): def conv(self, fn): raise NotImplementedError() + class Address(self.classes.Address): + pass + class SomeSubClass(SomeClass): @hybrid_property def upper_name(self): @@ -269,9 +274,17 @@ class TestORMInspection(_fixtures.FixtureTest): def foo(self): raise NotImplementedError() - t = Table('sometable', MetaData(), + m = MetaData() + t = Table('sometable', m, Column('id', Integer, primary_key=True)) - mapper(SomeClass, t) + ta = Table('address_t', m, + Column('id', Integer, primary_key=True), + Column('s_id', ForeignKey('sometable.id')) + ) + mapper(SomeClass, t, properties={ + "addresses": relationship(Address) + }) + mapper(Address, ta) mapper(SomeSubClass, inherits=SomeClass) insp = inspect(SomeSubClass)