Skip to content

Commit f300ca0

Browse files
authored
feat: add Query Builder whereColumn methods (#10150)
1 parent 3f91249 commit f300ca0

7 files changed

Lines changed: 332 additions & 0 deletions

File tree

system/Database/BaseBuilder.php

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,97 @@ public function orWhere($key, $value = null, ?bool $escape = null)
736736
return $this->whereHaving('QBWhere', $key, $value, 'OR ', $escape);
737737
}
738738

739+
/**
740+
* Generates a WHERE clause that compares two columns.
741+
*
742+
* @param non-empty-string $first First column name, optionally with comparison operator
743+
* @param non-empty-string $second Second column name
744+
* @param bool|null $escape Whether to protect identifiers
745+
*
746+
* @return $this
747+
*
748+
* @throws InvalidArgumentException
749+
*/
750+
public function whereColumn(string $first, string $second, ?bool $escape = null): static
751+
{
752+
return $this->whereColumnHaving('QBWhere', $first, $second, 'AND ', $escape);
753+
}
754+
755+
/**
756+
* Generates an OR WHERE clause that compares two columns.
757+
*
758+
* @param non-empty-string $first First column name, optionally with comparison operator
759+
* @param non-empty-string $second Second column name
760+
* @param bool|null $escape Whether to protect identifiers
761+
*
762+
* @return $this
763+
*
764+
* @throws InvalidArgumentException
765+
*/
766+
public function orWhereColumn(string $first, string $second, ?bool $escape = null): static
767+
{
768+
return $this->whereColumnHaving('QBWhere', $first, $second, 'OR ', $escape);
769+
}
770+
771+
/**
772+
* @used-by whereColumn()
773+
* @used-by orWhereColumn()
774+
*
775+
* @param 'QBHaving'|'QBWhere' $qbKey
776+
* @param non-empty-string $first First column name, optionally with comparison operator
777+
* @param non-empty-string $second Second column name
778+
* @param non-empty-string $type
779+
* @param bool|null $escape Whether to protect identifiers
780+
*
781+
* @return $this
782+
*
783+
* @throws InvalidArgumentException
784+
*/
785+
protected function whereColumnHaving(string $qbKey, string $first, string $second, string $type = 'AND ', ?bool $escape = null): static
786+
{
787+
[$first, $operator] = $this->parseWhereColumnFirst($first);
788+
$second = trim($second);
789+
790+
if ($first === '' || $second === '') {
791+
$caller = debug_backtrace(0, 2)[1]['function'];
792+
793+
throw new InvalidArgumentException(sprintf('%s() expects $first and $second to be non-empty strings', $caller));
794+
}
795+
796+
$escape ??= $this->db->protectIdentifiers;
797+
798+
$prefix = $this->{$qbKey} === [] ? $this->groupGetType('') : $this->groupGetType($type);
799+
800+
$this->{$qbKey}[] = [
801+
'columnComparison' => true,
802+
'condition' => $prefix,
803+
'escape' => $escape,
804+
'first' => $first,
805+
'operator' => $operator,
806+
'second' => $second,
807+
];
808+
809+
return $this;
810+
}
811+
812+
/**
813+
* Extracts the operator from the first whereColumn() column.
814+
*
815+
* @param string $first The first column, optionally ending with a comparison operator
816+
*
817+
* @return array{string, string}
818+
*/
819+
private function parseWhereColumnFirst(string $first): array
820+
{
821+
$first = trim($first);
822+
823+
if (preg_match('/\s*(!=|<>|<=|>=|=|<|>)\s*$/', $first, $match) === 1) {
824+
return [rtrim(substr($first, 0, -strlen($match[0]))), trim($match[1])];
825+
}
826+
827+
return [$first, '='];
828+
}
829+
739830
/**
740831
* @used-by where()
741832
* @used-by orWhere()
@@ -1383,6 +1474,7 @@ protected function groupEndPrepare(string $clause = 'QBWhere')
13831474
* @used-by _like()
13841475
* @used-by whereHaving()
13851476
* @used-by _whereIn()
1477+
* @used-by whereColumnHaving()
13861478
* @used-by havingGroupStart()
13871479
*/
13881480
protected function groupGetType(string $type): string
@@ -3114,6 +3206,12 @@ protected function compileWhereHaving(string $qbKey): string
31143206
continue;
31153207
}
31163208

3209+
if (($qbkey['columnComparison'] ?? false) === true) {
3210+
$qbkey = $this->compileColumnComparison($qbkey);
3211+
3212+
continue;
3213+
}
3214+
31173215
if ($qbkey['escape'] === false) {
31183216
$qbkey = $qbkey['condition'];
31193217

@@ -3177,6 +3275,21 @@ protected function compileWhereHaving(string $qbKey): string
31773275
return '';
31783276
}
31793277

3278+
/**
3279+
* @used-by compileWhereHaving()
3280+
*
3281+
* @param array{columnComparison: true, condition: string, escape: bool, first: string, operator: string, second: string} $condition
3282+
*/
3283+
private function compileColumnComparison(array $condition): string
3284+
{
3285+
if ($condition['escape']) {
3286+
$condition['first'] = $this->db->protectIdentifiers($condition['first'], false, true);
3287+
$condition['second'] = $this->db->protectIdentifiers($condition['second'], false, true);
3288+
}
3289+
3290+
return $condition['condition'] . $condition['first'] . ' ' . $condition['operator'] . ' ' . $condition['second'];
3291+
}
3292+
31803293
/**
31813294
* Escapes identifiers in GROUP BY statements at execution time.
31823295
*

system/Model.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
* @method $this orNotHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
7373
* @method $this orNotLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
7474
* @method $this orWhere($key, $value = null, ?bool $escape = null)
75+
* @method $this orWhereColumn(string $first, string $second, ?bool $escape = null)
7576
* @method $this orWhereIn(?string $key = null, $values = null, ?bool $escape = null)
7677
* @method $this orWhereNotIn(?string $key = null, $values = null, ?bool $escape = null)
7778
* @method $this select($select = '*', ?bool $escape = null)
@@ -83,6 +84,7 @@
8384
* @method $this when($condition, callable $callback, ?callable $defaultCallback = null)
8485
* @method $this whenNot($condition, callable $callback, ?callable $defaultCallback = null)
8586
* @method $this where($key, $value = null, ?bool $escape = null)
87+
* @method $this whereColumn(string $first, string $second, ?bool $escape = null)
8688
* @method $this whereIn(?string $key = null, $values = null, ?bool $escape = null)
8789
* @method $this whereNotIn(?string $key = null, $values = null, ?bool $escape = null)
8890
*

tests/system/Database/Builder/PrefixTest.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,19 @@ public function testPrefixesSetOnTableNamesWithWhereClause(): void
5555
$this->assertSame($expectedBinds, $builder->getBinds());
5656
}
5757

58+
public function testPrefixesSetOnTableNamesWithWhereColumnClause(): void
59+
{
60+
$builder = $this->db->table('users');
61+
62+
$expectedSQL = 'SELECT * FROM "ci_users" WHERE "ci_users"."created_at" < "ci_users"."updated_at"';
63+
$expectedBinds = [];
64+
65+
$builder->whereColumn('users.created_at <', 'users.updated_at');
66+
67+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
68+
$this->assertSame($expectedBinds, $builder->getBinds());
69+
}
70+
5871
public function testPrefixWithSubquery(): void
5972
{
6073
$expected = <<<'NOWDOC'

tests/system/Database/Builder/WhereTest.php

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

1616
use CodeIgniter\Database\BaseBuilder;
1717
use CodeIgniter\Database\RawSql;
18+
use CodeIgniter\Exceptions\InvalidArgumentException;
1819
use CodeIgniter\Test\CIUnitTestCase;
1920
use CodeIgniter\Test\Mock\MockConnection;
2021
use DateTime;
@@ -351,6 +352,143 @@ public function testOrWhereSameColumn(): void
351352
$this->assertSame($expectedBinds, $builder->getBinds());
352353
}
353354

355+
#[DataProvider('provideWhereColumnWithOperators')]
356+
public function testWhereColumnWithOperators(string $first, string $operator): void
357+
{
358+
$builder = $this->db->table('users');
359+
360+
$builder->whereColumn($first, 'updated_at');
361+
362+
$expectedSQL = sprintf('SELECT * FROM "users" WHERE "created_at" %s "updated_at"', $operator);
363+
$expectedBinds = [];
364+
365+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
366+
$this->assertSame($expectedBinds, $builder->getBinds());
367+
}
368+
369+
/**
370+
* @return iterable<string, array{string, string}>
371+
*/
372+
public static function provideWhereColumnWithOperators(): iterable
373+
{
374+
return [
375+
'default' => ['created_at', '='],
376+
'=' => ['created_at =', '='],
377+
'!=' => ['created_at !=', '!='],
378+
'<>' => ['created_at <>', '<>'],
379+
'<' => ['created_at <', '<'],
380+
'>' => ['created_at >', '>'],
381+
'<=' => ['created_at <=', '<='],
382+
'>=' => ['created_at >=', '>='],
383+
];
384+
}
385+
386+
public function testWhereColumnWithAlias(): void
387+
{
388+
$builder = $this->db->table('users u');
389+
390+
$builder->whereColumn('u.updated_at >', 'u.created_at');
391+
392+
$expectedSQL = 'SELECT * FROM "users" "u" WHERE "u"."updated_at" > "u"."created_at"';
393+
$expectedBinds = [];
394+
395+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
396+
$this->assertSame($expectedBinds, $builder->getBinds());
397+
}
398+
399+
public function testOrWhereColumn(): void
400+
{
401+
$builder = $this->db->table('users');
402+
403+
$builder->where('active', 1)
404+
->orWhereColumn('updated_at >', 'created_at');
405+
406+
$expectedSQL = 'SELECT * FROM "users" WHERE "active" = 1 OR "updated_at" > "created_at"';
407+
$expectedBinds = [
408+
'active' => [
409+
1,
410+
true,
411+
],
412+
];
413+
414+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
415+
$this->assertSame($expectedBinds, $builder->getBinds());
416+
}
417+
418+
public function testWhereColumnWithGroupedConditions(): void
419+
{
420+
$builder = $this->db->table('users');
421+
422+
$builder->groupStart()
423+
->whereColumn('created_at', 'updated_at')
424+
->orWhereColumn('updated_at >', 'created_at')
425+
->groupEnd()
426+
->where('active', 1);
427+
428+
$expectedSQL = 'SELECT * FROM "users" WHERE ( "created_at" = "updated_at" OR "updated_at" > "created_at" ) AND "active" = 1';
429+
430+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
431+
}
432+
433+
public function testWhereColumnNoEscape(): void
434+
{
435+
$builder = $this->db->table('users');
436+
437+
$builder->whereColumn('LOWER(users.email)', 'normalized_email', escape: false);
438+
439+
$expectedSQL = 'SELECT * FROM "users" WHERE LOWER(users.email) = normalized_email';
440+
$expectedBinds = [];
441+
442+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
443+
$this->assertSame($expectedBinds, $builder->getBinds());
444+
}
445+
446+
public function testWhereColumnTreatsSecondArgumentAsColumnName(): void
447+
{
448+
$builder = $this->db->table('users');
449+
450+
$builder->whereColumn('created_at', 'like');
451+
452+
$expectedSQL = 'SELECT * FROM "users" WHERE "created_at" = "like"';
453+
$expectedBinds = [];
454+
455+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
456+
$this->assertSame($expectedBinds, $builder->getBinds());
457+
}
458+
459+
public function testWhereColumnIgnoresOperatorsInsideFirstArgument(): void
460+
{
461+
$builder = $this->db->table('users');
462+
463+
$builder->whereColumn("JSON_EXTRACT(data, '$.a>b')", 'updated_at', escape: false);
464+
465+
$expectedSQL = 'SELECT * FROM "users" WHERE JSON_EXTRACT(data, \'$.a>b\') = updated_at';
466+
$expectedBinds = [];
467+
468+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
469+
$this->assertSame($expectedBinds, $builder->getBinds());
470+
}
471+
472+
#[DataProvider('provideWhereColumnInvalidColumnThrowInvalidArgumentException')]
473+
public function testWhereColumnInvalidColumnThrowInvalidArgumentException(string $first, string $second): void
474+
{
475+
$this->expectException(InvalidArgumentException::class);
476+
477+
$builder = $this->db->table('users');
478+
$builder->whereColumn($first, $second);
479+
}
480+
481+
/**
482+
* @return iterable<string, array{string, string}>
483+
*/
484+
public static function provideWhereColumnInvalidColumnThrowInvalidArgumentException(): iterable
485+
{
486+
return [
487+
'empty first column' => ['', 'updated_at'],
488+
'empty second column' => ['created_at =', ''],
489+
];
490+
}
491+
354492
public function testWhereIn(): void
355493
{
356494
$builder = $this->db->table('jobs');

user_guide_src/source/changelogs/v4.8.0.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,8 @@ Database
208208
Query Builder
209209
-------------
210210

211+
- Added ``whereColumn()`` and ``orWhereColumn()`` to compare one column to another column while protecting identifiers by default. See :ref:`query-builder-where-column`.
212+
211213
Forge
212214
-----
213215

0 commit comments

Comments
 (0)