diff --git a/baklava/src/main/scala/pl/iterators/kebs/baklava/params/enums/KebsBaklavaEnumsParams.scala b/baklava/src/main/scala/pl/iterators/kebs/baklava/params/enums/KebsBaklavaEnumsParams.scala index 6910a159..ed6cbcee 100644 --- a/baklava/src/main/scala/pl/iterators/kebs/baklava/params/enums/KebsBaklavaEnumsParams.scala +++ b/baklava/src/main/scala/pl/iterators/kebs/baklava/params/enums/KebsBaklavaEnumsParams.scala @@ -3,112 +3,77 @@ package pl.iterators.kebs.baklava.params.enums import pl.iterators.baklava.{ToHeader, ToPathParam, ToQueryParam} import pl.iterators.kebs.core.enums.{EnumLike, ValueEnumLike, ValueEnumLikeEntry} +import scala.annotation.unused + trait KebsBaklavaEnumsParams { implicit def toQueryParamEnum[T](implicit _enum: EnumLike[T]): ToQueryParam[T] = new ToQueryParam[T] { - override def apply(t: T): Seq[String] = { - val _ = _enum // fix warning - Seq(t.toString) // todo use getName with kebs 2.1.0 - } + override def apply(t: T): Seq[String] = Seq(_enum.getName(t)) } implicit def toPathParamEnum[T](implicit _enum: EnumLike[T]): ToPathParam[T] = new ToPathParam[T] { - override def apply(t: T): String = { - val _ = _enum // fix warning - t.toString // todo use getName with kebs 2.1.0 - } + override def apply(t: T): String = _enum.getName(t) } implicit def toHeaderEnum[T](implicit _enum: EnumLike[T]): ToHeader[T] = new ToHeader[T] { - override def apply(value: T): Option[String] = { - val _ = _enum // fix warning - Some(value.toString) // todo use getName with kebs 2.1.0 - } + override def apply(value: T): Option[String] = Some(_enum.getName(value)) - override def unapply(value: String): Option[T] = { - val _ = _enum // fix warning - _enum.values.find(_.toString == value) - } + override def unapply(value: String): Option[T] = _enum.withNameOption(value) } trait KebsBaklavaEnumsUppercaseParams { implicit def toQueryParamEnum[T](implicit _enum: EnumLike[T]): ToQueryParam[T] = new ToQueryParam[T] { - override def apply(t: T): Seq[String] = { - val _ = _enum // fix warning - Seq(t.toString.toUpperCase) - } + override def apply(t: T): Seq[String] = Seq(_enum.getName(t).toUpperCase) } implicit def toPathParamEnum[T](implicit _enum: EnumLike[T]): ToPathParam[T] = new ToPathParam[T] { - override def apply(t: T): String = { - val _ = _enum // fix warning - t.toString.toUpperCase - } + override def apply(t: T): String = _enum.getName(t).toUpperCase } implicit def toHeaderEnum[T](implicit _enum: EnumLike[T]): ToHeader[T] = new ToHeader[T] { - override def apply(value: T): Option[String] = { - val _ = _enum // fix warning - Some(value.toString.toUpperCase) - } - - override def unapply(value: String): Option[T] = { - val _ = _enum // fix warning - _enum.values.find(_.toString.toUpperCase == value) - } + override def apply(value: T): Option[String] = Some(_enum.getName(value).toUpperCase) + + override def unapply(value: String): Option[T] = _enum.withNameUppercaseOnlyOption(value) } } trait KebsBaklavaEnumsLowercaseParams { implicit def toQueryParamEnum[T](implicit _enum: EnumLike[T]): ToQueryParam[T] = new ToQueryParam[T] { - override def apply(t: T): Seq[String] = { - val _ = _enum // fix warning - Seq(t.toString.toLowerCase) - } + override def apply(t: T): Seq[String] = Seq(_enum.getName(t).toLowerCase) } implicit def toPathParamEnum[T](implicit _enum: EnumLike[T]): ToPathParam[T] = new ToPathParam[T] { - override def apply(t: T): String = { - val _ = _enum // fix warning - t.toString.toLowerCase - } + override def apply(t: T): String = _enum.getName(t).toLowerCase } implicit def toHeaderEnum[T](implicit _enum: EnumLike[T]): ToHeader[T] = new ToHeader[T] { - override def apply(value: T): Option[String] = { - val _ = _enum // fix warning - Some(value.toString.toLowerCase) - } - - override def unapply(value: String): Option[T] = { - val _ = _enum // fix warning - _enum.values.find(_.toString.toLowerCase == value) - } + override def apply(value: T): Option[String] = Some(_enum.getName(value).toLowerCase) + + override def unapply(value: String): Option[T] = _enum.withNameLowercaseOnlyOption(value) } } } trait KebsBaklavaValueEnumsParams { - implicit def toQueryParamValueEnum[T, V <: ValueEnumLikeEntry[T]](implicit - valueEnumLike: ValueEnumLike[T, V], + implicit def toQueryParamValueEnum[V, E <: ValueEnumLikeEntry[V]](implicit + @unused valueEnumLike: ValueEnumLike[V, E], tsm: ToQueryParam[V] - ): ToQueryParam[T] = new ToQueryParam[T] { - override def apply(t: T): Seq[String] = tsm(valueEnumLike.valueOf(t)) + ): ToQueryParam[E] = new ToQueryParam[E] { + override def apply(e: E): Seq[String] = tsm(e.value) } - implicit def toPathParamValueEnum[T, V <: ValueEnumLikeEntry[T]](implicit - valueEnumLike: ValueEnumLike[T, V], + implicit def toPathParamValueEnum[V, E <: ValueEnumLikeEntry[V]](implicit + @unused valueEnumLike: ValueEnumLike[V, E], tsm: ToPathParam[V] - ): ToPathParam[T] = new ToPathParam[T] { - override def apply(t: T): String = tsm(valueEnumLike.valueOf(t)) + ): ToPathParam[E] = new ToPathParam[E] { + override def apply(e: E): String = tsm(e.value) } - implicit def toHeaderValueEnum[T, V <: ValueEnumLikeEntry[T]](implicit - valueEnumLike: ValueEnumLike[T, V], + implicit def toHeaderValueEnum[V, E <: ValueEnumLikeEntry[V]](implicit + valueEnumLike: ValueEnumLike[V, E], tsm: ToHeader[V] - ): ToHeader[T] = new ToHeader[T] { - override def apply(value: T): Option[String] = tsm(valueEnumLike.valueOf(value)) + ): ToHeader[E] = new ToHeader[E] { + override def apply(e: E): Option[String] = tsm(e.value) - override def unapply(value: String): Option[T] = - tsm.unapply(value).flatMap(v => valueEnumLike.getValuesToEntriesMap.toList.find(_._2 == v).map(_._1)) + override def unapply(value: String): Option[E] = tsm.unapply(value).flatMap(valueEnumLike.withValueOption) } } diff --git a/baklava/src/main/scala/pl/iterators/kebs/baklava/schema/KebsBaklavaSchema.scala b/baklava/src/main/scala/pl/iterators/kebs/baklava/schema/KebsBaklavaSchema.scala index c74e3f8a..e8558b16 100644 --- a/baklava/src/main/scala/pl/iterators/kebs/baklava/schema/KebsBaklavaSchema.scala +++ b/baklava/src/main/scala/pl/iterators/kebs/baklava/schema/KebsBaklavaSchema.scala @@ -13,16 +13,17 @@ trait KebsBaklavaSchema { cls: ClassTag[T] ): Schema[T] = { new Schema[T] { - val className: String = cls.runtimeClass.getName // TODO: this won't capture opaque type's name. - val `type`: SchemaType = schema.`type` - val format: Option[String] = schema.format - val properties: Map[String, Schema[?]] = schema.properties - val items: Option[Schema[?]] = schema.items - val `enum`: Option[Set[String]] = schema.`enum` - val required: Boolean = schema.required - val additionalProperties: Boolean = schema.additionalProperties - val default: Option[T] = schema.default.map(valueClassLike.apply) - val description: Option[String] = schema.description + val className: String = cls.runtimeClass.getName // TODO: this won't capture opaque type's name. + val `type`: SchemaType = schema.`type` + val format: Option[String] = schema.format + val properties: Map[String, Schema[?]] = schema.properties + val items: Option[Schema[?]] = schema.items + val `enum`: Option[Set[String]] = schema.`enum` + val required: Boolean = schema.required + val additionalProperties: Boolean = schema.additionalProperties + override val additionalPropertiesSchema: Option[Schema[?]] = schema.additionalPropertiesSchema + val default: Option[T] = schema.default.map(valueClassLike.apply) + val description: Option[String] = schema.description } } @@ -42,13 +43,14 @@ trait KebsBaklavaSchema { case "java.util.UUID" => Some("uuid") case _ => schema.format } - val properties: Map[String, Schema[?]] = schema.properties - val items: Option[Schema[?]] = schema.items - val `enum`: Option[Set[String]] = schema.`enum` - val required: Boolean = schema.required - val additionalProperties: Boolean = schema.additionalProperties - val default: Option[T] = schema.default.map(instanceConverter.decode) - val description: Option[String] = schema.description + val properties: Map[String, Schema[?]] = schema.properties + val items: Option[Schema[?]] = schema.items + val `enum`: Option[Set[String]] = schema.`enum` + val required: Boolean = schema.required + val additionalProperties: Boolean = schema.additionalProperties + override val additionalPropertiesSchema: Option[Schema[?]] = schema.additionalPropertiesSchema + val default: Option[T] = schema.default.map(instanceConverter.decode) + val description: Option[String] = schema.description } } } diff --git a/baklava/src/main/scala/pl/iterators/kebs/baklava/schema/enums/KebsBaklavaEnumsSchema.scala b/baklava/src/main/scala/pl/iterators/kebs/baklava/schema/enums/KebsBaklavaEnumsSchema.scala index 9d74254c..b73a45f9 100644 --- a/baklava/src/main/scala/pl/iterators/kebs/baklava/schema/enums/KebsBaklavaEnumsSchema.scala +++ b/baklava/src/main/scala/pl/iterators/kebs/baklava/schema/enums/KebsBaklavaEnumsSchema.scala @@ -13,7 +13,7 @@ trait KebsBaklavaEnumsSchema { val format: Option[String] = None val properties: Map[String, Schema[?]] = Map.empty val items: Option[Schema[?]] = None - val `enum`: Option[Set[String]] = Some(enumLike.values.map(_.toString).toSet) + val `enum`: Option[Set[String]] = Some(enumLike.names.toSet) val required: Boolean = true val additionalProperties: Boolean = false val default: Option[T] = None @@ -28,7 +28,7 @@ trait KebsBaklavaEnumsSchema { val format: Option[String] = None val properties: Map[String, Schema[?]] = Map.empty val items: Option[Schema[?]] = None - val `enum`: Option[Set[String]] = Some(enumLike.values.map(_.toString.toUpperCase).toSet) + val `enum`: Option[Set[String]] = Some(enumLike.names.map(_.toUpperCase).toSet) val required: Boolean = true val additionalProperties: Boolean = false val default: Option[T] = None @@ -44,7 +44,7 @@ trait KebsBaklavaEnumsSchema { val format: Option[String] = None val properties: Map[String, Schema[?]] = Map.empty val items: Option[Schema[?]] = None - val `enum`: Option[Set[String]] = Some(enumLike.values.map(_.toString.toLowerCase).toSet) + val `enum`: Option[Set[String]] = Some(enumLike.names.map(_.toLowerCase).toSet) val required: Boolean = true val additionalProperties: Boolean = false val default: Option[T] = None @@ -54,21 +54,21 @@ trait KebsBaklavaEnumsSchema { } trait KebsBaklavaValueEnumsSchema { - implicit def valueEnumLikeSchema[T, V <: ValueEnumLikeEntry[T]](implicit - valueEnumLike: ValueEnumLike[T, V], + implicit def valueEnumLikeSchema[V, E <: ValueEnumLikeEntry[V]](implicit + valueEnumLike: ValueEnumLike[V, E], schema: Schema[V], - cls: ClassTag[T] - ): Schema[T] = { - new Schema[T] { + cls: ClassTag[E] + ): Schema[E] = { + new Schema[E] { val className: String = cls.runtimeClass.getName val `type`: SchemaType = schema.`type` val format: Option[String] = schema.format val properties: Map[String, Schema[?]] = schema.properties val items: Option[Schema[?]] = schema.items - val `enum`: Option[Set[String]] = Some(valueEnumLike.values.map(_.toString).toSet) + val `enum`: Option[Set[String]] = Some(valueEnumLike.values.map(_.value.toString).toSet) val required: Boolean = schema.required val additionalProperties: Boolean = schema.additionalProperties - val default: Option[T] = None + val default: Option[E] = None val description: Option[String] = schema.description } } diff --git a/baklava/src/test/scala/pl/iterators/kebs/baklava/BaklavaEnumsTests.scala b/baklava/src/test/scala/pl/iterators/kebs/baklava/BaklavaEnumsTests.scala new file mode 100644 index 00000000..66980e89 --- /dev/null +++ b/baklava/src/test/scala/pl/iterators/kebs/baklava/BaklavaEnumsTests.scala @@ -0,0 +1,106 @@ +package pl.iterators.kebs.baklava + +import enumeratum.values.{IntEnum, IntEnumEntry} +import enumeratum.{Enum, EnumEntry} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.iterators.baklava._ +import pl.iterators.kebs.core.enums.ValueEnumLikeEntry +import pl.iterators.kebs.enumeratum.{KebsEnumeratum, KebsValueEnumeratum} + +object BaklavaEnumsTests { + sealed trait Status extends EnumEntry + object Status extends Enum[Status] { + case object Active extends Status { override val entryName = "is-active" } + case object Inactive extends Status + val values = findValues + } + + sealed abstract class Level(val value: Int) extends IntEnumEntry with ValueEnumLikeEntry[Int] + object Level extends IntEnum[Level] { + case object Low extends Level(1) + case object High extends Level(10) + val values = findValues + } + + object BaklavaInstances extends BaklavaQueryParams with BaklavaPathParams with BaklavaHeaders +} + +class BaklavaEnumsTests extends AnyFunSuite with Matchers with KebsEnumeratum with KebsValueEnumeratum { + import BaklavaEnumsTests._ + import BaklavaInstances._ + + test("enum schema and params use entryName") { + import pl.iterators.kebs.baklava.params.enums._ + import pl.iterators.kebs.baklava.schema.enums._ + + val schema = implicitly[Schema[Status]] + schema.className shouldBe classOf[Status].getName + schema.`type` shouldBe SchemaType.StringType + schema.format shouldBe None + schema.`enum` shouldBe Some(Set("is-active", "Inactive")) + schema.required shouldBe true + schema.default shouldBe None + implicitly[ToHeader[Status]].unapply("Active") shouldBe None + implicitly[ToQueryParam[Status]].apply(Status.Active) shouldBe Seq("is-active") + implicitly[ToPathParam[Status]].apply(Status.Active) shouldBe "is-active" + implicitly[ToHeader[Status]].apply(Status.Active) shouldBe Some("is-active") + implicitly[ToHeader[Status]].unapply("is-active") shouldBe Some(Status.Active) + } + + test("enum schema and params use entryName - uppercase") { + import pl.iterators.kebs.baklava.params.enums.uppercase._ + import pl.iterators.kebs.baklava.schema.enums.uppercase._ + + implicitly[Schema[Status]].`enum` shouldBe Some(Set("IS-ACTIVE", "INACTIVE")) + implicitly[ToQueryParam[Status]].apply(Status.Active) shouldBe Seq("IS-ACTIVE") + implicitly[ToPathParam[Status]].apply(Status.Active) shouldBe "IS-ACTIVE" + implicitly[ToHeader[Status]].apply(Status.Active) shouldBe Some("IS-ACTIVE") + implicitly[ToHeader[Status]].unapply("IS-ACTIVE") shouldBe Some(Status.Active) + } + + test("enum schema and params use entryName - lowercase") { + import pl.iterators.kebs.baklava.params.enums.lowercase._ + import pl.iterators.kebs.baklava.schema.enums.lowercase._ + + implicitly[Schema[Status]].`enum` shouldBe Some(Set("is-active", "inactive")) + implicitly[ToQueryParam[Status]].apply(Status.Inactive) shouldBe Seq("inactive") + implicitly[ToPathParam[Status]].apply(Status.Inactive) shouldBe "inactive" + implicitly[ToHeader[Status]].apply(Status.Inactive) shouldBe Some("inactive") + implicitly[ToHeader[Status]].unapply("inactive") shouldBe Some(Status.Inactive) + } + + test("value enum schema uses the value type and values") { + import pl.iterators.kebs.baklava.schema.enums._ + + val schema = implicitly[Schema[Level]] + schema.className shouldBe classOf[Level].getName + schema.`type` shouldBe SchemaType.IntegerType + schema.format shouldBe Some("int32") + schema.`enum` shouldBe Some(Set("1", "10")) + schema.default shouldBe None + } + + test("value enum schema keeps the value type description") { + import pl.iterators.kebs.baklava.schema.enums._ + + val schema = valueEnumLikeSchema[Int, Level](implicitly, Schema.intSchema.withDescription("a level"), implicitly) + schema.description shouldBe Some("a level") + } + + test("value enum params use values") { + import pl.iterators.kebs.baklava.params.enums._ + + implicitly[ToQueryParam[Level]].apply(Level.High) shouldBe Seq("10") + implicitly[ToPathParam[Level]].apply(Level.Low) shouldBe "1" + implicitly[ToHeader[Level]].apply(Level.High) shouldBe Some("10") + implicitly[ToHeader[Level]].unapply("10") shouldBe Some(Level.High) + implicitly[ToHeader[Level]].unapply("5") shouldBe None + } + + test("value enum instances don't apply to the value type") { + import pl.iterators.kebs.baklava.schema.enums._ + + implicitly[Schema[Int]].`enum` shouldBe None + } +} diff --git a/baklava/src/test/scala/pl/iterators/kebs/baklava/BaklavaParamsTests.scala b/baklava/src/test/scala/pl/iterators/kebs/baklava/BaklavaParamsTests.scala new file mode 100644 index 00000000..b5911443 --- /dev/null +++ b/baklava/src/test/scala/pl/iterators/kebs/baklava/BaklavaParamsTests.scala @@ -0,0 +1,57 @@ +package pl.iterators.kebs.baklava + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.iterators.baklava._ +import pl.iterators.kebs.baklava.params.KebsBaklavaParams +import pl.iterators.kebs.core.macros.CaseClass1ToValueClass +import pl.iterators.kebs.instances.time.LocalDateString + +import java.time.LocalDate + +object BaklavaParamsTests { + case class UserName(value: String) + case class Age(value: Int) +} + +class BaklavaParamsTests + extends AnyFunSuite + with Matchers + with KebsBaklavaParams + with CaseClass1ToValueClass + with LocalDateString + with BaklavaQueryParams + with BaklavaPathParams + with BaklavaHeaders { + import BaklavaParamsTests._ + + private val date = LocalDate.of(2024, 5, 17) + + test("value class query params use the underlying value") { + implicitly[ToQueryParam[UserName]].apply(UserName("joe")) shouldBe Seq("joe") + implicitly[ToQueryParam[Age]].apply(Age(42)) shouldBe Seq("42") + } + + test("value class path params use the underlying value") { + implicitly[ToPathParam[UserName]].apply(UserName("joe")) shouldBe "joe" + implicitly[ToPathParam[Age]].apply(Age(42)) shouldBe "42" + } + + test("value class headers use the underlying value") { + implicitly[ToHeader[UserName]].apply(UserName("joe")) shouldBe Some("joe") + implicitly[ToHeader[UserName]].unapply("joe") shouldBe Some(UserName("joe")) + implicitly[ToHeader[Age]].apply(Age(42)) shouldBe Some("42") + implicitly[ToHeader[Age]].unapply("42") shouldBe Some(Age(42)) + implicitly[ToHeader[Age]].unapply("not a number") shouldBe None + } + + test("instance converter query and path params use the encoded value") { + implicitly[ToQueryParam[LocalDate]].apply(date) shouldBe Seq("2024-05-17") + implicitly[ToPathParam[LocalDate]].apply(date) shouldBe "2024-05-17" + } + + test("instance converter headers use the encoded value") { + implicitly[ToHeader[LocalDate]].apply(date) shouldBe Some("2024-05-17") + implicitly[ToHeader[LocalDate]].unapply("2024-05-17") shouldBe Some(date) + } +} diff --git a/baklava/src/test/scala/pl/iterators/kebs/baklava/BaklavaSchemaTests.scala b/baklava/src/test/scala/pl/iterators/kebs/baklava/BaklavaSchemaTests.scala new file mode 100644 index 00000000..0478d13e --- /dev/null +++ b/baklava/src/test/scala/pl/iterators/kebs/baklava/BaklavaSchemaTests.scala @@ -0,0 +1,83 @@ +package pl.iterators.kebs.baklava + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.iterators.baklava.{Schema, SchemaType} +import pl.iterators.kebs.baklava.schema.KebsBaklavaSchema +import pl.iterators.kebs.core.macros.CaseClass1ToValueClass +import pl.iterators.kebs.instances.net.URIString +import pl.iterators.kebs.instances.time.{DurationString, InstantString, LocalDateString, YearMonthString, ZonedDateTimeString} +import pl.iterators.kebs.instances.util.UUIDString + +import java.net.URI +import java.time.{Duration, Instant, LocalDate, YearMonth, ZonedDateTime} +import java.util.UUID + +object BaklavaSchemaTests { + case class UserName(value: String) + case class Age(value: Int) + case class Tags(value: Map[String, Int]) +} + +class BaklavaSchemaTests + extends AnyFunSuite + with Matchers + with KebsBaklavaSchema + with CaseClass1ToValueClass + with InstantString + with LocalDateString + with ZonedDateTimeString + with YearMonthString + with DurationString + with URIString + with UUIDString { + import BaklavaSchemaTests._ + + test("value class schema follows the underlying type") { + val userName = implicitly[Schema[UserName]] + userName.className shouldBe classOf[UserName].getName + userName.`type` shouldBe SchemaType.StringType + userName.format shouldBe None + userName.`enum` shouldBe None + userName.required shouldBe true + + val age = implicitly[Schema[Age]] + age.`type` shouldBe SchemaType.IntegerType + age.format shouldBe Some("int32") + } + + test("value class schema keeps the underlying default and description") { + val base = Schema.stringSchema.withDefault("joe").withDescription("a user name") + val schema = valueClassLikeSchema[UserName, String](implicitly, base, implicitly) + schema.default shouldBe Some(UserName("joe")) + schema.description shouldBe Some("a user name") + } + + test("value class schema keeps the underlying map value schema") { + val schema = implicitly[Schema[Tags]] + schema.`type` shouldBe SchemaType.ObjectType + schema.additionalProperties shouldBe true + schema.additionalPropertiesSchema.map(_.`type`) shouldBe Some(SchemaType.IntegerType) + } + + test("instance converter schema follows the encoded type, with well-known formats") { + implicitly[Schema[LocalDate]].format shouldBe Some("date") + implicitly[Schema[Instant]].format shouldBe Some("date-time") + implicitly[Schema[ZonedDateTime]].format shouldBe Some("date-time") + implicitly[Schema[URI]].format shouldBe Some("uri") + implicitly[Schema[UUID]].format shouldBe Some("uuid") + + val yearMonth = implicitly[Schema[YearMonth]] + yearMonth.className shouldBe classOf[YearMonth].getName + yearMonth.`type` shouldBe SchemaType.StringType + yearMonth.format shouldBe None + implicitly[Schema[Duration]].format shouldBe None + } + + test("instance converter schema keeps the encoded default and description") { + val base = Schema.stringSchema.withDefault("2024-05").withDescription("a month") + val schema = instanceConverter[YearMonth, String](implicitly, base, implicitly) + schema.default shouldBe Some(YearMonth.of(2024, 5)) + schema.description shouldBe Some("a month") + } +} diff --git a/build.sbt b/build.sbt index 7e4dd5aa..f9aa6e81 100644 --- a/build.sbt +++ b/build.sbt @@ -196,7 +196,8 @@ lazy val commonSettings = baseSettings ++ Seq( ) lazy val baklavaSettings = commonSettings ++ Seq( - libraryDependencies += baklava + libraryDependencies += baklava, + libraryDependencies += (enumeratumInTest.value) ) lazy val slickSettings = commonSettings ++ Seq( diff --git a/circe/src/main/scala/pl/iterators/kebs/circe/enums/KebsCirceEnums.scala b/circe/src/main/scala/pl/iterators/kebs/circe/enums/KebsCirceEnums.scala index d1f4a148..2b42520a 100644 --- a/circe/src/main/scala/pl/iterators/kebs/circe/enums/KebsCirceEnums.scala +++ b/circe/src/main/scala/pl/iterators/kebs/circe/enums/KebsCirceEnums.scala @@ -5,12 +5,12 @@ import pl.iterators.kebs.core.enums.{EnumLike, ValueEnumLike, ValueEnumLikeEntry trait KebsCirceEnums { @inline protected final def enumNameDeserializationError[E](`enum`: EnumLike[E], name: String): String = { - val enumNames = `enum`.getNamesToValuesMap.values.mkString(", ") + val enumNames = `enum`.names.mkString(", ") s"$name should be one of $enumNames" } @inline protected final def enumValueDeserializationError[E](`enum`: EnumLike[E], value: Json): String = { - val enumNames = `enum`.getNamesToValuesMap.values.mkString(", ") + val enumNames = `enum`.names.mkString(", ") s"$value should be a string of value $enumNames" } @@ -29,12 +29,12 @@ trait KebsCirceEnums { def lowercaseEnumDecoder[E](`enum`: EnumLike[E]): Decoder[E] = enumDecoder[E](`enum`, `enum`.withNameLowercaseOnlyOption(_)) def lowercaseEnumEncoder[E](`enum`: EnumLike[E]): Encoder[E] = - enumEncoder[E](`enum`, (e: E) => e.toString.toLowerCase) + enumEncoder[E](`enum`, (e: E) => `enum`.getName(e).toLowerCase) def uppercaseEnumDecoder[E](`enum`: EnumLike[E]): Decoder[E] = enumDecoder[E](`enum`, `enum`.withNameUppercaseOnlyOption(_)) def uppercaseEnumEncoder[E](`enum`: EnumLike[E]): Encoder[E] = - enumEncoder[E](`enum`, (e: E) => e.toString.toUpperCase()) + enumEncoder[E](`enum`, (e: E) => `enum`.getName(e).toUpperCase()) implicit def enumDecoderImpl[E](implicit ev: EnumLike[E]): Decoder[E] = enumDecoder(ev) diff --git a/circe/src/test/scala/pl/iterators/kebs/circe/formats/CirceEnumEntryNameTests.scala b/circe/src/test/scala/pl/iterators/kebs/circe/formats/CirceEnumEntryNameTests.scala new file mode 100644 index 00000000..3974393f --- /dev/null +++ b/circe/src/test/scala/pl/iterators/kebs/circe/formats/CirceEnumEntryNameTests.scala @@ -0,0 +1,43 @@ +package pl.iterators.kebs.circe.formats + +import enumeratum.{Enum, EnumEntry} +import io.circe._ +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.iterators.kebs.circe.enums.{KebsCirceEnums, KebsCirceEnumsLowercase, KebsCirceEnumsUppercase} +import pl.iterators.kebs.enumeratum.KebsEnumeratum + +object CirceEnumEntryNameTests { + sealed trait Status extends EnumEntry + object Status extends Enum[Status] { + case object Active extends Status { override val entryName = "is-active" } + case object Inactive extends Status + val values = findValues + } +} + +class CirceEnumEntryNameTests extends AnyFunSuite with Matchers with KebsEnumeratum { + import CirceEnumEntryNameTests._ + + object KebsProtocol extends KebsCirceEnums + object KebsProtocolUppercase extends KebsCirceEnumsUppercase + object KebsProtocolLowercase extends KebsCirceEnumsLowercase + + test("enum Encoder / Decoder use entryName") { + import KebsProtocol._ + implicitly[Encoder[Status]].apply(Status.Active) shouldBe Json.fromString("is-active") + implicitly[Decoder[Status]].decodeJson(Json.fromString("IS-ACTIVE")) shouldBe Right(Status.Active) + } + + test("enum Encoder / Decoder use entryName - uppercase") { + import KebsProtocolUppercase._ + implicitly[Encoder[Status]].apply(Status.Active) shouldBe Json.fromString("IS-ACTIVE") + implicitly[Decoder[Status]].decodeJson(Json.fromString("IS-ACTIVE")) shouldBe Right(Status.Active) + } + + test("enum Encoder / Decoder use entryName - lowercase") { + import KebsProtocolLowercase._ + implicitly[Encoder[Status]].apply(Status.Active) shouldBe Json.fromString("is-active") + implicitly[Decoder[Status]].decodeJson(Json.fromString("is-active")) shouldBe Right(Status.Active) + } +} diff --git a/core/src/main/scala/pl/iterators/kebs/core/enums/EnumLike.scala b/core/src/main/scala/pl/iterators/kebs/core/enums/EnumLike.scala index b88f5b3d..ae19b6d8 100644 --- a/core/src/main/scala/pl/iterators/kebs/core/enums/EnumLike.scala +++ b/core/src/main/scala/pl/iterators/kebs/core/enums/EnumLike.scala @@ -22,11 +22,25 @@ trait EnumLike[T] { def valueOf(name: String): T = getNamesToValuesMap.getOrElse(name, throw new IllegalArgumentException(s"enum case not found: $name")) def valueOfIgnoreCase(name: String): T = - values.find(_.toString.equalsIgnoreCase(name)).getOrElse(throw new IllegalArgumentException(s"enum case not found: $name")) - def withNameIgnoreCase(name: String): T = values.find(_.toString.equalsIgnoreCase((name))).get - def withNameIgnoreCaseOption(name: String): Option[T] = values.find(_.toString.equalsIgnoreCase((name))) + withNameInsensitiveOption(name).getOrElse(throw new IllegalArgumentException(s"enum case not found: $name")) + def withNameIgnoreCase(name: String): T = + withNameInsensitiveOption(name).getOrElse(throw new NoSuchElementException(buildNotFoundMessage(name))) + def withNameIgnoreCaseOption(name: String): Option[T] = withNameInsensitiveOption(name) def fromOrdinal(ordinal: Int): T = values.lift(ordinal).getOrElse(throw new NoSuchElementException(ordinal.toString)) def indexOf(member: T): Int = values.zipWithIndex.toMap.getOrElse(member, -1) private def buildNotFoundMessage(name: String): String = s"$name should be one of ${names.mkString(", ")}" } + +object EnumLike { + def apply[T](entries: => immutable.Seq[T], name: T => String): EnumLike[T] = new EnumLike[T] { + override lazy val values: immutable.Seq[T] = entries + override lazy val valuesToNamesMap: Map[T, String] = values.map(v => v -> name(v)).toMap + override lazy val getNamesToValuesMap: Map[String, T] = values.map(v => name(v) -> v).toMap + override lazy val names: immutable.Seq[String] = values.map(name) + private lazy val indices: Map[T, Int] = values.zipWithIndex.toMap + override def getName(e: T): String = name(e) + override def withNameOption(name: String): Option[T] = getNamesToValuesMap.get(name) + override def indexOf(member: T): Int = indices.getOrElse(member, -1) + } +} diff --git a/core/src/main/scala/pl/iterators/kebs/core/enums/ValueEnumLike.scala b/core/src/main/scala/pl/iterators/kebs/core/enums/ValueEnumLike.scala index 77240a2f..74194494 100644 --- a/core/src/main/scala/pl/iterators/kebs/core/enums/ValueEnumLike.scala +++ b/core/src/main/scala/pl/iterators/kebs/core/enums/ValueEnumLike.scala @@ -20,7 +20,16 @@ trait ValueEnumLike[ValueType, EntryType <: ValueEnumLikeEntry[ValueType]] { values.zipWithIndex.find { case (entry, _) => member == entry }.map { case (_, index) => index }.getOrElse(-1) } -private[core] object ValueEnumLike { +object ValueEnumLike { + def apply[ValueType, EntryType <: ValueEnumLikeEntry[ValueType]]( + entries: => immutable.Seq[EntryType] + ): ValueEnumLike[ValueType, EntryType] = new ValueEnumLike[ValueType, EntryType] { + override lazy val values: immutable.Seq[EntryType] = entries + override lazy val getValuesToEntriesMap: Map[ValueType, EntryType] = values.map(v => v.value -> v).toMap + override def withValueOption(i: ValueType): Option[EntryType] = getValuesToEntriesMap.get(i) + override def valueOfOption(value: ValueType): Option[EntryType] = getValuesToEntriesMap.get(value) + } + private def valuesToEntriesMap[ValueType, EntryType <: ValueEnumLikeEntry[ValueType]]( `enum`: ValueEnumLike[ValueType, EntryType] ): Map[ValueType, EntryType] = `enum`.values.map(v => v.value -> v).toMap diff --git a/docs/core-concepts.md b/docs/core-concepts.md index 99a96e5a..fdbe0f06 100644 --- a/docs/core-concepts.md +++ b/docs/core-concepts.md @@ -62,6 +62,8 @@ trait EnumLike[T] { Instances are provided for Scala 3 `enum` types (via `kebs-enum`), Scala 2 `scala.Enumeration` (via `kebs-enum`), and Enumeratum enums (via `kebs-enumeratum`). +An entry's name comes from `getName`: `entryName` for Enumeratum enums (including custom `entryName` overrides and casing mixins such as `EnumEntry.Uppercase`), and `toString` for Scala 3 `enum`s and `scala.Enumeration`. All integration modules serialize and look up enums by this name. `values` keeps declaration order. + ## ValueEnumLikeEntry[V] / ValueEnumLike[V, E] For enumerations where each entry maps to a specific value (e.g. an `Int` or `String`) rather than its name. diff --git a/docs/slick.md b/docs/slick.md index 163163cf..3a5c29c0 100644 --- a/docs/slick.md +++ b/docs/slick.md @@ -64,6 +64,8 @@ class ArrayTestTable(tag: Tag) } ``` +`Seq` columns (stored as Postgres arrays, like `List`) are also supported. For enums they work out of the box. For value classes and instance types, mix in `KebsSeqImplicits`. It is opt-in because in Scala 3 these instances confuse type inference of slick-pg array extension methods (such as `@>`) on `List` columns. + ## Postgres hstore support Mix in `HStoreImplicits` alongside `KebsSlickSupport` and the appropriate instance traits: @@ -104,4 +106,4 @@ trait API extends super.API with KebsSlickSupport with KebsEnum with KebsValueEn By default, enums are stored using their entry name. For lowercase or uppercase storage, use the casing variant inner traits instead of the default enum implicits. These are available as inner traits within `KebsSlickSupport` (e.g. `KebsLowercaseEnumImplicits`, `KebsUppercaseEnumImplicits`). -Enum types also work as Postgres array column types (`List[MyEnum]`) and as hstore map keys/values. +Enum types also work as Postgres array column types (`List[MyEnum]`, `Seq[MyEnum]`) and as hstore map keys/values. diff --git a/doobie/src/main/scala/pl/iterators/kebs/doobie/enums/KebsDoobieEnums.scala b/doobie/src/main/scala/pl/iterators/kebs/doobie/enums/KebsDoobieEnums.scala index 7f031bce..3c56bb5d 100644 --- a/doobie/src/main/scala/pl/iterators/kebs/doobie/enums/KebsDoobieEnums.scala +++ b/doobie/src/main/scala/pl/iterators/kebs/doobie/enums/KebsDoobieEnums.scala @@ -6,31 +6,31 @@ import pl.iterators.kebs.core.enums.{EnumLike, ValueEnumLike, ValueEnumLikeEntry import scala.reflect.ClassTag trait KebsDoobieEnums { - implicit def enumMeta[E](implicit e: EnumLike[E], m: Meta[String]): Meta[E] = m.imap(e.withName)(_.toString) + implicit def enumMeta[E](implicit e: EnumLike[E], m: Meta[String]): Meta[E] = m.imap(e.withName)(e.getName) implicit def enumArrayMeta[E](implicit e: EnumLike[E], m: Meta[Array[String]], cte: ClassTag[E]): Meta[Array[E]] = - m.imap(_.map(e.withName))(_.map(_.toString)) + m.imap(_.map(e.withName))(_.map(e.getName)) implicit def enumOptionArrayMeta[E](implicit e: EnumLike[E], m: Meta[Array[Option[String]]], cte: ClassTag[Option[E]] - ): Meta[Array[Option[E]]] = m.imap(_.map(_.map(e.withName)))(_.map(_.map(_.toString))) + ): Meta[Array[Option[E]]] = m.imap(_.map(_.map(e.withName)))(_.map(_.map(e.getName))) trait KebsDoobieEnumsUppercase { implicit def enumUppercaseMeta[E](implicit e: EnumLike[E], m: Meta[String]): Meta[E] = - m.imap(e.withNameUppercaseOnly)(_.toString.toUpperCase) + m.imap(e.withNameUppercaseOnly)(e.getName(_).toUpperCase) implicit def enumUppercaseArrayMeta[E](implicit e: EnumLike[E], m: Meta[Array[String]], cte: ClassTag[E]): Meta[Array[E]] = - m.imap(_.map(e.withNameUppercaseOnly))(_.map(_.toString.toUpperCase)) + m.imap(_.map(e.withNameUppercaseOnly))(_.map(e.getName(_).toUpperCase)) implicit def enumUppercaseOptionArrayMeta[E](implicit e: EnumLike[E], m: Meta[Array[Option[String]]]): Meta[Array[Option[E]]] = - m.imap(_.map(_.map(e.withNameUppercaseOnly)))(_.map(_.map(_.toString.toUpperCase))) + m.imap(_.map(_.map(e.withNameUppercaseOnly)))(_.map(_.map(e.getName(_).toUpperCase))) } trait KebsDoobieEnumsLowercase { implicit def enumLowercaseMeta[E](implicit e: EnumLike[E], m: Meta[String]): Meta[E] = - m.imap(e.withNameLowercaseOnly)(_.toString.toLowerCase) + m.imap(e.withNameLowercaseOnly)(e.getName(_).toLowerCase) implicit def enumLowercaseArrayMeta[E](implicit e: EnumLike[E], m: Meta[Array[String]], cte: ClassTag[E]): Meta[Array[E]] = - m.imap(_.map(e.withNameLowercaseOnly))(_.map(_.toString.toLowerCase)) + m.imap(_.map(e.withNameLowercaseOnly))(_.map(e.getName(_).toLowerCase)) implicit def enumLowercaseOptionArrayMeta[E](implicit e: EnumLike[E], m: Meta[Array[Option[String]]]): Meta[Array[Option[E]]] = - m.imap(_.map(_.map(e.withNameLowercaseOnly)))(_.map(_.map(_.toString.toLowerCase))) + m.imap(_.map(_.map(e.withNameLowercaseOnly)))(_.map(_.map(e.getName(_).toLowerCase))) } } diff --git a/enum/src/main/scala-2/pl/iterators/kebs/enums/KebsEnum.scala b/enum/src/main/scala-2/pl/iterators/kebs/enums/KebsEnum.scala index e147e8e7..0b81ae50 100644 --- a/enum/src/main/scala-2/pl/iterators/kebs/enums/KebsEnum.scala +++ b/enum/src/main/scala-2/pl/iterators/kebs/enums/KebsEnum.scala @@ -16,7 +16,7 @@ class EnumerationEntryMacros(val c: blackbox.Context) { val objectStr = valueType.toString.replaceFirst(".Value$", "") val objectName = c.typecheck(c.parse(s"$objectStr: $objectStr.type")) c.Expr[EnumLike[E]]( - q"new _root_.pl.iterators.kebs.core.enums.EnumLike[$valueType] { override def valuesToNamesMap: Map[$valueType, String] = ($objectName).values.map(v => v -> v.toString).toMap }" + q"_root_.pl.iterators.kebs.core.enums.EnumLike[$valueType](($objectName).values.toList, (v: $valueType) => v.toString)" ) } } diff --git a/enum/src/main/scala-3/pl/iterators/kebs/enums/KebsEnum.scala b/enum/src/main/scala-3/pl/iterators/kebs/enums/KebsEnum.scala index 76c1baf9..8533a7f7 100644 --- a/enum/src/main/scala-3/pl/iterators/kebs/enums/KebsEnum.scala +++ b/enum/src/main/scala-3/pl/iterators/kebs/enums/KebsEnum.scala @@ -9,9 +9,7 @@ import pl.iterators.kebs.core.enums.EnumLike trait KebsEnum { inline implicit def kebsEnumScala[E <: Enum](using m: Mirror.SumOf[E]): EnumLike[E] = { val enumValues = summonCases[m.MirroredElemTypes, E] - new EnumLike[E] { - override def valuesToNamesMap: Map[E, String] = enumValues.map(v => v -> v.toString).toMap - } + EnumLike[E](enumValues, _.toString) } } diff --git a/enum/src/main/scala-3/pl/iterators/kebs/enums/KebsValueEnum.scala b/enum/src/main/scala-3/pl/iterators/kebs/enums/KebsValueEnum.scala index 56ee7a0f..bb65434e 100644 --- a/enum/src/main/scala-3/pl/iterators/kebs/enums/KebsValueEnum.scala +++ b/enum/src/main/scala-3/pl/iterators/kebs/enums/KebsValueEnum.scala @@ -1,6 +1,5 @@ package pl.iterators.kebs.enums -import scala.collection.immutable import scala.compiletime.{constValue, erasedValue, error, summonInline} import scala.deriving.Mirror @@ -11,9 +10,7 @@ trait KebsValueEnum { m: Mirror.SumOf[E] ): ValueEnumLike[V, E] = { val enumValues = summonValueCases[m.MirroredElemTypes, V, E] - new ValueEnumLike[V, E] { - override def values: immutable.Seq[E] = enumValues.toSeq - } + ValueEnumLike[V, E](enumValues) } } diff --git a/enumeratum/src/main/scala-2/pl/iterators/kebs/enumeratum/KebsEnumeratum.scala b/enumeratum/src/main/scala-2/pl/iterators/kebs/enumeratum/KebsEnumeratum.scala index 9886998d..d0337a11 100644 --- a/enumeratum/src/main/scala-2/pl/iterators/kebs/enumeratum/KebsEnumeratum.scala +++ b/enumeratum/src/main/scala-2/pl/iterators/kebs/enumeratum/KebsEnumeratum.scala @@ -19,8 +19,11 @@ class EnumeratumEntryMacros(val c: blackbox.Context) extends MacroUtils { val EnumEntry = weakTypeOf[E] assertEnumEntry(EnumEntry, s"${EnumEntry.typeSymbol} must subclass EnumEntry") + val Companion = companion(EnumEntry) + if (Companion == NoSymbol) c.abort(c.enclosingPosition, s"${EnumEntry.typeSymbol} has no companion object") + c.Expr[EnumLike[E]]( - q"new _root_.pl.iterators.kebs.core.enums.EnumLike[${EnumEntry.typeSymbol}] { override def valuesToNamesMap: Map[${EnumEntry.typeSymbol}, String] = ${companion(EnumEntry)}.values.map(v => v -> v.entryName).toMap }" + q"_root_.pl.iterators.kebs.core.enums.EnumLike[$EnumEntry]($Companion.values.toList, (e: $EnumEntry) => e.entryName)" ) } } diff --git a/enumeratum/src/main/scala-2/pl/iterators/kebs/enumeratum/KebsValueEnumeratum.scala b/enumeratum/src/main/scala-2/pl/iterators/kebs/enumeratum/KebsValueEnumeratum.scala index 3e03f118..42b94d4b 100644 --- a/enumeratum/src/main/scala-2/pl/iterators/kebs/enumeratum/KebsValueEnumeratum.scala +++ b/enumeratum/src/main/scala-2/pl/iterators/kebs/enumeratum/KebsValueEnumeratum.scala @@ -25,12 +25,11 @@ class ValueEnumEntryMacros(val c: blackbox.Context) { val ValueType = weakTypeOf[ValueType] val EnumEntry = weakTypeOf[E] + val Companion = EnumEntry.typeSymbol.companion + if (Companion == NoSymbol) c.abort(c.enclosingPosition, s"${EnumEntry.typeSymbol} has no companion object") + c.Expr[ValueEnumLike[ValueType, E]]( - q""" - new _root_.pl.iterators.kebs.core.enums.ValueEnumLike[${ValueType}, ${EnumEntry}] { - override def values: Seq[$EnumEntry] = ${EnumEntry.typeSymbol.companion}.values.toSeq - } - """ + q"_root_.pl.iterators.kebs.core.enums.ValueEnumLike[$ValueType, $EnumEntry]($Companion.values.toList)" ) } } diff --git a/enumeratum/src/main/scala-3/pl/iterators/kebs/enumeratum/KebsEnumeratum.scala b/enumeratum/src/main/scala-3/pl/iterators/kebs/enumeratum/KebsEnumeratum.scala index 5b6005f1..e3bf36bd 100644 --- a/enumeratum/src/main/scala-3/pl/iterators/kebs/enumeratum/KebsEnumeratum.scala +++ b/enumeratum/src/main/scala-3/pl/iterators/kebs/enumeratum/KebsEnumeratum.scala @@ -9,9 +9,7 @@ import pl.iterators.kebs.core.enums.EnumLike trait KebsEnumeratum { inline implicit def enumLikeFromEnumeratum[E <: EnumEntry](using m: Mirror.SumOf[E]): EnumLike[E] = { val enumValues = summonCases[m.MirroredElemTypes, E] - new EnumLike[E] { - override def valuesToNamesMap: Map[E, String] = enumValues.map(v => v -> v.entryName).toMap - } + EnumLike[E](enumValues, _.entryName) } } diff --git a/enumeratum/src/main/scala-3/pl/iterators/kebs/enumeratum/KebsValueEnumeratum.scala b/enumeratum/src/main/scala-3/pl/iterators/kebs/enumeratum/KebsValueEnumeratum.scala index 35a7a6dd..96c13ca1 100644 --- a/enumeratum/src/main/scala-3/pl/iterators/kebs/enumeratum/KebsValueEnumeratum.scala +++ b/enumeratum/src/main/scala-3/pl/iterators/kebs/enumeratum/KebsValueEnumeratum.scala @@ -1,6 +1,5 @@ package pl.iterators.kebs.enumeratum -import scala.collection.immutable import enumeratum.values._ import scala.compiletime.{constValue, erasedValue, error, summonInline} import scala.deriving._ @@ -12,9 +11,7 @@ trait KebsValueEnumeratum { m: Mirror.SumOf[E] ): ValueEnumLike[V, E] = { val enumValues = summonValueCases[m.MirroredElemTypes, V, E] - new ValueEnumLike[V, E] { - override def values: immutable.Seq[E] = enumValues - } + ValueEnumLike[V, E](enumValues) } } diff --git a/enumeratum/src/test/scala/pl/iterators/kebs/enumeratum/EnumeratumEntryNameTest.scala b/enumeratum/src/test/scala/pl/iterators/kebs/enumeratum/EnumeratumEntryNameTest.scala new file mode 100644 index 00000000..0d713fc6 --- /dev/null +++ b/enumeratum/src/test/scala/pl/iterators/kebs/enumeratum/EnumeratumEntryNameTest.scala @@ -0,0 +1,61 @@ +package pl.iterators.kebs.enumeratum + +import enumeratum._ +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.iterators.kebs.core.enums.EnumLike + +object EnumeratumEntryNameTest { + sealed trait Status extends EnumEntry + object Status extends Enum[Status] { + case object Active extends Status { override val entryName = "is-active" } + case object Inactive extends Status + case object Blocked extends Status + case object Deleted extends Status + case object Pending extends Status + case object Archived extends Status + val values = findValues + } + + sealed trait Kind extends EnumEntry.Uppercase + object Kind extends Enum[Kind] { + case object RideHailing extends Kind + case object Delivery extends Kind + val values = findValues + } +} + +class EnumeratumEntryNameTest extends AnyFunSuite with Matchers with KebsEnumeratum { + import EnumeratumEntryNameTest._ + + private val status = implicitly[EnumLike[Status]] + private val kind = implicitly[EnumLike[Kind]] + + test("EnumLike uses entryName as the name") { + status.getName(Status.Active) shouldBe "is-active" + status.names shouldBe List("is-active", "Inactive", "Blocked", "Deleted", "Pending", "Archived") + kind.getName(Kind.RideHailing) shouldBe "RIDEHAILING" + } + + test("EnumLike looks entries up by entryName") { + status.withName("is-active") shouldBe Status.Active + status.withNameOption("Active") shouldBe None + status.withNameInsensitiveOption("IS-ACTIVE") shouldBe Some(Status.Active) + status.withNameIgnoreCase("Is-Active") shouldBe Status.Active + status.withNameIgnoreCaseOption("active") shouldBe None + status.valueOfIgnoreCase("IS-ACTIVE") shouldBe Status.Active + kind.withNameUppercaseOnly("RIDEHAILING") shouldBe Kind.RideHailing + kind.withNameLowercaseOnlyOption("ridehailing") shouldBe Some(Kind.RideHailing) + an[NoSuchElementException] should be thrownBy status.withNameIgnoreCase("xxx") + } + + test("EnumLike keeps declaration order") { + status.values shouldBe Status.values + status.fromOrdinal(4) shouldBe Status.Pending + status.indexOf(Status.Archived) shouldBe 5 + } + + test("EnumLike is not derived for a single entry type") { + "implicitly[EnumLike[Status.Active.type]]" shouldNot compile + } +} diff --git a/http4s/shared/src/main/scala/pl/iterators/kebs/http4s/KebsHttp4s.scala b/http4s/shared/src/main/scala/pl/iterators/kebs/http4s/KebsHttp4s.scala index d69888c4..d505d957 100644 --- a/http4s/shared/src/main/scala/pl/iterators/kebs/http4s/KebsHttp4s.scala +++ b/http4s/shared/src/main/scala/pl/iterators/kebs/http4s/KebsHttp4s.scala @@ -28,9 +28,7 @@ trait KebsHttp4s { object EnumString { def apply[T](implicit e: EnumLike[T]) = new PathVar[T](str => Try( - e.values - .find(_.toString.toUpperCase == str.toUpperCase) - .getOrElse(throw new IllegalArgumentException(s"enum case not found: $str")) + e.withNameInsensitiveOption(str).getOrElse(throw new IllegalArgumentException(s"enum case not found: $str")) ) ) } @@ -67,7 +65,7 @@ trait KebsHttp4s { ): QueryParamDecoder[T] = qpd.emap(u => Try(rep.decode(u)).toEither.left.map(t => ParseFailure(t.getMessage, t.getMessage))) implicit def enumQueryParamDecoder[E](implicit e: EnumLike[E]): QueryParamDecoder[E] = QueryParamDecoder[String].emap(str => Try( - e.values.find(_.toString.toUpperCase == str.toUpperCase).getOrElse(throw new IllegalArgumentException(s"enum case not found: $str")) + e.withNameInsensitiveOption(str).getOrElse(throw new IllegalArgumentException(s"enum case not found: $str")) ).toEither.left.map(t => ParseFailure(t.getMessage, t.getMessage)) ) } diff --git a/play-json/src/main/scala/pl/iterators/kebs/playjson/enums/KebsPlayJsonEnums.scala b/play-json/src/main/scala/pl/iterators/kebs/playjson/enums/KebsPlayJsonEnums.scala index 6263f9b4..23062813 100644 --- a/play-json/src/main/scala/pl/iterators/kebs/playjson/enums/KebsPlayJsonEnums.scala +++ b/play-json/src/main/scala/pl/iterators/kebs/playjson/enums/KebsPlayJsonEnums.scala @@ -8,7 +8,7 @@ trait KebsPlayJsonEnums { Reads.StringReads.flatMapResult { str => _comap(str) match { case Some(e) => JsSuccess(e) - case None => JsError(s"$str should be one of ${`enum`.getNamesToValuesMap.values.mkString(", ")}") + case None => JsError(s"$str should be one of ${`enum`.names.mkString(", ")}") } } @@ -18,17 +18,17 @@ trait KebsPlayJsonEnums { def enumDecoder[E](`enum`: EnumLike[E]): Reads[E] = enumDecoder[E](`enum`, `enum`.withNameInsensitiveOption(_)) def enumEncoder[E](`enum`: EnumLike[E]): Writes[E] = - enumEncoder[E](`enum`, (e: E) => e.toString) + enumEncoder[E](`enum`, (e: E) => `enum`.getName(e)) def lowercaseEnumDecoder[E](`enum`: EnumLike[E]): Reads[E] = enumDecoder[E](`enum`, `enum`.withNameLowercaseOnlyOption(_)) def lowercaseEnumEncoder[E](`enum`: EnumLike[E]): Writes[E] = - enumEncoder[E](`enum`, (e: E) => e.toString.toLowerCase) + enumEncoder[E](`enum`, (e: E) => `enum`.getName(e).toLowerCase) def uppercaseEnumDecoder[E](`enum`: EnumLike[E]): Reads[E] = enumDecoder[E](`enum`, `enum`.withNameUppercaseOnlyOption(_)) def uppercaseEnumEncoder[E](`enum`: EnumLike[E]): Writes[E] = - enumEncoder[E](`enum`, (e: E) => e.toString.toUpperCase()) + enumEncoder[E](`enum`, (e: E) => `enum`.getName(e).toUpperCase()) implicit def enumDecoderImpl[E](implicit ev: EnumLike[E]): Reads[E] = enumDecoder(ev) diff --git a/play-json/src/test/scala/pl/iterators/kebs/playjson/PlayJsonEnumEntryNameTests.scala b/play-json/src/test/scala/pl/iterators/kebs/playjson/PlayJsonEnumEntryNameTests.scala new file mode 100644 index 00000000..02b117ba --- /dev/null +++ b/play-json/src/test/scala/pl/iterators/kebs/playjson/PlayJsonEnumEntryNameTests.scala @@ -0,0 +1,39 @@ +package pl.iterators.kebs.playjson + +import enumeratum.{Enum, EnumEntry} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.iterators.kebs.enumeratum.KebsEnumeratum +import play.api.libs.json._ + +object PlayJsonEnumEntryNameTests { + sealed trait Status extends EnumEntry + object Status extends Enum[Status] { + case object Active extends Status { override val entryName = "is-active" } + case object Inactive extends Status + val values = findValues + } +} + +class PlayJsonEnumEntryNameTests extends AnyFunSuite with Matchers with KebsEnumeratum { + import PlayJsonEnumEntryNameTests._ + + test("enum Reads / Writes use entryName") { + import pl.iterators.kebs.playjson.enums._ + Json.toJson[Status](Status.Active) shouldBe JsString("is-active") + Json.fromJson[Status](JsString("IS-ACTIVE")) shouldBe JsSuccess(Status.Active) + Json.fromJson[Status](JsString("Active")) shouldBe JsError("Active should be one of is-active, Inactive") + } + + test("enum Reads / Writes use entryName - uppercase") { + import pl.iterators.kebs.playjson.enums.uppercase._ + Json.toJson[Status](Status.Active) shouldBe JsString("IS-ACTIVE") + Json.fromJson[Status](JsString("IS-ACTIVE")) shouldBe JsSuccess(Status.Active) + } + + test("enum Reads / Writes use entryName - lowercase") { + import pl.iterators.kebs.playjson.enums.lowercase._ + Json.toJson[Status](Status.Active) shouldBe JsString("is-active") + Json.fromJson[Status](JsString("is-active")) shouldBe JsSuccess(Status.Active) + } +} diff --git a/slick/src/main/scala/pl/iterators/kebs/slick/KebsSlickSupport.scala b/slick/src/main/scala/pl/iterators/kebs/slick/KebsSlickSupport.scala index ac027866..78d58554 100644 --- a/slick/src/main/scala/pl/iterators/kebs/slick/KebsSlickSupport.scala +++ b/slick/src/main/scala/pl/iterators/kebs/slick/KebsSlickSupport.scala @@ -181,15 +181,27 @@ trait KebsSlickSupport { this: JdbcProfile => } } + // opt-in, as in Scala 3 these break inference of slick-pg array extension methods (e.g. `@>`) on List columns + trait KebsSeqImplicits { + implicit def seqValueColumnType[CC, B](implicit rep1: ValueClassLike[CC, B], bct: BaseColumnType[List[B]]): BaseColumnType[Seq[CC]] = + MappedColumnType.base[Seq[CC], List[B]](_.map(rep1.unapply).toList, _.map(rep1.apply)) + + implicit def seqInstanceConverterColumnType[CC, B](implicit + ico: InstanceConverter[CC, B], + bct: BaseColumnType[List[B]] + ): BaseColumnType[Seq[CC]] = + MappedColumnType.base[Seq[CC], List[B]](_.map(ico.encode).toList, _.map(ico.decode)) + } + protected trait SlickEnum { def enumColumn[E](`enum`: EnumLike[E])(implicit bct: BaseColumnType[String], cls: ClassTag[E]): BaseColumnType[E] = MappedColumnType.base[E, String](`enum`.getName, `enum`.withName) def uppercaseEnumColumn[E](`enum`: EnumLike[E])(implicit bct: BaseColumnType[String], cls: ClassTag[E]): BaseColumnType[E] = - MappedColumnType.base[E, String](_.toString.toUpperCase, `enum`.withNameUppercaseOnly) + MappedColumnType.base[E, String](`enum`.getName(_).toUpperCase, `enum`.withNameUppercaseOnly) def lowercaseEnumColumn[E](`enum`: EnumLike[E])(implicit bct: BaseColumnType[String], cls: ClassTag[E]): BaseColumnType[E] = - MappedColumnType.base[E, String](_.toString.toLowerCase, `enum`.withNameLowercaseOnly) + MappedColumnType.base[E, String](`enum`.getName(_).toLowerCase, `enum`.withNameLowercaseOnly) } protected trait SlickValueEnum { @@ -211,11 +223,14 @@ trait KebsSlickSupport { this: JdbcProfile => valueEnumColumnType(ev) implicit def enumListColumn[E](implicit ev: EnumLike[E], bct: BaseColumnType[List[String]]): BaseColumnType[List[E]] = { - MappedColumnType.base[List[E], List[String]](_.map(_.toString), _.map(ev.withName)) + MappedColumnType.base[List[E], List[String]](_.map(ev.getName), _.map(ev.withName)) } + implicit def enumSeqColumn[E](implicit ev: EnumLike[E], bct: BaseColumnType[List[String]]): BaseColumnType[Seq[E]] = + MappedColumnType.base[Seq[E], List[String]](_.map(ev.getName).toList, _.map(ev.withName)) + implicit def enumToFromStringForHstore[E](implicit ev: EnumLike[E]): ToFromStringForHstore[E] = new ToFromStringForHstore[E] { - override def to(value: E): String = value.toString + override def to(value: E): String = ev.getName(value) override def from(value: String): E = ev.withName(value) } @@ -233,11 +248,14 @@ trait KebsSlickSupport { this: JdbcProfile => lowercaseEnumColumn(ev) implicit def enumListColumn[E](implicit ev: EnumLike[E], bct: BaseColumnType[List[String]]): BaseColumnType[List[E]] = { - MappedColumnType.base[List[E], List[String]](_.map(_.toString.toLowerCase), _.map(ev.withNameLowercaseOnly)) + MappedColumnType.base[List[E], List[String]](_.map(ev.getName(_).toLowerCase), _.map(ev.withNameLowercaseOnly)) } + implicit def enumSeqColumn[E](implicit ev: EnumLike[E], bct: BaseColumnType[List[String]]): BaseColumnType[Seq[E]] = + MappedColumnType.base[Seq[E], List[String]](_.map(ev.getName(_).toLowerCase).toList, _.map(ev.withNameLowercaseOnly)) + implicit def toFromStringForHstoreEnum[E](implicit ev: EnumLike[E]): ToFromStringForHstore[E] = new ToFromStringForHstore[E] { - override def to(value: E): String = value.toString.toLowerCase + override def to(value: E): String = ev.getName(value).toLowerCase override def from(value: String): E = ev.withNameLowercaseOnly(value) } } @@ -247,11 +265,14 @@ trait KebsSlickSupport { this: JdbcProfile => uppercaseEnumColumn(ev) implicit def enumListColumn[E](implicit ev: EnumLike[E], bct: BaseColumnType[List[String]]): BaseColumnType[List[E]] = { - MappedColumnType.base[List[E], List[String]](_.map(_.toString.toUpperCase), _.map(ev.withNameUppercaseOnly)) + MappedColumnType.base[List[E], List[String]](_.map(ev.getName(_).toUpperCase), _.map(ev.withNameUppercaseOnly)) } + implicit def enumSeqColumn[E](implicit ev: EnumLike[E], bct: BaseColumnType[List[String]]): BaseColumnType[Seq[E]] = + MappedColumnType.base[Seq[E], List[String]](_.map(ev.getName(_).toUpperCase).toList, _.map(ev.withNameUppercaseOnly)) + implicit def toFromStringForHstoreEnum[E](implicit ev: EnumLike[E]): ToFromStringForHstore[E] = new ToFromStringForHstore[E] { - override def to(value: E): String = value.toString.toUpperCase + override def to(value: E): String = ev.getName(value).toUpperCase override def from(value: String): E = ev.withNameUppercaseOnly(value) } } diff --git a/slick/src/test/scala/pl/iterators/kebs/slick/arrays/SlickPgArrayColumnTypeTests.scala b/slick/src/test/scala/pl/iterators/kebs/slick/arrays/SlickPgArrayColumnTypeTests.scala index 3e4a68c8..fa8e5f17 100644 --- a/slick/src/test/scala/pl/iterators/kebs/slick/arrays/SlickPgArrayColumnTypeTests.scala +++ b/slick/src/test/scala/pl/iterators/kebs/slick/arrays/SlickPgArrayColumnTypeTests.scala @@ -24,6 +24,18 @@ class SlickPgArrayColumnTypeTests extends AnyFunSuite with Matchers with KebsEnu with KebsEnumImplicits } + object MyPostgresProfileWithSeq extends ExPostgresProfile with PgArraySupport with KebsSlickSupport { + override val api: APIWithSeq = new APIWithSeq {} + trait APIWithSeq + extends ExtPostgresAPI + with ArrayImplicits + with KebsBasicImplicits + with KebsValueClassLikeImplicits + with KebsSeqImplicits + with CaseClass1ToValueClass + with KebsEnumImplicits + } + import MyPostgresProfile.api._ test("List column type") { """ @@ -67,4 +79,18 @@ class SlickPgArrayColumnTypeTests extends AnyFunSuite with Matchers with KebsEnu | } """.stripMargin should compile } + + test("Seq column type with value classes and enums") { + import MyPostgresProfileWithSeq.api._ + """ + | class SeqTestTable(tag: Tag) extends Table[(Long, Seq[Institution], Option[Seq[MarketFinancialProduct]], Seq[AnEnum])](tag, "SeqTest") { + | def id = column[Long]("id", O.AutoInc, O.PrimaryKey) + | def institutions = column[Seq[Institution]]("institutions") + | def mktFinancialProducts = column[Option[Seq[MarketFinancialProduct]]]("mktFinancialProducts") + | def enums = column[Seq[AnEnum]]("enums") + | + | def * = (id, institutions, mktFinancialProducts, enums) + | } + """.stripMargin should compile + } } diff --git a/slick/src/test/scala/pl/iterators/kebs/slick/enums/SlickEnumEntryNameTests.scala b/slick/src/test/scala/pl/iterators/kebs/slick/enums/SlickEnumEntryNameTests.scala new file mode 100644 index 00000000..8173df38 --- /dev/null +++ b/slick/src/test/scala/pl/iterators/kebs/slick/enums/SlickEnumEntryNameTests.scala @@ -0,0 +1,71 @@ +package pl.iterators.kebs.slick.enums + +import com.github.tminglei.slickpg._ +import enumeratum.{Enum, EnumEntry} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.iterators.kebs.enumeratum.KebsEnumeratum +import pl.iterators.kebs.slick.KebsSlickSupport + +object SlickEnumEntryNameTests { + sealed trait Status extends EnumEntry + object Status extends Enum[Status] { + case object Active extends Status { override val entryName = "is-active" } + case object Inactive extends Status + val values = findValues + } +} + +class SlickEnumEntryNameTests extends AnyFunSuite with Matchers with KebsEnumeratum { + import SlickEnumEntryNameTests._ + + trait PostgresDriver extends ExPostgresProfile with PgArraySupport with KebsSlickSupport { + override val api: EnumAPI = new EnumAPI {} + trait EnumAPI extends ExtPostgresAPI with ArrayImplicits with KebsBasicImplicits with KebsEnumImplicits + } + object PostgresDriver extends PostgresDriver + + trait UppercasePostgresDriver extends ExPostgresProfile with PgArraySupport with KebsSlickSupport { + override val api: EnumAPI = new EnumAPI {} + trait EnumAPI extends ExtPostgresAPI with ArrayImplicits with KebsBasicImplicits with KebsUppercaseEnumImplicits + } + object UppercasePostgresDriver extends UppercasePostgresDriver + + private def mapped[T, U](driver: ExPostgresProfile)(columnType: Any) = + columnType.asInstanceOf[driver.MappedJdbcType[T, U]] + + test("enum columns use entryName") { + import PostgresDriver.api._ + val column = mapped[Status, String](PostgresDriver)(implicitly[BaseColumnType[Status]]) + column.map(Status.Active) shouldBe "is-active" + column.comap("is-active") shouldBe Status.Active + } + + test("enum List and Seq columns use entryName") { + import PostgresDriver.api._ + val list = mapped[List[Status], List[String]](PostgresDriver)(implicitly[BaseColumnType[List[Status]]]) + list.map(List(Status.Active, Status.Inactive)) shouldBe List("is-active", "Inactive") + list.comap(List("is-active")) shouldBe List(Status.Active) + + val seq = mapped[Seq[Status], List[String]](PostgresDriver)(implicitly[BaseColumnType[Seq[Status]]]) + seq.map(Seq(Status.Active)) shouldBe List("is-active") + seq.comap(List("is-active", "Inactive")) shouldBe Seq(Status.Active, Status.Inactive) + } + + test("enum hstore entries use entryName") { + import PostgresDriver.api._ + val hstore = implicitly[PostgresDriver.ToFromStringForHstore[Status]] + hstore.to(Status.Active) shouldBe "is-active" + hstore.from("is-active") shouldBe Status.Active + } + + test("uppercase enum columns use entryName") { + import UppercasePostgresDriver.api._ + val column = mapped[Status, String](UppercasePostgresDriver)(implicitly[BaseColumnType[Status]]) + column.map(Status.Active) shouldBe "IS-ACTIVE" + column.comap("IS-ACTIVE") shouldBe Status.Active + + val seq = mapped[Seq[Status], List[String]](UppercasePostgresDriver)(implicitly[BaseColumnType[Seq[Status]]]) + seq.map(Seq(Status.Active)) shouldBe List("IS-ACTIVE") + } +} diff --git a/spray-json/src/main/scala/pl/iterators/kebs/sprayjson/enums/KebsSprayJsonEnums.scala b/spray-json/src/main/scala/pl/iterators/kebs/sprayjson/enums/KebsSprayJsonEnums.scala index c6664e58..ba0a105f 100644 --- a/spray-json/src/main/scala/pl/iterators/kebs/sprayjson/enums/KebsSprayJsonEnums.scala +++ b/spray-json/src/main/scala/pl/iterators/kebs/sprayjson/enums/KebsSprayJsonEnums.scala @@ -5,12 +5,12 @@ import spray.json.{JsString, JsValue, JsonFormat} trait KebsSprayJsonEnums { @inline protected final def enumNameDeserializationError[E](`enum`: EnumLike[E], name: String) = { - val enumNames = `enum`.getNamesToValuesMap.values.mkString(", ") + val enumNames = `enum`.names.mkString(", ") spray.json.deserializationError(s"$name should be one of $enumNames") } @inline protected final def enumValueDeserializationError[E](`enum`: EnumLike[E], value: JsValue) = { - val enumNames = `enum`.getNamesToValuesMap.values.mkString(", ") + val enumNames = `enum`.names.mkString(", ") spray.json.deserializationError(s"$value should be a string of value $enumNames") } @@ -21,11 +21,11 @@ trait KebsSprayJsonEnums { case _ => enumValueDeserializationError(`enum`, json) } } - def jsonFormat[E](`enum`: EnumLike[E]) = enumJsonFormat[E](`enum`, _.toString, `enum`.withNameInsensitiveOption(_)) + def jsonFormat[E](`enum`: EnumLike[E]) = enumJsonFormat[E](`enum`, `enum`.getName, `enum`.withNameInsensitiveOption(_)) def lowercaseJsonFormat[E](`enum`: EnumLike[E]) = - enumJsonFormat[E](`enum`, _.toString.toLowerCase, `enum`.withNameLowercaseOnlyOption(_)) + enumJsonFormat[E](`enum`, `enum`.getName(_).toLowerCase, `enum`.withNameLowercaseOnlyOption(_)) def uppercaseJsonFormat[E](`enum`: EnumLike[E]) = - enumJsonFormat[E](`enum`, _.toString.toUpperCase, `enum`.withNameUppercaseOnlyOption(_)) + enumJsonFormat[E](`enum`, `enum`.getName(_).toUpperCase, `enum`.withNameUppercaseOnlyOption(_)) implicit def jsonEnumFormat[E](implicit ev: EnumLike[E]): JsonFormat[E] = jsonFormat(ev) diff --git a/spray-json/src/test/scala/pl/iterators/kebs/sprayjson/formats/SprayEnumJsonFormatTests.scala b/spray-json/src/test/scala/pl/iterators/kebs/sprayjson/formats/SprayEnumJsonFormatTests.scala index 68a1463a..84f51d00 100644 --- a/spray-json/src/test/scala/pl/iterators/kebs/sprayjson/formats/SprayEnumJsonFormatTests.scala +++ b/spray-json/src/test/scala/pl/iterators/kebs/sprayjson/formats/SprayEnumJsonFormatTests.scala @@ -22,6 +22,15 @@ class SprayEnumJsonFormatTests extends AnyFunSuite with Matchers with KebsEnumer import Greeting._ + sealed trait Status extends EnumEntry + + object Status extends Enum[Status] { + val values = findValues + + case object Active extends Status { override val entryName = "is-active" } + case object Inactive extends Status + } + object KebsProtocol extends DefaultJsonProtocol with KebsSprayJson with KebsSprayJsonEnums object KebsProtocolUppercase extends DefaultJsonProtocol with KebsSprayJson with KebsSprayJsonEnumsUppercase object KebsProtocolLowercase extends DefaultJsonProtocol with KebsSprayJson with KebsSprayJsonEnumsLowercase @@ -64,4 +73,26 @@ class SprayEnumJsonFormatTests extends AnyFunSuite with Matchers with KebsEnumer jf.write(Hello) shouldBe JsString("HELLO") jf.write(GoodBye) shouldBe JsString("GOODBYE") } + + test("enum JsonFormat uses entryName") { + import KebsProtocol._ + val jf = implicitly[JsonFormat[Status]] + jf.write(Status.Active) shouldBe JsString("is-active") + jf.read(JsString("IS-ACTIVE")) shouldBe Status.Active + the[DeserializationException] thrownBy jf.read(JsString("Active")) should have message "Active should be one of is-active, Inactive" + } + + test("enum JsonFormat uses entryName - lowercase") { + import KebsProtocolLowercase._ + val jf = implicitly[JsonFormat[Status]] + jf.write(Status.Active) shouldBe JsString("is-active") + jf.read(JsString("is-active")) shouldBe Status.Active + } + + test("enum JsonFormat uses entryName - uppercase") { + import KebsProtocolUppercase._ + val jf = implicitly[JsonFormat[Status]] + jf.write(Status.Active) shouldBe JsString("IS-ACTIVE") + jf.read(JsString("IS-ACTIVE")) shouldBe Status.Active + } }