diff --git a/inc/functions.php b/inc/functions.php index e6ca2b0..f1ba00f 100644 --- a/inc/functions.php +++ b/inc/functions.php @@ -8,6 +8,7 @@ use RebelCode\Atlas\Expression\ColumnTerm; use RebelCode\Atlas\Expression\ExprInterface; use RebelCode\Atlas\Expression\Term; +use RebelCode\Atlas\Expression\VarExpr as ArgExpr; /** * Creates a new column term. @@ -218,3 +219,8 @@ function andAll(iterable $exprs): ?ExprInterface return $result; } } + +function arg(string $name): ArgExpr +{ + return new ArgExpr($name); +} diff --git a/src/DatabaseAdapter.php b/src/DatabaseAdapter.php index 4f50827..a01f362 100644 --- a/src/DatabaseAdapter.php +++ b/src/DatabaseAdapter.php @@ -7,6 +7,25 @@ /** An adapter for a database connection that allows queries created by Atlas to be executed. */ interface DatabaseAdapter { + /** + * Gets the placeholder to use for a value before preparing the query. + * + * @param string $name The name of the value. + * @param mixed $value The value. + * @return string The placeholder string, such as '?'. + */ + public function getPlaceholder(string $name, $value): string; + + /** + * Replaces ??{var}?? placeholders in the SQL with '?' and collects the + * values in the order they appear in the string. + * + * @param string $sql The SQL string + * @param list $values The values + * @return array{0:string,1:list} The SQL and var values. + */ + public function prepare(string $sql, array $values): array; + /** * Executes a query that returns results. * @@ -14,7 +33,7 @@ interface DatabaseAdapter * @return array[] A list of rows, where each row is a map of column names to values. * @throws DatabaseException If an error occurred while executing the query. */ - public function queryResults(string $sql): array; + public function queryResults(string $sql, array $args = []): array; /** * Executes a query and returns the number of affected rows. @@ -23,7 +42,7 @@ public function queryResults(string $sql): array; * @return int The number of affected rows. * @throws DatabaseException If an error occurred while executing the query. */ - public function queryNumRows(string $sql): int; + public function queryNumRows(string $sql, array $args = []): int; /** * Executes a query and returns whether the query was successful. @@ -32,7 +51,7 @@ public function queryNumRows(string $sql): int; * @return bool True if the query was successful, false otherwise. * @throws DatabaseException If an error occurred while executing the query. */ - public function query(string $sql): bool; + public function query(string $sql, array $args = []): bool; /** * Gets the value generated for an AUTO_INCREMENT column by the last query. diff --git a/src/Expression/VarExpr.php b/src/Expression/VarExpr.php new file mode 100644 index 0000000..e3f55d3 --- /dev/null +++ b/src/Expression/VarExpr.php @@ -0,0 +1,23 @@ +name = $name; + } + + public function getName(): string + { + return $this->name; + } + + protected function toBaseString(): string + { + return '??{' . $this->name . '}??'; + } +} diff --git a/src/Query.php b/src/Query.php index 540c794..0a5e6ca 100644 --- a/src/Query.php +++ b/src/Query.php @@ -46,8 +46,42 @@ abstract public function toSql(): string; /** * Executes the query. * + * @param array $args A map of values to interpolate. The keys + * correspond to {@link VarExpr} names. * @return mixed The query result. * @throws DatabaseException If an error occurred while executing the query. */ - abstract public function exec(); + abstract public function exec(array $args = []); + + /** + * Replaces ??{var}?? placeholders in the SQL with '?' and collects the + * values in the order they appear in the string. + * + * @param string $sql The SQL string + * @param array $args An array of args, mapping each arg name to its value. + * @return array{0:string,1:list} The SQL and var values. + */ + protected function templateVars(string $sql, array $args) + { + if (count($args) === 0) { + return [$sql, []]; + } + + $vars = []; + $result = preg_replace_callback( + '/(\?\?\{([\w\d_]+)\}\?\?)/im', + function (array $match) use ($args, &$vars) { + $name = trim($match[2] ?? ''); + if (empty($name) && !array_key_exists($name, $args)) { + return $match[1]; + } + $value = $args[$name]; + $vars[] = $value; + return $this->adapter->getPlaceholder($name, $value); + }, + $sql + ); + + return [$result, $vars]; + } } diff --git a/src/Query/CompoundQuery.php b/src/Query/CompoundQuery.php index 7f45bab..428defa 100644 --- a/src/Query/CompoundQuery.php +++ b/src/Query/CompoundQuery.php @@ -48,11 +48,11 @@ public function toSql(): string } /** @inheritDoc */ - public function exec() + public function exec(array $args = []) { $results = []; foreach ($this->queries as $query) { - $results[] = $query->exec(); + $results[] = $query->exec($args); } return $results; diff --git a/src/Query/CreateIndexQuery.php b/src/Query/CreateIndexQuery.php index 0c15d74..184a2a8 100644 --- a/src/Query/CreateIndexQuery.php +++ b/src/Query/CreateIndexQuery.php @@ -75,8 +75,10 @@ public function toSql(): string * * @return bool True if the index was created successfully, false otherwise. */ - public function exec(): bool + public function exec(array $args = []): bool { - return $this->getAdapter()->query($this->toSql()); + [$sql, $values] = $this->templateVars($this->toSql(), $args); + + return $this->getAdapter()->query($sql, $values); } } diff --git a/src/Query/CreateTableQuery.php b/src/Query/CreateTableQuery.php index 27b8cf8..28f1715 100644 --- a/src/Query/CreateTableQuery.php +++ b/src/Query/CreateTableQuery.php @@ -94,8 +94,10 @@ protected function compileSchema(Schema $schema): string * * @return bool True if the query was executed successfully, false otherwise. */ - public function exec(): bool + public function exec(array $args = []): bool { - return $this->getAdapter()->query($this->toSql()); + [$sql, $values] = $this->templateVars($this->toSql(), $args); + + return $this->getAdapter()->query($sql, $values); } } diff --git a/src/Query/DeleteQuery.php b/src/Query/DeleteQuery.php index 40abc43..30cfdba 100644 --- a/src/Query/DeleteQuery.php +++ b/src/Query/DeleteQuery.php @@ -80,8 +80,10 @@ public function toSql(): string * @return int The number of rows affected by the query. * @throws DatabaseException If an error occurred while executing the query. */ - public function exec(): int + public function exec(array $args = []): int { - return $this->getAdapter()->queryNumRows($this->toSql()); + [$sql, $values] = $this->templateVars($this->toSql(), $args); + + return $this->getAdapter()->queryNumRows($sql, $values); } } diff --git a/src/Query/DropTableQuery.php b/src/Query/DropTableQuery.php index 9def937..371192a 100644 --- a/src/Query/DropTableQuery.php +++ b/src/Query/DropTableQuery.php @@ -65,8 +65,10 @@ public function toSql(): string * * @return bool True if the table was dropped, false if not. */ - public function exec(): bool + public function exec(array $args = []): bool { - return $this->getAdapter()->query($this->toSql()); + [$sql, $values] = $this->templateVars($this->toSql(), $args); + + return $this->getAdapter()->query($sql, $values); } } diff --git a/src/Query/InsertQuery.php b/src/Query/InsertQuery.php index 5be10a7..ba3024b 100644 --- a/src/Query/InsertQuery.php +++ b/src/Query/InsertQuery.php @@ -153,10 +153,12 @@ protected function compileInsertValues(): string * * @return int|null The last inserted ID, or null if no rows were inserted. */ - public function exec(): ?int + public function exec(array $args = []): ?int { + [$sql, $values] = $this->templateVars($this->toSql(), $args); + $adapter = $this->getAdapter(); - $numRows = $adapter->queryNumRows($this->toSql()); + $numRows = $adapter->queryNumRows($sql, $values); if ($numRows > 0) { return $adapter->getInsertId(); diff --git a/src/Query/SelectQuery.php b/src/Query/SelectQuery.php index 4089e4b..c8947ec 100644 --- a/src/Query/SelectQuery.php +++ b/src/Query/SelectQuery.php @@ -220,8 +220,10 @@ public function compileSource(): string * * @return array[] A list of rows, where each row is a map of column names to values. */ - public function exec(): array + public function exec(array $args = []): array { - return $this->getAdapter()->queryResults($this->toSql()); + [$sql, $values] = $this->templateVars($this->toSql(), $args); + + return $this->getAdapter()->queryResults($sql, $values); } } diff --git a/src/Query/UpdateQuery.php b/src/Query/UpdateQuery.php index 097a286..ac33c46 100644 --- a/src/Query/UpdateQuery.php +++ b/src/Query/UpdateQuery.php @@ -83,8 +83,10 @@ public function toSql(): string * @return int The number of rows affected by the query. * @throws DatabaseException If an error occurred while executing the query. */ - public function exec(): int + public function exec(array $args = []): int { - return $this->getAdapter()->queryNumRows($this->toSql()); + [$sql, $values] = $this->templateVars($this->toSql(), $args); + + return $this->getAdapter()->queryNumRows($sql, $values); } } diff --git a/tests/Query/SelectQueryTest.php b/tests/Query/SelectQueryTest.php index 721c8b6..3072437 100644 --- a/tests/Query/SelectQueryTest.php +++ b/tests/Query/SelectQueryTest.php @@ -2,11 +2,14 @@ namespace RebelCode\Atlas\Test\Query; +use PHPUnit\Framework\MockObject\MockClass; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use RebelCode\Atlas\DatabaseAdapter; use RebelCode\Atlas\DataSource; use RebelCode\Atlas\Expression\ColumnTerm; use RebelCode\Atlas\Expression\ExprInterface; +use RebelCode\Atlas\Expression\VarExpr; use RebelCode\Atlas\Group; use RebelCode\Atlas\Join; use RebelCode\Atlas\Order; @@ -14,6 +17,8 @@ use RebelCode\Atlas\Query\SelectQuery; use RebelCode\Atlas\Test\Helpers\ReflectionHelper; +use function RebelCode\Atlas\andAll; +use function RebelCode\Atlas\arg; use function RebelCode\Atlas\col; use function RebelCode\Atlas\table; @@ -467,4 +472,31 @@ public function testNotExists() $this->assertEquals("NOT EXISTS(SELECT * FROM `test` WHERE (`role` = 'admin'))", $exists->toSql()); } + + public function testWithVars() + { + $args = ['foo' => 'VALUE1', 'bar' => 'VALUE2']; + + $adapter = $this->createMock(DatabaseAdapter::class); + $adapter->expects($this->exactly(2)) + ->method('getPlaceholder') + ->withConsecutive(['bar', 'VALUE2'], ['foo', 'VALUE1']) + ->willReturnOnConsecutiveCalls('', ''); + + $adapter->expects($this->once()) + ->method('queryResults') + ->with( + 'SELECT * FROM `test` WHERE ((`name` = ) AND (`age` = ))', + ['VALUE2', 'VALUE1'] + ); + + $query = (new SelectQuery($adapter)) + ->from(table('test')) + ->where(andAll([ + col('name')->eq(arg('bar')), + col('age')->eq(arg('foo')), + ])); + + $query->exec($args); + } }