カテゴリー
Linux

laravel-permission。 ユーザーに紐づく全てのパーミッションを取得する。この時、パーミッションがロールに属している場合はそのロールも取得したい、を実現する Eloquent ORM とリレーション

環境

$ php artisan --version
Laravel Framework 6.20.30
$
$ composer show | grep spatie/laravel-permission
spatie/laravel-permission                 4.2.0     Permission handling for Laravel 6.0 and up
$

準備

前提として、 spatie/laravel-permission: Associate users with roles and permissions を使用しています。

Example App | laravel-permission | Spatie のコードをほぼそのまま使い、準備をしました。

database/seeders/PermissionsDemoSeeder.php は一部変更し、 create したパーミッションを変数に入れ、最初に作成するユーザー (Example User) に $user->givePermissionTo($permission3); と直接パーミッションを割り当てました。

database/seeders/PermissionsDemoSeeder.php 全体です。

<?php

use App\User;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;

class PermissionsDemoSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        // Reset cached roles and permissions
        app()[PermissionRegistrar::class]->forgetCachedPermissions();

        // create permissions
        Permission::create(['name' => 'edit articles']);
        Permission::create(['name' => 'delete articles']);
        $permission3 = Permission::create(['name' => 'publish articles']);
        Permission::create(['name' => 'unpublish articles']);

        // create roles and assign existing permissions
        $role1 = Role::create(['name' => 'writer']);
        $role1->givePermissionTo('edit articles');
        $role1->givePermissionTo('delete articles');

        $role2 = Role::create(['name' => 'admin']);
        $role2->givePermissionTo('publish articles');
        $role2->givePermissionTo('unpublish articles');

        $role3 = Role::create(['name' => 'Super-Admin']);
        // gets all permissions via Gate::before rule; see AuthServiceProvider

        // create demo users
        $user = factory(User::class)->create([
            'name' => 'Example User',
            'email' => 'test@example.com',
        ]);
        $user->assignRole($role1);
        $user->givePermissionTo($permission3);

        $user = factory(User::class)->create([
            'name' => 'Example Admin User',
            'email' => 'admin@example.com',
        ]);
        $user->assignRole($role2);

        $user = factory(User::class)->create([
            'name' => 'Example Super-Admin User',
            'email' => 'superadmin@example.com',
        ]);
        $user->assignRole($role3);
    }
}

php artisan migrate:fresh --seed --seeder=PermissionsDemoSeeder で検証用のデータを用意し、 tinker を起動した後に楽できるように use して準備完了です。

php artisan tinker

use App\User;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Illuminate\Database\Eloquent\Builder;

以後、本投稿では tinker で試行錯誤をいたしました。

laravel-permission ライブラリをそのまま使って実現できないか軽く試した

  • ユーザーに紐づくパーミッションを、直接紐づくのもロール経由で紐づくものも、全て取得する getAllPermissions() がある。これを使う場合、取得したパーミッションに紐づくロールは取得できなかった (紐づく Pivot や MorphPivot は取得できていた) 。
  • メソッドのソースコードを確認したが、パーミッションに紐づくロールを取得する術はなさそうだった。

以上のことから、自分でなんとかするしかないと考え、試行錯誤を始めました。

少しずつ試行錯誤。まずは達成したいことを分割した。

複雑な内容なため、いきなり完成系は難しいと考えました。ですので

  • ユーザーに紐づく全てのパーミッションを取得する。この時、パーミッションがロールに属している場合はそのロールも取得したい。

を次の 2 つにわけ、まずはそれぞれのパーミッションを取得することを目標としました。

  • パーミッション n—n ユーザー、という関係にある、ロールには紐づいていないユーザーに直接紐づくパーミッションを取得する。
  • パーミッション n—n ロール n—n ユーザー、という関係にある、ユーザーに紐づいておりさらにロールと紐づいているパーミッションを取得する。

ユーザーに直接紐づくパーミッションを取得する

  • パーミッション n—n ユーザー、という関係にある、ロールには紐づいていないユーザーに直接紐づくパーミッションを取得する。
  • ユーザーに直接紐づいており、ロールとは紐づいていないパーミッションの場合、パーミッションのプロパティ roles の値は空の配列 [] としたい。

次で実現できました。

>>> // [id が 1 のユーザーと紐付きのあるパーミッション] と [id が 1 のユーザーと紐付きのあるロール]
>>> Permission::with(['roles' => function ($query) { $query->whereHas('users', function ($query) { $query->where('id', 1); }); }])->whereHas('users', function (Builder $query) { $query->where('id', 1); })->get()->toArray();

=> [
     [
       "id" => 3,
       "name" => "publish articles",
       "guard_name" => "web",
       "created_at" => "2021-08-12 21:24:03",
       "updated_at" => "2021-08-12 21:24:03",
       "roles" => [],
     ],
   ]
>>>

以下、少しずつ試行錯誤した時のものです。

// ユーザーと紐付きのあるパーミッション
Permission::has('users')->get();
// id が 1 のユーザーと紐付きのあるパーミッション
Permission::whereHas('users', function (Builder $query) { $query->where('id', 1); })->get();
// ユーザーと紐付きのあるパーミッションと [id が 1 のユーザーと紐付きのあるロール]
Permission::with(['roles' => function ($query) { $query->whereHas('users', function ($query) { $query->where('id', 1); }); }])->has('users')->get();
// [id が 1 のユーザーと紐付きのあるパーミッション] と [id が 1 のユーザーと紐付きのあるロール]
Permission::with(['roles' => function ($query) { $query->whereHas('users', function ($query) { $query->where('id', 1); }); }])->whereHas('users', function (Builder $query) { $query->where('id', 1); })->get()->toArray();

ユーザーに紐づいておりさらにロールと紐づいているパーミッションを取得する

  • パーミッション n—n ロール n—n ユーザー、という関係にある、ユーザーに紐づいておりさらにロールと紐づいているパーミッションを取得する。

次で実現できました。

>>> // [id が 1 のユーザーに紐づいているロール] に紐づいている [パーミッションとそのロール]
>>> Permission::with('roles')->whereHas('roles.users', function (Builder $query) { $query->where('id', 1); })->orderBy('id', 'asc')->get()->toArray();
=> [
     [
       "id" => 1,
       "name" => "edit articles",
       "guard_name" => "web",
       "created_at" => "2021-08-12 21:24:03",
       "updated_at" => "2021-08-12 21:24:03",
       "roles" => [
         [
           "id" => 1,
           "name" => "writer",
           "guard_name" => "web",
           "created_at" => "2021-08-12 21:24:03",
           "updated_at" => "2021-08-12 21:24:03",
           "pivot" => [
             "permission_id" => 1,
             "role_id" => 1,
           ],
         ],
       ],
     ],
     [
       "id" => 2,
       "name" => "delete articles",
       "guard_name" => "web",
       "created_at" => "2021-08-12 21:24:03",
       "updated_at" => "2021-08-12 21:24:03",
       "roles" => [
         [
           "id" => 1,
           "name" => "writer",
           "guard_name" => "web",
           "created_at" => "2021-08-12 21:24:03",
           "updated_at" => "2021-08-12 21:24:03",
           "pivot" => [
             "permission_id" => 2,
             "role_id" => 1,
           ],
         ],
       ],
     ],
   ]
>>>

以下、少しずつ試行錯誤した時のものです。

// ロールに紐づいているパーミッション
Permission::has('roles')->get();
// ロールに紐づいている [パーミッションとそのロール]
Permission::with('roles')->has('roles')->get();

// [ユーザーに紐づいているロール] に紐づいているパーミッション
Permission::has('roles.users')->get();
// [ユーザーに紐づいているロール] に紐づいている [パーミッションとそのロールとユーザー]
Permission::with('roles.users')->has('roles.users')->orderBy('id', 'asc')->get();
// [id が 1 のユーザーに紐づいているロール] に紐づいている [パーミッションとそのロールとユーザー]
Permission::with('roles.users')->whereHas('roles.users', function (Builder $query) { $query->where('id', 1); })->orderBy('id', 'asc')->get();

// [id が 1 のユーザーに紐づいているロール] に紐づいている [パーミッションとそのロール]
Permission::with('roles')->whereHas('roles.users', function (Builder $query) { $query->where('id', 1); })->orderBy('id', 'asc')->get();

分割したパーミッション取得を 1 つにする

それぞれで取得したパーミッションのコレクションを、 PHP 側でマージすることも可能と思います。ですけれども、本投稿では Eloquent ORM を駆使して一発で取得することを目標としました。

今までの実例のコメントから、次を or で合わせたモデルを取得することになります。

  • [id が 1 のユーザーと紐付きのあるパーミッション] と [id が 1 のユーザーと紐付きのあるロール]
  • [id が 1 のユーザーに紐づいているロール] に紐づいている [パーミッションとそのロール]

この条件を試行錯誤も経ながら整理したのが次となります。最終的に次の条件のモデルを取得することとなりました。

  • [id が 1 のユーザーと紐付きのある、または、[id が 1 のユーザーに紐づいているロール] に紐づいている] パーミッションと [id が 1 のユーザーに紐づいているロール]

"または" を実現する、つまり () or () を実現するために パラメータのグループ分け を駆使しました。この時勉強になったこととして次を挙げます。

  • where メソッドの中に with メソッドを入れた場合、外側にその with メソッドの内容は適用されないことがわかった。
  • したがって、 with(['roles' => function ($query) { $query->whereHas('users', function ($query) { $query->where('id', 1); }); }])with('roles') を where メソッドから外出しし、対象となるリレーションが同じなため複雑な方のみを残した。

最終的に、次でうまく行きました、完成です!

// [id が 1 のユーザーと紐付きのある、または、[id が 1 のユーザーに紐づいているロール] に紐づいている] パーミッションと [id が 1 のユーザーに紐づいているロール]
Permission::with(['roles' => function ($query) { $query->whereHas('users', function ($query) { $query->where('id', 1); }); }])->
where(function ($query) {
    $query->whereHas('users', function (Builder $query) { $query->where('id', 1); });
})->
orWhere(function ($query) {
    $query->whereHas('roles.users', function (Builder $query) { $query->where('id', 1); })->orderBy('id', 'asc');
})->
get()->toArray();

実際に実行したときの出力です。

>>> // [id が 1 のユーザーと紐付きのある、または、[id が 1 のユーザーに紐づいているロール] に紐づいている] パーミッションと [id が 1 のユーザーに紐づいているロール]

>>> Permission::with(['roles' => function ($query) { $query->whereHas('users', function ($query) { $query->where('id', 1); }); }])->
... where(function ($query) {
...     $query->whereHas('users', function (Builder $query) { $query->where('id', 1); });
... })->
... orWhere(function ($query) {
...     $query->whereHas('roles.users', function (Builder $query) { $query->where('id', 1); })->orderBy('id', 'asc');
... })->
... get()->toArray();
=> [
     [
       "id" => 1,
       "name" => "edit articles",
       "guard_name" => "web",
       "created_at" => "2021-08-12 21:24:03",
       "updated_at" => "2021-08-12 21:24:03",
       "roles" => [
         [
           "id" => 1,
           "name" => "writer",
           "guard_name" => "web",
           "created_at" => "2021-08-12 21:24:03",
           "updated_at" => "2021-08-12 21:24:03",
           "pivot" => [
             "permission_id" => 1,
             "role_id" => 1,
           ],
         ],
       ],
     ],
     [
       "id" => 2,
       "name" => "delete articles",
       "guard_name" => "web",
       "created_at" => "2021-08-12 21:24:03",
       "updated_at" => "2021-08-12 21:24:03",
       "roles" => [
         [
           "id" => 1,
           "name" => "writer",
           "guard_name" => "web",
           "created_at" => "2021-08-12 21:24:03",
           "updated_at" => "2021-08-12 21:24:03",
           "pivot" => [
             "permission_id" => 2,
             "role_id" => 1,
           ],
         ],
       ],
     ],
     [
       "id" => 3,
       "name" => "publish articles",
       "guard_name" => "web",
       "created_at" => "2021-08-12 21:24:03",
       "updated_at" => "2021-08-12 21:24:03",
       "roles" => [],
     ],
   ]
>>>

補足。トラブルシューティング

一つ目のエラー

本投稿とは全く別の機会でスムースに進まなかったことがありましたので残しておきたいと思います。

tinker でうまく本投稿のことが実現できました。そして、実際にコードを書き、ユニットテストを実行すると、エラーとなってしまいました。エラーの内容は、 Permission モデルでのリレーション roles() メソッド、また、Role モデルのリレーション permissions() メソッドで発生したことを示していました。

どちらも同じエラー内容、同じ解消方法でしたので、 Permission をとり挙げます。

この時のプロジェクトでは、ライブラリのモデル Spatie\Permission\Models\PermissionSpatie\Permission\Models\Role をそのまま使わず extend したモデルを使用していました。これがエラーの原因となっていました。

Spatie\Permission\Models\Permission では roles() を定義しております。一方で、このモデルクラスは use HasRoles; しておりこのトレイトの中でも roles() を定義しております。この場合、トレイトではなく、モデルクラスの roles() が使用されます。

一方で、 extend してこちらで用意した Permission モデルでは、

  • Spatie\Permission\Models\Permission を extend した。
  • Spatie\Permission\Traits\HasRoles トレイトを use した。
  • roles() は定義していない。

という状況でした。 roles() を extend したクラスで定義していないことにより、トレイトの roles() が使用されてしまい、エラーとなったのでした。

ですので、 extend したクラスでも、 roles() を定義して、エラーを解消しました。ただ、今にして思うとトレイトを使ったクラスを extend した時点で継承したクラスからはトレイトのメソッドも使えるような気がします (試していない) 。したがって、 extend したクラスではトレイトを使わないようにしても、エラーを解消できたかもしれません。

二つ目のエラー

さて、これでエラーが解消できましたけれども、もう一つエラーと遭遇しました。エラーの内容は、 Permission モデル、 Role モデルのリレーションを定義した users() メソッドで発生したことを示していました。

これも先ほどのエラーと似たような内容でした。 Permission を例に説明を続けます。

エラーでは、 users() メソッドの morphedByMany の第一引数の値がダメ、とのことでした。ここに指定するのはクラス名です。

Spatie\Permission\Models\Permissionusers() では getModelForGuard($this->attributes['guard_name']), と書かれており動的にクラス名を取得しています。

このエラーが発生したプロジェクトでは、 1 種類の固定のクラス名を指定できれば良いことがわかっていました。そこで、 users() をこちらで用意した Spatie\Permission\Models\Permission を extend したモデルクラスで定義し直し、この時問題となった第一引数にはクラス名を固定で、つまり User::class という形で書き込みました。

これで、エラーが解消されました♪

おわりに

本投稿の試みやエラー対処によってライブラリへの理解が深まったためなのか、このライブラリを使う意義が薄まってきたように思いました。何もエラーや試行錯誤に遭遇しなければライブラリを使った方が時間が掛からないので良いのですけれども、汎用的に使えるように設計されていることが、本投稿のレベルでは逆に仇となって解決までに時間がかかってしまったように思えます。

このライブラリでは users, permissions, roles のテーブルをうまく使うためのものと捉えれば、自前で作っても良いのではないか、そう思えました。

以上です。

コメントを残す