Add Table::findUnhydrated() and SelectUnhydratedQuery for type-safe non-hydrated reads#19441
Open
dereuromark wants to merge 13 commits into
Open
Add Table::findUnhydrated() and SelectUnhydratedQuery for type-safe non-hydrated reads#19441dereuromark wants to merge 13 commits into
dereuromark wants to merge 13 commits into
Conversation
Splits hydrated and non-hydrated query paths at the source instead of modeling both shapes in one SelectQuery<TEntity|array> union. Common callers stay on find() and get SelectQuery<TEntity>; the rare path moves to a dedicated entry point with an honest type. - New ArrayQuery extends SelectQuery<array<string, mixed>>; locks hydration off in the constructor; throws on enableHydration(true) - New Table::findArray(string, ...mixed): ArrayQuery — runs the requested finder against an ArrayQuery and returns it - SelectQuery::disableHydration() gets a soft docblock deprecation pointing at findArray(); no runtime trigger, removed in 6.0 - Tests cover instance shape, first/firstOrFail array returns, all and iteration, enableHydration guard, and finder dispatch
Dogfoods the new entry point against real consumers. Both sites already treat the result as an array; the rewrite removes the misleading disableHydration() call and lets the type system know upfront. - DatabaseSession::read() — single-row PK fetch, reads $result['data'] - Table::_processFindOrCreate() existence check — count() of a select 1 Two of the four remaining src/ disableHydration() calls go away. The other two (TreeBehavior, EavStrategy) need follow-ups: an Association findArray() and a Table arrayQuery() parallel to selectQuery().
ADmad
reviewed
May 14, 2026
Keeps the result-shape vocabulary consistent with the existing (en|dis)ableHydration / isHydrationEnabled family and makes the relationship to the deprecated disableHydration() toggle obvious. findArray() also misleadingly implied a literal array return rather than a query object. Method-name change only; ArrayQuery class and behavior unchanged.
Aligns the class name with the findUnhydrated() entry point and the (en|dis)ableHydration vocabulary, so the method, class, and deprecated toggle all speak the same language. Class rename + file move only; behavior unchanged.
The split-package static analysis resolves cakephp/* deps from released packagist versions, so the Http package cannot see the unreleased Table::findUnhydrated() (ORM package). Internal dogfooding of new ORM API can only happen within the ORM package until release; the _processFindOrCreate() migration stays since it lives in Table itself. DatabaseSession reverts to the released disableHydration() API.
This reverts commit 1584ee8.
ADmad
reviewed
May 16, 2026
ADmad
reviewed
May 16, 2026
Anchors the class to the SelectQuery family (SelectQuery / InsertQuery / UpdateQuery / DeleteQuery) it extends — it is specifically a select query that is unhydrated. findUnhydrated() method name unchanged. Class + file move only; behavior unchanged.
Two robustness fixes for findUnhydrated(): - It now builds through QueryFactory::selectUnhydrated() via the new Table::selectUnhydratedQuery(), paralleling selectQuery(). Apps that inject a custom QueryFactory get consistent behavior between find() and findUnhydrated() instead of the latter silently bypassing the injection point. - callFinder() only guarantees a SelectQuery, not the same subclass. A finder that discards the passed query and returns a fresh one now triggers a clear CakeException naming the finder, instead of a cryptic return-type TypeError or a silently hydrated result. Adds regression tests for both: injected-factory is honored, and a fresh-query finder throws the descriptive exception.
- SelectUnhydratedQuery::find() now applies the same guard as
Table::findUnhydrated(): a chained finder that returns a fresh query
fails with a clear CakeException instead of the inherited
SelectQuery::find() throwing a cryptic return-type TypeError.
- Revert exists() and DatabaseSession::read() to
find('all')->disableHydration(). These foundational internal paths
must keep tolerating finders that override findAll() by returning a
fresh query (previously supported); the strict findUnhydrated()
contract is opt-in for new public callers only. Also keeps
findUnhydrated()/SelectUnhydratedQuery confined to the ORM package.
Adds a regression test for the chained-finder guard.
Drop the enableHydration() and find() overrides. Each throwing guard fought the ORM's Liskov assumptions: the eager loader unconditionally calls enableHydration($shouldHydrate) on association queries (SelectLoader::_buildQuery), so the throw broke contain()/eager loading for any association query starting from findUnhydrated(). The class's value is the static type (extends SelectQuery<array<string, mixed>>), not a runtime lock. Runtime behavior is now identical to find()->disableHydration(); the type binding still survives finder dispatch where a bare generic annotation would decay. The strict, opt-in guard stays at the public entry point (Table::findUnhydrated()), which is isolated from ORM internals. Replaces the enableHydration-throws tests with a contain() eager-load interop regression test.
Clarify that selectUnhydrated() is an independent construction point (like select/insert/update/delete) and that apps with a custom SelectQuery subclass should override it too for the non-hydrating path. Doc-only.
The anonymous Table subclass used in testFinderReturningFreshQueryThrows derived its alias from the anonymous class name (Table@anonymous /long/path/file.php:NN). On Postgres that exceeds the 61-char identifier-alias limit and threw an alias-length exception before the finder guard ran, so the test failed only on pgsql. Pass an explicit short alias.
ADmad
reviewed
May 17, 2026
…ructor - Bump @since/@deprecated markers from 5.next to the concrete 5.4.0 release - Replace the SelectUnhydratedQuery constructor with a protected property override (protected bool _hydrate = false), dropping the now-unused Table import and fully qualifying the @see reference
Member
Author
|
Pushed the review fixes:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
Table::findUnhydrated()+ aCake\ORM\Query\SelectUnhydratedQueryclass. Hydrated and non-hydrated reads get separate entry points with separate static types —find()keeps the typedSelectQuery<TEntity>the common path always wanted; the rare array path moves to a class whose type matches its runtime.Motivation
In real consumer code
find()->first()isUser|array|null, so anything entity-shaped trips PHPStan even though the caller never opted intodisableHydration(). This splits the two shapes at construction so the common path stays clean and the array path is honestly typed.Design — the class is a static-typing vehicle, nothing more
SelectUnhydratedQuery extends SelectQuery<array<string, mixed>>:_hydrate = false. No behavioral overrides. It is fully substitutable forSelectQuery— runtime behavior is identical tofind()->disableHydration(), so eager loading, association finders and the rest of the ORM treat it like any other select query.first()/firstOrFail()/all()/toArray()/ iteration resolve to arrays instead ofentity|array, and crucially that binding survives finder dispatch, where a bareSelectQuery<array<string, mixed>>annotation would decay (every custom finder is typedfindFoo(SelectQuery): SelectQuerywith no generic; core's ownfind()needs a manual var-cast annotation aftercallFinder()for exactly this reason).Table::findUnhydrated(string $type = 'all', ...): SelectUnhydratedQuery:Table::selectUnhydratedQuery()/QueryFactory::selectUnhydrated()seam, so a custom injectedQueryFactoryis honored exactly like it is forfind().callFinder()likefind()— any userland custom finder is reused as-is, zero finder duplication.CakeExceptionnaming the finder, instead of a cryptic return-typeTypeError. This guard lives only at the public entry point, isolated from ORM internals.SelectQuery::disableHydration()gets a docblock deprecation marker (5.next) pointing atfindUnhydrated(); no runtime trigger. Targeted for removal in 6.0.Naming
findUnhydrated()/SelectUnhydratedQuerykeeps the vocabulary consistent with the existingenableHydration()/disableHydration()/isHydrationEnabled()family and anchors the class to theSelectQuery/InsertQuery/UpdateQuery/DeleteQueryquery-type family. (Settled with ADmad in review.)Scope
In: the entry point, the class, the factory seam, the soft deprecation, tests.
Out (deliberately, for follow-up if the shape lands):
findList/findThreadedare hydration-neutral (key⇒value / nested tree regardless of hydration) — a docs note, not extra API; removingdisableHydration()entirely (6.0); touchingDatabase\Query\SelectQuerycovariance (that is #19440's territory). Internal core call sites are intentionally not migrated —exists()andDatabaseSession::read()stay onfind()->disableHydration()so foundational paths keep tolerating finders that overridefindAll()by returning a fresh query.Review hardening
Went through several review passes (ADmad + an external pass). Net result: every attempt to make the subclass behaviorally stricter than
disableHydration()(throwing on re-hydration, on fresh-query finders) broke ORM substitutability — most sharply,SelectLoader::_buildQuery()unconditionally callsenableHydration()on association queries, so a throw brokecontain()/eager loading. Resolution: the subclass is now minimal and fully substitutable; strictness lives only at the opt-in public entry point.Verification
Coding Standard & Static AnalysisandStatic Analysis for Split Packagesgreen.contain()eager-load interop and finder-guard paths covered by regression tests.Related
Table::find()+ TSubject covariance (the alternative direction this one makes optional)