// IN-list hoisting and slot analysis: a batch-constant list bakes to a // prebuilt membership index (cInConst); a loop-invariant carried list // rebuilds once per match-call (cInCarried); Slots reports the row slots a // compiled expression reads for WHERE pushdown.
package compile import ( chickpeas ) // memKind discriminates an inMembership representation. type memKind uint8 const ( // memNodes: an all-node-id list as a roaring bitmap. memNodes memKind = iota // memHash: hashable scalars keyed by their canonical encoding. memHash // memLinear: a mixed/unhashable list kept for the exact linear // Equal scan (nulls, NaN, non-scalars, temporals -- whose coercions a // hash probe cannot reproduce). memLinear ) // inMembership is a prebuilt membership index over an IN list, picking the // representation by element type; all three are value-identical to the // plain IN evaluation. type inMembership struct { kind memKind // nodes is a sorted deduplicated id slice: IN lists are usually // small-to-moderate, so a contiguous binary search beats a // compressed-set container walk per probe. nodes []uint32 // dense is the adaptive node bitmap: past memDenseProbes probes the // binary search flips to an O(1) bit test over the id span -- the // label-test densification pattern, for the membership a filter // probes millions of times per query. dense []uint64 probes uint32 hash map[string]struct{} linear []value.Value hasNull bool // items retains the evaluated list for representation upgrades that // need the original elements (the atom-id membership over a string // column); IN lists are small, so the retention is negligible. items []value.Value // probe is a reused key buffer for memHash probes: resultFor runs // once per row in a single execution's goroutine, so the encoded key // need not escape between probes. A map lookup on string(probe) does // not allocate. probe []byte } // memDenseProbes is the probe count past which a node membership // densifies; the bitmap spans up to the largest member id, so the memory // (span/8 bytes) is only paid by memberships hot enough to amortize it. const memDenseProbes = 1 << 16 // hasNode reports a node id's membership, densifying adaptively. Callers // hold the same *inMembership across probes (a single execution's // goroutine), so the flip happens at most once per membership. func ( *inMembership) ( uint32) bool { if .dense == nil { .probes++ if .probes < memDenseProbes || len(.nodes) == 0 { , := slices.BinarySearch(.nodes, ) return } := int(.nodes[len(.nodes)-1]) + 1 := make([]uint64, (+63)/64) for , := range .nodes { [>>6] |= 1 << ( & 63) } .dense = } := int( >> 6) return < len(.dense) && .dense[]>>(&63)&1 == 1 } // memKey projects a value to its membership key, comma-ok false when it // cannot participate in a hash probe (null, NaN, or a non-scalar -- // temporals coerce against ints under Equal, so they scan linearly). // An integral float keys as the equal integer, mirroring Equal. func memKey( []byte, value.Value) ([]byte, bool) { switch .Kind() { case value.KindBool, value.KindInt, value.KindStr, value.KindNode, value.KindRel: return value.AppendKey(, ), true case value.KindFloat: , := .AsFloat() if != { // NaN return , false } return value.AppendKey(, ), true default: return , false } } // buildMembership indexes an evaluated IN list. func buildMembership( []value.Value) inMembership { := len() > 0 for , := range { if .Kind() != value.KindNode { = false break } } if { := make([]uint32, len()) for , := range { , := .AsNode() [] = uint32() } slices.Sort() return inMembership{kind: memNodes, nodes: slices.Compact(), items: } } := make(map[string]struct{}, len()) for , := range { , := memKey(nil, ) if ! { // An unhashable element forces the exact linear fallback. := false for , := range { if .IsNull() { = true break } } return inMembership{kind: memLinear, linear: , hasNull: , items: } } [string()] = struct{}{} } return inMembership{kind: memHash, hash: , items: } } // atomSet resolves the membership's string elements to their interned // atom ids, sorted -- the probe set for a string-column IN, where a hit // is one column read and a small search instead of a string resolution // and an encoded hash probe. Empty and never-interned elements are // excluded (no stored non-empty text can equal them); non-string // elements never equal a string probe and null collapses to a prune // under the predicate's truthy fold, so both drop. ok=false when the // snapshot interned a non-zero empty atom (the reader folds its text to // absent, which the id test cannot see). func ( *inMembership) ( *chickpeas.Snapshot) ([]uint32, bool) { if , := .PropertyKey(""); && != 0 { return nil, false } := make([]uint32, 0, len(.items)) for , := range .items { , := .AsStr() if ! || == "" { continue } if , := .PropertyKey(); && != 0 { = append(, ) } } slices.Sort() return slices.Compact(), true } // resultFor is the openCypher IN result for a non-null probe: true on a // hit, else null when the list contained a null element, else false. func ( *inMembership) ( value.Value) value.Value { := false switch .kind { case memNodes: if , := .AsNode(); { = .hasNode(uint32()) } case memHash: if , := memKey(.probe[:0], ); { .probe = _, = .hash[string()] } default: for , := range .linear { if value.Equal(, ) { = true break } } } switch { case : return value.Bool(true) case .hasNull: return value.Null() } return value.Bool(false) } // Slots collects the row slots a compiled expression reads; hasSlow is set // for nodes whose inner references aren't slot-resolved (interpreter // fallbacks, functions, subqueries without a memo set). A memoized // subquery reads exactly its memo slots, so it can push down to where its // correlated bindings bind -- but hasWalk reports it separately, because // the memo only bounds REPEAT cost: on every distinct correlation tuple // the subquery still walks the graph, so its placement must respect walk // cost, not just read placement (task 115: classify by what the predicate // DOES, not by which evaluation path it takes). func ( *Compiled) ( []int, , bool) { slotsOf(.c, &, &, &) return , , } func slotsOf( cnode, *[]int, , *bool) { switch n := .(type) { case *cLit: case *cSlot: * = append(*, .s) case *cProp: * = append(*, .slot) case *cCmpPropConst: * = append(*, .prop.slot) case *cNot: (.e, , , ) case *cNeg: (.e, , , ) case *cBin: (.l, , , ) (.r, , , ) case *cList: for , := range .xs { (, , , ) } case *cIn: (.e, , , ) (.list, , , ) case *cInConst: (.e, , , ) case *cInCarried: (.e, , , ) (.list, , , ) case *cIsNull: (.e, , , ) case *cCase: if .operand != nil { (.operand, , , ) } for , := range .whens { ([0], , , ) ([1], , , ) } if .els != nil { (.els, , , ) } case *cSubquery: if .hasMemo { * = append(*, .memoSlots...) * = true } else { * = true } case *cFunc: // Resolved scalar functions are pure over their arguments (the // FuncOp purity contract: deterministic, graph-less, no ambient // state), so a function conjunct places at the level its argument // slots bind instead of pinning to the last op -- a mid-pattern // predicate then prunes before the walk fans out. Sound only // while the level map tracks every relationship slot, including // a variable-length expand's list slot. NOTE this arm's verdict // also feeds two EVALUATE-ONCE decisions, not just placement: // the const IN-list membership bake (allConst below) and the // carried-list hoist (hoistCarried) -- both unsound for a // volatile function, which reads no slots and so passes every // slot-based test vacuously. The purity contract on FuncOp // (funcop.go) is what keeps volatile functions out of this arm // entirely; a change admitting one must revisit all three // consumers plus foldFunc. for , := range .args { (, , , ) } default: // cSlow (an uncompilable expression with unknown reads) and any // future node kind keep conservative last-level placement. * = true } } // HoistConstIn rewrites IN nodes whose list is invariant over the row // batch (every slot it reads is batch-constant, no slow node) into a baked // membership index, evaluated once against a sample row. func ( *eval.Ctx, *Compiled, func(int) bool, []value.Value, map[string]int) *Compiled { return newCompiled(hoistConst(, .c, .g, , , ), .g) } func hoistConst( *eval.Ctx, cnode, *chickpeas.Snapshot, func(int) bool, []value.Value, map[string]int) cnode { switch n := .(type) { case *cNot: return &cNot{e: (, .e, , , , )} case *cNeg: return &cNeg{e: (, .e, , , , )} case *cBin: return &cBin{op: .op, l: (, .l, , , , ), r: (, .r, , , , )} case *cList: := make([]cnode, len(.xs)) for , := range .xs { [] = (, , , , , ) } return &cList{xs: } case *cIsNull: return &cIsNull{e: (, .e, , , , ), negated: .negated} case *cIn: var []int , := false, false slotsOf(.list, &, &, &) := ! && ! // a walking list is never hoist-constant for , := range { if !() { = false break } } if { if , := ceval(, .list, , , ).AsList(); { return &cInConst{ e: (, .e, , , , ), m: buildMembership(), } } } return &cIn{ e: (, .e, , , , ), list: (, .list, , , , ), } default: return } } // HoistCarriedIn rewrites remaining IN nodes whose list reads only // carried-in slots (loop-invariant per match-call, not batch-constant) // into the per-epoch cached form. Applied after HoistConstIn so // batch-constant lists keep their cheaper baked set. func ( *Compiled, func(int) bool) *Compiled { return newCompiled(hoistCarried(.c, ), .g) } func hoistCarried( cnode, func(int) bool) cnode { switch n := .(type) { case *cNot: return &cNot{e: (.e, )} case *cNeg: return &cNeg{e: (.e, )} case *cBin: return &cBin{op: .op, l: (.l, ), r: (.r, )} case *cList: := make([]cnode, len(.xs)) for , := range .xs { [] = (, ) } return &cList{xs: } case *cIsNull: return &cIsNull{e: (.e, ), negated: .negated} case *cIn: var []int , := false, false slotsOf(.list, &, &, &) if ! && ! && len() > 0 { := true for , := range { if !() { = false break } } if { return &cInCarried{e: (.e, ), list: .list} } } return &cIn{e: (.e, ), list: (.list, )} default: return } }