From 00f5e8a2039594b9651f239cca1485c6ab42d720 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Fri, 1 May 2026 05:25:46 +0200 Subject: [PATCH 1/4] 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> --- system/Database/BaseBuilder.php | 108 ++++++++++++++++ system/Model.php | 2 + tests/system/Database/Builder/PrefixTest.php | 13 ++ tests/system/Database/Builder/WhereTest.php | 117 ++++++++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 2 + .../source/database/query_builder.rst | 58 +++++++++ .../source/database/query_builder/123.php | 7 ++ 7 files changed, 307 insertions(+) create mode 100644 user_guide_src/source/database/query_builder/123.php diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 883c1aa7e5ec..786895fad644 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -736,6 +736,94 @@ 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 + * @param non-empty-string|null $operator Comparison operator, or second column name when $second is null + * @param non-empty-string|null $second Second column name + * @param bool|null $escape Whether to protect identifiers + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function whereColumn(string $first, ?string $operator = null, ?string $second = null, ?bool $escape = null) + { + return $this->whereColumnHaving('QBWhere', $first, $operator, $second, 'AND ', $escape); + } + + /** + * Generates an OR WHERE clause that compares two columns. + * + * @param non-empty-string $first First column name + * @param non-empty-string|null $operator Comparison operator, or second column name when $second is null + * @param non-empty-string|null $second Second column name + * @param bool|null $escape Whether to protect identifiers + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function orWhereColumn(string $first, ?string $operator = null, ?string $second = null, ?bool $escape = null) + { + return $this->whereColumnHaving('QBWhere', $first, $operator, $second, 'OR ', $escape); + } + + /** + * @used-by whereColumn() + * @used-by orWhereColumn() + * + * @param 'QBHaving'|'QBWhere' $qbKey + * @param non-empty-string $first First column name + * @param non-empty-string|null $operator Comparison operator, or second column name when $second is null + * @param non-empty-string|null $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 $operator = null, ?string $second = null, string $type = 'AND ', ?bool $escape = null) + { + if ($second === null) { + $second = $operator; + $operator = '='; + } elseif ($operator === null) { + $operator = '='; + } + + $first = trim($first); + $operator = trim($operator); + $second = trim((string) $second); + + if ($first === '' || $second === '') { + throw new InvalidArgumentException(sprintf('%s() expects $first and $second to be non-empty strings', debug_backtrace(0, 2)[1]['function'])); + } + + if (! in_array($operator, ['=', '!=', '<>', '<', '>', '<=', '>='], true)) { + throw new InvalidArgumentException(sprintf('%s() expects $operator to be one of: =, !=, <>, <, >, <=, >=', debug_backtrace(0, 2)[1]['function'])); + } + + 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; + } + /** * @used-by where() * @used-by orWhere() @@ -1383,6 +1471,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 @@ -3114,6 +3203,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']; @@ -3177,6 +3272,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. * diff --git a/system/Model.php b/system/Model.php index 581ae3c18672..bc8fe913a362 100644 --- a/system/Model.php +++ b/system/Model.php @@ -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 $operator = null, ?string $second = null, ?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) @@ -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 $operator = null, ?string $second = null, ?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) * diff --git a/tests/system/Database/Builder/PrefixTest.php b/tests/system/Database/Builder/PrefixTest.php index 03b8cd99e4d3..765304cd0aac 100644 --- a/tests/system/Database/Builder/PrefixTest.php +++ b/tests/system/Database/Builder/PrefixTest.php @@ -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' diff --git a/tests/system/Database/Builder/WhereTest.php b/tests/system/Database/Builder/WhereTest.php index 2d0c2a3e1233..fba6d52ee896 100644 --- a/tests/system/Database/Builder/WhereTest.php +++ b/tests/system/Database/Builder/WhereTest.php @@ -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; @@ -351,6 +352,122 @@ 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()); + } + + #[DataProvider('provideWhereColumnInvalidColumnThrowInvalidArgumentException')] + public function testWhereColumnInvalidColumnThrowInvalidArgumentException(string $first, ?string $operator, ?string $second): void + { + $this->expectException(InvalidArgumentException::class); + + $builder = $this->db->table('users'); + $builder->whereColumn($first, $operator, $second); + } + + /** + * @return iterable + */ + public static function provideWhereColumnInvalidColumnThrowInvalidArgumentException(): iterable + { + return [ + 'empty first column' => ['', '=', 'updated_at'], + 'empty second column' => ['created_at', '= ', ''], + 'empty second column as second arg' => ['created_at', '', null], + 'missing second' => ['created_at', null, null], + ]; + } + + 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'); diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 1c3f95efc78b..9ff9c3fbcd07 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -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 ----- diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 480b8c18d92e..bd5a2c9ce024 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -365,6 +365,36 @@ instances are joined by **OR**: .. literalinclude:: query_builder/029.php +.. _query-builder-where-column: + +$builder->whereColumn() +----------------------- + +.. versionadded:: 4.8.0 + +Compares one column to another column. If no operator is provided, ``=`` is +used: + +.. literalinclude:: query_builder/123.php + +When two arguments are passed, the second argument is treated as the column to +compare against. When three arguments are passed, the second argument is treated +as the comparison operator. Supported operators are ``=``, ``!=``, ``<>``, ``<``, +``>``, ``<=``, and ``>=``. Empty column names or unsupported operators throw an +``InvalidArgumentException``. + +Column names are protected by default, unless the ``$escape`` parameter is +``false``. + +.. warning:: Do not pass user-supplied data as column names. Values should use + ``where()`` or another value-binding method instead. + +$builder->orWhereColumn() +------------------------- + +This method is identical to ``whereColumn()``, except that multiple instances +are joined by **OR**. + $builder->whereIn() ------------------- @@ -1534,6 +1564,34 @@ Class Reference Generates the ``WHERE`` portion of the query. Separates multiple calls with ``OR``. + .. php:method:: whereColumn($first[, $operator = null[, $second = null[, $escape = null]]]) + + :param string $first: First column name + :param string $operator: Comparison operator, or second column name when ``$second`` is ``null`` + :param string $second: Second column name + :param bool $escape: Whether to protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE`` clause that compares two columns. Separates multiple calls with ``AND``. + If ``$second`` is omitted, ``$operator`` is used as the second column and ``=`` is used as the comparison operator. + Supported operators are ``=``, ``!=``, ``<>``, ``<``, ``>``, ``<=``, and ``>=``. + Unsupported operators throw an ``InvalidArgumentException``. + + .. php:method:: orWhereColumn($first[, $operator = null[, $second = null[, $escape = null]]]) + + :param string $first: First column name + :param string $operator: Comparison operator, or second column name when ``$second`` is ``null`` + :param string $second: Second column name + :param bool $escape: Whether to protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE`` clause that compares two columns. Separates multiple calls with ``OR``. + If ``$second`` is omitted, ``$operator`` is used as the second column and ``=`` is used as the comparison operator. + Supported operators are ``=``, ``!=``, ``<>``, ``<``, ``>``, ``<=``, and ``>=``. + Unsupported operators throw an ``InvalidArgumentException``. + .. php:method:: orWhereIn([$key = null[, $values = null[, $escape = null]]]) :param string $key: The field to search diff --git a/user_guide_src/source/database/query_builder/123.php b/user_guide_src/source/database/query_builder/123.php new file mode 100644 index 000000000000..a86b3ad1a440 --- /dev/null +++ b/user_guide_src/source/database/query_builder/123.php @@ -0,0 +1,7 @@ +whereColumn('created_at', 'updated_at'); +// Produces: WHERE created_at = updated_at + +$builder->whereColumn('updated_at', '>', 'created_at'); +// Produces: WHERE updated_at > created_at From 3e153b6398da57d776525aae86f86d0e9c575223 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Fri, 1 May 2026 09:41:32 +0200 Subject: [PATCH 2/4] refactor(database): align whereColumn with builder conventions - Parse comparison operators from the first column argument - Reject ambiguous operator-as-second-column usage - Update docs, examples, tests, and Model PHPDoc - Keep operator parsing local to avoid changing existing where/having behavior Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/BaseBuilder.php | 92 ++++++++++++------- system/Model.php | 4 +- tests/system/Database/Builder/PrefixTest.php | 2 +- tests/system/Database/Builder/WhereTest.php | 23 +++-- .../source/database/query_builder.rst | 23 ++--- .../source/database/query_builder/123.php | 5 +- 6 files changed, 88 insertions(+), 61 deletions(-) diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 786895fad644..d86023b362cf 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -739,71 +739,67 @@ public function orWhere($key, $value = null, ?bool $escape = null) /** * Generates a WHERE clause that compares two columns. * - * @param non-empty-string $first First column name - * @param non-empty-string|null $operator Comparison operator, or second column name when $second is null - * @param non-empty-string|null $second Second column name - * @param bool|null $escape Whether to protect identifiers + * @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 $operator = null, ?string $second = null, ?bool $escape = null) + public function whereColumn(string $first, string $second, ?bool $escape = null) { - return $this->whereColumnHaving('QBWhere', $first, $operator, $second, 'AND ', $escape); + 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 - * @param non-empty-string|null $operator Comparison operator, or second column name when $second is null - * @param non-empty-string|null $second Second column name - * @param bool|null $escape Whether to protect identifiers + * @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 $operator = null, ?string $second = null, ?bool $escape = null) + public function orWhereColumn(string $first, string $second, ?bool $escape = null) { - return $this->whereColumnHaving('QBWhere', $first, $operator, $second, 'OR ', $escape); + 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 - * @param non-empty-string|null $operator Comparison operator, or second column name when $second is null - * @param non-empty-string|null $second Second column name - * @param non-empty-string $type - * @param bool|null $escape Whether to protect identifiers + * @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 $operator = null, ?string $second = null, string $type = 'AND ', ?bool $escape = null) + protected function whereColumnHaving(string $qbKey, string $first, string $second, string $type = 'AND ', ?bool $escape = null) { - if ($second === null) { - $second = $operator; - $operator = '='; - } elseif ($operator === null) { - $operator = '='; - } - - $first = trim($first); - $operator = trim($operator); - $second = trim((string) $second); + $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', debug_backtrace(0, 2)[1]['function'])); + throw new InvalidArgumentException(sprintf('%s() expects $first and $second to be non-empty strings', $caller)); } - if (! in_array($operator, ['=', '!=', '<>', '<', '>', '<=', '>='], true)) { - throw new InvalidArgumentException(sprintf('%s() expects $operator to be one of: =, !=, <>, <, >, <=, >=', debug_backtrace(0, 2)[1]['function'])); + // The second argument must be a column name, not an operator. + // This rejects likely missing-column mistakes like whereColumn('created_at', '>='). + if (in_array( + strtoupper($second), + ['=', '!=', '<>', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', 'BETWEEN', 'IS NULL', 'IS NOT NULL'], + true, + )) { + throw new InvalidArgumentException(sprintf('%s() expects $second to be a column name, not an operator', $caller)); } if (! is_bool($escape)) { @@ -824,6 +820,38 @@ protected function whereColumnHaving(string $qbKey, string $first, ?string $oper 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])]; + } + + $operator = $this->getOperatorFromWhereKey($first); + + if ($operator !== false) { + end($operator); + $operator = trim(current($operator)); + + if ($operator !== '') { + throw new InvalidArgumentException(sprintf('%s() expects $first to contain one of: =, !=, <>, <, >, <=, >=', $caller)); + } + } + + return [$first, '=']; + } + /** * @used-by where() * @used-by orWhere() diff --git a/system/Model.php b/system/Model.php index bc8fe913a362..7781a7227c0a 100644 --- a/system/Model.php +++ b/system/Model.php @@ -72,7 +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 $operator = null, ?string $second = 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) @@ -84,7 +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 $operator = null, ?string $second = 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) * diff --git a/tests/system/Database/Builder/PrefixTest.php b/tests/system/Database/Builder/PrefixTest.php index 765304cd0aac..a88de27c7a9b 100644 --- a/tests/system/Database/Builder/PrefixTest.php +++ b/tests/system/Database/Builder/PrefixTest.php @@ -62,7 +62,7 @@ public function testPrefixesSetOnTableNamesWithWhereColumnClause(): void $expectedSQL = 'SELECT * FROM "ci_users" WHERE "ci_users"."created_at" < "ci_users"."updated_at"'; $expectedBinds = []; - $builder->whereColumn('users.created_at', '<', 'users.updated_at'); + $builder->whereColumn('users.created_at <', 'users.updated_at'); $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); $this->assertSame($expectedBinds, $builder->getBinds()); diff --git a/tests/system/Database/Builder/WhereTest.php b/tests/system/Database/Builder/WhereTest.php index fba6d52ee896..a20b7bb243a5 100644 --- a/tests/system/Database/Builder/WhereTest.php +++ b/tests/system/Database/Builder/WhereTest.php @@ -369,7 +369,7 @@ public function testWhereColumnWithOperator(): void { $builder = $this->db->table('users'); - $builder->whereColumn('updated_at', '>', 'created_at'); + $builder->whereColumn('updated_at >', 'created_at'); $expectedSQL = 'SELECT * FROM "users" WHERE "updated_at" > "created_at"'; $expectedBinds = []; @@ -382,7 +382,7 @@ public function testWhereColumnWithAlias(): void { $builder = $this->db->table('users u'); - $builder->whereColumn('u.updated_at', '>', 'u.created_at'); + $builder->whereColumn('u.updated_at >', 'u.created_at'); $expectedSQL = 'SELECT * FROM "users" "u" WHERE "u"."updated_at" > "u"."created_at"'; $expectedBinds = []; @@ -396,7 +396,7 @@ public function testOrWhereColumn(): void $builder = $this->db->table('users'); $builder->where('active', 1) - ->orWhereColumn('updated_at', '>', 'created_at'); + ->orWhereColumn('updated_at >', 'created_at'); $expectedSQL = 'SELECT * FROM "users" WHERE "active" = 1 OR "updated_at" > "created_at"'; $expectedBinds = [ @@ -416,7 +416,7 @@ public function testWhereColumnWithGroupedConditions(): void $builder->groupStart() ->whereColumn('created_at', 'updated_at') - ->orWhereColumn('updated_at', '>', 'created_at') + ->orWhereColumn('updated_at >', 'created_at') ->groupEnd() ->where('active', 1); @@ -439,24 +439,23 @@ public function testWhereColumnNoEscape(): void } #[DataProvider('provideWhereColumnInvalidColumnThrowInvalidArgumentException')] - public function testWhereColumnInvalidColumnThrowInvalidArgumentException(string $first, ?string $operator, ?string $second): void + public function testWhereColumnInvalidColumnThrowInvalidArgumentException(string $first, string $second): void { $this->expectException(InvalidArgumentException::class); $builder = $this->db->table('users'); - $builder->whereColumn($first, $operator, $second); + $builder->whereColumn($first, $second); } /** - * @return iterable + * @return iterable */ public static function provideWhereColumnInvalidColumnThrowInvalidArgumentException(): iterable { return [ - 'empty first column' => ['', '=', 'updated_at'], - 'empty second column' => ['created_at', '= ', ''], - 'empty second column as second arg' => ['created_at', '', null], - 'missing second' => ['created_at', null, null], + 'empty first column' => ['', 'updated_at'], + 'empty second column' => ['created_at =', ''], + 'operator as second' => ['created_at', '>='], ]; } @@ -465,7 +464,7 @@ public function testWhereColumnInvalidOperatorThrowInvalidArgumentException(): v $this->expectException(InvalidArgumentException::class); $builder = $this->db->table('users'); - $builder->whereColumn('created_at', 'LIKE', 'updated_at'); + $builder->whereColumn('created_at LIKE', 'updated_at'); } public function testWhereIn(): void diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index bd5a2c9ce024..5cd0d77df0ce 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -377,11 +377,10 @@ used: .. literalinclude:: query_builder/123.php -When two arguments are passed, the second argument is treated as the column to -compare against. When three arguments are passed, the second argument is treated -as the comparison operator. Supported operators are ``=``, ``!=``, ``<>``, ``<``, -``>``, ``<=``, and ``>=``. Empty column names or unsupported operators throw an -``InvalidArgumentException``. +You can include an operator in the first parameter in order to control the +comparison. Supported operators are ``=``, ``!=``, ``<>``, ``<``, ``>``, ``<=``, +and ``>=``. Empty column names, unsupported operators, or passing an operator +as the second argument throw an ``InvalidArgumentException``. Column names are protected by default, unless the ``$escape`` parameter is ``false``. @@ -1564,31 +1563,29 @@ Class Reference Generates the ``WHERE`` portion of the query. Separates multiple calls with ``OR``. - .. php:method:: whereColumn($first[, $operator = null[, $second = null[, $escape = null]]]) + .. php:method:: whereColumn($first, $second[, $escape = null]) - :param string $first: First column name - :param string $operator: Comparison operator, or second column name when ``$second`` is ``null`` + :param string $first: First column name, optionally with comparison operator :param string $second: Second column name :param bool $escape: Whether to protect identifiers :returns: ``BaseBuilder`` instance (method chaining) :rtype: ``BaseBuilder`` Generates a ``WHERE`` clause that compares two columns. Separates multiple calls with ``AND``. - If ``$second`` is omitted, ``$operator`` is used as the second column and ``=`` is used as the comparison operator. + If ``$first`` does not include an operator, ``=`` is used as the comparison operator. Supported operators are ``=``, ``!=``, ``<>``, ``<``, ``>``, ``<=``, and ``>=``. Unsupported operators throw an ``InvalidArgumentException``. - .. php:method:: orWhereColumn($first[, $operator = null[, $second = null[, $escape = null]]]) + .. php:method:: orWhereColumn($first, $second[, $escape = null]) - :param string $first: First column name - :param string $operator: Comparison operator, or second column name when ``$second`` is ``null`` + :param string $first: First column name, optionally with comparison operator :param string $second: Second column name :param bool $escape: Whether to protect identifiers :returns: ``BaseBuilder`` instance (method chaining) :rtype: ``BaseBuilder`` Generates a ``WHERE`` clause that compares two columns. Separates multiple calls with ``OR``. - If ``$second`` is omitted, ``$operator`` is used as the second column and ``=`` is used as the comparison operator. + If ``$first`` does not include an operator, ``=`` is used as the comparison operator. Supported operators are ``=``, ``!=``, ``<>``, ``<``, ``>``, ``<=``, and ``>=``. Unsupported operators throw an ``InvalidArgumentException``. diff --git a/user_guide_src/source/database/query_builder/123.php b/user_guide_src/source/database/query_builder/123.php index a86b3ad1a440..7fa2e112f95c 100644 --- a/user_guide_src/source/database/query_builder/123.php +++ b/user_guide_src/source/database/query_builder/123.php @@ -3,5 +3,8 @@ $builder->whereColumn('created_at', 'updated_at'); // Produces: WHERE created_at = updated_at -$builder->whereColumn('updated_at', '>', 'created_at'); +$builder->whereColumn('updated_at >', 'created_at'); // Produces: WHERE updated_at > created_at + +$builder->whereColumn('updated_at !=', 'created_at'); +// Produces: WHERE updated_at != created_at From e52f905c58e36704ad0411a2be91a38ccc4de932 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Sat, 2 May 2026 15:25:18 +0200 Subject: [PATCH 3/4] refactor(database): address whereColumn review feedback - Treat the second argument as a normal column identifier - Limit first-argument operator detection to terminal operators - Add regression tests for identifier and expression edge cases - Update docs for the refined validation behavior Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/BaseBuilder.php | 31 ++++++++----------- tests/system/Database/Builder/WhereTest.php | 27 +++++++++++++++- .../source/database/query_builder.rst | 4 +-- 3 files changed, 41 insertions(+), 21 deletions(-) diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index d86023b362cf..10e16f05e730 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -792,16 +792,6 @@ protected function whereColumnHaving(string $qbKey, string $first, string $secon throw new InvalidArgumentException(sprintf('%s() expects $first and $second to be non-empty strings', $caller)); } - // The second argument must be a column name, not an operator. - // This rejects likely missing-column mistakes like whereColumn('created_at', '>='). - if (in_array( - strtoupper($second), - ['=', '!=', '<>', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', 'BETWEEN', 'IS NULL', 'IS NOT NULL'], - true, - )) { - throw new InvalidArgumentException(sprintf('%s() expects $second to be a column name, not an operator', $caller)); - } - if (! is_bool($escape)) { $escape = $this->db->protectIdentifiers; } @@ -838,15 +828,20 @@ private function parseWhereColumnFirst(string $first, string $caller): array return [rtrim(substr($first, 0, -strlen($match[0]))), trim($match[1])]; } - $operator = $this->getOperatorFromWhereKey($first); - - if ($operator !== false) { - end($operator); - $operator = trim(current($operator)); + $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 ($operator !== '') { - throw new InvalidArgumentException(sprintf('%s() expects $first to contain one of: =, !=, <>, <, >, <=, >=', $caller)); - } + if (preg_match('/(?:' . implode('|', $unsupportedOperators) . ')\s*$/i', $first) === 1) { + throw new InvalidArgumentException(sprintf('%s() expects $first to contain one of: =, !=, <>, <, >, <=, >=', $caller)); } return [$first, '=']; diff --git a/tests/system/Database/Builder/WhereTest.php b/tests/system/Database/Builder/WhereTest.php index a20b7bb243a5..acd35810e965 100644 --- a/tests/system/Database/Builder/WhereTest.php +++ b/tests/system/Database/Builder/WhereTest.php @@ -438,6 +438,32 @@ public function testWhereColumnNoEscape(): void $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 { @@ -455,7 +481,6 @@ public static function provideWhereColumnInvalidColumnThrowInvalidArgumentExcept return [ 'empty first column' => ['', 'updated_at'], 'empty second column' => ['created_at =', ''], - 'operator as second' => ['created_at', '>='], ]; } diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 5cd0d77df0ce..963825578674 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -379,8 +379,8 @@ used: You can include an operator in the first parameter in order to control the comparison. Supported operators are ``=``, ``!=``, ``<>``, ``<``, ``>``, ``<=``, -and ``>=``. Empty column names, unsupported operators, or passing an operator -as the second argument throw an ``InvalidArgumentException``. +and ``>=``. Empty column names or unsupported operators throw an +``InvalidArgumentException``. Column names are protected by default, unless the ``$escape`` parameter is ``false``. From 287432cef1e72e8f9c8f897853d420caac13d97c Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Sat, 2 May 2026 21:33:51 +0200 Subject: [PATCH 4/4] refactor(database): simplify whereColumn operator parsing - Detect only supported terminal comparison operators - Default to equality when no supported operator is found - Remove unsupported-operator validation - Update tests and docs for the simpler behavior Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/BaseBuilder.php | 25 +++---------------- tests/system/Database/Builder/WhereTest.php | 8 ------ .../source/database/query_builder.rst | 10 +++----- 3 files changed, 7 insertions(+), 36 deletions(-) diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 10e16f05e730..92517aad20e6 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -785,7 +785,7 @@ public function orWhereColumn(string $first, string $second, ?bool $escape = nul 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); + [$first, $operator] = $this->parseWhereColumnFirst($first); $second = trim($second); if ($first === '' || $second === '') { @@ -813,14 +813,11 @@ protected function whereColumnHaving(string $qbKey, string $first, string $secon /** * 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 + * @param string $first The first column, optionally ending with a comparison operator * * @return array{string, string} - * - * @throws InvalidArgumentException */ - private function parseWhereColumnFirst(string $first, string $caller): array + private function parseWhereColumnFirst(string $first): array { $first = trim($first); @@ -828,22 +825,6 @@ private function parseWhereColumnFirst(string $first, string $caller): array 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)); - } - return [$first, '=']; } diff --git a/tests/system/Database/Builder/WhereTest.php b/tests/system/Database/Builder/WhereTest.php index acd35810e965..f645a7fc9163 100644 --- a/tests/system/Database/Builder/WhereTest.php +++ b/tests/system/Database/Builder/WhereTest.php @@ -484,14 +484,6 @@ public static function provideWhereColumnInvalidColumnThrowInvalidArgumentExcept ]; } - 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'); diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 963825578674..0e32b9878963 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -379,8 +379,8 @@ used: You can include an operator in the first parameter in order to control the comparison. Supported operators are ``=``, ``!=``, ``<>``, ``<``, ``>``, ``<=``, -and ``>=``. Empty column names or unsupported operators throw an -``InvalidArgumentException``. +and ``>=``. If none of these operators is detected at the end of the first +parameter, ``=`` is used. Empty column names throw an ``InvalidArgumentException``. Column names are protected by default, unless the ``$escape`` parameter is ``false``. @@ -1572,9 +1572,8 @@ Class Reference :rtype: ``BaseBuilder`` Generates a ``WHERE`` clause that compares two columns. Separates multiple calls with ``AND``. - If ``$first`` does not include an operator, ``=`` is used as the comparison operator. + If ``$first`` does not end with a supported operator, ``=`` is used as the comparison operator. Supported operators are ``=``, ``!=``, ``<>``, ``<``, ``>``, ``<=``, and ``>=``. - Unsupported operators throw an ``InvalidArgumentException``. .. php:method:: orWhereColumn($first, $second[, $escape = null]) @@ -1585,9 +1584,8 @@ Class Reference :rtype: ``BaseBuilder`` Generates a ``WHERE`` clause that compares two columns. Separates multiple calls with ``OR``. - If ``$first`` does not include an operator, ``=`` is used as the comparison operator. + If ``$first`` does not end with a supported operator, ``=`` is used as the comparison operator. Supported operators are ``=``, ``!=``, ``<>``, ``<``, ``>``, ``<=``, and ``>=``. - Unsupported operators throw an ``InvalidArgumentException``. .. php:method:: orWhereIn([$key = null[, $values = null[, $escape = null]]])