From 8b25e94c8b2cedc0e90a5474575875fd4f2d5117 Mon Sep 17 00:00:00 2001 From: bancer Date: Sun, 30 Nov 2025 14:41:13 +0100 Subject: [PATCH 1/3] Rename some methods and classes and improve documention --- README.md | 12 +-- composer.json | 12 ++- ...tQuery.php => NativeQueryResultMapper.php} | 68 ++++++++++-- src/ORM/NativeSQLMapperTrait.php | 49 +++++++-- tests/TestCase/ORM/NativeQueryMapperTest.php | 100 +++++++++--------- 5 files changed, 166 insertions(+), 75 deletions(-) rename src/ORM/{StatementQuery.php => NativeQueryResultMapper.php} (51%) diff --git a/README.md b/README.md index e99875d..cd28a31 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ use NativeSQLMapperTrait; ```php $ArticlesTable = $this->fetchTable(ArticlesTable::class); -$stmt = $ArticlesTable->prepareSQL(" +$stmt = $ArticlesTable->prepareNativeStatement(" SELECT id AS Articles__id, title AS Articles__title @@ -72,7 +72,7 @@ $stmt = $ArticlesTable->prepareSQL(" "); $stmt->bindValue('title', 'My Article Title'); /** @var \App\Model\Entity\Article[] $entities */ -$entities = $ArticlesTable->fromNativeQuery($stmt)->all(); +$entities = $ArticlesTable->mapNativeStatement($stmt)->all(); ``` `$entities` now contains hydrated `Article` entities based on the SQL result. @@ -82,7 +82,7 @@ $entities = $ArticlesTable->fromNativeQuery($stmt)->all(); ## 🔁 hasMany Example Using Minimalistic SQL ```php -$stmt = $ArticlesTable->prepareSQL(" +$stmt = $ArticlesTable->prepareNativeStatement(" SELECT a.id AS Articles__id, title AS Articles__title, @@ -93,7 +93,7 @@ $stmt = $ArticlesTable->prepareSQL(" LEFT JOIN comments AS c ON a.id=c.article_id "); -$entities = $ArticlesTable->fromNativeQuery($stmt)->all(); +$entities = $ArticlesTable->mapNativeStatement($stmt)->all(); ``` `$entities` now contains an array of Article objects with Comment objects as children. @@ -114,7 +114,7 @@ Notice that `FROM` and `JOIN` clauses may use short or long aliases or no aliase ```php $ArticlesTable = $this->fetchTable(ArticlesTable::class); -$stmt = $ArticlesTable->prepareSQL(" +$stmt = $ArticlesTable->prepareNativeStatement(" SELECT Articles.id AS Articles__id, Articles.title AS Articles__title, @@ -126,7 +126,7 @@ $stmt = $ArticlesTable->prepareSQL(" LEFT JOIN tags AS Tags ON Tags.id=ArticlesTags.tag_id "); -$entities = $ArticlesTable->fromNativeQuery($stmt)->all(); +$entities = $ArticlesTable->mapNativeStatement($stmt)->all(); ``` You can find more examples in tests - https://github.com/bancer/native-sql-mapper/tree/develop/tests/TestCase/ORM. diff --git a/composer.json b/composer.json index 83c4cdb..f7943b9 100644 --- a/composer.json +++ b/composer.json @@ -30,8 +30,17 @@ "minimum-stability": "stable", "scripts": { "all-tests": [ + "echo '------------------------------------------'", + "echo '--- PHPSTAN TESTS ---'", + "echo '------------------------------------------'", "@phpstan", + "echo '------------------------------------------'", + "echo '--- PHPCS TESTS ---'", + "echo '------------------------------------------'", "@phpcs", + "echo '------------------------------------------'", + "echo '--- PHPUNIT TESTS ---'", + "echo '------------------------------------------'", "@phpunit" ], "ci-tests": [ @@ -42,7 +51,8 @@ "phpstan": "vendor/bin/phpstan analyse -c phpstan.neon", "phpcs": "vendor/bin/phpcs --standard=PSR12 -p src tests", "phpunit": "vendor/bin/phpunit tests", - "phpunit-coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit tests --coverage-text" + "phpunit-coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit tests --coverage-text", + "phpunit-coverage-html": "XDEBUG_MODE=coverage vendor/bin/phpunit tests --coverage-html coverage/" }, "config": { "allow-plugins": { diff --git a/src/ORM/StatementQuery.php b/src/ORM/NativeQueryResultMapper.php similarity index 51% rename from src/ORM/StatementQuery.php rename to src/ORM/NativeQueryResultMapper.php index 83d3aa1..8f11384 100644 --- a/src/ORM/StatementQuery.php +++ b/src/ORM/NativeQueryResultMapper.php @@ -7,17 +7,53 @@ use Cake\Database\StatementInterface; use Cake\ORM\Table; -class StatementQuery +/** + * Wrapper around a prepared SQL statement that executes it + * and hydrates the result set into CakePHP entities using + * a mapping strategy inferred from column aliases. + * + * This class is created via `prepareNativeStatement()` and + * `mapNativeStatement()` in the `NativeSQLMapperTrait`. + */ +class NativeQueryResultMapper { + /** + * The root table used to determine entity classes, + * associations, and hydration rules. + * + * @var \Cake\ORM\Table + */ protected Table $rootTable; + + /** + * The prepared PDO statement to be executed. + * + * @var \Cake\Database\StatementInterface + */ protected StatementInterface $stmt; + + /** + * Whether the statement has already been executed. + * + * @var bool + */ protected bool $isExecuted; /** - * @var mixed[]|null + * Custom mapping strategy used to hydrate entities. + * If null, a MappingStrategy will be automatically built + * based on detected column aliases. + * + * @var array|null */ protected $mapStrategy = null; + /** + * Constructor. + * + * @param \Cake\ORM\Table $rootTable The root table instance. + * @param \Cake\Database\StatementInterface $stmt The prepared statement. + */ public function __construct(Table $rootTable, StatementInterface $stmt) { $this->rootTable = $rootTable; @@ -26,21 +62,26 @@ public function __construct(Table $rootTable, StatementInterface $stmt) } /** - * Provide a custom mapping strategy. + * Provide a custom mapping strategy instead of relying + * on automatic alias inference. + * + * The structure must match the output of MappingStrategy::toArray(). * - * @param mixed[] $strategy + * @param array $strategy Mapping configuration. * @return $this */ - public function mapStrategy(array $strategy): self + public function setMappingStrategy(array $strategy): self { $this->mapStrategy = $strategy; return $this; } /** - * Execute and hydrate results. + * Execute the SQL statement if not executed yet, fetch all rows, + * build (or use) the mapping strategy, and hydrate the result set + * into entities. * - * @return \Cake\Datasource\EntityInterface[] + * @return \Cake\Datasource\EntityInterface[] Hydrated entity list. */ public function all(): array { @@ -64,10 +105,17 @@ public function all(): array } /** - * Extracts aliases of the columns from the query's result set. + * Extract column aliases used in the SQL result set. + * + * Each column must follow `{Alias}__{column}` format. + * Throws UnknownAliasException if the alias format is invalid. + * + * @param array|mixed> $rows Result set rows. + * @return string[] Sorted list of unique aliases. * - * @param mixed[] $rows Result set rows. - * @return string[] + * @throws \InvalidArgumentException If the first row is not an array. + * @throws \Bancer\NativeQueryMapper\ORM\UnknownAliasException + * If a column does not follow expected alias format. */ protected function extractAliases(array $rows): array { diff --git a/src/ORM/NativeSQLMapperTrait.php b/src/ORM/NativeSQLMapperTrait.php index c37663a..567b91b 100644 --- a/src/ORM/NativeSQLMapperTrait.php +++ b/src/ORM/NativeSQLMapperTrait.php @@ -6,24 +6,57 @@ use Cake\Database\StatementInterface; +/** + * NativeSQLMapperTrait + * + * Provides convenience functions for working with native SQL queries in + * CakePHP Table classes. It allows preparing raw SQL statements using + * the table's connection driver and wrapping executed statements in a + * NativeQueryResultMapper object, enabling automatic entity and association + * mapping based on CakePHP-style column aliases. + */ trait NativeSQLMapperTrait { /** - * Create a StatementQuery wrapper for a prepared statement. + * Wrap a prepared statement in a NativeQueryResultMapper, enabling the + * mapping of native SQL result sets into fully hydrated entities. * - * @param \Cake\Database\StatementInterface $stmt - * @return \Bancer\NativeQueryMapper\ORM\StatementQuery + * Typically used after calling prepareNativeStatement() and binding + * the statement parameters. + * + * Example: + * ```php + * $stmt = $ArticlesTable->prepareNativeStatement(" + * SELECT id AS Articles__id FROM articles + * "); + * $entities = $ArticlesTable->mapNativeStatement($stmt)->all(); + * ``` + * + * @param \Cake\Database\StatementInterface $stmt Prepared statement. + * @return \Bancer\NativeQueryMapper\ORM\NativeQueryResultMapper Wrapper for ORM-level mapping of native results. */ - public function fromNativeQuery(StatementInterface $stmt): StatementQuery + public function mapNativeStatement(StatementInterface $stmt): NativeQueryResultMapper { - return new StatementQuery($this, $stmt); + return new NativeQueryResultMapper($this, $stmt); } /** - * @param string $stmt - * @return \Cake\Database\StatementInterface + * Prepare a native SQL statement using the table's database + * connection driver. This provides direct access to low-level PDO-style + * prepared statements while still using the CakePHP connection. + * + * Example: + * ```php + * $stmt = $ArticlesTable->prepareNativeStatement(" + * SELECT id AS Articles__id FROM articles WHERE title = :title + * "); + * $stmt->bindValue('title', 'Example'); + * ``` + * + * @param string $stmt Raw SQL string to prepare. + * @return \Cake\Database\StatementInterface Prepared statement ready for parameter binding and execution. */ - public function prepareSQL(string $stmt): StatementInterface + public function prepareNativeStatement(string $stmt): StatementInterface { return $this->getConnection()->getDriver()->prepare($stmt); } diff --git a/tests/TestCase/ORM/NativeQueryMapperTest.php b/tests/TestCase/ORM/NativeQueryMapperTest.php index 7c59921..6b72725 100644 --- a/tests/TestCase/ORM/NativeQueryMapperTest.php +++ b/tests/TestCase/ORM/NativeQueryMapperTest.php @@ -48,13 +48,13 @@ public function testInvalidAlias(): void $this->expectExceptionMessage($expectedMessage); /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); - $stmt = $ArticlesTable->prepareSQL(" + $stmt = $ArticlesTable->prepareNativeStatement(" SELECT a.id AS a__id, a.title AS a__title FROM articles AS a "); - $ArticlesTable->fromNativeQuery($stmt)->all(); + $ArticlesTable->mapNativeStatement($stmt)->all(); } public function testMissingAlias(): void @@ -63,13 +63,13 @@ public function testMissingAlias(): void $this->expectExceptionMessage("Column 'title' must use an alias in the format {Alias}__title"); /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); - $stmt = $ArticlesTable->prepareSQL(" + $stmt = $ArticlesTable->prepareNativeStatement(" SELECT id AS Articles__id, title FROM articles "); - $ArticlesTable->fromNativeQuery($stmt)->all(); + $ArticlesTable->mapNativeStatement($stmt)->all(); } public function testIncompleteAlias(): void @@ -80,13 +80,13 @@ public function testIncompleteAlias(): void ); /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); - $stmt = $ArticlesTable->prepareSQL(" + $stmt = $ArticlesTable->prepareNativeStatement(" SELECT id AS Articles__id, title AS Articles__ FROM articles "); - $ArticlesTable->fromNativeQuery($stmt)->all(); + $ArticlesTable->mapNativeStatement($stmt)->all(); } public function testUnrecognizedRootAlias(): void @@ -95,13 +95,13 @@ public function testUnrecognizedRootAlias(): void $this->expectExceptionMessage("None of the root table associations match alias 'Books'"); /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); - $stmt = $ArticlesTable->prepareSQL(" + $stmt = $ArticlesTable->prepareNativeStatement(" SELECT id AS Articles__id, title AS Books__title FROM articles "); - $ArticlesTable->fromNativeQuery($stmt)->all(); + $ArticlesTable->mapNativeStatement($stmt)->all(); } public function testUnrecognizedChildAlias(): void @@ -110,7 +110,7 @@ public function testUnrecognizedChildAlias(): void $this->expectExceptionMessage("None of the table associations match alias 'Books'"); /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); - $stmt = $ArticlesTable->prepareSQL(" + $stmt = $ArticlesTable->prepareNativeStatement(" SELECT Articles.id AS Articles__id, Articles.title AS Articles__title, @@ -121,14 +121,14 @@ public function testUnrecognizedChildAlias(): void LEFT JOIN comments AS Comments ON Articles.id=Comments.article_id "); - $ArticlesTable->fromNativeQuery($stmt)->all(); + $ArticlesTable->mapNativeStatement($stmt)->all(); } public function testEmptyResultSet(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); - $stmt = $ArticlesTable->prepareSQL(" + $stmt = $ArticlesTable->prepareNativeStatement(" SELECT Articles.id AS Articles__id, Articles.title AS Articles__title @@ -136,7 +136,7 @@ public function testEmptyResultSet(): void WHERE Articles.title = :title "); $stmt->bindValue('title', 'Non-existing-title'); - $actual = $ArticlesTable->fromNativeQuery($stmt)->all(); + $actual = $ArticlesTable->mapNativeStatement($stmt)->all(); static::assertSame([], $actual); } @@ -144,13 +144,13 @@ public function testSimplestSelect(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); - $stmt = $ArticlesTable->prepareSQL(" + $stmt = $ArticlesTable->prepareNativeStatement(" SELECT Articles.id AS Articles__id, Articles.title AS Articles__title FROM articles AS Articles "); - $actual = $ArticlesTable->fromNativeQuery($stmt)->all(); + $actual = $ArticlesTable->mapNativeStatement($stmt)->all(); static::assertCount(5, $actual); static::assertInstanceOf(Article::class, $actual[0]); $expected = [ @@ -169,13 +169,13 @@ public function testSimplestSelectMinimalSQL(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); - $stmt = $ArticlesTable->prepareSQL(" + $stmt = $ArticlesTable->prepareNativeStatement(" SELECT id AS Articles__id, title AS Articles__title FROM articles "); - $actual = $ArticlesTable->fromNativeQuery($stmt)->all(); + $actual = $ArticlesTable->mapNativeStatement($stmt)->all(); static::assertCount(5, $actual); static::assertInstanceOf(Article::class, $actual[0]); $expected = [ @@ -196,7 +196,7 @@ public function testHasManyWithoutIdColumn(): void $this->expectExceptionMessage("'Articles__id' column must be present in the query's SELECT clause"); /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); - $stmt = $ArticlesTable->prepareSQL(" + $stmt = $ArticlesTable->prepareNativeStatement(" SELECT Articles.title AS Articles__title, Comments.id AS Comments__id, @@ -206,14 +206,14 @@ public function testHasManyWithoutIdColumn(): void LEFT JOIN comments AS Comments ON Articles.id=Comments.article_id "); - $ArticlesTable->fromNativeQuery($stmt)->all(); + $ArticlesTable->mapNativeStatement($stmt)->all(); } public function testHasMany(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); - $stmt = $ArticlesTable->prepareSQL(" + $stmt = $ArticlesTable->prepareNativeStatement(" SELECT Articles.id AS Articles__id, Articles.title AS Articles__title, @@ -224,7 +224,7 @@ public function testHasMany(): void LEFT JOIN comments AS Comments ON Articles.id=Comments.article_id "); - $actual = $ArticlesTable->fromNativeQuery($stmt)->all(); + $actual = $ArticlesTable->mapNativeStatement($stmt)->all(); static::assertCount(5, $actual); static::assertInstanceOf(Article::class, $actual[0]); $actualComments = $actual[0]->get('comments'); @@ -264,7 +264,7 @@ public function testHasManyMinimalSQL(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); - $stmt = $ArticlesTable->prepareSQL(" + $stmt = $ArticlesTable->prepareNativeStatement(" SELECT a.id AS Articles__id, title AS Articles__title, @@ -275,7 +275,7 @@ public function testHasManyMinimalSQL(): void LEFT JOIN comments AS c ON a.id=c.article_id "); - $actual = $ArticlesTable->fromNativeQuery($stmt)->all(); + $actual = $ArticlesTable->mapNativeStatement($stmt)->all(); static::assertCount(5, $actual); static::assertInstanceOf(Article::class, $actual[0]); $actualComments = $actual[0]->get('comments'); @@ -315,7 +315,7 @@ public function testBelongsTo(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\CommentsTable $CommentsTable */ $CommentsTable = $this->fetchTable(CommentsTable::class); - $stmt = $CommentsTable->prepareSQL(" + $stmt = $CommentsTable->prepareNativeStatement(" SELECT Comments.id AS Comments__id, Comments.article_id AS Comments__article_id, @@ -326,7 +326,7 @@ public function testBelongsTo(): void LEFT JOIN articles AS Articles ON Articles.id=Comments.article_id "); - $actual = $CommentsTable->fromNativeQuery($stmt)->all(); + $actual = $CommentsTable->mapNativeStatement($stmt)->all(); static::assertCount(5, $actual); static::assertInstanceOf(Comment::class, $actual[0]); static::assertInstanceOf(Article::class, $actual[0]->get('article')); @@ -356,7 +356,7 @@ public function testBelongsToWithoutIdColumns(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\CommentsTable $CommentsTable */ $CommentsTable = $this->fetchTable(CommentsTable::class); - $stmt = $CommentsTable->prepareSQL(" + $stmt = $CommentsTable->prepareNativeStatement(" SELECT article_id AS Comments__article_id, content AS Comments__content, @@ -365,7 +365,7 @@ public function testBelongsToWithoutIdColumns(): void LEFT JOIN articles ON articles.id=comments.article_id "); - $actual = $CommentsTable->fromNativeQuery($stmt)->all(); + $actual = $CommentsTable->mapNativeStatement($stmt)->all(); static::assertCount(5, $actual); static::assertInstanceOf(Comment::class, $actual[0]); static::assertInstanceOf(Article::class, $actual[0]->get('article')); @@ -393,7 +393,7 @@ public function testBelongsToMinimalSQL(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\CommentsTable $CommentsTable */ $CommentsTable = $this->fetchTable(CommentsTable::class); - $stmt = $CommentsTable->prepareSQL(" + $stmt = $CommentsTable->prepareNativeStatement(" SELECT c.id AS Comments__id, article_id AS Comments__article_id, @@ -404,7 +404,7 @@ public function testBelongsToMinimalSQL(): void LEFT JOIN articles AS a ON a.id=c.article_id "); - $actual = $CommentsTable->fromNativeQuery($stmt)->all(); + $actual = $CommentsTable->mapNativeStatement($stmt)->all(); static::assertCount(5, $actual); static::assertInstanceOf(Comment::class, $actual[0]); static::assertInstanceOf(Article::class, $actual[0]->get('article')); @@ -434,7 +434,7 @@ public function testHasOne(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\UsersTable $UsersTable */ $UsersTable = $this->fetchTable(UsersTable::class); - $stmt = $UsersTable->prepareSQL(" + $stmt = $UsersTable->prepareNativeStatement(" SELECT Users.id AS Users__id, Users.username AS Users__username, @@ -445,7 +445,7 @@ public function testHasOne(): void LEFT JOIN profiles AS Profiles ON Users.id=Profiles.user_id "); - $actual = $UsersTable->fromNativeQuery($stmt)->all(); + $actual = $UsersTable->mapNativeStatement($stmt)->all(); static::assertCount(5, $actual); static::assertInstanceOf(User::class, $actual[0]); static::assertInstanceOf(Profile::class, $actual[0]->get('profile')); @@ -476,7 +476,7 @@ public function testHasOneMinimalSQL(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\UsersTable $UsersTable */ $UsersTable = $this->fetchTable(UsersTable::class); - $stmt = $UsersTable->prepareSQL(" + $stmt = $UsersTable->prepareNativeStatement(" SELECT u.id AS Users__id, username AS Users__username, @@ -487,7 +487,7 @@ public function testHasOneMinimalSQL(): void LEFT JOIN profiles AS p ON u.id=p.user_id "); - $actual = $UsersTable->fromNativeQuery($stmt)->all(); + $actual = $UsersTable->mapNativeStatement($stmt)->all(); static::assertCount(5, $actual); static::assertInstanceOf(User::class, $actual[0]); static::assertInstanceOf(Profile::class, $actual[0]->get('profile')); @@ -518,7 +518,7 @@ public function testBelongsToManySimple(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); - $stmt = $ArticlesTable->prepareSQL(" + $stmt = $ArticlesTable->prepareNativeStatement(" SELECT Articles.id AS Articles__id, Articles.title AS Articles__title, @@ -530,7 +530,7 @@ public function testBelongsToManySimple(): void LEFT JOIN tags AS Tags ON Tags.id=ArticlesTags.tag_id "); - $actual = $ArticlesTable->fromNativeQuery($stmt)->all(); + $actual = $ArticlesTable->mapNativeStatement($stmt)->all(); static::assertCount(5, $actual); static::assertInstanceOf(Article::class, $actual[0]); $actualTags = $actual[0]->get('tags'); @@ -569,7 +569,7 @@ public function testBelongsToManySimpleMinimalSQL(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); - $stmt = $ArticlesTable->prepareSQL(" + $stmt = $ArticlesTable->prepareNativeStatement(" SELECT a.id AS Articles__id, title AS Articles__title, @@ -581,7 +581,7 @@ public function testBelongsToManySimpleMinimalSQL(): void LEFT JOIN tags AS t ON t.id=at.tag_id "); - $actual = $ArticlesTable->fromNativeQuery($stmt)->all(); + $actual = $ArticlesTable->mapNativeStatement($stmt)->all(); static::assertCount(5, $actual); static::assertInstanceOf(Article::class, $actual[0]); $actualTags = $actual[0]->get('tags'); @@ -620,7 +620,7 @@ public function testBelongsToManyFetchJoinTable(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); - $stmt = $ArticlesTable->prepareSQL(" + $stmt = $ArticlesTable->prepareNativeStatement(" SELECT Articles.id AS Articles__id, Articles.title AS Articles__title, @@ -635,7 +635,7 @@ public function testBelongsToManyFetchJoinTable(): void LEFT JOIN tags AS Tags ON Tags.id=ArticlesTags.tag_id "); - $actual = $ArticlesTable->fromNativeQuery($stmt)->all(); + $actual = $ArticlesTable->mapNativeStatement($stmt)->all(); static::assertCount(5, $actual); static::assertInstanceOf(Article::class, $actual[0]); $actualTags = $actual[0]->get('tags'); @@ -687,7 +687,7 @@ public function testBelongsToManyFetchJoinTableMinimalSQL(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); - $stmt = $ArticlesTable->prepareSQL(" + $stmt = $ArticlesTable->prepareNativeStatement(" SELECT a.id AS Articles__id, title AS Articles__title, @@ -702,7 +702,7 @@ public function testBelongsToManyFetchJoinTableMinimalSQL(): void LEFT JOIN tags AS t ON t.id=at.tag_id "); - $actual = $ArticlesTable->fromNativeQuery($stmt)->all(); + $actual = $ArticlesTable->mapNativeStatement($stmt)->all(); static::assertCount(5, $actual); static::assertInstanceOf(Article::class, $actual[0]); $actualTags = $actual[0]->get('tags'); @@ -754,7 +754,7 @@ public function testDeepAssociations(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\CountriesTable $CountriesTable */ $CountriesTable = $this->fetchTable(CountriesTable::class); - $stmt = $CountriesTable->prepareSQL(" + $stmt = $CountriesTable->prepareNativeStatement(" SELECT Countries.id AS Countries__id, Countries.name AS Countries__name, @@ -783,7 +783,7 @@ public function testDeepAssociations(): void LEFT JOIN comments AS Comments ON Comments.article_id=Articles.id "); - $actual = $CountriesTable->fromNativeQuery($stmt)->all(); + $actual = $CountriesTable->mapNativeStatement($stmt)->all(); static::assertCount(5, $actual); static::assertInstanceOf(Country::class, $actual[0]); $actualUsers = $actual[0]->get('users'); @@ -857,7 +857,7 @@ public function testDeepAssociationsMinimalSQL(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\CountriesTable $CountriesTable */ $CountriesTable = $this->fetchTable(CountriesTable::class); - $stmt = $CountriesTable->prepareSQL(" + $stmt = $CountriesTable->prepareNativeStatement(" SELECT c.id AS Countries__id, c.name AS Countries__name, @@ -886,7 +886,7 @@ public function testDeepAssociationsMinimalSQL(): void LEFT JOIN comments AS cm ON cm.article_id=a.id "); - $actual = $CountriesTable->fromNativeQuery($stmt)->all(); + $actual = $CountriesTable->mapNativeStatement($stmt)->all(); static::assertCount(5, $actual); static::assertInstanceOf(Country::class, $actual[0]); $actualUsers = $actual[0]->get('users'); @@ -960,7 +960,7 @@ public function testDeepAssociationsWithBelongsToMany(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\CountriesTable $CountriesTable */ $CountriesTable = $this->fetchTable(CountriesTable::class); - $stmt = $CountriesTable->prepareSQL(" + $stmt = $CountriesTable->prepareNativeStatement(" SELECT Countries.id AS Countries__id, Countries.name AS Countries__name, @@ -991,7 +991,7 @@ public function testDeepAssociationsWithBelongsToMany(): void LEFT JOIN comments AS Comments ON Comments.article_id=Articles.id "); - $actual = $CountriesTable->fromNativeQuery($stmt)->all(); + $actual = $CountriesTable->mapNativeStatement($stmt)->all(); static::assertCount(5, $actual); static::assertInstanceOf(Country::class, $actual[0]); $actualUsers = $actual[0]->get('users'); @@ -1095,7 +1095,7 @@ public function testDeepAssociationsWithBelongsToManyMinimalSQL(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\CountriesTable $CountriesTable */ $CountriesTable = $this->fetchTable(CountriesTable::class); - $stmt = $CountriesTable->prepareSQL(" + $stmt = $CountriesTable->prepareNativeStatement(" SELECT c.id AS Countries__id, c.name AS Countries__name, @@ -1126,7 +1126,7 @@ public function testDeepAssociationsWithBelongsToManyMinimalSQL(): void LEFT JOIN comments AS cm ON cm.article_id=a.id "); - $actual = $CountriesTable->fromNativeQuery($stmt)->all(); + $actual = $CountriesTable->mapNativeStatement($stmt)->all(); static::assertCount(5, $actual); static::assertInstanceOf(Country::class, $actual[0]); $actualUsers = $actual[0]->get('users'); @@ -1230,14 +1230,14 @@ public function testDatetimeFields(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\CommentsTable $CommentsTable */ $CommentsTable = $this->fetchTable(CommentsTable::class); - $stmt = $CommentsTable->prepareSQL(" + $stmt = $CommentsTable->prepareNativeStatement(" SELECT id AS Comments__id, content AS Comments__content, created AS Comments__created FROM comments "); - $actual = $CommentsTable->fromNativeQuery($stmt)->all(); + $actual = $CommentsTable->mapNativeStatement($stmt)->all(); static::assertCount(5, $actual); static::assertInstanceOf(Comment::class, $actual[0]); $expected = [ From 222bb4ed33a6a226350dc9a4f7980eb64a4ba6d5 Mon Sep 17 00:00:00 2001 From: bancer Date: Sun, 30 Nov 2025 15:31:08 +0100 Subject: [PATCH 2/3] Rename a class and improve documentation --- src/ORM/NativeQueryResultMapper.php | 2 +- ...ursive.php => RecursiveEntityHydrator.php} | 101 +++++++++++++----- 2 files changed, 75 insertions(+), 28 deletions(-) rename src/ORM/{AutoHydratorRecursive.php => RecursiveEntityHydrator.php} (74%) diff --git a/src/ORM/NativeQueryResultMapper.php b/src/ORM/NativeQueryResultMapper.php index 8f11384..7b5f89d 100644 --- a/src/ORM/NativeQueryResultMapper.php +++ b/src/ORM/NativeQueryResultMapper.php @@ -100,7 +100,7 @@ public function all(): array $this->mapStrategy = $strategy->build()->toArray(); $aliasMap = $strategy->getAliasMap(); } - $hydrator = new AutoHydratorRecursive($this->rootTable, $this->mapStrategy, $aliasMap); + $hydrator = new RecursiveEntityHydrator($this->rootTable, $this->mapStrategy, $aliasMap); return $hydrator->hydrateMany($rows); } diff --git a/src/ORM/AutoHydratorRecursive.php b/src/ORM/RecursiveEntityHydrator.php similarity index 74% rename from src/ORM/AutoHydratorRecursive.php rename to src/ORM/RecursiveEntityHydrator.php index ad5bec7..a692c22 100644 --- a/src/ORM/AutoHydratorRecursive.php +++ b/src/ORM/RecursiveEntityHydrator.php @@ -9,11 +9,26 @@ use Cake\Utility\Hash; use RuntimeException; -class AutoHydratorRecursive +/** + * Recursively hydrates nested CakePHP entities from a set of rows produced + * by a native SQL query using Cake-style `{Alias}__{field}` column naming. + * + * This class constructs entity graphs according to a precomputed + * `MappingStrategy`, supports deep associations and belongsToMany relations, + * and caches hydrated entities to avoid duplication during recursion. + */ +class RecursiveEntityHydrator { + /** + * The root Table instance initiating hydration. + * + * @var \Cake\ORM\Table + */ protected Table $rootTable; /** + * Supported association types in the mapping strategy. + * * @var string[] */ protected array $associationTypes = [ @@ -24,46 +39,55 @@ class AutoHydratorRecursive ]; /** - * Precomputed mapping strategy. + * Precomputed mapping strategy produced by MappingStrategy::build()->toArray(). * * @var mixed[] */ protected array $mappingStrategy = []; /** + * Maps each alias to a map of hashed field sets to entity index. + * + * Example: * [ - * '{alias}' => [ - * '{hash}' => {index}, - * ], + * 'Articles' => [ + * 'a1b2c3' => 0, + * 'd4e5f6' => 1, + * ], * ] * - * @var int[][] + * @var array> */ protected array $entitiesMap = []; /** - * The map of aliases and corresponding Table objects. + * A map of aliases to their corresponding Table objects. * - * @var array + * @var array */ protected array $aliasMap = []; /** + * List of hydrated root-level entities. + * * @var \Cake\Datasource\EntityInterface[] */ protected array $entities = []; /** - * If mapping strategy contains hasMany or belongsToMany association then all mapped models must have primary keys. + * Whether the presence of primary keys is mandatory for all entities, + * inferred automatically based on the mapping strategy. * - * @var boolean + * @var bool */ - protected bool $isPrimaryKeyRequired; + private bool $isPrimaryKeyRequired; /** - * @param \Cake\ORM\Table $rootTable - * @param mixed[] $mappingStrategy Mapping strategy. - * @param array $aliasMap Aliases and corresponding Table objects. + * Constructor. + * + * @param \Cake\ORM\Table $rootTable The root Table instance. + * @param mixed[] $mappingStrategy Precomputed mapping strategy. + * @param array $aliasMap Map of aliases to Table objects. */ public function __construct(Table $rootTable, array $mappingStrategy, array $aliasMap) { @@ -73,7 +97,9 @@ public function __construct(Table $rootTable, array $mappingStrategy, array $ali } /** - * @param mixed[][] $rows + * Hydrate an array of rows into a list of fully mapped entities. + * + * @param mixed[][] $rows Flat rows from PDO::FETCH_ASSOC. * @return \Cake\Datasource\EntityInterface[] */ public function hydrateMany(array $rows): array @@ -86,11 +112,13 @@ public function hydrateMany(array $rows): array } /** + * Recursively map aliases to entities and attach them to their parent entities. * - * @param mixed[] $mappingStrategy - * @param mixed[][] $row - * @param \Cake\Datasource\EntityInterface $parent - * @param string $parentAssociation + * @param mixed[] $mappingStrategy Strategy node for the current level. + * @param mixed[][] $row Parsed row grouped by alias. + * @param \Cake\Datasource\EntityInterface|null $parent Parent entity, if any. + * @param string|null $parentAssociation Association type joining child to parent. + * @return void */ protected function map( array $mappingStrategy, @@ -185,10 +213,16 @@ protected function map( } /** - * @param class-string<\Cake\Datasource\EntityInterface> $className Entity class name. - * @param mixed[] $fields Entity fields with values. - * @param string $alias Entity alias. - * @param string[]|string|null $primaryKey The name(s) of the primary key column(s). + * Create an entity from raw field data using either: + * - Table marshaller (preferred), or + * - direct entity instantiation (fallback). + * + * Returns null when the row for the alias is "empty" (all NULL fields). + * + * @param class-string<\Cake\Datasource\EntityInterface> $className Entity class. + * @param mixed[] $fields Raw database fields. + * @param string $alias Alias of the entity. + * @param string[]|string|null $primaryKey Primary key name(s). * @return \Cake\Datasource\EntityInterface|null */ protected function constructEntity( @@ -243,8 +277,11 @@ protected function constructEntity( } /** - * @param mixed[] $fields - * @param string|null $parentEntityHash + * Compute a stable hash for an entity's field set, + * optionally including the parent entity's hash for hasMany relations. + * + * @param mixed[] $fields Raw database fields. + * @param string|null $parentEntityHash The hash of the parent entity object. * @return string */ protected function computeFieldsHash(array $fields, ?string $parentEntityHash = null): string @@ -254,6 +291,8 @@ protected function computeFieldsHash(array $fields, ?string $parentEntityHash = } /** + * Determine if the current mapping-strategy node contains associations. + * * @param mixed[] $node * @return bool */ @@ -264,6 +303,13 @@ protected function hasAssociations(array $node): bool } /** + * Parse rows grouped by `{Alias}__{field}` format into: + * + * [ + * ['Articles' => [...], 'Comments' => [...]], + * ['Articles' => [...], 'Comments' => [...]], + * ] + * * @param mixed[][] $rows * @return mixed[][][] */ @@ -282,8 +328,9 @@ protected function parse(array $rows): array } /** - * Checks whether the mapping strategy requires all primary keys to be present. - * If mapping strategy contains hasMany or belongsToMany association then all mapped models must have primary keys. + * Check if the strategy requires primary keys for ALL mapped entities. + * + * Required when using hasMany or belongsToMany associations. * * @return bool */ From 5eddfe754c67dd91623b827ea9693d2479d489c8 Mon Sep 17 00:00:00 2001 From: bancer Date: Sun, 30 Nov 2025 16:04:40 +0100 Subject: [PATCH 3/3] Add missing strict_types declaration --- src/polyfill_str_contains.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/polyfill_str_contains.php b/src/polyfill_str_contains.php index f07b778..1a053e5 100644 --- a/src/polyfill_str_contains.php +++ b/src/polyfill_str_contains.php @@ -1,5 +1,7 @@