You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
DTO projection — projectAs(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.
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> */publicfunction 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):
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.
Finders may no longer reshape under find() (the 5.x deprecation becomes a hard error). find() is now honestly SelectQuery<TEntity>.
Carve out built-in reshapers (find('list') -> typed toList() entry, kept as a thin BC alias or removed).
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
map() naming — map() vs transform() vs project() (note projectAs() already exists for DTOs)?
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.
Carve out find('list') into a typed entry, or keep it plus a small finder-name type map?
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.
Summary
Follow-up discussion to #19441 (which split hydration into a type-honest
unhydratedFind()/UnhydratedSelectQuery).Today
Table::find('x')->first()is statically typedEntityInterface|null, but the runtime result can be an array, a DTO, or any other shape when the query usesprojectAs(),formatResults(..., OVERWRITE), ordisableHydration(). The type is then a lie, and it is invisible at the call site —$x = $table->find('auth')->...->first()gives no hint that$xis actually an array or a DTO.I see also hacks like this then in the code:
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:
unhydratedFind()→ arrays).projectAs(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 typedEntityInterface|nullwhile returning aFooDtoat runtime. This taint is live in 5.x today.formatResults(OVERWRITE)reshaping — the formatter closure can return any shape. The generic system cannot track this, and cruciallyfind(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 aclass-string, so PHPStan can infer the target type directly — no closure inference needed. This is a pure docblock change on already-shipped code:This mirrors how
disableHydration()already rebinds the generic via its return annotation (static<array<string,mixed>>) —projectAsis simply inconsistent today by returning a bare$this.B. Add
map()for arbitrary closure reshapingFor reshapes that have no
class-stringto infer from (array projections, computed shapes):Reshape becomes visible at the call site and the type follows it:
Prototype results (validated with PHPStan
dumpType, level 8)Pure docblock generics — no PHPStan extension required:
find('all')->first()EntityInterface|nullfind('all')->projectAs(FooDto::class)SelectQuery<FooDto>find('all')->projectAs(FooDto::class)->first()FooDto|nullfind('all')->projectAs(FooDto::class)->firstOrFail()FooDtofind('all')->map(rows -> ['id'=>1])SelectQuery<array{id: int}>...->first()array{id: int}|nullfind('all')->map(rows -> [1,2,3])->first()intnot in the bound)find('all')->map(rows -> $r)->first()(identity)EntityInterface|nullThe call-site inference above is the solid, verified result. Two implementation notes from the spike, stated honestly:
map()subtlety is the same-instance generic rebind (it mutates$thisbut returns a different generic); a small helper typed at the class bound resolves it cleanly, the same var-tag narrowing idiomTable::find()already uses.object(needed forprojectAs, see below) changes the message of a pre-existing baselined entry atSelectQuery::find()— astatic-covariance friction wherecallFinder()'s return can't be provenstatic. 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
TSubjectto\Cake\Datasource\EntityInterface|array. DTOs are plainobject, outside that bound. SinceprojectAs()already ships in 5.3, the result set legitimately holds out-of-bound values today. To typeprojectAshonestly the bound has to widen to includeobject: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 plainSelectQuery<TEntity>type. They need either a dedicated typed entry point (e.g. atoList()parallel tounhydratedFind()) 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)
projectAs()to rebind its return generic viaclass-string<T>(returnstatic<T>) — docblock-only change to already-shipped code, makes the live DTO taint honest. Requires widening the class bound to includeobject, which in turn de-baselines the pre-existingSelectQuery::find()covariance entry (must be re-handled).SelectQuery::map()for arbitrary closure reshaping.formatResults(..., OVERWRITE)(docblock only, no runtime trigger), pointing atmap(). Decoration use (APPEND/PREPEND) stays supported.first()/firstOrFail()), so existing lies surface in users' test suites before 6.0.6.x (breaking cleanup)
formatResults(OVERWRITE); reshape only viamap().disableHydration()(already slated by Add Table::findUnhydrated() and UnhydratedSelectQuery for type-safe non-hydrated reads #19441).find()(the 5.x deprecation becomes a hard error).find()is now honestlySelectQuery<TEntity>.find('list')-> typedtoList()entry, kept as a thin BC alias or removed).End state
find()always entities,unhydratedFind()arrays,projectAs()DTOs,map()explicit visible reshape,toList()typed built-in projection. NodisableHydration(), noformatResults(OVERWRITE), no dispatch-erasure lies — and no PHPStan extension needed.Open questions
map()naming —map()vstransform()vsproject()(noteprojectAs()already exists for DTOs)?EntityInterface|array|object, or just collapse toobjectsince entities and arrays are both already objects/arrays?objectwould not coverarray— so the three-way union is likely the minimal honest bound.find('list')into a typed entry, or keep it plus a small finder-name type map?