diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index 524ccc91fb2b..84c9ac19615b 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -757,6 +757,8 @@ protected function eagerLoadRelation(array $models, $name, Closure $constraints) $constraints($relation); + $this->query->prependDatabaseNameIfCrossDatabaseQuery($relation->getBaseQuery()); + // Once we have the results, we just match those back up to their parent models // using the relationship instance. Then we just return the finished arrays // of models which have been eagerly hydrated and are readied for return. diff --git a/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php b/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php index 2412bc68fd03..7d53dde6ab63 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php +++ b/src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php @@ -54,6 +54,8 @@ public function has($relation, $operator = '>=', $count = 1, $boolean = 'and', C $relation->getRelated()->newQueryWithoutRelationships(), $this ); + $this->query->prependDatabaseNameIfCrossDatabaseQuery($hasQuery->getQuery()); + // Next we will call any given callback as an "anonymous" scope so they can get the // proper logical grouping of the where clauses if needed by this Eloquent query // builder. Then, we will be ready to finalize and return this query instance. diff --git a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php index a6422b6855e6..1cb112e7e0cc 100755 --- a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php @@ -210,9 +210,12 @@ protected function performJoin($query = null) // model instance. Then we can set the "where" for the parent models. $query->join( $this->table, - $this->getQualifiedRelatedKeyName(), - '=', - $this->getQualifiedRelatedPivotKeyName() + function ($join) { + $join->on($this->getQualifiedRelatedKeyName(), '=', $this->getQualifiedRelatedPivotKeyName()); + if (is_null($this->using)) { + $join->setConnection($this->parent->getConnection()); + } + } ); return $this; @@ -335,6 +338,10 @@ public function using($class) { $this->using = $class; + foreach ($this->query?->getQuery()->joins ?? [] as $join) { + $join->setConnection((new $class)->getConnection()); + } + return $this; } diff --git a/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php b/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php index 1514274ce380..52e368fa9749 100644 --- a/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php @@ -127,7 +127,13 @@ protected function performJoin(Builder $query = null) $farKey = $this->getQualifiedFarKeyName(); - $query->join($this->throughParent->getTable(), $this->getQualifiedParentKeyName(), '=', $farKey); + $query->join( + $this->throughParent->getTable(), + function ($join) use ($farKey) { + $join->on($this->getQualifiedParentKeyName(), '=', $farKey); + $join->setConnection($this->parent->getConnection()); + } + ); if ($this->throughParentSoftDeletes()) { $query->withGlobalScope('SoftDeletableHasManyThrough', function ($query) { diff --git a/src/Illuminate/Database/Query/Builder.php b/src/Illuminate/Database/Query/Builder.php index 87d5ade2ee26..e4927e30c4b7 100755 --- a/src/Illuminate/Database/Query/Builder.php +++ b/src/Illuminate/Database/Query/Builder.php @@ -111,7 +111,7 @@ class Builder implements BuilderContract /** * The table joins for the query. * - * @var array + * @var \Illuminate\Database\Query\JoinClause[]|null */ public $joins; @@ -369,7 +369,12 @@ protected function createSub($query) protected function parseSub($query) { if ($query instanceof self || $query instanceof EloquentBuilder || $query instanceof Relation) { - $query = $this->prependDatabaseNameIfCrossDatabaseQuery($query); + $baseQuery = match (get_class($query)) { + EloquentBuilder::class => $query->getQuery(), + Relation::class => $query->getQuery()->getQuery(), + default => $query + }; + $this->prependDatabaseNameIfCrossDatabaseQuery($baseQuery); return [$query->toSql(), $query->getBindings()]; } elseif (is_string($query)) { @@ -384,21 +389,57 @@ protected function parseSub($query) /** * Prepend the database name if the given query is on another database. * - * @param mixed $query + * @param self $query * @return mixed + * + * @internal This method is not meant to be used or overwritten outside the framework. */ - protected function prependDatabaseNameIfCrossDatabaseQuery($query) + public function prependDatabaseNameIfCrossDatabaseQuery($query) { - if ($query->getConnection()->getDatabaseName() !== - $this->getConnection()->getDatabaseName()) { - $databaseName = $query->getConnection()->getDatabaseName(); + if (($database = $query->getConnection()->getDatabaseName()) !== $this->getConnection()->getDatabaseName()) { + $schema = ''; + if ($query->getConnection()->getDriverName() === 'sqlsrv') { + $schema = ($query->getConnection()->getConfig('schema') ?? 'dbo').'.'; + } - if (! str_starts_with($query->from, $databaseName) && ! str_contains($query->from, '.')) { - $query->from($databaseName.'.'.$query->from); + if ($this->shouldPrefixDatabaseName($query->from, $database)) { + $query->from($database.'.'.$schema.$query->from); + $query->prependDatabaseNameForJoins(); } } + } - return $query; + /** + * Prepend the database name to each join table. + * + * @return void + * + * @internal This method is not meant to be used or overwritten outside the framework. + */ + public function prependDatabaseNameForJoins() + { + foreach ($this->joins ?? [] as $join) { + $schema = ''; + if ($join->getConnection()->getDriverName() === 'sqlsrv') { + $schema = ($join->getConnection()->getConfig('schema') ?? 'dbo').'.'; + } + + if ($this->shouldPrefixDatabaseName($join->table, $joinDatabase = $join->getConnection()->getDatabaseName())) { + $join->table = $joinDatabase.'.'.$schema.$join->table; + } + } + } + + /** + * Determine if the table should be prefixed with the database name. + * + * @param string $table + * @param string $database + * @return bool + */ + protected function shouldPrefixDatabaseName($table, $database) + { + return ! str_starts_with($table, $database) && ! str_contains($table, '.'); } /** diff --git a/src/Illuminate/Database/Query/JoinClause.php b/src/Illuminate/Database/Query/JoinClause.php index aef1c9aa547d..51345e495a37 100755 --- a/src/Illuminate/Database/Query/JoinClause.php +++ b/src/Illuminate/Database/Query/JoinClause.php @@ -3,6 +3,7 @@ namespace Illuminate\Database\Query; use Closure; +use Illuminate\Database\ConnectionInterface; class JoinClause extends Builder { @@ -122,6 +123,19 @@ public function newQuery() return new static($this->newParentQuery(), $this->type, $this->table); } + /** + * Set the connection for this join clause. + * + * @param \Illuminate\Database\ConnectionInterface $connection + * @return $this + */ + public function setConnection(ConnectionInterface $connection) + { + $this->connection = $connection; + + return $this; + } + /** * Create a new query instance for sub-query. * diff --git a/tests/Database/DatabaseEloquentBelongsToManyWithDefaultAttributesTest.php b/tests/Database/DatabaseEloquentBelongsToManyWithDefaultAttributesTest.php index 512f87454691..bfab5cd85ae0 100644 --- a/tests/Database/DatabaseEloquentBelongsToManyWithDefaultAttributesTest.php +++ b/tests/Database/DatabaseEloquentBelongsToManyWithDefaultAttributesTest.php @@ -52,7 +52,9 @@ public function getRelationArguments() $related->shouldReceive('getKeyName')->andReturn('id'); $related->shouldReceive('qualifyColumn')->with('id')->andReturn('users.id'); - $builder->shouldReceive('join')->once()->with('club_user', 'users.id', '=', 'club_user.user_id'); + $builder->shouldReceive('join')->once()->with( + m::on(fn ($arg) => is_string($arg)), m::on(fn ($arg) => is_callable($arg)) + ); $builder->shouldReceive('where')->once()->with('club_user.club_id', '=', 1); $builder->shouldReceive('where')->once()->with('club_user.is_admin', '=', 1, 'and'); diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php index 2d73e8a7dba6..6a613a0dfb3c 100755 --- a/tests/Database/DatabaseEloquentBuilderTest.php +++ b/tests/Database/DatabaseEloquentBuilderTest.php @@ -763,7 +763,9 @@ public function testEagerLoadRelationsCanBeFlushed() public function testRelationshipEagerLoadProcess() { - $builder = m::mock(Builder::class.'[getRelation]', [$this->getMockQueryBuilder()]); + $baseBuilder = $this->getMockQueryBuilder(); + $baseBuilder->shouldReceive('prependDatabaseNameIfCrossDatabaseQuery')->once(); + $builder = m::mock(Builder::class.'[getRelation]', [$baseBuilder]); $builder->setEagerLoads(['orders' => function ($query) { $_SERVER['__eloquent.constrain'] = $query; }]); @@ -772,6 +774,7 @@ public function testRelationshipEagerLoadProcess() $relation->shouldReceive('initRelation')->once()->with(['models'], 'orders')->andReturn(['models']); $relation->shouldReceive('getEager')->once()->andReturn(['results']); $relation->shouldReceive('match')->once()->with(['models'], ['results'], 'orders')->andReturn(['models.matched']); + $relation->shouldReceive('getBaseQuery')->once()->andReturn(new \stdClass); $builder->shouldReceive('getRelation')->once()->with('orders')->andReturn($relation); $results = $builder->eagerLoadRelations(['models']); @@ -783,6 +786,7 @@ public function testRelationshipEagerLoadProcess() public function testRelationshipEagerLoadProcessForImplicitlyEmpty() { $queryBuilder = $this->getMockQueryBuilder(); + $queryBuilder->shouldReceive('prependDatabaseNameIfCrossDatabaseQuery')->once(); $builder = m::mock(Builder::class.'[getRelation]', [$queryBuilder]); $builder->setEagerLoads(['parentFoo' => function ($query) { $_SERVER['__eloquent.constrain'] = $query; diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index a720c224a3a4..172e68c132df 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -545,6 +545,8 @@ public function testEagerLoadingWithColumns() public function testWithWhereHasWithSpecificColumns() { $model = new EloquentModelWithWhereHasStub; + $connection = $this->addMockConnection($model); + $connection->shouldReceive('getDatabaseName')->andReturn('forge'); $instance = $model->newInstance()->newQuery()->withWhereHas('foo:diaa,fares'); $builder = m::mock(Builder::class); $builder->shouldReceive('select')->once()->with(['diaa', 'fares']); @@ -2524,6 +2526,8 @@ protected function addMockConnection($model) $connection->shouldReceive('query')->andReturnUsing(function () use ($connection, $grammar, $processor) { return new BaseBuilder($connection, $grammar, $processor); }); + + return $connection; } public function testTouchingModelWithTimestamps() diff --git a/tests/Database/DatabaseEloquentMorphToManyTest.php b/tests/Database/DatabaseEloquentMorphToManyTest.php index 32ccbe4b6705..31c372e133b8 100644 --- a/tests/Database/DatabaseEloquentMorphToManyTest.php +++ b/tests/Database/DatabaseEloquentMorphToManyTest.php @@ -101,7 +101,9 @@ public function getRelationArguments() $related->shouldReceive('qualifyColumn')->with('id')->andReturn('tags.id'); $related->shouldReceive('getMorphClass')->andReturn(get_class($related)); - $builder->shouldReceive('join')->once()->with('taggables', 'tags.id', '=', 'taggables.tag_id'); + $builder->shouldReceive('join')->once()->with( + m::on(fn ($arg) => is_string($arg)), m::on(fn ($arg) => is_callable($arg)) + ); $builder->shouldReceive('where')->once()->with('taggables.taggable_id', '=', 1); $builder->shouldReceive('where')->once()->with('taggables.taggable_type', get_class($parent)); diff --git a/tests/Integration/Database/EloquentCrossDatabaseTest.php b/tests/Integration/Database/EloquentCrossDatabaseTest.php new file mode 100644 index 000000000000..94916cd04e13 --- /dev/null +++ b/tests/Integration/Database/EloquentCrossDatabaseTest.php @@ -0,0 +1,252 @@ +get('database.default'), ['mysql', 'sqlsrv'])) { + $this->markTestSkipped("Cross database queries not supported for $default."); + } + + define('__TEST_DEFAULT_CONNECTION', $default); + define('__TEST_SECONDARY_CONNECTION', $default.'_two'); + + // Create a second connection based on the first connection, but with a different database. + $app['config']->set('database.connections.'.__TEST_SECONDARY_CONNECTION, array_merge( + $app['config']->get('database.connections.'.__TEST_DEFAULT_CONNECTION), + ['database' => 'forge_two'] + )); + + parent::getEnvironmentSetUp($app); + } + + protected function setUpDatabaseRequirements(Closure $callback): void + { + $db = $this->app['config']->get('database.connections.'.__TEST_SECONDARY_CONNECTION.'.database'); + try { + $this->app['db']->connection()->statement('CREATE DATABASE '.$db); + } catch(QueryException $e) { + // ... + } + + parent::setUpDatabaseRequirements($callback); + } + + protected function defineDatabaseMigrationsAfterDatabaseRefreshed() + { + tap(Schema::connection(__TEST_DEFAULT_CONNECTION), function ($schema) { + try { + $schema->create('posts', function (Blueprint $table) { + $table->increments('id'); + $table->string('title'); + $table->foreignId('user_id')->nullable(); + $table->foreignId('root_tag_id')->nullable(); + }); + } catch (QueryException $e) { + // + } + }); + + tap(Schema::connection(__TEST_SECONDARY_CONNECTION), function ($schema) { + try { + $schema->create('users', function (Blueprint $table) { + $table->increments('id'); + $table->string('username'); + }); + + $schema->create('sub_posts', function (Blueprint $table) { + $table->increments('id'); + $table->string('title'); + $table->foreignId('post_id'); + }); + + $schema->create('views', function (Blueprint $table) { + $table->increments('id'); + $table->integer('hits')->default(1); + $table->morphs('viewable'); + }); + + $schema->create('viewables', function (Blueprint $table) { + $table->foreignId('view_id'); + $table->morphs('viewable'); + }); + + $schema->create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('content'); + $table->foreignId('sub_post_id'); + }); + + $schema->create('tags', function (Blueprint $table) { + $table->increments('id'); + $table->string('tag'); + }); + + $schema->create('post_tag', function (Blueprint $table) { + $table->foreignId('post_id'); + $table->foreignId('tag_id'); + }); + } catch (QueryException $e) { + // + } + }); + + tap(DB::connection(__TEST_DEFAULT_CONNECTION), function ($db) { + $db->table('posts')->insert([ + ['title' => 'Foobar', 'user_id' => 1], + ['title' => 'The title', 'user_id' => 1], + ]); + }); + + tap(DB::connection(__TEST_SECONDARY_CONNECTION), function ($db) { + $db->table('users')->insert([ + ['username' => 'Lortay Wellot'], + ]); + + $db->table('sub_posts')->insert([ + ['title' => 'The subpost title', 'post_id' => 1], + ]); + + $db->table('comments')->insert([ + ['content' => 'The comment content', 'sub_post_id' => 1], + ]); + + $db->table('views')->insert([ + ['hits' => 123, 'viewable_id' => 1, 'viewable_type' => Post::class], + ]); + }); + } + + protected function destroyDatabaseMigrations() + { + Schema::dropIfExists('posts'); + + foreach (['users', 'sub_posts', 'comments', 'views', 'viewables', 'tags', 'post_tag'] as $table) { + Schema::connection(__TEST_SECONDARY_CONNECTION)->dropIfExists($table); + } + } + + public function testRelationships() + { + // We only test general compilation without errors here, indicating that cross-database queries have been + // executed correctly. + + foreach (['comments', 'rootTag', 'subPosts', 'tags', 'user', 'view', 'viewables'] as $relation) { + $this->assertInstanceOf(Collection::class, Post::query()->with($relation)->get()); + $this->assertInstanceOf(Collection::class, Post::query()->withCount($relation)->get()); + $this->assertInstanceOf(Collection::class, Post::query()->whereHas($relation)->get()); + } + + $this->assertInstanceOf(Collection::class, View::query()->with('posts')->get()); + $this->assertInstanceOf(Collection::class, View::query()->withCount('posts')->get()); + $this->assertInstanceOf(Collection::class, View::query()->whereHas('posts')->get()); + $this->assertInstanceOf(Collection::class, View::query()->with('viewable')->get()); + $this->assertInstanceOf(Collection::class, View::query()->whereHas('viewable')->get()); + } +} + +abstract class BaseModel extends Model +{ + public $timestamps = false; + protected $guarded = []; +} + +abstract class SecondaryBaseModel extends BaseModel +{ + protected $connection = __TEST_SECONDARY_CONNECTION; +} + +class Post extends BaseModel +{ + protected $connection = __TEST_DEFAULT_CONNECTION; + + public function comments() + { + return $this->hasManyThrough(Comment::class, SubPost::class, 'post_id', 'id'); + } + + public function rootTag() + { + return $this->hasOne(Tag::class, 'id', 'root_tag_id'); + } + + public function subPosts() + { + return $this->hasMany(SubPost::class); + } + + public function tags() + { + return $this->belongsToMany(Tag::class)->using(PostTag::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } + + public function view() + { + return $this->morphOne(View::class, 'viewable'); + } + + public function viewables() + { + return $this->morphToMany(View::class, 'viewable', 'viewables')->using(Viewable::class); + } +} + +class User extends SecondaryBaseModel +{ + // +} + +class SubPost extends SecondaryBaseModel +{ + // +} + +class Comment extends SecondaryBaseModel +{ + // +} + +class Tag extends SecondaryBaseModel +{ + // +} + +class View extends SecondaryBaseModel +{ + public function posts() + { + return $this->morphedByMany(Post::class, 'viewable'); + } + + public function viewable() + { + return $this->morphTo(); + } +} + +class PostTag extends Pivot +{ + protected $connection = __TEST_SECONDARY_CONNECTION; +} + +class Viewable extends Pivot +{ + protected $connection = __TEST_SECONDARY_CONNECTION; +}