Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/ORM/Query/QueryFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ public function select(Table $table): SelectQuery
return new SelectQuery($table);
}

/**
* Create a new non-hydrating SelectUnhydratedQuery instance.
*
* This is an independent construction seam, like select()/insert()/etc.
* Applications that override select() to return a custom SelectQuery
* subclass and want the same custom behavior on the non-hydrating path
* should override this method too (returning their own
* SelectUnhydratedQuery subclass).
*
* @param \Cake\ORM\Table $table The table this query is starting on.
* @return \Cake\ORM\Query\SelectUnhydratedQuery
* @since 5.4.0
*/
public function selectUnhydrated(Table $table): SelectUnhydratedQuery
{
return new SelectUnhydratedQuery($table);
}

/**
* Create a new InsertQuery instance.
*
Expand Down
4 changes: 4 additions & 0 deletions src/ORM/Query/SelectQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -1519,6 +1519,10 @@ public function enableHydration(bool $enable = true)
* Disabling hydration will cause array results to be returned for the query
* instead of entities.
*
* @deprecated 5.4.0 Use {@see \Cake\ORM\Table::findUnhydrated()} for
* type-safe non-hydrated reads. The fluent toggle returns a `static`
* that lies about its result shape; `findUnhydrated()` returns an
* `SelectUnhydratedQuery` whose type matches the runtime. Removed in 6.0.
* @return static<array<string,mixed>>
* @phpcsSuppress SlevomatCodingStandard.TypeHints.ReturnTypeHint.MissingNativeTypeHint
*/
Expand Down
43 changes: 43 additions & 0 deletions src/ORM/Query/SelectUnhydratedQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);

/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 5.4.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace Cake\ORM\Query;

/**
* Non-hydrating SelectQuery variant. Always returns arrays.
*
* Behaves exactly like `SelectQuery->disableHydration()` at runtime — it is
* fully substitutable for {@see SelectQuery} (eager loading, association
* finders and the rest of the ORM may treat it like any other select query).
* Its sole purpose is the static type: because it extends
* `SelectQuery<array<string, mixed>>`, `first()` / `firstOrFail()` / `all()` /
* `toArray()` / iteration resolve to arrays instead of `entity|array`, and
* that binding survives finder dispatch where a bare generic annotation would
* decay.
*
* Use {@see \Cake\ORM\Table::findUnhydrated()} as the entry point. This class is the
* type-safe replacement for `SelectQuery->disableHydration()`, which becomes
* a hard error in 6.0.
*
* @extends \Cake\ORM\Query\SelectQuery<array<string, mixed>>
*/
class SelectUnhydratedQuery extends SelectQuery
{
/**
* @var bool
*/
protected bool $_hydrate = false;
}
56 changes: 56 additions & 0 deletions src/ORM/Table.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
use Cake\ORM\Query\InsertQuery;
use Cake\ORM\Query\QueryFactory;
use Cake\ORM\Query\SelectQuery;
use Cake\ORM\Query\SelectUnhydratedQuery;
use Cake\ORM\Query\UpdateQuery;
use Cake\ORM\Rule\IsUnique;
use Cake\Utility\Inflector;
Expand Down Expand Up @@ -1368,6 +1369,50 @@ public function find(string $type = 'all', mixed ...$args): SelectQuery
return $query;
}

/**
* Type-safe non-hydrated read. Equivalent in behavior to
* `find($type, ...)->disableHydration()` but the type system knows the
* results are arrays rather than entities.
*
* Construction methods (where/join/order/contain/finders) behave the same
* as on a regular {@see SelectQuery}; only the result-fetch methods
* (first/firstOrFail/all/toArray/iteration) differ in shape.
*
* ```
* $rows = $articlesTable->findUnhydrated()->where(['published' => true])->all();
* // $rows: iterable<array<string, mixed>>
* ```
*
* Only finders that mutate and return the query they were given are
* supported here (the overwhelming majority). A finder that discards the
* passed query and returns a freshly built one (e.g. by delegating to
* `find()`) cannot preserve the non-hydrating contract and triggers an
* exception rather than a silent hydrated result.
*
* @param string $type The type of finder to call.
* @param mixed ...$args Arguments matching the finder's parameters.
* @return \Cake\ORM\Query\SelectUnhydratedQuery
* @throws \Cake\Core\Exception\CakeException When the finder does not return the passed query.
* @since 5.4.0
*/
public function findUnhydrated(string $type = 'all', mixed ...$args): SelectUnhydratedQuery
{
$query = $this->selectUnhydratedQuery();
$result = $this->callFinder($type, $query, ...$args);

if (!$result instanceof SelectUnhydratedQuery) {
throw new CakeException(sprintf(
'The `%s` finder must return the query it was given when called via findUnhydrated(); '
. 'got `%s` instead. Finders that build a fresh query cannot preserve the '
. 'non-hydrating contract — use find() for those.',
$type,
get_debug_type($result),
));
}

return $result;
}

/**
* Returns the query as passed.
*
Expand Down Expand Up @@ -1844,6 +1889,17 @@ public function selectQuery(): SelectQuery
return $query;
}

/**
* Creates a new non-hydrating select query.
*
* @return \Cake\ORM\Query\SelectUnhydratedQuery
* @since 5.4.0
*/
public function selectUnhydratedQuery(): SelectUnhydratedQuery
{
return $this->queryFactory->selectUnhydrated($this);
}

/**
* Creates a new insert query
*
Expand Down
220 changes: 220 additions & 0 deletions tests/TestCase/ORM/Query/SelectUnhydratedQueryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);

/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 5.4.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace Cake\Test\TestCase\ORM\Query;

use Cake\Core\Exception\CakeException;
use Cake\Datasource\ConnectionManager;
use Cake\Datasource\Exception\RecordNotFoundException;
use Cake\ORM\Query\QueryFactory;
use Cake\ORM\Query\SelectQuery;
use Cake\ORM\Query\SelectUnhydratedQuery;
use Cake\ORM\Table;
use Cake\TestSuite\TestCase;

/**
* Tests the type-safe non-hydrated query path: Table::findUnhydrated() and
* the SelectUnhydratedQuery class it returns.
*/
class SelectUnhydratedQueryTest extends TestCase
{
/**
* @var array<string>
*/
protected array $fixtures = [
'core.Articles',
'core.Authors',
];

/**
* @var \Cake\ORM\Table
*/
protected Table $articles;

protected function setUp(): void
{
parent::setUp();
$this->articles = $this->getTableLocator()->get('Articles');
}

/**
* findUnhydrated() is the type-safe entry point for non-hydrated reads.
* It returns an SelectUnhydratedQuery (not a plain SelectQuery), so consumers know
* up-front that results will be arrays.
*/
public function testFindUnhydratedReturnsSelectUnhydratedQuery(): void
{
$query = $this->articles->findUnhydrated();

$this->assertInstanceOf(SelectUnhydratedQuery::class, $query);
$this->assertInstanceOf(SelectQuery::class, $query);
$this->assertFalse($query->isHydrationEnabled());
}

/**
* first() on an SelectUnhydratedQuery resolves to an array (or null when empty),
* matching the runtime hydration setting locked in by the constructor.
*/
public function testFirstReturnsArrayOrNull(): void
{
$row = $this->articles->findUnhydrated()->where(['id' => 1])->first();

$this->assertIsArray($row);
$this->assertSame(1, $row['id']);

$missing = $this->articles->findUnhydrated()->where(['id' => 99999])->first();
$this->assertNull($missing);
}

/**
* firstOrFail() returns an array on success and throws the same
* RecordNotFoundException as the entity path on miss.
*/
public function testFirstOrFailReturnsArrayOrThrows(): void
{
$row = $this->articles->findUnhydrated()->where(['id' => 1])->firstOrFail();
$this->assertIsArray($row);
$this->assertSame(1, $row['id']);

$this->expectException(RecordNotFoundException::class);
$this->articles->findUnhydrated()->where(['id' => 99999])->firstOrFail();
}

/**
* all() and iteration both produce array rows — confirms the locked
* `_hydrate=false` flag flows through the result-set decoration.
*/
public function testAllAndIterationProduceArrays(): void
{
$resultSet = $this->articles->findUnhydrated()->orderBy(['id' => 'ASC'])->all();
$rows = $resultSet->toArray();

$this->assertNotEmpty($rows);
foreach ($rows as $row) {
$this->assertIsArray($row);
$this->assertArrayHasKey('id', $row);
}
}

/**
* SelectUnhydratedQuery is fully substitutable for SelectQuery: the ORM
* may re-enable hydration on it (the eager loader does exactly this when
* normalizing association queries). It must not fight that — contain()
* with a hydrated parent has to keep working.
*/
public function testInteroperatesWithContainEagerLoading(): void
{
$this->articles->belongsTo('Authors');

$rows = $this->articles
->findUnhydrated()
->contain('Authors')
->where(['Articles.id' => 1])
->toArray();

$this->assertNotEmpty($rows);
$this->assertIsArray($rows[0]);
$this->assertArrayHasKey('author', $rows[0]);
$this->assertIsArray($rows[0]['author']);
}

/**
* Re-enabling hydration is allowed (it just flips the flag, like any
* SelectQuery) — the value of this class is the static type, not a
* runtime lock.
*/
public function testEnableHydrationIsNotLocked(): void
{
$query = $this->articles->findUnhydrated();
$this->assertFalse($query->isHydrationEnabled());

$query->enableHydration(true);
$this->assertTrue($query->isHydrationEnabled());
}

/**
* Custom finders called via findUnhydrated() receive the SelectUnhydratedQuery itself,
* so finder-applied builder methods (where/orderBy/contain/...) flow
* through without losing the array shape.
*/
public function testFinderReceivesSelectUnhydratedQuery(): void
{
$query = $this->articles->findUnhydrated('all')->where(['id >' => 0]);

$this->assertInstanceOf(SelectUnhydratedQuery::class, $query);
$rows = $query->orderBy(['id' => 'ASC'])->limit(2)->toArray();

$this->assertCount(2, $rows);
foreach ($rows as $row) {
$this->assertIsArray($row);
}
}

/**
* findUnhydrated() must build through the injected QueryFactory (like
* find() does), not by instantiating SelectUnhydratedQuery directly —
* otherwise apps with a custom QueryFactory get divergent behavior
* between find() and findUnhydrated().
*/
public function testHonorsInjectedQueryFactory(): void
{
$factory = new class extends QueryFactory {
public function selectUnhydrated(Table $table): SelectUnhydratedQuery
{
return new class ($table) extends SelectUnhydratedQuery {
};
}
};
$table = $this->getTableLocator()->get('ArticlesCustomFactory', [
'className' => Table::class,
'table' => 'articles',
'queryFactory' => $factory,
]);

$query = $table->findUnhydrated();

$this->assertInstanceOf(SelectUnhydratedQuery::class, $query);
$this->assertNotSame(
SelectUnhydratedQuery::class,
$query::class,
'findUnhydrated() bypassed the injected QueryFactory.',
);
}

/**
* A finder that discards the passed query and returns a freshly built
* one cannot preserve the non-hydrating contract. findUnhydrated() must
* fail loudly with a clear message naming the finder, not return a
* silently hydrated query or hit a cryptic TypeError.
*/
public function testFinderReturningFreshQueryThrows(): void
{
$table = new class (['alias' => 'Articles', 'table' => 'articles', 'connection' => ConnectionManager::get('test')]) extends Table {
/**
* @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query The passed query.
* @return \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array>
*/
public function findFresh(SelectQuery $query): SelectQuery
{
return $this->find();
}
};

$this->expectException(CakeException::class);
$this->expectExceptionMessage('`fresh` finder must return the query it was given');
$table->findUnhydrated('fresh');
}
}