Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ QueryPath Changelog

# Unreleased changes

- **Breaking behaviour change:** `DOMQuery::is()` now behaves like jQuery's `.is()`. It tests the elements held in the current match set and returns `true` when at least one of them matches the selector. Previously it ran a descendant search, so `html5qp('<p><span>foo</span></p>', 'p')->is('span')` returned `true`. Use `has()` if you need the old "does the set contain something matching this selector" behaviour (#51)
- `DOMQuery::is()` no longer raises a fatal error when the match set contains non-element nodes (text nodes, comments, processing instructions). Those nodes cannot match a CSS selector, so they are skipped (#51)
- Fix `parents($selector)` so the selector filters the ancestors themselves, instead of matching any ancestor that merely *contains* an element matching the selector. `qp($xml, 'Demographics > Age > Name')->parents('Demographics')` now returns only `<Demographics>`, matching jQuery (#62)
- Apply the same fix to the other selector-filtered traversal methods in `QueryPath\Helpers\QueryFilters`: `parent()`, `parents()`, `parentsUntil()`, `closest()`, `next()`, `nextAll()`, `nextUntil()`, `prev()`, `prevAll()`, `prevUntil()`, `siblings()`, and `not()` (#62)
- **Behaviour change:** `parents()` and `parentsUntil()` now return their results in reverse document order with duplicates removed, as jQuery does. Previously a set built from more than one starting element was grouped by starting element (#62)
- **Breaking behaviour change:** `DOMQuery::filter()` now narrows the match set to the members that match the selector, as jQuery does, instead of keeping any element that merely *contains* a match. `qp($file, 'inner')->filter('li')` returned both `<inner>` elements and now returns none. Use `has()` for the old behaviour
- Fix `children($selector)`, which resolved `:scope` against each child rather than the document element, so `children(':scope')` matched every child
- `filter()` and `children($selector)` now evaluate the selector against the whole candidate set in a single pass, rather than one node at a time. A per-node pass cannot evaluate a selector describing a position within the set, since each node is the only member of its own one-element set
- Reorganise, modernise, and repair the `examples/` directory. Each example now lives in its own subdirectory with an `index.php`, and the full set is indexed in `examples/quickstart-guide.md`
- Convert the remaining legacy examples: `simple_example.php`, `techniques.php`, `svg.php`, `rss.php`, `odt.php`, `parse_php.php`, and `sparql.php`
- Fix examples that no longer ran: send a `User-Agent` where remote hosts now require one, resolve paths relative to the example rather than the working directory, and stop relying on the removed `qp.php` autoloader and the PHP 8 incompatible `eachLambda()`
Expand Down
118 changes: 118 additions & 0 deletions src/CSS/DOMTraverser/Util.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

namespace QueryPath\CSS\DOMTraverser;

use DOMNode;
use QueryPath\CSS\EventHandler;
use SplObjectStorage;

/**
* Utilities for DOM Traversal.
Expand Down Expand Up @@ -165,4 +167,120 @@ public static function parseAnB($rule): array

return [$aVal, $bVal];
}

/**
* Sort nodes into document order.
*
* PHP's DOM has no compareDocumentPosition(), so each node is described by the list of
* child offsets from the document down to it, and those lists are compared element by
* element. Sibling offsets are indexed a whole child list at a time and memoized for the
* duration of the sort — computing them by walking previousSibling per node is quadratic
* on the width of the parent.
*
* The memo lives only as long as the call, so a document mutated between calls cannot
* produce a stale answer.
*
* @param array $nodes
*
* @return array
*/
public static function sortDocumentOrder(array $nodes): array
{
if (count($nodes) < 2) {
return array_values($nodes);
}

$offsets = new SplObjectStorage();
$indexed = [];
foreach ($nodes as $node) {
$indexed[] = [self::documentOrderPath($node, $offsets), $node];
}

usort($indexed, function ($a, $b) {
return self::comparePaths($a[0], $b[0]);
});

$sorted = [];
foreach ($indexed as $entry) {
$sorted[] = $entry[1];
}

return $sorted;
}

/**
* Describe a node's position as the child offsets from the document down to it.
*
* @param DOMNode $node
* @param SplObjectStorage $offsets
*
* @return array
*/
private static function documentOrderPath($node, SplObjectStorage $offsets): array
{
$path = [];
while ($node instanceof DOMNode && $node->parentNode !== null) {
$path[] = self::siblingOffset($node, $offsets);
$node = $node->parentNode;
}

// Built leaf-first; array_unshift() per step would be quadratic on depth.
return array_reverse($path);
}

/**
* A node's offset among its parent's children.
*
* The parent's whole child list is indexed on the first request, so sorting a wide set of
* siblings costs one pass over the list rather than one pass per node.
*
* @param DOMNode $node
* @param SplObjectStorage $offsets
*
* @return int
*/
private static function siblingOffset($node, SplObjectStorage $offsets): int
{
if ($offsets->offsetExists($node)) {
return $offsets[$node];
}

$offset = 0;
foreach ($node->parentNode->childNodes as $sibling) {
$offsets[$sibling] = $offset++;
}

if ($offsets->offsetExists($node)) {
return $offsets[$node];
}

// The node has to be one of its parent's children, but do not assume the DOM handed
// back the same PHP object we were given.
$offset = 0;
for ($sibling = $node->previousSibling; $sibling !== null; $sibling = $sibling->previousSibling) {
++$offset;
}

return $offset;
}

/**
* Compare two paths from documentOrderPath().
*
* @param array $a
* @param array $b
*
* @return int
*/
private static function comparePaths(array $a, array $b): int
{
$shared = min(count($a), count($b));
for ($i = 0; $i < $shared; ++$i) {
if ($a[$i] !== $b[$i]) {
return $a[$i] < $b[$i] ? -1 : 1;
}
}

return count($a) - count($b);
}
}
104 changes: 104 additions & 0 deletions src/Helpers/NodeMatcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

namespace QueryPath\Helpers;

use DOMElement;
use QueryPath\CSS\DOMTraverser;
use QueryPath\CSS\ParseException;
use SplObjectStorage;

/**
* Test nodes that are already in hand against a CSS selector.
*
* This is deliberately different from running a find(): find() searches the
* <em>descendants</em> of the nodes it is given, so for `<a><b/></a>` it would report
* that the `a` element "matches" the selector `b`. Here the supplied nodes are
* themselves the only candidates, so `b` matches the `b` element and nothing else.
*
* The DOMTraverser is built in "initialized" mode, which tells it to treat the
* supplied set as the candidate set rather than seeding it with a descendant
* search. Combinators are still resolved against the real document by walking up
* from each candidate, so full selectors (e.g. `div > p`) keep working.
*
* The scope node is deliberately left at its default (the document element) so that
* `:scope` means the same thing here as it does in find(). Passing a candidate as the
* scope node would make every candidate match `:scope`.
*/
final class NodeMatcher
{

/**
* Reduce a set of nodes to those that match a selector.
*
* Nodes that are not elements can never match a CSS selector, and are skipped
* rather than raising an error.
*
* @param SplObjectStorage $nodes
* The candidate nodes.
* @param string $selector
* A valid CSS selector.
*
* @return SplObjectStorage
* The subset of $nodes that match the selector.
* @throws ParseException
*/
public static function filter(SplObjectStorage $nodes, $selector): SplObjectStorage
{
$candidates = new SplObjectStorage();
foreach ($nodes as $node) {
if ($node instanceof DOMElement) {
$candidates->offsetSet($node);
}
}

if (count($candidates) === 0) {
return $candidates;
}

$traverser = new DOMTraverser($candidates, true);
$traverser->find($selector);
$matched = $traverser->matches();

// Returned in the caller's order rather than the traverser's, so callers do not each
// have to re-walk their own input to restore it.
$found = new SplObjectStorage();
foreach ($nodes as $node) {
if ($matched->offsetExists($node)) {
$found->offsetSet($node);
}
}

return $found;
}

/**
* Test whether at least one of the given nodes matches a selector.
*
* @param SplObjectStorage $nodes
* @param string $selector
*
* @return bool
* @throws ParseException
*/
/**
* Test whether a single node, taken as an element, matches a selector.
*
* @param mixed $node
* The node to test. Anything that is not an element returns FALSE.
* @param string $selector
*
* @return bool
* @throws ParseException
*/
public static function matchesNode($node, $selector): bool
{
if (! $node instanceof DOMElement) {
return false;
}

$nodes = new SplObjectStorage();
$nodes->offsetSet($node);

return count(self::filter($nodes, $selector)) > 0;
}
}
27 changes: 21 additions & 6 deletions src/Helpers/QueryChecks.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,33 @@ trait QueryChecks
{

/**
* Given a selector, this checks to see if the current set has one or more matches.
* Check the current set of elements against a selector, and return TRUE if at least
* one of them matches.
*
* This behaves like jQuery's is(): only the elements <em>in</em> the current match set are
* tested. Neither the descendants nor the ancestors of those elements are considered.
* Use has() if you need to know whether the current set <em>contains</em> something that
* matches a selector.
*
* Unlike jQuery's version, this supports full selectors (not just simple ones).
*
* @param string|DOMNode $selector
* The selector to search for. As of QueryPath 2.1.1, this also supports passing a
* DOMNode object.
* Non-element nodes in the match set (text nodes, comments, processing instructions, and
* so on) can never match a CSS selector, and are simply skipped.
*
* @param string|DOMNode|Traversable $selector
* The selector to test the current match set against. As of QueryPath 2.1.1, this also
* supports passing a DOMNode object, in which case the current match set must consist of
* exactly that one node, or a Traversable (e.g. another DOMQuery's match set), in which
* case the two sets must contain exactly the same nodes.
*
* @return boolean
* TRUE if one or more elements match. FALSE if no match is found.
* @throws Exception
* @throws Exception
* @throws ParseException
* @see get()
* @see eq()
* @see has()
* @see filter()
*/
public function is($selector): bool
{
Expand Down Expand Up @@ -63,7 +76,9 @@ public function is($selector): bool
throw new Exception('Cannot compare an object to a DOMQuery.');
}

return $this->branch($selector)->count() > 0;
// Only the elements in the match set are candidates: a descendant that matches
// the selector must not make is() true. See NodeMatcher for the details.
return count(NodeMatcher::filter($this->matches, $selector)) > 0;
}

/**
Expand Down
Loading
Loading