Laravelのupsertは裏で何をやってるのか

upsert()
のようなクエリ系の関数は
Illuminate\Database\Eloquent\Builder
クラスに存在する。
引数を整形してタイムスタンプの設定を追加して
Illuminate\Database\Query\Builder
クラスのupsertを使用している
public function upsert(array $values, $uniqueBy, $update = null)
{
if (empty($values)) {
return 0;
}
if (! is_array(reset($values))) {
$values = [$values];
}
if (is_null($update)) {
$update = array_keys(reset($values));
}
return $this->toBase()->upsert(
$this->addTimestampsToUpsertValues($values),
$uniqueBy,
$this->addUpdatedAtToUpsertColumns($update)
);
}
Builderクラスは2つあるが、Eloquentの方はCollectionの生成まで担当し、Queryの方はクエリ文の生成を担っている。なのでEloquentはQueryの方をラップしている。返り値を読むと前者はCollectionになっているので、違いが分かり易い。

というわけでQueryの方のBuilderクラスのupsert関数は以下。
public function upsert(array $values, $uniqueBy, $update = null)
{
if (empty($values)) {
return 0;
} elseif ($update === []) {
return (int) $this->insert($values);
}
if (! is_array(reset($values))) {
$values = [$values];
} else {
foreach ($values as $key => $value) {
ksort($value);
$values[$key] = $value;
}
}
if (is_null($update)) {
$update = array_keys(reset($values));
}
$this->applyBeforeQueryCallbacks();
$bindings = $this->cleanBindings(array_merge(
Arr::flatten($values, 1),
collect($update)->reject(function ($value, $key) {
return is_int($key);
})->all()
));
return $this->connection->affectingStatement(
$this->grammar->compileUpsert($this, $values, (array) $uniqueBy, $update),
$bindings
);
}
Eloquentと重複する箇所もあるけど、最初に引数の整形が入る。
その後にapplyBeforeQueryCallbacks()
で事前発行するクエリを実行し、バインドを作って、
これをgrammerのcompileUpsertに渡している。
GrammerはIlluminate\Database\Query\Grammars
にあるクラスで、
DB間のクエリ文の相違を吸収し、具体的なクエリ文を作るクラスになる。
ちなみにapplyBeforeQueryCallbacks()に入りうるクエリはソースコードを辿る分にはどうもofMany()
系由来くらいっぽい。

以下はMySQLのGrammerに相当するMySQLGrammer
からの抜粋
public function compileUpsert(Builder $query, array $values, array $uniqueBy, array $update)
{
$sql = $this->compileInsert($query, $values).' on duplicate key update ';
$columns = collect($update)->map(function ($value, $key) {
return is_numeric($key)
? $this->wrap($value).' = values('.$this->wrap($value).')'
: $this->wrap($key).' = '.$this->parameter($value);
})->implode(', ');
return $sql.$columns;
}
on duplicate key update
を用いたinsertをしていることが分かる。
MySQLではPosgresのようなon conflict
がないので、実はupsert関数の第二引数$uniqueBy
はMySQL環境においては入力しても意味ない。コード上でもここまで運んでおいて使ってないのが分かる。
下はPosgres版のGrammerクラスで、on conflict
が使用されていてこちらでは$uniqueBy
がきちんと使われている。
public function compileUpsert(Builder $query, array $values, array $uniqueBy, array $update)
{
$sql = $this->compileInsert($query, $values);
$sql .= ' on conflict ('.$this->columnize($uniqueBy).') do update set ';
$columns = collect($update)->map(function ($value, $key) {
return is_numeric($key)
? $this->wrap($value).' = '.$this->wrapValue('excluded').'.'.$this->wrap($value)
: $this->wrap($key).' = '.$this->parameter($value);
})->implode(', ');
return $sql.$columns;
}