From: Diana Clarke Date: Thu, 16 Mar 2017 20:05:18 +0000 (-0400) Subject: Allow reuse of hybrid_property across subclasses X-Git-Tag: rel_1_2_0b1~140 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=caeb274e287f514a50524fc9fe4aeedcb3740147;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git Allow reuse of hybrid_property across subclasses 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 Fixes: #3911 Fixes: #3912 Change-Id: Iff033d8ccaae20ded9289cbfa789c376759381f5 --- diff --git a/doc/build/changelog/changelog_12.rst b/doc/build/changelog/changelog_12.rst index 95e7b0a5b5..cd36f41384 100644 --- a/doc/build/changelog/changelog_12.rst +++ b/doc/build/changelog/changelog_12.rst @@ -13,6 +13,23 @@ .. 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 diff --git a/doc/build/changelog/migration_12.rst b/doc/build/changelog/migration_12.rst index 0e1270d45b..7097c6aec9 100644 --- a/doc/build/changelog/migration_12.rst +++ b/doc/build/changelog/migration_12.rst @@ -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 ==================================== diff --git a/lib/sqlalchemy/ext/hybrid.py b/lib/sqlalchemy/ext/hybrid.py index 509dd560ad..17049d9953 100644 --- a/lib/sqlalchemy/ext/hybrid.py +++ b/lib/sqlalchemy/ext/hybrid.py @@ -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): diff --git a/test/ext/test_hybrid.py b/test/ext/test_hybrid.py index aefcec4059..20a76d6d21 100644 --- a/test/ext/test_hybrid.py +++ b/test/ext/test_hybrid.py @@ -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'