diff --git a/.changeset/21376-boolean-comparand-compilers.md b/.changeset/21376-boolean-comparand-compilers.md new file mode 100644 index 00000000000..f33ef6fe24c --- /dev/null +++ b/.changeset/21376-boolean-comparand-compilers.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-security': minor +'@objectstack/service-analytics': minor +--- + +Row-level security policies and the analytics native-SQL path judge a comparand against a declared boolean field by the platform's boolean-comparand rule, the one the data engine's `where` already applies + +Clause-②: no (narrowing) + + + +**BREAKING**: this narrows what two compilers outside the engine's `where` door accept. The RLS compile seam now drops a row-level policy, and the analytics native-SQL face now refuses a query, when either compares a declared boolean field with a comparand outside the accepted set. It ships as `minor` under the launch-window convention for accept-set narrowings. No export, type or error code changes. + +- **Row-level security (`@objectstack/plugin-security`).** A compiled `using` / `check` predicate on a `boolean` or `toggle` column (or a `formula` returning `boolean`) is judged by `booleanComparandDoorVerdict` from `@objectstack/spec/data`, in the same pass as the number rule. `'true'` / `'false'`, `'1'` / `'0'` and `1` / `0` are read as the boolean each names. Anything else the rule refuses (a string such as `'yes'`, `'TRUE'` or `''`, a number other than `1` / `0`) drops the policy as a refused comparand: the read is filtered by the deny sentinel, the write is refused 403, and the WARN line names the clause, the field and the position. Before, `record.flag != 'true'` kept every row on SQLite and the write check admitted every row, so the exclusion the author wrote was not applied. +- **Analytics native SQL (`@objectstack/service-analytics`).** The query's `where` (and the dataset query's `runtimeFilter`, which is merged into it), each measure's own `filter` and a dataset's own `filter` are judged by the same rule before the statement compiles. An accepted spelling is read as its boolean, and anything else the rule refuses is refused `INVALID_FILTER` / 400 with the rule's own message, before any statement runs. The native strategy now answers what the engine-aggregate strategy answers. Before, `{ flag: 'true' }` counted no rows on SQLite, `{ flag: { $ne: 'true' } }` counted every row, and `{ flag: 'yes' }` answered 200. +- **What you may notice.** A policy or analytics filter that compared a boolean field with a value outside the accepted set now refuses instead of answering. Write `true` / `false`. A policy `record.flag == 1` now admits writing a `true` row, which its read already showed. +- **Unchanged.** A boolean literal, a column that is not boolean, a `{ $field }` reference, and an object whose declaration cannot be read (nothing is judged without one). diff --git a/content/docs/permissions/rls.mdx b/content/docs/permissions/rls.mdx index 396a2c537ad..b8271ae66a0 100644 --- a/content/docs/permissions/rls.mdx +++ b/content/docs/permissions/rls.mdx @@ -197,14 +197,23 @@ Layer 1 (business RLS) ─┘ ## The fail-closed contract -Four ways a policy denies rather than leaks: +Five ways a policy denies rather than leaks: 1. A policy exists but **every** applicable expression fails to compile → a deny-everything filter. 2. A referenced context variable is missing, null, or an empty array → that policy drops out (it cannot match). 3. A policy references a column the object doesn't have → deny. -4. `check` is omitted → `using` stands in as the `check`. The choice is +4. A policy compares a column with a literal its declared type cannot be + compared with → that policy drops out, for `using` and `check` alike: on a + `boolean` / `toggle` column, anything but `true` / `false` and the + spellings `'true'` / `'false'`, `1` / `0` and `'1'` / `'0'` + (`active == 'yes'`, `active != 'TRUE'`, `active == 2`); on a numeric + column, a comparand that is not a number (a string with no numeric reading, + a boolean). These are the comparisons a caller's `where` is refused for + (`INVALID_FILTER`). An accepted spelling is read as the value it names, so + `active != 'true'` hides the `true` rows exactly as `active != true` does. +5. `check` is omitted → `using` stands in as the `check`. The choice is made per operation across all the applicable policies, not policy by policy: when any of them declares a `check`, only the declared checks decide, and a USING-only sibling's `using` is not part of the check. diff --git a/packages/plugins/plugin-security/src/rls-boolean-comparand-door.test.ts b/packages/plugins/plugin-security/src/rls-boolean-comparand-door.test.ts new file mode 100644 index 00000000000..7e11069617a --- /dev/null +++ b/packages/plugins/plugin-security/src/rls-boolean-comparand-door.test.ts @@ -0,0 +1,300 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21376] A row-level policy comparing a BOOLEAN column with a comparand that + * is not a boolean is refused at the RLS compile seam, and an accepted + * spelling is narrowed to the boolean it names — as the engine's `where` door + * answers the same comparison — so the read, the write and a caller's `where` + * give one answer. + * + * Driven through the real engine, the real plugin and `SqlDriver`, as a member + * resolving a permission set whose `using` and `check` are the same predicate, + * over two rows (`t` stores `true`, `f` stores `false`). Measured before the + * seam ran the spec's boolean-comparand verdict (`booleanComparandDoorVerdict`, + * the one the engine's `where` door consults beside the number one): + * + * | predicate | read, SQLite | read, PostgreSQL 16 | write `true` / `false` | `where` twin | + * |---|---|---|---|---| + * | `record.flag == true` | `t` | `t` | admitted / 403 | `t` | + * | `record.flag == 'true'` | none | `t` | 403 / 403 | `t` | + * | `record.flag != 'true'` | **`f`, `t`** | `f` | **admitted** / admitted | `f` | + * | `record.flag == 'yes'` | none | **`t`** | 403 / 403 | `INVALID_FILTER` / 400 | + * | `record.flag == 1` | `t` | `t` | 403 / 403 | `t` | + * | `record.flag == '1'` | `t` | `t` | 403 / 403 | `t` | + * + * The negation was fail-open on both faces: the read kept the row the author + * excluded, and the write check admitted it. The policy's comparand is now + * judged by the spec's verdict in the same walk as the number arm: `'true'` / + * `'false'`, `'1'` / `'0'` and `1` / `0` narrow to the boolean each names, so + * every cell answers the `where` twin's rows and the write check admits + * exactly what the read shows; anything else the verdict refuses drops the + * policy through the `refused-comparand` route for both clauses (the read gets + * the deny sentinel, the write a 403). `record.flag == true` is the control + * and does not move. + * + * The compiled policy filter is deep-frozen as `compileCelToFilter` returns it + * (the mock below), so every cell also holds the narrowing to copy-on-write: + * an edit in place would throw. The last block reads the frozen filter back. + * + * The PostgreSQL cell runs where `OS_TEST_POSTGRES_URL` is set and is a named + * skip otherwise; no CI step provisions that variable for this package. Each + * live table is dropped after its case. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +/** Every compiled policy filter `compileCelToFilter` handed the seam, deep-frozen, by predicate. */ +const compiled = new Map(); + +function deepFreeze(value: T): T { + if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value); + for (const child of Object.values(value as Record)) deepFreeze(child); + } + return value; +} + +vi.mock('@objectstack/formula', async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + compileCelToFilter: ((expression, options) => { + const result = real.compileCelToFilter(expression, options); + if (result.ok) { + deepFreeze(result.filter); + const key = typeof expression === 'string' ? expression : (expression.source ?? ''); + compiled.set(key, [...(compiled.get(key) ?? []), result.filter]); + } + return result; + }) as typeof real.compileCelToFilter, + }; +}); + +const SYS_CTX = { isSystem: true, userId: 'usr_system' }; +const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; + +interface Cell { + id: 'sqlite' | 'pg'; + label: string; + config: () => Record | null; +} + +const DRIVER_CELLS: readonly Cell[] = [ + { id: 'sqlite', label: 'SqlDriver, SQLite', config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) }, + { + id: 'pg', + label: 'SqlDriver, live PostgreSQL', + config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null), + }, +]; + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) { + try { await cleanups.pop()!(); } catch { /* noop */ } + } +}); + +let seq = 0; +/** One engine and plugin over `config`, with ONE policy whose `using` and `check` are the same predicate. */ +async function boot(config: Record, predicate: string) { + const OBJ = `qa_rls_boolean_${process.pid}_${++seq}`; + const driver = new SqlDriver(config as never); + const engine = new ObjectQL(); + engine.registerDriver(driver as never, true); + await engine.init(); + engine.registerApp({ + id: `com.objectstack.qa.rls-boolean-comparand-${seq}`, + name: 'RLS boolean comparand', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + { + name: OBJ, + label: 'Flagged line', + sharingModel: 'public_read_write', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + flag: { name: 'flag', type: 'boolean' }, + }, + }, + ], + } as never); + await engine.syncSchemas(); + cleanups.push(async () => { + if (config.client === 'pg') await (driver as unknown as { execute(sql: string): Promise }).execute(`drop table if exists ${OBJ}`); + await engine.destroy(); + }); + + const set = PermissionSetSchema.parse({ + name: 'qa_boolean_guard', + objects: { [OBJ]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + rowLevelSecurity: [{ name: 'boolean_guard', object: OBJ, operation: 'all', using: predicate, check: predicate }], + }); + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [MEMBER_DEFAULT, set], + }, + }; + const warn = vi.fn(); + const ctx = { + logger: { info: vi.fn(), warn, error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); + + await engine.insert(OBJ, { id: 't', flag: true }, { context: SYS_CTX } as never); + await engine.insert(OBJ, { id: 'f', flag: false }, { context: SYS_CTX } as never); + + const caller = { userId: 'usr_member', positions: ['qa_pos'], permissions: [set.name], posture: 'MEMBER' }; + const ids = (rows: unknown) => (rows as Array<{ id: string }>).map((r) => r.id).sort(); + /** The ids the member's read shows. */ + const shown = async () => ids(await engine.find(OBJ, { context: caller } as never)); + /** The engine's `where` door on the same comparison: the rows it serves, or its refusal envelope. */ + const whereTwin = (where: Record) => + engine.find(OBJ, { where, context: SYS_CTX } as never).then(ids, (e: unknown) => envelopeOf(e)); + const write = (id: string, flag: boolean) => + outcome(engine.insert(OBJ, { id, flag }, { context: caller } as never)); + /** `clause:reason` of every fail-closed drop of this policy the compile seam logged. */ + const drops = () => [ + ...new Set( + warn.mock.calls + .map((call) => call[1] as { reason?: string; clause?: string; policy?: string } | undefined) + .filter((meta) => meta?.policy === 'boolean_guard') + .map((meta) => `${meta!.clause}:${meta!.reason}`), + ), + ].sort(); + /** The `detail` of each fail-closed drop of this policy, by clause. */ + const details = () => + Object.fromEntries( + warn.mock.calls + .map((call) => call[1] as { clause?: string; policy?: string; detail?: string } | undefined) + .filter((meta) => meta?.policy === 'boolean_guard') + .map((meta) => [meta!.clause!, meta!.detail!]), + ) as Record; + return { shown, whereTwin, write, drops, details }; +} + +type Envelope = { code: string; status: number }; +const DENIED: Envelope = { code: 'PERMISSION_DENIED', status: 403 }; +const REFUSED: Envelope = { code: 'INVALID_FILTER', status: 400 }; +const envelopeOf = (e: unknown): Envelope => { + const x = e as { code?: string; status?: number; statusCode?: number }; + return { code: String(x?.code), status: Number(x?.statusCode ?? x?.status) }; +}; +const outcome = (p: Promise): Promise<'admitted' | Envelope> => + p.then(() => 'admitted' as const, (e: unknown) => envelopeOf(e)); + +interface Row { + predicate: string; + /** The same comparison as a caller's `where`. */ + where: Record; + /** What the member's read shows — the `where` twin's rows. */ + shown: string[]; + /** The write check on a `true` row and on a `false` row. */ + writes: readonly ['admitted' | Envelope, 'admitted' | Envelope]; +} + +/** Each cell answers the engine-door column; the write check admits what the read shows. */ +const NARROWED: readonly Row[] = [ + // The control: a boolean literal. Unchanged on every face. + { predicate: 'record.flag == true', where: { flag: true }, shown: ['t'], writes: ['admitted', DENIED] }, + { predicate: "record.flag == 'true'", where: { flag: 'true' }, shown: ['t'], writes: ['admitted', DENIED] }, + // The negation hides the row the author excluded, and the write check refuses it. + { predicate: "record.flag != 'true'", where: { flag: { $ne: 'true' } }, shown: ['f'], writes: [DENIED, 'admitted'] }, + { predicate: "record.flag == 'false'", where: { flag: 'false' }, shown: ['f'], writes: [DENIED, 'admitted'] }, + // The number spelling: the read was already right on both dialects; the + // write check compared the stored `true` with `1` and refused it. + { predicate: 'record.flag == 1', where: { flag: 1 }, shown: ['t'], writes: ['admitted', DENIED] }, + { predicate: "record.flag == '1'", where: { flag: '1' }, shown: ['t'], writes: ['admitted', DENIED] }, + { predicate: "record.flag in ['true']", where: { flag: { $in: ['true'] } }, shown: ['t'], writes: ['admitted', DENIED] }, +]; + +/** Each refused: no row shown, both writes 403, both clauses dropped, and the twin is the engine's 400. */ +const REFUSED_ROWS: ReadonlyArray]> = [ + ["record.flag == 'yes'", { flag: 'yes' }], + ["record.flag != 'yes'", { flag: { $ne: 'yes' } }], + ["record.flag == 'TRUE'", { flag: 'TRUE' }], + ["record.flag in [true, 'on']", { flag: { $in: [true, 'on'] } }], + // A number other than 1 / 0 is the verdict's refusal too: the seam carries no + // table of its own, so it answers whatever the spec's verdict answers. + ['record.flag == 2', { flag: 2 }], +]; + +for (const cell of DRIVER_CELLS) { + const config = cell.config(); + const suffix = config ? '' : ' (skipped: set OS_TEST_POSTGRES_URL to run this cell)'; + + describe.skipIf(!config)(`[#21376] a boolean comparand is narrowed at the RLS seam as the where door narrows it — ${cell.label}${suffix}`, () => { + for (const row of NARROWED) { + it(`${row.predicate}: the read shows [${row.shown.join(', ')}], the where twin's rows, and the write check agrees`, async () => { + const r = await boot(config!, row.predicate); + expect(await r.whereTwin(row.where)).toEqual(row.shown); + expect(await r.shown()).toEqual(row.shown); + expect(await r.write('wt', true)).toEqual(row.writes[0]); + expect(await r.write('wf', false)).toEqual(row.writes[1]); + expect(r.drops()).toEqual([]); + }); + } + }); + + describe.skipIf(!config)(`[#21376] a string that names no boolean is refused at the RLS seam, read and write alike — ${cell.label}${suffix}`, () => { + for (const [predicate, where] of REFUSED_ROWS) { + it(`${predicate}: no row is shown, both writes are 403, and the where twin is INVALID_FILTER / 400`, async () => { + const r = await boot(config!, predicate); + expect(await r.whereTwin(where)).toEqual(REFUSED); + expect(await r.shown()).toEqual([]); + expect(await r.write('wt', true)).toEqual(DENIED); + expect(await r.write('wf', false)).toEqual(DENIED); + // Both clauses dropped the policy through the shared-face route. + expect(r.drops()).toEqual(['check:refused-comparand', 'using:refused-comparand']); + }); + } + }); +} + +describe('[#21376] the refusal names its clause and the comparand\'s position', () => { + it("record.flag != 'yes': each clause's detail is rooted at that clause", async () => { + const r = await boot(DRIVER_CELLS[0].config()!, "record.flag != 'yes'"); + expect(await r.shown()).toEqual([]); + expect(await r.write('wt', true)).toEqual(DENIED); + const { using, check } = r.details(); + expect(using).toContain('`using` predicate compares a boolean column'); + expect(using).toContain('using.flag.$ne'); + expect(check).toContain('`check` predicate compares a boolean column'); + expect(check).toContain('check.flag.$ne'); + }); +}); + +describe('[#21376] narrowing is copy-on-write: the compiled policy filter is never edited', () => { + it("record.flag != 'true': the filter the compiler returned still holds the string after the read and the write", async () => { + const predicate = "record.flag != 'true'"; + compiled.delete(predicate); + const r = await boot(DRIVER_CELLS[0].config()!, predicate); + expect(await r.shown()).toEqual(['f']); + expect(await r.write('wt', true)).toEqual(DENIED); + const filters = compiled.get(predicate) ?? []; + // One compile per read and per write check, every one frozen and unedited. + expect(filters.length).toBeGreaterThanOrEqual(2); + for (const filter of filters) { + expect(Object.isFrozen(filter)).toBe(true); + expect(JSON.stringify(filter)).toContain('"true"'); + } + }); +}); diff --git a/packages/plugins/plugin-security/src/rls-compiler.ts b/packages/plugins/plugin-security/src/rls-compiler.ts index bcc1b64f7d0..ebab98d8268 100644 --- a/packages/plugins/plugin-security/src/rls-compiler.ts +++ b/packages/plugins/plugin-security/src/rls-compiler.ts @@ -38,6 +38,17 @@ import { numberComparandRefusalMessage, type NumberComparandDoorFieldMeta, } from '@objectstack/spec/data'; +// [#21376] The boolean-comparand verdict the engine's `where` door consults +// beside the number one (`@objectstack/objectql`'s +// `boolean-comparand-declared-type-door.ts`), read from the same spec module — +// one verdict, one set of accepted spellings, one refusal sentence — and run in +// the same walk as the number arm (`judgeCompiledComparands`). +import { + booleanComparandDoorVerdict, + booleanComparandFieldVerdict, + booleanComparandRefusalMessage, + type BooleanComparandDoorFieldMeta, +} from '@objectstack/spec/data'; /** * Why a policy's predicate produced no filter — the compiler's OWN answer, @@ -161,6 +172,16 @@ export interface RlsFieldGuard { * number ({@link judgeCompiledComparands}). Absent, nothing is judged: the * door the engine runs for an object with no field map judges nothing * either. + * + * [#21376] The map records EVERY declared column (`security-plugin.ts`'s + * `loadObjectFieldNames` writes each one, and leaves the class to the + * verdict), so it is also the slice the spec's boolean-comparand verdict + * reads — the same `type` / `returnType` pair (`BooleanComparandDoorFieldMeta`). + * A comparand a BOOLEAN column cannot be compared with (`'yes'`, a number + * other than `1` / `0` — whatever the verdict refuses) is refused here as the + * engine's `where` door refuses it, and an accepted spelling is narrowed to + * its boolean. Each verdict decides which columns it judges; the two classes + * are disjoint. */ number?: ReadonlyMap; } @@ -324,10 +345,19 @@ type RlsComparandVerdict = * * [#21242] With the guard's declared column types ({@link RlsFieldGuard.number}), * a third face runs between those two: the spec's number-comparand verdict, the - * one the engine's `where` door consults ({@link narrowPolicyNumberComparands}). + * one the engine's `where` door consults ({@link narrowPolicyComparands}). * A comparand a numeric column cannot be compared with is refused through the * same route, and a numeric string is narrowed to its number. * + * [#21376] The same walk carries the boolean arm, as the engine's walk does: + * the spec's boolean-comparand verdict, read off the same declared types. A + * comparand a boolean column cannot be compared with (`'yes'`, `2`) is refused + * through the same route, and `'true'` / `'false'`, `'1'` / `'0'` and `1` / `0` + * narrow to the boolean each names. Before it ran, `record.flag != 'true'` + * compiled to `{ flag: { $ne: 'true' } }`, which the read compared with the + * stored `1` / `0` on SQLite and so kept EVERY row — the exclusion the author + * wrote was not applied — and which the write check admitted for every row. + * * Returns the faces' own filter (the type face narrows an exact-range `bigint` * copy-on-write and returns the same reference otherwise), then LOWERED by the * shared `lowerFilterCondition` (ADR-0053 D-D1, amended — #5930): this is the @@ -341,7 +371,7 @@ type RlsComparandVerdict = function judgeCompiledComparands( filter: Record, lowering: FilterLoweringOptions, - numberFields?: ReadonlyMap, + declaredFields?: ReadonlyMap, clause: 'using' | 'check' = 'using', ): RlsComparandVerdict { try { @@ -350,8 +380,9 @@ function judgeCompiledComparands( // after the shape door, before the comparand-type door. A refusal here // leaves through the catch below like the other faces' refusals, so the // policy is dropped for `using` and `check` alike: the read gets the deny - // sentinel and the write a 403, one answer for one comparison. - const judged = numberFields ? narrowPolicyNumberComparands(filter, numberFields, clause, clause) : filter; + // sentinel and the write a 403, one answer for one comparison. [#21376] + // The boolean arm rides the same walk, so its refusal leaves the same way. + const judged = declaredFields ? narrowPolicyComparands(filter, declaredFields, clause, clause) : filter; // [ADR-0053 D-D1, amended — #5930] The RLS compile seam's lowering, AFTER // both faces (the amendment's items 2-3). No token resolution precedes it // because none exists on either clause: the engine resolves the caller's @@ -363,9 +394,10 @@ function judgeCompiledComparands( // owns its refusal. return { ok: true, filter: lowerFilterCondition(normalizeFilterComparandTypes(judged), lowering) }; } catch (thrown) { - // [#21242] The number door's refusal carries its own detail, already - // written for the clause being compiled (see `numberRefusalDetail`). - if (thrown instanceof PolicyNumberComparandRefusal) return { ok: false, detail: thrown.detail }; + // [#21242, #21376] A declared-type arm's refusal carries its own detail, + // already written for the clause being compiled (see `numberRefusalDetail` + // and `booleanRefusalDetail`). + if (thrown instanceof PolicyComparandRefusal) return { ok: false, detail: thrown.detail }; const { code, status } = (thrown ?? {}) as { code?: unknown; status?: unknown }; if (!(thrown instanceof Error) || typeof code !== 'string' || typeof status !== 'number') throw thrown; // The face's sentence is quoted whole (it names the operator, the field and @@ -381,9 +413,14 @@ function judgeCompiledComparands( } } -/** The operators whose one comparand the number door judges — the spec's list, never a re-listing. */ +/** + * The operators whose one comparand the declared-type arms judge — the spec's + * list, never a re-listing. [#21376] The boolean arm's list is the number + * door's BY IDENTITY (`BOOLEAN_COMPARAND_DOOR_SCALAR_OPERATORS`), as in the + * engine's walk, so both arms judge at the same positions by construction. + */ const NUMBER_DOOR_SCALAR_OPERATORS: ReadonlySet = new Set(NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS); -/** The operators each of whose MEMBERS the number door judges. */ +/** The operators each of whose MEMBERS the declared-type arms judge (likewise shared by identity). */ const NUMBER_DOOR_LIST_OPERATORS: ReadonlySet = new Set(NUMBER_COMPARAND_DOOR_LIST_OPERATORS); /** A plain object: a filter node or an operator map, never a comparand (a `Date` is data). */ @@ -400,18 +437,20 @@ function withProvenanceOf(from: unknown, to: T): T { } /** - * [#21242] The number door's refusal of one compiled policy comparand. It + * [#21242] A declared-type arm's refusal of one compiled policy comparand. It * carries the ADR-0112 envelope the shared faces' refusals carry, and the * detail the fail-closed WARN line prints, already written for the clause - * being compiled ({@link numberRefusalDetail}). + * being compiled ({@link numberRefusalDetail}, {@link booleanRefusalDetail}). + * [#21376] One class for both arms: {@link judgeCompiledComparands} routes + * them the same way. */ -class PolicyNumberComparandRefusal extends Error { +class PolicyComparandRefusal extends Error { readonly code: string; readonly status: number; readonly detail: string; constructor(detail: string, code: string, status: number) { super(detail); - this.name = 'PolicyNumberComparandRefusal'; + this.name = 'PolicyComparandRefusal'; this.detail = detail; this.code = code; this.status = status; @@ -437,59 +476,97 @@ function numberRefusalDetail(clause: 'using' | 'check', code: string, message: s } /** - * One comparand on a numeric column, by the spec's verdict: the narrowed - * number, the comparand unchanged, or a thrown {@link PolicyNumberComparandRefusal} - * in the spec's words — the refusal {@link judgeCompiledComparands} routes. + * [#21376] The WARN detail for a boolean-arm refusal, naming the clause — the + * number detail's shape. The spec's sentence is quoted whole bar its closing + * full stop, rooted at the clause (`using.flag.$ne`). */ -function judgedNumberComparand( - meta: NumberComparandDoorFieldMeta, - field: string, - comparand: unknown, - path: string, - clause: 'using' | 'check', -): unknown { - const verdict = numberComparandDoorVerdict(meta, comparand); - if (verdict.verdict === 'narrows') return verdict.value; - if (verdict.verdict !== 'door-refusal') return comparand; - const message = numberComparandRefusalMessage({ - field, - declaredType: meta.type, - ...(meta.returnType === undefined ? {} : { returnType: meta.returnType }), - path, - value: comparand, - form: verdict.form, - boundByDriver: clause === 'using', - }); - // The envelope is spelled as literals, the ones the spec's verdict types its - // refusal with (`code: 'INVALID_FILTER'`, `status: 400`), so the codes this - // site stamps are readable at the site (`check:dispatcher-error-vocabulary`). - throw new PolicyNumberComparandRefusal(numberRefusalDetail(clause, 'INVALID_FILTER', message), 'INVALID_FILTER', 400); +function booleanRefusalDetail(clause: 'using' | 'check', code: string, message: string): string { + const consumer = clause === 'using' + ? 'the read, where a driver binds it' + : 'the write check, which evaluates it in-process'; + return ( + `the compiled \`${clause}\` predicate compares a boolean column with a comparand that is not a boolean (${code}), ` + + `so the policy was not handed to ${consumer}. In the platform's boolean-comparand words: ${message.replace(/\.$/, '')}` + ); } -/** One judged column's constraint, `{ amount: }`, with its comparands judged. */ -function narrowedNumberFieldSpec( - meta: NumberComparandDoorFieldMeta, - field: string, - spec: unknown, - path: string, - clause: 'using' | 'check', -): unknown { +/** + * One declared-type arm's judgment of ONE comparand at a judged position: the + * narrowed value, the comparand unchanged, or a thrown + * {@link PolicyComparandRefusal}. [#21376] {@link narrowedFieldSpec} hands it + * every judged position, so both arms judge at the same boundaries. + */ +type JudgeOne = (comparand: unknown, path: string) => unknown; + +/** + * The number arm, bound to a numeric column: by the spec's verdict, the + * narrowed number, the comparand unchanged, or a thrown + * {@link PolicyComparandRefusal} in the spec's words — the refusal + * {@link judgeCompiledComparands} routes. + */ +const numberArm = (meta: NumberComparandDoorFieldMeta, field: string, clause: 'using' | 'check'): JudgeOne => + (comparand, path) => { + const verdict = numberComparandDoorVerdict(meta, comparand); + if (verdict.verdict === 'narrows') return verdict.value; + if (verdict.verdict !== 'door-refusal') return comparand; + const message = numberComparandRefusalMessage({ + field, + declaredType: meta.type, + ...(meta.returnType === undefined ? {} : { returnType: meta.returnType }), + path, + value: comparand, + form: verdict.form, + boundByDriver: clause === 'using', + }); + // The envelope is spelled as literals, the ones the spec's verdict types its + // refusal with (`code: 'INVALID_FILTER'`, `status: 400`), so the codes this + // site stamps are readable at the site (`check:dispatcher-error-vocabulary`). + throw new PolicyComparandRefusal(numberRefusalDetail(clause, 'INVALID_FILTER', message), 'INVALID_FILTER', 400); + }; + +/** + * [#21376] The boolean arm, bound to a boolean column (`boolean`, `toggle`, a + * `formula` returning `boolean`): by the spec's verdict + * (`booleanComparandDoorVerdict`), the boolean an accepted spelling names, the + * comparand unchanged, or a thrown {@link PolicyComparandRefusal} in the + * spec's words (`booleanComparandRefusalMessage`). ⛔ Nothing here reads a + * spelling: the verdict does. + */ +const booleanArm = (meta: BooleanComparandDoorFieldMeta, field: string, clause: 'using' | 'check'): JudgeOne => + (comparand, path) => { + const verdict = booleanComparandDoorVerdict(meta, comparand); + if (verdict.verdict === 'narrows') return verdict.value; + if (verdict.verdict !== 'door-refusal') return comparand; + const message = booleanComparandRefusalMessage({ + field, + declaredType: meta.type, + ...(meta.returnType === undefined ? {} : { returnType: meta.returnType }), + path, + value: comparand, + form: verdict.form, + }); + // Literals, as the number arm spells them (`check:dispatcher-error-vocabulary`). + throw new PolicyComparandRefusal(booleanRefusalDetail(clause, 'INVALID_FILTER', message), 'INVALID_FILTER', 400); + }; + +/** One judged column's constraint, `{ amount: }`, with its comparands judged by `judge`. */ +function narrowedFieldSpec(judge: JudgeOne, spec: unknown, path: string): unknown { // Not filter structure: the implicit-equality comparand. - if (!isPlainFilterNode(spec)) return judgedNumberComparand(meta, field, spec, path, clause); + if (!isPlainFilterNode(spec)) return judge(spec, path); // A `{ $field }` reference is not a literal, and a plain object with no `$` // key is not this door's subject — each is left for the face that owns it. if (typeof spec.$field === 'string' || !Object.keys(spec).some((k) => k.startsWith('$'))) return spec; let out: Record | undefined; for (const [op, comparand] of Object.entries(spec)) { if (NUMBER_DOOR_SCALAR_OPERATORS.has(op)) { - const judged = judgedNumberComparand(meta, field, comparand, `${path}.${op}`, clause); + const judged = judge(comparand, `${path}.${op}`); if (judged !== comparand) (out ??= { ...spec })[op] = judged; continue; } if (!NUMBER_DOOR_LIST_OPERATORS.has(op) || !Array.isArray(comparand)) continue; let members: unknown[] | undefined; comparand.forEach((member, index) => { - const judged = judgedNumberComparand(meta, field, member, `${path}.${op}[${index}]`, clause); + const judged = judge(member, `${path}.${op}[${index}]`); if (judged !== member) (members ??= [...comparand])[index] = judged; }); if (members) (out ??= { ...spec })[op] = withProvenanceOf(comparand, members); @@ -514,8 +591,13 @@ function narrowedNumberFieldSpec( * its own reading. The same comparison in a caller's `where` is refused by the * engine's door, so the policy is refused here, once, for both clauses. The * paths are rooted at the clause being compiled (`check.amount.$lte`). + * + * [#21376] Where the number arm does not judge a key, the boolean arm may + * (`booleanComparandFieldVerdict` / `booleanComparandDoorVerdict`): the two + * classes are disjoint, so at most one arm judges a key — the engine walk's + * order, in one traversal with one set of boundaries. */ -function narrowPolicyNumberComparands( +function narrowPolicyComparands( node: T, fields: ReadonlyMap, path: string, @@ -531,19 +613,28 @@ function narrowPolicyNumberComparands( if (!Array.isArray(value)) continue; let arms: unknown[] | undefined; value.forEach((arm, index) => { - const walked = narrowPolicyNumberComparands(arm, fields, `${here}[${index}]`, clause, depth + 1); + const walked = narrowPolicyComparands(arm, fields, `${here}[${index}]`, clause, depth + 1); if (walked !== arm) (arms ??= [...value])[index] = walked; }); if (arms) next = withProvenanceOf(value, arms); } else if (key === '$not') { - next = narrowPolicyNumberComparands(value, fields, here, clause, depth + 1); + next = narrowPolicyComparands(value, fields, here, clause, depth + 1); } else { // Another `$` key is not a column, and a dotted key names a path, not a // declared column: neither is this door's subject. if (key.startsWith('$') || key.includes('.')) continue; const meta = fields.get(key); - if (!meta || numberComparandFieldVerdict(meta) !== 'judged') continue; - next = narrowedNumberFieldSpec(meta, key, value, here, clause); + if (!meta) continue; + // Only a judged field can refuse or narrow a comparand; a `formula` whose + // return type is unreadable is `deferred` and everything else + // `not-judged` — each spec verdict decides, never a list here. + if (numberComparandFieldVerdict(meta) === 'judged') { + next = narrowedFieldSpec(numberArm(meta, key, clause), value, here); + } else if (booleanComparandFieldVerdict(meta) === 'judged') { + next = narrowedFieldSpec(booleanArm(meta, key, clause), value, here); + } else { + continue; + } } if (next !== value) (out ??= { ...node })[key] = next; } diff --git a/packages/services/service-analytics/src/__tests__/native-sql-boolean-comparand-door.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-boolean-comparand-door.test.ts new file mode 100644 index 00000000000..232d9659f69 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/native-sql-boolean-comparand-door.test.ts @@ -0,0 +1,289 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21376] The native-SQL strategy answers a comparand against a declared + * BOOLEAN column what the engine's `where` door answers: `'true'` / `'false'`, + * `'1'` / `'0'` and `1` / `0` narrow to the boolean each names, and anything + * else the verdict refuses (`'yes'`, `2`) is refused `INVALID_FILTER` / 400 + * before a statement runs. + * + * ## Measured on the base, through these doors + * + * Three rows (`t1`, `t2` store `true`, `f1` stores `false`), counted by the + * cube read (`AnalyticsService.query`, what `POST /api/v1/analytics/query` + * relays) and the dataset door (`AnalyticsService.queryDataset`, what + * `POST /api/v1/analytics/dataset/query` relays, its `runtimeFilter` merged + * into the `where`), each on two faces of the plugin's own composition: the + * native strategy (one raw statement) and the ObjectQL strategy, whose + * `engine.aggregate` runs the engine's `where` door — the target column. + * + * | filter | native, SQLite | native, PostgreSQL 16 | engine door | + * |:--|:--|:--|:--| + * | `{ flag: true }` | 2 | 2 | 2 | + * | `{ flag: 'true' }` | **0** | 2 | 2 | + * | `{ flag: { $ne: 'true' } }` | **3** | 1 | 1 | + * | `{ flag: 'yes' }` | **200, 0** | **200, 2** | 400 `INVALID_FILTER` | + * | `{ flag: 1 }` | 2 | 2 | 2 | + * | `{ flag: '1' }` | 2 | 2 | 2 | + * + * A stored boolean is `1` / `0` on SQLite, so the string `'true'` equalled + * neither and the negation kept every row; PostgreSQL reads `'yes'` as `true`. + * The strategy now runs the spec's verdict (`booleanComparandDoorVerdict`) on + * every filter it compiles: the caller's `where`, each measure's own `filter` + * and the dataset's own scope. Every cell below asserts the engine face's + * answer AND the native face's equality with it, and which strategy answered. + * + * Each filter handed in is deep-frozen, and so is the registered dataset's + * own `filter` and its measure's `filter`: narrowing is copy-on-write, so an + * edit in place would throw. + * + * The PostgreSQL cell runs where `OS_TEST_POSTGRES_URL` is set and is a named + * skip otherwise; no CI step provisions that variable for this package. The + * live cell owns its table, dropped before and after. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { Cube } from '@objectstack/spec/data'; +import type { AnalyticsService } from '../analytics-service.js'; +import { AnalyticsServicePlugin } from '../plugin.js'; + +const OBJECT = 'os21376_flag_ledger'; + +const LEDGER = { + name: OBJECT, + label: 'Boolean comparand ledger', + fields: { + note: { name: 'note', type: 'text' as const }, + flag: { name: 'flag', type: 'boolean' as const }, + }, +}; + +const ROWS = [ + { id: 't1', note: 'n', flag: true }, + { id: 't2', note: 'n', flag: true }, + { id: 'f1', note: 'n', flag: false }, +] as const; + +const CUBE: Cube = { + name: 'os21376_flag_cube', + title: 'Boolean comparand cube', + sql: OBJECT, + public: true, + measures: { row_count: { type: 'count', sql: '*', label: 'Rows' } }, + dimensions: { + note: { type: 'string', sql: 'note', label: 'Note' }, + flag: { type: 'boolean', sql: 'flag', label: 'Flag' }, + }, +} as Cube; + +/** The inline dataset the dataset door carries. */ +const INLINE = { + name: 'os21376_flag_inline', + label: 'Boolean comparand inline dataset', + object: OBJECT, + dimensions: [{ name: 'note', field: 'note', type: 'string' }], + measures: [{ name: 'row_count', aggregate: 'count' }], +}; + +function deepFreeze(value: T): T { + if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value); + for (const child of Object.values(value as Record)) deepFreeze(child); + } + return value; +} + +/** A registered dataset whose own scope and measure filter spell booleans as text, frozen. */ +const REGISTERED = deepFreeze({ + name: 'os21376_flag_registered', + label: 'Boolean comparand registered dataset', + object: OBJECT, + filter: { flag: { $ne: 'false' } }, + dimensions: [{ name: 'note', field: 'note', type: 'string' }], + measures: [ + { name: 'row_count', aggregate: 'count' }, + { name: 'none_count', aggregate: 'count', filter: { flag: '0' } }, + ], +}); + +type Answer = number | { code: string; status: number }; + +/** Each filter, the engine door's answer, and the doors it is asked at. */ +const CELLS: ReadonlyArray = [ + // The controls: a boolean, and the number spelling. + [{ flag: true }, 2, 'a boolean'], + [{ flag: 1 }, 2, 'the number spelling'], + [{ flag: false }, 1, 'a boolean'], + // Narrowed. + [{ flag: 'true' }, 2, 'the canonical string'], + [{ flag: 'false' }, 1, 'the canonical string'], + [{ flag: '1' }, 2, 'a stringified storage form'], + [{ flag: { $eq: '0' } }, 1, 'a stringified storage form under $eq'], + // The negation hides the excluded rows. + [{ flag: { $ne: 'true' } }, 1, 'the string negation'], + [{ flag: { $nin: ['true'] } }, 1, 'the string list negation'], + [{ flag: { $in: ['false', '1'] } }, 3, 'a list of spellings'], + [{ $or: [{ flag: 'false' }, { note: 'x' }] }, 1, 'under $or'], + [{ $not: { flag: 'true' } }, 1, 'under $not'], + // Refused: no boolean reading. + [{ flag: 'yes' }, { code: 'INVALID_FILTER', status: 400 }, 'no boolean reading'], + [{ flag: { $ne: 'TRUE' } }, { code: 'INVALID_FILTER', status: 400 }, 'another letter case'], + [{ flag: { $in: [true, 'on'] } }, { code: 'INVALID_FILTER', status: 400 }, 'a list member with no boolean reading'], + // A number other than 1 / 0: the verdict's refusal, read from the spec, never a table here. + [{ flag: 2 }, { code: 'INVALID_FILTER', status: 400 }, 'a number other than 1 / 0'], +]; + +interface Cell { + id: 'sqlite' | 'pg'; + label: string; + env: string | null; + config: () => Record | null; +} + +const DRIVER_CELLS: readonly Cell[] = [ + { id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) }, + { + id: 'pg', + label: 'live postgres', + env: 'OS_TEST_POSTGRES_URL', + config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null), + }, +]; + +const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; + +type Face = 'native' | 'engine'; + +for (const cell of DRIVER_CELLS) { + const config = cell.config(); + describe.skipIf(!config)( + `[#21376] analytics native SQL — a boolean comparand answers what the engine door answers (${cell.label})${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, + () => { + let driver: any; + let engine: ObjectQL; + /** Raw-SQL statements and engine aggregates that read THIS object. */ + const reads = { rawSql: 0, aggregate: 0 }; + const services: Partial> = {}; + + const dropTable = async () => { + if (cell.id === 'pg') await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + }; + + /** The total count a face answers, or its refusal envelope, and what read the object. */ + const ask = async (face: Face, run: (svc: AnalyticsService) => Promise<{ rows: unknown[] }>) => { + const before = { ...reads }; + const answer: Answer = await run(services[face]!).then( + (res) => (res.rows as Array>).reduce((sum, r) => sum + Number(r.row_count ?? 0), 0), + (e: Error & { code?: string; status?: number }) => ({ code: String(e.code), status: Number(e.status), message: e.message }) as Answer, + ); + return { answer, rawSql: reads.rawSql - before.rawSql, aggregate: reads.aggregate - before.aggregate }; + }; + + const cubeRead = (where: unknown) => (svc: AnalyticsService) => + svc.query({ cube: CUBE.name, measures: ['row_count'], where: deepFreeze(structuredClone(where)) } as never) as Promise<{ rows: unknown[] }>; + const datasetRead = (runtimeFilter: unknown) => (svc: AnalyticsService) => + svc.queryDataset(INLINE as never, { measures: ['row_count'], dimensions: ['note'], runtimeFilter: deepFreeze(structuredClone(runtimeFilter)) } as never) as Promise<{ rows: unknown[] }>; + + /** Both faces at one door: the engine face answers `engine`, the native face the same. */ + const expectBothFaces = async (door: (filter: unknown) => (svc: AnalyticsService) => Promise<{ rows: unknown[] }>, filter: unknown, engineAnswer: Answer) => { + const viaEngine = await ask('engine', door(filter)); + const viaNative = await ask('native', door(filter)); + const strip = (a: Answer) => (typeof a === 'number' ? a : { code: a.code, status: a.status }); + expect(strip(viaEngine.answer), 'the engine door').toEqual(engineAnswer); + expect(strip(viaNative.answer), 'the native strategy answers what the engine door answers').toEqual(engineAnswer); + expect(viaEngine.rawSql, 'the engine face ran no raw statement').toBe(0); + if (typeof engineAnswer === 'number') { + expect(viaNative.rawSql, 'NativeSQLStrategy answered').toBeGreaterThanOrEqual(1); + expect(viaNative.aggregate, 'no engine aggregate on the native face').toBe(0); + } else { + expect(viaNative.rawSql, 'refused before any statement ran').toBe(0); + const message = (viaNative.answer as { message?: string }).message ?? ''; + expect(message).toContain("'flag'"); + expect(message).toContain('where'); + } + }; + + beforeAll(async () => { + driver = new SqlDriver(config as any); + await dropTable(); + engine = new ObjectQL({ logger: quiet } as any); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(LEDGER as any); + await engine.syncSchemas(); + for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); + + const realExecute = (engine as any).execute.bind(engine); + (engine as any).execute = (sql: unknown, opts?: { object?: string }) => { + if (opts?.object === OBJECT) reads.rawSql += 1; + return realExecute(sql, opts); + }; + const realAggregate = engine.aggregate.bind(engine); + (engine as any).aggregate = (object: string, ...rest: unknown[]) => { + if (object === OBJECT) reads.aggregate += 1; + return (realAggregate as any)(object, ...rest); + }; + + // The plugin's own composition over the real engine: both auto-bridges + // (`native`), and the same narrowed to the engine-aggregate path (`engine`). + for (const [face, caps] of [ + ['native', undefined], + ['engine', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })], + ] as const) { + const registered: Record = {}; + await new AnalyticsServicePlugin({ cubes: [CUBE], ...(caps ? { queryCapabilities: caps } : {}) } as any).init({ + getService: (name: string) => (name === 'data' ? engine : registered[name]), + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, + hook: () => {}, + logger: quiet, + } as never); + services[face] = registered.analytics as AnalyticsService; + services[face]!.registerDataset(REGISTERED as never); + } + }); + + afterAll(async () => { + await dropTable(); + try { await engine?.destroy(); } catch { /* noop */ } + }); + + describe('the cube read — the `where` of POST /analytics/query', () => { + for (const [filter, engineAnswer, label] of CELLS) { + it(`${JSON.stringify(filter)} (${label}) answers ${JSON.stringify(engineAnswer)}`, async () => { + await expectBothFaces(cubeRead, filter, engineAnswer); + }); + } + + it('the FilterArray spelling and the cube-qualified member are judged too', async () => { + await expectBothFaces(cubeRead, [['flag', '=', 'true']], 2); + await expectBothFaces(cubeRead, { [`${CUBE.name}.flag`]: { $ne: 'true' } }, 1); + await expectBothFaces(cubeRead, [['flag', '=', 'yes']], { code: 'INVALID_FILTER', status: 400 }); + }); + }); + + describe('the dataset door — the `runtimeFilter` of POST /api/v1/analytics/dataset/query', () => { + for (const [filter, engineAnswer, label] of CELLS) { + it(`${JSON.stringify(filter)} (${label}) answers ${JSON.stringify(engineAnswer)}`, async () => { + await expectBothFaces(datasetRead, filter, engineAnswer); + }); + } + }); + + describe("a registered dataset's own scope and measure filter, read by the cube door", () => { + it("`filter: { flag: { $ne: 'false' } }` scopes to the true rows, and `filter: { flag: '0' }` on a measure counts none of them", async () => { + for (const face of ['engine', 'native'] as const) { + const before = { ...reads }; + const res = await services[face]!.query({ cube: REGISTERED.name, measures: ['row_count', 'none_count'] } as never) as { rows: Array> }; + const total = (m: string) => res.rows.reduce((sum, r) => sum + Number(r[m] ?? 0), 0); + expect(total('row_count'), `${face}: the dataset scope keeps the true rows`).toBe(2); + expect(total('none_count'), `${face}: no kept row is false`).toBe(0); + if (face === 'native') expect(reads.rawSql - before.rawSql, 'NativeSQLStrategy answered').toBeGreaterThanOrEqual(1); + } + }); + }); + }, + ); +} diff --git a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts index d1ae690aef0..1520c77cff3 100644 --- a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts @@ -7,6 +7,7 @@ import type { AnalyticsStrategy, StrategyContext, DatasetScopedStrategyContext } import { declaredDatetimeLowering, findNestedRelationCondition, + invalidFilterError, lowerAnalyticsWhere, normalizeAnalyticsFilterTree, toSqlBindValue, @@ -14,6 +15,18 @@ import { SQL_CONST_TRUE, type NormalizedFilterNode, } from './filter-normalizer.js'; +// [#21376] The boolean-comparand verdict the engine's `where` door consults +// (`@objectstack/objectql`'s `boolean-comparand-declared-type-door.ts`), read +// from the same spec module — one verdict, one set of accepted spellings, one +// refusal sentence — and run on every filter this compiler compiles +// ({@link judgedBooleanComparands}). +import { + BOOLEAN_COMPARAND_DOOR_LIST_OPERATORS, + BOOLEAN_COMPARAND_DOOR_SCALAR_OPERATORS, + booleanComparandDoorVerdict, + booleanComparandFieldVerdict, + booleanComparandRefusalMessage, +} from '@objectstack/spec/data'; import { findCrossFieldComparand, findUninterpretableTemporalMember } from '../comparand-shape.js'; import { assertReadScopeCannotVacate, compileScopedFilterToSql } from '../read-scope-sql.js'; import { nonTextColumnResolver, textOperatorPolarity } from '../non-text-column.js'; @@ -260,6 +273,162 @@ interface StatementClauses { readonly joins: StatementJoins; } +// ── [#21376] The boolean-comparand verdict, on every filter this compiler compiles ── +// +// The engine judges a comparand against a declared boolean column at its one +// field-aware filter walk (`@objectstack/objectql`'s +// `boolean-comparand-declared-type-door.ts`), by the spec's verdict +// (`booleanComparandDoorVerdict`, `@objectstack/spec/data`): `true` / `false` +// pass, `"true"` / `"false"`, `"1"` / `"0"` and `1` / `0` narrow to the boolean +// each names, anything else it refuses (`'yes'`, `2`) is `INVALID_FILTER` / 400. This strategy +// compiles its filters to SQL itself, past that walk, so a string reached the +// driver as written: on SQLite a stored boolean is `1` / `0`, and the string +// `'true'` equals neither — `{ flag: 'true' }` counted no row, `{ flag: { $ne: +// 'true' } }` counted every row, and `{ flag: 'yes' }` answered 200 with zero +// where the engine answers 400 (PostgreSQL reads `'yes'` as `true` and counted +// the true rows). So the same verdict runs here, on the caller's `where` (the +// dataset door's `runtimeFilter` arrives merged into it), each measure's own +// `filter` and the dataset's own scope — every filter that reaches +// `compileFilterNode` — and the strategy answers what the engine door answers. +// ⛔ Nothing here reads a spelling: the verdict does. ⛔ No second rule. + +/** + * The declared type of the column a filter member binds against, or + * `undefined` when the host cannot answer. + */ +type MemberDeclaredType = (member: string) => string | undefined; + +/** The operators whose one comparand the verdict judges — the spec's list, never a re-listing. */ +const BOOLEAN_DOOR_SCALAR_OPERATORS: ReadonlySet = new Set(BOOLEAN_COMPARAND_DOOR_SCALAR_OPERATORS); +/** The operators each of whose MEMBERS the verdict judges. */ +const BOOLEAN_DOOR_LIST_OPERATORS: ReadonlySet = new Set(BOOLEAN_COMPARAND_DOOR_LIST_OPERATORS); + +/** A plain object: a filter node or an operator map, never a comparand (a `Date` is data). */ +function isPlainFilterNode(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** + * The member reader for {@link judgedBooleanComparands}: the declared type the + * host's `declaredFieldType` hook answers for the column `target` resolves a + * member to — the same (object, column) every other declared-type question in + * this compiler asks (the datetime lowering, the text-operator constant, + * `$empty`). `null` when the host wired no hook: "cannot answer, do not block", + * the tiering every such hook here takes. + */ +function memberDeclaredType( + ctx: StrategyContext, + target: (member: string) => { object: string; field: string }, +): MemberDeclaredType | null { + const declared = (ctx as DatasetScopedStrategyContext).declaredFieldType; + if (typeof declared !== 'function') return null; + return (member) => { + const { object, field } = target(member); + return declared.call(ctx, object, field); + }; +} + +/** + * One comparand at a judged position on a boolean column, by the spec's + * verdict: the boolean an accepted spelling names, the comparand unchanged, or + * a refusal in the `where` door's envelope (`invalidFilterError`, + * `INVALID_FILTER` / 400) carrying the spec's sentence. + */ +function judgedBooleanComparand(member: string, declaredType: string, comparand: unknown, path: string): unknown { + const verdict = booleanComparandDoorVerdict({ type: declaredType }, comparand); + if (verdict.verdict === 'narrows') return verdict.value; + if (verdict.verdict !== 'door-refusal') return comparand; + throw invalidFilterError( + `[analytics] ${booleanComparandRefusalMessage({ field: member, declaredType, path, value: comparand, form: verdict.form })}`, + ); +} + +/** One judged member's constraint, `{ flag: }`, with its comparands judged. Copy-on-write. */ +function narrowedBooleanFieldSpec(member: string, declaredType: string, spec: unknown, path: string): unknown { + // Not filter structure: the implicit-equality comparand. + if (!isPlainFilterNode(spec)) return judgedBooleanComparand(member, declaredType, spec, path); + // A `{ $field }` reference is not a literal, and a plain object with no `$` + // key is not this verdict's subject — each is left for the face that owns it. + if (typeof spec.$field === 'string' || !Object.keys(spec).some((k) => k.startsWith('$'))) return spec; + let out: Record | undefined; + for (const [op, comparand] of Object.entries(spec)) { + if (BOOLEAN_DOOR_SCALAR_OPERATORS.has(op)) { + const judged = judgedBooleanComparand(member, declaredType, comparand, `${path}.${op}`); + if (judged !== comparand) (out ??= { ...spec })[op] = judged; + continue; + } + if (!BOOLEAN_DOOR_LIST_OPERATORS.has(op) || !Array.isArray(comparand)) continue; + let members: unknown[] | undefined; + comparand.forEach((value, index) => { + const judged = judgedBooleanComparand(member, declaredType, value, `${path}.${op}[${index}]`); + if (judged !== value) (members ??= [...comparand])[index] = judged; + }); + if (members) (out ??= { ...spec })[op] = members; + } + return out ?? spec; +} + +/** + * The lowered condition with every comparand on a declared boolean column + * judged: through `$and`, `$or` and `$not`, at every member key (another `$` + * key at node level is not a member). The positions are the spec's + * (`BOOLEAN_COMPARAND_DOOR_SCALAR_OPERATORS` / `…_LIST_OPERATORS`). A member is + * judged at the column it binds against, so the cube-qualified spelling + * (`.flag`) and a relationship path are judged at their column too. + * Copy-on-write: a subtree nothing narrowed is returned by reference, so a + * filter the dataset registry holds is never edited. + */ +function narrowBooleanComparands(node: unknown, typeOf: MemberDeclaredType, path: string, depth = 0): unknown { + if (depth > 32 || !isPlainFilterNode(node)) return node; + let out: Record | undefined; + for (const [key, value] of Object.entries(node)) { + const here = `${path}.${key}`; + let next: unknown = value; + if (key === '$and' || key === '$or') { + if (!Array.isArray(value)) continue; + let arms: unknown[] | undefined; + value.forEach((arm, index) => { + const walked = narrowBooleanComparands(arm, typeOf, `${here}[${index}]`, depth + 1); + if (walked !== arm) (arms ??= [...value])[index] = walked; + }); + if (arms) next = arms; + } else if (key === '$not') { + next = narrowBooleanComparands(value, typeOf, here, depth + 1); + } else { + if (key.startsWith('$')) continue; + const declaredType = typeOf(key); + // The spec's field verdict decides which columns are judged — a `formula` + // reaches here with no `returnType` (the host relays none) and is + // `deferred`, as the spec defers one; never a list here. + if (declaredType === undefined || booleanComparandFieldVerdict({ type: declaredType }) !== 'judged') continue; + next = narrowedBooleanFieldSpec(key, declaredType, value, here); + } + if (next !== value) (out ??= { ...node })[key] = next; + } + return out ?? node; +} + +/** + * `source` (a `{ where }` carrier, as {@link normalizeAnalyticsFilterTree} + * takes it) with the spec's boolean verdict applied to its lowered condition: + * `source` itself when nothing narrows (or the host cannot answer), else a + * `{ where }` carrying the narrowed condition. A refusal is thrown. + * + * The condition is lowered by `lowerAnalyticsWhere` — the shared comparand + * faces' door, which refuses what it refuses first, in its own words — and + * `normalizeAnalyticsFilterTree` lowers the narrowed condition again: the + * faces are idempotent on their own output. + */ +function judgedBooleanComparands(source: unknown, typeOf: MemberDeclaredType | null): unknown { + if (!typeOf) return source; + const condition = lowerAnalyticsWhere(source); + if (!condition) return source; + const judged = narrowBooleanComparands(condition, typeOf, 'where'); + return judged === condition ? source : { where: judged }; +} + /** * NativeSQLStrategy — Priority 1 * @@ -925,6 +1094,10 @@ export class NativeSQLStrategy implements AnalyticsStrategy { // bare-day copy (`buildFilterClause`'s `lte` arm) stays until its deletion // card, and is idempotent on the lowered bound. const lowering = declaredDatetimeLowering(ctx, (member) => this.resolveStorageTarget(cube, member, tableName, joins.referenceOf)); + // [#21376] The boolean-comparand verdict's member reader, asked of the + // SAME target, and applied at the same three filter positions, before + // each is normalized ({@link judgedBooleanComparands}). + const booleanTypeOf = memberDeclaredType(ctx, (member) => this.resolveStorageTarget(cube, member, tableName, joins.referenceOf)); // Build SELECT for measures if (query.measures && query.measures.length > 0) { @@ -938,7 +1111,7 @@ export class NativeSQLStrategy implements AnalyticsStrategy { const measureFilter = datasetScope?.measureFilters?.[measure]; const predicate = measureFilter ? this.compileFilterNode( - normalizeAnalyticsFilterTree({ where: measureFilter }, lowering), + normalizeAnalyticsFilterTree(judgedBooleanComparands({ where: measureFilter }, booleanTypeOf), lowering), cube, tableName, joins, @@ -956,7 +1129,7 @@ export class NativeSQLStrategy implements AnalyticsStrategy { // used to be dropped instead of compiled. const whereClauses: string[] = []; const filterSql = this.compileFilterNode( - normalizeAnalyticsFilterTree(query, lowering), + normalizeAnalyticsFilterTree(judgedBooleanComparands(query, booleanTypeOf), lowering), cube, tableName, joins, @@ -973,7 +1146,7 @@ export class NativeSQLStrategy implements AnalyticsStrategy { // predicate with itself selects the same rows. if (datasetScope?.filter) { const scopeSql = this.compileFilterNode( - normalizeAnalyticsFilterTree({ where: datasetScope.filter }, lowering), + normalizeAnalyticsFilterTree(judgedBooleanComparands({ where: datasetScope.filter }, booleanTypeOf), lowering), cube, tableName, joins,