Doctrine ORM Partial Hydration 详解:数组水合下按需加载实体部分字段
【免费下载链接】ormDoctrine Object Relational Mapper (ORM)项目地址: https://gitcode.com/gh_mirrors/or/orm
Partial Hydration(部分水合)是 Doctrine ORM 提供的一种查询优化手段:在使用数组水合(array hydration)时,只从数据库加载实体字段的一个子集,同时保留基于实体关联关系构建的嵌套结果结构。本文以 docs/en/reference/partial-hydration.rst 为骨架,结合 Doctrine ORM 3.x 源码,完整讲解PARTIAL关键字的 DQL 语法、解析与校验规则、SQL 生成原理、可用性边界(为何仅限数组水合)以及版本演进历史,帮助你在大批量导出、列表渲染等场景中安全地使用这一优化特性。
Partial Hydration 是什么
Partial Hydration 指的是:查询结果以数组(而非实体对象)的形式返回,但只加载实体的一部分字段。与完全加载所有字段相比,它减少了从数据库读取的列数、网络传输的数据量和水合阶段的内存占用。
其核心特性是:
- 只加载字段子集:DQL 中用
PARTIAL 别名.{字段列表}声明需要加载的字段; - 嵌套结构不变:即使字段是部分加载,通过 JOIN 关联产生的嵌套数组结构(如
user.addresses)依然按照实体关系组织; - 仅限数组水合:该特性只在
Query#getArrayResult()对应的 array hydrator 中允许使用。
原文档给出的经典示例:
<?php $users = $em->createQuery( "SELECT PARTIAL u.{id,name}, partial a.{id,street} FROM MyApp\Domain\User u JOIN u.addresses a" )->getArrayResult();这个查询只从users表取出id、name两列,从addresses表取出id、street两列,但结果仍然是[['id' => …, 'name' => …, 'addresses' => [['id' => …, 'street' => …]]]]这样基于User → Address关联关系组织的嵌套数组。
为什么需要 Partial Hydration
原文档明确指出,这是一项性能优化(useful optimization),适用于不需要实体全部字段的场景,例如:
- 数据导出:将大量记录导出为 CSV、JSON 或 Excel,通常只需要少量关键列;
- 批量渲染:列表页、报表页只需要展示几个字段,却要为每条记录加载
TEXT/BLOB等大字段; - 内存敏感场景:结果集很大时,少加载一个包含大文本的列,就能显著降低峰值内存占用。
注意区分:Partial Hydration 针对的是数组结果;如果查询结果要水合成实体对象,Doctrine 默认是禁止部分加载的(详见下文"为什么仅限数组水合"),因为部分对象会破坏实体不变式(broken invariants),这正是 docs/en/reference/partial-objects.rst 中详细讨论的问题。
PARTIAL 的 DQL 语法
语法规则
从 src/Query/Parser.php 的语法注释可以看到PARTIAL表达式的正式文法:
PartialObjectExpression ::= "PARTIAL" IdentificationVariable "." PartialFieldSet PartialFieldSet ::= "{" SimpleStateField {"," SimpleStateField}* "}"即:PARTIAL关键字 + 识别变量(查询别名)+ 点号 + 花括号包裹的字段列表,字段之间用逗号分隔。
<?php // 单实体部分加载 $users = $em->createQuery( "SELECT PARTIAL u.{id, name} FROM MyApp\Domain\User u" )->getArrayResult(); // 关联实体也部分加载(原文档示例) $users = $em->createQuery( "SELECT PARTIAL u.{id, name}, partial a.{id, street} FROM MyApp\Domain\User u JOIN u.addresses a" )->getArrayResult(); // 带 WHERE 条件与参数 $users = $em->createQuery( "SELECT PARTIAL u.{id, name} FROM MyApp\Domain\User u WHERE u.username = ?1" )->setParameter(1, 'alice')->getArrayResult();嵌套(embeddable)字段
从 Parser.php 的解析逻辑可以看出,字段列表中的第一个字段以及逗号之后的每个字段,都允许通过额外的点号继续解析——这意味着可嵌入对象(embeddable)的字段可以展开书写:
<?php // 假设 User 内嵌了 AddressEmbeddable(含 street、city 字段) $users = $em->createQuery( "SELECT PARTIAL u.{id, name, address.street, address.city} FROM MyApp\Domain\User u" )->getArrayResult();字段集最后会作为字符串数组保存在 PartialObjectExpression.php 这个 AST 节点的partialFieldSet属性中,供后续语义验证与 SQL 生成使用。
语义约束:哪些字段能出现在 PARTIAL 中
PARTIAL不是想写什么字段就写什么字段。Parser 在解析完整个 DQL 后,会通过processDeferredPartialObjectExpressions()(Parser.php)对每个部分表达式做延迟语义校验,规则有两条:
1. 字段必须真实存在且可加载
对partialFieldSet中的每个字段,必须满足以下条件之一,否则抛出语义错误There is no mapped field named 'X' on class Y.:
- 是实体的普通字段映射(存在于
fieldMappings); - 是to-one 关联的拥有侧(owning side)映射——即外键所在的一方(
associationMappings[field]->isToOneOwningSide())。
<?php // 错误:status 字段并不存在于 User 的映射中 "SELECT PARTIAL u.{id, nonExistent} FROM MyApp\Domain\User u"2. 部分字段集必须包含完整标识符
校验代码(Parser.php)要求:
if (array_intersect($class->identifier, $expr->partialFieldSet) !== $class->identifier) { $this->semanticalError( 'The partial field selection of class ' . $class->name . ' must contain the identifier.', ... ); }即字段列表必须包含实体的全部主键字段(复合主键则全部列出)。这是因为 Doctrine 需要主键来组装结果、维护身份映射;缺少主键的 PARTIAL 查询会在解析阶段直接被拒绝:
<?php // 错误:缺少主键 id "SELECT PARTIAL u.{name} FROM MyApp\Domain\User u"底层原理:从 DQL 到 SQL 的转换
1. 词法与语法解析
PARTIAL是 DQL 词法器(TokenType.php)识别的一个关键字。Parser 在 SELECT 子句表达式中遇到T_PARTIAL标记时,进入PartialObjectExpression()解析分支(Parser.php),将字段集包装成 AST 节点。
2. 标记部分加载并生成 SQL
SQL 生成阶段在 SqlWalker.php 完成。当 SELECT 表达式是PartialObjectExpression时,SqlWalker 会:
- 给当前查询设置内部提示
HINT_PARTIAL('doctrine.partial',定义于 SqlWalker.php),见 SqlWalker.php; - 调用
walkObjectExpression()为每个被选中实体生成列清单。
walkObjectExpression()的核心逻辑(SqlWalker.php)是遍历类的全部fieldMappings,然后做字段过滤:
foreach ($class->fieldMappings as $fieldName => $mapping) { if ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true)) { continue; // 部分加载:不在字段集中的列直接跳过 } // ... 生成 "sqlTableAlias.columnName AS columnAlias" }换句话说,未列出的字段根本不会进入 SELECT 列表——这正是 Partial Hydration 减少数据库 IO 的根本原因。对于继承映射,SqlWalker 还会依据字段所属的继承层级选择正确的表($mapping->inherited,见 SqlWalker.php)。
3. 注册到结果集映射
生成 SQL 的同时,SqlWalker 会把部分加载信息注册进ResultSetMapping:
- 顶层实体通过
markPartialEntityResult()标记(SqlWalker.php); - 被 JOIN 的实体通过
addJoinedEntityResult(..., $isPartial)的布尔参数标记(ResultSetMapping.php); - 这些标记最终落入
ResultSetMapping::$partialAliases(ResultSetMapping.php),水合器正是依据它来判断哪些别名是部分加载的。
4. 数组水合
在 array hydrator 中,部分加载的列被直接组装进嵌套数组。整个链路为:Query#getArrayResult()→ArrayHydrator→ResultSetMapping→ 按关联结构组装嵌套结果。
为什么仅限数组水合:对象水合被明确禁止
原文档强调 Partial Hydration 只允许在array hydrator中使用。如果试图用getResult()(对象水合)执行包含PARTIAL的查询,会抛出异常。
这个行为在源码中有两处明确体现:
1. 水合层的异常
src/Internal/Hydration/HydrationException.php 定义了专门异常:
public static function partialObjectHydrationDisallowed(): self { return new self('Hydration of entity objects is not allowed when DQL PARTIAL keyword is used.'); }2. 对象水合器对部分别名的识别
ObjectHydrator.php 与 SimpleObjectHydrator.php 会读取partialAliases并把isPartial提示传递给UnitOfWork::createEntity();而 UnitOfWork.php 仅在启用了 PHP 8.4 原生懒对象(native lazy objects)时,才允许通过isPartial提示创建部分加载的懒 ghost 对象,否则相关路径会受限。
为什么禁止对象水合的部分对象?原因在 partial-objects.rst 中讲得很清楚:部分对象是不变量被破坏的对象(broken invariants),调用方无法区分"关联字段本来就是 NULL"还是"关联字段尚未加载",极易引发空指针等问题。因此 Doctrine 的默认策略是:要对象,就加载完整对象;只要部分字段,就请使用数组水合。
部分加载与查询缓存、二级缓存
从 DefaultQueryCache.php 可以看到,查询缓存对携带HINT_PARTIAL或历史遗留的HINT_FORCE_PARTIAL_LOAD提示的查询做了特殊分流处理。可以推断:部分加载查询带有"结果取决于运行时字段子集"的特性,不应与常规查询共享同样的缓存策略。实际项目中如果既用了PARTIAL又期望命中二级缓存,需要仔细核对缓存键与提示的交互,避免拿到错误的缓存结果。
历史遗留的 HINT_FORCE_PARTIAL_LOAD 与版本演进
版本演进(ORM 3.x 的反复)
PARTIAL关键字的历史在 UPGRADE.md 中有完整记录,这是理解本文档背景的关键:
- ORM 3.0:作为 BC BREAK,
PARTIAL关键字、PartialObjectExpressionAST、SqlWalker::HINT_PARTIAL、Query::HINT_FORCE_PARTIAL_LOAD以及EntityManager::getPartialReference()全部被移除; - ORM 3.2:
PARTIAL关键字被重新引入,但只允许用于数组水合(即本文讨论的 partial hydration);PartialObjectExpression与SqlWalker::HINT_PARTIAL也随之一并恢复。
这也解释了为什么当前文档明确限定"Partial hydration of entities is allowed in the array hydrator"——这是 3.2 重新引入时的刻意收窄:数组水合安全,对象水合仍被禁止。
遗留的 HINT_FORCE_PARTIAL_LOAD
在更早的 ORM 2.x 时代,开发者可以通过查询提示Query::HINT_FORCE_PARTIAL_LOAD(常量定义于 src/Query.php)强制查询以部分对象形式水合实体。相关说明仍保留在 dql-doctrine-query-language.rst:
Query::HINT_FORCE_PARTIAL_LOAD— Allows to hydrate objects although not all their columns are fetched... 该提示已废弃并将在未来移除。
历史异常 QueryException::partialObjectsAreDangerous() 的文案也印证了这条演进路径:"Loading partial objects is dangerous. Fetch full objects or consider using a different fetch mode." —— 当前代码中,这条路径已基本被"仅数组水合 + 可选 PHP 8.4 原生懒对象"取代。
PHP 8.4 原生懒对象下的 Partial 对象(关联阅读)
如果要在对象形态下享受"先加载部分字段、按需补全"的收益,partial-objects.rst 给出了 PHP 8.4+ 的现代方案:
- 启用原生懒对象后,
PARTIAL查询返回的对象表现为lazy ghost:仅PARTIAL列出的字段被急切加载,其余标量字段保持未初始化; - 首次访问未加载字段时,Doctrine 自动发起
SELECT补全整个实体,此后该对象与普通查询加载的对象无异; - 在懒初始化触发之前修改已加载字段是安全的——内存中的修改值会被保留,不会被数据库值覆盖。
这一点在 UnitOfWork.php 的HINT_REFRESH_ENTITY处理与 tests/Tests/ORM/Functional/PartialObjectsTest.php 中都有源码级验证:测试用例证实,部分查询后originalEntityData只包含id、name,修改name再触发懒初始化,name的修改不被覆盖,且变更集快照仍保留数据库原始值。
注意:getPartialReference()API 在 ORM 3.0 中已被移除(UPGRADE.md),不要在新代码中使用。
性能验证与适用边界
仓库的基准测试目录提供了两个与本文主题直接相关的性能基准:
- SimpleQueryPartialObjectHydrationPerformanceBench.php:简单查询的部分对象水合性能基准;
- MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php:混合 fetch join 场景下的部分水合基准。
两者都通过Query::HINT_FORCE_PARTIAL_LOAD驱动(见 MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php),可用phpbench运行(参考仓库根目录 phpbench.json)。
何时该用 / 何时不该用
推荐场景:
- 结果集大、字段多,尤其是包含
TEXT/BLOB/长字符串列; - 只需要少量列用于导出、渲染、统计;
- 使用
getArrayResult()而非对象水合。
谨慎/避免场景:
- 需要实体对象的完整行为(请加载完整对象或使用 PHP 8.4 原生懒对象);
- 字段子集无法确定包含全部主键(语法校验直接拒绝);
- 字段集太小导致反复查询(过早优化反而增加复杂度)。
总结
Partial Hydration 是 Doctrine ORM 为"数组化、少字段、大批量"读取场景提供的精准优化工具:
- 语法上使用
PARTIAL 别名.{字段,...},字段集必须包含完整主键,且只能引用真实字段或 to-one 拥有侧关联; - 底层由 Parser 延迟语义校验、SqlWalker 过滤字段生成精简 SQL、ResultSetMapping 记录部分别名、ArrayHydrator 组装嵌套数组,链路清晰完整;
- 它被刻意限制在数组水合范围内——对象水合遇
PARTIAL会直接抛异常,这是 Doctrine 对部分对象危险性(broken invariants)的防御性设计; - 该特性经历了 ORM 3.0 移除、3.2 为数组水合重新引入的演进,理解这段历史有助于避免在升级时踩坑;
- 若坚持使用对象形态的部分加载,请转向 PHP 8.4 原生懒对象方案,并参考 PartialObjectsTest.php 理解其变更集语义。
进一步阅读:完整语法与更多 DQL 特性见 dql-doctrine-query-language.rst;部分对象问题与 PHP 8.4 懒加载详见 partial-objects.rst;升级注意事项见 UPGRADE.md。
【免费下载链接】ormDoctrine Object Relational Mapper (ORM)项目地址: https://gitcode.com/gh_mirrors/or/orm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考