Skip to content

Commit 2a5322b

Browse files
authored
Merge pull request #5817 from kenjis/feat-qb-raw-sql
feat: QueryBuilder raw SQL string support
2 parents ab63c8d + a02151b commit 2a5322b

11 files changed

Lines changed: 483 additions & 111 deletions

File tree

system/Database/BaseBuilder.php

Lines changed: 93 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ public function ignore(bool $ignore = true)
356356
/**
357357
* Generates the SELECT portion of the query
358358
*
359-
* @param array|string $select
359+
* @param array|RawSql|string $select
360360
*
361361
* @return $this
362362
*/
@@ -371,6 +371,12 @@ public function select($select = '*', ?bool $escape = null)
371371
$escape = $this->db->protectIdentifiers;
372372
}
373373

374+
if ($select instanceof RawSql) {
375+
$this->QBSelect[] = $select;
376+
377+
return $this;
378+
}
379+
374380
foreach ($select as $val) {
375381
$val = trim($val);
376382

@@ -643,8 +649,8 @@ public function join(string $table, string $cond, string $type = '', ?bool $esca
643649
* Generates the WHERE portion of the query.
644650
* Separates multiple calls with 'AND'.
645651
*
646-
* @param mixed $key
647-
* @param mixed $value
652+
* @param array|RawSql|string $key
653+
* @param mixed $value
648654
*
649655
* @return $this
650656
*/
@@ -659,9 +665,9 @@ public function where($key, $value = null, ?bool $escape = null)
659665
* Generates the WHERE portion of the query.
660666
* Separates multiple calls with 'OR'.
661667
*
662-
* @param mixed $key
663-
* @param mixed $value
664-
* @param bool $escape
668+
* @param array|RawSql|string $key
669+
* @param mixed $value
670+
* @param bool $escape
665671
*
666672
* @return $this
667673
*/
@@ -676,26 +682,34 @@ public function orWhere($key, $value = null, ?bool $escape = null)
676682
* @used-by having()
677683
* @used-by orHaving()
678684
*
679-
* @param mixed $key
680-
* @param mixed $value
685+
* @param array|RawSql|string $key
686+
* @param mixed $value
681687
*
682688
* @return $this
683689
*/
684690
protected function whereHaving(string $qbKey, $key, $value = null, string $type = 'AND ', ?bool $escape = null)
685691
{
686-
if (! is_array($key)) {
687-
$key = [$key => $value];
692+
if ($key instanceof RawSql) {
693+
$keyValue = [(string) $key => $key];
694+
$escape = false;
695+
} elseif (! is_array($key)) {
696+
$keyValue = [$key => $value];
697+
} else {
698+
$keyValue = $key;
688699
}
689700

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

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

698-
if ($v !== null) {
709+
if ($v instanceof RawSql) {
710+
$k = '';
711+
$op = '';
712+
} elseif ($v !== null) {
699713
$op = $this->getOperator($k, true);
700714

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

734-
$this->{$qbKey}[] = [
735-
'condition' => $prefix . $k . $op . $v,
736-
'escape' => $escape,
737-
];
748+
if ($v instanceof RawSql) {
749+
$this->{$qbKey}[] = [
750+
'condition' => $v->with($prefix . $k . $op . $v),
751+
'escape' => $escape,
752+
];
753+
} else {
754+
$this->{$qbKey}[] = [
755+
'condition' => $prefix . $k . $op . $v,
756+
'escape' => $escape,
757+
];
758+
}
738759
}
739760

740761
return $this;
@@ -911,7 +932,7 @@ protected function _whereIn(?string $key = null, $values = null, bool $not = fal
911932
* Generates a %LIKE% portion of the query.
912933
* Separates multiple calls with 'AND'.
913934
*
914-
* @param mixed $field
935+
* @param array|RawSql|string $field
915936
*
916937
* @return $this
917938
*/
@@ -924,7 +945,7 @@ public function like($field, string $match = '', string $side = 'both', ?bool $e
924945
* Generates a NOT LIKE portion of the query.
925946
* Separates multiple calls with 'AND'.
926947
*
927-
* @param mixed $field
948+
* @param array|RawSql|string $field
928949
*
929950
* @return $this
930951
*/
@@ -937,7 +958,7 @@ public function notLike($field, string $match = '', string $side = 'both', ?bool
937958
* Generates a %LIKE% portion of the query.
938959
* Separates multiple calls with 'OR'.
939960
*
940-
* @param mixed $field
961+
* @param array|RawSql|string $field
941962
*
942963
* @return $this
943964
*/
@@ -950,7 +971,7 @@ public function orLike($field, string $match = '', string $side = 'both', ?bool
950971
* Generates a NOT LIKE portion of the query.
951972
* Separates multiple calls with 'OR'.
952973
*
953-
* @param mixed $field
974+
* @param array|RawSql|string $field
954975
*
955976
* @return $this
956977
*/
@@ -963,7 +984,7 @@ public function orNotLike($field, string $match = '', string $side = 'both', ?bo
963984
* Generates a %LIKE% portion of the query.
964985
* Separates multiple calls with 'AND'.
965986
*
966-
* @param mixed $field
987+
* @param array|RawSql|string $field
967988
*
968989
* @return $this
969990
*/
@@ -976,7 +997,7 @@ public function havingLike($field, string $match = '', string $side = 'both', ?b
976997
* Generates a NOT LIKE portion of the query.
977998
* Separates multiple calls with 'AND'.
978999
*
979-
* @param mixed $field
1000+
* @param array|RawSql|string $field
9801001
*
9811002
* @return $this
9821003
*/
@@ -989,7 +1010,7 @@ public function notHavingLike($field, string $match = '', string $side = 'both',
9891010
* Generates a %LIKE% portion of the query.
9901011
* Separates multiple calls with 'OR'.
9911012
*
992-
* @param mixed $field
1013+
* @param array|RawSql|string $field
9931014
*
9941015
* @return $this
9951016
*/
@@ -1002,7 +1023,7 @@ public function orHavingLike($field, string $match = '', string $side = 'both',
10021023
* Generates a NOT LIKE portion of the query.
10031024
* Separates multiple calls with 'OR'.
10041025
*
1005-
* @param mixed $field
1026+
* @param array|RawSql|string $field
10061027
*
10071028
* @return $this
10081029
*/
@@ -1021,20 +1042,50 @@ public function orNotHavingLike($field, string $match = '', string $side = 'both
10211042
* @used-by notHavingLike()
10221043
* @used-by orNotHavingLike()
10231044
*
1024-
* @param mixed $field
1045+
* @param array|RawSql|string $field
10251046
*
10261047
* @return $this
10271048
*/
10281049
protected function _like($field, string $match = '', string $type = 'AND ', string $side = 'both', string $not = '', ?bool $escape = null, bool $insensitiveSearch = false, string $clause = 'QBWhere')
10291050
{
1030-
if (! is_array($field)) {
1031-
$field = [$field => $match];
1032-
}
1033-
10341051
$escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
10351052
$side = strtolower($side);
10361053

1037-
foreach ($field as $k => $v) {
1054+
if ($field instanceof RawSql) {
1055+
$k = (string) $field;
1056+
$v = $match;
1057+
$insensitiveSearch = false;
1058+
1059+
$prefix = empty($this->{$clause}) ? $this->groupGetType('') : $this->groupGetType($type);
1060+
1061+
if ($side === 'none') {
1062+
$bind = $this->setBind($field->getBindingKey(), $v, $escape);
1063+
} elseif ($side === 'before') {
1064+
$bind = $this->setBind($field->getBindingKey(), "%{$v}", $escape);
1065+
} elseif ($side === 'after') {
1066+
$bind = $this->setBind($field->getBindingKey(), "{$v}%", $escape);
1067+
} else {
1068+
$bind = $this->setBind($field->getBindingKey(), "%{$v}%", $escape);
1069+
}
1070+
1071+
$likeStatement = $this->_like_statement($prefix, $k, $not, $bind, $insensitiveSearch);
1072+
1073+
// some platforms require an escape sequence definition for LIKE wildcards
1074+
if ($escape === true && $this->db->likeEscapeStr !== '') {
1075+
$likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar);
1076+
}
1077+
1078+
$this->{$clause}[] = [
1079+
'condition' => $field->with($likeStatement),
1080+
'escape' => $escape,
1081+
];
1082+
1083+
return $this;
1084+
}
1085+
1086+
$keyValue = ! is_array($field) ? [$field => $match] : $field;
1087+
1088+
foreach ($keyValue as $k => $v) {
10381089
if ($insensitiveSearch === true) {
10391090
$v = strtolower($v);
10401091
}
@@ -1269,8 +1320,8 @@ public function groupBy($by, ?bool $escape = null)
12691320
/**
12701321
* Separates multiple calls with 'AND'.
12711322
*
1272-
* @param array|string $key
1273-
* @param mixed $value
1323+
* @param array|RawSql|string $key
1324+
* @param mixed $value
12741325
*
12751326
* @return $this
12761327
*/
@@ -1282,8 +1333,8 @@ public function having($key, $value = null, ?bool $escape = null)
12821333
/**
12831334
* Separates multiple calls with 'OR'.
12841335
*
1285-
* @param array|string $key
1286-
* @param mixed $value
1336+
* @param array|RawSql|string $key
1337+
* @param mixed $value
12871338
*
12881339
* @return $this
12891340
*/
@@ -2339,6 +2390,8 @@ protected function compileSelect($selectOverride = false): string
23392390

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

2463+
if ($qbkey['condition'] instanceof RawSql) {
2464+
$qbkey = $qbkey['condition'];
2465+
2466+
continue;
2467+
}
2468+
24102469
if ($qbkey['escape'] === false) {
24112470
$qbkey = $qbkey['condition'];
24122471

system/Database/RawSql.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
/**
4+
* This file is part of CodeIgniter 4 framework.
5+
*
6+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
7+
*
8+
* For the full copyright and license information, please view
9+
* the LICENSE file that was distributed with this source code.
10+
*/
11+
12+
namespace CodeIgniter\Database;
13+
14+
class RawSql
15+
{
16+
/**
17+
* @var string Raw SQL string
18+
*/
19+
private string $string;
20+
21+
public function __construct(string $sqlString)
22+
{
23+
$this->string = $sqlString;
24+
}
25+
26+
public function __toString(): string
27+
{
28+
return $this->string;
29+
}
30+
31+
/**
32+
* Create new instance with new SQL string
33+
*/
34+
public function with(string $newSqlString): self
35+
{
36+
$new = clone $this;
37+
$new->string = $newSqlString;
38+
39+
return $new;
40+
}
41+
42+
/**
43+
* Returns unique id for binding key
44+
*/
45+
public function getBindingKey(): string
46+
{
47+
return 'RawSql' . spl_object_id($this);
48+
}
49+
}

tests/system/Database/Builder/LikeTest.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace CodeIgniter\Database\Builder;
1313

1414
use CodeIgniter\Database\BaseBuilder;
15+
use CodeIgniter\Database\RawSql;
1516
use CodeIgniter\Test\CIUnitTestCase;
1617
use CodeIgniter\Test\Mock\MockConnection;
1718

@@ -47,6 +48,29 @@ public function testSimpleLike()
4748
$this->assertSame($expectedBinds, $builder->getBinds());
4849
}
4950

51+
/**
52+
* @see https://github.com/codeigniter4/CodeIgniter4/issues/3970
53+
*/
54+
public function testLikeWithRawSql()
55+
{
56+
$builder = new BaseBuilder('users', $this->db);
57+
58+
$sql = "concat(users.name, ' ', IF(users.surname IS NULL or users.surname = '', '', users.surname))";
59+
$rawSql = new RawSql($sql);
60+
$builder->like($rawSql, 'value', 'both');
61+
62+
$expectedSQL = "SELECT * FROM \"users\" WHERE {$sql} LIKE '%value%' ESCAPE '!' ";
63+
$expectedBinds = [
64+
$rawSql->getBindingKey() => [
65+
'%value%',
66+
true,
67+
],
68+
];
69+
70+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
71+
$this->assertSame($expectedBinds, $builder->getBinds());
72+
}
73+
5074
public function testLikeNoSide()
5175
{
5276
$builder = new BaseBuilder('job', $this->db);

tests/system/Database/Builder/SelectTest.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use CodeIgniter\Database\BaseBuilder;
1515
use CodeIgniter\Database\Exceptions\DataException;
16+
use CodeIgniter\Database\RawSql;
1617
use CodeIgniter\Database\SQLSRV\Builder as SQLSRVBuilder;
1718
use CodeIgniter\Test\CIUnitTestCase;
1819
use CodeIgniter\Test\Mock\MockConnection;
@@ -95,6 +96,20 @@ public function testSelectWorksWithComplexSelects()
9596
$this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect()));
9697
}
9798

99+
/**
100+
* @see https://github.com/codeigniter4/CodeIgniter4/issues/4355
101+
*/
102+
public function testSelectWorksWithRawSql()
103+
{
104+
$builder = new BaseBuilder('users', $this->db);
105+
106+
$sql = 'REGEXP_SUBSTR(ral_anno,"[0-9]{1,2}([,.][0-9]{1,3})([,.][0-9]{1,3})") AS ral';
107+
$builder->select(new RawSql($sql));
108+
109+
$expected = 'SELECT ' . $sql . ' FROM "users"';
110+
$this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect()));
111+
}
112+
98113
public function testSelectMinWithNoAlias()
99114
{
100115
$builder = new BaseBuilder('invoices', $this->db);

0 commit comments

Comments
 (0)