Skip to content

feat: add closure-based Query Builder join conditions#10186

Draft
memleakd wants to merge 5 commits into
codeigniter4:4.8from
memleakd:feat/join-condition-ergonomics
Draft

feat: add closure-based Query Builder join conditions#10186
memleakd wants to merge 5 commits into
codeigniter4:4.8from
memleakd:feat/join-condition-ergonomics

Conversation

@memleakd
Copy link
Copy Markdown
Contributor

@memleakd memleakd commented May 10, 2026

Description

This PR proposes a closure-based way to build Query Builder JOIN ON clauses.

Today, simple joins are easy:

$builder->join('orders', 'orders.user_id = users.id');

But once a join needs bound values, OR conditions, or grouped ON (...) logic, users often have to fall back to longer raw SQL strings or RawSql. That works, but it gives up some of the safety and readability the Query Builder usually provides.

This PR keeps the existing join() API intact and adds a native option for those more complex join conditions:

use CodeIgniter\Database\JoinClause;

$builder->join('orders', static function (JoinClause $join): void {
    $join->on('orders.user_id', 'users.id')
        ->where('orders.status', 'paid');
});

For grouped conditions:

$builder->join('orders', static function (JoinClause $join): void {
    $join->on('orders.user_id', 'users.id')
        ->groupStart()
            ->where('orders.status', 'paid')
            ->orWhere('orders.status', 'pending')
        ->groupEnd();
});

This produces a protected, bound JOIN ON clause without requiring users to manually write the whole condition as raw SQL.

The closure receives a JoinClause object with familiar Query Builder-style methods:

  • on() / orOn() for column comparisons
  • where() / orWhere() for bound value comparisons
  • groupStart() / orGroupStart() / notGroupStart() / orNotGroupStart() / groupEnd() for grouped conditions

Existing string and RawSql join conditions continue to work as before. RawSql remains the explicit escape hatch for cases that do not fit this small join-condition API.

Why

This helps real-world applications express safer and clearer joins, especially when joins include conditional matching, status filters, soft-delete checks, tenant constraints, or grouped OR logic inside the ON clause.

Instead of choosing between a simple protected join string and a fully raw condition, users get a CodeIgniter-native middle path that keeps identifiers protected and values bound.

Changes

  • Added CodeIgniter\Database\JoinClause.
  • Added closure support to BaseBuilder::join().
  • Reused the shared join condition compiler from the SQLSRV builder.
  • Added user guide docs and changelog entry.
  • Added focused tests for escaping, binds, grouped/nested conditions, invalid group state, aliases, and SQLSRV table naming.

Checklist:

  • Securely signed commits
  • Component(s) with PHPDoc blocks, only if necessary or adds value (without duplication)
  • Unit testing, with >80% coverage
  • User guide updated
  • Conforms to style guide

@github-actions github-actions Bot added the 4.8 PRs that target the `4.8` branch. label May 10, 2026
Copy link
Copy Markdown
Member

@paulbalandan paulbalandan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Few comments below. Also, you noted you updated Model but I don't see any changes.

Comment thread system/Database/Postgre/Builder.php Outdated
Comment thread system/Database/SQLSRV/Builder.php Outdated
Comment thread system/Database/BaseBuilder.php Outdated
Comment thread system/Database/BaseBuilder.php Outdated
Comment thread system/Database/JoinClause.php Outdated
Comment thread system/Database/JoinClause.php Outdated
memleakd added 2 commits May 11, 2026 18:41
- Add JoinClause for protected JOIN ON column and value conditions
- Support grouped and nested join conditions with Query Builder-style methods
- Share join condition compilation with SQLSRV
- Document the closure join API and add focused builder coverage

Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
@memleakd memleakd force-pushed the feat/join-condition-ergonomics branch from 5d80b80 to c0bf639 Compare May 11, 2026 16:41
Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
@memleakd
Copy link
Copy Markdown
Contributor Author

Few comments below. Also, you noted you updated Model but I don't see any changes.

Thanks for catching that. I removed the Model PHPDoc change to keep this PR focused on Query Builder behavior and avoid unrelated Psalm noise, so there are no Model changes now. I also updated the PR body to remove that mention.

memleakd added 2 commits May 11, 2026 19:55
Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
Copy link
Copy Markdown
Member

@michalsn michalsn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm gonna be honest. I'm not a big fan of this PR.

The closure syntax is nice, but JoinClause effectively creates a second condition-building API next to Query Builder. Once we add JoinClause::where(), users will reasonably expect it to behave like normal $builder->where() and to add related methods like like(), whereIn(), etc.

Also, we already have inconsistent behavir:

$builder->where('job.status LIKE', 'p%'); // works
$join->where('job.status LIKE', 'p%'); // compiles incorrectly

$builder->where('job.deleted_at =', null); // works
$join->where('job.deleted_at =', null); // compiles incorrectly

I don't think we should duplicate Query Builder condition parsing inside JoinClause. The only version I would be comfortable with is one where JOIN conditions go through the same condition-building logic the Query Builder already uses, so behavior stays consistent across the framework.

But that would require a large rewrite, which likely cannot be done without a BC break.

@memleakd
Copy link
Copy Markdown
Contributor Author

Thanks for being direct. I agree with this concern.

I also agree that duplicating Query Builder condition parsing inside JoinClause is not the right direction, so I'll move this PR back to draft for now.

I'd like to first investigate whether condition building/compilation can be made reusable internally without changing existing behavior. If that foundation can be done cleanly, then this PR can come back on top of it, with JOIN conditions using the same logic as the rest of Query Builder.

I'd appreciate any prior tips, guidance, or suggestions from anyone with deeper experience in the framework codebase before I start looking into it.

@memleakd memleakd marked this pull request as draft May 13, 2026 20:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.8 PRs that target the `4.8` branch.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants