From 23530d7a7ba3c632cecb156b4cf930d40961a9f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 18:45:16 +0000 Subject: [PATCH 1/4] feat(service-analytics): both filter faces answer $empty by the field's declared type (wip) Claude-Session: https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs Co-authored-by: Claude --- .../src/analytics-service.ts | 23 +- .../src/empty-operator-sql.ts | 204 ++++++++++++++++++ .../services/service-analytics/src/plugin.ts | 15 +- .../service-analytics/src/read-scope-sql.ts | 153 ++++++++++++- .../src/strategies/filter-normalizer.ts | 89 +++++++- .../src/strategies/native-sql-strategy.ts | 19 ++ .../src/strategies/objectql-strategy.ts | 31 +++ .../service-analytics/src/strategies/types.ts | 18 +- 8 files changed, 534 insertions(+), 18 deletions(-) create mode 100644 packages/services/service-analytics/src/empty-operator-sql.ts diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index ed3f461f5c4..34361fc6cc3 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -697,6 +697,15 @@ export interface AnalyticsServiceConfig { * that field's storage scale via `percentScaleOf`, so a renderer scales by * declared metadata instead of guessing from the value. * - Date bucketing: a date vs datetime dimension drills by the right bound. + * + * [#20445] `type` and `multiple` are also the field's DECLARED value shape, + * which the SQL compilers read through `declaredValueShape` to answer the + * `$empty` operator: what counts as empty is the field's row of the ruled + * per-type table (`expandEmptyOperator`, `@objectstack/spec/data`), and a + * multi-capable type (`select`, `lookup`, `user`, …) is list-valued only + * with `multiple: true`. Answer `multiple` as the field declares it; a host + * that leaves it out has every multi-capable field read as single-valued. + * * ⚠️ [#17560] `returnType` was a FOURTH member here (#16236), carried for one * reader: `measureResultType` translated a `formula` field's declared result * type into the measure column's wire word. The director ruling of decision @@ -710,7 +719,10 @@ export interface AnalyticsServiceConfig { * Prime Directive #10 refuses. A host that still answers it is simply * ignored; ⛔ nothing here reads it. */ - sourceFieldMeta?: (object: string, field: string) => { type?: string; defaultCurrency?: string; max?: number } | undefined; + sourceFieldMeta?: ( + object: string, + field: string, + ) => { type?: string; multiple?: boolean; defaultCurrency?: string; max?: number } | undefined; /** * [#15684] The SQL dialect of the datasource backing `object` — `'sqlite'`, * `'postgres'`, `'mysql'`, or `undefined` when the host cannot answer. @@ -1028,6 +1040,15 @@ export class AnalyticsService implements IAnalyticsService { // at compile time. A host that wired no hook answers `undefined`, and // the compilers keep the behaviour they had. declaredFieldType: (object: string, field: string) => config.sourceFieldMeta?.(object, field)?.type, + // [#20445] …and the declared value shape off the same hook, for the + // `$empty` operator's per-type expansion. No type, no shape: a host that + // cannot name the type cannot name the row, and the compilers refuse the + // operator rather than guess one (`empty-operator-sql.ts`). + declaredValueShape: (object: string, field: string) => { + const meta = config.sourceFieldMeta?.(object, field); + if (typeof meta?.type !== 'string' || meta.type === '') return undefined; + return { type: meta.type, multiple: meta.multiple === true }; + }, // [#15684] The dialect that will run the compiled statement, so the // case-EXACT text family picks a construct that IS case-exact there. // Same tiering as the hook above: `undefined` keeps today's `LIKE`. diff --git a/packages/services/service-analytics/src/empty-operator-sql.ts b/packages/services/service-analytics/src/empty-operator-sql.ts new file mode 100644 index 00000000000..05d539c7647 --- /dev/null +++ b/packages/services/service-analytics/src/empty-operator-sql.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20445] The `$empty` operator on this package's three SQL compilers: the + * read-scope lowering (`read-scope-sql.ts`), `NativeSQLStrategy.buildFilterClause` + * and the `ObjectQLStrategy` echo of it. Ruling A on #20399 gives each compile + * surface an arm for the operator, and the spec gives every arm ONE expansion + * to call, `expandEmptyOperator(fieldDef)` (`@objectstack/spec/data`): the + * per-type 「is empty」 table ruled on #20311. + * + * | the field's declared row (`EmptyOperatorExpansion.arm`) | `$empty: true` matches | `$empty: false` | + * |---|---|---| + * | `text` (the text-like types) | null or `''` | the complement | + * | `multi_value` (a list-valued field) | null or `[]` | the complement | + * | `null_only` (every other type) | null | the complement | + * + * ## Why a compiler asks the host, and what it does when the host cannot answer + * + * The row is a property of the field's DECLARATION (its type, and `multiple` + * for the multi-capable types), which the filter does not carry. The host + * answers it through {@link DatasetScopedStrategyContext.declaredValueShape}, + * from the same `AnalyticsServiceConfig.sourceFieldMeta` hook the + * declared-type rule (#14079) reads. The compiler then calls the spec's + * expansion itself; this module never restates the table. + * + * A host that cannot answer (no field metadata wired, or no such field on the + * object) gets a REFUSAL, never a guess. The "no declaration" reading the spec + * gives the JS faces (null, `''` and `[]` all empty, `isEmptyFilterValue` + * without an expansion) cannot be compiled to SQL without the type either: a + * `''` comparison against a numeric column is a type error on Postgres, and + * an empty list is only recognisable as JSON. So "cannot answer, do not block" + * — the posture the declared-type rule takes for text operators, which had a + * behaviour to fall back to — has nothing to fall back to here. + * + * ## The SQL, per row + * + * - `null_only`: `col IS NULL` / `col IS NOT NULL`. + * - `text`: `(col IS NULL OR col = '')` / `(col IS NOT NULL AND col <> '')`, + * the empty string bound like every other comparand. + * - `multi_value`: `(col IS NULL OR L)` / `(col IS NOT NULL AND NOT L)`, where + * `L` is the dialect's test for "this stored value is the empty JSON list". + * A multi-value field is a JSON column on the SQL family (`driver-sql`'s + * `isMultiValueField`-keyed storage): TEXT on SQLite, `json` on Postgres and + * MySQL. `L` is FALSE, never NULL, for any stored value that is not an empty + * list, a non-array JSON value included. + * + * Every predicate is TOTAL — TRUE or FALSE for every row, never UNKNOWN — + * because both polarities spell their NULL case out. So a `$not` over `$empty` + * needs no NULL guard (`operatorIsNullTotal` answers `true` for it on both + * faces), and `NOT (…)` is the exact complement. + * + * `L` has no construct on the `'unknown'` dialect: no JSON test parses on all + * three dialects, and the text-match family's `unknown` arm (a construct that + * happens to parse everywhere) has no counterpart here. The multi-value row + * is refused there; the other two rows need no dialect and are compiled on + * every one. + */ + +import { expandEmptyOperator, type EmptyOperatorExpansion, type ValueShapeFieldDef } from '@objectstack/spec/data'; +import type { StrategyContext } from '@objectstack/spec/contracts'; +import type { DatasetScopedStrategyContext } from './strategies/types.js'; +import { invalidFilterError } from './strategies/filter-normalizer.js'; +import { sqlDialectFor, type AnalyticsSqlDialect } from './text-match-sql.js'; + +/** + * The per-object question "what is this field's declared value shape?", read + * off the context's `declaredValueShape` hook, or `undefined` when the host + * wired no hook. The same shape as `nonTextColumnResolver` (#14079). + */ +export function declaredValueShapeResolver( + ctx: StrategyContext, + objectName: string, +): ((field: string) => ValueShapeFieldDef | undefined) | undefined { + const declared = (ctx as DatasetScopedStrategyContext).declaredValueShape; + if (typeof declared !== 'function') return undefined; + return (field: string) => declared.call(ctx, objectName, field); +} + +/** + * "Is this stored JSON value the empty list?", as one boolean SQL expression + * that is FALSE (not NULL, not an error) for any other value, or `null` when + * the dialect has no construct. + * + * - SQLite: a multi-value column is TEXT. `json_valid` is asked first, inside a + * `CASE` (whose branches are evaluated lazily, which a bare `AND` is not + * documented to be), so a malformed stored value answers FALSE instead of + * failing the statement; `json_type` keeps a non-array JSON value (for which + * `json_array_length` answers 0) from counting as an empty list. + * - Postgres: the column is `json`, which has no equality operator, so it is + * compared as `jsonb`, whose equality is structural. + * - MySQL: the column is `JSON`; `JSON_LENGTH` answers 0 for an empty object + * too, so the type is asked beside it. + */ +function emptyJsonListSql(dialect: AnalyticsSqlDialect, column: string): string | null { + switch (dialect) { + case 'sqlite': + return ( + `(CASE WHEN json_valid(${column}) THEN json_type(${column}) = 'array' ` + + `AND json_array_length(${column}) = 0 ELSE 0 END)` + ); + case 'postgres': + return `(CAST(${column} AS jsonb) = CAST('[]' AS jsonb))`; + case 'mysql': + return `(JSON_TYPE(${column}) = 'ARRAY' AND JSON_LENGTH(${column}) = 0)`; + default: + return null; + } +} + +/** One `$empty` predicate to compile. */ +export interface EmptyOperatorSqlRequest { + /** The dialect that will run the statement; `'unknown'` has no multi-value arm. */ + dialect: AnalyticsSqlDialect; + /** The column reference, already quoted / alias-qualified by the caller. */ + column: string; + /** The field's row of the ruled table: `expandEmptyOperator(fieldDef)`. */ + expansion: EmptyOperatorExpansion; + /** `true` for `$empty: true`, `false` for its complement. */ + empty: boolean; + /** Push one value and return its placeholder, in the caller's own scheme. */ + bind: (value: unknown) => string; +} + +/** + * The `$empty` predicate for one field, or `null` when the dialect has no + * construct for the field's row (the multi-value row on `'unknown'`). A `null` + * answer binds nothing, so the caller's parameter list stays aligned with the + * placeholders it has emitted, and the caller REFUSES — it never reads `null` + * as "no constraint". + */ +export function emptyOperatorPredicateSql(req: EmptyOperatorSqlRequest): string | null { + const { column, empty } = req; + switch (req.expansion.arm) { + case 'null_only': + return empty ? `${column} IS NULL` : `${column} IS NOT NULL`; + case 'text': { + const blank = req.bind(''); + return empty ? `(${column} IS NULL OR ${column} = ${blank})` : `(${column} IS NOT NULL AND ${column} <> ${blank})`; + } + case 'multi_value': { + const list = emptyJsonListSql(req.dialect, column); + if (list === null) return null; + return empty ? `(${column} IS NULL OR ${list})` : `(${column} IS NOT NULL AND NOT ${list})`; + } + default: { + // The spec's `EmptyOperatorArm` is a closed union of the three rows + // above; a fourth row reaching here is a spec change this module has not + // been taught, and it must fail loudly rather than answer for a row + // nobody wrote an arm for. + const unknownArm: never = req.expansion.arm; + throw new Error(`[analytics] no $empty arm for the declared row ${JSON.stringify(unknownArm)}`); + } + } +} + +/** + * The analytics `where` door's `empty` / `notEmpty` leaf (the normalizer's + * lowering of `$empty: true | false`) as SQL, for the two compilers of that + * door's tree: `NativeSQLStrategy.buildFilterClause`, whose statement + * executes, and the `ObjectQLStrategy` echo of it. + * + * Refused in the `where` door's envelope (`INVALID_FILTER` / 400, message + * kept — the caller wrote the filter) when the host cannot name the field's + * declaration, or when a list-valued field meets the `'unknown'` dialect. + * Both refusals come before anything binds. + */ +export function whereEmptyLeafSql(req: { + ctx: StrategyContext | undefined; + /** The (object, column) the leaf's member binds against; `undefined` = unknown. */ + target: { object: string; field: string } | undefined; + column: string; + empty: boolean; + bind: (value: unknown) => string; +}): string { + const { ctx, target } = req; + const shape = ctx && target ? declaredValueShapeResolver(ctx, target.object)?.(target.field) : undefined; + if (!ctx || !target || !shape) { + const named = target ? `field "${target.field}" of "${target.object}"` : 'this field'; + throw invalidFilterError( + `[analytics] Operator "$empty" is answered by the field's DECLARED type, and this analytics host ` + + `could not name the declaration of ${named} (no field metadata is wired, or the object declares ` + + `no such field). What counts as empty depends on it — null or '' for a text-like field, null or ` + + `[] for a multi-value field, null only for every other type — so the operator is refused rather ` + + `than guessed. Filter on a declared field, or use "$null" for "has no value". The filter was NOT ` + + `applied.`, + ); + } + const sql = emptyOperatorPredicateSql({ + dialect: sqlDialectFor(ctx, target.object), + column: req.column, + expansion: expandEmptyOperator(shape), + empty: req.empty, + bind: req.bind, + }); + if (sql === null) { + throw invalidFilterError( + `[analytics] Operator "$empty" on field "${target.field}" of "${target.object}" targets a multi-value ` + + `field, whose empty list is tested with a JSON function that differs per SQL dialect, and the ` + + `dialect of this datasource is not known to the analytics host. It is refused rather than ` + + `guessed; "$null" answers "has no value" on every dialect. The filter was NOT applied.`, + ); + } + return sql; +} diff --git a/packages/services/service-analytics/src/plugin.ts b/packages/services/service-analytics/src/plugin.ts index 899feba0167..f1c12b7d761 100644 --- a/packages/services/service-analytics/src/plugin.ts +++ b/packages/services/service-analytics/src/plugin.ts @@ -1150,15 +1150,26 @@ export class AnalyticsServicePlugin implements Plugin { // `formula` on the compatibility table's storage ground, so the pair no // longer reaches a response for a type to describe. ⛔ Carrying the key // on regardless would relay metadata into a seam nothing reads. + // + // [#20445] `multiple` IS read: with `type` it is the field's declared + // value shape, the input of the `$empty` operator's per-type expansion + // on the three SQL compilers (`declaredValueShape`). Relayed as the + // field declares it, so a `multiple: true` lookup is list-valued there + // exactly as it is in `driver-sql`'s storage. sourceFieldMeta: (object: string, field: string) => { const f = dataEngine()?.getObject?.(object)?.fields?.[field] as - | { type?: string; max?: number; currencyConfig?: { currencyMode?: string; defaultCurrency?: string } } + | { + type?: string; + multiple?: boolean; + max?: number; + currencyConfig?: { currencyMode?: string; defaultCurrency?: string }; + } | undefined; if (!f) return undefined; const fixedCurrency = f.currencyConfig?.currencyMode === 'fixed' ? f.currencyConfig.defaultCurrency : undefined; - return { type: f.type, max: f.max, defaultCurrency: fixedCurrency }; + return { type: f.type, multiple: f.multiple === true, max: f.max, defaultCurrency: fixedCurrency }; }, // #5033 — the datasource an object is bound to, used ONLY to name the // actual cause when a dataset's SQL references a table that is not on the diff --git a/packages/services/service-analytics/src/read-scope-sql.ts b/packages/services/service-analytics/src/read-scope-sql.ts index 56730426f18..72a8d504b74 100644 --- a/packages/services/service-analytics/src/read-scope-sql.ts +++ b/packages/services/service-analytics/src/read-scope-sql.ts @@ -9,6 +9,9 @@ import { assertListComparandShapes, normalizeFilterComparandTypes } from '@objec // its reason half, asked at {@link compileOperator}'s `$icontains` arm and at // the engine-bound merges through {@link assertReadScopeComparandsRunnable}. import { isRefusedTextComparand, textComparandRefusalReason } from '@objectstack/spec/data'; +// [#20445] The `$empty` operator's one expansion, asked at {@link compileOperator}'s +// `$empty` arm for the field's declared row of the ruled per-type table. +import { expandEmptyOperator, type ValueShapeFieldDef } from '@objectstack/spec/data'; // [#19995] The engine's own placeholder resolver (`ObjectQL.resolveWhereTokens` // is a call to it), run on a read scope, alone, at the ObjectQL merge sites by // {@link assertReadScopePlaceholdersResolvable}, and [#20075] before the @@ -21,6 +24,7 @@ import type { EngineFilterJudgement, EngineFilterJudgementOptions } from '@objec import type { ReadScopeFilterJudge } from './strategies/types.js'; import { type LikeShape } from './like-pattern.js'; import { textMatchPredicateSql, normalizeSqlDialect } from './text-match-sql.js'; +import { emptyOperatorPredicateSql } from './empty-operator-sql.js'; import { textOperatorPolarity } from './non-text-column.js'; import { CROSS_FIELD_COMPARISON_OPERATORS, @@ -52,7 +56,8 @@ import { * * Supports the operators the RLS layer and common policies emit: implicit * equality, `$eq/$ne/$gt/$gte/$lt/$lte/$in/$nin/$between/$contains/$notContains/ - * $startsWith/$endsWith/$null/$exists`, and `$and/$or/$not` combinators. + * $startsWith/$endsWith/$null/$exists`, and `$and/$or/$not` combinators — plus + * `$icontains` (#6520) and the staged `$empty` (#20445, the last section). * * ## `''` means TRUE, and that is a value — not "nothing happened" * @@ -584,6 +589,43 @@ import { * them. Such a host keeps today's behaviour for the four classes, and says so * once in its log: `AnalyticsService` when it was given no judge at all, * `AnalyticsServicePlugin` when its data engine lacks the member. + * + * ## `$empty` is answered by the field's DECLARED type (#20445, ruling A on #20399) + * + * The spec declares `$empty: boolean` with the ruled per-type 「is empty」 table + * as its meaning (#20311) and gives every compile surface one expansion to + * call, `expandEmptyOperator(fieldDef)`. This compiler's arm + * ({@link compileOperator} → {@link compileEmptyOperator}) asks the caller for + * the field's declaration ({@link ReadScopeCompileOptions.declaredValueShape}), + * expands it, and compiles the row with `empty-operator-sql.ts`: + * + * - text-like: `(col IS NULL OR col = '')`; + * - multi-value: `(col IS NULL OR )`; + * - every other type: `col IS NULL`; + * - `$empty: false` is the exact complement of each, and every one of them + * is TOTAL, so a `$not` over it needs no NULL guard. + * + * Refused, in this module's envelope, when the caller cannot name the field's + * declaration, when a list-valued field meets the `'unknown'` dialect, and + * when the flag is not a boolean ({@link assertBooleanFlagComparands}, the + * gate the two null flags already had). + * + * The operator stays STAGED — absent from `FILTER_OPERATORS` until every face + * has its arm (the maintainer's amendment of ruling A: 「照 $like 先例分阶段」) + * — so no in-repo producer emits it in a read scope yet; the CEL lowering's + * `is_empty` still emits `$null`. An in-process `getReadScope` producer can. + * + * The ObjectQL execute face does not meet this compiler: it hands the scope to + * the engine, whose `$empty` arm is the engine lane's (`driver-sql` and its + * heirs, a sibling card of ruling A). Until that arm lands the engine's driver + * refuses the operator there (`INVALID_FILTER` / 400, the operator and field + * withheld from its message), so one scope is refused on that face and + * answered on the other two; nothing is dropped on any of them. + * + * What `$empty` did NOT change is the envelope of an operator this compiler + * has no arm for: still `READ_SCOPE_COMPILE_FAILED` / 500, withheld, per the + * #5367 section above. The note at {@link compileOperator}'s `default:` arm + * records why a 400 would be the wrong class here. */ const IDENT = /^[a-z_][a-z0-9_]*$/i; @@ -679,6 +721,15 @@ export interface ReadScopeCompileOptions { * section. */ context?: ExecutionContextLike; + /** + * [#20445] The DECLARED value shape of `field` (its type, and `multiple`), + * or `undefined` when the caller cannot name it. The `$empty` arm expands + * it through `expandEmptyOperator` (`@objectstack/spec/data`); a field it + * answers `undefined` for — and every field, when the option is absent — + * has its `$empty` refused, never guessed. Both of this compiler's + * consumers fill it from the context's `declaredValueShape` hook. + */ + declaredValueShape?: (field: string) => ValueShapeFieldDef | undefined; } /** A node the compiler can walk: a plain object, not `null` and not an array. */ @@ -1558,7 +1609,9 @@ function assertDefinedComparands(field: string, spec: unknown): void { if (spec === undefined) throw undefinedComparandError(field, root); if (!isFilterNode(spec)) return; for (const [op, opValue] of Object.entries(spec)) { - if (!op.startsWith('$') || op === '$null' || op === '$exists') continue; + // [#20445] `$empty` is the third declared-boolean flag, skipped for the + // reason the other two are and refused by the same boolean-domain gate. + if (!op.startsWith('$') || BOOLEAN_FLAG_OPERATORS.includes(op as BooleanFlagOperator)) continue; const opPath = `${root}.${op}`; if (opValue === undefined) throw undefinedComparandError(field, opPath); if (!Array.isArray(opValue)) continue; @@ -1645,11 +1698,20 @@ function assertDefinedComparands(field: string, spec: unknown): void { * what makes "declared boolean" mean enforced boolean regardless of who writes * the scope. Graded on that measurement, not on the issue's opening wording. */ +/** + * [#20445] The flags `FieldOperatorsSchema` declares `z.boolean()`: the two + * null flags and the `$empty` operator. One list for the two gates that read + * it, {@link assertDefinedComparands} (which skips them) and + * {@link assertBooleanFlagComparands} (which refuses a non-boolean). + */ +const BOOLEAN_FLAG_OPERATORS = ['$null', '$exists', '$empty'] as const; +type BooleanFlagOperator = (typeof BOOLEAN_FLAG_OPERATORS)[number]; + function nonBooleanFlagComparandError(op: string, field: string, path: string): Error { return readScopeCompileError( `[read-scope-sql] comparand for "${op}" at ${path} is not a boolean — refusing to build read scope ` + - `(fail-closed). @objectstack/spec FieldOperatorsSchema declares both $null and $exists as ` + - `z.boolean(), and this compiler used to read the comparand by TRUTHINESS instead — so a ` + + `(fail-closed). @objectstack/spec FieldOperatorsSchema declares $null, $exists and $empty as ` + + `z.boolean(), and this compiler once read the $null / $exists comparand by TRUTHINESS instead — so a ` + `non-boolean was silently sorted into one of the two declared answers rather than refused. The ` + `string "false" is TRUTHY, which is the case that matters: it landed on the side OPPOSITE the ` + `false it was written to mean, turning "rows with no ${field}" into "rows that have one" — a ` + @@ -1689,10 +1751,18 @@ function nonBooleanFlagComparandError(op: string, field: string, path: string): * refused. The classification is DISCARDED either way (the leaf still reaches * `compileField` and still throws), and the rewrite's own synthesised leaves * (`{ $null: false }`, `{ $null: true }`) are literal booleans by construction. + * + * [#20445] `$empty` is the third flag, and it joins the gate on the day its arm + * lands rather than after a flip is measured: the spec declares it + * `z.boolean()` exactly like the other two, and its arm reads `=== true`, so + * without this gate every non-boolean — the string `"true"` included — would + * compile to the `$empty: false` arm. The shared comparand faces judge a flag + * as a literal comparand and admit a string, a number, `null` or a list, so + * nothing else refuses it. */ function assertBooleanFlagComparands(field: string, spec: unknown): void { if (!isFilterNode(spec)) return; - for (const op of ['$null', '$exists'] as const) { + for (const op of BOOLEAN_FLAG_OPERATORS) { if (!Object.prototype.hasOwnProperty.call(spec, op)) continue; if (typeof spec[op] === 'boolean') continue; throw nonBooleanFlagComparandError(op, field, `"${field}".${op}`); @@ -1970,11 +2040,76 @@ function compileOperator( // {@link nullValueSatisfiesOperator} now mirrors (#5146 / #5298). case '$null': return val === true ? `${col} IS NULL` : `${col} IS NOT NULL`; case '$exists': return val === true ? `${col} IS NOT NULL` : `${col} IS NULL`; + // [#20445] `val` is a boolean here too — the same gate refused anything + // else — so `=== true` is the whole choice between the arm and its + // complement. See {@link compileEmptyOperator}. + case '$empty': return compileEmptyOperator(col, val === true, field, params, opts); + // ⚠️ [#20445] An operator outside this arm list stays a SERVER fault, + // `READ_SCOPE_COMPILE_FAILED` / 500 with the message withheld, and NOT the + // `where` door's `INVALID_FILTER` / 400. The scope was not written by the + // caller of this query: both callers of this compiler hand it + // `ctx.getReadScope(object)`, which the plugin answers from the security + // service's compiled sharing rules / permission sets or from the host's + // own `getReadScope` option. A 400 would tell that caller to repair a + // request that was never the problem and would relay the policy's + // operator and field to them — the two defects the #5367 ruling (the + // module header's "Every refusal here is a SERVER fault" section, + // re-affirmed as #7598 Q2 = A) closed. default: throw readScopeCompileError(`[read-scope-sql] unsupported operator "${op}" on "${field}" (fail-closed).`); } } +/** + * [#20445] The `$empty` arm: the field's DECLARED row of the ruled per-type + * table, from the spec's one expansion, compiled by `empty-operator-sql.ts`. + * + * The declaration comes from the caller ({@link ReadScopeCompileOptions.declaredValueShape}); + * this compiler calls `expandEmptyOperator` on it and keeps no table of its + * own. Two refusals, both in this module's one envelope and both before + * anything binds, so `params` stays aligned: + * + * - the caller cannot name the field's declaration. The row decides what the + * SQL compares (`''` is a type error against a numeric column on Postgres, + * and an empty list is only recognisable as JSON), so there is no reading to + * fall back to; + * - the field is list-valued and the dialect is `'unknown'`, where no JSON + * test parses on every engine. + */ +function compileEmptyOperator( + col: string, + empty: boolean, + field: string, + params: unknown[], + opts: ReadScopeCompileOptions, +): string { + const shape = opts.declaredValueShape?.(field); + if (!shape) { + throw readScopeCompileError( + `[read-scope-sql] "$empty" on "${field}" needs the field's declared type, and this host could not ` + + `name it (no field metadata wired, or no such field on the object) — refusing to build read scope ` + + `(fail-closed). What counts as empty depends on the declaration: null or '' for a text-like ` + + `field, null or [] for a multi-value field, null only for every other type. The producer to fix ` + + `is whoever BUILT this read scope, or the host's field metadata — never the caller of this query.`, + ); + } + const sql = emptyOperatorPredicateSql({ + dialect: normalizeSqlDialect(opts.dialect), + column: col, + expansion: expandEmptyOperator(shape), + empty, + bind: (v) => bind(params, v), + }); + if (sql === null) { + throw readScopeCompileError( + `[read-scope-sql] "$empty" on "${field}" is a multi-value field, whose empty list is tested with a ` + + `JSON function that differs per SQL dialect, and the dialect of this datasource is not known — ` + + `refusing to build read scope (fail-closed) rather than guessing the construct.`, + ); + } + return sql; +} + // ── [#5146] NULL-safe `$not` ───────────────────────────────────────────────── /** @@ -2040,6 +2175,10 @@ function nullValueSatisfiesOperator(op: string, value: unknown): boolean { // `$null: true` and `$exists: false` are the same question, so these two // arms are correctly each other's MIRROR, not each other's copy (#5369). case '$exists': return value === false; + // [#20445] Null is empty on every row of the ruled table, so a NULL column + // satisfies `$empty: true` and fails its complement — by identity, as the + // arm reads it, behind the same boolean gate. + case '$empty': return value === true; // Negative-polarity set / substring tests hold vacuously for an absent value. case '$nin': return true; // `$notContains` is the one operator where the two JS backends disagree for @@ -2058,6 +2197,10 @@ function operatorIsNullTotal(op: string, value: unknown): boolean { case '$null': case '$exists': return true; + // [#20445] Both polarities spell their NULL case out (`col IS NULL OR …` / + // `col IS NOT NULL AND …`, `empty-operator-sql.ts`), so the arm is TOTAL. + case '$empty': + return true; // A null comparand makes these null PREDICATES too, not comparisons. case '$eq': case '$ne': diff --git a/packages/services/service-analytics/src/strategies/filter-normalizer.ts b/packages/services/service-analytics/src/strategies/filter-normalizer.ts index a7bdec97178..cfa118e0c18 100644 --- a/packages/services/service-analytics/src/strategies/filter-normalizer.ts +++ b/packages/services/service-analytics/src/strategies/filter-normalizer.ts @@ -415,6 +415,32 @@ * node is built, in this door's envelope and with the message kept. `true` and * `false` lower exactly as before. * + * # `$empty` lowers to a leaf its consumers answer by the DECLARED type (#20445) + * + * The spec declares `$empty: boolean` with the ruled per-type 「is empty」 table + * as its meaning (#20311) — text-like: null or `''`; multi-value: null or `[]`; + * every other type: null only; `false` the complement — and ruling A on + * #20399 gives each compile surface an arm through the one expansion, + * `expandEmptyOperator(fieldDef)`. This door used to pass it through + * {@link lowerAnalyticsWhere} and then refuse it in {@link fieldLeaves} as an + * unsupported operator (`INVALID_FILTER` / 400). + * + * It now lowers to a valueless `empty` / `notEmpty` leaf, and each consumer of + * the tree answers the leaf where the field's declaration is known: + * `NativeSQLStrategy.buildFilterClause` and the `ObjectQLStrategy` echo expand + * the host's declared value shape through the spec and compile the row with + * `empty-operator-sql.ts` (refusing, in this door's envelope, when the host + * cannot name the field's declaration or a list-valued field meets the + * `'unknown'` dialect); `ObjectQLStrategy.convertFilter` hands `{ $empty }` + * to the engine, whose arm is the engine lane's. A non-boolean flag is + * refused by {@link assertBooleanNullFlags} with the two null flags. The leaf + * is TOTAL on every consumer, so the `$not` rewrite adds no guard to it. + * + * The operator stays STAGED (absent from `FILTER_OPERATORS`, 「照 $like 先例分阶段」): + * the view operators `is_empty` / `is_not_empty` still lower to `$null`, and + * the draft preview does not evaluate `$empty` — it refuses it with its other + * unevaluated operators (`preview-evaluator.ts`). + * * Row-result cover: `filter-operator-coverage.test.ts` for the operator * vocabulary, `native-sql-filter-logic-conformance.test.ts`, which runs the * SHARED combinator table (`FILTER_LOGIC_CASES`, #3774) that the SQL compiler, @@ -944,7 +970,9 @@ function assertDefinedComparands(field: string, spec: unknown): void { if (spec === undefined) throw undefinedComparandError(field, root); if (!isFilterObject(spec)) return; for (const [op, opValue] of Object.entries(spec)) { - if (!op.startsWith('$') || op === '$null' || op === '$exists') continue; + // [#20445] `$empty` is the third declared-boolean flag: skipped for the + // reason the null flags are, and refused by the same boolean-domain gate. + if (!op.startsWith('$') || NULL_FLAG_OPERATORS.has(op)) continue; const opPath = `${root}.${op}`; if (opValue === undefined) throw undefinedComparandError(field, opPath); if (!Array.isArray(opValue)) continue; @@ -1158,6 +1186,24 @@ function fieldLeaves(key: string, raw: unknown): NormalizedFilterNode[] { continue; } + // [#20445] `$empty` lowers to its own two leaves, `empty` / `notEmpty`, + // valueless like `notSet` / `set` — NOT to them, and not to any tree + // of the existing leaves: what counts as empty is the field's + // DECLARED row of the ruled per-type table (null or `''`, null or + // `[]`, null only), this function sees no declaration, and the + // multi-value row cannot be spelled in the lowered vocabulary at all + // (an empty list is refused as an equality comparand — ruling 乙 on + // #19757). Each consumer of the tree resolves the row where it knows + // the field: the two SQL compilers through the host's declared value + // shape and the spec's `expandEmptyOperator`, the engine path by + // handing the operator to the engine. The flag is a boolean here — + // `assertBooleanNullFlags` refused anything else — so `=== true` is + // the whole choice. + if (opKey === '$empty') { + leaf(wrapper[opKey] === true ? 'empty' : 'notEmpty', []); + continue; + } + // [#5332] A `null` COMPARAND is a null PREDICATE, not a value // comparison: `$eq: null` is `IS NULL` (`notSet`) and `$ne: null` is // `IS NOT NULL` (`set`) — the same two leaves the `raw === null` branch @@ -1212,7 +1258,7 @@ function fieldLeaves(key: string, raw: unknown): NormalizedFilterNode[] { // driver-memory made the same call for the same reason in #3948. throw invalidFilterError( `[analytics] Unsupported filter operator "${opKey}" on "${key}". ` + - `Supported: ${Object.keys(MONGO_TO_CUBE_OP).join(', ')}, $between, $null, $exists, ` + + `Supported: ${Object.keys(MONGO_TO_CUBE_OP).join(', ')}, $between, $null, $exists, $empty, ` + `and the $and/$or/$not combinators. ` + `Dropping it would silently widen the query to rows the filter excludes.`, ); @@ -1437,6 +1483,9 @@ function nullValueSatisfiesOperator(op: string, value: unknown): boolean { case '$ne': return value !== null; case '$null': return value === true; case '$exists': return value === false; + // [#20445] Null is empty on every row of the ruled table, so a NULL column + // satisfies `$empty: true` and fails its complement. + case '$empty': return value === true; // Negative-polarity set / substring tests hold vacuously for an absent value. case '$nin': return true; // `$notContains` is the one operator where the two JS backends disagree for @@ -1491,6 +1540,12 @@ function operatorIsNullTotal(op: string, value: unknown): boolean { case '$null': case '$exists': return true; + // [#20445] `empty` / `notEmpty` spell their NULL case out on both SQL + // compilers (`col IS NULL OR …` / `col IS NOT NULL AND …`), and the engine + // answers the operator by its own arm, so the leaf is TOTAL: a guard would + // only restate what the predicate already says. + case '$empty': + return true; // [#5332] A `null` comparand makes these null PREDICATES too — `notSet` / // `set`, not comparisons — so they are total by construction and take NO // guard. Left out, `{$not: {stage: {$eq: null}}}` wrapped `stage IS NOT NULL @@ -1939,8 +1994,24 @@ function normalizeWhereComparandTypes(node: T, path = 'where'): T { // ── [#20040] The null flags' boolean DOMAIN, on every spelling that carries one ── -/** The two flags `FieldOperatorsSchema` declares `z.boolean()`. */ -const NULL_FLAG_OPERATORS: ReadonlySet = new Set(['$null', '$exists']); +/** + * The flags `FieldOperatorsSchema` declares `z.boolean()`, each with what its + * `true` / `false` asks for (the refusal's prescription): the two null flags + * and [#20445] the `$empty` operator, which joins the gate with its arm. One + * table, so a flag cannot be gated without a prescription or described + * without being gated. + */ +const FLAG_MEANINGS = { + $null: ['has no value', 'has a value'], + $exists: ['has a value', 'has no value'], + $empty: ['is empty by its declared type', 'is not empty'], +} as const satisfies Record; +type BooleanFlagOperator = keyof typeof FLAG_MEANINGS; +const NULL_FLAG_OPERATORS: ReadonlySet = new Set(Object.keys(FLAG_MEANINGS)); + +function isBooleanFlagOperator(op: string): op is BooleanFlagOperator { + return NULL_FLAG_OPERATORS.has(op); +} /** What arrived where a flag's boolean belongs, for the refusal below. */ function describeFlagComparand(value: unknown): string { @@ -1993,15 +2064,15 @@ function describeFlagComparand(value: unknown): string { * condition reads one way wherever it is refused; the rest names what THIS * door used to do. The message carries no tracker number (a runtime string). */ -function nonBooleanFlagError(op: string, field: string, path: string, value: unknown): Error { - const [whenTrue, whenFalse] = op === '$null' ? ['has no value', 'has a value'] : ['has a value', 'has no value']; +function nonBooleanFlagError(op: BooleanFlagOperator, field: string, path: string, value: unknown): Error { + const [whenTrue, whenFalse] = FLAG_MEANINGS[op]; return invalidFilterError( `[analytics] Operator "${op}" on field "${field}" requires a boolean comparand (true or false). ` + `Received ${describeFlagComparand(value)} at ${path}. @objectstack/spec FieldOperatorsSchema ` + `declares ${op} as a boolean, and a non-boolean is refused rather than coerced because the ` + `backends read one in OPPOSITE directions — one as IS NULL, another as IS NOT NULL. This ` + - `analytics filter used to read every non-boolean as IS NOT NULL, so the string "true" and the ` + - `string "false" asked for the same rows. Write the boolean itself: "${op}": true matches rows ` + + `analytics filter used to read every non-boolean $null / $exists as IS NOT NULL, so the string ` + + `"true" and the string "false" asked for the same rows. Write the boolean itself: "${op}": true matches rows ` + `whose "${field}" ${whenTrue}, "${op}": false rows whose "${field}" ${whenFalse}. The filter was ` + `NOT applied.`, ); @@ -2054,7 +2125,7 @@ function assertBooleanNullFlags(node: unknown, path = 'where'): void { forEachWhereFieldEntry(node, path, (key, spec, at) => { if (!isFilterObject(spec)) return; for (const [op, value] of Object.entries(spec)) { - if (!NULL_FLAG_OPERATORS.has(op) || typeof value === 'boolean') continue; + if (!isBooleanFlagOperator(op) || typeof value === 'boolean') continue; throw nonBooleanFlagError(op, key, `${at}.${key}.${op}`, value); } }); 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 745b0c5e1da..1742fbc131c 100644 --- a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts @@ -14,6 +14,7 @@ import { import { findCrossFieldComparand, findUninterpretableTemporalMember } from '../comparand-shape.js'; import { assertReadScopeCannotVacate, compileScopedFilterToSql } from '../read-scope-sql.js'; import { nonTextColumnResolver, textOperatorPolarity } from '../non-text-column.js'; +import { declaredValueShapeResolver, whereEmptyLeafSql } from '../empty-operator-sql.js'; import { datasetInvalidError, invalidMemberError } from '../dataset-refusal.js'; import { type LikeShape } from '../like-pattern.js'; import { textMatchPredicateSql, sqlDialectFor } from '../text-match-sql.js'; @@ -655,10 +656,13 @@ export class NativeSQLStrategy implements AnalyticsStrategy { // (`{current_user_id}`, `{today}`) binds the value the ObjectQL face's // engine resolves for this caller, on this hop, rather than its literal // text; one it cannot resolve is refused in the read-scope envelope. + // [#20445] …and so does the declared value shape, so a policy's `$empty` + // is answered by the field's row of the ruled per-type table. const { sql, params: scopeParams } = compileScopedFilterToSql(filter, alias, { nonTextColumn: nonTextColumnResolver(ctx, objectName), dialect: sqlDialectFor(ctx, objectName), context: ctx.context, + declaredValueShape: declaredValueShapeResolver(ctx, objectName), }); // [#13926] The #13640 door guard, at THIS strategy's merge site. This is // not an echo: `execute()` runs this method's output through @@ -1146,6 +1150,21 @@ export class NativeSQLStrategy implements AnalyticsStrategy { // so only the value comparisons take the normalised reference. if (operator === 'set') return `${rawCol} IS NOT NULL`; if (operator === 'notSet') return `${rawCol} IS NULL`; + // [#20445] `$empty`'s leaf, answered by the field's DECLARED row of the + // ruled per-type table (null or `''`, null or `[]`, null only) — the host's + // declared value shape expanded by the spec, compiled per dialect. Reads + // the column as stored, like the null predicates: emptiness is a property + // of the stored value, not of its temporal normalisation. Refused, before + // anything binds, when the host cannot name the declaration. + if (operator === 'empty' || operator === 'notEmpty') { + return whereEmptyLeafSql({ + ctx, + target, + column: rawCol, + empty: operator === 'empty', + bind: (v) => { params.push(v); return `$${params.length}`; }, + }); + } if (operator === 'in' || operator === 'notIn') { if (!values || values.length === 0) return null; diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index 1e2191e7d58..dae26260124 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -24,6 +24,7 @@ import { compileScopedFilterToSql, } from '../read-scope-sql.js'; import { nonTextColumnResolver, textOperatorPolarity } from '../non-text-column.js'; +import { declaredValueShapeResolver, whereEmptyLeafSql } from '../empty-operator-sql.js'; import { invalidMemberError } from '../dataset-refusal.js'; import { type LikeShape } from '../like-pattern.js'; import { textMatchPredicateSql, sqlDialectFor } from '../text-match-sql.js'; @@ -565,10 +566,13 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // [#20075] …and the request context `execute()` forwards to the engine, // so the echo prints the value the engine resolves a scope placeholder // to, and refuses one it cannot resolve, as `execute()` does. + // [#20445] …and the same declared value shape, so the echoed scope + // prints the `$empty` arm the executed native statement runs. const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope, tableName, { nonTextColumn: nonTextColumnResolver(ctx, tableName), dialect: sqlDialectFor(ctx, tableName), context: ctx.context, + declaredValueShape: declaredValueShapeResolver(ctx, tableName), }); // [#13926] The same door guard `execute()` trusts (`withReadScope`, // #13640), at the ECHO's own merge — so one read scope gets ONE verdict @@ -1265,6 +1269,26 @@ export class ObjectQLStrategy implements AnalyticsStrategy { ): string | null { if (operator === 'set') return `${col} IS NOT NULL`; if (operator === 'notSet') return `${col} IS NULL`; + // [#20445] `$empty`'s leaf, rendered by the SAME function + // `NativeSQLStrategy.buildFilterClause` compiles it with, on the same + // target and hook, so the three SQL compilers of this package print one + // predicate for it. A caller that hands no target or no context cannot be + // asked for the declaration and gets the refusal, not a guess. + // + // ⚠️ `execute()` hands `{ $empty }` to the ENGINE (`convertFilter`), whose + // arm is the engine lane's. Until `driver-sql` carries it, the engine + // refuses the operator (`INVALID_FILTER` / 400) while this echo prints the + // declared arm — the row set that arm is ruled to return, on a query that + // is refused rather than answered differently. No face drops it. + if (operator === 'empty' || operator === 'notEmpty') { + return whereEmptyLeafSql({ + ctx, + target, + column: col, + empty: operator === 'empty', + bind: (v) => { params.push(v); return `$${params.length}`; }, + }); + } if (!values || values.length === 0) return null; @@ -1824,6 +1848,13 @@ export class ObjectQLStrategy implements AnalyticsStrategy { private convertFilter(operator: string, values?: unknown[]): unknown { if (operator === 'set') return { $ne: null }; if (operator === 'notSet') return null; + // [#20445] `$empty` goes to the engine as the canonical operator the + // author wrote — never as a local expansion into `$null` / `$eq: ''` + // fragments, which could not spell the multi-value row at all (an empty + // list is refused as an equality comparand, ruling 乙 on #19757). The + // engine resolves the field's declared row against its own metadata. + if (operator === 'empty') return { $empty: true }; + if (operator === 'notEmpty') return { $empty: false }; if (!values || values.length === 0) return undefined; const v0 = values[0]; diff --git a/packages/services/service-analytics/src/strategies/types.ts b/packages/services/service-analytics/src/strategies/types.ts index 8132bf10ae4..41a6aeaefc1 100644 --- a/packages/services/service-analytics/src/strategies/types.ts +++ b/packages/services/service-analytics/src/strategies/types.ts @@ -15,7 +15,7 @@ export type { AnalyticsDriverCapabilities, } from '@objectstack/spec/contracts'; -import type { FilterCondition } from '@objectstack/spec/data'; +import type { FilterCondition, ValueShapeFieldDef } from '@objectstack/spec/data'; import type { IObjectQLEngine, StrategyContext } from '@objectstack/spec/contracts'; /** @@ -88,6 +88,22 @@ export interface DatasetScopedStrategyContext extends StrategyContext { * hook keeps the behaviour it had — "cannot answer, do not block". */ declaredFieldType?(objectName: string, field: string): string | undefined; + /** + * [#20445] The DECLARED value shape of `field` on `objectName` — its type + * and, for a multi-capable type, `multiple` — or `undefined` when the host + * cannot answer (no data engine wired, an object or field it does not know). + * + * The question the `$empty` operator turns on: what counts as empty is the + * field's row of the ruled per-type table, which `expandEmptyOperator` + * (`@objectstack/spec/data`) reads off exactly this shape, and the type + * alone cannot answer it (a `lookup` is null-only, a `lookup` with + * `multiple: true` is list-valued). Answered from the same + * `AnalyticsServiceConfig.sourceFieldMeta` hook `declaredFieldType` reads. + * Unlike that hook's text-operator rule, a compiler that gets no answer + * REFUSES the operator rather than keeping an older behaviour: there is no + * declaration-free SQL for it (`empty-operator-sql.ts` says why). + */ + declaredValueShape?(objectName: string, field: string): ValueShapeFieldDef | undefined; /** * [#15684] The SQL dialect of the datasource backing `objectName` — * `'sqlite'` / `'postgres'` / `'mysql'`, or `undefined` when the host cannot From 20994c105d390be7c1b3cdbb9e18713316e2035d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 18:48:44 +0000 Subject: [PATCH 2/4] test(service-analytics): pin $empty on both filter faces by the declared row Claude-Session: https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs Co-authored-by: Claude --- .../read-scope-empty-operator.test.ts | 249 +++++++++++++++ .../__tests__/where-empty-operator.test.ts | 286 ++++++++++++++++++ 2 files changed, 535 insertions(+) create mode 100644 packages/services/service-analytics/src/__tests__/read-scope-empty-operator.test.ts create mode 100644 packages/services/service-analytics/src/__tests__/where-empty-operator.test.ts diff --git a/packages/services/service-analytics/src/__tests__/read-scope-empty-operator.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-empty-operator.test.ts new file mode 100644 index 00000000000..91ab5c05de1 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/read-scope-empty-operator.test.ts @@ -0,0 +1,249 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20445] The read-scope face answers `$empty` by the field's DECLARED type. + * + * Ruling A on #20399 gives each compile surface an arm for the operator, and + * the spec gives every arm one expansion, `expandEmptyOperator(fieldDef)`: the + * ruled per-type 「is empty」 table (#20311). Text-like: null or `''`. + * Multi-value: null or `[]`. Every other type: null only. `$empty: false` is + * the complement. + * + * Executed on real SQLite (`sql.js`, the engine `driver-sql` falls back to), + * over one row per stored state a field can be in: null, `''`, the empty JSON + * list, a non-list JSON value and a real value. Two fields per scalar row kind + * — a `select` and a `number` — because the negative pin is the `select`: its + * column can hold `''`, and `''` is a VALUE on the null-only row. + * + * The compiled SQL is pinned only for the two dialects `sql.js` cannot run + * (Postgres, MySQL): what those strings DO is not measured here. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FilterCondition, ValueShapeFieldDef } from '@objectstack/spec/data'; + +import { compileScopedFilterToSql, type ReadScopeCompileOptions } from '../read-scope-sql.js'; + +const ALIAS = 't'; + +/** The declared shape of each fixture column: one per row of the ruled table, plus a second null-only type. */ +const SHAPES: Record = { + id: { type: 'text' }, + name: { type: 'text' }, + tags: { type: 'tags' }, + owners: { type: 'lookup', multiple: true }, + stage: { type: 'select' }, + amount: { type: 'number' }, +}; + +/** + * One row per stored state. The multi-value columns hold JSON text, as + * `driver-sql` stores them on SQLite. + * + * - `n` — no value anywhere. + * - `s` — the empty string in every column (a malformed JSON list on the + * multi-value columns, `''` on the `select`, and `0` on the number). + * - `l` — the empty JSON list `[]` (as TEXT on `name` and `stage`, where it is + * two characters, not a list). + * - `o` — a JSON value that is not a list (`{}`, a JSON string). + * - `v` — a real value in every column. + */ +const ROWS = [ + { id: 'n', name: null, tags: null, owners: null, stage: null, amount: null }, + { id: 's', name: '', tags: '', owners: '', stage: '', amount: 0 }, + { id: 'l', name: '[]', tags: '[]', owners: '[]', stage: '[]', amount: 7 }, + { id: 'o', name: 'o', tags: '{}', owners: '"u1"', stage: 'lost', amount: -1 }, + { id: 'v', name: 'acme', tags: '["a"]', owners: '["u1","u2"]', stage: 'won', amount: 5 }, +]; +const ALL = ROWS.map((r) => r.id).sort(); + +const complement = (ids: string[]): string[] => ALL.filter((id) => !ids.includes(id)); + +/** `$empty: true` per field, by the declared row. */ +const EMPTY: Record = { + name: ['n', 's'], // text-like: null or '' + tags: ['l', 'n'], // multi-value: null or [] + owners: ['l', 'n'], // multi-value through `multiple: true` + stage: ['n'], // null only — '' is a value on this row + amount: ['n'], // null only — 0 is a value +}; + +const SQLITE: ReadScopeCompileOptions = { dialect: 'sqlite', declaredValueShape: (field) => SHAPES[field] }; + +async function locateWasm(): Promise<((file: string) => string) | undefined> { + try { + const { createRequire } = await import('node:module'); + const require = createRequire(import.meta.url); + const pkgJsonPath = require.resolve('sql.js/package.json'); + const { dirname, join } = await import('node:path'); + const dir = dirname(pkgJsonPath); + return (file: string) => join(dir, 'dist', file); + } catch { + return undefined; + } +} + +interface CodedError extends Error { + code?: unknown; + status?: unknown; +} + +function refusalOf(fn: () => unknown): CodedError { + try { + fn(); + } catch (e) { + return e as CodedError; + } + throw new Error('expected a refusal, and the scope compiled'); +} + +describe('[#20445] compileScopedFilterToSql — `$empty` by the declared type, executed on SQLite', () => { + let db: any; + + const run = (scope: FilterCondition, options: ReadScopeCompileOptions = SQLITE): string[] => { + const { sql, params } = compileScopedFilterToSql(scope, ALIAS, options); + const stmt = db.prepare(`SELECT "id" FROM "t" AS "${ALIAS}" WHERE ${sql.length > 0 ? sql : '1 = 1'} ORDER BY "id"`); + stmt.bind(params as any[]); + const got: string[] = []; + while (stmt.step()) got.push(String(stmt.get()[0])); + stmt.free(); + return got; + }; + + beforeAll(async () => { + const mod: any = await import('sql.js'); + const initSqlJs = mod.default ?? mod; + const locateFile = await locateWasm(); + const SQL = await initSqlJs(locateFile ? { locateFile } : undefined); + db = new SQL.Database(); + db.run(`CREATE TABLE "t" ("id" TEXT PRIMARY KEY, "name" TEXT, "tags" TEXT, "owners" TEXT, "stage" TEXT, "amount" REAL);`); + const insert = db.prepare(`INSERT INTO "t" ("id","name","tags","owners","stage","amount") VALUES (?,?,?,?,?,?)`); + for (const r of ROWS) insert.run([r.id, r.name, r.tags, r.owners, r.stage, r.amount]); + insert.free(); + }); + + afterAll(() => { + db?.close(); + }); + + for (const [field, expected] of Object.entries(EMPTY)) { + it(`${field} (${SHAPES[field].type}${SHAPES[field].multiple ? ', multiple' : ''}): $empty: true → ${expected.join(',')}`, () => { + expect(run({ [field]: { $empty: true } })).toEqual(expected); + }); + it(`${field}: $empty: false is the exact complement`, () => { + expect(run({ [field]: { $empty: false } })).toEqual(complement(expected)); + }); + } + + it('the null-only row does NOT count the empty string: a select holding "" is not empty', () => { + expect(run({ stage: { $empty: true } })).not.toContain('s'); + expect(run({ stage: { $empty: false } })).toContain('s'); + }); + + it('a text column holding the two characters "[]" is a value, not an empty list', () => { + expect(run({ name: { $empty: true } })).not.toContain('l'); + }); + + it('on a multi-value column, "" and a non-list JSON value are values, not the empty list', () => { + expect(run({ tags: { $empty: true } })).not.toContain('s'); + expect(run({ tags: { $empty: true } })).not.toContain('o'); + expect(run({ owners: { $empty: true } })).not.toContain('o'); + }); + + describe('nested under $and / $or / $not', () => { + it('$not over $empty: true is its complement — no NULL guard needed, the arm is total', () => { + expect(run({ $not: { name: { $empty: true } } })).toEqual(complement(EMPTY.name)); + expect(run({ $not: { tags: { $empty: true } } })).toEqual(complement(EMPTY.tags)); + expect(run({ $not: { amount: { $empty: false } } })).toEqual(EMPTY.amount); + }); + + it('$and of two $empty: false', () => { + expect(run({ $and: [{ tags: { $empty: false } }, { name: { $empty: false } }] })).toEqual(['o', 'v']); + }); + + it('$or of two $empty: true', () => { + expect(run({ $or: [{ stage: { $empty: true } }, { tags: { $empty: true } }] })).toEqual(['l', 'n']); + }); + + it('$not over an $or of $empty', () => { + expect(run({ $not: { $or: [{ name: { $empty: true } }, { owners: { $empty: true } }] } })).toEqual(['o', 'v']); + }); + + it('beside another operator on the same field', () => { + expect(run({ name: { $empty: false, $ne: 'acme' } })).toEqual(['l', 'o']); + expect(run({ $not: { name: { $empty: false, $ne: 'acme' } } })).toEqual(['n', 's', 'v']); + }); + }); +}); + +describe('[#20445] the SQL each dialect is handed (Postgres / MySQL: compiled, not executed here)', () => { + const sqlOf = (dialect: string, scope: FilterCondition) => + compileScopedFilterToSql(scope, ALIAS, { dialect, declaredValueShape: (field) => SHAPES[field] }); + + it('text-like and null-only rows are dialect-free, and bind the empty string', () => { + for (const dialect of ['sqlite', 'postgres', 'mysql', 'unknown']) { + expect(sqlOf(dialect, { name: { $empty: true } })).toEqual({ sql: '("t"."name" IS NULL OR "t"."name" = ?)', params: [''] }); + expect(sqlOf(dialect, { name: { $empty: false } })).toEqual({ sql: '("t"."name" IS NOT NULL AND "t"."name" <> ?)', params: [''] }); + expect(sqlOf(dialect, { stage: { $empty: true } })).toEqual({ sql: '"t"."stage" IS NULL', params: [] }); + expect(sqlOf(dialect, { stage: { $empty: false } })).toEqual({ sql: '"t"."stage" IS NOT NULL', params: [] }); + } + }); + + it('Postgres compares the JSON column as jsonb', () => { + expect(sqlOf('postgres', { tags: { $empty: true } }).sql).toBe( + `("t"."tags" IS NULL OR (CAST("t"."tags" AS jsonb) = CAST('[]' AS jsonb)))`, + ); + expect(sqlOf('postgres', { tags: { $empty: false } }).sql).toBe( + `("t"."tags" IS NOT NULL AND NOT (CAST("t"."tags" AS jsonb) = CAST('[]' AS jsonb)))`, + ); + }); + + it('MySQL asks the JSON type beside the length', () => { + expect(sqlOf('mysql', { owners: { $empty: true } }).sql).toBe( + `("t"."owners" IS NULL OR (JSON_TYPE("t"."owners") = 'ARRAY' AND JSON_LENGTH("t"."owners") = 0))`, + ); + }); +}); + +describe('[#20445] refusals — READ_SCOPE_COMPILE_FAILED / 500, the module’s one envelope', () => { + const expectServerFault = (err: CodedError) => { + expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED'); + expect(err.status).toBe(500); + }; + + it('no declared value shape wired: refused, never guessed', () => { + const err = refusalOf(() => compileScopedFilterToSql({ name: { $empty: true } }, ALIAS, { dialect: 'sqlite' })); + expectServerFault(err); + expect(err.message).toContain('needs the field\'s declared type'); + }); + + it('a field the host cannot name: refused', () => { + expectServerFault(refusalOf(() => compileScopedFilterToSql({ ghost: { $empty: false } }, ALIAS, SQLITE))); + }); + + it('a multi-value field on the unknown dialect: refused; the other rows still compile there', () => { + const options: ReadScopeCompileOptions = { declaredValueShape: (field) => SHAPES[field] }; + const err = refusalOf(() => compileScopedFilterToSql({ owners: { $empty: true } }, ALIAS, options)); + expectServerFault(err); + expect(err.message).toContain('dialect of this datasource is not known'); + expect(() => compileScopedFilterToSql({ name: { $empty: true }, amount: { $empty: false } }, ALIAS, options)).not.toThrow(); + }); + + for (const flag of ['true', 'false', 1, null, [true]]) { + it(`a non-boolean flag (${JSON.stringify(flag)}) is refused by the boolean-domain gate`, () => { + const err = refusalOf(() => compileScopedFilterToSql({ name: { $empty: flag } } as FilterCondition, ALIAS, SQLITE)); + expectServerFault(err); + expect(err.message).toContain('comparand for "$empty" at "name".$empty is not a boolean'); + }); + } + + it('under $not, a non-boolean flag is still refused', () => { + expectServerFault(refusalOf(() => compileScopedFilterToSql({ $not: { name: { $empty: 'false' } } } as FilterCondition, ALIAS, SQLITE))); + }); + + it('an operator without an arm keeps READ_SCOPE_COMPILE_FAILED / 500, not the where door’s 400 (#5367 stands)', () => { + const err = refusalOf(() => compileScopedFilterToSql({ name: { $bogus: 1 } } as FilterCondition, ALIAS, SQLITE)); + expectServerFault(err); + expect(err.code).not.toBe('INVALID_FILTER'); + }); +}); diff --git a/packages/services/service-analytics/src/__tests__/where-empty-operator.test.ts b/packages/services/service-analytics/src/__tests__/where-empty-operator.test.ts new file mode 100644 index 00000000000..6b1186fa9af --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/where-empty-operator.test.ts @@ -0,0 +1,286 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20445] The analytics `where` face answers `$empty` by the field's DECLARED + * type. + * + * `lowerAnalyticsWhere` / `normalizeAnalyticsFilterTree` used to pass `$empty` + * through and then refuse it as an unsupported operator (`INVALID_FILTER` / + * 400). It now lowers to a valueless `empty` / `notEmpty` leaf, and each + * consumer of the tree answers it where the field's declaration is known: + * + * - `NativeSQLStrategy` — the statement that EXECUTES — expands the host's + * declared value shape through the spec (`expandEmptyOperator`) and compiles + * the row. Executed here on real SQLite (`sql.js`). + * - the `ObjectQLStrategy` echo (`/analytics/sql`) renders the same predicate, + * run here on the same database, so it returns the same rows. + * - the `ObjectQLStrategy` execute path hands `{ $empty }` to the ENGINE, whose + * arm is the engine lane's; pinned here as the condition handed over, not as + * the rows the engine returns. + * + * The ruled table (#20311): text-like = null or `''`; multi-value = null or + * `[]`; every other type = null only; `$empty: false` is the complement. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { Cube, ValueShapeFieldDef } from '@objectstack/spec/data'; +import type { AnalyticsQuery, StrategyContext } from '@objectstack/spec/contracts'; + +import { normalizeAnalyticsFilterTree } from '../strategies/filter-normalizer.js'; +import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js'; +import { ObjectQLStrategy } from '../strategies/objectql-strategy.js'; +import type { DatasetScopedStrategyContext } from '../strategies/types.js'; + +const TABLE = 't'; + +const SHAPES: Record = { + id: { type: 'text' }, + name: { type: 'text' }, + tags: { type: 'tags' }, + owners: { type: 'lookup', multiple: true }, + stage: { type: 'select' }, + amount: { type: 'number' }, +}; + +/** The same five stored states `read-scope-empty-operator.test.ts` measures. */ +const ROWS = [ + { id: 'n', name: null, tags: null, owners: null, stage: null, amount: null }, + { id: 's', name: '', tags: '', owners: '', stage: '', amount: 0 }, + { id: 'l', name: '[]', tags: '[]', owners: '[]', stage: '[]', amount: 7 }, + { id: 'o', name: 'o', tags: '{}', owners: '"u1"', stage: 'lost', amount: -1 }, + { id: 'v', name: 'acme', tags: '["a"]', owners: '["u1","u2"]', stage: 'won', amount: 5 }, +]; +const ALL = ROWS.map((r) => r.id).sort(); +const complement = (ids: string[]): string[] => ALL.filter((id) => !ids.includes(id)); + +const EMPTY: Record = { + name: ['n', 's'], + tags: ['l', 'n'], + owners: ['l', 'n'], + stage: ['n'], + amount: ['n'], +}; + +const CUBE: Cube = { + name: 'empties', + title: 'Empties', + sql: TABLE, + measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + dimensions: Object.fromEntries( + Object.keys(SHAPES).map((n) => [n, { name: n, label: n, type: 'string', sql: n }]), + ), + public: true, +} as unknown as Cube; + +async function locateWasm(): Promise<((file: string) => string) | undefined> { + try { + const { createRequire } = await import('node:module'); + const require = createRequire(import.meta.url); + const pkgJsonPath = require.resolve('sql.js/package.json'); + const { dirname, join } = await import('node:path'); + const dir = dirname(pkgJsonPath); + return (file: string) => join(dir, 'dist', file); + } catch { + return undefined; + } +} + +interface CodedError extends Error { + code?: unknown; + status?: unknown; +} + +async function refusalOf(fn: () => unknown): Promise { + try { + await fn(); + } catch (e) { + return e as CodedError; + } + throw new Error('expected a refusal, and the filter was served'); +} + +const query = (where: unknown): AnalyticsQuery => + ({ cube: 'empties', measures: ['total'], dimensions: ['id'], timezone: 'UTC', where }) as AnalyticsQuery; + +describe('[#20445] normalizeAnalyticsFilterTree — `$empty` lowers to its own valueless leaf', () => { + it('$empty: true → an `empty` leaf, $empty: false → `notEmpty`', () => { + expect(normalizeAnalyticsFilterTree({ where: { name: { $empty: true } } })).toEqual({ + kind: 'leaf', member: 'name', operator: 'empty', values: [], + }); + expect(normalizeAnalyticsFilterTree({ where: { name: { $empty: false } } })).toEqual({ + kind: 'leaf', member: 'name', operator: 'notEmpty', values: [], + }); + }); + + it('under $not the leaf takes no NULL guard: the arm is total on every consumer', () => { + expect(normalizeAnalyticsFilterTree({ where: { $not: { name: { $empty: true } } } })).toEqual({ + kind: 'not', child: { kind: 'leaf', member: 'name', operator: 'empty', values: [] }, + }); + }); + + for (const flag of ['true', 0, null, [true], new Date(0)]) { + it(`a non-boolean flag (${String(flag)}) is refused INVALID_FILTER / 400, with the null flags`, async () => { + const err = await refusalOf(() => normalizeAnalyticsFilterTree({ where: { name: { $empty: flag } } })); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain('Operator "$empty" on field "name" requires a boolean comparand'); + }); + } +}); + +describe('[#20445] the `where` face on SQLite — native execute, and the ObjectQL echo run on the same rows', () => { + let db: any; + let nativeCtx: StrategyContext; + let objectqlCtx: StrategyContext; + const handed: Array | undefined> = []; + + const runSql = (sql: string, params: unknown[]): string[] => { + const stmt = db.prepare(sql.replace(/\$\d+/g, '?')); + stmt.bind(params as any[]); + const out: string[] = []; + while (stmt.step()) out.push(String(stmt.getAsObject().id)); + stmt.free(); + return out.sort((x, y) => x.localeCompare(y)); + }; + + const nativeIds = async (where: unknown): Promise => { + const result = await new NativeSQLStrategy().execute(query(where), nativeCtx); + return result.rows.map((r) => String(r.id)).sort((x, y) => x.localeCompare(y)); + }; + + const echoIds = async (where: unknown): Promise => { + const { sql, params } = await new ObjectQLStrategy().generateSql(query(where), objectqlCtx); + return runSql(sql, params); + }; + + /** Both SQL faces, which must agree row for row. */ + const ids = async (where: unknown): Promise => { + const native = await nativeIds(where); + expect(await echoIds(where), 'the ObjectQL echo returns what native executes').toEqual(native); + return native; + }; + + beforeAll(async () => { + const mod: any = await import('sql.js'); + const initSqlJs = mod.default ?? mod; + const locateFile = await locateWasm(); + const SQL = await initSqlJs(locateFile ? { locateFile } : undefined); + db = new SQL.Database(); + db.run(`CREATE TABLE "t" ("id" TEXT PRIMARY KEY, "name" TEXT, "tags" TEXT, "owners" TEXT, "stage" TEXT, "amount" REAL);`); + const insert = db.prepare(`INSERT INTO "t" ("id","name","tags","owners","stage","amount") VALUES (?,?,?,?,?,?)`); + for (const r of ROWS) insert.run([r.id, r.name, r.tags, r.owners, r.stage, r.amount]); + insert.free(); + + const hooks: Partial = { + getCube: (name: string) => (name === 'empties' ? CUBE : undefined), + declaredValueShape: (object: string, field: string) => (object === TABLE ? SHAPES[field] : undefined), + sqlDialect: () => 'sqlite', + }; + nativeCtx = { + ...hooks, + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async (_object: string, sql: string, params: unknown[]) => { + const stmt = db.prepare(sql.replace(/\$\d+/g, '?')); + stmt.bind(params as any[]); + const out: Record[] = []; + while (stmt.step()) out.push(stmt.getAsObject()); + stmt.free(); + return out; + }, + } as StrategyContext; + objectqlCtx = { + ...hooks, + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (_object: string, options: { filter?: Record }) => { + handed.push(options.filter); + return []; + }, + } as StrategyContext; + }); + + afterAll(() => { + db?.close(); + }); + + for (const [field, expected] of Object.entries(EMPTY)) { + it(`${field} (${SHAPES[field].type}${SHAPES[field].multiple ? ', multiple' : ''}): $empty: true → ${expected.join(',')}`, async () => { + expect(await ids({ [field]: { $empty: true } })).toEqual(expected); + }); + it(`${field}: $empty: false is the exact complement`, async () => { + expect(await ids({ [field]: { $empty: false } })).toEqual(complement(expected)); + }); + } + + it('the null-only row does NOT count the empty string: a select holding "" is not empty', async () => { + expect(await ids({ stage: { $empty: true } })).not.toContain('s'); + expect(await ids({ stage: { $empty: false } })).toContain('s'); + }); + + it('on a multi-value column, "" and a non-list JSON value are values, not the empty list', async () => { + const got = await ids({ tags: { $empty: true } }); + expect(got).not.toContain('s'); + expect(got).not.toContain('o'); + }); + + describe('nested under $and / $or / $not', () => { + it('$not over $empty is its complement', async () => { + expect(await ids({ $not: { name: { $empty: true } } })).toEqual(complement(EMPTY.name)); + expect(await ids({ $not: { owners: { $empty: false } } })).toEqual(EMPTY.owners); + }); + + it('$and of two $empty: false', async () => { + expect(await ids({ $and: [{ tags: { $empty: false } }, { name: { $empty: false } }] })).toEqual(['o', 'v']); + }); + + it('$or of two $empty: true', async () => { + expect(await ids({ $or: [{ stage: { $empty: true } }, { tags: { $empty: true } }] })).toEqual(['l', 'n']); + }); + + it('$not over an $or of $empty', async () => { + expect(await ids({ $not: { $or: [{ name: { $empty: true } }, { owners: { $empty: true } }] } })).toEqual(['o', 'v']); + }); + + it('beside another operator on the same field', async () => { + expect(await ids({ name: { $empty: false, $ne: 'acme' } })).toEqual(['l', 'o']); + expect(await ids({ $not: { name: { $empty: false, $ne: 'acme' } } })).toEqual(['n', 's', 'v']); + }); + }); + + describe('the ObjectQL execute path hands the canonical operator to the engine', () => { + it('$empty: true / false arrive as `{ $empty }`, never expanded into $null / $eq fragments', async () => { + handed.length = 0; + await new ObjectQLStrategy().execute(query({ tags: { $empty: true } }), objectqlCtx); + await new ObjectQLStrategy().execute(query({ name: { $empty: false } }), objectqlCtx); + await new ObjectQLStrategy().execute(query({ $not: { stage: { $empty: true } } }), objectqlCtx); + expect(handed).toEqual([ + { tags: { $empty: true } }, + { name: { $empty: false } }, + { $and: [{ $not: { stage: { $empty: true } } }] }, + ]); + }); + }); + + describe('refusals — INVALID_FILTER / 400, the where door’s envelope', () => { + const expectCallerFault = (err: CodedError) => { + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + }; + + it('a host with no declared value shape: refused on both SQL faces, never guessed', async () => { + const bare = (ctx: StrategyContext) => ({ ...ctx, declaredValueShape: undefined }) as StrategyContext; + const native = await refusalOf(() => new NativeSQLStrategy().execute(query({ name: { $empty: true } }), bare(nativeCtx))); + expectCallerFault(native); + expect(native.message).toContain('could not name the declaration of field "name" of "t"'); + expectCallerFault(await refusalOf(() => new ObjectQLStrategy().generateSql(query({ name: { $empty: true } }), bare(objectqlCtx)))); + }); + + it('a multi-value field on the unknown dialect: refused; the other rows still compile there', async () => { + const noDialect = { ...nativeCtx, sqlDialect: undefined } as StrategyContext; + const err = await refusalOf(() => new NativeSQLStrategy().execute(query({ owners: { $empty: true } }), noDialect)); + expectCallerFault(err); + expect(err.message).toContain('dialect of this datasource is not known'); + const result = await new NativeSQLStrategy().execute(query({ name: { $empty: true }, amount: { $empty: false } }), noDialect); + expect(result.rows.map((r) => String(r.id)).sort()).toEqual(['s']); + }); + }); +}); From 6a07f8ccbd7a68757cae7bebb37e41c32be1aa8a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 18:52:07 +0000 Subject: [PATCH 3/4] chore(changeset): service-analytics $empty arms (patch) Claude-Session: https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs Co-authored-by: Claude --- .../20445-analytics-empty-operator-arms.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/20445-analytics-empty-operator-arms.md diff --git a/.changeset/20445-analytics-empty-operator-arms.md b/.changeset/20445-analytics-empty-operator-arms.md new file mode 100644 index 00000000000..083fc4cae2f --- /dev/null +++ b/.changeset/20445-analytics-empty-operator-arms.md @@ -0,0 +1,20 @@ +--- +'@objectstack/service-analytics': patch +--- + +fix(service-analytics): both analytics filter faces answer `$empty` by the field's declared type (#20445) + +Clause-②: no + +`$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: + +- **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). +- **Row-level read scopes** compiled to SQL (`compileScopedFilterToSql`): same rows, in the read-scope envelope. + +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). + +**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`). + +Host API: `AnalyticsServiceConfig.sourceFieldMeta` may now answer `multiple` beside `type`; `AnalyticsServicePlugin` relays it from the field definition. A host that omits it has every multi-capable field read as single-valued. `compileScopedFilterToSql` takes an optional `declaredValueShape` option. + +`$empty` stays staged: it is not in `FILTER_OPERATORS`, and the view operators `is_empty` / `is_not_empty` still lower to `$null`. From 611daa435f9947e1e13cb85b8b4121a8a4123e76 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 19:33:22 +0000 Subject: [PATCH 4/4] =?UTF-8?q?chore(changeset):=20service-analytics=20$em?= =?UTF-8?q?pty=20arms=20are=20minor,=20Clause-=E2=91=A1=20yes=20(widening)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new optional published members (sourceFieldMeta's multiple, compileScopedFilterToSql's declaredValueShape) widen the package's public surface; names the read-scope residual of a host that answers type without multiple. Claude-Session: https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs Co-authored-by: Claude --- .changeset/20445-analytics-empty-operator-arms.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/20445-analytics-empty-operator-arms.md b/.changeset/20445-analytics-empty-operator-arms.md index 083fc4cae2f..3f9abb12cf8 100644 --- a/.changeset/20445-analytics-empty-operator-arms.md +++ b/.changeset/20445-analytics-empty-operator-arms.md @@ -1,10 +1,10 @@ --- -'@objectstack/service-analytics': patch +'@objectstack/service-analytics': minor --- fix(service-analytics): both analytics filter faces answer `$empty` by the field's declared type (#20445) -Clause-②: no +Clause-②: yes (widening) `$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: @@ -15,6 +15,6 @@ A multi-value field's empty list is tested with a JSON function per SQL dialect **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`). -Host API: `AnalyticsServiceConfig.sourceFieldMeta` may now answer `multiple` beside `type`; `AnalyticsServicePlugin` relays it from the field definition. A host that omits it has every multi-capable field read as single-valued. `compileScopedFilterToSql` takes an optional `declaredValueShape` option. +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. `$empty` stays staged: it is not in `FILTER_OPERATORS`, and the view operators `is_empty` / `is_not_empty` still lower to `$null`.