diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index bcc3d73a0065..d032999ece7f 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -17,6 +17,7 @@ use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Pagination\Paginator; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use Illuminate\Support\Traits\ForwardsCalls; use ReflectionClass; @@ -137,6 +138,13 @@ class Builder implements BuilderContract */ protected $removedScopes = []; + /** + * The callbacks that should be invoked after retrieving data from the database. + * + * @var array + */ + protected $afterQueryCallbacks = []; + /** * Create a new Eloquent query builder instance. * @@ -723,7 +731,9 @@ public function get($columns = ['*']) $models = $builder->eagerLoadRelations($models); } - return $builder->getModel()->newCollection($models); + return $this->applyAfterQueryCallbacks( + $builder->getModel()->newCollection($models) + ); } /** @@ -852,6 +862,34 @@ protected function isNestedUnder($relation, $name) return str_contains($name, '.') && str_starts_with($name, $relation.'.'); } + /** + * Register a closure to be invoked after the query is executed. + * + * @param \Closure $callback + * @return $this + */ + public function afterQuery(Closure $callback) + { + $this->afterQueryCallbacks[] = $callback; + + return $this; + } + + /** + * Invoke the "after query" modification callbacks. + * + * @param mixed $result + * @return mixed + */ + public function applyAfterQueryCallbacks($result) + { + foreach ($this->afterQueryCallbacks as $afterQueryCallback) { + $result = $afterQueryCallback($result) ?: $result; + } + + return $result; + } + /** * Get a lazy collection for the given query. * @@ -860,8 +898,10 @@ protected function isNestedUnder($relation, $name) public function cursor() { return $this->applyScopes()->query->cursor()->map(function ($record) { - return $this->newModelInstance()->newFromBuilder($record); - }); + $model = $this->newModelInstance()->newFromBuilder($record); + + return $this->applyAfterQueryCallbacks($this->newModelInstance()->newCollection([$model]))->first(); + })->reject(fn ($model) => is_null($model)); } /** @@ -900,9 +940,11 @@ public function pluck($column, $key = null) return $results; } - return $results->map(function ($value) use ($column) { - return $this->model->newFromBuilder([$column => $value])->{$column}; - }); + return $this->applyAfterQueryCallbacks( + $results->map(function ($value) use ($column) { + return $this->model->newFromBuilder([$column => $value])->{$column}; + }) + ); } /** diff --git a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php index 064edbdd2577..7a90dc998cc0 100755 --- a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php @@ -883,7 +883,9 @@ public function get($columns = ['*']) $models = $builder->eagerLoadRelations($models); } - return $this->related->newCollection($models); + return $this->query->applyAfterQueryCallbacks( + $this->related->newCollection($models) + ); } /** diff --git a/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php b/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php index ab3d19fa4e9e..70dd922b20b0 100644 --- a/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php @@ -505,7 +505,9 @@ public function get($columns = ['*']) $models = $builder->eagerLoadRelations($models); } - return $this->related->newCollection($models); + return $this->query->applyAfterQueryCallbacks( + $this->related->newCollection($models) + ); } /** diff --git a/src/Illuminate/Database/Query/Builder.php b/src/Illuminate/Database/Query/Builder.php index 1061dd075099..3edd520fb1bc 100755 --- a/src/Illuminate/Database/Query/Builder.php +++ b/src/Illuminate/Database/Query/Builder.php @@ -208,6 +208,13 @@ class Builder implements BuilderContract */ public $beforeQueryCallbacks = []; + /** + * The callbacks that should be invoked after retrieving data from the database. + * + * @var array + */ + protected $afterQueryCallbacks = []; + /** * All of the available clause operators. * @@ -2770,6 +2777,34 @@ public function applyBeforeQueryCallbacks() $this->beforeQueryCallbacks = []; } + /** + * Register a closure to be invoked after the query is executed. + * + * @param \Closure $callback + * @return $this + */ + public function afterQuery(Closure $callback) + { + $this->afterQueryCallbacks[] = $callback; + + return $this; + } + + /** + * Invoke the "after query" modification callbacks. + * + * @param mixed $result + * @return mixed + */ + public function applyAfterQueryCallbacks($result) + { + foreach ($this->afterQueryCallbacks as $afterQueryCallback) { + $result = $afterQueryCallback($result) ?: $result; + } + + return $result; + } + /** * Get the SQL representation of the query. * @@ -2884,9 +2919,9 @@ public function get($columns = ['*']) return $this->processor->processSelect($this, $this->runSelect()); })); - return isset($this->groupLimit) - ? $this->withoutGroupLimitKeys($items) - : $items; + return $this->applyAfterQueryCallbacks( + isset($this->groupLimit) ? $this->withoutGroupLimitKeys($items) : $items + ); } /** @@ -3114,11 +3149,13 @@ public function cursor() $this->columns = ['*']; } - return new LazyCollection(function () { + return (new LazyCollection(function () { yield from $this->connection->cursor( $this->toSql(), $this->getBindings(), ! $this->useWritePdo ); - }); + }))->map(function ($item) { + return $this->applyAfterQueryCallbacks(collect([$item]))->first(); + })->reject(fn ($item) => is_null($item)); } /** @@ -3167,9 +3204,11 @@ function () { $key = $this->stripTableForPluck($key); - return is_array($queryResult[0]) + return $this->applyAfterQueryCallbacks( + is_array($queryResult[0]) ? $this->pluckFromArrayColumn($queryResult, $column, $key) - : $this->pluckFromObjectColumn($queryResult, $column, $key); + : $this->pluckFromObjectColumn($queryResult, $column, $key) + ); } /** diff --git a/tests/Integration/Database/AfterQueryTest.php b/tests/Integration/Database/AfterQueryTest.php new file mode 100644 index 000000000000..b1c4242e820a --- /dev/null +++ b/tests/Integration/Database/AfterQueryTest.php @@ -0,0 +1,387 @@ +increments('id'); + $table->integer('team_id')->nullable(); + }); + + Schema::create('teams', function (Blueprint $table) { + $table->increments('id'); + $table->integer('owner_id'); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + }); + + Schema::create('users_posts', function (Blueprint $table) { + $table->increments('id'); + $table->integer('user_id'); + $table->integer('post_id'); + $table->timestamps(); + }); + } + + public function testAfterQueryOnEloquentBuilder() + { + AfterQueryUser::create(); + AfterQueryUser::create(); + + $afterQueryIds = collect(); + + $users = AfterQueryUser::query() + ->afterQuery(function (Collection $users) use ($afterQueryIds) { + $afterQueryIds->push(...$users->pluck('id')->all()); + + foreach ($users as $user) { + $this->assertInstanceOf(AfterQueryUser::class, $user); + } + }) + ->get(); + + $this->assertCount(2, $users); + $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $users->pluck('id')->toArray()); + } + + public function testAfterQueryOnBaseBuilder() + { + AfterQueryUser::create(); + AfterQueryUser::create(); + + $afterQueryIds = collect(); + + $users = AfterQueryUser::query() + ->toBase() + ->afterQuery(function (Collection $users) use ($afterQueryIds) { + $afterQueryIds->push(...$users->pluck('id')->all()); + + foreach ($users as $user) { + $this->assertNotInstanceOf(AfterQueryUser::class, $user); + } + }) + ->get(); + + $this->assertCount(2, $users); + $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $users->pluck('id')->toArray()); + } + + public function testAfterQueryOnEloquentCursor() + { + AfterQueryUser::create(); + AfterQueryUser::create(); + + $afterQueryIds = collect(); + + $users = AfterQueryUser::query() + ->afterQuery(function (Collection $users) use ($afterQueryIds) { + $afterQueryIds->push(...$users->pluck('id')->all()); + + foreach ($users as $user) { + $this->assertInstanceOf(AfterQueryUser::class, $user); + } + }) + ->cursor(); + + $this->assertCount(2, $users); + $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $users->pluck('id')->toArray()); + } + + public function testAfterQueryOnBaseBuilderCursor() + { + AfterQueryUser::create(); + AfterQueryUser::create(); + + $afterQueryIds = collect(); + + $users = AfterQueryUser::query() + ->toBase() + ->afterQuery(function (Collection $users) use ($afterQueryIds) { + $afterQueryIds->push(...$users->pluck('id')->all()); + + foreach ($users as $user) { + $this->assertNotInstanceOf(AfterQueryUser::class, $user); + } + }) + ->cursor(); + + $this->assertCount(2, $users); + $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $users->pluck('id')->toArray()); + } + + public function testAfterQueryOnEloquentPluck() + { + AfterQueryUser::create(); + AfterQueryUser::create(); + + $afterQueryIds = collect(); + + $userIds = AfterQueryUser::query() + ->afterQuery(function (Collection $userIds) use ($afterQueryIds) { + $afterQueryIds->push(...$userIds->all()); + + foreach ($userIds as $userId) { + $this->assertIsInt($userId); + } + }) + ->pluck('id'); + + $this->assertCount(2, $userIds); + $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $userIds->toArray()); + } + + public function testAfterQueryOnBaseBuilderPluck() + { + AfterQueryUser::create(); + AfterQueryUser::create(); + + $afterQueryIds = collect(); + + $userIds = AfterQueryUser::query() + ->toBase() + ->afterQuery(function (Collection $userIds) use ($afterQueryIds) { + $afterQueryIds->push(...$userIds->all()); + + foreach ($userIds as $userId) { + $this->assertIsInt($userId); + } + }) + ->pluck('id'); + + $this->assertCount(2, $userIds); + $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $userIds->toArray()); + } + + public function testAfterQueryHookOnBelongsToManyRelationship() + { + $user = AfterQueryUser::create(); + $firstPost = AfterQueryPost::create(); + $secondPost = AfterQueryPost::create(); + + $user->posts()->attach($firstPost); + $user->posts()->attach($secondPost); + + $afterQueryIds = collect(); + + $posts = $user->posts() + ->afterQuery(function (Collection $posts) use ($afterQueryIds) { + $afterQueryIds->push(...$posts->pluck('id')->all()); + + foreach ($posts as $post) { + $this->assertInstanceOf(AfterQueryPost::class, $post); + } + }) + ->get(); + + $this->assertCount(2, $posts); + $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $posts->pluck('id')->toArray()); + } + + public function testAfterQueryHookOnHasManyThroughRelationship() + { + $user = AfterQueryUser::create(); + $team = AfterQueryTeam::create(['owner_id' => $user->id]); + + AfterQueryUser::create(['team_id' => $team->id]); + AfterQueryUser::create(['team_id' => $team->id]); + + $afterQueryIds = collect(); + + $teamMates = $user->teamMates() + ->afterQuery(function (Collection $teamMates) use ($afterQueryIds) { + $afterQueryIds->push(...$teamMates->pluck('id')->all()); + + foreach ($teamMates as $teamMate) { + $this->assertInstanceOf(AfterQueryUser::class, $teamMate); + } + }) + ->get(); + + $this->assertCount(2, $teamMates); + $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $teamMates->pluck('id')->toArray()); + } + + public function testAfterQueryOnEloquentBuilderCanAlterReturnedResult() + { + $firstUser = AfterQueryUser::create(); + $secondUser = AfterQueryUser::create(); + + $users = AfterQueryUser::query() + ->afterQuery(function () { + return collect(['foo', 'bar']); + }) + ->get(); + + $this->assertEquals(collect(['foo', 'bar']), $users); + + $users = AfterQueryUser::query() + ->afterQuery(function () { + return collect(['foo', 'bar']); + }) + ->pluck('id'); + + $this->assertEquals(collect(['foo', 'bar']), $users); + + $users = AfterQueryUser::query() + ->afterQuery(function ($users) use ($firstUser) { + return $users->first()->is($firstUser) ? collect(['foo', 'bar']) : collect(['bar', 'foo']); + }) + ->cursor(); + + $this->assertEquals(collect(['foo', 'bar']), $users->collect()); + + $users = AfterQueryUser::query() + ->afterQuery(function ($users) use ($firstUser) { + return $users->where('id', '!=', $firstUser->id); + }) + ->cursor(); + + $this->assertEquals([$secondUser->id], $users->collect()->pluck('id')->all()); + + $firstPost = AfterQueryPost::create(); + $secondPost = AfterQueryPost::create(); + + $firstUser->posts()->attach($firstPost); + $firstUser->posts()->attach($secondPost); + + $posts = $firstUser->posts() + ->afterQuery(function () { + return collect(['foo', 'bar']); + }) + ->get(); + + $this->assertEquals(collect(['foo', 'bar']), $posts); + + $user = AfterQueryUser::create(); + $team = AfterQueryTeam::create(['owner_id' => $user->id]); + + AfterQueryUser::create(['team_id' => $team->id]); + AfterQueryUser::create(['team_id' => $team->id]); + + $teamMates = $user->teamMates() + ->afterQuery(function () { + return collect(['foo', 'bar']); + }) + ->get(); + + $this->assertEquals(collect(['foo', 'bar']), $teamMates); + } + + public function testAfterQueryOnBaseBuilderCanAlterReturnedResult() + { + $firstUser = AfterQueryUser::create(); + $secondUser = AfterQueryUser::create(); + + $users = AfterQueryUser::query() + ->toBase() + ->afterQuery(function () { + return collect(['foo', 'bar']); + }) + ->get(); + + $this->assertEquals(collect(['foo', 'bar']), $users); + + $users = AfterQueryUser::query() + ->toBase() + ->afterQuery(function () { + return collect(['foo', 'bar']); + }) + ->pluck('id'); + + $this->assertEquals(collect(['foo', 'bar']), $users); + + $users = AfterQueryUser::query() + ->toBase() + ->afterQuery(function ($users) use ($firstUser) { + return $users->first()->id === $firstUser->id ? collect(['foo', 'bar']) : collect(['bar', 'foo']); + }) + ->cursor(); + + $this->assertEquals(collect(['foo', 'bar']), $users->collect()); + + $users = AfterQueryUser::query() + ->toBase() + ->afterQuery(function ($users) use ($firstUser) { + return $users->where('id', '!=', $firstUser->id); + }) + ->cursor(); + + $this->assertEquals([$secondUser->id], $users->collect()->pluck('id')->all()); + + $firstPost = AfterQueryPost::create(); + $secondPost = AfterQueryPost::create(); + + $firstUser->posts()->attach($firstPost); + $firstUser->posts()->attach($secondPost); + + $posts = $firstUser->posts() + ->toBase() + ->afterQuery(function () { + return collect(['foo', 'bar']); + }) + ->get(); + + $this->assertEquals(collect(['foo', 'bar']), $posts); + + $user = AfterQueryUser::create(); + $team = AfterQueryTeam::create(['owner_id' => $user->id]); + + AfterQueryUser::create(['team_id' => $team->id]); + AfterQueryUser::create(['team_id' => $team->id]); + + $teamMates = $user->teamMates() + ->toBase() + ->afterQuery(function () { + return collect(['foo', 'bar']); + }) + ->get(); + + $this->assertEquals(collect(['foo', 'bar']), $teamMates); + } +} + +class AfterQueryUser extends Model +{ + protected $table = 'users'; + protected $guarded = []; + public $timestamps = false; + + public function teamMates() + { + return $this->hasManyThrough(self::class, AfterQueryTeam::class, 'owner_id', 'team_id'); + } + + public function posts() + { + return $this->belongsToMany(AfterQueryPost::class, 'users_posts', 'user_id', 'post_id')->withTimestamps(); + } +} + +class AfterQueryTeam extends Model +{ + protected $table = 'teams'; + protected $guarded = []; + public $timestamps = false; + + public function members() + { + return $this->hasMany(AfterQueryUser::class, 'team_id'); + } +} + +class AfterQueryPost extends Model +{ + protected $table = 'posts'; + protected $guarded = []; + public $timestamps = false; +}