fix: validate where-operators and align the in-memory matcher to the documented set
`find({ where })` had two gaps that could silently return nothing. The in-memory
matcher (`matchesQuery`, used for egress re-validation and historical reads) was
missing several documented operators — `in`, `greaterThanOrEqual`,
`lessThanOrEqual` — so it disagreed with the index path, which does handle them.
And an unknown operator key (a typo, or `notIn` written where `not: { in }` was
meant) fell through to a nested-object-field interpretation and matched nothing.
- Align `matchesQuery` to the full documented operator set (add the missing
aliases; the default branch now throws instead of treating an unknown key as a
nested field — dotted paths remain the supported nested form).
- Validate the `where` filter up front (`validateWhereFilter`), throwing a typed
`BrainyError('INVALID_QUERY')` that names the unknown operator, recursing into
`allOf` / `anyOf` / `not`.
- Stop excluding a user field literally named `level` from the metadata index
(it collided with an internal never-index key), so numeric/exact filters on it
resolve instead of returning 0.
Adds unit coverage for the operator set and the throw-on-unknown behavior.
This commit is contained in:
parent
68da66024c
commit
6821e1980b
4 changed files with 176 additions and 13 deletions
82
tests/unit/where-operator-validation.test.ts
Normal file
82
tests/unit/where-operator-validation.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* Where-clause operator correctness (8.0.12) — the three query-layer fixes:
|
||||
*
|
||||
* 1. Unknown operators fail LOUD. A typo like `{ role: { notIn: [...] } }` or
|
||||
* any gibberish operator now throws BrainyError('INVALID_QUERY') instead of
|
||||
* silently matching nothing (the invisible-degrade class). Validated up
|
||||
* front in find(), so it throws even when the result set is empty.
|
||||
* 2. Documented operators the in-memory matcher was missing now work: `in`
|
||||
* (alias for oneOf) and the long-form comparison aliases
|
||||
* `greaterThanOrEqual` / `lessThanOrEqual`.
|
||||
* 3. A user metadata field named `level` is indexable + queryable — it was
|
||||
* silently dropped by an over-broad HNSW-internal skip-list, so
|
||||
* `where: { level: ... }` returned nothing.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { Brainy, NounType, BrainyError } from '../../src/index.js'
|
||||
|
||||
const brains: any[] = []
|
||||
async function makeBrain(): Promise<any> {
|
||||
const brain: any = new Brainy({ storage: { type: 'memory' }, requireSubtype: false, silent: true, plugins: [] })
|
||||
await brain.init()
|
||||
brains.push(brain)
|
||||
return brain
|
||||
}
|
||||
afterEach(async () => {
|
||||
for (const b of brains.splice(0)) await b.close().catch(() => {})
|
||||
})
|
||||
|
||||
const V = () => Array.from({ length: 384 }, () => Math.random())
|
||||
|
||||
describe('Where-operator validation + documented-operator coverage (8.0.12)', () => {
|
||||
it('unknown operators throw INVALID_QUERY instead of silently returning [] (item 3)', async () => {
|
||||
const brain = await makeBrain()
|
||||
for (const role of ['staff', 'customer', 'vendor']) {
|
||||
await brain.add({ data: role, type: NounType.Person, metadata: { role }, vector: V() })
|
||||
}
|
||||
// notIn is not an operator (use ne / not+in). It must THROW, not match nothing.
|
||||
await expect(brain.find({ type: NounType.Person, where: { role: { notIn: ['staff'] } } }))
|
||||
.rejects.toBeInstanceOf(BrainyError)
|
||||
await expect(brain.find({ type: NounType.Person, where: { role: { notIn: ['staff'] } } }))
|
||||
.rejects.toMatchObject({ type: 'INVALID_QUERY' })
|
||||
// Gibberish likewise.
|
||||
await expect(brain.find({ where: { role: { blorp: 1 } } })).rejects.toMatchObject({ type: 'INVALID_QUERY' })
|
||||
// Nested inside a logical operator is still validated.
|
||||
await expect(brain.find({ where: { not: { role: { nope: 1 } } } })).rejects.toMatchObject({ type: 'INVALID_QUERY' })
|
||||
await expect(brain.find({ where: { anyOf: [{ role: { xx: 1 } }] } })).rejects.toMatchObject({ type: 'INVALID_QUERY' })
|
||||
})
|
||||
|
||||
it('the documented `in` alias and long-form comparison aliases work (item 2)', async () => {
|
||||
const brain = await makeBrain()
|
||||
for (let i = 0; i < 9; i++) {
|
||||
await brain.add({ data: 'n' + i, type: NounType.Person, metadata: { role: ['staff', 'customer', 'vendor'][i % 3], score: i }, vector: V() })
|
||||
}
|
||||
await brain.flush()
|
||||
// `in` === oneOf
|
||||
expect((await brain.find({ type: NounType.Person, where: { role: { in: ['customer', 'vendor'] } } })).length).toBe(6)
|
||||
expect((await brain.find({ type: NounType.Person, where: { not: { role: { in: ['staff'] } } } })).length).toBe(6)
|
||||
// long-form comparison aliases (were silently unhandled by the in-memory matcher)
|
||||
expect((await brain.find({ type: NounType.Person, where: { score: { greaterThanOrEqual: 6 } } })).length).toBe(3) // 6,7,8
|
||||
expect((await brain.find({ type: NounType.Person, where: { score: { lessThanOrEqual: 2 } } })).length).toBe(3) // 0,1,2
|
||||
})
|
||||
|
||||
it('a user metadata field named "level" is indexable + queryable (item: HNSW field-name collision)', async () => {
|
||||
const brain = await makeBrain()
|
||||
for (let i = 0; i < 9; i++) {
|
||||
await brain.add({ data: 'entity ' + i, type: NounType.Person, metadata: { level: i }, vector: V() })
|
||||
}
|
||||
await brain.flush()
|
||||
expect((await brain.find({ type: NounType.Person, where: { level: 4 } })).length).toBe(1) // exact
|
||||
expect((await brain.find({ type: NounType.Person, where: { level: { gt: 4 } } })).length).toBe(4) // 5..8
|
||||
expect((await brain.find({ type: NounType.Person, where: { level: { exists: true } } })).length).toBe(9)
|
||||
})
|
||||
|
||||
it('valid operators are unaffected — control', async () => {
|
||||
const brain = await makeBrain()
|
||||
for (const s of ['a', 'b', 'c']) await brain.add({ data: s, type: NounType.Concept, metadata: { tag: s }, vector: V() })
|
||||
await brain.flush()
|
||||
expect((await brain.find({ type: NounType.Concept, where: { tag: { oneOf: ['a', 'b'] } } })).length).toBe(2)
|
||||
expect((await brain.find({ type: NounType.Concept, where: { tag: 'c' } })).length).toBe(1)
|
||||
expect((await brain.find({ type: NounType.Concept, where: { tag: { exists: true } } })).length).toBe(3)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue