pythonsqlalchemyrelationships

SQLAlchemy: Difference between joinedload and subqueryload behavior


I found a surprising difference between SQLAlchemy's joinedload, and subqueryload, specifically with how they handle with_polymorphic. When using subqueryload, I am not able to eagerly load a relationship on a subclass of the relationship included in the subqueryload, whereas joinedload seems to handle this just fine.

For example, say I have a User class that has-many Shape objects. A Rectangle is a shape, and say it has-a Widget related to it. When querying the User class, I'd like to be able to construct my query options so all of the user's rectangle's widgets will be eagerly fetched, and I'd like to query the shapes as a subquery (rather than a joined load) to reduce the amount of duplicated data in the returned result set. I can get it working by join loading the shapes relationship, but I can't get it working as a subquery. Any thoughts?

Example, in code:

import logging
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import (
    backref,
    joinedload,
    relationship,
    scoped_session,
    sessionmaker,
    subqueryload,
    with_polymorphic)
from sqlalchemy import (
    create_engine,
    Column,
    Integer,
    ForeignKey,
    MetaData,
    String)


logging.basicConfig()
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)


Base = declarative_base()


class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True)


class Shape(Base):
    __tablename__ = "shapes"
    __mapper_args__ = {"polymorphic_on": "type"}

    id = Column(Integer, primary_key=True)
    type = Column(String, nullable=False)
    owner_id = Column(Integer, ForeignKey("users.id"))
    owner = relationship("User", backref=backref("shapes"), cascade="all")


class Rectangle(Shape):
    __tablename__ = "shapes_rectangles"
    __mapper_args__ = {"polymorphic_identity": "RECT"}

    id = Column(Integer, ForeignKey("shapes.id"), primary_key=True)
    length = Column(Integer)
    width = Column(Integer)
    widget_id = Column(Integer, ForeignKey("widgets.id"))
    widget = relationship("Widget", cascade="all")


class Widget(Base):
    __tablename__ = "widgets"

    id = Column(Integer, primary_key=True)


engine = create_engine("sqlite://", echo=True)
metadata = MetaData()
with engine.begin() as connection:
    metadata = Base.metadata
    metadata.create_all(connection)

session = scoped_session(sessionmaker(bind=engine))

user = User()
widget = Widget()
rectangle = Rectangle(owner=user, widget=widget, length=10, width=5)
session.add_all([user, widget, rectangle])
session.commit()

shapes_load = subqueryload(
    User.shapes.of_type(
        with_polymorphic(Shape, "*", aliased=True)))

session.remove()
query = session.query(User) .options(shapes_load.joinedload(Rectangle.widget))
logger.info("##########subqueryload begin")
user = query.one()
logger.info("##########accessing widget")
user.shapes[0].widget
logger.info("##########subqueryload end")

shapes_load = joinedload(
    User.shapes.of_type(
        with_polymorphic(Shape, "*", aliased=True)))

session.remove()
query = session.query(User).options(shapes_load.joinedload(Rectangle.widget))
logger.info("##########joinedload begin")
user = query.one()
logger.info("##########accessing widget")
user.shapes[0].widget
logger.info("##########joinedload end")

Relevant output:

INFO:__main__:##########subqueryload begin
2017-12-19 23:03:36,826 INFO sqlalchemy.engine.base.Engine BEGIN (implicit)
INFO:sqlalchemy.engine.base.Engine:BEGIN (implicit)
2017-12-19 23:03:36,827 INFO sqlalchemy.engine.base.Engine SELECT users.id AS users_id
FROM users
INFO:sqlalchemy.engine.base.Engine:SELECT users.id AS users_id
FROM users
2017-12-19 23:03:36,827 INFO sqlalchemy.engine.base.Engine ()
INFO:sqlalchemy.engine.base.Engine:()
2017-12-19 23:03:36,829 INFO sqlalchemy.engine.base.Engine SELECT anon_1.shapes_id AS anon_1_shapes_id, anon_1.shapes_type AS anon_1_shapes_type, anon_1.shapes_owner_id AS anon
_1_shapes_owner_id, anon_1.shapes_rectangles_id AS anon_1_shapes_rectangles_id, anon_1.shapes_rectangles_length AS anon_1_shapes_rectangles_length, anon_1.shapes_rectangles_wid
th AS anon_1_shapes_rectangles_width, anon_1.shapes_rectangles_widget_id AS anon_1_shapes_rectangles_widget_id, anon_2.users_id AS anon_2_users_id
FROM (SELECT users.id AS users_id
FROM users) AS anon_2 JOIN (SELECT shapes.id AS shapes_id, shapes.type AS shapes_type, shapes.owner_id AS shapes_owner_id, shapes_rectangles.id AS shapes_rectangles_id, shapes_
rectangles.length AS shapes_rectangles_length, shapes_rectangles.width AS shapes_rectangles_width, shapes_rectangles.widget_id AS shapes_rectangles_widget_id
FROM shapes LEFT OUTER JOIN shapes_rectangles ON shapes.id = shapes_rectangles.id) AS anon_1 ON anon_2.users_id = anon_1.shapes_owner_id ORDER BY anon_2.users_id
INFO:sqlalchemy.engine.base.Engine:SELECT anon_1.shapes_id AS anon_1_shapes_id, anon_1.shapes_type AS anon_1_shapes_type, anon_1.shapes_owner_id AS anon_1_shapes_owner_id, anon
_1.shapes_rectangles_id AS anon_1_shapes_rectangles_id, anon_1.shapes_rectangles_length AS anon_1_shapes_rectangles_length, anon_1.shapes_rectangles_width AS anon_1_shapes_rect
angles_width, anon_1.shapes_rectangles_widget_id AS anon_1_shapes_rectangles_widget_id, anon_2.users_id AS anon_2_users_id
FROM (SELECT users.id AS users_id
FROM users) AS anon_2 JOIN (SELECT shapes.id AS shapes_id, shapes.type AS shapes_type, shapes.owner_id AS shapes_owner_id, shapes_rectangles.id AS shapes_rectangles_id, shapes_
rectangles.length AS shapes_rectangles_length, shapes_rectangles.width AS shapes_rectangles_width, shapes_rectangles.widget_id AS shapes_rectangles_widget_id
FROM shapes LEFT OUTER JOIN shapes_rectangles ON shapes.id = shapes_rectangles.id) AS anon_1 ON anon_2.users_id = anon_1.shapes_owner_id ORDER BY anon_2.users_id
2017-12-19 23:03:36,829 INFO sqlalchemy.engine.base.Engine ()
INFO:sqlalchemy.engine.base.Engine:()
INFO:__main__:##########accessing widget
2017-12-19 23:03:36,831 INFO sqlalchemy.engine.base.Engine SELECT widgets.id AS widgets_id
FROM widgets
WHERE widgets.id = ?
INFO:sqlalchemy.engine.base.Engine:SELECT widgets.id AS widgets_id
FROM widgets
WHERE widgets.id = ?
2017-12-19 23:03:36,831 INFO sqlalchemy.engine.base.Engine (1,)
INFO:sqlalchemy.engine.base.Engine:(1,)
INFO:__main__:##########subqueryload end
2017-12-19 23:03:36,832 INFO sqlalchemy.engine.base.Engine ROLLBACK
INFO:sqlalchemy.engine.base.Engine:ROLLBACK
INFO:__main__:##########joinedload begin
2017-12-19 23:03:36,837 INFO sqlalchemy.engine.base.Engine BEGIN (implicit)
INFO:sqlalchemy.engine.base.Engine:BEGIN (implicit)
2017-12-19 23:03:36,838 INFO sqlalchemy.engine.base.Engine SELECT users.id AS users_id, anon_1.shapes_id AS anon_1_shapes_id, anon_1.shapes_type AS anon_1_shapes_type, anon_1.s
hapes_owner_id AS anon_1_shapes_owner_id, widgets_1.id AS widgets_1_id, anon_1.shapes_rectangles_id AS anon_1_shapes_rectangles_id, anon_1.shapes_rectangles_length AS anon_1_sh
apes_rectangles_length, anon_1.shapes_rectangles_width AS anon_1_shapes_rectangles_width, anon_1.shapes_rectangles_widget_id AS anon_1_shapes_rectangles_widget_id
FROM users LEFT OUTER JOIN (SELECT shapes.id AS shapes_id, shapes.type AS shapes_type, shapes.owner_id AS shapes_owner_id, shapes_rectangles.id AS shapes_rectangles_id, shapes_
rectangles.length AS shapes_rectangles_length, shapes_rectangles.width AS shapes_rectangles_width, shapes_rectangles.widget_id AS shapes_rectangles_widget_id
FROM shapes LEFT OUTER JOIN shapes_rectangles ON shapes.id = shapes_rectangles.id) AS anon_1 ON users.id = anon_1.shapes_owner_id LEFT OUTER JOIN widgets AS widgets_1 ON widget
s_1.id = anon_1.shapes_rectangles_widget_id
INFO:sqlalchemy.engine.base.Engine:SELECT users.id AS users_id, anon_1.shapes_id AS anon_1_shapes_id, anon_1.shapes_type AS anon_1_shapes_type, anon_1.shapes_owner_id AS anon_1
_shapes_owner_id, widgets_1.id AS widgets_1_id, anon_1.shapes_rectangles_id AS anon_1_shapes_rectangles_id, anon_1.shapes_rectangles_length AS anon_1_shapes_rectangles_length,
anon_1.shapes_rectangles_width AS anon_1_shapes_rectangles_width, anon_1.shapes_rectangles_widget_id AS anon_1_shapes_rectangles_widget_id
FROM users LEFT OUTER JOIN (SELECT shapes.id AS shapes_id, shapes.type AS shapes_type, shapes.owner_id AS shapes_owner_id, shapes_rectangles.id AS shapes_rectangles_id, shapes_
rectangles.length AS shapes_rectangles_length, shapes_rectangles.width AS shapes_rectangles_width, shapes_rectangles.widget_id AS shapes_rectangles_widget_id
FROM shapes LEFT OUTER JOIN shapes_rectangles ON shapes.id = shapes_rectangles.id) AS anon_1 ON users.id = anon_1.shapes_owner_id LEFT OUTER JOIN widgets AS widgets_1 ON widget
s_1.id = anon_1.shapes_rectangles_widget_id
2017-12-19 23:03:36,839 INFO sqlalchemy.engine.base.Engine ()
INFO:sqlalchemy.engine.base.Engine:()
INFO:__main__:##########accessing widget
INFO:__main__:##########joinedload end

Solution

  • I discovered a bug with how I was using with_polymorphic. This patch [1] fixes the subqueryload query, so it does not emit a query when accessing user.shapes[0].widget (which is the expected behavior).

    In SQLAlchemy version 1.3, the original test case fails with an exception like:

    ArgumentError: Attribute "Rectangle.widget" does not link from element "with_polymorphic(Shape, [])".
    

    [1]

    @@ -72,12 +72,13 @@ rectangle = Rectangle(owner=user, widget=widget, length=10, width=5)
     session.add_all([user, widget, rectangle])
     session.commit()
    
    -shapes_load = subqueryload(
    -    User.shapes.of_type(
    -        with_polymorphic(Shape, "*", aliased=True)))
    +polymorphic_shape = with_polymorphic(Shape, "*", aliased=True)
    +shapes_load = subqueryload(User.shapes.of_type(polymorphic_shape))
    
     session.remove()
    -query = session.query(User) .options(shapes_load.joinedload(Rectangle.widget))
    +query = session.query(User).options(
    +    shapes_load.joinedload(polymorphic_shape.Rectangle.widget)
    +)
     logger.info("##########subqueryload begin")
     user = query.one()
     logger.info("##########accessing widget")
    @@ -85,11 +86,13 @@ user.shapes[0].widget
     logger.info("##########subqueryload end")
    
     shapes_load = joinedload(
    -    User.shapes.of_type(
    -        with_polymorphic(Shape, "*", aliased=True)))
    +    User.shapes.of_type(with_polymorphic(Shape, "*", aliased=True))
    +)
    
     session.remove()
    -query = session.query(User).options(shapes_load.joinedload(Rectangle.widget))
    +query = session.query(User).options(
    +    shapes_load.joinedload(polymorphic_shape.Rectangle.widget)
    +)
     logger.info("##########joinedload begin")
     user = query.one()
     logger.info("##########accessing widget")