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

## 元ネタ

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

- [[Proposal] findOrCreateMany · Issue #1695 · laravel/ideas](https://github.com/laravel/ideas/issues/1695)

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

## やりたいこと

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

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

## 実装

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

```php
/**
 * 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` が出てきて作りづらいのでパスかな…

## 使用例

```php
class PostCode extends Model
{
    use BulkFirstOrCreates;
}
```

```php
$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)
```