Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add generation of mongo updates as diff #336

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
63 changes: 54 additions & 9 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"
Expand All @@ -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)
Expand All @@ -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](
Expand All @@ -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
Expand Down Expand Up @@ -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"))
Expand Down
121 changes: 121 additions & 0 deletions core/src/main/scala/diffson/mongoupdate/MongoDiff.scala
Original file line number Diff line number Diff line change
@@ -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)
}
}

}
34 changes: 34 additions & 0 deletions core/src/main/scala/diffson/mongoupdate/Updates.scala
Original file line number Diff line number Diff line change
@@ -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

}
24 changes: 24 additions & 0 deletions core/src/main/scala/diffson/mongoupdate/package.scala
Original file line number Diff line number Diff line change
@@ -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]

}
71 changes: 71 additions & 0 deletions mongo/src/main/scala/diffson/bson/package.scala
Original file line number Diff line number Diff line change
@@ -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

}

}
Loading