From 5bf558aa45a03d7bb6364e07e7d8fff5b92158ab Mon Sep 17 00:00:00 2001 From: Mike Bayer Date: Thu, 9 Jul 2026 16:44:16 -0400 Subject: [PATCH] Allow overriding @validates for inheritance 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 | 2 +- doc/build/changelog/unreleased_21/2943.rst | 15 ++++ doc/build/orm/mapped_attributes.rst | 57 +++++++++++++ lib/sqlalchemy/orm/mapper.py | 18 ++++ lib/sqlalchemy/orm/strategies.py | 37 ++++++--- test/orm/test_validators.py | 95 ++++++++++++++++++++++ 6 files changed, 210 insertions(+), 14 deletions(-) create mode 100644 doc/build/changelog/unreleased_21/2943.rst diff --git a/doc/build/Makefile b/doc/build/Makefile index 325da5046e..718887dd60 100644 --- a/doc/build/Makefile +++ b/doc/build/Makefile @@ -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 index 0000000000..f048956500 --- /dev/null +++ b/doc/build/changelog/unreleased_21/2943.rst @@ -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` diff --git a/doc/build/orm/mapped_attributes.rst b/doc/build/orm/mapped_attributes.rst index b114680132..2e9e7f83fc 100644 --- a/doc/build/orm/mapped_attributes.rst +++ b/doc/build/orm/mapped_attributes.rst @@ -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 ----------------------------------------- diff --git a/lib/sqlalchemy/orm/mapper.py b/lib/sqlalchemy/orm/mapper.py index 14dab60191..faf0b45a32 100644 --- a/lib/sqlalchemy/orm/mapper.py +++ b/lib/sqlalchemy/orm/mapper.py @@ -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: diff --git a/lib/sqlalchemy/orm/strategies.py b/lib/sqlalchemy/orm/strategies.py index 017a05200f..403f81eeeb 100644 --- a/lib/sqlalchemy/orm/strategies.py +++ b/lib/sqlalchemy/orm/strategies.py @@ -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) diff --git a/test/orm/test_validators.py b/test/orm/test_validators.py index df7334d5cb..9b5a0d117c 100644 --- a/test/orm/test_validators.py +++ b/test/orm/test_validators.py @@ -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") -- 2.47.3