From 9f3d9b22eab7d5b46dbadbb15801bd754020c9fe Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Mon, 26 Oct 2026 17:01:02 +1100 Subject: [PATCH 1/3] Count even() and odd() from zero, as jQuery and :even/:odd do even() kept positions 1, 3, 5 and odd() kept 0, 2, 4, the reverse of jQuery's .even() and .odd() and of QueryPath's own :even and :odd pseudo-classes. Both now go through one helper. Fixes #145. Co-Authored-By: Claude Opus 5.5 --- src/Helpers/QueryFilters.php | 54 ++++++++++++++++---------------- tests/Issues/Issue145Test.php | 38 ++++++++++++++++++++++ tests/QueryPath/DOMQueryTest.php | 4 +-- 3 files changed, 67 insertions(+), 29 deletions(-) create mode 100644 tests/Issues/Issue145Test.php diff --git a/src/Helpers/QueryFilters.php b/src/Helpers/QueryFilters.php index 8b73c7b..a4d26a0 100644 --- a/src/Helpers/QueryFilters.php +++ b/src/Helpers/QueryFilters.php @@ -373,56 +373,56 @@ public function eachLambda($lambda): Query } /** - * Get the even elements, so counter-intuitively 1, 3, 5, etc. + * Get the matches at even, zero-based positions: 0, 2, 4, etc. + * + * As in jQuery, and as filter(':even') does, so the first match is kept. * * @return DOMQuery - * A DOMQuery wrapping all of the children. + * A DOMQuery wrapping the matches at even positions. * @throws ParseException - * @see parent() - * @see parents() - * @see next() - * @see prev() + * @see odd() * @since 2.1 * @author eabrand - * @see removeChildren() */ public function even(): Query { - $found = new SplObjectStorage(); - $even = false; - foreach ($this->matches as $m) { - if ($even && $m->nodeType === XML_ELEMENT_NODE) { - $found->offsetSet($m); - } - $even = $even ? false : true; - } - - return $this->inst($found, null); + return $this->everyOther(0); } /** - * Get the odd elements, so counter-intuitively 0, 2, 4, etc. + * Get the matches at odd, zero-based positions: 1, 3, 5, etc. + * + * As in jQuery, and as filter(':odd') does, so the first match is dropped. * * @return DOMQuery - * A DOMQuery wrapping all of the children. + * A DOMQuery wrapping the matches at odd positions. * @throws ParseException - * @see parent() - * @see parents() - * @see next() - * @see prev() + * @see even() * @since 2.1 * @author eabrand - * @see removeChildren() */ public function odd(): Query + { + return $this->everyOther(1); + } + + /** + * Keep every second match, starting at the given zero-based position. + * + * @param int $offset + * 0 for the even positions, 1 for the odd. + * + * @return DOMQuery + * @throws ParseException + */ + private function everyOther(int $offset): Query { $found = new SplObjectStorage(); - $odd = true; + $i = 0; foreach ($this->matches as $m) { - if ($odd && $m->nodeType === XML_ELEMENT_NODE) { + if ($i++ % 2 === $offset && $m->nodeType === XML_ELEMENT_NODE) { $found->offsetSet($m); } - $odd = $odd ? false : true; } return $this->inst($found, null); diff --git a/tests/Issues/Issue145Test.php b/tests/Issues/Issue145Test.php new file mode 100644 index 0000000..f260275 --- /dev/null +++ b/tests/Issues/Issue145Test.php @@ -0,0 +1,38 @@ +'; + + public function testEvenKeepsTheZeroBasedEvenPositions() + { + $this->assertSame('a,c,e', $this->idList(qp(self::XML, 'i')->even())); + $this->assertSame('a', $this->idList(qp(self::XML, '#a')->even())); + $this->assertSame('', $this->idList(qp(self::XML, 'nothing')->even())); + } + + public function testOddKeepsTheZeroBasedOddPositions() + { + $this->assertSame('b,d', $this->idList(qp(self::XML, 'i')->odd())); + $this->assertSame('', $this->idList(qp(self::XML, '#a')->odd())); + $this->assertSame('', $this->idList(qp(self::XML, 'nothing')->odd())); + } + + public function testEvenAndOddAgreeWithThePseudoClasses() + { + $qp = qp(self::XML, 'i'); + + $this->assertSame($this->idList($qp->filter(':even')), $this->idList($qp->even())); + $this->assertSame($this->idList($qp->filter(':odd')), $this->idList($qp->odd())); + } +} diff --git a/tests/QueryPath/DOMQueryTest.php b/tests/QueryPath/DOMQueryTest.php index 674f925..dba8df4 100644 --- a/tests/QueryPath/DOMQueryTest.php +++ b/tests/QueryPath/DOMQueryTest.php @@ -2460,14 +2460,14 @@ public function testEven() { $file = DATA_FILE; $this->assertEquals(1, qp($file, 'inner')->even()->count()); - $this->assertEquals(2, qp($file, 'li')->even()->count()); + $this->assertEquals(3, qp($file, 'li')->even()->count()); } public function testOdd() { $file = DATA_FILE; $this->assertEquals(1, qp($file, 'inner')->odd()->count()); - $this->assertEquals(3, qp($file, 'li')->odd()->count()); + $this->assertEquals(2, qp($file, 'li')->odd()->count()); } public function testFirst() From c852a5191bd9d8785f6393838bf093a2707c0aee Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Mon, 26 Oct 2026 17:17:21 +1100 Subject: [PATCH 2/3] Add a CHANGELOG entry Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 527a87d..4abdcec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 +- **Breaking behaviour change.** `even()` and `odd()` now count from zero, as jQuery's `.even()` and `.odd()` and QueryPath's own `:even` and `:odd` pseudo-classes do (issue #145). `even()` keeps the 1st, 3rd, 5th, ... matches and `odd()` keeps the 2nd, 4th, ..., the reverse of before. Swap the calls to keep the previous result # 4.2.0 From 346ec280b6aad8f4c344fb3d4b089fd7e48d3afb Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Mon, 26 Oct 2026 17:23:50 +1100 Subject: [PATCH 3/3] Share :even and :odd's implementation with even() and odd() Co-Authored-By: Claude Opus 5.5 --- src/Helpers/QueryFilters.php | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/src/Helpers/QueryFilters.php b/src/Helpers/QueryFilters.php index a4d26a0..31a69fc 100644 --- a/src/Helpers/QueryFilters.php +++ b/src/Helpers/QueryFilters.php @@ -373,9 +373,8 @@ public function eachLambda($lambda): Query } /** - * Get the matches at even, zero-based positions: 0, 2, 4, etc. - * - * As in jQuery, and as filter(':even') does, so the first match is kept. + * Get the matches at zero-based even positions (0, 2, 4, ...), as jQuery's .even() and + * the :even pseudo-class do. * * @return DOMQuery * A DOMQuery wrapping the matches at even positions. @@ -386,13 +385,12 @@ public function eachLambda($lambda): Query */ public function even(): Query { - return $this->everyOther(0); + return $this->positional('even'); } /** - * Get the matches at odd, zero-based positions: 1, 3, 5, etc. - * - * As in jQuery, and as filter(':odd') does, so the first match is dropped. + * Get the matches at zero-based odd positions (1, 3, 5, ...), as jQuery's .odd() and + * the :odd pseudo-class do. * * @return DOMQuery * A DOMQuery wrapping the matches at odd positions. @@ -403,24 +401,22 @@ public function even(): Query */ public function odd(): Query { - return $this->everyOther(1); + return $this->positional('odd'); } /** - * Keep every second match, starting at the given zero-based position. + * Apply a positional pseudo-class (`even`, `odd`, ...) to the match set, as filter() would. * - * @param int $offset - * 0 for the even positions, 1 for the odd. + * @param string $name * * @return DOMQuery * @throws ParseException */ - private function everyOther(int $offset): Query + private function positional(string $name): Query { $found = new SplObjectStorage(); - $i = 0; - foreach ($this->matches as $m) { - if ($i++ % 2 === $offset && $m->nodeType === XML_ELEMENT_NODE) { + foreach (Util::applyPositionalPseudoClass(iterator_to_array($this->matches, false), $name) as $m) { + if ($m->nodeType === XML_ELEMENT_NODE) { $found->offsetSet($m); } }