diff --git a/project/plugins.sbt b/project/plugins.sbt index 94168e4..75f26a9 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -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") diff --git a/src/main/paradox/comparing-xml.md b/src/main/paradox/comparing-xml.md index 0f82ff6..d5dbe73 100644 --- a/src/main/paradox/comparing-xml.md +++ b/src/main/paradox/comparing-xml.md @@ -124,4 +124,51 @@ XmlDiffers( Seq("second", "first"), Seq("test") ) -``` \ No newline at end of file +``` + +### 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 + + + + +``` + +would be considered equal to: +```xml + + + + +``` + +#### Example 2 + +This: + +```xml + + + + +``` + +would be considered equal to: +```xml + + + + +``` + +_(The ordering of nodes and attributes are both ignored)_ \ No newline at end of file diff --git a/xml-compare/src/main/scala/software/purpledragon/xml/XmlUtils.scala b/xml-compare/src/main/scala/software/purpledragon/xml/XmlUtils.scala new file mode 100644 index 0000000..649a367 --- /dev/null +++ b/xml-compare/src/main/scala/software/purpledragon/xml/XmlUtils.scala @@ -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)) + } + } +} diff --git a/xml-compare/src/main/scala/software/purpledragon/xml/compare/NormalisedNodeOrdering.scala b/xml-compare/src/main/scala/software/purpledragon/xml/compare/NormalisedNodeOrdering.scala new file mode 100644 index 0000000..03f51e7 --- /dev/null +++ b/xml-compare/src/main/scala/software/purpledragon/xml/compare/NormalisedNodeOrdering.scala @@ -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) + } + } +} diff --git a/xml-compare/src/main/scala/software/purpledragon/xml/compare/XmlCompare.scala b/xml-compare/src/main/scala/software/purpledragon/xml/compare/XmlCompare.scala index 4fe8c16..36e2e77 100644 --- a/xml-compare/src/main/scala/software/purpledragon/xml/compare/XmlCompare.scala +++ b/xml-compare/src/main/scala/software/purpledragon/xml/compare/XmlCompare.scala @@ -16,10 +16,11 @@ 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. @@ -27,6 +28,8 @@ import scala.xml.{Atom, Node} 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. * @@ -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) @@ -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)) @@ -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 + } + } } diff --git a/xml-compare/src/main/scala/software/purpledragon/xml/compare/options/DiffOption.scala b/xml-compare/src/main/scala/software/purpledragon/xml/compare/options/DiffOption.scala index 80960f0..669fed1 100644 --- a/xml-compare/src/main/scala/software/purpledragon/xml/compare/options/DiffOption.scala +++ b/xml-compare/src/main/scala/software/purpledragon/xml/compare/options/DiffOption.scala @@ -63,4 +63,24 @@ object DiffOption extends Enumeration { * }}} */ val StrictAttributeOrdering: DiffOption.Value = Value + + /** + * Ignores the ordering of XML elements. + * + * Enabling this makes this: + * {{{ + * + * + * + * + * }}} + * equal to: + * {{{ + * + * + * + * + * }}} + */ + val IgnoreChildOrder: DiffOption.Value = Value } diff --git a/xml-compare/src/test/scala/software/purpledragon/xml/XmlUtilsSpec.scala b/xml-compare/src/test/scala/software/purpledragon/xml/XmlUtilsSpec.scala new file mode 100644 index 0000000..3c876e4 --- /dev/null +++ b/xml-compare/src/test/scala/software/purpledragon/xml/XmlUtilsSpec.scala @@ -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() shouldBe (Nil, Map.empty[String, String]) + } + + it should "return attribute names and value map" in { + val (names, attributes) = XmlUtils.extractAttributes() + + names shouldBe Seq("field3", "field1", "field2") + attributes shouldBe Map( + "field1" -> "value-1", + "field2" -> "value-2", + "field3" -> "value-3" + ) + } +} diff --git a/xml-compare/src/test/scala/software/purpledragon/xml/compare/NormalisedNodeOrderingSpec.scala b/xml-compare/src/test/scala/software/purpledragon/xml/compare/NormalisedNodeOrderingSpec.scala new file mode 100644 index 0000000..508a811 --- /dev/null +++ b/xml-compare/src/test/scala/software/purpledragon/xml/compare/NormalisedNodeOrderingSpec.scala @@ -0,0 +1,110 @@ +/* + * 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 org.scalatest.{FlatSpec, Matchers} + +import scala.xml.{Comment, Node, PCData, Text} + +class NormalisedNodeOrderingSpec extends FlatSpec with Matchers { + private implicit val ordering: Ordering[Node] = NormalisedNodeOrdering + + "NormalisedNodeOrdering" should "order nodes by type" in { + val nodes = Seq[Node]( + Comment("commented"), + Text("textual"), + PCData("data"), + + ) + + nodes.sorted shouldBe Seq( + , + Text("textual"), + PCData("data"), + Comment("commented") + ) + } + + it should "order Elems firstly by label" in { + val nodes = Seq[Node]( + , + + ) + + nodes.sorted shouldBe Seq( + , + + ) + } + + it should "order Elems secondly by attribute count" in { + val nodes = Seq[Node]( + , + , + + ) + + nodes.sorted shouldBe Seq( + , + , + + ) + } + + it should "order Elems thirdly by attribute names" in { + val nodes = Seq[Node]( + , + , + + ) + + nodes.sorted shouldBe Seq( + , + , + + ) + } + + it should "order Elems finally by attribute values" in { + val nodes = Seq[Node]( + , + , + + ) + + nodes.sorted shouldBe Seq( + , + , + + ) + } + + it should "order Text nodes" in { + val nodes = Seq[Node](Text("banana"), Text("apple"), Text("cherry")) + nodes.sorted shouldBe Seq(Text("apple"), Text("banana"), Text("cherry")) + } + + it should "order PCData nodes" in { + val nodes = Seq[Node](PCData("banana"), PCData("apple"), PCData("cherry")) + nodes.sorted shouldBe Seq(PCData("apple"), PCData("banana"), PCData("cherry")) + } + + it should "order Comment nodes" in { + val nodes = Seq[Node](Comment("banana"), Comment("apple"), Comment("cherry")) + nodes.sorted shouldBe Seq(Comment("apple"), Comment("banana"), Comment("cherry")) + } +} diff --git a/xml-compare/src/test/scala/software/purpledragon/xml/compare/XmlCompareSpec.scala b/xml-compare/src/test/scala/software/purpledragon/xml/compare/XmlCompareSpec.scala index e77f58a..d498b9d 100644 --- a/xml-compare/src/test/scala/software/purpledragon/xml/compare/XmlCompareSpec.scala +++ b/xml-compare/src/test/scala/software/purpledragon/xml/compare/XmlCompareSpec.scala @@ -131,6 +131,13 @@ class XmlCompareSpec extends FlatSpec with Matchers { ) } + it should "not-match XML with children in different order with attributes" in { + XmlCompare.compare( + , + + ) shouldBe XmlDiffers("different value for attribute 'value'", "a", "b", Seq("test", "child")) + } + it should "not-match with multiple errors" in { XmlCompare.compare( text-1text-2, @@ -142,41 +149,87 @@ class XmlCompareSpec extends FlatSpec with Matchers { } "compare without IgnoreNamespacePrefix" should "not-match different namespace prefix" in { - XmlCompare.compare(, , Set.empty) shouldBe XmlDiffers( - "different namespace prefix", - "t", - "e", - Seq("t:test")) + XmlCompare.compare( + , + , + Set.empty + ) shouldBe XmlDiffers("different namespace prefix", "t", "e", Seq("t:test")) } it should "match same namespaces" in { - XmlCompare.compare(, , Set.empty) shouldBe XmlEqual + XmlCompare.compare( + , + , + Set.empty + ) shouldBe XmlEqual } "compare with IgnoreNamespace" should "match different namespace prefix" in { XmlCompare.compare( , , - Set(IgnoreNamespace)) shouldBe XmlEqual + Set(IgnoreNamespace) + ) shouldBe XmlEqual } it should "match different namespace" in { XmlCompare.compare( , , - Set(IgnoreNamespace)) shouldBe XmlEqual + Set(IgnoreNamespace) + ) shouldBe XmlEqual } "compare with StrictAttributeOrder" should "match with same attributes" in { - XmlCompare.compare(, , Set(StrictAttributeOrdering)) shouldBe XmlEqual + XmlCompare.compare( + , + , + Set(StrictAttributeOrdering) + ) shouldBe XmlEqual } it should "not-match with attributes in different order" in { - XmlCompare.compare(, , Set(StrictAttributeOrdering)) shouldBe XmlDiffers( + XmlCompare.compare( + , + , + Set(StrictAttributeOrdering) + ) shouldBe XmlDiffers( "different attribute ordering", Seq("first", "second"), Seq("second", "first"), Seq("test") ) } + + "compare with IgnoreChildOrder" should "match XML with children in same order" in { + XmlCompare.compare( + , + , + Set(IgnoreChildOrder) + ) shouldBe XmlEqual + } + + it should "match XML with children in different order" in { + XmlCompare.compare( + , + , + Set(IgnoreChildOrder) + ) shouldBe XmlEqual + } + + it should "match XML with children in different order with attributes" in { + XmlCompare.compare( + , + , + Set(IgnoreChildOrder) + ) shouldBe XmlEqual + } + + it should "match XML with children in different order with multiple attributes" in { + XmlCompare.compare( + , + , + Set(IgnoreChildOrder) + ) shouldBe XmlEqual + } }