Skip to content
Open
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 @@ -58,6 +58,7 @@ QueryPath Changelog
- 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
- **Behaviour change.** `prevAll()`, `prevUntil()`, `nextAll()`, `nextUntil()`, `siblings()`, `parent()` and `closest()` now return the results for a multi-element set in jQuery's order, instead of grouped by starting element (issue #117). `prevAll()` and `prevUntil()` return reverse document order, as `parents()` and `parentsUntil()` already did, and the others return document order. `qp($xml, 'c')->add('d')->prevAll()` now returns `c, b, a` rather than `b, a, c`. `next()` and `prev()` keep the order of the starting elements, as in jQuery, and the results for a single starting element are unchanged
- **Behaviour change.** A positional pseudo-class (`:first`, `:eq()`, `:last`, ...) in the stop selector of `nextUntil()`, `prevUntil()` and `parentsUntil()` now counts across the whole document, as jQuery's does, instead of seeing each sibling or ancestor as a one-element set (issue #118). `nextUntil(':first')` used to stop at once and now walks to the end, and `nextUntil(':eq(3)')` stops at the fourth element in the document. `closest()` no longer matches a positional selector at all, as in jQuery, where `closest(':first')` used to return the element itself. `next()`, `prev()` and `parent()` still test each candidate on its own, which agrees with jQuery for a single starting element
- **Behaviour change.** `is()` now counts a positional pseudo-class (`:first`, `:last`, `:eq()`, `:lt()`, `:gt()`, `:odd`, `:even`) across the whole document, as jQuery's `.is()` does, instead of within the match set (issue #140). It returns `true` if any element of the set is among the document's matches, so `qp($xml, '#c')->is(':first')` is now `false`. `filter()` still counts within the set. `is()` with a `DOMNode` or `SplObjectStorage` argument, or with a selector that has no positional pseudo-class, is unchanged

# 4.2.0

Expand Down
25 changes: 25 additions & 0 deletions src/Helpers/NodeMatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,31 @@ public function matchesInDocument($node): bool
return $this->inDocument === false ? $this->matches($node) : $this->inDocument->offsetExists($node);
}

/**
* Test whether at least one of the given nodes matches as jQuery's .is() tests it.
*
* @see matchesInDocument()
*
* @param SplObjectStorage $nodes
*
* @return bool
* @throws ParseException
*/
public function matchesAnyInDocument(SplObjectStorage $nodes): bool
{
if (! Util::selectorHasPositionalPseudoClass($this->selector)) {
return $this->matchesAny($nodes);
}

foreach ($nodes as $node) {
if ($this->matchesInDocument($node)) {
return true;
}
}

return false;
}

/**
* @param \DOMDocument $document
*
Expand Down
6 changes: 5 additions & 1 deletion src/Helpers/QueryChecks.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ trait QueryChecks
*
* Unlike jQuery's version, this supports full selectors (not just simple ones).
*
* As in jQuery, a positional selector (`:first`, `:eq()`, ...) counts across the whole
* document, not within the match set: `is(':first')` is TRUE only if the set holds the
* document's first element. filter() counts within the set.
*
* Non-element nodes in the match set can never match a CSS selector, and are skipped.
*
* @param string|DOMNode $selector
Expand Down Expand Up @@ -73,7 +77,7 @@ public function is($selector): bool
throw new Exception('Cannot compare an object to a DOMQuery.');
}

return (new NodeMatcher($selector))->matchesAny($this->matches);
return (new NodeMatcher($selector))->matchesAnyInDocument($this->matches);
}

/**
Expand Down
83 changes: 83 additions & 0 deletions tests/Issues/Issue140Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php
/**
* @file
*
* Regression tests for https://github.com/GravityPDF/querypath/issues/140
*
* is() counts a positional pseudo-class across the document, as jQuery's .is()
* does, where filter() counts it within the set. The expected values are
* jQuery 3.7.1's.
*/

namespace QueryPathTests;

use SplObjectStorage;

class Issue140Test extends TestCase
{
// Document order: r, a, b, c, d.
private const XML = '<?xml version="1.0"?><r><i id="a"/><i id="b"/><i id="c"/><i id="d"/></r>';

public function issueTableProvider(): array
{
return [
'#c :first' => [['c'], ':first', false],
'#c i:first' => [['c'], 'i:first', false],
'#a i:last' => [['a'], 'i:last', false],
'#c, #d i:eq(1)' => [['c', 'd'], 'i:eq(1)', false],
'#a :eq(1)' => [['a'], ':eq(1)', true],
'#c i:eq(2)' => [['c'], 'i:eq(2)', true],
'#c :eq(2)' => [['c'], ':eq(2)', false],
'#c, #d i:last' => [['c', 'd'], 'i:last', true],
'#a, #b i:gt(1)' => [['a', 'b'], 'i:gt(1)', false],
'#a, #d i:odd' => [['a', 'd'], 'i:odd', true],
'#b i:not(:first)' => [['b'], 'i:not(:first)', true],
'#a i:not(:first)' => [['a'], 'i:not(:first)', false],
];
}

/**
* @dataProvider issueTableProvider
*/
public function testIsCountsAPositionalSelectorAcrossTheDocument(array $ids, $selector, $expected)
{
$this->assertSame($expected, $this->selectIds(self::XML, ...$ids)->is($selector));
}

public function testFilterStillCountsWithinTheSet()
{
$this->assertSame('c', $this->idList($this->selectIds(self::XML, 'c')->filter('i:first')));
$this->assertSame('d', $this->idList($this->selectIds(self::XML, 'c', 'd')->filter('i:eq(1)')));
}

public function testNonPositionalSelectorsAreUnchanged()
{
$qp = $this->selectIds(self::XML, 'b', 'c');

$this->assertTrue($qp->is('i'));
$this->assertTrue($qp->is('#c'));
$this->assertTrue($qp->is('r > i:nth-child(2)'));
$this->assertFalse($qp->is('#a'));
$this->assertFalse($qp->is('r'));
}

public function testNodeAndSetArgumentsAreUnchanged()
{
$qp = $this->selectIds(self::XML, 'c');
$this->assertTrue($qp->is($qp->get(0)));
$this->assertFalse($qp->is(qp(self::XML, '#c')->get(0)));

$set = new SplObjectStorage();
$set->offsetSet($qp->get(0));
$this->assertTrue($qp->is($set));
}

public function testNodesThatAreNotElementsAreSkipped()
{
$xml = '<?xml version="1.0"?><r>text<i id="a"/><!-- c --><i id="b"/></r>';

$this->assertTrue(qp($xml, 'r')->contents()->is('i:first'));
$this->assertFalse(qp($xml, 'r')->contents()->is('i:eq(5)'));
$this->assertFalse(qp($xml, 'r')->contents()->eq(0)->is(':first'));
}
}
Loading