fix(8.0): column-store range queries honor exclusive bounds (lessThan/greaterThan)
getIdsForRange computed includeMin/includeMax correctly for every operator (gt→false/true, lt→true/false, between→true/true) but DROPPED them when the column store served the query — it called columnStore.rangeQuery(field,min,max) with no flags, and the column-store path was inclusive-only. So whenever a field lived in the column store, strict lessThan/greaterThan silently behaved as lte/gte. The sparse-fallback path already forwarded the flags, so this was column-store-only. Thread includeMin/includeMax end to end: - ColumnSegmentCursor: add textbook lowerBound/upperBound helpers and express binarySearchRange in terms of them. Inclusive/inclusive is byte-identical to the old impl (lowerBound(lo) == old binarySearchValue(lo).index; upperBound(hi) == old "first position after hi"). Exclusive lower advances past ALL duplicates of the boundary value; exclusive upper stops before them. - ColumnStore.rangeQuery: accept the flags, apply them in the segment cursor AND the tail-buffer linear scan (strict > / < when exclusive). A bound taken from a segment's own min/max stays inclusive — it is a real stored value. - VectorIndex/types interface + getIdsForRange call site updated. Surfaced by brainy-complete dual-bound test (year/popularity exclusive both ends → ['Express','Vue.js']; React@95 and Angular@75 correctly excluded). Added 5 column-store unit tests: exclusive lower, exclusive upper, both, duplicate boundary values, and the unflushed tail-buffer path. 1469 unit green.
This commit is contained in:
parent
d918f49287
commit
009e50681e
5 changed files with 185 additions and 37 deletions
|
|
@ -162,6 +162,79 @@ describe('ColumnStore', () => {
|
|||
expect(bitmap.has(ids[2])).toBe(false)
|
||||
expect(bitmap.has(ids[8])).toBe(false)
|
||||
})
|
||||
|
||||
// Exclusive-bound coverage — regression guard for the column-store bug where
|
||||
// includeMin/includeMax were dropped, so strict greaterThan/lessThan silently
|
||||
// behaved as gte/lte whenever the column store served the query.
|
||||
const seedPrices = async (): Promise<number[]> => {
|
||||
const ids: number[] = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
ids.push(idMapper.getOrAssign(`x${i}`))
|
||||
store.addEntity(BigInt(ids[i]), { price: i * 10 }) // 0,10,...,90
|
||||
}
|
||||
await store.flush()
|
||||
return ids
|
||||
}
|
||||
|
||||
it('honors exclusive lower bound — greaterThan (30, 70]', async () => {
|
||||
const ids = await seedPrices()
|
||||
const bm = await store.rangeQuery('price', 30, 70, false, true)
|
||||
// 40,50,60,70 → e4..e7 (30 excluded)
|
||||
expect(bm.size).toBe(4)
|
||||
expect(bm.has(ids[3])).toBe(false)
|
||||
expect(bm.has(ids[4])).toBe(true)
|
||||
expect(bm.has(ids[7])).toBe(true)
|
||||
})
|
||||
|
||||
it('honors exclusive upper bound — lessThan [30, 70)', async () => {
|
||||
const ids = await seedPrices()
|
||||
const bm = await store.rangeQuery('price', 30, 70, true, false)
|
||||
// 30,40,50,60 → e3..e6 (70 excluded)
|
||||
expect(bm.size).toBe(4)
|
||||
expect(bm.has(ids[7])).toBe(false)
|
||||
expect(bm.has(ids[3])).toBe(true)
|
||||
expect(bm.has(ids[6])).toBe(true)
|
||||
})
|
||||
|
||||
it('honors both exclusive bounds — (30, 70)', async () => {
|
||||
const ids = await seedPrices()
|
||||
const bm = await store.rangeQuery('price', 30, 70, false, false)
|
||||
// 40,50,60 → e4..e6
|
||||
expect(bm.size).toBe(3)
|
||||
expect(bm.has(ids[3])).toBe(false)
|
||||
expect(bm.has(ids[7])).toBe(false)
|
||||
expect(bm.has(ids[4])).toBe(true)
|
||||
expect(bm.has(ids[6])).toBe(true)
|
||||
})
|
||||
|
||||
it('exclusive lower excludes ALL duplicates of the boundary value', async () => {
|
||||
const a = idMapper.getOrAssign('dup_a'); store.addEntity(BigInt(a), { price: 30 })
|
||||
const b = idMapper.getOrAssign('dup_b'); store.addEntity(BigInt(b), { price: 30 })
|
||||
const c = idMapper.getOrAssign('dup_c'); store.addEntity(BigInt(c), { price: 30 })
|
||||
const d = idMapper.getOrAssign('dup_d'); store.addEntity(BigInt(d), { price: 40 })
|
||||
await store.flush()
|
||||
const bm = await store.rangeQuery('price', 30, 100, false, true) // (30, 100]
|
||||
expect(bm.has(a)).toBe(false)
|
||||
expect(bm.has(b)).toBe(false)
|
||||
expect(bm.has(c)).toBe(false)
|
||||
expect(bm.has(d)).toBe(true)
|
||||
})
|
||||
|
||||
it('honors exclusive bounds in the unflushed tail buffer', async () => {
|
||||
// No flush → values live only in the tail buffer (separate code path from
|
||||
// the persisted segment cursor).
|
||||
const ids: number[] = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
ids.push(idMapper.getOrAssign(`tail${i}`))
|
||||
store.addEntity(BigInt(ids[i]), { score: i * 10 }) // 0,10,20,30,40
|
||||
}
|
||||
const bm = await store.rangeQuery('score', 0, 40, false, false) // (0, 40)
|
||||
expect(bm.size).toBe(3) // 10,20,30
|
||||
expect(bm.has(ids[0])).toBe(false)
|
||||
expect(bm.has(ids[4])).toBe(false)
|
||||
expect(bm.has(ids[1])).toBe(true)
|
||||
expect(bm.has(ids[3])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue