Skip to content

Commit 571701c

Browse files
authored
Add option to ignore child ordering in comparisons (#4)
1 parent 324cc40 commit 571701c

File tree

9 files changed

+421
-22
lines changed

9 files changed

+421
-22
lines changed

project/plugins.sbt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ addSbtPlugin("com.eed3si9n" % "sbt-unidoc" % "0.4.2")
1414
addSbtPlugin("com.typesafe.sbt" % "sbt-ghpages" % "0.6.3")
1515
addSbtPlugin("com.typesafe.sbt" % "sbt-site" % "1.4.0")
1616
addSbtPlugin("com.lightbend.paradox" % "sbt-paradox" % "0.6.5")
17+
addSbtPlugin("com.thoughtworks.sbt-api-mappings" % "sbt-api-mappings" % "3.0.0")

src/main/paradox/comparing-xml.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,51 @@ XmlDiffers(
124124
Seq("second", "first"),
125125
Seq("test")
126126
)
127-
```
127+
```
128+
129+
### IgnoreChildOrder
130+
131+
If enabled the ordering of child elements will be ignored. This is handled by re-ordering child nodes using an arbitrary
132+
sorting algorithm before comparing them.
133+
134+
_Note: the first difference returned may be different if this option is enabled._
135+
136+
#### Example 1
137+
138+
This:
139+
140+
```xml
141+
<example>
142+
<child-1/>
143+
<child-2/>
144+
</example>
145+
```
146+
147+
would be considered equal to:
148+
```xml
149+
<example>
150+
<child-2/>
151+
<child-1/>
152+
</example>
153+
```
154+
155+
#### Example 2
156+
157+
This:
158+
159+
```xml
160+
<example>
161+
<child-1 attribute1="value-1" attribute2="value-2"/>
162+
<child-2 attribute="something"/>
163+
</example>
164+
```
165+
166+
would be considered equal to:
167+
```xml
168+
<example>
169+
<child-2 attribute="something"/>
170+
<child-1 attribute2="value-2" attribute1="value-1" />
171+
</example>
172+
```
173+
174+
_(The ordering of nodes and attributes are both ignored)_
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright 2017 Michael Stringer
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package software.purpledragon.xml
18+
19+
import scala.xml.Node
20+
21+
/**
22+
* Utilities for dealing with XML.
23+
*/
24+
object XmlUtils {
25+
26+
/**
27+
* Extracts names of attributes and a map of attributes from an XML [[scala.xml.Node Node]].
28+
*
29+
* @param node the XML node to extract attributes for.
30+
* @return a sequence of attribute names and a map of attribute values.
31+
*/
32+
def extractAttributes(node: Node): (Seq[String], Map[String, String]) = {
33+
node.attributes.foldLeft(Seq.empty[String], Map.empty[String, String]) {
34+
case ((keys, attribs), attrib) =>
35+
(keys :+ attrib.key, attribs + (attrib.key -> attrib.value.text))
36+
}
37+
}
38+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Copyright 2017 Michael Stringer
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package software.purpledragon.xml.compare
18+
19+
import software.purpledragon.xml.XmlUtils.extractAttributes
20+
21+
import scala.xml._
22+
23+
/**
24+
* Arbitrary ordering for XML nodes used to normalise XML when we are ignoring child ordering.
25+
*/
26+
private[compare] object NormalisedNodeOrdering extends Ordering[Node] {
27+
private def typeToOrdering(node: Node): Int = {
28+
node match {
29+
case _: Elem => 1
30+
case _: Text => 2
31+
case _: PCData => 3
32+
case _: Comment => 4
33+
}
34+
}
35+
36+
override def compare(x: Node, y: Node): Int = {
37+
(x, y) match {
38+
case (xe: Elem, ye: Elem) =>
39+
val labelOrder = xe.label compareTo ye.label
40+
41+
if (labelOrder != 0) {
42+
labelOrder
43+
} else {
44+
val (xAttributeNames, xAttributes) = extractAttributes(xe)
45+
val (yAttributeNames, yAttributes) = extractAttributes(ye)
46+
47+
// order by attribute count
48+
val attributeSizeOrder = xAttributeNames.size compareTo yAttributeNames.size
49+
50+
if (attributeSizeOrder != 0) {
51+
attributeSizeOrder
52+
} else {
53+
// compare attribute names
54+
val attributeNamesOrder = xAttributeNames.sorted zip yAttributeNames.sorted map {
55+
case (x, y) => x compareTo y
56+
}
57+
58+
// take first difference
59+
attributeNamesOrder.find(_ != 0) match {
60+
case Some(v) =>
61+
v
62+
case None =>
63+
// if not compare values
64+
val attributeValuesOrder = xAttributeNames map { name =>
65+
xAttributes(name) compareTo yAttributes(name)
66+
}
67+
68+
attributeValuesOrder.find(_ != 0).getOrElse(0)
69+
}
70+
}
71+
}
72+
73+
case (xe: Text, ye: Text) =>
74+
xe.text compareTo ye.text
75+
76+
case (xe: PCData, ye: PCData) =>
77+
xe.data compareTo ye.data
78+
79+
case (xe: Comment, ye: Comment) =>
80+
xe.commentText compareTo ye.commentText
81+
82+
case _ =>
83+
// different types - order by type
84+
typeToOrdering(x) compareTo typeToOrdering(y)
85+
}
86+
}
87+
}

xml-compare/src/main/scala/software/purpledragon/xml/compare/XmlCompare.scala

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,20 @@
1616

1717
package software.purpledragon.xml.compare
1818

19+
import software.purpledragon.xml.XmlUtils.extractAttributes
1920
import software.purpledragon.xml.compare.options.DiffOption._
20-
import software.purpledragon.xml.compare.options.DiffOptions
21+
import software.purpledragon.xml.compare.options.{DiffOption, DiffOptions}
2122

22-
import scala.xml.{Atom, Node}
23+
import scala.xml._
2324

2425
/**
2526
* Utility for comparing XML documents.
2627
*/
2728
object XmlCompare {
2829
private type Check = (Node, Node, DiffOptions, Seq[String]) => XmlDiff
2930

31+
private implicit val NodeOrdering = NormalisedNodeOrdering
32+
3033
/**
3134
* Default [[software.purpledragon.xml.compare.options.DiffOption.DiffOption DiffOption]]s to use during XML comparison.
3235
*
@@ -80,13 +83,6 @@ object XmlCompare {
8083
}
8184

8285
private def compareAttributes(left: Node, right: Node, options: DiffOptions, path: Seq[String]): XmlDiff = {
83-
def extractAttributes(node: Node): (Seq[String], Map[String, String]) = {
84-
node.attributes.foldLeft(Seq.empty[String], Map.empty[String, String]) {
85-
case ((keys, attribs), attrib) =>
86-
(keys :+ attrib.key, attribs + (attrib.key -> attrib.value.text))
87-
}
88-
}
89-
9086
val (leftKeys, leftMap) = extractAttributes(left)
9187
val (rightKeys, rightMap) = extractAttributes(right)
9288

@@ -115,8 +111,8 @@ object XmlCompare {
115111
}
116112

117113
private def compareChildren(left: Node, right: Node, options: DiffOptions, path: Seq[String]): XmlDiff = {
118-
val leftChildren = left.child.filterNot(c => c.isInstanceOf[Atom[_]])
119-
val rightChildren = right.child.filterNot(c => c.isInstanceOf[Atom[_]])
114+
val leftChildren = normalise(left.child, options)
115+
val rightChildren = normalise(right.child, options)
120116

121117
if (leftChildren.size != rightChildren.size) {
122118
XmlDiffers("different child count", leftChildren.size, rightChildren.size, extendPath(path, left))
@@ -136,4 +132,15 @@ object XmlCompare {
136132
private def extendPath(path: Seq[String], node: Node): Seq[String] = {
137133
path :+ node.nameToString(new StringBuilder()).toString
138134
}
135+
136+
private def normalise(nodes: Seq[Node], options: DiffOptions): Seq[Node] = {
137+
val sort = options.contains(DiffOption.IgnoreChildOrder)
138+
val filtered = nodes.filterNot(n => n.isInstanceOf[Atom[_]])
139+
140+
if (sort) {
141+
filtered.sorted
142+
} else {
143+
filtered
144+
}
145+
}
139146
}

xml-compare/src/main/scala/software/purpledragon/xml/compare/options/DiffOption.scala

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,24 @@ object DiffOption extends Enumeration {
6363
* }}}
6464
*/
6565
val StrictAttributeOrdering: DiffOption.Value = Value
66+
67+
/**
68+
* Ignores the ordering of XML elements.
69+
*
70+
* Enabling this makes this:
71+
* {{{
72+
* <example>
73+
* <child-1/>
74+
* <child-2/>
75+
* </example>
76+
* }}}
77+
* equal to:
78+
* {{{
79+
* <example>
80+
* <child-2/>
81+
* <child-1/>
82+
* </example>
83+
* }}}
84+
*/
85+
val IgnoreChildOrder: DiffOption.Value = Value
6686
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright 2017 Michael Stringer
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package software.purpledragon.xml
18+
19+
import org.scalatest.{FlatSpec, Matchers}
20+
21+
class XmlUtilsSpec extends FlatSpec with Matchers {
22+
"XmlUtils.extractAttributes" should "return empty values for no attributes" in {
23+
XmlUtils.extractAttributes(<empty/>) shouldBe (Nil, Map.empty[String, String])
24+
}
25+
26+
it should "return attribute names and value map" in {
27+
val (names, attributes) = XmlUtils.extractAttributes(<test field3="value-3" field1="value-1" field2="value-2"/>)
28+
29+
names shouldBe Seq("field3", "field1", "field2")
30+
attributes shouldBe Map(
31+
"field1" -> "value-1",
32+
"field2" -> "value-2",
33+
"field3" -> "value-3"
34+
)
35+
}
36+
}

0 commit comments

Comments
 (0)