diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4d5c8b..a32e476 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,11 +94,11 @@ jobs: - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: mkdir -p circe/jvm/target testkit/native/target target testkit/js/target .js/target core/.native/target playJson/jvm/target benchmarks/.jvm/target sprayJson/target core/.js/target circe/js/target core/.jvm/target .jvm/target .native/target circe/native/target playJson/js/target testkit/jvm/target project/target + run: mkdir -p mongo4cats/.jvm/target circe/jvm/target testkit/native/target target testkit/js/target .js/target core/.native/target playJson/jvm/target benchmarks/.jvm/target sprayJson/target core/.js/target circe/js/target mongo/.jvm/target core/.jvm/target .jvm/target .native/target circe/native/target playJson/js/target testkit/jvm/target project/target - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: tar cf targets.tar circe/jvm/target testkit/native/target target testkit/js/target .js/target core/.native/target playJson/jvm/target benchmarks/.jvm/target sprayJson/target core/.js/target circe/js/target core/.jvm/target .jvm/target .native/target circe/native/target playJson/js/target testkit/jvm/target project/target + run: tar cf targets.tar mongo4cats/.jvm/target circe/jvm/target testkit/native/target target testkit/js/target .js/target core/.native/target playJson/jvm/target benchmarks/.jvm/target sprayJson/target core/.js/target circe/js/target mongo/.jvm/target core/.jvm/target .jvm/target .native/target circe/native/target playJson/js/target testkit/jvm/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') diff --git a/build.sbt b/build.sbt index f363f73..39a2de5 100644 --- a/build.sbt +++ b/build.sbt @@ -6,6 +6,8 @@ val scala3 = "3.2.2" val scalatestVersion = "3.2.15" val scalacheckVersion = "1.17.0" +val weaverVersion = "0.8.2" +val mongo4catsVersion = "0.6.10" ThisBuild / scalaVersion := scala213 ThisBuild / crossScalaVersions := Seq(scala212, scala213, scala3) @@ -15,7 +17,7 @@ ThisBuild / tlSonatypeUseLegacyHost := true ThisBuild / tlFatalWarnings := false -ThisBuild / tlBaseVersion := "4.3" +ThisBuild / tlBaseVersion := "4.4" ThisBuild / organization := "org.gnieh" ThisBuild / organizationName := "Lucas Satabin" @@ -27,10 +29,17 @@ ThisBuild / developers := List( lazy val commonSettings = Seq( description := "Json diff/patch library", - homepage := Some(url("https://github.com/gnieh/diffson")) + homepage := Some(url("https://github.com/gnieh/diffson")), + libraryDependencies ++= List( + "org.scalatest" %%% "scalatest" % scalatestVersion % Test, + "org.scalacheck" %%% "scalacheck" % scalacheckVersion % Test, + "com.disneystreaming" %%% "weaver-cats" % weaverVersion % Test, + "com.disneystreaming" %%% "weaver-scalacheck" % weaverVersion % Test + ), + testFrameworks += new TestFramework("weaver.framework.CatsEffect") ) -lazy val diffson = tlCrossRootProject.aggregate(core, sprayJson, circe, playJson, testkit) +lazy val diffson = tlCrossRootProject.aggregate(core, sprayJson, circe, playJson, mongo, testkit) lazy val core = crossProject(JSPlatform, JVMPlatform, NativePlatform) .crossType(CrossType.Pure) @@ -41,9 +50,7 @@ lazy val core = crossProject(JSPlatform, JVMPlatform, NativePlatform) name := "diffson-core", libraryDependencies ++= Seq( "org.scala-lang.modules" %%% "scala-collection-compat" % "2.9.0", - "org.typelevel" %%% "cats-core" % "2.9.0", - "org.scalatest" %%% "scalatest" % scalatestVersion % Test, - "org.scalacheck" %%% "scalacheck" % scalacheckVersion % Test + "org.typelevel" %%% "cats-core" % "2.9.0" ), mimaBinaryIssueFilters ++= List( ProblemFilters.exclude[DirectMissingMethodProblem]( @@ -55,9 +62,21 @@ lazy val testkit = crossProject(JSPlatform, JVMPlatform, NativePlatform) .crossType(CrossType.Full) .in(file("testkit")) .settings(commonSettings: _*) - .settings(name := "diffson-testkit", - libraryDependencies ++= Seq("org.scalatest" %%% "scalatest" % scalatestVersion, - "org.scalacheck" %%% "scalacheck" % scalacheckVersion)) + .settings( + name := "diffson-testkit", + libraryDependencies ++= Seq( + "org.scalatest" %%% "scalatest" % scalatestVersion, + "org.scalacheck" %%% "scalacheck" % scalacheckVersion, + "com.disneystreaming" %%% "weaver-cats" % weaverVersion, + "com.disneystreaming" %%% "weaver-scalacheck" % weaverVersion + ) + ) + .jvmSettings( + libraryDependencies ++= List( + "io.github.kirill5k" %% "mongo4cats-embedded" % mongo4catsVersion, + "io.github.kirill5k" %% "mongo4cats-core" % mongo4catsVersion + ) + ) .dependsOn(core) lazy val sprayJson = project @@ -91,6 +110,32 @@ lazy val circe = crossProject(JSPlatform, JVMPlatform, NativePlatform) ) .dependsOn(core, testkit % Test) +lazy val mongo = crossProject(JVMPlatform) + .crossType(CrossType.Pure) + .in(file("mongo")) + .settings(commonSettings) + .settings( + name := "diffson-mongodb-driver", + libraryDependencies ++= List( + "org.mongodb" % "mongodb-driver-core" % "4.9.0" + ), + tlVersionIntroduced := Map("2.13" -> "4.5.0", "3" -> "4.5.0", "2.12" -> "4.5.0") + ) + .dependsOn(core, testkit % Test) + +lazy val mongo4cats = crossProject(JVMPlatform) + .crossType(CrossType.Pure) + .in(file("mongo4cats")) + .settings(commonSettings) + .settings( + name := "diffson-mongo4cats", + libraryDependencies ++= List( + "io.github.kirill5k" %% "mongo4cats-core" % mongo4catsVersion + ), + tlVersionIntroduced := Map("2.13" -> "4.5.0", "3" -> "4.5.0", "2.12" -> "4.5.0") + ) + .dependsOn(core, testkit % Test) + lazy val benchmarks = crossProject(JVMPlatform) .crossType(CrossType.Pure) .in(file("benchmarks")) diff --git a/core/src/main/scala/diffson/mongoupdate/MongoDiff.scala b/core/src/main/scala/diffson/mongoupdate/MongoDiff.scala new file mode 100644 index 0000000..45987db --- /dev/null +++ b/core/src/main/scala/diffson/mongoupdate/MongoDiff.scala @@ -0,0 +1,121 @@ +/* + * Copyright 2022 Lucas Satabin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package diffson +package mongoupdate + +import cats.Eval +import cats.data.Chain +import cats.syntax.all._ + +import scala.annotation.tailrec + +class MongoDiff[Bson, Update](implicit Bson: Jsony[Bson], Update: Updates[Update, Bson]) extends Diff[Bson, Update] { + private type Path = Chain[String] + + override def diff(bson1: Bson, bson2: Bson): Update = + diff(bson1, bson2, Chain.empty, Update.empty).value + + private def diff(bson1: Bson, bson2: Bson, path: Path, acc: Update): Eval[Update] = + (bson1, bson2) match { + case (JsObject(fields1), JsObject(fields2)) => + fieldsDiff(fields1.toList, fields2, path, acc) + case (JsArray(arr1), JsArray(arr2)) => + arrayDiff(arr1, arr2, path, acc) + case _ if bson1 === bson2 => + Eval.now(acc) + case _ => + Eval.now(Update.set(acc, path.mkString_("."), bson2)) + } + + private def fieldsDiff(fields1: List[(String, Bson)], + fields2: Map[String, Bson], + path: Path, + acc: Update): Eval[Update] = + fields1 match { + case (fld, value1) :: fields1 => + fields2.get(fld) match { + case Some(value2) => + diff(value1, value2, path.append(fld), acc).flatMap(fieldsDiff(fields1, fields2 - fld, path, _)) + case None => + fieldsDiff(fields1, fields2, path, Update.unset(acc, path.append(fld).mkString_("."))) + } + case Nil => + Eval.now(fields2.foldLeft(acc) { case (acc, (fld, value)) => + Update.set(acc, path.append(fld).mkString_("."), value) + }) + } + + private def arrayDiff(arr1: Vector[Bson], arr2: Vector[Bson], path: Path, acc: Update): Eval[Update] = { + val length1 = arr1.length + val length2 = arr2.length + if (length1 == length2) { + // same number of elements, diff them pairwise + (acc, 0).tailRecM { case (acc, idx) => + if (idx >= length1) + Eval.now(acc.asRight) + else + diff(arr1(idx), arr2(idx), path.append(idx.toString()), acc).map((_, idx + 1).asLeft) + } + } else if (length1 > length2) { + // elements were deleted from the array, this is not supported yet, so replace the entire array + Eval.now(Update.set(acc, path.mkString_("."), JsArray(arr2))) + } else { + val nbAdded = length2 - length1 + // there are some additions, and possibly some modifications + // elements can be added as a block only + + // first we commpute the common prefixes and suffixes + @tailrec + def commonPrefix(idx: Int): Int = + if (idx >= length1) + length1 + else if (arr1(idx) === arr2(idx)) + commonPrefix(idx + 1) + else + idx + val commonPrefixSize = commonPrefix(0) + @tailrec + def commonSuffix(idx1: Int, idx2: Int): Int = + if (idx1 < 0) + length1 + else if (arr1(idx1) === arr2(idx2)) + commonSuffix(idx1 - 1, idx2 - 1) + else + length1 - 1 - idx1 + val commonSuffixSize = commonSuffix(length1 - 1, length2 - 1) + + val update = + if (commonPrefixSize == length1) + // all elements are appended + Update.pushEach(acc, path.mkString_("."), arr2.drop(length1).toList) + else if (commonSuffixSize == length1) + // all elements are prepended + Update.pushEach(acc, path.mkString_("."), 0, arr2.dropRight(length1).toList) + else if (commonPrefixSize + commonSuffixSize == nbAdded) + // allements are inserted as a block in the middle + Update.pushEach(acc, + path.mkString_("."), + commonPrefixSize, + arr2.slice(commonPrefixSize, length2 - commonSuffixSize).toList) + else + Update.set(acc, path.mkString_("."), JsArray(arr2)) + + Eval.now(update) + } + } + +} diff --git a/core/src/main/scala/diffson/mongoupdate/Updates.scala b/core/src/main/scala/diffson/mongoupdate/Updates.scala new file mode 100644 index 0000000..51ab3ca --- /dev/null +++ b/core/src/main/scala/diffson/mongoupdate/Updates.scala @@ -0,0 +1,34 @@ +/* + * Copyright 2022 Lucas Satabin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package diffson.mongoupdate + +/** Typeclass describing the [[https://www.mongodb.com/docs/manual/reference/operator/update/ Mongo Update operators]] + * necessary to generate a diff. + */ +trait Updates[Update, Bson] { + + def empty: Update + + def set(base: Update, field: String, value: Bson): Update + + def unset(base: Update, field: String): Update + + def pushEach(base: Update, field: String, idx: Int, values: List[Bson]): Update + + def pushEach(base: Update, field: String, values: List[Bson]): Update + +} diff --git a/core/src/main/scala/diffson/mongoupdate/package.scala b/core/src/main/scala/diffson/mongoupdate/package.scala new file mode 100644 index 0000000..1f6a610 --- /dev/null +++ b/core/src/main/scala/diffson/mongoupdate/package.scala @@ -0,0 +1,24 @@ +/* + * Copyright 2022 Lucas Satabin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package diffson + +package object mongoupdate { + + implicit def MongoDiffDiff[Bson: Jsony, Update](implicit updates: Updates[Update, Bson]): Diff[Bson, Update] = + new MongoDiff[Bson, Update] + +} diff --git a/mongo/src/main/scala/diffson/bson/package.scala b/mongo/src/main/scala/diffson/bson/package.scala new file mode 100644 index 0000000..bd2faaf --- /dev/null +++ b/mongo/src/main/scala/diffson/bson/package.scala @@ -0,0 +1,71 @@ +/* + * Copyright 2022 Lucas Satabin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package diffson + +import cats.syntax.all._ +import com.mongodb.client.model.{PushOptions, Updates => JUpdates} +import org.bson._ +import org.bson.conversions.Bson + +import scala.jdk.CollectionConverters._ + +import mongoupdate.Updates + +package object bson { + + implicit object BsonJsony extends Jsony[BsonValue] { + + override def eqv(x: BsonValue, y: BsonValue): Boolean = + x == y + + override def show(t: BsonValue): String = t.toString() + + override def makeObject(fields: Map[String, BsonValue]): BsonValue = + new BsonDocument(fields.toList.map { case (key, value) => new BsonElement(key, value) }.asJava) + + override def fields(json: BsonValue): Option[Map[String, BsonValue]] = + json.isDocument().guard[Option].map(_ => json.asDocument().asScala.toMap) + + override def makeArray(values: Vector[BsonValue]): BsonValue = + new BsonArray(values.asJava) + + override def array(json: BsonValue): Option[Vector[BsonValue]] = + json.isArray().guard[Option].map(_ => json.asArray().asScala.toVector) + + override def Null: BsonValue = BsonNull.VALUE + + } + + implicit object BsonUpdates extends Updates[List[Bson], BsonValue] { + + override def empty: List[Bson] = Nil + + override def set(base: List[Bson], field: String, value: BsonValue): List[Bson] = + JUpdates.set(field, value) :: base + + override def unset(base: List[Bson], field: String): List[Bson] = + JUpdates.unset(field) :: base + + override def pushEach(base: List[Bson], field: String, idx: Int, values: List[BsonValue]): List[Bson] = + JUpdates.pushEach(field, values.asJava, new PushOptions().position(idx)) :: base + + override def pushEach(base: List[Bson], field: String, values: List[BsonValue]): List[Bson] = + JUpdates.pushEach(field, values.asJava) :: base + + } + +} diff --git a/mongo/src/test/scala/diffson/mongoupdate/ApplySpec.scala b/mongo/src/test/scala/diffson/mongoupdate/ApplySpec.scala new file mode 100644 index 0000000..c537002 --- /dev/null +++ b/mongo/src/test/scala/diffson/mongoupdate/ApplySpec.scala @@ -0,0 +1,30 @@ +/* + * Copyright 2022 Lucas Satabin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package diffson.bson + +import com.mongodb.client.model.Updates +import diffson.bson.ApplyUpdateSpec +import org.bson.conversions.Bson +import org.bson.{BsonDocument, BsonValue} + +object ApplySpec extends ApplyUpdateSpec[List[Bson], BsonValue] { + + override def fromBsonDocument(bson: BsonDocument): BsonValue = bson + + override def toUpdate(diff: List[Bson]): Bson = Updates.combine(diff: _*) + +} diff --git a/mongo/src/test/scala/diffson/mongoupdate/MongoMongoDiffSpec.scala b/mongo/src/test/scala/diffson/mongoupdate/MongoMongoDiffSpec.scala new file mode 100644 index 0000000..ded230c --- /dev/null +++ b/mongo/src/test/scala/diffson/mongoupdate/MongoMongoDiffSpec.scala @@ -0,0 +1,32 @@ +/* + * Copyright 2022 Lucas Satabin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package diffson +package bson + +import org.bson.conversions.Bson +import org.bson.{BsonInt32, BsonString, BsonValue} + +import mongoupdate._ +import test._ + +object MongoMongoDiffSpec extends MongoDiffSpec[List[Bson], BsonValue] { + + override def int(i: Int): BsonValue = new BsonInt32(i) + + override def string(s: String): BsonValue = new BsonString(s) + +} diff --git a/mongo/src/test/scala/diffson/mongoupdate/test.scala b/mongo/src/test/scala/diffson/mongoupdate/test.scala new file mode 100644 index 0000000..281ed18 --- /dev/null +++ b/mongo/src/test/scala/diffson/mongoupdate/test.scala @@ -0,0 +1,26 @@ +/* + * Copyright 2022 Lucas Satabin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package diffson.mongoupdate + +import cats.Eq +import org.bson.conversions.Bson + +object test { + + implicit val BsonEq: Eq[Bson] = Eq.fromUniversalEquals + +} diff --git a/mongo4cats/src/main/scala/diffson/bson4cats/package.scala b/mongo4cats/src/main/scala/diffson/bson4cats/package.scala new file mode 100644 index 0000000..4d0780f --- /dev/null +++ b/mongo4cats/src/main/scala/diffson/bson4cats/package.scala @@ -0,0 +1,52 @@ +package diffson + +import mongo4cats.bson.BsonValue +import mongo4cats.bson.Document +import diffson.mongoupdate.Updates +import mongo4cats.operations.Update +import mongo4cats.operations +import com.mongodb.client.model.PushOptions + +package object bson4cats { + + implicit object BsonJsony extends Jsony[BsonValue] { + + override def eqv(x: BsonValue, y: BsonValue): Boolean = + x == y + + override def show(t: BsonValue): String = t.toString() + + override def makeObject(fields: Map[String, BsonValue]): BsonValue = + BsonValue.document(Document(fields)) + + override def fields(json: BsonValue): Option[Map[String, BsonValue]] = + json.asDocument.map(_.toMap) + + override def makeArray(values: Vector[BsonValue]): BsonValue = + BsonValue.array(values) + + override def array(json: BsonValue): Option[Vector[BsonValue]] = + json.asList.map(_.toVector) + + override def Null: BsonValue = BsonValue.Null + + } + + implicit object BsonUpdates extends Updates[Update, BsonValue] { + + override def empty: Update = operations.Updates.empty + + override def set(base: Update, field: String, value: BsonValue): Update = + base.set(field, value) + + override def unset(base: Update, field: String): Update = + base.unset(field) + + override def pushEach(base: Update, field: String, idx: Int, values: List[BsonValue]): Update = + base.pushEach(field, values, new PushOptions().position(idx)) + + override def pushEach(base: Update, field: String, values: List[BsonValue]): Update = + base.pushEach(field, values) + + } +} diff --git a/mongo4cats/src/main/scala/mongo4cats/operations/Updates.scala b/mongo4cats/src/main/scala/mongo4cats/operations/Updates.scala new file mode 100644 index 0000000..0e249e0 --- /dev/null +++ b/mongo4cats/src/main/scala/mongo4cats/operations/Updates.scala @@ -0,0 +1,6 @@ +package mongo4cats.operations + +// trick to expose the empty updates +object Updates { + val empty: Update = UpdateBuilder(Nil) +} diff --git a/mongo4cats/src/test/scala/diffson/bson4cats/ApplySpec.scala b/mongo4cats/src/test/scala/diffson/bson4cats/ApplySpec.scala new file mode 100644 index 0000000..2849a13 --- /dev/null +++ b/mongo4cats/src/test/scala/diffson/bson4cats/ApplySpec.scala @@ -0,0 +1,18 @@ +package mongo4cats.diffson.bson4cats + +import diffson.bson.ApplyUpdateSpec +import diffson.bson4cats._ +import mongo4cats.bson.{BsonValue, Document} +import mongo4cats.operations.Update +import org.bson.BsonDocument +import org.bson.conversions.Bson + +object ApplySpec extends ApplyUpdateSpec[Update, BsonValue] { + + override def fromBsonDocument(bson: BsonDocument): BsonValue = + BsonValue.document(Document.fromJava(new org.bson.Document(bson))) + + override def toUpdate(diff: Update): Bson = + diff.toBson + +} diff --git a/mongo4cats/src/test/scala/diffson/bson4cats/DiffSpec.scala b/mongo4cats/src/test/scala/diffson/bson4cats/DiffSpec.scala new file mode 100644 index 0000000..dfb87c3 --- /dev/null +++ b/mongo4cats/src/test/scala/diffson/bson4cats/DiffSpec.scala @@ -0,0 +1,15 @@ +package diffson.bson4cats + +import diffson.mongoupdate.MongoDiffSpec +import mongo4cats.bson.BsonValue +import mongo4cats.operations.Update + +import test._ + +object DiffSpec extends MongoDiffSpec[Update, BsonValue] { + + override def int(i: Int): BsonValue = BsonValue.int(i) + + override def string(s: String): BsonValue = BsonValue.string(s) + +} diff --git a/mongo4cats/src/test/scala/diffson/bson4cats/test.scala b/mongo4cats/src/test/scala/diffson/bson4cats/test.scala new file mode 100644 index 0000000..de9e31e --- /dev/null +++ b/mongo4cats/src/test/scala/diffson/bson4cats/test.scala @@ -0,0 +1,8 @@ +package diffson.bson4cats + +import cats.Eq +import mongo4cats.operations.Update + +object test { + implicit val updateEq: Eq[Update] = Eq.fromUniversalEquals +} diff --git a/testkit/jvm/src/main/scala/diffson/mongoupdate/ApplyUpdateSpec.scala b/testkit/jvm/src/main/scala/diffson/mongoupdate/ApplyUpdateSpec.scala new file mode 100644 index 0000000..ac762d1 --- /dev/null +++ b/testkit/jvm/src/main/scala/diffson/mongoupdate/ApplyUpdateSpec.scala @@ -0,0 +1,105 @@ +/* + * Copyright 2022 Lucas Satabin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package diffson +package bson + +import cats.effect.{IO, Resource} +import cats.syntax.all._ +import cats.{Eq, Show} +import com.mongodb.client.model.Filters +import de.flapdoodle.embed.mongo.distribution.Version +import mongo4cats.client.MongoClient +import mongo4cats.embedded.EmbeddedMongo +import org.bson._ +import org.scalacheck.{Arbitrary, Gen} +import weaver._ +import weaver.scalacheck._ + +import scala.jdk.CollectionConverters._ + +import mongoupdate._ + +abstract class ApplyUpdateSpec[Update, Bson: Jsony](implicit Update: mongoupdate.Updates[Update, Bson]) + extends IOSuite + with Checkers { + + implicit val eq: Eq[BsonDocument] = Eq.fromUniversalEquals + + type Res = MongoClient[IO] + + override def sharedResource: Resource[IO, Res] = + EmbeddedMongo.start(27017, None, None, Version.V6_0_4) >> + MongoClient.fromConnectionString[IO]("mongodb://localhost:27017") + + implicit val arbitraryBson: Arbitrary[BsonDocument] = Arbitrary { + val genLeaf = Gen.oneOf( + Gen.choose(0, 1000).map(new BsonInt32(_)), + Gen.alphaNumStr.map(new BsonString(_)), + Gen.const(new BsonBoolean(true)), + Gen.const(new BsonBoolean(false)), + Gen.const(BsonNull.VALUE) + ) + + def genArray(depth: Int, length: Int): Gen[BsonValue] = + for { + n <- Gen.choose(length / 3, length / 2) + c <- Gen.listOfN(n, sizedBson(depth / 2, length / 2)) + } yield new BsonArray(c.asJava) + + def genDoc(depth: Int, length: Int): Gen[BsonDocument] = + for { + n <- Gen.choose(length / 3, length / 2) + c <- Gen.listOfN(n, sizedBson(depth / 2, length / 2)) + } yield new BsonDocument(c.mapWithIndex((v, idx) => new BsonElement(s"elt$idx", v)).asJava) + + def sizedBson(depth: Int, length: Int) = + if (depth <= 0) genLeaf + else Gen.frequency((1, genLeaf), (2, genArray(depth, length)), (2, genDoc(depth, length))) + + Gen.sized { depth => + Gen.sized { length => + genDoc(depth = depth, length = length) + } + } + } + + implicit val showDoc: Show[BsonDocument] = Show.fromToString + + def fromBsonDocument(bson: BsonDocument): Bson + + def toUpdate(diff: Update): conversions.Bson + + test("apply updates") { client => + ignore("SLOW") >> + forall { (bson1: BsonDocument, bson2: BsonDocument) => + val id = new BsonObjectId + bson1.put("_id", id) + bson2.put("_id", id) + + val diff = fromBsonDocument(bson1).diff(fromBsonDocument(bson2)) + for { + db <- client.getDatabase("testdb") + coll <- db.getCollection("docs") + doc = mongo4cats.bson.Document.fromJava(new Document(bson1)) + _ <- coll.insertOne(doc) + _ <- coll.updateOne(Filters.eq("_id", id), toUpdate(diff)) + foundDoc <- coll.find(Filters.eq("_id", id)).first + } yield expect.eql(Some(bson2), foundDoc.map(_.toBsonDocument)) + } + } + +} diff --git a/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala b/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala new file mode 100644 index 0000000..902ff00 --- /dev/null +++ b/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala @@ -0,0 +1,139 @@ +/* + * Copyright 2022 Lucas Satabin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package diffson +package mongoupdate + +import cats.Eq +import weaver._ + +abstract class MongoDiffSpec[Update: Eq, Bson](implicit Updates: Updates[Update, Bson], Jsony: Jsony[Bson]) + extends SimpleIOSuite { + + def int(i: Int): Bson + + def string(s: String): Bson + + def doc(value: Bson): Bson = + Jsony.makeObject(Map("value" -> value)) + + pureTest("append to empty array") { + val source = doc(Jsony.makeArray(Vector())) + val target = doc(Jsony.makeArray(Vector(string("a"), string("b"), string("c"), string("d")))) + + val d = source.diff(target) + + expect.eql(Updates.pushEach(Updates.empty, "value", List(string("a"), string("b"), string("c"), string("d"))), d) + } + + pureTest("append to array") { + + val source = doc(Jsony.makeArray(Vector(string("a"), string("b")))) + val target = doc(Jsony.makeArray(Vector(string("a"), string("b"), string("c"), string("d")))) + + val d = source.diff(target) + + expect.eql(Updates.pushEach(Updates.empty, "value", List(string("c"), string("d"))), d) + } + + pureTest("prepend to array") { + + val source = doc(Jsony.makeArray(Vector(string("c"), string("d")))) + val target = doc(Jsony.makeArray(Vector(string("a"), string("b"), string("c"), string("d")))) + + val d = source.diff(target) + + expect.eql(Updates.pushEach(Updates.empty, "value", 0, List(string("a"), string("b"))), d) + } + + pureTest("push in the middle of an array") { + val source = doc(Jsony.makeArray(Vector(string("a"), string("b"), string("f")))) + val target = + doc(Jsony.makeArray(Vector(string("a"), string("b"), string("c"), string("d"), string("e"), string("f")))) + + val d = source.diff(target) + + expect.eql(Updates.pushEach(Updates.empty, "value", 2, List(string("c"), string("d"), string("e"))), d) + } + + pureTest("push and modify") { + val source = doc(Jsony.makeArray(Vector(string("a"), string("b"), string("f")))) + val target = + doc(Jsony.makeArray(Vector(string("x"), string("b"), string("c"), string("d"), string("e"), string("f")))) + + val d = source.diff(target) + + expect.eql(Updates.set( + Updates.empty, + "value", + Jsony.makeArray(Vector(string("x"), string("b"), string("c"), string("d"), string("e"), string("f")))), + d) + } + + pureTest("modify in place") { + val source = doc(Jsony.makeArray(Vector(string("a"), string("e"), string("c"), string("f")))) + val target = + doc(Jsony.makeArray(Vector(string("a"), string("b"), string("c"), string("d")))) + + val d = source.diff(target) + + expect.eql(Updates.set(Updates.set(Updates.empty, "value.1", string("b")), "value.3", string("d")), d) + } + + pureTest("delete elements of an array") { + val source = + doc(Jsony.makeArray(Vector(string("a"), string("b"), string("c"), string("d"), string("e"), string("f")))) + val target = doc(Jsony.makeArray(Vector(string("a"), string("b"), string("f")))) + + val d = source.diff(target) + + expect.eql(Updates.set(Updates.empty, "value", Jsony.makeArray(Vector(string("a"), string("b"), string("f")))), d) + } + + pureTest("adding fields") { + val source = doc(Jsony.makeObject(Map("a" -> int(1), "c" -> int(3)))) + val target = doc(Jsony.makeObject(Map("a" -> int(1), "b" -> int(2), "c" -> int(3), "z" -> int(26)))) + + val d = source.diff(target) + + expect.eql(Updates.set(Updates.set(Updates.empty, "value.b", int(2)), "value.z", int(26)), d) + } + + pureTest("deleting fields") { + val source = doc(Jsony.makeObject(Map("a" -> int(1), "b" -> int(2), "c" -> int(3), "z" -> int(26)))) + val target = doc(Jsony.makeObject(Map("a" -> int(1), "c" -> int(3)))) + + val d = source.diff(target) + + expect.eql(Updates.unset(Updates.unset(Updates.empty, "value.b"), "value.z"), d) + } + + pureTest("mixed field modifications") { + val source = doc( + Jsony.makeObject( + Map("a" -> int(1), "b" -> int(2), "c" -> int(3), "z" -> Jsony.makeObject(Map("value" -> int(26)))))) + val target = doc( + Jsony.makeObject( + Map("a" -> int(1), "c" -> int(3), "d" -> int(4), "z" -> Jsony.makeObject(Map("value" -> int(-1)))))) + + val d = source.diff(target) + + expect.eql( + Updates.set(Updates.set(Updates.unset(Updates.empty, "value.b"), "value.z.value", int(-1)), "value.d", int(4)), + d) + } + +}