Skip to content

Commit

Permalink
Add option to ignore child ordering in comparisons (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
stringbean authored Oct 1, 2019
1 parent 324cc40 commit 571701c
Show file tree
Hide file tree
Showing 9 changed files with 421 additions and 22 deletions.
1 change: 1 addition & 0 deletions project/plugins.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ addSbtPlugin("com.eed3si9n" % "sbt-unidoc" % "0.4.2")
addSbtPlugin("com.typesafe.sbt" % "sbt-ghpages" % "0.6.3")
addSbtPlugin("com.typesafe.sbt" % "sbt-site" % "1.4.0")
addSbtPlugin("com.lightbend.paradox" % "sbt-paradox" % "0.6.5")
addSbtPlugin("com.thoughtworks.sbt-api-mappings" % "sbt-api-mappings" % "3.0.0")
49 changes: 48 additions & 1 deletion src/main/paradox/comparing-xml.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,51 @@ XmlDiffers(
Seq("second", "first"),
Seq("test")
)
```
```

### IgnoreChildOrder

If enabled the ordering of child elements will be ignored. This is handled by re-ordering child nodes using an arbitrary
sorting algorithm before comparing them.

_Note: the first difference returned may be different if this option is enabled._

#### Example 1

This:

```xml
<example>
<child-1/>
<child-2/>
</example>
```

would be considered equal to:
```xml
<example>
<child-2/>
<child-1/>
</example>
```

#### Example 2

This:

```xml
<example>
<child-1 attribute1="value-1" attribute2="value-2"/>
<child-2 attribute="something"/>
</example>
```

would be considered equal to:
```xml
<example>
<child-2 attribute="something"/>
<child-1 attribute2="value-2" attribute1="value-1" />
</example>
```

_(The ordering of nodes and attributes are both ignored)_
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Copyright 2017 Michael Stringer
*
* 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 software.purpledragon.xml

import scala.xml.Node

/**
* Utilities for dealing with XML.
*/
object XmlUtils {

/**
* Extracts names of attributes and a map of attributes from an XML [[scala.xml.Node Node]].
*
* @param node the XML node to extract attributes for.
* @return a sequence of attribute names and a map of attribute values.
*/
def extractAttributes(node: Node): (Seq[String], Map[String, String]) = {
node.attributes.foldLeft(Seq.empty[String], Map.empty[String, String]) {
case ((keys, attribs), attrib) =>
(keys :+ attrib.key, attribs + (attrib.key -> attrib.value.text))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright 2017 Michael Stringer
*
* 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 software.purpledragon.xml.compare

import software.purpledragon.xml.XmlUtils.extractAttributes

import scala.xml._

/**
* Arbitrary ordering for XML nodes used to normalise XML when we are ignoring child ordering.
*/
private[compare] object NormalisedNodeOrdering extends Ordering[Node] {
private def typeToOrdering(node: Node): Int = {
node match {
case _: Elem => 1
case _: Text => 2
case _: PCData => 3
case _: Comment => 4
}
}

override def compare(x: Node, y: Node): Int = {
(x, y) match {
case (xe: Elem, ye: Elem) =>
val labelOrder = xe.label compareTo ye.label

if (labelOrder != 0) {
labelOrder
} else {
val (xAttributeNames, xAttributes) = extractAttributes(xe)
val (yAttributeNames, yAttributes) = extractAttributes(ye)

// order by attribute count
val attributeSizeOrder = xAttributeNames.size compareTo yAttributeNames.size

if (attributeSizeOrder != 0) {
attributeSizeOrder
} else {
// compare attribute names
val attributeNamesOrder = xAttributeNames.sorted zip yAttributeNames.sorted map {
case (x, y) => x compareTo y
}

// take first difference
attributeNamesOrder.find(_ != 0) match {
case Some(v) =>
v
case None =>
// if not compare values
val attributeValuesOrder = xAttributeNames map { name =>
xAttributes(name) compareTo yAttributes(name)
}

attributeValuesOrder.find(_ != 0).getOrElse(0)
}
}
}

case (xe: Text, ye: Text) =>
xe.text compareTo ye.text

case (xe: PCData, ye: PCData) =>
xe.data compareTo ye.data

case (xe: Comment, ye: Comment) =>
xe.commentText compareTo ye.commentText

case _ =>
// different types - order by type
typeToOrdering(x) compareTo typeToOrdering(y)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,20 @@

package software.purpledragon.xml.compare

import software.purpledragon.xml.XmlUtils.extractAttributes
import software.purpledragon.xml.compare.options.DiffOption._
import software.purpledragon.xml.compare.options.DiffOptions
import software.purpledragon.xml.compare.options.{DiffOption, DiffOptions}

import scala.xml.{Atom, Node}
import scala.xml._

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

private implicit val NodeOrdering = NormalisedNodeOrdering

/**
* Default [[software.purpledragon.xml.compare.options.DiffOption.DiffOption DiffOption]]s to use during XML comparison.
*
Expand Down Expand Up @@ -80,13 +83,6 @@ object XmlCompare {
}

private def compareAttributes(left: Node, right: Node, options: DiffOptions, path: Seq[String]): XmlDiff = {
def extractAttributes(node: Node): (Seq[String], Map[String, String]) = {
node.attributes.foldLeft(Seq.empty[String], Map.empty[String, String]) {
case ((keys, attribs), attrib) =>
(keys :+ attrib.key, attribs + (attrib.key -> attrib.value.text))
}
}

val (leftKeys, leftMap) = extractAttributes(left)
val (rightKeys, rightMap) = extractAttributes(right)

Expand Down Expand Up @@ -115,8 +111,8 @@ object XmlCompare {
}

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

if (leftChildren.size != rightChildren.size) {
XmlDiffers("different child count", leftChildren.size, rightChildren.size, extendPath(path, left))
Expand All @@ -136,4 +132,15 @@ object XmlCompare {
private def extendPath(path: Seq[String], node: Node): Seq[String] = {
path :+ node.nameToString(new StringBuilder()).toString
}

private def normalise(nodes: Seq[Node], options: DiffOptions): Seq[Node] = {
val sort = options.contains(DiffOption.IgnoreChildOrder)
val filtered = nodes.filterNot(n => n.isInstanceOf[Atom[_]])

if (sort) {
filtered.sorted
} else {
filtered
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,24 @@ object DiffOption extends Enumeration {
* }}}
*/
val StrictAttributeOrdering: DiffOption.Value = Value

/**
* Ignores the ordering of XML elements.
*
* Enabling this makes this:
* {{{
* <example>
* <child-1/>
* <child-2/>
* </example>
* }}}
* equal to:
* {{{
* <example>
* <child-2/>
* <child-1/>
* </example>
* }}}
*/
val IgnoreChildOrder: DiffOption.Value = Value
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright 2017 Michael Stringer
*
* 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 software.purpledragon.xml

import org.scalatest.{FlatSpec, Matchers}

class XmlUtilsSpec extends FlatSpec with Matchers {
"XmlUtils.extractAttributes" should "return empty values for no attributes" in {
XmlUtils.extractAttributes(<empty/>) shouldBe (Nil, Map.empty[String, String])
}

it should "return attribute names and value map" in {
val (names, attributes) = XmlUtils.extractAttributes(<test field3="value-3" field1="value-1" field2="value-2"/>)

names shouldBe Seq("field3", "field1", "field2")
attributes shouldBe Map(
"field1" -> "value-1",
"field2" -> "value-2",
"field3" -> "value-3"
)
}
}
Loading

0 comments on commit 571701c

Please sign in to comment.