LoginSignup
4
0

More than 5 years have passed since last update.

play-jsonのReadsでundefinedとnullを区別する

Last updated at Posted at 2018-11-30
(JsPath \ "prop").readNullable[A]

で生成されるReads[Option[A]]で、JSONに"prop"がない(undefined)な場合と、"prop"というプロパティが存在し値がnullの場合を区別する必要がある場合の解決策です。

経緯は後述します。

結論

JsPathを拡張するimplicitクラスを作り、Reads[Option[Option[A]]]を返すreadOptionalというメソッドを作る。返されるReadsの振る舞いは以下の通り

  • 読み込むJsPathがundefinedの場合 -> JsSuccess(None)
  • 読み込むJsPathの値がnullの場合 -> JsSuccess(Some(None))
  • 読み込むJsPathに値が存在する場合 -> JsSuccess(Some(Some(A)))
  • 読み込むJsPathの末尾より前のプロパティが無い場合 -> JsError("error.path.missing")
    • (JsPath \ "a" \ "b"の場合に、末尾の"b"より前の"a"すら存在しない場合)

定義:

implicit class JsPathOps(self: JsPath) {
  def readOptional[A](implicit reads: Reads[A]): Reads[Option[Option[A]]] = {
    Reads[Option[Option[A]]] { json =>
      self.applyTillLast(json).fold(identity, _.fold(
        _ => JsSuccess(None),
        _ match {
          case JsNull => JsSuccess(Some(None))
          case js => reads.reads(js).repath(self).map(v => Some(Some(v)))
        }
      ))
    }
  }
}

※ JSON系の処理まとめているパッケージがあれば、そこのパッケージオブジェクトに定義するといいと思います。

使い方:

// Readsで生成したい型
case class UpdateTaskInput(
  name: Option[String],
  deadline: Option[Option[LocalDateTime]] // 利用するフィールド。Option[Option[X]]とする。
)

implicit val reads = (
  (JsPath \ "name").readNullable[String] and
  (JsPath \ "deadline").readOptional[LocalDateTime] // ⇦ 使ってる
)(UpdateTaskInput.apply _)

解説

JsPathのapplyTillLastメソッドは、Either[JsError, JsResult[JsValue]]を返します。引数のJsValueに対してJsPathを適用し以下のように値を返します。

  • JsPathの末尾より前のプロパティが無い -> Left(JsError("error.path.missing")
  • JsPathの末尾より前はある -> Right
    • 末尾はない -> Right(JsError("error.path.missing")
    • 末尾も存在する -> Right(JsSuccess(JsValue))
      • ここのJsValueJsNullかもしれない

identityはScala標準の引数をそのまま返す関数です。arg => argです。
readOptionalをちょっと分かりやすくしたバージョンは以下:

def readOptionalVerbose[A](implicit reads: Reads[A]): Reads[Option[Option[A]]] = {
  Reads[Option[Option[A]]] { json: JsValue =>
    self.applyTillLast(json).fold( // Either[JsError, JsResult[JsValue]]をfold
      (e: JsError) => e, // 末尾より前が無い場合JsError('error.path.missing`)をそのまま返す(identityと同じ)
      (jsResult: JsResult[JsValue]) => {
        jsResult.fold(
          _ => JsSuccess(None), // 末尾が無い(undefined)場合はJsSuccess(None)
          (jsValue: JsValue) => jsValue match {
            case JsNull => JsSuccess(Some(None)) // 末尾があるがnullの場合はJsSuccess(Some(None))
            case js => reads.reads(js).repath(self).map((v: A) => Some(Some(v)))
            // ↑JsNull以外の値なのでReads[A]を呼び出してSome(Some(A))にする
          }
        )
      }
    )
  }
}

経緯

こういった感じのタスク(Task)モデルがありました。

case class Task(name: String, deadline: Option[LocalDateTime])
                              // ↑任意の期限

このモデル更新のJSON形式のパターンを以下のようにしたい

  • { "name": "新タスク名", "deadline": null } -> 新しいタスク名を設定して期限を無し(None)にする
  • { "name": "新タスク名" } -> 新しいタスク名を設定してdeadlineはそのまま
  • { "deadline": "2018-01-01T00:00:00" } -> 新しい期限を設定してnameはそのまま

そして問題があるReadsは以下

// タスクアップデート用Input
case class UpdateTaskInput(name: Option[String], deadline: Option[LocalDateTime])

implicit val reads = (
  (JsPath \ "name").readNullable[String] and
  (JsPath \ "deadline").readNullable[LocalDateTime]
)(UpdateTaskInput.apply _)

この場合、JsPathのreadNullable[T]は、以下のように値を設定するReads[Option[T]]を生成します。

  • 対象のプロパティがない(undefined)の場合 -> None
  • 対象のプロパティがあり、値がnullの場合 -> None
  • 対象のプロパティにnullではない値が存在する場合 -> Some(Reads[T]の結果)

今回はundefinedとnullを区別したいのでこれだと不十分です。

一応、JsPathにはreadNullableWithDefaultという、デフォルト値をとり、JSONでundefinedならデフォルト値、JSONでnullならNoneとするメソッドがありますが、以下のようなことはしたく無い...

(JsPath \ "deadline").readNullableWithDefault[LocalDateTime](null)
// JSONでundefinedならnull、JSONでnullならNone, 値があったらSome(LocalDateTime)
// うーん。却下w

ということで、Taskの更新の入力値のdeadlineOption[Option[LocalDateTime]]で表現し、独自でメソッドを定義するしかなくなりました…(不本意)

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0