Skip to content
This repository was archived by the owner on Jan 24, 2025. It is now read-only.

Commit 8cea02d

Browse files
objectMatcher v3 (#282)
* Use scala macros to reduce number of methods for objectMatcher replacement * Add more tests for useMatcher macro * Should match list by value using object optional property * Should preserve order even if there are missing or additional entities * Should prefer identical matches when there are multiple possible choices * Set full of duplicates according to object matcher should be identical to itself * Introduce SetEntry box for sets * Map full of duplicates according to object matcher should be identical to itself * Simplify code * Make string implementation details package private * Fix docs compilation * Rename TupleInstances -> DiffTupleInstances * Remove optionObjectMacher * Fix incorrect test case * Move problematic test out of js tests
1 parent 43e5f2c commit 8cea02d

30 files changed

+712
-272
lines changed

cats/src/test/scala/com/softwaremill/diffx/cats/DiffxCatsTest.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class DiffxCatsTest extends AnyFreeSpec with Matchers {
1919

2020
"nonEmptySet" in {
2121
compare(NonEmptySet.of(1), NonEmptySet.of(2)) shouldBe DiffResultSet(
22-
List(DiffResultAdditional(1), DiffResultMissing(2))
22+
Set(DiffResultAdditional(1), DiffResultMissing(2))
2323
)
2424
}
2525

core/src/main/boilerplate/com/softwaremill/diffx/TupleInstances.scala.template renamed to core/src/main/boilerplate/com/softwaremill/diffx/DiffTupleInstances.scala.template

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.softwaremill.diffx
22

3-
trait TupleInstances {
3+
trait DiffTupleInstances {
44

55
[2..#implicit def dTuple1[[#T1#]](implicit [#d1: Diff[T1]#]): Diff[Tuple1[[#T1#]]] = new Diff[Tuple1[[#T1#]]] {
66
override def apply(

core/src/main/scala/com/softwaremill/diffx/Diff.scala

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
package com.softwaremill.diffx
2-
import com.softwaremill.diffx.ObjectMatcher.{IterableEntry, MapEntry}
2+
import com.softwaremill.diffx.ObjectMatcher.{IterableEntry, MapEntry, SetEntry}
33
import com.softwaremill.diffx.generic.{DiffMagnoliaDerivation, MagnoliaDerivedMacro}
44
import com.softwaremill.diffx.instances._
55

@@ -35,7 +35,7 @@ trait Diff[-T] { outer =>
3535
}
3636
}
3737

38-
object Diff extends MiddlePriorityDiff with TupleInstances with DiffxPlatformExtensions {
38+
object Diff extends MiddlePriorityDiff with DiffTupleInstances with DiffxPlatformExtensions {
3939
def apply[T: Diff]: Diff[T] = implicitly[Diff[T]]
4040

4141
def ignored[T]: Diff[T] = (_: T, _: T, _: DiffContext) => DiffResult.Ignored
@@ -64,7 +64,7 @@ object Diff extends MiddlePriorityDiff with TupleInstances with DiffxPlatformExt
6464
implicit def diffForOptional[T](implicit ddt: Diff[T]): Diff[Option[T]] = new DiffForOption[T](ddt)
6565
implicit def diffForSet[T, C[W] <: scala.collection.Set[W]](implicit
6666
dt: Diff[T],
67-
matcher: ObjectMatcher[T]
67+
matcher: ObjectMatcher[SetEntry[T]]
6868
): Diff[C[T]] = new DiffForSet[T, C](dt, matcher)
6969
implicit def diffForEither[L, R](implicit ld: Diff[L], rd: Diff[R]): Diff[Either[L, R]] =
7070
new DiffForEither[L, R](ld, rd)
@@ -112,12 +112,7 @@ case class DiffLens[T, U](outer: Diff[T], path: List[String]) {
112112

113113
def ignore(implicit config: DiffConfiguration): Diff[T] = outer.modifyUnsafe(path: _*)(config.makeIgnored)
114114

115-
def withMapMatcher[K, V](m: ObjectMatcher[MapEntry[K, V]])(implicit ev1: U <:< scala.collection.Map[K, V]): Diff[T] =
116-
outer.modifyMatcherUnsafe(path: _*)(m)
117-
def withSetMatcher[V](m: ObjectMatcher[V])(implicit ev2: U <:< scala.collection.Set[V]): Diff[T] =
118-
outer.modifyMatcherUnsafe(path: _*)(m)
119-
def withListMatcher[V](m: ObjectMatcher[IterableEntry[V]])(implicit ev3: U <:< Iterable[V]): Diff[T] =
120-
outer.modifyMatcherUnsafe(path: _*)(m)
115+
def useMatcher[M](matcher: ObjectMatcher[M]): Diff[T] = macro ModifyMacro.withObjectMatcher[T, U, M]
121116
}
122117
case class DerivedDiffLens[T, U](outer: Diff[T], path: List[String]) {
123118
def setTo(d: Diff[U]): Derived[Diff[T]] = using(_ => d)
@@ -130,12 +125,5 @@ case class DerivedDiffLens[T, U](outer: Diff[T], path: List[String]) {
130125
outer.modifyUnsafe(path: _*)(config.makeIgnored)
131126
)
132127

133-
def withMapMatcher[K, V](m: ObjectMatcher[MapEntry[K, V]])(implicit
134-
ev1: U <:< scala.collection.Map[K, V]
135-
): Derived[Diff[T]] =
136-
Derived(outer.modifyMatcherUnsafe(path: _*)(m))
137-
def withSetMatcher[V](m: ObjectMatcher[V])(implicit ev2: U <:< scala.collection.Set[V]): Derived[Diff[T]] =
138-
Derived(outer.modifyMatcherUnsafe(path: _*)(m))
139-
def withListMatcher[V](m: ObjectMatcher[IterableEntry[V]])(implicit ev3: U <:< Iterable[V]): Derived[Diff[T]] =
140-
Derived(outer.modifyMatcherUnsafe(path: _*)(m))
128+
def useMatcher[M](matcher: ObjectMatcher[M]): Derived[Diff[T]] = macro ModifyMacro.withObjectMatcherDerived[T, U, M]
141129
}

core/src/main/scala/com/softwaremill/diffx/DiffResult.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ case class DiffResultMap(entries: Map[DiffResult, DiffResult]) extends DiffResul
9494
override def isIdentical: Boolean = entries.forall { case (k, v) => k.isIdentical && v.isIdentical }
9595
}
9696

97-
case class DiffResultSet(diffs: List[DiffResult]) extends DiffResult {
97+
case class DiffResultSet(diffs: Set[DiffResult]) extends DiffResult {
9898
override private[diffx] def showIndented(indent: Int, renderIdentical: Boolean)(implicit
9999
c: ConsoleColorConfig
100100
): String = {

core/src/main/scala/com/softwaremill/diffx/TupleInstances.scala renamed to core/src/main/scala/com/softwaremill/diffx/DiffTupleInstances.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.softwaremill.diffx
22

3-
trait TupleInstances {
3+
trait DiffTupleInstances {
44

55
implicit def dTuple2[T1, T2](implicit d1: Diff[T1], d2: Diff[T2]): Diff[Tuple2[T1, T2]] = new Diff[Tuple2[T1, T2]] {
66
override def apply(

core/src/main/scala/com/softwaremill/diffx/ModifyMacro.scala

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.softwaremill.diffx
22

3+
import com.softwaremill.diffx.ObjectMatcher.{IterableEntry, MapEntry, SetEntry}
4+
35
import scala.annotation.tailrec
46
import scala.reflect.macros.blackbox
57

@@ -112,4 +114,36 @@ object ModifyMacro {
112114

113115
private[diffx] def modifiedFromPath[T, U](path: T => U): List[String] =
114116
macro modifiedFromPathMacro[T, U]
117+
118+
def withObjectMatcherDerived[T: c.WeakTypeTag, U: c.WeakTypeTag, M: c.WeakTypeTag](
119+
c: blackbox.Context
120+
)(matcher: c.Expr[ObjectMatcher[M]]): c.Tree = {
121+
import c.universe._
122+
val diff = withObjectMatcher[T, U, M](c)(matcher)
123+
q"com.softwaremill.diffx.Derived($diff)"
124+
}
125+
126+
def withObjectMatcher[T: c.WeakTypeTag, U: c.WeakTypeTag, M: c.WeakTypeTag](
127+
c: blackbox.Context
128+
)(matcher: c.Expr[ObjectMatcher[M]]): c.Tree = {
129+
import c.universe._
130+
val t = weakTypeOf[T]
131+
val u = weakTypeOf[U]
132+
val m = weakTypeOf[M]
133+
134+
val baseIsIterable = u <:< typeOf[Iterable[_]]
135+
val baseIsSet = u <:< typeOf[Set[_]]
136+
val baseIsMap = u <:< typeOf[Map[_, _]]
137+
val typeArgsTheSame = u.typeArgs == m.typeArgs
138+
val setRequirements = baseIsSet && typeArgsTheSame && m <:< typeOf[SetEntry[_]]
139+
val iterableRequirements = !baseIsSet && baseIsIterable && typeArgsTheSame && m <:< typeOf[IterableEntry[_]]
140+
val mapRequirements = baseIsMap && typeArgsTheSame && m <:< typeOf[MapEntry[_, _]]
141+
if (!setRequirements && !iterableRequirements && !mapRequirements) { // weakTypeOf[U] <:< tq"Iterable[${u.typeArgs.head.termSymbol}]"
142+
c.abort(c.enclosingPosition, s"Invalid objectMather type $u for given lens($t,$m)")
143+
}
144+
q"""
145+
val lens = ${c.prefix}
146+
lens.outer.modifyMatcherUnsafe(lens.path: _*)($matcher)
147+
"""
148+
}
115149
}

core/src/main/scala/com/softwaremill/diffx/ObjectMatcher.scala

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,29 +10,45 @@ trait ObjectMatcher[T] {
1010
object ObjectMatcher extends LowPriorityObjectMatcher {
1111
def apply[T: ObjectMatcher]: ObjectMatcher[T] = implicitly[ObjectMatcher[T]]
1212

13-
/** Given product of type T and its property U, match that products using U's objectMatcher */
14-
def by[T, U: ObjectMatcher](f: T => U): ObjectMatcher[T] = (left: T, right: T) =>
13+
private def by[T, U: ObjectMatcher](f: T => U): ObjectMatcher[T] = (left: T, right: T) =>
1514
ObjectMatcher[U].isSameObject(f(left), f(right))
1615

17-
/** Given MapEntry[K,V] match them using V's objectMatcher */
18-
def byValue[K, V: ObjectMatcher]: ObjectMatcher[MapEntry[K, V]] = ObjectMatcher.by[MapEntry[K, V], V](_.value)
16+
/** Given MapEntry[K,V], match them using K's objectMatcher */
17+
implicit def mapEntryByKey[K: ObjectMatcher, V]: ObjectMatcher[MapEntry[K, V]] =
18+
ObjectMatcher.by[MapEntry[K, V], K](_.key)
19+
20+
implicit def setEntryByValue[T: ObjectMatcher]: ObjectMatcher[SetEntry[T]] = ObjectMatcher.by[SetEntry[T], T](_.t)
1921

20-
/** Given MapEntry[K,V], where V is a type of product and U is a property of V, match them using U's objectMatcher */
21-
def byValue[K, V, U: ObjectMatcher](f: V => U): ObjectMatcher[MapEntry[K, V]] =
22-
ObjectMatcher.byValue[K, V](ObjectMatcher.by[V, U](f))
22+
type IterableEntry[T] = MapEntry[Int, T]
23+
case class SetEntry[T](t: T)
24+
case class MapEntry[K, V](key: K, value: V)
2325

24-
implicit def optionMatcher[T: ObjectMatcher]: ObjectMatcher[Option[T]] = (left: Option[T], right: Option[T]) => {
25-
(left, right) match {
26-
case (Some(l), Some(r)) => ObjectMatcher[T].isSameObject(l, r)
27-
case _ => false
28-
}
26+
def list[T] = new ObjectMatcherListHelper[T]
27+
28+
class ObjectMatcherListHelper[V] {
29+
def byValue[U: ObjectMatcher](f: V => U): ObjectMatcher[IterableEntry[V]] = byValue(ObjectMatcher.by[V, U](f))
30+
def byValue(implicit ev: ObjectMatcher[V]): ObjectMatcher[IterableEntry[V]] =
31+
ObjectMatcher.by[IterableEntry[V], V](_.value)
32+
33+
def byKey[U: ObjectMatcher](f: Int => U): ObjectMatcher[IterableEntry[V]] = byKey(ObjectMatcher.by(f))
34+
def byKey(implicit ko: ObjectMatcher[Int]): ObjectMatcher[IterableEntry[V]] = ObjectMatcher.by(_.key)
2935
}
3036

31-
/** Given MapEntry[K,V], match them using K's objectMatcher */
32-
implicit def byKey[K: ObjectMatcher, V]: ObjectMatcher[MapEntry[K, V]] = ObjectMatcher.by[MapEntry[K, V], K](_.key)
37+
def set[T] = new ObjectMatcherSetHelper[T]
3338

34-
type IterableEntry[T] = MapEntry[Int, T]
35-
case class MapEntry[K, V](key: K, value: V)
39+
class ObjectMatcherSetHelper[T] {
40+
def by[U: ObjectMatcher](f: T => U): ObjectMatcher[SetEntry[T]] = setEntryByValue(ObjectMatcher.by(f))
41+
}
42+
43+
def map[K, V] = new ObjectMatcherMapHelper[K, V]
44+
45+
class ObjectMatcherMapHelper[K, V] {
46+
def byValue[U: ObjectMatcher](f: V => U): ObjectMatcher[MapEntry[K, V]] = byValue(ObjectMatcher.by(f))
47+
def byValue(implicit ev: ObjectMatcher[V]): ObjectMatcher[MapEntry[K, V]] = ObjectMatcher.by(_.value)
48+
49+
def byKey[U: ObjectMatcher](f: K => U): ObjectMatcher[MapEntry[K, V]] = byKey(ObjectMatcher.by(f))
50+
def byKey(implicit ko: ObjectMatcher[K]): ObjectMatcher[MapEntry[K, V]] = ObjectMatcher.by(_.key)
51+
}
3652
}
3753

3854
trait LowPriorityObjectMatcher {
Lines changed: 65 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,89 @@
11
package com.softwaremill.diffx.instances
22

3-
import com.softwaremill.diffx.Matching.MatchingResults
43
import com.softwaremill.diffx.ObjectMatcher.{IterableEntry, MapEntry}
54
import com.softwaremill.diffx._
5+
import com.softwaremill.diffx.instances.DiffForIterable._
6+
import com.softwaremill.diffx.instances.internal.MatchResult
67

78
import scala.annotation.tailrec
8-
import scala.collection.immutable.{ListMap, ListSet}
9+
import scala.collection.immutable.ListMap
910

1011
private[diffx] class DiffForIterable[T, C[W] <: Iterable[W]](
1112
dt: Diff[T],
1213
matcher: ObjectMatcher[IterableEntry[T]]
1314
) extends Diff[C[T]] {
1415
override def apply(left: C[T], right: C[T], context: DiffContext): DiffResult = nullGuard(left, right) {
1516
(left, right) =>
16-
val keys = Range(0, Math.max(left.size, right.size))
17-
18-
val leftAsMap = left.toList.lift
19-
val rightAsMap = right.toList.lift
20-
val lEntries = keys.map(i => i -> leftAsMap(i)).collect { case (k, Some(v)) => MapEntry(k, v) }.toList
21-
val rEntries = keys.map(i => i -> rightAsMap(i)).collect { case (k, Some(v)) => MapEntry(k, v) }.toList
22-
2317
val adjustedMatcher = context.getMatcherOverride[IterableEntry[T]].getOrElse(matcher)
24-
val MatchingResults(unMatchedLeftInstances, unMatchedRightInstances, matchedInstances) =
25-
matchPairs(lEntries, rEntries, adjustedMatcher)
26-
val leftDiffs = unMatchedLeftInstances
27-
.diff(unMatchedRightInstances)
28-
.collectFirst { case MapEntry(k, v) => k -> DiffResultAdditional(v) }
29-
.toList
30-
val rightDiffs = unMatchedRightInstances
31-
.diff(unMatchedLeftInstances)
32-
.collectFirst { case MapEntry(k, v) => k -> DiffResultMissing(v) }
33-
.toList
34-
val matchedDiffs = matchedInstances.map { case (l, r) => l.key -> dt(l.value, r.value, context) }.toList
18+
val leftWithIndex = left.zipWithIndex.map { case (lv, i) => MapEntry(i, lv) }.toList
19+
val rightWithIndex = right.zipWithIndex.map { case (rv, i) => MapEntry(i, rv) }.toList
3520

36-
val diffs = ListMap((matchedDiffs ++ leftDiffs ++ rightDiffs).map { case (k, v) => k.toString -> v }: _*)
21+
val matches = matchPairs(leftWithIndex, rightWithIndex, adjustedMatcher, List.empty, context)
22+
val sortedDiffs = matches.sorted.map {
23+
case MatchResult.UnmatchedLeft(v) => DiffResultAdditional(v.value)
24+
case MatchResult.UnmatchedRight(v) => DiffResultMissing(v.value)
25+
case MatchResult.Matched(l, r) => dt.apply(l.value, r.value, context)
26+
}
27+
val reindexed = sortedDiffs.zipWithIndex.map(_.swap)
28+
val diffs = ListMap(reindexed.map { case (k, v) => k.toString -> v }: _*)
3729
DiffResultObject("List", diffs)
3830
}
3931

32+
@tailrec
4033
private def matchPairs(
4134
left: List[IterableEntry[T]],
4235
right: List[IterableEntry[T]],
43-
matcher: ObjectMatcher[IterableEntry[T]]
44-
): MatchingResults[IterableEntry[T]] = {
45-
@tailrec
46-
def loop(
47-
left: List[IterableEntry[T]],
48-
right: List[IterableEntry[T]],
49-
matches: List[(IterableEntry[T], IterableEntry[T])],
50-
leftUnmatched: List[IterableEntry[T]]
51-
): MatchingResults[IterableEntry[T]] = {
52-
left match {
53-
case lHead :: tail =>
54-
val maybeMatch = right.collectFirst {
55-
case r if matcher.isSameObject(lHead, r) => lHead -> r
56-
}
57-
maybeMatch match {
58-
case Some(m @ (_, rm)) =>
59-
loop(tail, right.filterNot(r => r.key == rm.key), matches :+ m, leftUnmatched)
60-
case None => loop(tail, right, matches, leftUnmatched :+ lHead)
61-
}
62-
case Nil => MatchingResults(ListSet(leftUnmatched: _*), ListSet(right: _*), ListSet(matches: _*))
63-
}
36+
matcher: ObjectMatcher[IterableEntry[T]],
37+
matched: List[MatchResult[IterableEntry[T]]],
38+
context: DiffContext
39+
): List[MatchResult[IterableEntry[T]]] = {
40+
right match {
41+
case ::(rHead, tailRight) =>
42+
val maybeMatched = left
43+
.collect { case l if matcher.isSameObject(rHead, l) => l -> rHead }
44+
.sortBy { case (l, r) => !dt.apply(l.value, r.value, context).isIdentical }
45+
.headOption
46+
maybeMatched match {
47+
case Some((lm, rm)) =>
48+
matchPairs(
49+
left.filterNot(l => l.key == lm.key),
50+
tailRight,
51+
matcher,
52+
matched :+ MatchResult.Matched(lm, rm),
53+
context
54+
)
55+
case None => matchPairs(left, tailRight, matcher, matched :+ MatchResult.UnmatchedRight(rHead), context)
56+
}
57+
case Nil => matched ++ left.map(l => MatchResult.UnmatchedLeft(l))
6458
}
65-
loop(left, right, List.empty, List.empty)
6659
}
6760
}
61+
62+
object DiffForIterable {
63+
implicit def iterableEntryOrdering[T]: Ordering[IterableEntry[T]] = Ordering.by(_.key)
64+
implicit def diffResultOrdering[T]: Ordering[MatchResult[IterableEntry[T]]] =
65+
new Ordering[MatchResult[IterableEntry[T]]] {
66+
override def compare(x: MatchResult[IterableEntry[T]], y: MatchResult[IterableEntry[T]]): Int = {
67+
(x, y) match {
68+
case (ur: MatchResult.UnmatchedRight[IterableEntry[T]], m: MatchResult.Matched[IterableEntry[T]]) =>
69+
iterableEntryOrdering.compare(ur.v, m.r)
70+
case (_: MatchResult.UnmatchedRight[_], _: MatchResult.UnmatchedLeft[_]) => 1
71+
case (ur: MatchResult.UnmatchedRight[IterableEntry[T]], ur2: MatchResult.UnmatchedRight[IterableEntry[T]]) =>
72+
iterableEntryOrdering.compare(ur.v, ur2.v)
73+
case (ur: MatchResult.UnmatchedLeft[IterableEntry[T]], m: MatchResult.Matched[IterableEntry[T]]) =>
74+
iterableEntryOrdering.compare(ur.v, m.l)
75+
case (_: MatchResult.UnmatchedLeft[_], _: MatchResult.UnmatchedRight[_]) => -1
76+
case (ur: MatchResult.UnmatchedLeft[IterableEntry[T]], ur2: MatchResult.UnmatchedLeft[IterableEntry[T]]) =>
77+
iterableEntryOrdering.compare(ur.v, ur2.v)
78+
case (m1: MatchResult.Matched[IterableEntry[T]], m2: MatchResult.Matched[IterableEntry[T]]) =>
79+
Ordering
80+
.by[MatchResult.Matched[IterableEntry[T]], (IterableEntry[T], IterableEntry[T])](m => (m.r, m.l))
81+
.compare(m1, m2)
82+
case (m: MatchResult.Matched[IterableEntry[T]], ur: MatchResult.UnmatchedRight[IterableEntry[T]]) =>
83+
iterableEntryOrdering.compare(m.r, ur.v)
84+
case (m: MatchResult.Matched[IterableEntry[T]], ul: MatchResult.UnmatchedLeft[IterableEntry[T]]) =>
85+
iterableEntryOrdering.compare(m.l, ul.v)
86+
}
87+
}
88+
}
89+
}

0 commit comments

Comments
 (0)