Repository: ajcastro/eager-load-pivot-relations Branch: master Commit: 9bfeb4a5d659 Files: 31 Total size: 34.3 KB Directory structure: gitextract__wxmb_sj/ ├── .gitattributes ├── .github/ │ └── workflows/ │ └── tests.yml ├── .gitignore ├── README.md ├── composer.json ├── phpunit.xml.dist ├── src/ │ ├── EagerLoadPivotBuilder.php │ └── EagerLoadPivotTrait.php └── tests/ ├── Database/ │ ├── Factories/ │ │ ├── BrandFactory.php │ │ ├── CarFactory.php │ │ ├── CarUserFactory.php │ │ ├── ColorFactory.php │ │ ├── TireFactory.php │ │ └── UserFactory.php │ └── Migrations/ │ ├── 2014_10_12_000000_create_users_table.php │ ├── 2014_10_12_100000_create_password_resets_table.php │ ├── 2019_06_02_131048_create_brands_table.php │ ├── 2019_06_02_131049_create_cars_table.php │ ├── 2019_06_02_131100_create_colors_table.php │ ├── 2019_06_02_131120_create_car_user_table.php │ └── 2019_06_02_131148_create_tires_table.php ├── Models/ │ ├── Brand.php │ ├── Car.php │ ├── CarUser.php │ ├── Color.php │ ├── Tire.php │ └── User.php ├── TestCase.php └── Unit/ ├── CountTest.php ├── PaginateTest.php └── PivotTest.php ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ /.editorconfig export-ignore /.gitattributes export-ignore /.gitignore export-ignore /.github export-ignore /phpunit.xml.dist export-ignore /tests export-ignore /CHANGELOG.md export-ignore /CONTRIBUTING.md export-ignore /README.md export-ignore ================================================ FILE: .github/workflows/tests.yml ================================================ name: Tests on: pull_request: push: branches: [master, main] jobs: test: runs-on: ubuntu-20.04 strategy: fail-fast: false matrix: php: [7.2, 7.3, 7.4, 8.0, 8.1] stability: [prefer-lowest, prefer-stable] name: PHP ${{ matrix.php }} - ${{ matrix.stability }} Test steps: - name: Checkout uses: actions/checkout@v2 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} extensions: dom, curl, libxml, mbstring, zip, pdo ini-values: error_reporting=E_ALL tools: composer:v2 coverage: none - name: Install dependencies uses: nick-invision/retry@v1 with: timeout_minutes: 5 max_attempts: 5 command: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress - name: Run tests run: ./vendor/bin/phpunit vendor/bin/phpunit --verbose ================================================ FILE: .gitignore ================================================ /vendor composer.lock *.cache ================================================ FILE: README.md ================================================ # Laravel Eloquent: Eager Load Pivot Relations Eager load pivot relations for Laravel Eloquent's BelongsToMany relation. Medium Story: https://medium.com/@ajcastro29/laravel-eloquent-eager-load-pivot-relations-dba579f3fd3a ## Installation ``` composer require ajcastro/eager-load-pivot-relations ``` ## Usage and Example There are use-cases where in a pivot model has relations to be eager loaded. Example, in a procurement system, we have the following: **Tables** ``` items - id - name units - id - name (pc, box, etc...) plans (annual procurement plan) - id plan_item (pivot for plans and items) - id - plan_id - item_id - unit_id ``` **Models** ```php class Unit extends \Eloquent { } use AjCastro\EagerLoadPivotRelations\EagerLoadPivotTrait; class Item extends \Eloquent { // Use the trait here to override eloquent builder. // It is used in this model because it is the relation model defined in // Plan::items() relation. use EagerLoadPivotTrait; public function plans() { return $this->belongsToMany('Plan', 'plan_item'); } } class Plan extends \Eloquent { public function items() { return $this->belongsToMany('Item', 'plan_item') ->using('PlanItem') // make sure to include the necessary foreign key in this case the `unit_id` ->withPivot('unit_id', 'qty', 'price'); } } // Pivot model class PlanItem extends \Illuminate\Database\Eloquent\Relations\Pivot { protected $table = 'plan_item'; public function unit() { return $this->belongsTo('Unit'); } } ``` From the code above, `plans` and `items` has `Many-to-Many` relationship. Each item in a plan has a selected `unit`, unit of measurement. It also possible for other scenario that the pivot model will have other many relations. ## Eager Loading Pivot Relations Use keyword `pivot` in eager loading pivot models. So from the example above, the pivot model `PlanItem` can eager load the `unit` relation by doing this: ``` return Plan::with('items.pivot.unit')->get(); ``` The resulting data structure will be: ![image](https://cloud.githubusercontent.com/assets/4918318/17958278/0d3c962a-6acb-11e6-8415-c48d01457cd6.png) You may also access other relations for example: ``` return Plan::with([ 'items.pivot.unit', 'items.pivot.unit.someRelation', 'items.pivot.anotherRelation', // It is also possible to eager load nested pivot models 'items.pivot.unit.someBelongsToManyRelation.pivot.anotherRelationFromAnotherPivot', ])->get(); ``` ## Custom Pivot Accessor You can customize the __"pivot accessor"__, so instead of using the keyword `pivot`, we can declare it as `planItem`. Just chain the `as()` method in the definition of the `BelongsToMany` relation. ```php class Plan extends \Eloquent { public function items() { return $this->belongsToMany('Item', 'plan_item') ->withPivot('unit_id', 'qty', 'price') ->using('PlanItem') ->as('planItem'); } } ``` Make sure we also use the trait to our main model which is the `Plan` model, because the package needs to acess the belongsToMany relation (`items` relation) to recognize the used pivot acessor. ```php use AjCastro\EagerLoadPivotRelations\EagerLoadPivotTrait; class Plan extends \Eloquent { use EagerLoadPivotTrait; } ``` So instead of using `pivot`, we can eager load it by defined pivot accessor `planItem`. ```php return Plan::with('items.planItem.unit')->get(); ``` ```php $plan = Plan::with('items.planItem.unit'); foreach ($plan->items as $item) { $unit = $item->planItem->unit; echo $unit->name; } ``` ## Other Examples and Use-cases https://github.com/ajcastro/eager-load-pivot-relations-examples ================================================ FILE: composer.json ================================================ { "name": "ajcastro/eager-load-pivot-relations", "description": "Eager load pivot relations for Laravel Eloquent's BelongsToMany relation.", "type": "library", "license": "MIT", "authors": [ { "name": "Arjon Jason Castro", "email": "ajcastro29@gmail.com" } ], "require": { "php": ">=5.6.0" }, "require-dev": { "laravel/framework": ">= 5.0.0", "laravel/legacy-factories": ">= 1.0.0", "orchestra/testbench": ">= 3.0.0", "phpunit/phpunit": ">= 6.0.0" }, "autoload": { "psr-4": { "AjCastro\\EagerLoadPivotRelations\\": "src" } }, "autoload-dev": { "psr-4": { "AjCastro\\EagerLoadPivotRelations\\Tests\\": "tests" }, "classmap": [ "tests/Database/Migrations" ] }, "extra": { "laravel": { "providers": [ ], "aliases": { } } } } ================================================ FILE: phpunit.xml.dist ================================================ ./tests/Unit ./tests/Feature ./src ================================================ FILE: src/EagerLoadPivotBuilder.php ================================================ watchForPivotAccessors($name); if ($this->isPivotAccessor($name)) { $this->eagerLoadPivotRelations($models, $name); return $models; } return parent::eagerLoadRelation($models, $name, $constraints); } /** * Watch for pivot accessors to register it as known pivot accessors. * * @param string $name * @return void */ protected function watchForPivotAccessors($name) { $model = $this->getModel(); if (!method_exists($model->newInstance(), $name)) { return; } $relation = $model->newInstance()->$name(); if ($relation instanceof BelongsToMany) { static::$knownPivotAccessors[] = $relation->getPivotAccessor(); } } /** * If relation name is a pivot accessor. * * @param string $name * @return boolean */ protected function isPivotAccessor($name) { return in_array($name, static::$knownPivotAccessors); } /** * Eager load pivot relations. * * @param array $models * @param string $pivotAccessor * @return void */ protected function eagerLoadPivotRelations($models, $pivotAccessor) { $pivots = Arr::pluck($models, $pivotAccessor); $pivots = head($pivots)->newCollection($pivots); $pivots->load($this->getPivotEagerLoadRelations($pivotAccessor)); } /** * Get the pivot relations to be eager loaded. * * @param string $pivotAccessor * @return array */ protected function getPivotEagerLoadRelations($pivotAccessor) { $relations = array_filter($this->eagerLoad, function ($relation) use ($pivotAccessor) { return $relation !== $pivotAccessor && Str::contains($relation, $pivotAccessor); }, ARRAY_FILTER_USE_KEY); return array_combine( array_map(function ($relation) use ($pivotAccessor) { return substr($relation, strlen("{$pivotAccessor}.")); }, array_keys($relations)), array_values($relations) ); } } ================================================ FILE: src/EagerLoadPivotTrait.php ================================================ $this->faker->word, 'logo' => $this->faker->imageUrl, ]; } } ================================================ FILE: tests/Database/Factories/CarFactory.php ================================================ */ class CarFactory extends Factory { protected $model = Car::class; public function definition() { return [ 'model' => $this->faker->words(rand(2, 4), true), 'brand_id' => function() { return Brand::factory()->create()->id; } ]; } } ================================================ FILE: tests/Database/Factories/CarUserFactory.php ================================================ */ class CarUserFactory extends Factory { protected $model = CarUser::class; public function definition() { return [ 'car_id' => function() { return Car::factory()->create()->id; }, 'color_id' => function() { return Color::factory()->create()->id; }, 'user_id' => function() { return User::factory()->create()->id; } ]; } } ================================================ FILE: tests/Database/Factories/ColorFactory.php ================================================ */ class ColorFactory extends Factory { protected $model = Color::class; public function definition() { return [ 'name' => $this->faker->word, ]; } } ================================================ FILE: tests/Database/Factories/TireFactory.php ================================================ */ class TireFactory extends Factory { protected $model = Tire::class; public function definition() { return [ 'brand' => $this->faker->word, 'profile_depth' => $this->faker->randomNumber(2), 'car_user_id' => function() { return CarUser::factory()->create()->id; } ]; } } ================================================ FILE: tests/Database/Factories/UserFactory.php ================================================ */ class UserFactory extends Factory { protected $model = User::class; public function definition() { return [ 'name' => $this->faker->name, 'email' => $this->faker->unique()->safeEmail, 'email_verified_at' => now(), 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password 'remember_token' => Str::random(10), ]; } } ================================================ FILE: tests/Database/Migrations/2014_10_12_000000_create_users_table.php ================================================ bigIncrements('id'); $table->string('name'); $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); $table->string('password'); $table->rememberToken(); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('users'); } } ================================================ FILE: tests/Database/Migrations/2014_10_12_100000_create_password_resets_table.php ================================================ string('email')->index(); $table->string('token'); $table->timestamp('created_at')->nullable(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('password_resets'); } } ================================================ FILE: tests/Database/Migrations/2019_06_02_131048_create_brands_table.php ================================================ bigIncrements('id'); $table->string('name'); $table->string('logo'); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('brands'); } } ================================================ FILE: tests/Database/Migrations/2019_06_02_131049_create_cars_table.php ================================================ bigIncrements('id'); $table->string('model'); $table->bigInteger('brand_id'); $table->foreign('brand_id')->references('id')->on('brands')->cascadeOnDelete(); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('cars'); } } ================================================ FILE: tests/Database/Migrations/2019_06_02_131100_create_colors_table.php ================================================ bigIncrements('id'); $table->string('name'); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('colors'); } } ================================================ FILE: tests/Database/Migrations/2019_06_02_131120_create_car_user_table.php ================================================ bigIncrements('id'); $table->unsignedBigInteger('car_id'); $table->foreign('car_id')->references('id')->on('cars')->cascadeOnDelete(); $table->unsignedBigInteger('color_id'); $table->foreign('color_id')->references('id')->on('colors')->cascadeOnDelete(); $table->unsignedBigInteger('user_id'); $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('car_user'); } } ================================================ FILE: tests/Database/Migrations/2019_06_02_131148_create_tires_table.php ================================================ bigIncrements('id'); $table->string('brand'); $table->integer('profile_depth'); $table->bigInteger('car_user_id'); $table->foreign('car_user_id')->references('id')->on('car_user')->cascadeOnDelete(); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('tires'); } } ================================================ FILE: tests/Models/Brand.php ================================================ hasMany(Car::class); } protected static function newFactory() { return BrandFactory::new(); } } ================================================ FILE: tests/Models/Car.php ================================================ belongsTo(Brand::class); } public function users() { return $this->belongsToMany(User::class) ->withPivot('color_id') ->using(CarUser::class) ->as('car_user'); } protected static function newFactory() { return CarFactory::new(); } } ================================================ FILE: tests/Models/CarUser.php ================================================ belongsTo(Car::class); } public function color() { return $this->belongsTo(Color::class); } public function tires() { return $this->hasMany(Tire::class); } public function user() { return $this->belongsTo(User::class); } protected static function newFactory() { return CarUserFactory::new(); } } ================================================ FILE: tests/Models/Color.php ================================================ belongsToMany(Car::class); } public function users() { return $this->belongsToMany(User::class); } protected static function newFactory() { return ColorFactory::new(); } } ================================================ FILE: tests/Models/Tire.php ================================================ 'datetime', ]; public function cars() { return $this->belongsToMany(Car::class) ->withPivot('color_id') ->using(CarUser::class); } protected static function newFactory() { return UserFactory::new(); } } ================================================ FILE: tests/TestCase.php ================================================ up(); ( new \CreatePasswordResetsTable )->up(); ( new \CreateBrandsTable )->up(); ( new \CreateCarsTable )->up(); ( new \CreateColorsTable )->up(); ( new \CreateCarUserTable )->up(); ( new \CreateTiresTable )->up(); } } ================================================ FILE: tests/Unit/CountTest.php ================================================ markTestSkipped('This could be a new feature, see #6'); $user = User::factory()->create(); $pivots = CarUser::factory( [ 'user_id' => $user->id ] )->count( 2 )->create(); $tires = rand(4, 8); foreach($pivots as $pivot) { Tire::factory(['car_user_id' => $pivot->id]) ->count($tires) ->create(); } $user = User::with( [ 'cars', 'cars.pivot.color', 'cars.pivot' => function($query) { return $query->withCount('tires'); } ] ) ->find( $user->id ); $this->assertSame($tires, $user->cars[0]->pivot->tires_count); } public function test_it_can_use_load_count_pivot_relations() { $this->markTestSkipped('This could be a new feature, see #6'); $user = User::factory()->create(); $pivots = CarUser::factory( [ 'user_id' => $user->id ] )->count( 2 )->create(); $tires = rand(4, 8); foreach($pivots as $pivot) { Tire::factory(['car_user_id' => $pivot->id]) ->count($tires) ->create(); } $user = User::find($user->id); $user->load([ 'cars', 'cars.pivot.color', 'cars.pivot' => function($query) { return $query->withCount('tires'); }]); $this->assertSame($tires, $user->cars[0]->pivot->tires_count); } } ================================================ FILE: tests/Unit/PaginateTest.php ================================================ count( 30 )->create(); $users = User::with([ 'cars', 'cars.pivot.color' ]) ->paginate(10); $this->assertInstanceOf(LengthAwarePaginator::class, $users); } public function test_it_can_paginate_after_eager_loading_pivot_relations() { $this->markTestSkipped('Failing see #3'); $pivots = CarUser::factory()->count( 30 )->create(); $user = User::find(1)->cars()->with(['pivot.color'])->paginate(10); $this->assertInstanceOf(LengthAwarePaginator::class, $user); } public function test_it_can_paginate_with_custom_pivot_relations() { $pivots = CarUser::factory()->count( 30 )->create(); $cars = Car::with([ 'users', 'users.car_user.color' ]) ->paginate(10); $this->assertInstanceOf(LengthAwarePaginator::class, $cars); } public function test_it_can_paginate_after_eager_loading_custom_pivot_relations() { $this->markTestSkipped('Failing see #3'); $pivots = CarUser::factory()->count( 30 )->create(); $car = Car::find(1)->users()->with(['car_user.color'])->paginate(10); $this->assertInstanceOf(LengthAwarePaginator::class, $car); } } ================================================ FILE: tests/Unit/PivotTest.php ================================================ create(); $pivots = CarUser::factory(['user_id' => $user->id])->count( 2 )->create(); $user = User::with([ 'cars', 'cars.pivot.color' ]) ->find($user->id); $this->assertInstanceOf(Car::class, $user->cars[0]); $this->assertInstanceOf(Color::class, $user->cars[0]->pivot->color); } public function test_it_can_use_load_pivot_relations() { $user = User::factory()->create(); $pivots = CarUser::factory(['user_id' => $user->id])->count( 2 )->create(); $user->load([ 'cars', 'cars.pivot.color' ]); $this->assertInstanceOf(Car::class, $user->cars[0]); $this->assertInstanceOf(Color::class, $user->cars[0]->pivot->color); } public function test_it_can_use_load_missing_pivot_relations() { $user = User::factory()->create(); $pivots = CarUser::factory(['user_id' => $user->id])->count( 2 )->create(); $user->loadMissing([ 'cars', 'cars.pivot.color' ]); $this->assertInstanceOf(Car::class, $user->cars[0]); $this->assertInstanceOf(Color::class, $user->cars[0]->pivot->color); } public function test_it_can_use_with_custom_pivot_relations() { $car = Car::factory()->create(); $pivots = CarUser::factory(['car_id' => $car->id])->count( 2 )->create(); $car = Car::with([ 'users', 'users.car_user.color' ]) ->find($car->id); $this->assertInstanceOf(User::class, $car->users[0]); $this->assertInstanceOf(Color::class, $car->users[0]->car_user->color); } public function test_it_can_use_load_custom_pivot_relations() { $car = Car::factory()->create(); $pivots = CarUser::factory(['car_id' => $car->id])->count( 2 )->create(); $car->load([ 'users', 'users.car_user.color' ]); $this->assertInstanceOf(User::class, $car->users[0]); $this->assertInstanceOf(Color::class, $car->users[0]->car_user->color); } public function test_it_can_use_load_missing_custom_pivot_relations() { $car = Car::factory()->create(); $pivots = CarUser::factory(['car_id' => $car->id])->count( 2 )->create(); $car->loadMissing([ 'users', 'users.car_user.color' ]); $this->assertInstanceOf(User::class, $car->users[0]); $this->assertInstanceOf(Color::class, $car->users[0]->car_user->color); } }