Implement parameter conversion and named parameters
[MAILPOET-6142]
This commit is contained in:
84
mailpoet/lib/Doctrine/WPDB/ConvertParameters.php
Normal file
84
mailpoet/lib/Doctrine/WPDB/ConvertParameters.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php declare (strict_types = 1);
|
||||
|
||||
namespace MailPoet\Doctrine\WPDB;
|
||||
|
||||
use MailPoet\Doctrine\WPDB\Exceptions\MissingParameterException;
|
||||
use MailPoetVendor\Doctrine\DBAL\ParameterType;
|
||||
use MailPoetVendor\Doctrine\DBAL\SQL\Parser\Visitor;
|
||||
|
||||
class ConvertParameters implements Visitor {
|
||||
private const PARAM_TYPE_MAP = [
|
||||
ParameterType::STRING => '%s',
|
||||
ParameterType::INTEGER => '%d',
|
||||
ParameterType::ASCII => '%s',
|
||||
ParameterType::BINARY => '%s',
|
||||
ParameterType::BOOLEAN => '%d',
|
||||
ParameterType::NULL => '%s',
|
||||
ParameterType::LARGE_OBJECT => '%s',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
private array $buffer = [];
|
||||
|
||||
/** @var array<array-key, array{0: string, 1: mixed, 2: int}> */
|
||||
private array $params;
|
||||
|
||||
private array $values = [];
|
||||
|
||||
private int $cursor = 1;
|
||||
|
||||
public function __construct(
|
||||
array $params
|
||||
) {
|
||||
$this->params = $params;
|
||||
}
|
||||
|
||||
public function acceptPositionalParameter(string $sql): void {
|
||||
$position = $this->cursor++;
|
||||
$this->acceptParameter($position);
|
||||
}
|
||||
|
||||
public function acceptNamedParameter(string $sql): void {
|
||||
$this->acceptParameter(trim($sql, ':'));
|
||||
}
|
||||
|
||||
public function acceptOther(string $sql): void {
|
||||
$this->buffer[] = $sql;
|
||||
}
|
||||
|
||||
public function getSQL(): string {
|
||||
return implode('', $this->buffer);
|
||||
}
|
||||
|
||||
public function getValues(): array {
|
||||
return $this->values;
|
||||
}
|
||||
|
||||
/** @param array-key $key */
|
||||
private function acceptParameter($key): void {
|
||||
if (!array_key_exists($key, $this->params)) {
|
||||
throw new MissingParameterException(sprintf("Parameter '%s' was defined in the query, but not provided.", $key));
|
||||
}
|
||||
[, $value, $type] = $this->params[$key];
|
||||
|
||||
// WPDB doesn't support NULL values. We need to handle them explicitly.
|
||||
if ($value === null) {
|
||||
$this->buffer[] = 'NULL';
|
||||
return;
|
||||
}
|
||||
|
||||
// WPDB doesn't accept non-scalar values. We need to cast them (PDO-like behavior).
|
||||
if (!is_scalar($value)) {
|
||||
if ($type === ParameterType::INTEGER) {
|
||||
$value = (int)$value; // @phpstan-ignore-line -- cast may fail and that's OK
|
||||
} elseif ($type === ParameterType::BOOLEAN) {
|
||||
$value = (bool)$value;
|
||||
} else {
|
||||
$value = (string)$value; // @phpstan-ignore-line -- cast may fail and that's OK
|
||||
}
|
||||
}
|
||||
|
||||
$this->values[] = $value;
|
||||
$this->buffer[] = self::PARAM_TYPE_MAP[$type] ?? '%s';
|
||||
}
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Doctrine\WPDB\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class MissingParameterException extends Exception {
|
||||
}
|
@@ -6,9 +6,11 @@ use MailPoet\Doctrine\WPDB\Exceptions\NotSupportedException;
|
||||
use MailPoetVendor\Doctrine\DBAL\Driver\Result;
|
||||
use MailPoetVendor\Doctrine\DBAL\Driver\Statement as StatementInterface;
|
||||
use MailPoetVendor\Doctrine\DBAL\ParameterType;
|
||||
use MailPoetVendor\Doctrine\DBAL\SQL\Parser;
|
||||
|
||||
class Statement implements StatementInterface {
|
||||
private Connection $connection;
|
||||
private Parser $parser;
|
||||
private string $sql;
|
||||
private array $params = [];
|
||||
|
||||
@@ -17,6 +19,7 @@ class Statement implements StatementInterface {
|
||||
string $sql
|
||||
) {
|
||||
$this->connection = $connection;
|
||||
$this->parser = new Parser(false);
|
||||
$this->sql = $sql;
|
||||
}
|
||||
|
||||
@@ -51,16 +54,12 @@ class Statement implements StatementInterface {
|
||||
);
|
||||
}
|
||||
|
||||
// Convert "?" placeholders to sprintf-like format expected by WPDB (basic implementation).
|
||||
// Note that this doesn't parse the SQL query properly and doesn't support named parameters.
|
||||
$sql = $this->sql;
|
||||
$values = [];
|
||||
foreach ($this->params as [$param, $value, $type]) {
|
||||
$replacement = $type === ParameterType::INTEGER || ParameterType::BOOLEAN ? '%d' : '%s';
|
||||
$pos = strpos($this->sql, '?');
|
||||
$sql = substr_replace($this->sql, $replacement, $pos, 1);
|
||||
$values[$param] = $value;
|
||||
}
|
||||
// Convert '?' parameters to WPDB format (sprintf-like: '%s', '%d', ...),
|
||||
// and add support for named parameters that are not supported by mysqli.
|
||||
$visitor = new ConvertParameters($this->params);
|
||||
$this->parser->parse($this->sql, $visitor);
|
||||
$sql = $visitor->getSQL();
|
||||
$values = $visitor->getValues();
|
||||
|
||||
global $wpdb;
|
||||
$query = count($values) > 0 ? $wpdb->prepare($sql, $values) : $sql;
|
||||
|
173
mailpoet/tests/unit/Doctrine/WPDB/ConvertParametersTest.php
Normal file
173
mailpoet/tests/unit/Doctrine/WPDB/ConvertParametersTest.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Doctrine\WPDB;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use MailPoet\Doctrine\WPDB\Exceptions\MissingParameterException;
|
||||
use MailPoetUnitTest;
|
||||
use MailPoetVendor\Doctrine\DBAL\ParameterType;
|
||||
use stdClass;
|
||||
|
||||
class ConvertParametersTest extends MailPoetUnitTest {
|
||||
public function testPositionalParameters(): void {
|
||||
$params = new ConvertParameters([
|
||||
1 => [1, 123, ParameterType::INTEGER],
|
||||
2 => [2, 'aaa', ParameterType::STRING],
|
||||
3 => [3, true, ParameterType::BOOLEAN],
|
||||
]);
|
||||
|
||||
$params->acceptOther('SELECT * FROM test_table WHERE id = ');
|
||||
$params->acceptPositionalParameter('?');
|
||||
$params->acceptOther(' AND value = ');
|
||||
$params->acceptPositionalParameter('?');
|
||||
$params->acceptOther(' AND isDeleted = ');
|
||||
$params->acceptPositionalParameter('?');
|
||||
|
||||
$this->assertSame(
|
||||
'SELECT * FROM test_table WHERE id = %d AND value = %s AND isDeleted = %d',
|
||||
$params->getSQL()
|
||||
);
|
||||
$this->assertSame([123, 'aaa', true], $params->getValues());
|
||||
}
|
||||
|
||||
public function testNamedParameters(): void {
|
||||
$params = new ConvertParameters([
|
||||
'id' => ['id', 123, ParameterType::INTEGER],
|
||||
'value' => ['value', 'aaa', ParameterType::STRING],
|
||||
'isDeleted' => ['isDeleted', true, ParameterType::BOOLEAN],
|
||||
]);
|
||||
|
||||
$params->acceptOther('SELECT * FROM test_table WHERE id = ');
|
||||
$params->acceptNamedParameter(':id');
|
||||
$params->acceptOther(' AND value = ');
|
||||
$params->acceptNamedParameter(':value');
|
||||
$params->acceptOther(' AND isDeleted = ');
|
||||
$params->acceptNamedParameter(':isDeleted');
|
||||
|
||||
$this->assertSame(
|
||||
'SELECT * FROM test_table WHERE id = %d AND value = %s AND isDeleted = %d',
|
||||
$params->getSQL()
|
||||
);
|
||||
$this->assertSame([123, 'aaa', true], $params->getValues());
|
||||
}
|
||||
|
||||
public function testRepeatedNamedParameters(): void {
|
||||
$params = new ConvertParameters([
|
||||
'value' => ['value', 'aaa', ParameterType::STRING],
|
||||
]);
|
||||
|
||||
$params->acceptOther('SELECT * FROM test_table WHERE value1 = ');
|
||||
$params->acceptNamedParameter(':value');
|
||||
$params->acceptOther(' AND value2 = ');
|
||||
$params->acceptNamedParameter(':value');
|
||||
|
||||
$this->assertSame(
|
||||
'SELECT * FROM test_table WHERE value1 = %s AND value2 = %s',
|
||||
$params->getSQL()
|
||||
);
|
||||
$this->assertSame(['aaa', 'aaa'], $params->getValues());
|
||||
}
|
||||
|
||||
public function testMixedParameters(): void {
|
||||
$params = new ConvertParameters([
|
||||
1 => [1, 123, ParameterType::INTEGER],
|
||||
2 => [2, 'aaa', ParameterType::STRING],
|
||||
3 => [3, true, ParameterType::BOOLEAN],
|
||||
'named1' => ['named1', 'bbb', ParameterType::STRING],
|
||||
'named2' => ['named2', 'ccc', ParameterType::ASCII],
|
||||
]);
|
||||
|
||||
$params->acceptOther('SELECT * FROM test_table WHERE id = ');
|
||||
$params->acceptPositionalParameter('?');
|
||||
$params->acceptOther(' AND named1 = ');
|
||||
$params->acceptNamedParameter(':named1');
|
||||
$params->acceptOther(' AND value = ');
|
||||
$params->acceptPositionalParameter('?');
|
||||
$params->acceptOther(' AND named2 = ');
|
||||
$params->acceptNamedParameter(':named2');
|
||||
$params->acceptOther(' AND isDeleted = ');
|
||||
$params->acceptPositionalParameter('?');
|
||||
|
||||
$this->assertSame(
|
||||
'SELECT * FROM test_table WHERE id = %d AND named1 = %s AND value = %s AND named2 = %s AND isDeleted = %d',
|
||||
$params->getSQL()
|
||||
);
|
||||
$this->assertSame([123, 'bbb', 'aaa', 'ccc', true], $params->getValues());
|
||||
}
|
||||
|
||||
public function testMissingPositionalParameter(): void {
|
||||
$params = new ConvertParameters([
|
||||
1 => [1, 123, ParameterType::INTEGER],
|
||||
]);
|
||||
|
||||
$params->acceptOther('SELECT * FROM test_table WHERE id = ');
|
||||
$params->acceptPositionalParameter('?');
|
||||
$params->acceptOther(' AND value = ');
|
||||
|
||||
$this->expectException(MissingParameterException::class);
|
||||
$this->expectExceptionMessage("Parameter '2' was defined in the query, but not provided.");
|
||||
|
||||
$params->acceptPositionalParameter('?');
|
||||
}
|
||||
|
||||
public function testMissingNamedParameter(): void {
|
||||
$params = new ConvertParameters([
|
||||
'id' => ['id', 123, ParameterType::INTEGER],
|
||||
]);
|
||||
|
||||
$params->acceptOther('SELECT * FROM test_table WHERE id = ');
|
||||
$params->acceptNamedParameter(':id');
|
||||
$params->acceptOther(' AND value = ');
|
||||
|
||||
$this->expectException(MissingParameterException::class);
|
||||
$this->expectExceptionMessage("Parameter 'value' was defined in the query, but not provided.");
|
||||
|
||||
$params->acceptNamedParameter(':value');
|
||||
}
|
||||
|
||||
public function testNullValues(): void {
|
||||
$params = new ConvertParameters([
|
||||
1 => [1, null, ParameterType::STRING],
|
||||
'named' => ['named', null, ParameterType::INTEGER],
|
||||
]);
|
||||
|
||||
$params->acceptOther('SELECT * FROM test_table WHERE id = ');
|
||||
$params->acceptPositionalParameter('?');
|
||||
$params->acceptOther(' AND value = ');
|
||||
$params->acceptNamedParameter(':named');
|
||||
|
||||
$this->assertSame(
|
||||
'SELECT * FROM test_table WHERE id = NULL AND value = NULL',
|
||||
$params->getSQL()
|
||||
);
|
||||
$this->assertSame([], $params->getValues());
|
||||
}
|
||||
|
||||
public function testNonScalarValues(): void {
|
||||
$dateTime = new class('2021-01-01 12:34:56', new DateTimeZone('UTC')) extends DateTimeImmutable {
|
||||
public function __toString(): string {
|
||||
return $this->format(DateTimeImmutable::W3C);
|
||||
}
|
||||
};
|
||||
|
||||
$params = new ConvertParameters([
|
||||
1 => [1, $dateTime, ParameterType::STRING],
|
||||
2 => [2, new stdClass(), ParameterType::BOOLEAN],
|
||||
3 => [3, ['abc'], ParameterType::INTEGER],
|
||||
]);
|
||||
|
||||
$params->acceptOther('SELECT * FROM test_table WHERE datetime = ');
|
||||
$params->acceptPositionalParameter('?');
|
||||
$params->acceptOther(' AND boolean = ');
|
||||
$params->acceptPositionalParameter('?');
|
||||
$params->acceptOther(' AND integer = ');
|
||||
$params->acceptPositionalParameter('?');
|
||||
|
||||
$this->assertSame(
|
||||
'SELECT * FROM test_table WHERE datetime = %s AND boolean = %d AND integer = %d',
|
||||
$params->getSQL()
|
||||
);
|
||||
$this->assertSame(['2021-01-01T12:34:56+00:00', true, 1], $params->getValues());
|
||||
}
|
||||
}
|
@@ -63,4 +63,61 @@ class StatementTest extends MailPoetUnitTest {
|
||||
$statement->bindValue(1, 'abc');
|
||||
$statement->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider parameterReplacementProvider
|
||||
*/
|
||||
public function testParameterReplacement(string $inputSql, string $outputSql, int $parameterCount): void {
|
||||
$wpdb = $this->getMockBuilder(stdClass::class)->addMethods(['prepare'])->getMock();
|
||||
$wpdb->expects($this->once())
|
||||
->method('prepare')
|
||||
->with($outputSql)
|
||||
->willReturn('');
|
||||
|
||||
$GLOBALS['wpdb'] = $wpdb;
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$statement = new Statement($connection, $inputSql);
|
||||
for ($i = 1; $i <= $parameterCount; $i++) {
|
||||
$statement->bindValue($i, 'abc');
|
||||
}
|
||||
$statement->execute();
|
||||
}
|
||||
|
||||
public function parameterReplacementProvider(): iterable {
|
||||
yield 'simple' => [
|
||||
'SELECT * FROM test_table WHERE value = ?',
|
||||
'SELECT * FROM test_table WHERE value = %s',
|
||||
1,
|
||||
];
|
||||
|
||||
yield 'with ? in string' => [
|
||||
"SELECT * FROM test_table WHERE value = ? AND name = 'a?c'",
|
||||
"SELECT * FROM test_table WHERE value = %s AND name = 'a?c'",
|
||||
1,
|
||||
];
|
||||
|
||||
yield 'with ? in string and multiple parameters' => [
|
||||
"SELECT * FROM test_table WHERE value = ? AND name = 'a?c' AND id = ?",
|
||||
"SELECT * FROM test_table WHERE value = %s AND name = 'a?c' AND id = %s",
|
||||
2,
|
||||
];
|
||||
|
||||
yield 'with JOIN' => [
|
||||
'SELECT * FROM test_table JOIN other_table ON test_table.id = other_table.id WHERE value = ?',
|
||||
'SELECT * FROM test_table JOIN other_table ON test_table.id = other_table.id WHERE value = %s',
|
||||
1,
|
||||
];
|
||||
|
||||
yield 'with subquery' => [
|
||||
"SELECT * FROM test_table WHERE value = ? AND name = (SELECT name FROM other_table WHERE id = ?)",
|
||||
'SELECT * FROM test_table WHERE value = %s AND name = (SELECT name FROM other_table WHERE id = %s)',
|
||||
2,
|
||||
];
|
||||
|
||||
yield 'complex' => [
|
||||
"SELECT CONCAT(key, '?') FROM test_table WHERE value = ? AND name = 'a?c' AND id = ? AND (SELECT name FROM other_table WHERE id = ?)",
|
||||
"SELECT CONCAT(key, '?') FROM test_table WHERE value = %s AND name = 'a?c' AND id = %s AND (SELECT name FROM other_table WHERE id = %s)",
|
||||
3,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user