Skip to content

Commit 00f5e8a

Browse files
committed
feat(database): add whereColumn query builder methods
- Add whereColumn() and orWhereColumn() for column-to-column comparisons - Protect compared identifiers by default and support explicit unescaped usage - Document supported operators and invalid argument behavior - Add Query Builder, prefix, Model PHPDoc, user guide, and changelog coverage Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
1 parent 2aa8491 commit 00f5e8a

7 files changed

Lines changed: 307 additions & 0 deletions

File tree

system/Database/BaseBuilder.php

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,94 @@ 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
743+
* @param non-empty-string|null $operator Comparison operator, or second column name when $second is null
744+
* @param non-empty-string|null $second Second column name
745+
* @param bool|null $escape Whether to protect identifiers
746+
*
747+
* @return $this
748+
*
749+
* @throws InvalidArgumentException
750+
*/
751+
public function whereColumn(string $first, ?string $operator = null, ?string $second = null, ?bool $escape = null)
752+
{
753+
return $this->whereColumnHaving('QBWhere', $first, $operator, $second, 'AND ', $escape);
754+
}
755+
756+
/**
757+
* Generates an OR WHERE clause that compares two columns.
758+
*
759+
* @param non-empty-string $first First column name
760+
* @param non-empty-string|null $operator Comparison operator, or second column name when $second is null
761+
* @param non-empty-string|null $second Second column name
762+
* @param bool|null $escape Whether to protect identifiers
763+
*
764+
* @return $this
765+
*
766+
* @throws InvalidArgumentException
767+
*/
768+
public function orWhereColumn(string $first, ?string $operator = null, ?string $second = null, ?bool $escape = null)
769+
{
770+
return $this->whereColumnHaving('QBWhere', $first, $operator, $second, 'OR ', $escape);
771+
}
772+
773+
/**
774+
* @used-by whereColumn()
775+
* @used-by orWhereColumn()
776+
*
777+
* @param 'QBHaving'|'QBWhere' $qbKey
778+
* @param non-empty-string $first First column name
779+
* @param non-empty-string|null $operator Comparison operator, or second column name when $second is null
780+
* @param non-empty-string|null $second Second column name
781+
* @param non-empty-string $type
782+
* @param bool|null $escape Whether to protect identifiers
783+
*
784+
* @return $this
785+
*
786+
* @throws InvalidArgumentException
787+
*/
788+
protected function whereColumnHaving(string $qbKey, string $first, ?string $operator = null, ?string $second = null, string $type = 'AND ', ?bool $escape = null)
789+
{
790+
if ($second === null) {
791+
$second = $operator;
792+
$operator = '=';
793+
} elseif ($operator === null) {
794+
$operator = '=';
795+
}
796+
797+
$first = trim($first);
798+
$operator = trim($operator);
799+
$second = trim((string) $second);
800+
801+
if ($first === '' || $second === '') {
802+
throw new InvalidArgumentException(sprintf('%s() expects $first and $second to be non-empty strings', debug_backtrace(0, 2)[1]['function']));
803+
}
804+
805+
if (! in_array($operator, ['=', '!=', '<>', '<', '>', '<=', '>='], true)) {
806+
throw new InvalidArgumentException(sprintf('%s() expects $operator to be one of: =, !=, <>, <, >, <=, >=', debug_backtrace(0, 2)[1]['function']));
807+
}
808+
809+
if (! is_bool($escape)) {
810+
$escape = $this->db->protectIdentifiers;
811+
}
812+
813+
$prefix = $this->{$qbKey} === [] ? $this->groupGetType('') : $this->groupGetType($type);
814+
815+
$this->{$qbKey}[] = [
816+
'columnComparison' => true,
817+
'condition' => $prefix,
818+
'escape' => $escape,
819+
'first' => $first,
820+
'operator' => $operator,
821+
'second' => $second,
822+
];
823+
824+
return $this;
825+
}
826+
739827
/**
740828
* @used-by where()
741829
* @used-by orWhere()
@@ -1383,6 +1471,7 @@ protected function groupEndPrepare(string $clause = 'QBWhere')
13831471
* @used-by _like()
13841472
* @used-by whereHaving()
13851473
* @used-by _whereIn()
1474+
* @used-by whereColumnHaving()
13861475
* @used-by havingGroupStart()
13871476
*/
13881477
protected function groupGetType(string $type): string
@@ -3114,6 +3203,12 @@ protected function compileWhereHaving(string $qbKey): string
31143203
continue;
31153204
}
31163205

3206+
if (($qbkey['columnComparison'] ?? false) === true) {
3207+
$qbkey = $this->compileColumnComparison($qbkey);
3208+
3209+
continue;
3210+
}
3211+
31173212
if ($qbkey['escape'] === false) {
31183213
$qbkey = $qbkey['condition'];
31193214

@@ -3177,6 +3272,19 @@ protected function compileWhereHaving(string $qbKey): string
31773272
return '';
31783273
}
31793274

3275+
/**
3276+
* @param array{columnComparison: true, condition: string, escape: bool, first: string, operator: string, second: string} $condition
3277+
*/
3278+
private function compileColumnComparison(array $condition): string
3279+
{
3280+
if ($condition['escape']) {
3281+
$condition['first'] = $this->db->protectIdentifiers($condition['first'], false, true);
3282+
$condition['second'] = $this->db->protectIdentifiers($condition['second'], false, true);
3283+
}
3284+
3285+
return $condition['condition'] . $condition['first'] . ' ' . $condition['operator'] . ' ' . $condition['second'];
3286+
}
3287+
31803288
/**
31813289
* Escapes identifiers in GROUP BY statements at execution time.
31823290
*

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 $operator = null, ?string $second = null, ?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 $operator = null, ?string $second = null, ?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: 117 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,122 @@ public function testOrWhereSameColumn(): void
351352
$this->assertSame($expectedBinds, $builder->getBinds());
352353
}
353354

355+
public function testWhereColumn(): void
356+
{
357+
$builder = $this->db->table('users');
358+
359+
$builder->whereColumn('created_at', 'updated_at');
360+
361+
$expectedSQL = 'SELECT * FROM "users" WHERE "created_at" = "updated_at"';
362+
$expectedBinds = [];
363+
364+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
365+
$this->assertSame($expectedBinds, $builder->getBinds());
366+
}
367+
368+
public function testWhereColumnWithOperator(): void
369+
{
370+
$builder = $this->db->table('users');
371+
372+
$builder->whereColumn('updated_at', '>', 'created_at');
373+
374+
$expectedSQL = 'SELECT * FROM "users" WHERE "updated_at" > "created_at"';
375+
$expectedBinds = [];
376+
377+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
378+
$this->assertSame($expectedBinds, $builder->getBinds());
379+
}
380+
381+
public function testWhereColumnWithAlias(): void
382+
{
383+
$builder = $this->db->table('users u');
384+
385+
$builder->whereColumn('u.updated_at', '>', 'u.created_at');
386+
387+
$expectedSQL = 'SELECT * FROM "users" "u" WHERE "u"."updated_at" > "u"."created_at"';
388+
$expectedBinds = [];
389+
390+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
391+
$this->assertSame($expectedBinds, $builder->getBinds());
392+
}
393+
394+
public function testOrWhereColumn(): void
395+
{
396+
$builder = $this->db->table('users');
397+
398+
$builder->where('active', 1)
399+
->orWhereColumn('updated_at', '>', 'created_at');
400+
401+
$expectedSQL = 'SELECT * FROM "users" WHERE "active" = 1 OR "updated_at" > "created_at"';
402+
$expectedBinds = [
403+
'active' => [
404+
1,
405+
true,
406+
],
407+
];
408+
409+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
410+
$this->assertSame($expectedBinds, $builder->getBinds());
411+
}
412+
413+
public function testWhereColumnWithGroupedConditions(): void
414+
{
415+
$builder = $this->db->table('users');
416+
417+
$builder->groupStart()
418+
->whereColumn('created_at', 'updated_at')
419+
->orWhereColumn('updated_at', '>', 'created_at')
420+
->groupEnd()
421+
->where('active', 1);
422+
423+
$expectedSQL = 'SELECT * FROM "users" WHERE ( "created_at" = "updated_at" OR "updated_at" > "created_at" ) AND "active" = 1';
424+
425+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
426+
}
427+
428+
public function testWhereColumnNoEscape(): void
429+
{
430+
$builder = $this->db->table('users');
431+
432+
$builder->whereColumn('LOWER(users.email)', 'normalized_email', escape: false);
433+
434+
$expectedSQL = 'SELECT * FROM "users" WHERE LOWER(users.email) = normalized_email';
435+
$expectedBinds = [];
436+
437+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
438+
$this->assertSame($expectedBinds, $builder->getBinds());
439+
}
440+
441+
#[DataProvider('provideWhereColumnInvalidColumnThrowInvalidArgumentException')]
442+
public function testWhereColumnInvalidColumnThrowInvalidArgumentException(string $first, ?string $operator, ?string $second): void
443+
{
444+
$this->expectException(InvalidArgumentException::class);
445+
446+
$builder = $this->db->table('users');
447+
$builder->whereColumn($first, $operator, $second);
448+
}
449+
450+
/**
451+
* @return iterable<string, array{string, ?string, ?string}>
452+
*/
453+
public static function provideWhereColumnInvalidColumnThrowInvalidArgumentException(): iterable
454+
{
455+
return [
456+
'empty first column' => ['', '=', 'updated_at'],
457+
'empty second column' => ['created_at', '= ', ''],
458+
'empty second column as second arg' => ['created_at', '', null],
459+
'missing second' => ['created_at', null, null],
460+
];
461+
}
462+
463+
public function testWhereColumnInvalidOperatorThrowInvalidArgumentException(): void
464+
{
465+
$this->expectException(InvalidArgumentException::class);
466+
467+
$builder = $this->db->table('users');
468+
$builder->whereColumn('created_at', 'LIKE', 'updated_at');
469+
}
470+
354471
public function testWhereIn(): void
355472
{
356473
$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
@@ -202,6 +202,8 @@ Database
202202
Query Builder
203203
-------------
204204

205+
- Added ``whereColumn()`` and ``orWhereColumn()`` to compare one column to another column while protecting identifiers by default. See :ref:`query-builder-where-column`.
206+
205207
Forge
206208
-----
207209

user_guide_src/source/database/query_builder.rst

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,36 @@ instances are joined by **OR**:
365365

366366
.. literalinclude:: query_builder/029.php
367367

368+
.. _query-builder-where-column:
369+
370+
$builder->whereColumn()
371+
-----------------------
372+
373+
.. versionadded:: 4.8.0
374+
375+
Compares one column to another column. If no operator is provided, ``=`` is
376+
used:
377+
378+
.. literalinclude:: query_builder/123.php
379+
380+
When two arguments are passed, the second argument is treated as the column to
381+
compare against. When three arguments are passed, the second argument is treated
382+
as the comparison operator. Supported operators are ``=``, ``!=``, ``<>``, ``<``,
383+
``>``, ``<=``, and ``>=``. Empty column names or unsupported operators throw an
384+
``InvalidArgumentException``.
385+
386+
Column names are protected by default, unless the ``$escape`` parameter is
387+
``false``.
388+
389+
.. warning:: Do not pass user-supplied data as column names. Values should use
390+
``where()`` or another value-binding method instead.
391+
392+
$builder->orWhereColumn()
393+
-------------------------
394+
395+
This method is identical to ``whereColumn()``, except that multiple instances
396+
are joined by **OR**.
397+
368398
$builder->whereIn()
369399
-------------------
370400

@@ -1534,6 +1564,34 @@ Class Reference
15341564

15351565
Generates the ``WHERE`` portion of the query. Separates multiple calls with ``OR``.
15361566

1567+
.. php:method:: whereColumn($first[, $operator = null[, $second = null[, $escape = null]]])
1568+
1569+
:param string $first: First column name
1570+
:param string $operator: Comparison operator, or second column name when ``$second`` is ``null``
1571+
:param string $second: Second column name
1572+
:param bool $escape: Whether to protect identifiers
1573+
:returns: ``BaseBuilder`` instance (method chaining)
1574+
:rtype: ``BaseBuilder``
1575+
1576+
Generates a ``WHERE`` clause that compares two columns. Separates multiple calls with ``AND``.
1577+
If ``$second`` is omitted, ``$operator`` is used as the second column and ``=`` is used as the comparison operator.
1578+
Supported operators are ``=``, ``!=``, ``<>``, ``<``, ``>``, ``<=``, and ``>=``.
1579+
Unsupported operators throw an ``InvalidArgumentException``.
1580+
1581+
.. php:method:: orWhereColumn($first[, $operator = null[, $second = null[, $escape = null]]])
1582+
1583+
:param string $first: First column name
1584+
:param string $operator: Comparison operator, or second column name when ``$second`` is ``null``
1585+
:param string $second: Second column name
1586+
:param bool $escape: Whether to protect identifiers
1587+
:returns: ``BaseBuilder`` instance (method chaining)
1588+
:rtype: ``BaseBuilder``
1589+
1590+
Generates a ``WHERE`` clause that compares two columns. Separates multiple calls with ``OR``.
1591+
If ``$second`` is omitted, ``$operator`` is used as the second column and ``=`` is used as the comparison operator.
1592+
Supported operators are ``=``, ``!=``, ``<>``, ``<``, ``>``, ``<=``, and ``>=``.
1593+
Unsupported operators throw an ``InvalidArgumentException``.
1594+
15371595
.. php:method:: orWhereIn([$key = null[, $values = null[, $escape = null]]])
15381596
15391597
:param string $key: The field to search
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
$builder->whereColumn('created_at', 'updated_at');
4+
// Produces: WHERE created_at = updated_at
5+
6+
$builder->whereColumn('updated_at', '>', 'created_at');
7+
// Produces: WHERE updated_at > created_at

0 commit comments

Comments
 (0)