├── .gitignore ├── LICENSE ├── README.md ├── XPath.cls └── XPath_Test.cls /.gitignore: -------------------------------------------------------------------------------- 1 | apex-xpath.sublime-* 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Jennifer Simonds 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # apex-xpath 2 | A utility class for Salesforce Apex that lets you easily query an XML DOM structure using a simple subset of XPath syntax. 3 | 4 | ## To Use 5 | 1. Create a new XPath object from an XML string, or if you've already created a Dom.Document then pass that to the constructor. 6 | 7 | 1. Call find, findFirst, getText, or getTextList, passing it an XPath expression and optionally a node from the DOM to start the search. (The default is to start the search at the root element). 8 | 9 | ## XPath Syntax Supported 10 | Apex-xpath supports a simple subset of XPath 1.0 "abbreviated" syntax. 11 | 12 | An xpath consists of one or more location steps. Each location step starts with a "/" (except the initial step, if it's a relative path), a "node test", and an optional "predicate": 13 | 14 | A **node test** is the part that specifies which tagnames to look for at a given point in the path: 15 | * `/tagname` The most common test is a plain tagname. 16 | * `/*` A tagname can be "*", meaning any immediate child. 17 | * `/namespace:tagname` You can include a namespace in the node test. 18 | * `/tagname/tagname` Paths can be up to 50 levels deep. (Limitation of Dom.Document class) 19 | * `./child/grandchild` A tagname of "." refers to the current element. 20 | * `../sibling/nephew`, `../../aunt/cousin` A tagname of ".." refers to the current element's parent. Useful for getting siblings, cousins, etc. 21 | 22 | A **predicate** is a filter expression inside "[ ]": 23 | * `/tagname[1]` Filter the results of the node test by "nth-result" (using 1-relative index) 24 | * `/tagname[@id]` Filter by "has this attribute" 25 | * `/tagname[@id=12345]` Filter by "attribute x = value" 26 | * `/tagname[@id="12345"]` Filter by "attribute x = value" 27 | 28 | ## Example 29 | String xml = some huge SOAP response containing orders; 30 | XPath xp = new XPath(xml); 31 | Dom.XmlNode[] orders = xp.find('/soapenv:Envelope/soapenv:Body/searchOrdersResponse/orders/order'); 32 | 33 | // If an order contains line items, here are a couple different ways to get their SKUs... 34 | for (Dom.XmlNode order : orders) { 35 | String[] Skus = xp.getTextList(order, 'lineitem/id'); 36 | String SkuCsv = xp.getText(order, ',', 'lineItem/id'); 37 | } 38 | 39 | ## The Properties 40 | **`Dom.Document doc`** 41 | 42 | The Dom.Document that was passed in or that we created from the XML source. 43 | 44 | **`Dom.XmlNode root`** 45 | 46 | The root node in the Dom.Document. 47 | 48 | ## The Methods 49 | In all the methods below that take an optional `start` parameter, the default is to start the search at the root node. 50 | 51 | **`XPath (String xmlsrc)`** 52 | 53 | Creates an XPath object from a snippet of XML. 54 | 55 | **`XPath (Dom.Document doc)`** 56 | 57 | Creates an XPath object from a Dom.Document object that you've already created. 58 | 59 | **`Dom.XmlNode[] find (String path)`** 60 | 61 | **`Dom.XmlNode[] find (Dom.XmlNode start, String path)`** 62 | 63 | Returns a List of all the nodes that are described by the path. 64 | 65 | **`Dom.XmlNode findFirst (String path)`** 66 | 67 | **`Dom.XmlNode findFirst (Dom.XmlNode start, String path)`** 68 | 69 | Returns the first node in the DOM that is described by the path. 70 | 71 | **`String getText (String path)`** 72 | 73 | **`String getText (Dom.XmlNode start, String path)`** 74 | 75 | Returns the string content of the first node in the DOM that is described by the path. 76 | 77 | **`String getText (String delimiter, String path)`** 78 | 79 | **`String getText (Dom.XmlNode start, String delimiter, String path)`** 80 | 81 | Returns the string contents of all the nodes that are described by the path, separated by the delimiter. 82 | 83 | **`String[] getTextList (String path)`** 84 | 85 | **`String[] getTextList (Dom.XmlNode start, String path)`** 86 | 87 | Returns the string contents of all the nodes that are described by the path, in a List. 88 | 89 | ## Submitting Pull Requests 90 | If you've fixed a bug or came up with a great addition to the API, I welcome your code submissions! To make things smoother, please follow these guidelines: 91 | 92 | - Create an Issue for the bug you're fixing or feature you're adding, if there isn't one already. This is especially important if you're adding a new feature or changing the API - the userbase needs to be able to discuss it first! 93 | - If someone is proposing a new feature or a change to the existing API, please chime in in the comments for that issue. It's important that the library evolves in a way that the userbase feels is useful and straightforward to use. 94 | - The default branch to submit pull requests to will normally be the current version under development. We'll try to release updates to Master in a controlled manner. 95 | - So far the project's coding conventions are rather straightforward: 96 | - K&R bracing (only because it's so ubiquitous, not because it's any good!). 97 | - Use the "[]" style when declaring Lists. 98 | - For each new method, write a comment block with a short description of what it does, what its parameters mean and their defaults, what it returns, and any caveats/gotchas/assumptions that the caller should know about. 99 | - If you're updating a method please review the comment block & update it if appropriate. 100 | -------------------------------------------------------------------------------- /XPath.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * A utility class that lets you easily query an XML DOM structure using a simple subset of XPath syntax. 3 | * 4 | * TO USE: 5 | * Create a new XPath object from an XML string, or create an XML DOM structure using Dom.Document 6 | * and pass that to the constructor. 7 | * Call find, findFirst, getText, or getTextList, passing it a node from the DOM (usually the root 8 | * element) and an XPath expression. 9 | * 10 | * 11 | * XPATH SYNTAX SUPPORTED: 12 | * "Node tests" are the part that specifies which tagnames to look for at a given point in the path: 13 | * mychild/mygrandchild Relative paths (relative to the specified node) 14 | * /tagname Absolute paths (searches from the root of the tree that the specified node is in) 15 | * /* A tagname can be "*", meaning any immediate child. 16 | * /namespace:tagname You can include a namespace in the node test. 17 | * /tagname/tagname Paths can be up to 50 levels deep. (Limitation of Dom.Document class) 18 | * ./child/grandchild A tagname of "." refers to the current element. (Not sure if this would even have an effect) 19 | * ../sibling/nephew A tagname of ".." refers to the current element's parent. Useful for getting siblings, cousins, etc. 20 | * ../../aunt/cousin 21 | * 22 | * "Predicates" are filter expressions inside "[ ]" which are supported at each level in the path: 23 | * /tagname[1] At any step in the path, filter by "nth-result" (using 1-relative index) 24 | * /tagname[@id] Filter by "has this attribute" 25 | * /tagname[@id=12345] Filter by "attribute x = value" 26 | * /tagname[@id="12345"] Filter by "attribute x = value" 27 | * 28 | * EXAMPLE: 29 | * XPath xp = new XPath(xml); 30 | * Dom.XmlNode entRoot = xp.findFirst(xp.root, '/soapenv:Envelope/soapenv:Body/searchEntitlementResponse'); 31 | * Dom.XmlNode status = xp.findFirst(entRoot, 'entitlement/status'); 32 | * Dom.XmlNode ent = xp.findFirst(entRoot, 'entitlement/simpleEntitlement'); 33 | * Dom.XmlNode[] lineItems = xp.find(ent, 'lineItems'); 34 | * String activationIdCSV = xp.getText(lineItems, ',', activationId/id'); 35 | * for (Dom.XmlNode lineItem : lineItems) { 36 | * String activationId = xp.getText(lineItem, 'activationId/id'); 37 | * } 38 | * 39 | * @author Jennifer Simonds 40 | * @version 1.0.0 41 | * @copyright 2015 Jennifer Simonds 42 | * @license MIT License http://opensource.org/licenses/MIT 43 | */ 44 | public class XPath 45 | { 46 | // The different types of predicate expressions. 47 | private static final Integer PRED_TYPE_INVALID = 0; 48 | private static final Integer PRED_TYPE_NONE = 1; 49 | private static final Integer PRED_TYPE_INDEX = 2; 50 | private static final Integer PRED_TYPE_HAS_ATTR = 3; 51 | private static final Integer PRED_TYPE_ATTR_VALUE = 4; 52 | 53 | // Regexes for parsing the parts of a pathnode. 54 | private static final String rxSlashes = '\\/{0,2}'; // Starts with zero, 1, or 2 slashes. 55 | private static final String rxNodeTest = '[^\\s^\\[^\\/]+'; // Nonblank chars before the next [ or / 56 | private static final String rxPredicate = '(\\[[^\\]]+\\])?'; // [anything] 57 | 58 | public Dom.Document doc {get; private set; } 59 | public Dom.XmlNode root {get; private set; } 60 | 61 | private PathNode[] compiledPath; 62 | 63 | private static Map cache = new Map(); 64 | 65 | 66 | /** 67 | * Constructs the XPath object from an XML source. 68 | * 69 | * @param xml Some XML that we want to parse. 70 | */ 71 | public XPath (String xml) { 72 | this.doc = new Dom.Document(); 73 | this.doc.load(xml); 74 | this.root = this.doc.getRootElement(); 75 | } 76 | 77 | 78 | /** 79 | * Constructs the XPath object from an existing Dom.Document. 80 | * 81 | * @param doc A Dom.Document that we've already created from some XML source. 82 | */ 83 | public XPath (Dom.Document doc) { 84 | this.doc = doc; 85 | this.root = this.doc.getRootElement(); 86 | } 87 | 88 | 89 | /** 90 | * Parses an XPath and uses it to search the DOM, returning the string inside the first 91 | * matching element it finds. 92 | * 93 | * @param startNode Which element to use as the path expression's starting point. (Default = rootnode) 94 | * @param path The XPath expression that describes which element we're looking for. 95 | * 96 | * @return String The text inside the first matching node. 97 | */ 98 | public String getText (String path) { 99 | return this.getText(this.root, path); 100 | } 101 | 102 | 103 | public String getText (Dom.XmlNode startNode, String path) { 104 | Dom.XmlNode[] nodes = this.find(startNode, path); 105 | String ret = null; 106 | 107 | if (nodes.size() > 0) { 108 | ret = nodes[0].getText(); 109 | } 110 | 111 | return ret; 112 | } 113 | 114 | 115 | /** 116 | * Parses an XPath and uses it to search the DOM for one or more elements, returning the text 117 | * inside them as a delimited-list string. 118 | * 119 | * @param startNode Which element to use as the path expression's starting point. (Default = rootnode) 120 | * @param delimiter Which character to use to separate the result strings. 121 | * @param path The XPath expression that describes which elements we're looking for. 122 | * 123 | * @return String The text inside the resulting nodes, separated by the delimiter. 124 | */ 125 | public String getText (String delimiter, String path) { 126 | return this.getText (this.root, delimiter, path); 127 | } 128 | 129 | 130 | public String getText (Dom.XmlNode startNode, String delimiter, String path) { 131 | Dom.XmlNode[] nodes = this.find(startNode, path); 132 | String[] strings = new String[0]; 133 | String ret = null; 134 | 135 | for (Dom.XmlNode node : nodes) { 136 | strings.add(node.getText()); 137 | } 138 | 139 | ret = String.join(strings, delimiter); 140 | 141 | return ret; 142 | } 143 | 144 | 145 | /** 146 | * Parses an XPath and uses it to search the DOM for one or more elements, returning a List of 147 | * the strings inside the elements. 148 | * 149 | * @param startNode Which element to use as the path expression's starting point. (Default = rootnode) 150 | * @param path The XPath expression that describes which elements we're looking for. 151 | * 152 | * @return String[] The text inside the resulting nodes. 153 | */ 154 | public String[] getTextList (String path) { 155 | return this.getTextList(this.root, path); 156 | } 157 | 158 | 159 | public String[] getTextList (Dom.XmlNode startNode, String path) { 160 | Dom.XmlNode[] nodes = this.find(startNode, path); 161 | String[] ret = new String[0]; 162 | 163 | for (Dom.XmlNode node : nodes) { 164 | ret.add(node.getText()); 165 | } 166 | 167 | return ret; 168 | } 169 | 170 | 171 | /** 172 | * Parses an XPath and uses it to search the DOM for the first matching element. 173 | * 174 | * @param startNode Which element to use as the path expression's starting point. (Default = rootnode) 175 | * @param path The XPath expression that describes which nodes we're looking for. 176 | * 177 | * @return Dom.XmlNode The first node that matches the expression, else null if none matched. 178 | */ 179 | public Dom.XmlNode findFirst (String path) { 180 | return this.findFirst(this.root, path); 181 | } 182 | 183 | 184 | public Dom.XmlNode findFirst (Dom.XmlNode startNode, String path) { 185 | Dom.XmlNode[] nodes = this.find(startNode, path); 186 | 187 | Dom.XmlNode ret = null; 188 | if (nodes.size() > 0) { 189 | ret = nodes[0]; 190 | } 191 | 192 | return ret; 193 | } 194 | 195 | 196 | /** 197 | * Parses an XPath and uses it to search the DOM for one or more elements. 198 | * 199 | * @param startNode Which element to use as the path expression's starting point. (Default = rootnode) 200 | * @param path The XPath expression that describes which nodes we're looking for. 201 | * 202 | * @return Dom.XmlNode[] Zero or more nodes that matches the expression. 203 | */ 204 | public Dom.XmlNode[] find (String path) { 205 | return this.find(this.root, path); 206 | } 207 | 208 | 209 | public Dom.XmlNode[] find (Dom.XmlNode startNode, String path) { 210 | Boolean childrenOnly = true; 211 | 212 | PathNode[] compiledPath = new PathNode[0]; 213 | 214 | Dom.XmlNode[] currNodes = new Dom.XmlNode[0]; 215 | Dom.XmlNode[] newNodes = new Dom.XmlNode[0]; 216 | 217 | 218 | // Sanity checks. 219 | if (startNode == null || String.isEmpty(path)) { 220 | return currNodes; 221 | } 222 | 223 | 224 | // Determine which node the path starts from. An xpath that starts with "/" or "//" is an 225 | // absolute path which starts from the doc's root, else the search starts from the specified node. 226 | if (path.startsWith('/')) { 227 | Dom.XmlNode root = this.doc.getRootElement(); 228 | if (root == null) { 229 | return currNodes; 230 | } 231 | else { 232 | currNodes.add(root); 233 | } 234 | } 235 | else { 236 | currNodes.add(startNode); 237 | } 238 | 239 | 240 | // See if the xpath has already been compiled. If not, compile it now and add it to the cache. 241 | if (this.isPathCached(path)) { 242 | compiledPath = XPath.cache.get(path); 243 | } 244 | else { 245 | compiledPath = this.compile(path); 246 | XPath.cache.put(path, compiledPath); 247 | } 248 | 249 | 250 | // Given the compiled path, iterate its List of nodes and process them against the actual DOM tree. 251 | Boolean is1stPathNode = true; 252 | for (PathNode compiledPathNode : compiledPath) { 253 | // For each pathnode we process, we end up with a list of nodes (currNodes) that are candidates 254 | // for fulfilling the xpath. 255 | newNodes.clear(); 256 | for (Dom.XmlNode node : currNodes) { 257 | if (is1stPathNode && compiledPathNode.numSlashes == 1) { 258 | // We're at the first pathnode in the path, and it started with a "/". 259 | // So this pathnode is referring to the root element. 260 | newNodes.addAll(this.processNode(node, compiledPathNode)); 261 | } 262 | else if (compiledPathNode.nodeTest.isDot()) { 263 | newNodes.add(node); 264 | } 265 | else if (compiledPathNode.nodeTest.isDoubleDot()) { 266 | if (node.getParent() != null) { 267 | newNodes.add(node.getParent()); 268 | } 269 | } 270 | else if (compiledPathNode.numSlashes <= 1) { 271 | // We're at the first pathnode and it did not start with a slash, or we're 272 | // past the first pathnode. Either way this pathnode is referring to this 273 | // element's children. 274 | newNodes.addAll(this.processChildren(node, compiledPathNode)); 275 | } 276 | else { // Starts with // 277 | System.debug('XPath - // is not supported'); 278 | break; 279 | } 280 | } 281 | 282 | // In some cases we can end up with duplicate nodes in our candidate list. So we de-dup the 283 | // list here. (NOTE: You might think, since we want to eliminate duplicates from the list 284 | // we should be storing them in a Set instead of a List. However, a Set is un-ordered, and 285 | // we want to keep the results in the same order that they appeared in the XML. So we'll 286 | // store them in a List to preserve their order and just de-dup them as necessary.) 287 | Set dedup = new Set(); 288 | for (Integer ix = newNodes.size() - 1; ix >= 0; ix--) { 289 | if (dedup.contains(newNodes[ix])) { 290 | newNodes.remove(ix); 291 | } 292 | else { 293 | dedup.add(newNodes[ix]); 294 | } 295 | } 296 | 297 | // Now we have a new list of nodes representing our candidates after analyzing this 298 | // node in the xpath. We'll analyze the next node in the path against this new list. 299 | currNodes = newNodes.clone(); 300 | 301 | is1stPathNode = false; 302 | } 303 | 304 | return currNodes; 305 | } 306 | 307 | 308 | /** 309 | * Determines whether or not an xpath has already been compiled and cached. 310 | */ 311 | public Boolean isPathCached (String path) { 312 | return XPath.cache.containsKey(path); 313 | } 314 | 315 | 316 | /** 317 | * Parses an XPath and compiles it into a more efficient form for the interpreter. 318 | * 319 | * @param path The XPath expression that describes which nodes we're looking for. 320 | * 321 | * @return PathNode[] Zero or more node definitions that matches the expression. 322 | */ 323 | private PathNode[] compile (String path) { 324 | PathNode[] compiled = new PathNode[0]; 325 | 326 | // Parse each node in the path & fill up our list of candidate nodes. 327 | Pattern pPathnodes = Pattern.compile(XPath.rxSlashes + XPath.rxNodeTest + XPath.rxPredicate); 328 | Matcher mPathnodes = pPathnodes.matcher(path); 329 | 330 | while (mPathnodes.find()) { 331 | String pathNodeSrc = mPathnodes.group(); 332 | 333 | PathNode pathnode = new PathNode(pathNodeSrc); 334 | compiled.add(pathnode); 335 | } 336 | 337 | return compiled; 338 | } 339 | 340 | 341 | /* 342 | * Given a node, determine if it matches the tagname & predicate. 343 | * 344 | * @return the node that was passed in to test, else null if it didn't pass the nodetest or 345 | * predicate. 346 | */ 347 | private Dom.XmlNode[] processNode(Dom.XmlNode node, PathNode pathNode) { 348 | Dom.XmlNode[] tempNodes = new Dom.XmlNode[0]; 349 | Dom.XmlNode[] retNodes = new Dom.XmlNode[0]; 350 | 351 | // See if this node matches the namespace (if specified) & tagname. 352 | if (this.matchesNodeTest(node, pathNode.nodeTest)) { 353 | tempNodes.add(node); 354 | } 355 | 356 | // Now filter it by the predicate, if any. I.e. if it doesn't pass the predicate test, remove 357 | // it from the candidate array. 358 | retNodes.addAll(this.filterByPredicate(tempNodes, pathNode.predicate)); 359 | 360 | return retNodes; 361 | } 362 | 363 | 364 | /* 365 | * Given a node, find all its children that match the tagname & predicate. 366 | * 367 | * @return a new list of nodes that represent the specified node's child, several children, or possibly 368 | * nothing at all if the xpath stopped matching successfully. 369 | */ 370 | private Dom.XmlNode[] processChildren(Dom.XmlNode node, PathNode pathNode) { 371 | Dom.XmlNode[] tempNodes = new Dom.XmlNode[0]; 372 | Dom.XmlNode[] retNodes = new Dom.XmlNode[0]; 373 | 374 | // Get all the immediate children that match the namespace (if specified) & tagname. 375 | for (Dom.XmlNode child : node.getChildElements()) { 376 | if (this.matchesNodeTest(child, pathNode.nodeTest)) { 377 | tempNodes.add(child); 378 | } 379 | } 380 | 381 | // Now, for each node that matches the tagname, filter it by the predicate, if any. 382 | retNodes.addAll(this.filterByPredicate(tempNodes, pathNode.predicate)); 383 | 384 | return retNodes; 385 | } 386 | 387 | 388 | /* 389 | * Determine whether a specific node matchs the namespace/tagname. 390 | * 391 | * @return true if this node's tagname (and possibly namespace) match the entry in the path, 392 | * else false. 393 | */ 394 | private Boolean matchesNodeTest(Dom.XmlNode node, NodeTest nodeTest) { 395 | if (node.getNodeType() != Dom.XmlNodeType.ELEMENT) { 396 | return false; 397 | } 398 | 399 | if (nodeTest.isDot()) { 400 | return true; 401 | } 402 | 403 | // If a namespace was specified in the xpath, check it against this node's tag. 404 | if (!String.isEmpty(nodeTest.ns)) { 405 | if (String.isEmpty(node.getNamespace()) || node.getPrefixFor(node.getNamespace()) != nodeTest.ns) { 406 | return false; 407 | } 408 | } 409 | 410 | // Check the tagname. 411 | if (nodeTest.tagname != '*' && node.getName() != nodeTest.tagname) { 412 | return false; 413 | } 414 | 415 | return true; 416 | } 417 | 418 | 419 | /* 420 | * Given a list of candidate nodes that have passed the namespace/tagname test, determine which 421 | * ones also match the predicate. 422 | * 423 | * @return List of nodes that match the predicate, else an empty list. 424 | */ 425 | private Dom.XmlNode[] filterByPredicate(Dom.XmlNode[] nodes, Predicate predicate) { 426 | Dom.XmlNode[] retNodes = new Dom.XmlNode[0]; 427 | 428 | if (predicate.type == PRED_TYPE_NONE) { 429 | retNodes = nodes; 430 | } 431 | 432 | else if (predicate.type != PRED_TYPE_INDEX) { 433 | for (Dom.XmlNode newNode : nodes) { 434 | if (this.matchesSimplePredicate(newNode, predicate)) { 435 | retNodes.add(newNode); 436 | } 437 | } 438 | } 439 | 440 | else { 441 | // If the predicate is an index-based filter (i.e. "[1]"), filter it here. 442 | if (0 < predicate.index 443 | && predicate.index <= nodes.size()) { 444 | retNodes.add(nodes[predicate.index - 1]); 445 | } 446 | } 447 | 448 | return retNodes; 449 | } 450 | 451 | 452 | /* 453 | * Determine whether a specific node matches a simple predicate. 454 | * 455 | * A simple predicate is one that refers to something about this node itself, as opposed to 456 | * something like the index predicate, which specifies which index in the results list should 457 | * be returned. 458 | * 459 | * @return true if the node matches the simple predicate, else false. 460 | */ 461 | private Boolean matchesSimplePredicate(Dom.XmlNode node, Predicate predicate) { 462 | if (node.getNodeType() != Dom.XmlNodeType.ELEMENT) { 463 | return false; 464 | } 465 | 466 | // Now, filter it by the predicate if any. 467 | if (predicate.type == PRED_TYPE_INVALID) { 468 | // No further filtering on the results. 469 | return false; 470 | } 471 | 472 | if (predicate.type == PRED_TYPE_NONE) { 473 | // No further filtering on the results. 474 | return true; 475 | } 476 | 477 | if (predicate.type == PRED_TYPE_INDEX) { 478 | // Ignore this. Index predicate must be processed by the caller, since it's a higher-order 479 | // filter than can be determined by examining an individual node. 480 | return true; 481 | } 482 | 483 | if (predicate.type == PRED_TYPE_HAS_ATTR) { 484 | if (String.isEmpty(node.getAttribute(predicate.attrName, ''))) { 485 | return false; 486 | } 487 | } 488 | 489 | if (predicate.type == PRED_TYPE_ATTR_VALUE) { 490 | if (node.getAttribute(predicate.attrName, '') != predicate.attrValue) { 491 | return false; 492 | } 493 | } 494 | 495 | return true; 496 | } 497 | 498 | 499 | /* 500 | * Compiled form of a segment of a path like "/namespace:tagname", "namespace:*[1]", "tagname[@attr]" etc. 501 | * It's a structured representation that we can easily interpret & cache. 502 | */ 503 | private class PathNode 504 | { 505 | Integer numSlashes = 1; // 0=start of a relative path, 1=children only, 2=all descendants 506 | public NodeTest nodeTest; 507 | public Predicate predicate; 508 | 509 | /* 510 | * Compiles a path node 511 | */ 512 | public PathNode (String pathnode) { 513 | // Handle the path separator. 514 | if (pathnode.startsWith('//')) { 515 | this.numSlashes = 2; 516 | pathnode = pathnode.substringAfter('//'); 517 | } 518 | else if (pathnode.startsWith('/')) { 519 | this.numSlashes = 1; 520 | pathnode = pathnode.substringAfter('/'); 521 | } 522 | else { 523 | this.numSlashes = 0; 524 | } 525 | 526 | // Burst this node in the path into the tagname & predicate (if any). 527 | Pattern pPathnode = Pattern.compile(XPath.rxNodeTest + XPath.rxPredicate); 528 | Matcher mPathnode = pPathnode.matcher(pathnode); 529 | mPathnode.find(); 530 | 531 | String nodeTestSrc = pathnode.substringBefore('['); 532 | this.nodeTest = new NodeTest(nodeTestSrc); 533 | 534 | String predicateSrc = pathnode.substringAfter(nodeTestSrc); 535 | this.predicate = new Predicate(predicateSrc); 536 | } 537 | } 538 | 539 | 540 | /* 541 | * Compiled form of a node test like "namespace:tagname", "namespace:*", tagname", or "*". 542 | * It's a structured representation that we can easily interpret. 543 | */ 544 | private class NodeTest 545 | { 546 | String ns = null; 547 | String tagname = null; 548 | 549 | /* 550 | * Compiles a node test. 551 | */ 552 | public NodeTest (String src) { 553 | if (src.contains(':')) { 554 | this.ns = src.substringBefore(':'); 555 | this.tagname = src.substringAfter(':'); 556 | } 557 | else { 558 | this.tagname = src; 559 | } 560 | } 561 | 562 | 563 | public Boolean isDot () { 564 | return String.isEmpty(this.ns) && this.tagname == '.'; 565 | } 566 | 567 | 568 | public Boolean isDoubleDot () { 569 | return String.isEmpty(this.ns) && this.tagname == '..'; 570 | } 571 | } 572 | 573 | 574 | /* 575 | * Compiled form of a predicate expression like [1], [@attr], or [@attr=value]. 576 | * It's a structured representation that we can easily interpret. 577 | */ 578 | private class Predicate 579 | { 580 | public Integer type = PRED_TYPE_NONE; 581 | public Integer index = null; 582 | public String attrName = null; 583 | public String attrValue = null; 584 | 585 | /* 586 | * Compiles a predicate expression. 587 | * 588 | * You can pass in a string with the [ ] or not. 589 | */ 590 | public Predicate (String src) { 591 | if (String.isEmpty(src)) { 592 | this.type = PRED_TYPE_NONE; 593 | return; 594 | } 595 | 596 | src = src.substringAfter ('['); 597 | src = src.substringBeforeLast(']'); 598 | if (src.isNumeric()) { 599 | this.type = PRED_TYPE_INDEX; 600 | this.index = (Integer.valueOf(src)); 601 | } 602 | 603 | else if (src.startsWith('@')) { 604 | src = src.substringAfter('@'); 605 | if (src.contains('=')) { 606 | this.type = PRED_TYPE_ATTR_VALUE; 607 | this.attrName = src.substringBefore('='); 608 | this.attrValue = src.substringAfter('=').removeStart('"').removeEnd('"'); 609 | } 610 | else if (!String.isEmpty(src)) { 611 | this.type = PRED_TYPE_HAS_ATTR; 612 | this.attrName = src; 613 | } 614 | else {// there is no predicate. 615 | this.type = PRED_TYPE_NONE; 616 | } 617 | } 618 | 619 | else { 620 | // ERROR! 621 | this.type = PRED_TYPE_INVALID; 622 | } 623 | } 624 | } 625 | } 626 | -------------------------------------------------------------------------------- /XPath_Test.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for XPath.cls 3 | * 4 | * @author Jennifer Simonds 5 | * @version 1.0.0 6 | * @copyright 2015 Jennifer Simonds 7 | * @license MIT License http://opensource.org/licenses/MIT 8 | */ 9 | @isTest 10 | private class XPath_Test { 11 | 12 | /* ************************************************************************ 13 | * TESTING THE CONSTRUCTORS 14 | *************************************************************************/ 15 | 16 | // Construct an XPath object from XML source. 17 | @isTest static void test_constructor_xml() { 18 | String xml = 'hello'; 19 | 20 | XPath xp = new XPath(xml); 21 | 22 | System.assertNotEquals(null, xp); 23 | } 24 | 25 | 26 | // Construct an XPath object from a Dom.Document. 27 | @isTest static void test_constructor_dom() { 28 | Dom.Document doc = new Dom.Document(); 29 | 30 | XPath xp = new XPath(doc); 31 | 32 | System.assertNotEquals(null, doc); 33 | } 34 | 35 | 36 | /* ************************************************************************ 37 | * TESTING find() & XPATH SYNTAX 38 | *************************************************************************/ 39 | 40 | // find() returns an array of nodes that match the xpath expression. 41 | @isTest static void test_tagname_single() { 42 | String xml = 'hello'; 43 | 44 | XPath xp = new XPath(xml); 45 | 46 | Dom.XmlNode[] actualNodes = xp.find(xp.root, '/a'); 47 | 48 | System.assertEquals(1, actualNodes.size()); 49 | System.assertEquals(Dom.XmlNodeType.ELEMENT, actualNodes[0].getNodeType()); 50 | System.assertEquals('a', actualNodes[0].getName()); 51 | System.assertEquals('hello', actualNodes[0].getText()); 52 | } 53 | 54 | 55 | // find() should find nodes that are more than just 1 level deep from the starting element. 56 | @isTest static void test_tagname_2levels() { 57 | String xml = 'child 1'; 58 | 59 | XPath xp = new XPath(xml); 60 | Dom.XmlNode root = xp.doc.getRootElement(); 61 | 62 | Dom.XmlNode[] actualNodes = xp.find(xp.root, '/a/b'); 63 | 64 | System.assertEquals(1, actualNodes.size()); 65 | System.assertEquals(Dom.XmlNodeType.ELEMENT, actualNodes[0].getNodeType()); 66 | System.assertEquals('b', actualNodes[0].getName()); 67 | System.assertEquals('child 1', actualNodes[0].getText()); 68 | } 69 | 70 | 71 | // The fact that the XML source is nicely formatted shouldn't make a difference. 72 | @isTest static void test_tagname_2levels_pretty() { 73 | String xml = 74 | '\n' + 75 | ' child 2\n' + 76 | ''; 77 | 78 | XPath xp = new XPath(xml); 79 | 80 | Dom.XmlNode[] actualNodes = xp.find(xp.root, '/a/b'); 81 | 82 | System.assertEquals(1, actualNodes.size()); 83 | System.assertEquals(Dom.XmlNodeType.ELEMENT, actualNodes[0].getNodeType()); 84 | System.assertEquals('b', actualNodes[0].getName()); 85 | System.assertEquals('child 2', actualNodes[0].getText()); 86 | } 87 | 88 | 89 | // Calling find() with default start should start from the root. 90 | @isTest static void test_find_default_start() { 91 | String xml = 92 | '\n' + 93 | ' child 2\n' + 94 | ''; 95 | 96 | XPath xp = new XPath(xml); 97 | 98 | Dom.XmlNode[] actualNodes = xp.find('/a/b'); 99 | 100 | System.assertEquals(1, actualNodes.size()); 101 | System.assertEquals(Dom.XmlNodeType.ELEMENT, actualNodes[0].getNodeType()); 102 | System.assertEquals('b', actualNodes[0].getName()); 103 | System.assertEquals('child 2', actualNodes[0].getText()); 104 | } 105 | 106 | 107 | // The simplest xpath expression is a bare tagname, meaning a relative path from the specified 108 | // element that matches the immediate children by tagname. 109 | @isTest static void test_tagname_relpath_short() { 110 | String xml = 111 | '\n' + 112 | ' \n' + 113 | ' \n' + 114 | ' great-grandchild 1\n' + 115 | ' \n' + 116 | ' \n' + 117 | ''; 118 | 119 | XPath xp = new XPath(xml); 120 | 121 | Dom.XmlNode[] bNodes = xp.find(xp.root, '/a/b'); 122 | Dom.XmlNode start = bNodes[0]; 123 | 124 | Dom.XmlNode[] actualNodes = xp.find(start, 'c'); 125 | 126 | System.assertEquals(1, actualNodes.size()); 127 | System.assertEquals(Dom.XmlNodeType.ELEMENT, actualNodes[0].getNodeType()); 128 | System.assertEquals('c', actualNodes[0].getName()); 129 | } 130 | 131 | 132 | // Relative paths should handle multiple levels, same as an absolute path does. 133 | @isTest static void test_tagname_relpath_long() { 134 | String xml = 135 | '\n' + 136 | ' \n' + 137 | ' \n' + 138 | ' great-grandchild 1\n' + 139 | ' \n' + 140 | ' \n' + 141 | ''; 142 | 143 | XPath xp = new XPath(xml); 144 | 145 | Dom.XmlNode[] bNodes = xp.find(xp.root, '/a/b'); 146 | Dom.XmlNode start = bNodes[0]; 147 | 148 | Dom.XmlNode[] actualNodes = xp.find(start, 'c/d'); 149 | 150 | System.assertEquals(1, actualNodes.size()); 151 | System.assertEquals(Dom.XmlNodeType.ELEMENT, actualNodes[0].getNodeType()); 152 | System.assertEquals('d', actualNodes[0].getName()); 153 | } 154 | 155 | 156 | // Calling find() with default start & a relative path should start from the root. 157 | @isTest static void test_find_default_start_relpath() { 158 | String xml = 159 | '\n' + 160 | ' \n' + 161 | ' \n' + 162 | ' great-grandchild 1\n' + 163 | ' \n' + 164 | ' \n' + 165 | ''; 166 | 167 | XPath xp = new XPath(xml); 168 | 169 | Dom.XmlNode[] actualNodes = xp.find('b/c/d'); 170 | 171 | System.assertEquals(1, actualNodes.size()); 172 | System.assertEquals(Dom.XmlNodeType.ELEMENT, actualNodes[0].getNodeType()); 173 | System.assertEquals('d', actualNodes[0].getName()); 174 | } 175 | 176 | 177 | // You can start a relative path with ".". 178 | @isTest static void test_tagname_relpath_dot() { 179 | String xml = 180 | '\n' + 181 | ' \n' + 182 | ' \n' + 183 | ' great-grandchild 1\n' + 184 | ' \n' + 185 | ' \n' + 186 | ''; 187 | 188 | XPath xp = new XPath(xml); 189 | 190 | Dom.XmlNode[] bNodes = xp.find(xp.root, '/a/b'); 191 | Dom.XmlNode start = bNodes[0]; 192 | 193 | Dom.XmlNode[] actualNodes = xp.find(start, './c/d'); 194 | 195 | System.assertEquals(1, actualNodes.size()); 196 | System.assertEquals(Dom.XmlNodeType.ELEMENT, actualNodes[0].getNodeType()); 197 | System.assertEquals('d', actualNodes[0].getName()); 198 | System.assertEquals('great-grandchild 1', actualNodes[0].getText()); 199 | } 200 | 201 | 202 | // A "." inside a path shouldn't have any effect, since it refers to the current "folder" in the path. 203 | @isTest static void test_tagname_relpath_dot_inside() { 204 | String xml = 205 | '\n' + 206 | ' \n' + 207 | ' \n' + 208 | ' great-grandchild 1\n' + 209 | ' \n' + 210 | ' \n' + 211 | ''; 212 | 213 | XPath xp = new XPath(xml); 214 | 215 | Dom.XmlNode[] bNodes = xp.find(xp.root, '/a/b'); 216 | Dom.XmlNode start = bNodes[0]; 217 | 218 | Dom.XmlNode[] actualNodes = xp.find(start, 'c/./d'); 219 | 220 | System.assertEquals(1, actualNodes.size()); 221 | System.assertEquals(Dom.XmlNodeType.ELEMENT, actualNodes[0].getNodeType()); 222 | System.assertEquals('d', actualNodes[0].getName()); 223 | System.assertEquals('great-grandchild 1', actualNodes[0].getText()); 224 | } 225 | 226 | 227 | // You can start a relative path with "..". 228 | @isTest static void test_tagname_relpath_doubledot() { 229 | String xml = 230 | '\n' + 231 | ' B 1\n' + 232 | ' B 2\n' + 233 | ' B 1\n' + 234 | ' B 2\n' + 235 | ''; 236 | 237 | XPath xp = new XPath(xml); 238 | 239 | Dom.XmlNode[] b1Nodes = xp.find(xp.root, '/a/b1'); 240 | Dom.XmlNode start = b1Nodes[0]; 241 | 242 | Dom.XmlNode[] actualNodes = xp.find(start, '../b2'); 243 | 244 | System.assertEquals(2, actualNodes.size()); 245 | System.assertEquals('b2', actualNodes[0].getName()); 246 | System.assertEquals('b2', actualNodes[1].getName()); 247 | System.assertEquals('B 1', actualNodes[0].getText()); 248 | System.assertEquals('B 2', actualNodes[1].getText()); 249 | } 250 | 251 | 252 | // A ".." inside a path should go up one level to the parent. 253 | @isTest static void test_tagname_relpath_doubledot_inside1() { 254 | String xml = 255 | '\n' + 256 | ' B 1\n' + 257 | ' B 2\n' + 258 | ' B 1\n' + 259 | ' B 2\n' + 260 | ''; 261 | 262 | XPath xp = new XPath(xml); 263 | 264 | Dom.XmlNode[] actualNodes = xp.find(xp.root, '/a/b1/..'); 265 | 266 | System.assertEquals(1, actualNodes.size()); 267 | System.assertEquals('a', actualNodes[0].getName()); 268 | } 269 | 270 | 271 | // A ".." inside a path should go up one level to the parent. 272 | @isTest static void test_tagname_relpath_doubledot_inside2() { 273 | String xml = 274 | '\n' + 275 | ' B 1\n' + 276 | ' B 2\n' + 277 | ' B 1\n' + 278 | ' B 2\n' + 279 | ''; 280 | 281 | XPath xp = new XPath(xml); 282 | 283 | Dom.XmlNode[] actualNodes = xp.find(xp.root, '/a/b1/../b2'); 284 | 285 | System.assertEquals(2, actualNodes.size()); 286 | System.assertEquals('B 1', actualNodes[0].getText()); 287 | System.assertEquals('B 2', actualNodes[1].getText()); 288 | } 289 | 290 | 291 | @isTest static void test_multiple_matches() { 292 | String xml = 293 | '\n' + 294 | ' child 1\n' + 295 | ' odd man out\n' + 296 | ' child 2\n' + 297 | ''; 298 | 299 | XPath xp = new XPath(xml); 300 | 301 | Dom.XmlNode[] actualNodes = xp.find(xp.root, '/a/b1'); 302 | 303 | System.assertEquals(2, actualNodes.size()); 304 | System.assertEquals(Dom.XmlNodeType.ELEMENT, actualNodes[0].getNodeType()); 305 | System.assertEquals('b1', actualNodes[0].getName()); 306 | System.assertEquals('child 1', actualNodes[0].getText()); 307 | System.assertEquals('child 2', actualNodes[1].getText()); 308 | } 309 | 310 | 311 | // If you use an xpath more than once, it should take the compiled path from the cache. 312 | @isTest static void test_cache() { 313 | String xml = 314 | '\n' + 315 | ' child 1.1\n' + 316 | ' child 1.2\n' + 317 | ' grandchild 2.1\n' + 318 | ' grandchild 2.2\n' + 319 | ' great-grandchild 3.1\n' + 320 | ' great-grandchild 3.2\n' + 321 | ' great-grandchild 3.3\n' + 322 | ' \n' + 323 | ' grandchild 2.3\n' + 324 | ' \n' + 325 | ' child 1.3\n' + 326 | ''; 327 | 328 | XPath xp = new XPath(xml); 329 | String path = '/a/b/c'; 330 | 331 | System.assertEquals(false, xp.isPathCached(path)); 332 | Dom.XmlNode[] actualNodes1 = xp.find(xp.root, path); 333 | 334 | System.assertEquals(true, xp.isPathCached(path)); 335 | Dom.XmlNode[] actualNodes2 = xp.find(xp.root, path); 336 | 337 | System.assertEquals(3, actualNodes1.size()); 338 | System.assertEquals(3, actualNodes2.size()); 339 | System.assertEquals('c', actualNodes1[0].getName()); 340 | System.assertEquals('c', actualNodes2[0].getName()); 341 | } 342 | 343 | 344 | @isTest static void test_findFirst_0_matches() { 345 | String xml = 346 | '\n' + 347 | ' child 1\n' + 348 | ' odd man out\n' + 349 | ' child 2\n' + 350 | ''; 351 | 352 | XPath xp = new XPath(xml); 353 | 354 | Dom.XmlNode actualNode = xp.findFirst(xp.root, '/a/xxx'); 355 | 356 | System.assertEquals(null, actualNode); 357 | } 358 | 359 | 360 | @isTest static void test_findFirst_1_match() { 361 | String xml = 362 | '\n' + 363 | ' child 1\n' + 364 | ' odd man out\n' + 365 | ' child 2\n' + 366 | ''; 367 | 368 | XPath xp = new XPath(xml); 369 | 370 | Dom.XmlNode actualNode = xp.findFirst(xp.root, '/a/b2'); 371 | 372 | System.assertNotEquals(null, actualNode); 373 | System.assertEquals('b2', actualNode.getName()); 374 | } 375 | 376 | 377 | @isTest static void test_findFirst_multiple_matches() { 378 | String xml = 379 | '\n' + 380 | ' child 1\n' + 381 | ' odd man out\n' + 382 | ' child 2\n' + 383 | ''; 384 | 385 | XPath xp = new XPath(xml); 386 | 387 | Dom.XmlNode actualNode = xp.findFirst(xp.root, '/a/b1'); 388 | 389 | System.assertNotEquals(null, actualNode); 390 | System.assertEquals('b1', actualNode.getName()); 391 | } 392 | 393 | 394 | // Calling findFirst() with default start should always start from the root. 395 | @isTest static void test_findFirst_default_start() { 396 | String xml = 397 | '\n' + 398 | ' \n' + 399 | ' \n' + 400 | ' great-grandchild 1\n' + 401 | ' great-grandchild 2\n' + 402 | ' \n' + 403 | ' \n' + 404 | ''; 405 | 406 | XPath xp = new XPath(xml); 407 | 408 | Dom.XmlNode actualNodeRel = xp.findFirst('b/c/d'); 409 | Dom.XmlNode actualNodeAbs = xp.findFirst('/a/b/c/d'); 410 | 411 | System.assertEquals('great-grandchild 1', actualNodeRel.getText()); 412 | System.assertEquals('great-grandchild 1', actualNodeAbs.getText()); 413 | } 414 | 415 | 416 | // A wildcard tagname should pick up all immediate child elements. 417 | @isTest static void test_tagname_star() { 418 | String xml = 419 | '\n' + 420 | ' child 1\n' + 421 | ' odd man out\n' + 422 | ' \n' + 423 | ' child 2\n' + 424 | ''; 425 | 426 | XPath xp = new XPath(xml); 427 | 428 | Dom.XmlNode[] actualNodes = xp.find(xp.root, '/a/*'); 429 | 430 | System.assertEquals(3, actualNodes.size()); 431 | System.assertEquals('b1', actualNodes[0].getName()); 432 | System.assertEquals('b2', actualNodes[1].getName()); 433 | System.assertEquals('b1', actualNodes[2].getName()); 434 | } 435 | 436 | 437 | @isTest static void test_namespace_tagname() { 438 | String xml = 439 | '\n' + 440 | ' hello\n' + 441 | ''; 442 | 443 | XPath xp = new XPath(xml); 444 | 445 | Dom.XmlNode[] actualNodes = xp.find(xp.root, '/a/myNs:b'); 446 | 447 | System.assertEquals(1, actualNodes.size()); 448 | System.assertEquals(Dom.XmlNodeType.ELEMENT, actualNodes[0].getNodeType()); 449 | System.assertEquals('b', actualNodes[0].getName()); 450 | System.assertEquals('hello', actualNodes[0].getText()); 451 | } 452 | 453 | 454 | @isTest static void test_ignore_namespace_tagname() { 455 | String xml = 456 | '\n' + 457 | ' hello\n' + 458 | ''; 459 | 460 | XPath xp = new XPath(xml); 461 | 462 | Dom.XmlNode[] actualNodes = xp.find(xp.root, '/a/b'); 463 | 464 | System.assertEquals(1, actualNodes.size()); 465 | System.assertEquals(Dom.XmlNodeType.ELEMENT, actualNodes[0].getNodeType()); 466 | System.assertEquals('b', actualNodes[0].getName()); 467 | System.assertEquals('hello', actualNodes[0].getText()); 468 | } 469 | 470 | 471 | /* ************************************************************************ 472 | * TESTING THE PREDICATES 473 | *************************************************************************/ 474 | 475 | @isTest static void test_predicate_index_true() { 476 | String xml = 477 | '\n' + 478 | ' child 1\n' + 479 | ' child 2\n' + 480 | ' child 3\n' + 481 | ''; 482 | 483 | XPath xp = new XPath(xml); 484 | 485 | Dom.XmlNode[] actualNodes = xp.find(xp.root, '/a/b[2]'); 486 | 487 | System.assertEquals(1, actualNodes.size()); 488 | System.assertEquals(Dom.XmlNodeType.ELEMENT, actualNodes[0].getNodeType()); 489 | System.assertEquals('child 2', actualNodes[0].getText()); 490 | } 491 | 492 | 493 | @isTest static void test_predicate_index_false() { 494 | String xml = 495 | '\n' + 496 | ' child 1\n' + 497 | ' child 2\n' + 498 | ' child 3\n' + 499 | ''; 500 | 501 | XPath xp = new XPath(xml); 502 | 503 | Dom.XmlNode[] actualNodes = xp.find(xp.root, '/a/b[4]'); 504 | 505 | System.assertEquals(0, actualNodes.size()); 506 | } 507 | 508 | 509 | @isTest static void test_predicate_index_bad_0_index() { 510 | String xml = 511 | '\n' + 512 | ' child 1\n' + 513 | ' child 2\n' + 514 | ' child 3\n' + 515 | ''; 516 | 517 | XPath xp = new XPath(xml); 518 | 519 | Dom.XmlNode[] actualNodes = xp.find(xp.root, '/a/b[0]'); 520 | 521 | System.assertEquals(0, actualNodes.size()); 522 | } 523 | 524 | 525 | // Looking for existence of a specific attribute that is found in 2 elements should 526 | // return 2 elements. 527 | @isTest static void test_predicate_has_attr_true() { 528 | String xml = 529 | '\n' + 530 | ' child 1\n' + 531 | ' child 2\n' + 532 | ' child 3\n' + 533 | ''; 534 | 535 | XPath xp = new XPath(xml); 536 | 537 | Dom.XmlNode[] actualNodes = xp.find(xp.root, '/a/b[@id]'); 538 | 539 | System.assertEquals(2, actualNodes.size()); 540 | System.assertEquals(Dom.XmlNodeType.ELEMENT, actualNodes[0].getNodeType()); 541 | System.assertEquals('child 1', actualNodes[0].getText()); 542 | System.assertEquals('child 2', actualNodes[1].getText()); 543 | } 544 | 545 | 546 | // Looking for existence of a specific attribute that isn't in any of the elements should 547 | // return 0 elements. 548 | @isTest static void test_predicate_has_attr_false() { 549 | String xml = 550 | '\n' + 551 | ' child 1\n' + 552 | ' child 2\n' + 553 | ' child 3\n' + 554 | ''; 555 | 556 | XPath xp = new XPath(xml); 557 | 558 | Dom.XmlNode[] actualNodes = xp.find(xp.root, '/a/b[@xxx]'); 559 | 560 | System.assertEquals(0, actualNodes.size()); 561 | } 562 | 563 | 564 | @isTest static void test_predicate_attr_value_true() { 565 | String xml = 566 | '\n' + 567 | ' child 1\n' + 568 | ' child 2\n' + 569 | ' child 3\n' + 570 | ''; 571 | 572 | XPath xp = new XPath(xml); 573 | 574 | Dom.XmlNode[] actualNodes = xp.find(xp.root, '/a/b[@id="1"]'); 575 | 576 | System.assertEquals(1, actualNodes.size()); 577 | System.assertEquals(Dom.XmlNodeType.ELEMENT, actualNodes[0].getNodeType()); 578 | System.assertEquals('child 1', actualNodes[0].getText()); 579 | } 580 | 581 | 582 | @isTest static void test_predicate_attr_value_false() { 583 | String xml = 584 | '\n' + 585 | ' child 1\n' + 586 | ' child 2\n' + 587 | ' child 3\n' + 588 | ''; 589 | 590 | XPath xp = new XPath(xml); 591 | 592 | Dom.XmlNode[] actualNodes = xp.find(xp.root, '/a/b[@id="3"]'); 593 | 594 | System.assertEquals(0, actualNodes.size()); 595 | } 596 | 597 | 598 | @isTest static void test_predicate_empty() { 599 | String xml = 600 | '\n' + 601 | ' child 1\n' + 602 | ' child 2\n' + 603 | ' child 3\n' + 604 | ''; 605 | 606 | XPath xp = new XPath(xml); 607 | 608 | Dom.XmlNode[] actualNodes = xp.find(xp.root, '/a/b[]'); 609 | 610 | System.assertEquals(0, actualNodes.size()); 611 | } 612 | 613 | 614 | @isTest static void test_predicate_invalid() { 615 | String xml = 616 | '\n' + 617 | ' child 1\n' + 618 | ' child 2\n' + 619 | ' child 3\n' + 620 | ''; 621 | 622 | XPath xp = new XPath(xml); 623 | 624 | Dom.XmlNode[] actualNodes = xp.find(xp.root, '/a/b[xxx]'); 625 | 626 | System.assertEquals(0, actualNodes.size()); 627 | } 628 | 629 | 630 | /* ************************************************************************ 631 | * TESTING getText & ITS VARIANTS 632 | *************************************************************************/ 633 | 634 | // getText is like find except it then gets the innerText of each result element. 635 | @isTest static void test_getText_single() { 636 | String xml = 637 | '\n' + 638 | ' child 1\n' + 639 | ''; 640 | 641 | XPath xp = new XPath(xml); 642 | 643 | String actualText = xp.getText(xp.root, '/a/b'); 644 | 645 | System.assertEquals('child 1', actualText); 646 | } 647 | 648 | 649 | // getText is like find except it then gets the innerText of each result element. 650 | @isTest static void test_getText_multiple() { 651 | String xml = 652 | '\n' + 653 | ' child 1\n' + 654 | ' child 2\n' + 655 | ' child 3\n' + 656 | ''; 657 | 658 | XPath xp = new XPath(xml); 659 | 660 | String actualText = xp.getText(xp.root, '/a/b'); 661 | 662 | System.assertEquals('child 1', actualText); 663 | } 664 | 665 | 666 | // Calling getText() with default start should always start from the root. 667 | @isTest static void test_getText_default_start() { 668 | String xml = 669 | '\n' + 670 | ' \n' + 671 | ' \n' + 672 | ' great-grandchild 1\n' + 673 | ' great-grandchild 2\n' + 674 | ' \n' + 675 | ' \n' + 676 | ''; 677 | 678 | XPath xp = new XPath(xml); 679 | 680 | String actualTextRel = xp.getText('b/c/d'); 681 | String actualTextAbs = xp.getText('/a/b/c/d'); 682 | 683 | System.assertEquals('great-grandchild 1', actualTextRel); 684 | System.assertEquals('great-grandchild 1', actualTextAbs); 685 | } 686 | 687 | 688 | // getTextList should return a List of Strings. 689 | @isTest static void test_getText_multiple_list() { 690 | String xml = 691 | '\n' + 692 | ' child 1\n' + 693 | ' child 2\n' + 694 | ' child 3\n' + 695 | ''; 696 | 697 | XPath xp = new XPath(xml); 698 | 699 | String[] actualText = xp.getTextList(xp.root, '/a/b'); 700 | 701 | System.assert(actualText.size() == 3); 702 | System.assertEquals('child 1', actualText[0]); 703 | System.assertEquals('child 2', actualText[1]); 704 | System.assertEquals('child 3', actualText[2]); 705 | } 706 | 707 | 708 | // Calling getTextList() with default start should always start from the root. 709 | @isTest static void test_getTextList_default_start() { 710 | String xml = 711 | '\n' + 712 | ' \n' + 713 | ' \n' + 714 | ' great-grandchild 1\n' + 715 | ' great-grandchild 2\n' + 716 | ' \n' + 717 | ' \n' + 718 | ''; 719 | 720 | XPath xp = new XPath(xml); 721 | 722 | String[] actualTextRel = xp.getTextList('b/c/d'); 723 | String[] actualTextAbs = xp.getTextList('/a/b/c/d'); 724 | 725 | System.assert(actualTextRel.size() == 2); 726 | System.assertEquals('great-grandchild 1', actualTextRel[0]); 727 | System.assert(actualTextAbs.size() == 2); 728 | System.assertEquals('great-grandchild 1', actualTextAbs[0]); 729 | } 730 | 731 | 732 | // getTextList with no matches should return an empty List of Strings. 733 | @isTest static void test_getText_multiple_list_empty() { 734 | String xml = 735 | '\n' + 736 | ' child 1\n' + 737 | ' child 2\n' + 738 | ' child 3\n' + 739 | ''; 740 | 741 | XPath xp = new XPath(xml); 742 | 743 | String[] actualText = xp.getTextList(xp.root, '/a/xxx'); 744 | 745 | System.assert(actualText.size() == 0); 746 | } 747 | 748 | 749 | // getText with 3 parameters should return a delimited list. 750 | @isTest static void test_getText_multiple_delimiter() { 751 | String xml = 752 | '\n' + 753 | ' child 1\n' + 754 | ' child 2\n' + 755 | ' child 3\n' + 756 | ''; 757 | 758 | XPath xp = new XPath(xml); 759 | 760 | String actualText = xp.getText(xp.root, '-', '/a/b'); 761 | 762 | System.assert(actualText.length() > 0); 763 | System.assertEquals('child 1-child 2-child 3', actualText); 764 | } 765 | 766 | 767 | // Calling getText() with 2 String parameters (default start) should always start from the root. 768 | @isTest static void test_getText_CSV_default_start() { 769 | String xml = 770 | '\n' + 771 | ' \n' + 772 | ' \n' + 773 | ' great-grandchild 1\n' + 774 | ' great-grandchild 2\n' + 775 | ' \n' + 776 | ' \n' + 777 | ''; 778 | 779 | XPath xp = new XPath(xml); 780 | 781 | String actualTextRel = xp.getText(',', 'b/c/d'); 782 | String actualTextAbs = xp.getText(',', '/a/b/c/d'); 783 | 784 | System.assertEquals('great-grandchild 1,great-grandchild 2', actualTextRel); 785 | System.assertEquals('great-grandchild 1,great-grandchild 2', actualTextAbs); 786 | } 787 | } 788 | --------------------------------------------------------------------------------