元ネタ
深夜の思いつきで 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)