Laravel Database——Eloquent Model 更新關聯模型

leoyang發表於2017-10-26

前言

本文 GitBook 地址: https://www.gitbook.com/book/leoyang90/laravel-source-analysis

在前兩篇文章中,向大家介紹了定義關聯關係的原始碼,還有基於關聯關係的關聯模型載入與查詢的原始碼分析,本文開始介紹第三部分,如何利用關聯關係來更新插入關聯模型。

 

hasOne/hasMany/MorphOne/MorphMany 更新與插入

save 方法

正向的一對一、一對多關聯儲存方法用於對子模型設定外來鍵值:

public function save(Model $model)
{
    $this->setForeignAttributesForCreate($model);

    return $model->save() ? $model : false;
}

protected function setForeignAttributesForCreate(Model $model)
{
    $model->setAttribute($this->getForeignKeyName(), $this->getParentKey());
}

public function getParentKey()
{
    return $this->parent->getAttribute($this->localKey);
}

saveMany 方法

public function saveMany($models)
{
    foreach ($models as $model) {
        $this->save($model);
    }

    return $models;
}

create 方法

create 方法與 save 方法功能一致,唯一不同的是 create 的引數是屬性,save 方法的引數是 model

public function create(array $attributes = [])
{
    return tap($this->related->newInstance($attributes), function ($instance) {
        $this->setForeignAttributesForCreate($instance);

        $instance->save();
    });
}

protected function setForeignAttributesForCreate(Model $model)
{
    $model->setAttribute($this->getForeignKeyName(), $this->getParentKey());
}

createMany 方法

public function createMany(array $records)
{
    $instances = $this->related->newCollection();

    foreach ($records as $record) {
        $instances->push($this->create($record));
    }

    return $instances;
}

make 方法

make 方法用於建立子模型物件,但是並不進行儲存操作:

public function make(array $attributes = [])
{
    return tap($this->related->newInstance($attributes), function ($instance) {
        $this->setForeignAttributesForCreate($instance);
    });
}

update 方法

update 方法用於更新子模型的屬性,值得注意的是時間戳的更新:

public function update(array $attributes)
{
    if ($this->related->usesTimestamps()) {
        $attributes[$this->relatedUpdatedAt()] = $this->related->freshTimestampString();
    }

    return $this->query->update($attributes);
}

findOrNew 方法

public function findOrNew($id, $columns = ['*'])
{
    if (is_null($instance = $this->find($id, $columns))) {
        $instance = $this->related->newInstance();

        $this->setForeignAttributesForCreate($instance);
    }

    return $instance;
}

firstOrCreate 方法

實際呼叫的是 create 方法:

public function firstOrCreate(array $attributes, array $values = [])
{
    if (is_null($instance = $this->where($attributes)->first())) {
        $instance = $this->create($attributes + $values);
    }

    return $instance;
}

updateOrCreate 方法

public function updateOrCreate(array $attributes, array $values = [])
{
    return tap($this->firstOrNew($attributes), function ($instance) use ($values) {
        $instance->fill($values);

        $instance->save();
    });
}

 

belongsTo/MorphTo 更新

save 方法

如果我們在子模型加一個包含關聯名稱的 touches 屬性後,當我們更新一個子模型時,對應父模型的 updated_at 欄位也會被同時更新:

class Comment extends Model
{
    protected $touches = ['post'];

    public function post()
    {
        return $this->belongsTo('App\Post');
    }
}

$comment = App\Comment::find(1);

$comment->text = '編輯了這條評論!';

$comment->save();

這是由於,對子模型呼叫 save 方法會引發 finishSave 函式:

protected function finishSave(array $options)
{
    $this->fireModelEvent('saved', false);

    if ($this->isDirty() && ($options['touch'] ?? true)) {
        $this->touchOwners();
    }

    $this->syncOriginal();
}

可以看到,touchOwners 函式被呼叫:

public function touchOwners()
{
    foreach ($this->touches as $relation) {
        $this->$relation()->touch();

        if ($this->$relation instanceof self) {
            $this->$relation->fireModelEvent('saved', false);

            $this->$relation->touchOwners();
        } elseif ($this->$relation instanceof Collection) {
            $this->$relation->each(function (Model $relation) {
                $relation->touchOwners();
            });
        }
    }
}

可以看到,touchOwners 函式會呼叫 touch 函式,該函式用於更新父模型的時間戳:

public function touch()
{
    $column = $this->getRelated()->getUpdatedAtColumn();

    $this->rawUpdate([$column => $this->getRelated()->freshTimestampString()]);
}

之後,父模型還會遞迴呼叫 touchOwners 函式,不斷更新上一級的父模型。

update 方法

belongsTo/MorphTo 的更新方法用於父模型的屬性更新:

public function update(array $attributes)
{
    return $this->getResults()->fill($attributes)->save();
}

associate 方法

如果想要更新 belongsTo 關聯時,可以使用 associate 方法。此方法會在子模型中設定外來鍵:

public function associate($model)
{
    $ownerKey = $model instanceof Model ? $model->getAttribute($this->ownerKey) : $model;

    $this->child->setAttribute($this->foreignKey, $ownerKey);

    if ($model instanceof Model) {
        $this->child->setRelation($this->relation, $model);
    }

    return $this->child;
}

dissociate 方法

當刪除 belongsTo 關聯時,可以使用 dissociate方法。此方法會設定關聯外來鍵為 null:

public function dissociate()
{
    $this->child->setAttribute($this->foreignKey, null);

    return $this->child->setRelation($this->relation, null);
}

 

belongsToMany 更新與插入

attach 方法

attach 方法用於為多對多關係新增新的關聯關係,主要進行了中間表的插入工作,用法:

$user = App\User::find(1);

$user->roles()->attach($roleId);

//也可以通過傳遞一個陣列引數向中間表寫入額外資料
$user->roles()->attach($roleId, ['expires' => $expires]);

//為了方便,還允許傳入 ID 陣列:
$user->roles()->attach([
    1 => ['expires' => $expires],
    2 => ['expires' => $expires]
]);

原始碼:

public function attach($id, array $attributes = [], $touch = true)
{
    $this->newPivotStatement()->insert($this->formatAttachRecords(
        $this->parseIds($id), $attributes
    ));

    if ($touch) {
        $this->touchIfTouching();
    }
}

protected function parseIds($value)
{
    if ($value instanceof Model) {
        return [$value->getKey()];
    }

    if ($value instanceof Collection) {
        return $value->modelKeys();
    }

    if ($value instanceof BaseCollection) {
        return $value->toArray();
    }

    return (array) $value;
}

public function newPivotStatement()
{
    return $this->query->getQuery()->newQuery()->from($this->table);
}

可以看到,attach 函式最重要的是對中間表插入新資料。

在說這段程式碼之前,我們要先說說多對多關聯關係獨有的設定:

中間表 Pivot 特殊初始化設定

  • 自定義中間表模型
class Role extends Model
{
    /**
     * 獲得此角色下的使用者。
     */
    public function users()
    {
        return $this->belongsToMany('App\User')->using('App\UserRole');
    }
}

using 原始碼非常簡單:

public function using($class)
{
    $this->using = $class;

    return $this;
}
  • 中間表時間戳欄位
return $this->belongsToMany('App\Role')->withTimestamps();

withTimestamps 原始碼:

public function withTimestamps($createdAt = null, $updatedAt = null)
{
    $this->pivotCreatedAt = $createdAt;
    $this->pivotUpdatedAt = $updatedAt;

    return $this->withPivot($this->createdAt(), $this->updatedAt());
}

public function createdAt()
{
    return $this->pivotCreatedAt ?: $this->parent->getCreatedAtColumn();
}

public function updatedAt()
{
    return $this->pivotUpdatedAt ?: $this->parent->getUpdatedAtColumn();
}
  • 中間表自定義欄位
return $this->belongsToMany('App\Role')->withPivot('column1', 'column2');

自定義欄位都會存放在 pivotColumns 中:

public function withPivot($columns)
{
    $this->pivotColumns = array_merge(
        $this->pivotColumns, is_array($columns) ? $columns : func_get_args()
    );

    return $this;
}

中間表時間戳

我們接著說中間表的插入程式碼:

protected function formatAttachRecords($ids, array $attributes)
{
    $records = [];

    $hasTimestamps = ($this->hasPivotColumn($this->createdAt()) ||
              $this->hasPivotColumn($this->updatedAt()));

    $attributes = $this->using
            ? $this->newPivot()->forceFill($attributes)->getAttributes()
            : $attributes;

    foreach ($ids as $key => $value) {
        $records[] = $this->formatAttachRecord(
            $key, $value, $attributes, $hasTimestamps
        );
    }

    return $records;
}

如果我們在設定多對多關聯關係的時候,使用了時間戳,那麼 hasTimestamps 就會為 true

初始化 Pivot

當我們設定了自定義的中間表模型時,就會呼叫 newPivot 函式:

public function newPivot(array $attributes = [], $exists = false)
{
    $pivot = $this->related->newPivot(
        $this->parent, $attributes, $this->table, $exists, $this->using
    );

    return $pivot->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey);
}

public function newPivot(Model $parent, array $attributes, $table, $exists, $using = null)
{
    return $using ? $using::fromRawAttributes($parent, $attributes, $table, $exists)
                  : Pivot::fromAttributes($parent, $attributes, $table, $exists);
}

public function setPivotKeys($foreignKey, $relatedKey)
{
    $this->foreignKey = $foreignKey;

    $this->relatedKey = $relatedKey;

    return $this;
}

可以看到,newPivot 會返回 Pivot 型別的物件,另外為中間表設定了 foreignKeyrelatedKey

生成 insert 陣列

protected function formatAttachRecord($key, $value, $attributes, $hasTimestamps)
{
    list($id, $attributes) = $this->extractAttachIdAndAttributes($key, $value, $attributes);

    return array_merge(
        $this->baseAttachRecord($id, $hasTimestamps), $attributes
    );
}

protected function extractAttachIdAndAttributes($key, $value, array $attributes)
{
    return is_array($value)
                ? [$key, array_merge($value, $attributes)]
                : [$value, $attributes];
}

extractAttachIdAndAttributes 用於獲得插入記錄的主鍵 id,與其對應的屬性。由於可以這樣進行傳入引數:

$user->roles()->attach([
    1 => ['expires' => $expires],
    2 => ['expires' => $expires]
]);

所以要判斷一下 value 是否是陣列。baseAttachRecord 最終生成用於 insert 的屬性陣列:

protected function baseAttachRecord($id, $timed)
{
    $record[$this->relatedPivotKey] = $id;

    $record[$this->foreignPivotKey] = $this->parent->{$this->parentKey};

    if ($timed) {
        $record = $this->addTimestampsToAttachment($record);
    }

    return $record;
}

protected function addTimestampsToAttachment(array $record, $exists = false)
{
    $fresh = $this->parent->freshTimestamp();

    if (! $exists && $this->hasPivotColumn($this->createdAt())) {
        $record[$this->createdAt()] = $fresh;
    }

    if ($this->hasPivotColumn($this->updatedAt())) {
        $record[$this->updatedAt()] = $fresh;
    }

    return $record;
}

touchIfTouching 更新多對多時間戳更新

對中間表進行插入操作後,就要對父模型與 related 模型進行時間戳更新操作:

public function touchIfTouching()
{
    if ($this->touchingParent()) {
        $this->getParent()->touch();
    }

    if ($this->getParent()->touches($this->relationName)) {
        $this->touch();
    }
}

public function touch()
{
    if (! $this->usesTimestamps()) {
        return false;
    }

    $this->updateTimestamps();

    return $this->save();
}

首先,如果 related 模型的 touchs 陣列中有本多對多關係,那麼父模型就要進行時間戳更新操作:

protected function touchingParent()
{
    return $this->getRelated()->touches($this->guessInverseRelation());
}

protected function guessInverseRelation()
{
    return Str::camel(Str::plural(class_basename($this->getParent())));
}

其次,如果父模型的 touchs 陣列中存在多對多關聯,那麼就要進行多對多關聯的 touch 函式,對 related 模型進行時間戳更新操作:

public function touch()
{
    $key = $this->getRelated()->getKeyName();

    $columns = [
        $this->related->getUpdatedAtColumn() => $this->related->freshTimestampString(),
    ];

    if (count($ids = $this->allRelatedIds()) > 0) {
        $this->getRelated()->newQuery()->whereIn($key, $ids)->update($columns);
    }
}

public function allRelatedIds()
{
    return $this->newPivotQuery()->pluck($this->relatedPivotKey);
}

save 方法

belongsToManysave 方法用於更新多對多關係,該函式會:

  • 更新 related 模型屬性
  • 在中間表中新增新的記錄
  • 更新父模型與 related 模型的時間戳

主要呼叫了 attach 函式:

public function save(Model $model, array $pivotAttributes = [], $touch = true)
{
    $model->save(['touch' => false]);

    $this->attach($model->getKey(), $pivotAttributes, $touch);

    return $model;
}

saveMany 方法

public function saveMany($models, array $pivotAttributes = [])
{
    foreach ($models as $key => $model) {
        $this->save($model, (array) ($pivotAttributes[$key] ?? []), false);
    }

    $this->touchIfTouching();

    return $models;
}

create 方法

多對多的 create 方法用於儲存 related 的屬性,並且可以為中間表新增 joining 屬性資訊:

public function create(array $attributes = [], array $joining = [], $touch = true)
{
    $instance = $this->related->newInstance($attributes);

    $instance->save(['touch' => false]);

    $this->attach($instance->getKey(), $joining, $touch);

    return $instance;
}

createMany 方法

public function createMany(array $records, array $joinings = [])
{
    $instances = [];

    foreach ($records as $key => $record) {
        $instances[] = $this->create($record, (array) ($joinings[$key] ?? []), false);
    }

    $this->touchIfTouching();

    return $instances;
}

detach 方法

detach 方法比較簡單,重要的是對中間表進行刪除操作:

public function detach($ids = null, $touch = true)
{
    $query = $this->newPivotQuery();

    if (! is_null($ids)) {
        $ids = $this->parseIds($ids);

        if (empty($ids)) {
            return 0;
        }

        $query->whereIn($this->relatedPivotKey, (array) $ids);
    }

    $results = $query->delete();

    if ($touch) {
        $this->touchIfTouching();
    }

    return $results;
}

同步關聯 sync

$user->roles()->sync([1, 2, 3]);

//可以通過 ID 傳遞其他額外的資料到中間表:
$user->roles()->sync([1 => ['expires' => true], 2, 3]);

原始碼:

public function sync($ids, $detaching = true)
{
    $changes = [
        'attached' => [], 'detached' => [], 'updated' => [],
    ];

    $current = $this->newPivotQuery()->pluck(
        $this->relatedPivotKey
    )->all();

    $detach = array_diff($current, array_keys(
        $records = $this->formatRecordsList($this->parseIds($ids))
    ));

    if ($detaching && count($detach) > 0) {
        $this->detach($detach);

        $changes['detached'] = $this->castKeys($detach);
    }

    $changes = array_merge(
        $changes, $this->attachNew($records, $current, false)
    );

    if (count($changes['attached']) ||
        count($changes['updated'])) {
        $this->touchIfTouching();
    }

    return $changes;
}

同步關聯需要刪除未出現的 id,更新已經存在 id,增添新出現的 id

 $current = $this->newPivotQuery()->pluck(
    $this->relatedPivotKey
)->all();

這句用於從中間表中取出所有關聯的中間表記錄,並且取出 relatedPivotKey 值。

$detach = array_diff($current, array_keys(
    $records = $this->formatRecordsList($this->parseIds($ids))
));

protected function formatRecordsList(array $records)
{
    return collect($records)->mapWithKeys(function ($attributes, $id) {
        if (! is_array($attributes)) {
            list($id, $attributes) = [$attributes, []];
        }

        return [$id => $attributes];
    })->all();
}

這句用於統計出待刪除的中間表記錄的 relatedPivotKey 值。

if ($detaching && count($detach) > 0) {
    $this->detach($detach);

    $changes['detached'] = $this->castKeys($detach);
}

這句進行刪除操作。

$changes = array_merge(
    $changes, $this->attachNew($records, $current, false)
);

protected function attachNew(array $records, array $current, $touch = true)
{
    $changes = ['attached' => [], 'updated' => []];

    foreach ($records as $id => $attributes) {
        if (! in_array($id, $current)) {
            $this->attach($id, $attributes, $touch);

            $changes['attached'][] = $this->castKey($id);
        }

        elseif (count($attributes) > 0 &&
            $this->updateExistingPivot($id, $attributes, $touch)) {
            $changes['updated'][] = $this->castKey($id);
        }
    }

    return $changes;
}

對於需要新增的記錄,直接呼叫方法 attach 即可。對於需要更新的記錄,需要呼叫 updateExistingPivot :

public function updateExistingPivot($id, array $attributes, $touch = true)
{
    if (in_array($this->updatedAt(), $this->pivotColumns)) {
        $attributes = $this->addTimestampsToAttachment($attributes, true);
    }

    $updated = $this->newPivotStatementForId($id)->update($attributes);

    if ($touch) {
        $this->touchIfTouching();
    }

    return $updated;
}

public function newPivotStatementForId($id)
{
    return $this->newPivotQuery()->where($this->relatedPivotKey, $id);
}

這個函式主要呼叫 update 方法。

切換關聯 toggle

多對多關聯也提供了一個 toggle 方法用於「切換」給定 IDs 的附加狀態。如果給定 ID 已附加,就會被移除。同樣的,如果給定 ID 已移除,就會被附加,原始碼:

public function toggle($ids, $touch = true)
{
    $changes = [
        'attached' => [], 'detached' => [],
    ];

    $records = $this->formatRecordsList($this->parseIds($ids));

    $detach = array_values(array_intersect(
        $this->newPivotQuery()->pluck($this->relatedPivotKey)->all(),
        array_keys($records)
    ));

    if (count($detach) > 0) {
        $this->detach($detach, false);

        $changes['detached'] = $this->castKeys($detach);
    }

    $attach = array_diff_key($records, array_flip($detach));

    if (count($attach) > 0) {
        $this->attach($attach, [], false);

        $changes['attached'] = array_keys($attach);
    }

    if ($touch && (count($changes['attached']) ||
                   count($changes['detached']))) {
        $this->touchIfTouching();
    }

    return $changes;
}

toggle 函式先 intersect 被關聯的主鍵,進行 detach 所有已經存在的記錄,再 diff 被關聯的主鍵,對其進行 attach 所有記錄。

findOrNew 方法

findOrNew 函式用於 related 模型的主鍵搜尋與新建:

public function findOrNew($id, $columns = ['*'])
{
    if (is_null($instance = $this->find($id, $columns))) {
        $instance = $this->related->newInstance();
    }

    return $instance;
}

firstOrNew 方法

firstOrNew 函式用於 related 模型的屬性搜尋與新建:

public function firstOrNew(array $attributes)
{
    if (is_null($instance = $this->where($attributes)->first())) {
        $instance = $this->related->newInstance($attributes);
    }

    return $instance;
}

firstOrCreate 方法

firstOrCreate 函式用於 related 模型的屬性搜尋與儲存,attributesrelated 模型的搜尋屬性或儲存屬性,joining 是中間表屬性:

public function firstOrCreate(array $attributes, array $joining = [], $touch = true)
{
    if (is_null($instance = $this->where($attributes)->first())) {
        $instance = $this->create($attributes, $joining, $touch);
    }

    return $instance;
}

updateOrCreate 方法

updateOrCreate 函式用於 related 模型的更新,attributesrelated 模型的搜尋屬性,valuesrelated 模型的更新屬性,joining 是中間表屬性:

public function updateOrCreate(array $attributes, array $values = [], array $joining = [], $touch = true)
{
    if (is_null($instance = $this->where($attributes)->first())) {
        return $this->create($values, $joining, $touch);
    }

    $instance->fill($values);

    $instance->save(['touch' => false]);

    return $instance;
}

相關文章