Predicate
What a query asks of a document.
Two rules give the tree its meaning, and both are consequences of how a path is walked rather than choices made here.
A leaf is existential over the values at its path. $.tags[*] eq "a" holds for a document with any tag equal to a, because the walk reports one value per occurrence and a leaf asks whether any of them satisfies it. A "for all" reading would need a second walk and a second definition of what a path means, which is the thing this engine most consistently refuses.
Each leaf is existential independently, so a conjunction over [*] is not correlated. This is the consequence of the rule above that surprises people, and it is a defined semantics rather than an oversight:
{"items":[{"sku":"A","qty":1},{"sku":"B","qty":5}]} matches
{"items":[{"sku":"A","qty":5},{"sku":"B","qty":1}]} matches
and($.items[*].sku eq "A", $.items[*].qty eq 5)The first document matches although no single element satisfies both: the sku comes from element 0 and the qty from element 1. Each leaf is settled by any value at its own path, and the conjunction is then over the two per-leaf answers rather than over elements. The indexed and unindexed answers are identical, which is the invariant holding exactly — an index changed the speed and not the answer.
To correlate, ask for it: ElemMatch. elemMatch(path("$.items[*]"), and(…)) holds only when one element satisfies the whole of its operand. It is a separate node rather than a mode of And precisely because the reading above is a defined semantics that must not change: an existing conjunction means what it has always meant, and the correlated question is a different question with a different spelling.
Splitting the document is still the other answer, and still a good one. One key per element — order:00123#item:00007 — makes each element a document, so the conjunction is over one document and the correlation is exact. It costs no engine feature, an ordered-key LSM reassembles the parent in one contiguous range scan, and $.items[*].sku collapses to $.sku. Which to reach for is a modelling decision: split when the elements are the things you query, and use ElemMatch when the document is.
Not is the document-level complement of that, so not($.tags[*] eq "a") holds for a document whose tags are all something else, for one whose tags are numbers, and for one with no tags at all. Which values count as comparable is type bracketing — a numeric predicate matches numbers only — and that rule has exactly one definition in the engine, in ColumnPredicate.matches, which every leaf lowers to.
There is deliberately no NE operator. not(path("$.a") eq 1) is the only spelling, so negation has one meaning; a separate NE would have to decide for itself what an absent path or a string-valued $.a does, and the day it disagreed with NOT EQ nothing would say which was right.
Build one with the DSL rather than by hand:
val predicate = and(
path("$.team") eq "analytics",
path("$.score") ge 10,
not(path("$.retired").exists()),
)Inheritors
Types
path carries at least one value, the JSON null included.
Some value at path is the JSON null.