Skip to content

Commit

Permalink
Refs #56 Feature/stringvalueenum (#57)
Browse files Browse the repository at this point in the history
* Add basic support for StringEnum

- Remove restraint on ValueEnums to be parameterised only on AnyVal types
- Add classes and tests for ValueEnums based on Strings

* Add Circe integration

* Add integration with Play-Json

* Add Play integration

* Add ReactiveMongo integration

* Add UPickle integration

* Bump versions
  • Loading branch information
lloydmeta authored Aug 4, 2016
1 parent 2481abe commit 45f4126
Show file tree
Hide file tree
Showing 32 changed files with 349 additions and 90 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ object Circe {
/**
* Returns an Encoder for the provided ValueEnum
*/
def encoder[ValueType <: AnyVal: Encoder, EntryType <: ValueEnumEntry[ValueType]](enum: ValueEnum[ValueType, EntryType]): Encoder[EntryType] = {
def encoder[ValueType: Encoder, EntryType <: ValueEnumEntry[ValueType]](enum: ValueEnum[ValueType, EntryType]): Encoder[EntryType] = {
new Encoder[EntryType] {
private val valueEncoder = implicitly[Encoder[ValueType]]
def apply(a: EntryType): Json = valueEncoder.apply(a.value)
Expand All @@ -24,7 +24,7 @@ object Circe {
/**
* Returns a Decoder for the provided ValueEnum
*/
def decoder[ValueType <: AnyVal: Decoder, EntryType <: ValueEnumEntry[ValueType]](enum: ValueEnum[ValueType, EntryType]): Decoder[EntryType] = {
def decoder[ValueType: Decoder, EntryType <: ValueEnumEntry[ValueType]](enum: ValueEnum[ValueType, EntryType]): Decoder[EntryType] = {
new Decoder[EntryType] {
private val valueDecoder = implicitly[Decoder[ValueType]]
def apply(c: HCursor): Result[EntryType] = valueDecoder.apply(c).flatMap { v =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import io.circe.{ Decoder, Encoder }
* Copyright 2016
*/

sealed trait CirceValueEnum[ValueType <: AnyVal, EntryType <: ValueEnumEntry[ValueType]] {
sealed trait CirceValueEnum[ValueType, EntryType <: ValueEnumEntry[ValueType]] {
this: ValueEnum[ValueType, EntryType] =>

/**
Expand Down Expand Up @@ -45,3 +45,11 @@ trait ShortCirceEnum[EntryType <: ShortEnumEntry] extends CirceValueEnum[Short,
implicit val circeEncoder = Circe.encoder(this)
implicit val circeDecoder = Circe.decoder(this)
}

/**
* CirceEnum for StringEnumEntry
*/
trait StringCirceEnum[EntryType <: StringEnumEntry] extends CirceValueEnum[String, EntryType] { this: ValueEnum[String, EntryType] =>
implicit val circeEncoder = Circe.encoder(this)
implicit val circeDecoder = Circe.decoder(this)
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ class CirceValueEnumSpec extends FunSpec with Matchers {
testCirceEnum("LongCirceEnum", CirceContentType)
testCirceEnum("ShortCirceEnum", CirceDrinks)
testCirceEnum("IntCirceEnum", CirceLibraryItem)
testCirceEnum("StringCirceEnum", CirceOperatingSystem)
testCirceEnum("IntCirceEnum with val value members", CirceMovieGenre)

// Test method that generates tests for most primitve-based ValueEnums when given a simple descriptor and the enum
private def testCirceEnum[ValueType <: AnyVal: Encoder: Decoder, EntryType <: ValueEnumEntry[ValueType]: Encoder: Decoder](enumKind: String, enum: ValueEnum[ValueType, EntryType] with CirceValueEnum[ValueType, EntryType]): Unit = {
private def testCirceEnum[ValueType: Encoder: Decoder, EntryType <: ValueEnumEntry[ValueType]: Encoder: Decoder](enumKind: String, enum: ValueEnum[ValueType, EntryType] with CirceValueEnum[ValueType, EntryType]): Unit = {
describe(enumKind) {

describe("to JSON") {
Expand Down Expand Up @@ -93,6 +94,19 @@ case object CirceLibraryItem extends IntEnum[CirceLibraryItem] with IntCirceEnum

}

sealed abstract class CirceOperatingSystem(val value: String) extends StringEnumEntry

case object CirceOperatingSystem extends StringEnum[CirceOperatingSystem] with StringCirceEnum[CirceOperatingSystem] {

case object Linux extends CirceOperatingSystem("linux")
case object OSX extends CirceOperatingSystem("osx")
case object Windows extends CirceOperatingSystem("windows")
case object Android extends CirceOperatingSystem("android")

val values = findValues

}

sealed abstract class CirceMovieGenre extends IntEnumEntry

case object CirceMovieGenre extends IntEnum[CirceMovieGenre] with IntCirceEnum[CirceMovieGenre] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import enumeratum.{ EnumMacros, ValueEnumMacros }

import scala.language.experimental.macros

sealed trait ValueEnum[ValueType <: AnyVal, EntryType <: ValueEnumEntry[ValueType]] {
sealed trait ValueEnum[ValueType, EntryType <: ValueEnumEntry[ValueType]] {

/**
* Map of [[ValueType]] to [[EntryType]] members
Expand Down Expand Up @@ -118,4 +118,33 @@ trait ShortEnum[A <: ShortEnumEntry] extends ValueEnum[Short, A] {
* if you aren't using this method...why are you even bothering with this lib?
*/
final protected def findValues: IndexedSeq[A] = macro ValueEnumMacros.findShortValueEntriesImpl[A]
}
}

object StringEnum {

/**
* Materializes a StringEnum for an in-scope StringEnumEntry
*/
implicit def materialiseStringValueEnum[EntryType <: StringEnumEntry]: StringEnum[EntryType] = macro EnumMacros.materializeEnumImpl[EntryType]

}

/**
* Value enum with [[ShortEnumEntry]] entries
*
* This is similar to [[enumeratum.Enum]], but different in that values must be
* literal values. This restraint allows us to enforce uniqueness at compile time.
*
* Note that uniqueness is only guaranteed if you do not do any runtime string manipulation on values.
*/
trait StringEnum[A <: StringEnumEntry] extends ValueEnum[String, A] {

/**
* Method that returns a Seq of [[A]] objects that the macro was able to find.
*
* You will want to use this in some way to implement your [[values]] method. In fact,
* if you aren't using this method...why are you even bothering with this lib?
*/
final protected def findValues: IndexedSeq[A] = macro ValueEnumMacros.findStringValueEntriesImpl[A]
}

Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ package enumeratum.values
* Copyright 2016
*/

sealed trait ValueEnumEntry[ValueType <: AnyVal] {
sealed trait ValueEnumEntry[ValueType] {

/**
* Value of this entry
Expand Down Expand Up @@ -52,4 +52,15 @@ abstract class LongEnumEntry extends ValueEnumEntry[Long]
/**
* Value Enum Entry parent class for [[Short]] valued entries
*/
abstract class ShortEnumEntry extends ValueEnumEntry[Short]
abstract class ShortEnumEntry extends ValueEnumEntry[Short]

/**
* Value Enum Entry parent class for [[String]] valued entries
*
* This is similar to [[enumeratum.Enum]], but different in that values must be
* literal values. This restraint allows us to enforce uniqueness at compile time.
*
* Note that uniqueness is only guaranteed if you do not do any runtime string manipulation on values.
*/
abstract class StringEnumEntry extends ValueEnumEntry[String]

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package enumeratum.values

/**
* Created by Lloyd on 8/4/16.
*
* Copyright 2016
*/
sealed abstract class OperatingSystem(val value: String) extends StringEnumEntry

case object OperatingSystem extends StringEnum[OperatingSystem] {

val values = findValues

case object Linux extends OperatingSystem("linux")
case object OSX extends OperatingSystem("osx")
case object Windows extends OperatingSystem("windows")
case object Android extends OperatingSystem("android")

}
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,16 @@ trait ValueEnumHelpers { this: FunSpec with Matchers =>
/*
* Generates tests for a given enum and groups the tests inside the given enumKind descriptor
*/
def testEnum[EntryType <: ValueEnumEntry[ValueType], ValueType <: AnyVal: Numeric](enumKind: String, enum: ValueEnum[ValueType, EntryType]): Unit = {
def testNumericEnum[EntryType <: ValueEnumEntry[ValueType], ValueType <: AnyVal: Numeric](enumKind: String, enum: ValueEnum[ValueType, EntryType]): Unit = {
val numeric = implicitly[Numeric[ValueType]]
testEnum(enumKind, enum, Seq(numeric.fromInt(Int.MaxValue)))
}

/*
* Generates tests for a given enum and groups the tests inside the given enumKind descriptor
*/
def testEnum[EntryType <: ValueEnumEntry[ValueType], ValueType](enumKind: String, enum: ValueEnum[ValueType, EntryType], invalidValues: Seq[ValueType]): Unit = {

describe(enumKind) {

describe("withValue") {
Expand All @@ -27,8 +35,10 @@ trait ValueEnumHelpers { this: FunSpec with Matchers =>
}

it("should throw on values that don't map to any entries") {
intercept[NoSuchElementException] {
enum.withValue(numeric.fromInt(Int.MaxValue))
invalidValues.foreach { invalid =>
intercept[NoSuchElementException] {
enum.withValue(invalid)
}
}
}

Expand All @@ -43,7 +53,9 @@ trait ValueEnumHelpers { this: FunSpec with Matchers =>
}

it("should return None when given values that do not map to any entries") {
enum.withValueOpt(numeric.fromInt(Int.MaxValue)) shouldBe None
invalidValues.foreach { invalid =>
enum.withValueOpt(invalid) shouldBe None
}
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ class ValueEnumSpec extends FunSpec with Matchers with ValueEnumHelpers {

}

testEnum("IntEnum", LibraryItem)
testEnum("ShortEnum", Drinks)
testEnum("LongEnum", ContentType)
testEnum("when using val members in the body", MovieGenre)
testNumericEnum("IntEnum", LibraryItem)
testNumericEnum("ShortEnum", Drinks)
testNumericEnum("LongEnum", ContentType)
testEnum("StringEnum", OperatingSystem, Seq("windows-phone"))
testNumericEnum("when using val members in the body", MovieGenre)

describe("finding companion object") {

Expand All @@ -48,6 +49,13 @@ class ValueEnumSpec extends FunSpec with Matchers with ValueEnumHelpers {
companion.values should contain(ContentType.Audio)
}

it("should work for StringEnum") {
def findCompanion[EntryType <: StringEnumEntry: StringEnum](entry: EntryType) = implicitly[StringEnum[EntryType]]
val companion = findCompanion(OperatingSystem.Android: OperatingSystem)
companion shouldBe OperatingSystem
companion.values should contain(OperatingSystem.Windows)
}

}

describe("compilation failures") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ object EnumFormats {
/**
* Returns a Reads for the provided ValueEnum based on the given base Reads for the Enum's value type
*/
def reads[ValueType <: AnyVal, EntryType <: ValueEnumEntry[ValueType]](enum: ValueEnum[ValueType, EntryType])(implicit baseReads: Reads[ValueType]): Reads[EntryType] = new Reads[EntryType] {
def reads[ValueType, EntryType <: ValueEnumEntry[ValueType]](enum: ValueEnum[ValueType, EntryType])(implicit baseReads: Reads[ValueType]): Reads[EntryType] = new Reads[EntryType] {
def reads(json: JsValue): JsResult[EntryType] = baseReads.reads(json).flatMap { s =>
val maybeBound = enum.withValueOpt(s)
maybeBound match {
Expand All @@ -25,14 +25,14 @@ object EnumFormats {
/**
* Returns a Writes for the provided ValueEnum based on the given base Writes for the Enum's value type
*/
def writes[ValueType <: AnyVal, EntryType <: ValueEnumEntry[ValueType]](enum: ValueEnum[ValueType, EntryType])(implicit baseWrites: Writes[ValueType]): Writes[EntryType] = new Writes[EntryType] {
def writes[ValueType, EntryType <: ValueEnumEntry[ValueType]](enum: ValueEnum[ValueType, EntryType])(implicit baseWrites: Writes[ValueType]): Writes[EntryType] = new Writes[EntryType] {
def writes(o: EntryType): JsValue = baseWrites.writes(o.value)
}

/**
* Returns a Formats for the provided ValueEnum based on the given base Reads and Writes for the Enum's value type
*/
def formats[ValueType <: AnyVal, EntryType <: ValueEnumEntry[ValueType]](enum: ValueEnum[ValueType, EntryType])(implicit baseReads: Reads[ValueType], baseWrites: Writes[ValueType]): Format[EntryType] = {
def formats[ValueType, EntryType <: ValueEnumEntry[ValueType]](enum: ValueEnum[ValueType, EntryType])(implicit baseReads: Reads[ValueType], baseWrites: Writes[ValueType]): Format[EntryType] = {
Format(reads(enum), writes(enum))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import play.api.libs.json.Format
* Copyright 2016
*/

trait PlayJsonValueEnum[ValueType <: AnyVal, EntryType <: ValueEnumEntry[ValueType]] { enum: ValueEnum[ValueType, EntryType] =>
trait PlayJsonValueEnum[ValueType, EntryType <: ValueEnumEntry[ValueType]] { enum: ValueEnum[ValueType, EntryType] =>

/**
* Implicit JSON format for the entries of this enum
Expand Down Expand Up @@ -36,4 +36,11 @@ trait LongPlayJsonValueEnum[EntryType <: LongEnumEntry] extends PlayJsonValueEnu
*/
trait ShortPlayJsonValueEnum[EntryType <: ShortEnumEntry] extends PlayJsonValueEnum[Short, EntryType] { this: ShortEnum[EntryType] =>
implicit val format: Format[EntryType] = EnumFormats.formats(this)
}

/**
* Enum implementation for String enum members that contains an implicit Play JSON Format
*/
trait StringPlayJsonValueEnum[EntryType <: StringEnumEntry] extends PlayJsonValueEnum[String, EntryType] { this: StringEnum[EntryType] =>
implicit val format: Format[EntryType] = EnumFormats.formats(this)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package enumeratum.values

import org.scalatest._
import play.api.libs.json.JsString

/**
* Created by Lloyd on 4/13/16.
Expand All @@ -11,26 +12,29 @@ class EnumFormatsSpec extends FunSpec with Matchers with EnumJsonFormatHelpers {

describe(".reads") {

testReads("IntEnum", LibraryItem)
testReads("LongEnum", ContentType)
testReads("ShortEnum", Drinks)
testNumericReads("IntEnum", LibraryItem)
testNumericReads("LongEnum", ContentType)
testNumericReads("ShortEnum", Drinks)
testReads("StringEnum", OperatingSystem, JsString)

}

describe(".writes") {

testWrites("IntEnum", LibraryItem)
testWrites("LongEnum", ContentType)
testWrites("ShortEnum", Drinks)
testNumericWrites("IntEnum", LibraryItem)
testNumericWrites("LongEnum", ContentType)
testNumericWrites("ShortEnum", Drinks)
testWrites("StringEnum", OperatingSystem, JsString)

}

describe(".formats") {

testFormats("IntEnum", LibraryItem)
testFormats("LongEnum", ContentType)
testFormats("ShortEnum", Drinks)
testFormats("PlayJsonValueEnum", JsonDrinks, Some(JsonDrinks.format))
testNumericFormats("IntEnum", LibraryItem)
testNumericFormats("LongEnum", ContentType)
testNumericFormats("ShortEnum", Drinks)
testFormats("StringEnum", OperatingSystem, JsString)
testNumericFormats("PlayJsonValueEnum", JsonDrinks, Some(JsonDrinks.format))

}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,33 @@ import org.scalatest.OptionValues._
*/
trait EnumJsonFormatHelpers { this: FunSpec with Matchers =>

def testWrites[EntryType <: ValueEnumEntry[ValueType], ValueType <: AnyVal: Numeric: Writes](enumKind: String, enum: ValueEnum[ValueType, EntryType], providedWrites: Option[Writes[EntryType]] = None): Unit = {
def testNumericWrites[EntryType <: ValueEnumEntry[ValueType], ValueType <: AnyVal: Numeric: Writes](enumKind: String, enum: ValueEnum[ValueType, EntryType], providedWrites: Option[Writes[EntryType]] = None): Unit = {
val numeric = implicitly[Numeric[ValueType]]
testWrites(enumKind, enum, { i: ValueType => JsNumber(numeric.toInt(i)) }, providedWrites)
}

def testWrites[EntryType <: ValueEnumEntry[ValueType], ValueType: Writes](enumKind: String, enum: ValueEnum[ValueType, EntryType], jsWrapper: ValueType => JsValue, providedWrites: Option[Writes[EntryType]] = None): Unit = {
val writes = providedWrites.getOrElse(EnumFormats.writes(enum))
describe(enumKind) {
it("should write proper JsValues") {
enum.values.foreach { entry =>
writes.writes(entry) shouldBe JsNumber(numeric.toInt(entry.value))
writes.writes(entry) shouldBe jsWrapper(entry.value)
}
}
}
}

def testReads[EntryType <: ValueEnumEntry[ValueType], ValueType <: AnyVal: Numeric: Reads](enumKind: String, enum: ValueEnum[ValueType, EntryType], providedReads: Option[Reads[EntryType]] = None): Unit = {
def testNumericReads[EntryType <: ValueEnumEntry[ValueType], ValueType <: AnyVal: Numeric: Reads](enumKind: String, enum: ValueEnum[ValueType, EntryType], providedReads: Option[Reads[EntryType]] = None): Unit = {
val numeric = implicitly[Numeric[ValueType]]
testReads(enumKind, enum, { i: ValueType => JsNumber(numeric.toInt(i)) }, providedReads)
}

def testReads[EntryType <: ValueEnumEntry[ValueType], ValueType: Reads](enumKind: String, enum: ValueEnum[ValueType, EntryType], jsWrapper: ValueType => JsValue, providedReads: Option[Reads[EntryType]] = None): Unit = {
val reads = providedReads.getOrElse(EnumFormats.reads(enum))
describe(enumKind) {
it("should read valid values") {
enum.values.foreach { entry =>
reads.reads(JsNumber(numeric.toInt(entry.value))).asOpt.value shouldBe entry
reads.reads(jsWrapper(entry.value)).asOpt.value shouldBe entry
}
}
it("should fail to read with invalid values") {
Expand All @@ -39,10 +47,15 @@ trait EnumJsonFormatHelpers { this: FunSpec with Matchers =>
}
}

def testFormats[EntryType <: ValueEnumEntry[ValueType], ValueType <: AnyVal: Numeric: Reads: Writes](enumKind: String, enum: ValueEnum[ValueType, EntryType], providedFormat: Option[Format[EntryType]] = None): Unit = {
def testNumericFormats[EntryType <: ValueEnumEntry[ValueType], ValueType <: AnyVal: Numeric: Reads: Writes](enumKind: String, enum: ValueEnum[ValueType, EntryType], providedFormat: Option[Format[EntryType]] = None): Unit = {
testNumericReads(enumKind, enum, providedFormat)
testNumericWrites(enumKind, enum, providedFormat)
}

def testFormats[EntryType <: ValueEnumEntry[ValueType], ValueType: Reads: Writes](enumKind: String, enum: ValueEnum[ValueType, EntryType], jsWrapper: ValueType => JsValue, providedFormat: Option[Format[EntryType]] = None): Unit = {
val format = providedFormat.getOrElse(EnumFormats.formats(enum))
testReads(enumKind, enum, Some(format))
testWrites(enumKind, enum, Some(format))
testReads(enumKind, enum, jsWrapper, Some(format))
testWrites(enumKind, enum, jsWrapper, Some(format))
}

}
Loading

0 comments on commit 45f4126

Please sign in to comment.