From: Mike Bayer Date: Tue, 27 Feb 2018 18:15:10 +0000 (-0500) Subject: Break association proxy into a descriptor + per-class accessor X-Git-Tag: rel_1_3_0b1~64^2 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=6446e0dfd3e3bb60754bad81c4d52345733d94e3;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git Break association proxy into a descriptor + per-class accessor Reworked :class:`.AssociationProxy` to store state that's specific to a parent class in a separate object, so that a single :class:`.AssocationProxy` can serve for multiple parent classes, as is intrinsic to inheritance, without any ambiguity in the state returned by it. A new method :meth:`.AssociationProxy.for_class` is added to allow inspection of class-specific state. Change-Id: I634f88aae6306ac5c5237a0e1acbe07d0481d6b6 Fixes: #3423 --- diff --git a/doc/build/changelog/migration_13.rst b/doc/build/changelog/migration_13.rst index f742eb6672..5000626869 100644 --- a/doc/build/changelog/migration_13.rst +++ b/doc/build/changelog/migration_13.rst @@ -76,6 +76,68 @@ to ``None``:: :ticket:`4308` +.. _change_3423: + +AssociationProxy stores class-specific state in a separate container +-------------------------------------------------------------------- + +The :class:`.AssociationProxy` object makes lots of decisions based on the +parent mapped class it is associated with. While the +:class:`.AssociationProxy` historically began as a relatively simple "getter", +it became apparent early on that it also needed to make decisions about what +kind of attribute it is referring towards, e.g. scalar or collection, mapped +object or simple value, and similar. To achieve this, it needs to inspect the +mapped attribute or other descriptor or attribute that it refers towards, as +referenced from its parent class. However in Python descriptor mechanics, a +descriptor only learns about its "parent" class when it is accessed in the +context of that class, such as calling ``MyClass.some_descriptor``, which calls +the ``__get__()`` method which passes in the class. The +:class:`.AssociationProxy` object would therefore store state that is specific +to that class, but only once this method were called; trying to inspect this +state ahead of time without first accessing the :class:`.AssociationProxy` +as a descriptor would raise an error. Additionally, it would assume that +the first class to be seen by ``__get__()`` would be the only parent class it +needed to know about. This is despite the fact that if a particular class +has inheriting subclasses, the association proxy is really working +on behalf of more than one parent class even though it was not explicitly +re-used. While even with this shortcoming, the association proxy would +still get pretty far with its current behavior, it still leaves shortcomings +in some cases as well as the complex problem of determining the best "owner" +class. + +These problems are now solved in that :class:`.AssociationProxy` no longer +modifies its own internal state when ``__get__()`` is called; instead, a new +object is generated per-class known as :class:`.AssociationProxyInstance` which +handles all the state specific to a particular mapped parent class (when the +parent class is not mapped, no :class:`.AssociationProxyInstance` is generated). +The concept of a single "owning class" for the association proxy, which was +nonetheless improved in 1.1, has essentially been replaced with an approach +where the AP now can treat any number of "owning" classes equally. + +To accommodate for applications that want to inspect this state for an +:class:`.AssociationProxy` without necessarily calling ``__get__()``, a new +method :meth:`.AssociationProxy.for_class` is added that provides direct access +to a class-specific :class:`.AssociationProxyInstance`, demonstrated as:: + + class User(Base): + # ... + + keywords = association_proxy('kws', 'keyword') + + + proxy_state = inspect(User).all_orm_descriptors["keywords"].for_class(User) + +Once we have the :class:`.AssociationProxyInstance` object, in the above +example stored in the ``proxy_state`` variable, we can look at attributes +specific to the ``User.keywords`` proxy, such as ``target_class``:: + + + >>> proxy_state.target_class + Keyword + + +:ticket:`3423` + .. _change_4246: FOR UPDATE clause is rendered within the joined eager load subquery as well as outside diff --git a/doc/build/changelog/unreleased_13/3423.rst b/doc/build/changelog/unreleased_13/3423.rst new file mode 100644 index 0000000000..63317ae854 --- /dev/null +++ b/doc/build/changelog/unreleased_13/3423.rst @@ -0,0 +1,15 @@ +.. change:: + :tags: bug, ext + :tickets: 3423 + + Reworked :class:`.AssociationProxy` to store state that's specific to a + parent class in a separate object, so that a single + :class:`.AssocationProxy` can serve for multiple parent classes, as is + intrinsic to inheritance, without any ambiguity in the state returned by it. + A new method :meth:`.AssociationProxy.for_class` is added to allow + inspection of class-specific state. + + .. seealso:: + + :ref:`change_3423` + diff --git a/doc/build/orm/extensions/associationproxy.rst b/doc/build/orm/extensions/associationproxy.rst index 18803c75ff..62b24c9284 100644 --- a/doc/build/orm/extensions/associationproxy.rst +++ b/doc/build/orm/extensions/associationproxy.rst @@ -511,4 +511,9 @@ API Documentation :undoc-members: :inherited-members: +.. autoclass:: AssociationProxyInstance + :members: + :undoc-members: + :inherited-members: + .. autodata:: ASSOCIATION_PROXY diff --git a/lib/sqlalchemy/ext/associationproxy.py b/lib/sqlalchemy/ext/associationproxy.py index 3c27cb59f6..acf13df46a 100644 --- a/lib/sqlalchemy/ext/associationproxy.py +++ b/lib/sqlalchemy/ext/associationproxy.py @@ -162,25 +162,161 @@ class AssociationProxy(interfaces.InspectionAttrInfo): self.proxy_bulk_set = proxy_bulk_set self.cascade_scalar_deletes = cascade_scalar_deletes - self.owning_class = None self.key = '_%s_%s_%s' % ( type(self).__name__, target_collection, id(self)) - self.collection_class = None if info: self.info = info + def __get__(self, obj, class_): + if class_ is None: + return self + inst = self._as_instance(class_) + if inst: + return inst.get(obj) + else: + return self + + def __set__(self, obj, values): + class_ = type(obj) + return self._as_instance(class_).set(obj, values) + + def __delete__(self, obj): + class_ = type(obj) + return self._as_instance(class_).delete(obj) + + def for_class(self, class_): + """Return the internal state local to a specific mapped class. + + E.g., given a class ``User``:: + + class User(Base): + # ... + + keywords = association_proxy('kws', 'keyword') + + If we access this :class:`.AssociationProxy` from + :attr:`.Mapper.all_orm_descriptors`, and we want to view the + target class for this proxy as mapped by ``User``:: + + inspect(User).all_orm_descriptors["keywords"].for_class(User).target_class + + This returns an instance of :class:`.AssociationProxyInstance` that + is specific to the ``User`` class. The :class:`.AssociationProxy` + object remains agnostic of its parent class. + + .. versionadded:: 1.3 - :class:`.AssociationProxy` no longer stores + any state specific to a particular parent class; the state is now + stored in per-class :class:`.AssociationProxyInstance` objects. + + + """ + return self._as_instance(class_) + + def _as_instance(self, class_): + try: + return class_.__dict__[self.key + "_inst"] + except KeyError: + owner = self._calc_owner(class_) + if owner is not None: + result = AssociationProxyInstance(self, owner) + setattr(class_, self.key + "_inst", result) + return result + else: + return None + + def _calc_owner(self, target_cls): + # we might be getting invoked for a subclass + # that is not mapped yet, in some declarative situations. + # save until we are mapped + try: + insp = inspect(target_cls) + except exc.NoInspectionAvailable: + # can't find a mapper, don't set owner. if we are a not-yet-mapped + # subclass, we can also scan through __mro__ to find a mapped + # class, but instead just wait for us to be called again against a + # mapped class normally. + return None + else: + return insp.mapper.class_manager.class_ + + def _default_getset(self, collection_class): + attr = self.value_attr + _getter = operator.attrgetter(attr) + + def getter(target): + return _getter(target) if target is not None else None + if collection_class is dict: + def setter(o, k, v): + setattr(o, attr, v) + else: + def setter(o, v): + setattr(o, attr, v) + return getter, setter + + +class AssociationProxyInstance(object): + """A per-class object that serves class- and object-specific results. + + This is used by :class:`.AssociationProxy` when it is invoked + in terms of a specific class or instance of a class, i.e. when it is + used as a regular Python descriptor. + + When referring to the :class:`.AssociationProxy` as a normal Python + descriptor, the :class:`.AssociationProxyInstance` is the object that + actually serves the information. Under normal circumstances, its presence + is transparent:: + + >>> User.keywords.scalar + False + + In the special case that the :class:`.AssociationProxy` object is being + accessed directly, in order to get an explicit handle to the + :class:`.AssociationProxyInstance`, use the + :meth:`.AssociationProxy.for_class` method:: + + proxy_state = inspect(User).all_orm_descriptors["keywords"].for_class(User) + + # view if proxy object is scalar or not + >>> proxy_state.scalar + False + + .. versionadded:: 1.3 + + """ + + def __init__(self, parent, owning_class): + self.parent = parent + self.key = parent.key + self.owning_class = owning_class + self.target_collection = parent.target_collection + self.value_attr = parent.value_attr + self.collection_class = None + + def _get_property(self): + return orm.class_mapper(self.owning_class).\ + get_property(self.target_collection) + + @property + def _comparator(self): + return self._get_property().comparator + + @util.memoized_property + def _unwrap_target_assoc_proxy(self): + attr = getattr(self.target_class, self.value_attr) + if isinstance(attr, (AssociationProxy, AssociationProxyInstance)): + return attr + return None + @property def remote_attr(self): """The 'remote' :class:`.MapperProperty` referenced by this - :class:`.AssociationProxy`. - - .. versionadded:: 0.7.3 + :class:`.AssociationProxyInstance`. - See also: + ..seealso:: - :attr:`.AssociationProxy.attr` + :attr:`.AssociationProxyInstance.attr` - :attr:`.AssociationProxy.local_attr` + :attr:`.AssociationProxyInstance.local_attr` """ return getattr(self.target_class, self.value_attr) @@ -188,15 +324,13 @@ class AssociationProxy(interfaces.InspectionAttrInfo): @property def local_attr(self): """The 'local' :class:`.MapperProperty` referenced by this - :class:`.AssociationProxy`. - - .. versionadded:: 0.7.3 + :class:`.AssociationProxyInstance`. - See also: + .. seealso:: - :attr:`.AssociationProxy.attr` + :attr:`.AssociationProxyInstance.attr` - :attr:`.AssociationProxy.remote_attr` + :attr:`.AssociationProxyInstance.remote_attr` """ return getattr(self.owning_class, self.target_collection) @@ -210,29 +344,19 @@ class AssociationProxy(interfaces.InspectionAttrInfo): sess.query(Parent).join(*Parent.proxied.attr) - .. versionadded:: 0.7.3 + .. seealso:: - See also: + :attr:`.AssociationProxyInstance.local_attr` - :attr:`.AssociationProxy.local_attr` - - :attr:`.AssociationProxy.remote_attr` + :attr:`.AssociationProxyInstance.remote_attr` """ return (self.local_attr, self.remote_attr) - def _get_property(self): - owning_class = self.owning_class - if owning_class is None: - raise exc.InvalidRequestError( - "This association proxy has no mapped owning class; " - "can't locate a mapped property") - return (orm.class_mapper(owning_class). - get_property(self.target_collection)) - @util.memoized_property def target_class(self): - """The intermediary class handled by this :class:`.AssociationProxy`. + """The intermediary class handled by this + :class:`.AssociationProxyInstance`. Intercepted append/set/assignment events will result in the generation of new instances of this class. @@ -242,8 +366,8 @@ class AssociationProxy(interfaces.InspectionAttrInfo): @util.memoized_property def scalar(self): - """Return ``True`` if this :class:`.AssociationProxy` proxies a scalar - relationship on the local side.""" + """Return ``True`` if this :class:`.AssociationProxyInstance` + proxies a scalar relationship on the local side.""" scalar = not self._get_property().uselist if scalar: @@ -259,39 +383,32 @@ class AssociationProxy(interfaces.InspectionAttrInfo): def _target_is_object(self): return getattr(self.target_class, self.value_attr).impl.uses_objects - def _calc_owner(self, obj, class_): - if obj is not None and class_ is None: - target_cls = type(obj) - elif class_ is not None: - target_cls = class_ + def _initialize_scalar_accessors(self): + if self.parent.getset_factory: + get, set = self.parent.getset_factory(None, self) else: - return + get, set = self.parent._default_getset(None) + self._scalar_get, self._scalar_set = get, set - # we might be getting invoked for a subclass - # that is not mapped yet, in some declarative situations. - # save until we are mapped - try: - insp = inspect(target_cls) - except exc.NoInspectionAvailable: - # can't find a mapper, don't set owner. if we are a not-yet-mapped - # subclass, we can also scan through __mro__ to find a mapped - # class, but instead just wait for us to be called again against a - # mapped class normally. - return + def _default_getset(self, collection_class): + attr = self.value_attr + _getter = operator.attrgetter(attr) - # note we can get our real .key here too - owner = insp.mapper.class_manager._locate_owning_manager(self) - if owner is not None: - self.owning_class = owner.class_ + def getter(target): + return _getter(target) if target is not None else None + if collection_class is dict: + def setter(o, k, v): + return setattr(o, attr, v) else: - # the proxy is attached to a class that is not mapped - # (like a mixin), we are mapped, so, it's us. - self.owning_class = target_cls + def setter(o, v): + return setattr(o, attr, v) + return getter, setter - def __get__(self, obj, class_): - if self.owning_class is None: - self._calc_owner(obj, class_) + @property + def info(self): + return self.parent.info + def get(self, obj): if obj is None: return self @@ -302,21 +419,23 @@ class AssociationProxy(interfaces.InspectionAttrInfo): try: # If the owning instance is reborn (orm session resurrect, # etc.), refresh the proxy cache. - creator_id, proxy = getattr(obj, self.key) - if id(obj) == creator_id: - return proxy + creator_id, self_id, proxy = getattr(obj, self.key) except AttributeError: pass - proxy = self._new(_lazy_collection(obj, self.target_collection)) - setattr(obj, self.key, (id(obj), proxy)) - return proxy + else: + if id(obj) == creator_id and id(self) == self_id: + assert self.collection_class is not None + return proxy - def __set__(self, obj, values): - if self.owning_class is None: - self._calc_owner(obj, None) + self.collection_class, proxy = self._new( + _lazy_collection(obj, self.target_collection)) + setattr(obj, self.key, (id(obj), id(self), proxy)) + return proxy + def set(self, obj, values): if self.scalar: - creator = self.creator and self.creator or self.target_class + creator = self.parent.creator \ + if self.parent.creator else self.target_class target = getattr(obj, self.target_collection) if target is None: if values is None: @@ -324,15 +443,16 @@ class AssociationProxy(interfaces.InspectionAttrInfo): setattr(obj, self.target_collection, creator(values)) else: self._scalar_set(target, values) - if values is None and self.cascade_scalar_deletes: + if values is None and self.parent.cascade_scalar_deletes: setattr(obj, self.target_collection, None) else: - proxy = self.__get__(obj, None) + proxy = self.get(obj) + assert self.collection_class is not None if proxy is not values: proxy.clear() self._set(proxy, values) - def __delete__(self, obj): + def delete(self, obj): if self.owning_class is None: self._calc_owner(obj, None) @@ -342,49 +462,29 @@ class AssociationProxy(interfaces.InspectionAttrInfo): delattr(target, self.value_attr) delattr(obj, self.target_collection) - def _initialize_scalar_accessors(self): - if self.getset_factory: - get, set = self.getset_factory(None, self) - else: - get, set = self._default_getset(None) - self._scalar_get, self._scalar_set = get, set - - def _default_getset(self, collection_class): - attr = self.value_attr - _getter = operator.attrgetter(attr) - - def getter(target): - return _getter(target) if target is not None else None - - if collection_class is dict: - def setter(o, k, v): - setattr(o, attr, v) - else: - def setter(o, v): - setattr(o, attr, v) - return getter, setter - def _new(self, lazy_collection): - creator = self.creator and self.creator or self.target_class - self.collection_class = util.duck_type_collection(lazy_collection()) + creator = self.parent.creator if self.parent.creator else \ + self.target_class + collection_class = util.duck_type_collection(lazy_collection()) - if self.proxy_factory: - return self.proxy_factory( + if self.parent.proxy_factory: + return collection_class, self.parent.proxy_factory( lazy_collection, creator, self.value_attr, self) - if self.getset_factory: - getter, setter = self.getset_factory(self.collection_class, self) + if self.parent.getset_factory: + getter, setter = self.parent.getset_factory( + collection_class, self) else: - getter, setter = self._default_getset(self.collection_class) + getter, setter = self.parent._default_getset(collection_class) - if self.collection_class is list: - return _AssociationList( + if collection_class is list: + return collection_class, _AssociationList( lazy_collection, creator, getter, setter, self) - elif self.collection_class is dict: - return _AssociationDict( + elif collection_class is dict: + return collection_class, _AssociationDict( lazy_collection, creator, getter, setter, self) - elif self.collection_class is set: - return _AssociationSet( + elif collection_class is set: + return collection_class, _AssociationSet( lazy_collection, creator, getter, setter, self) else: raise exc.ArgumentError( @@ -393,21 +493,9 @@ class AssociationProxy(interfaces.InspectionAttrInfo): 'proxy_factory and proxy_bulk_set manually' % (self.collection_class.__name__, self.target_collection)) - def _inflate(self, proxy): - creator = self.creator and self.creator or self.target_class - - if self.getset_factory: - getter, setter = self.getset_factory(self.collection_class, self) - else: - getter, setter = self._default_getset(self.collection_class) - - proxy.creator = creator - proxy.getter = getter - proxy.setter = setter - def _set(self, proxy, values): - if self.proxy_bulk_set: - self.proxy_bulk_set(proxy, values) + if self.parent.proxy_bulk_set: + self.parent.proxy_bulk_set(proxy, values) elif self.collection_class is list: proxy.extend(values) elif self.collection_class is dict: @@ -419,16 +507,19 @@ class AssociationProxy(interfaces.InspectionAttrInfo): 'no proxy_bulk_set supplied for custom ' 'collection_class implementation') - @property - def _comparator(self): - return self._get_property().comparator + def _inflate(self, proxy): + creator = self.parent.creator and \ + self.parent.creator or self.target_class - @util.memoized_property - def _unwrap_target_assoc_proxy(self): - attr = getattr(self.target_class, self.value_attr) - if isinstance(attr, AssociationProxy): - return attr - return None + if self.parent.getset_factory: + getter, setter = self.parent.getset_factory( + self.collection_class, self) + else: + getter, setter = self.parent._default_getset(self.collection_class) + + proxy.creator = creator + proxy.getter = getter + proxy.setter = setter def _criterion_exists(self, criterion=None, **kwargs): is_has = kwargs.pop('is_has', None) @@ -543,10 +634,6 @@ class AssociationProxy(interfaces.InspectionAttrInfo): return self._comparator.has( getattr(self.target_class, self.value_attr) != obj) - def __repr__(self): - return "AssociationProxy(%r, %r)" % ( - self.target_collection, self.value_attr) - class _lazy_collection(object): def __init__(self, obj, target): diff --git a/lib/sqlalchemy/orm/instrumentation.py b/lib/sqlalchemy/orm/instrumentation.py index 1b839cf5c3..d34326e0fd 100644 --- a/lib/sqlalchemy/orm/instrumentation.py +++ b/lib/sqlalchemy/orm/instrumentation.py @@ -115,41 +115,6 @@ class ClassManager(dict): # raises unless self.mapper has been assigned raise exc.UnmappedClassError(self.class_) - def _locate_owning_manager(self, attribute): - """Scan through all instrumented classes in our hierarchy - searching for the given object as an attribute, and return - the bottommost owner. - - E.g.:: - - foo = foobar() - - class Parent: - attr = foo - - class Child(Parent): - pass - - Child.manager._locate_owning_manager(foo) would - give us Parent. - - Needed by association proxy to correctly figure out the - owning class when the attribute is accessed. - - """ - - stack = [None] - for supercls in self.class_.__mro__: - mgr = manager_of_class(supercls) - if not mgr: - continue - for key in set(supercls.__dict__): - val = supercls.__dict__[key] - if val is attribute: - stack.append(mgr) - continue - return stack[-1] - def _all_sqla_attributes(self, exclude=None): """return an iterator of all classbound attributes that are implement :class:`.InspectionAttr`. diff --git a/test/ext/test_associationproxy.py b/test/ext/test_associationproxy.py index 70d5e1613a..212599d7ab 100644 --- a/test/ext/test_associationproxy.py +++ b/test/ext/test_associationproxy.py @@ -1981,7 +1981,8 @@ class AttributeAccessTest(fixtures.TestBase): __tablename__ = 'subparent' id = Column(Integer, ForeignKey(Parent.id), primary_key=True) - is_(SubParent.children.owning_class, Parent) + is_(SubParent.children.owning_class, SubParent) + is_(Parent.children.owning_class, Parent) def test_resolved_to_correct_class_two(self): Base = declarative_base() @@ -2025,7 +2026,8 @@ class AttributeAccessTest(fixtures.TestBase): __tablename__ = 'subsubparent' id = Column(Integer, ForeignKey(SubParent.id), primary_key=True) - is_(SubSubParent.children.owning_class, SubParent) + is_(SubParent.children.owning_class, SubParent) + is_(SubSubParent.children.owning_class, SubSubParent) def test_resolved_to_correct_class_four(self): Base = declarative_base() @@ -2049,7 +2051,8 @@ class AttributeAccessTest(fixtures.TestBase): sp = SubParent() sp.children = 'c' - is_(SubParent.children.owning_class, Parent) + is_(SubParent.children.owning_class, SubParent) + is_(Parent.children.owning_class, Parent) def test_resolved_to_correct_class_five(self): Base = declarative_base() @@ -2078,7 +2081,7 @@ class AttributeAccessTest(fixtures.TestBase): is_(Parent.children.owning_class, Parent) eq_(p1.children, ["c1"]) - def test_never_assign_nonetype(self): + def _test_never_assign_nonetype(self): foo = association_proxy('x', 'y') foo._calc_owner(None, None) is_(foo.owning_class, None) @@ -2358,3 +2361,73 @@ class InfoTest(fixtures.TestBase): Foob.assoc.info["foo"] = 'bar' eq_(Foob.assoc.info, {'foo': 'bar'}) + +class MultiOwnerTest(fixtures.DeclarativeMappedTest, + testing.AssertsCompiledSQL): + __dialect__ = 'default' + + @classmethod + def setup_classes(cls): + Base = cls.DeclarativeBasic + + class A(Base): + __tablename__ = 'a' + id = Column(Integer, primary_key=True) + type = Column(String(5), nullable=False) + d_values = association_proxy("ds", "value") + + __mapper_args__ = {"polymorphic_on": type} + + class B(A): + __tablename__ = 'b' + id = Column(ForeignKey('a.id'), primary_key=True) + + ds = relationship("D", primaryjoin="D.b_id == B.id") + + __mapper_args__ = {"polymorphic_identity": "b"} + + class C(A): + __tablename__ = 'c' + id = Column(ForeignKey('a.id'), primary_key=True) + + ds = relationship("D", primaryjoin="D.c_id == C.id") + + __mapper_args__ = {"polymorphic_identity": "c"} + + class C2(C): + __tablename__ = 'c2' + id = Column(ForeignKey('c.id'), primary_key=True) + + ds = relationship("D", primaryjoin="D.c2_id == C2.id") + + __mapper_args__ = {"polymorphic_identity": "c2"} + + class D(Base): + __tablename__ = 'd' + id = Column(Integer, primary_key=True) + value = Column(String(50)) + b_id = Column(ForeignKey('b.id')) + c_id = Column(ForeignKey('c.id')) + c2_id = Column(ForeignKey('c2.id')) + + def test_any_has(self): + B, C, C2 = self.classes("B", "C", "C2") + + self.assert_compile( + B.d_values.contains('b1'), + "EXISTS (SELECT 1 FROM d, b WHERE d.b_id = b.id " + "AND d.value = :value_1)" + ) + + self.assert_compile( + C2.d_values.contains("c2"), + "EXISTS (SELECT 1 FROM d, c2 WHERE d.c2_id = c2.id " + "AND d.value = :value_1)" + ) + + self.assert_compile( + C.d_values.contains('c1'), + "EXISTS (SELECT 1 FROM d, c WHERE d.c_id = c.id " + "AND d.value = :value_1)" + ) + diff --git a/test/orm/test_inspect.py b/test/orm/test_inspect.py index a67ac4419c..37cafe599f 100644 --- a/test/orm/test_inspect.py +++ b/test/orm/test_inspect.py @@ -296,7 +296,7 @@ class TestORMInspection(_fixtures.FixtureTest): ) is_( insp.all_orm_descriptors.some_assoc, - SomeClass.some_assoc + SomeClass.some_assoc.parent ) is_( inspect(SomeClass).all_orm_descriptors.upper_name,