]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Add before_mapper_configured event
authorChris Wilson <chris+github@qwirx.com>
Mon, 10 Dec 2018 21:37:51 +0000 (16:37 -0500)
committerMike Bayer <mike_mp@zzzcomputing.com>
Tue, 11 Dec 2018 03:31:21 +0000 (22:31 -0500)
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

doc/build/changelog/unreleased_13/4397.rst [new file with mode: 0644]
lib/sqlalchemy/event/registry.py
lib/sqlalchemy/orm/__init__.py
lib/sqlalchemy/orm/base.py
lib/sqlalchemy/orm/events.py
lib/sqlalchemy/orm/interfaces.py
lib/sqlalchemy/orm/mapper.py
test/orm/test_events.py

diff --git a/doc/build/changelog/unreleased_13/4397.rst b/doc/build/changelog/unreleased_13/4397.rst
new file mode 100644 (file)
index 0000000..de775c3
--- /dev/null
@@ -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`
+
+
index 75823048785fcbe7dd4ada0df555b8709c8739e1..8d4bada0b26c0a7722e3c431aa2f7e49622b86d9 100644 (file)
@@ -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
index f279c9b4091ee77db4eb3e207480791989b57ed7..1784ea21fb0f70f1ac810818d1be87bba8324c8f 100644 (file)
@@ -25,6 +25,7 @@ from .mapper import (
 from .interfaces import (
     EXT_CONTINUE,
     EXT_STOP,
+    EXT_SKIP,
     PropComparator,
 )
 from .deprecated_interfaces import (
index 62b4f59a96792c1d36ab044e004db748c461d878..deddaa5a4e1a190c562362e2da230a80f17313de 100644 (file)
@@ -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',
index f27849e153b9c33f91cecad322ba12a8b270644b..c414f548eeb1d20be05306e3de016468a56f3c33 100644 (file)
@@ -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`
index 95291377bddd26cfca58e49cd84fcf8dbcfe30c7..80d0a63037681a9b9db219e89334f5d09cccc73a 100644 (file)
@@ -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',
index f2b326359e41d55c7e59c57bbc212150c9df9451..fa731f7298c35f5d7ec52a43520c56f0d6ce1798 100644 (file)
@@ -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:
index f3cce7da356f2eb4fe28e387d73f89c395f84824..de193f1f03cfc5b8a1a9f0042e16d012cc1b39f4 100644 (file)
@@ -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):