news 2026/9/24 13:38:05

Doctrine ORM Partial Hydration 详解:数组水合下按需加载实体部分字段

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Doctrine ORM Partial Hydration 详解:数组水合下按需加载实体部分字段

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表取出idname两列,从addresses表取出idstreet两列,但结果仍然是[['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()ArrayHydratorResultSetMapping→ 按关联结构组装嵌套结果。

为什么仅限数组水合:对象水合被明确禁止

原文档强调 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_PARTIALQuery::HINT_FORCE_PARTIAL_LOAD以及EntityManager::getPartialReference()全部被移除;
  • ORM 3.2PARTIAL关键字被重新引入,但只允许用于数组水合(即本文讨论的 partial hydration);PartialObjectExpressionSqlWalker::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只包含idname,修改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 为"数组化、少字段、大批量"读取场景提供的精准优化工具:

  1. 语法上使用PARTIAL 别名.{字段,...},字段集必须包含完整主键,且只能引用真实字段或 to-one 拥有侧关联;
  2. 底层由 Parser 延迟语义校验、SqlWalker 过滤字段生成精简 SQL、ResultSetMapping 记录部分别名、ArrayHydrator 组装嵌套数组,链路清晰完整;
  3. 它被刻意限制在数组水合范围内——对象水合遇PARTIAL会直接抛异常,这是 Doctrine 对部分对象危险性(broken invariants)的防御性设计;
  4. 该特性经历了 ORM 3.0 移除、3.2 为数组水合重新引入的演进,理解这段历史有助于避免在升级时踩坑;
  5. 若坚持使用对象形态的部分加载,请转向 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 13:38:03

腾讯云轻量服务器升配实操指南:从资源诊断到配置校准

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/24 13:37:42

开源掌机DIY工作坊:从硬件选型到系统烧录的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/24 13:37:32

【Dv2Admin】CRUD时间范围区间周选择组件

在编程开发中,日期选择器是非常常见的组件,特别是在需要对时间进行严格管理的场景中,正确地配置起始时间和结束时间显得尤为重要。默认情况下,el-date-picker 的日期选择器以周日为一周的开始,这与某些用户的时间管理习惯可能不符,尤其在涉及国际项目时。 为了满足多样化…

作者头像 李华
网站建设 2026/9/24 13:36:05

烘焙后城市场景满是黑斑?用6步检查 Lightmap UV 与光照接缝

城市场景完成光照烘焙后&#xff0c;如果出现整面发黑、局部脏斑、模块接缝发亮&#xff0c;先不要急着提高灯光强度。更常见的原因是 Lightmap UV 重叠、UV 岛间距不足、光照贴图分辨率与对象尺寸不匹配&#xff0c;以及薄面、法线或模块边界存在问题。 本文用一个最小场景演…

作者头像 李华