diff --git a/README.md b/README.md index cd28a31..8a7589a 100644 --- a/README.md +++ b/README.md @@ -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 = <<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**) @@ -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. --- -``` - diff --git a/src/Database/InPlaceholders.php b/src/Database/InPlaceholders.php new file mode 100644 index 0000000..6ba0183 --- /dev/null +++ b/src/Database/InPlaceholders.php @@ -0,0 +1,147 @@ +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 + */ + 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 $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); + } +} diff --git a/tests/TestCase/Database/InPlaceholdersTest.php b/tests/TestCase/Database/InPlaceholdersTest.php new file mode 100644 index 0000000..dacf697 --- /dev/null +++ b/tests/TestCase/Database/InPlaceholdersTest.php @@ -0,0 +1,99 @@ +__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); + } +} diff --git a/tests/TestCase/ORM/NativeQueryMapperTest.php b/tests/TestCase/ORM/NativeQueryMapperTest.php index 64523e2..d184880 100644 --- a/tests/TestCase/ORM/NativeQueryMapperTest.php +++ b/tests/TestCase/ORM/NativeQueryMapperTest.php @@ -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