Open3

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

minminminmin

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になっているので、違いが分かり易い。

minminminmin

というわけで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()系由来くらいっぽい。

minminminmin

以下は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;
    }