Skip to content

Commit 2b53993

Browse files
fix(service-analytics): both filter faces answer $empty by the field's declared type (#20445) (#20498)
Fixes #20445 Clause-②: yes (widening) The `domain:services` lane's arms for the `$empty` operator, under ruling A on #20399 (`5865693155`). Both service-analytics filter faces now answer `{ f: { $empty: true | false } }` by the field's DECLARED row of the ruled per-type table. They reach it through the spec's one expansion, `expandEmptyOperator(fieldDef)` from `@objectstack/spec/data` (PR #20442), and keep no copy of the table: | declared row | `$empty: true` matches | `$empty: false` | |---|---|---| | text-like | null or `''` | the complement | | multi-value (incl. `multiple: true` on a multi-capable type) | null or `[]` | the complement | | every other type | null only | the complement | The staging does not move (「照 $like 先例分阶段」): `$empty` is **not** added to `FILTER_OPERATORS`, and the `is_empty` / `is_not_empty` lowering still emits `$null`. There is no `$eq: []` comparand anywhere (ruling 乙 on #19757 stands). ## What changed - **`empty-operator-sql.ts` (new).** The SQL for one `$empty` predicate per declared row. Null-only: `col IS NULL`. Text: `(col IS NULL OR col = '')`, with the empty string bound. Multi-value: `(col IS NULL OR L)`, where `L` is the dialect's empty-JSON-list test: SQLite `json_valid` guard inside a `CASE`, then `json_type(col) = 'array' AND json_array_length(col) = 0`; Postgres a `jsonb` equality with `'[]'`; MySQL `JSON_TYPE` = `'ARRAY'` and `JSON_LENGTH` = 0. `$empty: false` is the exact complement of each. Every predicate is TOTAL (never UNKNOWN), so a `$not` over `$empty` needs no NULL guard. - **Read-scope face (`compileScopedFilterToSql`).** A new `$empty` arm in `compileOperator`. It asks the caller for the field's declaration (new optional `declaredValueShape` option; both callers pass it from the context), calls `expandEmptyOperator`, and compiles the row. The flag joins the existing boolean-domain gate with `$null` / `$exists`. Both NULL-polarity tables gain the row (null satisfies `$empty: true`; the arm is total). - **`where` face (`lowerAnalyticsWhere` / `normalizeAnalyticsFilterTree`).** `fieldLeaves` stops refusing `$empty` and lowers it to a valueless `empty` / `notEmpty` leaf. The normalizer sees no field declaration, and the multi-value row cannot be spelled in the lowered vocabulary, so the row is resolved by each consumer of the tree: - `NativeSQLStrategy.buildFilterClause` (the executed statement) and the `ObjectQLStrategy` echo both call `whereEmptyLeafSql`: the host's declared shape, then the spec's expansion, then the row's SQL. - `ObjectQLStrategy.convertFilter` (the engine path) hands `{ $empty }` to the engine as written. Its arm is the engine lane's (#20444). - The flag joins `assertBooleanNullFlags` with `$null` / `$exists`. Both polarity tables gain the row. - **Where the declaration comes from.** A new context hook, `DatasetScopedStrategyContext.declaredValueShape(object, field)`. `AnalyticsService` answers it from the existing `sourceFieldMeta` hook (`type`, and now `multiple`). `AnalyticsServicePlugin` relays `multiple` from the field definition. **The field's declaration is never guessed.** A face that cannot name it refuses, before anything binds. On the `where` face that is `INVALID_FILTER` / 400; on the read-scope face it is `READ_SCOPE_COMPILE_FAILED` / 500. The same holds for a multi-value field on the `'unknown'` dialect, where no JSON test parses everywhere; the text and null-only rows need no dialect. Why a guess is not possible: the "no declaration" reading the spec gives the JS faces (null, `''` and `[]` all empty) has no SQL form without the type. `amount = ''` is a type error on Postgres, and an empty list is only recognisable as JSON. Both refusals are pinned with `code` + `status`. ## The question the card asked: should the read-scope face answer an unknown operator with 400? **No. It keeps `READ_SCOPE_COMPILE_FAILED` / 500 with the message withheld, and this PR says so in code at `compileOperator`'s `default:` arm.** The triage reading ("an authoring mistake answered as a server error is the wrong class") assumes the caller authored the input. Measured, the caller does not: - **Who writes what reaches `compileScopedFilterToSql`.** Its only two in-package callers are `NativeSQLStrategy.applyReadScope` and the `ObjectQLStrategy.generateSql` echo. Both pass `ctx.getReadScope(object)`. `AnalyticsServicePlugin` answers that hook either from the `security` service's `getReadFilter`, which compiles admin-authored sharing rules and permission sets, or from the host's own `getReadScope` plugin option. The analytics caller's own filter takes the other road, `filter-normalizer.ts`, and that road answers `INVALID_FILTER` / 400 for an unknown operator. - **The ruling already on file.** The read-scope module header records the #5367 maintainer ruling (2026-08-06, re-affirmed as #7598 Q2 = A). A read-scope refusal is a server fault: a 400 "told them to fix a request that was never the problem, and hid the fault from the 5xx alerting". A 4xx body also relayed "THE FIELD NAMES AND COMPARANDS OF THE RLS POLICY". The header states that the envelope "is not to be rewritten". #19995 (`60fdaa9e`, PR #20072) extended the same withheld 500 to the ObjectQL engine door for exactly that disclosure reason. - Pinned: `$bogus` in a read scope still answers `READ_SCOPE_COMPILE_FAILED` / 500 (`read-scope-empty-operator.test.ts`, last case). The existing envelope suites stay green unchanged. ## Filter-semantics compile-surface declaration Roster re-grepped on `fc0db22b` (`grep -rn 'matchesFilterCondition\|buildWhereSQL\|compileScopedFilterToSql' packages --include=*.ts`). | # | face | conclusion | |---|---|---| | 1 | `driver-sql` `applyFilterCondition` (and its `extends SqlDriver` heirs) | **out of scope**: sibling #20444 (`domain:engine`). Measured today: it refuses `$empty` with `INVALID_FILTER` / 400, operator and field withheld. Untouched here. | | 2 | turso `RemoteTransport` `buildWhereSQL` | **out of scope**: sibling #20444. Untouched. | | 3 | service-analytics `read-scope-sql` `compileScopedFilterToSql` | **changed**: the `$empty` arm above, and the boolean gate. | | 4 | service-analytics `filter-normalizer` `lowerAnalyticsWhere` / `normalizeAnalyticsFilterTree` | **changed**: the `empty` / `notEmpty` leaf, answered by NativeSQL and the ObjectQL echo, and handed to the engine by ObjectQL execute. | | 5 | `formula` `matchesFilterCondition` | **out of scope**: sibling #20444. Untouched. | | half-face | objectql `having-filter` (`applyHaving` / `matchesHaving`) | **out of scope**: sibling #20444. Untouched. | | unfrozen | `driver-memory` `checkCondition`, `driver-mongodb` `translateFieldOperators` | **out of scope**: sibling #20444. Untouched. | Two package-local consumers of face 4's tree, named so they don't read as missed: - **ObjectQL execute.** It hands `{ $empty }` to the engine. Until #20444's `driver-sql` arm lands, the engine refuses it, so this query is refused on this strategy, while the echo prints the declared arm and the native statement answers it. No face drops it. - **Draft preview (`preview-evaluator.ts`).** Unchanged. It already refuses `$empty` (`INVALID_FILTER` / 400) with its other unevaluated operators, `$null` among them. ## Evidence (HEAD `6a07f8cc`; the suite ran at `20994c10`, and HEAD adds only the changeset on top of it) - **Premise, re-measured on `fc0db22b` before editing.** `lowerAnalyticsWhere` passed `{ f: { $empty: true } }` through, and `normalizeAnalyticsFilterTree` refused it `INVALID_FILTER` / 400. `compileScopedFilterToSql` refused it `READ_SCOPE_COMPILE_FAILED` / 500, and `$bogus` got the same answer. `git grep -c '$empty'` over `service-analytics/src` read 0 hits; the control word `$null` read 10+ files. - **New pins.** - `read-scope-empty-operator.test.ts`: 31 tests, executed on `sql.js`. - `where-empty-operator.test.ts`: 27 tests. NativeSQL executes on `sql.js`, the ObjectQL echo runs on the same database and must return the same rows, and the condition handed to the engine is pinned. - Fixture: a text, a `tags`, a `lookup` with `multiple: true`, a `select` and a `number` field. Rows: null, `''`, `[]`, a non-list JSON value, and a value. - Covered: `$empty: false` as the complement; nesting under `$and` / `$or` / `$not`; beside another operator on the same field. Refusals assert `code` + `status`. - Postgres / MySQL SQL strings are pinned as compiled, **NOT MEASURED** as executed: there is no live server here. - **Package suite.** `pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2`: `Test Files 134 passed (134)` · `Tests 3151 passed (3151)`. - **Typecheck.** `pnpm --filter @objectstack/service-analytics exec tsc --noEmit --listFiles` exits 0, and its file list contains all three new files. - **Gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 62 commands; all 62 ran. `--ran` reconciliation: "62 derived famil(ies) accounted for — 60 run, 2 NOT-MEASURED". - NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`. Both exit 3 with `PREREQUISITE NOT MET`, because they need the whole workspace built. Narrowed probe instead: this package's built `dist/index.cjs` and `dist/index.js` both load and export `compileScopedFilterToSql`. - **Lint, narrowed.** - Population: `eslint.config.mjs` lints `packages/**/*.{ts,tsx,mts,cts}` with no type-aware parsing (no `parserOptions.project`). A verdict on an untouched file therefore cannot move with this diff. - `pnpm exec eslint --no-inline-config --format json` over the 10 changed `.ts` files: the JSON has 10 file entries, 0 errors, 0 warnings. ### Ablation: the negative pins can fail Committed first; each leg ran through `scripts/ablation-replace.mjs`, which applies the mutation, runs, and restores. Restore was proven blob == HEAD (`05d539c76470`) with `git diff HEAD` empty. - **A1: the null-only row counts `''`.** The `null_only` arm falls through to the text arm. **9 red across both files**, including "the null-only row does NOT count the empty string": `AssertionError: expected [ 'n', 's' ] to not include 's'`. - **A2: `$empty: false` stops being the complement.** The text arm's false branch becomes an OR. **7 red**, including "name: $empty: false is the exact complement": `expected [ 'l', 'o', 's', 'v' ] to deeply equal [ 'l', 'o', 'v' ]`. ## Acceptance notes (observations, not filed) - **The spec's staging prose goes stale here.** The `FILTER_OPERATORS` TSDoc table in `packages/spec/src/data/filter.zod.ts` still lists both service-analytics rows as REFUSES. Carrier: the flip card, which rewrites that table. No `packages/spec` edit here. - **Shared conformance cases belong in the spec.** A shared `$empty` conformance table (per-type rows × stored states, the way `FILTER_LOGIC_CASES` works) would let every face run one standard. That is the spec lane's to add, with the flip card, and is not added here. - **The flip card will need `$empty` rows** in `objectql-echo-operator-coverage.test.ts` (`OPERATOR_CASES`) and `objectql-icontains-arm.test.ts` (`SAMPLES`). Both assert their tables equal `FILTER_OPERATORS`, so they go red on the flip until the rows exist. - **A read scope carrying `$empty` on the ObjectQL execute face** is refused by the engine's driver as `INVALID_FILTER` / 400 with the operator and field withheld, not the read-scope 500. `judgeFilter` admits the operator because it stops before the driver. This predates the PR and closes when #20444 lands the driver arm. No in-repo producer emits `$empty` in a read scope (the CEL lowering's `is_empty` emits `$null`). ## Seat append (`domain:services` seat #6021, `session_017B6YKCGu8CTY2KBWgwaHAs`) - The `Clause-②` line changed from `no` to `yes (widening)`, per contract review FAIL `5876996555`. The PR adds two published members, `multiple?` on `AnalyticsServiceConfig.sourceFieldMeta`'s return shape and `declaredValueShape?` on `compileScopedFilterToSql`'s options. The changeset moves to `minor` in the patch round on this PR. --- _Generated by [Claude Code](https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 48efe91 commit 2b53993

11 files changed

Lines changed: 1089 additions & 18 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/service-analytics': minor
3+
---
4+
5+
fix(service-analytics): both analytics filter faces answer `$empty` by the field's declared type (#20445)
6+
7+
Clause-②: yes (widening)
8+
9+
`$empty: true | false` is declared by `@objectstack/spec` (`FieldOperatorsSchema`) with a per-type meaning: a text-like field is empty when it is null or `''`, a multi-value field (multiselect, checkboxes, tags, or a select / radio / lookup / user / file / image with `multiple: true`) when it is null or `[]`, and every other type only when it is null. `$empty: false` is the exact complement. Both of this package's filter faces now answer it by that table, through the spec's one expansion (`expandEmptyOperator`), instead of refusing it:
10+
11+
- **The analytics `where`** (`/analytics/query`, `/analytics/sql`, dataset filters): `NativeSQLStrategy` and the `ObjectQLStrategy` SQL echo compile the field's declared row; the ObjectQL execute path hands `{ $empty }` to the data engine, which answers it once the engine's own arm lands (until then the engine refuses it, `INVALID_FILTER` / 400, as it does today).
12+
- **Row-level read scopes** compiled to SQL (`compileScopedFilterToSql`): same rows, in the read-scope envelope.
13+
14+
A multi-value field's empty list is tested with a JSON function per SQL dialect (`json_array_length` on SQLite, a `jsonb` comparison on Postgres, `JSON_LENGTH` on MySQL).
15+
16+
**Refused, never guessed** — `INVALID_FILTER` / 400 on the `where` face, `READ_SCOPE_COMPILE_FAILED` / 500 on a read scope — when the host cannot name the field's declared type (no `sourceFieldMeta` wired, or no such field), when a multi-value field's datasource dialect is unknown, and when the flag is not a boolean (`$empty: 'true'` is refused like a non-boolean `$null`).
17+
18+
Host API (two new optional members, hence `minor`): `AnalyticsServiceConfig.sourceFieldMeta` may now answer `multiple` beside `type`, and `AnalyticsServicePlugin` relays it from the field definition; `compileScopedFilterToSql` takes an optional `declaredValueShape` option. A host whose `sourceFieldMeta` answers `type` but not `multiple` has every multi-capable field it declared `multiple: true` (select / radio / lookup / user / file / image) read as single-valued, which is the null-only row. On such a field a read scope's `$empty: false` then admits rows holding `[]`, and `$empty: true` misses them. Relay the field's `multiple` from its definition to get the list row.
19+
20+
`$empty` stays staged: it is not in `FILTER_OPERATORS`, and the view operators `is_empty` / `is_not_empty` still lower to `$null`.
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20445] The read-scope face answers `$empty` by the field's DECLARED type.
5+
*
6+
* Ruling A on #20399 gives each compile surface an arm for the operator, and
7+
* the spec gives every arm one expansion, `expandEmptyOperator(fieldDef)`: the
8+
* ruled per-type 「is empty」 table (#20311). Text-like: null or `''`.
9+
* Multi-value: null or `[]`. Every other type: null only. `$empty: false` is
10+
* the complement.
11+
*
12+
* Executed on real SQLite (`sql.js`, the engine `driver-sql` falls back to),
13+
* over one row per stored state a field can be in: null, `''`, the empty JSON
14+
* list, a non-list JSON value and a real value. Two fields per scalar row kind
15+
* — a `select` and a `number` — because the negative pin is the `select`: its
16+
* column can hold `''`, and `''` is a VALUE on the null-only row.
17+
*
18+
* The compiled SQL is pinned only for the two dialects `sql.js` cannot run
19+
* (Postgres, MySQL): what those strings DO is not measured here.
20+
*/
21+
22+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
23+
import type { FilterCondition, ValueShapeFieldDef } from '@objectstack/spec/data';
24+
25+
import { compileScopedFilterToSql, type ReadScopeCompileOptions } from '../read-scope-sql.js';
26+
27+
const ALIAS = 't';
28+
29+
/** The declared shape of each fixture column: one per row of the ruled table, plus a second null-only type. */
30+
const SHAPES: Record<string, ValueShapeFieldDef> = {
31+
id: { type: 'text' },
32+
name: { type: 'text' },
33+
tags: { type: 'tags' },
34+
owners: { type: 'lookup', multiple: true },
35+
stage: { type: 'select' },
36+
amount: { type: 'number' },
37+
};
38+
39+
/**
40+
* One row per stored state. The multi-value columns hold JSON text, as
41+
* `driver-sql` stores them on SQLite.
42+
*
43+
* - `n` — no value anywhere.
44+
* - `s` — the empty string in every column (a malformed JSON list on the
45+
* multi-value columns, `''` on the `select`, and `0` on the number).
46+
* - `l` — the empty JSON list `[]` (as TEXT on `name` and `stage`, where it is
47+
* two characters, not a list).
48+
* - `o` — a JSON value that is not a list (`{}`, a JSON string).
49+
* - `v` — a real value in every column.
50+
*/
51+
const ROWS = [
52+
{ id: 'n', name: null, tags: null, owners: null, stage: null, amount: null },
53+
{ id: 's', name: '', tags: '', owners: '', stage: '', amount: 0 },
54+
{ id: 'l', name: '[]', tags: '[]', owners: '[]', stage: '[]', amount: 7 },
55+
{ id: 'o', name: 'o', tags: '{}', owners: '"u1"', stage: 'lost', amount: -1 },
56+
{ id: 'v', name: 'acme', tags: '["a"]', owners: '["u1","u2"]', stage: 'won', amount: 5 },
57+
];
58+
const ALL = ROWS.map((r) => r.id).sort();
59+
60+
const complement = (ids: string[]): string[] => ALL.filter((id) => !ids.includes(id));
61+
62+
/** `$empty: true` per field, by the declared row. */
63+
const EMPTY: Record<string, string[]> = {
64+
name: ['n', 's'], // text-like: null or ''
65+
tags: ['l', 'n'], // multi-value: null or []
66+
owners: ['l', 'n'], // multi-value through `multiple: true`
67+
stage: ['n'], // null only — '' is a value on this row
68+
amount: ['n'], // null only — 0 is a value
69+
};
70+
71+
const SQLITE: ReadScopeCompileOptions = { dialect: 'sqlite', declaredValueShape: (field) => SHAPES[field] };
72+
73+
async function locateWasm(): Promise<((file: string) => string) | undefined> {
74+
try {
75+
const { createRequire } = await import('node:module');
76+
const require = createRequire(import.meta.url);
77+
const pkgJsonPath = require.resolve('sql.js/package.json');
78+
const { dirname, join } = await import('node:path');
79+
const dir = dirname(pkgJsonPath);
80+
return (file: string) => join(dir, 'dist', file);
81+
} catch {
82+
return undefined;
83+
}
84+
}
85+
86+
interface CodedError extends Error {
87+
code?: unknown;
88+
status?: unknown;
89+
}
90+
91+
function refusalOf(fn: () => unknown): CodedError {
92+
try {
93+
fn();
94+
} catch (e) {
95+
return e as CodedError;
96+
}
97+
throw new Error('expected a refusal, and the scope compiled');
98+
}
99+
100+
describe('[#20445] compileScopedFilterToSql — `$empty` by the declared type, executed on SQLite', () => {
101+
let db: any;
102+
103+
const run = (scope: FilterCondition, options: ReadScopeCompileOptions = SQLITE): string[] => {
104+
const { sql, params } = compileScopedFilterToSql(scope, ALIAS, options);
105+
const stmt = db.prepare(`SELECT "id" FROM "t" AS "${ALIAS}" WHERE ${sql.length > 0 ? sql : '1 = 1'} ORDER BY "id"`);
106+
stmt.bind(params as any[]);
107+
const got: string[] = [];
108+
while (stmt.step()) got.push(String(stmt.get()[0]));
109+
stmt.free();
110+
return got;
111+
};
112+
113+
beforeAll(async () => {
114+
const mod: any = await import('sql.js');
115+
const initSqlJs = mod.default ?? mod;
116+
const locateFile = await locateWasm();
117+
const SQL = await initSqlJs(locateFile ? { locateFile } : undefined);
118+
db = new SQL.Database();
119+
db.run(`CREATE TABLE "t" ("id" TEXT PRIMARY KEY, "name" TEXT, "tags" TEXT, "owners" TEXT, "stage" TEXT, "amount" REAL);`);
120+
const insert = db.prepare(`INSERT INTO "t" ("id","name","tags","owners","stage","amount") VALUES (?,?,?,?,?,?)`);
121+
for (const r of ROWS) insert.run([r.id, r.name, r.tags, r.owners, r.stage, r.amount]);
122+
insert.free();
123+
});
124+
125+
afterAll(() => {
126+
db?.close();
127+
});
128+
129+
for (const [field, expected] of Object.entries(EMPTY)) {
130+
it(`${field} (${SHAPES[field].type}${SHAPES[field].multiple ? ', multiple' : ''}): $empty: true → ${expected.join(',')}`, () => {
131+
expect(run({ [field]: { $empty: true } })).toEqual(expected);
132+
});
133+
it(`${field}: $empty: false is the exact complement`, () => {
134+
expect(run({ [field]: { $empty: false } })).toEqual(complement(expected));
135+
});
136+
}
137+
138+
it('the null-only row does NOT count the empty string: a select holding "" is not empty', () => {
139+
expect(run({ stage: { $empty: true } })).not.toContain('s');
140+
expect(run({ stage: { $empty: false } })).toContain('s');
141+
});
142+
143+
it('a text column holding the two characters "[]" is a value, not an empty list', () => {
144+
expect(run({ name: { $empty: true } })).not.toContain('l');
145+
});
146+
147+
it('on a multi-value column, "" and a non-list JSON value are values, not the empty list', () => {
148+
expect(run({ tags: { $empty: true } })).not.toContain('s');
149+
expect(run({ tags: { $empty: true } })).not.toContain('o');
150+
expect(run({ owners: { $empty: true } })).not.toContain('o');
151+
});
152+
153+
describe('nested under $and / $or / $not', () => {
154+
it('$not over $empty: true is its complement — no NULL guard needed, the arm is total', () => {
155+
expect(run({ $not: { name: { $empty: true } } })).toEqual(complement(EMPTY.name));
156+
expect(run({ $not: { tags: { $empty: true } } })).toEqual(complement(EMPTY.tags));
157+
expect(run({ $not: { amount: { $empty: false } } })).toEqual(EMPTY.amount);
158+
});
159+
160+
it('$and of two $empty: false', () => {
161+
expect(run({ $and: [{ tags: { $empty: false } }, { name: { $empty: false } }] })).toEqual(['o', 'v']);
162+
});
163+
164+
it('$or of two $empty: true', () => {
165+
expect(run({ $or: [{ stage: { $empty: true } }, { tags: { $empty: true } }] })).toEqual(['l', 'n']);
166+
});
167+
168+
it('$not over an $or of $empty', () => {
169+
expect(run({ $not: { $or: [{ name: { $empty: true } }, { owners: { $empty: true } }] } })).toEqual(['o', 'v']);
170+
});
171+
172+
it('beside another operator on the same field', () => {
173+
expect(run({ name: { $empty: false, $ne: 'acme' } })).toEqual(['l', 'o']);
174+
expect(run({ $not: { name: { $empty: false, $ne: 'acme' } } })).toEqual(['n', 's', 'v']);
175+
});
176+
});
177+
});
178+
179+
describe('[#20445] the SQL each dialect is handed (Postgres / MySQL: compiled, not executed here)', () => {
180+
const sqlOf = (dialect: string, scope: FilterCondition) =>
181+
compileScopedFilterToSql(scope, ALIAS, { dialect, declaredValueShape: (field) => SHAPES[field] });
182+
183+
it('text-like and null-only rows are dialect-free, and bind the empty string', () => {
184+
for (const dialect of ['sqlite', 'postgres', 'mysql', 'unknown']) {
185+
expect(sqlOf(dialect, { name: { $empty: true } })).toEqual({ sql: '("t"."name" IS NULL OR "t"."name" = ?)', params: [''] });
186+
expect(sqlOf(dialect, { name: { $empty: false } })).toEqual({ sql: '("t"."name" IS NOT NULL AND "t"."name" <> ?)', params: [''] });
187+
expect(sqlOf(dialect, { stage: { $empty: true } })).toEqual({ sql: '"t"."stage" IS NULL', params: [] });
188+
expect(sqlOf(dialect, { stage: { $empty: false } })).toEqual({ sql: '"t"."stage" IS NOT NULL', params: [] });
189+
}
190+
});
191+
192+
it('Postgres compares the JSON column as jsonb', () => {
193+
expect(sqlOf('postgres', { tags: { $empty: true } }).sql).toBe(
194+
`("t"."tags" IS NULL OR (CAST("t"."tags" AS jsonb) = CAST('[]' AS jsonb)))`,
195+
);
196+
expect(sqlOf('postgres', { tags: { $empty: false } }).sql).toBe(
197+
`("t"."tags" IS NOT NULL AND NOT (CAST("t"."tags" AS jsonb) = CAST('[]' AS jsonb)))`,
198+
);
199+
});
200+
201+
it('MySQL asks the JSON type beside the length', () => {
202+
expect(sqlOf('mysql', { owners: { $empty: true } }).sql).toBe(
203+
`("t"."owners" IS NULL OR (JSON_TYPE("t"."owners") = 'ARRAY' AND JSON_LENGTH("t"."owners") = 0))`,
204+
);
205+
});
206+
});
207+
208+
describe('[#20445] refusals — READ_SCOPE_COMPILE_FAILED / 500, the module’s one envelope', () => {
209+
const expectServerFault = (err: CodedError) => {
210+
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
211+
expect(err.status).toBe(500);
212+
};
213+
214+
it('no declared value shape wired: refused, never guessed', () => {
215+
const err = refusalOf(() => compileScopedFilterToSql({ name: { $empty: true } }, ALIAS, { dialect: 'sqlite' }));
216+
expectServerFault(err);
217+
expect(err.message).toContain('needs the field\'s declared type');
218+
});
219+
220+
it('a field the host cannot name: refused', () => {
221+
expectServerFault(refusalOf(() => compileScopedFilterToSql({ ghost: { $empty: false } }, ALIAS, SQLITE)));
222+
});
223+
224+
it('a multi-value field on the unknown dialect: refused; the other rows still compile there', () => {
225+
const options: ReadScopeCompileOptions = { declaredValueShape: (field) => SHAPES[field] };
226+
const err = refusalOf(() => compileScopedFilterToSql({ owners: { $empty: true } }, ALIAS, options));
227+
expectServerFault(err);
228+
expect(err.message).toContain('dialect of this datasource is not known');
229+
expect(() => compileScopedFilterToSql({ name: { $empty: true }, amount: { $empty: false } }, ALIAS, options)).not.toThrow();
230+
});
231+
232+
for (const flag of ['true', 'false', 1, null, [true]]) {
233+
it(`a non-boolean flag (${JSON.stringify(flag)}) is refused by the boolean-domain gate`, () => {
234+
const err = refusalOf(() => compileScopedFilterToSql({ name: { $empty: flag } } as FilterCondition, ALIAS, SQLITE));
235+
expectServerFault(err);
236+
expect(err.message).toContain('comparand for "$empty" at "name".$empty is not a boolean');
237+
});
238+
}
239+
240+
it('under $not, a non-boolean flag is still refused', () => {
241+
expectServerFault(refusalOf(() => compileScopedFilterToSql({ $not: { name: { $empty: 'false' } } } as FilterCondition, ALIAS, SQLITE)));
242+
});
243+
244+
it('an operator without an arm keeps READ_SCOPE_COMPILE_FAILED / 500, not the where door’s 400 (#5367 stands)', () => {
245+
const err = refusalOf(() => compileScopedFilterToSql({ name: { $bogus: 1 } } as FilterCondition, ALIAS, SQLITE));
246+
expectServerFault(err);
247+
expect(err.code).not.toBe('INVALID_FILTER');
248+
});
249+
});

0 commit comments

Comments
 (0)