在 Laravel 中同步一对多关系

Posted

技术标签:

【中文标题】在 Laravel 中同步一对多关系【英文标题】:Synchronizing a one-to-many relationship in Laravel 【发布时间】:2015-01-28 01:08:01 【问题描述】:

如果我有一个多对多关系,则使用其sync 方法更新关系非常容易。

但是我将使用什么来同步一对多关系?

posts:id, namelinks:id, name, post_id

这里,每个Post可以有多个Links。

我想根据输入的链接集合(例如,从我可以添加、删除和修改链接的 CRUD 表单)同步与数据库中特定帖子关联的链接。

应该删除我的输入集合中不存在的数据库中的链接。应该更新数据库和我的输入中存在的链接以反映输入,并且应该将仅存在于我的输入中的链接添加为数据库中的新记录。

总结期望的行为:

inputArray = true / db = false ---CREATE inputArray = false / db = true ---删除 inputArray = true / db = true ----更新

【问题讨论】:

【参考方案1】:

这是受 @alexw 启发的更新答案,适用于 laravel 7+ 也使用复合主键

在您的app/Providers/AppServiceProvider.phpboot 方法中添加此宏

Illuminate\Database\Eloquent\Relations\HasMany::macro( 'sync', function ( $data, $deleting = true ) 
    $changes = [
        'created' => [], 'deleted' => [], 'updated' => [],
    ];

    /**
     * Cast the given keys to integers if they are numeric and string otherwise.
     *
     * @param array $keys
     *
     * @return array
     */
    $castKeys = function ( array $keys ) 
        return (array)array_map( function ( $v ) 
            return is_numeric( $v ) ? (int)$v : (string)$v;
        , $keys );
    ;

    $relatedKeyName = $this->related->getKeyName();

    $getCompositeKey = function ( $row ) use ( $relatedKeyName ) 
        $keys = [];
        foreach ( (array)$relatedKeyName as $k ) 
            $keys[] = data_get( $row, $k );
        
        return join( '|', $keys );
    ;

    // First we need to attach any of the associated models that are not currently
    // in the child entity table. We'll spin through the given IDs, checking to see
    // if they exist in the array of current ones, and if not we will insert.
    $current = $this->newQuery()->get( $relatedKeyName )->map( $getCompositeKey )->toArray();

    // Separate the submitted data into "update" and "new"
    $updateRows = [];
    $newRows = [];
    foreach ( $data as $row ) 
        $key = $getCompositeKey( $row );
        // We determine "updateable" rows as those whose $relatedKeyName (usually 'id') is set, not empty, and
        // match a related row in the database.
        if ( ! empty( $key ) && in_array( $key, $current ) ) 
            $updateRows[$key] = $row;
         else 
            $newRows[] = $row;
        
    

    // Next, we'll determine the rows in the database that aren't in the "update" list.
    // These rows will be scheduled for deletion.  Again, we determine based on the relatedKeyName (typically 'id').
    $updateIds = array_keys( $updateRows );

    if ( $deleting ) 
        $deleteIds = [];
        foreach ( $current as $currentId ) 
            if ( ! in_array( $currentId, $updateIds ) ) 
                $deleteIds[$currentId] = array_combine( (array)$relatedKeyName, explode( '|', $currentId ) );
            
        
    
        // Delete any non-matching rows
        if ( count( $deleteIds ) > 0 ) 
            /**
             * @var \Illuminate\Database\Query\Builder $q
             */
            $q = $this->newQuery();
            $q->where(function ($q) use ( $relatedKeyName, $deleteIds) 
                foreach ( $deleteIds as $row ) 
                    $q->where( function ( $q ) use ( $relatedKeyName, $row ) 
                        foreach ( (array)$relatedKeyName as $key ) 
                            $q->where( $key, $row[$key] );
                        
                    , null, null, 'or' );
                
            );
            $q->delete();
    
            $changes['deleted'] = $castKeys( array_keys( $deleteIds ) );
        
    

    // Update the updatable rows
    foreach ( $updateRows as $id => $row ) 
        $q = $this->getRelated();
        foreach ( (array)$relatedKeyName as $key ) 
            $q->where( $key, $row[$key] );
        
        $q->update( $row );
    

    $changes['updated'] = $castKeys( $updateIds );

    // Insert the new rows
    $newIds = [];
    foreach ( $newRows as $row ) 
        $newModel = $this->create( $row );
        $newIds[] = $getCompositeKey( $newModel );
    

    $changes['created'] = $castKeys( $newIds );

    return $changes;
 );

复合主键模型示例

class PermissionAdmin extends Model 
    public $guarded = [];

    public $primaryKey = ['user_id', 'permission_id', 'user_type'];

    public $incrementing = false;

    public $timestamps = false;

然后你就可以使用 sync 方法,就像你通常使用它与 belongsToMany 关系一样

$user->roles()->sync([
    [
        'role_id' => 1
        'user_id' => 12
        'user_type' => 'admin'
    ],
    [
        'role_id' => 2
        'user_id' => 12
        'user_type' => 'admin'
    ]
]);

【讨论】:

【参考方案2】:

您可以使用UPSERT 插入或更新重复键,也可以使用关系。

这意味着您可以将旧数据与新数据进行比较,并使用包含要更新的数据的数组以及要插入到同一查询中的数据。

您也可以删除其他不需要的 id。

这里是一个例子:

    $toSave = [
        [
            'id'=>57,
            'link'=>'...',
            'input'=>'...',
        ],[
            'id'=>58,
            'link'=>'...',
            'input'=>'...',
        ],[
            'id'=>null,
            'link'=>'...',
            'input'=>'...',
        ],
    ];

    // Id of models you wish to keep
    // Keep existing that dont need update
    // And existing that will be updated
    // The query will remove the rest from the related Post
    $toKeep = [56,57,58];


    // We skip id 56 cause its equal to existing
    // We will insert or update the rest

    // Elements in $toSave without Id will be created into the relationship

    $this->$relation()->whereNotIn('id',$toKeep)->delete();

    $this->$relation()->upsert(
        $toSave,            // Data to be created or updated
        ['id'],             // Unique Id Column Key
        ['link','input']    // Columns to be updated in case of duplicate key, insert otherwise
    );

这将创建下一个查询:

delete from
  `links`
where
  `links`.`post_id` = 247
  and `links`.`post_id` is not null
  and `id` not in (56, 57, 58)

还有:

insert into
  `links` (`id`, `link`, `input`)
values
  (57, '...', '...'),
  (58, '...', '...'),
  (null, '...', '...')
  on duplicate key update
  `link` = values(`link`),
  `input` = values(`input`)

这就是您可以在 2 个查询中更新关系的所有元素的方法。例如,如果您有 1,000 个帖子,并且您想要更新所有帖子的所有链接。

【讨论】:

【参考方案3】:

另一个手动同步过程:

添加模型

class Post extends Model

    protected $fillable = ["name"];

    function links()
    
        return $this->hasMany("App\Link");
    


class Link extends Model

    protected $fillable = ["name", "post_id"];

    function post()
    
        return $this->belongsTo("App\Post");
    


class PostLink extends Model

    protected $fillable = ["post_id", "link_id"];

    function post()
    
        return $this->belongsTo("App\Post");
    

    function link()
    
        return $this->belongsTo("App\Link");
    

我们来了

// list ids from request
$linkIds = $request->input("link");
if (!empty($linkIds))

    // delete removed id from list in database
    PostLink::where('post_id','=', $post->id)->whereNotIn('post_id', $linkIds)->delete();
    // list remain id in database
    $postLinkIds = $post->links()->pluck('post_id')->toArray();
    // remove ids that already on db
    $linkIds = array_diff($linkIds, $postLinkIds);
    // check if still have id that must be save
    if (!empty($linkIds))
    
        foreach ($linkIds as $id)
        
            // save id to post
            $post->links()->create(['post_id' => $id]);
        
    

【讨论】:

【参考方案4】:

删除和读取相关实体的问题在于,它会破坏您可能对这些子实体拥有的任何外键约束。

更好的解决方案是修改 Laravel 的 HasMany 关系以包含 sync 方法:

<?php

namespace App\Model\Relations;

use Illuminate\Database\Eloquent\Relations\HasMany;

/**
 * @link https://github.com/laravel/framework/blob/5.4/src/Illuminate/Database/Eloquent/Relations/HasMany.php
 */
class HasManySyncable extends HasMany

    public function sync($data, $deleting = true)
    
        $changes = [
            'created' => [], 'deleted' => [], 'updated' => [],
        ];

        $relatedKeyName = $this->related->getKeyName();

        // First we need to attach any of the associated models that are not currently
        // in the child entity table. We'll spin through the given IDs, checking to see
        // if they exist in the array of current ones, and if not we will insert.
        $current = $this->newQuery()->pluck(
            $relatedKeyName
        )->all();
    
        // Separate the submitted data into "update" and "new"
        $updateRows = [];
        $newRows = [];
        foreach ($data as $row) 
            // We determine "updateable" rows as those whose $relatedKeyName (usually 'id') is set, not empty, and
            // match a related row in the database.
            if (isset($row[$relatedKeyName]) && !empty($row[$relatedKeyName]) && in_array($row[$relatedKeyName], $current)) 
                $id = $row[$relatedKeyName];
                $updateRows[$id] = $row;
             else 
                $newRows[] = $row;
            
        

        // Next, we'll determine the rows in the database that aren't in the "update" list.
        // These rows will be scheduled for deletion.  Again, we determine based on the relatedKeyName (typically 'id').
        $updateIds = array_keys($updateRows);
        $deleteIds = [];
        foreach ($current as $currentId) 
            if (!in_array($currentId, $updateIds)) 
                $deleteIds[] = $currentId;
            
        

        // Delete any non-matching rows
        if ($deleting && count($deleteIds) > 0) 
            $this->getRelated()->destroy($deleteIds);    
        

        $changes['deleted'] = $this->castKeys($deleteIds);

        // Update the updatable rows
        foreach ($updateRows as $id => $row) 
            $this->getRelated()->where($relatedKeyName, $id)
                 ->update($row);
        
        
        $changes['updated'] = $this->castKeys($updateIds);

        // Insert the new rows
        $newIds = [];
        foreach ($newRows as $row) 
            $newModel = $this->create($row);
            $newIds[] = $newModel->$relatedKeyName;
        

        $changes['created'] = $this->castKeys($newIds);

        return $changes;
    


    /**
     * Cast the given keys to integers if they are numeric and string otherwise.
     *
     * @param  array  $keys
     * @return array
     */
    protected function castKeys(array $keys)
    
        return (array) array_map(function ($v) 
            return $this->castKey($v);
        , $keys);
    
    
    /**
     * Cast the given key to an integer if it is numeric.
     *
     * @param  mixed  $key
     * @return mixed
     */
    protected function castKey($key)
    
        return is_numeric($key) ? (int) $key : (string) $key;
    

您可以覆盖 Eloquent 的 Model 类以使用 HasManySyncable 而不是标准的 HasMany 关系:

<?php

namespace App\Model;

use App\Model\Relations\HasManySyncable;
use Illuminate\Database\Eloquent\Model;

abstract class MyBaseModel extends Model

    /**
     * Overrides the default Eloquent hasMany relationship to return a HasManySyncable.
     *
     * @inheritDoc
     * @return \App\Model\Relations\HasManySyncable
     */
    public function hasMany($related, $foreignKey = null, $localKey = null)
    
        $instance = $this->newRelatedInstance($related);

        $foreignKey = $foreignKey ?: $this->getForeignKey();

        $localKey = $localKey ?: $this->getKeyName();

        return new HasManySyncable(
            $instance->newQuery(), $this, $instance->getTable().'.'.$foreignKey, $localKey
        );
    

假设您的 Post 模型扩展了 MyBaseModel 并具有 links() hasMany 关系,您可以执行以下操作:

$post->links()->sync([
    [
        'id' => 21,
        'name' => "LinkedIn profile"
    ],
    [
        'id' => null,
        'label' => "Personal website"
    ]
]);

将更新此多维数组中具有与子实体表 (links) 匹配的 id 的任何记录。表中不存在于该数组中的记录将被删除。数组中不存在于表中的记录(具有不匹配的id,或为空的id)将被视为“新”记录,并将被插入到数据库中。

【讨论】:

会不会影响laravel在后续操作中默认的has-many关系? 这看起来不错!但由于这是一个旧答案,我想知道它在 Laravel 的新版本中是否仍然可行。测试和工作。我将在我的项目中实现这一点。 @Ashish 不,它不会影响 laravel 的默认 has-many 关系操作,因为您只是向 laravel 的 HasMany 类添加一个名为 sync 的新函数,并且 不改变默认laravel的代码/行为。 @Pratik149 我知道。正如我两年前提出的这个问题。无论如何,谢谢。 @Ashish 哈哈酷,我没想到你会回复。实际上,对于将来会参考此答案并且与您有同样疑问的人,我实际上放弃了该评论,因此至少他们不会无人回答。【参考方案5】:

我喜欢这样做,它针对最少的查询和最少的更新进行了优化

首先,将要同步的链接 ID 放入数组中:$linkIds,并将帖子模型放入其自己的变量中:$post

Link::where('post_id','=',$post->id)->whereNotIn('id',$linkIds)//only remove unmatching
    ->update(['post_id'=>null]);
if($linkIds)//If links are empty the second query is useless
    Link::whereRaw('(post_id is null OR post_id<>'.$post->id.')')//Don't update already matching, I am using Raw to avoid a nested or, you can use nested OR
        ->whereIn('id',$linkIds)->update(['post_id'=>$post->id]);

【讨论】:

请记住,像这样的批量方法不会更新时间戳或触发模型事件。【参考方案6】:

不幸的是,一对多关系没有sync 方法。自己做很简单。至少如果您没有任何引用 links 的外键。因为这样您就可以简单地删除行并再次将它们全部插入。

$links = array(
    new Link(),
    new Link()
);

$post->links()->delete();
$post->links()->saveMany($links);

如果您真的需要更新现有的(无论出于何种原因),您需要完全按照您在问题中描述的方式进行操作。

【讨论】:

不要这样做,因为将来您可能需要存储数据透视数据......或更糟 - 另一个编码器将存储数据透视数据而不知道同步是假的。 抱歉,我的头在别处了。并不意味着“枢轴”数据。不过,重点仍然存在。 在 AUTOINCREMENT 的情况下,它不会更快地耗尽主键容量吗? 如果您只有两个相关模型,则很有用。不幸的是,就我而言,我有 3 个或更多模型取决于记录的 ID - 所以我不能删除它并重新创建。

以上是关于在 Laravel 中同步一对多关系的主要内容,如果未能解决你的问题,请参考以下文章

如何在 Laravel 中从多对多关系的一对多关系中获取项目?

一对多和一对多的关系 laravel

Laravel:belongsTo()关系假设一对多关系而不是一对一

无法在 Laravel 4 中检索一对多关系

Laravel 5.8 在数据透视表上保存一对多关系

Laravel 一对多关系结果