Fix parents($selector) so the selector filters the ancestors themselves - #69
Closed
jakejackson1 wants to merge 3 commits into
Closed
Fix parents($selector) so the selector filters the ancestors themselves#69jakejackson1 wants to merge 3 commits into
jakejackson1 wants to merge 3 commits into
Conversation
`parents($selector)` (and every other selector-filtered traversal method in
`QueryPath\Helpers\QueryFilters`) tested each candidate with
`QueryPath::with($node)->is($selector)`. `is()` runs a `find()`, which searches
the node's *descendants*, so any ancestor that merely contained a matching
element was reported as a match.
`qp($xml, 'Demographics > Age > Name')->parents('Demographics')` therefore
returned `Demographics`, `AmplifyReturn` and `ns1:AmplifyResponse` instead of
just `Demographics`.
Add a private `matchesNodeSelector()` helper that builds a `CSS\DOMTraverser`
in "initialized" mode with the single node as the candidate set, the same
machinery `children()` already uses. The node itself is the only candidate, so
the selector is matched against it as an element; combinators are still
evaluated by walking up from the candidate, so full selectors keep working.
`is()` and `filter()` are deliberately left alone: correcting them is the
subject of #51, and an existing assertion in `testFilter` depends on the
current containment behaviour.
Also align `parents()` and `parentsUntil()` result ordering with jQuery:
reverse document order with duplicates removed. Previously a set built from
more than one starting element was grouped by starting element.
Fixes #62
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## issue-51 #69 +/- ##
==============================================
+ Coverage 89.44% 89.74% +0.29%
- Complexity 1349 1363 +14
==============================================
Files 27 27
Lines 3041 3071 +30
==============================================
+ Hits 2720 2756 +36
+ Misses 321 315 -6 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
# Conflicts: # CHANGELOG.md
matchesNodeSelector() built its own DOMTraverser, duplicating the one in is().
It now delegates to QueryPath\Helpers\NodeMatcher (added on the issue-51 branch,
which this is stacked on).
That also fixes a bug: the candidate node was being passed to the traverser as
its scope node, so every candidate matched :scope and the selector stopped
filtering. parents(':scope') returned every ancestor instead of the document
element, disagreeing with find(':scope').
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 21, 2026
Member
Author
|
Collapsed into #72, which carries all of this work unchanged — same tree, one branch. Closing to keep the review in one place; reopen if the split turns out to be preferable. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #62
The bug
parents($selector)tested each ancestor withQueryPath::with($node, null, $this->options)->is($selector).is()is implemented asbranch($selector)->count() > 0, which runs afind()— andfind()searches the node's descendants. So the test was really "does this ancestor contain something matching the selector", not "does this ancestor match the selector".The approach
Added a private helper
QueryFilters::matchesNodeSelector()that builds aCSS\DOMTraverserwith the single node as its candidate set and$initialized = true. That mode tells the traverser to treat the supplied set as the candidates rather than seeding it with a descendant search, so the node itself is matched as an element. Combinators are still resolved by walking up from the candidate, so full selectors (AmplifyReturn > Demographics,ns1|AmplifyResponse) keep working.This is not new machinery —
children()already uses exactly this pattern (new DOMTraverser($tmp, true, $c)), and it is what the long-standing commented-out line infilter()was reaching for.Every
->is($selector)element test insrc/Helpers/QueryFilters.phpnow routes through the helper, because they all had the same defect:parent(),parents(),parentsUntil(),closest(),next(),nextAll(),nextUntil(),prev(),prevAll(),prevUntil(),siblings(),not().Some of these produced the right answer by accident.
closest('Demographics')was correct only because it breaks on the first hit walking upward;closest('AmplifyReturn')returnedns1:AmplifyResponse, becauseAmplifyReturncontains no descendant calledAmplifyReturn.parentsUntil('AmplifyReturn')collectedAmplifyReturnitself for the same reason.siblings($selector)delegated tofilter(), which has the same descendant-search problem; it now filters inline instead.Behaviour changes (please read)
Ordering.
parents()andparentsUntil()now return results in reverse document order with duplicates removed, matching jQuery. Previously results were accumulated per source element, so a set built from more than one starting element came back grouped by starting element:For a single starting element nothing changes — walking up already yielded reverse document order.
parent()keeps its per-element ordering (jQuery does not reverseparent()).Filtered traversal results shrink. Anything that previously matched only by containment no longer matches. This is the point of the fix, but it is a visible behaviour change for
parents(),parentsUntil(),closest(),next*(),prev*(),siblings(), andnot().No existing test encoded the old behaviour — the full suite (319 tests) passed unchanged before the new tests were added.
Interaction with #51 —
is()andfilter()left aloneThe root cause lives in shared code:
QueryChecks::is(). Fixingis()itself would fix all of the above in one line, and it would also be the fix for #51 (is()reporting containment matches). I deliberately did not touchis()orfilter()so the two PRs do not collide:is('Demographics')on<AmplifyReturn>still returnstruehere. That ishtml5qp('<p><span>foo</span></p>')->is('span')returns true #51's call to make.filter()has the identical bug. Switching it tonew DOMTraverser($tmp, true, $m)(the commented-out line already in the file) makesQueryPathTests\DOMQueryTest::testFilterfail: it assertsqp($file)->filter('li')->count() === 1, whereqp($file)is the root element and only contains anli. jQuery would return 0. Changing that means rewriting an existing test's expectations, which belongs with theis()fix, not here.When #51 lands,
matchesNodeSelector()and the fixedis()should be reconciled — most likely by havingis()use the same helper and moving the helper somewhere both traits can share. Note also thatmatchesNodeSelector(),sortReverseDocumentOrder(),documentOrderPath()andcompareDocumentOrderPaths()are private methods on theQueryFilterstrait; ifQueryChecksadds a method with any of those names,DOMQuerywill fatal on the collision.Deliberately left out
parent($selector)still walks the whole ancestor chain. jQuery's.parent(selector)returns the immediate parent filtered by the selector, and nothing if the immediate parent does not match. QueryPath returns the nearest matching ancestor — i.e. it behaves likeclosest(). Its docblock documents this explicitly ("this will return the nearest matching parent for each element"), so it looks like intentional legacy behaviour rather than the parents() doesn't match jQuery functionality when using selector #62 defect, and changing it is a separate, larger break. Flagging it rather than fixing it.prevAll()/prevUntil()ordering. jQuery reverses these too (rparentsprev). Their per-element order is already reverse document order; only the multi-element grouping diverges, same asparents()did. Left alone to keep this PR's ordering change scoped to the ancestor methods named in the issue.nextAll()/siblings()/children()ordering. jQuery appliesuniqueSort(forward document order) to these. QueryPath groups by source element. Not changed.Tests
New file
tests/Issues/Issue62Test.php(13 tests, 36 assertions) —tests/Issues/is a new directory;phpunit.xmlalready scans./tests/recursively so no config change was needed. Coverage includes the exact case from the issue, the namespaced fixture (ns1|AmplifyResponse,*|AmplifyResponse), selectors with combinators, reverse-document-order and de-duplication, and theclosest()/parentsUntil()/siblings()/next*()/prev*()/not()cases that shared the bug. It reuses the existingtests/amplify.xmlfixture rather than adding a new one.No PHP 7.2+ syntax was introduced.
🤖 Generated with Claude Code
Reconciliation with #67
This PR is now based on
issue-51(#67), notmain, and must be merged after it.#67 fixes
is()itself, which is the shared root cause. This PR removes everyis()call sitefrom
QueryFilters, so the two no longer overlap: #67 governs direct calls tois(), this PRgoverns the traversal methods, and nothing is fixed twice.
The private
matchesNodeSelector()helper added here duplicated the traverser setup inis().It now delegates to
QueryPath\Helpers\NodeMatcher, introduced in #67.One behaviour fix that came out of the merge
matchesNodeSelector()passed the candidate node to the traverser as its scope node, whichmade every candidate match
:scope— so a:scopeselector stopped filtering:NodeMatcherleaves the scope node at its default, soparents(':scope')andfind(':scope')now agree. Covered by
testScopePseudoClassIsResolvedAgainstTheDocument().Verified on the merged result: 344 tests, 1151 assertions, 0 failures;
phpcsandlint:min-phpclean.