Skip to content

Commit

Permalink
Merge pull request #5817 from kenjis/feat-qb-raw-sql
Browse files Browse the repository at this point in the history
feat: QueryBuilder raw SQL string support
  • Loading branch information
kenjis authored May 4, 2022
2 parents ab63c8d + a02151b commit 2a5322b
Show file tree
Hide file tree
Showing 11 changed files with 483 additions and 111 deletions.
127 changes: 93 additions & 34 deletions system/Database/BaseBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ public function ignore(bool $ignore = true)
/**
* Generates the SELECT portion of the query
*
* @param array|string $select
* @param array|RawSql|string $select
*
* @return $this
*/
Expand All @@ -371,6 +371,12 @@ public function select($select = '*', ?bool $escape = null)
$escape = $this->db->protectIdentifiers;
}

if ($select instanceof RawSql) {
$this->QBSelect[] = $select;

return $this;
}

foreach ($select as $val) {
$val = trim($val);

Expand Down Expand Up @@ -643,8 +649,8 @@ public function join(string $table, string $cond, string $type = '', ?bool $esca
* Generates the WHERE portion of the query.
* Separates multiple calls with 'AND'.
*
* @param mixed $key
* @param mixed $value
* @param array|RawSql|string $key
* @param mixed $value
*
* @return $this
*/
Expand All @@ -659,9 +665,9 @@ public function where($key, $value = null, ?bool $escape = null)
* Generates the WHERE portion of the query.
* Separates multiple calls with 'OR'.
*
* @param mixed $key
* @param mixed $value
* @param bool $escape
* @param array|RawSql|string $key
* @param mixed $value
* @param bool $escape
*
* @return $this
*/
Expand All @@ -676,26 +682,34 @@ public function orWhere($key, $value = null, ?bool $escape = null)
* @used-by having()
* @used-by orHaving()
*
* @param mixed $key
* @param mixed $value
* @param array|RawSql|string $key
* @param mixed $value
*
* @return $this
*/
protected function whereHaving(string $qbKey, $key, $value = null, string $type = 'AND ', ?bool $escape = null)
{
if (! is_array($key)) {
$key = [$key => $value];
if ($key instanceof RawSql) {
$keyValue = [(string) $key => $key];
$escape = false;
} elseif (! is_array($key)) {
$keyValue = [$key => $value];
} else {
$keyValue = $key;
}

// If the escape value was not set will base it on the global setting
if (! is_bool($escape)) {
$escape = $this->db->protectIdentifiers;
}

foreach ($key as $k => $v) {
foreach ($keyValue as $k => $v) {
$prefix = empty($this->{$qbKey}) ? $this->groupGetType('') : $this->groupGetType($type);

if ($v !== null) {
if ($v instanceof RawSql) {
$k = '';
$op = '';
} elseif ($v !== null) {
$op = $this->getOperator($k, true);

if (! empty($op)) {
Expand Down Expand Up @@ -731,10 +745,17 @@ protected function whereHaving(string $qbKey, $key, $value = null, string $type
$op = '';
}

$this->{$qbKey}[] = [
'condition' => $prefix . $k . $op . $v,
'escape' => $escape,
];
if ($v instanceof RawSql) {
$this->{$qbKey}[] = [
'condition' => $v->with($prefix . $k . $op . $v),
'escape' => $escape,
];
} else {
$this->{$qbKey}[] = [
'condition' => $prefix . $k . $op . $v,
'escape' => $escape,
];
}
}

return $this;
Expand Down Expand Up @@ -911,7 +932,7 @@ protected function _whereIn(?string $key = null, $values = null, bool $not = fal
* Generates a %LIKE% portion of the query.
* Separates multiple calls with 'AND'.
*
* @param mixed $field
* @param array|RawSql|string $field
*
* @return $this
*/
Expand All @@ -924,7 +945,7 @@ public function like($field, string $match = '', string $side = 'both', ?bool $e
* Generates a NOT LIKE portion of the query.
* Separates multiple calls with 'AND'.
*
* @param mixed $field
* @param array|RawSql|string $field
*
* @return $this
*/
Expand All @@ -937,7 +958,7 @@ public function notLike($field, string $match = '', string $side = 'both', ?bool
* Generates a %LIKE% portion of the query.
* Separates multiple calls with 'OR'.
*
* @param mixed $field
* @param array|RawSql|string $field
*
* @return $this
*/
Expand All @@ -950,7 +971,7 @@ public function orLike($field, string $match = '', string $side = 'both', ?bool
* Generates a NOT LIKE portion of the query.
* Separates multiple calls with 'OR'.
*
* @param mixed $field
* @param array|RawSql|string $field
*
* @return $this
*/
Expand All @@ -963,7 +984,7 @@ public function orNotLike($field, string $match = '', string $side = 'both', ?bo
* Generates a %LIKE% portion of the query.
* Separates multiple calls with 'AND'.
*
* @param mixed $field
* @param array|RawSql|string $field
*
* @return $this
*/
Expand All @@ -976,7 +997,7 @@ public function havingLike($field, string $match = '', string $side = 'both', ?b
* Generates a NOT LIKE portion of the query.
* Separates multiple calls with 'AND'.
*
* @param mixed $field
* @param array|RawSql|string $field
*
* @return $this
*/
Expand All @@ -989,7 +1010,7 @@ public function notHavingLike($field, string $match = '', string $side = 'both',
* Generates a %LIKE% portion of the query.
* Separates multiple calls with 'OR'.
*
* @param mixed $field
* @param array|RawSql|string $field
*
* @return $this
*/
Expand All @@ -1002,7 +1023,7 @@ public function orHavingLike($field, string $match = '', string $side = 'both',
* Generates a NOT LIKE portion of the query.
* Separates multiple calls with 'OR'.
*
* @param mixed $field
* @param array|RawSql|string $field
*
* @return $this
*/
Expand All @@ -1021,20 +1042,50 @@ public function orNotHavingLike($field, string $match = '', string $side = 'both
* @used-by notHavingLike()
* @used-by orNotHavingLike()
*
* @param mixed $field
* @param array|RawSql|string $field
*
* @return $this
*/
protected function _like($field, string $match = '', string $type = 'AND ', string $side = 'both', string $not = '', ?bool $escape = null, bool $insensitiveSearch = false, string $clause = 'QBWhere')
{
if (! is_array($field)) {
$field = [$field => $match];
}

$escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
$side = strtolower($side);

foreach ($field as $k => $v) {
if ($field instanceof RawSql) {
$k = (string) $field;
$v = $match;
$insensitiveSearch = false;

$prefix = empty($this->{$clause}) ? $this->groupGetType('') : $this->groupGetType($type);

if ($side === 'none') {
$bind = $this->setBind($field->getBindingKey(), $v, $escape);
} elseif ($side === 'before') {
$bind = $this->setBind($field->getBindingKey(), "%{$v}", $escape);
} elseif ($side === 'after') {
$bind = $this->setBind($field->getBindingKey(), "{$v}%", $escape);
} else {
$bind = $this->setBind($field->getBindingKey(), "%{$v}%", $escape);
}

$likeStatement = $this->_like_statement($prefix, $k, $not, $bind, $insensitiveSearch);

// some platforms require an escape sequence definition for LIKE wildcards
if ($escape === true && $this->db->likeEscapeStr !== '') {
$likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar);
}

$this->{$clause}[] = [
'condition' => $field->with($likeStatement),
'escape' => $escape,
];

return $this;
}

$keyValue = ! is_array($field) ? [$field => $match] : $field;

foreach ($keyValue as $k => $v) {
if ($insensitiveSearch === true) {
$v = strtolower($v);
}
Expand Down Expand Up @@ -1269,8 +1320,8 @@ public function groupBy($by, ?bool $escape = null)
/**
* Separates multiple calls with 'AND'.
*
* @param array|string $key
* @param mixed $value
* @param array|RawSql|string $key
* @param mixed $value
*
* @return $this
*/
Expand All @@ -1282,8 +1333,8 @@ public function having($key, $value = null, ?bool $escape = null)
/**
* Separates multiple calls with 'OR'.
*
* @param array|string $key
* @param mixed $value
* @param array|RawSql|string $key
* @param mixed $value
*
* @return $this
*/
Expand Down Expand Up @@ -2339,6 +2390,8 @@ protected function compileSelect($selectOverride = false): string

if (empty($this->QBSelect)) {
$sql .= '*';
} elseif ($this->QBSelect[0] instanceof RawSql) {
$sql .= (string) $this->QBSelect[0];
} else {
// Cycle through the "select" portion of the query and prep each column name.
// The reason we protect identifiers here rather than in the select() function
Expand Down Expand Up @@ -2407,6 +2460,12 @@ protected function compileWhereHaving(string $qbKey): string
continue;
}

if ($qbkey['condition'] instanceof RawSql) {
$qbkey = $qbkey['condition'];

continue;
}

if ($qbkey['escape'] === false) {
$qbkey = $qbkey['condition'];

Expand Down
49 changes: 49 additions & 0 deletions system/Database/RawSql.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace CodeIgniter\Database;

class RawSql
{
/**
* @var string Raw SQL string
*/
private string $string;

public function __construct(string $sqlString)
{
$this->string = $sqlString;
}

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

/**
* Create new instance with new SQL string
*/
public function with(string $newSqlString): self
{
$new = clone $this;
$new->string = $newSqlString;

return $new;
}

/**
* Returns unique id for binding key
*/
public function getBindingKey(): string
{
return 'RawSql' . spl_object_id($this);
}
}
24 changes: 24 additions & 0 deletions tests/system/Database/Builder/LikeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace CodeIgniter\Database\Builder;

use CodeIgniter\Database\BaseBuilder;
use CodeIgniter\Database\RawSql;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\Mock\MockConnection;

Expand Down Expand Up @@ -47,6 +48,29 @@ public function testSimpleLike()
$this->assertSame($expectedBinds, $builder->getBinds());
}

/**
* @see https://github.com/codeigniter4/CodeIgniter4/issues/3970
*/
public function testLikeWithRawSql()
{
$builder = new BaseBuilder('users', $this->db);

$sql = "concat(users.name, ' ', IF(users.surname IS NULL or users.surname = '', '', users.surname))";
$rawSql = new RawSql($sql);
$builder->like($rawSql, 'value', 'both');

$expectedSQL = "SELECT * FROM \"users\" WHERE {$sql} LIKE '%value%' ESCAPE '!' ";
$expectedBinds = [
$rawSql->getBindingKey() => [
'%value%',
true,
],
];

$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
$this->assertSame($expectedBinds, $builder->getBinds());
}

public function testLikeNoSide()
{
$builder = new BaseBuilder('job', $this->db);
Expand Down
15 changes: 15 additions & 0 deletions tests/system/Database/Builder/SelectTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use CodeIgniter\Database\BaseBuilder;
use CodeIgniter\Database\Exceptions\DataException;
use CodeIgniter\Database\RawSql;
use CodeIgniter\Database\SQLSRV\Builder as SQLSRVBuilder;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\Mock\MockConnection;
Expand Down Expand Up @@ -95,6 +96,20 @@ public function testSelectWorksWithComplexSelects()
$this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect()));
}

/**
* @see https://github.com/codeigniter4/CodeIgniter4/issues/4355
*/
public function testSelectWorksWithRawSql()
{
$builder = new BaseBuilder('users', $this->db);

$sql = 'REGEXP_SUBSTR(ral_anno,"[0-9]{1,2}([,.][0-9]{1,3})([,.][0-9]{1,3})") AS ral';
$builder->select(new RawSql($sql));

$expected = 'SELECT ' . $sql . ' FROM "users"';
$this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect()));
}

public function testSelectMinWithNoAlias()
{
$builder = new BaseBuilder('invoices', $this->db);
Expand Down
Loading

0 comments on commit 2a5322b

Please sign in to comment.