Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
131 changes: 131 additions & 0 deletions system/Database/BaseBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,117 @@ public function orWhere($key, $value = null, ?bool $escape = null)
return $this->whereHaving('QBWhere', $key, $value, 'OR ', $escape);
}

/**
* Generates a WHERE clause that compares two columns.
*
* @param non-empty-string $first First column name, optionally with comparison operator
* @param non-empty-string $second Second column name
* @param bool|null $escape Whether to protect identifiers
*
* @return $this
*
* @throws InvalidArgumentException
*/
public function whereColumn(string $first, string $second, ?bool $escape = null)
{
return $this->whereColumnHaving('QBWhere', $first, $second, 'AND ', $escape);
}

/**
* Generates an OR WHERE clause that compares two columns.
*
* @param non-empty-string $first First column name, optionally with comparison operator
* @param non-empty-string $second Second column name
* @param bool|null $escape Whether to protect identifiers
*
* @return $this
*
* @throws InvalidArgumentException
*/
public function orWhereColumn(string $first, string $second, ?bool $escape = null)
{
return $this->whereColumnHaving('QBWhere', $first, $second, 'OR ', $escape);
}

/**
* @used-by whereColumn()
* @used-by orWhereColumn()
*
* @param 'QBHaving'|'QBWhere' $qbKey
* @param non-empty-string $first First column name, optionally with comparison operator
* @param non-empty-string $second Second column name
* @param non-empty-string $type
* @param bool|null $escape Whether to protect identifiers
*
* @return $this
*
* @throws InvalidArgumentException
*/
protected function whereColumnHaving(string $qbKey, string $first, string $second, string $type = 'AND ', ?bool $escape = null)
{
$caller = debug_backtrace(0, 2)[1]['function'];
[$first, $operator] = $this->parseWhereColumnFirst($first, $caller);
$second = trim($second);

if ($first === '' || $second === '') {
throw new InvalidArgumentException(sprintf('%s() expects $first and $second to be non-empty strings', $caller));
}

if (! is_bool($escape)) {
$escape = $this->db->protectIdentifiers;
}

$prefix = $this->{$qbKey} === [] ? $this->groupGetType('') : $this->groupGetType($type);

$this->{$qbKey}[] = [
'columnComparison' => true,
'condition' => $prefix,
'escape' => $escape,
'first' => $first,
'operator' => $operator,
'second' => $second,
];

return $this;
}

/**
* Extracts the operator from the first whereColumn() column.
*
* @param string $first The first column, optionally ending with a comparison operator
* @param string $caller The public caller for exception messages
*
* @return array{string, string}
*
* @throws InvalidArgumentException
*/
private function parseWhereColumnFirst(string $first, string $caller): array
{
$first = trim($first);

if (preg_match('/\s*(!=|<>|<=|>=|=|<|>)\s*$/', $first, $match) === 1) {
return [rtrim(substr($first, 0, -strlen($match[0]))), trim($match[1])];
}

$unsupportedOperators = [
'\s+IS\s+NULL',
'\s+IS\s+NOT\s+NULL',
'\s+NOT\s+EXISTS',
'\s+EXISTS',
'\s+BETWEEN',
'\s+NOT\s+IN',
'\s+IN',
'\s+NOT\s+LIKE',
'\s+LIKE',
];

if (preg_match('/(?:' . implode('|', $unsupportedOperators) . ')\s*$/i', $first) === 1) {
throw new InvalidArgumentException(sprintf('%s() expects $first to contain one of: =, !=, <>, <, >, <=, >=', $caller));
}
Comment thread
memleakd marked this conversation as resolved.
Outdated

return [$first, '='];
}

/**
* @used-by where()
* @used-by orWhere()
Expand Down Expand Up @@ -1383,6 +1494,7 @@ protected function groupEndPrepare(string $clause = 'QBWhere')
* @used-by _like()
* @used-by whereHaving()
* @used-by _whereIn()
* @used-by whereColumnHaving()
* @used-by havingGroupStart()
*/
protected function groupGetType(string $type): string
Expand Down Expand Up @@ -3114,6 +3226,12 @@ protected function compileWhereHaving(string $qbKey): string
continue;
}

if (($qbkey['columnComparison'] ?? false) === true) {
$qbkey = $this->compileColumnComparison($qbkey);

continue;
}

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

Expand Down Expand Up @@ -3177,6 +3295,19 @@ protected function compileWhereHaving(string $qbKey): string
return '';
}

/**
* @param array{columnComparison: true, condition: string, escape: bool, first: string, operator: string, second: string} $condition
*/
private function compileColumnComparison(array $condition): string
{
if ($condition['escape']) {
$condition['first'] = $this->db->protectIdentifiers($condition['first'], false, true);
$condition['second'] = $this->db->protectIdentifiers($condition['second'], false, true);
}

return $condition['condition'] . $condition['first'] . ' ' . $condition['operator'] . ' ' . $condition['second'];
}

/**
* Escapes identifiers in GROUP BY statements at execution time.
*
Expand Down
2 changes: 2 additions & 0 deletions system/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
* @method $this orNotHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
* @method $this orNotLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
* @method $this orWhere($key, $value = null, ?bool $escape = null)
* @method $this orWhereColumn(string $first, string $second, ?bool $escape = null)
* @method $this orWhereIn(?string $key = null, $values = null, ?bool $escape = null)
* @method $this orWhereNotIn(?string $key = null, $values = null, ?bool $escape = null)
* @method $this select($select = '*', ?bool $escape = null)
Expand All @@ -83,6 +84,7 @@
* @method $this when($condition, callable $callback, ?callable $defaultCallback = null)
* @method $this whenNot($condition, callable $callback, ?callable $defaultCallback = null)
* @method $this where($key, $value = null, ?bool $escape = null)
* @method $this whereColumn(string $first, string $second, ?bool $escape = null)
* @method $this whereIn(?string $key = null, $values = null, ?bool $escape = null)
* @method $this whereNotIn(?string $key = null, $values = null, ?bool $escape = null)
*
Expand Down
13 changes: 13 additions & 0 deletions tests/system/Database/Builder/PrefixTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ public function testPrefixesSetOnTableNamesWithWhereClause(): void
$this->assertSame($expectedBinds, $builder->getBinds());
}

public function testPrefixesSetOnTableNamesWithWhereColumnClause(): void
{
$builder = $this->db->table('users');

$expectedSQL = 'SELECT * FROM "ci_users" WHERE "ci_users"."created_at" < "ci_users"."updated_at"';
$expectedBinds = [];

$builder->whereColumn('users.created_at <', 'users.updated_at');

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

public function testPrefixWithSubquery(): void
{
$expected = <<<'NOWDOC'
Expand Down
141 changes: 141 additions & 0 deletions tests/system/Database/Builder/WhereTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

use CodeIgniter\Database\BaseBuilder;
use CodeIgniter\Database\RawSql;
use CodeIgniter\Exceptions\InvalidArgumentException;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\Mock\MockConnection;
use DateTime;
Expand Down Expand Up @@ -351,6 +352,146 @@ public function testOrWhereSameColumn(): void
$this->assertSame($expectedBinds, $builder->getBinds());
}

public function testWhereColumn(): void
{
$builder = $this->db->table('users');

$builder->whereColumn('created_at', 'updated_at');

$expectedSQL = 'SELECT * FROM "users" WHERE "created_at" = "updated_at"';
$expectedBinds = [];

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

public function testWhereColumnWithOperator(): void
{
$builder = $this->db->table('users');

$builder->whereColumn('updated_at >', 'created_at');

$expectedSQL = 'SELECT * FROM "users" WHERE "updated_at" > "created_at"';
$expectedBinds = [];

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

public function testWhereColumnWithAlias(): void
{
$builder = $this->db->table('users u');

$builder->whereColumn('u.updated_at >', 'u.created_at');

$expectedSQL = 'SELECT * FROM "users" "u" WHERE "u"."updated_at" > "u"."created_at"';
$expectedBinds = [];

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

public function testOrWhereColumn(): void
{
$builder = $this->db->table('users');

$builder->where('active', 1)
->orWhereColumn('updated_at >', 'created_at');

$expectedSQL = 'SELECT * FROM "users" WHERE "active" = 1 OR "updated_at" > "created_at"';
$expectedBinds = [
'active' => [
1,
true,
],
];

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

public function testWhereColumnWithGroupedConditions(): void
{
$builder = $this->db->table('users');

$builder->groupStart()
->whereColumn('created_at', 'updated_at')
->orWhereColumn('updated_at >', 'created_at')
->groupEnd()
->where('active', 1);

$expectedSQL = 'SELECT * FROM "users" WHERE ( "created_at" = "updated_at" OR "updated_at" > "created_at" ) AND "active" = 1';

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

public function testWhereColumnNoEscape(): void
{
$builder = $this->db->table('users');

$builder->whereColumn('LOWER(users.email)', 'normalized_email', escape: false);

$expectedSQL = 'SELECT * FROM "users" WHERE LOWER(users.email) = normalized_email';
$expectedBinds = [];

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

public function testWhereColumnTreatsSecondArgumentAsColumnName(): void
{
$builder = $this->db->table('users');

$builder->whereColumn('created_at', 'like');

$expectedSQL = 'SELECT * FROM "users" WHERE "created_at" = "like"';
$expectedBinds = [];

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

public function testWhereColumnIgnoresOperatorsInsideFirstArgument(): void
{
$builder = $this->db->table('users');

$builder->whereColumn("JSON_EXTRACT(data, '$.a>b')", 'updated_at', escape: false);

$expectedSQL = 'SELECT * FROM "users" WHERE JSON_EXTRACT(data, \'$.a>b\') = updated_at';
$expectedBinds = [];

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

#[DataProvider('provideWhereColumnInvalidColumnThrowInvalidArgumentException')]
public function testWhereColumnInvalidColumnThrowInvalidArgumentException(string $first, string $second): void
{
$this->expectException(InvalidArgumentException::class);

$builder = $this->db->table('users');
$builder->whereColumn($first, $second);
}

/**
* @return iterable<string, array{string, string}>
*/
public static function provideWhereColumnInvalidColumnThrowInvalidArgumentException(): iterable
{
return [
'empty first column' => ['', 'updated_at'],
'empty second column' => ['created_at =', ''],
];
}

public function testWhereColumnInvalidOperatorThrowInvalidArgumentException(): void
{
$this->expectException(InvalidArgumentException::class);

$builder = $this->db->table('users');
$builder->whereColumn('created_at LIKE', 'updated_at');
}

public function testWhereIn(): void
{
$builder = $this->db->table('jobs');
Expand Down
2 changes: 2 additions & 0 deletions user_guide_src/source/changelogs/v4.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ Database
Query Builder
-------------

- Added ``whereColumn()`` and ``orWhereColumn()`` to compare one column to another column while protecting identifiers by default. See :ref:`query-builder-where-column`.

Forge
-----

Expand Down
Loading
Loading