[小ネタ]shapelessでcase class間の変換を行う

shapelessによるcase classの自動変換を試してみました。
2020.07.07

はじめに

scalaでコードを書いていると似ているけど微妙に違いcase classの間でデータの詰め替えを行うケースがよくあります(e.g. インフラレイヤーのDBレコードからドメインオブジェクトへ)。shapeless を使うとそのようなデータの詰め替えを簡単に実装できます。

build.sbt

今回のbuild.sbtは以下の通りです。

scalaVersion := "2.13.2"
libraryDependencies ++= Seq(
  "org.typelevel" %% "cats-core" % "2.1.1",
  "com.chuusai" %% "shapeless" % "2.3.3"
)

概要

ざっくりと書き出すと以下のような方法でcaseクラス間の変換をします。

  1. 変換元クラスのLabelledGenericによって元のインスタンスからフィールド名ラベル付きのHListを生成する
  2. Align, Intersectionによってフィールドの並び替えと共通フィールドの絞り込みを行う
  3. 変換後クラスのLabelledGenericによってHListからインスタンスを生成する

コード

以上を下記のように変換前後の型を表すFrom、Toを型パラメータとしてとるtrait MapToとして定義しコンパニオンオブジェクトでその実装を参照できるようにします。applyinstance は冗長に見えますがinstanceのimplicit パラメータのマクロによる解決のためにこのような実装が必要になります。

package example

import cats.Eq
import cats.implicits._
import shapeless._
import shapeless.ops.hlist

/**
 * Fromのインスタンスから各フィールド値をコピーしてToのインスタンスを生成する。From、Toは以下の条件をみたす必要がある
 * - Toのフィールドがすべて、同じ型、同じ名前でFromにフィールドとして存在する
 * @tparam From コピー元の型
 * @tparam To   コピー先の型
 */
sealed trait MapTo[From, To] {
  def apply(a: From): To
}

object MapTo {

  def apply[From, To](implicit mapTo: MapTo[From, To]): MapTo[From, To] = mapTo

  implicit def instance[From, To, FromRepr <: HList, ToRepr <: HList, Unaligned <: HList](
                                                                                           implicit
                                                                                           from: LabelledGeneric.Aux[From, FromRepr],
                                                                                           to: LabelledGeneric.Aux[To, ToRepr],
                                                                                           inter: hlist.Intersection.Aux[FromRepr, ToRepr, Unaligned],
                                                                                           align: hlist.Align[Unaligned, ToRepr]
                                                                                         ): MapTo[From, To] = new MapTo[From, To] {
    override def apply(a: From): To = to.from(inter(from.to(a)))
  }
}

object MapExample extends App {

  final case class PersonWithPhoneNumber(name: String, age: Int, address: Address, phoneNumber: String)

  final case class Address(pref: String, city: String, street: String)

  final case class Person(name: String, age: Int, address: Address)
  // 順序が違う
  final case class Person2(age: Int, address: Address, name:String)
  // PersonWithPhoneNumberにないフィールド
  final case class Person3(name:String, age: Int, address: Address, color:String)

  implicit val personWithPhoneNumberEq: Eq[PersonWithPhoneNumber] = Eq.fromUniversalEquals
  implicit val personEq: Eq[Person] = Eq.fromUniversalEquals
  implicit val person2Eq: Eq[Person2] = Eq.fromUniversalEquals
  implicit val addressEq: Eq[Address] = Eq.fromUniversalEquals

  //生成元
  val from = PersonWithPhoneNumber("ann", 2, Address("Hokkaido", "Sapporo", "Bird Street"), "090-1234-5678")

  assert(
    MapTo[PersonWithPhoneNumber, Person].apply(from)
      ===
      Person("ann", 2, Address("Hokkaido", "Sapporo", "Bird Street"))
  )

  assert(
    MapTo[PersonWithPhoneNumber, Person2].apply(from)
      ===
      Person2(2, Address("Hokkaido", "Sapporo", "Bird Street"), "ann")
  )

  // コンパイルエラー
  // Error:(66, 8) could not find implicit value for parameter mapTo: example.MapTo[example.MapExample.PersonWithPhoneNumber,example.MapExample.Person3]
  // MapTo[PersonWithPhoneNumber, Person3]

}

HListに対する他の操作

今回紹介したcase class間の変換を含むHListの操作の例がAdvanced Scala with Shapless Courseで紹介されていますのでそちらも合わせてどうぞ。