diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..1f158a8 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,96 @@ +# https://scalameta.org/scalafmt/docs/installation.html#sbt +version = 3.7.14 + +# https://scalameta.org/scalafmt/docs/configuration.html#scala-dialects +runner.dialect = scala3 + +# https://docs.scala-lang.org/style/declarations.html#modifiers +rewrite.rules = [SortModifiers] +rewrite.sortModifiers.order = [ + "`final`" + "`implicit`" + "`override`" + "`protected`" + "`private`" + "`lazy`" +] + +//rewrite.rules = [RedundantBraces] +//rewrite.redundantBraces.stringInterpolation = true +//rewrite.redundantBraces.defnBodies = "all" +//rewrite.redundantBraces.methodBodies = true +//rewrite.redundantBraces.includeUnitMethods = true +//rewrite.redundantBraces.generalExpressions = true +//rewrite.redundantBraces.ifElseExpressions = true +//newlines.afterCurlyLambdaParams=squash + +# https://scalameta.org/scalafmt/docs/configuration.html#imports +rewrite.rules = [Imports] +//rewrite.imports.sort = scalastyle +rewrite.imports.sort = ascii +rewrite.imports.expand = true +rewrite.imports.groups = [ + ["io.github.greenleafoss\\..*"], + ["org.mongodb\\..*"], + ["org.bson\\..*"], + ["java\\..*"], + ["scala\\..*"] +] + +# https://scalameta.org/scalafmt/docs/configuration.html#maxcolumn +# 80| 90| 100| 110| 120| +maxColumn = 120 + +# https://scalameta.org/scalafmt/docs/configuration.html#top-level-presets +preset = IntelliJ + +# https://scalameta.org/scalafmt/docs/configuration.html#alignpreset +align.preset=most +align.multiline = true +align.arrowEnumeratorGenerator = false +align.openParenCallSite = false +align.openParenDefnSite = true + +optIn.configStyleArguments = true + +verticalMultiline.atDefnSite = true +# https://scalameta.org/scalafmt/docs/configuration.html#after-only +# implicit +# override private val ctx: Context, +# private val ops: Ops +newlines.implicitParamListModifierForce = [after] +//newlines.implicitParamListModifierForce = [before, after] + +# https://scalameta.org/scalafmt/docs/configuration.html#indentextendsite +indent.extendSite = 2 + +# https://scalameta.org/scalafmt/docs/configuration.html#indentdefnsite +indent.defnSite = 4 + +indent.fewerBraces = always + + + +# https://scalameta.org/scalafmt/docs/configuration.html#trailing-commas +rewrite.trailingCommas.style = never + +# https://scalameta.org/scalafmt/docs/configuration.html#binpacking +# doesn't apply binpacking to calls with fewer arguments +binPack.literalsMinArgCount = 3 + +# https://scalameta.org/scalafmt/docs/configuration.html#binpackparentconstructors +binPack.parentConstructors = Never + +//newlines.beforeTemplateBodyIfBreakInParentCtors = true + +# https://scalameta.org/scalafmt/docs/configuration.html#literal-argument-lists +# List( +# 1, +# 2, +# 3, +# ) +binPack.literalArgumentLists = false +binPack.literalsSingleLine = false + +docstrings.style = Asterisk +docstrings.style = keep \ No newline at end of file diff --git a/README.md b/README.md index 632c582..2fa8400 100644 --- a/README.md +++ b/README.md @@ -5,37 +5,46 @@ [![green-leaf-mongo Scala version support](https://index.scala-lang.org/greenleafoss/green-leaf-mongo/green-leaf-mongo/latest-by-scala-version.svg)](https://index.scala-lang.org/greenleafoss/green-leaf-mongo/green-leaf-mongo) ## Short description -This extension created on top of official [MongoDB Scala Driver](http://mongodb.github.io/mongo-scala-driver), allows to fully utilize [Spray JSON](https://github.com/spray/spray-json) and represents bidirectional serialization for case classes in BSON, as well as flexible DSL for [MongoDB query operators](https://docs.mongodb.com/manual/reference/operator/query/), documents and collections. +This extension created on top of official [MongoDB Scala Driver](https://mongodb.github.io/mongo-scala-driver) and allows to fully utilize [Spray JSON](https://github.com/spray/spray-json) or [Play JSON](https://github.com/playframework/play-json) to represent bidirectional serialization for case classes in BSON, as well as flexible DSL for [MongoDB query operators](https://www.mongodb.com/docs/manual/reference/operator/query/), documents and collections. ## Usage ```scala // build.sbt -// https://mvnrepository.com/artifact/io.github.greenleafoss/green-leaf-mongo -libraryDependencies += "io.github.greenleafoss" %% "green-leaf-mongo" % "0.1.16.1" + +// https://mvnrepository.com/artifact/io.github.greenleafoss/green-leaf-mongo-core +// `green-leaf-mongo-core` can be used if you want to create your own extension + +// https://mvnrepository.com/artifact/io.github.greenleafoss/green-leaf-mongo-spray +libraryDependencies += "io.github.greenleafoss" %% "green-leaf-mongo-spray" % "3.0" + +// https://mvnrepository.com/artifact/io.github.greenleafoss/green-leaf-mongo-play +libraryDependencies += "io.github.greenleafoss" %% "green-leaf-mongo-play" % "3.0" ``` ## JSON and BSON protocols -`GreenLeafJsonProtocol` based on DefaultJsonProtocol from Spray JSON and allows to override predefined JsonFormats to make possible use custom seriallization in BSON format. -This trait also includes a few additional JsonFormats for _ZonedDateTime_, _ObjectId_, _scala Enumeration_ and _UUID_. +`GreenLeafMongoJsonBasicFormats` based on DefaultJsonProtocol from Spray JSON and allows to override predefined JsonFormats to make possible use custom serialization in BSON format. +This trait also includes a few additional JsonFormats for _LocalDate_, _LocalDateTime_, _ZonedDateTime_, _ObjectId_, _scala Enumeration_ and _UUID_. -`GreenLeafBsonProtocol` extends `GreenLeafJsonProtocol` and overrides _Long_, _ZonedDateTime_, _ObjectId_, _scala Enumeration_, _UUID_ and _Regex_ JSON formats to represent them in related BSON formats. +`PlayJsonProtocol` is a related extension for Play JSON library and `PlayJsonProtocol` for Spray JSON library. + +`SprayBsonProtocol`/`PlayBsonProtocol` extends related JsonProtocols and overrides _Int_, _Long_, _BigDecimal_, _LocalDate_, _LocalDateTime_, _ZonedDateTime_, _ObjectId_, _scala Enumeration_, _UUID_ and _Regex_ JSON formats to represent them in related BSON (MongoDB Extended JSON V2) formats https://www.mongodb.com/docs/manual/reference/mongodb-extended-json/#mongodb-extended-json-v2-usage. These base protocols allow to simply (de)serialize this instance to and from both JSON and BSON the same way as in Spray JSON: -```scala +```scala 3 // MODEL case class Test(id: ObjectId, i: Int, l: Long, b: Boolean, zdt: ZonedDateTime) // JSON -trait TestJsonProtocol extends GreenLeafJsonProtocol { - implicit def testJf = jsonFormat5(Test) -} +trait TestJsonProtocol extends SprayBsonProtocol: + given testJsonFormat = jsonFormat5(Test) + object TestJsonProtocol extends TestJsonProtocol // BSON -trait TestBsonProtocol extends TestJsonProtocol with GreenLeafBsonProtocol { - override implicit def testJf = jsonFormat(Test, "_id", "i", "l", "b", "zdt") -} +trait TestBsonProtocol extends SprayBsonProtocol: + given testBsonFormat = jsonFormat(Test, "_id", "i", "l", "b", "zdt") + object TestBsonProtocol extends TestBsonProtocol ``` @@ -43,7 +52,7 @@ Once protocols defined, we can make instance of Test case class and use TestJson ```scala val obj = Test(new ObjectId("5c72b799306e355b83ef3c86"), 1, 0x123456789L, true, "1970-01-01") -import TestJsonProtocol._ +import TestJsonProtocol.given println(obj.toJson.prettyPrint) ``` Output in this case will be: @@ -62,7 +71,7 @@ Changing single line of import `TestJsonProtocol` to `TestBsonProtocol` allows u ```scala val obj = Test(new ObjectId("5c72b799306e355b83ef3c86"), 1, 0x123456789L, true, "1970-01-01") -import TestBsonProtocol._ +import TestBsonProtocol.given println(obj.toJson.prettyPrint) ``` @@ -83,10 +92,10 @@ Output in this case will be: } ``` -Full code of the examples above available in `GreenLeafJsonAndBsonProtocolsTest`. +More examples available in implementation of **JsonProtocolSpec**/**BsonProtocolSpec** in Spray and Play project modules. ## GreenLeafMongoDsl -Import `GreenLeafMongoDsl._` makes it possible to write queries with a syntax that is more close to real queries in MongoDB, as was implemented in [Casbah Query DSL](http://mongodb.github.io/casbah/3.1/reference/query_dsl/). +`GreenLeafMongoFilterOps` makes it possible to write queries with a syntax that is more close to real queries in MongoDB, as was implemented in [Casbah Query DSL](http://mongodb.github.io/casbah/3.1/reference/query_dsl/). ```scala "size" $all ("S", "M", "L") @@ -100,17 +109,19 @@ Import `GreenLeafMongoDsl._` makes it possible to write queries with a syntax th "size" $nin ("S", "XXL") $or( "price" $lt 5, "price" $gt 1, "promotion" $eq true ) $and( "price" $lt 5, "price" $gt 1, "stock" $gte 1 ) -"price" $not { _ $gte 5.1 } +"price" $not { $gte (5.1) } $nor( "price" $eq 1.99 , "qty" $lt 20, "sale" $eq true ) "qty" $exists true +"results" $elemMatch $and("product" $eq "xyz", "score" $gte 8) // ... ``` -More examples of queries available in `GreenLeafMongoDslTest`. +More examples of queries available in **GreenLeafMongoFilterOpsSpec**. ## GreenLeafMongoDao -`GreenLeafMongoDao` extends `GreenLeafMongoDsl` and provides simple DSL to transform Mongo's _Observable[Document]_ instances to _Future[Seq[T]]_, _Future[Option[T]]_ and _Future[T]_. -In addition this trait provides many useful generic methods such as _insert_, _getById_, _findById_, _updateById_, _replaceById_ and others. -You can find more details and examples in [EntityWithIdAsFieldDaoTest](https://github.com/GreenLeafOSS/green-leaf-mongo/blob/master/src/test/scala/io/github/greenleafoss/mongo/EntityWithIdAsFieldDaoTest.scala), [EntityWithIdAsObjectDaoTest](https://github.com/GreenLeafOSS/green-leaf-mongo/blob/master/src/test/scala/io/github/greenleafoss/mongo/EntityWithIdAsObjectDaoTest.scala), [EntityWithOptionalFieldsDaoTest](https://github.com/GreenLeafOSS/green-leaf-mongo/blob/master/src/test/scala/io/github/greenleafoss/mongo/EntityWithOptionalFieldsDaoTest.scala) and [EntityWithoutIdDaoTest](https://github.com/GreenLeafOSS/green-leaf-mongo/blob/master/src/test/scala/io/github/greenleafoss/mongo/EntityWithoutIdDaoTest.scala). +`GreenLeafMongoDao` extends `GreenLeafMongoObservableToFutureOps` with `GreenLeafMongoFilterOps` and provides simple DSL to transform Mongo's _Observable[Document]_ instances to _Future[Seq[T]]_, _Future[Option[T]]_ and _Future[T]_. +In addition, this trait provides many useful generic methods such as _insert_, _getById_, _findById_, _updateById_, _replaceById_ and others. +`SprayMongoDao`/`PlayMongoDao` are related implementations for Spray and Play JSON libraries. +You can find more details and examples in the dao tests. diff --git a/build.sbt b/build.sbt index 56e7bb6..ea8f9c3 100644 --- a/build.sbt +++ b/build.sbt @@ -1,64 +1,91 @@ +// ************************************************** +// SETTINGS +// ************************************************** + +lazy val commonSettings = Seq( + version := "3.0", + description := + """ + |This extension created on top of official MongoDB Scala Driver. + |It allows to fully utilize Spray JSON and represents bidirectional serialization for case classes in BSON, + |as well as flexible DSL for MongoDB query operators, documents and collections. + |""".stripMargin, + licenses := List("Apache 2" -> new URL("https://www.apache.org/licenses/LICENSE-2.0.txt")), + homepage := Some(url("https://github.com/GreenLeafOSS/green-leaf-mongo")), + organization := "io.github.greenleafoss", + organizationName := "greenleafoss", + organizationHomepage := Some(url("https://github.com/greenleafoss")), + developers := List( + Developer( + id = "lashchenko", + name = "Andrii Lashchenko", + email = "andrew.lashchenko@gmail.com", + url = url("https://github.com/lashchenko") + ) + ), + scmInfo := Some( + ScmInfo( + url("https://github.com/GreenLeafOSS/green-leaf-mongo"), + "scm:git@github.com:GreenLeafOSS/green-leaf-mongo.git" + ) + ), + publishTo := { + // https://central.sonatype.org/news/20210223_new-users-on-s01/ + val nexus = "https://s01.oss.sonatype.org" + if (isSnapshot.value) Some("snapshots" at nexus + "/content/repositories/snapshots") + else Some("releases" at nexus + "/service/local/staging/deploy/maven2") + }, -name := "green-leaf-mongo" - -version := "0.1.16.1" - -description := "This extension created on top of official MongoDB Scala Driver, allows to fully utilize Spray JSON and represents bidirectional serialization for case classes in BSON, as well as flexible DSL for MongoDB query operators, documents and collections." -licenses := List("Apache 2" -> new URL("http://www.apache.org/licenses/LICENSE-2.0.txt")) -homepage := Some(url("https://github.com/GreenLeafOSS/green-leaf-mongo")) - -organization := "io.github.greenleafoss" -organizationName := "greenleafoss" -organizationHomepage := Some(url("https://github.com/greenleafoss")) - -developers := List( - Developer( - id = "lashchenko", - name = "Andrii Lashchenko", - email = "andrew.lashchenko@gmail.com", - url = url("https://github.com/lashchenko") - ) -) - -scmInfo := Some( - ScmInfo( - url("https://github.com/GreenLeafOSS/green-leaf-mongo"), - "scm:git@github.com:GreenLeafOSS/green-leaf-mongo.git" - ) -) - -publishTo := { // https://central.sonatype.org/news/20210223_new-users-on-s01/ - val nexus = "https://s01.oss.sonatype.org" - if (isSnapshot.value) Some("snapshots" at nexus + "/content/repositories/snapshots") - else Some("releases" at nexus + "/service/local/staging/deploy/maven2") -} - -// https://central.sonatype.org/news/20210223_new-users-on-s01/ -sonatypeCredentialHost := "https://s01.oss.sonatype.org" - -publishMavenStyle := true - -publishConfiguration := publishConfiguration.value.withOverwrite(true) - -scalaVersion := "3.3.0" - -crossScalaVersions := Seq("2.12.18", "2.13.11") - -scalacOptions ++= Seq( - "-deprecation" + sonatypeCredentialHost := "https://s01.oss.sonatype.org", + publishMavenStyle := true, + publishConfiguration := publishConfiguration.value.withOverwrite(true), + publishLocalConfiguration := publishLocalConfiguration.value.withOverwrite(true), + scalaVersion := "3.3.1", + scalacOptions ++= Seq( + "-deprecation", + "-feature", + "-explain" + ), + scalafmtOnCompile := true, + Test / parallelExecution := false, + Test / fork := true, + libraryDependencies += "org.slf4j" % "slf4j-api" % "2.0.7", + libraryDependencies += "org.slf4j" % "slf4j-simple" % "2.0.7" % Test, + libraryDependencies += "de.flapdoodle.embed" % "de.flapdoodle.embed.mongo" % "3.5.4" % Test, + libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.15" % Test, + libraryDependencies += "org.immutables" % "value" % "2.9.2" % Test ) -Test / parallelExecution := false -Test / fork := true - -libraryDependencies += "io.spray" %% "spray-json" % "1.3.6" -libraryDependencies += "org.mongodb.scala" %% "mongo-scala-driver" % "4.10.2" cross CrossVersion.for3Use2_13 - - -libraryDependencies += "org.slf4j" % "slf4j-api" % "2.0.7" -libraryDependencies += "org.slf4j" % "slf4j-simple" % "2.0.7" % Test - -libraryDependencies += "de.flapdoodle.embed" % "de.flapdoodle.embed.mongo" % "4.8.0" % Test -libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.16" % Test -libraryDependencies += "org.immutables" % "value" % "2.9.3" % Test +// ************************************************** +// PROJECTS +// ************************************************** + +lazy val core = (project in file("core")) + .settings(name := "green-leaf-mongo-core") + .settings(commonSettings) + .settings(libraryDependencies += "org.mongodb.scala" %% "mongo-scala-driver" % "4.9.0" cross CrossVersion.for3Use2_13) + +lazy val spray = (project in file("spray")) + .settings(name := "green-leaf-mongo-spray") + .settings(commonSettings) + .settings(libraryDependencies += "io.spray" %% "spray-json" % "1.3.6") + .dependsOn(core % "compile->compile;test->test") + +lazy val play = (project in file("play")) + .settings(name := "green-leaf-mongo-play") + .settings(commonSettings) + .settings(libraryDependencies += "com.typesafe.play" %% "play-json" % "2.10.1") + .dependsOn(core % "compile->compile;test->test") + +lazy val extensions: Seq[ProjectReference] = List[ProjectReference](spray, play) +lazy val aggregated: Seq[ProjectReference] = List[ProjectReference](core) ++ extensions + +lazy val root = (project in file(".")) + .settings(name := "green-leaf-mongo") + .settings(commonSettings) + // we don't need to publish core + spray + play together + .settings(publish / skip := true) + .aggregate(aggregated*) + .dependsOn(core % "compile->compile;test->test") + .dependsOn(extensions.map(_ % "compile->compile;compile->test")*) diff --git a/core/src/main/scala/io/github/greenleafoss/mongo/core/dao/GreenLeafMongoDao.scala b/core/src/main/scala/io/github/greenleafoss/mongo/core/dao/GreenLeafMongoDao.scala new file mode 100644 index 0000000..d64cbf9 --- /dev/null +++ b/core/src/main/scala/io/github/greenleafoss/mongo/core/dao/GreenLeafMongoDao.scala @@ -0,0 +1,177 @@ +package io.github.greenleafoss.mongo.core.dao + +import io.github.greenleafoss.mongo.core.filter.GreenLeafMongoFilterOps +import io.github.greenleafoss.mongo.core.log.Log +import io.github.greenleafoss.mongo.core.util.GreenLeafJsonBsonOps + +import org.mongodb.scala.* +import org.mongodb.scala.FindObservable +import org.mongodb.scala.MongoCollection +import org.mongodb.scala.SingleObservable +import org.mongodb.scala.bson.* +import org.mongodb.scala.bson.collection.immutable.Document +import org.mongodb.scala.bson.conversions.Bson +import org.mongodb.scala.model.FindOneAndReplaceOptions +import org.mongodb.scala.model.FindOneAndUpdateOptions +import org.mongodb.scala.result.InsertManyResult +import org.mongodb.scala.result.InsertOneResult + +import scala.concurrent.ExecutionContext +import scala.concurrent.Future +import scala.language.implicitConversions +import scala.reflect.ClassTag + +abstract class GreenLeafMongoDao[Id, E]( + using + override protected val ec: ExecutionContext) + extends GreenLeafMongoObservableToFutureOps + with GreenLeafMongoFilterOps + with Log: + + this: GreenLeafMongoDaoProtocol[Id, E] with GreenLeafJsonBsonOps => + + protected val collection: MongoCollection[Document] + + // _id, id, key, ... + protected val primaryKey: String = "_id" + + protected def defaultSortBy: BsonValue = BsonDocument() + + def insert(e: E): Future[InsertOneResult] = + val bson: BsonValue = e + log.debug(s"DAO.insertOne: $bson") + collection.insertOne(bson.asDocument()).toFuture() + + def insertMany(entities: Seq[E]): Future[InsertManyResult] = + val documents: Seq[Document] = entities.map(e => (e: BsonValue).asDocument()) + log.debug(s"DAO.insertMany: $documents") + collection.insertMany(documents).toFuture() + + protected def internalFind( + filter: BsonValue, + offset: Int, + limit: Int, + sortBy: BsonValue = defaultSortBy + ): FindObservable[Document] = + log.debug(s"DAO.internalFind: $filter") + collection.find(filter.asDocument()).skip(offset).limit(limit).sort(sortBy.asDocument()) + + def findOne( + filter: BsonValue, + offset: Int = 0, + sortBy: BsonValue = defaultSortBy + ): Future[Option[E]] = + internalFind(filter, offset, limit = 1, sortBy).asOpt[E] + + def find( + filter: BsonValue, + offset: Int = 0, + limit: Int = 0, + sortBy: BsonValue = defaultSortBy + ): Future[Seq[E]] = + internalFind(filter, offset, limit, sortBy).asSeq[E] + + def getById(id: Id): Future[E] = + internalFind(primaryKey $eq id, 0, 1).asObj[E] + + def findById(id: Id): Future[Option[E]] = + internalFind(primaryKey $eq id, 0, 1).asOpt[E] + + // JSON fields can have different order, so if Id type is object don't use this query. + // find({"id": { $in: [ {a: 1, b: 2 }, {a: 3, b: 4 }, ...] } }) - order of 'a' and 'b' fields may change + // find({"id": { $in: [ {"id.a": 1, "id.b": 2}, ... ] } }) - will not work + def findByIdsIn( + ids: Seq[Id], + offset: Int = 0, + limit: Int = 0, + sortBy: BsonValue = defaultSortBy + ): Future[Seq[E]] = + internalFind(primaryKey $in ids.map(id => id: BsonValue), offset, limit, sortBy).asSeq[E] + + def findByIdsOr( + ids: Seq[Id], + offset: Int = 0, + limit: Int = 0, + sortBy: BsonValue = defaultSortBy + ): Future[Seq[E]] = + internalFind($or(ids.map(id => primaryKey $is id): _*), offset, limit, sortBy).asSeq[E] + + def findAll(offset: Int = 0, limit: Int = 0, sortBy: BsonValue = defaultSortBy): Future[Seq[E]] = + find(BsonDocument(), offset, limit, sortBy) + + // ******************************************************************************** + // https://docs.mongodb.com/manual/reference/method/db.collection.findOneAndUpdate/ + // ******************************************************************************** + + protected def internalUpdate( + filter: BsonValue, + update: BsonValue, + upsert: Boolean = false + ): SingleObservable[Document] = + log.trace(s"DAO.internalUpdateBy [$primaryKey] : $filter") + // By default "ReturnDocument.BEFORE" property used and returns the document before the update + // val options = FindOneAndUpdateOptions().upsert(true).returnDocument(ReturnDocument.AFTER) + val options = FindOneAndUpdateOptions().upsert(upsert) + collection.findOneAndUpdate(filter.asDocument(), update.asDocument(), options) + + def updateById(id: Id, e: BsonValue, upsert: Boolean = false): Future[Option[E]] = + internalUpdate(primaryKey $eq id, e, upsert).asOpt[E] + + def update( + filter: BsonValue, + e: BsonValue, + upsert: Boolean = false + ): Future[Option[E]] = + internalUpdate(filter, e, upsert).asOpt[E] + + // ******************************************************************************** + // https://docs.mongodb.com/manual/reference/method/db.collection.findOneAndReplace/ + // ******************************************************************************** + + protected def internalReplace( + filter: BsonValue, + replacement: BsonValue, + upsert: Boolean = false + ): SingleObservable[Document] = + // By default "ReturnDocument.BEFORE" property used and returns the document before the update + // val option = FindOneAndReplaceOptions().upsert(true).returnDocument(ReturnDocument.AFTER) + val options = FindOneAndReplaceOptions().upsert(upsert) + collection.findOneAndReplace(filter.asDocument(), replacement.asDocument(), options) + + def replaceById(id: Id, e: E, upsert: Boolean = false): Future[Option[E]] = + internalReplace(primaryKey $eq id, e, upsert).asOpt[E] + + def createOrReplaceById(id: Id, e: E): Future[Option[E]] = + replaceById(id, e, upsert = true) + + /** + * NOT ATOMICALLY find a document and replace it. + * Impossible to upsert:true with a Dotted _id Query + * https://docs.mongodb.com/manual/reference/method/db.collection.update/#upsert-true-with-a-dotted-id-query + * @param id primary key filter + * @param e entity to replace + * @return None if document was created and Some(previous document) if the document was updated + */ + def replaceOrInsertById(id: Id, e: E): Future[Option[E]] = + replaceById(id, e /* upsert = false */ ).flatMap { + case beforeOpt @ Some(_) /* replaced */ => Future.successful(beforeOpt) + case None => insert(e).map { (_: InsertOneResult) => None } + } + + def replace(filter: BsonValue, e: E, upsert: Boolean = false): Future[Option[E]] = + internalReplace(filter, e, upsert).asOpt[E] + + def createOrReplace(filter: BsonValue, e: E): Future[Option[E]] = + replace(filter, e, upsert = true) + + def distinct[T: ClassTag](fieldName: String, filter: BsonValue): Future[Seq[T]] = + collection.distinct[T](fieldName, filter.asDocument()).toFuture() + + def aggregateBsonDocuments[A: JsonFormat](pipeline: Bson*): Future[Seq[Document]] = + collection.aggregate(pipeline).toFuture() + + def aggregate[A: JsonFormat](pipeline: Bson*): Future[Seq[A]] = + collection.aggregate(pipeline).asSeq[A] + + // def deleteById(id: Id): Future[E] = ??? + // def deleteByIds(id: Seq[Id]): Future[E] = ??? diff --git a/core/src/main/scala/io/github/greenleafoss/mongo/core/dao/GreenLeafMongoDaoProtocol.scala b/core/src/main/scala/io/github/greenleafoss/mongo/core/dao/GreenLeafMongoDaoProtocol.scala new file mode 100644 index 0000000..4d70e71 --- /dev/null +++ b/core/src/main/scala/io/github/greenleafoss/mongo/core/dao/GreenLeafMongoDaoProtocol.scala @@ -0,0 +1,11 @@ +package io.github.greenleafoss.mongo.core.dao + +import io.github.greenleafoss.mongo.core.util.GreenLeafJsonBsonOps + +trait GreenLeafMongoDaoProtocol[Id, E]: + + this: GreenLeafJsonBsonOps => + + protected given idFormat: JsonFormat[Id] + + protected given eFormat: JsonFormat[E] diff --git a/core/src/main/scala/io/github/greenleafoss/mongo/core/dao/GreenLeafMongoDaoProtocolObjectId.scala b/core/src/main/scala/io/github/greenleafoss/mongo/core/dao/GreenLeafMongoDaoProtocolObjectId.scala new file mode 100644 index 0000000..4ef1412 --- /dev/null +++ b/core/src/main/scala/io/github/greenleafoss/mongo/core/dao/GreenLeafMongoDaoProtocolObjectId.scala @@ -0,0 +1,8 @@ +package io.github.greenleafoss.mongo.core.dao + +import io.github.greenleafoss.mongo.core.util.GreenLeafJsonBsonOps + +import org.mongodb.scala.bson.ObjectId + +trait GreenLeafMongoDaoProtocolObjectId[E] extends GreenLeafMongoDaoProtocol[ObjectId, E]: + this: GreenLeafJsonBsonOps => diff --git a/core/src/main/scala/io/github/greenleafoss/mongo/core/dao/GreenLeafMongoObservableToFutureOps.scala b/core/src/main/scala/io/github/greenleafoss/mongo/core/dao/GreenLeafMongoObservableToFutureOps.scala new file mode 100644 index 0000000..a13597c --- /dev/null +++ b/core/src/main/scala/io/github/greenleafoss/mongo/core/dao/GreenLeafMongoObservableToFutureOps.scala @@ -0,0 +1,56 @@ +package io.github.greenleafoss.mongo.core.dao + +import io.github.greenleafoss.mongo.core.util.GreenLeafJsonBsonOps + +import org.mongodb.scala.* +import org.mongodb.scala.bson.collection.immutable.Document + +import scala.concurrent.ExecutionContext +import scala.concurrent.Future + +trait GreenLeafMongoObservableToFutureOps: + this: GreenLeafJsonBsonOps => + + protected given ec: ExecutionContext + + // ************************************************** + // FindObservable + // ************************************************** + + protected def mongoFindObservableAsSeq[E: JsonFormat](x: FindObservable[Document]): Future[Seq[E]] = + x.toFuture().map(seq => seq.map(_.toJson(jws).parseJson.convertTo[E])) + + protected def mongoFindObservableAsOpt[E: JsonFormat](x: FindObservable[Document]): Future[Option[E]] = + x.headOption().map(opt => opt.map(_.toJson(jws).parseJson.convertTo[E])) + + protected def mongoFindObservableAsObj[E: JsonFormat](x: FindObservable[Document]): Future[E] = + x.head().map(_.toJson(jws).parseJson.convertTo[E]) + + extension (x: FindObservable[Document]) + protected def asSeq[E: JsonFormat]: Future[Seq[E]] = mongoFindObservableAsSeq(x) + protected def asOpt[E: JsonFormat]: Future[Option[E]] = mongoFindObservableAsOpt(x) + protected def asObj[E: JsonFormat]: Future[E] = mongoFindObservableAsObj(x) + + // ************************************************** + // SingleObservable + // ************************************************** + + protected def mongoSingleObservableAsOpt[E: JsonFormat](x: SingleObservable[Document]): Future[Option[E]] = + x.headOption().map(opt => opt.map(_.toJson(jws).parseJson.convertTo[E])) + + protected def mongoSingleObservableAsObj[E: JsonFormat](x: SingleObservable[Document]): Future[E] = + x.head().map(_.toJson(jws).parseJson.convertTo[E]) + + extension (x: SingleObservable[Document]) + protected def asOpt[E: JsonFormat]: Future[Option[E]] = mongoSingleObservableAsOpt(x) + protected def asObj[E: JsonFormat]: Future[E] = mongoSingleObservableAsObj(x) + + // ************************************************** + // AggregateObservable + // ************************************************** + + protected def mongoAggregateObservableAsSeq[E: JsonFormat](x: AggregateObservable[Document]): Future[Seq[E]] = + x.toFuture().map(seq => seq.map(_.toJson(jws).parseJson.convertTo[E])) + + extension (x: AggregateObservable[Document]) + protected def asSeq[E: JsonFormat]: Future[Seq[E]] = mongoAggregateObservableAsSeq(x) diff --git a/core/src/main/scala/io/github/greenleafoss/mongo/core/filter/GreenLeafMongoDotNotationOps.scala b/core/src/main/scala/io/github/greenleafoss/mongo/core/filter/GreenLeafMongoDotNotationOps.scala new file mode 100644 index 0000000..faa8c6a --- /dev/null +++ b/core/src/main/scala/io/github/greenleafoss/mongo/core/filter/GreenLeafMongoDotNotationOps.scala @@ -0,0 +1,72 @@ +package io.github.greenleafoss.mongo.core.filter + +import io.github.greenleafoss.mongo.core.util.BsonDocumentOps.* + +import org.mongodb.scala.bson.BsonArray +import org.mongodb.scala.bson.BsonDocument +import org.mongodb.scala.bson.BsonValue + +import scala.jdk.CollectionConverters._ + +trait GreenLeafMongoDotNotationOps: + // https://www.mongodb.com/docs/v5.0/core/document/#dot-notation + + private def dotNotation(doc: BsonDocument): BsonValue = doc.entriesKv.foldLeft(doc) { + // { $exists: { ... } } + case (res, (opK, nestedDoc: BsonDocument)) if opK.startsWith("$") => res.update(opK -> dotNotation(nestedDoc)) + + // { $and: [ ... ] } + case (res, (opK, nestedArr: BsonArray)) if opK.startsWith("$") => res.update(opK -> dotNotation(nestedArr)) + + // { $operator: 123 } + case (res, (opK, nestedVal: BsonValue)) if opK.startsWith("$") => res.update(opK -> nestedVal) + + // {"_id.base": {"$eq": "USD"}, "_id.date": {"$eq": {"$date": "1970-01-01T00:00:00Z"}} + case (res, (fieldK, nestedDoc: BsonDocument)) if nestedDoc.containsKey("$eq") => + nestedDoc.get("$eq") match { + case d: BsonDocument => + // will replace by dot notation or the same data below + res.removeKey(fieldK) + // { field: { $eq: { ... } } } + d.entries.foldLeft(res) { + case (_, e) if !e.getKey.startsWith("$") => + // "_id": {"$eq": {"date": {"$eq": {$date": "1970-01-01T00:00:00Z"}} } } + res.update(s"$fieldK.${e.getKey}", BsonDocument("$eq" -> dotNotation(e.getValue))) + // "_id.date": {"$eq": {"$date": "2019-01-03T00:00:00Z"}} + case (_, e) => + // keep this KV as is, case like "_id.date": {"$eq": {"$date": "1970-01-01T00:00:00Z"}} + res.update(s"$fieldK", BsonDocument("$eq" -> BsonDocument(e.getKey -> e.getValue))) + } + + // { field: { $eq: 123 } } + case _ => res + } + + // { $and: [ ... ] } + case (res, (fieldK, nestedArr: BsonArray)) => + res.update(fieldK -> dotNotation(nestedArr)) + + case (res, _) => res + } + + private def dotNotation(arr: BsonArray): BsonValue = + BsonArray.fromIterable( + arr + .iterator() + .asScala + .map { + case d: BsonDocument => dotNotation(d) + case a: BsonArray => dotNotation(a) + case v: BsonValue => v + } + .iterator + .to(Iterable) + ) + + def dotNotation(bson: BsonValue): BsonValue = bson match { + case doc: BsonDocument => dotNotation(doc) + case arr: BsonArray => dotNotation(arr) + case value: BsonValue => value + } + +object GreenLeafMongoDotNotationOps extends GreenLeafMongoDotNotationOps diff --git a/core/src/main/scala/io/github/greenleafoss/mongo/core/filter/GreenLeafMongoFilterOps.scala b/core/src/main/scala/io/github/greenleafoss/mongo/core/filter/GreenLeafMongoFilterOps.scala new file mode 100644 index 0000000..6dd0250 --- /dev/null +++ b/core/src/main/scala/io/github/greenleafoss/mongo/core/filter/GreenLeafMongoFilterOps.scala @@ -0,0 +1,127 @@ +package io.github.greenleafoss.mongo.core.filter + +import io.github.greenleafoss.mongo.core.filter.GreenLeafMongoDotNotationOps.* +import io.github.greenleafoss.mongo.core.util.BsonDocumentOps.* + +import org.mongodb.scala.bson.* +import org.mongodb.scala.bson.BsonValue + +import scala.annotation.tailrec +import scala.annotation.targetName +import scala.jdk.CollectionConverters.* +import scala.util.matching.Regex + +trait GreenLeafMongoFilterOps: + + protected def $and(filters: BsonValue*): BsonDocument = + dotNotation(BsonDocument("$and" -> BsonArray.fromIterable(filters))).asDocument() + + protected def $or(filters: BsonValue*): BsonDocument = + dotNotation(BsonDocument("$or" -> BsonArray.fromIterable(filters))).asDocument() + + protected def $not(filter: BsonValue): BsonDocument = + dotNotation(BsonDocument("$not" -> filter)).asDocument() + + protected def $nor(filters: BsonValue*): BsonDocument = + dotNotation(BsonDocument("$nor" -> BsonArray.fromIterable(filters))).asDocument() + + protected def $gt(value: BsonValue): BsonDocument = + dotNotation(BsonDocument("$gt" -> value)).asDocument() + + protected def $lt(value: BsonValue): BsonDocument = + dotNotation(BsonDocument("$lt" -> value)).asDocument() + + protected def $gte(value: BsonValue): BsonDocument = + dotNotation(BsonDocument("$gte" -> value)).asDocument() + + protected def $lte(value: BsonValue): BsonDocument = + dotNotation(BsonDocument("$lte" -> value)).asDocument() + + protected def $in(values: Seq[BsonValue]): BsonDocument = + dotNotation(BsonDocument("$in" -> BsonArray.fromIterable(values))).asDocument() + + protected def $nin(values: Seq[BsonValue]): BsonDocument = + dotNotation(BsonDocument("$nin" -> BsonArray.fromIterable(values))).asDocument() + + protected def $exists(exists: Boolean): BsonDocument = + dotNotation(BsonDocument("$exists" -> BsonBoolean(exists))).asDocument() + + protected def $regex(pattern: Regex): BsonRegularExpression = + dotNotation(BsonRegularExpression(pattern)).asRegularExpression() + + protected def $regex(pattern: String): BsonDocument = + dotNotation(BsonRegularExpression(pattern)).asDocument() + + protected def $regex(pattern: String, options: String): BsonDocument = + dotNotation(BsonRegularExpression(pattern, options)).asDocument() + + protected def $elemMatch(filter: BsonValue): BsonDocument = + dotNotation(BsonDocument("$elemMatch" -> filter)).asDocument() + + protected def $size(size: Int): BsonDocument = + dotNotation(BsonDocument("$size" -> BsonInt32(size))).asDocument() + + extension (field: String) + + // @tailrec should be either a final (we can't do it with an extension methods) or private def + // @tailrec private def $equal(values: BsonValue*): BsonValue = values match + // case Seq(value) => dotNotation(BsonDocument(field -> BsonDocument("$eq" -> value))) + // case _ => dotNotation(BsonDocument(field -> BsonDocument("$eq" -> BsonArray.fromIterable(values)))) + + // @targetName("mongo-operator-eq-val") + protected def $eq(value: BsonValue): BsonDocument = + dotNotation(BsonDocument(field -> BsonDocument("$eq" -> value))).asDocument() + + // @targetName("mongo-operator-eq-seq") + protected def $eq(values: Seq[BsonValue]): BsonDocument = + dotNotation(BsonDocument(field -> BsonDocument("$eq" -> BsonArray.fromIterable(values)))).asDocument() + + protected def $is(value: BsonValue): BsonDocument = + $eq(value) + + protected def $is(values: Seq[BsonValue]): BsonDocument = + $eq(values) + + protected def $ne(value: BsonValue): BsonDocument = + dotNotation(BsonDocument(field -> BsonDocument("$ne" -> value))).asDocument() + + protected def $not(value: BsonValue): BsonDocument = + dotNotation(BsonDocument(field -> BsonDocument("$not" -> value))).asDocument() + + protected def $gt(value: BsonValue): BsonDocument = + dotNotation(BsonDocument(field -> BsonDocument("$gt" -> value))).asDocument() + + protected def $lt(value: BsonValue): BsonDocument = + dotNotation(BsonDocument(field -> BsonDocument("$lt" -> value))).asDocument() + + protected def $gte(value: BsonValue): BsonDocument = + dotNotation(BsonDocument(field -> BsonDocument("$gte" -> value))).asDocument() + + protected def $lte(value: BsonValue): BsonDocument = + dotNotation(BsonDocument(field -> BsonDocument("$lte" -> value))).asDocument() + + protected def $in(values: Seq[BsonValue]): BsonDocument = + dotNotation(BsonDocument(field -> BsonDocument("$in" -> BsonArray.fromIterable(values)))).asDocument() + + protected def $nin(values: Seq[BsonValue]): BsonDocument = + dotNotation(BsonDocument(field -> BsonDocument("$nin" -> BsonArray.fromIterable(values)))).asDocument() + + protected def $exists(exists: Boolean): BsonDocument = + dotNotation(BsonDocument(field -> BsonDocument("$exists" -> BsonBoolean(exists)))).asDocument() + + protected def $regex(pattern: Regex): BsonDocument = + dotNotation(BsonDocument(field -> BsonRegularExpression(pattern))).asDocument() + + protected def $regex(pattern: String, options: String = ""): BsonDocument = + dotNotation(BsonDocument(field -> BsonRegularExpression(pattern, options))).asDocument() + + protected def $elemMatch(filter: BsonValue): BsonDocument = + dotNotation(BsonDocument(field -> BsonDocument("$elemMatch" -> filter))).asDocument() + + protected def $size(size: Int): BsonDocument = + dotNotation(BsonDocument(field -> BsonDocument("$size" -> BsonInt32(size)))).asDocument() + + protected def $all(values: Seq[BsonValue]): BsonDocument = + dotNotation(BsonDocument(field -> BsonDocument("$all" -> BsonArray.fromIterable(values)))).asDocument() + +// object GreenLeafMongoFilterOps extends GreenLeafMongoFilterOps diff --git a/core/src/main/scala/io/github/greenleafoss/mongo/core/json/GreenLeafMongoJsonBasicFormats.scala b/core/src/main/scala/io/github/greenleafoss/mongo/core/json/GreenLeafMongoJsonBasicFormats.scala new file mode 100644 index 0000000..aa0ab5c --- /dev/null +++ b/core/src/main/scala/io/github/greenleafoss/mongo/core/json/GreenLeafMongoJsonBasicFormats.scala @@ -0,0 +1,111 @@ +package io.github.greenleafoss.mongo.core.json + +import io.github.greenleafoss.mongo.core.util.GreenLeafJsonBsonOps + +import org.mongodb.scala.bson.ObjectId + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZonedDateTime +import java.util.UUID + +trait GreenLeafMongoJsonBasicFormats: + this: GreenLeafJsonBsonOps => + + // INT + + // protected val $numberInt: String = "$numberInt" + protected def formatInt: JsonFormat[Int] + given IntJsonFormat: JsonFormat[Int] = formatInt + + // LONG + + // protected val $numberLong: String = "$numberLong" + protected def formatLong: JsonFormat[Long] + given LongJsonFormat: JsonFormat[Long] = formatLong + + // FLOAT + + protected def formatFloat: JsonFormat[Float] + given FloatJsonFormat: JsonFormat[Float] = formatFloat + + // DOUBLE + + // protected val $numberDouble: String = "$numberDouble" + protected def formatDouble: JsonFormat[Double] + given DoubleJsonFormat: JsonFormat[Double] = formatDouble + + // BYTE + + protected def formatByte: JsonFormat[Byte] + given ByteJsonFormat: JsonFormat[Byte] = formatByte + + // SHORT + + protected def formatShort: JsonFormat[Short] + given ShortJsonFormat: JsonFormat[Short] = formatShort + + // BIG DECIMAL + + // protected val $numberDecimal: String = "$numberDecimal" + + protected def formatBigDecimal: JsonFormat[BigDecimal] + given BigDecimalJsonFormat: JsonFormat[BigDecimal] = formatBigDecimal + + // BIG INT + + protected def formatBigInt: JsonFormat[BigInt] + given BigIntJsonFormat: JsonFormat[BigInt] = formatBigInt + + // UNIT + + protected def formatUnit: JsonFormat[Unit] + given UnitJsonFormat: JsonFormat[Unit] = formatUnit + + // BOOLEAN + + protected def formatBoolean: JsonFormat[Boolean] + given BooleanJsonFormat: JsonFormat[Boolean] = formatBoolean + + // CHAR + + protected def formatChar: JsonFormat[Char] + given CharJsonFormat: JsonFormat[Char] = formatChar + + // STRING + + protected def formatString: JsonFormat[String] + given StringJsonFormat: JsonFormat[String] = formatString + + // SYMBOL + + protected def formatSymbol: JsonFormat[Symbol] + given SymbolJsonFormat: JsonFormat[Symbol] = formatSymbol + + // LOCAL DATE + + // protected val $date: String = "$date" + protected def formatLocalDate: JsonFormat[LocalDate] + given LocalDateJsonFormat: JsonFormat[LocalDate] = formatLocalDate + + // LOCAL DATE TIME + + protected def formatLocalDateTime: JsonFormat[LocalDateTime] + given LocalDateTimeJsonFormat: JsonFormat[LocalDateTime] = formatLocalDateTime + + // ZONED DATE TIME + + protected def formatZonedDateTime: JsonFormat[ZonedDateTime] + given ZonedDateTimeJsonFormat: JsonFormat[ZonedDateTime] = formatZonedDateTime + + // UUID + + // protected val $oid: String = "$oid" + protected def formatUUID: JsonFormat[UUID] + given UUIDJsonFormat: JsonFormat[UUID] = formatUUID + + // OBJECT ID + + // protected val $regularExpression: String = "$regularExpression" + protected def formatObjectId: JsonFormat[ObjectId] + given ObjectIdJsonFormat: JsonFormat[ObjectId] = formatObjectId diff --git a/core/src/main/scala/io/github/greenleafoss/mongo/core/log/Log.scala b/core/src/main/scala/io/github/greenleafoss/mongo/core/log/Log.scala new file mode 100644 index 0000000..cf9f69b --- /dev/null +++ b/core/src/main/scala/io/github/greenleafoss/mongo/core/log/Log.scala @@ -0,0 +1,7 @@ +package io.github.greenleafoss.mongo.core.log + +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +trait Log: + protected val log: Logger = LoggerFactory.getLogger(getClass) diff --git a/core/src/main/scala/io/github/greenleafoss/mongo/core/util/BsonDocumentOps.scala b/core/src/main/scala/io/github/greenleafoss/mongo/core/util/BsonDocumentOps.scala new file mode 100644 index 0000000..c852cdc --- /dev/null +++ b/core/src/main/scala/io/github/greenleafoss/mongo/core/util/BsonDocumentOps.scala @@ -0,0 +1,25 @@ +package io.github.greenleafoss.mongo.core.util + +import org.mongodb.scala.bson.BsonDocument +import org.mongodb.scala.bson.BsonValue + +import java.util + +import scala.jdk.CollectionConverters._ +import scala.language.implicitConversions +import scala.util.chaining._ + +trait BsonDocumentOps: + + extension (doc: BsonDocument) + def removeKey(k: String): BsonDocument = doc.tap(_.remove(k)) + + def update(k: String, v: BsonValue): BsonDocument = doc.tap(_.put(k, v)) + + def update(kv: (String, BsonValue)): BsonDocument = update(kv._1, kv._2) + + def entries: Seq[util.Map.Entry[String, BsonValue]] = doc.entrySet().asScala.toSeq + + def entriesKv: Seq[(String, BsonValue)] = entries.map(e => e.getKey -> e.getValue) + +object BsonDocumentOps extends BsonDocumentOps diff --git a/core/src/main/scala/io/github/greenleafoss/mongo/core/util/GreenLeafJsonBsonOps.scala b/core/src/main/scala/io/github/greenleafoss/mongo/core/util/GreenLeafJsonBsonOps.scala new file mode 100644 index 0000000..8dce7f5 --- /dev/null +++ b/core/src/main/scala/io/github/greenleafoss/mongo/core/util/GreenLeafJsonBsonOps.scala @@ -0,0 +1,52 @@ +package io.github.greenleafoss.mongo.core.util + +import org.mongodb.scala.bson.BsonDocument +import org.mongodb.scala.bson.BsonValue + +import scala.language.implicitConversions +import scala.util.Try +import scala.util.chaining.* + +trait GreenLeafJsonBsonOps extends MongoExtendedJsonOps: + + // ************************************************** + // FORMATS + // ************************************************** + + type JsonFormat[_] + type Json + + // ************************************************** + // JSON + // ************************************************** + + extension (string: String) def parseJson: Json + extension [E: JsonFormat](e: E) def convertToJson: Json + extension (json: Json) def convertTo[E: JsonFormat]: E + + // ************************************************** + // BSON + // ************************************************** + + // find("number" $eq 123) + given convertToBson[E: JsonFormat]: Conversion[E, BsonValue] = _.pipe(convertToJson).pipe(convertJsonToBson) + + // find("number" $in Seq(1, 2, 3)) + given convertSeqToSeqBson[E: JsonFormat]: Conversion[Seq[E], Seq[BsonValue]] = _.map(e => e: BsonValue) + + extension (string: String) + def parseBson: BsonValue = Try(string.parseBsonDocument).getOrElse(convertJsonToBson(string.parseJson)) + + def parseBsonDocument: BsonDocument = BsonDocument(string) + + extension (bson: BsonValue) def convertTo[E: JsonFormat]: E = convertBsonToJson(bson).convertTo[E] + + // ************************************************** + // JSON <=> BSON + // ************************************************** + + protected def convertJsonToBson(json: Json): BsonValue + protected def convertBsonToJson(bson: BsonValue): Json + +object GreenLeafJsonBsonOps: + final case class JsonBsonErr(msg: String) extends RuntimeException(msg) diff --git a/core/src/main/scala/io/github/greenleafoss/mongo/core/util/LocalDateOps.scala b/core/src/main/scala/io/github/greenleafoss/mongo/core/util/LocalDateOps.scala new file mode 100644 index 0000000..9454aec --- /dev/null +++ b/core/src/main/scala/io/github/greenleafoss/mongo/core/util/LocalDateOps.scala @@ -0,0 +1,23 @@ +package io.github.greenleafoss.mongo.core.util + +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +trait LocalDateOps: + + val LocalDateFormatter: DateTimeFormatter = DateTimeFormatter.ISO_DATE.withZone(ZoneOffset.UTC) + + extension (ld: LocalDate) + def toEpochMilli(zone: ZoneOffset = ZoneOffset.UTC): Long = ld.atStartOfDay(zone).toInstant.toEpochMilli + def printLocalDate: String = ld.format(LocalDateFormatter) + + extension (string: String) def parseLocalDate: LocalDate = LocalDate.parse(string, LocalDateFormatter) + + extension (millis: Long) + def asLocalDate(zone: ZoneOffset = ZoneOffset.UTC): LocalDate = + Instant.ofEpochMilli(millis).atZone(zone).toLocalDate + +object LocalDateOps extends LocalDateOps diff --git a/core/src/main/scala/io/github/greenleafoss/mongo/core/util/LocalDateTimeOps.scala b/core/src/main/scala/io/github/greenleafoss/mongo/core/util/LocalDateTimeOps.scala new file mode 100644 index 0000000..9b3dabe --- /dev/null +++ b/core/src/main/scala/io/github/greenleafoss/mongo/core/util/LocalDateTimeOps.scala @@ -0,0 +1,22 @@ +package io.github.greenleafoss.mongo.core.util + +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +trait LocalDateTimeOps: + + val LocalDateTimeFormatter: DateTimeFormatter = DateTimeFormatter.ISO_DATE_TIME.withZone(ZoneOffset.UTC) + + extension (ldt: LocalDateTime) + def toEpochMilli(zone: ZoneOffset = ZoneOffset.UTC): Long = ldt.toInstant(zone).toEpochMilli + def printLocalDateTime: String = ldt.format(LocalDateTimeFormatter) + + extension (string: String) def parseLocalDateTime: LocalDateTime = LocalDateTime.parse(string, LocalDateTimeFormatter) + + extension (millis: Long) + def asLocalDateTime(zone: ZoneOffset = ZoneOffset.UTC): LocalDateTime = + Instant.ofEpochMilli(millis).atZone(zone).toLocalDateTime + +object LocalDateTimeOps extends LocalDateTimeOps diff --git a/core/src/main/scala/io/github/greenleafoss/mongo/core/util/MongoExtendedJsonOps.scala b/core/src/main/scala/io/github/greenleafoss/mongo/core/util/MongoExtendedJsonOps.scala new file mode 100644 index 0000000..c29f71c --- /dev/null +++ b/core/src/main/scala/io/github/greenleafoss/mongo/core/util/MongoExtendedJsonOps.scala @@ -0,0 +1,17 @@ +package io.github.greenleafoss.mongo.core.util + +import org.bson.json.JsonMode +import org.bson.json.JsonWriterSettings + +trait MongoExtendedJsonOps: + // private val jws: JsonWriterSettings = JsonWriterSettings.builder().outputMode(JsonMode.RELAXED).build() + protected val jws: JsonWriterSettings = JsonWriterSettings.builder().outputMode(JsonMode.EXTENDED).build() + + protected val $date: String = "$date" + protected val $numberDecimal: String = "$numberDecimal" + protected val $numberDouble: String = "$numberDouble" + protected val $numberLong: String = "$numberLong" + protected val $numberInt: String = "$numberInt" + protected val $oid: String = "$oid" + protected val $regularExpression: String = "$regularExpression" + // protected val $timestamp: String = "$timestamp" diff --git a/core/src/main/scala/io/github/greenleafoss/mongo/core/util/ZonedDateTimeOps.scala b/core/src/main/scala/io/github/greenleafoss/mongo/core/util/ZonedDateTimeOps.scala new file mode 100644 index 0000000..f4efb53 --- /dev/null +++ b/core/src/main/scala/io/github/greenleafoss/mongo/core/util/ZonedDateTimeOps.scala @@ -0,0 +1,25 @@ +package io.github.greenleafoss.mongo.core.util + +import java.time._ +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.time.temporal.TemporalUnit + +trait ZonedDateTimeOps: + + val ZonedDateTimeFormatter: DateTimeFormatter = DateTimeFormatter.ISO_DATE_TIME.withZone(ZoneOffset.UTC) + + extension (zdt: ZonedDateTime) + def toEpochMilli: Long = zdt.toInstant.toEpochMilli + def printZonedDateTime: String = zdt.format(ZonedDateTimeFormatter) + + extension (string: String) def parseZonedDateTime: ZonedDateTime = ZonedDateTime.parse(string, ZonedDateTimeFormatter) + + extension (millis: Long) + def asZonedDateTime(zone: ZoneOffset = ZoneOffset.UTC): ZonedDateTime = + ZonedDateTime.ofInstant(Instant.ofEpochMilli(millis), zone) + + def now(truncate: TemporalUnit = ChronoUnit.MILLIS): ZonedDateTime = + ZonedDateTime.now(ZoneOffset.UTC).truncatedTo(truncate) + +object ZonedDateTimeOps extends ZonedDateTimeOps diff --git a/core/src/test/resources/simplelogger.properties b/core/src/test/resources/simplelogger.properties new file mode 100644 index 0000000..d734068 --- /dev/null +++ b/core/src/test/resources/simplelogger.properties @@ -0,0 +1,8 @@ +# https://www.slf4j.org/api/org/slf4j/simple/SimpleLogger.html + +# org.slf4j.simpleLogger.defaultLogLevel = trace +org.slf4j.simpleLogger.defaultLogLevel = error +org.slf4j.simpleLogger.showThreadName = false +org.slf4j.simpleLogger.showShortLogName = true +org.slf4j.simpleLogger.showDateTime = true +org.slf4j.simpleLogger.dateTimeFormat = YYYY-MM-dd '@' HH:mm:ss \ No newline at end of file diff --git a/core/src/test/scala/io/github/greenleafoss/mongo/core/bson/BsonFormatSpec.scala b/core/src/test/scala/io/github/greenleafoss/mongo/core/bson/BsonFormatSpec.scala new file mode 100644 index 0000000..df1beee --- /dev/null +++ b/core/src/test/scala/io/github/greenleafoss/mongo/core/bson/BsonFormatSpec.scala @@ -0,0 +1,26 @@ +package io.github.greenleafoss.mongo.core.bson + +import io.github.greenleafoss.mongo.core.util.GreenLeafJsonBsonOps + +import org.mongodb.scala.bson.BsonValue + +import scala.language.implicitConversions + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +trait BsonFormatSpec extends AnyFlatSpec with Matchers: + + this: GreenLeafJsonBsonOps => + + // https://www.scalatest.org/user_guide/sharing_tests + + def bsonFormat[E: JsonFormat](e: E, bson: String): Unit = + bsonFormat(e, bson, bson) + + def bsonFormat[E: JsonFormat](e: E, eToBson: String, bsonToE: String): Unit = + it should s"serialize to BSON ($e) at ${System.nanoTime()}" in: + (e: BsonValue) shouldBe eToBson.parseBson + + it should s"deserialize from BSON ($e) at ${System.nanoTime()}" in: + bsonToE.parseBson.convertTo[E] shouldBe e diff --git a/core/src/test/scala/io/github/greenleafoss/mongo/core/bson/BsonProtocolSpec.scala b/core/src/test/scala/io/github/greenleafoss/mongo/core/bson/BsonProtocolSpec.scala new file mode 100644 index 0000000..46ce88e --- /dev/null +++ b/core/src/test/scala/io/github/greenleafoss/mongo/core/bson/BsonProtocolSpec.scala @@ -0,0 +1,43 @@ +package io.github.greenleafoss.mongo.core.bson + +import io.github.greenleafoss.mongo.core.json.GreenLeafMongoJsonBasicFormats +import io.github.greenleafoss.mongo.core.model.BasicFormats +import io.github.greenleafoss.mongo.core.model.Model +import io.github.greenleafoss.mongo.core.model.Models +import io.github.greenleafoss.mongo.core.util.GreenLeafJsonBsonOps + +trait BsonProtocolSpec extends BsonFormatSpec with BasicFormats: + this: GreenLeafJsonBsonOps with GreenLeafMongoJsonBasicFormats => + + given modelJsonFormat: JsonFormat[Model] + + "BSON protocol" should behave like bsonFormat(Models.default, Models.defaultBson) + + it should behave like bsonFormat(BooleanVal, BooleanBson) + + it should behave like bsonFormat(IntVal, IntBson) + + it should behave like bsonFormat(LongVal, LongBson) + // case when we need to deserialize { $numberInt: "123" } as Long + it should behave like bsonFormat(LongFromIntVal, LongFromIntBson, LongFromIntBsonRead) + + it should behave like bsonFormat(FloatVal, FloatBson) + + it should behave like bsonFormat(DoubleVal, DoubleBson) + + // case when we need to deserialize { $numberDecimal: "1.23" } as BigDecimal + it should behave like bsonFormat(BigDecimalVal, BigDecimalBson) + // case when we need to deserialize { $numberInt: "123" } as BigDecimal + it should behave like bsonFormat(BigDecimalFromIntVal, BigDecimalFromIntBson, BigDecimalFromIntBsonRead) + // case when we need to deserialize { $numberLong: "123" } as BigDecimal + it should behave like bsonFormat(BigDecimalFromLongVal, BigDecimalFromLongBson, BigDecimalFromLongBsonRead) + // case when we need to deserialize { $numberDouble: "123.0" } as BigDecimal + it should behave like bsonFormat(BigDecimalFromDouble, BigDecimalFromDoubleBson, BigDecimalFromDoubleBsonRead) + + it should behave like bsonFormat(LocalDateVal, LocalDateBson) + + it should behave like bsonFormat(LocalDateTimeVal, LocalDateTimeBson) + + it should behave like bsonFormat(ZonedDateTimeVal, ZonedDateTimeBson) + + it should behave like bsonFormat(ObjectIdVal, ObjectIdBson) diff --git a/core/src/test/scala/io/github/greenleafoss/mongo/core/dao/EntityWithIdAsFieldDaoSpec.scala b/core/src/test/scala/io/github/greenleafoss/mongo/core/dao/EntityWithIdAsFieldDaoSpec.scala new file mode 100644 index 0000000..7da10df --- /dev/null +++ b/core/src/test/scala/io/github/greenleafoss/mongo/core/dao/EntityWithIdAsFieldDaoSpec.scala @@ -0,0 +1,197 @@ +package io.github.greenleafoss.mongo.core.dao + +import io.github.greenleafoss.mongo.core.json.GreenLeafMongoJsonBasicFormats +import io.github.greenleafoss.mongo.core.mongo.TestMongoServer +import io.github.greenleafoss.mongo.core.util.GreenLeafJsonBsonOps + +import scala.concurrent.Future +import scala.language.implicitConversions + +object EntityWithIdAsFieldDaoSpec: + + // ************************************************** + // MODEL + // ID - is a single and simple field { "id": 1, "name": ... } + // ************************************************** + final case class Building(id: Long, name: String, height: Int, floors: Int, year: Int, address: String) + + // ************************************************** + // DAO BSON PROTOCOL + // ************************************************** + + trait BuildingModelBsonProtocol extends GreenLeafMongoDaoProtocol[Long, Building]: + this: GreenLeafMongoJsonBasicFormats with GreenLeafJsonBsonOps => + + // ************************************************** + // DAO + // ************************************************** + + abstract class BuildingDao extends TestGreenLeafMongoDao[Long, Building]: + + this: BuildingModelBsonProtocol with GreenLeafMongoJsonBasicFormats with GreenLeafJsonBsonOps => + + def findByName(name: String): Future[Seq[Building]] = + find("name" $regex (pattern = name, options = "i")) + + def findByFloors(minFloors: Int): Future[Seq[Building]] = + find("floors" $gte minFloors) + + def findByAddressAndYear(address: String, year: Int): Future[Seq[Building]] = + find($and("address" $regex (pattern = address, options = "i"), "year" $gte year)) + +abstract class EntityWithIdAsFieldDaoSpec extends TestMongoServer: + + import io.github.greenleafoss.mongo.core.dao.EntityWithIdAsFieldDaoSpec.* + + protected def newBuildingDao: BuildingDao + + // https://en.wikipedia.org/wiki/List_of_tallest_buildings_in_New_York_City#Tallest_buildings + protected val BuildingsInNyc: Map[Long, Building] = Map( + 1L -> Building(1, "One World Trade Center", 541, 104, 2014, "285 Fulton Street"), + 2L -> Building(2, "432 Park Avenue", 426, 96, 2015, "432 Park Avenue"), + 3L -> Building(3, "30 Hudson Yards", 387, 73, 2019, "West 33rd Street"), + 4L -> Building(4, "Empire State Building", 381, 103, 1931, "350 Fifth Avenue"), + 5L -> Building(5, "Bank of America Tower", 366, 54, 2009, "1101 Sixth Avenue"), + 6L -> Building(6, "3 World Trade Center", 329, 80, 2018, "175 Greenwich Street"), + 7L -> Building(7, "53W53", 320, 77, 2018, "53 West 53rd Street"), + 8L -> Building(8, "Chrysler Building", 319, 77, 1930, "405 Lexington Avenue"), + 9L -> Building(9, "The New York Times Building", 319, 52, 2007, "620 Eighth Avenue"), + 10L -> Building(10, "35 Hudson Yards", 308, 72, 2018, "532-560 West 33rd Street") + ) + + "BuildingDao (id as single field)" should: + + "insert one record" in: + val dao = newBuildingDao + for insertRes <- dao.insert(BuildingsInNyc(1)) + yield insertRes.wasAcknowledged shouldBe true + + "insert multiple records" in: + val dao = newBuildingDao + for insertRes <- dao.insertMany(Seq(BuildingsInNyc(2), BuildingsInNyc(3), BuildingsInNyc(4))) + yield insertRes.wasAcknowledged shouldBe true + + "find by id" in: + val dao = newBuildingDao + for + insertRes <- dao.insert(BuildingsInNyc(5)) + findRes <- dao.findById(5) + getRes <- dao.getById(5) + yield + insertRes.wasAcknowledged shouldBe true + findRes shouldBe Some(BuildingsInNyc(5)) + getRes shouldBe BuildingsInNyc(5) + + "find by ids" in: + val dao = newBuildingDao + for + insertRes <- dao.insertMany(Seq(BuildingsInNyc(6), BuildingsInNyc(7), BuildingsInNyc(8), BuildingsInNyc(9))) + x <- dao.findByIdsIn(Seq(6, 7, 8)) + y <- dao.findByIdsIn(Seq(9)) + yield + insertRes.getInsertedIds should not be empty + x should contain allElementsOf Seq(BuildingsInNyc(6), BuildingsInNyc(7), BuildingsInNyc(8)) + y should contain allElementsOf Seq(BuildingsInNyc(9)) + + "find all" in: + val dao = newBuildingDao + for + insertRes <- dao.insertMany(BuildingsInNyc.values.toSeq) + findAllRes <- dao.findAll() + yield + insertRes.getInsertedIds should not be empty + findAllRes should contain allElementsOf Seq( + BuildingsInNyc(1), + BuildingsInNyc(2), + BuildingsInNyc(3), + BuildingsInNyc(4), + BuildingsInNyc(5), + BuildingsInNyc(6), + BuildingsInNyc(7), + BuildingsInNyc(8), + BuildingsInNyc(9), + BuildingsInNyc(10) + ) + + "findByName" in: + val dao = newBuildingDao + for + insertRes <- dao.insertMany(Seq(BuildingsInNyc(9), BuildingsInNyc(10))) + xNewYorkTimes <- dao.findByName("new york times") + x35Hudson <- dao.findByName("35 Hudson") + yield + insertRes.getInsertedIds should not be empty + xNewYorkTimes should contain only BuildingsInNyc(9) + x35Hudson should contain only BuildingsInNyc(10) + + "findByFloors" in: + val dao = newBuildingDao + for + insertRes <- dao.insertMany(BuildingsInNyc.values.toSeq) + xGte90 <- dao.findByFloors(90) + xGte100 <- dao.findByFloors(100) + yield + insertRes.getInsertedIds should not be empty + xGte90 should contain only (BuildingsInNyc(1), BuildingsInNyc(2), BuildingsInNyc(4)) + xGte100 should contain only (BuildingsInNyc(1), BuildingsInNyc(4)) + + "findByAddressAndYear" in: + val dao = newBuildingDao + for + insertRes <- dao.insertMany(BuildingsInNyc.values.toSeq) + xAvenue2000 <- dao.findByAddressAndYear("aVeNue", 2000) + yield + insertRes.getInsertedIds should not be empty + xAvenue2000 should contain only (BuildingsInNyc(2), BuildingsInNyc(5), BuildingsInNyc(9)) + + "replaceById if previous entity doesn't exist" in: + val dao = newBuildingDao + for + updateRes <- dao.replaceById(1, BuildingsInNyc(1)) + findRes <- dao.findById(1) + yield + updateRes shouldBe None + // entity doesn't exist and upsert = false by default + findRes shouldBe None + + "createOrReplaceById if previous entity doesn't exist" in: + val dao = newBuildingDao + for + updateRes <- dao.createOrReplaceById(1, BuildingsInNyc(1)) + findRes <- dao.findById(1) + getRes <- dao.getById(1) + yield + updateRes shouldBe None + // entity doesn't exist but upsert = true in this case + findRes shouldBe Some(BuildingsInNyc(1)) + getRes shouldBe BuildingsInNyc(1) + + "replaceById if previous entity exists" in: + val dao = newBuildingDao + val entityToCreate = BuildingsInNyc(1) + val entityToUpdate = BuildingsInNyc(1).copy(name = "UPDATED") + for + insertRes <- dao.insert(entityToCreate) + updateRes <- dao.replaceById(1, entityToUpdate) + findRes <- dao.findById(1) + getRes <- dao.getById(1) + yield + insertRes.wasAcknowledged shouldBe true + updateRes shouldBe Some(entityToCreate) + findRes shouldBe Some(entityToUpdate) + getRes shouldBe entityToUpdate + + "createOrReplaceById if previous entity exists" in: + val dao = newBuildingDao + val entityToCreate = BuildingsInNyc(1) + val entityToUpdate = BuildingsInNyc(1).copy(name = "UPDATED") + for + insertRes <- dao.insert(entityToCreate) + updateRes <- dao.createOrReplaceById(1, entityToUpdate) + findRes <- dao.findById(1) + getRes <- dao.getById(1) + yield + insertRes.wasAcknowledged shouldBe true + updateRes shouldBe Some(entityToCreate) + findRes shouldBe Some(entityToUpdate) + getRes shouldBe entityToUpdate diff --git a/src/test/scala/io/github/greenleafoss/mongo/EntityWithIdAsObjectDaoTest.scala b/core/src/test/scala/io/github/greenleafoss/mongo/core/dao/EntityWithIdAsObjectDaoSpec.scala similarity index 53% rename from src/test/scala/io/github/greenleafoss/mongo/EntityWithIdAsObjectDaoTest.scala rename to core/src/test/scala/io/github/greenleafoss/mongo/core/dao/EntityWithIdAsObjectDaoSpec.scala index 75a77a5..a8ff59b 100644 --- a/src/test/scala/io/github/greenleafoss/mongo/EntityWithIdAsObjectDaoTest.scala +++ b/core/src/test/scala/io/github/greenleafoss/mongo/core/dao/EntityWithIdAsObjectDaoSpec.scala @@ -1,104 +1,86 @@ -package io.github.greenleafoss.mongo +package io.github.greenleafoss.mongo.core.dao -import java.time.ZonedDateTime -import java.util.UUID +import io.github.greenleafoss.mongo.core.json.GreenLeafMongoJsonBasicFormats +import io.github.greenleafoss.mongo.core.mongo.TestMongoServer +import io.github.greenleafoss.mongo.core.util.GreenLeafJsonBsonOps +import io.github.greenleafoss.mongo.core.util.LocalDateOps +import io.github.greenleafoss.mongo.core.util.LocalDateOps.* +import io.github.greenleafoss.mongo.core.util.ZonedDateTimeOps +import io.github.greenleafoss.mongo.core.util.ZonedDateTimeOps.* -import ZonedDateTimeOps._ -import GreenLeafMongoDao.DaoBsonProtocol import org.mongodb.scala.bson.collection.immutable.Document -import org.mongodb.scala.MongoCollection -import spray.json._ - -import scala.concurrent.Future - -object EntityWithIdAsObjectDaoTest { - object ExchangeRateModel { - - // MODEL - - // https://exchangeratesapi.io/ - - object Currency extends Enumeration { - type Currency = Value - val USD = Value("USD") - val GBP = Value("GBP") - val CAD = Value("CAD") - val PLN = Value("PLN") - val JPY = Value("JPY") - val EUR = Value("EUR") - // ... - } +import java.time.ZoneOffset +import java.time.ZonedDateTime - import Currency._ +import scala.concurrent.Future +import scala.language.implicitConversions - // ID as object { "id": { "base": "USD", "date": "2019-01-18" }, "rates": ... } - case class ExchangeRateId(base: Currency, date: ZonedDateTime) - // In official driver macro codecs don't allow to use Enum value as key in map and BigDecimals - case class ExchangeRate(id: ExchangeRateId, rates: Map[Currency, BigDecimal], updated: ZonedDateTime = now) +object EntityWithIdAsObjectDaoSpec: - // JSON - trait ExchangeRateJsonProtocol extends GreenLeafJsonProtocol { - implicit lazy val ExchangeRateCurrencyFormat: JsonFormat[Currency] = enumToJsonFormatAsString(Currency) - implicit lazy val ExchangeRateIdFormat: RootJsonFormat[ExchangeRateId] = jsonFormat2(ExchangeRateId.apply) - implicit lazy val ExchangeRateFormat: RootJsonFormat[ExchangeRate] = jsonFormat3(ExchangeRate.apply) - } + // ************************************************** + // MODELS + // https://exchangeratesapi.io/ + // ************************************************** - object ExchangeRateJsonProtocol extends ExchangeRateJsonProtocol + object Currency extends Enumeration { + type Currency = Value - // BSON + val USD = Value("USD") + val GBP = Value("GBP") + val CAD = Value("CAD") + val PLN = Value("PLN") + val JPY = Value("JPY") + val EUR = Value("EUR") + // ... + } - class ExchangeRateBsonProtocol - extends ExchangeRateJsonProtocol - with GreenLeafBsonProtocol - with DaoBsonProtocol[ExchangeRateId, ExchangeRate] { + import Currency.* - override implicit lazy val ExchangeRateFormat: RootJsonFormat[ExchangeRate] = - jsonFormat(ExchangeRate.apply, "_id", "rates", "updated") + // ID as object { "id": { "base": "USD", "date": "2019-01-18" }, "rates": ... } + final case class ExchangeRateId( + base: Currency, + date: ZonedDateTime) - override implicit val idFormat: RootJsonFormat[ExchangeRateId] = ExchangeRateIdFormat - override implicit val entityFormat: RootJsonFormat[ExchangeRate] = ExchangeRateFormat - } + // In official driver macro codecs don't allow to use Enum value as key in map and BigDecimals + final case class ExchangeRate( + id: ExchangeRateId, + rates: Map[Currency, BigDecimal], + updated: ZonedDateTime = ZonedDateTimeOps.now()) - } + // ************************************************** + // DAO BSON PROTOCOL + // ************************************************** - import ExchangeRateModel._ + trait ExchangeRateDaoBsonProtocol extends GreenLeafMongoDaoProtocol[ExchangeRateId, ExchangeRate]: + this: GreenLeafMongoJsonBasicFormats with GreenLeafJsonBsonOps => - class ExchangeRateDao(collectionName: String) extends TestGreenLeafMongoDao[ExchangeRateId, ExchangeRate] { + given CurrencyFormat: JsonFormat[Currency.Currency] - override protected val collection: MongoCollection[Document] = db.getCollection(collectionName) + abstract class ExchangeRateDao extends TestGreenLeafMongoDao[ExchangeRateId, ExchangeRate]: - override protected val protocol: ExchangeRateBsonProtocol = new ExchangeRateBsonProtocol - import protocol._ + this: ExchangeRateDaoBsonProtocol with GreenLeafMongoJsonBasicFormats with GreenLeafJsonBsonOps => - def findByDate(date: ZonedDateTime): Future[Seq[ExchangeRate]] = { + def findByDate(date: ZonedDateTime): Future[Seq[ExchangeRate]] = find("_id.date" $eq date) - } - def findByDateGt(date: ZonedDateTime): Future[Seq[ExchangeRate]] = { + def findByDateGt(date: ZonedDateTime): Future[Seq[ExchangeRate]] = find("_id.date" $gt date) - } - def findByDateGte(date: ZonedDateTime): Future[Seq[ExchangeRate]] = { + def findByDateGte(date: ZonedDateTime): Future[Seq[ExchangeRate]] = find("_id.date" $gte date) - } - } +abstract class EntityWithIdAsObjectDaoSpec extends TestMongoServer: - object ExchangeRateDao { - def apply(): ExchangeRateDao = new ExchangeRateDao("test-exchange-rate-" + UUID.randomUUID()) - } -} + import io.github.greenleafoss.mongo.core.dao.EntityWithIdAsObjectDaoSpec.Currency.* -class EntityWithIdAsObjectDaoTest extends TestMongoServer { + import EntityWithIdAsObjectDaoSpec.* - import ZonedDateTimeOps.Implicits.strToDate - import EntityWithIdAsObjectDaoTest._ - import ExchangeRateModel._ - import Currency._ + protected def newExchangeRateDao: ExchangeRateDao - private val ExchangeRates = Map[String, ExchangeRate]( + private given Conversion[String, ZonedDateTime] = _.parseLocalDate.atStartOfDay(ZoneOffset.UTC) + private val ExchangeRates = Map[String, ExchangeRate]( // https://api.exchangeratesapi.io/2019-01-02?base=USD&symbols=EUR,USD,GBP,PLN,CAD,JPY "2019-01-02" -> ExchangeRate( id = ExchangeRateId(USD, "2019-01-02"), @@ -142,7 +124,7 @@ class EntityWithIdAsObjectDaoTest extends TestMongoServer { "2019-01-05" -> ExchangeRate( id = ExchangeRateId(USD, "2019-01-05"), rates = Map( - USD -> BigDecimal(1.0), + USD -> BigDecimal(1.0) // EUR -> BigDecimal(0.8769622029), // PLN -> BigDecimal(3.7671665351), // GBP -> BigDecimal(0.7891607472), @@ -152,55 +134,48 @@ class EntityWithIdAsObjectDaoTest extends TestMongoServer { ) ) - "ExchangeRateDao (id as object)" should { + "ExchangeRateDao (id as object)" should: - "insert one record" in { - val dao = ExchangeRateDao() - for { - insertRes <- dao.insert(ExchangeRates("2019-01-02")) - } yield { - insertRes.wasAcknowledged shouldBe true - } - } + "insert one record" in: + val dao = newExchangeRateDao + for insertRes <- dao.insert(ExchangeRates("2019-01-02")) + yield insertRes.wasAcknowledged shouldBe true - "insert multiple records" in { - val dao = ExchangeRateDao() - for { - insertRes <- dao.insert(Seq(ExchangeRates("2019-01-03"), ExchangeRates("2019-01-04"))) - } yield { - insertRes.getInsertedIds should not be empty - } - } + "insert multiple records" in: + val dao = newExchangeRateDao + for insertRes <- dao.insertMany(Seq(ExchangeRates("2019-01-03"), ExchangeRates("2019-01-04"))) + yield insertRes.getInsertedIds should not be empty - "find all records" in { - val dao = ExchangeRateDao() - for { - insertRes <- dao.insert(Seq( - ExchangeRates("2019-01-02"), ExchangeRates("2019-01-03"), ExchangeRates("2019-01-04"))) - xAll <- dao.findAll() - } yield { + "find all records" in: + val dao = newExchangeRateDao + for + insertRes <- dao.insertMany( + Seq(ExchangeRates("2019-01-02"), ExchangeRates("2019-01-03"), ExchangeRates("2019-01-04")) + ) + xAll <- dao.findAll() + yield insertRes.getInsertedIds should not be empty xAll should contain allElementsOf Set( - ExchangeRates("2019-01-02"), ExchangeRates("2019-01-03"), ExchangeRates("2019-01-04")) - } - } + ExchangeRates("2019-01-02"), + ExchangeRates("2019-01-03"), + ExchangeRates("2019-01-04") + ) - "find records by id" in { - val dao = ExchangeRateDao() - for { - insertRes <- dao.insert(Seq( - ExchangeRates("2019-01-02"), ExchangeRates("2019-01-03"), ExchangeRates("2019-01-04"))) - findRes <- dao.findById(ExchangeRateId(USD, "2019-01-03")) - getRes <- dao.getById(ExchangeRateId(USD, "2019-01-03")) - } yield { + "find records by id" in: + val dao = newExchangeRateDao + for + insertRes <- dao.insertMany( + Seq(ExchangeRates("2019-01-02"), ExchangeRates("2019-01-03"), ExchangeRates("2019-01-04")) + ) + findRes <- dao.findById(ExchangeRateId(USD, "2019-01-03")) + getRes <- dao.getById(ExchangeRateId(USD, "2019-01-03")) + yield insertRes.getInsertedIds should not be empty findRes shouldBe Some(ExchangeRates("2019-01-03")) getRes shouldBe ExchangeRates("2019-01-03") - } - } - "find records by id with incorrect fields ordering" in { - val dao = ExchangeRateDao() + "find records by id with incorrect fields ordering" in: + val dao = newExchangeRateDao // 2019-01-03 // "_id": { "date": { "$date": 1546473600000 }, "base": "USD" }, @@ -209,12 +184,12 @@ class EntityWithIdAsObjectDaoTest extends TestMongoServer { |{ | "_id": { "date": { "$date": "2019-01-03T00:00:00.000Z" }, "base": "USD" }, | "rates": { - | "PLN": 3.787010927, - | "CAD": 1.3563623546, - | "GBP": 0.7958406768, - | "JPY": 107.6929855481, - | "USD": 1.0, - | "EUR":0.8812125485 + | "PLN": { $numberDecimal: "3.787010927" }, + | "CAD": { $numberDecimal: "1.3563623546" }, + | "GBP": { $numberDecimal: "0.7958406768" }, + | "JPY": { $numberDecimal: "107.6929855481" }, + | "USD": { $numberDecimal: "1.0" }, + | "EUR": { $numberDecimal: "0.8812125485" } | }, | "updated": { "$date": 1548022714195 } |} @@ -228,37 +203,38 @@ class EntityWithIdAsObjectDaoTest extends TestMongoServer { |{ | "_id": { "date": { "$date": "2019-01-04T00:00:00.000Z" }, "base": "USD" }, | "rates": { - | "PLN": 3.7671665351, - | "CAD": 1.3442076646, - | "GBP": 0.7891607472, - | "JPY": 108.0417434009, - | "USD": 1.0, - | "EUR": 0.8769622029 + | "PLN": { $numberDecimal: "3.7671665351" }, + | "CAD": { $numberDecimal: "1.3442076646" }, + | "GBP": { $numberDecimal: "0.7891607472" }, + | "JPY": { $numberDecimal: "108.0417434009" }, + | "USD": { $numberDecimal: "1.0" }, + | "EUR": { $numberDecimal: "0.8769622029" } | }, | "updated": { "$date": 1548022714195 } |} """.stripMargin ) - for { + for insertRes <- dao.insertDocuments(d1, d2) - findRes <- dao.findById(ExchangeRateId(USD, "2019-01-03")) - getRes <- dao.getById(ExchangeRateId(USD, "2019-01-03")) - } yield { + findRes <- dao.findById(ExchangeRateId(USD, "2019-01-03")) + getRes <- dao.getById(ExchangeRateId(USD, "2019-01-03")) + yield insertRes.getInsertedIds should not be empty - val resetUpdated = now - findRes.map(_.copy(updated = resetUpdated)) shouldBe Some(ExchangeRates("2019-01-03").copy(updated = resetUpdated)) + val resetUpdated = ZonedDateTimeOps.now() + findRes.map(_.copy(updated = resetUpdated)) shouldBe Some( + ExchangeRates("2019-01-03").copy(updated = resetUpdated) + ) getRes.copy(updated = resetUpdated) shouldBe ExchangeRates("2019-01-03").copy(updated = resetUpdated) - } - } "find records by ids" in { - val dao = ExchangeRateDao() + val dao = newExchangeRateDao for { - insertRes <- dao.insert(Seq( - ExchangeRates("2019-01-02"), ExchangeRates("2019-01-03"), ExchangeRates("2019-01-04"))) - x <- dao.findByIdsOr(Seq(ExchangeRateId(USD, "2019-01-03"), ExchangeRateId(USD, "2019-01-04"))) - y <- dao.findByIdsOr(Seq(ExchangeRateId(USD, "2019-01-02"))) + insertRes <- dao.insertMany( + Seq(ExchangeRates("2019-01-02"), ExchangeRates("2019-01-03"), ExchangeRates("2019-01-04")) + ) + x <- dao.findByIdsOr(Seq(ExchangeRateId(USD, "2019-01-03"), ExchangeRateId(USD, "2019-01-04"))) + y <- dao.findByIdsOr(Seq(ExchangeRateId(USD, "2019-01-02"))) } yield { insertRes.getInsertedIds should not be empty x should contain allElementsOf Set(ExchangeRates("2019-01-03"), ExchangeRates("2019-01-04")) @@ -267,13 +243,14 @@ class EntityWithIdAsObjectDaoTest extends TestMongoServer { } "find records by filter" in { - val dao = ExchangeRateDao() + val dao = newExchangeRateDao for { - insertRes <- dao.insert(Seq( - ExchangeRates("2019-01-02"), ExchangeRates("2019-01-03"), ExchangeRates("2019-01-04"))) - x <- dao.findByDate(date = "2019-01-02") - y <- dao.findByDateGt(date = "2019-01-03") - z <- dao.findByDateGte(date = "2019-01-04") + insertRes <- dao.insertMany( + Seq(ExchangeRates("2019-01-02"), ExchangeRates("2019-01-03"), ExchangeRates("2019-01-04")) + ) + x <- dao.findByDate(date = "2019-01-02") + y <- dao.findByDateGt(date = "2019-01-03") + z <- dao.findByDateGte(date = "2019-01-04") } yield { insertRes.getInsertedIds should not be empty x should contain allElementsOf Set(ExchangeRates("2019-01-02")) @@ -283,9 +260,9 @@ class EntityWithIdAsObjectDaoTest extends TestMongoServer { } "insert and update records" in { - val dao = ExchangeRateDao() + val dao = newExchangeRateDao - val id = ExchangeRateId(USD, "2019-01-05") + val id = ExchangeRateId(USD, "2019-01-05") val oldRate = ExchangeRates("2019-01-05") val newRate = ExchangeRate( id = ExchangeRateId(USD, "2019-01-05"), @@ -301,11 +278,11 @@ class EntityWithIdAsObjectDaoTest extends TestMongoServer { for { insertRes <- dao.insert(oldRate) - findRes1 <- dao.findById(id) - getRes1 <- dao.getById(id) + findRes1 <- dao.findById(id) + getRes1 <- dao.getById(id) updateRes <- dao.replaceById(id, newRate) - findRes2 <- dao.findById(id) - getRes2 <- dao.getById(id) + findRes2 <- dao.findById(id) + getRes2 <- dao.getById(id) } yield { insertRes.wasAcknowledged shouldBe true findRes1 shouldBe Some(oldRate) @@ -317,10 +294,10 @@ class EntityWithIdAsObjectDaoTest extends TestMongoServer { } "replaceById if previous entity doesn't exist" in { - val dao = ExchangeRateDao() + val dao = newExchangeRateDao for { updateRes <- dao.replaceById(ExchangeRates("2019-01-02").id, ExchangeRates("2019-01-02")) - findRes <- dao.findById(ExchangeRates("2019-01-02").id) + findRes <- dao.findById(ExchangeRates("2019-01-02").id) } yield { updateRes shouldBe None // entity doesn't exist and upsert = false by default @@ -329,11 +306,11 @@ class EntityWithIdAsObjectDaoTest extends TestMongoServer { } "replaceOrInsertById if previous entity doesn't exist" in { - val dao = ExchangeRateDao() + val dao = newExchangeRateDao for { updateRes <- dao.replaceOrInsertById(ExchangeRates("2019-01-02").id, ExchangeRates("2019-01-02")) - findRes <- dao.findById(ExchangeRates("2019-01-02").id) - getRes <- dao.getById(ExchangeRates("2019-01-02").id) + findRes <- dao.findById(ExchangeRates("2019-01-02").id) + getRes <- dao.getById(ExchangeRates("2019-01-02").id) } yield { updateRes shouldBe None // entity doesn't exist and will be inserted @@ -343,14 +320,14 @@ class EntityWithIdAsObjectDaoTest extends TestMongoServer { } "replaceById if previous entity exists" in { - val dao = ExchangeRateDao() + val dao = newExchangeRateDao val createEntity = ExchangeRates("2019-01-02") val updateEntity = ExchangeRates("2019-01-02").copy(rates = Map.empty) for { insertRes <- dao.insert(createEntity) updateRes <- dao.replaceById(createEntity.id, updateEntity) - findRes <- dao.findById(updateEntity.id) - getRes <- dao.getById(updateEntity.id) + findRes <- dao.findById(updateEntity.id) + getRes <- dao.getById(updateEntity.id) } yield { insertRes.wasAcknowledged shouldBe true updateRes shouldBe Some(createEntity) @@ -360,14 +337,14 @@ class EntityWithIdAsObjectDaoTest extends TestMongoServer { } "createOrReplaceById if previous entity exists" in { - val dao = ExchangeRateDao() + val dao = newExchangeRateDao val createEntity = ExchangeRates("2019-01-02") val updateEntity = ExchangeRates("2019-01-02").copy(rates = Map.empty) for { insertRes <- dao.insert(createEntity) updateRes <- dao.createOrReplaceById(createEntity.id, updateEntity) - findRes <- dao.findById(updateEntity.id) - getRes <- dao.getById(updateEntity.id) + findRes <- dao.findById(updateEntity.id) + getRes <- dao.getById(updateEntity.id) } yield { insertRes.wasAcknowledged shouldBe true updateRes shouldBe Some(createEntity) @@ -377,14 +354,14 @@ class EntityWithIdAsObjectDaoTest extends TestMongoServer { } "replaceOrInsertById if previous entity exists" in { - val dao = ExchangeRateDao() + val dao = newExchangeRateDao val createEntity = ExchangeRates("2019-01-02") val updateEntity = ExchangeRates("2019-01-02").copy(rates = Map.empty) for { insertRes <- dao.insert(createEntity) updateRes <- dao.replaceOrInsertById(createEntity.id, updateEntity) - findRes <- dao.findById(updateEntity.id) - getRes <- dao.getById(updateEntity.id) + findRes <- dao.findById(updateEntity.id) + getRes <- dao.getById(updateEntity.id) } yield { insertRes.wasAcknowledged shouldBe true updateRes shouldBe Some(createEntity) @@ -392,7 +369,3 @@ class EntityWithIdAsObjectDaoTest extends TestMongoServer { getRes shouldBe updateEntity } } - - } - -} diff --git a/core/src/test/scala/io/github/greenleafoss/mongo/core/dao/EntityWithOptionalFieldsDaoSpec.scala b/core/src/test/scala/io/github/greenleafoss/mongo/core/dao/EntityWithOptionalFieldsDaoSpec.scala new file mode 100644 index 0000000..58ae616 --- /dev/null +++ b/core/src/test/scala/io/github/greenleafoss/mongo/core/dao/EntityWithOptionalFieldsDaoSpec.scala @@ -0,0 +1,160 @@ +package io.github.greenleafoss.mongo.core.dao + +import io.github.greenleafoss.mongo.core.json.GreenLeafMongoJsonBasicFormats +import io.github.greenleafoss.mongo.core.mongo.TestMongoServer +import io.github.greenleafoss.mongo.core.util.GreenLeafJsonBsonOps + +import org.mongodb.scala.bson.BsonNull +import org.mongodb.scala.bson.BsonValue + +import scala.concurrent.Future +import scala.language.implicitConversions + +object EntityWithOptionalFieldsDaoSpec: + + // ************************************************** + // MODELS + // http://www.geonames.org + // ************************************************** + + final case class GeoKey(country: String, state: Option[String], city: Option[String]) + object GeoKeyOps: + def apply(country: String): GeoKey = GeoKey(country, None, None) + def apply(country: String, state: String): GeoKey = GeoKey(country, Some(state), None) + def apply(country: String, state: String, city: String): GeoKey = GeoKey(country, Some(state), Some(city)) + + final case class GeoRecord(key: GeoKey, name: String, population: Int) + + // ************************************************** + // DAO BSON PROTOCOL + // ************************************************** + + trait GeoModelDaoBsonProtocol extends GreenLeafMongoDaoProtocol[GeoKey, GeoRecord]: + this: GreenLeafMongoJsonBasicFormats with GreenLeafJsonBsonOps => + + // ************************************************** + // DAO + // ************************************************** + + abstract class GeoModelDao extends TestGreenLeafMongoDao[GeoKey, GeoRecord]: + + this: GeoModelDaoBsonProtocol with GreenLeafMongoJsonBasicFormats with GreenLeafJsonBsonOps => + + def findCountryBy(countryCode: String): Future[Option[GeoRecord]] = + // will return all records with this countryCode + // val filter = s"""{ "_id.country": $countryCode }""".parseBson + + // will not works because 'state' and 'city' fields may not exist or be nulls + // val filter = s"""{ "_id": { "country": $countryCode } }""".parseBson + + findOne($and("_id.country" $eq countryCode, "_id.state" $eq BsonNull(), "_id.city" $eq BsonNull())) + + def findStateBy(countryCode: String, stateCode: String): Future[Option[GeoRecord]] = + findOne($and("_id.country" $eq countryCode, "_id.state" $eq stateCode, "_id.city" $eq BsonNull())) + + def findCityBy(countryCode: String, stateCode: String, cityCode: String): Future[Option[GeoRecord]] = + findOne($and("_id.country" $eq countryCode, "_id.state" $eq stateCode, "_id.city" $eq cityCode)) + +abstract class EntityWithOptionalFieldsDaoSpec extends TestMongoServer: + + import EntityWithOptionalFieldsDaoSpec.* + + protected def newGeoModelDao: GeoModelDao + + protected val GeoRecords: Seq[GeoRecord] = Seq( + GeoRecord(GeoKeyOps("6252001"), "United States of America", 310232863), + GeoRecord(GeoKeyOps("6252001", "5128638"), "New York", 19274244), + GeoRecord(GeoKeyOps("6252001", "5128638", "5128581"), "New York City", 8175133), + GeoRecord(GeoKeyOps("6252001", "5128638", "5133273"), "Queens", 2272771), + GeoRecord(GeoKeyOps("6252001", "5128638", "5110302"), "Brooklyn", 2300664), + GeoRecord(GeoKeyOps("6252001", "5101760"), "New Jersey", 8751436), + GeoRecord(GeoKeyOps("6252001", "5101760", "5099836"), "Jersey City", 264290), + GeoRecord(GeoKeyOps("6252001", "5101760", "5099133"), "Hoboken", 53635), + GeoRecord(GeoKeyOps("6252001", "5332921"), "California", 37691912), + GeoRecord(GeoKeyOps("6252001", "5332921", "5391959"), "San Francisco", 864816), + GeoRecord(GeoKeyOps("146669"), "Republic of Cyprus", 1102677), + GeoRecord(GeoKeyOps("2921044"), "Federal Republic of Germany", 81802257), + GeoRecord(GeoKeyOps("2658434"), "Switzerland", 8484100), + GeoRecord(GeoKeyOps("294640"), "State of Israel", 7353985), + GeoRecord(GeoKeyOps("2635167"), "United Kingdom of Great Britain and Northern Ireland", 62348447), + GeoRecord(GeoKeyOps("2750405"), "Kingdom of the Netherlands", 16645000), + GeoRecord(GeoKeyOps("2661886"), "Kingdom of Sweden", 9828655), + GeoRecord(GeoKeyOps("732800"), "Republic of Bulgaria", 7148785), + GeoRecord(GeoKeyOps("719819"), "Hungary", 9982000), + GeoRecord(GeoKeyOps("3017382"), "Republic of France", 64768389), + GeoRecord(GeoKeyOps("798544"), "Republic of Poland", 38500000), + GeoRecord(GeoKeyOps("690791"), "Ukraine", 45415596) + ) + + "GeoModelDao" should: + + "findCountryBy" in: + val dao = newGeoModelDao + for + insertRes <- dao.insertMany(GeoRecords) + + // an explicit query {"_id.country": ... } + usaByCode <- dao.findCountryBy("6252001") + // implicit query from GeoKeyOps model - will be the same as the explicit query above + usaByKey <- dao.findById(GeoKeyOps("6252001")) + + cyprusByCode <- dao.findCountryBy("146669") + cyprusByKey <- dao.findById(GeoKeyOps("146669")) + + uaByCode <- dao.findCountryBy("690791") + uaByKey <- dao.findById(GeoKeyOps("690791")) + yield + insertRes.getInsertedIds should not be empty + + usaByCode shouldBe Some(GeoRecord(GeoKeyOps("6252001"), "United States of America", 310232863)) + usaByKey shouldBe Some(GeoRecord(GeoKeyOps("6252001"), "United States of America", 310232863)) + + cyprusByCode shouldBe Some(GeoRecord(GeoKeyOps("146669"), "Republic of Cyprus", 1102677)) + cyprusByKey shouldBe Some(GeoRecord(GeoKeyOps("146669"), "Republic of Cyprus", 1102677)) + + uaByCode shouldBe Some(GeoRecord(GeoKeyOps("690791"), "Ukraine", 45415596)) + uaByKey shouldBe Some(GeoRecord(GeoKeyOps("690791"), "Ukraine", 45415596)) + + "findStateBy" in: + val dao = newGeoModelDao + for + insertRes <- dao.insertMany(GeoRecords) + + nyByCode <- dao.findStateBy("6252001", "5128638") + nyByKey <- dao.findById(GeoKeyOps("6252001", "5128638")) + + njByCode <- dao.findStateBy("6252001", "5101760") + njByKey <- dao.findById(GeoKeyOps("6252001", "5101760")) + + caByCode <- dao.findStateBy("6252001", "5332921") + caByKey <- dao.findById(GeoKeyOps("6252001", "5332921")) + yield + insertRes.getInsertedIds should not be empty + + nyByCode shouldBe Some(GeoRecord(GeoKeyOps("6252001", "5128638"), "New York", 19274244)) + nyByKey shouldBe Some(GeoRecord(GeoKeyOps("6252001", "5128638"), "New York", 19274244)) + + njByCode shouldBe Some(GeoRecord(GeoKeyOps("6252001", "5101760"), "New Jersey", 8751436)) + njByKey shouldBe Some(GeoRecord(GeoKeyOps("6252001", "5101760"), "New Jersey", 8751436)) + + caByCode shouldBe Some(GeoRecord(GeoKeyOps("6252001", "5332921"), "California", 37691912)) + caByKey shouldBe Some(GeoRecord(GeoKeyOps("6252001", "5332921"), "California", 37691912)) + + "findCityBy" in: + val dao = newGeoModelDao + for + insertRes <- dao.insertMany(GeoRecords) + + nycByCode <- dao.findCityBy("6252001", "5128638", "5128581") + nycByKey <- dao.findById(GeoKeyOps("6252001", "5128638", "5128581")) + + hbkByCode <- dao.findCityBy("6252001", "5101760", "5099133") + hbkByKey <- dao.findById(GeoKeyOps("6252001", "5101760", "5099133")) + yield + insertRes.getInsertedIds should not be empty + + nycByCode shouldBe Some(GeoRecord(GeoKeyOps("6252001", "5128638", "5128581"), "New York City", 8175133)) + nycByKey shouldBe Some(GeoRecord(GeoKeyOps("6252001", "5128638", "5128581"), "New York City", 8175133)) + + hbkByCode shouldBe Some(GeoRecord(GeoKeyOps("6252001", "5101760", "5099133"), "Hoboken", 53635)) + hbkByKey shouldBe Some(GeoRecord(GeoKeyOps("6252001", "5101760", "5099133"), "Hoboken", 53635)) diff --git a/core/src/test/scala/io/github/greenleafoss/mongo/core/dao/EntityWithoutIdDaoSpec.scala b/core/src/test/scala/io/github/greenleafoss/mongo/core/dao/EntityWithoutIdDaoSpec.scala new file mode 100644 index 0000000..7c98883 --- /dev/null +++ b/core/src/test/scala/io/github/greenleafoss/mongo/core/dao/EntityWithoutIdDaoSpec.scala @@ -0,0 +1,137 @@ +package io.github.greenleafoss.mongo.core.dao + +import io.github.greenleafoss.mongo.core.dao.EntityWithoutIdDaoSpec.EventSource.EventSource +import io.github.greenleafoss.mongo.core.json.GreenLeafMongoJsonBasicFormats +import io.github.greenleafoss.mongo.core.mongo.TestMongoServer +import io.github.greenleafoss.mongo.core.util.GreenLeafJsonBsonOps +import io.github.greenleafoss.mongo.core.util.ZonedDateTimeOps + +import org.mongodb.scala.* +import org.mongodb.scala.bson.BsonDocument +import org.mongodb.scala.bson.BsonValue +import org.mongodb.scala.bson.ObjectId +import org.mongodb.scala.model.IndexOptions +import org.mongodb.scala.model.Indexes.* + +import java.time.ZonedDateTime + +import scala.concurrent.Future +import scala.language.implicitConversions + +object EntityWithoutIdDaoSpec: + + // ************************************************** + // MODELS + // ************************************************** + + object EventSource extends Enumeration: + type EventSource = Value + + val Internal: Value = Value(1, "Internal") + val WebApp: Value = Value(2, "WebApp") + val MobileApp: Value = Value(3, "MobileApp") + val DesktopApp: Value = Value(4, "DesktopApp") + + final case class Event( + userId: Long, + source: EventSource.EventSource, + comment: String, + timestamp: ZonedDateTime = ZonedDateTimeOps.now()) + + // ************************************************** + // DAO BSON PROTOCOL + // ************************************************** + + trait EventDaoBsonProtocol extends GreenLeafMongoDaoProtocolObjectId[Event]: + this: GreenLeafMongoJsonBasicFormats with GreenLeafJsonBsonOps => + given EventSourceFormat: JsonFormat[EventSource] + + // ************************************************** + // DAO + // ************************************************** + + abstract class EventDao extends TestGreenLeafMongoDao[ObjectId, Event]: + + this: EventDaoBsonProtocol with GreenLeafMongoJsonBasicFormats with GreenLeafJsonBsonOps => + + collection.createIndex(key = ascending("timestamp"), IndexOptions().name("idx-timestamp")).toFuture() + + override def findAll(offset: Int = 0, limit: Int = 0, sortBy: BsonValue = """{timestamp: 1}""".parseBson) + : Future[Seq[Event]] = + find(BsonDocument(), offset, limit, sortBy) + + def findLastN(limit: Int = 0, sortBy: BsonValue = """{timestamp: -1}""".parseBson): Future[Seq[Event]] = + find(BsonDocument(), 0, limit, sortBy) + + def findBySource(source: EventSource.EventSource): Future[Seq[Event]] = + find("source" $eq source) + +abstract class EntityWithoutIdDaoSpec extends TestMongoServer: + + import EntityWithoutIdDaoSpec.* + + protected def newEventDao: EventDao + + private val Events = Array( + Event(1L, EventSource.WebApp, "Request to create an account"), + Event(1L, EventSource.Internal, "Account created"), + Event(1L, EventSource.WebApp, "Request to extended access"), + Event(1L, EventSource.Internal, "Request to provide additional details"), + Event(1L, EventSource.DesktopApp, "Additional details provided"), + Event(1L, EventSource.Internal, "Additional details approved"), + Event(1L, EventSource.Internal, "Access granted"), + Event(2L, EventSource.WebApp, "Request to create an account"), + Event(2L, EventSource.Internal, "Account created"), + Event(3L, EventSource.WebApp, "Request to create an account"), + Event(3L, EventSource.Internal, "Account created") + ) + + "EventDao (entity without _id)" should: + + "insert one record" in: + val dao = newEventDao + for insertRes <- dao.insert(Events(0)) + yield insertRes.wasAcknowledged shouldBe true + + "insert multiple records" in: + val dao = newEventDao + for insertRes <- dao.insertMany(Seq(Events(1), Events(2), Events(3))) + yield insertRes.getInsertedIds should not be empty + + "find all" in: + val dao = newEventDao + for + insertRes <- dao.insertMany(Seq(Events(0), Events(1), Events(2), Events(3))) + xAll <- dao.findAll() + yield + insertRes.getInsertedIds should not be empty + xAll.size shouldBe 4 + xAll should contain allElementsOf Seq(Events(0), Events(1), Events(2), Events(3)) + xAll(0) shouldBe Events(0) + xAll(1) shouldBe Events(1) + xAll(2) shouldBe Events(2) + xAll(3) shouldBe Events(3) + + "find last N events" in: + val dao = newEventDao + for + insertRes <- dao.insertMany(Seq(Events(0), Events(1), Events(2), Events(3))) + xAll <- dao.findLastN(2) + yield + insertRes.getInsertedIds should not be empty + xAll.size shouldBe 2 + xAll should contain allElementsOf Seq(Events(2), Events(3)) + xAll(0) shouldBe Events(3) // last event + xAll(1) shouldBe Events(2) // last - 1 event + + "find by source" in: + val dao = newEventDao + for + insertRes <- dao.insertMany(Seq(Events(0), Events(1), Events(2), Events(3))) + xAll <- dao.findBySource(EventSource.Internal) + yield + insertRes.getInsertedIds should not be empty + xAll.size shouldBe 2 + xAll should contain allElementsOf Seq(Events(1), Events(3)) + xAll(0) shouldBe Events(1) + xAll(1) shouldBe Events(3) diff --git a/core/src/test/scala/io/github/greenleafoss/mongo/core/dao/TestGreenLeafMongoDao.scala b/core/src/test/scala/io/github/greenleafoss/mongo/core/dao/TestGreenLeafMongoDao.scala new file mode 100644 index 0000000..58090aa --- /dev/null +++ b/core/src/test/scala/io/github/greenleafoss/mongo/core/dao/TestGreenLeafMongoDao.scala @@ -0,0 +1,31 @@ +package io.github.greenleafoss.mongo.core.dao + +import io.github.greenleafoss.mongo.core.dao.GreenLeafMongoDao +import io.github.greenleafoss.mongo.core.dao.GreenLeafMongoDaoProtocol +import io.github.greenleafoss.mongo.core.util.GreenLeafJsonBsonOps + +import org.mongodb.scala.* +import org.mongodb.scala.MongoClient +import org.mongodb.scala.MongoDatabase +import org.mongodb.scala.bson.collection.immutable.Document +import org.mongodb.scala.result.InsertManyResult + +import java.util.UUID + +import scala.concurrent.ExecutionContext +import scala.concurrent.Future + +abstract class TestGreenLeafMongoDao[Id, E] + extends GreenLeafMongoDao[Id, E](using scala.concurrent.ExecutionContext.Implicits.global): + + this: GreenLeafMongoDaoProtocol[Id, E] with GreenLeafJsonBsonOps => + + protected lazy val db: MongoDatabase = MongoClient("mongodb://localhost:27027").getDatabase("test") + + protected lazy val collectionName: String = UUID.randomUUID().toString + + override protected val collection: MongoCollection[Document] = db.getCollection(collectionName) + + def insertDocuments(documents: Document*): Future[InsertManyResult] = + // for tests to insert records with custom ordering of fields + collection.insertMany(documents).toFuture() diff --git a/core/src/test/scala/io/github/greenleafoss/mongo/core/filter/GreenLeafMongoFilterOpsSpec.scala b/core/src/test/scala/io/github/greenleafoss/mongo/core/filter/GreenLeafMongoFilterOpsSpec.scala new file mode 100644 index 0000000..f5a8383 --- /dev/null +++ b/core/src/test/scala/io/github/greenleafoss/mongo/core/filter/GreenLeafMongoFilterOpsSpec.scala @@ -0,0 +1,290 @@ +package io.github.greenleafoss.mongo.core.filter + +import io.github.greenleafoss.mongo.core.json.GreenLeafMongoJsonBasicFormats +import io.github.greenleafoss.mongo.core.util.GreenLeafJsonBsonOps + +import org.mongodb.scala.bson.BsonValue + +import scala.language.implicitConversions + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +trait GreenLeafMongoFilterOpsSpec extends AnyWordSpec with Matchers with GreenLeafMongoFilterOps: + + this: GreenLeafJsonBsonOps with GreenLeafMongoJsonBasicFormats => + + "FilterOps" when: + "$eq" in: + // https://mongodb.com/docs/manual/reference/operator/query/eq/ + + ("qty" $eq 20) shouldBe """{ qty: { $eq: { $numberInt: "20" } } }""".parseBson + ("qty" $eq 20L) shouldBe """{ qty: { $eq: { $numberLong: "20" } } }""".parseBson + ("qty" $eq 0x123456789L) shouldBe """{ qty: { $eq: { $numberLong: "4886718345" } } }""".parseBson + ("qty" $eq 20.0f) shouldBe """{ qty: { $eq: { $numberDouble: "20.0" } } }""".parseBson + ("qty" $eq 20.0d) shouldBe """{ qty: { $eq: { $numberDouble: "20.0" } } }""".parseBson + ("qty" $eq BigDecimal(20.0)) shouldBe """{ qty: { $eq: { $numberDecimal: "20.0" } } }""".parseBson + ("item.name" $eq "ab") shouldBe """{ "item.name": { $eq: "ab" } }""".parseBson + ("tags" $eq "B") shouldBe """{ tags: { $eq: "B" } }""".parseBson + ("tags" $eq Seq("A", "B")) shouldBe """{ tags: { $eq: [ "A", "B" ] } }""".parseBson + ("qty" $eq Seq(1, 2)) shouldBe """{ qty: { $eq: [ { $numberInt: "1" }, { $numberInt: "2" } ] } }""".parseBson + + "$is ($eq operator alias)" in: + // https://mongodb.com/docs/manual/reference/operator/query/eq/ + + ("qty" $is 20) shouldBe """{ qty: { $eq: { $numberInt: "20" } } }""".parseBson + ("qty" $is 20L) shouldBe """{ qty: { $eq: { $numberLong: "20" } } }""".parseBson + ("qty" $is 0x123456789L) shouldBe """{ qty: { $eq: { $numberLong: "4886718345" } } }""".parseBson + ("qty" $is 20.0f) shouldBe """{ qty: { $eq: { $numberDouble: "20.0" } } }""".parseBson + ("qty" $is 20.0d) shouldBe """{ qty: { $eq: { $numberDouble: "20.0" } } }""".parseBson + ("qty" $is BigDecimal(20.0)) shouldBe """{ qty: { $eq: { $numberDecimal: "20.0" } } }""".parseBson + ("item.name" $is "ab") shouldBe """{ "item.name": { $eq: "ab" } }""".parseBson + ("tags" $is "B") shouldBe """{ tags: { $eq: "B" } }""".parseBson + ("tags" $is Seq("A", "B")) shouldBe """{ tags: { $eq: [ "A", "B" ] } }""".parseBson + + "$gt" in: + // https://mongodb.com/docs/manual/reference/operator/query/gt/ + + ("qty" $gt 20) shouldBe """{ qty: { $gt: { $numberInt: "20" } } }""".parseBson + ("carrier.fee" $gt 2) shouldBe """{ "carrier.fee": { $gt: { $numberInt: "2" } } }""".parseBson + + "$gte" in: + // https://mongodb.com/docs/manual/reference/operator/query/gte/ + + ("qty" $gte 20) shouldBe """{ qty: { $gte: { $numberInt: "20" } } }""".parseBson + ("carrier.fee" $gte 2) shouldBe """{ "carrier.fee": { $gte: { $numberInt: "2" } } }""".parseBson + + "$in" in: + // https://mongodb.com/docs/manual/reference/operator/query/in/ + + ("qty" $in Seq(5, 15)) shouldBe """{ qty: { $in: [ 5, 15 ] } }""".parseBson + // {"qty"={"$in"=BsonArray{values=[BsonDouble{value=2.700000047683716}, BsonDouble{value=3.1414999961853027}]}}} + // ("qty" $in Seq(2.7d, 3.1415d)) shouldBe """{ qty: { $in: [ 2.7, 3.1415] } }""".parseBson + ("qty" $in Seq( + BigDecimal("2.7"), + BigDecimal("3.1415") + )) shouldBe """{ qty: { $in: [ { $numberDecimal: "2.7" }, { $numberDecimal: "3.1415" }] } }""".parseBson + ("qty" $in Seq( + 0x123456789L, + 128, + 256, + 512 + )) shouldBe """{ qty: { $in: [ { $numberLong: "4886718345" }, 128, 256, 512 ] } }""".parseBson + ("tags" $in Seq("appliances", "school")) shouldBe """{ tags: { $in: ["appliances", "school"] } }""".parseBson + + // Impossible too use $regex inside $in query https://jira.mongodb.org/browse/SERVER-14595 + // ("tags" $in Seq( + // "^be".r, + // "^st".r + // )) shouldBe """{ tags: { "$in" : [ {"$regularExpression": {"pattern": "^be", "options": ""}}, {"$regularExpression": {"pattern": "^st", "options": ""}}] } }""".parseBson + + "$lt" in: + // https://mongodb.com/docs/manual/reference/operator/query/lt/ + + ("qty" $lt 20) shouldBe """{ qty: { $lt: { $numberInt: "20" } } }""".parseBson + ("carrier.fee" $lt 20) shouldBe """{ "carrier.fee": { $lt: { $numberInt: "20" } } }""".parseBson + + "$lte" in: + // https://mongodb.com/docs/manual/reference/operator/query/lte/ + + ("qty" $lte 20) shouldBe """{ qty: { $lte: { $numberInt: "20" } } }""".parseBson + ("carrier.fee" $lte 5) shouldBe """{ "carrier.fee": { $lte: { $numberInt: "5" } } }""".parseBson + + "$ne" in: + // https://mongodb.com/docs/manual/reference/operator/query/ne/ + + ("qty" $ne 20) shouldBe """{ qty: { $ne: { $numberInt: "20" } } }""".parseBson + ("carrier.state" $ne "NY") shouldBe """{ "carrier.state": { $ne: "NY" } }""".parseBson + + "$nin" in: + // https://mongodb.com/docs/manual/reference/operator/query/nin/ + + ("qty" $nin Seq(5, 15)) shouldBe """{ qty: { $nin: [ { $numberInt: "5" }, { $numberInt: "15" } ] } }""".parseBson + ("tags" $nin Seq("appliances", "school")) shouldBe """{ tags: { $nin: [ "appliances", "school" ] } }""".parseBson + + "$and" in: + // https://mongodb.com/docs/manual/reference/operator/query/and/ + + $and("price" $ne BigDecimal("1.99"), "price" $exists true) shouldBe + """{$and: [{price: {$ne: { $numberDecimal: "1.99" }}}, {price: {$exists :true}} ]}""".parseBson + + $and( + $or("price" $eq BigDecimal("0.99"), "price" $eq BigDecimal("1.99")), + $or("sale" $eq true, "qty" $lt 20) + ) shouldBe + """ + |{ + | $and: [ + | { $or: [ { price: { $eq: { $numberDecimal: "0.99" } } }, { price: { $eq: { $numberDecimal: "1.99" } } } ] }, + | { $or: [ { sale: {$eq: true } }, { qty: { $lt : { $numberInt: "20" } } } ] } + | ] + |} + """.stripMargin.parseBson + + "$not" in: + // https://mongodb.com/docs/manual/reference/operator/query/not/ + + ("price" $not { + $gt { + BigDecimal(1.99) + } + }) shouldBe """{ price: { $not: { $gt: { $numberDecimal: "1.99" } } } }""".parseBson + + ("item" $not { + $regex { + "^p.*".r + } + }) shouldBe """{ item: { "$not" : {"$regularExpression": {"pattern": "^p.*", "options": ""}} } }""".parseBson + + "$nor" in: + // https://mongodb.com/docs/manual/reference/operator/query/nor/ + + $nor("price" $eq BigDecimal(1.99), "sale" $eq true) shouldBe + """{ $nor: [ { price: { $eq: { $numberDecimal: "1.99" } } }, { sale: { $eq: true } } ] }""".parseBson + + $nor("price" $eq BigDecimal(1.99), "qty" $lt 20, "sale" $eq true) shouldBe + """{ $nor: [ { price: { $eq: { $numberDecimal: "1.99" } } }, { qty: { $lt: 20 } }, { sale: { $eq: true } } ] }""".parseBson + + $nor( + "price" $eq BigDecimal(1.99), + "price" $exists false, + "sale" $eq true, + "sale" $exists false + ) shouldBe + """ + |{ + | $nor: [ + | { price: { $eq: { $numberDecimal: "1.99" } } }, + | { price: { $exists: false } }, + | { sale: { $eq: true } }, + | { sale: { $exists: false } } + | ] + |} + """.stripMargin.parseBson + + "$or" in: + // https://mongodb.com/docs/manual/reference/operator/query/or/ + + $or("quantity" $lt 20, "price" $eq 10) shouldBe + """{ $or: [ { quantity: { $lt: 20 } }, { price: { $eq: 10 } } ] }""".parseBson + + "$exists" in: + // https://mongodb.com/docs/manual/reference/operator/query/exists/ + + $and("qty" $exists true, "qty" $nin Seq(5, 15)) shouldBe + """{ $and: [ { qty: {$exists: true} }, { qty: { $nin: [5,15] } } ] }""".parseBson + + "$type" in: + // https://mongodb.com/docs/manual/reference/operator/query/type/ + // TODO add support of this operator + "$type" shouldBe "$type" + + "$expr" in: + // https://mongodb.com/docs/manual/reference/operator/query/expr/ + // TODO add support of this operator + "$expr" shouldBe "$expr" + + "$jsonSchema" in: + // https://mongodb.com/docs/manual/reference/operator/query/jsonSchema/ + // TODO add support of this operator + "$jsonSchema" shouldBe "$jsonSchema" + + "$mod" in: + // https://mongodb.com/docs/manual/reference/operator/query/mod/ + // TODO add support of this operator + "$mod" shouldBe "$mod" + + "$regex" in: + // https://mongodb.com/docs/manual/reference/operator/query/regex/ + + $and("name" $regex "acme.*corp".r, "name" $nin Seq("acmeblahcorp")) shouldBe + """ + |{ + | $and: [ + | { "name" : { "$regularExpression" : { "pattern": "acme.*corp", "options" : "" } } }, + | { "name" : { "$nin" : ["acmeblahcorp"] } } + | ] + |} + """.stripMargin.parseBson + + // https://github.com/lampepfl/dotty/issues/15287 + // we can't use overloaded extensions like `def $regex(r: Regex)` and `def $regex(s: String)` + $and("name" $regex (pattern = "acme.*corp", options = "i"), "name" $nin Seq("acmeblahcorp")) shouldBe + """ + |{ + | $and: [ + | { "name" : { "$regularExpression" : { "pattern": "acme.*corp", "options" : "i" } } }, + | { "name" : { "$nin" : ["acmeblahcorp"] } } + | ] + |} + """.stripMargin.parseBson + + $and("name" $regex "acme.*corp".r, "name" $nin Seq("acmeblahcorp")) shouldBe + """ + |{ + | $and: [ + | { "name" : { "$regularExpression" : { "pattern": "acme.*corp", "options" : "" } } }, + | { "name" : { "$nin" : ["acmeblahcorp"] } } + | ] + |} + """.stripMargin.parseBson + + "$text" in: + // https://mongodb.com/docs/manual/reference/operator/query/text/ + // TODO add support of this operator + "$text" shouldBe "$text" + + "$where" in: + // https://mongodb.com/docs/manual/reference/operator/query/where/ + // TODO add support of this operator + "$where" shouldBe "$where" + + "Geospatial Query Operators" in: + // https://mongodb.com/docs/manual/reference/operator/query-geospatial/ + // TODO add support of these operators + "Geospatial Query Operators" shouldBe "Geospatial Query Operators" + + "$all" in: + // https://mongodb.com/docs/manual/reference/operator/query/all/ + + ("tags" $all Seq("ssl", "security")) shouldBe """{ tags: { $all: [ "ssl" , "security" ] } }""".parseBson + + ("qty.num" $all Seq(50)) shouldBe """{ "qty.num": { $all: [ 50 ] } }""".parseBson + + ( + "qty" $all Seq( + $elemMatch($and("size" $eq "M", "num" $gt 50)), + $elemMatch($and("num" $eq 100, "color" $eq "green")) + ) + ) shouldBe + """ + |{ + | "qty": { + | "$all": [ + | { "$elemMatch": { "$and": [ { "size": { "$eq": "M" } }, { "num": { "$gt": 50 } } ] } }, + | { "$elemMatch": { "$and": [ { "num": { "$eq": 100 } }, { "color": { "$eq": "green" } } ] } } + | ] + | } + |} + """.stripMargin.parseBson + + "$elemMatch" in: + // https://mongodb.com/docs/manual/reference/operator/query/elemMatch/ + + ("results" $elemMatch $and("product" $eq "xyz", "score" $gte 8)) shouldBe + """{ results: { $elemMatch: { $and: [ { product: { $eq: "xyz" } }, { score: { $gte : 8 } }] } } }""".parseBson + + ("results" $elemMatch ("product" $eq "xyz")) shouldBe + """{ results: { $elemMatch: { product: { $eq: "xyz" } } } }""".parseBson + + "$size" in: + // https://mongodb.com/docs/manual/reference/operator/query/size/ + + ("field" $size 2) shouldBe """{ field: { $size: 2 } } """.parseBson + + ("field" $size 1) shouldBe """{ field: { $size: 1 } } """.parseBson + + "Bitwise Query Operators" in: + // https://mongodb.com/docs/manual/reference/operator/query-bitwise/ + // TODO add support of these operators + "Bitwise Query Operators" shouldBe "Bitwise Query Operators" diff --git a/core/src/test/scala/io/github/greenleafoss/mongo/core/json/JsonFormatSpec.scala b/core/src/test/scala/io/github/greenleafoss/mongo/core/json/JsonFormatSpec.scala new file mode 100644 index 0000000..2c79710 --- /dev/null +++ b/core/src/test/scala/io/github/greenleafoss/mongo/core/json/JsonFormatSpec.scala @@ -0,0 +1,22 @@ +package io.github.greenleafoss.mongo.core.json + +import io.github.greenleafoss.mongo.core.log.Log +import io.github.greenleafoss.mongo.core.util.GreenLeafJsonBsonOps + +import java.util.UUID + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +trait JsonFormatSpec extends AnyFlatSpec with Matchers with Log: + + this: GreenLeafJsonBsonOps => + + // https://www.scalatest.org/user_guide/sharing_tests + + def jsonFormat[E: JsonFormat](e: E, json: String): Unit = + it should s"serialize to JSON ($e)" in: + convertToJson(e) shouldBe parseJson(json) + + it should s"deserialize from JSON ($e)" in: + parseJson(json).convertTo[E] shouldBe e diff --git a/core/src/test/scala/io/github/greenleafoss/mongo/core/json/JsonProtocolSpec.scala b/core/src/test/scala/io/github/greenleafoss/mongo/core/json/JsonProtocolSpec.scala new file mode 100644 index 0000000..37d4f71 --- /dev/null +++ b/core/src/test/scala/io/github/greenleafoss/mongo/core/json/JsonProtocolSpec.scala @@ -0,0 +1,22 @@ +package io.github.greenleafoss.mongo.core.json + +import io.github.greenleafoss.mongo.core.model.BasicFormats +import io.github.greenleafoss.mongo.core.model.Model +import io.github.greenleafoss.mongo.core.model.Models +import io.github.greenleafoss.mongo.core.util.GreenLeafJsonBsonOps + +trait JsonProtocolSpec extends JsonFormatSpec: + this: GreenLeafJsonBsonOps with GreenLeafMongoJsonBasicFormats => + given modelJsonFormat: JsonFormat[Model] + + "JSON protocol" should behave like jsonFormat(Models.default, Models.defaultJson) + it should behave like jsonFormat(BasicFormats.IntVal, BasicFormats.IntJson) + it should behave like jsonFormat(BasicFormats.LongVal, BasicFormats.LongJson) + it should behave like jsonFormat(BasicFormats.FloatVal, BasicFormats.FloatJson) + it should behave like jsonFormat(BasicFormats.DoubleVal, BasicFormats.DoubleJson) + it should behave like jsonFormat(BasicFormats.BigDecimalVal, BasicFormats.BigDecimalJson) + it should behave like jsonFormat(BasicFormats.LocalDateVal, BasicFormats.LocalDateJson) + it should behave like jsonFormat(BasicFormats.LocalDateTimeVal, BasicFormats.LocalDateTimeJson) + it should behave like jsonFormat(BasicFormats.ZonedDateTimeVal, BasicFormats.ZonedDateTimeJson) + it should behave like jsonFormat(BasicFormats.BooleanVal, BasicFormats.BooleanJson) + it should behave like jsonFormat(BasicFormats.ObjectIdVal, BasicFormats.ObjectIdJson) diff --git a/core/src/test/scala/io/github/greenleafoss/mongo/core/model/BasicFormats.scala b/core/src/test/scala/io/github/greenleafoss/mongo/core/model/BasicFormats.scala new file mode 100644 index 0000000..541e06b --- /dev/null +++ b/core/src/test/scala/io/github/greenleafoss/mongo/core/model/BasicFormats.scala @@ -0,0 +1,72 @@ +package io.github.greenleafoss.mongo.core.model + +import io.github.greenleafoss.mongo.core.util.LocalDateOps.* +import io.github.greenleafoss.mongo.core.util.LocalDateTimeOps.* +import io.github.greenleafoss.mongo.core.util.MongoExtendedJsonOps +import io.github.greenleafoss.mongo.core.util.ZonedDateTimeOps.* + +import org.mongodb.scala.bson.ObjectId + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZonedDateTime + +trait BasicFormats extends MongoExtendedJsonOps: + + val BooleanVal: Boolean = true + val BooleanJson: String = "true" + val BooleanBson: String = "true" + + val IntVal: Int = 127 + val IntJson: String = "127" + val IntBson: String = s"""{ "${$numberInt}": "127" }""" + + val LongVal: Long = 1234567890L + val LongJson: String = "1234567890" + val LongBson: String = s"""{ "${$numberLong}": "1234567890" }""" + + val LongFromIntVal: Long = 1234567890L + val LongFromIntBson: String = s"""{ "${$numberLong}": "1234567890" }""" + val LongFromIntBsonRead: String = s"""{ "${$numberInt}": "1234567890" }""" + + val FloatVal: Float = 2.7f + val FloatJson: String = "2.7" + val FloatBson: String = s"""{ "${$numberDouble}": "2.7" }""" + + val DoubleVal: Double = 3.1415d + val DoubleJson: String = "3.1415" + val DoubleBson: String = s"""{ "${$numberDouble}": "3.1415" }""" + + val BigDecimalVal: BigDecimal = BigDecimal("3.1415926535897932384626") + val BigDecimalJson: String = "3.1415926535897932384626" + val BigDecimalBson: String = s"""{ "${$numberDecimal}": "3.1415926535897932384626" }""" + + val BigDecimalFromIntVal: BigDecimal = BigDecimal("1024") + val BigDecimalFromIntBson: String = s"""{ "${$numberDecimal}": "1024" }""" + val BigDecimalFromIntBsonRead: String = s"""{ "${$numberInt}": "1024" }""" + + val BigDecimalFromLongVal: BigDecimal = BigDecimal("512") + val BigDecimalFromLongBson: String = s"""{ "${$numberDecimal}": "512" }""" + val BigDecimalFromLongBsonRead: String = s"""{ "${$numberLong}": "512" }""" + + val BigDecimalFromDouble: BigDecimal = BigDecimal("2.56") + val BigDecimalFromDoubleBson: String = s"""{ "${$numberDecimal}": "2.56" }""" + val BigDecimalFromDoubleBsonRead: String = s"""{ "${$numberDouble}": "2.56" }""" + + val LocalDateVal: LocalDate = "1970-01-01".parseLocalDate + val LocalDateJson: String = s"\"$LocalDateVal\"" + val LocalDateBson: String = s"""{ "${$date}": { "${$numberLong}": "0" } }""" + + val LocalDateTimeVal: LocalDateTime = "1970-01-01T00:00:01".parseLocalDateTime + val LocalDateTimeJson: String = s"\"$LocalDateTimeVal\"" + val LocalDateTimeBson: String = s"""{ "${$date}": { "${$numberLong}": "1000" } }""" + + val ZonedDateTimeVal: ZonedDateTime = "1970-01-01T00:00:10Z".parseZonedDateTime + val ZonedDateTimeJson: String = s"\"$ZonedDateTimeVal\"" + val ZonedDateTimeBson: String = s"""{ "${$date}": { "${$numberLong}": "10000" } }""" + + val ObjectIdVal: ObjectId = new ObjectId("651897e08d0568496e7e4b96") + val ObjectIdJson: String = "\"651897e08d0568496e7e4b96\"" + val ObjectIdBson: String = s"""{ "${$oid}": "651897e08d0568496e7e4b96" }""" + +object BasicFormats extends BasicFormats diff --git a/core/src/test/scala/io/github/greenleafoss/mongo/core/model/Model.scala b/core/src/test/scala/io/github/greenleafoss/mongo/core/model/Model.scala new file mode 100644 index 0000000..2e120e3 --- /dev/null +++ b/core/src/test/scala/io/github/greenleafoss/mongo/core/model/Model.scala @@ -0,0 +1,16 @@ +package io.github.greenleafoss.mongo.core.model + +import org.mongodb.scala.bson.ObjectId + +import java.time.ZonedDateTime + +final case class Model( + id: Option[ObjectId] = None, + string: String, + int: Int, + long: Long, + boolean: Boolean, + zdt: ZonedDateTime, + opt: Option[String], + set: Set[Int], + list: List[Long]) diff --git a/core/src/test/scala/io/github/greenleafoss/mongo/core/model/Models.scala b/core/src/test/scala/io/github/greenleafoss/mongo/core/model/Models.scala new file mode 100644 index 0000000..7d9d3ba --- /dev/null +++ b/core/src/test/scala/io/github/greenleafoss/mongo/core/model/Models.scala @@ -0,0 +1,86 @@ +package io.github.greenleafoss.mongo.core.model + +import io.github.greenleafoss.mongo.core.util.ZonedDateTimeOps + +import org.mongodb.scala.bson.ObjectId + +object Models: + val default: Model = Model( + id = Some(new ObjectId("6513d8b74729ff3782b39571")), + string = "STRING", + int = 127, + long = 1234567890L, + boolean = true, + zdt = ZonedDateTimeOps.parseZonedDateTime("1970-01-01T00:00:00Z"), + opt = Some("defined"), + set = Set(1, 2, 3), + list = List(100L, 200L, 300L) + ) + + val defaultJson: String = + """ + |{ + | "boolean": true, + | "id": "6513d8b74729ff3782b39571", + | "int": 127, + | "list": [ + | 100, + | 200, + | 300 + | ], + | "long": 1234567890, + | "opt": "defined", + | "set": [ + | 1, + | 2, + | 3 + | ], + | "string": "STRING", + | "zdt": "1970-01-01T00:00:00Z" + |} + |""".stripMargin + + val defaultBson: String = + """ + |{ + | "boolean": true, + | "id": { + | "$oid": "6513d8b74729ff3782b39571" + | }, + | "int": { + | "$numberInt": "127" + | }, + | "long": { + | "$numberLong": "1234567890" + | }, + | "string": "STRING", + | "zdt": { + | "$date": { + | "$numberLong": "0" + | } + | }, + | "opt": "defined", + | "set": [ + | { + | "$numberInt": "1" + | }, + | { + | "$numberInt": "2" + | }, + | { + | "$numberInt": "3" + | } + | ], + | "list": [ + | { + | "$numberLong": "100" + | }, + | { + | "$numberLong": "200" + | }, + | { + | "$numberLong": "300" + | } + | ] + |} + |""".stripMargin diff --git a/core/src/test/scala/io/github/greenleafoss/mongo/core/mongo/TestMongoServer.scala b/core/src/test/scala/io/github/greenleafoss/mongo/core/mongo/TestMongoServer.scala new file mode 100644 index 0000000..d1d90bf --- /dev/null +++ b/core/src/test/scala/io/github/greenleafoss/mongo/core/mongo/TestMongoServer.scala @@ -0,0 +1,68 @@ +package io.github.greenleafoss.mongo.core.mongo + +import io.github.greenleafoss.mongo.core.log.Log + +import de.flapdoodle.embed.mongo.MongodExecutable +import de.flapdoodle.embed.mongo.MongodStarter +import de.flapdoodle.embed.mongo.config.Defaults.* +import de.flapdoodle.embed.mongo.config.MongoCmdOptions +import de.flapdoodle.embed.mongo.config.MongodConfig +import de.flapdoodle.embed.mongo.config.Net +import de.flapdoodle.embed.mongo.distribution.Version +import de.flapdoodle.embed.mongo.packageresolver.Command +import de.flapdoodle.embed.process.config.process.ProcessOutput +import de.flapdoodle.embed.process.io.ConsoleOutputStreamProcessor +import de.flapdoodle.embed.process.io.Processors +import de.flapdoodle.embed.process.io.Slf4jLevel +import de.flapdoodle.embed.process.runtime.Network +import org.scalatest.BeforeAndAfterAll +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AsyncWordSpec + +object TestMongoServer extends Log: + + // https://github.com/flapdoodle-oss/de.flapdoodle.embed.mongo#usage---optimization + + private val command = Command.MongoD + + private val runtimeConfig = runtimeConfigFor(command) + .artifactStore(extractedArtifactStoreFor(command).withDownloadConfig(downloadConfigFor(command).build())) + .processOutput( + ProcessOutput.silent() + // ProcessOutput + // .builder() + // .output(Processors.logTo(log, Slf4jLevel.DEBUG)) + // .error(Processors.logTo(log, Slf4jLevel.ERROR)) + // .commands(Processors.namedConsole("[console>]")) + // .build() + ) + .build() + + protected lazy val mongodExe: MongodExecutable = MongodStarter + .getInstance(runtimeConfig) + .prepare( + MongodConfig + .builder() + .version(Version.Main.V6_0) + .net(new Net("localhost", 27027, Network.localhostIsIPv6())) + .cmdOptions( + MongoCmdOptions + .builder() + .storageEngine("ephemeralForTest") + // https://docs.mongodb.com/manual/reference/configuration-options/#storage.syncPeriodSecs + // If you set storage.syncPeriodSecs to 0, MongoDB will not sync the memory mapped files to disk. + // If you set storage.syncPeriodSecs to 0 for testing purposes, you should also set --nojournal to true. + .syncDelay(0) + .useNoJournal(true) + .build() + ) + .build() + ) + +trait TestMongoServer extends AsyncWordSpec with Matchers with BeforeAndAfterAll: + private val mongod = TestMongoServer.mongodExe.start() + + // we can preload test data here if needed + override protected def beforeAll(): Unit = super.beforeAll() + + override protected def afterAll(): Unit = if (mongod.isProcessRunning) mongod.stop() diff --git a/core/src/test/scala/io/github/greenleafoss/mongo/core/mongo/TestMongoServerApp.scala b/core/src/test/scala/io/github/greenleafoss/mongo/core/mongo/TestMongoServerApp.scala new file mode 100644 index 0000000..1eef3b2 --- /dev/null +++ b/core/src/test/scala/io/github/greenleafoss/mongo/core/mongo/TestMongoServerApp.scala @@ -0,0 +1,28 @@ +package io.github.greenleafoss.mongo.core.mongo + +import de.flapdoodle.embed.mongo.MongodStarter +import de.flapdoodle.embed.mongo.config.Defaults.runtimeConfigFor +import de.flapdoodle.embed.mongo.config.MongodConfig +import de.flapdoodle.embed.mongo.config.Net +import de.flapdoodle.embed.mongo.distribution.Version +import de.flapdoodle.embed.mongo.packageresolver.Command +import de.flapdoodle.embed.process.runtime.Network + +object TestMongoServerApp: + + @main def main(): Unit = + val runtimeCfg = runtimeConfigFor(Command.MongoD) + // .processOutput(ProcessOutput.silent()) + // .processOutput(new ProcessOutput(new ConsoleOutputStreamProcessor(), new ConsoleOutputStreamProcessor(), new ConsoleOutputStreamProcessor())) + // .processOutput(ProcessOutput.) + .build() + + val starter: MongodStarter = MongodStarter.getInstance(runtimeCfg) + + val mongoCfg = MongodConfig + .builder() + .version(Version.Main.V6_0) + .net(new Net("localhost", 27027, Network.localhostIsIPv6())) + .build() + + starter.prepare(mongoCfg).start() diff --git a/play/src/main/scala/io/github/greenleafoss/mongo/play/bson/PlayBsonProtocol.scala b/play/src/main/scala/io/github/greenleafoss/mongo/play/bson/PlayBsonProtocol.scala new file mode 100644 index 0000000..9df883f --- /dev/null +++ b/play/src/main/scala/io/github/greenleafoss/mongo/play/bson/PlayBsonProtocol.scala @@ -0,0 +1,196 @@ +package io.github.greenleafoss.mongo.play.bson + +import io.github.greenleafoss.mongo.core.util.ZonedDateTimeOps.* +import io.github.greenleafoss.mongo.play.json.PlayJsonProtocol + +import org.mongodb.scala.bson.ObjectId + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.ZonedDateTime + +import scala.util.Try +import scala.util.matching.Regex + +import play.api.libs.json.* + +trait PlayBsonProtocol extends PlayJsonProtocol: + + /** + * https://www.mongodb.com/docs/manual/reference/mongodb-extended-json/#mongodb-bsontype-Int32 + * {{{ + * { "$numberInt": "" } + * }}} + * */ + override protected def formatInt: JsonFormat[Int] = new JsonFormat[Int]: + override def writes(v: Int): JsValue = JsObject(Map($numberInt -> JsString(v.toString))) + + override def reads(json: JsValue): JsResult[Int] = json \ $numberInt match + case JsDefined(JsString(v)) => JsSuccess(v.toInt) + case _ => JsError(s"Expected Int as {${$numberInt}: }, but got $json") + + /** + * https://www.mongodb.com/docs/manual/reference/mongodb-extended-json/#mongodb-bsontype-Int64 + * {{{ + * { "$numberLong": "" } + * }}} + */ + override protected def formatLong: JsonFormat[Long] = new JsonFormat[Long]: + override def writes(v: Long): JsValue = JsObject(Map($numberLong -> JsString(v.toString))) + + override def reads(json: JsValue): JsResult[Long] = json match + case JsObject(fields) if fields.contains($numberLong) => + json \ $numberLong match + case JsDefined(JsString(v)) => JsSuccess(v.toLong) + case _ => JsError(s"Expected Long as {${$numberLong}: }, but got $json") + + case JsObject(fields) if fields.contains($numberInt) => IntJsonFormat.reads(json).map(_.toLong) + + case _ => JsError(s"Expected Long as {${$numberLong}: }, but got $json") + + /** + * https://www.mongodb.com/docs/manual/reference/mongodb-extended-json/#mongodb-bsontype-Double + * {{{ + * {"$numberDouble": "" } + * }}} + */ + override protected def formatFloat: JsonFormat[Float] = new JsonFormat[Float]: + override def writes(v: Float): JsValue = v match + case Float.NegativeInfinity => JsObject(Map($numberDouble -> JsString("-Infinity"))) + case Float.PositiveInfinity => JsObject(Map($numberDouble -> JsString("Infinity"))) + case Float.NaN => JsObject(Map($numberDouble -> JsString("NaN"))) + case x => JsObject(Map($numberDouble -> JsString(x.toString))) + + override def reads(json: JsValue): JsResult[Float] = json \ $numberDouble match + case JsDefined(JsString("-Infinity")) => JsSuccess(Float.NegativeInfinity) + case JsDefined(JsString("Infinity")) => JsSuccess(Float.PositiveInfinity) + case JsDefined(JsString("NaN")) => JsSuccess(Float.NaN) + case JsDefined(JsString(x)) => JsSuccess(x.toFloat) + case _ => JsError(s"Expected Float as {${$numberDouble}: }, but got $json") + + /** + * https://www.mongodb.com/docs/manual/reference/mongodb-extended-json/#mongodb-bsontype-Double + * {{{ + * {"$numberDouble": "" } + * }}} + */ + override protected def formatDouble: JsonFormat[Double] = new JsonFormat[Double]: + override def writes(value: Double): JsValue = value match + case Double.NegativeInfinity => JsObject(Map($numberDouble -> JsString("-Infinity"))) + case Double.PositiveInfinity => JsObject(Map($numberDouble -> JsString("Infinity"))) + case Double.NaN => JsObject(Map($numberDouble -> JsString("NaN"))) + case x => JsObject(Map($numberDouble -> JsString(x.toString))) + + override def reads(json: JsValue): JsResult[Double] = json match + case JsObject(fields) if fields.contains($numberDouble) => + json \ $numberDouble match + case JsDefined(JsString("-Infinity")) => JsSuccess(Double.NegativeInfinity) + case JsDefined(JsString("Infinity")) => JsSuccess(Double.PositiveInfinity) + case JsDefined(JsString("NaN")) => JsSuccess(Double.NaN) + case JsDefined(JsString(x)) => JsSuccess(x.toDouble) + + case _ => JsError(s"Expected Double as {${$numberDouble}: }, but got $json") + + case _ => JsError(s"Expected Double as {${$numberDouble}: }, but got $json") + + /** + * https://www.mongodb.com/docs/manual/reference/mongodb-extended-json/#mongodb-bsontype-Decimal128 + * {{{ + * { "$numberDecimal": "" } + * }}} + */ + override protected def formatBigDecimal: JsonFormat[BigDecimal] = new JsonFormat[BigDecimal]: + override def writes(value: BigDecimal): JsValue = JsObject(Map($numberDecimal -> JsString(value.toString))) + + override def reads(json: JsValue): JsResult[BigDecimal] = json match + case JsObject(fields) if fields.contains($numberDecimal) => + json \ $numberDecimal match + case JsDefined(JsString(v)) => JsResult.fromTry(Try(BigDecimal(v))) + case _ => JsError(s"Expected BigDecimal as {${$numberDecimal}: }, but got $json") + + case JsObject(fields) if fields.contains($numberInt) => IntJsonFormat.reads(json).map(BigDecimal.apply) + case JsObject(fields) if fields.contains($numberLong) => LongJsonFormat.reads(json).map(BigDecimal.apply) + case JsObject(fields) if fields.contains($numberDouble) => DoubleJsonFormat.reads(json).map(BigDecimal.apply) + case JsString(value) => JsResult.fromTry(Try(BigDecimal(value))) + case JsNumber(value) => JsSuccess(value) + + case _ => JsError(s"Expected BigDecimal as {${$numberDecimal}: }, but got $json") + + /** + * https://www.mongodb.com/docs/upcoming/reference/mongodb-extended-json/#mongodb-bsontype-Date + * {{{ + * {"$date": {"$numberLong": ""}} + * }}} + */ + override protected def formatZonedDateTime: JsonFormat[ZonedDateTime] = new JsonFormat[ZonedDateTime]: + override def writes(value: ZonedDateTime): JsValue = JsObject( + Map($date -> LongJsonFormat.writes(value.toEpochMilli)) + ) + + override def reads(json: JsValue): JsResult[ZonedDateTime] = json \ $date match + // $date is millis + case JsDefined(millis) => LongJsonFormat.reads(millis).map(_.asZonedDateTime()) + + // unexpected json + case _ => JsError(s"Expected ZonedDateTime as {${$date}: <${$numberLong}>}, but got $json") + + /** + * https://www.mongodb.com/docs/upcoming/reference/mongodb-extended-json/#mongodb-bsontype-Date + * {{{ + * {"$date": {"$numberLong": ""}} + * }}} + */ + override protected def formatLocalDateTime: JsonFormat[LocalDateTime] = new JsonFormat[LocalDateTime]: + override def writes(value: LocalDateTime): JsValue = + ZonedDateTimeJsonFormat.writes(ZonedDateTime.from(value.atZone(ZoneOffset.UTC))) + + override def reads(json: JsValue): JsResult[LocalDateTime] = + ZonedDateTimeJsonFormat.reads(json).map(LocalDateTime.from) + + /** + * https://www.mongodb.com/docs/upcoming/reference/mongodb-extended-json/#mongodb-bsontype-Date + * {{{ + * {"$date": {"$numberLong": ""}} + * }}} + */ + override protected def formatLocalDate: JsonFormat[LocalDate] = new JsonFormat[LocalDate]: + override def writes(value: LocalDate): JsValue = LocalDateTimeJsonFormat.writes(value.atStartOfDay()) + + override def reads(json: JsValue): JsResult[LocalDate] = LocalDateTimeJsonFormat.reads(json).map(LocalDate.from) + + /** + * https://www.mongodb.com/docs/manual/reference/mongodb-extended-json/#mongodb-bsontype-ObjectId + * {{{ + * { "$oid": "" } + * }}} + */ + override protected def formatObjectId: JsonFormat[ObjectId] = new JsonFormat[ObjectId]: + override def writes(value: ObjectId): JsValue = JsObject(Map($oid -> JsString(value.toString))) + + override def reads(json: JsValue): JsResult[ObjectId] = json \ $oid match + case JsDefined(JsString(oid)) => JsSuccess(new ObjectId(oid)) + case _ => JsError(s"Expected ObjectId as {${$oid}: }, but got $json") + + /** + * https://www.mongodb.com/docs/manual/reference/mongodb-extended-json/#mongodb-bsontype-Regular-Expression + * {{{ + * { + * "$regularExpression": { + * "pattern": "", + * "options": "" + * } + * } + * }}} + */ + given RegexBsonFormat: JsonFormat[Regex] = new JsonFormat[Regex]: + override def writes(value: Regex): JsValue = + JsObject( + Map($regularExpression -> JsObject(Map("pattern" -> JsString(value.toString), "options" -> JsString("")))) + ) + + override def reads(json: JsValue): JsResult[Regex] = json \ $regularExpression \ "pattern" match + case JsDefined(JsString(pattern)) => JsSuccess(pattern.r) + case _ => JsError(s"Expected Regex as {${$regularExpression}}, but got $json") + +object PlayBsonProtocol extends PlayBsonProtocol diff --git a/play/src/main/scala/io/github/greenleafoss/mongo/play/dao/PlayMongoDao.scala b/play/src/main/scala/io/github/greenleafoss/mongo/play/dao/PlayMongoDao.scala new file mode 100644 index 0000000..7d21b58 --- /dev/null +++ b/play/src/main/scala/io/github/greenleafoss/mongo/play/dao/PlayMongoDao.scala @@ -0,0 +1,13 @@ +package io.github.greenleafoss.mongo.play.dao + +import io.github.greenleafoss.mongo.core.dao.GreenLeafMongoDao +import io.github.greenleafoss.mongo.play.util.PlayJsonBsonOps + +import scala.concurrent.ExecutionContext + +trait PlayMongoDao[Id, E]( + using + override protected val ec: ExecutionContext) + extends GreenLeafMongoDao[Id, E] + with PlayMongoDaoProtocol[Id, E] + with PlayJsonBsonOps diff --git a/play/src/main/scala/io/github/greenleafoss/mongo/play/dao/PlayMongoDaoProtocol.scala b/play/src/main/scala/io/github/greenleafoss/mongo/play/dao/PlayMongoDaoProtocol.scala new file mode 100644 index 0000000..926200f --- /dev/null +++ b/play/src/main/scala/io/github/greenleafoss/mongo/play/dao/PlayMongoDaoProtocol.scala @@ -0,0 +1,7 @@ +package io.github.greenleafoss.mongo.play.dao + +import io.github.greenleafoss.mongo.core.dao.GreenLeafMongoDaoProtocol +import io.github.greenleafoss.mongo.play.bson.PlayBsonProtocol +import io.github.greenleafoss.mongo.play.util.PlayJsonBsonOps + +trait PlayMongoDaoProtocol[Id, E] extends GreenLeafMongoDaoProtocol[Id, E] with PlayBsonProtocol with PlayJsonBsonOps diff --git a/play/src/main/scala/io/github/greenleafoss/mongo/play/dao/PlayMongoDaoProtocolObjectId.scala b/play/src/main/scala/io/github/greenleafoss/mongo/play/dao/PlayMongoDaoProtocolObjectId.scala new file mode 100644 index 0000000..9563d7c --- /dev/null +++ b/play/src/main/scala/io/github/greenleafoss/mongo/play/dao/PlayMongoDaoProtocolObjectId.scala @@ -0,0 +1,6 @@ +package io.github.greenleafoss.mongo.play.dao + +import org.mongodb.scala.bson.ObjectId + +trait PlayMongoDaoProtocolObjectId[E] extends PlayMongoDaoProtocol[ObjectId, E]: + override protected given idFormat: JsonFormat[ObjectId] = formatObjectId diff --git a/play/src/main/scala/io/github/greenleafoss/mongo/play/json/PlayJsonProtocol.scala b/play/src/main/scala/io/github/greenleafoss/mongo/play/json/PlayJsonProtocol.scala new file mode 100644 index 0000000..eb6bf25 --- /dev/null +++ b/play/src/main/scala/io/github/greenleafoss/mongo/play/json/PlayJsonProtocol.scala @@ -0,0 +1,72 @@ +package io.github.greenleafoss.mongo.play.json + +import io.github.greenleafoss.mongo.core.json.GreenLeafMongoJsonBasicFormats +import io.github.greenleafoss.mongo.core.util.ZonedDateTimeOps +import io.github.greenleafoss.mongo.play.util.PlayJsonBsonOps + +import org.mongodb.scala.bson.ObjectId + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZonedDateTime +import java.util.UUID + +import play.api.libs.json.* +import play.api.libs.json.given +trait PlayJsonProtocol extends GreenLeafMongoJsonBasicFormats with PlayJsonBsonOps: + + override protected def formatInt: JsonFormat[Int] = Format(Reads.IntReads, Writes.IntWrites) + + override protected def formatLong: JsonFormat[Long] = Format(Reads.LongReads, Writes.LongWrites) + + override protected def formatFloat: JsonFormat[Float] = Format(Reads.FloatReads, Writes.FloatWrites) + + override protected def formatDouble: JsonFormat[Double] = Format(Reads.DoubleReads, Writes.DoubleWrites) + + override protected def formatByte: JsonFormat[Byte] = Format(Reads.ByteReads, Writes.ByteWrites) + + override protected def formatShort: JsonFormat[Short] = Format(Reads.ShortReads, Writes.ShortWrites) + + override protected def formatBigDecimal: JsonFormat[BigDecimal] = Format(Reads.bigDecReads, Writes.BigDecimalWrites) + + override protected def formatBigInt: JsonFormat[BigInt] = Format(Reads.BigIntReads, Writes.BigIntWrites) + + override protected def formatUnit: JsonFormat[Unit] = new JsonFormat[Unit]: + override def reads(json: JsValue): JsResult[Unit] = JsSuccess(()) + override def writes(o: Unit): JsValue = JsObject.empty + + override protected def formatBoolean: JsonFormat[Boolean] = Format(Reads.BooleanReads, Writes.BooleanWrites) + + override protected def formatChar: JsonFormat[Char] = ??? + + override protected def formatString: JsonFormat[String] = Format(Reads.StringReads, Writes.StringWrites) + + override protected def formatSymbol: JsonFormat[Symbol] = ??? + + override protected def formatLocalDate: JsonFormat[LocalDate] = + Format(Reads.DefaultLocalDateReads, Writes.DefaultLocalDateWrites) + + override protected def formatLocalDateTime: JsonFormat[LocalDateTime] = + Format(Reads.DefaultLocalDateTimeReads, Writes.DefaultLocalDateTimeWrites) + + override protected def formatZonedDateTime: JsonFormat[ZonedDateTime] = new JsonFormat[ZonedDateTime]: + import ZonedDateTimeOps.* + + override def writes(value: ZonedDateTime): JsValue = + JsString(value.printZonedDateTime) + + def reads(json: JsValue): JsResult[ZonedDateTime] = json match + case JsString(zdt) => JsSuccess(zdt.parseZonedDateTime) + case _ => JsError(s"Expected ZonedDateTime, but got $json") + + override protected def formatUUID: JsonFormat[UUID] = Format(Reads.uuidReads, Writes.UuidWrites) + + override protected def formatObjectId: JsonFormat[ObjectId] = new JsonFormat[ObjectId]: + + def writes(obj: ObjectId): JsValue = JsString(obj.toString) + + def reads(json: JsValue): JsResult[ObjectId] = json match + case JsString(value) => JsSuccess(new ObjectId(value)) + case x => JsError(s"Expected ObjectId, but got $x") + +object PlayJsonProtocol extends PlayJsonProtocol diff --git a/play/src/main/scala/io/github/greenleafoss/mongo/play/util/PlayJsonBsonOps.scala b/play/src/main/scala/io/github/greenleafoss/mongo/play/util/PlayJsonBsonOps.scala new file mode 100644 index 0000000..7351a50 --- /dev/null +++ b/play/src/main/scala/io/github/greenleafoss/mongo/play/util/PlayJsonBsonOps.scala @@ -0,0 +1,88 @@ +package io.github.greenleafoss.mongo.play.util + +import io.github.greenleafoss.mongo.core.util.GreenLeafJsonBsonOps +import io.github.greenleafoss.mongo.core.util.GreenLeafJsonBsonOps.JsonBsonErr +import io.github.greenleafoss.mongo.core.util.ZonedDateTimeOps.* + +import org.mongodb.scala.bson.BsonArray +import org.mongodb.scala.bson.BsonBoolean +import org.mongodb.scala.bson.BsonDateTime +import org.mongodb.scala.bson.BsonDecimal128 +import org.mongodb.scala.bson.BsonDocument +import org.mongodb.scala.bson.BsonDouble +import org.mongodb.scala.bson.BsonInt32 +import org.mongodb.scala.bson.BsonInt64 +import org.mongodb.scala.bson.BsonNull +import org.mongodb.scala.bson.BsonNumber +import org.mongodb.scala.bson.BsonObjectId +import org.mongodb.scala.bson.BsonString +import org.mongodb.scala.bson.BsonValue +import org.mongodb.scala.bson.ObjectId + +import org.bson.json.JsonMode +import org.bson.json.JsonWriterSettings + +import java.time.ZonedDateTime +import java.util.Date + +import scala.jdk.CollectionConverters.* +import scala.language.implicitConversions +import scala.util.chaining.* + +import play.api.libs.json.* +import play.api.libs.json.given + +trait PlayJsonBsonOps extends GreenLeafJsonBsonOps: + + // ************************************************** + // FORMATS + // ************************************************** + + override type JsonFormat[E] = Format[E] + override type Json = JsValue + + // ************************************************** + // JSON + // ************************************************** + + extension (string: String) override def parseJson: Json = Json.parse(string) + extension [E: JsonFormat](e: E) override def convertToJson: Json = Json.toJson(e) + extension (json: Json) override def convertTo[E: JsonFormat]: E = json.as[E] + + import io.github.greenleafoss.mongo.play.bson.PlayBsonProtocol.* + import io.github.greenleafoss.mongo.play.bson.PlayBsonProtocol.given + + override protected def convertJsonToBson(json: Json): BsonValue = json match + case JsObject(x) if x.contains($oid) => BsonObjectId(json.convertTo[ObjectId]) + case JsObject(x) if x.contains($date) => BsonDateTime(json.convertTo[ZonedDateTime].toEpochMilli) + case JsObject(x) if x.contains($numberDecimal) => BsonDecimal128(json.convertTo[BigDecimal]) + case JsObject(x) if x.contains($numberDouble) => BsonDouble(json.convertTo[Double]) + case JsObject(x) if x.contains($numberLong) => BsonInt64(json.convertTo[Long]) + case JsObject(x) if x.contains($numberInt) => BsonInt32(json.convertTo[Int]) + case JsObject(x) => BsonDocument(x.map { case (k, v) => k -> convertJsonToBson(v) }) + case JsArray(x) => BsonArray.fromIterable(x.map(convertJsonToBson)) + case JsBoolean(x) => BsonBoolean(x) + case JsString(x) => BsonString(x) + case JsNull => BsonNull() + case _ => throw JsonBsonErr(s"Unknown input in JSON to BSON: $json") + + // ************************************************** + // BSON + // ************************************************** + + override protected def convertBsonToJson(bson: BsonValue): Json = bson match + case x: BsonDocument => x.toJson(jws).parseJson + case x: BsonArray => JsArray(x.getValues.iterator().asScala.map(convertBsonToJson).toVector) + case x: BsonDateTime => x.getValue.asZonedDateTime().convertToJson + case x: BsonString => x.getValue.convertToJson + case x: BsonBoolean => x.getValue.convertToJson + case x: BsonObjectId => x.getValue.convertToJson + case x: BsonInt32 => x.getValue.convertToJson + case x: BsonInt64 => x.getValue.convertToJson + case x: BsonDouble => x.getValue.convertToJson + case x: BsonDecimal128 => BigDecimal(x.decimal128Value().bigDecimalValue()).convertToJson + case _: BsonNull => JsNull + case _ => throw JsonBsonErr(s"Unknown input in BSON to JSON: $bson") + + +object PlayJsonBsonOps extends PlayJsonBsonOps diff --git a/play/src/test/scala/io/github/greenleafoss/mongo/play/bson/PlayBsonFormatSpec.scala b/play/src/test/scala/io/github/greenleafoss/mongo/play/bson/PlayBsonFormatSpec.scala new file mode 100644 index 0000000..312aa37 --- /dev/null +++ b/play/src/test/scala/io/github/greenleafoss/mongo/play/bson/PlayBsonFormatSpec.scala @@ -0,0 +1,5 @@ +package io.github.greenleafoss.mongo.play.bson + +import io.github.greenleafoss.mongo.core.bson.BsonProtocolSpec + +class PlayBsonFormatSpec extends BsonProtocolSpec with PlayModelBsonProtocol diff --git a/play/src/test/scala/io/github/greenleafoss/mongo/play/bson/PlayModelBsonProtocol.scala b/play/src/test/scala/io/github/greenleafoss/mongo/play/bson/PlayModelBsonProtocol.scala new file mode 100644 index 0000000..e98b923 --- /dev/null +++ b/play/src/test/scala/io/github/greenleafoss/mongo/play/bson/PlayModelBsonProtocol.scala @@ -0,0 +1,5 @@ +package io.github.greenleafoss.mongo.play.bson + +import io.github.greenleafoss.mongo.play.json.PlayModelJsonProtocol + +trait PlayModelBsonProtocol extends PlayModelJsonProtocol with PlayBsonProtocol diff --git a/play/src/test/scala/io/github/greenleafoss/mongo/play/dao/PlayEntityWithIdAsFieldDaoSpec.scala b/play/src/test/scala/io/github/greenleafoss/mongo/play/dao/PlayEntityWithIdAsFieldDaoSpec.scala new file mode 100644 index 0000000..9c53b27 --- /dev/null +++ b/play/src/test/scala/io/github/greenleafoss/mongo/play/dao/PlayEntityWithIdAsFieldDaoSpec.scala @@ -0,0 +1,28 @@ +package io.github.greenleafoss.mongo.play.dao + +import io.github.greenleafoss.mongo.core.dao.EntityWithIdAsFieldDaoSpec +import io.github.greenleafoss.mongo.core.dao.EntityWithIdAsFieldDaoSpec.* + +import play.api.libs.functional.syntax.* +import play.api.libs.functional.syntax.given +import play.api.libs.json.* + +class PlayEntityWithIdAsFieldDaoSpec extends EntityWithIdAsFieldDaoSpec: + + private trait PlayBuildingModelBsonProtocol + extends BuildingModelBsonProtocol + with PlayMongoDaoProtocol[Long, Building]: + override given idFormat: JsonFormat[Long] = formatLong + override given eFormat: JsonFormat[Building] = ( + (JsPath \ "_id").format[Long] and + (JsPath \ "name").format[String] and + (JsPath \ "height").format[Int] and + (JsPath \ "floors").format[Int] and + (JsPath \ "year").format[Int] and + (JsPath \ "address").format[String] + // )(Building.apply, unlift(Building.unapply)) + )(Building.apply, b => (b.id, b.name, b.height, b.floors, b.year, b.address)) + + private class PlayBuildingDao extends BuildingDao with PlayBuildingModelBsonProtocol + + override protected def newBuildingDao: BuildingDao = PlayBuildingDao() diff --git a/play/src/test/scala/io/github/greenleafoss/mongo/play/dao/PlayEntityWithIdAsObjectDaoSpec.scala b/play/src/test/scala/io/github/greenleafoss/mongo/play/dao/PlayEntityWithIdAsObjectDaoSpec.scala new file mode 100644 index 0000000..f382231 --- /dev/null +++ b/play/src/test/scala/io/github/greenleafoss/mongo/play/dao/PlayEntityWithIdAsObjectDaoSpec.scala @@ -0,0 +1,35 @@ +package io.github.greenleafoss.mongo.play.dao + +import io.github.greenleafoss.mongo.core.dao.EntityWithIdAsObjectDaoSpec +import io.github.greenleafoss.mongo.core.dao.EntityWithIdAsObjectDaoSpec.* +import io.github.greenleafoss.mongo.core.dao.EntityWithIdAsObjectDaoSpec.Currency + +import java.time.ZonedDateTime + +import scala.util.Try + +import play.api.libs.functional.syntax.* +import play.api.libs.json.* +import play.api.libs.json.given + +class PlayEntityWithIdAsObjectDaoSpec extends EntityWithIdAsObjectDaoSpec: + private trait PlayExchangeRateDaoBsonProtocol + extends ExchangeRateDaoBsonProtocol + with PlayMongoDaoProtocol[ExchangeRateId, ExchangeRate]: + override given CurrencyFormat: JsonFormat[Currency.Currency] = Json.formatEnum(Currency) + + given CurrencyKeyReads: KeyReads[Currency.Currency] = KeyReads(x => JsResult.fromTry(Try(Currency.withName(x)))) + given CurrencyKeyWrites: KeyWrites[Currency.Currency] = KeyWrites(_.toString) + + override given idFormat: JsonFormat[ExchangeRateId] = Json.format[ExchangeRateId] + + // override given eFormat: JsonFormat[ExchangeRate] = Json.format[ExchangeRate] + override given eFormat: JsonFormat[ExchangeRate] = ( + (JsPath \ "_id").format[ExchangeRateId] and + (JsPath \ "rates").format[Map[Currency.Currency, BigDecimal]] and + (JsPath \ "updated").format[ZonedDateTime] + )(ExchangeRate.apply, e => (e.id, e.rates, e.updated)) + + private class PlayExchangeRateDao extends ExchangeRateDao with PlayExchangeRateDaoBsonProtocol + + override protected def newExchangeRateDao: ExchangeRateDao = PlayExchangeRateDao() diff --git a/play/src/test/scala/io/github/greenleafoss/mongo/play/dao/PlayEntityWithOptionalFieldsDaoSpec.scala b/play/src/test/scala/io/github/greenleafoss/mongo/play/dao/PlayEntityWithOptionalFieldsDaoSpec.scala new file mode 100644 index 0000000..ad5bd92 --- /dev/null +++ b/play/src/test/scala/io/github/greenleafoss/mongo/play/dao/PlayEntityWithOptionalFieldsDaoSpec.scala @@ -0,0 +1,23 @@ +package io.github.greenleafoss.mongo.play.dao + +import io.github.greenleafoss.mongo.core.dao.EntityWithOptionalFieldsDaoSpec +import io.github.greenleafoss.mongo.core.dao.EntityWithOptionalFieldsDaoSpec.* + +import play.api.libs.functional.syntax.* +import play.api.libs.json.* + +class PlayEntityWithOptionalFieldsDaoSpec extends EntityWithOptionalFieldsDaoSpec: + + private trait PlayGeoModelDaoBsonProtocol + extends GeoModelDaoBsonProtocol + with PlayMongoDaoProtocol[GeoKey, GeoRecord]: + override protected given idFormat: JsonFormat[GeoKey] = Json.format[GeoKey] + override protected given eFormat: JsonFormat[GeoRecord] = ( + // we want to use 'key' field as '_id' + (JsPath \ "_id").format[GeoKey] and + (JsPath \ "name").format[String] and + (JsPath \ "population").format[Int] + )(GeoRecord.apply, x => (x.key, x.name, x.population)) + + private class PlayGeoModelDao extends GeoModelDao with PlayGeoModelDaoBsonProtocol + override protected def newGeoModelDao: GeoModelDao = PlayGeoModelDao() diff --git a/play/src/test/scala/io/github/greenleafoss/mongo/play/dao/PlayEntityWithoutIdDaoSpec.scala b/play/src/test/scala/io/github/greenleafoss/mongo/play/dao/PlayEntityWithoutIdDaoSpec.scala new file mode 100644 index 0000000..2f15c23 --- /dev/null +++ b/play/src/test/scala/io/github/greenleafoss/mongo/play/dao/PlayEntityWithoutIdDaoSpec.scala @@ -0,0 +1,17 @@ +package io.github.greenleafoss.mongo.play.dao + +import io.github.greenleafoss.mongo.core.dao.EntityWithoutIdDaoSpec +import io.github.greenleafoss.mongo.core.dao.EntityWithoutIdDaoSpec.* +import io.github.greenleafoss.mongo.core.dao.EntityWithoutIdDaoSpec.EventSource.EventSource + +import play.api.libs.json.Json + +class PlayEntityWithoutIdDaoSpec extends EntityWithoutIdDaoSpec: + + private trait PlayEventDaoBsonProtocol extends EventDaoBsonProtocol with PlayMongoDaoProtocolObjectId[Event]: + override given EventSourceFormat: JsonFormat[EventSource.EventSource] = Json.formatEnum(EventSource) + override given eFormat: JsonFormat[Event] = Json.format[Event] + + private class PlayEventDao extends EventDao with PlayEventDaoBsonProtocol + + override protected def newEventDao: EventDao = PlayEventDao() diff --git a/play/src/test/scala/io/github/greenleafoss/mongo/play/filter/PlayFilterSpec.scala b/play/src/test/scala/io/github/greenleafoss/mongo/play/filter/PlayFilterSpec.scala new file mode 100644 index 0000000..a91e5f2 --- /dev/null +++ b/play/src/test/scala/io/github/greenleafoss/mongo/play/filter/PlayFilterSpec.scala @@ -0,0 +1,11 @@ +package io.github.greenleafoss.mongo.play.filter + +import io.github.greenleafoss.mongo.core.filter.GreenLeafMongoFilterOpsSpec +import io.github.greenleafoss.mongo.play.bson.PlayBsonProtocol +import io.github.greenleafoss.mongo.play.util.PlayJsonBsonOps + +import scala.language.implicitConversions + +import org.scalatest.wordspec.AnyWordSpec + +class PlayFilterSpec extends GreenLeafMongoFilterOpsSpec with PlayBsonProtocol with PlayJsonBsonOps diff --git a/play/src/test/scala/io/github/greenleafoss/mongo/play/json/PlayJsonFormatSpec.scala b/play/src/test/scala/io/github/greenleafoss/mongo/play/json/PlayJsonFormatSpec.scala new file mode 100644 index 0000000..e3a34b4 --- /dev/null +++ b/play/src/test/scala/io/github/greenleafoss/mongo/play/json/PlayJsonFormatSpec.scala @@ -0,0 +1,5 @@ +package io.github.greenleafoss.mongo.play.json + +import io.github.greenleafoss.mongo.core.json.JsonProtocolSpec + +class PlayJsonFormatSpec extends JsonProtocolSpec with PlayModelJsonProtocol diff --git a/play/src/test/scala/io/github/greenleafoss/mongo/play/json/PlayModelJsonProtocol.scala b/play/src/test/scala/io/github/greenleafoss/mongo/play/json/PlayModelJsonProtocol.scala new file mode 100644 index 0000000..b7c2490 --- /dev/null +++ b/play/src/test/scala/io/github/greenleafoss/mongo/play/json/PlayModelJsonProtocol.scala @@ -0,0 +1,10 @@ +package io.github.greenleafoss.mongo.play.json + +import io.github.greenleafoss.mongo.core.model.Model + +import PlayJsonProtocol.given +import play.api.libs.json.* +import play.api.libs.json.given + +trait PlayModelJsonProtocol extends PlayJsonProtocol: + given modelJsonFormat: JsonFormat[Model] = Json.format[Model] diff --git a/project/build.properties b/project/build.properties index 875272d..dde206f 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.8.2 \ No newline at end of file +sbt.version=1.9.6 \ No newline at end of file diff --git a/project/plugins.sbt b/project/plugins.sbt index f2f5eb2..cb6ef6c 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,3 @@ -addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.9.21") -addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.2.1") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.6") +addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.9.18") +addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.2.1") diff --git a/spray/src/main/scala/io/github/greenleafoss/mongo/spray/bson/SprayBsonProtocol.scala b/spray/src/main/scala/io/github/greenleafoss/mongo/spray/bson/SprayBsonProtocol.scala new file mode 100644 index 0000000..c9c1218 --- /dev/null +++ b/spray/src/main/scala/io/github/greenleafoss/mongo/spray/bson/SprayBsonProtocol.scala @@ -0,0 +1,215 @@ +package io.github.greenleafoss.mongo.spray.bson + +import io.github.greenleafoss.mongo.core.util.ZonedDateTimeOps.* +import io.github.greenleafoss.mongo.spray.json.SprayJsonProtocol + +import org.mongodb.scala.bson.ObjectId + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.ZonedDateTime + +import scala.util.matching.Regex + +import spray.json.* + +trait SprayBsonProtocol extends SprayJsonProtocol: + + /** + * https://www.mongodb.com/docs/manual/reference/mongodb-extended-json/#mongodb-bsontype-Int32 + * {{{ + * { "$numberInt": "" } + * }}} + **/ + override protected def formatInt: JsonFormat[Int] = new JsonFormat[Int]: + override def write(v: Int): JsValue = JsObject($numberInt -> JsString(v.toString)) + + override def read(json: JsValue): Int = json match + case JsObject(fields) if fields.contains($numberInt) => + fields($numberInt) match + case JsString(v) => v.toInt + case _ => deserializationError(s"Expected Int as {${$numberInt}: }, but got $json") + + case _ => deserializationError(s"Expected Int as {${$numberInt}: }, but got $json") + + /** + * https://www.mongodb.com/docs/manual/reference/mongodb-extended-json/#mongodb-bsontype-Int64 + * {{{ + * { "$numberLong": "" } + * }}} + */ + override protected def formatLong: JsonFormat[Long] = new JsonFormat[Long]: + override def write(v: Long): JsValue = JsObject($numberLong -> JsString(v.toString)) + + override def read(json: JsValue): Long = json match + case JsObject(fields) if fields.contains($numberLong) => + fields($numberLong) match + case JsString(v) => v.toLong + case _ => deserializationError(s"Expected Long as {${$numberLong}: }, but got $json") + + case JsObject(fields) if fields.contains($numberInt) => IntJsonFormat.read(json).toLong + + case _ => deserializationError(s"Expected Long as {${$numberLong}: }, but got $json") + + /** + * https://www.mongodb.com/docs/manual/reference/mongodb-extended-json/#mongodb-bsontype-Double + * {{{ + * {"$numberDouble": "" } + * }}} + */ + override protected def formatFloat: JsonFormat[Float] = new JsonFormat[Float]: + override def write(v: Float): JsValue = v match + case Float.NegativeInfinity => JsObject($numberDouble -> JsString("-Infinity")) + case Float.PositiveInfinity => JsObject($numberDouble -> JsString("Infinity")) + case Float.NaN => JsObject($numberDouble -> JsString("NaN")) + case x => JsObject($numberDouble -> JsString(x.toString)) + + override def read(json: JsValue): Float = json match + case JsObject(fields) if fields.contains($numberDouble) => + fields($numberDouble) match + case JsString("-Infinity") => Float.NegativeInfinity + case JsString("Infinity") => Float.PositiveInfinity + case JsString("NaN") => Float.NaN + case JsString(x) => x.toFloat + + case _ => deserializationError(s"Expected Float as {${$numberDouble}: }, but got $json") + + case _ => deserializationError(s"Expected Float as {${$numberDouble}: }, but got $json") + + /** + * https://www.mongodb.com/docs/manual/reference/mongodb-extended-json/#mongodb-bsontype-Double + * {{{ + * {"$numberDouble": "" } + * }}} + */ + override protected def formatDouble: JsonFormat[Double] = new JsonFormat[Double]: + override def write(value: Double): JsValue = value match + case Double.NegativeInfinity => JsObject($numberDouble -> JsString("-Infinity")) + case Double.PositiveInfinity => JsObject($numberDouble -> JsString("Infinity")) + case Double.NaN => JsObject($numberDouble -> JsString("NaN")) + case x => JsObject($numberDouble -> JsString(x.toString)) + + override def read(json: JsValue): Double = json match + case JsObject(fields) if fields.contains($numberDouble) => + fields($numberDouble) match + case JsString("-Infinity") => Double.NegativeInfinity + case JsString("Infinity") => Double.PositiveInfinity + case JsString("NaN") => Double.NaN + case JsString(x) => x.toDouble + + case _ => deserializationError(s"Expected Double as {${$numberDouble}: }, but got $json") + + case _ => deserializationError(s"Expected Double as {${$numberDouble}: }, but got $json") + + /** + * https://www.mongodb.com/docs/manual/reference/mongodb-extended-json/#mongodb-bsontype-Decimal128 + * {{{ + * { "$numberDecimal": "" } + * }}} + */ + override protected def formatBigDecimal: JsonFormat[BigDecimal] = new JsonFormat[BigDecimal]: + override def write(value: BigDecimal): JsValue = JsObject($numberDecimal -> JsString(value.toString)) + + override def read(json: JsValue): BigDecimal = json match + case JsObject(fields) if fields.contains($numberDecimal) => + fields($numberDecimal) match + case JsString(v) => BigDecimal(v) + case _ => deserializationError(s"Expected BigDecimal as {${$numberDecimal}: }, but got $json") + + case JsObject(fields) if fields.contains($numberInt) => BigDecimal(IntJsonFormat.read(json)) + case JsObject(fields) if fields.contains($numberLong) => BigDecimal(LongJsonFormat.read(json)) + case JsObject(fields) if fields.contains($numberDouble) => BigDecimal(DoubleJsonFormat.read(json)) + case JsString(value) => BigDecimal(value) + case JsNumber(value) => value + + case _ => deserializationError(s"Expected BigDecimal as {${$numberDecimal}: }, but got $json") + + /** + * https://www.mongodb.com/docs/upcoming/reference/mongodb-extended-json/#mongodb-bsontype-Date + * {{{ + * {"$date": {"$numberLong": ""}} + * }}} + */ + override protected def formatZonedDateTime: JsonFormat[ZonedDateTime] = new JsonFormat[ZonedDateTime]: + override def write(value: ZonedDateTime): JsValue = JsObject($date -> LongJsonFormat.write(value.toEpochMilli)) + + override def read(json: JsValue): ZonedDateTime = json match + // $date is millis + case JsObject(fields) if fields.contains($date) => LongJsonFormat.read(fields($date)).asZonedDateTime() + + // unexpected json + case _ => deserializationError(s"Expected ZonedDateTime as {${$date}: <${$numberLong}>}, but got $json") + + /** + * https://www.mongodb.com/docs/upcoming/reference/mongodb-extended-json/#mongodb-bsontype-Date + * {{{ + * {"$date": {"$numberLong": ""}} + * }}} + */ + override protected def formatLocalDateTime: JsonFormat[LocalDateTime] = new JsonFormat[LocalDateTime]: + override def write(value: LocalDateTime): JsValue = + ZonedDateTimeJsonFormat.write(ZonedDateTime.from(value.atZone(ZoneOffset.UTC))) + override def read(json: JsValue): LocalDateTime = LocalDateTime.from(ZonedDateTimeJsonFormat.read(json)) + + /** + * https://www.mongodb.com/docs/upcoming/reference/mongodb-extended-json/#mongodb-bsontype-Date + * {{{ + * {"$date": {"$numberLong": ""}} + * }}} + */ + override protected def formatLocalDate: JsonFormat[LocalDate] = new JsonFormat[LocalDate]: + override def write(value: LocalDate): JsValue = LocalDateTimeJsonFormat.write(value.atStartOfDay()) + override def read(json: JsValue): LocalDate = LocalDate.from(LocalDateTimeJsonFormat.read(json)) + + /** + * https://www.mongodb.com/docs/manual/reference/mongodb-extended-json/#mongodb-bsontype-ObjectId + * {{{ + * { "$oid": "" } + * }}} + */ + override protected def formatObjectId: JsonFormat[ObjectId] = new JsonFormat[ObjectId]: + override def write(value: ObjectId): JsValue = JsObject($oid -> JsString(value.toString)) + + override def read(json: JsValue): ObjectId = json match + case JsObject(fields) if fields.contains($oid) => + fields($oid) match + case JsString(oid) => new ObjectId(oid) + case _ => deserializationError(s"Expected ObjectId as {${$oid}: }, but got $json") + + case _ => deserializationError(s"Expected ObjectId as {${$oid}: }, but got $json") + + /** + * https://www.mongodb.com/docs/manual/reference/mongodb-extended-json/#mongodb-bsontype-Regular-Expression + * {{{ + * { + * "$regularExpression": { + * "pattern": "", + * "options": "" + * } + * } + * }}} + */ + given RegexBsonFormat: JsonFormat[Regex] = new JsonFormat[Regex]: + override def write(value: Regex): JsValue = + JsObject( + $regularExpression -> JsObject( + "pattern" -> JsString(value.toString), + "options" -> JsString.empty + ) + ) + + override def read(json: JsValue): Regex = json match + case JsObject(fields) if fields.contains($regularExpression) => + fields($regularExpression) match + case JsObject(regularExpression) if regularExpression.contains("pattern") => + regularExpression("pattern") match + case JsString(pattern) => pattern.r + + case _ => deserializationError(s"Expected Regex as {${$regularExpression}}, but got $json") + + case _ => deserializationError(s"Expected Regex as {${$regularExpression}}, but got $json") + + case _ => deserializationError(s"Expected Regex as {${$regularExpression}}, but got $json") + +object SprayBsonProtocol extends SprayBsonProtocol diff --git a/spray/src/main/scala/io/github/greenleafoss/mongo/spray/dao/SprayMongoDao.scala b/spray/src/main/scala/io/github/greenleafoss/mongo/spray/dao/SprayMongoDao.scala new file mode 100644 index 0000000..2408f69 --- /dev/null +++ b/spray/src/main/scala/io/github/greenleafoss/mongo/spray/dao/SprayMongoDao.scala @@ -0,0 +1,13 @@ +package io.github.greenleafoss.mongo.spray.dao + +import io.github.greenleafoss.mongo.core.dao.GreenLeafMongoDao +import io.github.greenleafoss.mongo.spray.util.SprayJsonBsonOps + +import scala.concurrent.ExecutionContext + +abstract class SprayMongoDao[Id, E]( + using + override protected val ec: ExecutionContext) + extends GreenLeafMongoDao[Id, E] + with SprayMongoDaoProtocol[Id, E] + with SprayJsonBsonOps diff --git a/spray/src/main/scala/io/github/greenleafoss/mongo/spray/dao/SprayMongoDaoProtocol.scala b/spray/src/main/scala/io/github/greenleafoss/mongo/spray/dao/SprayMongoDaoProtocol.scala new file mode 100644 index 0000000..f0f9d5e --- /dev/null +++ b/spray/src/main/scala/io/github/greenleafoss/mongo/spray/dao/SprayMongoDaoProtocol.scala @@ -0,0 +1,7 @@ +package io.github.greenleafoss.mongo.spray.dao + +import io.github.greenleafoss.mongo.core.dao.GreenLeafMongoDaoProtocol +import io.github.greenleafoss.mongo.spray.bson.SprayBsonProtocol +import io.github.greenleafoss.mongo.spray.util.SprayJsonBsonOps + +trait SprayMongoDaoProtocol[Id, E] extends GreenLeafMongoDaoProtocol[Id, E] with SprayBsonProtocol with SprayJsonBsonOps diff --git a/spray/src/main/scala/io/github/greenleafoss/mongo/spray/dao/SprayMongoDaoProtocolObjectId.scala b/spray/src/main/scala/io/github/greenleafoss/mongo/spray/dao/SprayMongoDaoProtocolObjectId.scala new file mode 100644 index 0000000..960c5cd --- /dev/null +++ b/spray/src/main/scala/io/github/greenleafoss/mongo/spray/dao/SprayMongoDaoProtocolObjectId.scala @@ -0,0 +1,6 @@ +package io.github.greenleafoss.mongo.spray.dao + +import org.mongodb.scala.bson.ObjectId + +trait SprayMongoDaoProtocolObjectId[E] extends SprayMongoDaoProtocol[ObjectId, E]: + override protected given idFormat: JsonFormat[ObjectId] = formatObjectId diff --git a/spray/src/main/scala/io/github/greenleafoss/mongo/spray/json/SprayJsonProtocol.scala b/spray/src/main/scala/io/github/greenleafoss/mongo/spray/json/SprayJsonProtocol.scala new file mode 100644 index 0000000..9d8b502 --- /dev/null +++ b/spray/src/main/scala/io/github/greenleafoss/mongo/spray/json/SprayJsonProtocol.scala @@ -0,0 +1,95 @@ +package io.github.greenleafoss.mongo.spray.json + +import io.github.greenleafoss.mongo.core.json.GreenLeafMongoJsonBasicFormats +import io.github.greenleafoss.mongo.core.util.LocalDateOps.* +import io.github.greenleafoss.mongo.core.util.LocalDateTimeOps.* +import io.github.greenleafoss.mongo.core.util.ZonedDateTimeOps.* +import io.github.greenleafoss.mongo.spray.util.SprayJsonBsonOps + +import org.mongodb.scala.bson.ObjectId + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZonedDateTime +import java.util.UUID + +import spray.json.* + +trait SprayJsonProtocol + extends GreenLeafMongoJsonBasicFormats + with SprayJsonBsonOps + with StandardFormats + with CollectionFormats + with ProductFormats + with AdditionalFormats: + + override protected def formatInt: JsonFormat[Int] = DefaultJsonProtocol.IntJsonFormat + + override protected def formatLong: JsonFormat[Long] = DefaultJsonProtocol.LongJsonFormat + + override protected def formatFloat: JsonFormat[Float] = DefaultJsonProtocol.FloatJsonFormat + + override protected def formatDouble: JsonFormat[Double] = DefaultJsonProtocol.DoubleJsonFormat + + override protected def formatByte: JsonFormat[Byte] = DefaultJsonProtocol.ByteJsonFormat + + override protected def formatShort: JsonFormat[Short] = DefaultJsonProtocol.ShortJsonFormat + + override protected def formatBigDecimal: JsonFormat[BigDecimal] = DefaultJsonProtocol.BigDecimalJsonFormat + + override protected def formatBigInt: JsonFormat[BigInt] = DefaultJsonProtocol.BigIntJsonFormat + + override protected def formatUnit: JsonFormat[Unit] = DefaultJsonProtocol.UnitJsonFormat + + override protected def formatBoolean: JsonFormat[Boolean] = DefaultJsonProtocol.BooleanJsonFormat + + override protected def formatChar: JsonFormat[Char] = DefaultJsonProtocol.CharJsonFormat + + override protected def formatString: JsonFormat[String] = DefaultJsonProtocol.StringJsonFormat + + override protected def formatSymbol: JsonFormat[Symbol] = DefaultJsonProtocol.SymbolJsonFormat + + override protected def formatLocalDate: JsonFormat[LocalDate] = new JsonFormat[LocalDate]: + def write(value: LocalDate): JsValue = JsString(value.printLocalDate) + + def read(json: JsValue): LocalDate = json match + case JsString(ld) => ld.parseLocalDate + case _ => deserializationError(s"Expected LocalDateTime, but got $json") + + override protected def formatLocalDateTime: JsonFormat[LocalDateTime] = new JsonFormat[LocalDateTime]: + def write(value: LocalDateTime): JsValue = JsString(value.printLocalDateTime) + + def read(json: JsValue): LocalDateTime = json match + case JsString(ldt) => ldt.parseLocalDateTime + case _ => deserializationError(s"Expected LocalDateTime, but got $json") + + override protected def formatZonedDateTime: JsonFormat[ZonedDateTime] = new JsonFormat[ZonedDateTime]: + def write(value: ZonedDateTime): JsValue = JsString(value.printZonedDateTime) + + def read(json: JsValue): ZonedDateTime = json match + case JsString(zdt) => zdt.parseZonedDateTime + case _ => deserializationError(s"Expected ZonedDateTime, but got $json") + + override protected def formatUUID: JsonFormat[UUID] = new JsonFormat[UUID]: + def write(v: UUID): JsValue = JsString(v.toString) + + def read(value: JsValue): UUID = value match + case JsString(v) => UUID.fromString(v) + case x => deserializationError(s"Expected UUID, but got $x") + + override protected def formatObjectId: JsonFormat[ObjectId] = new JsonFormat[ObjectId]: + def write(obj: ObjectId): JsValue = JsString(obj.toString) + + def read(jsValue: JsValue): ObjectId = jsValue match + case JsString(value) => new ObjectId(value) + case x => deserializationError(s"Expected ObjectId, but got $x") + + def enumToJsonFormatAsString(e: Enumeration): JsonFormat[e.Value] = new JsonFormat[e.Value]: + def write(v: e.Value): JsValue = StringJsonFormat.write(v.toString) + def read(value: JsValue): e.Value = e.withName(StringJsonFormat.read(value)) + + def enumToJsonFormatAsInt(e: Enumeration): JsonFormat[e.Value] = new JsonFormat[e.Value]: + def write(v: e.Value): JsValue = IntJsonFormat.write(v.id) + def read(value: JsValue): e.Value = e.apply(IntJsonFormat.read(value)) + +object SprayJsonProtocol extends SprayJsonProtocol diff --git a/spray/src/main/scala/io/github/greenleafoss/mongo/spray/util/SprayJsonBsonOps.scala b/spray/src/main/scala/io/github/greenleafoss/mongo/spray/util/SprayJsonBsonOps.scala new file mode 100644 index 0000000..f23bd84 --- /dev/null +++ b/spray/src/main/scala/io/github/greenleafoss/mongo/spray/util/SprayJsonBsonOps.scala @@ -0,0 +1,88 @@ +package io.github.greenleafoss.mongo.spray.util + +import io.github.greenleafoss.mongo.core.util.GreenLeafJsonBsonOps +import io.github.greenleafoss.mongo.core.util.GreenLeafJsonBsonOps.JsonBsonErr +import io.github.greenleafoss.mongo.core.util.ZonedDateTimeOps +import io.github.greenleafoss.mongo.core.util.ZonedDateTimeOps.* +import io.github.greenleafoss.mongo.spray.bson.SprayBsonProtocol + +import org.mongodb.scala.bson.BsonArray +import org.mongodb.scala.bson.BsonBoolean +import org.mongodb.scala.bson.BsonDateTime +import org.mongodb.scala.bson.BsonDecimal128 +import org.mongodb.scala.bson.BsonDocument +import org.mongodb.scala.bson.BsonDouble +import org.mongodb.scala.bson.BsonInt32 +import org.mongodb.scala.bson.BsonInt64 +import org.mongodb.scala.bson.BsonNull +import org.mongodb.scala.bson.BsonObjectId +import org.mongodb.scala.bson.BsonString +import org.mongodb.scala.bson.BsonValue +import org.mongodb.scala.bson.ObjectId + +import org.bson.json.JsonMode +import org.bson.json.JsonWriterSettings + +import java.time.ZonedDateTime +import java.util.Date + +import scala.jdk.CollectionConverters.* +import scala.language.implicitConversions +import scala.util.chaining.* + +import spray.json.* +import spray.json.DefaultJsonProtocol.* + +trait SprayJsonBsonOps extends GreenLeafJsonBsonOps: + + // ************************************************** + // FORMATS + // ************************************************** + + override type JsonFormat[E] = spray.json.JsonFormat[E] + override type Json = spray.json.JsValue + + // ************************************************** + // JSON + // ************************************************** + + extension (string: String) override def parseJson: Json = spray.json.JsonParser(string) + extension [E: JsonFormat](e: E) override def convertToJson: Json = e.toJson + extension (json: Json) override def convertTo[E: JsonFormat]: E = json.convertTo[E] + + import SprayBsonProtocol.* + import SprayBsonProtocol.given + override protected def convertJsonToBson(json: Json): BsonValue = json match + case JsObject(x) if x.contains($oid) => BsonObjectId(json.convertTo[ObjectId]) + case JsObject(x) if x.contains($date) => BsonDateTime(json.convertTo[ZonedDateTime].toEpochMilli) + case JsObject(x) if x.contains($numberDecimal) => BsonDecimal128(json.convertTo[BigDecimal]) + case JsObject(x) if x.contains($numberDouble) => BsonDouble(json.convertTo[Double]) + case JsObject(x) if x.contains($numberLong) => BsonInt64(json.convertTo[Long]) + case JsObject(x) if x.contains($numberInt) => BsonInt32(json.convertTo[Int]) + case JsObject(x) => BsonDocument(x.map { case (k, v) => k -> convertJsonToBson(v) }) + case JsArray(x) => BsonArray.fromIterable(x.map(convertJsonToBson)) + case JsBoolean(x) => BsonBoolean(x) + case JsString(x) => BsonString(x) + case JsNull => BsonNull() + case null => BsonNull() + case _ => throw JsonBsonErr(s"Unknown input in JSON to BSON: $json") + + // ************************************************** + // BSON + // ************************************************** + + override protected def convertBsonToJson(bson: BsonValue): Json = bson match + case x: BsonDocument => x.toJson(jws).parseJson + case x: BsonArray => JsArray(x.getValues.iterator().asScala.map(convertBsonToJson).toVector) + case x: BsonDateTime => x.getValue.asZonedDateTime().convertToJson + case x: BsonString => x.getValue.convertToJson + case x: BsonBoolean => x.getValue.convertToJson + case x: BsonObjectId => x.getValue.convertToJson + case x: BsonInt32 => x.getValue.convertToJson + case x: BsonInt64 => x.getValue.convertToJson + case x: BsonDouble => x.getValue.convertToJson + case x: BsonDecimal128 => BigDecimal(x.decimal128Value().bigDecimalValue()).convertToJson + case _: BsonNull => JsNull + case _ => throw JsonBsonErr(s"Unknown input in BSON to JSON: $bson") + +object SprayJsonBsonOps extends SprayJsonBsonOps diff --git a/spray/src/test/scala/io/github/greenleafoss/mongo/spray/bson/SprayBsonProtocolSpec.scala b/spray/src/test/scala/io/github/greenleafoss/mongo/spray/bson/SprayBsonProtocolSpec.scala new file mode 100644 index 0000000..6b30040 --- /dev/null +++ b/spray/src/test/scala/io/github/greenleafoss/mongo/spray/bson/SprayBsonProtocolSpec.scala @@ -0,0 +1,5 @@ +package io.github.greenleafoss.mongo.spray.bson + +import io.github.greenleafoss.mongo.core.bson.BsonProtocolSpec + +class SprayBsonProtocolSpec extends BsonProtocolSpec with SprayModelBsonProtocol diff --git a/spray/src/test/scala/io/github/greenleafoss/mongo/spray/bson/SprayModelBsonProtocol.scala b/spray/src/test/scala/io/github/greenleafoss/mongo/spray/bson/SprayModelBsonProtocol.scala new file mode 100644 index 0000000..c3fcea2 --- /dev/null +++ b/spray/src/test/scala/io/github/greenleafoss/mongo/spray/bson/SprayModelBsonProtocol.scala @@ -0,0 +1,5 @@ +package io.github.greenleafoss.mongo.spray.bson + +import io.github.greenleafoss.mongo.spray.json.SprayModelJsonProtocol + +trait SprayModelBsonProtocol extends SprayModelJsonProtocol with SprayBsonProtocol diff --git a/spray/src/test/scala/io/github/greenleafoss/mongo/spray/dao/SprayEntityWithIdAsFieldDaoSpec.scala b/spray/src/test/scala/io/github/greenleafoss/mongo/spray/dao/SprayEntityWithIdAsFieldDaoSpec.scala new file mode 100644 index 0000000..fae61a5 --- /dev/null +++ b/spray/src/test/scala/io/github/greenleafoss/mongo/spray/dao/SprayEntityWithIdAsFieldDaoSpec.scala @@ -0,0 +1,18 @@ +package io.github.greenleafoss.mongo.spray.dao + +import io.github.greenleafoss.mongo.core.dao.EntityWithIdAsFieldDaoSpec +import io.github.greenleafoss.mongo.core.dao.EntityWithIdAsFieldDaoSpec.* + +import spray.json.DefaultJsonProtocol + +class SprayEntityWithIdAsFieldDaoSpec extends EntityWithIdAsFieldDaoSpec: + private trait SprayBuildingModelBsonProtocol + extends BuildingModelBsonProtocol + with SprayMongoDaoProtocol[Long, Building]: + override given idFormat: JsonFormat[Long] = formatLong + override given eFormat: JsonFormat[Building] = + DefaultJsonProtocol.jsonFormat(Building.apply, "_id", "name", "height", "floors", "year", "address") + + private class SprayBuildingDao extends BuildingDao with SprayBuildingModelBsonProtocol + + override protected def newBuildingDao: BuildingDao = SprayBuildingDao() diff --git a/spray/src/test/scala/io/github/greenleafoss/mongo/spray/dao/SprayEntityWithIdAsObjectDaoSpec.scala b/spray/src/test/scala/io/github/greenleafoss/mongo/spray/dao/SprayEntityWithIdAsObjectDaoSpec.scala new file mode 100644 index 0000000..b703baa --- /dev/null +++ b/spray/src/test/scala/io/github/greenleafoss/mongo/spray/dao/SprayEntityWithIdAsObjectDaoSpec.scala @@ -0,0 +1,22 @@ +package io.github.greenleafoss.mongo.spray.dao + +import io.github.greenleafoss.mongo.core.dao.EntityWithIdAsObjectDaoSpec +import io.github.greenleafoss.mongo.core.dao.EntityWithIdAsObjectDaoSpec.* +import io.github.greenleafoss.mongo.core.dao.EntityWithIdAsObjectDaoSpec.Currency.Currency + +import spray.json.* +import spray.json.DefaultJsonProtocol.* + +class SprayEntityWithIdAsObjectDaoSpec extends EntityWithIdAsObjectDaoSpec: + private trait SprayExchangeRateDaoBsonProtocol + extends ExchangeRateDaoBsonProtocol + with SprayMongoDaoProtocol[ExchangeRateId, ExchangeRate]: + override given CurrencyFormat: JsonFormat[Currency.Currency] = enumToJsonFormatAsString(Currency) + + override given idFormat: JsonFormat[ExchangeRateId] = jsonFormat2(ExchangeRateId.apply) + + override given eFormat: JsonFormat[ExchangeRate] = jsonFormat(ExchangeRate.apply, "_id", "rates", "updated") + + private class SprayExchangeRateDao extends ExchangeRateDao with SprayExchangeRateDaoBsonProtocol + + override protected def newExchangeRateDao: ExchangeRateDao = SprayExchangeRateDao() diff --git a/spray/src/test/scala/io/github/greenleafoss/mongo/spray/dao/SprayEntityWithOptionalFieldsDaoSpec.scala b/spray/src/test/scala/io/github/greenleafoss/mongo/spray/dao/SprayEntityWithOptionalFieldsDaoSpec.scala new file mode 100644 index 0000000..e709366 --- /dev/null +++ b/spray/src/test/scala/io/github/greenleafoss/mongo/spray/dao/SprayEntityWithOptionalFieldsDaoSpec.scala @@ -0,0 +1,20 @@ +package io.github.greenleafoss.mongo.spray.dao + +import io.github.greenleafoss.mongo.core.dao.EntityWithOptionalFieldsDaoSpec +import io.github.greenleafoss.mongo.core.dao.EntityWithOptionalFieldsDaoSpec.* + +import spray.json.DefaultJsonProtocol +import spray.json.DefaultJsonProtocol.given + +class SprayEntityWithOptionalFieldsDaoSpec extends EntityWithOptionalFieldsDaoSpec: + + private trait SprayGeoModelDaoBsonProtocol + extends GeoModelDaoBsonProtocol + with SprayMongoDaoProtocol[GeoKey, GeoRecord]: + override protected given idFormat: JsonFormat[GeoKey] = + DefaultJsonProtocol.jsonFormat3(GeoKey.apply) + override protected given eFormat: JsonFormat[GeoRecord] = + DefaultJsonProtocol.jsonFormat(GeoRecord.apply, "_id", "name", "population") + + private class SprayGeoModelDao extends GeoModelDao with SprayGeoModelDaoBsonProtocol + override protected def newGeoModelDao: GeoModelDao = SprayGeoModelDao() diff --git a/spray/src/test/scala/io/github/greenleafoss/mongo/spray/dao/SprayEntityWithoutIdDaoSpec.scala b/spray/src/test/scala/io/github/greenleafoss/mongo/spray/dao/SprayEntityWithoutIdDaoSpec.scala new file mode 100644 index 0000000..2a9f382 --- /dev/null +++ b/spray/src/test/scala/io/github/greenleafoss/mongo/spray/dao/SprayEntityWithoutIdDaoSpec.scala @@ -0,0 +1,26 @@ +package io.github.greenleafoss.mongo.spray.dao + +import io.github.greenleafoss.mongo.core.dao.EntityWithoutIdDaoSpec +import io.github.greenleafoss.mongo.core.dao.EntityWithoutIdDaoSpec.* +import io.github.greenleafoss.mongo.core.dao.EntityWithoutIdDaoSpec.EventSource.EventSource +import io.github.greenleafoss.mongo.spray.json.SprayJsonProtocol + +import spray.json.* + +class SprayEntityWithoutIdDaoSpec extends EntityWithoutIdDaoSpec: + + // Let's suppose we already have some JsonFormats for these models + private trait SprayEventDaoJsonProtocol extends SprayJsonProtocol: + given EventSourceFormat: JsonFormat[EventSource.EventSource] = enumToJsonFormatAsString(EventSource) + given ModelEventFormat: JsonFormat[Event] = DefaultJsonProtocol.jsonFormat4(Event.apply) + + // so we can reuse it and just mix SprayMongoDaoProtocolObjectId + private trait SprayEventDaoBsonProtocol + extends SprayEventDaoJsonProtocol + with EventDaoBsonProtocol + with SprayMongoDaoProtocolObjectId[Event]: + override given eFormat: JsonFormat[Event] = ModelEventFormat + + private class SprayEventDao extends EventDao with SprayEventDaoBsonProtocol + + override protected def newEventDao: EventDao = SprayEventDao() diff --git a/spray/src/test/scala/io/github/greenleafoss/mongo/spray/filter/SprayFilterSpec.scala b/spray/src/test/scala/io/github/greenleafoss/mongo/spray/filter/SprayFilterSpec.scala new file mode 100644 index 0000000..53a5338 --- /dev/null +++ b/spray/src/test/scala/io/github/greenleafoss/mongo/spray/filter/SprayFilterSpec.scala @@ -0,0 +1,11 @@ +package io.github.greenleafoss.mongo.spray.filter + +import io.github.greenleafoss.mongo.core.filter.GreenLeafMongoFilterOpsSpec +import io.github.greenleafoss.mongo.spray.bson.SprayBsonProtocol +import io.github.greenleafoss.mongo.spray.util.SprayJsonBsonOps + +import scala.language.implicitConversions + +import org.scalatest.wordspec.AnyWordSpec + +class SprayFilterSpec extends GreenLeafMongoFilterOpsSpec with SprayBsonProtocol with SprayJsonBsonOps diff --git a/spray/src/test/scala/io/github/greenleafoss/mongo/spray/json/SprayJsonProtocolSpec.scala b/spray/src/test/scala/io/github/greenleafoss/mongo/spray/json/SprayJsonProtocolSpec.scala new file mode 100644 index 0000000..8e1e85a --- /dev/null +++ b/spray/src/test/scala/io/github/greenleafoss/mongo/spray/json/SprayJsonProtocolSpec.scala @@ -0,0 +1,5 @@ +package io.github.greenleafoss.mongo.spray.json + +import io.github.greenleafoss.mongo.core.json.JsonProtocolSpec + +class SprayJsonProtocolSpec extends JsonProtocolSpec with SprayModelJsonProtocol diff --git a/spray/src/test/scala/io/github/greenleafoss/mongo/spray/json/SprayModelJsonProtocol.scala b/spray/src/test/scala/io/github/greenleafoss/mongo/spray/json/SprayModelJsonProtocol.scala new file mode 100644 index 0000000..e4d75ed --- /dev/null +++ b/spray/src/test/scala/io/github/greenleafoss/mongo/spray/json/SprayModelJsonProtocol.scala @@ -0,0 +1,9 @@ +package io.github.greenleafoss.mongo.spray.json + +import io.github.greenleafoss.mongo.core.model.Model + +import spray.json.DefaultJsonProtocol.* +import spray.json.JsonFormat + +trait SprayModelJsonProtocol extends SprayJsonProtocol: + given modelJsonFormat: JsonFormat[Model] = jsonFormat9(Model.apply) diff --git a/src/main/scala/io/github/greenleafoss/mongo/GreenLeafBsonProtocol.scala b/src/main/scala/io/github/greenleafoss/mongo/GreenLeafBsonProtocol.scala deleted file mode 100644 index 02ce2f2..0000000 --- a/src/main/scala/io/github/greenleafoss/mongo/GreenLeafBsonProtocol.scala +++ /dev/null @@ -1,141 +0,0 @@ -package io.github.greenleafoss.mongo - -import java.time.{Instant, ZoneOffset, ZonedDateTime} -import org.mongodb.scala.bson.ObjectId -import spray.json.{JsNull, JsNumber, JsObject, JsString, JsValue, JsonFormat, NullOptions, deserializationError} - -import scala.util.matching.Regex - -trait GreenLeafBsonProtocol - extends GreenLeafJsonProtocol - with NullOptions { - - // https://docs.mongodb.com/manual/reference/mongodb-extended-json/#numberlong - override implicit def LongJsonFormat: JsonFormat[Long] = new JsonFormat[Long] { - - override def write(obj: Long): JsValue = JsNumber(obj) - - def read(jsValue: JsValue): Long = jsValue match { - case JsObject(fields) => fields("$numberLong") match { - case JsString(v) => v.toLong - case JsNumber(v) => v.toLong - case x => deserializationError(s"Expected Long as {$$numberLong: }, but got $x") - } - case JsString(v) => v.toLong - case JsNumber(v) => v.toLong - case x => deserializationError(s"Expected Long as JsString/JsNumber, but got $x") - } - } - - override implicit def FloatJsonFormat: JsonFormat[Float] = new JsonFormat[Float] { - - def write(value: Float): JsValue = value match { - case Float.NegativeInfinity => JsObject("$numberDouble" -> JsString("-Infinity")) - case Float.PositiveInfinity => JsObject("$numberDouble" -> JsString("Infinity")) - case Float.NaN => JsObject("$numberDouble" -> JsString("NaN")) - case x => JsNumber(x) - } - - def read(value: JsValue): Float = value match { - case JsObject(fields) => fields("$numberDouble") match { - case JsString("-Infinity") => Float.NegativeInfinity - case JsString("Infinity") => Float.PositiveInfinity - case JsString("NaN") | JsNull | JsString.empty => Float.NaN - case x => deserializationError(s"Expected Float as {$$numberDouble: }, but got $x") - } - case JsNumber(x) => x.floatValue - case JsString(x) => x.toFloat - case x => deserializationError(s"Expected Float as JsNumber/JsString, but got $x") - } - } - - override implicit def DoubleJsonFormat: JsonFormat[Double] = new JsonFormat[Double] { - - def write(value: Double): JsValue = value match { - case Float.NegativeInfinity => JsObject("$numberDouble" -> JsString("-Infinity")) - case Float.PositiveInfinity => JsObject("$numberDouble" -> JsString("Infinity")) - case Float.NaN => JsObject("$numberDouble" -> JsString("NaN")) - case x => JsNumber(x) - } - - def read(value: JsValue): Double = value match { - case JsObject(fields) => fields("$numberDouble") match { - case JsString("-Infinity") => Double.NegativeInfinity - case JsString("Infinity") => Double.PositiveInfinity - case JsString("NaN") => Double.NaN - case x => deserializationError(s"Expected Double as {$$numberDouble: }, but got $x") - } - case JsNumber(x) => x.floatValue - case JsString(x) => x.toFloat - case x => deserializationError(s"Expected Double as JsNumber/JsString, but got $x") - } - } - - // https://docs.mongodb.com/manual/core/shell-types/#numberdecimal - // https://docs.mongodb.com/manual/reference/mongodb-extended-json/#numberdecimal - override implicit def BigDecimalJsonFormat: JsonFormat[BigDecimal] = new JsonFormat[BigDecimal] { - - override def write(obj: BigDecimal): JsValue = JsObject("$numberDecimal" -> JsString(obj.toString())) - - override def read(jsValue: JsValue): BigDecimal = jsValue match { - case JsNumber(v) => v - case JsString(v) => BigDecimal(v) - case JsObject(fields) => fields("$numberDecimal") match { - case JsString(v) => BigDecimal(v) - case x => deserializationError(s"Expected BigDecimal as {$$numberDecimal: }, but got $x") - } - case x => deserializationError(s"Expected BigDecimal as JsString, but got $x") - } - - - } - - // https://docs.mongodb.com/manual/reference/bson-types/#date - // https://docs.mongodb.com/upcoming/reference/mongodb-extended-json/#mongodb-bsontype-Date - override implicit def ZdtJsonFormat: JsonFormat[ZonedDateTime] = new JsonFormat[ZonedDateTime] with ZonedDateTimeOps { - - def write(obj: ZonedDateTime): JsValue = obj match { - case _ => JsObject("$date" -> JsString(obj.format(DateTimeIsoPattern))) - } - - def read(jsValue: JsValue): ZonedDateTime = jsValue match { - case JsObject(fields) => fields("$date") match { - case JsNumber(v) => Instant.ofEpochMilli(v.toLong).atZone(ZoneOffset.UTC) - // {"$date": ""} e.g. 1970-01-01T01:02:03+04:00 - case JsString(zdt) if zdt.length >= 20 => parseDateTimeIso (zdt) - case x => deserializationError(s"Expected ZonedDateTime as {$$date: }, but got $x") - } - case x => deserializationError(s"Expected ZonedDateTime as {$$date: }, but got $x") - } - } - - // https://docs.mongodb.com/manual/reference/bson-types/#objectid - // https://docs.mongodb.com/manual/reference/mongodb-extended-json/#oid - override implicit def ObjectIdJsonFormat: JsonFormat[ObjectId] = new JsonFormat[ObjectId] { - def write(obj: ObjectId): JsValue = JsObject("$oid" -> JsString(obj.toString)) - - def read(jsValue: JsValue): ObjectId = jsValue match { - case JsObject(fields) => fields("$oid") match { - case JsString(oid) => new ObjectId(oid) - case x => deserializationError(s"Expected ObjectId as {$$oid: }, but got $x") - } - case x => deserializationError(s"Expected ObjectId as {$$oid: }, but got $x") - } - } - - implicit def RegexJsonFormat: JsonFormat[Regex] = new JsonFormat[Regex] { - - override def write(obj: Regex): JsValue = JsObject("$regex" -> JsString(obj.toString)) - - override def read(jsValue: JsValue): Regex = jsValue match { - case JsObject(fields) => fields("$regex") match { - case JsString(v) => v.r - case x => deserializationError(s"Expected Regex as {$$regex: }, but got $x") - } - case x => deserializationError(s"Expected Regex as {$$regex: }, but got $x") - } - - } -} - -object GreenLeafBsonProtocol extends GreenLeafBsonProtocol diff --git a/src/main/scala/io/github/greenleafoss/mongo/GreenLeafJsonProtocol.scala b/src/main/scala/io/github/greenleafoss/mongo/GreenLeafJsonProtocol.scala deleted file mode 100644 index 5069d8c..0000000 --- a/src/main/scala/io/github/greenleafoss/mongo/GreenLeafJsonProtocol.scala +++ /dev/null @@ -1,201 +0,0 @@ -package io.github.greenleafoss.mongo - -import java.time.ZonedDateTime -import java.util.UUID - -import org.mongodb.scala.bson.ObjectId -import spray.json.{AdditionalFormats, CollectionFormats, DefaultJsonProtocol, JsBoolean, JsFalse, JsNull, JsNumber, JsString, JsTrue, JsValue, JsonFormat, ProductFormats, StandardFormats, deserializationError} - -trait GreenLeafJsonProtocol - extends StandardFormats - with CollectionFormats - with ProductFormats - with AdditionalFormats { - - implicit def IntJsonFormat: JsonFormat[Int] = new JsonFormat[Int] { - def write(x: Int): JsNumber = { - require(x ne null) - JsNumber(x) - } - - def read(value: JsValue): Int = value match { - case JsNumber(x) => x.intValue - case JsString.empty => 0 - case JsString(x) => x.toInt - case x => deserializationError("Expected Int as JsNumber/JsString, but got " + x) - } - } - - implicit def LongJsonFormat: JsonFormat[Long] = new JsonFormat[Long] { - def write(x: Long): JsNumber = { - require(x ne null) - JsNumber(x) - } - - def read(value: JsValue): Long = value match { - case JsNumber(x) => x.longValue - case JsString.empty => 0L - case JsString(x) => x.toLong - case x => deserializationError("Expected Long as JsNumber/JsString, but got " + x) - } - } - - implicit def FloatJsonFormat: JsonFormat[Float] = new JsonFormat[Float] { - def write(x: Float): JsValue = { - require(x ne null) - JsNumber(x) - } - - def read(value: JsValue): Float = value match { - case JsNumber(x) => x.floatValue - case JsString.empty => 0f - case JsString(x) => x.toFloat - case JsNull => Float.NaN - case x => deserializationError("Expected Float as JsNumber/JsString, but got " + x) - } - } - - implicit def DoubleJsonFormat: JsonFormat[Double] = new JsonFormat[Double] { - def write(x: Double): JsValue = { - require(x ne null) - JsNumber(x) - } - - def read(value: JsValue): Double = value match { - case JsNumber(x) => x.doubleValue - case JsString.empty => 0d - case JsString(x) => x.toDouble - case JsNull => Double.NaN - case x => deserializationError("Expected Double as JsNumber/JsString, but got " + x) - } - } - - implicit def ByteJsonFormat: JsonFormat[Byte] = new JsonFormat[Byte] { - def write(x: Byte): JsNumber = { - require(x ne null) - JsNumber(x) - } - - def read(value: JsValue): Byte = value match { - case JsNumber(x) => x.byteValue - case JsString.empty => 0.toByte - case JsString(x) => x.toByte - case x => deserializationError("Expected Byte as JsNumber/JsString, but got " + x) - } - } - - implicit def ShortJsonFormat: JsonFormat[Short] = new JsonFormat[Short] { - def write(x: Short): JsNumber = { - require(x ne null) - JsNumber(x) - } - - def read(value: JsValue): Short = value match { - case JsNumber(x) => x.shortValue - case JsString.empty => 0.toShort - case JsString(x) => x.toShort - case x => deserializationError("Expected Short as JsNumber/JsString, but got " + x) - } - } - - implicit def BigDecimalJsonFormat: JsonFormat[BigDecimal] = new JsonFormat[BigDecimal] { - def write(x: BigDecimal): JsNumber = { - require(x ne null) - JsNumber(x) - } - - def read(value: JsValue): BigDecimal = value match { - case JsNumber(x) => x - case JsString.empty => BigDecimal(0) - case JsString(x) => BigDecimal(x) - case x => deserializationError("Expected BigDecimal as JsNumber/JsString, but got " + x) - } - } - - implicit def BigIntJsonFormat: JsonFormat[BigInt] = new JsonFormat[BigInt] { - def write(x: BigInt): JsNumber = { - require(x ne null) - JsNumber(x) - } - - def read(value: JsValue): BigInt = value match { - case JsNumber(x) => x.toBigInt - case JsString.empty => BigInt(0) - case JsString(x) => BigInt(x) - case x => deserializationError("Expected BigInt as JsNumber/JsString, but got " + x) - } - } - - implicit def UnitJsonFormat: JsonFormat[Unit] = DefaultJsonProtocol.UnitJsonFormat - - implicit def BooleanJsonFormat: JsonFormat[Boolean] = new JsonFormat[Boolean] { - def write(x: Boolean): JsBoolean = { - require(x ne null) - JsBoolean(x) - } - - def read(value: JsValue): Boolean = value match { - case JsTrue => true - case JsFalse => false - case JsString(x) => java.lang.Boolean.parseBoolean(x) - case x => deserializationError("Expected JsBoolean/JsString, but got " + x) - } - } - - implicit def CharJsonFormat: JsonFormat[Char] = DefaultJsonProtocol.CharJsonFormat - - implicit def StringJsonFormat: JsonFormat[String] = DefaultJsonProtocol.StringJsonFormat - - implicit def SymbolJsonFormat: JsonFormat[Symbol] = DefaultJsonProtocol.SymbolJsonFormat - - implicit def ZdtJsonFormat: JsonFormat[ZonedDateTime] = new JsonFormat[ZonedDateTime] with ZonedDateTimeOps { - def write(obj: ZonedDateTime): JsValue = JsString(obj.format(DateTimePattern)) - - def read(jsValue: JsValue): ZonedDateTime = jsValue match { - case JsString(zdt) if zdt.length >= 20 => parseDateTimeIso (zdt) // 1970-01-01T01:02:03+04:00 - case JsString(zdt) if zdt.length >= 19 && zdt.contains('T') => parseDateTimeIso(zdt) // 1970-01-01T00:00:00 - case JsString(zdt) if zdt.length == 19 => parseDateTime (zdt) // 1970-01-01 00:00:00 - case JsString(zdt) => parseDate (zdt) - case x => deserializationError(s"Expected ZonedDateTime, but got $x") - } - } - - implicit def ObjectIdJsonFormat: JsonFormat[ObjectId] = new JsonFormat[ObjectId] { - def write(obj: ObjectId): JsValue = JsString(obj.toString) - - def read(jsValue: JsValue): ObjectId = jsValue match { - case JsString(value) => new ObjectId(value) - case x => deserializationError(s"Expected ObjectId, but got $x") - } - } - - def enumToJsonFormatAsString(e: Enumeration): JsonFormat[e.Value] = new JsonFormat[e.Value] { - def write(v: e.Value): JsValue = JsString(v.toString) - - def read(value: JsValue): e.Value = value match { - case JsString(v) => e.withName(v) - case x => deserializationError(s"Expected enum, but got $x") - } - } - - def enumToJsonFormatAsInt(e: Enumeration): JsonFormat[e.Value] = new JsonFormat[e.Value] { - def write(v: e.Value): JsValue = JsNumber(v.id) - - def read(value: JsValue): e.Value = value match { - case JsNumber(v) => e.apply(v.intValue) - case x => deserializationError(s"Expected enum, but got $x") - } - } - - implicit def UuidAsStrJsonFormat: JsonFormat[UUID] = new JsonFormat[UUID] { - def write(v: UUID): JsValue = JsString(v.toString) - - def read(value: JsValue): UUID = value match { - case JsString(v) => UUID.fromString(v) - case x => deserializationError(s"Expected UUID, but got $x") - } - } - -} - -object GreenLeafJsonProtocol extends GreenLeafJsonProtocol diff --git a/src/main/scala/io/github/greenleafoss/mongo/GreenLeafMongoDao.scala b/src/main/scala/io/github/greenleafoss/mongo/GreenLeafMongoDao.scala deleted file mode 100644 index 0678418..0000000 --- a/src/main/scala/io/github/greenleafoss/mongo/GreenLeafMongoDao.scala +++ /dev/null @@ -1,180 +0,0 @@ -package io.github.greenleafoss.mongo - -import GreenLeafMongoDao.DaoBsonProtocol -import org.bson.json.{JsonMode, JsonWriterSettings} -import org.mongodb.scala.bson.collection.immutable.Document -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.model.{FindOneAndReplaceOptions, FindOneAndUpdateOptions} -import org.mongodb.scala.{FindObservable, MongoCollection, MongoDatabase, SingleObservable} -import org.mongodb.scala.result.{InsertManyResult, InsertOneResult} -import org.mongodb.scala._ -import spray.json._ - -import scala.concurrent.{ExecutionContext, Future} -import scala.reflect.ClassTag - -object GreenLeafMongoDao { - trait DaoBsonProtocol[Id, E] { - val jws: JsonWriterSettings = JsonWriterSettings.builder().outputMode(JsonMode.RELAXED).build() - implicit def idFormat : JsonFormat[Id] - implicit def entityFormat: JsonFormat[E] - } -} - -trait GreenLeafMongoDao[Id, E] - extends GreenLeafMongoDsl - with MongoObservableToFuture { - - protected implicit val ec: ExecutionContext - - protected val db: MongoDatabase - protected val collection: MongoCollection[Document] - - protected val protocol: DaoBsonProtocol[Id, E] - import protocol.{idFormat, entityFormat} - override protected lazy val jws: JsonWriterSettings = protocol.jws - - // _id, id, key, ... - protected val primaryKey: String = "_id" - protected def skipNull: Boolean = true - - protected def defaultSortBy: Bson = Document("""{}""") - - def insert(e: E): Future[InsertOneResult] = { - val d: Document = e.toJson.skipNull(skipNull) - log.trace(s"DAO.insertOne: $d") - collection.insertOne(d).toFuture() - } - - def insert(entities: Seq[E]): Future[InsertManyResult] = { - // Document([ obj1, obj2, ... ]) can't be created - // [ Document(obj1), Document(obj2), ... ] - OK - val documents = entities.map(d => Document(d.toJson.skipNull(skipNull).compactPrint)) - log.trace(s"DAO.insertMany: $documents") - collection.insertMany(documents).toFuture() - } - - protected def internalFind(filter: Bson, offset: Int, limit: Int, sortBy: Bson = defaultSortBy): FindObservable[Document] = { - log.trace("DAO.internalFind: " + filter.toString) - collection.find(filter).skip(offset).limit(limit).sort(sortBy) - } - - def findOne(filter: Bson, offset: Int = 0, sortBy: Bson = defaultSortBy): Future[Option[E]] = { - internalFind(filter, offset, limit = 1, sortBy).asOpt[E] - } - - def find(filter: Bson, offset: Int = 0, limit: Int = 0, sortBy: Bson = defaultSortBy): Future[Seq[E]] = { - internalFind(filter, offset, limit, sortBy).asSeq[E] - } - - def getById(id: Id): Future[E] = { - val filter = primaryKey $eq id - log.trace(s"DAO.getById [$primaryKey] : $filter") - internalFind(filter, 0, 1).asObj[E] - } - - def findById(id: Id): Future[Option[E]] = { - val filter = primaryKey $eq id - log.trace(s"DAO.findById [$primaryKey] : $filter") - internalFind(filter, 0, 1).asOpt[E] - } - - // JSON fields can have different order, so if Id type is object don't use this query. - // find({"id": { $in: [ {a: 1, b: 2 }, {a: 3, b: 4 }, ...] } }) - order of 'a' and 'b' fields may change - // find({"id": { $in: [ {"id.a": 1, "id.b": 2}, ... ] } }) - will not work - def findByIdsIn(ids: Seq[Id], offset: Int = 0, limit: Int = 0, sortBy: Bson = defaultSortBy): Future[Seq[E]] = ids match { - case Nil => Future.successful(Seq.empty) - case id :: Nil => findById(id).map(_.toSeq) - case _ => internalFind(primaryKey.$in(ids: _*), offset, limit, sortBy).asSeq[E] - } - - def findByIdsOr(ids: Seq[Id], offset: Int = 0, limit: Int = 0, sortBy: Bson = defaultSortBy): Future[Seq[E]] = ids match { - case Nil => Future.successful(Seq.empty) - case id :: Nil => findById(id).map(_.toSeq) - case _ => internalFind($or(ids.map(_.asJsonExpanded(primaryKey)): _*), offset, limit, sortBy).asSeq[E] - } - - def findAll(offset: Int = 0, limit: Int = 0, sortBy: Bson = defaultSortBy): Future[Seq[E]] = { - find(Document.empty, offset, limit, sortBy) - } - - // ******************************************************************************** - // https://docs.mongodb.com/manual/reference/method/db.collection.findOneAndUpdate/ - // ******************************************************************************** - - protected def internalUpdate(filter: Bson, update: Bson, upsert: Boolean = false): SingleObservable[Document] = { - log.trace(s"DAO.internalUpdateBy [$primaryKey] : $filter") - // By default "ReturnDocument.BEFORE" property used and returns the document before the update - // val option = FindOneAndUpdateOptions().upsert(true).returnDocument(ReturnDocument.AFTER) - val option = FindOneAndUpdateOptions().upsert(upsert) - collection.findOneAndUpdate(filter, update, option) - } - - def updateById(id: Id, e: Document, upsert: Boolean = false): Future[Option[E]] = { - val filter = primaryKey $eq id - log.trace(s"DAO.updateById [$primaryKey] : $filter") - internalUpdate(filter, e, upsert).asOpt[E] - } - - def update(filter: Bson, e: Document, upsert: Boolean = false): Future[Option[E]] = { - log.trace(s"DAO.updateBy [$primaryKey] : $filter") - internalUpdate(filter, e, upsert).asOpt[E] - } - - - // ******************************************************************************** - // https://docs.mongodb.com/manual/reference/method/db.collection.findOneAndReplace/ - // ******************************************************************************** - - protected def internalReplace(filter: Bson, replacement: Document, upsert: Boolean = false): SingleObservable[Document] = { - log.trace(s"DAO.internalReplaceBy : $filter") - // By default "ReturnDocument.BEFORE" property used and returns the document before the update - // val option = FindOneAndReplaceOptions().upsert(true).returnDocument(ReturnDocument.AFTER) - val option = FindOneAndReplaceOptions().upsert(upsert) - collection.findOneAndReplace(filter, replacement, option) - } - - def replaceById(id: Id, e: E, upsert: Boolean = false): Future[Option[E]] = { - val filter = primaryKey $eq id - internalReplace(filter, e.toJson.skipNull(skipNull), upsert).asOpt[E] - } - - def createOrReplaceById(id: Id, e: E): Future[Option[E]] = { - replaceById(id, e, upsert = true) - } - - /** - * NOT ATOMICALLY find a document and replace it. - * Impossible to upsert:true with a Dotted _id Query - * https://docs.mongodb.com/manual/reference/method/db.collection.update/#upsert-true-with-a-dotted-id-query - * @param id primary key filter - * @param e entity to replace - * @return None if document was created and Some(previous document) if the document was updated - */ - def replaceOrInsertById(id: Id, e: E): Future[Option[E]] = { - replaceById(id, e /* upsert = false */).flatMap { - case beforeOpt @ Some(_) /* replaced */ => Future.successful(beforeOpt) - case None => insert(e).map { (_: InsertOneResult) => None } - } - } - - def replace(filter: Bson, e: E, upsert: Boolean = false): Future[Option[E]] = { - internalReplace(filter, e.toJson.skipNull(skipNull), upsert).asOpt[E] - } - - def createOrReplace(filter: Bson, e: E): Future[Option[E]] = { - replace(filter, e, upsert = true) - } - - def distinct[T](fieldName: String, filter: Bson)(implicit ct: ClassTag[T]): Future[Seq[T]] = { - collection.distinct[T](fieldName, filter).toFuture() - } - - def aggregate(pipeline: Seq[Bson]): Future[Seq[Document]] = { - collection.aggregate(pipeline).toFuture() - } - - def deleteById(id: Id): Future[E] = ??? - def deleteByIds(id: Seq[Id]): Future[E] = ??? - -} diff --git a/src/main/scala/io/github/greenleafoss/mongo/GreenLeafMongoDsl.scala b/src/main/scala/io/github/greenleafoss/mongo/GreenLeafMongoDsl.scala deleted file mode 100644 index b09e594..0000000 --- a/src/main/scala/io/github/greenleafoss/mongo/GreenLeafMongoDsl.scala +++ /dev/null @@ -1,577 +0,0 @@ -package io.github.greenleafoss.mongo - -import org.bson.json.{JsonMode, JsonWriterSettings} -import org.mongodb.scala.MongoClient -import org.mongodb.scala.bson.BsonDocument -import org.mongodb.scala.bson.collection.immutable.Document -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.model.Filters -import org.slf4j.{Logger, LoggerFactory} -import spray.json._ - -import scala.language.implicitConversions -import scala.util.matching.Regex - -trait GreenLeafMongoDsl { - - // System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "debug") - - protected val log: Logger = LoggerFactory.getLogger(getClass) - - protected def jws: JsonWriterSettings - - private implicit class BsonToJsObjectTransformer(d: Bson) { - def toJson: JsObject = d.toBsonDocument().toJson(jws).parseJson match { - case jObj: JsObject => jObj - case x => throw new IllegalArgumentException(s"Expected JsObject, but got $x") - } - } - - implicit def json2document(j: JsValue): Document = { - import org.mongodb.scala.bsonDocumentToDocument - org.bson.BsonDocument.parse(j.compactPrint) - } - - protected def seqObjAsSeqJsVal[T](seq: Seq[T])(implicit writer: JsonWriter[T]): Seq[JsValue] = { - seq.map(_.asJsonExpanded) - } - - implicit class JsValueWithoutNull(j: JsValue) { - - private def skipNull(jsArray: JsArray): JsArray = { - JsArray(jsArray.elements.flatMap { - case JsNull => None - case v: JsArray => Some(skipNull(v)) - case v: JsObject => Some(skipNull(v)) - case v: JsValue => Some(v) - }) - } - - private def skipNull(jsObject: JsObject): JsObject = { - JsObject(jsObject.fields.foldLeft(Map.empty[String, JsValue]) { - case (res, (_, JsNull)) => res - case (res, (k, v: JsObject)) => res ++ Map(k -> skipNull(v)) - case (res, (k, v: JsArray)) => res ++ Map(k -> skipNull(v)) - case (res, (k, v)) => res ++ Map(k -> v) - }) - } - - def skipNull(skip: Boolean = true): JsValue = j match { - case v: JsObject if skip => skipNull(v) - case v: JsArray if skip => skipNull(v) - case v => v - } - - } - - - implicit class ObjAsJson[T](t: T) { - - private def expandJson(path: String, jsValue: JsValue): JsValue = { - - def bsonQueryWithPath(path: String, in: JsValue): Map[String, JsValue] = in match { - case JsObject(fields) => fields.foldLeft(Map.empty[String, JsValue]) { - case (res, (field, v)) if field.startsWith("$") => - res ++ (if (path.nonEmpty) Map(path -> JsObject(field -> v)) else Map(field -> v)) - case (res, (field, v)) => - res ++ bsonQueryWithPath(if (path.nonEmpty) s"$path.$field" else field, v) - } - case value: JsValue => Map(path -> value) - } - - jsValue match { - case _: JsObject => JsObject(bsonQueryWithPath(path, jsValue)) - case _ => jsValue - } - } - - def asJsonExpanded(implicit writer: JsonWriter[T]): JsValue = expandJson("", t.toJson) - def asJsonExpanded(path: String)(implicit writer: JsonWriter[T]): JsValue = expandJson(path, t.toJson) - } - - - private def query(fieldName: String, operator: String, jsValue: JsValue): JsObject = { - require(fieldName.nonEmpty, "FieldName should be non empty.") - require(operator.nonEmpty, "QueryOperator should be non empty.") - - def bsonQueryWithPath(path: String, in: JsValue): Map[String, JsValue] = in match { - case JsObject(fields) => fields.foldLeft(Map.empty[String, JsValue]) { - case (res, (field, v)) if field.startsWith("$") => res ++ Map(path -> JsObject(operator -> JsObject(field -> v))) - case (res, (field, v)) => res ++ bsonQueryWithPath(s"$path.$field", v) - } - case value: JsValue => Map(path -> JsObject(operator -> value)) - } - - JsObject(bsonQueryWithPath(fieldName, jsValue)) - } - - - /** - * $and performs a logical AND operation on an array of two or more expressions - * (e.g. expression1, expression2, etc.) and selects the documents that satisfy all the expressions in the array. - * The $and operator uses short-circuit evaluation. - * If the first expression (e.g. expression1) evaluates to false, MongoDB will not evaluate the remaining expressions. - * @see https://docs.mongodb.com/manual/reference/operator/query/and/ - * - * @example {{{$and("price" $ne 1.99, "price" $exists true)}}} - * - * @param filters are expressions - * @return the filter - */ - def $and(filters: JsValue*): JsObject = - JsObject("$and" -> JsArray(filters: _*)) - - /** - * $and performs a logical AND operation on an array of two or more expressions - * (e.g. expression1, expression2, etc.) and selects the documents that satisfy all the expressions in the array. - * The $and operator uses short-circuit evaluation. - * If the first expression (e.g. expression1) evaluates to false, MongoDB will not evaluate the remaining expressions. - * @see https://docs.mongodb.com/manual/reference/operator/query/and/ - * - * @example {{{$and("price" $ne 1.99, "price" $exists true)}}} - * - * @param filters are expressions - * @return the filter - */ - def $and[T](filters: T*)(implicit writer: JsonWriter[T]): JsObject = - JsObject("$and" -> JsArray(seqObjAsSeqJsVal(filters): _*)) - - /** - * The $or operator performs a logical OR operation on an array of two or more expressions and selects the documents - * that satisfy at least one of the expressions. - * @see https://docs.mongodb.com/manual/reference/operator/query/or/ - * - * @example {{{$or("quantity" $lt 20, "price" $eq 10)}}} - * - * @param filters are expressions - * @return the filter - */ - def $or[T](filters: T*)(implicit writer: JsonWriter[T]): JsObject = - JsObject("$or" -> JsArray(seqObjAsSeqJsVal(filters).toVector)) - - /** - * The $or operator performs a logical OR operation on an array of two or more expressions and selects the documents - * that satisfy at least one of the expressions. - * @see https://docs.mongodb.com/manual/reference/operator/query/or/ - * - * @example {{{$or("quantity" $lt 20, "price" $eq 10)}}} - * - * @param filters are expressions - * @return the filter - */ - def $or(filters: JsValue*): JsObject = - JsObject("$or" -> JsArray(filters: _*)) - - /** - * $nor performs a logical NOR operation on an array of one or more query expression and selects the documents that - * fail all the query expressions in the array. - * @see https://docs.mongodb.com/manual/reference/operator/query/nor/ - * - * @example {{{$nor("price" $eq 1.99, "qty" $lt 20, "sale" $eq true)}}} - * - * @param filters are expressions - * @return the filter - */ - def $nor(filters: JsValue*): JsObject = - JsObject("$nor" -> JsArray(filters: _*)) - - /** - * $nor performs a logical NOR operation on an array of one or more query expression and selects the documents that - * fail all the query expressions in the array. - * @see https://docs.mongodb.com/manual/reference/operator/query/nor/ - * - * @example {{{$nor("price" $eq 1.99, "qty" $lt 20, "sale" $eq true)}}} - * - * @param filters are expressions - * @return the filter - */ - def $nor[T](filters: T*)(implicit writer: JsonWriter[T]): JsObject = - JsObject("$nor" -> JsArray(seqObjAsSeqJsVal(filters): _*)) - - /** - * The $elemMatch operator matches documents that contain an array field with at least one element that matches - * all the specified query criteria. - * @see https://docs.mongodb.com/manual/reference/operator/query/elemMatch/ - * - * @example - * {{{"qty" $all ( - * $elemMatch ($and("size" $eq "M", "num" $gt 50)), - * $elemMatch ($and("num" $eq 100, "color" $eq "green")) - * ) - * }}} - * @param v is value - * @return the filter - */ - def $elemMatch(v: JsValue): JsObject = - JsObject("$elemMatch" -> v) - - implicit class FiltersDsl(field: String) { - - /** - * Specifies equality condition. - * The "$eq" operator matches documents where the value of a field equals the specified value. - * @see https://docs.mongodb.com/manual/reference/operator/query/eq/ - * - * @example {{{"qty" $eq 20}}} - * - * @param v is value - * @return the filter - */ - def $eq(v: JsValue): JsObject = - query(field, "$eq", v) - - /** - * Specifies equality condition. - * The "$eq" operator matches documents where the value of a field equals the specified value. - * @see https://docs.mongodb.com/manual/reference/operator/query/eq/ - * - * @example {{{"qty" $eq 20}}} - * - * @param v is value - * @return the filter - */ - def $eq[T](v: T)(implicit writer: JsonWriter[T]): JsObject = - query(field, "$eq", v.asJsonExpanded) - - /** - * Specifies alias for equality condition "$eq" operator. - * This method implemented to reduce possible issues with Scala 3. - * The $is operator matches documents where the value of a field equals the specified value. - * @see https://docs.mongodb.com/manual/reference/operator/query/eq/ - * - * @example {{{"qty" $is 20}}} - * - * @param v is value - * @return the filter - */ - def $is(v: JsValue): JsObject = - query(field, "$eq", v) - - /** - * Specifies alias for equality condition $es operator. - * This method implemented to reduce possible issues with Scala 3. - * The $is operator matches documents where the value of a field equals the specified value. - * @see https://docs.mongodb.com/manual/reference/operator/query/eq/ - * - * @example {{{"qty" $is 20}}} - * - * @param v is value - * @return the filter - */ - def $is[T](v: T)(implicit writer: JsonWriter[T]): JsObject = - query(field, "$eq", v.asJsonExpanded) - - /** - * $ne selects the documents where the value of the field is not equal to the specified value. - * This includes documents that do not contain the field. - * @see https://docs.mongodb.com/manual/reference/operator/query/ne/ - * - * @example {{{"qty" $ne 20}}} - * - * @param v is value - * @return the filter - */ - def $ne(v: JsValue): JsObject = - query(field, "$ne", v) - - /** - * $ne selects the documents where the value of the field is not equal to the specified value. - * This includes documents that do not contain the field. - * @see https://docs.mongodb.com/manual/reference/operator/query/ne/ - * - * @example {{{"qty" $ne 20}}} - * - * @param v is value - * @return the filter - */ - def $ne[T](v: T)(implicit writer: JsonWriter[T]): JsObject = - query(field, "$ne", v.asJsonExpanded) - - /** - * $gt selects those documents where the value of the field is greater than (i.e. >) the specified value. - * @see https://docs.mongodb.com/manual/reference/operator/query/gt/ - * - * @example {{{"qty" $gt 20}}} - * - * @param v is value - * @return the filter - */ - def $gt(v: JsValue): JsObject = - query(field, "$gt", v) - - /** - * $gt selects those documents where the value of the field is greater than (i.e. >) the specified value. - * @see https://docs.mongodb.com/manual/reference/operator/query/gt/ - * - * @example {{{"qty" $gt 20}}} - * - * @param v is value - * @return the filter - */ - def $gt[T](v: T)(implicit writer: JsonWriter[T]): JsObject = - query(field, "$gt", v.asJsonExpanded) - - /** - * $gte selects the documents where the value of the field is greater than or equal to (i.e. >=) a specified - * value (e.g. value.) - * @see https://docs.mongodb.com/manual/reference/operator/query/gte/ - * - * @example {{{"qty" $gte 20}}} - * - * @param v is value - * @return the filter - */ - def $gte(v: JsValue): JsObject = - query(field, "$gte", v) - - /** - * $gte selects the documents where the value of the field is greater than or equal to (i.e. >=) a specified - * value (e.g. value.) - * @see https://docs.mongodb.com/manual/reference/operator/query/gte/ - * - * @example {{{"qty" $gte 20}}} - * - * @param v is value - * @return the filter - */ - def $gte[T](v: T)(implicit writer: JsonWriter[T]): JsObject = - query(field, "$gte", v.asJsonExpanded) - - /** - * $lt selects the documents where the value of the field is less than (i.e. <) the specified value. - * @see https://docs.mongodb.com/manual/reference/operator/query/lt/ - * - * @example {{{"qty" $lt 20}}} - * - * @param v is value - * @return the filter - */ - def $lt(v: JsValue): JsObject = - query(field, "$lt", v) - - /** - * $lt selects the documents where the value of the field is less than (i.e. <) the specified value. - * @see https://docs.mongodb.com/manual/reference/operator/query/lt/ - * - * @example {{{"qty" $lt 20}}} - * - * @param v is value - * @return the filter - */ - def $lt[T](v: T)(implicit writer: JsonWriter[T]): JsObject = - query(field, "$lt", v.asJsonExpanded) - - /** - * $lte selects the documents where the value of the field is less than or equal to (i.e. <=) the specified value. - * @see https://docs.mongodb.com/manual/reference/operator/query/lte/ - * - * @example {{{"qty" $lte 20}}} - * - * @param v is value - * @return the filter - */ - def $lte(v: JsValue): JsObject = - query(field, "$lte", v) - - /** - * $lte selects the documents where the value of the field is less than or equal to (i.e. <=) the specified value. - * @see https://docs.mongodb.com/manual/reference/operator/query/lte/ - * - * @example {{{"qty" $lte 20}}} - * - * @param v is value - * @return the filter - */ - def $lte[T](v: T)(implicit writer: JsonWriter[T]): JsObject = - query(field, "$lte", v.asJsonExpanded) - - /** - * The $in operator selects the documents where the value of a field equals any value in the specified array. - * @see https://docs.mongodb.com/manual/reference/operator/query/in/ - * - * @example {{{"qty" $in (5, 15)}}} - * - * @param v is value - * @return the filter - */ - def $in(v: JsValue*): JsObject = - JsObject(field -> JsObject("$in" -> JsArray(v: _*))) - - /** - * The $in operator selects the documents where the value of a field equals any value in the specified array. - * @see https://docs.mongodb.com/manual/reference/operator/query/in/ - * - * @example {{{"qty" $in (5, 15)}}} - * - * @param v is value - * @return the filter - */ - def $in[T](v: T*)(implicit writer: JsonWriter[T]): JsObject = - JsObject(field -> JsObject("$in" -> JsArray(seqObjAsSeqJsVal(v): _*))) - - /** - * $nin selects the documents where: - * • the field value is not in the specified array or - * • the field does not exist. - * @see https://docs.mongodb.com/manual/reference/operator/query/nin/ - * - * @example {{{"qty" $nin (5, 15)}}} - * - * @param v is value - * @return the filter - */ - def $nin(v: JsValue*): JsObject = - JsObject(field -> JsObject("$nin" -> JsArray(v: _*))) - - /** - * $nin selects the documents where: - * • the field value is not in the specified array or - * • the field does not exist. - * @see https://docs.mongodb.com/manual/reference/operator/query/nin/ - * - * @example {{{"qty" $nin (5, 15)}}} - * - * @param v is value - * @return the filter - */ - def $nin[T](v: T*)(implicit writer: JsonWriter[T]): JsObject = - JsObject(field -> JsObject("$nin" -> JsArray(seqObjAsSeqJsVal(v): _*))) - - /** - * When exists is true, $exists matches the documents that contain the field, including documents where the - * field value is null. - * If exists is false, the query returns only the documents that do not contain the field. - * @see https://docs.mongodb.com/manual/reference/operator/query/exists/ - * - * @example {{{"qty" $exists true}}} - * - * @param exists is value - * @return the filter - */ - def $exists(exists: Boolean): JsObject = - JsObject(field -> JsObject("$exists" -> JsBoolean(exists))) - - /** - * Provides regular expression capabilities for pattern matching strings in queries. - * MongoDB uses Perl compatible regular expressions (i.e. “PCRE” ) version 8.41 with UTF-8 support. - * @see https://docs.mongodb.com/manual/reference/operator/query/regex/ - * - * @example {{{"name" $regex "acme.*corp"}}} - * - * @param pattern is pattern - * @return the filter - */ - def $regex(pattern: String): JsObject = - JsObject(field -> JsObject("$regex" -> JsString(pattern))) - - /** - * Provides regular expression capabilities for pattern matching strings in queries. - * MongoDB uses Perl compatible regular expressions (i.e. “PCRE” ) version 8.41 with UTF-8 support. - * @see https://docs.mongodb.com/manual/reference/operator/query/regex/ - * - * @example {{{"name" $regex ("acme.*corp", "i")}}} - * - * @param pattern is pattern - * @param options is options - * @return the filter - */ - def $regex(pattern: String, options: String): JsObject = - JsObject(field -> JsObject("$regex" -> JsString(pattern), "$options" -> JsString(options))) - - /** - * Provides regular expression capabilities for pattern matching strings in queries. - * MongoDB uses Perl compatible regular expressions (i.e. “PCRE” ) version 8.41 with UTF-8 support. - * @see https://docs.mongodb.com/manual/reference/operator/query/regex/ - * - * @example {{{"name" $regex "acme.*corp".r}}} - * - * @param regex is regular expressions value - * @return the filter - */ - def $regex(regex: Regex): JsObject = - Filters.regex(field, regex).toJson - - /** - * The $all operator selects the documents where the value of a field is an array that contains all the specified - * elements. - * @see https://docs.mongodb.com/manual/reference/operator/query/all/ - * - * @example {{{"tags" $all ("ssl", "security")}}} - * - * @param v is value - * @return the filter - */ - def $all(v: JsValue*): JsObject = - JsObject(field -> JsObject("$all" -> JsArray(v: _*))) - - /** - * The $all operator selects the documents where the value of a field is an array that contains all the specified - * elements. - * @see https://docs.mongodb.com/manual/reference/operator/query/all/ - * - * @example {{{"tags" $all ("ssl", "security")}}} - * - * @param v is value - * @return the filter - */ - def $all[T](v: T*)(implicit writer: JsonWriter[T]): JsObject = - JsObject(field -> JsObject("$all" -> JsArray(seqObjAsSeqJsVal(v): _*))) - - /** - * The $elemMatch operator matches documents that contain an array field with at least one element that matches - * all the specified query criteria. - * @see https://docs.mongodb.com/manual/reference/operator/query/elemMatch/ - * - * @example {{{"results" $elemMatch ("product" $eq "xyz")}}} - * - * @param v is value - * @return the filter - */ - def $elemMatch(v: JsValue): JsObject = - JsObject(field -> JsObject("$elemMatch" -> v)) - - /** - * The $elemMatch operator matches documents that contain an array field with at least one element that matches - * all the specified query criteria. - * @see https://docs.mongodb.com/manual/reference/operator/query/elemMatch/ - * - * @example {{{"results" $elemMatch ("product" $eq "xyz")}}} - * - * @param v is value - * @return the filter - */ - def $elemMatch[T](v: T)(implicit writer: JsonWriter[T]): JsObject = - JsObject(field -> JsObject("$elemMatch" -> v.asJsonExpanded)) - - /** - * The $size operator matches any array with the number of elements specified by the argument. - * @see https://docs.mongodb.com/manual/reference/operator/query/size/ - * - * @example {{{"field" $size 2}}} - * - * @param size is value - * @return the filter - */ - def $size(size: Int): JsObject = - JsObject(field -> JsObject("$size" -> JsNumber(size))) - - /** - * $not performs a logical NOT operation on the specified operator-expression and selects the documents that - * do not match the operator-expression. - * This includes documents that do not contain the field. - * - * @example {{{"price" $not { _ $gt 1.99 } }}} - * - * @see https://docs.mongodb.com/manual/reference/operator/query/not/ - * @param filter is operator-expression. - * @return the filter - */ - def $not(filter: String => JsValue): JsObject = - Filters.not(Document(filter(field).compactPrint)).toJson - } - -} - -object GreenLeafMongoDsl extends GreenLeafMongoDsl { - override protected val jws: JsonWriterSettings = JsonWriterSettings.builder().outputMode(JsonMode.RELAXED).build() -} diff --git a/src/main/scala/io/github/greenleafoss/mongo/MongoObservableToFuture.scala b/src/main/scala/io/github/greenleafoss/mongo/MongoObservableToFuture.scala deleted file mode 100644 index 961c49f..0000000 --- a/src/main/scala/io/github/greenleafoss/mongo/MongoObservableToFuture.scala +++ /dev/null @@ -1,55 +0,0 @@ -package io.github.greenleafoss.mongo - -import org.bson.json.{JsonMode, JsonWriterSettings} -import org.mongodb.scala.bson.collection.immutable.Document -import org.mongodb.scala.{AggregateObservable, FindObservable, SingleObservable} -import org.mongodb.scala._ -import spray.json._ - -import scala.concurrent.{ExecutionContext, Future} - -trait MongoObservableToFuture { - - protected def jws: JsonWriterSettings - - protected def findObservableAsSeq[T](x: FindObservable[Document])(implicit jf: JsonFormat[T], ec: ExecutionContext): Future[Seq[T]] = { - x.toFuture().map(_.map(_.toJson(jws).parseJson.convertTo[T])) - } - - protected def findObservableAsOpt[T](x: FindObservable[Document])(implicit jf: JsonFormat[T], ec: ExecutionContext): Future[Option[T]] = { - x.headOption().map(_.map(_.toJson(jws).parseJson.convertTo[T])) - } - - protected def findObservableAsObj[T](x: FindObservable[Document])(implicit jf: JsonFormat[T], ec: ExecutionContext): Future[T] = { - x.head().map(_.toJson(jws).parseJson.convertTo[T]) - } - - protected implicit class MongoFindObservableAsFutureDsl(x: FindObservable[Document]) { - def asSeq[T](implicit jf: JsonFormat[T], ec: ExecutionContext): Future[Seq[T]] = findObservableAsSeq(x) - def asOpt[T](implicit jf: JsonFormat[T], ec: ExecutionContext): Future[Option[T]] = findObservableAsOpt(x) - def asObj[T](implicit jf: JsonFormat[T], ec: ExecutionContext): Future[T] = findObservableAsObj(x) - } - - - protected def singleObservableAsOpt[T](x: SingleObservable[Document])(implicit jf: JsonFormat[T], ec: ExecutionContext): Future[Option[T]] = { - x.toFutureOption().map { _.map(_.toJson(jws).parseJson.convertTo[T]) } - } - - protected implicit class MongoSingleObservableDocumentToFutureRes(x: SingleObservable[Document]) { - def asOpt[T](implicit jf: JsonFormat[T], ec: ExecutionContext): Future[Option[T]] = singleObservableAsOpt(x) - } - - - protected def aggObservableAsOpt[T](x: AggregateObservable[Document])(implicit jf: JsonFormat[T], ec: ExecutionContext): Future[Seq[T]] = { - x.toFuture().map(_.map(_.toJson(jws).parseJson.convertTo[T])) - } - - protected implicit class MongoAggObservableAsFutureDsl(x: AggregateObservable[Document]) { - def asSeq[T](implicit jf: JsonFormat[T], ec: ExecutionContext): Future[Seq[T]] = aggObservableAsOpt(x) - } - -} - -object MongoObservableToFuture extends MongoObservableToFuture { - override protected val jws: JsonWriterSettings = JsonWriterSettings.builder().outputMode(JsonMode.RELAXED).build() -} diff --git a/src/main/scala/io/github/greenleafoss/mongo/ZonedDateTimeOps.scala b/src/main/scala/io/github/greenleafoss/mongo/ZonedDateTimeOps.scala deleted file mode 100644 index be54cd9..0000000 --- a/src/main/scala/io/github/greenleafoss/mongo/ZonedDateTimeOps.scala +++ /dev/null @@ -1,36 +0,0 @@ -package io.github.greenleafoss.mongo - -import java.time._ -import java.time.format.DateTimeFormatter -import java.time.temporal.{ChronoUnit, TemporalUnit} - -trait ZonedDateTimeOps { - - val DatePattern: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneOffset.UTC) - val DateTimePattern: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneOffset.UTC) - val DateTimeIsoPattern: DateTimeFormatter = DateTimeFormatter.ISO_DATE_TIME.withZone(ZoneOffset.UTC) - - def parse(str: String, formatter: DateTimeFormatter): ZonedDateTime = ZonedDateTime.parse(str, formatter) - // https://bugs.openjdk.java.net/browse/JDK-8041360 - def parseDate(str: String): ZonedDateTime = LocalDate.parse(str, DatePattern).atStartOfDay(ZoneOffset.UTC) - def parseDateTime(str: String): ZonedDateTime = parse(str, DateTimePattern) - def parseDateTimeIso(str: String): ZonedDateTime = parse(str, DateTimeIsoPattern) - - def print(zdt: ZonedDateTime, formatter: DateTimeFormatter): String = zdt.format(formatter) - def printDate(zdt: ZonedDateTime): String = print(zdt, DatePattern) - def printDateTime(zdt: ZonedDateTime): String = print(zdt, DateTimePattern) - def printDateTimeIso(zdt: ZonedDateTime): String = print(zdt, DateTimeIsoPattern) - - def now(truncate: TemporalUnit = ChronoUnit.MILLIS): ZonedDateTime = ZonedDateTime.now(ZoneOffset.UTC).truncatedTo(truncate) - def now: ZonedDateTime = now(ChronoUnit.MILLIS) - - object Implicits { - import scala.language.implicitConversions - - implicit def strToDate(str: String): ZonedDateTime = parseDate(str) - implicit def strToDateTime(str: String): ZonedDateTime = parseDateTime(str) - implicit def strToDateTimeIso(str: String): ZonedDateTime = parseDateTimeIso(str) - } -} - -object ZonedDateTimeOps extends ZonedDateTimeOps diff --git a/src/test/scala/io/github/greenleafoss/mongo/BigDecimalJsonAndBsonFormatTest.scala b/src/test/scala/io/github/greenleafoss/mongo/BigDecimalJsonAndBsonFormatTest.scala deleted file mode 100644 index 9029ccf..0000000 --- a/src/test/scala/io/github/greenleafoss/mongo/BigDecimalJsonAndBsonFormatTest.scala +++ /dev/null @@ -1,33 +0,0 @@ -package io.github.greenleafoss.mongo - -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AnyWordSpec -import spray.json._ - -class BigDecimalJsonAndBsonFormatTest extends AnyWordSpec with Matchers { - - "BigDecimalJsonFormat" should { - - "write as JsNumber in JSON" in { - import GreenLeafJsonProtocol._ - BigDecimal("3.1415").toJson shouldBe JsNumber("3.1415") - } - - "read value from JsNumber in JSON" in { - import GreenLeafJsonProtocol._ - "3.1415".parseJson.convertTo[BigDecimal] shouldBe BigDecimal("3.1415") - } - - "write value as $numberDecimal in BSON" in { - import GreenLeafBsonProtocol._ - BigDecimal("3.1415").toJson shouldBe JsObject("$numberDecimal" -> JsString("3.1415")) - } - - "read value from $numberDecimal in BSON" in { - import GreenLeafBsonProtocol._ - """{"$numberDecimal": "3.1415"}""".parseJson.convertTo[BigDecimal] shouldBe BigDecimal("3.1415") - } - - } - -} diff --git a/src/test/scala/io/github/greenleafoss/mongo/BooleanJsonFormatTest.scala b/src/test/scala/io/github/greenleafoss/mongo/BooleanJsonFormatTest.scala deleted file mode 100644 index c82505b..0000000 --- a/src/test/scala/io/github/greenleafoss/mongo/BooleanJsonFormatTest.scala +++ /dev/null @@ -1,27 +0,0 @@ -package io.github.greenleafoss.mongo - -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AnyWordSpec -import spray.json._ - -class BooleanJsonFormatTest extends AnyWordSpec with Matchers { - - import GreenLeafJsonProtocol._ - - "BooleanJsonFormat" should { - - "read Boolean value as JsBoolean in JSON" in { - "true".parseJson.convertTo[Boolean] shouldBe true - "false".parseJson.convertTo[Boolean] shouldBe false - } - - "read Boolean value as JsString in JSON" in { - "\"true\"".parseJson.convertTo[Boolean] shouldBe true - "\"TRUE\"".parseJson.convertTo[Boolean] shouldBe true - "\"false\"".parseJson.convertTo[Boolean] shouldBe false - "\"FALSE\"".parseJson.convertTo[Boolean] shouldBe false - } - - } - -} \ No newline at end of file diff --git a/src/test/scala/io/github/greenleafoss/mongo/EntityWithIdAsFieldDaoTest.scala b/src/test/scala/io/github/greenleafoss/mongo/EntityWithIdAsFieldDaoTest.scala deleted file mode 100644 index 18a6e1f..0000000 --- a/src/test/scala/io/github/greenleafoss/mongo/EntityWithIdAsFieldDaoTest.scala +++ /dev/null @@ -1,251 +0,0 @@ -package io.github.greenleafoss.mongo - -import java.util.UUID - -import GreenLeafMongoDao.DaoBsonProtocol -import org.mongodb.scala.bson.collection.immutable.Document -import org.mongodb.scala.MongoCollection -import spray.json._ - -import scala.concurrent.Future - -object EntityWithIdAsFieldDaoTest { - - object BuildingModel { - - // MODEL - - // ID as single field { "id": 1, "name": ... } - case class Building(id: Long, name: String, height: Int, floors: Int, year: Int, address: String) - - // JSON - - trait BuildingModelJsonProtocol extends GreenLeafJsonProtocol { - implicit lazy val BuildingFormat: RootJsonFormat[Building] = jsonFormat6(Building.apply) - } - - object BuildingModelJsonProtocol extends BuildingModelJsonProtocol - - // BSON - - class BuildingModelBsonProtocol - extends BuildingModelJsonProtocol - with GreenLeafBsonProtocol - with DaoBsonProtocol[Long, Building] { - - override implicit lazy val BuildingFormat: RootJsonFormat[Building] = - jsonFormat(Building.apply, "_id", "name", "height", "floors", "year", "address") - - override implicit val idFormat: JsonFormat[Long] = LongJsonFormat - override implicit val entityFormat: JsonFormat[Building] = BuildingFormat - } - - } - - import BuildingModel._ - - - class BuildingDao(collectionName: String) extends TestGreenLeafMongoDao[Long, Building] { - - override protected val collection: MongoCollection[Document] = db.getCollection(collectionName) - - override protected val protocol: BuildingModelBsonProtocol = new BuildingModelBsonProtocol - import protocol._ - - def findByName(name: String): Future[Seq[Building]] = { - find("name" $regex (name, "i")) - } - - def findByFloors(minFloors: Int): Future[Seq[Building]] = { - find("floors" $gte minFloors) - } - - def findByAddressAndYear(address: String, year: Int): Future[Seq[Building]] = { - find($and("address" $regex (address, "i"), "year" $gte year)) - } - } - - object BuildingDao { - def apply(): BuildingDao = new BuildingDao("test-building-" + UUID.randomUUID()) - } -} - -class EntityWithIdAsFieldDaoTest extends TestMongoServer { - - import EntityWithIdAsFieldDaoTest._ - import BuildingModel._ - - // https://en.wikipedia.org/wiki/List_of_tallest_buildings_in_New_York_City#Tallest_buildings - val BuildingsInNyc = Map( - 1L -> Building(1, "One World Trade Center", 541, 104, 2014, "285 Fulton Street"), - 2L -> Building(2, "432 Park Avenue", 426, 96, 2015, "432 Park Avenue"), - 3L -> Building(3, "30 Hudson Yards", 387, 73, 2019, "West 33rd Street"), - 4L -> Building(4, "Empire State Building", 381, 103, 1931, "350 Fifth Avenue"), - 5L -> Building(5, "Bank of America Tower", 366, 54, 2009, "1101 Sixth Avenue"), - 6L -> Building(6, "3 World Trade Center", 329, 80, 2018, "175 Greenwich Street"), - 7L -> Building(7, "53W53", 320, 77, 2018, "53 West 53rd Street"), - 8L -> Building(8, "Chrysler Building", 319, 77, 1930, "405 Lexington Avenue"), - 9L -> Building(9, "The New York Times Building", 319, 52, 2007, "620 Eighth Avenue"), - 10L -> Building(10, "35 Hudson Yards", 308, 72, 2018, "532-560 West 33rd Street") - ) - - "BuildingDao (id as single field)" should { - - "insert one record" in { - val dao = BuildingDao() - for { - insertRes <- dao.insert(BuildingsInNyc(1)) - } yield { - insertRes.wasAcknowledged shouldBe true - } - } - - "insert multiple records" in { - val dao = BuildingDao() - for { - insertRes <- dao.insert(Seq(BuildingsInNyc(2), BuildingsInNyc(3), BuildingsInNyc(4))) - } yield { - insertRes.wasAcknowledged shouldBe true - } - } - - "find by id" in { - val dao = BuildingDao() - for { - insertRes <- dao.insert(BuildingsInNyc(5)) - findRes <- dao.findById(5) - getRes <- dao.getById(5) - } yield { - insertRes.wasAcknowledged shouldBe true - findRes shouldBe Some(BuildingsInNyc(5)) - getRes shouldBe BuildingsInNyc(5) - } - } - - "find by ids" in { - val dao = BuildingDao() - for { - insertRes <- dao.insert(Seq(BuildingsInNyc(6), BuildingsInNyc(7), BuildingsInNyc(8), BuildingsInNyc(9))) - x <- dao.findByIdsIn(Seq(6, 7, 8)) - y <- dao.findByIdsIn(Seq(9)) - } yield { - insertRes.getInsertedIds should not be empty - x should contain allElementsOf Seq(BuildingsInNyc(6), BuildingsInNyc(7), BuildingsInNyc(8)) - y should contain allElementsOf Seq(BuildingsInNyc(9)) - } - } - - "find all" in { - val dao = BuildingDao() - for { - insertRes <- dao.insert(BuildingsInNyc.values.toSeq) - findAllRes <- dao.findAll() - } yield { - insertRes.getInsertedIds should not be empty - findAllRes should contain allElementsOf Seq( - BuildingsInNyc(1), BuildingsInNyc(2), BuildingsInNyc(3), BuildingsInNyc(4), BuildingsInNyc(5), - BuildingsInNyc(6), BuildingsInNyc(7), BuildingsInNyc(8), BuildingsInNyc(9), BuildingsInNyc(10)) - } - } - - "findByName" in { - val dao = BuildingDao() - for { - insertRes <- dao.insert(Seq(BuildingsInNyc(9), BuildingsInNyc(10))) - xNewYorkTimes <- dao.findByName("new york times") - x35Hudson <- dao.findByName("35 Hudson") - } yield { - insertRes.getInsertedIds should not be empty - xNewYorkTimes should contain only BuildingsInNyc(9) - x35Hudson should contain only BuildingsInNyc(10) - } - } - - "findByFloors" in { - val dao = BuildingDao() - for { - insertRes <- dao.insert(BuildingsInNyc.values.toSeq) - xGte90 <- dao.findByFloors(90) - xGte100 <- dao.findByFloors(100) - } yield { - insertRes.getInsertedIds should not be empty - xGte90 should contain only (BuildingsInNyc(1), BuildingsInNyc(2), BuildingsInNyc(4)) - xGte100 should contain only (BuildingsInNyc(1), BuildingsInNyc(4)) - } - } - - "findByAddressAndYear" in { - val dao = BuildingDao() - for { - insertRes <- dao.insert(BuildingsInNyc.values.toSeq) - xAvenue2000 <- dao.findByAddressAndYear("aVeNue", 2000) - } yield { - insertRes.getInsertedIds should not be empty - xAvenue2000 should contain only (BuildingsInNyc(2), BuildingsInNyc(5), BuildingsInNyc(9)) - } - } - - "replaceById if previous entity doesn't exist" in { - val dao = BuildingDao() - for { - updateRes <- dao.replaceById(1, BuildingsInNyc(1)) - findRes <- dao.findById(1) - } yield { - updateRes shouldBe None - // entity doesn't exist and upsert = false by default - findRes shouldBe None - } - } - - "createOrReplaceById if previous entity doesn't exist" in { - val dao = BuildingDao() - for { - updateRes <- dao.createOrReplaceById(1, BuildingsInNyc(1)) - findRes <- dao.findById(1) - getRes <- dao.getById(1) - } yield { - updateRes shouldBe None - // entity doesn't exist but upsert = true in this case - findRes shouldBe Some(BuildingsInNyc(1)) - getRes shouldBe BuildingsInNyc(1) - } - } - - "replaceById if previous entity exists" in { - val dao = BuildingDao() - val entityToCreate = BuildingsInNyc(1) - val entityToUpdate = BuildingsInNyc(1).copy(name = "UPDATED") - for { - insertRes <- dao.insert(entityToCreate) - updateRes <- dao.replaceById(1, entityToUpdate) - findRes <- dao.findById(1) - getRes <- dao.getById(1) - } yield { - insertRes.wasAcknowledged shouldBe true - updateRes shouldBe Some(entityToCreate) - findRes shouldBe Some(entityToUpdate) - getRes shouldBe entityToUpdate - } - } - - "createOrReplaceById if previous entity exists" in { - val dao = BuildingDao() - val entityToCreate = BuildingsInNyc(1) - val entityToUpdate = BuildingsInNyc(1).copy(name = "UPDATED") - for { - insertRes <- dao.insert(entityToCreate) - updateRes <- dao.createOrReplaceById(1, entityToUpdate) - findRes <- dao.findById(1) - getRes <- dao.getById(1) - } yield { - insertRes.wasAcknowledged shouldBe true - updateRes shouldBe Some(entityToCreate) - findRes shouldBe Some(entityToUpdate) - getRes shouldBe entityToUpdate - } - } - - } - -} - diff --git a/src/test/scala/io/github/greenleafoss/mongo/EntityWithOptionalFieldsDaoTest.scala b/src/test/scala/io/github/greenleafoss/mongo/EntityWithOptionalFieldsDaoTest.scala deleted file mode 100644 index fd19e4a..0000000 --- a/src/test/scala/io/github/greenleafoss/mongo/EntityWithOptionalFieldsDaoTest.scala +++ /dev/null @@ -1,201 +0,0 @@ -package io.github.greenleafoss.mongo - -import GreenLeafMongoDao.DaoBsonProtocol - -import java.util.UUID -import org.mongodb.scala.{Document, MongoCollection} -import spray.json._ - -import scala.concurrent.Future -import scala.language.implicitConversions - -object EntityWithOptionalFieldsDaoTest { - object GeoModel { - // http://www.geonames.org - - // MODEL - case class GeoKey(country: String, state: Option[String] = None, city: Option[String] = None) - case class GeoRecord(key: GeoKey, name: String, population: Int) - - object GeoKeyOps { - def apply(country: String, state: String) = GeoKey(country, Some(state), None) - def apply(country: String, state: String, city: String) = GeoKey(country, Some(state), Some(city)) - } - - // JSON - trait GeoModelJsonProtocol extends GreenLeafJsonProtocol { - implicit val GeoKeyFormat: RootJsonFormat[GeoKey] = jsonFormat3(GeoKey.apply) - implicit val GeoRecordFormat: RootJsonFormat[GeoRecord] = jsonFormat3(GeoRecord.apply) - } - - object GeoModelJsonProtocol extends GeoModelJsonProtocol - - // BSON - class GeoModelBsonProtocol - extends GeoModelJsonProtocol - with GreenLeafBsonProtocol - with DaoBsonProtocol[GeoKey, GeoRecord] { - - override implicit val GeoRecordFormat: RootJsonFormat[GeoRecord] = jsonFormat( - GeoRecord.apply, "_id", "name", "population") - - override implicit def idFormat: RootJsonFormat[GeoKey] = GeoKeyFormat - override implicit def entityFormat: RootJsonFormat[GeoRecord] = GeoRecordFormat - } - - } - - import GeoModel._ - - class GeoModelDao(collectionName: String) extends TestGreenLeafMongoDao[GeoKey, GeoRecord] { - - override protected val collection: MongoCollection[Document] = db.getCollection(collectionName) - - override protected val protocol: GeoModelBsonProtocol = new GeoModelBsonProtocol - import protocol._ - - def findCountryBy(countryCode: String): Future[Option[GeoRecord]] = { - // will return all records with this countryCode - // val filter = Document(s"""{ "_id.country": $countryCode }""") - - // will not works because 'state' and 'city' fields may not exist or be nulls - // val filter = Document(s"""{ "_id": { "country": $countryCode } }""") - - val filter = $and("_id.country" $eq countryCode, "_id.state" $eq JsNull, "_id.city" $eq JsNull) - findOne(filter) - } - - def findStateBy(countryCode: String, stateCode: String): Future[Option[GeoRecord]] = { - val filter = $and("_id.country" $eq countryCode, "_id.state" $eq stateCode, "_id.city" $eq JsNull) - findOne(filter) - } - - def findCityBy(countryCode: String, stateCode: String, cityCode: String): Future[Option[GeoRecord]] = { - val filter = $and("_id.country" $eq countryCode, "_id.state" $eq stateCode, "_id.city" $eq cityCode) - findOne(filter) - } - - } - - object GeoModelDao { - def apply(): GeoModelDao = new GeoModelDao("test-geo-model-" + UUID.randomUUID()) - } - - -} -class EntityWithOptionalFieldsDaoTest extends TestMongoServer { - - System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "trace") - - import EntityWithOptionalFieldsDaoTest._ - import GeoModel._ - - private implicit def strToStrOpt(str: String): Option[String] = Some(str) - - protected val GeoRecords = Seq( - GeoRecord(GeoKey("6252001"), "United States of America", 310232863), - GeoRecord(GeoKey("6252001", "5128638"), "New York", 19274244), - GeoRecord(GeoKey("6252001", "5128638", "5128581"), "New York City", 8175133), - GeoRecord(GeoKey("6252001", "5128638", "5133273"), "Queens", 2272771), - GeoRecord(GeoKey("6252001", "5128638", "5110302"), "Brooklyn", 2300664), - GeoRecord(GeoKey("6252001", "5101760"), "New Jersey", 8751436), - GeoRecord(GeoKey("6252001", "5101760", "5099836"), "Jersey City", 264290), - GeoRecord(GeoKey("6252001", "5101760", "5099133"), "Hoboken", 53635), - GeoRecord(GeoKey("6252001", "5332921"), "California", 37691912), - GeoRecord(GeoKey("6252001", "5332921", "5391959"), "San Francisco", 864816), - GeoRecord(GeoKey("146669"), "Republic of Cyprus", 1102677), - GeoRecord(GeoKey("2921044"), "Federal Republic of Germany", 81802257), - GeoRecord(GeoKey("2658434"), "Switzerland", 8484100), - GeoRecord(GeoKey("294640"), "State of Israel", 7353985), - GeoRecord(GeoKey("2635167"), "United Kingdom of Great Britain and Northern Ireland", 62348447), - GeoRecord(GeoKey("2750405"), "Kingdom of the Netherlands", 16645000), - GeoRecord(GeoKey("2661886"), "Kingdom of Sweden", 9828655), - GeoRecord(GeoKey("732800"), "Republic of Bulgaria", 7148785), - GeoRecord(GeoKey("719819"), "Hungary", 9982000), - GeoRecord(GeoKey("3017382"), "Republic of France", 64768389), - GeoRecord(GeoKey("798544"), "Republic of Poland", 38500000), - GeoRecord(GeoKey("690791"), "Ukraine", 45415596) - ) - - "GeoModelDao" should { - - "findCountryBy" in { - val dao = GeoModelDao() - for { - insertRes <- dao.insert(GeoRecords) - - usaByCode <- dao.findCountryBy("6252001") - usaByKey <- dao.findById(GeoKey("6252001")) - - cyprusByCode <- dao.findCountryBy("146669") - cyprusByKey <- dao.findById(GeoKey("146669")) - - uaByCode <- dao.findCountryBy("690791") - uaByKey <- dao.findById(GeoKey("690791")) - } yield { - insertRes.getInsertedIds should not be empty - - usaByCode shouldBe Some(GeoRecord(GeoKey("6252001"), "United States of America", 310232863)) - usaByKey shouldBe Some(GeoRecord(GeoKey("6252001"), "United States of America", 310232863)) - - cyprusByCode shouldBe Some(GeoRecord(GeoKey("146669"), "Republic of Cyprus", 1102677)) - cyprusByKey shouldBe Some(GeoRecord(GeoKey("146669"), "Republic of Cyprus", 1102677)) - - uaByCode shouldBe Some(GeoRecord(GeoKey("690791"), "Ukraine", 45415596)) - uaByKey shouldBe Some(GeoRecord(GeoKey("690791"), "Ukraine", 45415596)) - - } - } - - - "findStateBy" in { - val dao = GeoModelDao() - for { - insertRes <- dao.insert(GeoRecords) - - nyByCode <- dao.findStateBy("6252001", "5128638") - nyByKey <- dao.findById(GeoKey("6252001", "5128638")) - - njByCode <- dao.findStateBy("6252001", "5101760") - njByKey <- dao.findById(GeoKey("6252001", "5101760")) - - caByCode <- dao.findStateBy("6252001", "5332921") - caByKey <- dao.findById(GeoKey("6252001", "5332921")) - } yield { - insertRes.getInsertedIds should not be empty - - nyByCode shouldBe Some(GeoRecord(GeoKey("6252001", "5128638"), "New York", 19274244)) - nyByKey shouldBe Some(GeoRecord(GeoKey("6252001", "5128638"), "New York", 19274244)) - - njByCode shouldBe Some(GeoRecord(GeoKey("6252001", "5101760"), "New Jersey", 8751436)) - njByKey shouldBe Some(GeoRecord(GeoKey("6252001", "5101760"), "New Jersey", 8751436)) - - caByCode shouldBe Some(GeoRecord(GeoKey("6252001", "5332921"), "California", 37691912)) - caByKey shouldBe Some(GeoRecord(GeoKey("6252001", "5332921"), "California", 37691912)) - } - } - - "findCityBy" in { - val dao = GeoModelDao() - for { - insertRes <- dao.insert(GeoRecords) - - nycByCode <- dao.findCityBy("6252001", "5128638", "5128581") - nycByKey <- dao.findById(GeoKey("6252001", "5128638", "5128581")) - - hbkByCode <- dao.findCityBy("6252001", "5101760", "5099133") - hbkByKey <- dao.findById(GeoKey("6252001", "5101760", "5099133")) - } yield { - insertRes.getInsertedIds should not be empty - - nycByCode shouldBe Some(GeoRecord(GeoKey("6252001", "5128638", "5128581"), "New York City", 8175133)) - nycByKey shouldBe Some(GeoRecord(GeoKey("6252001", "5128638", "5128581"), "New York City", 8175133)) - - hbkByCode shouldBe Some(GeoRecord(GeoKey("6252001", "5101760", "5099133"), "Hoboken", 53635)) - hbkByKey shouldBe Some(GeoRecord(GeoKey("6252001", "5101760", "5099133"), "Hoboken", 53635)) - } - } - - } - -} diff --git a/src/test/scala/io/github/greenleafoss/mongo/EntityWithoutIdDaoTest.scala b/src/test/scala/io/github/greenleafoss/mongo/EntityWithoutIdDaoTest.scala deleted file mode 100644 index 6f33a88..0000000 --- a/src/test/scala/io/github/greenleafoss/mongo/EntityWithoutIdDaoTest.scala +++ /dev/null @@ -1,176 +0,0 @@ -package io.github.greenleafoss.mongo - -import java.time.ZonedDateTime -import java.util.UUID -import ZonedDateTimeOps._ -import io.github.greenleafoss.mongo.GreenLeafMongoDao.DaoBsonProtocol -import org.mongodb.scala.bson.ObjectId -import org.mongodb.scala.bson.collection.immutable.Document -import org.mongodb.scala.bson.conversions.Bson -import org.mongodb.scala.model.IndexOptions -import org.mongodb.scala.model.Indexes._ -import org.mongodb.scala.MongoCollection -import org.mongodb.scala._ -import spray.json.{JsonFormat, RootJsonFormat} - -import scala.concurrent.Future - -object EntityWithoutIdDaoTest { - - object EventModel { - - // MODEL - - object EventSource extends Enumeration { - type EventSource = Value - - val Internal: Value = Value(1, "Internal") - val WebApp: Value = Value(2, "WebApp") - val MobileApp: Value = Value(3, "MobileApp") - val DesktopApp: Value = Value(4, "DesktopApp") - - } - - case class Event(userId: Long, source: EventSource.EventSource, comment: String, timestamp: ZonedDateTime = now) - - // JSON - - trait EventJsonProtocol extends GreenLeafJsonProtocol { - implicit lazy val EventSourceFormat: JsonFormat[EventSource.EventSource] = enumToJsonFormatAsString(EventSource) - implicit lazy val EventFormat: RootJsonFormat[Event] = jsonFormat4(Event.apply) - } - - object EventJsonProtocol extends EventJsonProtocol - - // BSON - - class EventBsonProtocol - extends EventJsonProtocol - with GreenLeafBsonProtocol - with DaoBsonProtocol[ObjectId, Event] { - - override implicit lazy val EventSourceFormat: JsonFormat[EventSource.EventSource] = - enumToJsonFormatAsInt(EventSource) - - override implicit val idFormat: JsonFormat[ObjectId] = ObjectIdJsonFormat - override implicit val entityFormat: RootJsonFormat[Event] = EventFormat - } - - } - - import EventModel._ - - class EventDao(collectionName: String) extends TestGreenLeafMongoDao[ObjectId, Event] { - - protected val collection: MongoCollection[Document] = db.getCollection(collectionName) - collection.createIndex(key = ascending("timestamp"), IndexOptions().name("idx-timestamp")).toFuture() - - override protected val protocol: EventBsonProtocol = new EventBsonProtocol - import protocol._ - - override def findAll(offset: Int = 0, limit: Int = 0, sortBy: Bson = Document("""{timestamp: 1}""")): Future[Seq[Event]] = { - find(Document.empty, offset, limit, sortBy) - } - - def findLastN(limit: Int = 0, sortBy: Bson = Document("""{timestamp: -1}""")): Future[Seq[Event]] = { - find(Document.empty, 0, limit, sortBy) - } - - def findBySource(source: EventSource.EventSource): Future[Seq[Event]] = { - find("source" $eq source) - } - } - - object EventDao { - def apply(): EventDao = new EventDao("test-event-" + UUID.randomUUID()) - } -} - -class EntityWithoutIdDaoTest extends TestMongoServer { - - import EntityWithoutIdDaoTest._ - import EventModel._ - - private val Events = Array( - Event(1L, EventSource.WebApp, "Request to create an account"), - Event(1L, EventSource.Internal, "Account created"), - Event(1L, EventSource.WebApp, "Request to extended access"), - Event(1L, EventSource.Internal, "Request to provide additional details"), - Event(1L, EventSource.DesktopApp, "Additional details provided"), - Event(1L, EventSource.Internal, "Additional details approved"), - Event(1L, EventSource.Internal, "Access granted"), - - Event(2L, EventSource.WebApp, "Request to create an account"), - Event(2L, EventSource.Internal, "Account created"), - - Event(3L, EventSource.WebApp, "Request to create an account"), - Event(3L, EventSource.Internal, "Account created") - ) - - "EventDao (entity without _id)" should { - - "insert one record" in { - val dao = EventDao() - for { - insertRes <- dao.insert(Events(0)) - } yield { - insertRes.wasAcknowledged shouldBe true - } - } - - "insert multiple records" in { - val dao = EventDao() - for { - insertRes <- dao.insert(Seq(Events(1), Events(2), Events(3))) - } yield { - insertRes.getInsertedIds should not be empty - } - } - - "find all" in { - val dao = EventDao() - for { - insertRes <- dao.insert(Seq(Events(0), Events(1), Events(2), Events(3))) - xAll <- dao.findAll() - } yield { - insertRes.getInsertedIds should not be empty - xAll.size shouldBe 4 - xAll should contain allElementsOf Seq(Events(0), Events(1), Events(2), Events(3)) - xAll(0) shouldBe Events(0) - xAll(1) shouldBe Events(1) - xAll(2) shouldBe Events(2) - xAll(3) shouldBe Events(3) - } - } - - "find last N events" in { - val dao = EventDao() - for { - insertRes <- dao.insert(Seq(Events(0), Events(1), Events(2), Events(3))) - xAll <- dao.findLastN(2) - } yield { - insertRes.getInsertedIds should not be empty - xAll.size shouldBe 2 - xAll should contain allElementsOf Seq(Events(2), Events(3)) - xAll(0) shouldBe Events(3) // last event - xAll(1) shouldBe Events(2) // last - 1 event - } - } - - "find by source" in { - val dao = EventDao() - for { - insertRes <- dao.insert(Seq(Events(0), Events(1), Events(2), Events(3))) - xAll <- dao.findBySource(EventSource.Internal) - } yield { - insertRes.getInsertedIds should not be empty - xAll.size shouldBe 2 - xAll should contain allElementsOf Seq(Events(1), Events(3)) - xAll(0) shouldBe Events(1) - xAll(1) shouldBe Events(3) - } - } - - } - -} diff --git a/src/test/scala/io/github/greenleafoss/mongo/GreenLeafJsonAndBsonProtocolsTest.scala b/src/test/scala/io/github/greenleafoss/mongo/GreenLeafJsonAndBsonProtocolsTest.scala deleted file mode 100644 index 4a5ace7..0000000 --- a/src/test/scala/io/github/greenleafoss/mongo/GreenLeafJsonAndBsonProtocolsTest.scala +++ /dev/null @@ -1,88 +0,0 @@ -package io.github.greenleafoss.mongo - -import java.time.ZonedDateTime - -import ZonedDateTimeOps.Implicits.strToDate - -import org.mongodb.scala.bson.ObjectId -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AnyWordSpec -import spray.json._ - -object GreenLeafJsonAndBsonProtocolsTest { - - // MODEL - case class Test(id: ObjectId, i: Int, l: Long, b: Boolean, zdt: ZonedDateTime) - - // JSON - trait TestJsonProtocol extends GreenLeafJsonProtocol { - implicit def testJf: RootJsonFormat[Test] = jsonFormat5(Test.apply) - } - object TestJsonProtocol extends TestJsonProtocol - - // BSON - trait TestBsonProtocol extends TestJsonProtocol with GreenLeafBsonProtocol { - override implicit def testJf: RootJsonFormat[Test] = jsonFormat(Test.apply, "_id", "i", "l", "b", "zdt") - } - object TestBsonProtocol extends TestBsonProtocol -} - -class GreenLeafJsonAndBsonProtocolsTest - extends AnyWordSpec - with Matchers { - - import GreenLeafJsonAndBsonProtocolsTest._ - private val obj = Test(new ObjectId("5c72b799306e355b83ef3c86"), 1, 0x123456789L, true, "1970-01-01") - - private val json = - """ - |{ - | "id": "5c72b799306e355b83ef3c86", - | "i": 1, - | "l": 4886718345, - | "b": true, - | "zdt": "1970-01-01 00:00:00" - |} - """.stripMargin - - private val bson = - """ - |{ - | "_id": { - | "$oid": "5c72b799306e355b83ef3c86" - | }, - | "i": 1, - | "l": 4886718345, - | "b": true, - | "zdt": { - | "$date": "1970-01-01T00:00:00Z" - | } - |} - """.stripMargin - - - "GreenLeafJsonAndBsonProtocols" should { - - "write Obj as JSON" in { - import TestJsonProtocol._ - obj.toJson shouldBe json.parseJson - } - - "read JSON as Obj" in { - import TestJsonProtocol._ - json.parseJson.convertTo[Test] shouldBe obj - } - - "write Obj as BSON" in { - import TestBsonProtocol._ - obj.toJson shouldBe bson.parseJson - } - - "read BSON as Obj" in { - import TestBsonProtocol._ - bson.parseJson.convertTo[Test] shouldBe obj - } - - } - -} diff --git a/src/test/scala/io/github/greenleafoss/mongo/GreenLeafMongoDslTest.scala b/src/test/scala/io/github/greenleafoss/mongo/GreenLeafMongoDslTest.scala deleted file mode 100644 index c586ea8..0000000 --- a/src/test/scala/io/github/greenleafoss/mongo/GreenLeafMongoDslTest.scala +++ /dev/null @@ -1,313 +0,0 @@ -package io.github.greenleafoss.mongo - -import org.mongodb.scala.bson.collection.immutable.Document -import org.mongodb.scala.bson.conversions.Bson -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AsyncWordSpec -import spray.json._ - -class GreenLeafMongoDslTest - extends AsyncWordSpec - with Matchers { - - import GreenLeafBsonProtocol._ - import GreenLeafMongoDsl._ - - implicit class JsValueToBson(jsValue: JsValue) { - def asBson: Bson = Document(jsValue.compactPrint) - } - - implicit class JsonStringToBson(str: String) { - def asBson: Bson = Document(str) - } - - // TODO add tests for custom case classes - - "GreenLeafMongoDsl" should { - - "$eq" in { - // https://docs.mongodb.com/manual/reference/operator/query/eq/ - - ("qty" $eq 20).asBson shouldBe """{ qty: { $eq: 20 } }""".asBson - ("qty" $eq 20L).asBson shouldBe """{ qty: { $eq: 20 } }""".asBson - ("qty" $eq 0x123456789L).asBson shouldBe """{ qty: { $eq: { $numberLong: "4886718345" } } }""".asBson - ("qty" $eq 20.0f).asBson shouldBe """{ qty: { $eq: 20.0 } }""".asBson - ("qty" $eq 20.0d).asBson shouldBe """{ qty: { $eq: 20.0 } }""".asBson - ("qty" $eq BigDecimal(20.0)).asBson shouldBe """{ qty: { $eq: { $numberDecimal: "20.0" } } }""".asBson - ("item.name" $eq "ab").asBson shouldBe """{ "item.name": { $eq: "ab" } }""".asBson - ("tags" $eq "B").asBson shouldBe """{ tags: { $eq: "B" } }""".asBson - ("tags" $eq ("A", "B")).asBson shouldBe """{ tags: { $eq: [ "A", "B" ] } }""".asBson - } - - "$is ($eq operator alias)" in { - // https://docs.mongodb.com/manual/reference/operator/query/eq/ - - ("qty" $is 20).asBson shouldBe """{ qty: { $eq: 20 } }""".asBson - ("qty" $is 20L).asBson shouldBe """{ qty: { $eq: 20 } }""".asBson - ("qty" $is 0x123456789L).asBson shouldBe """{ qty: { $eq: { $numberLong: "4886718345" } } }""".asBson - ("qty" $is 20.0f).asBson shouldBe """{ qty: { $eq: 20.0 } }""".asBson - ("qty" $is 20.0d).asBson shouldBe """{ qty: { $eq: 20.0 } }""".asBson - ("qty" $is BigDecimal(20.0)).asBson shouldBe """{ qty: { $eq: { $numberDecimal: "20.0" } } }""".asBson - ("item.name" $is "ab").asBson shouldBe """{ "item.name": { $eq: "ab" } }""".asBson - ("tags" $is "B").asBson shouldBe """{ tags: { $eq: "B" } }""".asBson - ("tags" $is ("A", "B")).asBson shouldBe """{ tags: { $eq: [ "A", "B" ] } }""".asBson - } - - "$gt" in { - // https://docs.mongodb.com/manual/reference/operator/query/gt/ - - ("qty" $gt 20).asBson shouldBe """{ qty: { $gt: 20 } }""".asBson - ("carrier.fee" $gt 2).asBson shouldBe """{ "carrier.fee": { $gt: 2 } }""".asBson - } - - "$gte" in { - // https://docs.mongodb.com/manual/reference/operator/query/gte/ - - ("qty" $gte 20).asBson shouldBe """{ qty: { $gte: 20 } }""".asBson - ("carrier.fee" $gte 2).asBson shouldBe """{ "carrier.fee": { $gte: 2 } }""".asBson - } - - "$in" in { - // https://docs.mongodb.com/manual/reference/operator/query/in/ - - ("qty" $in (5, 15)).asBson shouldBe """{ qty: { $in: [ 5, 15 ] } }""".asBson - ("qty" $in (2.7, 3.1415)).asBson shouldBe """{ qty: { $in: [ 2.7, 3.1415] } }""".asBson - ("qty" $in (0x123456789L, 128, 256, 512)).asBson shouldBe """{ qty: { $in: [ { $numberLong: "4886718345" }, 128, 256, 512 ] } }""".asBson - ("tags" $in ("appliances", "school")).asBson shouldBe """{ tags: { $in: ["appliances", "school"] } }""".asBson - // Impossible too use $regex inside $in query https://jira.mongodb.org/browse/SERVER-14595 - ("tags" $in ("^be".r, "^st".r)).asBson shouldBe """{ tags: { "$in" : [ { "$regex": "^be" }, { "$regex": "^st" }] } }""".asBson - } - - "$lt" in { - // https://docs.mongodb.com/manual/reference/operator/query/lt/ - - ("qty" $lt 20).asBson shouldBe """{ qty: { $lt: 20 } }""".asBson - ("carrier.fee" $lt 20).asBson shouldBe """{ "carrier.fee": { $lt: 20 } }""".asBson - } - - "$lte" in { - // https://docs.mongodb.com/manual/reference/operator/query/lte/ - - ("qty" $lte 20).asBson shouldBe """{ qty: { $lte: 20 } }""".asBson - ("carrier.fee" $lte 5).asBson shouldBe """{ "carrier.fee": { $lte: 5 } }""".asBson - } - - "$ne" in { - // https://docs.mongodb.com/manual/reference/operator/query/ne/ - - ("qty" $ne 20).asBson shouldBe """{ qty: { $ne: 20 } }""".asBson - ("carrier.state" $ne "NY").asBson shouldBe """{ "carrier.state": { $ne: "NY" } }""".asBson - } - - "$nin" in { - // https://docs.mongodb.com/manual/reference/operator/query/nin/ - - ("qty" $nin (5, 15)).asBson shouldBe """{ qty: { $nin: [ 5, 15 ] } }""".asBson - ("tags" $nin ("appliances", "school")).asBson shouldBe """{ tags: { $nin: [ "appliances", "school" ] } }""".asBson - } - - "$and" in { - // https://docs.mongodb.com/manual/reference/operator/query/and/ - - $and("price" $ne 1.99, "price" $exists true).asBson shouldBe - """{$and: [{price: {$ne: 1.99}}, {price: {$exists :true}} ]}""".asBson - - $and( - $or("price" $eq 0.99, "price" $eq 1.99), - $or("sale" $eq true, "qty" $lt 20) - ).asBson shouldBe - """ - |{ - | $and: [ - | { $or: [ { price: { $eq: 0.99 } }, { price: { $eq: 1.99 } } ] }, - | { $or: [ { sale: {$eq: true } }, { qty: { $lt : 20 } } ] } - | ] - |} - """.stripMargin.asBson - } - - "$not" in { - // https://docs.mongodb.com/manual/reference/operator/query/not/ - - ("price" $not { _ $gt 1.99 }).asBson shouldBe """{ price: { $not: { $gt: 1.99 } } }""".asBson - - ("item" $not { _ $regex "^p.*".r }).asBson shouldBe - """{ item: { "$not" : { "$regex" : "^p.*", "$options" : "" } } }""".asBson - } - - "$nor" in { - // https://docs.mongodb.com/manual/reference/operator/query/nor/ - - $nor("price" $eq 1.99, "sale" $eq true).asBson shouldBe - """{ $nor: [ { price: { $eq: 1.99 } }, { sale: { $eq: true } } ] }""".asBson - - $nor("price" $eq 1.99, "qty" $lt 20, "sale" $eq true).asBson shouldBe - """{ $nor: [ { price: { $eq: 1.99 } }, { qty: { $lt: 20 } }, { sale: { $eq: true } } ] }""".asBson - - $nor( - "price" $eq 1.99, - "price" $exists false, - "sale" $eq true, - "sale" $exists false - ).asBson shouldBe - """ - |{ - | $nor: [ - | { price: { $eq: 1.99 } }, - | { price: { $exists: false } }, - | { sale: { $eq: true } }, - | { sale: { $exists: false } } - | ] - |} - """.stripMargin.asBson - } - - "$or" in { - // https://docs.mongodb.com/manual/reference/operator/query/or/ - - $or("quantity" $lt 20, "price" $eq 10).asBson shouldBe - """{ $or: [ { quantity: { $lt: 20 } }, { price: { $eq: 10 } } ] }""".asBson - } - - "$exists" in { - // https://docs.mongodb.com/manual/reference/operator/query/exists/ - - $and("qty" $exists true, "qty" $nin (5, 15)).asBson shouldBe - """{ $and: [ { qty: {$exists: true} }, { qty: { $nin: [5,15] } } ] }""".asBson - } - - "$type" in { - // https://docs.mongodb.com/manual/reference/operator/query/type/ - // TODO add support of this operator - "$type" shouldBe "$type" - } - - "$expr" in { - // https://docs.mongodb.com/manual/reference/operator/query/expr/ - // TODO add support of this operator - "$expr" shouldBe "$expr" - } - - "$jsonSchema" in { - // https://docs.mongodb.com/manual/reference/operator/query/jsonSchema/ - // TODO add support of this operator - "$jsonSchema" shouldBe "$jsonSchema" - } - - "$mod" in { - // https://docs.mongodb.com/manual/reference/operator/query/mod/ - // TODO add support of this operator - "$mod" shouldBe "$mod" - } - - "$regex" in { - // https://docs.mongodb.com/manual/reference/operator/query/regex/ - - $and("name" $regex "acme.*corp", "name" $nin "acmeblahcorp").asBson shouldBe - """ - |{ - | $and: [ - | { "name" : { "$regex" : "acme.*corp" } }, - | { "name" : { "$nin" : ["acmeblahcorp"] } } - | ] - |} - """.stripMargin.asBson - - $and("name" $regex ("acme.*corp", "i"), "name" $nin "acmeblahcorp").asBson shouldBe - """ - |{ - | $and: [ - | { "name" : { "$regex" : "acme.*corp", "$options" : "i" } }, - | { "name" : { "$nin" : ["acmeblahcorp"] } } - | ] - |} - """.stripMargin.asBson - - $and("name" $regex "acme.*corp".r, "name" $nin "acmeblahcorp").asBson shouldBe - """ - |{ - | $and: [ - | { "name" : { "$regex" : "acme.*corp", "$options" : "" } }, - | { "name" : { "$nin" : ["acmeblahcorp"] } } - | ] - |} - """.stripMargin.asBson - - } - - "$text" in { - // https://docs.mongodb.com/manual/reference/operator/query/text/ - // TODO add support of this operator - "$text" shouldBe "$text" - } - - "$where" in { - // https://docs.mongodb.com/manual/reference/operator/query/where/ - // TODO add support of this operator - "$where" shouldBe "$where" - } - - "Geospatial Query Operators" in { - // https://docs.mongodb.com/manual/reference/operator/query-geospatial/ - // TODO add support of these operators - "Geospatial Query Operators" shouldBe "Geospatial Query Operators" - } - - "$all" in { - // https://docs.mongodb.com/manual/reference/operator/query/all/ - - ("tags" $all ("ssl", "security")).asBson shouldBe """{ tags: { $all: [ "ssl" , "security" ] } }""".asBson - - ("qty.num" $all 50).asBson shouldBe """{ "qty.num": { $all: [ 50 ] } }""".asBson - - ( - "qty" $all ( - $elemMatch ($and("size" $eq "M", "num" $gt 50)), - $elemMatch ($and("num" $eq 100, "color" $eq "green")) - ) - ).asBson shouldBe - """ - |{ - | "qty": { - | "$all": [ - | { "$elemMatch": { "$and": [ { "size": { "$eq": "M" } }, { "num": { "$gt": 50 } } ] } }, - | { "$elemMatch": { "$and": [ { "num": { "$eq": 100 } }, { "color": { "$eq": "green" } } ] } } - | ] - | } - |} - """.stripMargin.asBson - } - - "$elemMatch" in { - // https://docs.mongodb.com/manual/reference/operator/query/elemMatch/ - - ("results" $elemMatch JsObject("$gte" -> JsNumber(80), "$lt" -> JsNumber(85))).asBson shouldBe - """{ results: { $elemMatch: { $gte: 80, $lt: 85 } } }""".asBson - - ("results" $elemMatch Map("$gte" -> 80, "$lt" -> 85)).asBson shouldBe - """{ results: { $elemMatch: { $gte: 80, $lt: 85 } } }""".asBson - - ("results" $elemMatch $and("product" $eq "xyz", "score" $gte 8)).asBson shouldBe - """{ results: { $elemMatch: { $and: [ { product: { $eq: "xyz" } }, { score: { $gte : 8 } }] } } }""".asBson - - ("results" $elemMatch ("product" $eq "xyz")).asBson shouldBe - """{ results: { $elemMatch: { product: { $eq: "xyz" } } } }""".asBson - } - - "$size" in { - // https://docs.mongodb.com/manual/reference/operator/query/size/ - - ("field" $size 2).asBson shouldBe """{ field: { $size: 2 } } """.asBson - - ("field" $size 1).asBson shouldBe """{ field: { $size: 1 } } """.asBson - } - - "Bitwise Query Operators" in { - // https://docs.mongodb.com/manual/reference/operator/query-bitwise/ - // TODO add support of these operators - "Bitwise Query Operators" shouldBe "Bitwise Query Operators" - } - - } - -} diff --git a/src/test/scala/io/github/greenleafoss/mongo/JsValueWithoutNullTest.scala b/src/test/scala/io/github/greenleafoss/mongo/JsValueWithoutNullTest.scala deleted file mode 100644 index f515972..0000000 --- a/src/test/scala/io/github/greenleafoss/mongo/JsValueWithoutNullTest.scala +++ /dev/null @@ -1,81 +0,0 @@ -package io.github.greenleafoss.mongo - -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AnyWordSpec -import spray.json._ - -class JsValueWithoutNullTest extends AnyWordSpec with Matchers { - - case class TestNulls( - i: Option[Int], - l: Option[Long], - s: Option[String], - d: Option[Double], - n: Option[BigDecimal], - b: Option[Boolean], - a: Option[Seq[Option[Int]]] = None) - - object TestNullsBsonProtocol extends GreenLeafBsonProtocol { - implicit val testNullsFormat: RootJsonFormat[TestNulls] = jsonFormat(TestNulls.apply, "i", "l", "s", "d", "n", "b", "a") - } - - import GreenLeafMongoDsl.JsValueWithoutNull - import TestNullsBsonProtocol._ - - "JsValueWithoutNull" should { - - "not remove JsNull values from JSON if skipNull(false)" in { - TestNulls(Some(1), Some(0x123456789L), Some("a"), Some(2.7), Some(3.14), Some(true)).toJson.skipNull(false).compactPrint shouldBe - """{"a":null,"b":true,"d":2.7,"i":1,"l":4886718345,"n":{"$numberDecimal":"3.14"},"s":"a"}""" - - TestNulls(None, Some(0x123456789L), Some("a"), Some(2.7), Some(3.14), Some(true)).toJson.skipNull(false).compactPrint shouldBe - """{"a":null,"b":true,"d":2.7,"i":null,"l":4886718345,"n":{"$numberDecimal":"3.14"},"s":"a"}""" - - TestNulls(None, None, Some("a"), Some(2.7), Some(3.14), Some(true)).toJson.skipNull(false).compactPrint shouldBe - """{"a":null,"b":true,"d":2.7,"i":null,"l":null,"n":{"$numberDecimal":"3.14"},"s":"a"}""" - - TestNulls(None, None, None, Some(2.7), Some(3.14), Some(true)).toJson.skipNull(false).compactPrint shouldBe - """{"a":null,"b":true,"d":2.7,"i":null,"l":null,"n":{"$numberDecimal":"3.14"},"s":null}""" - - TestNulls(None, None, None, None, Some(3.14), Some(true)).toJson.skipNull(false).compactPrint shouldBe - """{"a":null,"b":true,"d":null,"i":null,"l":null,"n":{"$numberDecimal":"3.14"},"s":null}""" - - TestNulls(None, None, None, None, None, Some(true)).toJson.skipNull(false).compactPrint shouldBe - """{"a":null,"b":true,"d":null,"i":null,"l":null,"n":null,"s":null}""" - - TestNulls(None, None, None, None, None, None).toJson.skipNull(false).compactPrint shouldBe - """{"a":null,"b":null,"d":null,"i":null,"l":null,"n":null,"s":null}""" - - TestNulls(None, None, None, None, None, None, Some(Seq(Some(1), None, Some(3)))).toJson.skipNull(false).compactPrint shouldBe - """{"a":[1,null,3],"b":null,"d":null,"i":null,"l":null,"n":null,"s":null}""" - } - - "remove JsNull values from JSON if skipNull(true)" in { - TestNulls(Some(1), Some(0x123456789L), Some("a"), Some(2.7), Some(3.14), Some(true)).toJson.skipNull().compactPrint shouldBe - """{"s":"a","n":{"$numberDecimal":"3.14"},"i":1,"b":true,"l":4886718345,"d":2.7}""" - - TestNulls(None, Some(0x123456789L), Some("a"), Some(2.7), Some(3.14), Some(true)).toJson.skipNull().compactPrint shouldBe - """{"s":"a","n":{"$numberDecimal":"3.14"},"b":true,"l":4886718345,"d":2.7}""" - - TestNulls(None, None, Some("a"), Some(2.7), Some(3.14), Some(true)).toJson.skipNull().compactPrint shouldBe - """{"b":true,"d":2.7,"n":{"$numberDecimal":"3.14"},"s":"a"}""" - - TestNulls(None, None, None, Some(2.7), Some(3.14), Some(true)).toJson.skipNull().compactPrint shouldBe - """{"b":true,"d":2.7,"n":{"$numberDecimal":"3.14"}}""" - - TestNulls(None, None, None, None, Some(3.14), Some(true)).toJson.skipNull().compactPrint shouldBe - """{"b":true,"n":{"$numberDecimal":"3.14"}}""" - - TestNulls(None, None, None, None, None, Some(true)).toJson.skipNull().compactPrint shouldBe - """{"b":true}""" - - TestNulls(None, None, None, None, None, None).toJson.skipNull().compactPrint shouldBe - """{}""" - - TestNulls(None, None, None, None, None, None, Some(Seq(Some(1), None, Some(3)))).toJson.skipNull().compactPrint shouldBe - """{"a":[1,3]}""" - } - - } - -} diff --git a/src/test/scala/io/github/greenleafoss/mongo/LongJsonAndBsonFormatTest.scala b/src/test/scala/io/github/greenleafoss/mongo/LongJsonAndBsonFormatTest.scala deleted file mode 100644 index 7ea0a95..0000000 --- a/src/test/scala/io/github/greenleafoss/mongo/LongJsonAndBsonFormatTest.scala +++ /dev/null @@ -1,35 +0,0 @@ -package io.github.greenleafoss.mongo - -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AnyWordSpec -import spray.json._ - -class LongJsonAndBsonFormatTest extends AnyWordSpec with Matchers { - - "LongJsonFormat" should { - - "write small (int) value as JsNumber in JSON" in { - import GreenLeafJsonProtocol._ - 1L.toJson shouldBe JsNumber(1) - 1024L.toJson shouldBe JsNumber(1024) - } - - "write large (long) value as JsNumber in JSON" in { - import GreenLeafJsonProtocol._ - 0x123456789L.toJson shouldBe JsNumber(4886718345L) - } - - "write small (int) value as number in BSON" in { - import GreenLeafBsonProtocol._ - 1L.toJson shouldBe JsNumber(1) - 1024L.toJson shouldBe JsNumber(1024) - } - - "write large (long) value as $numberLong in BSON" in { - import GreenLeafBsonProtocol._ - 0x123456789L.toJson shouldBe JsNumber("4886718345") - } - - } - -} diff --git a/src/test/scala/io/github/greenleafoss/mongo/NumbersJsonFormatTest.scala b/src/test/scala/io/github/greenleafoss/mongo/NumbersJsonFormatTest.scala deleted file mode 100644 index c37259f..0000000 --- a/src/test/scala/io/github/greenleafoss/mongo/NumbersJsonFormatTest.scala +++ /dev/null @@ -1,102 +0,0 @@ -package io.github.greenleafoss.mongo - -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AnyWordSpec -import spray.json._ - -class NumbersJsonFormatTest extends AnyWordSpec with Matchers { - - import GreenLeafJsonProtocol._ - - "NumbersJsonFormats" should { - - "read Int value as JsNumber in JSON" in { - "1024".parseJson.convertTo[Int] shouldBe 1024 - } - - "read Int value as JsString in JSON" in { - "\"1024\"".parseJson.convertTo[Int] shouldBe 1024 - "\"\"".parseJson.convertTo[Int] shouldBe 0 - } - - - "read Long value as JsNumber in JSON" in { - "4886718345".parseJson.convertTo[Long] shouldBe 0x123456789L - } - - "read Long value as JsString in JSON" in { - "\"4886718345\"".parseJson.convertTo[Long] shouldBe 0x123456789L - "\"0\"".parseJson.convertTo[Long] shouldBe 0L - } - - - "read Float value as JsNumber in JSON" in { - "3.1415".parseJson.convertTo[Float] shouldBe 3.1415f - } - - "read Float value as JsString in JSON" in { - "\"3.1415\"".parseJson.convertTo[Float] shouldBe 3.1415f - "\"\"".parseJson.convertTo[Float] shouldBe 0f - } - - - "read Double value as JsNumber in JSON" in { - "3.1415926535".parseJson.convertTo[Double] shouldBe 3.1415926535d - } - - "read Double value as JsString in JSON" in { - "\"3.1415926535\"".parseJson.convertTo[Double] shouldBe 3.1415926535d - "\"\"".parseJson.convertTo[Double] shouldBe 0d - } - - - "read Byte value as JsNumber in JSON" in { - "0".parseJson.convertTo[Byte] shouldBe 0.toByte - "1".parseJson.convertTo[Byte] shouldBe 1.toByte - } - - "read Byte value as JsString in JSON" in { - "\"0\"".parseJson.convertTo[Byte] shouldBe 0.toByte - "\"1\"".parseJson.convertTo[Byte] shouldBe 1.toByte - "\"\"".parseJson.convertTo[Byte] shouldBe 0.toByte - } - - - "read Short value as JsNumber in JSON" in { - "0".parseJson.convertTo[Short] shouldBe 0.toShort - "1".parseJson.convertTo[Short] shouldBe 1.toShort - } - - "read Short value as JsString in JSON" in { - "\"0\"".parseJson.convertTo[Short] shouldBe 0.toShort - "\"1\"".parseJson.convertTo[Short] shouldBe 1.toShort - "\"\"".parseJson.convertTo[Short] shouldBe 0.toShort - } - - - "read BigDecimal value as JsNumber in JSON" in { - "3.141592653589793238462643383279".parseJson.convertTo[BigDecimal] shouldBe BigDecimal("3.141592653589793238462643383279") - "2.718281828459045235360287471352".parseJson.convertTo[BigDecimal] shouldBe BigDecimal("2.718281828459045235360287471352") - } - - "read BigDecimal value as JsString in JSON" in { - "\"3.141592653589793238462643383279\"".parseJson.convertTo[BigDecimal] shouldBe BigDecimal("3.141592653589793238462643383279") - "\"2.718281828459045235360287471352\"".parseJson.convertTo[BigDecimal] shouldBe BigDecimal("2.718281828459045235360287471352") - "\"\"".parseJson.convertTo[BigDecimal] shouldBe BigDecimal(0) - } - - - "read BigInt value as JsNumber in JSON" in { - "3316923598096294713661".parseJson.convertTo[BigInt] shouldBe BigInt("3316923598096294713661") - "1000000000000066600000000000001".parseJson.convertTo[BigInt] shouldBe BigInt("1000000000000066600000000000001") - } - - "read BigInt value as JsString in JSON" in { - "\"3316923598096294713661\"".parseJson.convertTo[BigInt] shouldBe BigInt("3316923598096294713661") - "\"1000000000000066600000000000001\"".parseJson.convertTo[BigInt] shouldBe BigInt("1000000000000066600000000000001") - "\"\"".parseJson.convertTo[BigInt] shouldBe BigInt(0) - } - - } - -} diff --git a/src/test/scala/io/github/greenleafoss/mongo/TestGreenLeafMongoDao.scala b/src/test/scala/io/github/greenleafoss/mongo/TestGreenLeafMongoDao.scala deleted file mode 100644 index 122d9c3..0000000 --- a/src/test/scala/io/github/greenleafoss/mongo/TestGreenLeafMongoDao.scala +++ /dev/null @@ -1,21 +0,0 @@ -package io.github.greenleafoss.mongo - -import org.mongodb.scala.bson.collection.immutable.Document -import org.mongodb.scala.{MongoClient, MongoDatabase} -import org.mongodb.scala.result.InsertManyResult -import org.mongodb.scala._ - -import scala.concurrent.{ExecutionContext, Future} - -abstract class TestGreenLeafMongoDao[Id, E] extends GreenLeafMongoDao[Id, E] { - - override protected implicit val ec: ExecutionContext = scala.concurrent.ExecutionContext.Implicits.global - - // TODO move to config - override protected val db: MongoDatabase = MongoClient("mongodb://localhost:27027").getDatabase("test") - - def insertDocuments(documents: Document*): Future[InsertManyResult] = { - collection.insertMany(documents).toFuture() - } - -} diff --git a/src/test/scala/io/github/greenleafoss/mongo/TestMongoServer.scala b/src/test/scala/io/github/greenleafoss/mongo/TestMongoServer.scala deleted file mode 100644 index efb3aa8..0000000 --- a/src/test/scala/io/github/greenleafoss/mongo/TestMongoServer.scala +++ /dev/null @@ -1,65 +0,0 @@ -package io.github.greenleafoss.mongo - - -import de.flapdoodle.embed.mongo.commands.{ImmutableMongodArguments, MongodArguments} -import de.flapdoodle.embed.mongo.config.Net -import de.flapdoodle.embed.mongo.distribution.Version -import de.flapdoodle.embed.mongo.transitions.{ImmutableMongod, Mongod, RunningMongodProcess} -import de.flapdoodle.embed.process.io.{ImmutableProcessOutput, ProcessOutput, Processors, Slf4jLevel} -import de.flapdoodle.reverse.TransitionWalker -import de.flapdoodle.reverse.transitions.Start -import org.scalatest.BeforeAndAfterAll -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AsyncWordSpec -import org.slf4j.{Logger, LoggerFactory} - -object TestMongoServer { - - private val logger: Logger = LoggerFactory.getLogger(getClass) - - private val mongoPort: Int = 27027 - private val mongoVersion: Version.Main = Version.Main.V6_0 - - private val processOutput: ImmutableProcessOutput = ProcessOutput.builder - .commands(Processors.logTo(logger, Slf4jLevel.DEBUG)) - .output(Processors.logTo(logger, Slf4jLevel.INFO)) - .error(Processors.logTo(logger, Slf4jLevel.ERROR)) - .build - - // @see https://www.mongodb.com/docs/manual/reference/program/mongod/ - private val mongodArguments: ImmutableMongodArguments = MongodArguments.defaults - .withSyncDelay(0) - .withStorageEngine("ephemeralForTest") - .withUseNoJournal(true) - .withUseNoPrealloc(true) - - private val mongod: ImmutableMongod = Mongod.instance - .withNet(Start.to(classOf[Net]).initializedWith(Net.defaults.withPort(mongoPort))) - .withProcessOutput(Start.to(classOf[ProcessOutput]).initializedWith(processOutput)) - .withMongodArguments(Start.to(classOf[MongodArguments]).initializedWith(mongodArguments)) - - def start(): TransitionWalker.ReachedState[RunningMongodProcess] = - mongod.start(mongoVersion) - - def stop(runningMongod: TransitionWalker.ReachedState[RunningMongodProcess]): Unit = - if (runningMongod.current().isAlive) runningMongod.close() -} - -trait TestMongoServer extends AsyncWordSpec with Matchers with BeforeAndAfterAll { - private val runningMongoDb = TestMongoServer.start() - - // we can preload test data here if needed - override protected def beforeAll(): Unit = super.beforeAll() - - override protected def afterAll(): Unit = TestMongoServer.stop(runningMongoDb) -} - -object TestMongoServerApp extends App { - import scala.io.StdIn.readLine - - private val runningMongod = TestMongoServer.start() - - readLine("Press 'Enter' to shutdown MongoDB") - - TestMongoServer.stop(runningMongod) -}