Skip to content

Commit 536f2d5

Browse files
fix(service-analytics): the ObjectQL strategy serves $icontains; a valid comparand no longer answers an uncoded 500 (#20098) (#20131)
Fixes #20098 Clause-②: no ## What this changes `ObjectQLStrategy.convertFilter` (`packages/services/service-analytics/src/strategies/objectql-strategy.ts`) translates each normalized filter leaf into the `FilterCondition` operand the engine expects. The normalizer emits an `icontains` leaf for `$icontains`, and for the `FilterArray` `icontains`, which `parseFilterAST` lowers to `$icontains` first. `convertFilter` had no arm for that leaf, so every `$icontains` fell to its catch-all and threw a bare `Error` with no `code` and no `status`. That included a valid `'acme'`. The new arm is one line in the shape the four sibling text arms use: ```ts case 'icontains': return { $icontains: String(v0) }; ``` - It passes the canonical spec operator through. `FILTER_OPERATORS` declares `$icontains`, and `StringOperatorSchema` types it `z.string()`. - The ASCII-only fold (#4706 Q1 = A) stays with the engine and the driver, as on every other face. ⛔ No local fold, no `$regex` (#5557 is the precedent), and no second implementation of the text semantics. - PR #20096's comparand refusals still come first and are unchanged. The `where` door refuses an empty or non-string `$icontains` before a leaf exists, so `String(…)` is an identity on everything that can reach this arm. Ablation D below measures what that ordering protects. - The same arm serves the other two callers of `filterNodeToCondition`: a compiled dataset's scope and a measure filter. - The catch-all throw is untouched. It stays loud for an operator the normalizer never emits. ## Measured first (recorded on this branch as `75a7c920aa`, before any source change) Base `origin/main` `7c1039b388`. The fixture is a real `ObjectQL` engine over a real `SqliteWasmDriver` (sql.js). Rows (field `name`): a1 'Acme Corp', a2 'ACME ltd', a3 'beta', a4 NULL, a5 '', a6 'CAFÉ', a7 'café'. The echo's SQL was run on the same database. The throwaway harnesses were not committed. **In process (the `where` door, caller-authored):** | cell | `engine.find` direct | `ObjectQLStrategy.execute` | `AnalyticsService.query` (ObjectQL) | native execute | echo (run) | `AnalyticsService.query` (native) | |:--|:--|:--|:--|:--|:--|:--| | `$icontains: 'acme'` | a1 a2 | bare `Error`, `code` undefined, `status` undefined: "[analytics] ObjectQL strategy cannot express filter operator "icontains"" | same bare `Error` | a1 a2 | a1 a2 | a1 a2 | | `$icontains: 'ACME'` | a1 a2 | bare `Error` | bare `Error` | a1 a2 | a1 a2 | a1 a2 | | `$icontains: 'CAFÉ'` (ASCII-only fold) | a6 | bare `Error` | bare `Error` | a6 | a6 | a6 | | `$icontains: 'café'` | a7 | bare `Error` | bare `Error` | a7 | a7 | a7 | | `$not` over `$icontains: 'acme'` | a3 a4 a5 a6 a7 | bare `Error` | bare `Error` | a3 a4 a5 a6 a7 | a3 a4 a5 a6 a7 | a3 a4 a5 a6 a7 | | `FilterArray` `['name','icontains','acme']` | a1 a2 | bare `Error` | bare `Error` | a1 a2 | a1 a2 | a1 a2 | | `$contains: 'acme'` (case-exact control) | (none) | (none) | (none) | (none) | (none) | (none) | **The HTTP door, `POST /api/v1/analytics/query`, with an ObjectQL-served datasource.** The card recorded this leg as a code reading. This PR measures it: `packages/runtime`'s real route (`createDispatcherPlugin`, the fake-server shape `analytics-query-read-scope-withhold.test.ts` uses) over a real `AnalyticsService` imported from source, with `objectqlAggregate: true`, `nativeSql: false`, and `executeAggregate` bound to `engine.aggregate`. Read and measured only; `packages/runtime` is not edited. | cell | status | `body.error` | `engine.aggregate` calls | |:--|:--|:--|:--| | `$icontains: 'acme'` | **500** | `INTERNAL_ERROR`, message "[analytics] ObjectQL strategy cannot express filter operator "icontains". Treating it as an equality …" relayed | 0 | | `$icontains: 'CAFÉ'` | 500 | same | 0 | | `$not` over `$icontains: 'acme'` | 500 | same | 0 | | `FilterArray` `['name','icontains','acme']` | 400 | `VALIDATION_FAILED`, `where`: expected record, received array (the door is object-only) | 0 | | `$icontains: ''` (the #20068 refusal) | 400 | `VALIDATION_FAILED` at `where.name.$icontains`, the spec's reason | 0 | | `$contains: 'cme'` (control) | 200 | rows a1 | 1 | **`convertFilter`'s input vocabulary against its arms.** Each `FILTER_OPERATORS` spelling was sent through `normalizeAnalyticsFilterTree`, then the ObjectQL strategy, the engine and the native face: | spelling | leaf operator(s) the normalizer emits | `engine.find` | ObjectQL execute | native | verdict | |:--|:--|:--|:--|:--|:--| | `$eq` | `equals` | a3 | a3 | a3 | covered | | `$ne` | `notSet`, `notEquals` | a1 a2 a4 a5 a6 a7 | same | same | covered | | `$gt` / `$gte` / `$lt` / `$lte` | `gt` / `gte` / `lt` / `lte` | equal on all three faces | = | = | covered | | `$in` / `$nin` | `in` / `notSet`, `notIn` | equal on all three | = | = | covered | | `$between` | `gte`, `lte` | a2 a5 a6 | same | same | covered | | `$contains` / `$notContains` / `$startsWith` / `$endsWith` | same-named leaves | equal on all three | = | = | covered | | `$icontains` | `icontains` | a1 a2 | **bare `Error`** | a1 a2 | **missing: fixed here** | | `$null` / `$exists` | `notSet` / `set` | a4 | a4 | a4 | covered | | `$like` / `$ilike` (staged, not in `FILTER_OPERATORS`) | refused at the door, `INVALID_FILTER` / 400 | a1 a2 (the engine answers) | refused at the door | refused at the door | intentionally refused: the spec's staging table names `service-analytics` a REFUSING face | | `$regex` (retired) | refused at the door, `INVALID_FILTER` / 400 | refused, `INVALID_FILTER` / 400 | refused at the door | refused at the door | intentionally refused | `icontains` is the only operator the normalizer emits that `convertFilter` has no arm for. No other operator is missing, so this PR does not widen. ## After (head `109691058d`, `origin/main` `6780e34af5` merged in) - In process, every cell above answers on every face, and each face answers the rows `engine.find` answers. `'acme'` / `'ACME'` give a1 a2. `'CAFÉ'` gives a6 only and `'café'` gives a7 only. `$not` gives a3 a4 a5 a6 a7. The `FilterArray` gives a1 a2. - HTTP re-measure: `$icontains: 'acme'` is **200, rows a1 a2**, with 1 engine call. `'CAFÉ'` is 200, a6. `$not` is 200, a3 a4 a5 a6 a7. The `FilterArray` and `''` rows are unchanged: 400 `VALIDATION_FAILED` at the door parse. - The engine receives `{ name: { $icontains: 'acme' } }` for `'acme'` (the comparand verbatim, whatever its case), and nothing else. ## Tests New file `packages/services/service-analytics/src/__tests__/objectql-icontains-arm.test.ts`, 27 cases, over a real `ObjectQL` + `SqliteWasmDriver`: - Six cells: `'acme'`, `'ACME'`, `'CAFÉ'`, `'café'`, `$not` and the `FilterArray` spelling. Each takes its reference from `engine.find` first, then asserts the same row ids on `ObjectQLStrategy.execute`, `AnalyticsService.query` (ObjectQL), native execute and `AnalyticsService.query` (native). - The engine receives the canonical `$icontains` with the comparand verbatim (`'acme'`, `'ACME'`, `'CAFÉ'`). - A dataset scope and a measure filter reach the same arm, with the native face as the control. - Every `FILTER_OPERATORS` spelling: a completeness assertion over the sample table, then per spelling the engine's rows (non-trivial) on the ObjectQL and native faces, with every `$` key the engine received drawn from the declared vocabulary. - The catch-all CONTROL, through the private translator: an operator the normalizer never emits still throws, naming it. Every leaf operator the normalizer emits for the declared vocabulary translates without a throw. `icontains-text-comparand-refusal.test.ts` (PR #20096): its `CONTROL` case had excluded the ObjectQL faces from the `'acme'` row "while `convertFilter` had no `icontains` arm". It now runs every face. **Every refusal case in that file is unchanged and green.** At head `109691058d`: - `pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2`: **128 files, 3017 tests passed**. - `pnpm --filter @objectstack/service-analytics run typecheck`: exit 0. `tsc --noEmit --listFiles` includes all three touched `.ts` files. ### Ablations (each through `scripts/ablation-replace.mjs`, WRAP mode, anchor hit x1, all at head `109691058d`) The tests import the subject by relative `src` path, and `@objectstack/objectql` is aliased to `src` in this package's vitest config, so no `dist` is in the path. | leg | mutation | red / green (two files, 71 cases, unless noted) | restore | |:--|:--|:--|:--| | A (reverse verification) | the `icontains` arm removed | **11 red** / 60 green: all six cells, the verbatim pin, dataset scope + measure filter, the `$icontains` vocabulary row, the emitted-leaf translation case, and PR #20096's widened control. Its refusal cases stay green: the door refuses before the arm | blob `91aacf6cf586` == HEAD, `git diff HEAD` empty | | B (local fold) | `String(v0).toLowerCase()` in the arm | **2 red** / 69 green: `'CAFÉ'` answered **a7 instead of a6** on the ObjectQL face, and the verbatim pin. `'acme'` / `'ACME'` / `'café'` stay green, as predicted: only the non-ASCII upper-case row shows a local fold | blob == HEAD | | C (catch-all) | `default:` returns `v0`, the equality #4128 removed | **1 red** / 26 green (new file only): the catch-all CONTROL | blob == HEAD | | D (door first) | #20068's `where`-door refusal disabled (`filter-normalizer.ts`) | **6 red** / 38 skipped (PR #20096's file, `-t ObjectQL`): on both ObjectQL faces, `$icontains: 42` was **answered `[]`**, laundered to `'42'` by the arm's `String(…)`. `''` and `$not` over `''` reached the engine (1 call instead of 0). This measures why the refusal must stay first | blob `a7bdec971782` == HEAD | | E (folded onto the case-exact sibling) | the arm returns `$contains` | **8 red** / 63 green: `'acme'`, `'ACME'`, `$not`, `FilterArray`, the verbatim pin, dataset scope + measure filter, the `$icontains` vocabulary row, PR #20096's control. `'CAFÉ'` / `'café'` stay green, as predicted: case-exact and ASCII-folded agree on those two | blob == HEAD | The first attempt at leg C was a no-op. Its replacement contained the anchor text, so the tool refused (`anchor count moved 1 -> 1`), restored, and ran no test. It was re-run with a non-overlapping replacement, which is the reading above. ## Gates (head `109691058d`) `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 60 families from the real diff (4 paths, +383 / -12). That is 6 more than the dispatch-time list: `check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt`, `check:where-matcher`. `--ran` reconciliation: **60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN** (a derived zero: every line carries its exit code). - All 60 families: exit 0. Among them: `check-adr-0087-registration --base origin/main` ("this PR adds no declared-breaking changeset"), `check-changeset-no-major`, `check-empty-changeset`, `check:nul-bytes`, `check:cross-package-test-inputs` and `check:test-source-alias`. `check:type-check-debt` re-measured 4 ledger entries with none above its number. `check:dual-build-cjs-loads` passed: 105 published require entry points across 67 packages load. - On the previous head (`08dd00660f`, before `origin/main` moved again), the same 60 were run. Two refused their prerequisite (exit 3) until their closure was built: `check:type-check-debt` then gave exit 0, and `check:dual-build-cjs-loads` stayed NOT MEASURED there. Both are measured green at this head. - `GITHUB_TOKEN=… node scripts/check-issue-citations.mjs`: exit 0, 8 citations, all resolve. The `--census` spelling takes a workflow value and is NOT MEASURED locally. - Lint, narrowed and proven: `eslint --no-inline-config --format json` over the 3 changed `.ts` files gave 3 files, 0 errors, 0 warnings. `isPathIgnored` answers false for each. `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict of any untouched file. - The rebuilt `service-analytics/dist/index.js` and `index.cjs` each carry `return { $icontains: String(v0) }` once, and `index.cjs` loads under `require` (17 exports). ## Acceptance notes - **`String(…)` and the ordering it relies on.** The arm follows its four siblings' `String(…)`. Today that is an identity, because the door refuses every empty or non-string `$icontains` first. Ablation D shows the cost if that door ever regressed: on this face, `42` would be laundered to `'42'` and answered with no row. Passed unconverted, `driver-sql`'s own refusal would have been a second net. PR #20096's pins go red either way, so the door's absence cannot land silently. The sibling shape was kept, as the dispatch specified. - **The `FilterArray` spelling reaches this face in process only.** `POST /analytics/query` types `where` as an object and refuses an array whole (400 `VALIDATION_FAILED`, measured above). The spelling reaches `ObjectQLStrategy` through direct `AnalyticsService.query` callers, and its row pin here covers that. - **The catch-all still throws a bare `Error`.** After this PR, no operator the normalizer emits reaches it; the vocabulary block pins that. An arrival would mean the two tables drifted again, which is a server fault by construction, so the uncoded 500 stays as it was. - The fixture adds two rows (a6 'CAFÉ', a7 'café') to #20068's five. The pair differs only in the case of a letter the fold must not touch, which is what makes the ASCII-only boundary observable. ## Out-of-scope findings None. The vocabulary enumeration above found no other operator that `convertFilter` lacks and the engine accepts. `$like` / `$ilike` / `$regex` are refused at the door on purpose. --- _Generated by [Claude Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bc80e16 commit 536f2d5

4 files changed

Lines changed: 383 additions & 12 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): `$icontains` works on a query the ObjectQL strategy serves (#20098)
6+
7+
Clause-②: no
8+
9+
`ObjectQLStrategy` had no translation for the case-insensitive `$icontains`
10+
operator, so every such filter on a datasource it serves failed. A valid
11+
`{ name: { $icontains: 'acme' } }` included. `POST /analytics/query` answered
12+
`500 INTERNAL_ERROR` ("ObjectQL strategy cannot express filter operator
13+
"icontains""), while the native SQL face and the `/analytics/sql` echo served
14+
the rows.
15+
16+
The strategy now hands the engine the canonical `$icontains`, the same way it
17+
passes `$contains`, `$notContains`, `$startsWith` and `$endsWith`. The engine
18+
and the driver apply the case fold, so the ObjectQL face answers the same rows
19+
as every other face: the fold is ASCII-only, so `'CAFÉ'` does not match
20+
`'café'`. This covers the query's `where` in both spellings (`$icontains` and
21+
the `FilterArray` `icontains`), under `$not`, a compiled dataset's scope and a
22+
measure filter. An empty or non-string comparand is still refused with
23+
`INVALID_FILTER` / 400 before the strategy runs.

‎packages/services/service-analytics/src/__tests__/icontains-text-comparand-refusal.test.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -254,10 +254,9 @@ describe('[#20068] every analytics face over a real engine: refused before anyth
254254
for (const [face, run] of WHERE_FACES) {
255255
expect(await run(undefined), `${face}: no where`).toEqual(ALL);
256256
}
257-
// The ObjectQL engine path is left out of this one row: it cannot express
258-
// `$icontains` for ANY comparand today, a separate defect this card records
259-
// and does not move.
260-
for (const [face, run] of WHERE_FACES.filter(([f]) => !f.includes('ObjectQL'))) {
257+
// [#20098] Every face, the ObjectQL ones included: they were left out of
258+
// this row while `convertFilter` had no `icontains` arm.
259+
for (const [face, run] of WHERE_FACES) {
261260
expect(await run({ name: { $icontains: 'acme' } }), `${face}: $icontains 'acme'`).toEqual(['a1', 'a2']);
262261
}
263262
});
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20098] The ObjectQL strategy serves `$icontains`: it hands the engine the
5+
* canonical `$icontains`, and the engine and driver apply the ASCII-only fold.
6+
*
7+
* `ObjectQLStrategy.convertFilter` translates each normalized leaf into the
8+
* operand the engine's `FilterCondition` expects. The normalizer emits an
9+
* `icontains` leaf for `$icontains` (and for the `FilterArray` `icontains`,
10+
* lowered to `$icontains` first), and `convertFilter` had no arm for it. Every
11+
* `$icontains` fell to the catch-all. Measured at base `7c1039b388` over a real
12+
* `ObjectQL` engine and a real `SqliteWasmDriver`, with the rows below:
13+
*
14+
* | cell | engine direct | ObjectQL execute / service | native execute, echo |
15+
* |---|---|---|---|
16+
* | `$icontains: 'acme'` | a1 a2 | bare `Error`, no `code`, no `status` | a1 a2 |
17+
* | `$icontains: 'CAFÉ'` | a6 | bare `Error` | a6 |
18+
* | `$not` over `$icontains: 'acme'` | a3 a4 a5 a6 a7 | bare `Error` | a3 a4 a5 a6 a7 |
19+
* | `FilterArray` `['name','icontains','acme']` | a1 a2 | bare `Error` | a1 a2 |
20+
*
21+
* Through `POST /api/v1/analytics/query` with an ObjectQL-served datasource the
22+
* valid `'acme'` answered `500 INTERNAL_ERROR`, and `engine.aggregate` was never
23+
* called.
24+
*
25+
* The arm now passes the operator through in the shape its four text siblings
26+
* use. ⛔ It does not fold, and it does not use `$regex` (#5557). The fold
27+
* belongs to the engine and the driver, as on every other face. So this file
28+
* asserts ROW IDS on every face against the engine answering the same filter
29+
* directly. The `'CAFÉ'` / `'café'` pair is the #4706 Q1 = A boundary: a local
30+
* fold that lower-cases the comparand would answer `'café'` for `'CAFÉ'`, which
31+
* the contract excludes.
32+
*
33+
* The empty and non-string comparands PR #20096 refuses stay refused before
34+
* this arm is reached. Their pins live in
35+
* `icontains-text-comparand-refusal.test.ts` and are unchanged.
36+
*/
37+
38+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
39+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
40+
import { ObjectQL } from '@objectstack/objectql';
41+
import { FILTER_OPERATORS, LOGICAL_OPERATORS, type Cube, type FilterCondition } from '@objectstack/spec/data';
42+
import type { AnalyticsQuery, StrategyContext } from '@objectstack/spec/contracts';
43+
44+
import { collectFilterLeaves, normalizeAnalyticsFilterTree } from '../strategies/filter-normalizer.js';
45+
import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js';
46+
import { ObjectQLStrategy } from '../strategies/objectql-strategy.js';
47+
import type { DatasetScope } from '../strategies/types.js';
48+
import { AnalyticsService } from '../analytics-service.js';
49+
50+
const OBJECT = 'acct';
51+
/**
52+
* The #20068 rows, plus the non-ASCII pair the ASCII-only boundary turns on:
53+
* `a6` / `a7` differ ONLY in the case of a letter the fold must not touch.
54+
*/
55+
const ROWS = [
56+
{ id: 'a1', name: 'Acme Corp', amt: 1 },
57+
{ id: 'a2', name: 'ACME ltd', amt: 5 },
58+
{ id: 'a3', name: 'beta', amt: 10 },
59+
{ id: 'a4', name: null, amt: null },
60+
{ id: 'a5', name: '', amt: 3 },
61+
{ id: 'a6', name: 'CAFÉ', amt: 7 },
62+
{ id: 'a7', name: 'café', amt: 9 },
63+
];
64+
const FIELDS: Record<string, { type: string; name: string }> = {
65+
id: { type: 'text', name: 'id' },
66+
name: { type: 'text', name: 'name' },
67+
amt: { type: 'number', name: 'amt' },
68+
};
69+
const CUBE: Cube = {
70+
name: 'accts',
71+
sql: OBJECT,
72+
measures: { n: { sql: '*', type: 'count', title: 'n' } },
73+
dimensions: Object.fromEntries(
74+
[['id', 'string'], ['name', 'string'], ['amt', 'number']].map(([n, t]) => [n, { name: n, label: n, type: t, sql: n }]),
75+
),
76+
public: false,
77+
} as unknown as Cube;
78+
const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } } as never;
79+
80+
const ids = (rows: Array<Record<string, unknown>>) => rows.map((r) => String(r.id)).sort();
81+
82+
describe('[#20098] `$icontains` on the ObjectQL strategy, over a real engine', () => {
83+
let driver: SqliteWasmDriver;
84+
let engine: ObjectQL;
85+
/** Every `filter` the strategy handed `engine.aggregate`, in call order. */
86+
let engineFilters: unknown[] = [];
87+
let datasetScope: DatasetScope | undefined;
88+
89+
const runRawSql = async (sql: string, params: unknown[]): Promise<Record<string, unknown>[]> => {
90+
const result = await driver.execute(sql.replace(/\$\d+/g, '?'), params);
91+
if (Array.isArray(result)) return result as Record<string, unknown>[];
92+
if (result && typeof result === 'object' && 'rows' in (result as Record<string, unknown>)) {
93+
return (result as { rows: Record<string, unknown>[] }).rows;
94+
}
95+
return [];
96+
};
97+
const executeAggregate = async (objectName: string, options: Record<string, any>) => {
98+
engineFilters.push(options.filter);
99+
return (await engine.aggregate(objectName, {
100+
where: options.filter,
101+
groupBy: options.groupBy,
102+
aggregations: options.aggregations?.map((a: any) => ({
103+
function: a.method, field: a.field, alias: a.alias, ...(a.filter ? { filter: a.filter } : {}),
104+
})),
105+
context: options.context,
106+
} as never)) as Record<string, unknown>[];
107+
};
108+
const ctxFor = (nativeSql: boolean): StrategyContext =>
109+
({
110+
getCube: (name: string) => (name === 'accts' ? CUBE : undefined),
111+
queryCapabilities: () => ({ nativeSql, objectqlAggregate: !nativeSql, inMemory: false }),
112+
executeRawSql: (_object: string, sql: string, params: unknown[]) => runRawSql(sql, params),
113+
executeAggregate,
114+
getDatasetScope: () => datasetScope,
115+
declaredFieldType: (_object: string, field: string) => FIELDS[field]?.type,
116+
sqlDialect: () => 'sqlite',
117+
}) as unknown as StrategyContext;
118+
const service = (nativeSql: boolean) =>
119+
new AnalyticsService({
120+
cubes: [CUBE],
121+
logger: quiet,
122+
queryCapabilities: () => ({ nativeSql, objectqlAggregate: !nativeSql, inMemory: false }),
123+
executeRawSql: (_object: string, sql: string, params: unknown[]) => runRawSql(sql, params),
124+
executeAggregate: executeAggregate as never,
125+
sqlDialect: () => 'sqlite',
126+
});
127+
const q = (where?: unknown, extra: Partial<AnalyticsQuery> = {}) =>
128+
({
129+
cube: 'accts', dimensions: ['id'], measures: ['n'],
130+
...(where === undefined ? {} : { where }),
131+
...extra,
132+
}) as unknown as AnalyticsQuery;
133+
134+
beforeAll(async () => {
135+
driver = new SqliteWasmDriver({ filename: ':memory:' });
136+
(driver as unknown as { logger: unknown }).logger = quiet;
137+
await driver.initObjects([{ name: OBJECT, fields: FIELDS } as never]);
138+
for (const row of ROWS) await driver.create(OBJECT, { ...row });
139+
engine = new ObjectQL({ logger: quiet });
140+
engine.registerDriver(driver as never, true);
141+
await engine.init();
142+
engine.registerObject({ name: OBJECT, label: 'Acct', fields: FIELDS } as never);
143+
});
144+
afterAll(async () => {
145+
await driver?.disconnect?.();
146+
});
147+
148+
/**
149+
* The engine answering the filter itself: the reference every analytics face
150+
* must agree with. A `FilterArray` case is referenced by the object spelling
151+
* `parseFilterAST` lowers it to.
152+
*/
153+
const engineIds = async (where: unknown) => ids((await engine.find(OBJECT, { where } as never)) as never);
154+
155+
const FACES: Array<[string, (where: unknown) => Promise<string[]>]> = [
156+
['ObjectQLStrategy.execute', async (w) => ids((await new ObjectQLStrategy().execute(q(w), ctxFor(false))).rows)],
157+
['AnalyticsService.query (ObjectQL)', async (w) => ids((await service(false).query(q(w))).rows as never)],
158+
['native execute', async (w) => ids((await new NativeSQLStrategy().execute(q(w), ctxFor(true))).rows)],
159+
['AnalyticsService.query (native)', async (w) => ids((await service(true).query(q(w))).rows as never)],
160+
];
161+
162+
const CASES: Array<{ name: string; where: unknown; reference: FilterCondition; expected: string[] }> = [
163+
{
164+
name: "'acme' — the fold runs on the COLUMN",
165+
where: { name: { $icontains: 'acme' } },
166+
reference: { name: { $icontains: 'acme' } },
167+
expected: ['a1', 'a2'],
168+
},
169+
{
170+
name: "'ACME' — and on the comparand",
171+
where: { name: { $icontains: 'ACME' } },
172+
reference: { name: { $icontains: 'ACME' } },
173+
expected: ['a1', 'a2'],
174+
},
175+
{
176+
name: "'CAFÉ' — ASCII-only (#4706 Q1 = A): É is not folded, so 'café' is NOT matched",
177+
where: { name: { $icontains: 'CAFÉ' } },
178+
reference: { name: { $icontains: 'CAFÉ' } },
179+
expected: ['a6'],
180+
},
181+
{
182+
name: "'café' — the mirror: 'CAFÉ' is NOT matched",
183+
where: { name: { $icontains: 'café' } },
184+
reference: { name: { $icontains: 'café' } },
185+
expected: ['a7'],
186+
},
187+
{
188+
name: "$not over 'acme' — the matches are excluded, the NULL and '' rows kept",
189+
where: { $not: { name: { $icontains: 'acme' } } },
190+
reference: { $not: { name: { $icontains: 'acme' } } },
191+
expected: ['a3', 'a4', 'a5', 'a6', 'a7'],
192+
},
193+
{
194+
name: "the FilterArray spelling ['name', 'icontains', 'acme']",
195+
where: ['name', 'icontains', 'acme'],
196+
reference: { name: { $icontains: 'acme' } },
197+
expected: ['a1', 'a2'],
198+
},
199+
];
200+
201+
for (const c of CASES) {
202+
it(`${c.name}: every face answers the engine's own rows`, async () => {
203+
// The reference first, so a wrong expectation is caught against the
204+
// engine rather than written into every face below.
205+
expect(await engineIds(c.reference), 'engine.find').toEqual(c.expected);
206+
for (const [face, run] of FACES) {
207+
expect(await run(c.where), face).toEqual(c.expected);
208+
}
209+
});
210+
}
211+
212+
it('the engine receives the canonical `$icontains` with the comparand VERBATIM — no local fold, no `$regex`', async () => {
213+
for (const comparand of ['acme', 'ACME', 'CAFÉ']) {
214+
engineFilters = [];
215+
await new ObjectQLStrategy().execute(q({ name: { $icontains: comparand } }), ctxFor(false));
216+
expect(engineFilters, comparand).toEqual([{ name: { $icontains: comparand } }]);
217+
}
218+
});
219+
220+
it('a compiled dataset\'s scope and a measure filter reach the same arm', async () => {
221+
// Both are lowered by `filterNodeToCondition`, the path the `where` shares,
222+
// so neither needs an arm of its own — this pins that they serve too.
223+
datasetScope = { filter: { name: { $icontains: 'acme' } } };
224+
try {
225+
expect(ids((await new ObjectQLStrategy().execute(q(), ctxFor(false))).rows), 'dataset scope').toEqual(['a1', 'a2']);
226+
expect(ids((await new NativeSQLStrategy().execute(q(), ctxFor(true))).rows), 'dataset scope, native').toEqual(['a1', 'a2']);
227+
228+
datasetScope = { measureFilters: { n: { name: { $icontains: 'ACME' } } } };
229+
const count = async (strategy: ObjectQLStrategy | NativeSQLStrategy, nativeSql: boolean) =>
230+
Number((await strategy.execute(q(undefined, { dimensions: [] }), ctxFor(nativeSql))).rows[0]?.n);
231+
expect(await count(new ObjectQLStrategy(), false), 'measure filter').toBe(2);
232+
expect(await count(new NativeSQLStrategy(), true), 'measure filter, native').toBe(2);
233+
} finally {
234+
datasetScope = undefined;
235+
}
236+
});
237+
238+
// ── The vocabulary, derived rather than listed ─────────────────────────────
239+
240+
/**
241+
* One sample per authorable operator, so the next operator the normalizer
242+
* learns without an arm here fails in this block rather than as a 500.
243+
*/
244+
const SAMPLES: Record<string, FilterCondition> = {
245+
$eq: { name: { $eq: 'beta' } },
246+
$ne: { name: { $ne: 'beta' } },
247+
$gt: { amt: { $gt: 5 } },
248+
$gte: { amt: { $gte: 5 } },
249+
$lt: { amt: { $lt: 5 } },
250+
$lte: { amt: { $lte: 5 } },
251+
$in: { name: { $in: ['beta', 'café'] } },
252+
$nin: { name: { $nin: ['beta'] } },
253+
$between: { amt: { $between: [3, 7] } },
254+
$contains: { name: { $contains: 'cme' } },
255+
$notContains: { name: { $notContains: 'cme' } },
256+
$startsWith: { name: { $startsWith: 'A' } },
257+
$endsWith: { name: { $endsWith: 'ltd' } },
258+
$icontains: { name: { $icontains: 'acme' } },
259+
$null: { name: { $null: true } },
260+
$exists: { name: { $exists: true } },
261+
};
262+
263+
it('the sample table covers every operator `FILTER_OPERATORS` declares', () => {
264+
expect(Object.keys(SAMPLES).sort()).toEqual([...FILTER_OPERATORS].sort());
265+
});
266+
267+
for (const op of FILTER_OPERATORS) {
268+
it(`${op}: the ObjectQL strategy answers the engine's rows, through canonical operators only`, async () => {
269+
const where = SAMPLES[op];
270+
const expected = await engineIds(where);
271+
// A non-trivial reference: neither every row (a dropped predicate) nor,
272+
// for these samples, no row at all.
273+
expect(expected.length, `${op} reference`).toBeGreaterThan(0);
274+
expect(expected.length, `${op} reference`).toBeLessThan(ROWS.length);
275+
engineFilters = [];
276+
expect(ids((await new ObjectQLStrategy().execute(q(where), ctxFor(false))).rows), op).toEqual(expected);
277+
expect(ids((await new NativeSQLStrategy().execute(q(where), ctxFor(true))).rows), `${op}, native`).toEqual(expected);
278+
// Every operator key the engine received is one the contract declares.
279+
const declared = new Set<string>([...FILTER_OPERATORS, ...LOGICAL_OPERATORS]);
280+
const walk = (node: unknown): void => {
281+
if (Array.isArray(node)) return node.forEach(walk);
282+
if (!node || typeof node !== 'object') return;
283+
for (const [key, value] of Object.entries(node)) {
284+
if (key.startsWith('$')) expect(declared.has(key), `${op} → engine received ${key}`).toBe(true);
285+
walk(value);
286+
}
287+
};
288+
walk(engineFilters);
289+
});
290+
}
291+
});
292+
293+
// ─────────────────────────────────────────────────────────────────────────────
294+
295+
describe('[#20098] the catch-all is untouched', () => {
296+
/**
297+
* Reached through the private translator on purpose, as
298+
* `objectql-echo-operator-coverage.test.ts` reaches the echo's: the public
299+
* door refuses an operator outside the normalizer's table with a 400 before
300+
* a leaf exists, so an arrival here means the normalizer and this translator
301+
* drifted apart — the exact shape of #20098 — and the exit must stay loud.
302+
*/
303+
const convert = (operator: string, values: unknown[]) =>
304+
(new ObjectQLStrategy() as unknown as { convertFilter(o: string, v: unknown[]): unknown }).convertFilter(operator, values);
305+
306+
it('CONTROL: an operator the normalizer never emits still throws, naming it', () => {
307+
let thrown: unknown;
308+
try {
309+
convert('sortOf', ['w']);
310+
} catch (e) {
311+
thrown = e;
312+
}
313+
expect(thrown).toBeInstanceOf(Error);
314+
expect((thrown as Error).message).toContain('"sortOf"');
315+
});
316+
317+
it('every leaf operator the normalizer emits for the declared vocabulary is translated, none thrown', () => {
318+
const emitted = new Set<string>();
319+
const samples: FilterCondition[] = [
320+
{ f: { $eq: 1 } }, { f: { $ne: 1 } }, { f: { $gt: 1 } }, { f: { $gte: 1 } }, { f: { $lt: 1 } },
321+
{ f: { $lte: 1 } }, { f: { $in: ['x'] } }, { f: { $nin: ['x'] } }, { f: { $between: [1, 2] } },
322+
{ f: { $contains: 'x' } }, { f: { $notContains: 'x' } }, { f: { $startsWith: 'x' } },
323+
{ f: { $endsWith: 'x' } }, { f: { $icontains: 'x' } }, { f: { $null: true } }, { f: { $null: false } },
324+
{ f: { $exists: true } }, { f: { $exists: false } }, { f: null } as unknown as FilterCondition, { f: 'x' },
325+
];
326+
for (const where of samples) {
327+
for (const leaf of collectFilterLeaves(normalizeAnalyticsFilterTree({ where } as never))) {
328+
emitted.add(leaf.operator);
329+
expect(() => convert(leaf.operator, leaf.values), `${JSON.stringify(where)} → ${leaf.operator}`).not.toThrow();
330+
}
331+
}
332+
expect(emitted.has('icontains')).toBe(true);
333+
});
334+
});

0 commit comments

Comments
 (0)