]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Allow reuse of hybrid_property across subclasses
authorDiana Clarke <diana.joan.clarke@gmail.com>
Thu, 16 Mar 2017 20:05:18 +0000 (16:05 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Tue, 21 Mar 2017 19:42:42 +0000 (15:42 -0400)
The :class:`sqlalchemy.ext.hybrid.hybrid_property` class now supports
calling mutators like ``@setter``, ``@expression`` etc. multiple times
across subclasses, and now provides a ``@getter`` mutator, so that
a particular hybrid can be repurposed across subclasses or other
classes.  This now matches the behavior of ``@property`` in standard
Python.

Co-authored-by: Mike Bayer <mike_mp@zzzcomputing.com>
Fixes: #3911
Fixes: #3912
Change-Id: Iff033d8ccaae20ded9289cbfa789c376759381f5

doc/build/changelog/changelog_12.rst
doc/build/changelog/migration_12.rst
lib/sqlalchemy/ext/hybrid.py
test/ext/test_hybrid.py

index 95e7b0a5b5253cd6311537792cec926471cd2cfb..cd36f413842238cecff4192b212e4c7276533726 100644 (file)
 .. changelog::
     :version: 1.2.0b1
 
+    .. change:: 3911_3912
+        :tags: bug, ext
+        :tickets: 3911, 3912
+
+        The :class:`sqlalchemy.ext.hybrid.hybrid_property` class now supports
+        calling mutators like ``@setter``, ``@expression`` etc. multiple times
+        across subclasses, and now provides a ``@getter`` mutator, so that
+        a particular hybrid can be repurposed across subclasses or other
+        classes.  This now matches the behavior of ``@property`` in standard
+        Python.
+
+        .. seealso::
+
+            :ref:`change_3911_3912`
+
+
+
     .. change:: 1546
         :tags: feature, sql, postgresql, mysql, oracle
         :tickets: 1546
index 0e1270d45b9bed72189356c4c59b357fa12e920c..7097c6aec97b5cf9579af5efbd70558c2442864e 100644 (file)
@@ -153,6 +153,67 @@ if this "append" event is the second part of a bulk replace::
 :ticket:`3896`
 
 
+.. _change_3911_3912:
+
+Hybrid attributes support reuse among subclasses, redefinition of @getter
+-------------------------------------------------------------------------
+
+The :class:`sqlalchemy.ext.hybrid.hybrid_property` class now supports
+calling mutators like ``@setter``, ``@expression`` etc. multiple times
+across subclasses, and now provides a ``@getter`` mutator, so that
+a particular hybrid can be repurposed across subclasses or other
+classes.  This now is similar to the behavior of ``@property`` in standard
+Python::
+
+    class FirstNameOnly(Base):
+        # ...
+
+        first_name = Column(String)
+
+        @hybrid_property
+        def name(self):
+            return self.first_name
+
+        @name.setter
+        def name(self, value):
+            self.first_name = value
+
+    class FirstNameLastName(FirstNameOnly):
+        # ...
+
+        last_name = Column(String)
+
+        @FirstNameOnly.name.getter
+        def name(self):
+            return self.first_name + ' ' + self.last_name
+
+        @name.setter
+        def name(self, value):
+            self.first_name, self.last_name = value.split(' ', maxsplit=1)
+
+        @name.expression
+        def name(cls):
+            return func.concat(cls.first_name, ' ', cls.last_name)
+
+Above, the ``FirstNameOnly.name`` hybrid is referenced by the
+``FirstNameLastName`` subclass in order to repurpose it specifically to the
+new subclass.   This is achieved by copying the hybrid object to a new one
+within each call to ``@getter``, ``@setter``, as well as in all other
+mutator methods like ``@expression``, leaving the previous hybrid's definition
+intact.  Previously, methods like ``@setter`` would modify the existing
+hybrid in-place, interfering with the definition on the superclass.
+
+.. note:: Be sure to read the documentation at :ref:`hybrid_reuse_subclass`
+   for important notes regarding how to override
+   :meth:`.hybrid_property.expression`
+   and :meth:`.hybrid_property.comparator`, as a special qualifier
+   :attr:`.hybrid_property.overrides` may be necessary to avoid name
+   conflicts with :class:`.QueryableAttribute` in some cases.
+
+:ticket:`3911`
+
+:ticket:`3912`
+
 New Features and Improvements - Core
 ====================================
 
index 509dd560adf00304c27c79fd32cbb3883be111a8..17049d995334212ec825ba0aa35780aff1249db0 100644 (file)
@@ -372,6 +372,67 @@ lowercasing can be applied to all comparison operations (i.e. ``eq``,
         def operate(self, op, other):
             return op(func.lower(self.__clause_element__()), func.lower(other))
 
+.. _hybrid_reuse_subclass:
+
+Reusing Hybrid Properties across Subclasses
+-------------------------------------------
+
+A hybrid can be referred to from a superclass, to allow modifying
+methods like :meth:`.hybrid_property.getter`, :meth:`.hybrid_property.setter`
+to be used to redefine those methods on a subclass.  This is similar to
+how the standard Python ``@property`` object works::
+
+    class FirstNameOnly(Base):
+        # ...
+
+        first_name = Column(String)
+
+        @hybrid_property
+        def name(self):
+            return self.first_name
+
+        @name.setter
+        def name(self, value):
+            self.first_name = value
+
+    class FirstNameLastName(FirstNameOnly):
+        # ...
+
+        last_name = Column(String)
+
+        @FirstNameOnly.name.getter
+        def name(self):
+            return self.first_name + ' ' + self.last_name
+
+        @name.setter
+        def name(self, value):
+            self.first_name, self.last_name = value.split(' ', 1)
+
+Above, the ``FirstNameLastName`` class refers to the hybrid from
+``FirstNameOnly.name`` to repurpose its getter and setter for the subclass.
+
+When overriding :meth:`.hybrid_property.expression` and
+:meth:`.hybrid_property.comparator` alone as the first reference
+to the superclass, these names conflict
+with the same-named accessors on the class-level :class:`.QueryableAttribute`
+object returned at the class level.  To override these methods when
+referring directly to the parent class descriptor, add
+the special qualifier :attr:`.hybrid_property.overrides`, which will
+de-reference the instrumented attribute back to the hybrid object::
+
+    class FirstNameLastName(FirstNameOnly):
+        # ...
+
+        last_name = Column(String)
+
+        @FirstNameOnly.overrides.expression
+        def name(cls):
+            return func.concat(cls.first_name, ' ', cls.last_name)
+
+.. versionadded:: 1.2 Added :meth:`.hybrid_property.getter` as well as the
+   ability to redefine accessors per-subclass.
+
+
 Hybrid Value Objects
 --------------------
 
@@ -714,7 +775,9 @@ class hybrid_property(interfaces.InspectionAttrInfo):
     is_attribute = True
     extension_type = HYBRID_PROPERTY
 
-    def __init__(self, fget, fset=None, fdel=None, expr=None):
+    def __init__(
+            self, fget, fset=None, fdel=None,
+            expr=None, custom_comparator=None):
         """Create a new :class:`.hybrid_property`.
 
         Usage is typically via decorator::
@@ -734,12 +797,14 @@ class hybrid_property(interfaces.InspectionAttrInfo):
         self.fget = fget
         self.fset = fset
         self.fdel = fdel
-        self.expression(expr or fget)
+        self.expr = expr
+        self.custom_comparator = custom_comparator
+
         util.update_wrapper(self, fget)
 
     def __get__(self, instance, owner):
         if instance is None:
-            return self.expr(owner)
+            return self._expr_comparator(owner)
         else:
             return self.fget(instance)
 
@@ -753,29 +818,96 @@ class hybrid_property(interfaces.InspectionAttrInfo):
             raise AttributeError("can't delete attribute")
         self.fdel(instance)
 
-    def setter(self, fset):
-        """Provide a modifying decorator that defines a value-setter method."""
+    def _copy(self, **kw):
+        defaults = {
+            key: value
+            for key, value in self.__dict__.items()
+            if not key.startswith("_")}
+        defaults.update(**kw)
+        return type(self)(**defaults)
 
-        self.fset = fset
+    @property
+    def overrides(self):
+        """Prefix for a method that is overriding an existing attribute.
+
+        The :attr:`.hybrid_property.overrides` accessor just returns
+        this hybrid object, which when called at the class level from
+        a parent class, will de-reference the "instrumented attribute"
+        normally returned at this level, and allow modifying decorators
+        like :meth:`.hybrid_property.expression` and
+        :meth:`.hybrid_property.comparator`
+        to be used without conflicting with the same-named attributes
+        normally present on the :class:`.QueryableAttribute`::
+
+            class SuperClass(object):
+                # ...
+
+                @hybrid_property
+                def foobar(self):
+                    return self._foobar
+
+            class SubClass(SuperClass):
+                # ...
+
+                @SuperClass.foobar.overrides.expression
+                def foobar(cls):
+                    return func.subfoobar(self._foobar)
+
+        .. versionadded:: 1.2
+
+        .. seealso::
+
+            :ref:`hybrid_reuse_subclass`
+
+         """
         return self
 
+    def getter(self, fget):
+        """Provide a modifying decorator that defines a getter method.
+
+        .. versionadded:: 1.2
+
+        """
+
+        return self._copy(fget=fget)
+
+    def setter(self, fset):
+        """Provide a modifying decorator that defines a setter method."""
+
+        return self._copy(fset=fset)
+
     def deleter(self, fdel):
-        """Provide a modifying decorator that defines a
-        value-deletion method."""
+        """Provide a modifying decorator that defines a deletion method."""
 
-        self.fdel = fdel
-        return self
+        return self._copy(fdel=fdel)
 
     def expression(self, expr):
         """Provide a modifying decorator that defines a SQL-expression
-        producing method."""
+        producing method.
+
+        When a hybrid is invoked at the class level, the SQL expression given
+        here is wrapped inside of a specialized :class:`.QueryableAttribute`,
+        which is the same kind of object used by the ORM to represent other
+        mapped attributes.   The reason for this is so that other class-level
+        attributes such as docstrings and a reference to the hybrid itself may
+        be maintained within the structure that's returned, without any
+        modifications to the original SQL expression passed in.
+
+        .. note::
+
+           when referring to a hybrid property  from an owning class (e.g.
+           ``SomeClass.some_hybrid``), an instance of
+           :class:`.QueryableAttribute` is returned, representing the
+           expression or comparator object as well as this  hybrid object.
+           However, that object itself has accessors called ``expression`` and
+           ``comparator``; so when attempting to override these decorators on a
+           subclass, it may be necessary to qualify it using the
+           :attr:`.hybrid_property.overrides` modifier first.  See that
+           modifier for details.
 
-        def _expr(cls):
-            return ExprComparator(expr(cls), self)
-        util.update_wrapper(_expr, expr)
+        """
 
-        self.expr = _expr
-        return self.comparator(_expr)
+        return self._copy(expr=expr)
 
     def comparator(self, comparator):
         """Provide a modifying decorator that defines a custom
@@ -784,17 +916,56 @@ class hybrid_property(interfaces.InspectionAttrInfo):
         The return value of the decorated method should be an instance of
         :class:`~.hybrid.Comparator`.
 
+        When a hybrid is invoked at the class level, the
+        :class:`~.hybrid.Comparator` object given here is wrapped inside of a
+        specialized :class:`.QueryableAttribute`, which is the same kind of
+        object used by the ORM to represent other mapped attributes.   The
+        reason for this is so that other class-level attributes such as
+        docstrings and a reference to the hybrid itself may be maintained
+        within the structure that's returned, without any modifications to the
+        original comparator object passed in.
+
+        .. note::
+
+           when referring to a hybrid property  from an owning class (e.g.
+           ``SomeClass.some_hybrid``), an instance of
+           :class:`.QueryableAttribute` is returned, representing the
+           expression or comparator object as this  hybrid object.  However,
+           that object itself has accessors called ``expression`` and
+           ``comparator``; so when attempting to override these decorators on a
+           subclass, it may be necessary to qualify it using the
+           :attr:`.hybrid_property.overrides` modifier first.  See that
+           modifier for details.
+
         """
+        return self._copy(custom_comparator=comparator)
+
+    @util.memoized_property
+    def _expr_comparator(self):
+        if self.custom_comparator is not None:
+            return self._get_comparator(self.custom_comparator)
+        elif self.expr is not None:
+            return self._get_expr(self.expr)
+        else:
+            return self._get_expr(self.fget)
 
-        proxy_attr = attributes.\
-            create_proxied_attribute(self)
+    def _get_expr(self, expr):
 
-        def expr(owner):
+        def _expr(cls):
+            return ExprComparator(expr(cls), self)
+        util.update_wrapper(_expr, expr)
+
+        return self._get_comparator(_expr)
+
+    def _get_comparator(self, comparator):
+
+        proxy_attr = attributes.create_proxied_attribute(self)
+
+        def expr_comparator(owner):
             return proxy_attr(
                 owner, self.__name__, self, comparator(owner),
                 doc=comparator.__doc__ or self.__doc__)
-        self.expr = expr
-        return self
+        return expr_comparator
 
 
 class Comparator(interfaces.PropComparator):
index aefcec40591e034bc3967c92f4d15090b5eb453a..20a76d6d21d0468c171232a367842ad81dff0de9 100644 (file)
@@ -120,6 +120,7 @@ class PropertyExpressionTest(fixtures.TestBase, AssertsCompiledSQL):
 
         return A
 
+
     def _relationship_fixture(self):
         Base = declarative_base()
 
@@ -265,6 +266,118 @@ class PropertyValueTest(fixtures.TestBase, AssertsCompiledSQL):
         eq_(a1._value, 10)
 
 
+class PropertyOverrideTest(fixtures.TestBase, AssertsCompiledSQL):
+    __dialect__ = 'default'
+
+    def _fixture(self):
+        Base = declarative_base()
+
+        class Person(Base):
+            __tablename__ = 'person'
+            id = Column(Integer, primary_key=True)
+            _name = Column(String)
+
+            @hybrid.hybrid_property
+            def name(self):
+                return self._name
+
+            @name.setter
+            def name(self, value):
+                self._name = value.title()
+
+        class OverrideSetter(Person):
+            __tablename__ = 'override_setter'
+            id = Column(Integer, ForeignKey('person.id'), primary_key=True)
+            other = Column(String)
+
+            @Person.name.setter
+            def name(self, value):
+                self._name = value.upper()
+
+        class OverrideGetter(Person):
+            __tablename__ = 'override_getter'
+            id = Column(Integer, ForeignKey('person.id'), primary_key=True)
+            other = Column(String)
+
+            @Person.name.getter
+            def name(self):
+                return "Hello " + self._name
+
+        class OverrideExpr(Person):
+            __tablename__ = 'override_expr'
+            id = Column(Integer, ForeignKey('person.id'), primary_key=True)
+            other = Column(String)
+
+            @Person.name.overrides.expression
+            def name(self):
+                return func.concat("Hello", self._name)
+
+        class FooComparator(hybrid.Comparator):
+            def __clause_element__(self):
+                return func.concat("Hello", self.expression._name)
+
+        class OverrideComparator(Person):
+            __tablename__ = 'override_comp'
+            id = Column(Integer, ForeignKey('person.id'), primary_key=True)
+            other = Column(String)
+
+            @Person.name.overrides.comparator
+            def name(self):
+                return FooComparator(self)
+
+        return (
+            Person, OverrideSetter, OverrideGetter,
+            OverrideExpr, OverrideComparator
+        )
+
+    def test_property(self):
+        Person, _, _, _, _ = self._fixture()
+        p1 = Person()
+        p1.name = 'mike'
+        eq_(p1._name, 'Mike')
+        eq_(p1.name, 'Mike')
+
+    def test_override_setter(self):
+        _, OverrideSetter, _, _, _ = self._fixture()
+        p1 = OverrideSetter()
+        p1.name = 'mike'
+        eq_(p1._name, 'MIKE')
+        eq_(p1.name, 'MIKE')
+
+    def test_override_getter(self):
+        _, _, OverrideGetter, _, _ = self._fixture()
+        p1 = OverrideGetter()
+        p1.name = 'mike'
+        eq_(p1._name, 'Mike')
+        eq_(p1.name, 'Hello Mike')
+
+    def test_override_expr(self):
+        Person, _, _, OverrideExpr, _ = self._fixture()
+
+        self.assert_compile(
+            Person.name.__clause_element__(),
+            "person._name"
+        )
+
+        self.assert_compile(
+            OverrideExpr.name.__clause_element__(),
+            "concat(:concat_1, person._name)"
+        )
+
+    def test_override_comparator(self):
+        Person, _, _, _, OverrideComparator = self._fixture()
+
+        self.assert_compile(
+            Person.name.__clause_element__(),
+            "person._name"
+        )
+
+        self.assert_compile(
+            OverrideComparator.name.__clause_element__(),
+            "concat(:concat_1, person._name)"
+        )
+
+
 class PropertyMirrorTest(fixtures.TestBase, AssertsCompiledSQL):
     __dialect__ = 'default'