Skip to content

Add cross-connection join support#234

Open
Pablo1Gustavo wants to merge 16 commits into
kirschbaum-development:masterfrom
Pablo1Gustavo:multiple-database-connections-support
Open

Add cross-connection join support#234
Pablo1Gustavo wants to merge 16 commits into
kirschbaum-development:masterfrom
Pablo1Gustavo:multiple-database-connections-support

Conversation

@Pablo1Gustavo

@Pablo1Gustavo Pablo1Gustavo commented Apr 15, 2026

Copy link
Copy Markdown

#34

🔗 Cross-Connection Join Support for eloquent-power-joins

📖 Overview

Added support for joining Eloquent relationships that live on different database connections. Previously, the package always used the base query's connection prefix for all joined tables, producing incorrect SQL when the related model used a different connection (and thus a different table prefix).

Cross-connection qualification is now automatic and plug-and-play — no manual configuration required. The package detects the driver and applies the correct behaviour:

  • MySQL: joins across connections are automatically qualified with the database name (database.table)
  • SQLite: cross-connection joins skip qualification entirely (SQLite requires ATTACH DATABASE and does not support the simple database.table syntax)

🆕 New File: src/ConnectionAwareTable.php

A helper class that centralizes all cross-connection awareness logic:

  • isCrossConnection(Model $owner, $baseQuery) — detects when the related model's connection differs from the base query's
  • tableReference(...) — returns a plain string for same-connection joins (grammar handles prefixing as before), or an Expression with the related connection's prefix already baked in for cross-connection joins
  • columnReference(...) — same logic for table.column references
  • tableOrAliasReference(...) / columnOrAliasReference(...) — alias-aware variants that check the static alias cache first
  • rewriteQualifiedColumn(...) — rewrites a "table.col" string to a properly prefixed Expression
  • qualifiedDatabaseName(Model $model): ?string — returns the database name to use as a qualifier, or null when not applicable (SQLite driver or empty database name). Used internally and by PowerJoinClause

Removed: the previous opt-in API (qualifyWithDatabaseName, disableDatabaseQualification, shouldQualifyWithDatabase) has been removed. Qualification is now fully automatic based on the connection driver.


✏️ Modified: src/Mixins/JoinRelationship.php

  • Removed the string type hint from $table in the newPowerJoinClause closure to allow Expression objects to be passed
  • Updated orderByPowerJoins to use ConnectionAwareTable::columnReference() instead of sprintf('%s.%s', $table, $column), so the ORDER BY column reference carries the correct prefix for cross-connection joins

🛠️ Modified: src/PowerJoinClause.php

Multiple fixes to handle Expression objects flowing through string-manipulation methods:

  • Constructor — removed string type hint from $table; extracts the plain table name from an Expression to populate $this->tableName
  • as() method — when $this->table is an Expression, appends the alias to it as a new Expression; otherwise uses the original string format
  • useTableAliasInConditions() — unwraps Expression from $where['column'] before calling Str::contains() on soft-delete checks
  • useAliasInWhereColumnType() — unwraps Expression from $where['first'] and $where['second'] before string replacements
  • useAliasInWhereBasicType() — unwraps Expression from $where['column'] before string replacements
  • whereNull() — guards Str::contains() with is_string($columns) to avoid errors when an Expression is passed
  • where() — cross-connection detection: when no alias is set but the column references the model's own table and the model is on a different connection, rewrites the plain "table.col" string into a fully-qualified Expression using ConnectionAwareTable::qualifiedDatabaseName()

🧪 Modified: tests/JoinRelationshipAcrossConnectionsTest.php

54 tests covering all major scenarios.

The test setup uses two named MySQL-driver connections (primary / secondary) with:

  • Distinct database names (primary_db / secondary_db)
  • Table prefixes (pri_ / sec_)

SQLite prefix behaviour is tested via a dedicated :memory: sub-case that verifies the database name is never emitted as a qualifier.

📊 Test Coverage

Category Tests
Basic join types HasMany (inner/left/right), BelongsTo, HasOne — verifies related prefix and absence of wrong prefix
Nested relationships HasMany→HasMany, BelongsTo→BelongsTo, zig-zag across connections
Polymorphic MorphMany, MorphToMany (morph type qualifier), MorphTo (same/cross-connection morphable)
HasManyThrough Through table and far table both use related connection's prefix
Soft deletes deleted_at IS NULL uses correct prefix; withTrashed suppresses clause; onlyTrashed emits IS NOT NULL
Extra conditions / scopes Relationship-level where() scopes; callback closures on joinRelationship
Aliases Inline as(), joinRelationshipUsingAlias, string alias shorthand, array alias form, nested usingAlias
Auto-select SELECT clause uses the base model's own prefix
powerJoinHas / powerJoinWhereHas / powerJoinDoesntHave Basic, nested, count threshold, with and without callback
orderByPowerJoins Plain sort, left join sort, three-level nested, all aggregation variants (count, sum, avg, min, max)
Connection prefix (SQLite) Prefix is included in table name; :memory: database name is never emitted as a qualifier
Alias cache isolation Alias from one query does not bleed into the next
Mixed same/cross-connection Multiple joinRelationship calls with both same- and different-connection relations
Multiple cross-connection siblings Three unrelated cross-connection joins in one query
Duplicate join guard Same cross-connection relationship joined twice produces only one JOIN clause
Same-connection regression Joining two models on the same connection does not wrap the table in an Expression

@nathanheffley

Copy link
Copy Markdown
Member

Hey Pablo! This looks pretty interesting. With a feature this big, could you possibly create an example repository and comment a link here that uses this branch to do some hands on testing with?

@nathanheffley

Copy link
Copy Markdown
Member

I also don't see documentation on how to use this, especially clarifications on how the connections need to be on the same server.

Remove the opt-in qualifyWithDatabaseName() API in favour of automatic database.table qualification for MySQL/MariaDB drivers. SQLite is excluded since it requires ATTACH DATABASE for cross-database access.
…nd coverage.

Switch the Author model to the primary connection with a real database name, refactor all assertions to reflect automatic database qualification, and add tests for gaps including scopes, withTrashed BelongsTo, MorphTo same-connection, MorphToMany morph type condition, doesntHave MorphMany, powerJoinHas count threshold, and three-level nested orderByPowerJoins.
@Pablo1Gustavo

Copy link
Copy Markdown
Author

Ok @nathanheffley! I made some changes and improvements, and also added a section to the README. Now I will provide a project with a POC for this.

@nathanheffley

nathanheffley commented Apr 16, 2026

Copy link
Copy Markdown
Member

Hey @Pablo1Gustavo ! I'm interested in getting this working for you but the PR is at almost 2k lines and changes A LOT of stuff. I'm not sure that this method of implementation is going to be desirable to merge.

I'd love to see that POC so that I can think through alternative implementations. From all these fixes, I'm having some doubts over this specific PR being the best way forward.

With a POC and all these tests, we can probably come up with something a bit easier to maintain.

@Pablo1Gustavo

Copy link
Copy Markdown
Author

Hello @nathanheffley
It's really a big change, I've been quite hyperfocused on it. I'm already working on the POC, which is actually proving valuable for finding some bugs and areas for improvement. I believe I can send you the PR link today.

@Pablo1Gustavo

Copy link
Copy Markdown
Author

Hi! I built the small POC to test the multi-DB connections branch across MySQL, PostgreSQL, and SQLite with cross-connection JOINs:
https://github.com/Pablo1Gustavo/laravel-multi-database-connection-poc

Setup is in the README (a script switches drivers), and the same tests run on each.

Results:
ConnectionAwareTable works well, once refs are properly qualified, the same query code runs unchanged across all three. The limitations found are only related to how the database engine behaves and handles this issue.

Notes (non-MySQL/MariaDB):

  • PostgreSQL: needs schema (not DB name) for cross-joins—handled via a connection listener override.
  • SQLite: requires ATTACH DATABASE on each PDO open—handled in the same listener.
  • Added a schema config key for both.

The branch already works great from the POC’s perspective.

Happy to add you as a collaborator if you want to explore it.

@luisdalmolin

Copy link
Copy Markdown
Member

Hey @Pablo1Gustavo, we've been quite busy and want to give this the proper attention for the effort you put in. But it's in our list of things to take a look.

@vasconcelos-giovanni

Copy link
Copy Markdown

Cross-connection joins are one of the most painful parts of working with distributed databases in Laravel. Usually, you're stuck manually prefixing tables or abandoning Eloquent for raw queries. The fact that this PR makes it 'automatic and plug-and-play' is a massive win for the developer experience. Removing the opt-in API in favor of automatic detection shows a lot of confidence in the implementation. Truly excellent work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants