SqlAlchemy 2.0 中文文档(十九)(3)https://developer.aliyun.com/article/1562932
连接式急加载
连接式急加载是 SQLAlchemy ORM 包含的最古老的急加载样式。它通过将 JOIN(默认为 LEFT OUTER join)连接到发出的 SELECT 语句,并从与父级相同的结果集填充目标标量/集合来工作。
在映射级别,这看起来像是:
class Address(Base): # ... user: Mapped[User] = relationship(lazy="joined")
连接式急加载通常作为查询的选项应用,而不是作为映射的默认加载选项,特别是当用于集合而不是多对一引用时。这通过使用joinedload()
加载器选项来实现:
>>> from sqlalchemy import select >>> from sqlalchemy.orm import joinedload >>> stmt = select(User).options(joinedload(User.addresses)).filter_by(name="spongebob") >>> spongebob = session.scalars(stmt).unique().all() SELECT addresses_1.id AS addresses_1_id, addresses_1.email_address AS addresses_1_email_address, addresses_1.user_id AS addresses_1_user_id, users.id AS users_id, users.name AS users_name, users.fullname AS users_fullname, users.nickname AS users_nickname FROM users LEFT OUTER JOIN addresses AS addresses_1 ON users.id = addresses_1.user_id WHERE users.name = ? ['spongebob']
提示
当涉及到一对多或多对多集合时,包括joinedload()
时,必须对返回的结果应用Result.unique()
方法,该方法将通过主键使传入的行唯一化,否则会被联接乘以。如果没有这个方法,ORM 将引发错误。
这在现代 SQLAlchemy 中不是自动的,因为它改变了结果集的行为,以返回比语句通常返回的 ORM 对象少的行数。因此,SQLAlchemy 保持了对Result.unique()
的使用明确,这样就不会产生返回的对象在主键上的唯一性。
默认情况下发出的 JOIN 是一个 LEFT OUTER JOIN,以允许引用一个不存在相关行的主对象。对于保证具有元素的属性,例如对一个相关对象的多对一引用,其中引用的外键不为 NULL,通过使用内连接可以使查询更有效率;这可以通过映射级别的relationship.innerjoin
标志来实现:
class Address(Base): # ... user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) user: Mapped[User] = relationship(lazy="joined", innerjoin=True)
在查询选项级别,通过joinedload.innerjoin
标志:
from sqlalchemy import select from sqlalchemy.orm import joinedload stmt = select(Address).options(joinedload(Address.user, innerjoin=True))
当在包含 OUTER JOIN 的链中应用时,JOIN 将会右嵌套自身:
>>> from sqlalchemy import select >>> from sqlalchemy.orm import joinedload >>> stmt = select(User).options( ... joinedload(User.addresses).joinedload(Address.widgets, innerjoin=True) ... ) >>> results = session.scalars(stmt).unique().all() SELECT widgets_1.id AS widgets_1_id, widgets_1.name AS widgets_1_name, addresses_1.id AS addresses_1_id, addresses_1.email_address AS addresses_1_email_address, addresses_1.user_id AS addresses_1_user_id, users.id AS users_id, users.name AS users_name, users.fullname AS users_fullname, users.nickname AS users_nickname FROM users LEFT OUTER JOIN ( addresses AS addresses_1 JOIN widgets AS widgets_1 ON addresses_1.widget_id = widgets_1.id ) ON users.id = addresses_1.user_id
提示
如果在发出 SELECT 时使用数据库行锁定技术,这意味着使用Select.with_for_update()
方法来发出 SELECT…FOR UPDATE,那么根据所使用的后端的行为,连接表也可能被锁定。基于这个原因,不建议同时使用联接式急加载和 SELECT…FOR UPDATE。
联接式急加载的禅意
由于联接式急加载似乎与Select.join()
的使用有很多相似之处,因此经常会产生何时以及如何使用它的混淆。重要的是要理解这样一个区别,即虽然Select.join()
用于修改查询的结果,但joinedload()
竭尽全力不修改查询的结果,而是隐藏渲染联接的效果,以便仅允许相关对象存在。
加载策略背后的哲学是,任何一组加载方案都可以应用于特定的查询,并且结果不会改变 - 只有用于完全加载相关对象和集合的 SQL 语句数量会改变。一个特定的查询可能起初使用了所有的延迟加载。在上下文中使用后,可能会发现特定的属性或集合总是被访问,更改这些属性的加载器策略将更有效率。策略可以更改而不影响查询的其他部分,结果将保持不变,但 SQL 语句数量会减少。理论上(而且在实践中几乎是如此),对Select
所做的任何操作都不会因为加载器策略的改变而使其基于不同的一组主对象或相关对象加载不同的集合。
特别是 joinedload()
如何实现这一结果不以任何方式影响返回的实体行,它创建了查询中添加的连接的匿名别名,以便它们不能被查询的其他部分引用。例如,下面的查询使用 joinedload()
创建了从 users
到 addresses
的 LEFT OUTER JOIN,然而对 Address.email_address
添加的 ORDER BY
是无效的 - 查询中没有命名 Address
实体:
>>> from sqlalchemy import select >>> from sqlalchemy.orm import joinedload >>> stmt = ( ... select(User) ... .options(joinedload(User.addresses)) ... .filter(User.name == "spongebob") ... .order_by(Address.email_address) ... ) >>> result = session.scalars(stmt).unique().all() SELECT addresses_1.id AS addresses_1_id, addresses_1.email_address AS addresses_1_email_address, addresses_1.user_id AS addresses_1_user_id, users.id AS users_id, users.name AS users_name, users.fullname AS users_fullname, users.nickname AS users_nickname FROM users LEFT OUTER JOIN addresses AS addresses_1 ON users.id = addresses_1.user_id WHERE users.name = ? ORDER BY addresses.email_address <-- this part is wrong ! ['spongebob']
上面,ORDER BY addresses.email_address
是无效的,因为 addresses
不在 FROM 列表中。加载 User
记录并按电子邮件地址排序的正确方法是使用 Select.join()
:
>>> from sqlalchemy import select >>> stmt = ( ... select(User) ... .join(User.addresses) ... .filter(User.name == "spongebob") ... .order_by(Address.email_address) ... ) >>> result = session.scalars(stmt).unique().all() SELECT users.id AS users_id, users.name AS users_name, users.fullname AS users_fullname, users.nickname AS users_nickname FROM users JOIN addresses ON users.id = addresses.user_id WHERE users.name = ? ORDER BY addresses.email_address ['spongebob']
当然,上面的语句与之前的语句不同,因为根本没有包含来自 addresses
的列在结果中。我们可以添加 joinedload()
回来,这样就有了两个连接 - 一个是我们正在排序的连接,另一个是匿名使用的,用于加载 User.addresses
集合的内容:
>>> stmt = ( ... select(User) ... .join(User.addresses) ... .options(joinedload(User.addresses)) ... .filter(User.name == "spongebob") ... .order_by(Address.email_address) ... ) >>> result = session.scalars(stmt).unique().all() SELECT addresses_1.id AS addresses_1_id, addresses_1.email_address AS addresses_1_email_address, addresses_1.user_id AS addresses_1_user_id, users.id AS users_id, users.name AS users_name, users.fullname AS users_fullname, users.nickname AS users_nickname FROM users JOIN addresses ON users.id = addresses.user_id LEFT OUTER JOIN addresses AS addresses_1 ON users.id = addresses_1.user_id WHERE users.name = ? ORDER BY addresses.email_address ['spongebob']
我们在上面看到 Select.join()
的用法是提供我们希望在后续查询条件中使用的 JOIN 子句,而我们对 joinedload()
的用法只关注于为结果中的每个 User
加载 User.addresses
集合。在这种情况下,这两个连接很可能是多余的 - 而事实上它们确实是。如果我们只想使用一个 JOIN 来加载集合并排序,我们可以使用 contains_eager()
选项,下面描述了 将明确的 JOIN/语句路由到急切加载的集合。但要了解为什么 joinedload()
所做的事情,请考虑如果我们过滤某个特定的 Address
:
>>> stmt = ( ... select(User) ... .join(User.addresses) ... .options(joinedload(User.addresses)) ... .filter(User.name == "spongebob") ... .filter(Address.email_address == "someaddress@foo.com") ... ) >>> result = session.scalars(stmt).unique().all() SELECT addresses_1.id AS addresses_1_id, addresses_1.email_address AS addresses_1_email_address, addresses_1.user_id AS addresses_1_user_id, users.id AS users_id, users.name AS users_name, users.fullname AS users_fullname, users.nickname AS users_nickname FROM users JOIN addresses ON users.id = addresses.user_id LEFT OUTER JOIN addresses AS addresses_1 ON users.id = addresses_1.user_id WHERE users.name = ? AND addresses.email_address = ? ['spongebob', 'someaddress@foo.com']
上面我们可以看到,这两个 JOIN 扮演着非常不同的角色。其中一个将精确匹配一行,即 User
和 Address
的连接,其中 Address.email_address=='someaddress@foo.com'
。另一个 LEFT OUTER JOIN 将匹配与 User
相关的所有 Address
行,并且仅用于填充返回的那些 User
对象的 User.addresses
集合。
通过将 joinedload()
的使用方式更改为另一种加载方式,我们可以完全独立于用于检索实际所需的 User
行的 SQL,改变集合的加载方式。以下我们将 joinedload()
改为 selectinload()
:
>>> stmt = ( ... select(User) ... .join(User.addresses) ... .options(selectinload(User.addresses)) ... .filter(User.name == "spongebob") ... .filter(Address.email_address == "someaddress@foo.com") ... ) >>> result = session.scalars(stmt).all() SELECT users.id AS users_id, users.name AS users_name, users.fullname AS users_fullname, users.nickname AS users_nickname FROM users JOIN addresses ON users.id = addresses.user_id WHERE users.name = ? AND addresses.email_address = ? ['spongebob', 'someaddress@foo.com'] # ... selectinload() emits a SELECT in order # to load all address records ...
当使用连接式贪婪加载时,如果查询包含影响外部连接返回行的修饰符,例如使用 DISTINCT、LIMIT、OFFSET 或等效操作,完成的语句首先被包装在一个子查询中,连接专门用于连接式贪婪加载被应用于子查询。SQLAlchemy 的连接式贪婪加载额外努力,然后再努力十英里,绝对确保它不会影响查询的最终结果,只影响集合和相关对象的加载方式,无论查询的格式如何。
另请参阅
将显式连接/语句路由到贪婪加载的集合 - 使用 contains_eager()
### 连接式贪婪加载的禅意
由于连接式贪婪加载似乎与 Select.join()
的使用方式有很多相似之处,因此在何时以及如何使用它经常会产生困惑。重要的是要理解,虽然 Select.join()
用于更改查询的结果,但 joinedload()
却极力避免更改查询的结果,而是隐藏渲染连接的效果,以允许相关对象存在。
装载策略背后的哲学是,任何一组装载方案都可以应用于特定的查询,并且结果不会改变——只有完全加载相关对象和集合所需的 SQL 语句数量会改变。一个特定的查询可能首先使用所有的延迟加载。在上下文中使用后,可能会发现特定属性或集合总是被访问,并且更改这些的加载策略会更有效。该策略可以在不修改查询的其他部分的情况下更改,结果将保持相同,但会发出更少的 SQL 语句。理论上(实际上基本如此),无论你对 Select
做什么修改,都不会使其根据加载策略的变化加载不同的主要或相关对象集合。
如何使用joinedload()
来实现不影响返回的实体行的结果,它的特点是创建查询中添加的连接的匿名别名,以便其他查询的部分不能引用它们。例如,下面的查询使用joinedload()
创建了一个从users
到addresses
的左外连接,但是针对Address.email_address
添加的ORDER BY
是无效的 - 查询中未命名Address
实体:
>>> from sqlalchemy import select >>> from sqlalchemy.orm import joinedload >>> stmt = ( ... select(User) ... .options(joinedload(User.addresses)) ... .filter(User.name == "spongebob") ... .order_by(Address.email_address) ... ) >>> result = session.scalars(stmt).unique().all() SELECT addresses_1.id AS addresses_1_id, addresses_1.email_address AS addresses_1_email_address, addresses_1.user_id AS addresses_1_user_id, users.id AS users_id, users.name AS users_name, users.fullname AS users_fullname, users.nickname AS users_nickname FROM users LEFT OUTER JOIN addresses AS addresses_1 ON users.id = addresses_1.user_id WHERE users.name = ? ORDER BY addresses.email_address <-- this part is wrong ! ['spongebob']
上述中,ORDER BY addresses.email_address
是无效的,因为addresses
不在 FROM 列表中。加载User
记录并按电子邮件地址排序的正确方法是使用Select.join()
:
>>> from sqlalchemy import select >>> stmt = ( ... select(User) ... .join(User.addresses) ... .filter(User.name == "spongebob") ... .order_by(Address.email_address) ... ) >>> result = session.scalars(stmt).unique().all() SELECT users.id AS users_id, users.name AS users_name, users.fullname AS users_fullname, users.nickname AS users_nickname FROM users JOIN addresses ON users.id = addresses.user_id WHERE users.name = ? ORDER BY addresses.email_address ['spongebob']
当然,上面的语句与前面的语句不同,因为根本没有包含来自addresses
的列在结果中。我们可以重新添加joinedload()
,以便有两个连接 - 一个是我们正在排序的连接,另一个是匿名使用的,用于加载User.addresses
集合的内容:
>>> stmt = ( ... select(User) ... .join(User.addresses) ... .options(joinedload(User.addresses)) ... .filter(User.name == "spongebob") ... .order_by(Address.email_address) ... ) >>> result = session.scalars(stmt).unique().all() SELECT addresses_1.id AS addresses_1_id, addresses_1.email_address AS addresses_1_email_address, addresses_1.user_id AS addresses_1_user_id, users.id AS users_id, users.name AS users_name, users.fullname AS users_fullname, users.nickname AS users_nickname FROM users JOIN addresses ON users.id = addresses.user_id LEFT OUTER JOIN addresses AS addresses_1 ON users.id = addresses_1.user_id WHERE users.name = ? ORDER BY addresses.email_address ['spongebob']
以上我们看到,我们使用Select.join()
来提供我们希望在随后的查询条件中使用的 JOIN 子句,而我们使用joinedload()
只关心加载每个结果中的User.addresses
集合。在这种情况下,这两个连接很可能是多余的 - 它们确实是。如果我们只想使用一个 JOIN 来加载集合并排序,我们可以使用contains_eager()
选项,下面描述了将显式的连接/语句路由到急加载的集合。但要看看为什么joinedload()
会做它的工作,考虑一下如果我们过滤特定的Address
:
>>> stmt = ( ... select(User) ... .join(User.addresses) ... .options(joinedload(User.addresses)) ... .filter(User.name == "spongebob") ... .filter(Address.email_address == "someaddress@foo.com") ... ) >>> result = session.scalars(stmt).unique().all() SELECT addresses_1.id AS addresses_1_id, addresses_1.email_address AS addresses_1_email_address, addresses_1.user_id AS addresses_1_user_id, users.id AS users_id, users.name AS users_name, users.fullname AS users_fullname, users.nickname AS users_nickname FROM users JOIN addresses ON users.id = addresses.user_id LEFT OUTER JOIN addresses AS addresses_1 ON users.id = addresses_1.user_id WHERE users.name = ? AND addresses.email_address = ? ['spongebob', 'someaddress@foo.com']
上面,我们可以看到这两个 JOIN 有非常不同的角色。一个将精确匹配一个行,即User
和Address
的连接,其中Address.email_address=='someaddress@foo.com'
。另一个左外连接将匹配与User
相关的所有Address
行,并且仅用于为返回的User
对象填充User.addresses
集合。
通过改变joinedload()
的使用方式为另一种加载样式,我们可以完全独立于用于检索实际所需User
行的 SQL,改变集合的加载方式。以下我们将joinedload()
改为selectinload()
:
>>> stmt = ( ... select(User) ... .join(User.addresses) ... .options(selectinload(User.addresses)) ... .filter(User.name == "spongebob") ... .filter(Address.email_address == "someaddress@foo.com") ... ) >>> result = session.scalars(stmt).all() SELECT users.id AS users_id, users.name AS users_name, users.fullname AS users_fullname, users.nickname AS users_nickname FROM users JOIN addresses ON users.id = addresses.user_id WHERE users.name = ? AND addresses.email_address = ? ['spongebob', 'someaddress@foo.com'] # ... selectinload() emits a SELECT in order # to load all address records ...
当使用连接式急切加载时,如果查询包含影响联接外部返回的行的修饰符,例如使用 DISTINCT、LIMIT、OFFSET 或等效的修饰符,完成的语句首先包装在一个子查询中,并且专门用于连接式急切加载的联接应用于子查询。SQLAlchemy 的连接式急切加载努力工作,然后再走十英里,绝对确保它不会影响查询的最终结果,只影响加载集合和相关对象的方式,无论查询的格式是什么。
另请参阅
将显式联接/语句路由到急切加载的集合 - 使用contains_eager()
选择性加载
在大多数情况下,选择性加载是急切加载对象集合的最简单和最有效的方法。唯一不可行的选择性急切加载的情况是当模型使用复合主键,并且后端数据库不支持具有 IN 的元组时,这种情况目前包括 SQL Server。
使用"selectin"
参数或使用selectinload()
加载器选项提供了“选择 IN”急切加载。这种加载样式发出一个 SELECT,该 SELECT 引用父对象的主键值,或者在一对多关系的情况下引用子对象的主键值,位于 IN 子句中,以加载相关联的关系:
>>> from sqlalchemy import select >>> from sqlalchemy.orm import selectinload >>> stmt = ( ... select(User) ... .options(selectinload(User.addresses)) ... .filter(or_(User.name == "spongebob", User.name == "ed")) ... ) >>> result = session.scalars(stmt).all() SELECT users.id AS users_id, users.name AS users_name, users.fullname AS users_fullname, users.nickname AS users_nickname FROM users WHERE users.name = ? OR users.name = ? ('spongebob', 'ed') SELECT addresses.id AS addresses_id, addresses.email_address AS addresses_email_address, addresses.user_id AS addresses_user_id FROM addresses WHERE addresses.user_id IN (?, ?) (5, 7)
在上面,第二个 SELECT 引用了addresses.user_id IN (5, 7)
,其中的“5”和“7”是前两个加载的User
对象的主键值;在一批对象完全加载后,它们的主键值被注入到第二个 SELECT 的IN
子句中。因为User
和Address
之间的关系具有简单的主键连接条件,并且提供了User
的主键值可以从Address.user_id
派生,所以该语句根本没有联接或子查询。
对于简单的一对多加载,也不需要 JOIN,因为使用父对象的外键值即可:
>>> from sqlalchemy import select >>> from sqlalchemy.orm import selectinload >>> stmt = select(Address).options(selectinload(Address.user)) >>> result = session.scalars(stmt).all() SELECT addresses.id AS addresses_id, addresses.email_address AS addresses_email_address, addresses.user_id AS addresses_user_id FROM addresses SELECT users.id AS users_id, users.name AS users_name, users.fullname AS users_fullname, users.nickname AS users_nickname FROM users WHERE users.id IN (?, ?) (1, 2)
提示
“简单”是指 relationship.primaryjoin
条件表达了“一”侧的主键和“多”侧的直接外键之间的相等比较,没有任何其他条件。
选择 IN 加载还支持多对多的关系,在目前的情况下,它会跨越所有三个表进行 JOIN,以匹配一边到另一边的行。
关于这种加载方式需要知道的事情包括:
- 此策略每次会发出一个 SELECT,最多为 500 个父主键值,因为主键被渲染为 SQL 语句中的大型 IN 表达式。一些数据库,如 Oracle,对 IN 表达式的大小有硬限制,总体上,SQL 字符串的大小不应该是任意大的。
- 由于“选择加载”依赖于 IN,对于具有复合主键的映射,它必须使用 IN 的“元组”形式,看起来像
WHERE (table.column_a, table.column_b) IN ((?, ?), (?, ?), (?, ?))
。这种语法目前不受 SQL Server 支持,对于 SQLite,需要至少 3.15 版本。SQLAlchemy 中没有特殊的逻辑来提前检查哪些平台支持此语法;如果运行在不支持的平台上,数据库将立即返回错误。SQLAlchemy 之所以仅运行 SQL 以使其失败的优点是,如果特定的数据库确实开始支持此语法,则无需对 SQLAlchemy 进行任何更改(就像 SQLite 的情况一样)。
子查询预加载
旧特性
subqueryload()
预加载器在大多数情况下已经过时,被设计更简单、更灵活,例如 Yield Per 等功能的 selectinload()
策略取代,并在大多数情况下发出更有效的 SQL 语句。由于 subqueryload()
依赖于重新解释原始的 SELECT 语句,当给出非常复杂的源查询时,它可能无法有效地工作。
对于具有复合主键的对象的预加载集合的特定情况,subqueryload()
在 Microsoft SQL Server 后端上继续没有支持“元组 IN”语法的情况下仍可能有用。
子查询加载在操作上类似于选择加载,但是发出的 SELECT 语句是从原始语句派生的,并且查询结构比选择加载更复杂。
使用relationship.lazy
中的"subquery"
参数提供子查询即时加载,或者使用subqueryload()
加载器选项。
子查询即时加载的操作是为要加载的每个关系发出第二个 SELECT 语句,在所有结果对象中一次完成加载。该 SELECT 语句引用原始 SELECT 语句,包装在一个子查询中,以便我们检索返回的主对象的相同主键列表,然后将其链接到加载所有集合成员的总和:
>>> from sqlalchemy import select >>> from sqlalchemy.orm import subqueryload >>> stmt = select(User).options(subqueryload(User.addresses)).filter_by(name="spongebob") >>> results = session.scalars(stmt).all() SELECT users.id AS users_id, users.name AS users_name, users.fullname AS users_fullname, users.nickname AS users_nickname FROM users WHERE users.name = ? ('spongebob',) SELECT addresses.id AS addresses_id, addresses.email_address AS addresses_email_address, addresses.user_id AS addresses_user_id, anon_1.users_id AS anon_1_users_id FROM ( SELECT users.id AS users_id FROM users WHERE users.name = ?) AS anon_1 JOIN addresses ON anon_1.users_id = addresses.user_id ORDER BY anon_1.users_id, addresses.id ('spongebob',)
关于这种加载方式需要了解的事项包括:
- “子查询”加载策略发出的 SELECT 语句,与“selectin”不同,需要一个子查询,并将继承原始查询中存在的任何性能限制。子查询本身也可能因使用的数据库的具体情况而产生性能损失。
- “子查询”加载会对正确工作施加一些特殊的排序要求。使用
subqueryload()
的查询,结合使用诸如Select.limit()
或Select.offset()
之类的限定修饰符,应始终包括针对唯一列(如主键)的Select.order_by()
,以便由subqueryload()
发出的附加查询包含与父查询使用的相同排序。否则,内部查询可能返回错误的行:
# incorrect, no ORDER BY stmt = select(User).options(subqueryload(User.addresses).limit(1)) # incorrect if User.name is not unique stmt = select(User).options(subqueryload(User.addresses)).order_by(User.name).limit(1) # correct stmt = ( select(User) .options(subqueryload(User.addresses)) .order_by(User.name, User.id) .limit(1) )
- 另请参见
为什么推荐使用 ORDER BY 与 LIMIT(特别是与 subqueryload() 一起)? - 详细示例 - 当在许多层次深的即时加载中使用“子查询”加载时,还会产生额外的性能/复杂性问题,因为子查询将被重复嵌套。
- “子查询”加载与 Yield Per 提供的“批量”加载不兼容,无论是集合还是标量关系。
由于上述原因,“选择”策略应优先于“子查询”。
另请参见
选择 IN 加载
使用什么类型的加载?
使用哪种类型的加载通常归结为优化 SQL 执行次数、生成的 SQL 复杂度和获取的数据量之间的权衡。
一对多/多对多集合 - 通常最好使用selectinload()
加载策略。它发出一个额外的 SELECT,尽可能少地使用表,不影响原始语句,并且对于任何类型的起始查询都是最灵活的。它唯一的主要限制是在使用不支持“tuple IN”的后端上使用具有复合主键的表,目前包括 SQL Server 和非常旧的 SQLite 版本;所有其他包含的后端都支持它。
多对一 - joinedload()
策略是最通用的策略。在特殊情况下,如果存在非常少量的潜在相关值,则immediateload()
策略也可能有用,因为如果相关对象已经存在,则此策略将从本地Session
获取对象而不发出任何 SQL。
多态急加载
支持在每个急加载基础上指定多态选项。请参见 Eager Loading of Polymorphic Subtypes 部分,了解PropComparator.of_type()
方法与with_polymorphic()
函数的结合示例。
SqlAlchemy 2.0 中文文档(十九)(5)https://developer.aliyun.com/article/1562936