From: Chris Wilson Date: Mon, 10 Dec 2018 21:37:51 +0000 (-0500) Subject: Add before_mapper_configured event X-Git-Tag: rel_1_3_0b2~56^2 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=65ea042302693492cbaee96dfed5e9337e0d96a4;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git Add before_mapper_configured event This event is intended to allow a specific mapper to be skipped during the configure step, by returning a value of `.orm.interfaces.EXT_SKIP` which means the mapper will be skipped within this configure run. The "new mappers" flag will remain set in this case and the configure operation will occur again. This event, and its return value, make it possible to query one base while a different one still needs configuration, which cannot be completed at this time. Fixes: #4397 Change-Id: I122e556f6a4ff842ad15315dcf39e19bb7f9a744 Pull-request: https://github.com/sqlalchemy/sqlalchemy/pull/4403 --- diff --git a/doc/build/changelog/unreleased_13/4397.rst b/doc/build/changelog/unreleased_13/4397.rst new file mode 100644 index 0000000000..de775c30e8 --- /dev/null +++ b/doc/build/changelog/unreleased_13/4397.rst @@ -0,0 +1,17 @@ +.. change:: + :tags: feature, orm + :tickets: 4397 + + Added new :meth:`.MapperEvents.before_mapper_configured` event. This + event complements the other "configure" stage mapper events with a per + mapper event that receives each :class:`.Mapper` right before its + configure step, and additionally may be used to prevent or delay the + configuration of specific :class:`.Mapper` objects using a new + return value :attr:`.orm.interfaces.EXT_SKIP`. See the + documentation link for an example. + + .. seealso:: + + :ref:`.MapperEvents.before_mapper_configured` + + diff --git a/lib/sqlalchemy/event/registry.py b/lib/sqlalchemy/event/registry.py index 7582304878..8d4bada0b2 100644 --- a/lib/sqlalchemy/event/registry.py +++ b/lib/sqlalchemy/event/registry.py @@ -225,7 +225,7 @@ class _EventKey(object): return self._key in _key_to_collection def base_listen(self, propagate=False, insert=False, - named=False): + named=False, retval=None): target, identifier, fn = \ self.dispatch_target, self.identifier, self._listen_fn diff --git a/lib/sqlalchemy/orm/__init__.py b/lib/sqlalchemy/orm/__init__.py index f279c9b409..1784ea21fb 100644 --- a/lib/sqlalchemy/orm/__init__.py +++ b/lib/sqlalchemy/orm/__init__.py @@ -25,6 +25,7 @@ from .mapper import ( from .interfaces import ( EXT_CONTINUE, EXT_STOP, + EXT_SKIP, PropComparator, ) from .deprecated_interfaces import ( diff --git a/lib/sqlalchemy/orm/base.py b/lib/sqlalchemy/orm/base.py index 62b4f59a96..deddaa5a4e 100644 --- a/lib/sqlalchemy/orm/base.py +++ b/lib/sqlalchemy/orm/base.py @@ -149,6 +149,7 @@ _INSTRUMENTOR = ('mapper', 'instrumentor') EXT_CONTINUE = util.symbol('EXT_CONTINUE') EXT_STOP = util.symbol('EXT_STOP') +EXT_SKIP = util.symbol('EXT_SKIP') ONETOMANY = util.symbol( 'ONETOMANY', diff --git a/lib/sqlalchemy/orm/events.py b/lib/sqlalchemy/orm/events.py index f27849e153..c414f548ee 100644 --- a/lib/sqlalchemy/orm/events.py +++ b/lib/sqlalchemy/orm/events.py @@ -437,7 +437,9 @@ class _EventsHold(event.RefCollection): _dispatch_target = None @classmethod - def _listen(cls, event_key, raw=False, propagate=False, **kw): + def _listen( + cls, event_key, raw=False, propagate=False, + retval=False, **kw): target, identifier, fn = \ event_key.dispatch_target, event_key.identifier, event_key.fn @@ -447,7 +449,7 @@ class _EventsHold(event.RefCollection): collection = target.all_holds[target.class_] = {} event.registry._stored_in_collection(event_key, target) - collection[event_key._key] = (event_key, raw, propagate) + collection[event_key._key] = (event_key, raw, propagate, retval) if propagate: stack = list(target.class_.__subclasses__()) @@ -459,7 +461,7 @@ class _EventsHold(event.RefCollection): # we are already going through __subclasses__() # so leave generic propagate flag False event_key.with_dispatch_target(subject).\ - listen(raw=raw, propagate=False, **kw) + listen(raw=raw, propagate=False, retval=retval, **kw) def remove(self, event_key): target, identifier, fn = \ @@ -474,7 +476,7 @@ class _EventsHold(event.RefCollection): for subclass in class_.__mro__: if subclass in cls.all_holds: collection = cls.all_holds[subclass] - for event_key, raw, propagate in collection.values(): + for event_key, raw, propagate, retval in collection.values(): if propagate or subclass is class_: # since we can't be sure in what order different # classes in a hierarchy are triggered with @@ -482,7 +484,7 @@ class _EventsHold(event.RefCollection): # assignment, instead of using the generic propagate # flag. event_key.with_dispatch_target(subject).\ - listen(raw=raw, propagate=False) + listen(raw=raw, propagate=False, retval=retval) class _InstanceEventsHold(_EventsHold): @@ -659,6 +661,54 @@ class MapperEvents(event.Events): """ + def before_mapper_configured(self, mapper, class_): + """Called right before a specific mapper is to be configured. + + This event is intended to allow a specific mapper to be skipped during + the configure step, by returning the :attr:`.orm.interfaces.EXT_SKIP` + symbol which indicates to the :func:`.configure_mappers` call that this + particular mapper (or hierarchy of mappers, if ``propagate=True`` is + used) should be skipped in the current configuration run. When one or + more mappers are skipped, the he "new mappers" flag will remain set, + meaning the :func:`.configure_mappers` function will continue to be + called when mappers are used, to continue to try to configure all + available mappers. + + In comparison to the other configure-level events, + :meth:`.MapperEvents.before_configured`, + :meth:`.MapperEvents.after_configured`, and + :meth:`.MapperEvents.mapper_configured`, the + :meth;`.MapperEvents.before_mapper_configured` event provides for a + meaningful return value when it is registered with the ``retval=True`` + parameter. + + .. versionadded:: 1.3 + + e.g.:: + + from sqlalchemy.orm import EXT_SKIP + + Base = declarative_base() + + DontConfigureBase = declarative_base() + + @event.listens_for( + DontConfigureBase, + "before_mapper_configured", retval=True, propagate=True) + def dont_configure(mapper, cls): + return EXT_SKIP + + + .. seealso:: + + :meth:`.MapperEvents.before_configured` + + :meth:`.MapperEvents.after_configured` + + :meth:`.MapperEvents.mapper_configured` + + """ + def mapper_configured(self, mapper, class_): r"""Called when a specific mapper has completed its own configuration within the scope of the :func:`.configure_mappers` call. @@ -708,6 +758,8 @@ class MapperEvents(event.Events): :meth:`.MapperEvents.after_configured` + :meth:`.MapperEvents.before_mapper_configured` + """ # TODO: need coverage for this event @@ -734,8 +786,9 @@ class MapperEvents(event.Events): Contrast this event to :meth:`.MapperEvents.after_configured`, which is invoked after the series of mappers has been configured, - as well as :meth:`.MapperEvents.mapper_configured`, which is invoked - on a per-mapper basis as each one is configured to the extent possible. + as well as :meth:`.MapperEvents.before_mapper_configured` + and :meth:`.MapperEvents.mapper_configured`, which are both invoked + on a per-mapper basis. Theoretically this event is called once per application, but is actually called any time new mappers @@ -757,6 +810,8 @@ class MapperEvents(event.Events): .. seealso:: + :meth:`.MapperEvents.before_mapper_configured` + :meth:`.MapperEvents.mapper_configured` :meth:`.MapperEvents.after_configured` @@ -808,6 +863,8 @@ class MapperEvents(event.Events): .. seealso:: + :meth:`.MapperEvents.before_mapper_configured` + :meth:`.MapperEvents.mapper_configured` :meth:`.MapperEvents.before_configured` diff --git a/lib/sqlalchemy/orm/interfaces.py b/lib/sqlalchemy/orm/interfaces.py index 95291377bd..80d0a63037 100644 --- a/lib/sqlalchemy/orm/interfaces.py +++ b/lib/sqlalchemy/orm/interfaces.py @@ -23,7 +23,7 @@ from __future__ import absolute_import from .. import util from ..sql import operators from .base import (ONETOMANY, MANYTOONE, MANYTOMANY, - EXT_CONTINUE, EXT_STOP, NOT_EXTENSION) + EXT_CONTINUE, EXT_STOP, EXT_SKIP, NOT_EXTENSION) from .base import InspectionAttr, InspectionAttrInfo, _MappedAttribute import collections from .. import inspect @@ -36,6 +36,7 @@ __all__ = ( 'AttributeExtension', 'EXT_CONTINUE', 'EXT_STOP', + 'EXT_SKIP', 'ONETOMANY', 'MANYTOMANY', 'MANYTOONE', diff --git a/lib/sqlalchemy/orm/mapper.py b/lib/sqlalchemy/orm/mapper.py index f2b326359e..fa731f7298 100644 --- a/lib/sqlalchemy/orm/mapper.py +++ b/lib/sqlalchemy/orm/mapper.py @@ -26,7 +26,9 @@ from ..sql import expression, visitors, operators, util as sql_util from . import instrumentation, attributes, exc as orm_exc, loading from . import properties from . import util as orm_util -from .interfaces import MapperProperty, InspectionAttr, _MappedAttribute +from .interfaces import MapperProperty, InspectionAttr, _MappedAttribute, \ + EXT_SKIP + from .base import _class_to_mapper, _state_mapper, class_mapper, \ state_str, _INSTRUMENTOR @@ -3014,6 +3016,8 @@ def configure_mappers(): if not Mapper._new_mappers: return + has_skip = False + Mapper.dispatch._for_class(Mapper).before_configured() # initialize properties on all mappers # note that _mapper_registry is unordered, which @@ -3021,6 +3025,15 @@ def configure_mappers(): # the order of mapper compilation for mapper in list(_mapper_registry): + run_configure = None + for fn in mapper.dispatch.before_mapper_configured: + run_configure = fn(mapper, mapper.class_) + if run_configure is EXT_SKIP: + has_skip = True + break + if run_configure is EXT_SKIP: + continue + if getattr(mapper, '_configure_failed', False): e = sa_exc.InvalidRequestError( "One or more mappers failed to initialize - " @@ -3030,6 +3043,7 @@ def configure_mappers(): % (mapper, mapper._configure_failed)) e._configure_failed = mapper._configure_failed raise e + if not mapper.configured: try: mapper._post_configure_properties() @@ -3042,7 +3056,8 @@ def configure_mappers(): mapper._configure_failed = exc raise - Mapper._new_mappers = False + if not has_skip: + Mapper._new_mappers = False finally: _already_compiling = False finally: diff --git a/test/orm/test_events.py b/test/orm/test_events.py index f3cce7da35..de193f1f03 100644 --- a/test/orm/test_events.py +++ b/test/orm/test_events.py @@ -2,6 +2,7 @@ from sqlalchemy.testing import assert_raises_message, assert_raises import sqlalchemy as sa from sqlalchemy import testing from sqlalchemy import Integer, String +from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.testing.schema import Table, Column from sqlalchemy.orm import mapper, relationship, \ create_session, class_mapper, \ @@ -9,7 +10,9 @@ from sqlalchemy.orm import mapper, relationship, \ Session, sessionmaker, attributes, configure_mappers from sqlalchemy.orm.instrumentation import ClassManager from sqlalchemy.orm import instrumentation, events -from sqlalchemy.testing import eq_, is_not_ +from sqlalchemy.orm import EXT_SKIP +from sqlalchemy.orm.mapper import _mapper_registry +from sqlalchemy.testing import eq_, is_not_, assert_raises from sqlalchemy.testing import fixtures from sqlalchemy.testing import AssertsCompiledSQL from sqlalchemy.testing.util import gc_collect @@ -474,6 +477,63 @@ class MapperEventsTest(_RemoveListeners, _fixtures.FixtureTest): canary.mock_calls ) + def test_before_mapper_configured_event(self): + """Test [ticket:4397]. + + This event is intended to allow a specific mapper to be skipped during + the configure step, by returning a value of + :attr:`.orm.interfaces.EXT_SKIP` which means the mapper will be skipped + within this configure run. The "new mappers" flag will remain set in + this case and the configure operation will occur again. + + This event, and its return value, make it possible to query one base + while a different one still needs configuration, which cannot be + completed at this time. + """ + + User, users = self.classes.User, self.tables.users + mapper(User, users) + + AnotherBase = declarative_base() + + class Animal(AnotherBase): + __tablename__ = 'animal' + species = Column(String(30), primary_key=True) + __mapper_args__ = dict( + polymorphic_on='species', polymorphic_identity='Animal') + + # Register the first classes and create their Mappers: + configure_mappers() + + unconfigured = [m for m in _mapper_registry if not m.configured] + eq_(0, len(unconfigured)) + + # Declare a subclass, table and mapper, which refers to one that has + # not been loaded yet (Employer), and therefore cannot be configured: + class Mammal(Animal): + nonexistent = relationship('Nonexistent') + + # These new classes should not be configured at this point: + unconfigured = [m for m in _mapper_registry if not m.configured] + eq_(1, len(unconfigured)) + + # Now try to query User, which is internally consistent. This query + # fails by default because Mammal needs to be configured, and cannot + # be: + def probe(): + s = Session() + s.query(User) + assert_raises(sa.exc.InvalidRequestError, probe) + + # If we disable configuring mappers while querying, then it succeeds: + @event.listens_for( + AnotherBase, "before_mapper_configured", propagate=True, + retval=True) + def disable_configure_mappers(mapper, cls): + return EXT_SKIP + + probe() + class DeclarativeEventListenTest(_RemoveListeners, fixtures.DeclarativeMappedTest):