]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Allow overriding @validates for inheritance
authorMike Bayer <mike_mp@zzzcomputing.com>
Thu, 9 Jul 2026 20:44:16 +0000 (16:44 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Sat, 11 Jul 2026 17:14:12 +0000 (13:14 -0400)
When a subclass overrides a :func:`_orm.validates` method using the
same method name as the parent class, only the subclass validator is
now invoked for instances of the subclass.  The subclass validator
may call ``super()`` to also invoke the parent class validator.
Previously, the parent validator was always used regardless of
whether the subclass provided an override.  Pull request courtesy
Indivar Mishra.

as an aside, also sets -j auto for the sphinx autobuild
utility in the makefile, seems to work.  I would assume this was
not working when I originally added this option.

Fixes: #2943
Closes: #10574
Pull-request: https://github.com/sqlalchemy/sqlalchemy/pull/10574
Pull-request-sha: d310bef35b0ac3e5b840842b62807fc4008fd086

Change-Id: If0dab1ce5d93dbba4686d9cab36b81f6a79bfc1e

doc/build/Makefile
doc/build/changelog/unreleased_21/2943.rst [new file with mode: 0644]
doc/build/orm/mapped_attributes.rst
lib/sqlalchemy/orm/mapper.py
lib/sqlalchemy/orm/strategies.py
test/orm/test_validators.py

index 325da5046e665f148c6818f308fcea9111433e22..718887dd60919f0cc792404685014ac7794c76dd 100644 (file)
@@ -14,7 +14,7 @@ PAPEROPT_letter = -D latex_paper_size=letter
 ALLSPHINXOPTS   = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
 # the i18n builder cannot share the environment and doctrees with the others
 I18NSPHINXOPTS  = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
-AUTOBUILDSPHINXOPTS = -T .
+AUTOBUILDSPHINXOPTS = -T -j auto .
 
 .PHONY: help clean html autobuild dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest dist-html site-mako gettext
 
diff --git a/doc/build/changelog/unreleased_21/2943.rst b/doc/build/changelog/unreleased_21/2943.rst
new file mode 100644 (file)
index 0000000..f048956
--- /dev/null
@@ -0,0 +1,15 @@
+.. change::
+    :tags: usecase, orm
+    :tickets: 2943
+
+    When a subclass overrides a :func:`_orm.validates` method using the
+    same method name as the parent class, only the subclass validator is
+    now invoked for instances of the subclass.  The subclass validator
+    may call ``super()`` to also invoke the parent class validator.
+    Previously, the parent validator was always used regardless of
+    whether the subclass provided an override.  Pull request courtesy
+    Indivar Mishra.
+
+    .. seealso::
+
+        :ref:`validators_subclass_override`
index b114680132e332588091a8200a9bc174c0eab567..2e9e7f83fc312e6a94321873d27fc7ffbd90b641 100644 (file)
@@ -107,6 +107,63 @@ described at :class:`~.AttributeEvents`.
 
 .. autofunction:: validates
 
+.. _validators_subclass_override:
+
+Overriding Validators in Subclasses
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. versionadded:: 2.1
+
+A subclass may override a validator defined on its parent class by
+applying the :func:`.validates` decorator to a method that uses the same
+name as the parent's validator.  When this is the case, only the subclass
+validator is invoked for instances of the subclass; the parent's
+validator is **not** called automatically.
+
+This allows the subclass to fully replace the parent's validation
+behavior.  If the subclass wishes to combine its own behavior with that
+of the parent, it may call ``super()`` within the overriding method to
+also invoke the parent validator::
+
+    from sqlalchemy.orm import validates
+
+
+    class User(Base):
+        __tablename__ = "user"
+
+        id = mapped_column(Integer, primary_key=True)
+        email = mapped_column(String)
+
+        @validates("email")
+        def validate_email(self, key, address):
+            if "@" not in address:
+                raise ValueError("failed simple email validation")
+            return address
+
+
+    class VerifiedUser(User):
+        @validates("email")
+        def validate_email(self, key, address):
+            # call the parent validator first
+            address = super().validate_email(key, address)
+            if not address.endswith("@example.com"):
+                raise ValueError("only example.com addresses allowed")
+            return address
+
+Above, assigning to ``VerifiedUser.email`` invokes
+``VerifiedUser.validate_email`` only; the call to ``super()`` is what
+causes ``User.validate_email`` to run as well.  Without that
+``super()`` call, the parent's validator would be skipped entirely.
+
+.. versionchanged:: 2.1
+
+   A subclass overriding a parent's validator using the same method name
+   now replaces the parent validator rather than the parent validator
+   running unconditionally.  Previously, the parent validator would
+   always run even when the subclass provided an override, which made it
+   difficult for a subclass to alter or replace the parent's validation
+   behavior.
+
 Using Custom Datatypes at the Core Level
 -----------------------------------------
 
index 14dab6019133f5ee7cdbb11be2206d18448908ee..faf0b45a321e2059ed0e267919a2f81790adc5d4 100644 (file)
@@ -4357,6 +4357,21 @@ def validates(
     modify or replace the value before proceeding. The function should
     otherwise return the given value.
 
+    When a subclass overrides a validator for the same attribute using
+    the same method name, only the subclass validator is invoked.  The
+    subclass validator may call ``super()`` to also invoke the parent
+    class validator.
+
+    A subclass that overrides a validator using the same method name
+    as the parent now replaces the parent validator entirely, rather
+    than the parent validator being invoked unconditionally.  The
+    subclass validator may opt in to the parent's behavior by calling
+    ``super()``.  See :ref:`validators_subclass_override` for
+    background on this change.
+
+    .. versionchanged:: 2.1 Added support for overriding of validators
+       on subclasses.
+
     Note that a validator for a collection **cannot** issue a load of that
     collection within the validation routine - this usage raises
     an assertion to avoid recursion overflows.  This is a reentrant
@@ -4381,6 +4396,9 @@ def validates(
 
       :ref:`simple_validators` - usage examples for :func:`.validates`
 
+      :ref:`validators_subclass_override` - overriding validators in
+      subclasses
+
     """
 
     def wrap(fn: _Fn) -> _Fn:
index 017a05200fca4fcbc5add646c79b30face83f072..403f81eeebfd1a7163b03ba5cf06e73b965e5489 100644 (file)
@@ -79,30 +79,32 @@ def _register_attribute(
     default_scalar_value=None,
     **kw,
 ):
-    listen_hooks = []
+
+    # event-hook registration functions organized into "pre validate" and "post
+    # validate" collections.  Each hook registration function generates a new
+    # AttributeEvents registration for this attribute.  What we are controlling
+    # here is the order in which these attribute events are established. Hook
+    # registration functions invoked in the order of first pre-validate, then
+    # user-specific validation i.e. `@validates`, then post-validate.
+    # within `@validates` we are scanning for hooks from child-most classes
+    # first (this suits the feature added in #2943).
+    pre_validate_hooks = []
+    post_validate_hooks = []
 
     uselist = useobject and prop.uselist
 
     if useobject and prop.single_parent:
-        listen_hooks.append(_single_parent_validator)
-
-    if prop.key in prop.parent.validators:
-        fn, opts = prop.parent.validators[prop.key]
-        listen_hooks.append(
-            lambda desc, prop: orm_util._validator_events(
-                desc, prop.key, fn, **opts
-            )
-        )
+        pre_validate_hooks.append(_single_parent_validator)
 
     if useobject:
-        listen_hooks.append(unitofwork._track_cascade_events)
+        post_validate_hooks.append(unitofwork._track_cascade_events)
 
     # need to assemble backref listeners
     # after the singleparentvalidator, mapper validator
     if useobject:
         backref = prop.back_populates
         if backref and prop._effective_sync_backref:
-            listen_hooks.append(
+            post_validate_hooks.append(
                 lambda desc, prop: attributes._backref_listeners(
                     desc, backref, uselist
                 )
@@ -145,7 +147,16 @@ def _register_attribute(
                 **kw,
             )
 
-            for hook in listen_hooks:
+            for hook in pre_validate_hooks:
+                hook(desc, prop)
+
+            for super_m in m.iterate_to_root():
+                if prop.key in super_m.validators:
+                    fn, opts = super_m.validators[prop.key]
+                    orm_util._validator_events(desc, prop.key, fn, **opts)
+                    break
+
+            for hook in post_validate_hooks:
                 hook(desc, prop)
 
 
index df7334d5cb584d1a81b23badd03e14b6a2b6de6f..9b5a0d117c0f2fc1ad05d153a39e99904181a83f 100644 (file)
@@ -1,14 +1,19 @@
 from unittest.mock import call
 from unittest.mock import Mock
 
+from sqlalchemy import Column
 from sqlalchemy import exc
+from sqlalchemy import Integer
+from sqlalchemy import String
 from sqlalchemy import testing
 from sqlalchemy.orm import collections
+from sqlalchemy.orm import declarative_base
 from sqlalchemy.orm import relationship
 from sqlalchemy.orm import validates
 from sqlalchemy.testing import assert_raises
 from sqlalchemy.testing import assert_raises_message
 from sqlalchemy.testing import eq_
+from sqlalchemy.testing import expect_raises_message
 from sqlalchemy.testing import ne_
 from sqlalchemy.testing.entities import ComparableEntity
 from sqlalchemy.testing.fixtures import fixture_session
@@ -447,3 +452,93 @@ class ValidatorTest(_fixtures.FixtureTest):
                         call("user", User(addresses=[])),
                     ],
                 )
+
+    def _inheritance_override_classes(self):
+        Base = declarative_base()
+
+        class A(Base):
+            __tablename__ = "a"
+            id = Column(Integer, primary_key=True)
+            data = Column(String)
+            foo = Column(String)
+
+            @validates("data")
+            def validate_data(self, key, value):
+                return "Call from A : " + value
+
+            @validates("foo")
+            def validate_foo(self, key, value):
+                ne_(value, "exclude for A")
+                return value
+
+        class B(A):
+            foo2 = Column(String)
+            bar = Column(String)
+
+            @validates("data")
+            def validate_data(self, key, value):
+                return "Call from B : " + value
+
+            @validates("foo")
+            def validate_foo(self, key, value):
+                value = super().validate_foo(key, value)
+                ne_(value, "exclude for B")
+                return value
+
+            @validates("foo2", "bar")
+            def validate_foobar(self, key, value):
+                if key == "foo2":
+                    return value + "_"
+                return "_" + value
+
+        class C(B):
+            @validates("foo2", "bar")
+            def validate_foobar(self, key, value):
+                if key == "foo2":
+                    return value + "-"
+                return "-" + value
+
+        return A, B, C
+
+    def test_validator_override_parent(self):
+        A, B, C = self._inheritance_override_classes()
+
+        obj = A(data="ed")
+        eq_(obj.data, "Call from A : ed")
+        with expect_raises_message(AssertionError, "exclude for A"):
+            obj.foo = "exclude for A"
+        obj.foo = "exclude for B"
+
+    def test_validator_override_subclass_replaces_parent(self):
+        A, B, C = self._inheritance_override_classes()
+
+        obj = B(data="ed")
+        eq_(obj.data, "Call from B : ed")
+
+    def test_validator_override_subclass_calls_super(self):
+        A, B, C = self._inheritance_override_classes()
+
+        obj = B(data="ed")
+        with expect_raises_message(AssertionError, "exclude for A"):
+            obj.foo = "exclude for A"
+        with expect_raises_message(AssertionError, "exclude for B"):
+            obj.foo = "exclude for B"
+        obj.foo = "Some other value"
+
+    def test_validator_override_subclass_multi_attribute(self):
+        A, B, C = self._inheritance_override_classes()
+
+        obj = B(data="ed")
+        obj.foo2 = "foo"
+        obj.bar = "bar"
+        eq_(obj.foo2, "foo_")
+        eq_(obj.bar, "_bar")
+
+    def test_validator_override_grandchild_replaces_parent(self):
+        A, B, C = self._inheritance_override_classes()
+
+        obj = C(data="ed")
+        obj.foo2 = "foo"
+        obj.bar = "bar"
+        eq_(obj.foo2, "foo-")
+        eq_(obj.bar, "-bar")