LoginSignup
34
17

More than 3 years have passed since last update.

【Laravel】 Model::firstOrCreate() をバルクインサートで実現する

Last updated at Posted at 2019-10-10

元ネタ

深夜の思いつきで laravel/ideas から拾ってきて即席で書いてしまった。

スレ主 findOrCreateMany って書いてるけど個人的には bulkFirstOrCreate() のほうが命名近い気がするのでこれで。

やりたいこと

Model::firstOrCreate()1 SELECT + 1 INSERT によって実現されるが,複数レコードに対してやると無駄が多い。そのため,複数レコードを処理するときにも 1 SELECT + 1 INSERT だけで済むようにしたい。

Laravel の Eloquent Model は公式でバルクインサートをサポートしておらず,あくまでサポートしているのは Eloquent Builder と Query Builder のみであるため,タイムスタンプやオートインクリメント値を自分でセットするなど少々工夫が必要。

実装

完全に Model::firstOrCreate() を再現しようとすると困難なので, ユニーク属性は1つ という制約を設けた上で作成する。第1引数にユニークと見なすキーの名前,第2引数に属性群の配列を渡す。

/**
 * Trait BulkFirstOrCreates
 *
 * @mixin \Illuminate\Database\Eloquent\Model
 */
trait BulkFirstOrCreates
{
    /**
     * @param  string|string[]                          $uniqueKeyName
     * @param  array                                    $attributesArray
     * @return \Illuminate\Database\Eloquent\Collection
     */
    public static function bulkFirstOrCreate(string $uniqueKeyName, array $attributesArray)
    {
        $instance = new static();

        // Retrieve actually existing models
        $existingModels = $instance
            ->newQuery()
            ->whereIn($uniqueKeyName, array_column($attributesArray, $uniqueKeyName))
            ->get();

        // Mix timestamp attributes into $attributesArray entries
        if ($instance->usesTimestamps()) {
            $instance->updateTimestamps();
            $attributesArray = collect($attributesArray)
                ->map(function (array $attributes) use ($instance) {
                    return $attributes + $instance->getAttributes();
                })
                ->all();
        }

        // Create new models only from non-existent values
        $instance->newQueryWithoutRelationships()->insert(
            $nonExistentAttributesArray = collect($attributesArray)
                ->whereNotIn($uniqueKeyName, $existingModels->pluck($uniqueKeyName))
                ->all()
        );

        // Retrieve last insert ID
        $lastInsertId = (int)$instance->getConnection()->getPdo()->lastInsertId();

        // Simulate model hydration without running SELECT query
        $createdModels = $instance
            ->newModelQuery()
            ->hydrate($nonExistentAttributesArray)
            ->each(function (self $model) use (&$lastInsertId) {
                // Assign auto-increment value
                if ($model->getIncrementing()) {
                    $model->{$model->getKeyName()} = $lastInsertId++;
                    $model->syncOriginal();
                }

                // Fire "eloquent.created" event
                $model->wasRecentlyCreated = true;
                $model->fireModelEvent('created', false);
            });

        // Sort in the $valuesArray order and return as a Collection
        return $instance
            ->newCollection(array_column($attributesArray, null, $uniqueKeyName))
            ->replace($existingModels->keyBy($uniqueKeyName))
            ->replace($createdModels->keyBy($uniqueKeyName))
            ->values();
    }
}

firstOrNew() updateOrCreate() とかも含めて汎用化できそうだったら今後ライブラリ化するかも…
と思ったが, firstOrNew() なんて作っても意味ないし, updateOrCreate() は MySQL 固有文法の ELT FIELD が出てきて作りづらいのでパスかな…

使用例

class PostCode extends Model
{
    use BulkFirstOrCreates;
}
$codes = PostCode::bulkFirstOrCreate('code', [
    ['code' => '2001'],
    ['code' => '2002'],
]);
var_dump($codes[0]->id); // int(1)
var_dump($codes[0]->code); // string(4) "2001"
var_dump($codes[0]->wasRecentlyCreated); // bool(true)
var_dump($codes[1]->id); // int(2)
var_dump($codes[1]->code); // string(4) "2002"
var_dump($codes[1]->wasRecentlyCreated); // bool(true)

$codes = PostCode::bulkFirstOrCreate('code', [
    ['code' => '2000'],
    ['code' => '2001'],
    ['code' => '2002'],
    ['code' => '2003'],
]);
var_dump($codes[0]->id); // int(3)
var_dump($codes[0]->code); // string(4) "2000"
var_dump($codes[0]->wasRecentlyCreated); // bool(true)
var_dump($codes[1]->id); // int(1)
var_dump($codes[1]->code); // string(4) "2001"
var_dump($codes[1]->wasRecentlyCreated); // bool(false)
var_dump($codes[2]->id); // int(2)
var_dump($codes[2]->code); // string(4) "2002"
var_dump($codes[2]->wasRecentlyCreated); // bool(false)
var_dump($codes[3]->id); // int(4)
var_dump($codes[3]->code); // string(4) "2003"
var_dump($codes[3]->wasRecentlyCreated); // bool(true)
34
17
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
34
17