Query filter contract
August 29, 2026 · View on GitHub
The query package guarantees, for every Filter produced by ParseCondition
or the exported constructors (NewComp, NewCompValue, NewInValue):
-
Build once, reuse concurrently. A built filter is immutable. It may be shared by any number of queries running in parallel;
OkandIndexBoundstake no locks and never mutate filter state. Corollary for hand-constructed filters: do not mutate exported fields (Comp.EqValue,In.Values) after the filter has been handed to a query. -
Alloc-free evaluation.
Okperforms zero heap allocations once the per-query*syncpool.DocBufferis warm. Per-document scratch lives in that buffer (one per executing query), never in the filter. -
Read-only bound bytes.
IndexBoundsmay return bounds whoseStart/Endalias filter-owned memory (Comp.EqValue,In's interned keys, the static type-edge table). These slices are clipped tocap == len, so planner bound-extension appends (AdjustBoundsForNonUnique,padForwardBounds,padReverseBounds) reallocate instead of writing into memory shared across queries. Code consuming bounds must never writeStart/Endin place. -
Two bound channels.
Filter.IndexBoundsis the WIDE channel: a sound over-approximation (superset of every matching doc's index entries) —Andkeeps only the first contributing same-field conjunct, which is what keeps array/multi-key seeks correct.TightIndexBounds(query/tight_bounds.go) is the TIGHT channel: same-field conjuncts interval-intersected viaBounds.Intersect, plus an explicitemptyflag for provably-empty value sets. Tight bounds are safe for cost estimation always, and for seeks/emptiness decisions only where fan-out entries provably cannot exist (pk namespace, scalar-proven indexes) — see theTightIndexBoundsdoc comment. Rules 1-3 apply to both channels.
Pinned by query/filter_contract_test.go: TestFilterConcurrentReuse (run
under -race), TestFilterOkAllocFree, and
TestIndexBounds_FilterOwnedBytesAreCapped; the tight channel additionally by
query/tight_bounds_test.go (TestTightIndexBounds_CapClipped,
TestTightIndexBounds_SubsetOfWide).
Note: the contract covers filters only. Sort/modifier values are cheap to
build per query and carry no such guarantee.
-
Source filters do not evaluate.
TextandKnnare SOURCE filters: matching is performed by an index scan the executor builds, not byOk. TheirOkis a fail-direction choice, not a predicate:Text.Okreturnstrueunconditionally (fail-open),Knn.Okreturnsfalse(fail-closed — a leaked$knnmust match nothing rather than everything, because the query verbs includeDelete). External consumers that post-filter by callingFilter.Okdirectly (the subscription pattern) MUST reject filters containing either — detect them withquery.ContainsSourceFilter(walks the whole tree).Corollary for CUSTOM
Filterimplementations: never embed a source filter inside one. The detection/rejection walks descend only the package's own node types (And/Or/Nor/Not/Key, value and pointer forms alike) — a foreign type is structurally opaque, so an embeddedKnnsilently matches nothing (fail-closed inherited through a pass-through wrapper) and an embeddedTextsilently matches everything, on every verb,err == nil. A custom filter that INVERTS its innerOkreflects fail-closed into match-all, exactly likeNotwould — that is arbitrary user matching code, outside what any walk can guard. Pinned byTestKnn_InsideCustomFilterFailsClosed. -
TypeVectorF32is not orderable (Rule V). InComp, an ordering op ($gt/$gte/$lt/$lte) evaluates tofalsewhenever either side is a packed vector — including vector-vs-vector, which is stricter than item 13's bracketing.$eqis byte equality,$neits negation. The parser additionally rejects an ordering op whose OPERAND is a$vectorliteral (ErrVectorNotOrderable), which also keeps it out of$not. Consequence (item 13 again):{"v":{"$not":{"$gt":1}}}matches a vector-valuedv—$notof an unsatisfiable comparison is true. -
$vector,$oidand$binaryare forbidden as option-key names inside any operator's options object. anyenc decodes a single-key{"$vector":[…]}(et al.) object into a typed VALUE before the query parser sees it, so an options object whose sole key were one of these would change type depending on which other options are present. This is why$knn's payload key is$query. -
A filter overriding
Ok's truth direction must be checked againstGuaranteesPresence. It probes the inner filter'sOkdirectly (!Ok(nil) && !Ok(null)⇒ "guarantees presence") — a fail-closedOkreads as the AGGRESSIVE answer and feeds sparse-index selection. Source filters get explicitfalsearms there; any futureOk-overriding filter needs the same. -
Null matches missing. A missing field evaluates as an explicit
nullthroughout the filter surface:Okreceives a nil*anyenc.Value, the index stores the doc under theTypeNullkey, and every equality-family operator treats the two identically —{"$eq":null},{"$in":[…,null,…]}match missing fields;{"$ne":null},{"$nin":[…,null,…]}exclude them (Mongo's null model). Sparse-index selection follows automatically:GuaranteesPresenceprobesOk(nil)/Ok(null), so an operator matching either keeps sparse indexes out of the plan. -
Array sort keys are the min/max element. A sort field holding a non-empty array sorts by its MINIMUM element ascending / MAXIMUM element descending — chosen from all elements, independent of any query predicate (MongoDB ≥ 4.4 semantics, SERVER-19402). This is one definition shared by every consumer:
SortField.AppendKey, the raw fast path (AppendKeyRaw, byte-identical —TestSortAppendKeyRawParity), the aggregation$sortstage, and index-order-providing scans (the planner demotesExactSortto an in-memory sort whenever the index's intrinsic order could differ: any sort run on a compound index — its whole-array entries can precede the key element in either direction, since element types tagged aboveTypeArrayexist — or a single-field sort field with a lower cut ascending / an upper cut descending — unless the index is scalar-proven via its sticky multikey flag. A cut that is only a type bracket edge (item 13) is opened back up instead of demoting: the scan then covers the pre-bracketing range, whose open side cannot hide the extremum element, and the residual filter still applies the bracket). Documented divergences from Mongo, both deliberate: an EMPTY array sorts by its whole-array encoding (after scalars, where the index stores its only entry; Mongo sorts[]before null — matching that would need a key encoding belowTypeNullon disk), and cross-type order is anyenc tag order, not the BSON type order, as everywhere else in this engine. -
$regexis RE2, case-sensitive by default;$optionsis its only modifier. The pattern is compiled by Go'sregexp(RE2 syntax — no backreferences or lookarounds), unanchored, case-sensitive. Mongo-style{f: {"$regex": "...", "$options": "i"}}is accepted with the flags RE2 shares with Mongo:i(case-insensitive),m(multiline^/$),s(dot matches newline); Mongo'sxanduare rejected (ParseError,Op: "$options").$optionsis in the operator vocabulary but is NOT a predicate: it must accompany a$regexin the same condition object (standalone or top-level use is a parse rejection), and it compiles into the siblingRegexpfilter — equivalent to prefixing the pattern with(?flags). Inline flag groups in the pattern itself remain legal. Duplicate$optionskeys collapse last-wins in the JSON parser (standard JSON behavior); the surviving occurrence is validated like any other. Anchored-prefix index bounds (^literal…) are suppressed exactly when a flag can widen the match:i(case folding) andm(any-line anchoring) keep the scan wide — via$optionsor a leading^(?i)in the pattern — whilesonly changes what.matches and keeps the prefix bounds. Pinned byTestRegexp(query/filter_test.go) and the$optionscases inTestParseError. -
Parse rejections are structured. Everything
ParseCondition,ParseModifier, and the aggregation pipeline parser reject — unknown operator, wrong operand type, malformed$and/$or/$norarray, bad$regex, unknown modifier, unknown stage, … — is reported as a*query.ParseErrorwhoseSourcenames the grammar ("filter", the default when empty;"modifier";"pipeline"), whosePathlocates the offending key inside the input document ("tags.$sizee","$and.1.price.$gt","$inc.count"; pipeline paths lead with the stage index:"1.$match.a.$gt"), whoseOpnames the operator at fault, and whoseReasonis a self-contained message. Finer classes stay reachable througherrors.Is(ParseError.Err, theUnwraptarget):ErrUnknownOperatorfor vocabulary misses across all three grammars,ErrVectorNotOrderablefor ordering ops on vector operands. A known operator in a position that does not accept it ({"$eq":1}at top level,{"$set":{"$a":1}}) is deliberately NOTErrUnknownOperator. There is no swallow-and-fallback anywhere in the grammars: a$pullobject operand is a condition (as in Mongo), and a malformed one is a rejection, never a literal-equality pull — a swallowed error would make the same bytes mean different pulls across library versions in a multi-process deployment. Each vocabulary is data:query.Operators(),query.ModifierOperators(),anystore.AggregateStages()andanystore.AggregateAccumulators()return exactly what the parsers recognize, so callers advertising a grammar (docs, 400 payloads) never hand-copy the lists. Pinned byquery/errors_test.go(TestParseError,TestParseModifierError,TestParseConditionErrorsAreStructured,TestOperators,TestModifierOperators) andinternal/aggregate/pipeline_parse_test.go(TestPipelineParseError,TestStages,TestAccumulators). -
Ordering predicates are type-bracketed; sort is not.
$gt/$gte/$lt/$ltecompare only inside one type bracket, as MongoDB's query operators do: a value of another type is never less or greater, it is not matched. A bracket is the anyenc type tag, except thatfalse/trueform one bracket ({"$gt":false}matchestrue); numbers already share one type. A missing field isnulland so in no other bracket:{"$lt":5}never matches an absent field,{"$gte":null}/{"$lte":null}match null and missing,{"$gt":null}/{"$lt":null}match nothing. Array fields bracket per element (a whole-array comparison needs an array operand).$eq/$ne/$in/$ninwere already type-strict and are unchanged ($ne 5still matches a string, as in Mongo).Comp.IndexBoundsemits the bracket-clamped range —$gt Xis(X, <next tag>),$lt Xis[<tag>, X)— so bounds stay the exact value image of the predicate (the planner's residual elisions depend on that, together with its key-suffix pads) and a wrong-typed literal is an empty seek, not an index walk. The bracket edges are marked on theBound(StartIsTypeEdge/EndIsTypeEdge) so the planner can tell an edge from a value cut. Shapes with nothing to seek contribute no bounds: an empty half-open range ({"$lt":null},{"$lt":false}), an empty operand, and an ordering op against a vector (Rule V).$pullconditions are query predicates and bracket the same way.Sort, index-order scans and the aggregation expression operators ($gt,$cmp,$min/$max,$sort) keep the full anyenc tag order, exactly as Mongo's sort and$exprkeep full BSON order. Pinned byTestComp_TypeBracketing,TestCompOkScalar_MatchesMarshalReference(the oracle carries the rule) andTestGuaranteesPresence($lt/$ltewith a non-null operand guarantee presence: they reject null and missing).