动态

详情 返回 返回

Laravel 多態關聯的模型本地化套用 - 动态 详情

Laravel 多態關聯的模型本地化套用

前言

在項目中,一般情況下,我們可以使用單個字段來創建一個一對一或者一對多關聯,比如一個 User 有多個 Post。

而除了這些,我們偶爾會遇到一些關聯關係除了需要根據 ID 進行關聯外,還可能需要根據不同的 Type 去關聯不同的模型,而這,就是多態關聯。

在 Laravel 中,為我們提供了開箱即用的多態關聯。

準備環境

軟件 版本
Windows 11 24H2
PHP 8.2.6 ZTS Visual C++ 2019 x64
Xdebug 3.3.2
SQLite --
Laravel Framework 11.34.2 [注1]

表結構[注2]

classDiagram
direction BT
class comments {
   varchar commentable_type
   integer commentable_id
   text body
   datetime created_at
   datetime updated_at
   integer id
}
class posts {
   varchar title
   text content
   datetime created_at
   datetime updated_at
   integer id
}
class videos {
   varchar title
   varchar url
   datetime created_at
   datetime updated_at
   integer id
}

如上圖所示,我們存在三張表,其中,comments 使用 commentable_id 分別與 posts 和 videos 關聯,至於具體需要關聯哪一個模型,則需要根據 commentable_type 來決定。

默認情況下,Laravel 在 commentable_type 中填充的是 Post 和 Video 模型的完整命名空間類名。也就是分別為:App\Models\PostApp\Models\Video,這個設計帶來了一些小問題。

  • 我們的 type 字段和實體類名強關聯了,如果將來類可能要改名字,就勢必要修改數據庫內的數據才行。
  • 這裏麪包含了 \ 這個 “麻煩”的字符,有些 SQL 工具你還需要多次轉義後才能構成最終的查詢,尤其是有些特別的查詢。
  • 這裏完整的類名太過於冗長,他們幾乎都有一樣的前綴,只是後面的類名不一樣而已。

基於以上的問題,Laravel 官方也提供瞭解決方案,就是使用 Relation::morphMap 方法,它用起來就像下面這樣。

public function boot(){
    Relation::morphMap([
        'post' => 'App\Models\Post',
        'video' => 'App\Models\Video',
    ]);
}

等等,不對啊,我看文檔中明明是 Relation::enforceMorphMap,你怎麼説是 morphMap,其實都沒錯,雖然你現在看到最新的 Laravel 文檔中提到的是 Relation::enforceMorphMap ,但是實際上,morphMap 也是可以用的,因為這是之前遺留下的方法,那既然有 morphMap 了,為什麼還需要 enforceMorphMap

問得好,在使用 morphMap 我們可以選擇為部分的 morph 設置映射,比如上面的例子中,我可以只映射 post、而不映射 video,也就是説,在數據庫中,post 的 type 保存的是 post、而 video 卻保存的 App\Models\Video,造成了一種不一致了。尤其是這裏的 “多態”,就意味着,一開始可能只有這兩個類型,結果後面又新增一個類型,前面的你都映射了,而後面的同事忘了映射,就導致數據庫裏面的數據看起來亂糟糟的了。

所以為了解決這個問題,自 Laravel 8.x 開始,引入了 enforceMorphMap 這個 Feature,並且文檔裏面也改為了這個方法,現在如果你使用了這個,模型卻忘了映射,就會拋出一個 ClassMorphViolationException 異常,而不是像之前那樣默不作聲,從而幫助你在開發階段發現這個問題,它內部其實是調用了 requireMorphMap + morphMap 而已,當然,如果你喜歡之前那樣,還是可以調用舊的 morphMap 方法 。

遇到的問題

實際上,上面都是理想完美狀態,有一些特別的限制。

  • 定義的別名必須是全局唯一的。
  • 使用數字作為別名時,被意外的重置(Laravel < 5.5)

有時候我們的業務中就是使用了數字來區分不同的 type,並且不同模型的 type 可能還會重複,比如我們希望在 Comment 中,type 1/2 分別表示 Post 和 Video,而在 Attachment 中,type 1/2 又分別表示 User 和 Product。

通常我們直接這樣做的話,都會得到一個錯誤:Class name must be a valid object or a string,它來自於 PHP,因為 PHP 在嘗試 new 這個類的時候,無法解析了。

這個需求很“常見”是吧,但是目前卻做不到。

在早期的版本中,其實還存在另外一個由 morphMap 帶來的問題

    public static function morphMap(array $map = null, $merge = true)
    {
        $map = static::buildMorphMapFromModels($map);

        if (is_array($map)) {
            static::$morphMap = $merge && static::$morphMap
                            ? array_merge(static::$morphMap, $map) : $map;
        }

        return static::$morphMap;
    }

如上的代碼中,如果我們傳入的映射鍵是數字的,代碼進入 array_merge 的分支時,就會因為 array_merge 導致索引被重置……

  • Illuminate\Database\Eloquent\Relations\Relation::morphMap should allow numeric types · Issue #12845 · laravel/framework

在上面的這個討論中,還提到了另外一個問題,就是能否以組的方式,為每個都定義映射,不過這個問題的討論並沒有結果,但是類似的期待還是挺多的,甚至還出現了一些偏門的。

  • \[8.x] Option to use table names when morphing by taylorotwell · Pull Request #38451 · laravel/framework

這個 PR 中,添加了一個 Relation::morphUsingTableNames() 方法,用於自動映射表名,但是因為一些複雜的原因,這個 PR 最後還是被 Revert 了,因為它還存在一些其他不能被修復的問題。

image.png

其實在這個 PR 中,還有另外一個聲音:

image.png

imliam on Aug 20, 2021

While this code is being changed, maybe it would also be useful to add an optional property/method to models (eg. morphableName) that can be used - so each model can represent its own morphable name itself instead of being done elsewhere in a map?

尋找解藥

不過值得一提的是 Laravel 中確實有一個存在類似能力的方法,那就是 HasRelationships::getActualClassNameForMorph,目前,他會在你使用單個模型訪問尚未加載的多態模型時被調用,會傳入 type 的值作為參數,也就是説,如果數據庫中保存的是 1 ,這裏就會傳入 1。

這個方法的默認實現如下:

    public static function getActualClassNameForMorph($class)
    {
        return Arr::get(Relation::morphMap() ?: [], $class, $class);
    }

可以看到,這裏實際上就是調用了前面定義的映射而已,現在我們到 Comment 添加這個方法的複寫:

    public static function getActualClassNameForMorph($class)
    {
        return match ((int) $class) {
            1 => Post::class,
            2 => Video::class,
            default => parent::getActualClassNameForMorph($class),
        };
    }

然後你就會發現一個,好像確實可以。當你執行編寫下面的代碼時,你確實拿到了對應的 commentable

dump(\App\Models\Comment::first()->commentable);

看起來很美好,你應更很快就會發現,這實際上帶來了另外一個問題,你沒法使用 with 和 load 進行預加載關係了

當你使用 with 或者 load 時,你將會得到一個跟沒有使用這個方法時一樣的錯誤:Class name must be a valid object or a string,是的,沒錯,他就是真的沒有使用這個方法……,想不到吧。

這其實是 Laravel 給我們開的一個“小玩笑”,當我們從一個尚未預加載 commentable 的模型時,如果訪問 commentable,那麼 Laravel 按照以下路徑來訪問到我們剛剛覆蓋的 getActualClassNameForMorph 方法。

graph TD
    A[HasRelationships.morphInstanceTo]
    B[HasRelationships.morphTo]
    C[Comment.commentable]

    B --> A
    C --> B

Laravel 先調用了我們的關係方法 commentable,然後關係裏面調用了 morphTo,接着又調用了 morphInstanceTo,然後在 morphInstanceTo 調用了我們剛剛創建的方法。

    protected function morphInstanceTo($target, $name, $type, $id, $ownerKey)
    {
        $instance = $this->newRelatedInstance(
            static::getActualClassNameForMorph($target)
        );

        return $this->newMorphTo(
            $instance->newQuery(), $this, $id, $ownerKey ?? $instance->getKeyName(), $type, $name
        );
    }

注意,這裏之所以能夠訪問到我們定義的 getActualClassNameForMorph 方法,正是因為這裏使用了 statis 進行調用(延遲綁定)

現在,再來看看 getActualClassNameForMorph 的另一條訪問路徑

graph TD
    E[MorphTo.createModelByType]
    F[MorphTo.getResultsByType]
    G[MorphTo.getEager]
    H[Builder.eagerLoadRelation]
    I[Builder.eagerLoadRelations]
    J[Model.load]
    K[Builder.get]
    L[HasOneOrManyThrough.get]
    M[BelongsToMany.get]

    F --> E
    G --> F
    H --> G
    I --> H
    J --> I
    K --> I
    L --> I
    M --> I

可以看到,常用的 Builder.get 和 Model.load 都最終會訪問到 MorphTo.createModelByType最終在這個方法裏面調用了 getActualClassNameForMorph

    public function createModelByType($type)
    {
        $class = Model::getActualClassNameForMorph($type);

        return tap(new $class, function ($instance) {
            if (! $instance->getConnectionName()) {
                $instance->setConnection($this->getConnection()->getName());
            }
        });
    }

而這裏卻使用了 Model 進行調用,這是為什麼呢?

現在來會看一下完整的調用路徑

graph TD
    N[HasRelationships.getActualClassNameForMorph]
    A[HasRelationships.morphInstanceTo]
    B[HasRelationships.morphTo]
    C[Comment.commentable]
    E[MorphTo.createModelByType]
    F[MorphTo.getResultsByType]
    G[MorphTo.getEager]
    H[Builder.eagerLoadRelation]
    I[Builder.eagerLoadRelations]
    J[Builder.get]
    K[BelongsToMany.get]
    L[Model.load]
    M[HasOneOrManyThrough.get]

    A --> N
    B --> A
    C --> B
    E --> N
    F --> E
    G --> F
    H --> G
    I --> H
    J --> I
    K --> I
    L --> I
    M --> I

可以看到,當我們直接訪問 commentable 的時候,實際上是走了左邊的路徑,而當我們使用 with/load 時走的是右邊的路徑。

此時最大的區別就在於,HasRelationships 本身是一個 Trait,它在基礎 Model 中被 use,那也就是説,在上面調用 HasRelationships.morphInstanceTo 的時候 static 實際上還是我們調用的 Model(Comment),而 MorphTo 呢?它是一個單獨的類,所以這裏是用了 Model 進行調用,那我這裏就訪問不到 Comment 嗎?其實不然,在 MorphTo 這個類中,因為其祖先類是 Relation,其實裏面有一個 parent 屬性,這個屬性保存的就是 Comment 的實例。

classDiagram
    Relation <|-- BelongsTo
    BelongsTo <|-- MorphTo

    Relation : +Model parent
    class Relation
    class BelongsTo 
    class MorphTo

但是,至於 Laravel 中為什麼會這樣做,暫時不太清楚,沒有找到相關資料。

值得一提的是,在 Laravel 5.4 之前,確實有過類似的用法,此後都被取代了。

image.png

  • Cleaning up morphTo relationship. · laravel/framework@3cf7f17

其實從上面的 PR 中不難看到,一開始 morphInstanceTo 裏面也是使用的 Model 進行調用。

image.png

只是在隨後的一個 PR 中,有人貢獻了一次,並且把這裏改成了 static 調用,並且原因也是這個貢獻者希望使用數字來為每個模型定義映射,這個 PR 也是被合併了,也就達到了現在的樣子,但是對於 createModelByType 裏面的調用,這位貢獻者也提交了 PR,但是這個 PR 因為 Travis 的測試失敗了,因為他這裏是直接粗暴的調用了 static,前面説過,這裏的 static 實際上是指向了 MorphTo,所以自然是行不通的,且作者沒有及時維護,被關閉了,至此就遺留至今 🫥

  • 5.4 Allow getActualClassNameForMorph() used by createModelByType() to be overridden by masterwto · Pull Request #18099 · laravel/framework
  • 5.4 Update HasRelations::morphInstanceTo() by masterwto · Pull Request #18058 · laravel/framework

現在,讓我們打開 MorphTo::createModelByType ,修改 Model 為 parent

    public function createModelByType($type)
    {
-        $class = Model::getActualClassNameForMorph($type);
+        $class = $this->parent::getActualClassNameForMorph($type);

        return tap(new $class, function ($instance) {
            if (! $instance->getConnectionName()) {
                $instance->setConnection($this->getConnection()->getName());
            }
        });
    }

ok,返回我們的代碼,你就可以發現 load 和 with 都可以正常工作了。

但是,這就夠了嗎?

如果你的表中只有一個這樣的多態關聯關係,這就夠了,如果你有多個,這可能會不夠,因為,getActualClassNameForMorph 這裏雖然是傳入的 type,但是他並沒有告訴你,這是來自哪一個字段,這在預加載(with/load) 的時候變得極為困難。

當然,更重要的是目前這個還是要你去改核心的代碼或者通過其他方式改核心的代碼,有些難以達到。

陷入僵局

其實在幾年前我也處理過這個問題當然是還在用 Laravel 5.4 版本,當時根據一通調試後,確定了 Laravel 內部在訪問關聯字段的時候,其實是會通過訪問器的 get*Attribute,所以當時的解決方案就是,創建了一個 getCommentableTypeAttribute 這樣的 訪問器。

    public function commentableType(): Attribute
    {
        return Attribute::get(fn($value) => match ((int) $value) {
            1 => Post::class,
            2 => Video::class,
            default => $value,
        });
    }

好像確實可以,with/load 都工作了。

但是,當我們不適用 with/load 時,直接訪問未加載的關係屬性(commentable)時,你就會發現,又出錯了,這次得到的時 Class "1" not found 這樣的錯誤。

What?

其實啊,這是在 HasRelationships::morphTo 這個方法的 return 那裏,有一個判斷,其主要目的是,使用 getAttributeFromArray 獲取模型的 $attributes 屬性,並且返回指定 key 的,也就是説,他這裏實際上時直接訪問的 attributes 的值,並不會經過任何的訪問器……,自然就沒法使用。

    public function morphTo($name = null, $type = null, $id = null, $ownerKey = null)
    {
        // If no name is provided, we will use the backtrace to get the function name
        // since that is most likely the name of the polymorphic interface. We can
        // use that to get both the class and foreign key that will be utilized.
        $name = $name ?: $this->guessBelongsToRelation();

        [$type, $id] = $this->getMorphs(
            Str::snake($name), $type, $id
        );

        // If the type value is null it is probably safe to assume we're eager loading
        // the relationship. In this case we'll just pass in a dummy query where we
        // need to remove any eager loads that may already be defined on a model.
        return is_null($class = $this->getAttributeFromArray($type)) || $class === ''
                    ? $this->morphEagerTo($name, $type, $id, $ownerKey)
                    : $this->morphInstanceTo($class, $name, $type, $id, $ownerKey);
    }

不過,注意後面的判斷,如果 attributes 有這個字段,就會調用 morphEagerTo,反之調用後面的 morphInstanceTo,其實這裏的註釋就寫的很明白,如果此時從 attributes 上獲取的 type 為空,那麼就有可能我們其實是在使用 with(因為這時候查詢並沒有結束,所以返回了 morphEagerTo),而現在這裏是有值的,也就是數據庫裏面保存的 1/2,不過,別忘了,上面提到的眾多 getActualClassNameForMorph 調用者中,就有這個 morphInstanceTo,是的,沒錯。。。我們還可能需要重寫 getActualClassNameForMorph 方法來應對沒有預加載的情況。

現在加上之前的 getActualClassNameForMorph 實現,就成了下面這樣

public function commentable(): MorphTo
{
    return $this->morphTo();
}    

public static function getActualClassNameForMorph($class)
{
    return match ((int) $class) {
        1 => Post::class,
        2 => Video::class,
        default => parent::getActualClassNameForMorph($class),
    };
}

public function commentableType(): Attribute
{
    return Attribute::get(fn($value) => match ((int) $value) {
        1 => Post::class,
        2 => Video::class,
        default => $value,
    });
}

你還可以使用舊版的 get*Attribute 方法。

public function commentable(): MorphTo
{
    return $this->morphTo();
}    

public static function getActualClassNameForMorph($class)
{
    return match ((int) $class) {
        1 => Post::class,
        2 => Video::class,
        default => parent::getActualClassNameForMorph($class),
    };
}

public function getCommentableTypeAttribute($value)
{
    return match ((int) $value) {
        1 => Post::class,
        2 => Video::class,
        default => $value,
    };
}

現在,刷新頁面,你會發現基本已經完美了,我們沒有改變任何 Laravel 內部的代碼,只是改變了我們的模型。

現在可以直接訪問 commentable ,也可以使用 with/load

到此就完美了嗎?

不,並沒有

仔細觀察,你會發現上面這個方案還存在一個小小的問題,那就是我們必須要覆蓋 commentable_type 的讀出字段,這樣我們如果需要在接口返回時,就會顯示成我們轉換後的類名了,而不是原始的 1/2,那麼有沒有辦法呢,其實是有的,那就是再新建一個 commentTypeRef 的字段,在這裏面返回原始值,現在你的 Comment 模型應該就會像下面這樣。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class Comment extends Model
{
    use HasFactory;

    protected $appends = ['commentable_type_ref'];

    public static function getActualClassNameForMorph($class)
    {
        return match ((int) $class) {
            1 => Post::class,
            2 => Video::class,
            default => parent::getActualClassNameForMorph($class),
        };
    }

    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }

    public function commentableType(): Attribute
    {
        return Attribute::get(fn($value) => match ((int) $value) {
            1 => Post::class,
            2 => Video::class,
            default => $value,
        });
    }

    public function commentableTypeRef(): Attribute
    {
        return Attribute::get(fn() => (int) $this->attributes['commentable_type']);
    }


}

現在接口會返回一個 commentable_type_ref 字段,我這裏偷懶了,直接轉為 int 了。

很好,但美中不足,問題雖然解決了,但是前端原來用來判斷的字段變了,不過這也無傷大雅,跟前端友好溝通一下就好了。

如果你想更進一步,那不妨繼續看看。

更進一步

在上面的內容中,我們基本已經解決了我們的需求,只是有一些小瑕疵。

現在來思考一個問題,在我們創建模型關聯的時候,使用到的字段,是必須要數據庫中存在的嗎?

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;

class Post extends Model
{
    use HasFactory;

    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id');
    }

    public function userId(): Attribute
    {
        return Attribute::get(fn() => $this->id);
    }
}

在這裏,我們為 Post 模型創建了一個 user 的關係,如果你查看最初的表定義代碼,你會發現,posts 表裏面並沒有 user_id 這個字段,而我卻在下面使用獲取其創建了一個 user_id 字段,那這樣能行嗎?

答案是可行的。

Route::get('/', function () {
-    $comments = \App\Models\Comment::limit(5)->get();
-
-    $comments->load('commentable');
-    $comment = $comments->first();
-    $commentable = $comment->commentable;
-
-    return $comments;
-
+    
+    $post = \App\Models\Post::with('user')->first();
+
+    return $post;
});
{
    "id": 1,
    "title": "voluptatibus",
    "content": "fugit",
    "created_at": "2024-11-29T15:44:42.000000Z",
    "updated_at": "2024-11-29T15:44:42.000000Z",
    "user": {
        "id": 1,
        "name": "Test User",
        "email": "test@example.com",
        "email_verified_at": "2024-12-01T03:53:24.000000Z",
        "created_at": "2024-12-01T03:53:24.000000Z",
        "updated_at": "2024-12-01T03:53:24.000000Z"
    }
}

沒錯,它可以正確的加載出 user 來,並且還可以使用 with/load 和直接訪問。

有沒有一點點的驚訝,這便是解決前面這個問題的所在。

現在, 回到 Comment 模型,把原本 commentable 裏面的 morphTo 參數完善一下,,同時清理掉不再需要的 commentable_type_ref 吧,現在讓我們用回之前的測試代碼。

-protected $appends = ['commentable_type_ref'];

...

public function commentable(): MorphTo
{
-    return $this->morphTo();
+    return $this->morphTo('commentable', 'commentable_type_class', 'commentable_id', 'id');
}

-public function commentableType(): Attribute
+public function commentableTypeClass(): Attribute
{
-    return Attribute::get(fn($value) => match ((int) $value) {
+    return Attribute::get(fn() => match ((int) $this->commentable_type) {
        1 => Post::class,
        2 => Video::class,
-       default => $value,
+       default => $this->commentable_type,
    });
}


-public function commentableTypeRef(): Attribute
-{
-    return Attribute::get(fn() => (int) $this->attributes['commentable_type']);
-}

得到結果

[
    {
        "id": 1,
        "commentable_type": "1",
        "commentable_id": 5,
        "body": "iste",
        "created_at": "2024-11-29T15:48:28.000000Z",
        "updated_at": "2024-11-29T15:48:28.000000Z",
        "commentable": {
            "id": 5,
            "title": "sunt",
            "content": "numquam",
            "created_at": "2024-11-29T15:48:28.000000Z",
            "updated_at": "2024-11-29T15:48:28.000000Z"
        }
    }
]

現在,完美了沒?我只想説, 還差一點點~

為了後面的步驟更清晰,我先來裝一個 laravel-debugger

composer require barryvdh/laravel-debugbar --dev

別忘了在 .env 中添加配置項以啓用

DEBUGBAR_ENABLED=true

嗷,現在你訪問接口應該不會看到 laravel-debugger,這是因為他不會對 JSON 響應生效,如果想要看到,可以隨便訪問一個 404 頁面,然後就能看到那個熟悉的 icon 了,然後點擊 icon,再點擊右上角的從右至左第三個圖標(打開文件),選擇其他頁面的請求。

image.png

現在,把我們的測試代碼改成下面這樣

Route::get('/', function () {
    $comments = \App\Models\Comment::limit(1)->get();
    $comment = $comments->first();

    return $comment->commentable;
});

然後你就得到了結果

{
    "id": 5,
    "commentable_type": "1",
    "commentable_id": 5,
    "body": "reprehenderit",
    "created_at": "2024-11-29T15:48:28.000000Z",
    "updated_at": "2024-11-29T15:48:28.000000Z"
}

是的,你沒有看錯……,這裏明明返回的是 commentable,也就是原本應該返回 Post 或者 Video 的,這裏卻貌似返回了 comments.id = 5 的數據,有些離譜吧。。。

image-20241201122814943.png

還記得我們前面提到的 morphTo 方法嗎?這裏也會走到的喔,他會使用 getAttributeFromArray 來獲取 $attributes 屬性上的字段來判斷(在這裏理論上是數據庫中的字段),如果獲取到了,他會調用 morphInstanceTo,反之他則認為這是一個來自 with的調用,繼而調用 morphEagerTo

而我們這裏的 commentable_type_class 其實是虛構而來的,它並不存在於數據庫中的真實字段,自然,這裏獲取出來的就成了空,進入了預加載的邏輯,而實際上我們其實是在直接訪問未預加載的數據,就導致了這個錯誤。

如何修復這個問題?既然,我們需要給數據庫返回的結果添加一個字段,那在 select 的時候,處理一下不就好了嗎?

Route::get('/', function () {
    $comments = \App\Models\Comment::select(['*', DB::raw("CASE
        WHEN commentable_type = 1 THEN '\\App\\Models\\Post'
        WHEN commentable_type = 2 THEN '\\App\\Models\\Video'
        ELSE commentable_type
    END AS commentable_type_class")])->limit(1)->get();
    $comment = $comments->first();

    return $comment->commentable;
});

確實,我們通過這樣解決了前面的 bug,實現我們的需求,只是代碼看起來有億點點糟糕 🤔

什麼?還有高手!

最終方案

其實參考前面的內容不難發現,我們前面都完成了,只是,在最後一部,需要向 $attributes 添加字段這裏卡住了,那麼,有沒有什麼方法,可以讓我們在 $attributes 上添加字段呢,答案是有的。

你可能會想到,如果只是往上面添加一個字段,那直接覆蓋模型的 boot 方法然後給 $attributes 追加一個不就好了。

但是,那還是可能會影響到上面的 morphTo 方法,所以,我們的最終目的是,要找到一個合適的時機去添加,並且還要儘可能的考慮可能帶來的其他問題。

而 Laravel 的模型中恰好有這一類不同時機調用的方法,那就是模型事件。

選擇一個合適的模型事件

打開 Laravel 文檔,可以看到模型有以下事件。

事件名稱 觸發時機
retrieved 當模型實例從數據庫中檢索出來時觸發。
creating 在保存模型之前觸發(創建新記錄)。
created 在模型成功保存到數據庫後觸發(新記錄)。
updating 在更新現有模型時,數據被提交到數據庫之前觸發。
updated 在模型成功更新後觸發。
saving 在保存模型之前觸發(包括創建和更新)。
saved 在模型成功保存後觸發(包括創建和更新)。
deleting 在刪除模型之前觸發。
deleted 在模型成功刪除後觸發。
trashed 當模型被軟刪除(即 delete())時觸發。
forceDeleting 在強制刪除(即 forceDelete())之前觸發(跳過軟刪除)。
forceDeleted 在強制刪除後觸發(跳過軟刪除)。
restoring 在恢復軟刪除的模型時觸發。
restored 在恢復軟刪除的模型成功後觸發。
replicating 在複製模型實例(使用 replicate())時觸發。

看到以上表格,你應該一眼就看到了 retrieved 事件,是的,沒錯,我們就需要在模型從數據庫中被檢索出來的時候去處理,現在改寫我們的 Comment 模型代碼。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class Comment extends Model
{
    use HasFactory;

    protected static function booted()
    {
        parent::booted();

        self::retrieved(function (Comment $comment) {
            $comment->attributes['commentable_type_class'] = match ((int) $comment->commentable_type) {
                1 => Post::class,
                2 => Video::class,
                default => $comment->commentable_type,
            };

            $comment->makeHidden(['commentable_type_class']);
        });
    }


    public function commentable(): MorphTo
    {
        return $this->morphTo('commentable', 'commentable_type_class', 'commentable_id', 'id');
    }

}

首先,先覆蓋掉 booted 方法,並調用 parent::booted(),然後開始我們的邏輯。

retrieved 時,我們修改傳入的 Comment 模型,向其 $attributes 上添加一個 comment_type_class

與此同時,因為我們並不希望這個字段返回到我們的響應中,所以我們使用了 makeHidden,將其隱藏。

現在還有一個小問題,就是,如果我們的 comment 設置了 $fillable$guarded 都是 [] 的時候,就會出問題。

SQLSTATE[HY000]: General error: 1 no such column: commentable_type_class (Connection: sqlite, SQL: update "comments" set "commentable_type_class" = App\Models\Post, "updated_at" = 2024-12-01 04:57:01 where "id" = 1)

這個問題也很好解決,查看上面的事件表格,你會發現,saving,會在創建和保存前執行,所以我們只需要在這裏再 unset 掉剛剛添加的屬性,就好了。

self::saving(function (Comment $comment) {
    unset($comment->attributes['commentable_type_class']);
});

至此,我們的目的已經達成,首先,我們沒有破壞原本的 commentable_type、其次,我們也沒有破壞 Laravel 內部任何東西,甚至,如果你一個模型中有多個多態,也可以如此炮製。

完整的 Comment 模型如下。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class Comment extends Model
{
    use HasFactory;

    protected static function booted()
    {
        parent::booted();

        self::retrieved(function (Comment $comment) {
            $comment->attributes['commentable_type_class'] = match ((int) $comment->commentable_type) {
                1 => Post::class,
                2 => Video::class,
                default => $comment->commentable_type,
            };

            $comment->makeHidden(['commentable_type_class']);
        });

        self::saving(function (Comment $comment) {
            unset($comment->attributes['commentable_type_class']);
        });
    }


    public function commentable(): MorphTo
    {
        return $this->morphTo('commentable', 'commentable_type_class', 'commentable_id', 'id');
    }

}

結束

其實,這個實現還是有一些小問題,因為 Laravel 內部多態設計的原因,在以上的解決方案中,你將無法使用 hasMorph whereHasMorph 等相關方法,這説起來有一些複雜,所以,如果你需要用到這兩個方法,那麼很遺憾,這個方案並不適用於你,你應該考慮從新調整數據庫的設計,以適配 Laravel 的多態關聯。

對此,如果你的數據量不是很大,我還是很建議你儘早調整你的數據庫設計,使其符合 Laravel 的規範要求,而不是像這樣去處理它,畢竟這個方案,可能還存在一些我沒有預料到的問題,但是我已經儘可能的去驗證了。

注言

注1:這個方案應該適用於 Laravel 6.x(含)以後的版本,但我未作過多的測試

圖注:你應該注意到了裏面有很多的圖片生成,這些都是使用的 Markdown 的 mermaid 擴展代碼塊來渲染的,這些調用棧都是使用的 PHPStorm 的 Navigate 下的 Call Hierarchy,然後導出文本,然後交給 ChatGPT 來生成對應的 mermaid 圖形,提示詞如下:

1、精簡以下數據內容,比如過於重複的路徑前綴
2、使用 markdown 支持的圖形語法,選擇合適的圖形,並生成原始的 markdown 內容。

image.png

注2:、上面的數據庫示例表結構,也是由 ChatGPT 創建的遷移模型來實現的,但是數據的 Seeder 是由 Laravel Idea 生成的。

Creative Commons 4.0 授權協議 (CC BY 4.0)

此作品採用 Creative Commons 署名 4.0 國際許可協議 進行許可。

user avatar invalidnull 头像 zjkal 头像 sy_records 头像 seth9shi 头像 headofhouchang 头像 aoshunseo 头像 tangzhangming 头像 huifeideniao 头像 jkdataapi 头像 kip_67231fa160bbc 头像 yinan_5f40c19d52b29 头像 saxiaoyige 头像
点赞 13 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.