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
37 changes: 35 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,41 @@ You can find more examples in tests - https://github.com/bancer/native-sql-mappe

---

## ➕ BONUS: IN() placeholder helper for native SQL

When working with **native SQL queries** in CakePHP, PDO does not support binding arrays directly to `IN (…)` clauses. Each value must be expanded into its own placeholder and bound individually.

The `InPlaceholders` class provides a small, explicit helper that removes this boilerplate while keeping native SQL fully transparent and predictable.

##### What it does

`InPlaceholders` is a **value object** that:

- Generates named placeholders for use inside SQL `IN (…)` clauses
- Binds all values to a prepared statement safely
- Infers the correct PDO parameter type automatically (or accepts one explicitly)
- Fails fast on invalid input (empty prefix or empty value list)

There is **no ORM magic** involved — this works purely at the native SQL / PDO level.

##### Basic example

```php
use Bancer\NativeQueryMapper\Database\InPlaceholders;

$statuses = new InPlaceholders('status', [1, 5, 9]);
$sql = <<<SQL
SELECT email as Users__email
FROM users
WHERE status_id IN ($statuses)
SQL;
$stmt = $this->prepareNativeStatement($sql);
$statuses->bindValuesToStatement($stmt);
$entities = $this->mapNativeStatement($stmt)->all();
```

---

## ⚠️ Requirements

- Cake ORM **4.x** or **5.x** (or CakePHP **4.x** or **5.x**)
Expand All @@ -167,5 +202,3 @@ You can find more examples in tests - https://github.com/bancer/native-sql-mappe
It fills the gap between raw PDO statements and the ORM — allowing complex SQL while preserving the integrity of your entity graphs.

---
```

147 changes: 147 additions & 0 deletions src/Database/InPlaceholders.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<?php

declare(strict_types=1);

namespace Bancer\NativeQueryMapper\Database;

use Cake\Database\StatementInterface;
use InvalidArgumentException;
use PDO;

/**
* Value object representing a set of named placeholders for use in SQL IN() clauses.
*
* This class encapsulates:
* - Placeholder generation (via __toString())
* - Safe binding of multiple scalar values to a prepared statement
* - Lazy inference of the appropriate PDO parameter type
*
* Example:
* ```php
* $statuses = new InPlaceholders('status', [1, 5, 9]);
* $sql = "SELECT email AS Users_email FROM users WHERE status_id IN ($statuses)";
* $stmt = $this->prepareNativeStatement($sql);
* $statuses->bindValuesToStatement($stmt);
* ```
*
* Resulting SQL:
* ```sql
* SELECT email AS Users_email FROM users WHERE status_id status_id IN (:status_0, :status_1, :status_2)
* ```
*/
class InPlaceholders
{
/**
* Placeholder name prefix (without colon).
*
* Example: "status" -> :status_0, :status_1, ...
*
* @var string
*/
private string $prefix;

/**
* Scalar values to be bound to the placeholders.
*
* @var list<scalar>
*/
private array $values;

/**
* PDO parameter type used when binding values.
*
* If not provided, the type is inferred from the first value.
*
* @var string|int|null
*/
private $pdoType;

/**
* Constructor.
*
* @param string $prefix Placeholder prefix (eg. "status").
* @param list<scalar> $values Values for the IN() clause
* @param string|int|null $pdoType PDO::PARAM_* constant or name of configured Type class.
* Same as `$type` parameter of \Cake\Database\StatementInterface::bindValue()
*/
public function __construct(string $prefix, array $values, $pdoType = null)
{
if ($prefix === '') {
throw new InvalidArgumentException('IN() placeholders cannot be constructed with an empty prefix');
}
if ($values === []) {
throw new InvalidArgumentException('IN() placeholders cannot be constructed with an empty value list');
}
$this->prefix = $prefix;
$this->values = $values;
$this->pdoType = $pdoType;
}

/**
* Bind all placeholder values to the prepared statement.
*
* Placeholders are bound using the pattern:
* :{prefix}_{index}
*
* @param \Cake\Database\StatementInterface $stmt Prepared statement.
* @return void
*/
public function bindValuesToStatement(StatementInterface $stmt): void
{
foreach ($this->values as $index => $value) {
$stmt->bindValue($this->prefix . '_' . $index, $value, $this->getPdoType());
}
}

/**
* Resolve the PDO parameter type.
*
* If the type was not provided in the constructor, it is inferred lazily
* from the first value in the list.
*
* @return string|int PDO::PARAM_* constant or name of configured Type class.
*/
private function getPdoType()
{
if (!isset($this->pdoType)) {
$this->pdoType = $this->inferPdoType();
}
return $this->pdoType;
}

/**
* Infer the PDO parameter type from the first value.
*
* @return int PDO::PARAM_* constant
*/
private function inferPdoType(): int
{
$first = $this->values[0];
if (is_int($first)) {
return PDO::PARAM_INT;
}
if (is_bool($first)) {
return PDO::PARAM_BOOL;
}
return PDO::PARAM_STR;
}

/**
* Generate the SQL placeholder list for use inside an IN() clause.
*
* Example output:
* ```sql
* :status_0, :status_1, :status_2
* ```
*
* @return string
*/
public function __toString(): string
{
$placeholders = [];
foreach ($this->values as $index => $_) {
$placeholders[] = ':' . $this->prefix . '_' . $index;
}
return implode(', ', $placeholders);
}
}
99 changes: 99 additions & 0 deletions tests/TestCase/Database/InPlaceholdersTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace Bancer\NativeQueryMapper\Test\TestCase\Database;

use Bancer\NativeQueryMapperTest\TestApp\Model\Entity\Article;
use Bancer\NativeQueryMapperTest\TestApp\Model\Entity\User;
use Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable;
use Bancer\NativeQueryMapperTest\TestApp\Model\Table\UsersTable;
use Bancer\NativeQueryMapper\Database\InPlaceholders;
use Cake\ORM\Locator\LocatorAwareTrait;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;

class InPlaceholdersTest extends TestCase
{
use LocatorAwareTrait;

public function testConstructorEmptyPrefix(): void
{
static::expectException(InvalidArgumentException::class);
static::expectExceptionMessage('IN() placeholders cannot be constructed with an empty prefix');
new InPlaceholders('', []);
}

public function testConstructorEmptyValues(): void
{
static::expectException(InvalidArgumentException::class);
static::expectExceptionMessage('IN() placeholders cannot be constructed with an empty value list');
new InPlaceholders('status', []);
}

public function testToString(): void
{
$InPlaceholders = new InPlaceholders('id', [3, 5]);
$expected = ':id_0, :id_1';
$actual = $InPlaceholders->__toString();
static::assertSame($expected, $actual);
}

public function testBindValuesToStatementInt(): void
{
$userIds = [2, 5];
$inPlaceholders = new InPlaceholders('user', $userIds);
/** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */
$ArticlesTable = $this->fetchTable(ArticlesTable::class);
$stmt = $ArticlesTable->prepareNativeStatement("
SELECT
id AS Articles__id,
title AS Articles__title
FROM articles AS a
WHERE a.user_id IN($inPlaceholders)
");
$inPlaceholders->bindValuesToStatement($stmt);
$actual = $ArticlesTable->mapNativeStatement($stmt)->all();
static::assertCount(2, $actual);
static::assertInstanceOf(Article::class, $actual[0]);
$expected = [
'id' => 2,
'title' => 'Article 2',
];
static::assertEquals($expected, $actual[0]->toArray());
$cakeEntities = $ArticlesTable->find()
->select(['id', 'title'])
->where(['user_id IN' => $userIds])
->toArray();
static::assertEquals($cakeEntities, $actual);
}

public function testBindValuesToStatementStrings(): void
{
$users = ['bob', 'eve'];
$inPlaceholders = new InPlaceholders('user', $users);
/** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\UsersTable $UsersTable */
$UsersTable = $this->fetchTable(UsersTable::class);
$stmt = $UsersTable->prepareNativeStatement("
SELECT
id AS Users__id,
username AS Users__username
FROM users AS u
WHERE u.username IN($inPlaceholders)
");
$inPlaceholders->bindValuesToStatement($stmt);
$actual = $UsersTable->mapNativeStatement($stmt)->all();
static::assertCount(2, $actual);
static::assertInstanceOf(User::class, $actual[0]);
$expected = [
'id' => 2,
'username' => 'bob',
];
static::assertEquals($expected, $actual[0]->toArray());
$cakeEntities = $UsersTable->find()
->select(['id', 'username'])
->where(['username IN' => $users])
->toArray();
static::assertEquals($cakeEntities, $actual);
}
}
3 changes: 1 addition & 2 deletions tests/TestCase/ORM/NativeQueryMapperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,7 @@ public function testSimplestSelect(): void
$cakeEntities = $ArticlesTable->find()
->select(['id', 'title'])
->toArray();
$this->assertEqualsEntities($cakeEntities, $actual);
//static::assertEquals($cakeEntities, $actual);
static::assertEquals($cakeEntities, $actual);
}

public function testSimplestSelectMinimalSQL(): void
Expand Down