Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ QueryPath Changelog
- **Behaviour change.** Fix `:has()` never matching in `find()`, `filter()` and the other methods that use `QueryPath\CSS\DOMTraverser`, so that `ul:has(li)` selected nothing and `:not(:has(li))` selected everything (issue #121). `:has()` now matches an element with a descendant matching its argument, and supports relative selectors: `ul:has(> li)` (a child), `h2:has(+ p)` (the next sibling) and `h2:has(~ p)` (a later sibling). A positional pseudo-class in the argument indexes each element's own matches, so `ul:has(li:eq(2))` selects every list with at least three items
- **Behaviour change.** `remove($selector)` and `replaceAll($selector)` now select with `QueryPath\CSS\DOMTraverser`, the engine behind `find()`, instead of the legacy `QueryPath\CSS\QueryPathEventHandler` (issues #126, #123, #124). `remove($selector)` searches the descendants of the match set, as `find()` and `detach($selector)` do, so `qp($xml, 'p')->remove('p')` no longer removes the `<p>` elements themselves. `replaceAll($selector, $document)` still searches the whole of `$document`, document element included. Both now accept `:matches()`, which threw "Unknown Pseudo-Class: matches", and apply a positional `:not()` argument to the whole set, so `remove('li:not(:first)')` keeps only the first `<li>` instead of removing nothing. `QueryPathEventHandler` is no longer used and is deprecated
- Add `QueryPath\CSS\DOMTraverser::filterNodes($nodes, $selector)`, which returns the given nodes that themselves match a selector, in the order given (issue #116). `filter()`, `is()`, the selector-filtered traversal methods, and `:not()`/`:matches()` all use it. `DOMTraverser` no longer modifies a parsed selector, so queries sharing a `PseudoClass` handler parse each selector once: `nextUntil()` over 2000 siblings drops from about 16 ms to 6 ms, and `tr:not(:nth-child(2n))` on a 2000-row table from 15 ms to 6 ms
- Consolidate the sibling and ancestor walks behind `next()`, `prev()`, `nextAll()`, `prevAll()`, `nextUntil()`, `prevUntil()`, `parent()`, `parents()`, `parentsUntil()` and `closest()` into one helper, so a fix to one no longer has to be repeated in its mirror (issue #115). As a side effect, `parentsUntil()` no longer loops forever on an element that is not attached to the document, and `nextUntil('')` and `prevUntil('')` treat an empty selector as no selector, as the other methods already did, instead of throwing `QueryPath\CSS\ParseException`. `closest('')` returns an empty set, as in jQuery. `nextUntil()`, `prevUntil()` and `parentsUntil()` also stop at an element already collected from an earlier starting element, as `nextAll()` and `prevAll()` do, so `nextUntil()` on each of 2000 siblings falls from about 100 ms to 1 ms

# 4.2.0

Expand Down
223 changes: 63 additions & 160 deletions src/Helpers/QueryFilters.php
Original file line number Diff line number Diff line change
Expand Up @@ -557,21 +557,7 @@ public function lastChild(): Query
*/
public function nextUntil($selector = null): Query
{
$found = new SplObjectStorage();
$matcher = new NodeMatcher($selector);
foreach ($this->matches as $m) {
while (isset($m->nextSibling)) {
$m = $m->nextSibling;
if ($m->nodeType === XML_ELEMENT_NODE) {
if (null !== $selector && $matcher->matches($m)) {
break;
}
$found->offsetSet($m);
}
}
}

return $this->inst($found, null);
return $this->inst($this->walk('nextSibling', $selector, false), null);
}

/**
Expand All @@ -597,22 +583,7 @@ public function nextUntil($selector = null): Query
*/
public function prevUntil($selector = null): Query
{
$found = new SplObjectStorage();
$matcher = new NodeMatcher($selector);
foreach ($this->matches as $m) {
while (isset($m->previousSibling)) {
$m = $m->previousSibling;
if ($m->nodeType === XML_ELEMENT_NODE) {
if (null !== $selector && $matcher->matches($m)) {
break;
}

$found->offsetSet($m);
}
}
}

return $this->inst($found, null);
return $this->inst($this->walk('previousSibling', $selector, false), null);
}

/**
Expand All @@ -634,21 +605,7 @@ public function prevUntil($selector = null): Query
*/
public function parentsUntil($selector = null): Query
{
$found = new SplObjectStorage();
$matcher = new NodeMatcher($selector);
foreach ($this->matches as $m) {
while ($m->parentNode->nodeType !== XML_DOCUMENT_NODE) {
$m = $m->parentNode;
if ($m->nodeType === XML_ELEMENT_NODE) {
if (! empty($selector) && $matcher->matches($m)) {
break;
}
$found->offsetSet($m);
}
}
}

return $this->inst($this->sortReverseDocumentOrder($found), null);
return $this->inst($this->sortReverseDocumentOrder($this->walk('parentNode', $selector, false)), null);
}

/**
Expand Down Expand Up @@ -732,19 +689,12 @@ public function not($selector): Query
*/
public function closest($selector): Query
{
$found = new SplObjectStorage();
$matcher = new NodeMatcher($selector);
foreach ($this->matches as $m) {
for (; $m !== null && $m->nodeType !== XML_DOCUMENT_NODE; $m = $m->parentNode) {
if ($matcher->matches($m)) {
$found->offsetSet($m);
break;
}
}
// As in jQuery, nothing is closest to an empty selector.
if (empty($selector)) {
return $this->inst(new SplObjectStorage(), null);
}

// XXX: Should this be an in-place modification?
return $this->inst($found, null);
return $this->inst($this->walk('parentNode', $selector, true, true), null);
}

/**
Expand All @@ -765,7 +715,7 @@ public function closest($selector): Query
*/
public function parent($selector = null): Query
{
return $this->getParentElements($selector, true);
return $this->inst($this->walk('parentNode', $selector, true), null);
}

/**
Expand All @@ -786,53 +736,11 @@ public function parent($selector = null): Query
*/
public function parents($selector = null): Query
{
return $this->getParentElements($selector, false);
}

/**
* Get ancestor(s) of each element in the DOMQuery.
*
* If a selector is present, only matching ancestors will be retrieved.
*
* @param string|null $selector
* A valid CSS 3 Selector.
* @param bool $immediate
* If function should return only the immediate parent
*
* @return DOMQuery
* A DOMNode object containing the matching ancestors.
* @throws ParseException
* @throws Exception
*/
private function getParentElements(?string $selector, bool $immediate): Query
{
$found = new SplObjectStorage();
$matcher = new NodeMatcher($selector);
foreach ($this->matches as $m) {
while ($m->parentNode && $m->parentNode->nodeType !== XML_DOCUMENT_NODE) {
$m = $m->parentNode;
if ($m->nodeType !== XML_ELEMENT_NODE) {
continue;
}
// parent() stops at the nearest matching ancestor of each element, so it tests
// the walk as it goes; parents() collects every ancestor and filters below.
if (! $immediate) {
$found->offsetSet($m);
} elseif (empty($selector) || $matcher->matches($m)) {
$found->offsetSet($m);
break;
}
}
}

if ($immediate) {
return $this->inst($found, null);
}

// jQuery returns the ancestors of a multi-element set in reverse document order, with
// duplicates removed. The selector is applied after that ordering, so a positional
// selector counts from the closest ancestor.
return $this->inst($this->filterCandidates($this->sortReverseDocumentOrder($found), $selector), null);
// duplicates removed. The selector is applied after that ordering.
$ancestors = $this->sortReverseDocumentOrder($this->walk('parentNode', null, false));

return $this->inst($this->filterCandidates($ancestors, $selector), null);
}

/**
Expand All @@ -856,19 +764,7 @@ private function getParentElements(?string $selector, bool $immediate): Query
*/
public function next($selector = null): Query
{
$found = new SplObjectStorage();
$matcher = new NodeMatcher($selector);
foreach ($this->matches as $m) {
while (isset($m->nextSibling)) {
$m = $m->nextSibling;
if ($m->nodeType === XML_ELEMENT_NODE && (empty($selector) || $matcher->matches($m))) {
$found->offsetSet($m);
break;
}
}
}

return $this->inst($found, null);
return $this->inst($this->walk('nextSibling', $selector, true), null);
}

/**
Expand All @@ -892,21 +788,7 @@ public function next($selector = null): Query
*/
public function nextAll($selector = null): Query
{
$found = new SplObjectStorage();
foreach ($this->matches as $m) {
while (isset($m->nextSibling)) {
$m = $m->nextSibling;
// Every sibling beyond one already collected was collected with it.
if ($found->offsetExists($m)) {
break;
}
if ($m->nodeType === XML_ELEMENT_NODE) {
$found->offsetSet($m);
}
}
}

return $this->inst($this->filterCandidates($found, $selector), null);
return $this->inst($this->filterCandidates($this->walk('nextSibling', null, false), $selector), null);
}

/**
Expand All @@ -931,19 +813,7 @@ public function nextAll($selector = null): Query
*/
public function prev($selector = null): Query
{
$found = new SplObjectStorage();
$matcher = new NodeMatcher($selector);
foreach ($this->matches as $m) {
while (isset($m->previousSibling)) {
$m = $m->previousSibling;
if ($m->nodeType === XML_ELEMENT_NODE && (empty($selector) || $matcher->matches($m))) {
$found->offsetSet($m);
break;
}
}
}

return $this->inst($found, null);
return $this->inst($this->walk('previousSibling', $selector, true), null);
}

/**
Expand All @@ -967,21 +837,7 @@ public function prev($selector = null): Query
*/
public function prevAll($selector = null): Query
{
$found = new SplObjectStorage();
foreach ($this->matches as $m) {
while (isset($m->previousSibling)) {
$m = $m->previousSibling;
// Every sibling beyond one already collected was collected with it.
if ($found->offsetExists($m)) {
break;
}
if ($m->nodeType === XML_ELEMENT_NODE) {
$found->offsetSet($m);
}
}
}

return $this->inst($this->filterCandidates($found, $selector), null);
return $this->inst($this->filterCandidates($this->walk('previousSibling', null, false), $selector), null);
}

/**
Expand Down Expand Up @@ -1090,6 +946,53 @@ public function siblings($selector = null): Query
return $this->inst($this->filterCandidates($found, $selector), null);
}

/**
* Walk from each matched element along a DOM axis, collecting the elements passed.
*
* @param string $axis
* The property to follow: 'nextSibling', 'previousSibling' or 'parentNode'.
* @param string|null $selector
* An optional CSS selector. With $first, the element to collect; otherwise, the element
* to stop before.
* @param bool $first
* Collect only the first element matching the selector (the first element, if there is
* none), rather than every element up to the first match.
* @param bool $inclusive
* Start the walk at the matched element itself rather than at its neighbour.
*
* @return SplObjectStorage
* @throws ParseException
*/
private function walk(string $axis, $selector, bool $first, bool $inclusive = false): SplObjectStorage
{
$found = new SplObjectStorage();
$matcher = empty($selector) ? null : new NodeMatcher($selector);
foreach ($this->matches as $m) {
$n = $inclusive ? $m : $m->{$axis};
for (; $n !== null && $n->nodeType !== XML_DOCUMENT_NODE; $n = $n->{$axis}) {
if ($n->nodeType !== XML_ELEMENT_NODE) {
continue;
}

if ($first) {
if ($matcher === null || $matcher->matches($n)) {
$found->offsetSet($n);
break;
}
continue;
}

// Every element beyond one already collected was collected with it, up to the same stop.
if ($found->offsetExists($n) || ($matcher !== null && $matcher->matches($n))) {
break;
}
$found->offsetSet($n);
}
}

return $found;
}

/**
* Sort a set of nodes into reverse document order.
*
Expand Down
42 changes: 42 additions & 0 deletions tests/QueryPath/DOMQueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2516,6 +2516,48 @@ public function testParentsUntil()
$this->assertEquals(2, qp($file, 'li')->parentsUntil('root')->count());
}

public function testParentsUntilOutsideTheDocument()
{
$doc = qp('<?xml version="1.0"?><root/>')->get(0)->ownerDocument;
$parent = $doc->createElement('parent');
$child = $parent->appendChild($doc->createElement('child'));

$this->assertSame('parent', qp($child)->parentsUntil('root')->tag());
$this->assertSame('parent', qp($child)->parentsUntil()->tag());
}

public function testUntilWalksFromElementsOnEachOthersPath()
{
$xml = '<?xml version="1.0"?><r><x><y><z/></y></x><a/><b/><c/><stop/><e/><f/></r>';

$tagsOf = function ($qp) {
$tags = [];
foreach ($qp->get() as $node) {
$tags[] = $node->tagName;
}
sort($tags);

return $tags;
};

$this->assertSame(['b', 'c', 'f'], $tagsOf(qp($xml, 'a, b, e')->nextUntil('stop')));
$this->assertSame(['a', 'b', 'e'], $tagsOf(qp($xml, 'c, f')->prevUntil('stop, x')));
$this->assertSame(['x', 'y'], $tagsOf(qp($xml, 'z, y')->parentsUntil('r')));
}

public function testTraversalTreatsAnEmptySelectorAsNone()
{
$xml = '<?xml version="1.0"?><r><a/><b/><c/></r>';

$this->assertSame('c', qp($xml, 'b')->nextUntil('')->tag());
$this->assertSame('a', qp($xml, 'b')->prevUntil('')->tag());
$this->assertSame('r', qp($xml, 'b')->parentsUntil('')->tag());
$this->assertSame('c', qp($xml, 'b')->next('')->tag());
$this->assertSame('a', qp($xml, 'b')->prev('')->tag());
$this->assertSame('r', qp($xml, 'b')->parent('')->tag());
$this->assertCount(0, qp($xml, 'b')->closest(''));
}

public function testSort()
{
$xml = '<?xml version="1.0"?><r><s/><i>1</i><i>5</i><i>2</i><i>1</i><e/></r>';
Expand Down
Loading