APIのNULL・未指定・空文字を混同しない設計|JSON・PATCH・DB更新の実務ルール

「APIのNULL・未指定・空文字を混同しない設計|JSON・PATCH・DB更新の実務ルール」の内容を表す技術イラスト

API更新でnull、field未指定、空文字""、false、0を一括して扱うと、変更していない項目が消えることがあります。JSON、PATCH、DB、CSV、Pythonを通して、値の有無と更新操作を分離する設計を整理します。

目次

まず5つの状態を分ける

プロフィール更新を例にすると、似ている状態にも別の意味があります。

状態JSON例代表的な意味
field未指定{}変更しない
null{"note": null}値を解除する、または不明
空文字{"note": ""}文字列は存在するが0文字
false{"enabled": false}無効という真偽値
0{"retry_count": 0}回数がゼロという数値

RFC 8259では、JSONの値としてstring、number、boolean、null、object、arrayを定義しています。一方、fieldが存在しない状態は値ではなく、objectにそのname/valueペアがない状態です。

したがって、次のような判定は危険です。

if not data.get("enabled"):
    # false、0、null、空文字、未指定が同じ分岐へ入る
    ...

存在確認と値の確認は分ける必要があります。

登録と部分更新では必須項目が違う

新規登録では名称などを必須にしますが、部分更新では送られたfieldだけを変更します。

{
  "enabled": false
}

これは「enabledをfalseへ変更し、他は維持する」という契約です。未指定fieldをnullで埋めてはいけません。

処理の流れは次のようになります。

JSONを受信
  ↓
fieldの存在を確認
  ↓
存在するfieldだけ型と値を検証
  ↓
各fieldを keep / set / clear に分類
  ↓
DBへ部分更新
  ↓
更新後のrecordを返す

JSON Schemaで必須とnull許可を分ける

JSON Schema Draft 2020-12では、requiredが必要なpropertyを、typeが値の型を指定します。必須とnull許可は別の制約です。部分更新用では必須fieldを設けず、minPropertiesで1項目以上を要求できます。

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "minProperties": 1,
  "properties": {
    "display_name": {
      "type": "string",
      "minLength": 1
    },
    "note": {
      "type": ["string", "null"]
    },
    "enabled": {
      "type": "boolean"
    }
  },
  "additionalProperties": false
}

noteは文字列またはnull、display_nameは空文字とnullを拒否、enabledはbooleanだけを許可します。文字列"false"を暗黙変換してはいけません。

JSON Merge Patchではnullが削除を表す

PATCHというHTTP methodだけでは、bodyの更新規則は決まりません。どのpatch形式を使うかをContent-TypeとAPI仕様で明示します。

RFC 7396のJSON Merge Patchでは、新しいmemberは追加、既存memberは置換、nullは削除を表します。Content-Typeはapplication/merge-patch+jsonです。

更新前が次のデータだとします。

{
  "display_name": "Pump unit A",
  "note": "Inspection required",
  "enabled": true
}

次のmerge patchを適用します。

{
  "note": null,
  "enabled": false
}

結果は次のようになります。

{
  "display_name": "Pump unit A",
  "enabled": false
}

display_nameは維持され、noteは削除されます。JSON Merge Patchは、明示的なJSON nullを保存したいresourceには適さない場合があります。

独自PATCHで「nullをDBのNULLとして保存する」と決めることもできますが、そのAPI独自の契約です。JSON Merge Patchと同じものとして説明してはいけません。

DBではNULLと空文字を別に保存する

SQLiteでは、次のように制約を定義できます。

CREATE TABLE profiles (
    id           TEXT PRIMARY KEY NOT NULL,
    display_name TEXT NOT NULL CHECK (length(trim(display_name)) > 0),
    note         TEXT,
    enabled      INTEGER NOT NULL CHECK (enabled IN (0, 1)),
    version      INTEGER NOT NULL DEFAULT 1
);
  • display_name:NULLと空白だけの文字列を拒否
  • note:NULLを許可
  • enabled:0または1で保存し、NULLを拒否
  • version:同時更新の衝突検出に利用

NULL判定にはIS NULLまたはIS NOT NULLを使います。SQLiteのNULL処理が示すように、NULLとUNIQUEやDISTINCTの関係には差があるため、一意性をNULLへ依存させない設計が安全です。

Pythonでは未指定をNoneで表さない

Pythonの標準json moduleでは、JSONのnullはNoneへ変換されます。そのため、dict.get()だけでは未指定とnullを区別できません。

専用のsentinelを使うと、3状態を明示できます。

MISSING = object()
ALLOWED_FIELDS = {"display_name", "note", "enabled"}


def apply_profile_patch(current: dict, patch: dict) -> dict:
    if not isinstance(patch, dict):
        raise TypeError("patchはobjectである必要があります")

    unknown = set(patch) - ALLOWED_FIELDS
    if unknown:
        raise ValueError(f"未定義fieldです: {sorted(unknown)}")

    updated = current.copy()

    display_name = patch.get("display_name", MISSING)
    if display_name is not MISSING:
        if not isinstance(display_name, str) or not display_name.strip():
            raise ValueError("display_nameは空でない文字列が必要です")
        updated["display_name"] = display_name.strip()

    note = patch.get("note", MISSING)
    if note is not MISSING:
        if note is not None and not isinstance(note, str):
            raise TypeError("noteは文字列またはnullが必要です")
        updated["note"] = note

    enabled = patch.get("enabled", MISSING)
    if enabled is not MISSING:
        if type(enabled) is not bool:
            raise TypeError("enabledはbooleanが必要です")
        updated["enabled"] = enabled

    return updated

この関数では、field未指定なら維持、note: nullならNoneを設定、note: ""なら空文字を設定します。空文字もNULLと同じ扱いにしたいなら、暗黙に変換するのではなく入力契約へ明記します。

SQLでは更新列をallowlistへ限定し、値をparameter bindingで渡します。受信field名をそのまま連結してはいけません。

CSVでは操作列を追加する

CSVの空cellだけでは、「変更しない」「NULLへする」「空文字を設定する」を区別できません。差分更新用CSVでは、値とは別に操作を持たせます。

id,note_action,note_value,enabled_action,enabled_value
profile-001,keep,,set,false
profile-002,clear,,keep,
profile-003,set,,keep,
  • keep:既存値を維持
  • clear:NULLまたは削除へ変更
  • set:valueの内容を設定。空cellなら空文字

action、文字コード、改行、空cellの意味をデータ辞書へ記載し、一覧CSVと差分更新CSVを分けます。

冪等性と再実行

最終値を指定する更新は再実行してもfieldの結果が変わりませんが、「回数を1増やす」は毎回変化します。

再送がある連携では、次を決めます。

  • 最終値を指定する更新を基本にする
  • request IDやevent IDで同じ要求を識別する
  • 同じIDの処理結果を再利用する
  • 更新前versionを照合し、古い値による上書きを防ぐ
  • 監査用日時やversionが変化する場合は、業務値の冪等性と分けて扱う

「未指定をNULLで埋める」実装は、再送以前に既存値を破壊します。まず更新契約を確定することが先です。

セキュリティと公開情報の分離

部分更新では、想定外fieldをそのままDBへ渡すmass assignmentが危険です。たとえば利用者がrole、owner_id、approvedなどをbodyへ追加できても、更新してはいけません。

  • 更新可能fieldをallowlistで固定する
  • 認可対象のfieldはサーバー側で権限を確認する
  • API key、token、内部メモを公開用objectから分離する
  • エラーログへ機密値をそのまま出さない
  • 未定義fieldを無視するか拒否するかを統一する

additionalProperties: falseは構造検証に役立ちますが、認可の代わりにはなりません。

再利用可能な更新契約にする

fieldごとの意味を機械可読な定義へすると、API、DB、CSV、画面フォームで再利用できます。

field: note
type:
  - string
  - null
create:
  required: true
update:
  missing: keep
  null: clear
  empty_string: set_empty
database:
  nullable: true
security:
  writable_by:
    - owner

この契約からJSON Schema、入力フォーム、Python validator、DB更新処理、テストケースを派生できます。スキーマを変更するときは版を付け、旧clientが送る値の意味を変えないようにします。

まとめ

NULL、未指定、空文字、false、0は、見た目が「空」に近くても別のデータです。

  • field未指定は値ではなく、部分更新では「維持」と定義できる
  • nullを許可することとfieldを必須にすることは別に設計する
  • JSON Merge Patchではnullがmember削除を表す
  • DBのNULL、空文字、0を同一視しない
  • Pythonではsentinelを使って未指定とNoneを分ける
  • CSV差分更新では操作列と値列を分離する
  • falseや0をfalsy判定だけで欠損扱いしない
  • 更新可能fieldと機密fieldを分離する

各状態の意味をインターフェース契約として固定すれば、API、DB、CSV、Python間で、意図しない削除や上書きを防ぎながらデータを再利用できます。

参考情報

参考になったらシェアしてください
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

機械設計・油圧・CAD・Python・AIなど、ものづくりに関わる技術を扱っています。工学知識を整理・構造化し、設計や自動化に再利用できる形へ変えていくことを目指しています。

目次