(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))
- ここの
JsValue
はJsNull
かもしれない
- ここの
- 末尾はない ->
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の更新の入力値のdeadline
はOption[Option[LocalDateTime]]
で表現し、独自でメソッドを定義するしかなくなりました…(不本意)