From 8fef53d44a57008dea411b882b12bc3d5d1ca2e0 Mon Sep 17 00:00:00 2001 From: Sergey Nastich Date: Wed, 12 Sep 2018 16:10:17 -0400 Subject: Add `Trimmed` tag and its logic (revisited) (#215) * Add option and iterable converters for transparent `@@ Trimmed` creation. * Move tagging stuff to a separate package - relieve `core.scala` from some extra code. * Add Tagging stuff and publishing section to README.md --- README.md | 40 ++++++++++++-- src/main/scala/xyz/driver/core/core.scala | 25 ++------- .../scala/xyz/driver/core/tagging/tagging.scala | 62 +++++++++++++++++++++ .../scala/xyz/driver/core/BlobStorageTest.scala | 1 + src/test/scala/xyz/driver/core/CoreTest.scala | 12 +++-- src/test/scala/xyz/driver/core/FileTest.scala | 48 ++++++++--------- src/test/scala/xyz/driver/core/JsonTest.scala | 11 ++-- .../scala/xyz/driver/core/rest/DriverAppTest.scala | 1 - .../xyz/driver/core/tagging/TaggingTest.scala | 63 ++++++++++++++++++++++ 9 files changed, 204 insertions(+), 59 deletions(-) create mode 100644 src/main/scala/xyz/driver/core/tagging/tagging.scala create mode 100644 src/test/scala/xyz/driver/core/tagging/TaggingTest.scala diff --git a/README.md b/README.md index 952961f..e65edf5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ Core library is used to provide ways to implement practices established in [Driv ## Components - * `core package` provides `Id` and `Name` implementations (with equal and ordering), utils for ScalaZ `OptionT`, and also `make` and `using` functions, + * `core package` provides `Id` and `Name` implementations (with equal and ordering), utils for ScalaZ `OptionT`, and also `make` and `using` functions and `@@` (tagged) type, + * `tagging` Utilities for tagging primitive types for extra type safety, as well as some tags that involve extra transformation upon deserializing with spray, * `config` Contains method `loadDefaultConfig` with default way of providing config to the application, * `domain` Common generic domain objects, e.g., `Email` and `PhoneNumber`, * `messages` Localization messages supporting different locales and methods to read from config, @@ -75,8 +76,38 @@ for { } yield x ``` +### `@@` or Tagged types + +For type definitions, the only import required is + +```scala +import xyz.driver.core.@@ +``` + +which provides just the ability to tag types: `val value: String @@ Important`. Two `String`s with different tags will +be distinguished by the compiler, helping reduce the possibility of mixing values passed into methods with several +arguments of identical types. + +To work with tags in actual values, use the following convenience methods: + +```scala +import xyz.driver.core.tagging._ + +val str = "abc".tagged[Important] +``` + +or go back to plain (say, in case you have an implicit for untagged value) + +```scala +// val trimmedExternalId: String @@ Trimmed = "123" + +Trials.filter(_.externalId === trimmedExternalId.untag) +``` + ### `Time` and `TimeProvider` +**NOTE: The contents of this section has been deprecated - use java.time.Clock instead** + Usage examples for `Time` (also check [TimeTest](https://github.com/drivergroup/driver-core/blob/master/src/test/scala/xyz/driver/core/TimeTest.scala) for more examples). Time(234L).isAfter(Time(123L)) @@ -175,8 +206,7 @@ Stats it gives access to are, 2. Publish to local repository, to use changes in depending projects: - $ sbt publish-local - -3. Release a new version of core: + $ sbt publishLocal - $ sbt release +3. In order to release a new version of core, merge your PR, tag the HEAD master commit with the next version + (don't forget the "v." prefix) and push tags - Travis will release the artifact automatically! diff --git a/src/main/scala/xyz/driver/core/core.scala b/src/main/scala/xyz/driver/core/core.scala index 846bed3..2ab4e88 100644 --- a/src/main/scala/xyz/driver/core/core.scala +++ b/src/main/scala/xyz/driver/core/core.scala @@ -1,16 +1,15 @@ package xyz.driver -import scalaz.{Equal, Monad, OptionT} import eu.timepit.refined.api.{Refined, Validate} import eu.timepit.refined.collection.NonEmpty +import scalaz.{Equal, Monad, OptionT} import xyz.driver.core.rest.errors.ExternalServiceException +import xyz.driver.core.tagging.Tagged import scala.concurrent.{ExecutionContext, Future} // TODO: this package seems too complex, look at all the features we need! -import scala.language.reflectiveCalls -import scala.language.higherKinds -import scala.language.implicitConversions +import scala.language.{higherKinds, implicitConversions, reflectiveCalls} package object core { @@ -29,14 +28,7 @@ package object core { } } - object tagging { - private[core] trait Tagged[+V, +Tag] - - implicit class Taggable[V <: Any](val v: V) extends AnyVal { - def tagged[Tag]: V @@ Tag = v.asInstanceOf[V @@ Tag] - } - } - type @@[+V, +Tag] = V with tagging.Tagged[V, Tag] + type @@[+V, +Tag] = V with Tagged[V, Tag] implicit class OptionTExtensions[H[_]: Monad, T](optionTValue: OptionT[H, T]) { @@ -129,13 +121,4 @@ package core { final case class Base64(value: String) - trait Trimmed - - object Trimmed { - import tagging._ - - implicit def string2Trimmed(str: String): String @@ Trimmed = str.trim().tagged[Trimmed] - - implicit def name2Trimmed[T](name: Name[T]): Name[T] @@ Trimmed = Name[T](name.value.trim()).tagged[Trimmed] - } } diff --git a/src/main/scala/xyz/driver/core/tagging/tagging.scala b/src/main/scala/xyz/driver/core/tagging/tagging.scala new file mode 100644 index 0000000..5b6599e --- /dev/null +++ b/src/main/scala/xyz/driver/core/tagging/tagging.scala @@ -0,0 +1,62 @@ +package xyz.driver.core + +import scala.collection.generic.CanBuildFrom +import scala.language.{higherKinds, implicitConversions} + +/** + * @author sergey + * @since 9/11/18 + */ +package object tagging { + + implicit class Taggable[V <: Any](val v: V) extends AnyVal { + def tagged[Tag]: V @@ Tag = v.asInstanceOf[V @@ Tag] + } + +} + +package tagging { + + sealed trait Tagged[+V, +Tag] + + object Tagged { + implicit class TaggedOps[V, Tag](val v: V @@ Tag) extends AnyVal { + def tagless: V = v + } + + implicit def orderingMagnet[V, Tag](implicit ord: Ordering[V]): Ordering[V @@ Tag] = + ord.asInstanceOf[Ordering[V @@ Tag]] + + } + + sealed trait Trimmed + + object Trimmed { + + implicit def apply[V](trimmable: V)(implicit ev: CanBeTrimmed[V]): V @@ Trimmed = { + ev.trim(trimmable).tagged[Trimmed] + } + + sealed trait CanBeTrimmed[T] { + def trim(trimmable: T): T + } + + implicit object StringCanBeTrimmed extends CanBeTrimmed[String] { + def trim(str: String): String = str.trim() + } + + implicit def nameCanBeTrimmed[T]: CanBeTrimmed[Name[T]] = new CanBeTrimmed[Name[T]] { + def trim(name: Name[T]): Name[T] = Name[T](name.value.trim()) + } + + implicit def option2Trimmed[V: CanBeTrimmed](option: Option[V]): Option[V @@ Trimmed] = + option.map(Trimmed(_)) + + implicit def coll2Trimmed[T, C[_] <: Traversable[_]](coll: C[T])( + implicit ev: C[T] <:< Traversable[T], + tr: CanBeTrimmed[T], + bf: CanBuildFrom[Nothing, T @@ Trimmed, C[T @@ Trimmed]]): C[T @@ Trimmed] = + ev(coll).map(Trimmed(_)(tr)).to[C] + } + +} diff --git a/src/test/scala/xyz/driver/core/BlobStorageTest.scala b/src/test/scala/xyz/driver/core/BlobStorageTest.scala index 637f9e0..811cc60 100644 --- a/src/test/scala/xyz/driver/core/BlobStorageTest.scala +++ b/src/test/scala/xyz/driver/core/BlobStorageTest.scala @@ -12,6 +12,7 @@ import xyz.driver.core.storage.{BlobStorage, FileSystemBlobStorage} import scala.concurrent.Future import scala.concurrent.duration._ +import scala.language.postfixOps class BlobStorageTest extends FlatSpec with ScalaFutures { diff --git a/src/test/scala/xyz/driver/core/CoreTest.scala b/src/test/scala/xyz/driver/core/CoreTest.scala index d280d73..f448d24 100644 --- a/src/test/scala/xyz/driver/core/CoreTest.scala +++ b/src/test/scala/xyz/driver/core/CoreTest.scala @@ -34,8 +34,10 @@ class CoreTest extends FlatSpec with Matchers with MockitoSugar { (Id[String]("1234213") === Id[String]("213414")) should be(false) (Id[String]("213414") === Id[String]("1234213")) should be(false) - Seq(Id[String]("4"), Id[String]("3"), Id[String]("2"), Id[String]("1")).sorted should contain - theSameElementsInOrderAs(Seq(Id[String]("1"), Id[String]("2"), Id[String]("3"), Id[String]("4"))) + val ids = Seq(Id[String]("4"), Id[String]("3"), Id[String]("2"), Id[String]("1")) + val sorted = Seq(Id[String]("1"), Id[String]("2"), Id[String]("3"), Id[String]("4")) + + ids.sorted should contain theSameElementsInOrderAs sorted } it should "have type-safe conversions" in { @@ -68,8 +70,9 @@ class CoreTest extends FlatSpec with Matchers with MockitoSugar { (Name[String]("foo") === Name[String]("bar")) should be(false) (Name[String]("bar") === Name[String]("foo")) should be(false) - Seq(Name[String]("d"), Name[String]("cc"), Name[String]("a"), Name[String]("bbb")).sorted should contain - theSameElementsInOrderAs(Seq(Name[String]("a"), Name[String]("bbb"), Name[String]("cc"), Name[String]("d"))) + val names = Seq(Name[String]("d"), Name[String]("cc"), Name[String]("a"), Name[String]("bbb")) + val sorted = Seq(Name[String]("a"), Name[String]("bbb"), Name[String]("cc"), Name[String]("d")) + names.sorted should contain theSameElementsInOrderAs sorted } "Revision" should "have equality working correctly" in { @@ -81,4 +84,5 @@ class CoreTest extends FlatSpec with Matchers with MockitoSugar { (bla === foo) should be(false) (foo === bla) should be(false) } + } diff --git a/src/test/scala/xyz/driver/core/FileTest.scala b/src/test/scala/xyz/driver/core/FileTest.scala index 8728089..554a991 100644 --- a/src/test/scala/xyz/driver/core/FileTest.scala +++ b/src/test/scala/xyz/driver/core/FileTest.scala @@ -62,17 +62,17 @@ class FileTest extends FlatSpec with Matchers with MockitoSugar { val s3Storage = new S3Storage(amazonS3Mock, testBucket, scala.concurrent.ExecutionContext.global) - val filesBefore = Await.result(s3Storage.list(testDirPath).run, 10 seconds) + val filesBefore = Await.result(s3Storage.list(testDirPath).run, 10.seconds) filesBefore shouldBe empty - val fileExistsBeforeUpload = Await.result(s3Storage.exists(testFilePath), 10 seconds) + val fileExistsBeforeUpload = Await.result(s3Storage.exists(testFilePath), 10.seconds) fileExistsBeforeUpload should be(false) - Await.result(s3Storage.upload(sourceTestFile, testFilePath), 10 seconds) + Await.result(s3Storage.upload(sourceTestFile, testFilePath), 10.seconds) - val filesAfterUpload = Await.result(s3Storage.list(testDirPath).run, 10 seconds) + val filesAfterUpload = Await.result(s3Storage.list(testDirPath).run, 10.seconds) filesAfterUpload.size should be(1) - val fileExistsAfterUpload = Await.result(s3Storage.exists(testFilePath), 10 seconds) + val fileExistsAfterUpload = Await.result(s3Storage.exists(testFilePath), 10.seconds) fileExistsAfterUpload should be(true) val uploadedFileLine = filesAfterUpload.head uploadedFileLine.name should be(Name[File](testFileName)) @@ -80,15 +80,15 @@ class FileTest extends FlatSpec with Matchers with MockitoSugar { uploadedFileLine.revision.id.length should be > 0 uploadedFileLine.lastModificationDate.millis should be > 0L - val downloadedFile = Await.result(s3Storage.download(testFilePath).run, 10 seconds) + val downloadedFile = Await.result(s3Storage.download(testFilePath).run, 10.seconds) downloadedFile shouldBe defined downloadedFile.foreach { _.getAbsolutePath.endsWith(testFilePath.toString) should be(true) } - Await.result(s3Storage.delete(testFilePath), 10 seconds) + Await.result(s3Storage.delete(testFilePath), 10.seconds) - val filesAfterRemoval = Await.result(s3Storage.list(testDirPath).run, 10 seconds) + val filesAfterRemoval = Await.result(s3Storage.list(testDirPath).run, 10.seconds) filesAfterRemoval shouldBe empty } @@ -103,18 +103,18 @@ class FileTest extends FlatSpec with Matchers with MockitoSugar { val fileStorage = new FileSystemStorage(scala.concurrent.ExecutionContext.global) - val filesBefore = Await.result(fileStorage.list(testDirPath).run, 10 seconds) + val filesBefore = Await.result(fileStorage.list(testDirPath).run, 10.seconds) filesBefore shouldBe empty - val fileExistsBeforeUpload = Await.result(fileStorage.exists(testFilePath), 10 seconds) + val fileExistsBeforeUpload = Await.result(fileStorage.exists(testFilePath), 10.seconds) fileExistsBeforeUpload should be(false) - Await.result(fileStorage.upload(sourceTestFile, testFilePath), 10 seconds) + Await.result(fileStorage.upload(sourceTestFile, testFilePath), 10.seconds) - val filesAfterUpload = Await.result(fileStorage.list(testDirPath).run, 10 seconds) + val filesAfterUpload = Await.result(fileStorage.list(testDirPath).run, 10.seconds) filesAfterUpload.size should be(1) - val fileExistsAfterUpload = Await.result(fileStorage.exists(testFilePath), 10 seconds) + val fileExistsAfterUpload = Await.result(fileStorage.exists(testFilePath), 10.seconds) fileExistsAfterUpload should be(true) val uploadedFileLine = filesAfterUpload.head @@ -123,13 +123,13 @@ class FileTest extends FlatSpec with Matchers with MockitoSugar { uploadedFileLine.revision.id.length should be > 0 uploadedFileLine.lastModificationDate.millis should be > 0L - val downloadedFile = Await.result(fileStorage.download(testFilePath).run, 10 seconds) + val downloadedFile = Await.result(fileStorage.download(testFilePath).run, 10.seconds) downloadedFile shouldBe defined downloadedFile.map(_.getAbsolutePath) should be(Some(testFilePath.toString)) - Await.result(fileStorage.delete(testFilePath), 10 seconds) + Await.result(fileStorage.delete(testFilePath), 10.seconds) - val filesAfterRemoval = Await.result(fileStorage.list(testDirPath).run, 10 seconds) + val filesAfterRemoval = Await.result(fileStorage.list(testDirPath).run, 10.seconds) filesAfterRemoval shouldBe empty } @@ -174,10 +174,10 @@ class FileTest extends FlatSpec with Matchers with MockitoSugar { blobMock // after file is uploaded ) - val filesBefore = Await.result(gcsStorage.list(testDirPath).run, 10 seconds) + val filesBefore = Await.result(gcsStorage.list(testDirPath).run, 10.seconds) filesBefore shouldBe empty - val fileExistsBeforeUpload = Await.result(gcsStorage.exists(testFilePath), 10 seconds) + val fileExistsBeforeUpload = Await.result(gcsStorage.exists(testFilePath), 10.seconds) fileExistsBeforeUpload should be(false) when(gcsMock.get(testBucket.value)).thenReturn(bucketMock) @@ -185,23 +185,23 @@ class FileTest extends FlatSpec with Matchers with MockitoSugar { when(bucketMock.create(org.mockito.Matchers.eq(testFileName), any[FileInputStream], any[BlobWriteOption])) .thenReturn(blobMock) - Await.result(gcsStorage.upload(sourceTestFile, testFilePath), 10 seconds) + Await.result(gcsStorage.upload(sourceTestFile, testFilePath), 10.seconds) - val filesAfterUpload = Await.result(gcsStorage.list(testDirPath).run, 10 seconds) + val filesAfterUpload = Await.result(gcsStorage.list(testDirPath).run, 10.seconds) filesAfterUpload.size should be(1) - val fileExistsAfterUpload = Await.result(gcsStorage.exists(testFilePath), 10 seconds) + val fileExistsAfterUpload = Await.result(gcsStorage.exists(testFilePath), 10.seconds) fileExistsAfterUpload should be(true) - val downloadedFile = Await.result(gcsStorage.download(testFilePath).run, 10 seconds) + val downloadedFile = Await.result(gcsStorage.download(testFilePath).run, 10.seconds) downloadedFile shouldBe defined downloadedFile.foreach { _.getAbsolutePath should endWith(testFilePath.toString) } - Await.result(gcsStorage.delete(testFilePath), 10 seconds) + Await.result(gcsStorage.delete(testFilePath), 10.seconds) - val filesAfterRemoval = Await.result(gcsStorage.list(testDirPath).run, 10 seconds) + val filesAfterRemoval = Await.result(gcsStorage.list(testDirPath).run, 10.seconds) filesAfterRemoval shouldBe empty } diff --git a/src/test/scala/xyz/driver/core/JsonTest.scala b/src/test/scala/xyz/driver/core/JsonTest.scala index 9a079b2..2aa3572 100644 --- a/src/test/scala/xyz/driver/core/JsonTest.scala +++ b/src/test/scala/xyz/driver/core/JsonTest.scala @@ -18,11 +18,12 @@ import xyz.driver.core.auth.AuthCredentials import xyz.driver.core.domain.{Email, PhoneNumber} import xyz.driver.core.json._ import xyz.driver.core.json.enumeratum.HasJsonFormat -import xyz.driver.core.tagging.Taggable +import xyz.driver.core.tagging._ import xyz.driver.core.time.provider.SystemTimeProvider import xyz.driver.core.time.{Time, TimeOfDay} import scala.collection.immutable.IndexedSeq +import scala.language.postfixOps class JsonTest extends WordSpec with Matchers with Inspectors { import DefaultJsonProtocol._ @@ -55,10 +56,12 @@ class JsonTest extends WordSpec with Matchers with Inspectors { } "read and write correct JSON when there's an implicit conversion defined" in { - JsString(" some string ").convertTo[String @@ Trimmed] shouldBe "some string" + val input = " some string " - val trimmed: String @@ Trimmed = " some string " - trimmed.toJson shouldBe JsString("some string") + JsString(input).convertTo[String @@ Trimmed] shouldBe input.trim() + + val trimmed: String @@ Trimmed = input + trimmed.toJson shouldBe JsString(trimmed) } } diff --git a/src/test/scala/xyz/driver/core/rest/DriverAppTest.scala b/src/test/scala/xyz/driver/core/rest/DriverAppTest.scala index 8f552db..118024a 100644 --- a/src/test/scala/xyz/driver/core/rest/DriverAppTest.scala +++ b/src/test/scala/xyz/driver/core/rest/DriverAppTest.scala @@ -1,6 +1,5 @@ package xyz.driver.core.rest -import akka.http.scaladsl.model.headers.CacheDirectives.`no-cache` import akka.http.scaladsl.model.headers._ import akka.http.scaladsl.model.{HttpMethod, StatusCodes} import akka.http.scaladsl.server.{Directives, Route} diff --git a/src/test/scala/xyz/driver/core/tagging/TaggingTest.scala b/src/test/scala/xyz/driver/core/tagging/TaggingTest.scala new file mode 100644 index 0000000..14dfaf9 --- /dev/null +++ b/src/test/scala/xyz/driver/core/tagging/TaggingTest.scala @@ -0,0 +1,63 @@ +package xyz.driver.core.tagging + +import org.scalatest.{Matchers, WordSpec} +import xyz.driver.core.{@@, Name} + +/** + * @author sergey + * @since 9/11/18 + */ +class TaggingTest extends WordSpec with Matchers { + + "@@ Trimmed" should { + "produce values transparently from Strings and Names (by default)" in { + val s: String @@ Trimmed = " trimmed " + val n: Name[Int] @@ Trimmed = Name(" trimmed ") + + s shouldBe "trimmed" + n shouldBe Name[Int]("trimmed") + } + + "produce values transparently from values that have an implicit conversion defined" in { + import scala.language.implicitConversions + implicit def stringSeq2Trimmed(stringSeq: Seq[String]): Seq[String] @@ Trimmed = + stringSeq.map(_.trim()).tagged[Trimmed] + + val strings: Seq[String] @@ Trimmed = Seq(" trimmed1 ", " trimmed2 ") + strings shouldBe Seq("trimmed1", "trimmed2") + } + + "produce values transparently from Options of values that have Trimmed implicits" in { + val maybeStringDirect: Option[String @@ Trimmed] = Some(" trimmed ") + val maybeStringFromMap: Option[String @@ Trimmed] = Map("s" -> " trimmed ").get("s") + + val maybeNameDirect: Option[Name[Int] @@ Trimmed] = Some(Name(" trimmed ")) + val maybeNameFromMap: Option[Name[Int] @@ Trimmed] = Map("s" -> Name[Int](" trimmed ")).get("s") + + maybeStringDirect shouldBe Some("trimmed") + maybeStringFromMap shouldBe Some("trimmed") + maybeNameDirect shouldBe Some(Name[Int]("trimmed")) + maybeNameFromMap shouldBe Some(Name[Int]("trimmed")) + } + + "produce values transparently from collections of values that have Trimmed implicits" in { + val strings = Seq("s" -> " trimmed1 ", "s" -> " trimmed2 ") + val names = strings.map { + case (k, v) => k -> Name[Int](v) + } + + val trimmedStrings: Seq[String @@ Trimmed] = strings.groupBy(_._1)("s").map(_._2) + val trimmedNames: Seq[Name[Int] @@ Trimmed] = names.groupBy(_._1)("s").map(_._2) + + trimmedStrings shouldBe Seq("trimmed1", "trimmed2") + trimmedNames shouldBe Seq("trimmed1", "trimmed2").map(Name[Int]) + } + + "have Ordering" in { + val names: Seq[Name[Int] @@ Trimmed] = Seq(" 2 ", " 1 ", "3").map(Name[Int]) + + names.sorted should contain inOrderOnly (Name("1"), Name("2"), Name("3")) + } + } + +} -- cgit v1.2.3