Drizzleのupsert(onConflictDoUpdate)はCOALESCEが無いと直前の値をnullで潰す
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
Drizzleのupsert(onConflictDoUpdate)でsetにCOALESCEを書かないと、新データが部分的にnullな時間帯に、直前まで保持していた実測値をnullで上書きしてしまう。
結論
Drizzleの.onConflictDoUpdate()は、setに渡した値をそのまま既存行に上書きします。 新データの取得元が一時的に一部の列だけnullを返す時間帯があると、素朴にset: { pm25, uvIndex, ... }と書いただけでは、直前まで保持していた正常な実測値がnullで潰されます。直し方は、値を保持したい列だけcoalesce(excluded.列, テーブル.列)に変えることです。
症状
サイネージに表示している大気質(PM2.5・光化学オキシダント・UV指数)と暑さ指数(WBGT)は、外部データ(そらまめ君・環境省)を定期取得してPostgreSQLへupsertしています。ある時間帯だけ、その日ずっと表示されていたはずの実測PM2.5や、記録していたはずのWBGTピーク値が、ウィジェット上で「—」に変わる現象がありました。
エラーは出ません。取得ジョブ自体は成功しています。
原因
そらまめ君・環境省のCSVは、HTMLフォールバックに切り替わったり該当測定局のデータが無かったりする時間帯に、値が全てnullで返ってくることがあります。
修正前のair-quality.tsは、upsert時にその新しい値をそのままsetへ渡していました。
.onConflictDoUpdate({
target: [airQualityIndex.areaCode, airQualityIndex.source, airQualityIndex.forecastDate],
set: {
areaName: input.areaName ?? null,
fetchedAt: input.fetchedAt ?? new Date(),
pm25,
pm25Band,
oxidant,
uvIndex,
uvBand,
raw: rawValue,
updatedAt: new Date(),
updatedBy: null,
},
})
areaCode / source / forecastDate(=当日)が競合キーなので、同じ日に何度も再取得するたびにこのonConflictDoUpdateが走ります。取得結果がnullに倒れた回の実行では、pm25やuvIndexにもnullが入ったままsetに渡り、既存行にあった直前の実測値をそのままnullで上書きしていました。heat-alerts.tsのWBGT(wbgtMax / wbgtBand)も同じ構造で同じ事故が起きます。
競合キーにforecastDate(当日)が含まれるため、この上書きは「同日内」に限られます。日をまたげば別行になるので影響は当日のうちに収まりますが、その当日のうちはウィジェットが残りの時間帯すべて「—」のままになります。
直し方
保持したい列だけ、sqlテンプレートでcoalesce(excluded.列, テーブル.列)に変えました。excluded.列が今回のINSERT値(取れなければnull)、テーブル.列が上書き前の既存行の値です。
.onConflictDoUpdate({
target: [airQualityIndex.areaCode, airQualityIndex.source, airQualityIndex.forecastDate],
set: {
areaName: input.areaName ?? null,
fetchedAt: input.fetchedAt ?? new Date(),
pm25: sql`coalesce(excluded.${sql.raw(airQualityIndex.pm25.name)}, ${airQualityIndex.pm25})`,
pm25Band: sql`coalesce(excluded.${sql.raw(airQualityIndex.pm25Band.name)}, ${airQualityIndex.pm25Band})`,
oxidant: sql`coalesce(excluded.${sql.raw(airQualityIndex.oxidant.name)}, ${airQualityIndex.oxidant})`,
uvIndex: sql`coalesce(excluded.${sql.raw(airQualityIndex.uvIndex.name)}, ${airQualityIndex.uvIndex})`,
uvBand: sql`coalesce(excluded.${sql.raw(airQualityIndex.uvBand.name)}, ${airQualityIndex.uvBand})`,
raw: rawValue,
updatedAt: new Date(),
updatedBy: null,
},
})
coalesceは左から順に見て最初のnon-null値を返すので、excluded.pm25(新データ)がnullのときだけ既存のpm25(直前の実測値)が残ります。新データが値を持っていれば、そちらが優先されて正しく更新されます。
heat-alerts.ts側は、全列一律にはしていません。alertLevelは無条件更新のままです。
set: {
areaName: input.areaName ?? null,
fetchedAt: input.fetchedAt ?? new Date(),
alertLevel,
wbgtMax: sql`coalesce(excluded.${sql.raw(heatAlerts.wbgtMax.name)}, ${heatAlerts.wbgtMax})`,
wbgtBand: sql`coalesce(excluded.${sql.raw(heatAlerts.wbgtBand.name)}, ${heatAlerts.wbgtBand})`,
raw: rawValue,
updatedAt: new Date(),
updatedBy: null,
}
alertLevelは夕方にsevereからnoneへ落ちるような、値そのものが変わることが正しい列です。ここまでCOALESCEにすると、本来なら反映すべき「警戒レベルが下がった」という更新まで直前の値に固定されてしまいます。COALESCEで守るべきは「一時的にnullへ倒れうる測定値」だけで、「正当にnullや別の値へ遷移しうる状態」まで一律に保護すると別の不具合を生みます。
schema(列定義)自体は変えていないため、この修正にマイグレーションは不要でした。
再発防止
このパターンは、weather_forecastsの気温列で先に採用されていた対処です。今回はそれを大気質とWBGTに横展開したもので、コード側にも判断の理由をコメントとして残しています。
// ★ 測定値は新値が null なら既存値を保持(weather-forecasts.ts の気温と同じ対処)。
// そらまめが HTML フォールバック / 該当局なしで全 null に倒れる時間帯があり、非 COALESCE の
// 上書きだと同日先行時間帯の実測 PM2.5 を null で潰し、サイネージが残りの一日「—」になる。
外部データソースをupsertで取り込む処理を新しく書くときは、「新しい取得が失敗ではなく成功しつつも部分的にnullを返しうるか」を先に確認し、そうであればsetに渡す前に素朴な代入とCOALESCEのどちらが適切かを列ごとに判断する、という基準がこのコメントの形で残っています。
よくある質問
Q1なぜCOALESCEが無いと直前の値が消えるのですか?
onConflictDoUpdateのsetは、渡した値をそのまま既存行に書き込みます。外部APIやCSVがHTMLフォールバックや該当局なしで一時的に全項目nullを返す時間帯があると、そのnullがそのままsetに渡り、直前まで保持していた実測値を上書きして消してしまいます。
Q2alertLevelだけCOALESCEにしなかったのはなぜですか?
alertLevelは無条件更新のままにしています。夕方にsevereからnoneへ落ちるような正当な状態遷移を許すためで、COALESCEにすると本来更新すべき値まで直前の値に固定されてしまいます。COALESCEにしたのはWBGTの数値とバンドだけです。
Q3この修正にマイグレーションは必要でしたか?
不要でした。COALESCEはupsertのSET句の書き方を変えるだけで、テーブルのスキーマ(列定義)自体は変わっていないためです。
Q4同じ対処は他のテーブルにもありますか?
weather_forecastsの気温列にすでに同じCOALESCEの対処が入っており、そこでの実績を大気質(PM2.5・オキシダント・UV)と暑さ指数(WBGT)のupsertにも横展開しました。
確認した環境
- drizzle-orm ^0.45.2 / PostgreSQL
- 2026-07-13 に fix commit で修正(バグ探索スイープの1件)
この記事の根拠
- TypeScriptファイル 97〜112行目コミット 0da560c
- TypeScriptファイル 97〜116行目コミット 97c43ca
- TypeScriptファイル 87〜100行目コミット 0da560c
- TypeScriptファイル 87〜104行目コミット 97c43ca
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。