From 2d5cdae5f1daaa773d06eeeb53701f53d8aabe54 Mon Sep 17 00:00:00 2001 From: Lucas Satabin Date: Wed, 8 Mar 2023 19:20:00 +0100 Subject: [PATCH 01/15] Add Mongo Update diff implementation --- .../scala/diffson/mongoupdate/MongoDiff.scala | 129 ++++++++++++++++++ .../scala/diffson/mongoupdate/Updates.scala | 34 +++++ 2 files changed, 163 insertions(+) create mode 100644 core/src/main/scala/diffson/mongoupdate/MongoDiff.scala create mode 100644 core/src/main/scala/diffson/mongoupdate/Updates.scala 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..c5a1699 --- /dev/null +++ b/core/src/main/scala/diffson/mongoupdate/MongoDiff.scala @@ -0,0 +1,129 @@ +/* + * 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 lcs.Lcs +import scala.annotation.tailrec + +class MongoDiff[Bson, Update](implicit Bson: Jsony[Bson], Update: Updates[Update, Bson], Lcs: Lcs[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.keys.foldLeft(acc)((acc, fld) => Update.unset(acc, path.append(fld).mkString_(".")))) + } + + 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 + // the LCS is computed to decide where elements are added + // if there is several additions in several places + // or a mix of additions and other modifications, + // then we just replace the entire array, to avoid conflicts + val lcs = Lcs.lcs(arr1.toList, arr2.toList) + + @tailrec + def loop(lcs: List[(Int, Int)], idx1: Int, idx2: Int): Update = + lcs match { + case (newIdx1, newIdx2) :: rest => + if (newIdx1 == idx1 + 1 && newIdx2 == idx2 + 1) { + // sequence goes forward in both arrays, continue looping + loop(rest, newIdx1, newIdx2) + } else if (idx1 == -1 && newIdx2 == nbAdded) { + // element are added at the beginning, but we must make sure that the rest + // of the LCS is the original array itself + // this is the case if the LCS length is the array length + if (lcs.length == length1) { + Update.pushEach(acc, path.mkString_("."), 0, arr2.slice(0, nbAdded).toList) + } else { + // otherwise there are some changes that would conflict, replace the entire array + Update.set(acc, path.mkString_("."), JsArray(arr2)) + } + } else if (newIdx1 - idx1 == nbAdded && newIdx2 - idx2 == nbAdded) { + // there is a bigger gap in original array, it must be where the elements are inserted + // otherwise we stop and replace the entire array + // if gap is of the right size, check that the rest of the LCS represents the suffix of both arrays + if (lcs.length == length1 - newIdx1) { + Update.pushEach(acc, path.mkString_("."), idx1 + 1, arr2.slice(idx2 + 1, nbAdded).toList) + } else { + // otherwise there are some changes that would conflict, replace the entire array + Update.set(acc, path.mkString_("."), JsArray(arr2)) + } + } else { + // otherwise replace the entire array + Update.set(acc, path.mkString_("."), JsArray(arr2)) + } + case Nil if idx1 == length1 - 1 => + // we reached the end of the original array, + // it means every new element is appended to the end + Update.pushEach(acc, path.mkString_("."), arr2.slice(idx2, idx2 + nbAdded).toList) + case _ => + // in any other case, just replace the entire array + Update.set(acc, path.mkString_("."), JsArray(arr2)) + } + Eval.now(loop(lcs, -1, -1)) + } + } + +} 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 + +} From f66add368fa7b0da18f4d7b1677ee7b27c2e82e0 Mon Sep 17 00:00:00 2001 From: Lucas Satabin Date: Wed, 8 Mar 2023 19:58:16 +0100 Subject: [PATCH 02/15] Add implementation for `BsonValue` diffs --- build.sbt | 14 +++- .../src/main/scala/diffson/bson/package.scala | 73 +++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 mongo/src/main/scala/diffson/bson/package.scala diff --git a/build.sbt b/build.sbt index f363f73..c529a5a 100644 --- a/build.sbt +++ b/build.sbt @@ -15,7 +15,7 @@ ThisBuild / tlSonatypeUseLegacyHost := true ThisBuild / tlFatalWarnings := false -ThisBuild / tlBaseVersion := "4.3" +ThisBuild / tlBaseVersion := "4.4" ThisBuild / organization := "org.gnieh" ThisBuild / organizationName := "Lucas Satabin" @@ -30,7 +30,7 @@ lazy val commonSettings = Seq( homepage := Some(url("https://github.com/gnieh/diffson")) ) -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) @@ -91,6 +91,16 @@ 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-mongo", + libraryDependencies ++= List( + "org.mongodb" % "mongodb-driver-core" % "4.9.0" + )) + .dependsOn(core, testkit % Test) + lazy val benchmarks = crossProject(JVMPlatform) .crossType(CrossType.Pure) .in(file("benchmarks")) 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..9cf24a5 --- /dev/null +++ b/mongo/src/main/scala/diffson/bson/package.scala @@ -0,0 +1,73 @@ +/* + * 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 org.bson._ +import mongoupdate.Updates +import com.mongodb.client.model.{Updates => JUpdates} +import scala.jdk.CollectionConverters._ +import org.bson.conversions.Bson +import com.mongodb.client.model.PushOptions + +package object bson { + + implicit object BsonJsony extends Jsony[BsonValue] { + + override def eqv(x: BsonValue, y: BsonValue): Boolean = + (x, y) match { + case (null, null) => true + case (null, _) | (_, null) => false + case _ => x.equals(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]] = + Option.when(json.isDocument())(json.asDocument().asScala.toMap) + + override def makeArray(values: Vector[BsonValue]): BsonValue = + new BsonArray(values.asJava) + + override def array(json: BsonValue): Option[Vector[BsonValue]] = + Option.when(json.isArray())(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 + + } + +} From c5ebb0fe838750ae3d0dc2dbaa4de50e136b476a Mon Sep 17 00:00:00 2001 From: Lucas Satabin Date: Wed, 8 Mar 2023 20:05:28 +0100 Subject: [PATCH 03/15] Add new project to workflows --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4d5c8b..2159a67 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 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 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') From 6c5f1dd30acc3bf26287d6744258d17c7f858667 Mon Sep 17 00:00:00 2001 From: Lucas Satabin Date: Sat, 25 Mar 2023 14:30:46 +0100 Subject: [PATCH 04/15] Add array diff tests --- build.sbt | 26 ++++-- .../scala/diffson/mongoupdate/MongoDiff.scala | 11 +-- .../scala/diffson/mongoupdate/package.scala | 21 +++++ .../src/main/scala/diffson/bson/package.scala | 10 ++- .../mongoupdate/MongoMongoDiffSpec.scala | 17 ++++ .../scala/diffson/mongoupdate/package.scala | 10 +++ .../diffson/mongoupdate/MongoDiffSpec.scala | 85 +++++++++++++++++++ 7 files changed, 162 insertions(+), 18 deletions(-) create mode 100644 core/src/main/scala/diffson/mongoupdate/package.scala create mode 100644 mongo/src/test/scala/diffson/mongoupdate/MongoMongoDiffSpec.scala create mode 100644 mongo/src/test/scala/diffson/mongoupdate/package.scala create mode 100644 testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala diff --git a/build.sbt b/build.sbt index c529a5a..611e97a 100644 --- a/build.sbt +++ b/build.sbt @@ -6,6 +6,7 @@ val scala3 = "3.2.2" val scalatestVersion = "3.2.15" val scalacheckVersion = "1.17.0" +val weaverVersion = "0.8.2" ThisBuild / scalaVersion := scala213 ThisBuild / crossScalaVersions := Seq(scala212, scala213, scala3) @@ -27,7 +28,14 @@ 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, mongo, testkit) @@ -41,9 +49,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 +61,15 @@ 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 + ) + ) .dependsOn(core) lazy val sprayJson = project diff --git a/core/src/main/scala/diffson/mongoupdate/MongoDiff.scala b/core/src/main/scala/diffson/mongoupdate/MongoDiff.scala index c5a1699..e0abc10 100644 --- a/core/src/main/scala/diffson/mongoupdate/MongoDiff.scala +++ b/core/src/main/scala/diffson/mongoupdate/MongoDiff.scala @@ -100,12 +100,12 @@ class MongoDiff[Bson, Update](implicit Bson: Jsony[Bson], Update: Updates[Update // otherwise there are some changes that would conflict, replace the entire array Update.set(acc, path.mkString_("."), JsArray(arr2)) } - } else if (newIdx1 - idx1 == nbAdded && newIdx2 - idx2 == nbAdded) { + } else if (newIdx2 - 1 - idx2 == nbAdded) { // there is a bigger gap in original array, it must be where the elements are inserted // otherwise we stop and replace the entire array // if gap is of the right size, check that the rest of the LCS represents the suffix of both arrays if (lcs.length == length1 - newIdx1) { - Update.pushEach(acc, path.mkString_("."), idx1 + 1, arr2.slice(idx2 + 1, nbAdded).toList) + Update.pushEach(acc, path.mkString_("."), idx1 + 1, arr2.slice(idx2 + 1, idx2 + 1 + nbAdded).toList) } else { // otherwise there are some changes that would conflict, replace the entire array Update.set(acc, path.mkString_("."), JsArray(arr2)) @@ -114,13 +114,10 @@ class MongoDiff[Bson, Update](implicit Bson: Jsony[Bson], Update: Updates[Update // otherwise replace the entire array Update.set(acc, path.mkString_("."), JsArray(arr2)) } - case Nil if idx1 == length1 - 1 => + case Nil => // we reached the end of the original array, // it means every new element is appended to the end - Update.pushEach(acc, path.mkString_("."), arr2.slice(idx2, idx2 + nbAdded).toList) - case _ => - // in any other case, just replace the entire array - Update.set(acc, path.mkString_("."), JsArray(arr2)) + Update.pushEach(acc, path.mkString_("."), arr2.slice(idx2 + 1, idx2 + 1 + nbAdded).toList) } Eval.now(loop(lcs, -1, -1)) } 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..67afe90 --- /dev/null +++ b/core/src/main/scala/diffson/mongoupdate/package.scala @@ -0,0 +1,21 @@ +package diffson + +import diffson.lcs.Lcs + +package object mongoupdate { + + object lcsdiff { + implicit def MongoDiffDiff[Bson: Jsony: Lcs, Update](implicit updates: Updates[Update, Bson]): Diff[Bson, Update] = + new MongoDiff[Bson, Update]()(implicitly, implicitly, implicitly[Lcs[Bson]].savedHashes) + } + + object simplediff { + private implicit def nolcs[Bson]: Lcs[Bson] = new Lcs[Bson] { + def savedHashes = this + def lcs(seq1: List[Bson], seq2: List[Bson], low1: Int, high1: Int, low2: Int, high2: Int): List[(Int, Int)] = Nil + } + 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 index 9cf24a5..e01b33c 100644 --- a/mongo/src/main/scala/diffson/bson/package.scala +++ b/mongo/src/main/scala/diffson/bson/package.scala @@ -16,12 +16,14 @@ package diffson -import org.bson._ -import mongoupdate.Updates +import com.mongodb.client.model.PushOptions import com.mongodb.client.model.{Updates => JUpdates} -import scala.jdk.CollectionConverters._ +import org.bson._ import org.bson.conversions.Bson -import com.mongodb.client.model.PushOptions + +import scala.jdk.CollectionConverters._ + +import mongoupdate.Updates package object bson { 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..e14ec2c --- /dev/null +++ b/mongo/src/test/scala/diffson/mongoupdate/MongoMongoDiffSpec.scala @@ -0,0 +1,17 @@ +package diffson +package bson + +import org.bson.BsonInt32 +import org.bson.BsonString +import org.bson.BsonValue +import org.bson.conversions.Bson + +import mongoupdate._ + +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/package.scala b/mongo/src/test/scala/diffson/mongoupdate/package.scala new file mode 100644 index 0000000..c101dfe --- /dev/null +++ b/mongo/src/test/scala/diffson/mongoupdate/package.scala @@ -0,0 +1,10 @@ +package diffson + +import cats.Eq +import org.bson.conversions.Bson + +package object mongoupdate { + + implicit val BsonEq: Eq[Bson] = Eq.fromUniversalEquals + +} 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..6bc2de1 --- /dev/null +++ b/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala @@ -0,0 +1,85 @@ +package diffson +package mongoupdate + +import cats.Eq +import weaver._ + +import lcs._ +import lcsdiff._ + +abstract class MongoDiffSpec[Update: Eq, Bson](implicit Updates: Updates[Update, Bson], Jsony: Jsony[Bson]) + extends SimpleIOSuite { + + implicit val lcs = new Patience[Bson] + + 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("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) + } + +} From 5c7a2b9c65202bc47e46785a6067c0d4dfdc3667 Mon Sep 17 00:00:00 2001 From: Lucas Satabin Date: Sat, 25 Mar 2023 14:39:32 +0100 Subject: [PATCH 05/15] Make it compile with 2.12 --- .../main/scala/diffson/mongoupdate/package.scala | 16 ++++++++++++++++ mongo/src/main/scala/diffson/bson/package.scala | 5 +++-- .../diffson/mongoupdate/MongoMongoDiffSpec.scala | 16 ++++++++++++++++ .../test/scala/diffson/mongoupdate/package.scala | 16 ++++++++++++++++ .../diffson/mongoupdate/MongoDiffSpec.scala | 16 ++++++++++++++++ 5 files changed, 67 insertions(+), 2 deletions(-) diff --git a/core/src/main/scala/diffson/mongoupdate/package.scala b/core/src/main/scala/diffson/mongoupdate/package.scala index 67afe90..a7847c9 100644 --- a/core/src/main/scala/diffson/mongoupdate/package.scala +++ b/core/src/main/scala/diffson/mongoupdate/package.scala @@ -1,3 +1,19 @@ +/* + * 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 diffson.lcs.Lcs diff --git a/mongo/src/main/scala/diffson/bson/package.scala b/mongo/src/main/scala/diffson/bson/package.scala index e01b33c..b14110d 100644 --- a/mongo/src/main/scala/diffson/bson/package.scala +++ b/mongo/src/main/scala/diffson/bson/package.scala @@ -16,6 +16,7 @@ package diffson +import cats.syntax.all._ import com.mongodb.client.model.PushOptions import com.mongodb.client.model.{Updates => JUpdates} import org.bson._ @@ -42,13 +43,13 @@ package object bson { new BsonDocument(fields.toList.map { case (key, value) => new BsonElement(key, value) }.asJava) override def fields(json: BsonValue): Option[Map[String, BsonValue]] = - Option.when(json.isDocument())(json.asDocument().asScala.toMap) + json.isDocument().guard[Option].as(json.asDocument().asScala.toMap) override def makeArray(values: Vector[BsonValue]): BsonValue = new BsonArray(values.asJava) override def array(json: BsonValue): Option[Vector[BsonValue]] = - Option.when(json.isArray())(json.asArray().asScala.toVector) + json.isArray().guard[Option].as(json.asArray().asScala.toVector) override def Null: BsonValue = BsonNull.VALUE diff --git a/mongo/src/test/scala/diffson/mongoupdate/MongoMongoDiffSpec.scala b/mongo/src/test/scala/diffson/mongoupdate/MongoMongoDiffSpec.scala index e14ec2c..70505b3 100644 --- a/mongo/src/test/scala/diffson/mongoupdate/MongoMongoDiffSpec.scala +++ b/mongo/src/test/scala/diffson/mongoupdate/MongoMongoDiffSpec.scala @@ -1,3 +1,19 @@ +/* + * 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 diff --git a/mongo/src/test/scala/diffson/mongoupdate/package.scala b/mongo/src/test/scala/diffson/mongoupdate/package.scala index c101dfe..7dcafe0 100644 --- a/mongo/src/test/scala/diffson/mongoupdate/package.scala +++ b/mongo/src/test/scala/diffson/mongoupdate/package.scala @@ -1,3 +1,19 @@ +/* + * 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.Eq diff --git a/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala b/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala index 6bc2de1..e2aa28d 100644 --- a/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala +++ b/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala @@ -1,3 +1,19 @@ +/* + * 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 From c5337400162a48059a7e53b8a43d046eb444126c Mon Sep 17 00:00:00 2001 From: Lucas Satabin Date: Sat, 25 Mar 2023 14:52:54 +0100 Subject: [PATCH 06/15] Make test compile with Scala 3 --- .../src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala b/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala index e2aa28d..9c0e5d5 100644 --- a/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala +++ b/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala @@ -26,7 +26,7 @@ import lcsdiff._ abstract class MongoDiffSpec[Update: Eq, Bson](implicit Updates: Updates[Update, Bson], Jsony: Jsony[Bson]) extends SimpleIOSuite { - implicit val lcs = new Patience[Bson] + implicit val lcs: Lcs[Bson] = new Patience[Bson] def int(i: Int): Bson From 2030205ee866028705e7ed6e44701b4b01d5c5f4 Mon Sep 17 00:00:00 2001 From: Lucas Satabin Date: Sat, 25 Mar 2023 14:58:58 +0100 Subject: [PATCH 07/15] Make conversion lazy --- mongo/src/main/scala/diffson/bson/package.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mongo/src/main/scala/diffson/bson/package.scala b/mongo/src/main/scala/diffson/bson/package.scala index b14110d..c3f06f2 100644 --- a/mongo/src/main/scala/diffson/bson/package.scala +++ b/mongo/src/main/scala/diffson/bson/package.scala @@ -43,13 +43,13 @@ package object bson { 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].as(json.asDocument().asScala.toMap) + 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].as(json.asArray().asScala.toVector) + json.isArray().guard[Option].map(_ => json.asArray().asScala.toVector) override def Null: BsonValue = BsonNull.VALUE From 23618bd279d1829d23df183f61ef1b75d5eb6358 Mon Sep 17 00:00:00 2001 From: Lucas Satabin Date: Sat, 25 Mar 2023 15:05:18 +0100 Subject: [PATCH 08/15] Make MiMa happy with new module --- build.sbt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/build.sbt b/build.sbt index 611e97a..c802db0 100644 --- a/build.sbt +++ b/build.sbt @@ -107,10 +107,13 @@ lazy val mongo = crossProject(JVMPlatform) .crossType(CrossType.Pure) .in(file("mongo")) .settings(commonSettings) - .settings(name := "diffson-mongo", - libraryDependencies ++= List( - "org.mongodb" % "mongodb-driver-core" % "4.9.0" - )) + .settings( + name := "diffson-mongo", + 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 benchmarks = crossProject(JVMPlatform) From e8a1447123402e92abaa4475fb4cec77f5e767c1 Mon Sep 17 00:00:00 2001 From: Lucas Satabin Date: Sat, 25 Mar 2023 18:00:07 +0100 Subject: [PATCH 09/15] Add object diff tests --- .../scala/diffson/mongoupdate/MongoDiff.scala | 4 ++- .../diffson/mongoupdate/MongoDiffSpec.scala | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/core/src/main/scala/diffson/mongoupdate/MongoDiff.scala b/core/src/main/scala/diffson/mongoupdate/MongoDiff.scala index e0abc10..621fd37 100644 --- a/core/src/main/scala/diffson/mongoupdate/MongoDiff.scala +++ b/core/src/main/scala/diffson/mongoupdate/MongoDiff.scala @@ -56,7 +56,9 @@ class MongoDiff[Bson, Update](implicit Bson: Jsony[Bson], Update: Updates[Update fieldsDiff(fields1, fields2, path, Update.unset(acc, path.append(fld).mkString_("."))) } case Nil => - Eval.now(fields2.keys.foldLeft(acc)((acc, fld) => Update.unset(acc, path.append(fld).mkString_(".")))) + 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] = { diff --git a/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala b/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala index 9c0e5d5..5e0e896 100644 --- a/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala +++ b/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala @@ -98,4 +98,37 @@ abstract class MongoDiffSpec[Update: Eq, Bson](implicit Updates: Updates[Update, 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) + } + } From 46121dcca00bf975cce1435f8a2f0ec8011ef2ae Mon Sep 17 00:00:00 2001 From: Lucas Satabin Date: Sun, 26 Mar 2023 14:53:24 +0200 Subject: [PATCH 10/15] Add spec tests for testing correctness of mongo updates --- build.sbt | 5 +- .../diffson/mongoupdate/ApplyUpdateSpec.scala | 104 ++++++++++++++++++ .../mongoupdate/MongoMongoDiffSpec.scala | 1 + .../mongoupdate/{package.scala => test.scala} | 4 +- 4 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 mongo/src/test/scala/diffson/mongoupdate/ApplyUpdateSpec.scala rename mongo/src/test/scala/diffson/mongoupdate/{package.scala => test.scala} (94%) diff --git a/build.sbt b/build.sbt index c802db0..677e567 100644 --- a/build.sbt +++ b/build.sbt @@ -7,6 +7,7 @@ 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) @@ -110,7 +111,9 @@ lazy val mongo = crossProject(JVMPlatform) .settings( name := "diffson-mongo", libraryDependencies ++= List( - "org.mongodb" % "mongodb-driver-core" % "4.9.0" + "org.mongodb" % "mongodb-driver-core" % "4.9.0", + "io.github.kirill5k" %% "mongo4cats-embedded" % mongo4catsVersion % Test, + "io.github.kirill5k" %% "mongo4cats-core" % mongo4catsVersion % Test ), tlVersionIntroduced := Map("2.13" -> "4.5.0", "3" -> "4.5.0", "2.12" -> "4.5.0") ) diff --git a/mongo/src/test/scala/diffson/mongoupdate/ApplyUpdateSpec.scala b/mongo/src/test/scala/diffson/mongoupdate/ApplyUpdateSpec.scala new file mode 100644 index 0000000..d21cdec --- /dev/null +++ b/mongo/src/test/scala/diffson/mongoupdate/ApplyUpdateSpec.scala @@ -0,0 +1,104 @@ +/* + * 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 lcs._ +import mongoupdate.lcsdiff._ + +import cats.Show +import cats.effect.IO +import cats.effect.Resource +import cats.syntax.all._ +import de.flapdoodle.embed.mongo.distribution.Version +import mongo4cats.client.MongoClient +import mongo4cats.embedded.EmbeddedMongo +import org.bson._ +import org.scalacheck.Arbitrary +import org.scalacheck.Gen +import weaver._ +import weaver.scalacheck._ + +import scala.jdk.CollectionConverters._ +import com.mongodb.client.model.Filters +import com.mongodb.client.model.Updates + +object ApplyUpdateSpec extends IOSuite with Checkers { + + implicit val lcs: Lcs[BsonValue] = new Patience[BsonValue] + + 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 + + test("apply updates") { client => + forall { (bson1: BsonDocument, bson2: BsonDocument) => + val id = new BsonObjectId + bson1.put("_id", id) + bson2.put("_id", id) + + val diff = (bson1: BsonValue).diff(bson2) + for { + db <- client.getDatabase("testdb") + coll <- db.getCollection("docs") + doc = mongo4cats.bson.Document.fromJava(new Document(bson1)) + _ <- coll.insertOne(doc) + update = Updates.combine(diff: _*) + _ <- coll.updateOne(Filters.eq("_id", id), update) + foundDoc <- coll.find(Filters.eq("_id", id)).first + } yield expect.eql(Some(bson2: BsonValue), foundDoc.map(_.toBsonDocument)) + IO.pure(expect(true)) + } + } + +} diff --git a/mongo/src/test/scala/diffson/mongoupdate/MongoMongoDiffSpec.scala b/mongo/src/test/scala/diffson/mongoupdate/MongoMongoDiffSpec.scala index 70505b3..3748bca 100644 --- a/mongo/src/test/scala/diffson/mongoupdate/MongoMongoDiffSpec.scala +++ b/mongo/src/test/scala/diffson/mongoupdate/MongoMongoDiffSpec.scala @@ -23,6 +23,7 @@ import org.bson.BsonValue import org.bson.conversions.Bson import mongoupdate._ +import test._ object MongoMongoDiffSpec extends MongoDiffSpec[List[Bson], BsonValue] { diff --git a/mongo/src/test/scala/diffson/mongoupdate/package.scala b/mongo/src/test/scala/diffson/mongoupdate/test.scala similarity index 94% rename from mongo/src/test/scala/diffson/mongoupdate/package.scala rename to mongo/src/test/scala/diffson/mongoupdate/test.scala index 7dcafe0..281ed18 100644 --- a/mongo/src/test/scala/diffson/mongoupdate/package.scala +++ b/mongo/src/test/scala/diffson/mongoupdate/test.scala @@ -14,12 +14,12 @@ * limitations under the License. */ -package diffson +package diffson.mongoupdate import cats.Eq import org.bson.conversions.Bson -package object mongoupdate { +object test { implicit val BsonEq: Eq[Bson] = Eq.fromUniversalEquals From b23882fa29996279df3cc63d11e41aa33c619220 Mon Sep 17 00:00:00 2001 From: Lucas Satabin Date: Sun, 26 Mar 2023 15:24:33 +0200 Subject: [PATCH 11/15] Simplify array diff The prepend case is just aspecial case of insertion at an index. --- .../scala/diffson/mongoupdate/MongoDiff.scala | 10 ---------- mongo/src/main/scala/diffson/bson/package.scala | 3 +-- .../diffson/mongoupdate/ApplyUpdateSpec.scala | 15 ++++++--------- .../diffson/mongoupdate/MongoMongoDiffSpec.scala | 4 +--- .../scala/diffson/mongoupdate/MongoDiffSpec.scala | 10 ++++++++++ 5 files changed, 18 insertions(+), 24 deletions(-) diff --git a/core/src/main/scala/diffson/mongoupdate/MongoDiff.scala b/core/src/main/scala/diffson/mongoupdate/MongoDiff.scala index 621fd37..ed7c771 100644 --- a/core/src/main/scala/diffson/mongoupdate/MongoDiff.scala +++ b/core/src/main/scala/diffson/mongoupdate/MongoDiff.scala @@ -92,16 +92,6 @@ class MongoDiff[Bson, Update](implicit Bson: Jsony[Bson], Update: Updates[Update if (newIdx1 == idx1 + 1 && newIdx2 == idx2 + 1) { // sequence goes forward in both arrays, continue looping loop(rest, newIdx1, newIdx2) - } else if (idx1 == -1 && newIdx2 == nbAdded) { - // element are added at the beginning, but we must make sure that the rest - // of the LCS is the original array itself - // this is the case if the LCS length is the array length - if (lcs.length == length1) { - Update.pushEach(acc, path.mkString_("."), 0, arr2.slice(0, nbAdded).toList) - } else { - // otherwise there are some changes that would conflict, replace the entire array - Update.set(acc, path.mkString_("."), JsArray(arr2)) - } } else if (newIdx2 - 1 - idx2 == nbAdded) { // there is a bigger gap in original array, it must be where the elements are inserted // otherwise we stop and replace the entire array diff --git a/mongo/src/main/scala/diffson/bson/package.scala b/mongo/src/main/scala/diffson/bson/package.scala index c3f06f2..d3a788e 100644 --- a/mongo/src/main/scala/diffson/bson/package.scala +++ b/mongo/src/main/scala/diffson/bson/package.scala @@ -17,8 +17,7 @@ package diffson import cats.syntax.all._ -import com.mongodb.client.model.PushOptions -import com.mongodb.client.model.{Updates => JUpdates} +import com.mongodb.client.model.{PushOptions, Updates => JUpdates} import org.bson._ import org.bson.conversions.Bson diff --git a/mongo/src/test/scala/diffson/mongoupdate/ApplyUpdateSpec.scala b/mongo/src/test/scala/diffson/mongoupdate/ApplyUpdateSpec.scala index d21cdec..cc61971 100644 --- a/mongo/src/test/scala/diffson/mongoupdate/ApplyUpdateSpec.scala +++ b/mongo/src/test/scala/diffson/mongoupdate/ApplyUpdateSpec.scala @@ -17,25 +17,22 @@ package diffson package bson -import lcs._ -import mongoupdate.lcsdiff._ - import cats.Show -import cats.effect.IO -import cats.effect.Resource +import cats.effect.{IO, Resource} import cats.syntax.all._ +import com.mongodb.client.model.{Filters, Updates} import de.flapdoodle.embed.mongo.distribution.Version import mongo4cats.client.MongoClient import mongo4cats.embedded.EmbeddedMongo import org.bson._ -import org.scalacheck.Arbitrary -import org.scalacheck.Gen +import org.scalacheck.{Arbitrary, Gen} import weaver._ import weaver.scalacheck._ import scala.jdk.CollectionConverters._ -import com.mongodb.client.model.Filters -import com.mongodb.client.model.Updates + +import lcs._ +import mongoupdate.lcsdiff._ object ApplyUpdateSpec extends IOSuite with Checkers { diff --git a/mongo/src/test/scala/diffson/mongoupdate/MongoMongoDiffSpec.scala b/mongo/src/test/scala/diffson/mongoupdate/MongoMongoDiffSpec.scala index 3748bca..ded230c 100644 --- a/mongo/src/test/scala/diffson/mongoupdate/MongoMongoDiffSpec.scala +++ b/mongo/src/test/scala/diffson/mongoupdate/MongoMongoDiffSpec.scala @@ -17,10 +17,8 @@ package diffson package bson -import org.bson.BsonInt32 -import org.bson.BsonString -import org.bson.BsonValue import org.bson.conversions.Bson +import org.bson.{BsonInt32, BsonString, BsonValue} import mongoupdate._ import test._ diff --git a/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala b/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala index 5e0e896..7733561 100644 --- a/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala +++ b/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala @@ -54,6 +54,16 @@ abstract class MongoDiffSpec[Update: Eq, Bson](implicit Updates: Updates[Update, 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 = From 3dc7157fecc92bb722ae129c73d25add497c44b3 Mon Sep 17 00:00:00 2001 From: Lucas Satabin Date: Sun, 26 Mar 2023 15:57:52 +0200 Subject: [PATCH 12/15] Generalize property testing for various BSON libraries --- build.sbt | 12 +++++--- .../scala/diffson/mongoupdate/ApplySpec.scala | 30 +++++++++++++++++++ .../diffson/mongoupdate/ApplyUpdateSpec.scala | 24 +++++++++------ 3 files changed, 53 insertions(+), 13 deletions(-) create mode 100644 mongo/src/test/scala/diffson/mongoupdate/ApplySpec.scala rename {mongo/src/test => testkit/jvm/src/main}/scala/diffson/mongoupdate/ApplyUpdateSpec.scala (81%) diff --git a/build.sbt b/build.sbt index 677e567..8a2ac2e 100644 --- a/build.sbt +++ b/build.sbt @@ -71,6 +71,12 @@ lazy val testkit = crossProject(JSPlatform, JVMPlatform, NativePlatform) "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 @@ -109,11 +115,9 @@ lazy val mongo = crossProject(JVMPlatform) .in(file("mongo")) .settings(commonSettings) .settings( - name := "diffson-mongo", + name := "diffson-mongodb-driver", libraryDependencies ++= List( - "org.mongodb" % "mongodb-driver-core" % "4.9.0", - "io.github.kirill5k" %% "mongo4cats-embedded" % mongo4catsVersion % Test, - "io.github.kirill5k" %% "mongo4cats-core" % mongo4catsVersion % Test + "org.mongodb" % "mongodb-driver-core" % "4.9.0" ), tlVersionIntroduced := Map("2.13" -> "4.5.0", "3" -> "4.5.0", "2.12" -> "4.5.0") ) 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/ApplyUpdateSpec.scala b/testkit/jvm/src/main/scala/diffson/mongoupdate/ApplyUpdateSpec.scala similarity index 81% rename from mongo/src/test/scala/diffson/mongoupdate/ApplyUpdateSpec.scala rename to testkit/jvm/src/main/scala/diffson/mongoupdate/ApplyUpdateSpec.scala index cc61971..4f3b69f 100644 --- a/mongo/src/test/scala/diffson/mongoupdate/ApplyUpdateSpec.scala +++ b/testkit/jvm/src/main/scala/diffson/mongoupdate/ApplyUpdateSpec.scala @@ -17,10 +17,10 @@ package diffson package bson -import cats.Show import cats.effect.{IO, Resource} import cats.syntax.all._ -import com.mongodb.client.model.{Filters, Updates} +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 @@ -34,9 +34,13 @@ import scala.jdk.CollectionConverters._ import lcs._ import mongoupdate.lcsdiff._ -object ApplyUpdateSpec extends IOSuite with Checkers { +abstract class ApplyUpdateSpec[Update, Bson: Jsony](implicit Update: mongoupdate.Updates[Update, Bson]) + extends IOSuite + with Checkers { - implicit val lcs: Lcs[BsonValue] = new Patience[BsonValue] + implicit val lcs: Lcs[Bson] = new Patience[Bson] + + implicit val eq: Eq[BsonDocument] = Eq.fromUniversalEquals type Res = MongoClient[IO] @@ -78,23 +82,25 @@ object ApplyUpdateSpec extends IOSuite with Checkers { implicit val showDoc: Show[BsonDocument] = Show.fromToString + def fromBsonDocument(bson: BsonDocument): Bson + + def toUpdate(diff: Update): conversions.Bson + test("apply updates") { client => forall { (bson1: BsonDocument, bson2: BsonDocument) => val id = new BsonObjectId bson1.put("_id", id) bson2.put("_id", id) - val diff = (bson1: BsonValue).diff(bson2) + 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) - update = Updates.combine(diff: _*) - _ <- coll.updateOne(Filters.eq("_id", id), update) + _ <- coll.updateOne(Filters.eq("_id", id), toUpdate(diff)) foundDoc <- coll.find(Filters.eq("_id", id)).first - } yield expect.eql(Some(bson2: BsonValue), foundDoc.map(_.toBsonDocument)) - IO.pure(expect(true)) + } yield expect.eql(Some(bson2), foundDoc.map(_.toBsonDocument)) } } From 05b69b9ae3af2cb7c35b99b127ead3ae201e596e Mon Sep 17 00:00:00 2001 From: Lucas Satabin Date: Sun, 26 Mar 2023 16:28:06 +0200 Subject: [PATCH 13/15] Add mongo4cats `BsonValue` support --- .github/workflows/ci.yml | 4 +- build.sbt | 13 +++++ .../src/main/scala/diffson/bson/package.scala | 6 +-- .../scala/diffson/bson4cats/package.scala | 52 +++++++++++++++++++ .../scala/mongo4cats/operations/Updates.scala | 6 +++ .../scala/diffson/bson4cats/ApplySpec.scala | 18 +++++++ .../scala/diffson/bson4cats/DiffSpec.scala | 15 ++++++ .../test/scala/diffson/bson4cats/test.scala | 8 +++ 8 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 mongo4cats/src/main/scala/diffson/bson4cats/package.scala create mode 100644 mongo4cats/src/main/scala/mongo4cats/operations/Updates.scala create mode 100644 mongo4cats/src/test/scala/diffson/bson4cats/ApplySpec.scala create mode 100644 mongo4cats/src/test/scala/diffson/bson4cats/DiffSpec.scala create mode 100644 mongo4cats/src/test/scala/diffson/bson4cats/test.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2159a67..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 mongo/.jvm/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 mongo/.jvm/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 8a2ac2e..39a2de5 100644 --- a/build.sbt +++ b/build.sbt @@ -123,6 +123,19 @@ lazy val mongo = crossProject(JVMPlatform) ) .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/mongo/src/main/scala/diffson/bson/package.scala b/mongo/src/main/scala/diffson/bson/package.scala index d3a788e..bd2faaf 100644 --- a/mongo/src/main/scala/diffson/bson/package.scala +++ b/mongo/src/main/scala/diffson/bson/package.scala @@ -30,11 +30,7 @@ package object bson { implicit object BsonJsony extends Jsony[BsonValue] { override def eqv(x: BsonValue, y: BsonValue): Boolean = - (x, y) match { - case (null, null) => true - case (null, _) | (_, null) => false - case _ => x.equals(y) - } + x == y override def show(t: BsonValue): String = t.toString() 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 +} From 53c78987e1e01be3c6c73b02521f6e0d389317e1 Mon Sep 17 00:00:00 2001 From: Lucas Satabin Date: Sun, 26 Mar 2023 16:42:21 +0200 Subject: [PATCH 14/15] Ignore slow property tests --- .../diffson/mongoupdate/ApplyUpdateSpec.scala | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/testkit/jvm/src/main/scala/diffson/mongoupdate/ApplyUpdateSpec.scala b/testkit/jvm/src/main/scala/diffson/mongoupdate/ApplyUpdateSpec.scala index 4f3b69f..8ec28dd 100644 --- a/testkit/jvm/src/main/scala/diffson/mongoupdate/ApplyUpdateSpec.scala +++ b/testkit/jvm/src/main/scala/diffson/mongoupdate/ApplyUpdateSpec.scala @@ -87,21 +87,22 @@ abstract class ApplyUpdateSpec[Update, Bson: Jsony](implicit Update: mongoupdate def toUpdate(diff: Update): conversions.Bson test("apply updates") { client => - 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)) - } + 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)) + } } } From 9eb5e2505418cfb773a65b42df3e3f8fa340cc4a Mon Sep 17 00:00:00 2001 From: Lucas Satabin Date: Sun, 26 Mar 2023 17:33:09 +0200 Subject: [PATCH 15/15] Make mongo diff LCS independent Given the specificities of the mongo diff for arrays, there is no need to compute an expensive LCS for computing whether the target array is the result of adding a block of consecutive values somewhere in the source array. --- .../scala/diffson/mongoupdate/MongoDiff.scala | 71 ++++++++++--------- .../scala/diffson/mongoupdate/package.scala | 17 +---- .../diffson/mongoupdate/ApplyUpdateSpec.scala | 5 +- .../diffson/mongoupdate/MongoDiffSpec.scala | 5 -- 4 files changed, 40 insertions(+), 58 deletions(-) diff --git a/core/src/main/scala/diffson/mongoupdate/MongoDiff.scala b/core/src/main/scala/diffson/mongoupdate/MongoDiff.scala index ed7c771..45987db 100644 --- a/core/src/main/scala/diffson/mongoupdate/MongoDiff.scala +++ b/core/src/main/scala/diffson/mongoupdate/MongoDiff.scala @@ -21,11 +21,9 @@ import cats.Eval import cats.data.Chain import cats.syntax.all._ -import lcs.Lcs import scala.annotation.tailrec -class MongoDiff[Bson, Update](implicit Bson: Jsony[Bson], Update: Updates[Update, Bson], Lcs: Lcs[Bson]) - extends Diff[Bson, Update] { +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 = @@ -79,39 +77,44 @@ class MongoDiff[Bson, Update](implicit Bson: Jsony[Bson], Update: Updates[Update val nbAdded = length2 - length1 // there are some additions, and possibly some modifications // elements can be added as a block only - // the LCS is computed to decide where elements are added - // if there is several additions in several places - // or a mix of additions and other modifications, - // then we just replace the entire array, to avoid conflicts - val lcs = Lcs.lcs(arr1.toList, arr2.toList) + // first we commpute the common prefixes and suffixes @tailrec - def loop(lcs: List[(Int, Int)], idx1: Int, idx2: Int): Update = - lcs match { - case (newIdx1, newIdx2) :: rest => - if (newIdx1 == idx1 + 1 && newIdx2 == idx2 + 1) { - // sequence goes forward in both arrays, continue looping - loop(rest, newIdx1, newIdx2) - } else if (newIdx2 - 1 - idx2 == nbAdded) { - // there is a bigger gap in original array, it must be where the elements are inserted - // otherwise we stop and replace the entire array - // if gap is of the right size, check that the rest of the LCS represents the suffix of both arrays - if (lcs.length == length1 - newIdx1) { - Update.pushEach(acc, path.mkString_("."), idx1 + 1, arr2.slice(idx2 + 1, idx2 + 1 + nbAdded).toList) - } else { - // otherwise there are some changes that would conflict, replace the entire array - Update.set(acc, path.mkString_("."), JsArray(arr2)) - } - } else { - // otherwise replace the entire array - Update.set(acc, path.mkString_("."), JsArray(arr2)) - } - case Nil => - // we reached the end of the original array, - // it means every new element is appended to the end - Update.pushEach(acc, path.mkString_("."), arr2.slice(idx2 + 1, idx2 + 1 + nbAdded).toList) - } - Eval.now(loop(lcs, -1, -1)) + 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/package.scala b/core/src/main/scala/diffson/mongoupdate/package.scala index a7847c9..1f6a610 100644 --- a/core/src/main/scala/diffson/mongoupdate/package.scala +++ b/core/src/main/scala/diffson/mongoupdate/package.scala @@ -16,22 +16,9 @@ package diffson -import diffson.lcs.Lcs - package object mongoupdate { - object lcsdiff { - implicit def MongoDiffDiff[Bson: Jsony: Lcs, Update](implicit updates: Updates[Update, Bson]): Diff[Bson, Update] = - new MongoDiff[Bson, Update]()(implicitly, implicitly, implicitly[Lcs[Bson]].savedHashes) - } - - object simplediff { - private implicit def nolcs[Bson]: Lcs[Bson] = new Lcs[Bson] { - def savedHashes = this - def lcs(seq1: List[Bson], seq2: List[Bson], low1: Int, high1: Int, low2: Int, high2: Int): List[(Int, Int)] = Nil - } - implicit def MongoDiffDiff[Bson: Jsony, Update](implicit updates: Updates[Update, Bson]): Diff[Bson, Update] = - new MongoDiff[Bson, Update] - } + implicit def MongoDiffDiff[Bson: Jsony, Update](implicit updates: Updates[Update, Bson]): Diff[Bson, Update] = + new MongoDiff[Bson, Update] } diff --git a/testkit/jvm/src/main/scala/diffson/mongoupdate/ApplyUpdateSpec.scala b/testkit/jvm/src/main/scala/diffson/mongoupdate/ApplyUpdateSpec.scala index 8ec28dd..ac762d1 100644 --- a/testkit/jvm/src/main/scala/diffson/mongoupdate/ApplyUpdateSpec.scala +++ b/testkit/jvm/src/main/scala/diffson/mongoupdate/ApplyUpdateSpec.scala @@ -31,15 +31,12 @@ import weaver.scalacheck._ import scala.jdk.CollectionConverters._ -import lcs._ -import mongoupdate.lcsdiff._ +import mongoupdate._ abstract class ApplyUpdateSpec[Update, Bson: Jsony](implicit Update: mongoupdate.Updates[Update, Bson]) extends IOSuite with Checkers { - implicit val lcs: Lcs[Bson] = new Patience[Bson] - implicit val eq: Eq[BsonDocument] = Eq.fromUniversalEquals type Res = MongoClient[IO] diff --git a/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala b/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala index 7733561..902ff00 100644 --- a/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala +++ b/testkit/shared/src/main/scala/diffson/mongoupdate/MongoDiffSpec.scala @@ -20,14 +20,9 @@ package mongoupdate import cats.Eq import weaver._ -import lcs._ -import lcsdiff._ - abstract class MongoDiffSpec[Update: Eq, Bson](implicit Updates: Updates[Update, Bson], Jsony: Jsony[Bson]) extends SimpleIOSuite { - implicit val lcs: Lcs[Bson] = new Patience[Bson] - def int(i: Int): Bson def string(s: String): Bson