Skip to content
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ QueryPath Changelog
- Support positional pseudo-classes on any simple selector in a chain, not just the last one: `ul:first li` selects the list items of the first `ul`, and each comma-separated group is filtered independently before the union is returned in document order
- Support positional pseudo-classes as the argument to `:not()`, `:has()` and `:matches()`, so the common `li:not(:first)` idiom now excludes the first match rather than every match
- `QueryPath\CSS\DOMTraverser\PseudoClass::elementMatches()` now throws `QueryPath\CSS\NotImplementedException` for the positional pseudo-classes. They cannot be answered for a node in isolation; use a `Traverser`, which applies them after traversal
- **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
- `nextAll()` and `prevAll()` stop walking at the first sibling already collected from an earlier starting element, rather than re-walking to the end of the list. On a 4,000-item list `nextAll()` fell from about 430 ms to under 10 ms
- 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
9 changes: 5 additions & 4 deletions src/CSS/DOMTraverser.php
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,12 @@ protected function resolveGroup(array $selectorGroup): SplObjectStorage
*/
protected function resolveSubject(array $selectorGroup, int $index): SplObjectStorage
{
// Initialize matches if necessary.
// Initialize matches if necessary. A caller-supplied set may hold non-element nodes,
// which can never match a selector.
if ($index === 0 && $this->initialized) {
$candidates = $this->matches;
$candidates = array_filter(iterator_to_array($this->matches, false), function ($node) {
return $node instanceof DOMElement;
});
} else {
$candidates = $this->initialMatch($selectorGroup[$index], $this->matches);
}
Expand All @@ -270,7 +273,6 @@ protected function resolveSubject(array $selectorGroup, int $index): SplObjectSt
// array that then has to be copied into one.
if (empty($setFilters)) {
$found = $this->newMatches();
/** @var DOMElement $candidate */
foreach ($candidates as $candidate) {
if ($this->matchesSimpleSelector($candidate, $selectorGroup, $index)) {
$found->offsetSet($candidate);
Expand All @@ -283,7 +285,6 @@ protected function resolveSubject(array $selectorGroup, int $index): SplObjectSt
}

$matched = [];
/** @var DOMElement $candidate */
foreach ($candidates as $candidate) {
if ($this->matchesSimpleSelector($candidate, $selectorGroup, $index)) {
$matched[] = $candidate;
Expand Down
131 changes: 131 additions & 0 deletions src/Helpers/NodeMatcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

namespace QueryPath\Helpers;

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

/**
* Test nodes that are already in hand against a CSS selector.
*
* Unlike find(), which searches the <em>descendants</em> of the nodes it is given, the
* supplied nodes are themselves the only candidates: for `<a><b/></a>`, `b` matches the
* `b` element and not `a`. Combinators are still resolved against the real document, so
* full selectors (e.g. `div > p`) keep working.
*
* Prefer the set methods wherever there is a set. A per-node pass cannot answer a selector
* that describes a position within the set (:first, :eq(n), :odd, ...), because each node
* would be the only member of its own one-element set.
*
* matches() remembers each per-node answer, because the walk-and-stop traversals
* (closest(), nextUntil(), ...) revisit the same nodes from every starting element. An
* instance must not outlive the call that made it, or a later DOM change could leave it
* holding stale answers.
*/
final class NodeMatcher
{

/**
* @var string
*/
private $selector;

/**
* @var SplObjectStorage
*/
private $results;

/**
* @param string $selector
* A valid CSS selector.
*/
public function __construct($selector)
{
$this->selector = $selector;
$this->results = new SplObjectStorage();
}

/**
* Test whether a single node, taken as an element, matches the selector.
*
* @param DOMNode $node
* The node to test. A node that is not an element returns FALSE.
*
* @return bool
* @throws ParseException
*/
public function matches($node): bool
{
if (! $this->results->offsetExists($node)) {
$single = new SplObjectStorage();
$single->offsetSet($node);
$this->results[$node] = $this->matchesAny($single);
}

return $this->results[$node];
}

/**
* Reduce a set of nodes to those that match the selector, preserving their order.
*
* Nodes that are not elements can never match a CSS selector, and are skipped.
*
* @param SplObjectStorage $nodes
* The candidate nodes.
*
* @return SplObjectStorage
* @throws ParseException
*/
public function filter(SplObjectStorage $nodes): SplObjectStorage
{
if (count($nodes) === 0) {
return new SplObjectStorage();
}

$matched = $this->matching($nodes);

// The traverser returns document order; callers such as parents() need their own.
$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 the selector.
*
* @param SplObjectStorage $nodes
*
* @return bool
* @throws ParseException
*/
public function matchesAny(SplObjectStorage $nodes): bool
{
return count($nodes) > 0 && count($this->matching($nodes)) > 0;
}

/**
* Find the nodes that match the selector, in document order.
*
* @param SplObjectStorage $nodes
*
* @return SplObjectStorage
* @throws ParseException
*/
public function matching(SplObjectStorage $nodes): SplObjectStorage
{
// "Initialized" mode makes the traverser take the supplied set as its candidates rather
// than seeding them with a descendant search. The scope node is left at its default (the
// document element) so that `:scope` means the same here as in find(); scoping to a
// candidate would make every candidate match `:scope`.
$traverser = new DOMTraverser($nodes, true);

return $traverser->find($this->selector)->matches();
}
}
16 changes: 13 additions & 3 deletions src/Helpers/QueryChecks.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,30 @@ 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).
*
* Non-element nodes in the match set can never match a CSS selector, and are skipped.
*
* @param string|DOMNode $selector
* The selector to search for. As of QueryPath 2.1.1, this also supports passing a
* DOMNode object.
*
* @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 +73,7 @@ public function is($selector): bool
throw new Exception('Cannot compare an object to a DOMQuery.');
}

return $this->branch($selector)->count() > 0;
return (new NodeMatcher($selector))->matchesAny($this->matches);
}

/**
Expand Down
Loading
Loading