Skip to content
Merged
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
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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,
Expand All @@ -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.

Expand All @@ -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,
Expand All @@ -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.

Expand Down
12 changes: 11 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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": {
Expand Down
142 changes: 142 additions & 0 deletions src/ORM/NativeQueryResultMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<?php

declare(strict_types=1);

namespace Bancer\NativeQueryMapper\ORM;

use Cake\Database\StatementInterface;
use Cake\ORM\Table;

/**
* 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;

/**
* Custom mapping strategy used to hydrate entities.
* If null, a MappingStrategy will be automatically built
* based on detected column aliases.
*
* @var array<string,mixed>|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;
$this->stmt = $stmt;
$this->isExecuted = false;
}

/**
* Provide a custom mapping strategy instead of relying
* on automatic alias inference.
*
* The structure must match the output of MappingStrategy::toArray().
*
* @param array<string,mixed> $strategy Mapping configuration.
* @return $this
*/
public function setMappingStrategy(array $strategy): self
{
$this->mapStrategy = $strategy;
return $this;
}

/**
* 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[] Hydrated entity list.
*/
public function all(): array
{
if (!$this->isExecuted) {
$this->stmt->execute();
$this->isExecuted = true;
}
$rows = $this->stmt->fetchAll(\PDO::FETCH_ASSOC);
if (!$rows) {
return [];
}
$aliasMap = [];
if ($this->mapStrategy === null) {
$aliases = $this->extractAliases($rows);
$strategy = new MappingStrategy($this->rootTable, $aliases);
$this->mapStrategy = $strategy->build()->toArray();
$aliasMap = $strategy->getAliasMap();
}
$hydrator = new RecursiveEntityHydrator($this->rootTable, $this->mapStrategy, $aliasMap);
return $hydrator->hydrateMany($rows);
}

/**
* 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<int,array<string,mixed>|mixed> $rows Result set rows.
* @return string[] Sorted list of unique aliases.
*
* @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
{
$firstRow = $rows[0] ?? [];
if (!is_array($firstRow)) {
throw new \InvalidArgumentException('First element of the result set is not an array');
}
$keys = array_keys($firstRow);
$aliases = [];
foreach ($keys as $key) {
if (!is_string($key) || !str_contains($key, '__')) {
throw new UnknownAliasException("Column '$key' must use an alias in the format {Alias}__$key");
}
[$alias, $field] = explode('__', $key, 2);
if (mb_strlen($alias) <= 0 || mb_strlen($field) <= 0) {
$message = "Alias '$key' is invalid. Column alias must use {Alias}__{column_name} format";
throw new UnknownAliasException($message);
}
$aliases[] = $alias;
}
sort($aliases);
return $aliases;
}
}
49 changes: 41 additions & 8 deletions src/ORM/NativeSQLMapperTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading