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

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

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

  • posts:id, name
  • links:id, name, post_id

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

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

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

总结期望的行为:

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

【问题讨论】:

    标签: php laravel eloquent one-to-many crud


    【解决方案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 - 所以我不能删除它并重新创建。
          猜你喜欢
          • 2015-02-14
          • 2018-07-23
          • 1970-01-01
          • 2019-06-01
          • 1970-01-01
          • 2018-06-15
          • 2018-09-27
          • 2017-12-24
          • 2018-04-20
          相关资源
          最近更新 更多