#12555·orm

Improve partial lazy load efficiency by selecting only needed columns

Author: beberleiCreated Aug 7, 2026Updated Aug 20, 2026
LabelsImprovement

For now, lazy loading a partial proxy from #12210 fetches all columns. We couzld optimize this to load only needed columns.

This change touches a lot of code lightly, which makes it complicated to get right.

A first analysis of Claude Code Opus 5.
txt
Where the inefficiency lives

  The partial-lazy-object machinery from #12210 has two halves:

  1. Load time (UnitOfWork::createEntity(), src/UnitOfWork.php:2516-2562): when a partial DQL query hydrates a new entity, it builds a native lazy ghost via
  ProxyFactory::getProxy($class->name, $id, false), writes only the selected scalar fields onto it as raw values, and records which fields were loaded in
  partialObjectLoadedFields[$oid] (markAsPartiallyLoaded(), line 3141).
  2. Trigger time (ProxyFactory::getProxy(), src/Proxy/ProxyFactory.php:259-272): the ghost initializer closure is generic — it doesn't know this is a partial object. It
  always calls $entityPersister->loadById($identifier, $object), which runs BasicEntityPersister::getSelectColumnsSQL() (src/Persisters/Entity/BasicEntityPersister.php:1261) —
  this always selects every mapped column. UnitOfWork::createEntity() then correctly merges that full row without clobbering already-loaded/modified fields (the $existingData
  logic added in the same PR), but the SQL round-trip itself already paid for every column.

  So the merge-safety half exists; only the "ask the DB for less" half is missing. partialObjectLoadedFields is exactly the source of truth needed to compute what's still
  missing — it's just not consulted before the SELECT is built.

  Design

  Thread a "which fields to select" parameter from the point where we know the loaded-field set (inside createEntity(), while building the ghost) down to SQL generation,
  defaulting to "all fields" everywhere else so behavior for plain reference proxies, find(), and refresh() is unchanged.

  UnitOfWork::createEntity()               (knows loaded fields from $data)
    → computes missingFields = allScalarFields - loadedFields
    → ProxyFactory::getProxy(..., onlyProperties: missingFields ∪ identifier)
        → ghost initializer closure captures onlyProperties
          → EntityPersister::loadById($id, $entity, onlyProperties)
            → BasicEntityPersister::load(..., onlyProperties)
              → getSelectSQL(..., onlyProperties)
                → getSelectColumnsSQL(onlyProperties)   [NEW: restricted column list + fresh RSM]

  Step-by-step changes

  1. UnitOfWork::createEntity() (src/UnitOfWork.php:2518-2519)
  At the point getProxy() is called, $data (the row from the partial query) is already in scope. Compute the loaded field names right there instead of waiting for
  markAsPartiallyLoaded():
  $loadedFields = array_keys(array_intersect_key($data, $class->fieldMappings));
  $entity = $this->em->getProxyFactory()->getProxy($class->name, $id, false, $loadedFields);
  markAsPartiallyLoaded($entity, $loadedFields) (called a few lines later) stays as-is — it's the record UOW uses for changeset diffing, independent of what SQL gets
  generated.

  2. ProxyFactory::getProxy() (src/Proxy/ProxyFactory.php:252)
  Add array|null $loadedFields = null. When non-null, compute the complement once (at proxy-creation time, captured by the closure) and pass it to the persister:
  $onlyProperties = $loadedFields !== null
      ? array_diff(array_keys($classMetadata->fieldMappings), $loadedFields)
      : null;
  Important invariant: identifier fields must always be included in $onlyProperties, even though they're already "loaded" — UnitOfWork::createEntity() unconditionally does
  $this->identifierFlattener->flattenIdentifier($class, $data) on the returned row (src/UnitOfWork.php:2435), so if the follow-up SELECT omits id columns, the re-entrant
  createEntity() call breaks before it even reaches the merge logic. So really: $onlyProperties = array_unique(array_merge($missingFields, $classMetadata->identifier)).

  The closure becomes:
  $original = $entityPersister->loadById($identifier, $object, $onlyProperties);
  When $loadedFields is null (ordinary reference proxies via registerManagedProxy), $onlyProperties stays null → behavior is byte-for-byte what it is today.

  3. EntityPersister interface + BasicEntityPersister (src/Persisters/Entity/EntityPersister.php:166,186, BasicEntityPersister.php:727,756)
  Add array|null $onlyProperties = null to load() and loadById(), threaded into getSelectSQL() → getSelectColumnsSQL($onlyProperties).

  4. BasicEntityPersister::getSelectColumnsSQL() (line 1261) — the core of the work.
  Two behavioral branches:
  UnitOfWork::createEntity()               (knows loaded fields from $data)
    → computes missingFields = allScalarFields - loadedFields
    → ProxyFactory::getProxy(..., onlyProperties: missingFields ∪ identifier)
        → ghost initializer closure captures onlyProperties
          → EntityPersister::loadById($id, $entity, onlyProperties)
            → BasicEntityPersister::load(..., onlyProperties)
              → getSelectSQL(..., onlyProperties)
                → getSelectColumnsSQL(onlyProperties)   [NEW: restricted column list + fresh RSM]

  Step-by-step changes

  1. UnitOfWork::createEntity() (src/UnitOfWork.php:2518-2519)
  At the point getProxy() is called, $data (the row from the partial query) is already in scope. Compute the loaded field names right there instead of waiting for
  markAsPartiallyLoaded():
  $loadedFields = array_keys(array_intersect_key($data, $class->fieldMappings));
  $entity = $this->em->getProxyFactory()->getProxy($class->name, $id, false, $loadedFields);
  markAsPartiallyLoaded($entity, $loadedFields) (called a few lines later) stays as-is — it's the record UOW uses for changeset diffing, independent of what SQL gets
  generated.

  2. ProxyFactory::getProxy() (src/Proxy/ProxyFactory.php:252)
  Add array|null $loadedFields = null. When non-null, compute the complement once (at proxy-creation time, captured by the closure) and pass it to the persister:
  $onlyProperties = $loadedFields !== null
      ? array_diff(array_keys($classMetadata->fieldMappings), $loadedFields)
      : null;
  Important invariant: identifier fields must always be included in $onlyProperties, even though they're already "loaded" — UnitOfWork::createEntity() unconditionally does
  $this->identifierFlattener->flattenIdentifier($class, $data) on the returned row (src/UnitOfWork.php:2435), so if the follow-up SELECT omits id columns, the re-entrant
  createEntity() call breaks before it even reaches the merge logic. So really: $onlyProperties = array_unique(array_merge($missingFields, $classMetadata->identifier)).

  The closure becomes:
  $original = $entityPersister->loadById($identifier, $object, $onlyProperties);
  When $loadedFields is null (ordinary reference proxies via registerManagedProxy), $onlyProperties stays null → behavior is byte-for-byte what it is today.

  3. EntityPersister interface + BasicEntityPersister (src/Persisters/Entity/EntityPersister.php:166,186, BasicEntityPersister.php:727,756)
  Add array|null $onlyProperties = null to load() and loadById(), threaded into getSelectSQL() → getSelectColumnsSQL($onlyProperties).

  4. BasicEntityPersister::getSelectColumnsSQL() (line 1261) — the core of the work.
  Two behavioral branches:
  - $onlyProperties === null: unchanged, keeps using the cached $currentPersisterContext->selectColumnListSql and the persister's shared $currentPersisterContext->rsm.
  - $onlyProperties !== null: must not touch the shared cache or the shared RSM (that RSM is reused by every full load through this persister — mutating it for a one-off
  restricted query would corrupt subsequent full loads). Build a fresh, local ResultSetMapping, populate it with addEntityResult() + field entries for only the requested field
  names, skip caching (selectColumnListSql cache stays untouched), and return [sql, localRsm] (or thread the local RSM back out via a second return / out-param —
  getSelectSQL() needs to hand that RSM to the hydrator instead of $this->currentPersisterContext->rsm).

  This is the trickiest refactor: today getSelectColumnSQL() and the association/eager-join branches inside getSelectColumnsSQL() write straight into
  $this->currentPersisterContext->rsm. For v1 scope, the restricted path only needs scalar class->fieldNames (partial DQL never selects associations —
  partialObjectLoadedFields itself is scoped to $class->fieldMappings, see src/UnitOfWork.php:2559-2561), so the restricted branch can skip the eager-association-join logic
  entirely and just emit plain columns + a minimal RSM — much simpler than the general method.

  5. Caching decision: don't cache restricted column lists in v1. This code path fires at most once per partial-then-touched entity (after which partialObjectLoadedFields is
  unset and the entity is fully loaded), so there's no repeated-query benefit to caching, and caching by field-set risks unbounded growth per persister. Revisit only if
  profiling shows it matters.

  6. Inheritance persisters (SingleTablePersister, JoinedSubclassPersister) override getSelectColumnsSQL() with no params today (SingleTablePersister.php:35,
  JoinedSubclassPersister.php:365). Give the base signature a default of null and have these overrides accept-but-currently-ignore it (fall back to full column list) — safe,
  backward-compatible, and scoped out of phase 1. Partial objects combined with class table inheritance is already the rarer/messier corner (discriminator resolution, joined
  tables); optimizing it is a natural phase 2.

  7. Embeddable ghosts (ProxyFactory::getEmbeddableProxy(), line 208) have the same "always full load" shape ($entityPersister->loadById($entityIdentifier, $parentEntity),
  line 236) but load the parent entity, not columns of the embeddable itself — same fix applies transitively once loadById is optimized, but only if the parent's own
  missing-field set is threaded through here too. Worth a follow-up test but no separate mechanism needed.

  Edge cases to cover in tests

  - Missing-field SELECT includes identifier columns even though "loaded" (regression test: accessing an unloaded field on a composite-PK partial entity still resolves the
  entity in the identity map correctly).
  - Optimistic-locking @Version field: if selected initially, must not be re-selected; if not selected initially (misuse), must appear in the follow-up SELECT.
  - User modifies a partial field, then triggers lazy init on another field — existing PartialObjectsTest::testPartialObjectLazyInitDoesNotOverwriteChangedProperty must keep
  passing (merge logic in createEntity() is untouched by this change).
  - Query-count/SQL-content assertions: extend PartialObjectsTest using getQueryLog()/assertSQLEquals-style helpers already used elsewhere in the suite to assert the
  lazy-triggered SELECT's column list omits already-loaded columns and includes only the missing ones + identifier.
  - SimpleObjectHydrator path (isPartial hint set at src/Internal/Hydration/SimpleObjectHydrator.php:42) — confirm native-query / result-set-mapping-based partial loads aren't
  affected differently than DQL partial loads.
  - STI/JOINED entities with partial objects: assert current (full-load) behavior is preserved, not broken, since phase 1 doesn't touch those persisters.

  Suggested phasing

  - Phase 1: BasicEntityPersister (unmapped-inheritance / STI is phase 2 as noted), scalar fields only, no column-list caching for restricted loads.
  - Phase 2: SingleTablePersister/JoinedSubclassPersister/UnionSubclassPersister, and revisit caching if it shows up in profiling.