Skip to content

Commit c4d73f2

Browse files
authored
Merge pull request #11 from bancer/InPlaceholders
Add InPlaceholders class
2 parents 9906cb3 + 530d813 commit c4d73f2

4 files changed

Lines changed: 282 additions & 4 deletions

File tree

README.md

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,41 @@ You can find more examples in tests - https://github.com/bancer/native-sql-mappe
142142

143143
---
144144

145+
## ➕ BONUS: IN() placeholder helper for native SQL
146+
147+
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.
148+
149+
The `InPlaceholders` class provides a small, explicit helper that removes this boilerplate while keeping native SQL fully transparent and predictable.
150+
151+
##### What it does
152+
153+
`InPlaceholders` is a **value object** that:
154+
155+
- Generates named placeholders for use inside SQL `IN (…)` clauses
156+
- Binds all values to a prepared statement safely
157+
- Infers the correct PDO parameter type automatically (or accepts one explicitly)
158+
- Fails fast on invalid input (empty prefix or empty value list)
159+
160+
There is **no ORM magic** involved — this works purely at the native SQL / PDO level.
161+
162+
##### Basic example
163+
164+
```php
165+
use Bancer\NativeQueryMapper\Database\InPlaceholders;
166+
167+
$statuses = new InPlaceholders('status', [1, 5, 9]);
168+
$sql = <<<SQL
169+
SELECT email as Users__email
170+
FROM users
171+
WHERE status_id IN ($statuses)
172+
SQL;
173+
$stmt = $this->prepareNativeStatement($sql);
174+
$statuses->bindValuesToStatement($stmt);
175+
$entities = $this->mapNativeStatement($stmt)->all();
176+
```
177+
178+
---
179+
145180
## ⚠️ Requirements
146181

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

169204
---
170-
```
171-

src/Database/InPlaceholders.php

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Bancer\NativeQueryMapper\Database;
6+
7+
use Cake\Database\StatementInterface;
8+
use InvalidArgumentException;
9+
use PDO;
10+
11+
/**
12+
* Value object representing a set of named placeholders for use in SQL IN() clauses.
13+
*
14+
* This class encapsulates:
15+
* - Placeholder generation (via __toString())
16+
* - Safe binding of multiple scalar values to a prepared statement
17+
* - Lazy inference of the appropriate PDO parameter type
18+
*
19+
* Example:
20+
* ```php
21+
* $statuses = new InPlaceholders('status', [1, 5, 9]);
22+
* $sql = "SELECT email AS Users_email FROM users WHERE status_id IN ($statuses)";
23+
* $stmt = $this->prepareNativeStatement($sql);
24+
* $statuses->bindValuesToStatement($stmt);
25+
* ```
26+
*
27+
* Resulting SQL:
28+
* ```sql
29+
* SELECT email AS Users_email FROM users WHERE status_id status_id IN (:status_0, :status_1, :status_2)
30+
* ```
31+
*/
32+
class InPlaceholders
33+
{
34+
/**
35+
* Placeholder name prefix (without colon).
36+
*
37+
* Example: "status" -> :status_0, :status_1, ...
38+
*
39+
* @var string
40+
*/
41+
private string $prefix;
42+
43+
/**
44+
* Scalar values to be bound to the placeholders.
45+
*
46+
* @var list<scalar>
47+
*/
48+
private array $values;
49+
50+
/**
51+
* PDO parameter type used when binding values.
52+
*
53+
* If not provided, the type is inferred from the first value.
54+
*
55+
* @var string|int|null
56+
*/
57+
private $pdoType;
58+
59+
/**
60+
* Constructor.
61+
*
62+
* @param string $prefix Placeholder prefix (eg. "status").
63+
* @param list<scalar> $values Values for the IN() clause
64+
* @param string|int|null $pdoType PDO::PARAM_* constant or name of configured Type class.
65+
* Same as `$type` parameter of \Cake\Database\StatementInterface::bindValue()
66+
*/
67+
public function __construct(string $prefix, array $values, $pdoType = null)
68+
{
69+
if ($prefix === '') {
70+
throw new InvalidArgumentException('IN() placeholders cannot be constructed with an empty prefix');
71+
}
72+
if ($values === []) {
73+
throw new InvalidArgumentException('IN() placeholders cannot be constructed with an empty value list');
74+
}
75+
$this->prefix = $prefix;
76+
$this->values = $values;
77+
$this->pdoType = $pdoType;
78+
}
79+
80+
/**
81+
* Bind all placeholder values to the prepared statement.
82+
*
83+
* Placeholders are bound using the pattern:
84+
* :{prefix}_{index}
85+
*
86+
* @param \Cake\Database\StatementInterface $stmt Prepared statement.
87+
* @return void
88+
*/
89+
public function bindValuesToStatement(StatementInterface $stmt): void
90+
{
91+
foreach ($this->values as $index => $value) {
92+
$stmt->bindValue($this->prefix . '_' . $index, $value, $this->getPdoType());
93+
}
94+
}
95+
96+
/**
97+
* Resolve the PDO parameter type.
98+
*
99+
* If the type was not provided in the constructor, it is inferred lazily
100+
* from the first value in the list.
101+
*
102+
* @return string|int PDO::PARAM_* constant or name of configured Type class.
103+
*/
104+
private function getPdoType()
105+
{
106+
if (!isset($this->pdoType)) {
107+
$this->pdoType = $this->inferPdoType();
108+
}
109+
return $this->pdoType;
110+
}
111+
112+
/**
113+
* Infer the PDO parameter type from the first value.
114+
*
115+
* @return int PDO::PARAM_* constant
116+
*/
117+
private function inferPdoType(): int
118+
{
119+
$first = $this->values[0];
120+
if (is_int($first)) {
121+
return PDO::PARAM_INT;
122+
}
123+
if (is_bool($first)) {
124+
return PDO::PARAM_BOOL;
125+
}
126+
return PDO::PARAM_STR;
127+
}
128+
129+
/**
130+
* Generate the SQL placeholder list for use inside an IN() clause.
131+
*
132+
* Example output:
133+
* ```sql
134+
* :status_0, :status_1, :status_2
135+
* ```
136+
*
137+
* @return string
138+
*/
139+
public function __toString(): string
140+
{
141+
$placeholders = [];
142+
foreach ($this->values as $index => $_) {
143+
$placeholders[] = ':' . $this->prefix . '_' . $index;
144+
}
145+
return implode(', ', $placeholders);
146+
}
147+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Bancer\NativeQueryMapper\Test\TestCase\Database;
6+
7+
use Bancer\NativeQueryMapperTest\TestApp\Model\Entity\Article;
8+
use Bancer\NativeQueryMapperTest\TestApp\Model\Entity\User;
9+
use Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable;
10+
use Bancer\NativeQueryMapperTest\TestApp\Model\Table\UsersTable;
11+
use Bancer\NativeQueryMapper\Database\InPlaceholders;
12+
use Cake\ORM\Locator\LocatorAwareTrait;
13+
use InvalidArgumentException;
14+
use PHPUnit\Framework\TestCase;
15+
16+
class InPlaceholdersTest extends TestCase
17+
{
18+
use LocatorAwareTrait;
19+
20+
public function testConstructorEmptyPrefix(): void
21+
{
22+
static::expectException(InvalidArgumentException::class);
23+
static::expectExceptionMessage('IN() placeholders cannot be constructed with an empty prefix');
24+
new InPlaceholders('', []);
25+
}
26+
27+
public function testConstructorEmptyValues(): void
28+
{
29+
static::expectException(InvalidArgumentException::class);
30+
static::expectExceptionMessage('IN() placeholders cannot be constructed with an empty value list');
31+
new InPlaceholders('status', []);
32+
}
33+
34+
public function testToString(): void
35+
{
36+
$InPlaceholders = new InPlaceholders('id', [3, 5]);
37+
$expected = ':id_0, :id_1';
38+
$actual = $InPlaceholders->__toString();
39+
static::assertSame($expected, $actual);
40+
}
41+
42+
public function testBindValuesToStatementInt(): void
43+
{
44+
$userIds = [2, 5];
45+
$inPlaceholders = new InPlaceholders('user', $userIds);
46+
/** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */
47+
$ArticlesTable = $this->fetchTable(ArticlesTable::class);
48+
$stmt = $ArticlesTable->prepareNativeStatement("
49+
SELECT
50+
id AS Articles__id,
51+
title AS Articles__title
52+
FROM articles AS a
53+
WHERE a.user_id IN($inPlaceholders)
54+
");
55+
$inPlaceholders->bindValuesToStatement($stmt);
56+
$actual = $ArticlesTable->mapNativeStatement($stmt)->all();
57+
static::assertCount(2, $actual);
58+
static::assertInstanceOf(Article::class, $actual[0]);
59+
$expected = [
60+
'id' => 2,
61+
'title' => 'Article 2',
62+
];
63+
static::assertEquals($expected, $actual[0]->toArray());
64+
$cakeEntities = $ArticlesTable->find()
65+
->select(['id', 'title'])
66+
->where(['user_id IN' => $userIds])
67+
->toArray();
68+
static::assertEquals($cakeEntities, $actual);
69+
}
70+
71+
public function testBindValuesToStatementStrings(): void
72+
{
73+
$users = ['bob', 'eve'];
74+
$inPlaceholders = new InPlaceholders('user', $users);
75+
/** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\UsersTable $UsersTable */
76+
$UsersTable = $this->fetchTable(UsersTable::class);
77+
$stmt = $UsersTable->prepareNativeStatement("
78+
SELECT
79+
id AS Users__id,
80+
username AS Users__username
81+
FROM users AS u
82+
WHERE u.username IN($inPlaceholders)
83+
");
84+
$inPlaceholders->bindValuesToStatement($stmt);
85+
$actual = $UsersTable->mapNativeStatement($stmt)->all();
86+
static::assertCount(2, $actual);
87+
static::assertInstanceOf(User::class, $actual[0]);
88+
$expected = [
89+
'id' => 2,
90+
'username' => 'bob',
91+
];
92+
static::assertEquals($expected, $actual[0]->toArray());
93+
$cakeEntities = $UsersTable->find()
94+
->select(['id', 'username'])
95+
->where(['username IN' => $users])
96+
->toArray();
97+
static::assertEquals($cakeEntities, $actual);
98+
}
99+
}

tests/TestCase/ORM/NativeQueryMapperTest.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,7 @@ public function testSimplestSelect(): void
161161
$cakeEntities = $ArticlesTable->find()
162162
->select(['id', 'title'])
163163
->toArray();
164-
$this->assertEqualsEntities($cakeEntities, $actual);
165-
//static::assertEquals($cakeEntities, $actual);
164+
static::assertEquals($cakeEntities, $actual);
166165
}
167166

168167
public function testSimplestSelectMinimalSQL(): void

0 commit comments

Comments
 (0)