Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"php.version": "7.4"
}
9 changes: 9 additions & 0 deletions src/Query.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,13 @@ public function params(): array
{
return $this->params;
}

public function toQueryWithNamedParams(string $template = QueryWithNamedParams::DEFAULT_PARAM_NAME_TEMPLATE): QueryWithNamedParams
{
return new QueryWithNamedParams(
$this->sql(),
$this->params(),
$template
);
}
}
10 changes: 10 additions & 0 deletions src/Query/AbstractQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Latitude\QueryBuilder\ExpressionInterface;
use Latitude\QueryBuilder\Query;
use Latitude\QueryBuilder\QueryInterface;
use Latitude\QueryBuilder\QueryWithNamedParams;

abstract class AbstractQuery implements QueryInterface
{
Expand Down Expand Up @@ -42,4 +43,13 @@ public function params(EngineInterface $engine): array
{
return $this->asExpression()->params($engine);
}

public function toQueryWithNamedParams(EngineInterface $engine, string $template = QueryWithNamedParams::DEFAULT_PARAM_NAME_TEMPLATE): QueryWithNamedParams
{
return new QueryWithNamedParams(
$this->sql($engine),
$this->params($engine),
$template
);
}
}
97 changes: 97 additions & 0 deletions src/QueryWithNamedParams.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

declare(strict_types=1);

namespace Latitude\QueryBuilder;

use function count;
use function preg_replace_callback;
use function ini_get;
use function ini_set;
use function is_bool;
use function sprintf;

final class QueryWithNamedParams
{
public const DEFAULT_PARAM_NAME_TEMPLATE = ':param_%d';

/**
* Matches:
* ? outside of ' " ` [] or sql comments (with capture group)
* Comments --, #, and /* (without capture group)
*/
public const REGEX_PATTERN_QUESTION_MARKS = '/(?:\'(?:\\\\.|[^\\\\\'])*\'|\"(?:\\\\.|[^\\\\\"])*\"|\`(?:\\\\.|[^\\\\\`])*\`|\[(?:\\\\.|[^\[\]])*?\]|(\?)(?=(?:[^\'\"\`\[\]]|\'(?:\\\\.|[^\\\\\'])*\'|\"(?:\\\\.|[^\\\\\"])*\"|\`(?:\\\\.|[^\\\\\`])*\`|\[(?:\\\\.|[^\[\]])*?\])*$)|(?:\-\-[^\r\n]*|\/\*[\s\S]*?\*\/|\#.*))/m';
public const INI_PCRE_JIT = 'pcre.jit';

private string $sql;
private array $params;

public function __construct(string $sql, array $params, string $template = self::DEFAULT_PARAM_NAME_TEMPLATE)
{
if (count($params) == 0) {
$this->sql = $sql;
$this->params = $params;

return;
}

$index = 0;
$namedParams = [];

$sqlWithNamedParams = $this->pregReplaceCallback(
static::REGEX_PATTERN_QUESTION_MARKS,
function (array $match) use ($template, $params, &$index, &$namedParams): string {
if (!$this->isQuestionMarkMatch($match)) {
return $match[0];
}

$key = sprintf($template, $index + 1);
$value = $params[$index];

$namedParams[$key] = $value;

$index++;

return $key;
},
$sql
);

$this->sql = $sqlWithNamedParams;
$this->params = $namedParams;
}

public function sql(): string
{
return $this->sql;
}

public function params(): array
{
return $this->params;
}

private function pregReplaceCallback(string $pattern, callable $callback, string $subject): string
{
/**
* To prevent possible out-of-memory issues, PCRE just in time compilation needs to be temporarily disabled
* This can only happen with large recursive capture groups when nested quotes or very large IN lists are present
*/
$ini = ini_get(static::INI_PCRE_JIT);

ini_set(static::INI_PCRE_JIT, '0');

$result = (string) preg_replace_callback($pattern, $callback, $subject);

if (!is_bool($ini)) {
ini_set(static::INI_PCRE_JIT, $ini);
}

return $result;
}

private function isQuestionMarkMatch(array $match): bool
{
return count($match) > 1;
}
}
79 changes: 79 additions & 0 deletions tests/Query/QueryWithNamedParamsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

namespace Latitude\QueryBuilder\Query;

use Latitude\QueryBuilder\Engine\CommonEngine;
use Latitude\QueryBuilder\Engine\MySqlEngine;
use Latitude\QueryBuilder\Engine\SqlServerEngine;
use Latitude\QueryBuilder\TestCase;

class QueryWithNamedParamsTest extends TestCase
{
public function testCommonEscape(): void
{
$commonEngine = new CommonEngine();

$query = $commonEngine->makeInsert()
->into('users')
->columns('firstname?', 'lastname', 'email')
->values('rick', 'anderson', 'rick.anderson@mail.com')
->compile()
->toQueryWithNamedParams();

$this->assertEquals('INSERT INTO "users" ("firstname?", "lastname", "email") VALUES (:param_1, :param_2, :param_3)', $query->sql());
$this->assertSame(
[
':param_1' => 'rick',
':param_2' => 'anderson',
':param_3' => 'rick.anderson@mail.com'
],
$query->params()
);
}

public function testBackTickEscape(): void
{
$backTicksEngine = new MySqlEngine();

$query = $backTicksEngine->makeInsert()
->into('users')
->columns('firstname?', 'lastname', 'email')
->values('rick', 'anderson', 'rick.anderson@mail.com')
->compile()
->toQueryWithNamedParams('{p%d}');

$this->assertEquals('INSERT INTO `users` (`firstname?`, `lastname`, `email`) VALUES ({p1}, {p2}, {p3})', $query->sql());
$this->assertSame(
[
'{p1}' => 'rick',
'{p2}' => 'anderson',
'{p3}' => 'rick.anderson@mail.com'
],
$query->params()
);
}

public function testBracketsEscape(): void
{
$bracketsEngine = new SqlServerEngine();

$query = $bracketsEngine->makeInsert()
->into('users')
->columns('firstname?', 'lastname', 'email')
->values('rick', 'anderson', 'rick.anderson@mail.com')
->compile()
->toQueryWithNamedParams(':{%d}');

$this->assertEquals('INSERT INTO [users] ([firstname?], [lastname], [email]) VALUES (:{1}, :{2}, :{3})', $query->sql());
$this->assertSame(
[
':{1}' => 'rick',
':{2}' => 'anderson',
':{3}' => 'rick.anderson@mail.com'
],
$query->params()
);
}
}
Loading