Skip to content

RFC: Make find() always return entities — explicit type-tracked reshaping via map() #19482

Description

@dereuromark

Summary

Follow-up discussion to #19441 (which split hydration into a type-honest unhydratedFind() / UnhydratedSelectQuery).

Today Table::find('x')->first() is statically typed EntityInterface|null, but the runtime result can be an array, a DTO, or any other shape when the query uses projectAs(), formatResults(..., OVERWRITE), or disableHydration(). The type is then a lie, and it is invisible at the call site — $x = $table->find('auth')->...->first() gives no hint that $x is actually an array or a DTO.

I see also hacks like this then in the code:

/** @var array|null $user */ // phpcs:ignore SlevomatCodingStandard.Commenting.InlineDocCommentDeclaration.MissingVariable
// @phpstan-ignore-next-line varTag.type
$user = $this->fetchTable('Users')->find('auth')
        ->where(['username' => $request->getData('username')])
        ->first();

With the inline annotation we "hack" phpstan in some way, and we dont know what the real values returned are until we actually really test it.

This RFC proposes a path to make find() always honestly return entities, and to make any shape change explicit and type-tracked at the call site.

Root cause

Three distinct axes corrupt the result type:

  1. Hydration on/off — already addressed by Add Table::findUnhydrated() and UnhydratedSelectQuery for type-safe non-hydrated reads #19441 (unhydratedFind() → arrays).
  2. DTO projectionprojectAs(class-string $dtoClass) shipped in Add DTO projection support via projectAs() query method #19135, already in 5.3. It hydrates rows into DTO objects but is annotated as returning $this, so the generic never rebinds. find()->projectAs(FooDto::class)->first() is typed EntityInterface|null while returning a FooDto at runtime. This taint is live in 5.x today.
  3. formatResults(OVERWRITE) reshaping — the formatter closure can return any shape. The generic system cannot track this, and crucially find(string $type): SelectQuery<TEntity> is fixed at the signature, so whatever a finder does internally is erased from the caller's static type (dispatch erasure).

Proposal

Two complementary, type-tracked rebinds, plus a finder reshape policy.

A. Fix projectAs() to rebind the generic (5.next quick win)

projectAs() takes a class-string, so PHPStan can infer the target type directly — no closure inference needed. This is a pure docblock change on already-shipped code:

/**
 * @template T of object
 * @param class-string<T> $dtoClass The DTO class name
 * @return static<T>
 */
public function projectAs(string $dtoClass)

This mirrors how disableHydration() already rebinds the generic via its return annotation (static<array<string,mixed>>) — projectAs is simply inconsistent today by returning a bare $this.

B. Add map() for arbitrary closure reshaping

For reshapes that have no class-string to infer from (array projections, computed shapes):

/**
 * @template TNew of \Cake\Datasource\EntityInterface|array
 * @param \Closure(\Cake\Datasource\ResultSetInterface<array-key, TSubject>, \Cake\ORM\Query\SelectQuery<TSubject>): iterable<TNew> $mapper
 * @return \Cake\ORM\Query\SelectQuery<TNew>
 */
public function map(Closure $mapper): SelectQuery;

Reshape becomes visible at the call site and the type follows it:

$users = $table->find('auth')->first();             // EntityInterface|null  (honest)
$dto   = $table->find('all')->projectAs(FooDto::class)->first();  // FooDto|null
$rows  = $table->find('all')->map(/* ... */)->first();           // array{...}|null

Prototype results (validated with PHPStan dumpType, level 8)

Pure docblock generics — no PHPStan extension required:

Call-site expression Inferred type
find('all')->first() EntityInterface|null
find('all')->projectAs(FooDto::class) SelectQuery<FooDto>
find('all')->projectAs(FooDto::class)->first() FooDto|null
find('all')->projectAs(FooDto::class)->firstOrFail() FooDto
find('all')->map(rows -> ['id'=>1]) SelectQuery<array{id: int}>
...->first() array{id: int}|null
find('all')->map(rows -> [1,2,3])->first() rejected (int not in the bound)
find('all')->map(rows -> $r)->first() (identity) EntityInterface|null

The call-site inference above is the solid, verified result. Two implementation notes from the spike, stated honestly:

  • The map() subtlety is the same-instance generic rebind (it mutates $this but returns a different generic); a small helper typed at the class bound resolves it cleanly, the same var-tag narrowing idiom Table::find() already uses.
  • Widening the class bound to include object (needed for projectAs, see below) changes the message of a pre-existing baselined entry at SelectQuery::find() — a static-covariance friction where callFinder()'s return can't be proven static. Changing the bound de-baselines it, so it resurfaces and needs handling (re-baseline or a narrowing annotation) as part of the implementation. This is not a blocker for the proposal, but it is real work, not "free".

The class bound is already violated — decision needed now, not later

The class template constrains TSubject to \Cake\Datasource\EntityInterface|array. DTOs are plain object, outside that bound. Since projectAs() already ships in 5.3, the result set legitimately holds out-of-bound values today. To type projectAs honestly the bound has to widen to include object:

@template-covariant TSubject of \Cake\Datasource\EntityInterface|array|object

This is no longer a hypothetical 6.x "if DTO happens" question — it is required to make the already-shipped DTO path type-honest. Caveat from the spike: widening the bound de-baselines the pre-existing SelectQuery::find() covariance entry noted above, so the implementation has to address that entry too.

Other constraints

Built-in reshaping finders (find('list'), find('threaded'), find('combolist')) still lie under a plain SelectQuery<TEntity> type. They need either a dedicated typed entry point (e.g. a toList() parallel to unhydratedFind()) or a small static finder-name type map. A general per-finder return-type PHPStan extension is not needed if finders are barred from reshaping.

Suggested sequencing

5.next (additive, BC-safe)

  • Fix projectAs() to rebind its return generic via class-string<T> (return static<T>) — docblock-only change to already-shipped code, makes the live DTO taint honest. Requires widening the class bound to include object, which in turn de-baselines the pre-existing SelectQuery::find() covariance entry (must be re-handled).
  • Add SelectQuery::map() for arbitrary closure reshaping.
  • Soft-deprecate shape-changing formatResults(..., OVERWRITE) (docblock only, no runtime trigger), pointing at map(). Decoration use (APPEND / PREPEND) stays supported.
  • Runtime deprecation when a hydrated finder yields a non-entity (cheap single-row check in first() / firstOrFail()), so existing lies surface in users' test suites before 6.0.
  • Leave built-in reshaping finders behavior untouched; document the planned 6.x carve-out.

6.x (breaking cleanup)

End state

find() always entities, unhydratedFind() arrays, projectAs() DTOs, map() explicit visible reshape, toList() typed built-in projection. No disableHydration(), no formatResults(OVERWRITE), no dispatch-erasure lies — and no PHPStan extension needed.

Open questions

  1. map() naming — map() vs transform() vs project() (note projectAs() already exists for DTOs)?
  2. Bound widening: EntityInterface|array|object, or just collapse to object since entities and arrays are both already objects/arrays? object would not cover array — so the three-way union is likely the minimal honest bound.
  3. Carve out find('list') into a typed entry, or keep it plus a small finder-name type map?
  4. How early in the 5.x line can the reshape deprecation land? It needs a full minor-version runway before 6.0 turns it into a hard error.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions