Java tutorial
/** * The MIT License (MIT) * Copyright (c) 2013 Signavio, OMG BPMN Model Interchange Working Group * * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. * */ package org.omg.bpmn.miwg.util.xml.diff; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Pattern; import org.apache.commons.lang3.tuple.Pair; import org.apache.log4j.Logger; import org.custommonkey.xmlunit.Diff; import org.custommonkey.xmlunit.Difference; import org.custommonkey.xmlunit.DifferenceConstants; import org.custommonkey.xmlunit.DifferenceListener; import org.custommonkey.xmlunit.NodeDetail; import org.omg.bpmn.miwg.util.xml.XPathUtil; import org.w3c.dom.Attr; import org.w3c.dom.Document; import org.w3c.dom.NamedNodeMap; import org.w3c.dom.Node; /** * Difference listener for XMLUnit's {@link Diff} class. * Decides for each difference found, whether it actually is a valid difference concerning SVG representation of diagrams * * @author philipp.maschke * */ public abstract class AbstractXmlDifferenceListener implements DifferenceListener { //TODO rename to DIFFDocumentType public enum XmlDiffDocumentType { CONTROL, TEST } public static final String REGEXP_SIGNAVIO_ID = "sid-........-....-....-....-............"; private static final String ATTR_NULL = "null"; private static final Logger LOGGER = Logger.getLogger(AbstractXmlDifferenceListener.class); protected XmlUnitHelper helper; private Set<Node> ignoredNodes = new HashSet<Node>(); private Set<Node> ignoredNodesParents = new HashSet<Node>(); private Set<String> idsAndIdRefs = new HashSet<String>(); private Set<String> caseInsensitiveAttributes = new HashSet<String>(); private Set<String> ignoredNamespacesInAttributes = new HashSet<String>(); private Set<String> languageSpecificAttributes = new HashSet<String>(); private Map<Node, Set<String>> defaultAttributesMap = new HashMap<Node, Set<String>>(); private Map<Node, Set<String>> ignoredAttributesMap = new HashMap<Node, Set<String>>(); private Map<Node, Set<String>> optionalAttributesMap = new HashMap<Node, Set<String>>(); private Map<String, Pattern> defaultAttributeValues = new HashMap<String, Pattern>(); private int numIgnoredDiffs; private int numAcceptedDiffs; /** * Initializes the difference listener according to the configuration. * * All XPath expressions are evaluated and the returned nodes stored for * later comparison. This is necessary, because the nodes accessible through * the XmlUnit API are not comparable to the actual document nodes. That comparison, however, * is necessary to resolve differences. * * @param controlDoc * @param testDoc * @param configuration */ public void initialize(Document controlDoc, Document testDoc, XmlDiffConfiguration configuration) { LOGGER.trace("DifferenceListener Initialization started"); ignoredNodes.clear(); ignoredNodesParents.clear(); idsAndIdRefs.clear(); caseInsensitiveAttributes.clear(); ignoredNamespacesInAttributes.clear(); languageSpecificAttributes.clear(); defaultAttributesMap.clear(); ignoredAttributesMap.clear(); optionalAttributesMap.clear(); defaultAttributeValues.clear(); numIgnoredDiffs = numAcceptedDiffs = 0; helper = new XmlUnitHelper(controlDoc, testDoc, configuration.getElementsPrefixMatcher()); List<Node> tmpNodeList; // parse ignored attributes parseAttributes(configuration.getDefaultAttributes(), defaultAttributesMap); // parse ignored nodes for (String ignoredNodeXpath : configuration.getIgnoredNodes()) { tmpNodeList = helper.getAllMatchingNodesFromBothDocuments(ignoredNodeXpath); for (Node ignoredNode : tmpNodeList) { ignoredNodes.add(ignoredNode); ignoredNodesParents.add(ignoredNode.getParentNode()); } } // parse ignored attributes parseAttributes(configuration.getIgnoredAttributes(), ignoredAttributesMap); // parse optional attributes parseAttributes(configuration.getIgnoredAttributes(), optionalAttributesMap); defaultAttributeValues.putAll(configuration.getDefaultAttributeValues()); // parse case insensitive attributes for (String caseAttributeName : configuration.getCaseInsensitiveAttributeNames()) { this.caseInsensitiveAttributes.add(caseAttributeName); } // store names of id and id reference attributes for (String attrName : configuration.getIdsAndIdRefNames()) { this.idsAndIdRefs.add(attrName); } // ignored namespaces in attributes for (String ns : configuration.getIgnoredNamespacesInAttributes()) { this.ignoredNamespacesInAttributes.add(ns); } //language specific attributes for (String attrName : configuration.getLanguageSpecificAttributes()) { this.languageSpecificAttributes.add(attrName); } LOGGER.trace("DifferenceListener Initialization finished"); } private void parseAttributes(List<String> attrs, Map<Node, Set<String>> map) { List<Node> tmpNodeList; for (String attrXpath : attrs) { // split attribute name and XPath for node Pair<String, String> nodeAndAttribute = XPathUtil.splitXPathIntoNodeAndAttribute(attrXpath); tmpNodeList = helper.getAllMatchingNodesFromBothDocuments(nodeAndAttribute.getKey()); for (Node attrNode : tmpNodeList) { getAttributeSetForNode(attrNode, map).add(nodeAndAttribute.getValue()); } } } @Override public void skippedComparison(Node node, Node node1) { //do nothing } // TODO better performance @Override public int differenceFound(Difference difference) { try { if (isInIgnoredNode(difference) || isCausedByIgnoredChildNode(difference) || isCausedByIgnorableMissingId(difference) || isCausedByIgnorableDifferingId(difference) || isCausedByIgnoredAttribute(difference) || isCausedByOptionalAttribute(difference) || isCausedByIgnorableAttributeValue(difference) || isCausedByNamespaceProblems(difference) || isCausedByAdditionalNamespace(difference) || isCausedByLanguageSettings(difference) || isCausedByCapitalizationOfAttributeValue(difference) || isCausedByAddingDefaultAttribute(difference) || isRedundantSummaryError(difference) || canDifferenceBeIgnored(difference)) { numIgnoredDiffs++; return DifferenceListener.RETURN_IGNORE_DIFFERENCE_NODES_IDENTICAL; } numAcceptedDiffs++; return DifferenceListener.RETURN_ACCEPT_DIFFERENCE; } catch (Exception e) { LOGGER.error("Exception while examining difference: " + difference.toString(), e); return DifferenceListener.RETURN_ACCEPT_DIFFERENCE; } } public int getNumberOfIgnoredDifferences() { return numIgnoredDiffs; } public int getNumberOfAcceptedDifferences() { return numAcceptedDiffs; } /** * This is the place where inheriting listeners can add their own checks, if other options did not work * (implementing abstract methods, adjusting the configuration, overwriting protected 'isCausedBy...' methods) * @param difference * @return whether the given difference can be ignored */ abstract protected boolean canDifferenceBeIgnored(Difference difference); /** * Sometimes attributes can be ignored if they have specific values. Inheriting listeners can check for these attributes here. * <p/> * Examples: * <ul> * <li>SVG: transform=translate(0) * <li>SVG: transform=rotate(0) * </ul> * @param attrValue * @param attrName * @return whether the given property can be ignored (i.e. it has no impact on the semantic of the document) */ protected boolean canAttributeBeIgnored(String attrName, String attrValue) { Pattern defaultValRegex = defaultAttributeValues.get(attrName); if (defaultValRegex != null) { return defaultValRegex.matcher(attrValue).matches(); } return false; } /** * Check whether the differences between the ids from control and test document can be ignored. * @param difference * @param controlId * @param testId * @return */ abstract protected boolean canDifferingIdBeIgnored(Difference difference, String controlId, String testId); /** * Check whether a missing id (in the test document) can be ignored. * @param difference * @param controlId the id of the control document * @return */ abstract protected boolean canMissingIdBeIgnored(Difference difference, String controlId); /** * Ignores all attribute value differences for attributes marked as being language (locale) specific * @param difference * @return */ protected boolean isCausedByLanguageSettings(Difference difference) { if (difference.getId() == DifferenceConstants.ATTR_VALUE_ID && languageSpecificAttributes .contains(difference.getControlNodeDetail().getNode().getLocalName())) { return true; } return false; } /** * Checks whether a difference in attribute values is caused by variations in the capitalization of textual representation of the same value * Example: * "true" vs. "TRUE vs. "True" * * @param difference * @return */ protected boolean isCausedByCapitalizationOfAttributeValue(Difference difference) { if (difference.getId() == DifferenceConstants.ATTR_VALUE_ID) { String testValue = difference.getTestNodeDetail().getValue(); String controlValue = difference.getControlNodeDetail().getValue(); //check case-insensitive attributes String testName = difference.getTestNodeDetail().getNode().getLocalName(); if (caseInsensitiveAttributes.contains(testName) && testValue.equalsIgnoreCase(controlValue)) { return true; } } return false; } /** * Server renderer removes all attributes with no values, client renderer doesn't always do this -> * ignore missing attributes if they don't have a value. * Also ignores missing transform attributes, when editor generates a "transform='translate(0)'" * @param difference * @return */ protected boolean isCausedByIgnorableAttributeValue(Difference difference) { if (difference.getId() == DifferenceConstants.ATTR_NAME_NOT_FOUND_ID) { //name is the same for both NodeDetail detail = difference.getControlNodeDetail(); XmlDiffDocumentType type; if (detail.getValue() == null || detail.getValue().equals(ATTR_NULL)) { type = XmlDiffDocumentType.TEST; detail = difference.getTestNodeDetail(); } else { type = XmlDiffDocumentType.CONTROL; detail = difference.getControlNodeDetail(); } String attrName = detail.getValue(); if (attrName != null) { attrName = helper.addNamespacePrefixToAttribute(attrName, detail.getNode()); Node attrNode = detail.getNode().getAttributes().getNamedItem(attrName); String attrValue = (attrNode == null) ? null : attrNode.getNodeValue(); if (canAttributeBeIgnored(attrName, attrValue)) { Node node = helper.getDocumentNode(difference, type, false); getAttributeSetForNode(node, ignoredAttributesMap).add(attrName); return true; } } } return false; } /** * Editor generates different ids for some attributes. * If this difference is caused by such an attribute, the method checks whether the fixed part * corresponds to the common id structure ({@link #REGEXP_ID}) and compares the variable part for equality */ protected boolean isCausedByIgnorableDifferingId(Difference difference) { if (difference.getId() == DifferenceConstants.ATTR_VALUE_ID && idsAndIdRefs.contains(difference.getControlNodeDetail().getNode().getLocalName())) { String controlId = difference.getControlNodeDetail().getValue(); String testId = difference.getTestNodeDetail().getValue(); return canDifferingIdBeIgnored(difference, controlId, testId); } return false; } /** * Some elements don't need an 'id' attribute. The client renderer still generates one for them, the server doesn't. * Ignores differences where such an id is missing * * @param difference * @return */ protected boolean isCausedByIgnorableMissingId(Difference difference) { if (difference.getId() == DifferenceConstants.ATTR_NAME_NOT_FOUND_ID && difference.getControlNodeDetail().getValue().equals("id")) { String id = difference.getControlNodeDetail().getNode().getAttributes().getNamedItem("id") .getNodeValue(); return canMissingIdBeIgnored(difference, id); } else if (difference.getId() == DifferenceConstants.ELEMENT_NUM_ATTRIBUTES_ID) { int controlNum = Integer.valueOf(difference.getControlNodeDetail().getValue()); int testNum = Integer.valueOf(difference.getTestNodeDetail().getValue()); if (testNum == (controlNum - 1)) {// check if only 1 attribute less // in test node Node idNode = difference.getControlNodeDetail().getNode().getAttributes().getNamedItem("id"); if (idNode == null) { return false; } String id = idNode.getNodeValue(); return canMissingIdBeIgnored(difference, id); } } return false; } /** * Sometimes differences are generated, where the namespaces are the same, but the prefixes are different. * Ignores such differences * * @param difference * @return */ protected boolean isCausedByNamespaceProblems(Difference difference) { if (difference.getId() == DifferenceConstants.NAMESPACE_PREFIX_ID) { if (difference.getControlNodeDetail().getNode().getNamespaceURI() .equals(difference.getTestNodeDetail().getNode().getNamespaceURI())) { return true; } } return false; } /** * Vendors may introduce new namespaces and these should not be reported as * significant differences. * * @param difference * @return * @see https://github.com/bpmn-miwg/bpmn-miwg-tools/issues/13 */ protected boolean isCausedByAdditionalNamespace(Difference difference) { try { Attr attr = null; int len = difference.getTestNodeDetail().getNode().getAttributes().getLength(); for (int i = 0; i < len; i++) { Attr tmp = (Attr) difference.getTestNodeDetail().getNode().getAttributes().item(i); if (difference.getTestNodeDetail().getValue().equals(tmp.getLocalName())) { // this is the attribute in question attr = tmp; break; } } if (attr != null) { String uri = attr.getNamespaceURI(); if (uri != null && difference.getControlNodeDetail().getNode().getOwnerDocument() .lookupNamespaceURI(uri) == null) { // So the control document does not have this namespace // that the test doc does => OK to ignore. return true; } } } catch (NullPointerException e) { // Assume because the namespace scenario we are looking for is // not cause of difference and report it } return false; } /** * Ignores all differences caused by an ignored attribute (see {@link DiffConfiguration#getIgnoredAttributes()} * @param difference * @return */ protected boolean isCausedByIgnoredAttribute(Difference difference) { if (difference.getId() == DifferenceConstants.ATTR_NAME_NOT_FOUND_ID) { //ignore attributes belonging to a certain namespace String attrName = difference.getControlNodeDetail().getValue(); Node node; if (attrName == null || attrName.equals(ATTR_NULL)) { attrName = difference.getTestNodeDetail().getValue(); node = helper.getDocumentNode(difference, XmlDiffDocumentType.TEST, false); } else { node = helper.getDocumentNode(difference, XmlDiffDocumentType.CONTROL, false); } String namespace = helper.getNamespaceOfAttribute(attrName, node.getAttributes()); if (namespace != null && ignoredNamespacesInAttributes.contains(namespace)) { //add attribute name to ignored attributes so that a difference in attribute numbers can be caught later on getAttributeSetForNode(node, ignoredAttributesMap).add(attrName); return true; } } return isCausedByAttribute(difference, false); } /** * Ignores all differences, where an optional attribute is missing (see {@link DiffConfiguration#getOptionalAttributes()} * @param difference * @return */ protected boolean isCausedByOptionalAttribute(Difference difference) { return isCausedByAttribute(difference, true); } protected boolean isCausedByAttribute(Difference difference, boolean isOptional) { if (difference.getId() != DifferenceConstants.ATTR_NAME_NOT_FOUND_ID && difference.getId() != DifferenceConstants.ELEMENT_NUM_ATTRIBUTES_ID && difference.getId() != DifferenceConstants.HAS_CHILD_NODES_ID && difference.getId() != DifferenceConstants.ATTR_VALUE_ID) { return false; } // get the nodes that are different // don't use 'difference.getControlNodeDetail().getNode()' because it // returns a node that is not even equal to ones returned by the xPath // parser Set<String> attributeSet; Node node; for (XmlDiffDocumentType type : XmlDiffDocumentType.values()) {// test whether one of // the document // nodes contains an // ignored attribute // optional attributes must have same value, if they are set if (!isOptional && difference.getId() == DifferenceConstants.ATTR_VALUE_ID) { // xpath originally points to the actual attribute node -> // remove attribute reference from xpath node = helper.getDocumentNode(difference, type, true); if (node != null) { // String attributeName = difference.getControlNodeDetail().getNode().getNodeName(); String attributeName = XPathUtil .getAttributeNameFromXPath(difference.getControlNodeDetail().getXpathLocation()); if (getAttributeSetForNode(node, ignoredAttributesMap).contains(attributeName)) { return true; } } } node = helper.getDocumentNode(difference, type, false); if (node == null) { continue; } if (isOptional) { attributeSet = getAttributeSetForNode(node, optionalAttributesMap); } else { attributeSet = getAttributeSetForNode(node, ignoredAttributesMap); } switch (difference.getId()) { case DifferenceConstants.ATTR_NAME_NOT_FOUND_ID: String attrName = difference.getControlNodeDetail().getValue(); if (attrName == null || attrName.equals(ATTR_NULL)) { attrName = difference.getTestNodeDetail().getValue(); attrName = helper.addNamespacePrefixToAttribute(attrName, difference.getTestNodeDetail().getNode()); } else { attrName = helper.addNamespacePrefixToAttribute(attrName, difference.getControlNodeDetail().getNode()); } if (attributeSet.contains(attrName)) { return true; } break; case DifferenceConstants.ELEMENT_NUM_ATTRIBUTES_ID: case DifferenceConstants.HAS_CHILD_NODES_ID: if (attributeSet.size() > 0) { return true; } if (containsAttributesOfIgnoredNamespaces(node)) { return true; } if (containsIgnorableAttributeValues(node)) { return true; } break; } } return false; } /** * Ignores all differences caused by an ignored child node (see {@link DiffConfiguration#getIgnoredNodes()} * @param difference * @return */ protected boolean isCausedByIgnoredChildNode(Difference difference) { switch (difference.getId()) { case DifferenceConstants.CHILD_NODELIST_LENGTH_ID: case DifferenceConstants.HAS_CHILD_NODES_ID: for (XmlDiffDocumentType type : XmlDiffDocumentType.values()) { Node node = helper.getDocumentNode(difference, type, false); if (node == null) { continue; } if (ignoredNodesParents.contains(node)) { return true; } if (findCorrespondingNode(ignoredNodesParents, node) != null) { return true; } } break; case DifferenceConstants.CHILD_NODELIST_SEQUENCE_ID: for (XmlDiffDocumentType type : XmlDiffDocumentType.values()) { Node node = helper.getDocumentNode(difference, type, false); if (node == null) { continue; } if (ignoredNodesParents.contains(node.getParentNode())) { return true; } if (findCorrespondingNode(ignoredNodesParents, node.getParentNode()) != null) { return true; } } break; case DifferenceConstants.CHILD_NODE_NOT_FOUND_ID: Node node; if (difference.getControlNodeDetail().getNode() != null) { node = helper.getDocumentNode(difference, XmlDiffDocumentType.CONTROL, false); } else { node = helper.getDocumentNode(difference, XmlDiffDocumentType.TEST, false); } if (ignoredNodes.contains(node)) { return true; } } return false; } /** * Ignores all differences when the test model has added optional attributes * missing from the reference. * * @param difference * @return */ protected boolean isCausedByAddingDefaultAttribute(Difference difference) { if (difference.getId() == DifferenceConstants.ATTR_NAME_NOT_FOUND_ID) { // ignore attributes belonging to a certain namespace String attrName = difference.getControlNodeDetail().getValue(); String testAttrName = difference.getTestNodeDetail().getValue(); Node node; if (attrName == null || attrName.equals(ATTR_NULL)) { node = helper.getDocumentNode(difference, XmlDiffDocumentType.TEST, false); } else { node = helper.getDocumentNode(difference, XmlDiffDocumentType.CONTROL, false); } Set<String> defaultedAttributesForNode = getAttributeSetForNode(node, defaultAttributesMap); for (String string : defaultedAttributesForNode) { if (testAttrName.equals(string)) { return true; } } } return false; } /** * Ignore an error that summarises errors in children that are reported * separately. * * @see https://github.com/bpmn-miwg/bpmn-miwg-tools/issues/10 * @param difference * @return */ protected boolean isRedundantSummaryError(Difference difference) { if ("number of element attributes".equals(difference.getDescription())) { return true; } return false; } /** * Ignores all differences within an ignored node (see {@link DiffConfiguration#getIgnoredNodes()} * @param difference * @return */ protected boolean isInIgnoredNode(Difference difference) { Node controlNode = helper.getDocumentNode(difference, XmlDiffDocumentType.CONTROL, false); if (controlNode == null) { return false; } else { Node current = controlNode; while (current != null) { if (ignoredNodes.contains(current)) { return true; } // else, try to find a node thats equal to current if (findCorrespondingNode(ignoredNodes, current) != null) { return true; } // if no equal node found continue with parent if (current instanceof Attr) { current = ((Attr) current).getOwnerElement(); } else { current = current.getParentNode(); } } } return false; // add same test for testNode if blacklisted nodes are not ignored } // ************************************************************************************** // Helper methods // ************************************************************************************** private boolean containsAttributesOfIgnoredNamespaces(Node node) { NamedNodeMap attrs = node.getAttributes(); if (attrs == null) { return false; } String ns = null; for (int i = 0; i < attrs.getLength(); i++) { ns = attrs.item(i).getNamespaceURI(); if (ns != null && ignoredNamespacesInAttributes.contains(ns)) { return true; } } return false; } private boolean containsIgnorableAttributeValues(Node node) { NamedNodeMap attrs = node.getAttributes(); if (attrs == null) { return false; } for (int i = 0; i < attrs.getLength(); i++) { Attr attr = (Attr) attrs.item(i); if (isEmptyOrIgnorableAttributeValue(attr.getValue(), attr.getName())) { return true; } } return false; } private boolean isEmptyOrIgnorableAttributeValue(String attrValue, String attrName) { return attrValue == null || attrValue.trim().equals("") || canAttributeBeIgnored(attrName, attrValue); } private Set<String> getAttributeSetForNode(Node node, Map<Node, Set<String>> nodeToAttributesMap) { Set<String> attributeNames = nodeToAttributesMap.get(node); if (attributeNames == null) { Node corresponding = findCorrespondingNode(nodeToAttributesMap, node); if (corresponding != null) { attributeNames = nodeToAttributesMap.get(corresponding); } if (attributeNames == null) { attributeNames = new HashSet<String>(); nodeToAttributesMap.put(node, attributeNames); } } return attributeNames; } private Node findCorrespondingNode(Map<Node, Set<String>> map, Node node) { // try to find existing node that corresponds to this one // using Node.isEqual(Node), don't use Node.isSimilar(Node)! for (Node existing : map.keySet()) { if (node.isEqualNode(existing)) { // exchange the new node for the old one to prevent additional // searches for the same node Set<String> attributeNames = map.remove(existing); map.put(node, attributeNames); return node; } } return null; } private Node findCorrespondingNode(Set<Node> set, Node node) { for (Node existing : set) { if (node.isEqualNode(existing)) { // exchange the new node for the old one to prevent // additional searches for the same node set.remove(existing); set.add(node); return node; } } return null; } }