Match selectors against the nodes in hand, not their descendants - #72
Draft
jakejackson1 wants to merge 2 commits into
Draft
Match selectors against the nodes in hand, not their descendants#72jakejackson1 wants to merge 2 commits into
jakejackson1 wants to merge 2 commits into
Conversation
is(), filter(), and the selector-filtered traversal methods all asked "does this
node contain a match?" where jQuery asks "is this node a match?". They ran a
descendant search against each candidate, so a selector that matched anything
below a candidate kept that candidate.
html5qp('<p><span>foo</span></p>', 'p')->is('span'); // true, expected false
qp($xml, 'Demographics > Age > Name')->parents('Demographics');
// <Demographics>, <AmplifyReturn>, <ns1:AmplifyResponse>
qp($file, 'inner')->filter('li')->count(); // 2, expected 0
has() does what these used to do, and is unchanged: it remains the migration path
for callers that want the containment behaviour.
The single-node test is extracted into QueryPath\Helpers\NodeMatcher so the three
call paths cannot drift apart. It builds the traverser in "initialized" mode,
which treats the supplied nodes as the candidates rather than seeding a
descendant search, and leaves the scope node at its default so :scope resolves
against the document element exactly as it does in find(). Passing a candidate as
the scope node made every candidate match :scope, which is why parents(':scope')
returned every ancestor and children(':scope') returned every child.
filter() and children() filter their candidates as one set rather than one node
at a time. A per-node pass cannot evaluate a selector describing a position
within the set, because each node is the only member of its own one-element set.
parents() and parentsUntil() now return reverse document order with duplicates
removed, as jQuery does. Previously a set built from more than one starting
element was grouped by starting element.
DOMQueryTest::testFilter asserted the containment result and is rebaselined. It
is the test the original author's "fails unit tests" comment on filter() referred
to; it was the only one.
Fixes #51
Fixes #62
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 21, 2026
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #72 +/- ##
============================================
+ Coverage 89.44% 89.57% +0.12%
- Complexity 1342 1366 +24
============================================
Files 26 27 +1
Lines 3023 3070 +47
============================================
+ Hits 2704 2750 +46
- Misses 319 320 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
jakejackson1
added a commit
that referenced
this pull request
Aug 21, 2026
The assertion held the <div> and expected is(':text') to be true, which only
worked because is() ran a descendant search. #72 makes is() test the elements in
the match set, as jQuery does, so that assertion would flip to false.
Rewritten so it does not depend on which semantics are in force: the containment
question is asked with has(), which is what it always meant, and is() is asked of
the inputs themselves. It passes both with and without #72.
Also renamed $textNode to $firstInput in this test. contents()->eq(0) here is the
first <input> element, not a text node — the name is accurate in the sibling test
below, where the fixture really does hold text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jakejackson1
added a commit
that referenced
this pull request
Aug 21, 2026
Two assertions reached find() for a node that was already in their own match set, which only worked because find() self-matched. #73 makes find() search descendants only, as jQuery does. Rewritten to ask each question of the method that answers it: find() of a real descendant, filter()/is() of the elements in the set. The mixed-node fixture gains a nested <em> so find() still has something to reach, which keeps the point of the test — that a set holding a text node does not cause a fatal — intact on both sides of the selector. Passes with and without #72/#73. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two problems, both in the machinery this PR introduced. The selector was still evaluated per node in 12 places. The changelog states the rule — a per-node pass cannot answer a selector describing a position within the set, because each node is the only member of its own one-element set — and then filter() and children() honour it while every other method routes through matchesNodeSelector(). It does not show up yet, because :first and friends are still sibling-positional on main. Once #70 lands, not(':first') returns the empty set, siblings(':first') returns every sibling, and nextAll(':first') returns all of them. Verified against #70 merged locally. not(), siblings(), nextAll(), prevAll() and the parents() path now collect their candidates and filter the set once. That also drops a selector parse and four SplObjectStorage allocations per candidate: parents('div') on a 20-deep tree of 100 leaves parsed 'div' 2000 times for one call. matchesNodeSelector() stays for the loops that genuinely need a per-node answer — nextUntil(), prevUntil(), parentsUntil(), closest(), next(), prev() and parent(), which stop at the first match rather than collecting a set. The document-order sort was quadratic on document width. documentOrderPath() walked previousSibling per node, so sorting n siblings cost n^2/2 pointer hops, and array_unshift() per level made it quadratic on depth too. Sorting is now Util::sortDocumentOrder(), which indexes each child list once and memoizes for the duration of the call. parents() over a 4000-wide document: 253ms -> 33ms, linear again. The sorter is named and shaped to match the one #70 adds to the same file, so merging the two is a single "defined twice, keep one" rather than a reconcile. NodeMatcher::filter() now returns its result in the caller's order, so the order-restoring walk filter() and children() each carried is gone, and matchesAny() folds into matchesNode(). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Collapses #67, #69 and #71 into one branch. They were three PRs because there was one agent per
issue, but they are one defect at three layers, and reviewing them stacked meant reading the same
fix three times. Those PRs are closed in favour of this one.
Fixes #51. Fixes #62. Also fixes the
filter()bug that had no issue.The defect
is(),filter()and the selector-filtered traversal methods asked "does this node contain amatch?" where jQuery asks "is this node a match?". Each ran a descendant search against the
candidate, so anything matching below it kept it:
has()does exactly what these used to do and is unchanged — it is the migration path for anyonedepending on the old behaviour.
The fix
The single-node test is extracted into
QueryPath\Helpers\NodeMatcher, so the three call pathscannot drift apart. It builds the traverser in "initialized" mode — treating the supplied nodes as
the candidate set rather than seeding a descendant search — which is machinery the engine already
had; nothing under
src/CSS/changed.It also leaves the scope node at its default. Passing a candidate as the scope node makes every
candidate match
:scope, which is why the selector silently stopped filtering:filter()andchildren()filter their candidates as one set rather than one node at a time.A per-node pass cannot evaluate a selector describing a position within the set, because each
node is the only member of its own one-element set.
parents()/parentsUntil()now return reverse document order with duplicates removed, per thejQuery spec quoted in #62.
Breaking changes
is($sel)filter($sel)parents(),parentsUntil(),closest(),parent(),next*(),prev*(),siblings(),not()parents(),parentsUntil()orderingis()also no longer fatals on a match set holding non-element nodes —DOMTextraisedCall to undefined method DOMText::getElementsByTagName().The author's comment on filter()
filter()carried this since 2009:It fails exactly one test —
DOMQueryTest::testFilter, whose two meaningful assertions both pinthe containment result. Rebaselined here, with two tests added: one pinning the
filter()/has()difference so the migration path stays covered, one for set-level evaluation. No other existing
test encoded the old behaviour.
Interaction with #66
#66 documents a known limitation:
children()andfilter()built a traverser per node, so apositional pseudo-class saw a one-element set and
children('li:first')returned everylichild.Set-level filtering resolves it. Verified by merging #66 into this branch locally — 382 tests pass,
and
filter(':first')→a1,filter(':eq(3)')→b1,filter(':odd')→a2,b1,children('li:first')→x1, all matching jQuery. #66 needs no change; this removes thereason for its caveat.
#50 (issue #49) conflicts in one assertion: its committed
assertTrue($q->is(':text'))holds$qas the
<div>while only its children are:text, and becomesfalsehere. It needs flipping toassertFalseat merge.Verification
vendor/bin/phpunit— 346 tests, 1164 assertions, 0 failures (2 pre-existingcreate_functionskips)composer run lintandcomposer run lint:min-php— clean, PHP 7.1+tests/Issues/Issue51Test.php(11 tests),tests/Issues/Issue62Test.php(14 tests), 3 inDOMQueryTest🤖 Generated with Claude Code