Skip to content

Add Table::findUnhydrated() and SelectUnhydratedQuery for type-safe non-hydrated reads#19441

Open
dereuromark wants to merge 13 commits into
cakephp:5.nextfrom
dereuromark:array-query-split
Open

Add Table::findUnhydrated() and SelectUnhydratedQuery for type-safe non-hydrated reads#19441
dereuromark wants to merge 13 commits into
cakephp:5.nextfrom
dereuromark:array-query-split

Conversation

@dereuromark
Copy link
Copy Markdown
Member

@dereuromark dereuromark commented May 14, 2026

Alternative direction to #19440, picking up LordSimal's review there: instead of narrowing the union and patching covariance, split the hydrated and non-hydrated read paths at the source so the union never has to exist.

Summary

Adds Table::findUnhydrated() + a Cake\ORM\Query\SelectUnhydratedQuery class. Hydrated and non-hydrated reads get separate entry points with separate static types — find() keeps the typed SelectQuery<TEntity> the common path always wanted; the rare array path moves to a class whose type matches its runtime.

// Hydrated, default — type system knows you get an entity.
$user = $usersTable->find()->where(['id' => 1])->first();         // User|null

// Non-hydrated, explicit — type system knows you get an array.
$row  = $usersTable->findUnhydrated()->where(['id' => 1])->first(); // array<string, mixed>|null

Motivation

In real consumer code find()->first() is User|array|null, so anything entity-shaped trips PHPStan even though the caller never opted into disableHydration(). 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>>:

  • Constructor sets _hydrate = false. No behavioral overrides. It is fully substitutable for SelectQuery — runtime behavior is identical to find()->disableHydration(), so eager loading, association finders and the rest of the ORM treat it like any other select query.
  • Its entire value is the static type: first() / firstOrFail() / all() / toArray() / iteration resolve to arrays instead of entity|array, and crucially that binding survives finder dispatch, where a bare SelectQuery<array<string, mixed>> annotation would decay (every custom finder is typed findFoo(SelectQuery): SelectQuery with no generic; core's own find() needs a manual var-cast annotation after callFinder() for exactly this reason).

Table::findUnhydrated(string $type = 'all', ...): SelectUnhydratedQuery:

  • Builds through the QueryFactory via the new Table::selectUnhydratedQuery() / QueryFactory::selectUnhydrated() seam, so a custom injected QueryFactory is honored exactly like it is for find().
  • Dispatches through callFinder() like find() — any userland custom finder is reused as-is, zero finder duplication.
  • A finder that discards the passed query and returns a fresh one cannot preserve the contract; it fails with a clear CakeException naming the finder, instead of a cryptic return-type TypeError. This guard lives only at the public entry point, isolated from ORM internals.

SelectQuery::disableHydration() gets a docblock deprecation marker (5.next) pointing at findUnhydrated(); no runtime trigger. Targeted for removal in 6.0.

Naming

findUnhydrated() / SelectUnhydratedQuery keeps the vocabulary consistent with the existing enableHydration() / disableHydration() / isHydrationEnabled() family and anchors the class to the SelectQuery / InsertQuery / UpdateQuery / DeleteQuery query-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/findThreaded are hydration-neutral (key⇒value / nested tree regardless of hydration) — a docs note, not extra API; removing disableHydration() entirely (6.0); touching Database\Query\SelectQuery covariance (that is #19440's territory). Internal core call sites are intentionally not migrated — exists() and DatabaseSession::read() stay on find()->disableHydration() so foundational paths keep tolerating finders that override findAll() 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 calls enableHydration() on association queries, so a throw broke contain()/eager loading. Resolution: the subclass is now minimal and fully substitutable; strictness lives only at the opt-in public entry point.

Verification

  • Full matrix green: testsuite on MySQL / MariaDB / Postgres / SQLite (PHP 8.2–8.5), Windows + SQL Server.
  • Coding Standard & Static Analysis and Static Analysis for Split Packages green.
  • Local: phpunit, phpstan, phpcs clean; the contain() eager-load interop and finder-guard paths covered by regression tests.

Related

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().
@dereuromark dereuromark added this to the 5.4.0 milestone May 14, 2026
Comment thread src/ORM/Table.php Outdated
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.
@dereuromark dereuromark changed the title Add Table::findArray() and ArrayQuery for type-safe non-hydrated reads Add Table::findUnhydrated() and UnhydratedQuery for type-safe non-hydrated reads May 15, 2026
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.
Comment thread src/ORM/Query/UnhydratedQuery.php Outdated
Comment thread src/ORM/Table.php Outdated
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.
@dereuromark dereuromark marked this pull request as ready for review May 17, 2026 13:25
@dereuromark dereuromark changed the title Add Table::findUnhydrated() and UnhydratedQuery for type-safe non-hydrated reads Add Table::findUnhydrated() and SelectUnhydratedQuery for type-safe non-hydrated reads May 17, 2026
Comment thread src/ORM/Query/SelectUnhydratedQuery.php Outdated
Comment thread src/ORM/Query/SelectUnhydratedQuery.php Outdated
Comment thread src/ORM/Table.php Outdated
Comment thread src/ORM/Table.php Outdated
Comment thread tests/TestCase/ORM/Query/SelectUnhydratedQueryTest.php Outdated
Comment thread src/ORM/Query/QueryFactory.php Outdated
…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
@dereuromark
Copy link
Copy Markdown
Member Author

dereuromark commented May 17, 2026

Pushed the review fixes:

  • Bumped all @since / @deprecated markers from 5.next to the concrete 5.4.0 release (matches VERSION.txt 5.4.0-RC1) in SelectUnhydratedQuery, Table::findUnhydrated() / selectUnhydratedQuery(), QueryFactory::selectUnhydrated(), SelectQuery::disableHydration(), and the test.
  • Replaced the SelectUnhydratedQuery constructor with a protected bool $_hydrate = false; property override as suggested — dropped the now-unused Table import and fully qualified the @see reference so it still resolves.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants