diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index a583f7045f02..f4f448b76c95 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -625,21 +625,13 @@ public function fromSubquery(BaseBuilder $from, string $alias): self /** * Generates the JOIN portion of the query * - * @param RawSql|string $cond + * @param Closure(JoinClause): void|RawSql|string $cond * * @return $this */ public function join(string $table, $cond, string $type = '', ?bool $escape = null) { - if ($type !== '') { - $type = strtoupper(trim($type)); - - if (! in_array($type, $this->joinTypes, true)) { - $type = ''; - } else { - $type .= ' '; - } - } + $type = $this->normalizeJoinType($type); // Extract any aliases that might exist. We use this information // in the protectIdentifiers to know whether to add a table prefix @@ -654,10 +646,39 @@ public function join(string $table, $cond, string $type = '', ?bool $escape = nu $table = $this->db->protectIdentifiers($table, true, null, false); } + $cond = $this->compileJoinCondition($cond, $escape); + + // Assemble the JOIN statement + $this->QBJoin[] = $type . 'JOIN ' . $table . $cond; + + return $this; + } + + protected function normalizeJoinType(string $type): string + { + if ($type === '') { + return ''; + } + + $type = strtoupper(trim($type)); + + return in_array($type, $this->joinTypes, true) ? $type . ' ' : ''; + } + + /** + * @param Closure(JoinClause): void|RawSql|string $cond + */ + protected function compileJoinCondition(Closure|RawSql|string $cond, bool $escape): string + { if ($cond instanceof RawSql) { - $this->QBJoin[] = $type . 'JOIN ' . $table . ' ON ' . $cond; + return ' ON ' . $cond; + } - return $this; + if ($cond instanceof Closure) { + $joinClause = new JoinClause($this->db, fn (string $key, mixed $value, bool $escape): string => $this->setBind($key, $value, $escape), $escape); + $cond($joinClause); + + return $joinClause->compile(); } if (! $this->hasOperator($cond)) { @@ -701,10 +722,7 @@ public function join(string $table, $cond, string $type = '', ?bool $escape = nu } } - // Assemble the JOIN statement - $this->QBJoin[] = $type . 'JOIN ' . $table . $cond; - - return $this; + return $cond; } /** diff --git a/system/Database/JoinClause.php b/system/Database/JoinClause.php new file mode 100644 index 000000000000..0c578ee95b1b --- /dev/null +++ b/system/Database/JoinClause.php @@ -0,0 +1,302 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use Closure; +use CodeIgniter\Exceptions\InvalidArgumentException; + +/** + * Builds conditions for a JOIN ON clause. + */ +class JoinClause +{ + /** + * @var list + */ + private array $conditions = []; + + private int $conditionCount = 0; + + /** + * @var list + */ + private array $groupConditionCounts = []; + + private bool $groupStarted = false; + + /** + * @param Closure(string, mixed, bool): string $setBind + * + * @internal This class is normally created by BaseBuilder::join(). + */ + public function __construct( + private readonly BaseConnection $db, + private readonly Closure $setBind, + private readonly bool $escape, + ) { + } + + /** + * Adds a column comparison to the JOIN ON clause. + * + * @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 + * + * @throws InvalidArgumentException + */ + public function on(string $first, string $second, ?bool $escape = null): static + { + return $this->onColumn($first, $second, 'AND ', $escape); + } + + /** + * Adds an OR column comparison to the JOIN ON clause. + * + * @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 + * + * @throws InvalidArgumentException + */ + public function orOn(string $first, string $second, ?bool $escape = null): static + { + return $this->onColumn($first, $second, 'OR ', $escape); + } + + /** + * Adds a value comparison to the JOIN ON clause. + * + * @param non-empty-string $key Column name, optionally with comparison operator + * @param mixed $value Value to bind + * @param bool|null $escape Whether to protect identifiers + * + * @throws InvalidArgumentException + */ + public function where(string $key, mixed $value = null, ?bool $escape = null): static + { + return $this->whereHaving($key, $value, 'AND ', $escape); + } + + /** + * Adds an OR value comparison to the JOIN ON clause. + * + * @param non-empty-string $key Column name, optionally with comparison operator + * @param mixed $value Value to bind + * @param bool|null $escape Whether to protect identifiers + * + * @throws InvalidArgumentException + */ + public function orWhere(string $key, mixed $value = null, ?bool $escape = null): static + { + return $this->whereHaving($key, $value, 'OR ', $escape); + } + + /** + * Starts a condition group. + */ + public function groupStart(): static + { + return $this->groupStartPrepare(); + } + + /** + * Starts a condition group, prefixed with OR. + */ + public function orGroupStart(): static + { + return $this->groupStartPrepare('', 'OR '); + } + + /** + * Starts a condition group, prefixed with NOT. + */ + public function notGroupStart(): static + { + return $this->groupStartPrepare('NOT '); + } + + /** + * Starts a condition group, prefixed with OR NOT. + */ + public function orNotGroupStart(): static + { + return $this->groupStartPrepare('NOT ', 'OR '); + } + + /** + * Ends the current condition group. + * + * @throws InvalidArgumentException + */ + public function groupEnd(): static + { + if ($this->groupConditionCounts === []) { + throw new InvalidArgumentException('JoinClause groupEnd() called without a matching groupStart().'); + } + + $conditionCount = array_pop($this->groupConditionCounts); + + if ($conditionCount === 0) { + throw new InvalidArgumentException('JoinClause groups must contain at least one condition.'); + } + + $this->conditions[] = ')'; + $this->groupStarted = false; + $this->incrementConditionCount(); + + return $this; + } + + /** + * Compiles the JOIN ON clause conditions. + * + * @internal + * + * @throws InvalidArgumentException + */ + public function compile(): string + { + if ($this->groupConditionCounts !== []) { + throw new InvalidArgumentException('JoinClause groups must be balanced.'); + } + + if ($this->conditionCount === 0) { + throw new InvalidArgumentException('JoinClause must contain at least one condition.'); + } + + return ' ON ' . implode('', $this->conditions); + } + + /** + * @param non-empty-string $first + * @param non-empty-string $second + */ + private function onColumn(string $first, string $second, string $type, ?bool $escape): static + { + [$first, $operator] = $this->parseFirstColumn($first); + $second = trim($second); + + if ($first === '' || $second === '') { + throw new InvalidArgumentException('JoinClause column comparisons expect $first and $second to be non-empty strings.'); + } + + $escape ??= $this->escape; + + if ($escape) { + $first = $this->db->protectIdentifiers($first, false, true); + $second = $this->db->protectIdentifiers($second, false, true); + } + + $this->appendCondition($this->prefix($type) . $first . ' ' . $operator . ' ' . $second); + + return $this; + } + + private function whereHaving(string $key, mixed $value, string $type, ?bool $escape): static + { + $key = trim($key); + + if ($key === '') { + throw new InvalidArgumentException('JoinClause value comparisons expect $key to be a non-empty string.'); + } + + $escape ??= $this->escape; + + if ($value !== null) { + [$key, $operator] = $this->parseWhereKey($key); + $bind = ($this->setBind)($key, $value, $escape); + $condition = $this->protectIdentifier($key, $escape) . $operator . " :{$bind}:"; + } elseif (preg_match('/\s*(!=|<>|IS(?:\s+NOT)?)\s*$/i', $key, $match, PREG_OFFSET_CAPTURE) === 1) { + $key = substr($key, 0, $match[0][1]); + $operator = $match[1][0] === '=' || strcasecmp($match[1][0], 'IS') === 0 ? ' IS NULL' : ' IS NOT NULL'; + $condition = $this->protectIdentifier($key, $escape) . $operator; + } else { + $condition = $this->protectIdentifier($key, $escape) . ' IS NULL'; + } + + $this->appendCondition($this->prefix($type) . $condition); + + return $this; + } + + private function groupStartPrepare(string $not = '', string $type = 'AND '): static + { + $this->conditions[] = $this->prefix($type) . $not . '('; + $this->groupConditionCounts[] = 0; + $this->groupStarted = true; + + return $this; + } + + /** + * @return array{string, string} + */ + private function parseFirstColumn(string $first): array + { + $first = trim($first); + + if (preg_match('/\s*(!=|<>|<=|>=|=|<|>)\s*$/', $first, $match) === 1) { + return [rtrim(substr($first, 0, -strlen($match[0]))), trim($match[1])]; + } + + return [$first, '=']; + } + + /** + * @return array{string, string} + */ + private function parseWhereKey(string $key): array + { + if (preg_match('/\s*(!=|<>|<=|>=|=|<|>)\s*$/', $key, $match) === 1) { + return [rtrim(substr($key, 0, -strlen($match[0]))), ' ' . trim($match[1])]; + } + + return [$key, ' =']; + } + + private function protectIdentifier(string $identifier, bool $escape): string + { + return $escape ? $this->db->protectIdentifiers($identifier) : $identifier; + } + + private function prefix(string $type): string + { + if ($this->conditions === [] || $this->groupStarted) { + $this->groupStarted = false; + + return ''; + } + + return ' ' . $type; + } + + private function appendCondition(string $condition): void + { + $this->conditions[] = $condition; + $this->incrementConditionCount(); + } + + private function incrementConditionCount(): void + { + if ($this->groupConditionCounts === []) { + $this->conditionCount++; + + return; + } + + $index = array_key_last($this->groupConditionCounts); + $this->groupConditionCounts[$index]++; + } +} diff --git a/system/Database/Postgre/Builder.php b/system/Database/Postgre/Builder.php index 8f060dfba176..51b8495f81c9 100644 --- a/system/Database/Postgre/Builder.php +++ b/system/Database/Postgre/Builder.php @@ -13,8 +13,10 @@ namespace CodeIgniter\Database\Postgre; +use Closure; use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\JoinClause; use CodeIgniter\Database\RawSql; use CodeIgniter\Exceptions\InvalidArgumentException; use TypeError; @@ -342,7 +344,7 @@ protected function _like_statement(?string $prefix, string $column, ?string $not /** * Generates the JOIN portion of the query * - * @param RawSql|string $cond + * @param Closure(JoinClause): void|RawSql|string $cond * * @return BaseBuilder */ diff --git a/system/Database/SQLSRV/Builder.php b/system/Database/SQLSRV/Builder.php index ffda2ba6cb08..eae1a86885a1 100644 --- a/system/Database/SQLSRV/Builder.php +++ b/system/Database/SQLSRV/Builder.php @@ -13,9 +13,11 @@ namespace CodeIgniter\Database\SQLSRV; +use Closure; use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Exceptions\DataException; +use CodeIgniter\Database\JoinClause; use CodeIgniter\Database\RawSql; use CodeIgniter\Database\ResultInterface; use CodeIgniter\Exceptions\InvalidArgumentException; @@ -94,21 +96,13 @@ protected function _truncate(string $table): string /** * Generates the JOIN portion of the query * - * @param RawSql|string $cond + * @param Closure(JoinClause): void|RawSql|string $cond * * @return $this */ public function join(string $table, $cond, string $type = '', ?bool $escape = null) { - if ($type !== '') { - $type = strtoupper(trim($type)); - - if (! in_array($type, $this->joinTypes, true)) { - $type = ''; - } else { - $type .= ' '; - } - } + $type = $this->normalizeJoinType($type); // Extract any aliases that might exist. We use this information // in the protectIdentifiers to know whether to add a table prefix @@ -118,52 +112,13 @@ public function join(string $table, $cond, string $type = '', ?bool $escape = nu $escape = $this->db->protectIdentifiers; } - if (! $this->hasOperator($cond)) { - $cond = ' USING (' . ($escape ? $this->db->escapeIdentifiers($cond) : $cond) . ')'; - } elseif ($escape === false) { - $cond = ' ON ' . $cond; - } else { - // Split multiple conditions - if (preg_match_all('/\sAND\s|\sOR\s/i', $cond, $joints, PREG_OFFSET_CAPTURE) >= 1) { - $conditions = []; - $joints = $joints[0]; - array_unshift($joints, ['', 0]); - - for ($i = count($joints) - 1, $pos = strlen($cond); $i >= 0; $i--) { - $joints[$i][1] += strlen($joints[$i][0]); // offset - $conditions[$i] = substr($cond, $joints[$i][1], $pos - $joints[$i][1]); - $pos = $joints[$i][1] - strlen($joints[$i][0]); - $joints[$i] = $joints[$i][0]; - } - - ksort($conditions); - } else { - $conditions = [$cond]; - $joints = ['']; - } - - $cond = ' ON '; - - foreach ($conditions as $i => $condition) { - $operator = $this->getOperator($condition); - - // Workaround for BETWEEN - if ($operator === false) { - $cond .= $joints[$i] . $condition; - - continue; - } - - $cond .= $joints[$i]; - $cond .= preg_match('/(\(*)?([\[\]\w\.\'-]+)' . preg_quote($operator, '/') . '(.*)/i', $condition, $match) ? $match[1] . $this->db->protectIdentifiers($match[2]) . $operator . $this->db->protectIdentifiers($match[3]) : $condition; - } - } - // Do we want to escape the table name? if ($escape === true) { $table = $this->db->protectIdentifiers($table, true, null, false); } + $cond = $this->compileJoinCondition($cond, $escape); + // Assemble the JOIN statement $this->QBJoin[] = $type . 'JOIN ' . $this->getFullName($table) . $cond; diff --git a/tests/system/Database/Builder/JoinTest.php b/tests/system/Database/Builder/JoinTest.php index 04145671e7df..d49c5e360ba1 100644 --- a/tests/system/Database/Builder/JoinTest.php +++ b/tests/system/Database/Builder/JoinTest.php @@ -14,11 +14,14 @@ namespace CodeIgniter\Database\Builder; use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\JoinClause; use CodeIgniter\Database\Postgre\Builder as PostgreBuilder; use CodeIgniter\Database\RawSql; use CodeIgniter\Database\SQLSRV\Builder as SQLSRVBuilder; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; /** @@ -99,6 +102,331 @@ public function testJoinMultipleConditionsBetween(): void $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); } + public function testJoinClosureWithColumnComparison(): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->join('job', static function (JoinClause $join): void { + $join->on('user.id', 'job.id'); + }); + + $expectedSQL = 'SELECT * FROM "user" JOIN "job" ON "user"."id" = "job"."id"'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + public function testJoinClosureWithColumnOperator(): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->join('job', static function (JoinClause $join): void { + $join->on('user.id >=', 'job.id'); + }); + + $expectedSQL = 'SELECT * FROM "user" JOIN "job" ON "user"."id" >= "job"."id"'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + public function testJoinClosureWithValueCondition(): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->join('job', static function (JoinClause $join): void { + $join->on('user.id', 'job.id') + ->where('job.name', 'Developer'); + }); + + $expectedSQL = 'SELECT * FROM "user" JOIN "job" ON "user"."id" = "job"."id" AND "job"."name" = \'Developer\''; + $expectedBinds = [ + 'job.name' => [ + 'Developer', + true, + ], + ]; + + $this->assertSame($expectedBinds, $builder->getBinds()); + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + public function testJoinClosureWithOuterBindCollision(): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->where('job.name', 'Accountant') + ->join('job', static function (JoinClause $join): void { + $join->on('user.id', 'job.id') + ->where('job.name', 'Developer'); + }); + + $expectedSQL = 'SELECT * FROM "user" JOIN "job" ON "user"."id" = "job"."id" AND "job"."name" = \'Developer\' WHERE "job"."name" = \'Accountant\''; + $expectedBinds = [ + 'job.name' => [ + 'Accountant', + true, + ], + 'job.name.1' => [ + 'Developer', + true, + ], + ]; + + $this->assertSame($expectedBinds, $builder->getBinds()); + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + public function testJoinClosureWithNullConditions(): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->join('job', static function (JoinClause $join): void { + $join->on('user.id', 'job.id') + ->where('job.deleted_at') + ->orWhere('job.archived_at !='); + }); + + $expectedSQL = 'SELECT * FROM "user" JOIN "job" ON "user"."id" = "job"."id" AND "job"."deleted_at" IS NULL OR "job"."archived_at" IS NOT NULL'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + public function testJoinClosureWithOrConditions(): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->join('job', static function (JoinClause $join): void { + $join->on('user.id', 'job.id') + ->orOn('user.email', 'job.email') + ->orWhere('job.name', 'Developer'); + }); + + $expectedSQL = 'SELECT * FROM "user" JOIN "job" ON "user"."id" = "job"."id" OR "user"."email" = "job"."email" OR "job"."name" = \'Developer\''; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + #[DataProvider('provideJoinClosureWithGroupedValueConditions')] + public function testJoinClosureWithGroupedValueConditions(string $groupStartMethod, string $expectedSQL): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->join('orders', static function (JoinClause $join) use ($groupStartMethod): void { + $join->on('orders.user_id', 'user.id') + ->{$groupStartMethod}() + ->where('orders.status', 'paid') + ->orWhere('orders.status', 'pending') + ->groupEnd(); + }); + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + /** + * @return iterable + */ + public static function provideJoinClosureWithGroupedValueConditions(): iterable + { + return [ + 'and group' => ['groupStart', 'SELECT * FROM "user" JOIN "orders" ON "orders"."user_id" = "user"."id" AND ("orders"."status" = \'paid\' OR "orders"."status" = \'pending\')'], + 'or group' => ['orGroupStart', 'SELECT * FROM "user" JOIN "orders" ON "orders"."user_id" = "user"."id" OR ("orders"."status" = \'paid\' OR "orders"."status" = \'pending\')'], + 'and not group' => ['notGroupStart', 'SELECT * FROM "user" JOIN "orders" ON "orders"."user_id" = "user"."id" AND NOT ("orders"."status" = \'paid\' OR "orders"."status" = \'pending\')'], + 'or not group' => ['orNotGroupStart', 'SELECT * FROM "user" JOIN "orders" ON "orders"."user_id" = "user"."id" OR NOT ("orders"."status" = \'paid\' OR "orders"."status" = \'pending\')'], + ]; + } + + public function testJoinClosureWithGroupedColumnComparisons(): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->join('contacts', static function (JoinClause $join): void { + $join->groupStart() + ->on('contacts.user_id', 'user.id') + ->orOn('contacts.email', 'user.email') + ->groupEnd(); + }); + + $expectedSQL = 'SELECT * FROM "user" JOIN "contacts" ON ("contacts"."user_id" = "user"."id" OR "contacts"."email" = "user"."email")'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + public function testJoinClosureWithNestedGroups(): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->join('orders', static function (JoinClause $join): void { + $join->on('orders.user_id', 'user.id') + ->groupStart() + ->where('orders.status', 'paid') + ->orGroupStart() + ->where('orders.status', 'pending') + ->where('orders.approved_at !=') + ->groupEnd() + ->groupEnd(); + }); + + $expectedSQL = 'SELECT * FROM "user" JOIN "orders" ON "orders"."user_id" = "user"."id" AND ("orders"."status" = \'paid\' OR ("orders"."status" = \'pending\' AND "orders"."approved_at" IS NOT NULL))'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + public function testJoinClosureWithAlias(): void + { + $builder = new BaseBuilder('jobs j', $this->db); + + $builder->join('users u', static function (JoinClause $join): void { + $join->on('u.id', 'j.id') + ->where('u.name', 'Derek Jones'); + }, 'LEFT'); + + $expectedSQL = 'SELECT * FROM "jobs" "j" LEFT JOIN "users" "u" ON "u"."id" = "j"."id" AND "u"."name" = \'Derek Jones\''; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + public function testJoinClosureNoEscape(): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->join('job', static function (JoinClause $join): void { + $join->on('LOWER(user.email)', 'job.email', false) + ->where('job.name', 'Developer'); + }); + + $expectedSQL = 'SELECT * FROM "user" JOIN "job" ON LOWER(user.email) = job.email AND "job"."name" = \'Developer\''; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + public function testJoinClosureInheritsNoEscape(): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->join('job', static function (JoinClause $join): void { + $join->on('user.id', 'job.id') + ->where('job.name', 'Developer'); + }, escape: false); + + $expectedSQL = 'SELECT * FROM "user" JOIN job ON user.id = job.id AND job.name = Developer'; + $expectedBinds = [ + 'job.name' => [ + 'Developer', + false, + ], + ]; + + $this->assertSame($expectedBinds, $builder->getBinds()); + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + public function testJoinClosureGroupInheritsNoEscape(): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->join('job', static function (JoinClause $join): void { + $join->on('user.id', 'job.id') + ->groupStart() + ->where('job.name', 'Developer') + ->orWhere('job.name', 'Designer') + ->groupEnd(); + }, escape: false); + + $expectedSQL = 'SELECT * FROM "user" JOIN job ON user.id = job.id AND (job.name = Developer OR job.name = Designer)'; + $expectedBinds = [ + 'job.name' => [ + 'Developer', + false, + ], + 'job.name.1' => [ + 'Designer', + false, + ], + ]; + + $this->assertSame($expectedBinds, $builder->getBinds()); + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + public function testJoinClosureGroupMaintainsBindCollisions(): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->where('job.name', 'Accountant') + ->join('job', static function (JoinClause $join): void { + $join->on('user.id', 'job.id') + ->groupStart() + ->where('job.name', 'Developer') + ->orWhere('job.name', 'Designer') + ->groupEnd(); + }); + + $expectedBinds = [ + 'job.name' => [ + 'Accountant', + true, + ], + 'job.name.1' => [ + 'Developer', + true, + ], + 'job.name.2' => [ + 'Designer', + true, + ], + ]; + + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + public function testJoinClosureRequiresCondition(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('JoinClause must contain at least one condition.'); + + $builder = new BaseBuilder('user', $this->db); + + $builder->join('job', static function (JoinClause $join): void { + }); + } + + public function testJoinClosureGroupEndRequiresMatchingGroupStart(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('JoinClause groupEnd() called without a matching groupStart().'); + + $builder = new BaseBuilder('user', $this->db); + + $builder->join('job', static function (JoinClause $join): void { + $join->groupEnd(); + }); + } + + public function testJoinClosureGroupRequiresCondition(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('JoinClause groups must contain at least one condition.'); + + $builder = new BaseBuilder('user', $this->db); + + $builder->join('job', static function (JoinClause $join): void { + $join->groupStart() + ->groupEnd(); + }); + } + + public function testJoinClosureRequiresBalancedGroups(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('JoinClause groups must be balanced.'); + + $builder = new BaseBuilder('user', $this->db); + + $builder->join('job', static function (JoinClause $join): void { + $join->groupStart() + ->on('user.id', 'job.id'); + }); + } + /** * @see https://github.com/codeigniter4/CodeIgniter4/issues/3832 */ @@ -144,4 +472,20 @@ public function testJoinWithAlias(): void $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); } + + public function testJoinClosureWithSqlsrvFullTableName(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('jobs', $this->db); + $builder->testMode(); + $builder->join('users u', static function (JoinClause $join): void { + $join->on('u.id', 'jobs.id') + ->where('u.name', 'Derek Jones'); + }, 'LEFT'); + + $expectedSQL = 'SELECT * FROM "test"."dbo"."jobs" LEFT JOIN "test"."dbo"."users" "u" ON "u"."id" = "jobs"."id" AND "u"."name" = \'Derek Jones\''; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } } diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 9efb8fd61ea8..d933b4b4756b 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -210,6 +210,7 @@ Database Query Builder ------------- +- Added Closure-based ``join()`` conditions for building ``JOIN ON`` clauses with protected column comparisons, bound values, and grouped conditions. See :ref:`query-builder-join-closure`. - Added ``whereColumn()`` and ``orWhereColumn()`` to compare one column to another column while protecting identifiers by default. See :ref:`query-builder-where-column`. - Added new ``incrementMany()`` and ``decrementMany()`` methods to ``CodeIgniter\Database\BaseBuilder`` for performing bulk increment/decrement operations. diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 8b6787947eda..7956a848d2f6 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -265,6 +265,37 @@ outer``, and ``right outer``. .. literalinclude:: query_builder/020.php +.. _query-builder-join-closure: + +Closure Conditions +^^^^^^^^^^^^^^^^^^ + +.. versionadded:: 4.8.0 + +You can pass a Closure as the second parameter to build the ``JOIN ON`` clause +with protected column comparisons, bound values, and grouped conditions: + +.. literalinclude:: query_builder/125.php + +The Closure receives a ``CodeIgniter\Database\JoinClause`` instance. Use +``on()`` and ``orOn()`` to compare columns, and ``where()`` and ``orWhere()`` +to compare a column to a value. Like ``whereColumn()``, ``on()`` uses ``=`` by +default and accepts a supported comparison operator at the end of the first +column. + +You can group join conditions with ``groupStart()``, ``orGroupStart()``, +``notGroupStart()``, ``orNotGroupStart()``, and ``groupEnd()``: + +.. literalinclude:: query_builder/126.php + +.. note:: Groups need to be balanced; make sure every ``groupStart()`` is + matched by a ``groupEnd()``. + +.. warning:: Do not pass user-supplied data as column names. Values should use + ``where()`` or ``orWhere()`` so they can be bound by the Query Builder. The + Closure API does not accept raw SQL condition strings; use the existing + string or ``RawSql`` JOIN condition forms when raw SQL is required. + .. _query-builder-join-rawsql: RawSql @@ -1535,14 +1566,15 @@ Class Reference .. php:method:: join($table, $cond[, $type = ''[, $escape = null]]) :param string $table: Table name to join - :param string|RawSql $cond: The JOIN ON condition + :param Closure|RawSql|string $cond: The JOIN ON condition :param string $type: The JOIN type :param bool $escape: Whether to escape values and identifiers :returns: ``BaseBuilder`` instance (method chaining) :rtype: ``BaseBuilder`` - Adds a ``JOIN`` clause to a query. Since v4.2.0, ``RawSql`` can be used - as the JOIN ON condition. See also :ref:`query-builder-join`. + Adds a ``JOIN`` clause to a query. Since v4.8.0, a Closure can be used + to build the JOIN ON condition. Since v4.2.0, ``RawSql`` can be used as + the JOIN ON condition. See also :ref:`query-builder-join`. .. php:method:: where($key[, $value = null[, $escape = null]]) diff --git a/user_guide_src/source/database/query_builder/125.php b/user_guide_src/source/database/query_builder/125.php new file mode 100644 index 000000000000..73a4bcc7c031 --- /dev/null +++ b/user_guide_src/source/database/query_builder/125.php @@ -0,0 +1,8 @@ +join('orders', static function (JoinClause $join): void { + $join->on('orders.user_id', 'users.id') + ->where('orders.status', 'paid'); +}); diff --git a/user_guide_src/source/database/query_builder/126.php b/user_guide_src/source/database/query_builder/126.php new file mode 100644 index 000000000000..b15c202b51e7 --- /dev/null +++ b/user_guide_src/source/database/query_builder/126.php @@ -0,0 +1,11 @@ +join('orders', static function (JoinClause $join): void { + $join->on('orders.user_id', 'users.id') + ->groupStart() + ->where('orders.status', 'paid') + ->orWhere('orders.status', 'pending') + ->groupEnd(); +});