]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
- Support is improved for supplying a :func:`.join` construct as the
authorMike Bayer <mike_mp@zzzcomputing.com>
Thu, 23 Jan 2014 01:16:47 +0000 (20:16 -0500)
committerMike Bayer <mike_mp@zzzcomputing.com>
Thu, 23 Jan 2014 01:16:47 +0000 (20:16 -0500)
target of :paramref:`.relationship.secondary` for the purposes
of creating very complex :func:`.relationship` join conditions.
The change includes adjustments to query joining, joined eager loading
to not render a SELECT subquery, changes to lazy loading such that
the "secondary" target is properly included in the SELECT, and
changes to declarative to better support specification of a
join() object with classes as targets.

doc/build/changelog/changelog_09.rst
doc/build/orm/mapper_config.rst
doc/build/orm/relationships.rst
lib/sqlalchemy/ext/declarative/clsregistry.py
lib/sqlalchemy/orm/mapper.py
lib/sqlalchemy/orm/relationships.py
lib/sqlalchemy/orm/strategies.py
test/ext/declarative/test_basic.py
test/orm/inheritance/test_manytomany.py
test/orm/test_joins.py
test/orm/test_relationships.py

index 76832652840c8993d1ffbddecaf97456c60704f2..893e3970fee263bfbd22f76d026bce760f9aa327 100644 (file)
 .. changelog::
     :version: 0.9.2
 
+    .. change::
+        :tags: feature, orm
+
+        Support is improved for supplying a :func:`.join` construct as the
+        target of :paramref:`.relationship.secondary` for the purposes
+        of creating very complex :func:`.relationship` join conditions.
+        The change includes adjustments to query joining, joined eager loading
+        to not render a SELECT subquery, changes to lazy loading such that
+        the "secondary" target is properly included in the SELECT, and
+        changes to declarative to better support specification of a
+        join() object with classes as targets.
+
+        The new use case is somewhat experimental, but a new documentation section
+        has been added.
+
+        .. seealso::
+
+            :ref:`composite_secondary_join`
+
     .. change::
         :tags: bug, mysql, sql
         :tickets: 2917
index 17bd31a6f4b262bba73ab6b916e4088dfa6d11be..22f483c7e2cde6fcad174d1109715f8c9b6ade63 100644 (file)
@@ -1307,11 +1307,13 @@ table metadata.
 One potential use case for another mapper to exist at the same time is if we
 wanted to load instances of our class not just from the immediate :class:`.Table`
 to which it is mapped, but from another selectable that is a derivation of that
-:class:`.Table`.   While there technically is a way to create such a :func:`.mapper`,
-using the ``non_primary=True`` option, this approach is virtually never needed.
-Instead, we use the functionality of the :class:`.Query` object to achieve this,
-using a method such as :meth:`.Query.select_from`
-or :meth:`.Query.from_statement` to specify a derived selectable.
+:class:`.Table`.   To create a second mapper that only handles querying
+when used explicitly, we can use the :paramref:`.mapper.non_primary` argument.
+In practice, this approach is usually not needed, as we
+can do this sort of thing at query time using methods such as
+:meth:`.Query.select_from`, however it is useful in the rare case that we
+wish to build a :func:`.relationship` to such a mapper.  An example of this is
+at :ref:`relationship_non_primary_mapper`.
 
 Another potential use is if we genuinely want instances of our class to
 be persisted into different tables at different times; certain kinds of
index 67a41c8087df9ec3d8941af1557363908b03baa6..cdff64064110e8d67e525ec63797fe0fbe385534 100644 (file)
@@ -948,9 +948,10 @@ Specifying Alternate Join Conditions
 The default behavior of :func:`.relationship` when constructing a join
 is that it equates the value of primary key columns
 on one side to that of foreign-key-referring columns on the other.
-We can change this criterion to be anything we'd like using the ``primaryjoin``
-argument, as well as the ``secondaryjoin`` argument in the case when
-a "secondary" table is used.
+We can change this criterion to be anything we'd like using the
+:paramref:`.relationship.primaryjoin`
+argument, as well as the :paramref:`.relationship.secondaryjoin`
+argument in the case when a "secondary" table is used.
 
 In the example below, using the ``User`` class
 as well as an ``Address`` class which stores a street address,  we
@@ -1149,6 +1150,163 @@ Note that in both examples, the ``backref`` keyword specifies a ``left_nodes``
 backref - when :func:`.relationship` creates the second relationship in the reverse
 direction, it's smart enough to reverse the ``primaryjoin`` and ``secondaryjoin`` arguments.
 
+.. _composite_secondary_join:
+
+Composite "Secondary" Joins
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. note::
+
+    This section features some new and experimental features of SQLAlchemy.
+
+Sometimes, when one seeks to build a :func:`.relationship` between two tables
+there is a need for more than just two or three tables to be involved in
+order to join them.  This is an area of :func:`.relationship` where one seeks
+to push the boundaries of what's possible, and often the ultimate solution to
+many of these exotic use cases needs to be hammered out on the SQLAlchemy mailing
+list.
+
+In more recent versions of SQLAlchemy, the :paramref:`.relationship.secondary`
+parameter can be used in some of these cases in order to provide a composite
+target consisting of multiple tables.   Below is an example of such a
+join condition (requires version 0.9.2 at least to function as is)::
+
+    class A(Base):
+        __tablename__ = 'a'
+
+        id = Column(Integer, primary_key=True)
+        b_id = Column(ForeignKey('b.id'))
+
+        d = relationship("D",
+                    secondary="join(B, D, B.d_id == D.id)."
+                                "join(C, C.d_id == D.id)",
+                    primaryjoin="and_(A.b_id == B.id, A.id == C.a_id)",
+                    secondaryjoin="D.id == B.d_id",
+                    uselist=False
+                    )
+
+    class B(Base):
+        __tablename__ = 'b'
+
+        id = Column(Integer, primary_key=True)
+        d_id = Column(ForeignKey('d.id'))
+
+    class C(Base):
+        __tablename__ = 'c'
+
+        id = Column(Integer, primary_key=True)
+        a_id = Column(ForeignKey('a.id'))
+        d_id = Column(ForeignKey('d.id'))
+
+    class D(Base):
+        __tablename__ = 'd'
+
+        id = Column(Integer, primary_key=True)
+
+In the above example, we provide all three of :paramref:`.relationship.secondary`,
+:paramref:`.relationship.primaryjoin`, and :paramref:`.relationship.secondaryjoin`,
+in the declarative style referring to the named tables ``a``, ``b``, ``c``, ``d``
+directly.  A query from ``A`` to ``D`` looks like:
+
+.. sourcecode:: python+sql
+
+    sess.query(A).join(A.d).all()
+
+    {opensql}SELECT a.id AS a_id, a.b_id AS a_b_id
+    FROM a JOIN (
+        b AS b_1 JOIN d AS d_1 ON b_1.d_id = d_1.id
+            JOIN c AS c_1 ON c_1.d_id = d_1.id)
+        ON a.b_id = b_1.id AND a.id = c_1.a_id JOIN d ON d.id = b_1.d_id
+
+In the above example, we take advantage of being able to stuff multiple
+tables into a "secondary" container, so that we can join across many
+tables while still keeping things "simple" for :func:`.relationship`, in that
+there's just "one" table on both the "left" and the "right" side; the
+complexity is kept within the middle.
+
+.. versionadded:: 0.9.2  Support is improved for allowing a :func:`.join()`
+   construct to be used directly as the target of the :paramref:`.relationship.secondary`
+   argument, including support for joins, eager joins and lazy loading,
+   as well as support within declarative to specify complex conditions such
+   as joins involving class names as targets.
+
+.. _relationship_non_primary_mapper:
+
+Relationship to Non Primary Mapper
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+In the previous section, we illustrated a technique where we used
+:paramref:`.relationship.secondary` in order to place additional tables
+within a join condition.   There is one complex join case where even this technique is not
+sufficient; when we seek to join from ``A`` to ``B``, making use of any
+number of ``C``, ``D``, etc. in between, however there are also join conditions
+between ``A`` and ``B`` *directly*.  In this case, the join from ``A`` to ``B``
+may be difficult to express with just a complex ``primaryjoin`` condition, as the
+intermediary tables may need special handling, and it is also not expressable
+with a ``secondary`` object, since the ``A->secondary->B`` pattern does not support
+any references between ``A`` and ``B`` directly.  When this **extremely advanced**
+case arises, we can resort to creating a second mapping as a target for
+the relationship.  This is where we use :func:`.mapper` in order to make a mapping to
+a class that includes all the additional tables we need for this join.
+In order to produce this mapper as an "alternative" mapping for our class,
+we use the :paramref:`.mapper.non_primary` flag.
+
+Below illustrates a :func:`.relationship` with a simple join from ``A`` to
+``B``, however the primaryjoin condition is augmented with two additional
+entities ``C`` and ``D``, which also must have rows that line up with
+the rows in both ``A`` and ``B`` simultaneously::
+
+    class A(Base):
+        __tablename__ = 'a'
+
+        id = Column(Integer, primary_key=True)
+        b_id = Column(ForeignKey('b.id'))
+
+    class B(Base):
+        __tablename__ = 'b'
+
+        id = Column(Integer, primary_key=True)
+
+    class C(Base):
+        __tablename__ = 'c'
+
+        id = Column(Integer, primary_key=True)
+        a_id = Column(ForeignKey('a.id'))
+
+    class D(Base):
+        __tablename__ = 'd'
+
+        id = Column(Integer, primary_key=True)
+        c_id = Column(ForeignKey('c.id'))
+        b_id = Column(ForeignKey('b.id'))
+
+    # 1. set up the join() as a variable, so we can refer
+    # to it in the mapping multiple times.
+    j = join(B, D, D.b_id == B.id).join(C, C.id == D.c_id)
+
+    # 2. Create a new mapper() to B, with non_primary=True.
+    # Columns in the join with the same name must be
+    # disambiguated within the mapping, using named properties.
+    B_viacd = mapper(B, j, non_primary=True, properties={
+        "b_id": [j.c.b_id, j.c.d_b_id],
+        "d_id": j.c.d_id
+        })
+
+    A.b = relationship(B_viacd, primaryjoin=A.b_id == B_viacd.c.b_id)
+
+In the above case, our non-primary mapper for ``B`` will emit for
+additional columns when we query; these can be ignored:
+
+.. sourcecode:: python+sql
+
+    sess.query(A).join(A.b).all()
+
+    {opensql}SELECT a.id AS a_id, a.b_id AS a_b_id, d_1.id AS d_1_id,
+            b_1.id AS b_1_id, d_1.b_id AS d_1_b_id, d_1.c_id AS d_1_c_id,
+            c_1.id AS c_1_id, c_1.a_id AS c_1_a_id
+    FROM a LEFT OUTER JOIN (b AS b_1 JOIN d AS d_1 ON d_1.b_id = b_1.id
+                JOIN c AS c_1 ON c_1.id = d_1.c_id) ON a.b_id = b_1.id
+
 
 Building Query-Enabled Properties
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
index fda1cffb5c5c1cd4d21932bea1d8b78ce261d5d3..8b846746f53a6c25c8eb20259b038f527b9e0b35 100644 (file)
@@ -14,6 +14,7 @@ from ...orm.properties import ColumnProperty, RelationshipProperty, \
 from ...schema import _get_table_key
 from ...orm import class_mapper, interfaces
 from ... import util
+from ... import inspection
 from ... import exc
 import weakref
 
@@ -207,6 +208,9 @@ class _GetColumns(object):
                                 " directly to a Column)." % key)
         return getattr(self.cls, key)
 
+inspection._inspects(_GetColumns)(
+            lambda target: inspection.inspect(target.cls))
+
 
 class _GetTable(object):
     def __init__(self, key, metadata):
index a853efc3fffa354421a08a839252d5dbfcba0b39..26f105bec4cbf76c13c0b1f16eeab1fc3128e0d0 100644 (file)
@@ -292,8 +292,12 @@ class Mapper(_InspectionAttr):
           mapping of the class to an alternate selectable, for loading
           only.
 
-          The ``non_primary`` feature is rarely needed with modern
-          usage.
+          :paramref:`.Mapper.non_primary` is not an often used option, but
+          is useful in some specific :func:`.relationship` cases.
+
+          .. seealso::
+
+              :ref:`relationship_non_primary_mapper`
 
         :param order_by: A single :class:`.Column` or list of :class:`.Column`
            objects for which selection operations should use as the default
index 6fdedd38281d3080dc9b77d5e0f3ba41e045c54e..62d4d6b6c58243e18771a7c8698969740c9a57e6 100644 (file)
@@ -111,11 +111,14 @@ class RelationshipProperty(StrategizedProperty):
         strategy_class=None, _local_remote_pairs=None,
         query_class=None,
             info=None):
-        """Provide a relationship of a primary Mapper to a secondary Mapper.
+        """Provide a relationship between two mapped classes.
 
         This corresponds to a parent-child or associative table relationship.  The
         constructed class is an instance of :class:`.RelationshipProperty`.
 
+        For an overview of basic patterns with :func:`.relationship` as
+        used with Declarative, see :ref:`relationship_patterns`.
+
         A typical :func:`.relationship`, used in a classical mapping::
 
            mapper(Parent, properties={
@@ -165,8 +168,11 @@ class RelationshipProperty(StrategizedProperty):
 
         :param secondary:
           for a many-to-many relationship, specifies the intermediary
-          table, and is an instance of :class:`.Table`.  The ``secondary`` keyword
-          argument should generally only
+          table, and is typically an instance of :class:`.Table`.
+          In less common circumstances, the argument may also be specified
+          as an :class:`.Alias` construct, or even a :class:`.Join` construct.
+
+          The ``secondary`` keyword argument should generally only
           be used for a table that is not otherwise expressed in any class
           mapping, unless this relationship is declared as view only, otherwise
           conflicting persistence operations can occur.
@@ -175,6 +181,13 @@ class RelationshipProperty(StrategizedProperty):
           also be passed as a callable function which is evaluated at
           mapper initialization time.
 
+          .. seealso::
+
+              :ref:`relationships_many_to_many`
+
+          .. versionadded:: 0.9.2 :paramref:`.relationship.secondary` works
+             more effectively when referring to a :class:`.Join` instance.
+
         :param active_history=False:
           When ``True``, indicates that the "previous" value for a
           many-to-one reference should be loaded when replaced, if
@@ -562,6 +575,10 @@ class RelationshipProperty(StrategizedProperty):
           which is evaluated at mapper initialization time, and may be passed as a
           Python-evaluable string when using Declarative.
 
+          .. seealso::
+
+              :ref:`relationship_primaryjoin`
+
         :param remote_side:
           used for self-referential relationships, indicates the column or
           list of columns that form the "remote side" of the relationship.
@@ -593,6 +610,10 @@ class RelationshipProperty(StrategizedProperty):
           which is evaluated at mapper initialization time, and may be passed as a
           Python-evaluable string when using Declarative.
 
+          .. seealso::
+
+              :ref:`relationship_primaryjoin`
+
         :param single_parent=(True|False):
           when True, installs a validator which will prevent objects
           from being associated with more than one parent at a time.
@@ -2422,7 +2443,7 @@ class JoinCondition(object):
 
         if aliased:
             if secondary is not None:
-                secondary = secondary.alias()
+                secondary = secondary.alias(flat=True)
                 primary_aliasizer = ClauseAdapter(secondary)
                 secondary_aliasizer = \
                     ClauseAdapter(dest_selectable,
index 033e3d064463ba83f56ef02345019ef37a082b40..bd9b02d24b8af83d1873a8a586ee4bec735308e0 100644 (file)
@@ -527,6 +527,10 @@ class LazyLoader(AbstractRelationshipLoader):
     def _emit_lazyload(self, strategy_options, session, state, ident_key, passive):
         q = session.query(self.mapper)._adapt_all_clauses()
 
+
+        if self.parent_property.secondary is not None:
+            q = q.select_from(self.mapper, self.parent_property.secondary)
+
         q = q._with_invoke_all_eagers(False)
 
         pending = not state.key
index 1f14d81645ec6bb5b925ea1e7573b255c643c1d6..496aad36971c116a51f7527852e9e9fed415b6f3 100644 (file)
@@ -409,6 +409,46 @@ class DeclarativeTest(DeclarativeTestBase):
             "user_1.firstname || :firstname_1 || user_1.lastname"
         )
 
+    def test_string_dependency_resolution_asselectable(self):
+        class A(Base):
+            __tablename__ = 'a'
+
+            id = Column(Integer, primary_key=True)
+            b_id = Column(ForeignKey('b.id'))
+
+            d = relationship("D",
+                            secondary="join(B, D, B.d_id == D.id)."
+                                        "join(C, C.d_id == D.id)",
+                            primaryjoin="and_(A.b_id == B.id, A.id == C.a_id)",
+                            secondaryjoin="D.id == B.d_id",
+                        )
+
+        class B(Base):
+            __tablename__ = 'b'
+
+            id = Column(Integer, primary_key=True)
+            d_id = Column(ForeignKey('d.id'))
+
+        class C(Base):
+            __tablename__ = 'c'
+
+            id = Column(Integer, primary_key=True)
+            a_id = Column(ForeignKey('a.id'))
+            d_id = Column(ForeignKey('d.id'))
+
+        class D(Base):
+            __tablename__ = 'd'
+
+            id = Column(Integer, primary_key=True)
+        s = Session()
+        self.assert_compile(
+            s.query(A).join(A.d),
+            "SELECT a.id AS a_id, a.b_id AS a_b_id FROM a JOIN "
+                "(b AS b_1 JOIN d AS d_1 ON b_1.d_id = d_1.id "
+                "JOIN c AS c_1 ON c_1.d_id = d_1.id) ON a.b_id = b_1.id "
+                "AND a.id = c_1.a_id JOIN d ON d.id = b_1.d_id",
+        )
+
     def test_string_dependency_resolution_no_table(self):
 
         class User(Base, fixtures.ComparableEntity):
index 51b797940801aea10143279a9202a9df15e2a65c..e3d2c90dea8becb22aff7314cf0cbcee11edb0f0 100644 (file)
@@ -216,11 +216,13 @@ class InheritTest3(fixtures.MappedTest):
 
         class Blub(Bar):
             def __repr__(self):
-                return "Blub id %d, data %s, bars %s, foos %s" % (self.id, self.data, repr([b for b in self.bars]), repr([f for f in self.foos]))
+                return "Blub id %d, data %s, bars %s, foos %s" % (
+                        self.id, self.data, repr([b for b in self.bars]),
+                        repr([f for f in self.foos]))
 
         mapper(Blub, blub, inherits=Bar, properties={
-            'bars':relationship(Bar, secondary=blub_bar, lazy='joined'),
-            'foos':relationship(Foo, secondary=blub_foo, lazy='joined'),
+            'bars': relationship(Bar, secondary=blub_bar, lazy='joined'),
+            'foos': relationship(Foo, secondary=blub_foo, lazy='joined'),
         })
 
         sess = create_session()
index 5f48b39b14086918b905ce0cf6fb3777a6a275c1..e0eb7c3e01d6cc7113a88530c3d15907dd81db9e 100644 (file)
@@ -2376,3 +2376,5 @@ class SelfReferentialM2MTest(fixtures.MappedTest):
              ])).order_by(Node.id).all(),
             [Node(data='n1'), Node(data='n2')]
         )
+
+
index 717f136c0c5cdbafb8537006d61dc9d82aaabc46..8f7e2bd5576d377cb4341ea330c5dd6d1eb9a30d 100644 (file)
@@ -8,7 +8,7 @@ from sqlalchemy.orm import mapper, relationship, relation, \
                     backref, create_session, configure_mappers, \
                     clear_mappers, sessionmaker, attributes,\
                     Session, composite, column_property, foreign,\
-                    remote, synonym
+                    remote, synonym, joinedload
 from sqlalchemy.orm.interfaces import ONETOMANY, MANYTOONE, MANYTOMANY
 from sqlalchemy.testing import eq_, startswith_, AssertsCompiledSQL, is_
 from sqlalchemy.testing import fixtures
@@ -2523,6 +2523,191 @@ class AmbiguousFKResolutionTest(_RelationshipErrors, fixtures.MappedTest):
         sa.orm.configure_mappers()
 
 
+class SecondaryNestedJoinTest(fixtures.MappedTest, AssertsCompiledSQL,
+                        testing.AssertsExecutionResults):
+    """test support for a relationship where the 'secondary' table is a
+    compound join().
+
+    join() and joinedload() should use a "flat" alias, lazyloading needs
+    to ensure the join renders.
+
+    """
+    run_setup_mappers = 'once'
+    run_inserts = 'once'
+    run_deletes = None
+
+    @classmethod
+    def define_tables(cls, metadata):
+        Table('a', metadata,
+                Column('id', Integer, primary_key=True, test_needs_autoincrement=True),
+                Column('name', String(30)),
+                Column('b_id', ForeignKey('b.id'))
+            )
+        Table('b', metadata,
+                Column('id', Integer, primary_key=True, test_needs_autoincrement=True),
+                Column('name', String(30)),
+                Column('d_id', ForeignKey('d.id'))
+            )
+        Table('c', metadata,
+                Column('id', Integer, primary_key=True, test_needs_autoincrement=True),
+                Column('name', String(30)),
+                Column('a_id', ForeignKey('a.id')),
+                Column('d_id', ForeignKey('d.id'))
+            )
+        Table('d', metadata,
+                Column('id', Integer, primary_key=True, test_needs_autoincrement=True),
+                Column('name', String(30)),
+            )
+
+    @classmethod
+    def setup_classes(cls):
+        class A(cls.Comparable):
+            pass
+        class B(cls.Comparable):
+            pass
+        class C(cls.Comparable):
+            pass
+        class D(cls.Comparable):
+            pass
+
+    @classmethod
+    def setup_mappers(cls):
+        A, B, C, D = cls.classes.A, cls.classes.B, cls.classes.C, cls.classes.D
+        a, b, c, d = cls.tables.a, cls.tables.b, cls.tables.c, cls.tables.d
+        j = sa.join(b, d, b.c.d_id == d.c.id).join(c, c.c.d_id == d.c.id)
+        #j = join(b, d, b.c.d_id == d.c.id).join(c, c.c.d_id == d.c.id).alias()
+        mapper(A, a, properties={
+            "b": relationship(B),
+            "d": relationship(D, secondary=j,
+                        primaryjoin=and_(a.c.b_id == b.c.id, a.c.id == c.c.a_id),
+                        secondaryjoin=d.c.id == b.c.d_id,
+                        #primaryjoin=and_(a.c.b_id == j.c.b_id, a.c.id == j.c.c_a_id),
+                        #secondaryjoin=d.c.id == j.c.b_d_id,
+                        uselist=False
+                    )
+            })
+        mapper(B, b, properties={
+                "d": relationship(D)
+            })
+        mapper(C, c, properties={
+                "a": relationship(A),
+                "d": relationship(D)
+            })
+        mapper(D, d)
+
+    @classmethod
+    def insert_data(cls):
+        A, B, C, D = cls.classes.A, cls.classes.B, cls.classes.C, cls.classes.D
+        sess = Session()
+        a1, a2, a3, a4 = A(name='a1'), A(name='a2'), A(name='a3'), A(name='a4')
+        b1, b2, b3, b4 = B(name='b1'), B(name='b2'), B(name='b3'), B(name='b4')
+        c1, c2, c3, c4 = C(name='c1'), C(name='c2'), C(name='c3'), C(name='c4')
+        d1, d2 = D(name='d1'), D(name='d2')
+
+        a1.b = b1
+        a2.b = b2
+        a3.b = b3
+        a4.b = b4
+
+        c1.a = a1
+        c2.a = a2
+        c3.a = a2
+        c4.a = a4
+
+        c1.d = d1
+        c2.d = d2
+        c3.d = d1
+        c4.d = d2
+
+        b1.d = d1
+        b2.d = d1
+        b3.d = d2
+        b4.d = d2
+
+        sess.add_all([a1, a2, a3, a4, b1, b2, b3, b4, c1, c2, c4, c4, d1, d2])
+        sess.commit()
+
+    def test_render_join(self):
+        A, D = self.classes.A, self.classes.D
+        sess = Session()
+        self.assert_compile(
+            sess.query(A).join(A.d),
+            "SELECT a.id AS a_id, a.name AS a_name, a.b_id AS a_b_id "
+            "FROM a JOIN (b AS b_1 JOIN d AS d_1 ON b_1.d_id = d_1.id "
+                "JOIN c AS c_1 ON c_1.d_id = d_1.id) ON a.b_id = b_1.id "
+                "AND a.id = c_1.a_id JOIN d ON d.id = b_1.d_id",
+            dialect="postgresql"
+        )
+
+    def test_render_joinedload(self):
+        A, D = self.classes.A, self.classes.D
+        sess = Session()
+        self.assert_compile(
+            sess.query(A).options(joinedload(A.d)),
+            "SELECT a.id AS a_id, a.name AS a_name, a.b_id AS a_b_id, "
+            "d_1.id AS d_1_id, d_1.name AS d_1_name FROM a LEFT OUTER JOIN "
+            "(b AS b_1 JOIN d AS d_2 ON b_1.d_id = d_2.id JOIN c AS c_1 "
+                "ON c_1.d_id = d_2.id JOIN d AS d_1 ON d_1.id = b_1.d_id) "
+                "ON a.b_id = b_1.id AND a.id = c_1.a_id",
+            dialect="postgresql"
+        )
+
+    def test_render_lazyload(self):
+        from sqlalchemy.testing.assertsql import CompiledSQL
+
+        A, D = self.classes.A, self.classes.D
+        sess = Session()
+        a1 = sess.query(A).filter(A.name == 'a1').first()
+
+        def go():
+            a1.d
+
+        # here, the "lazy" strategy has to ensure the "secondary"
+        # table is part of the "select_from()", since it's a join().
+        # referring to just the columns wont actually render all those
+        # join conditions.
+        self.assert_sql_execution(
+                testing.db,
+                go,
+                CompiledSQL(
+                    "SELECT d.id AS d_id, d.name AS d_name FROM b "
+                    "JOIN d ON b.d_id = d.id JOIN c ON c.d_id = d.id "
+                    "WHERE :param_1 = b.id AND :param_2 = c.a_id AND d.id = b.d_id",
+                    {'param_1': a1.id, 'param_2': a1.id}
+                )
+        )
+
+    mapping = {
+        "a1": "d1",
+        "a2": None,
+        "a3": None,
+        "a4": "d2"
+    }
+
+    def test_join(self):
+        A, D = self.classes.A, self.classes.D
+        sess = Session()
+
+        for a, d in sess.query(A, D).outerjoin(A.d):
+            eq_(self.mapping[a.name], d.name if d is not None else None)
+
+
+    def test_joinedload(self):
+        A, D = self.classes.A, self.classes.D
+        sess = Session()
+
+        for a in sess.query(A).options(joinedload(A.d)):
+            d = a.d
+            eq_(self.mapping[a.name], d.name if d is not None else None)
+
+    def test_lazyload(self):
+        A, D = self.classes.A, self.classes.D
+        sess = Session()
+
+        for a in sess.query(A):
+            d = a.d
+            eq_(self.mapping[a.name], d.name if d is not None else None)
+
 class InvalidRelationshipEscalationTest(_RelationshipErrors, fixtures.MappedTest):
 
     @classmethod