]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Create object- and column-oriented versions of AssociationProxyInstance
authorMike Bayer <mike_mp@zzzcomputing.com>
Tue, 23 Oct 2018 23:38:46 +0000 (19:38 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Fri, 26 Oct 2018 20:34:33 +0000 (16:34 -0400)
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

doc/build/changelog/migration_13.rst
doc/build/changelog/unreleased_13/4351.rst [new file with mode: 0644]
doc/build/orm/extensions/associationproxy.rst
lib/sqlalchemy/ext/associationproxy.py
test/ext/test_associationproxy.py
test/orm/test_inspect.py

index d1ebe846c9351c23d3c0b383f585be2ac6ed0125..585a9c3a5aecbac07a6985a837a580d8815dc39e 100644 (file)
@@ -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 (file)
index 0000000..51f9f60
--- /dev/null
@@ -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`
+
index 62b24c92843010abafb991a3f1007db3147cadd9..0ad919c08a00711532671ccffb68ff60e5f3efd2 100644 (file)
@@ -516,4 +516,12 @@ API Documentation
    :undoc-members:
    :inherited-members:
 
+.. autoclass:: ObjectAssociationProxyInstance
+   :members:
+   :inherited-members:
+
+.. autoclass:: ColumnAssociationProxyInstance
+   :members:
+   :inherited-members:
+
 .. autodata:: ASSOCIATION_PROXY
index 1c28b10a17b09bf0a10d5b9d2f947fae098db4e4..629b4ac64953aeafbf466bed9d4f76582acf557f 100644 (file)
@@ -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
index 2204f13c172d0ec1296af09e95fe09debfeae767..c661c9e01313dcd5792a90f402c39024c07fd6d5 100644 (file)
@@ -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
 
index 0eaca3136a02cb693ac8ee89de2ba818e0d72477..8353102081f40fecc1552183fc5124c580eb0409 100644 (file)
@@ -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)