Add in comparator, negated modifier, and plain-JSON nested-path filters#460
Add in comparator, negated modifier, and plain-JSON nested-path filters#460
in comparator, negated modifier, and plain-JSON nested-path filters#460Conversation
…ilters
Tier-1 query enhancements that move common SQL shapes from residual filters
toward indexable / cleanly-expressible Resource API conditions:
- `in` comparator with REST `(v1,v2,v3)` value-list syntax (e.g.
`?status=in=(active,pending)`); `not_in` via the new `not_` prefix.
Phase 1 routes through filter; index-merge optimization is deferred.
- `negated: boolean` flag on conditions, plus `not_X` prefix support
(`not_starts_with`, `not_contains`, `not_between`, ...). The parser
strips the prefix and sets the flag; the execution layer also normalizes
`not_X` from structured queries via `resolveComparator`.
- Plain-JSON nested-path filters (e.g. `?address.city=Denver`) — previously
rejected unless `address` was a declared relationship. Path walking uses
`some` semantics on array intermediates, mirroring relationship traversal.
- Multi-character FIQL operators (`=in=`, `=between=`, `=not_starts_with=`)
now pass parser validation.
- SubSelect projection on plain-JSON arrays now maps per-element instead of
collapsing to a single object (fixes `select: [{name: 'addresses', select: ['city']}]`
when the value is an array of plain objects).
- Filter-side range cases for `gele`/`gtlt`/`gtle`/`gelt` (the chained-condition
collapse output) — previously only `between` was handled in `filterByType`.
- `attributeComparator` and `estimateCondition` are guarded against undefined
records / missing intermediate paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| | 'le' | ||
| | 'greater_than' | ||
| | 'greater_than_equal' | ||
| | 'in' |
There was a problem hiding this comment.
not_X comparators missing from the Comparator union
not_in, not_starts_with, not_between, not_contains, and not_ends_with are valid runtime inputs to the structured query API — prepareConditions normalizes them, and the test suite has a test explicitly titled "accepts not_in directly without going through parser". They're not in the Comparator union, so TypeScript callers get a compile error when using these strings.
not_equal (the existing ne alias) is already in the type and sets the precedent. Either add the new not_X entries here, or clarify in the test description that these are internal aliases not intended as typed public inputs (in which case { comparator: 'in', negated: true } is the only typed path).
| | 'in' | |
| + | 'in' |
(Extend to add
'not_in' | 'not_starts_with' | 'not_between' | 'not_contains' | 'not_ends_with'after'in'if these are intended as public API strings.)
|
One finding: 1.
|
Summary
Tier-1 Resource API query additions (RESTful + structured shapes), covering items from
RESOURCE-API-TIER1-PROPOSAL.md:incomparator — native, with REST(v1,v2,v3)value-list syntax (?status=in=(active,pending)). Phase 1 routes through filter; index-merge optimization is a Phase 2 follow-up.negatedmodifier — both via anegated: booleanflag onDirectConditionand vianot_Xprefix on comparator names (not_in,not_starts_with,not_contains,not_between,not_ends_with). The parser strips the prefix and sets the flag; the execution layer normalizes structured queries the same way viaresolveComparator.attribute: ['address', 'city'](or REST?address.city=…) is no longer rejected when the first segment isn't a declared relationship. Array intermediates usesomesemantics, mirroring relationship traversal.select: [{ name: 'addresses', select: ['city'] }]now correctly maps per-element when the value is an array of plain JSON objects (previously collapsed to one undefined entry).=in=,=between=,=not_starts_with=etc. parse.gele/gtlt/gtle/gelt(the chained-condition collapse output) are now handled infilterByType, fixing a latent bug where chainedgt/lton non-indexed/full-scan paths threw "Unknown query comparator".Why
The SQL engine translates
WHERE x IN (...),WHERE address.city = …, andWHERE NOT (...)shapes into Resource API conditions viawhereToConditions.ts. Today these become residual filters (extra rows pulled, JS-filtered) because the Resource API rejects nested non-relationship paths and has no nativein/not. This change moves them to first-class condition shapes, so the SQL engine can emit them directly and the optimizer can target indexable plans.What to look at first
resources/search.ts— the bulk of the logic. Specific spots:resolveComparatorandbuildCondition— alias resolution +not_stripping +(v1,v2)list-value detection at parse time.searchByIndex— non-relationship array attributes now fall through to filter (line ~206); thenegatedbranch overrides start/end to a full scan andskipIndexforces primary-store iteration so null-valued records still match a negated filter (semantic correctness for nulls outside the secondary index).filterByType—'in'case (cachedSet);negatedwrapping at the end; new plain-JSON nested-path branch using a syntheticNESTED_PATH_TABLEto recurse cleanly through the comparator switch.resources/Table.ts—prepareConditionsnormalizesnot_Xfrom structured queries, and allows nested paths whose root attribute exists but whose leaf is not in the schema (skips type coercion in that case to avoid coercing string values as the root's object type). SubSelect array fix is intransformEntryForSelect.unitTests/resources/query-tier1.test.js— 39 tests, including parsing, execution, nested paths, multi-value association (independent vs chained), and edge cases (null values, triple-nested array intermediates, backwards-compat).Notes for review
value=gt=(4)still produces the literal string"(4)"becausegtisn't a list-value comparator. The(...)list parsing only kicks in forinandbetween. Confirmed by an explicit regression test.status: nullmatchesnot_in=(active)becausenullis not in the list. This required iterating the primary store (not the secondary index) for negated queries on indexed attributes — the secondary index doesn't include null entries unlessindexNullsis set.child.age=gt=10&child.age=lt=15) match when ANY child satisfies each independently (different children OK); chained (child.age=gt=10&=lt=15) collapses to a single range filter that requires the SAME child to satisfy both. Tests confirm both shapes; the chained-condition execution path inTable.ts:prepareConditionsis unchanged (still limited to gt/lt → range).git diff | gemini -p …— flagged null-record behavior (now tested + fixed), unusedgetNestedValue(kept as a public utility — it's tested and may be useful to callers like the SQL engine), and recommended triple-nested path tests (added).unitTests/security/keys.test.js(cert handling) andunitTests/resources/transaction.test.js(test ordering, passes alone) fail on this branch the same way they fail on main.Test plan
npx mocha unitTests/resources/query-tier1.test.js— 39 passingnpx mocha unitTests/resources/query.test.js— 102 passing (no regressions)npx mocha unitTests/resources/query-parse.test.js— 21 passing (no regressions)npm run test:unit:resources— 379 passing (1 pre-existing flaky transaction test)npm run lint:required— cleannpm run format:write— clean🤖 Generated with Claude Code