Skip to content

Commit 3e3449f

Browse files
authored
Merge pull request #8 from bancer/develop
Rename some methods and classes and improve documentation
2 parents 2eb9fda + 5eddfe7 commit 3e3449f

8 files changed

Lines changed: 326 additions & 186 deletions

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ use NativeSQLMapperTrait;
6363

6464
```php
6565
$ArticlesTable = $this->fetchTable(ArticlesTable::class);
66-
$stmt = $ArticlesTable->prepareSQL("
66+
$stmt = $ArticlesTable->prepareNativeStatement("
6767
SELECT
6868
id AS Articles__id,
6969
title AS Articles__title
@@ -72,7 +72,7 @@ $stmt = $ArticlesTable->prepareSQL("
7272
");
7373
$stmt->bindValue('title', 'My Article Title');
7474
/** @var \App\Model\Entity\Article[] $entities */
75-
$entities = $ArticlesTable->fromNativeQuery($stmt)->all();
75+
$entities = $ArticlesTable->mapNativeStatement($stmt)->all();
7676
```
7777

7878
`$entities` now contains hydrated `Article` entities based on the SQL result.
@@ -82,7 +82,7 @@ $entities = $ArticlesTable->fromNativeQuery($stmt)->all();
8282
## 🔁 hasMany Example Using Minimalistic SQL
8383

8484
```php
85-
$stmt = $ArticlesTable->prepareSQL("
85+
$stmt = $ArticlesTable->prepareNativeStatement("
8686
SELECT
8787
a.id AS Articles__id,
8888
title AS Articles__title,
@@ -93,7 +93,7 @@ $stmt = $ArticlesTable->prepareSQL("
9393
LEFT JOIN comments AS c
9494
ON a.id=c.article_id
9595
");
96-
$entities = $ArticlesTable->fromNativeQuery($stmt)->all();
96+
$entities = $ArticlesTable->mapNativeStatement($stmt)->all();
9797
```
9898
`$entities` now contains an array of Article objects with Comment objects as children.
9999

@@ -114,7 +114,7 @@ Notice that `FROM` and `JOIN` clauses may use short or long aliases or no aliase
114114

115115
```php
116116
$ArticlesTable = $this->fetchTable(ArticlesTable::class);
117-
$stmt = $ArticlesTable->prepareSQL("
117+
$stmt = $ArticlesTable->prepareNativeStatement("
118118
SELECT
119119
Articles.id AS Articles__id,
120120
Articles.title AS Articles__title,
@@ -126,7 +126,7 @@ $stmt = $ArticlesTable->prepareSQL("
126126
LEFT JOIN tags AS Tags
127127
ON Tags.id=ArticlesTags.tag_id
128128
");
129-
$entities = $ArticlesTable->fromNativeQuery($stmt)->all();
129+
$entities = $ArticlesTable->mapNativeStatement($stmt)->all();
130130
```
131131
You can find more examples in tests - https://github.com/bancer/native-sql-mapper/tree/develop/tests/TestCase/ORM.
132132

composer.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,17 @@
3030
"minimum-stability": "stable",
3131
"scripts": {
3232
"all-tests": [
33+
"echo '------------------------------------------'",
34+
"echo '--- PHPSTAN TESTS ---'",
35+
"echo '------------------------------------------'",
3336
"@phpstan",
37+
"echo '------------------------------------------'",
38+
"echo '--- PHPCS TESTS ---'",
39+
"echo '------------------------------------------'",
3440
"@phpcs",
41+
"echo '------------------------------------------'",
42+
"echo '--- PHPUNIT TESTS ---'",
43+
"echo '------------------------------------------'",
3544
"@phpunit"
3645
],
3746
"ci-tests": [
@@ -42,7 +51,8 @@
4251
"phpstan": "vendor/bin/phpstan analyse -c phpstan.neon",
4352
"phpcs": "vendor/bin/phpcs --standard=PSR12 -p src tests",
4453
"phpunit": "vendor/bin/phpunit tests",
45-
"phpunit-coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit tests --coverage-text"
54+
"phpunit-coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit tests --coverage-text",
55+
"phpunit-coverage-html": "XDEBUG_MODE=coverage vendor/bin/phpunit tests --coverage-html coverage/"
4656
},
4757
"config": {
4858
"allow-plugins": {
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Bancer\NativeQueryMapper\ORM;
6+
7+
use Cake\Database\StatementInterface;
8+
use Cake\ORM\Table;
9+
10+
/**
11+
* Wrapper around a prepared SQL statement that executes it
12+
* and hydrates the result set into CakePHP entities using
13+
* a mapping strategy inferred from column aliases.
14+
*
15+
* This class is created via `prepareNativeStatement()` and
16+
* `mapNativeStatement()` in the `NativeSQLMapperTrait`.
17+
*/
18+
class NativeQueryResultMapper
19+
{
20+
/**
21+
* The root table used to determine entity classes,
22+
* associations, and hydration rules.
23+
*
24+
* @var \Cake\ORM\Table
25+
*/
26+
protected Table $rootTable;
27+
28+
/**
29+
* The prepared PDO statement to be executed.
30+
*
31+
* @var \Cake\Database\StatementInterface
32+
*/
33+
protected StatementInterface $stmt;
34+
35+
/**
36+
* Whether the statement has already been executed.
37+
*
38+
* @var bool
39+
*/
40+
protected bool $isExecuted;
41+
42+
/**
43+
* Custom mapping strategy used to hydrate entities.
44+
* If null, a MappingStrategy will be automatically built
45+
* based on detected column aliases.
46+
*
47+
* @var array<string,mixed>|null
48+
*/
49+
protected $mapStrategy = null;
50+
51+
/**
52+
* Constructor.
53+
*
54+
* @param \Cake\ORM\Table $rootTable The root table instance.
55+
* @param \Cake\Database\StatementInterface $stmt The prepared statement.
56+
*/
57+
public function __construct(Table $rootTable, StatementInterface $stmt)
58+
{
59+
$this->rootTable = $rootTable;
60+
$this->stmt = $stmt;
61+
$this->isExecuted = false;
62+
}
63+
64+
/**
65+
* Provide a custom mapping strategy instead of relying
66+
* on automatic alias inference.
67+
*
68+
* The structure must match the output of MappingStrategy::toArray().
69+
*
70+
* @param array<string,mixed> $strategy Mapping configuration.
71+
* @return $this
72+
*/
73+
public function setMappingStrategy(array $strategy): self
74+
{
75+
$this->mapStrategy = $strategy;
76+
return $this;
77+
}
78+
79+
/**
80+
* Execute the SQL statement if not executed yet, fetch all rows,
81+
* build (or use) the mapping strategy, and hydrate the result set
82+
* into entities.
83+
*
84+
* @return \Cake\Datasource\EntityInterface[] Hydrated entity list.
85+
*/
86+
public function all(): array
87+
{
88+
if (!$this->isExecuted) {
89+
$this->stmt->execute();
90+
$this->isExecuted = true;
91+
}
92+
$rows = $this->stmt->fetchAll(\PDO::FETCH_ASSOC);
93+
if (!$rows) {
94+
return [];
95+
}
96+
$aliasMap = [];
97+
if ($this->mapStrategy === null) {
98+
$aliases = $this->extractAliases($rows);
99+
$strategy = new MappingStrategy($this->rootTable, $aliases);
100+
$this->mapStrategy = $strategy->build()->toArray();
101+
$aliasMap = $strategy->getAliasMap();
102+
}
103+
$hydrator = new RecursiveEntityHydrator($this->rootTable, $this->mapStrategy, $aliasMap);
104+
return $hydrator->hydrateMany($rows);
105+
}
106+
107+
/**
108+
* Extract column aliases used in the SQL result set.
109+
*
110+
* Each column must follow `{Alias}__{column}` format.
111+
* Throws UnknownAliasException if the alias format is invalid.
112+
*
113+
* @param array<int,array<string,mixed>|mixed> $rows Result set rows.
114+
* @return string[] Sorted list of unique aliases.
115+
*
116+
* @throws \InvalidArgumentException If the first row is not an array.
117+
* @throws \Bancer\NativeQueryMapper\ORM\UnknownAliasException
118+
* If a column does not follow expected alias format.
119+
*/
120+
protected function extractAliases(array $rows): array
121+
{
122+
$firstRow = $rows[0] ?? [];
123+
if (!is_array($firstRow)) {
124+
throw new \InvalidArgumentException('First element of the result set is not an array');
125+
}
126+
$keys = array_keys($firstRow);
127+
$aliases = [];
128+
foreach ($keys as $key) {
129+
if (!is_string($key) || !str_contains($key, '__')) {
130+
throw new UnknownAliasException("Column '$key' must use an alias in the format {Alias}__$key");
131+
}
132+
[$alias, $field] = explode('__', $key, 2);
133+
if (mb_strlen($alias) <= 0 || mb_strlen($field) <= 0) {
134+
$message = "Alias '$key' is invalid. Column alias must use {Alias}__{column_name} format";
135+
throw new UnknownAliasException($message);
136+
}
137+
$aliases[] = $alias;
138+
}
139+
sort($aliases);
140+
return $aliases;
141+
}
142+
}

src/ORM/NativeSQLMapperTrait.php

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,57 @@
66

77
use Cake\Database\StatementInterface;
88

9+
/**
10+
* NativeSQLMapperTrait
11+
*
12+
* Provides convenience functions for working with native SQL queries in
13+
* CakePHP Table classes. It allows preparing raw SQL statements using
14+
* the table's connection driver and wrapping executed statements in a
15+
* NativeQueryResultMapper object, enabling automatic entity and association
16+
* mapping based on CakePHP-style column aliases.
17+
*/
918
trait NativeSQLMapperTrait
1019
{
1120
/**
12-
* Create a StatementQuery wrapper for a prepared statement.
21+
* Wrap a prepared statement in a NativeQueryResultMapper, enabling the
22+
* mapping of native SQL result sets into fully hydrated entities.
1323
*
14-
* @param \Cake\Database\StatementInterface $stmt
15-
* @return \Bancer\NativeQueryMapper\ORM\StatementQuery
24+
* Typically used after calling prepareNativeStatement() and binding
25+
* the statement parameters.
26+
*
27+
* Example:
28+
* ```php
29+
* $stmt = $ArticlesTable->prepareNativeStatement("
30+
* SELECT id AS Articles__id FROM articles
31+
* ");
32+
* $entities = $ArticlesTable->mapNativeStatement($stmt)->all();
33+
* ```
34+
*
35+
* @param \Cake\Database\StatementInterface $stmt Prepared statement.
36+
* @return \Bancer\NativeQueryMapper\ORM\NativeQueryResultMapper Wrapper for ORM-level mapping of native results.
1637
*/
17-
public function fromNativeQuery(StatementInterface $stmt): StatementQuery
38+
public function mapNativeStatement(StatementInterface $stmt): NativeQueryResultMapper
1839
{
19-
return new StatementQuery($this, $stmt);
40+
return new NativeQueryResultMapper($this, $stmt);
2041
}
2142

2243
/**
23-
* @param string $stmt
24-
* @return \Cake\Database\StatementInterface
44+
* Prepare a native SQL statement using the table's database
45+
* connection driver. This provides direct access to low-level PDO-style
46+
* prepared statements while still using the CakePHP connection.
47+
*
48+
* Example:
49+
* ```php
50+
* $stmt = $ArticlesTable->prepareNativeStatement("
51+
* SELECT id AS Articles__id FROM articles WHERE title = :title
52+
* ");
53+
* $stmt->bindValue('title', 'Example');
54+
* ```
55+
*
56+
* @param string $stmt Raw SQL string to prepare.
57+
* @return \Cake\Database\StatementInterface Prepared statement ready for parameter binding and execution.
2558
*/
26-
public function prepareSQL(string $stmt): StatementInterface
59+
public function prepareNativeStatement(string $stmt): StatementInterface
2760
{
2861
return $this->getConnection()->getDriver()->prepare($stmt);
2962
}

0 commit comments

Comments
 (0)