diff --git a/.changeset/20116-filter-save-door-type-face-and-widget.md b/.changeset/20116-filter-save-door-type-face-and-widget.md new file mode 100644 index 00000000000..25f610084c0 --- /dev/null +++ b/.changeset/20116-filter-save-door-type-face-and-widget.md @@ -0,0 +1,74 @@ +--- +"@objectstack/spec": minor +--- + +fix(spec)!: a filter carrying a comparand the comparand-type face refuses is refused when it is saved, and every charted presentation filter judges its nested relations (#20116) + +**BREAKING** — an accept-set narrowing of published authoring schemas, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. The save door narrows to exactly what the query faces already refuse; this is stage 2 of the change whose first stage registered `filter-query-face-comparands-refused-at-save`. The hand-migration prescription is registered under protocol major 18 as `filter-comparand-types-and-widget-nested-slots-refused-at-save`. + +## What changes + +**The comparand-type face, asked on save.** `FilterConditionSchema` now refuses, at parse, every comparand the comparand-type face (`normalizeFilterComparandTypes`, the accepted set `string | number | bigint | boolean | null | Date`) refuses on every query: + +- a plain object where a single value belongs — `{ stage: { $eq: { a: 1 } } }`, and a `{ $field: … }` whose name is not a string; +- a `Map`, a class instance, a function or a Symbol; +- `undefined`, as `{ owner: undefined }` or under an operator; +- a bigint beyond ±2^53; + +as the comparand itself, as an implicit-equality comparand, or as an `$in` / `$nin` / `$between` list member (`{ stage: { $in: [{ a: 1 }] } }`). The face is called read-only as the judge, after the comparand-shape face, so the save door refuses exactly what it refuses and passes what it passes. A field value that is not a PLAIN object (a `Map`, a class instance) is the comparand the face calls it, never an empty nested relation. + +**Every charted presentation filter is an analytics carrier.** `DashboardWidgetSchema.filter`, `ReportSchema.runtimeFilter` and `JoinedReportBlockSchema.runtimeFilter` now declare the same filter as `DatasetSchema.filter` and `DatasetMeasureSchema.filter`, because the dataset executor ANDs each into the same analytics query. So they judge the slots INSIDE a nested-relation condition the way the analytics `where` door does: `{ acct: { stage: { $in: ["won", null] } } }`, `{ acct: { region: ["a"] } }` and `{ acct: { region: { $eq: ["a"] } } }` are refused on save at `widgets.0.filter.acct.…`, `runtimeFilter.acct.…` and `blocks.0.runtimeFilter.acct.…`, as on the two dataset carriers — every shape the earlier stage refuses at the top level, and every type-face value above. That declaration moved, verbatim, into its own module shared by the five carriers; every carrier's published JSON Schema body is byte-identical. + +Measured on `origin/main` `17bd3187` before the change: `FilterConditionSchema`, a dataset `filter`, a dataset measure `filter`, a dashboard widget `filter`, a report `runtimeFilter` and a joined report block `runtimeFilter` each parsed with `success: true` for `{ stage: { $eq: { a: 1 } } }`, `{ stage: { $in: [{ a: 1 }] } }` and a `Map` comparand, at the top level and inside a nested relation. The comparand-type face and the analytics `where` door refused each with `INVALID_FILTER` / 400. A dashboard widget `filter`, a report `runtimeFilter` and a joined report block `runtimeFilter` also parsed with `success: true` for the three nested-relation shapes above, which the analytics door refuses when they are charted. + +**One issue per slot at the top level and in the combinators — a dedupe; no verdict moves.** The faces' verdict on a slot is one refusal: the first the query doors give, in their order — the comparand-shape face, then the comparand-type face, then the `$null` / `$exists` flag rule. So `{ stage: { $null: { a: 1 } } }` reads as the type face's refusal, as it does on chart. At the top level of a filter and in its `$and` / `$or` / `$not` members, where a face refuses a slot the schema door's own `$icontains` and date-preset arms stay silent on it. Before, two issues could land there for one defect: `{ created_at: { $between: ["last_7_days"] } }` reported the malformed range at `created_at.$between` AND the preset endpoint at `created_at.$between.0`; now it reports the range only, and the preset is reported once the range is fixed. + +Inside a nested relation on an analytics carrier (a dataset or measure `filter`, a widget `filter`, a report or joined-block `runtimeFilter`) a slot can still carry TWO issues. There the carrier's nested-relation walk asks the faces, while the schema door's own `$icontains` and date-preset arms keep judging nested slots as they did before this change. So `{ acct: { name: { $icontains: new Map() } } }` gets both the `$icontains` sentence and the type face's at `filter.acct.name.$icontains`, and `{ acct: { created_at: { $between: ["last_7_days"] } } }` gets the malformed range at `filter.acct.created_at.$between` and the preset endpoint at `….$between.0`. The document is refused either way; only the issue count differs. + +Every document refused before is still refused, and every document accepted before is still accepted — the dedupe removes only a second issue on an already-refused slot at the top level and in the combinators. + +**The words are the type face's**, less its location clause (`at where..`), because the issue's path carries the location: for example `Filter comparand is a plain object ({"a":1}), which no driver can compare. A comparison value must be a string, number, bigint, boolean, null or Date. Refusing rather than guessing: …` at `filter.stage.$eq`, and at `filter.stage.$in.1` for a list member. + +`defineStack`, `os validate` and a save through the metadata protocol (`422 INVALID_METADATA`) refuse such a document at the slot's path. + +## What does NOT change + +- **Nothing stored is rewritten, and nothing is dropped.** The parse fails and strips nothing. The read path does not re-validate stored rows, so a stored document keeps loading, and its next save is refused. +- **What the face passes still passes:** a `Date`, a `{ $field: "column" }` reference, a `{placeholder}` string the engine resolves at request time (`{current_user_id}`, `{today}`), and a bigint within ±2^53. The face narrows such a bigint to its number on a query; the save door keeps it as written. +- **The shared reach is unchanged.** On every carrier but the three analytics ones, a field spec with no `$` key (a nested-relation condition) is not judged, because no face descends one. +- **The data-engine calls' `where` option still parses.** Its type is a union whose first arm is an open record. The face refuses the shape when the call runs. +- **No key, export or JSON Schema body changes.** The published JSON Schema cannot state a refinement; the new nested-relation rule is recorded as a dropped refinement at `ui/DashboardWidget` `filter`, `ui/Dashboard` `widgets.element.filter`, `ui/Report` `runtimeFilter` and the joined block's `runtimeFilter`, and at the same positions inside the installed-package manifests. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `{ stage: { $eq: { a: 1 } } }` | the one value you meant: `{ stage: { $eq: "won" } }` | +| `{ stage: { $in: [{ a: 1 }] } }` | a list of values: `{ stage: { $in: ["won", "lost"] } }` | +| `{ amount: { $gt: { $field: 5 } } }` | a column name: `{ amount: { $gt: { $field: "budget" } } }` | +| `{ owner: undefined }`, `{ owner: { $eq: undefined } }` | `{ owner: { $eq: null } }` ("has no value"), `{ owner: { $ne: null } }` ("has a value"), or omit the key | +| `{ tags: new Map(…) }`, a class instance | the value itself, as a string, number, boolean, `null` or `Date` | +| `{ qty: { $gt: 2n ** 60n } }` | a bound within ±2^53, or the value compared as a string | +| a widget `filter: { acct: { stage: { $in: ["won", null] } } }` | `{ $or: [{ acct: { stage: { $in: ["won"] } } }, { acct: { stage: { $null: true } } }] }` | +| a widget `filter: { acct: { region: ["a", "b"] } }` | `{ acct: { region: { $in: ["a", "b"] } } }` | +| a report or joined-block `runtimeFilter` with either nested shape above | the same rewrite, at `runtimeFilter` / `blocks..runtimeFilter` | + +### FROM → TO at the HTTP doors + +Same status, different code: the refusal now comes from the route's schema door, located on the member, instead of from the analytics filter normalizer. + +| request | before | after | +|:--|:--|:--| +| `POST /analytics/dataset/query` with `selection.runtimeFilter: { stage: { $eq: { a: 1 } } }` | `400 INVALID_FILTER` from the analytics normalizer, in the comparand-type face's sentence | `400 VALIDATION_FAILED`, `details.fields[]` entry `selection.runtimeFilter.stage.$eq` with the sentence `Filter comparand is a plain object ({"a":1}), which no driver can compare. …` | +| the same route with `selection.runtimeFilter: { stage: { $in: ["won", { a: 1 }] } }` | `400 INVALID_FILTER` | `400 VALIDATION_FAILED` at `selection.runtimeFilter.stage.$in.1` | +| `POST /analytics/query` (`AnalyticsQueryRequestSchema`) with either shape in `where` | `400 INVALID_FILTER` | refused by the request schema at `where.stage.$eq` / `where.stage.$in.1`, answered `400 VALIDATION_FAILED` | + +A client that branches on `INVALID_FILTER` for these shapes reads `VALIDATION_FAILED` instead. Both are 400 and both name the field. The other refused values cannot arrive over HTTP: JSON has no `Map`, `undefined` or bigint. + +## Who is affected, measured + +A literal scan of every shape, with a lit control per shape, over `examples/**` and the non-test `packages/**` of this repository, the console repository at its pinned commit `f8a9d0fb05` and the cloud repository's `main` at `96eb092fbf`, found no authored filter carrying a plain object where a value belongs, a `Map` or class instance, `undefined` or a bigint beyond 2^53 — every hit was prose, a driver's operator switch, or a conformance table — and no `filter` / `runtimeFilter` / `where` / `relatedListFilter` whose first entry is a nested relation holding a list or an operator map. A runtime walk of every filter in the example stacks (`app-crm`, `app-todo`, `app-multi-package`, and `app-showcase`'s metadata modules) compared the old and new doors on each and found none refused by the new one alone. Deployed datasets, dashboards and reports were NOT measured. Validating each stack, or re-saving each document, finds every instance the surface above lists. + +Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. Comparand values that every query face already refused are now refused on save as well, and a dashboard widget filter and both report runtimeFilters judge the nested-relation slots the analytics door already refused on chart. + + diff --git a/packages/rest/src/analytics-filter-refusal-envelope.test.ts b/packages/rest/src/analytics-filter-refusal-envelope.test.ts index d2a139b4d6e..83687c0501c 100644 --- a/packages/rest/src/analytics-filter-refusal-envelope.test.ts +++ b/packages/rest/src/analytics-filter-refusal-envelope.test.ts @@ -220,7 +220,10 @@ describe('[#5352] POST /analytics/dataset/query — a filter refusal reaches the * earlier — at the route's schema door — and the block above no longer claims * them. [#20116] A fourth joined them: the one-bound `$between`, once * `FilterConditionSchema` began refusing on save every comparand slot the - * shared comparand-shape face refuses on query. + * shared comparand-shape face refuses on query. [#20116, stage 2] Then the + * comparand-TYPE face's JSON-representable cells — a plain object where one + * value belongs, as the comparand or as a list member — once that schema asked + * the type face too. * * ⚠️ This is a CODE change on a live wire surface, so it is recorded with the * measurement that justifies it rather than as a test edit. Since #17551 the @@ -236,6 +239,8 @@ describe('[#5352] POST /analytics/dataset/query — a filter refusal reaches the * | `{ $not: 5 }` | refused at the schema | refused at the schema | * | `{ stage: {} }` | passes the schema | passes the schema | * | `{ amount: { $between: [10] } }` | refused at the schema (#20116) | refused at the schema (#20116) | + * | `{ stage: { $eq: { a: 1 } } }` | refused at the schema (#20116 stage 2) | refused at the schema (#20116 stage 2) | + * | `{ stage: { $in: ['won', { a: 1 }] } }` | refused at the schema (#20116 stage 2) | refused at the schema (#20116 stage 2) | * | `{ $nor: [{…}] }` | passes the schema | passes the schema | * | `{ $or: [] }` | passes the schema | passes the schema | * @@ -270,6 +275,23 @@ describe('[#17551] the structurally-malformed filter spellings are refused at th member: 'selection.runtimeFilter.amount.$between', sentence: /^Operator "\$between" on field "amount" requires a \[min, max\] value array\. Received array \(\[10\]\)\. A range needs exactly two bounds/, }, + { + // [#20116, stage 2] Before, this crossed the schema and the normalizer + // refused it `INVALID_FILTER` / 400 in the comparand-TYPE face's words + // (`at where.stage.$eq`). The schema door now asks that face on save and + // prints the same sentence less its location, located on the member. + name: 'a plain object where a single value belongs', + runtimeFilter: { stage: { $eq: { a: 1 } } }, + member: 'selection.runtimeFilter.stage.$eq', + sentence: /^Filter comparand is a plain object \(\{"a":1\}\), which no driver can compare\. A comparison value must be a string, number, bigint, boolean, null or Date\./, + }, + { + // [#20116, stage 2] The same face, on a list member: located on the member. + name: 'a plain object as an $in member', + runtimeFilter: { stage: { $in: ['won', { a: 1 }] } }, + member: 'selection.runtimeFilter.stage.$in.1', + sentence: /^Filter comparand is a plain object \(\{"a":1\}\), which no driver can compare\./, + }, ]; for (const c of AT_THE_DOOR) { diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 849265b1c19..0638d08198b 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 212, - "droppedRefinementSites": 605, + "droppedRefinementSites": 622, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -55,6 +55,7 @@ "manifest.connectors.element.out", "manifest.dashboards.element.globalFilters.element", "manifest.dashboards.element.widgets.element", + "manifest.dashboards.element.widgets.element.filter", "manifest.datasets.element", "manifest.datasets.element.filter", "manifest.datasets.element.include.element", @@ -79,6 +80,8 @@ "manifest.permissions.element.rowLevelSecurity.element", "manifest.reports.element", "manifest.reports.element.blocks.element", + "manifest.reports.element.blocks.element.runtimeFilter", + "manifest.reports.element.runtimeFilter", "manifest.reports.element.runtimeFilter.lazy", "manifest.sharingRules.element.sharedWith", "manifest.skills.element.triggerConditions.element", @@ -156,6 +159,7 @@ "data.options[1].manifest.connectors.element.out", "data.options[1].manifest.dashboards.element.globalFilters.element", "data.options[1].manifest.dashboards.element.widgets.element", + "data.options[1].manifest.dashboards.element.widgets.element.filter", "data.options[1].manifest.datasets.element", "data.options[1].manifest.datasets.element.filter", "data.options[1].manifest.datasets.element.include.element", @@ -179,6 +183,8 @@ "data.options[1].manifest.permissions.element.rowLevelSecurity.element", "data.options[1].manifest.reports.element", "data.options[1].manifest.reports.element.blocks.element", + "data.options[1].manifest.reports.element.blocks.element.runtimeFilter", + "data.options[1].manifest.reports.element.runtimeFilter", "data.options[1].manifest.reports.element.runtimeFilter.lazy", "data.options[1].manifest.sharingRules.element.sharedWith", "data.options[1].manifest.skills.element.triggerConditions.element", @@ -241,6 +247,7 @@ "options[1].manifest.connectors.element.out", "options[1].manifest.dashboards.element.globalFilters.element", "options[1].manifest.dashboards.element.widgets.element", + "options[1].manifest.dashboards.element.widgets.element.filter", "options[1].manifest.datasets.element", "options[1].manifest.datasets.element.filter", "options[1].manifest.datasets.element.include.element", @@ -264,6 +271,8 @@ "options[1].manifest.permissions.element.rowLevelSecurity.element", "options[1].manifest.reports.element", "options[1].manifest.reports.element.blocks.element", + "options[1].manifest.reports.element.blocks.element.runtimeFilter", + "options[1].manifest.reports.element.runtimeFilter", "options[1].manifest.reports.element.runtimeFilter.lazy", "options[1].manifest.sharingRules.element.sharedWith", "options[1].manifest.skills.element.triggerConditions.element", @@ -287,6 +296,7 @@ "data.packages.element.options[1].manifest.connectors.element.out", "data.packages.element.options[1].manifest.dashboards.element.globalFilters.element", "data.packages.element.options[1].manifest.dashboards.element.widgets.element", + "data.packages.element.options[1].manifest.dashboards.element.widgets.element.filter", "data.packages.element.options[1].manifest.datasets.element", "data.packages.element.options[1].manifest.datasets.element.filter", "data.packages.element.options[1].manifest.datasets.element.include.element", @@ -309,6 +319,8 @@ "data.packages.element.options[1].manifest.permissions.element.rowLevelSecurity.element", "data.packages.element.options[1].manifest.reports.element", "data.packages.element.options[1].manifest.reports.element.blocks.element", + "data.packages.element.options[1].manifest.reports.element.blocks.element.runtimeFilter", + "data.packages.element.options[1].manifest.reports.element.runtimeFilter", "data.packages.element.options[1].manifest.reports.element.runtimeFilter.lazy", "data.packages.element.options[1].manifest.sharingRules.element.sharedWith", "data.packages.element.options[1].manifest.skills.element.triggerConditions.element", @@ -1167,12 +1179,14 @@ "sites": [ "globalFilters.element", "widgets.element", + "widgets.element.filter", "widgets.element.filter.lazy" ] }, "ui/DashboardWidget": { "sites": [ "", + "filter", "filter.lazy" ] }, @@ -1276,6 +1290,7 @@ "ui/JoinedReportBlock": { "sites": [ "", + "runtimeFilter", "runtimeFilter.lazy" ] }, @@ -1396,6 +1411,8 @@ "sites": [ "", "blocks.element", + "blocks.element.runtimeFilter", + "runtimeFilter", "runtimeFilter.lazy" ] }, diff --git a/packages/spec/src/data/filter-save-door-face-parity.test.ts b/packages/spec/src/data/filter-save-door-face-parity.test.ts index a9d0a8af5f4..fd96f89a495 100644 --- a/packages/spec/src/data/filter-save-door-face-parity.test.ts +++ b/packages/spec/src/data/filter-save-door-face-parity.test.ts @@ -4,10 +4,23 @@ * [#20116] The SAVE door (`FilterConditionSchema`) refuses exactly the comparand * slots the QUERY faces refuse — at the top level and in every `$and` / `$or` / * `$not` member, and not inside a nested-relation condition, which the face - * never descends. The two dataset carriers, charted through the analytics + * never descends. The analytics carriers (a dataset `filter`, a measure + * `filter` and, since stage 2, a dashboard widget `filter` and a report's and a + * joined report block's `runtimeFilter`), charted through the analytics * `where` door that DOES descend a relation, refuse the same slots inside one * (§5), asking the same function. * + * Stage 2 adds the comparand-TYPE face (`normalizeFilterComparandTypes`) to + * "the query faces": measured on `origin/main` `17bd3187`, every save door + * accepted `{ stage: { $eq: { a: 1 } } }`, `{ stage: { $in: [{ a: 1 }] } }` and a + * `Map` comparand, top level and nested, while that face and the analytics + * door refused each with `INVALID_FILTER` / 400; and a dashboard widget + * `filter`, a report `runtimeFilter` and a joined block `runtimeFilter` + * accepted `{ acct: { stage: { $in: ['won', null] } } }` and #20080's nested + * equality lists, which the analytics door refuses on chart. §1 and §5 + * ask all three rules; §6 pins the type face's words and table; §7 the + * carriers. + * * Measured on `origin/main` `af32cf9a` before the change: `DatasetSchema` * (filter and measure filter), a dashboard widget `filter` and a report * `runtimeFilter` each answered `success: true` for `{ stage: { $null: 'x' } }`, @@ -35,11 +48,16 @@ import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + import { StandardErrorCode } from '../api/errors.zod'; +import { analyticsCarrierFilter } from '../ui/analytics-carrier-filter'; import { DashboardSchema } from '../ui/dashboard.zod'; import { DatasetMeasureSchema, DatasetSchema } from '../ui/dataset.zod'; import { ReportSchema } from '../ui/report.zod'; import { assertListComparandShapes } from './filter-comparand-shape'; +import { ComparandTypeProbe, FILTER_COMPARAND_TYPE_CASES } from './filter-comparand-type-conformance'; +import { normalizeFilterComparandTypes } from './filter-comparand-type'; import { isRefusedTextComparand } from './filter-text-comparand'; import { FieldOperatorsSchema, FilterConditionSchema } from './filter.zod'; @@ -56,6 +74,34 @@ function faceRefusal(where: unknown): (Error & { code?: string; status?: number return undefined; } +/** The comparand-TYPE face's refusal of `where`, or `undefined` when it accepts (its narrowed copy discarded). */ +function typeFaceRefusal(where: unknown): (Error & { code?: string; status?: number }) | undefined { + try { + normalizeFilterComparandTypes(where); + } catch (error) { + return error as Error & { code?: string; status?: number }; + } + return undefined; +} + +/** A test-name rendering that survives a bigint, a `Map`, `undefined` and a function. */ +function show(value: unknown): string { + return JSON.stringify(value, (_key, v: unknown) => { + if (typeof v === 'bigint') return `${v}n`; + if (v === undefined) return 'undefined'; + if (v instanceof Map) return 'Map'; + if (typeof v === 'function' || typeof v === 'symbol') return String(v); + return v; + }); +} + +/** Filter STRUCTURE as the type face classifies it: a PLAIN object, prototype `Object.prototype` or `null`. */ +function isPlainObject(value: unknown): boolean { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + /** Issues whose path starts with `prefix` (dot-joined). */ function issuesUnder(result: Parsed, prefix: string): Issue[] { if (result.success) return []; @@ -119,11 +165,31 @@ const BATTERY: ReadonlyArray = [ ['a pair with a { $field } MIN', [{ $field: 'a' }, 5]], ['a list of one { $field }', [{ $field: 'a' }]], ['a nested list', [[1]]], + // Stage 2 — the comparand-TYPE face's arms, and the neighbours it passes. + ['a Map', new Map([['a', 1]])], + ['a class instance', new ComparandTypeProbe()], + ['a function', () => 1], + ['a Symbol', Symbol('x')], + ['a { $field } with a non-string name', { $field: 5 }], + ['an empty object', {}], + ['a bigint within 2^53', 5n], + ['a bigint beyond 2^53', 2n ** 60n], + ['a list holding a plain object', ['won', { a: 1 }]], + ['a list holding a Map', [new Map()]], + ['a list holding undefined', [1, undefined]], + ['a list holding a bigint beyond 2^53', [2n ** 60n]], + ['a pair with a plain-object MAX', [1, { a: 1 }]], + ['a pair of bigints within 2^53', [1n, 9n]], ]; -/** What the query faces answer for one operator slot: the shape face, and the flag rule. */ +/** + * What the query faces answer for one operator slot: the shape face, the type + * face, and the flag rule — the three `parseFilterAST`, the engine seam and + * the analytics door run, in that order. + */ function queryFacesRefuse(op: string, comparand: unknown): boolean { if (faceRefusal({ f: { [op]: comparand } })) return true; + if (typeFaceRefusal({ f: { [op]: comparand } })) return true; return BOOLEAN_SLOTS.includes(op) && typeof comparand !== 'boolean'; } @@ -141,7 +207,7 @@ const POSITIONS: ReadonlyArray { - it('the table is derived, not hand-listed, and covers every arm the face and the flag rule judge', () => { + it('the table is derived, not hand-listed, and covers every arm the faces and the flag rule judge', () => { // The vocabulary is the enforced copy's, so a new operator joins the table. expect(OPERATORS).toEqual(expect.arrayContaining(['$eq', '$ne', '$gt', '$in', '$nin', '$between', '$null', '$exists'])); expect(BOOLEAN_SLOTS.sort()).toEqual(['$exists', '$null']); @@ -149,6 +215,12 @@ describe('#20116 §1 — the enumeration: the save door refuses exactly what the // the guard against a battery that silently stopped reaching an arm. const faceJudged = OPERATORS.filter((op) => BATTERY.some(([, c]) => faceRefusal({ f: { [op]: c } }))); expect(faceJudged.sort()).toEqual(['$between', '$eq', '$gt', '$gte', '$in', '$lt', '$lte', '$ne', '$nin']); + // [stage 2] The TYPE face judges every declared operator — its own test + // reconciles its scalar/list split against this same vocabulary — so every + // one of them must refuse some battery shape here, as a scalar comparand or + // as a list member. + const typeJudged = OPERATORS.filter((op) => BATTERY.some(([, c]) => typeFaceRefusal({ f: { [op]: c } }))); + expect(typeJudged.sort()).toEqual([...OPERATORS].sort()); }); for (const [position, wrap, prefix] of POSITIONS) { @@ -172,14 +244,20 @@ describe('#20116 §1 — the enumeration: the save door refuses exactly what the } it('the implicit-equality slot, every battery shape that is a comparand there', () => { + let refused = 0; for (const [label, comparand] of BATTERY) { - if (comparand !== null && typeof comparand === 'object' && !Array.isArray(comparand) && !(comparand instanceof Date)) { - continue; // a plain object in this slot is a nested condition, not a comparand - } - const expected = faceRefusal({ f: comparand }) !== undefined; + // A PLAIN object in this slot is a nested condition or an operator map, + // not a comparand — the type face's own classification. A `Map` or a + // class instance IS a comparand here, and the type face judges it. + if (isPlainObject(comparand)) continue; + const expected = faceRefusal({ f: comparand }) !== undefined || typeFaceRefusal({ f: comparand }) !== undefined; const got = issuesUnder(FilterConditionSchema.safeParse({ f: comparand }), 'f'); + if (expected) refused += 1; expect(got.length > 0, label).toBe(expected); } + // The implicit slot's refusals: every list, `undefined`, a Map, a class + // instance, a function, a Symbol and the bigint beyond 2^53. + expect(refused).toBeGreaterThanOrEqual(15); }); it('inside a nested-relation condition the door answers as the face does — it never descends one', () => { @@ -302,7 +380,7 @@ describe('#20116 §2 — each refusal: issue code, path and the prescription', ( for (const [, comparand] of BATTERY) { const result = FilterConditionSchema.safeParse({ f: { [op]: comparand } }); for (const issue of issuesUnder(result, `f.${op}`)) { - expect(issue.message, `${op} ← ${JSON.stringify(comparand)}`).not.toContain(' at where.'); + expect(issue.message, `${op} ← ${show(comparand)}`).not.toContain(' at where.'); } } } @@ -347,6 +425,10 @@ describe('#20116 §3 — the stored carriers', () => { name: 'pipeline', label: 'Pipeline', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'], runtimeFilter, }); + const joinedReport = (runtimeFilter: unknown) => ({ + name: 'pipeline_joined', label: 'Pipeline', type: 'joined', + blocks: [{ name: 'won', label: 'Won', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'], runtimeFilter }], + }); /** The collector's members, each with the slot path it is refused at. */ const MEMBERS: ReadonlyArray, slot: string]> = [ @@ -358,15 +440,24 @@ describe('#20116 §3 — the stored carriers', () => { [{ stage: { $in: ['won', null] } }, 'stage.$in.1'], [{ amount: { $between: [null, 5] } }, 'amount.$between.0'], [{ stage: { $ne: ['won', 'lost'] } }, 'stage.$ne'], + // [stage 2] M-type: the comparand-TYPE face's cells. + [{ stage: { $eq: { a: 1 } } }, 'stage.$eq'], + [{ stage: { $in: [{ a: 1 }] } }, 'stage.$in.0'], + [{ stage: { $eq: new Map([['a', 1]]) } }, 'stage.$eq'], + [{ stage: new Map([['a', 1]]) }, 'stage'], + [{ stage: { $nin: ['lost', new Map()] } }, 'stage.$nin.1'], + [{ amount: { $gt: 2n ** 60n } }, 'amount.$gt'], + [{ stage: undefined }, 'stage'], ]; - it.each(MEMBERS)('%j — refused by the dataset filter, a measure filter, a widget filter and a report runtimeFilter', (where, slot) => { + it.each(MEMBERS.map(([where, slot]) => [show(where), where, slot] as const))('%s — refused by the dataset filter, a measure filter, a widget filter and both report runtimeFilters', (_label, where, slot) => { const expected = issueAt(FilterConditionSchema.safeParse(where), slot).message; expect(issueAt(DatasetSchema.safeParse(dataset({ filter: where })), `filter.${slot}`).message).toBe(expected); const measure = dataset({ measures: [{ name: 'deal_count', aggregate: 'count', filter: where }] }); expect(issueAt(DatasetSchema.safeParse(measure), `measures.0.filter.${slot}`).message).toBe(expected); expect(issueAt(DashboardSchema.safeParse(dashboard(where)), `widgets.0.filter.${slot}`).message).toBe(expected); expect(issueAt(ReportSchema.safeParse(report(where)), `runtimeFilter.${slot}`).message).toBe(expected); + expect(issueAt(ReportSchema.safeParse(joinedReport(where)), `blocks.0.runtimeFilter.${slot}`).message).toBe(expected); }); it('CONTROL — the same carriers publish the boolean flags and keep them', () => { @@ -411,10 +502,28 @@ describe('#20116 §4 — what stays accepted, at both doors', () => { ['every one of the above under $and / $or / $not', { $and: [{ stage: { $ne: null } }], $or: [{ amount: { $gt: { $field: 'b' } } }], $not: { stage: { $in: [] } }, }], + // [stage 2] What the TYPE face passes: its six accepted types, a + // `{ $field }` reference, and the `{placeholder}` strings the engine + // resolves only at request time (after both faces have run). + ['$eq: a { $field } reference — not a literal', { amount: { $eq: { $field: 'budget' } } }], + ['$gte: a {placeholder} date macro, resolved at request time', { closed_at: { $gte: '{today}' } }], + ['an implicit {current_user_id}, resolved at request time', { owner_id: '{current_user_id}' }], + ['$in: {placeholder} members', { owner_id: { $in: ['{current_user_id}', 'usr_1'] } }], + ['$eq: a bigint within 2^53 — KEPT as written, never narrowed on save', { qty: { $eq: 100n } }], + ['$in: bigints within 2^53', { qty: { $in: [100n, 2n ** 53n] } }], + ['$between: Dates', { closed_at: { $between: [DAY, DAY] } }], + ['an implicit Date', { closed_at: DAY }], + ['an implicit boolean, number and null', { active: true, qty: 0, note: null }], + ['a spec the type face classifies as a { $field } reference is stepped around whole, as that face does', { + amount: { $field: 'budget', $gt: new Map() }, + }], + ['a Map INSIDE a nested relation — neither face descends one', { acct: { stage: new Map([['a', 1]]) } }], + ['a plain object under $eq INSIDE a nested relation — neither face descends one', { acct: { stage: { $eq: { a: 1 } } } }], ])('%s', (_label, where) => { expect(faceRefusal(where)).toBeUndefined(); + expect(typeFaceRefusal(where)).toBeUndefined(); const result = FilterConditionSchema.safeParse(where); - expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + expect(result.success, show(result.error?.issues)).toBe(true); // Accepted means KEPT: the door returns the document it was given. expect(result.data).toEqual(where); }); @@ -424,12 +533,14 @@ describe('#20116 §4 — what stays accepted, at both doors', () => { // §5 Inside a nested relation, the ANALYTICS carriers answer as the analytics door // --------------------------------------------------------------------------- -describe('#20116 §5 — inside a nested relation, the dataset carriers refuse what the analytics door refuses', () => { +describe('#20116 §5 — inside a nested relation, the analytics carriers refuse what the analytics door refuses', () => { // The analytics `where` door flattens a nested relation to dotted members and // hands each entry to the same query faces it hands a top-level entry. The - // dataset carriers' own walk (`refuseNestedRelationComparands`, #20207's) - // reaches those entries and asks the same function the shared walk asks, so - // the table is §1's, one relation down, on both carriers. + // analytics carriers' own walk (`refuseNestedRelationComparands`, #20207's, + // `ui/analytics-carrier-filter.ts` since stage 2) reaches those entries and + // asks the same function the shared walk asks, so the table is §1's, one + // relation down, on every carrier — the two dataset carriers, the dashboard + // widget's `filter`, and a report's and a joined block's `runtimeFilter`. const dataset = (filter: unknown) => ({ name: 'deals_ds', label: 'Deals', @@ -438,25 +549,69 @@ describe('#20116 §5 — inside a nested relation, the dataset carriers refuse w measures: [{ name: 'deal_count', aggregate: 'count' }], filter, }); + const dashboard = (filter: unknown) => ({ + name: 'sales', + label: 'Sales', + widgets: [{ id: 'won_deals', type: 'metric', dataset: 'deals', values: ['total'], filter }], + }); - it.each([ + /** + * Every analytics carrier — every stored filter the analytics door charts: + * its parse, and where its filter sits in the document. The two report rows + * were EXPECTED-OPEN until the report half of the collector folded in (the + * carrier on `ReportSchema.runtimeFilter` and `JoinedReportBlockSchema.runtimeFilter`). + */ + const CARRIERS: ReadonlyArray Parsed, at: string]> = [ + ['dataset filter', (filter) => DatasetSchema.safeParse(dataset(filter)), 'filter'], + ['measure filter', (filter) => DatasetMeasureSchema.safeParse({ name: 'deal_count', aggregate: 'count', filter }), 'filter'], + ['dashboard widget filter', (filter) => DashboardSchema.safeParse(dashboard(filter)), 'widgets.0.filter'], + ['report runtimeFilter', (runtimeFilter) => ReportSchema.safeParse({ + name: 'pipeline', label: 'Pipeline', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'], runtimeFilter, + }), 'runtimeFilter'], + ['joined report block runtimeFilter', (runtimeFilter) => ReportSchema.safeParse({ + name: 'pipeline_joined', label: 'Pipeline', type: 'joined', + blocks: [{ name: 'won', label: 'Won', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'], runtimeFilter }], + }), 'blocks.0.runtimeFilter'], + ]; + + /** The collector's nested members, each with the slot it is refused at inside the relation. */ + const NESTED_MEMBERS: ReadonlyArray }, slot: string]> = [ + [{ acct: { stage: { $null: 'x' } } }, 'acct.stage.$null'], + [{ acct: { stage: { $exists: 'false' } } }, 'acct.stage.$exists'], + [{ acct: { stage: { $null: null } } }, 'acct.stage.$null'], + [{ acct: { amount: { $gt: null } } }, 'acct.amount.$gt'], + [{ acct: { stage: { $in: 'won' } } }, 'acct.stage.$in'], + [{ acct: { stage: { $in: ['won', null] } } }, 'acct.stage.$in.1'], + [{ acct: { amount: { $between: [null, 5] } } }, 'acct.amount.$between.0'], + [{ acct: { stage: { $ne: ['won', 'lost'] } } }, 'acct.stage.$ne'], + // [stage 2] #20080's equality lists, M-widget's other named shape. + [{ acct: { region: ['a'] } }, 'acct.region'], + [{ acct: { region: { $eq: ['a'] } } }, 'acct.region.$eq'], + // [stage 2] M-type inside a relation. + [{ acct: { stage: { $eq: { a: 1 } } } }, 'acct.stage.$eq'], + [{ acct: { stage: { $in: [{ a: 1 }] } } }, 'acct.stage.$in.0'], + [{ acct: { stage: { $eq: new Map([['a', 1]]) } } }, 'acct.stage.$eq'], + [{ acct: { stage: new Map([['a', 1]]) } }, 'acct.stage'], + ]; + + const HOPS = [ ['one hop', (e: Record) => ({ acct: e }), 'acct.f'], ['two hops, under $or', (e: Record) => ({ $or: [{ acct: { owner: e } }] }), '$or.0.acct.owner.f'], - ] as const)('every operator × comparand cell, %s', (_label, wrap, prefix) => { + ] as const; + + it.each(HOPS)('every operator × comparand cell, %s, on every carrier', (_label, wrap, prefix) => { const mismatches: string[] = []; let refused = 0; for (const op of OPERATORS) { for (const [label, comparand] of BATTERY) { const expected = queryFacesRefuse(op, comparand) || textArmRefuses(op, comparand); const filter = wrap({ f: { [op]: comparand } }); - const scoped = issuesUnder(DatasetSchema.safeParse(dataset(filter)), `filter.${prefix}.${op}`); - const measure = issuesUnder( - DatasetMeasureSchema.safeParse({ name: 'deal_count', aggregate: 'count', filter }), - `filter.${prefix}.${op}`, - ); if (expected) refused += 1; - if ((scoped.length > 0) !== expected || (measure.length > 0) !== expected) { - mismatches.push(`${op} ← ${label}: faces ${expected ? 'REFUSE' : 'ACCEPT'}, dataset ${scoped.length > 0 ? 'REFUSE' : 'ACCEPT'}, measure ${measure.length > 0 ? 'REFUSE' : 'ACCEPT'}`); + for (const [carrier, parse, at] of CARRIERS) { + const got = issuesUnder(parse(filter), `${at}.${prefix}.${op}`); + if ((got.length > 0) !== expected) { + mismatches.push(`${carrier}: ${op} ← ${label}: faces ${expected ? 'REFUSE' : 'ACCEPT'}, door ${got.length > 0 ? 'REFUSE' : 'ACCEPT'}`); + } } } } @@ -464,39 +619,189 @@ describe('#20116 §5 — inside a nested relation, the dataset carriers refuse w expect(refused).toBeGreaterThan(40); }); - it.each([ - [{ acct: { stage: { $null: 'x' } } }, 'acct.stage.$null'], - [{ acct: { stage: { $exists: 'false' } } }, 'acct.stage.$exists'], - [{ acct: { stage: { $null: null } } }, 'acct.stage.$null'], - [{ acct: { amount: { $gt: null } } }, 'acct.amount.$gt'], - [{ acct: { stage: { $in: 'won' } } }, 'acct.stage.$in'], - [{ acct: { stage: { $in: ['won', null] } } }, 'acct.stage.$in.1'], - [{ acct: { amount: { $between: [null, 5] } } }, 'acct.amount.$between.0'], - [{ acct: { stage: { $ne: ['won', 'lost'] } } }, 'acct.stage.$ne'], - ] as const)('the collector\'s nested member %j — one issue per carrier, in the top-level sentence', (filter, slot) => { - // The same words as the top-level form: the field named is the leaf, as - // the analytics door names it, and the issue path carries the relation. - const leaf = slot.split('.').slice(1).join('.'); - const topLevel = { [leaf.split('.')[0]!]: (filter.acct as Record)[leaf.split('.')[0]!] }; - const expected = issueAt(FilterConditionSchema.safeParse(topLevel), leaf).message; - const scoped = issueAt(DatasetSchema.safeParse(dataset(filter)), `filter.${slot}`); - expect(scoped.code).toBe('custom'); - expect(scoped.message).toBe(expected); - const measure = issueAt(DatasetMeasureSchema.safeParse({ name: 'deal_count', aggregate: 'count', filter }), `filter.${slot}`); - expect(measure.message).toBe(expected); - }); - - it('CONTROL — the null predicate, references, empty lists and flags pass inside a relation, and are kept', () => { + it.each(HOPS)('the implicit-equality slot, %s, on every carrier — a Map or a class instance is the comparand it is', (_label, wrap, prefix) => { + let refused = 0; + for (const [label, comparand] of BATTERY) { + if (isPlainObject(comparand)) continue; // a nested condition or an operator map, not a comparand + const expected = faceRefusal({ f: comparand }) !== undefined || typeFaceRefusal({ f: comparand }) !== undefined; + if (expected) refused += 1; + for (const [carrier, parse, at] of CARRIERS) { + const got = issuesUnder(parse(wrap({ f: comparand } as Record)), `${at}.${prefix}`); + expect(got.length > 0, `${carrier}: ${label}`).toBe(expected); + } + } + expect(refused).toBeGreaterThanOrEqual(15); + }); + + it('every carrier the analytics door charts is on the list — no stored presentation filter keeps the shared reach alone', () => { + // The five stored filters `dataset-executor.ts` hands to the analytics + // `where` door: the dataset's own and its measures', and the presentation + // scopes it ANDs in as `runtimeFilter` (a widget's `filter`, a report's and + // a joined block's `runtimeFilter`). + expect(CARRIERS.map(([carrier]) => carrier)).toEqual([ + 'dataset filter', 'measure filter', 'dashboard widget filter', 'report runtimeFilter', 'joined report block runtimeFilter', + ]); + }); + + it.each(NESTED_MEMBERS.map(([filter, slot]) => [show(filter), filter, slot] as const))( + 'the collector\'s nested member %s — one issue per carrier, in the top-level sentence', + (_label, filter, slot) => { + // The same words as the top-level form: the field named is the leaf, as + // the analytics door names it, and the issue path carries the relation. + const leaf = slot.split('.').slice(1).join('.'); + const topLevel = { [leaf.split('.')[0]!]: (filter.acct as Record)[leaf.split('.')[0]!] }; + const expected = issueAt(FilterConditionSchema.safeParse(topLevel), leaf).message; + // Every carrier refuses it on save, once, at its own path. + for (const [carrier, parse, at] of CARRIERS) { + const issue = issueAt(parse(filter), `${at}.${slot}`); + expect(issue.code, carrier).toBe('custom'); + expect(issue.message, carrier).toBe(expected); + } + }, + ); + + it('CONTROL — the null predicate, references, empty lists, flags, Dates and placeholders pass inside a relation on every carrier, and are kept', () => { const filter = { acct: { stage: { $ne: null, $in: [] }, - owner: { region: { $eq: null } }, + owner: { region: { $eq: null }, id: '{current_user_id}' }, amount: { $gt: { $field: 'floor' }, $between: [1, 9] }, active: { $null: false, $exists: true }, + closed_at: { $gte: DAY, $lt: '{today}' }, + qty: { $in: [1n, 2] }, }, }; - const parsed = DatasetSchema.safeParse(dataset(filter)); - expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); - expect(parsed.data!.filter).toEqual(filter); + for (const [carrier, parse, at] of CARRIERS) { + const parsed = parse(filter); + expect(parsed.success, `${carrier}: ${show(parsed.error?.issues)}`).toBe(true); + const kept = at.split('.').reduce((node, key) => (node as Record)[key], (parsed as { data?: unknown }).data); + expect(kept, carrier).toEqual(filter); + } + }); +}); + +// --------------------------------------------------------------------------- +// §6 The comparand-TYPE face: its words, its own table, one issue per slot +// --------------------------------------------------------------------------- + +describe('#20116 §6 — the comparand-TYPE face at the save door', () => { + /** The type face's message for `where`, with its ` at ` location clause removed. */ + function typeFaceSentenceWithoutLocation(where: unknown, facePath: string): string { + const face = typeFaceRefusal(where); + expect(face, `the type face accepted ${show(where)}`).toBeDefined(); + expect(face!.code).toBe(StandardErrorCode.enum.INVALID_FILTER); + expect(face!.status).toBe(400); + const location = ` at ${facePath} `; + // The clause is really there, exactly once, so removing it is not vacuous. + expect(face!.message.split(location)).toHaveLength(2); + return face!.message.replace(location, ' '); + } + + it.each([ + ['a plain object under $eq (M-type)', { stage: { $eq: { a: 1 } } }, 'stage.$eq', 'where.stage.$eq', 'a plain object ({"a":1})'], + ['a plain object under $ne', { stage: { $ne: { a: 1 } } }, 'stage.$ne', 'where.stage.$ne', 'a plain object'], + ['a Map under $gt', { amount: { $gt: new Map() } }, 'amount.$gt', 'where.amount.$gt', 'a Map instance'], + ['undefined under $contains', { name: { $contains: undefined } }, 'name.$contains', 'where.name.$contains', 'is undefined'], + ['a plain-object $in member (M-type)', { stage: { $in: ['won', { a: 1 }] } }, 'stage.$in.1', 'where.stage.$in[1]', 'a plain object'], + ['a Map $nin member', { stage: { $nin: [new Map()] } }, 'stage.$nin.0', 'where.stage.$nin[0]', 'a Map instance'], + ['a plain-object $between MAX', { amount: { $between: [1, { a: 1 }] } }, 'amount.$between.1', 'where.amount.$between[1]', 'a plain object'], + ['an implicit Map (M-type)', { stage: new Map([['a', 1]]) }, 'stage', 'where.stage', 'a Map instance'], + ['an implicit class instance', { qty: new ComparandTypeProbe() }, 'qty', 'where.qty', 'a ComparandTypeProbe instance'], + ['an implicit undefined', { owner: undefined }, 'owner', 'where.owner', 'is undefined'], + ['a function', { qty: { $eq: () => 1 } }, 'qty.$eq', 'where.qty.$eq', 'a function'], + ['a Symbol', { qty: { $eq: Symbol('x') } }, 'qty.$eq', 'where.qty.$eq', 'a Symbol'], + ['a bigint beyond 2^53', { qty: { $gt: 2n ** 60n } }, 'qty.$gt', 'where.qty.$gt', 'exceeds 2^53'], + ['a { $field } whose name is not a string', { amount: { $gt: { $field: 5 } } }, 'amount.$gt', 'where.amount.$gt', 'a plain object'], + ] as const)('%s — the type face\'s sentence, less its location, at the slot or member', (_label, where, issuePath, facePath, names) => { + const issue = issueAt(FilterConditionSchema.safeParse(where), issuePath); + expect(issue.code).toBe('custom'); + expect(issue.message).toBe(typeFaceSentenceWithoutLocation(where, facePath)); + expect(issue.message).toMatch(/^Filter comparand (is|is the) /); + expect(issue.message).toContain(names); + // The prescription is the accepted set, named. + expect(issue.message).toContain('A comparison value must be a string, number, bigint, boolean, null or Date.'); + expect(issue.message).not.toContain(' at where'); + }); + + it.each([ + ['$null: a plain object — the type face\'s refusal, not the flag rule\'s', { stage: { $null: { a: 1 } } }, 'stage.$null', /^Filter comparand is a plain object/], + ['$exists: undefined — the type face\'s refusal', { stage: { $exists: undefined } }, 'stage.$exists', /^Filter comparand is undefined\./], + ['$null: a bigint within 2^53 — the type face passes it, the flag rule refuses it', { stage: { $null: 5n } }, 'stage.$null', /^Operator "\$null" on field "stage" requires a boolean comparand/], + ['$icontains: a Map — the type face\'s refusal, and the #19514 text arm stays silent', { name: { $icontains: new Map() } }, 'name.$icontains', /^Filter comparand is a Map instance/], + ['$between: [undefined, 5] — the shape face\'s blank endpoint answers first', { amount: { $between: [undefined, 5] } }, 'amount.$between.0', /^A blank value is not a valid \$between endpoint/], + ] as const)('one slot, one issue, in the query doors\' order — %s', (_label, where, issuePath, head) => { + const result = FilterConditionSchema.safeParse(where); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.path.join('.'))).toEqual([issuePath]); + expect(issueAt(result, issuePath).message).toMatch(head); + }); + + it('every refused slot of one document is reported, each at its own path — the type face\'s among the shape face\'s', () => { + const result = FilterConditionSchema.safeParse({ + stage: { $eq: { a: 1 }, $in: 'won' }, + amount: { $gt: new Map(), $lt: null }, + owner: undefined, + $or: [{ tags: { $nin: ['x', { a: 1 }] } }], + }); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.path.join('.')).sort()).toEqual([ + '$or.0.tags.$nin.1', + 'amount.$gt', + 'amount.$lt', + 'owner', + 'stage.$eq', + 'stage.$in', + ]); + }); + + it.each(FILTER_COMPARAND_TYPE_CASES.map((row) => [row.name, row] as const))( + 'the type face\'s own conformance table — %s', + (_name, row) => { + const filter = row.filter(); + const result = FilterConditionSchema.safeParse(filter); + if (row.verdict === 'door-refusal') { + // Refused on save — and the rows are the query doors' refusals, in + // their envelope, so the save door is refusing what they refuse. + expect(result.success, show(filter)).toBe(false); + expect(faceRefusal(filter) ?? typeFaceRefusal(filter)).toMatchObject({ code: row.code, status: 400 }); + for (const issue of result.error!.issues) expect(issue.message).not.toContain(' at where'); + } else { + // Every accepted row is accepted AND kept as written — a bigint stays a + // bigint; the query face's narrowing is a query-time rewrite. + expect(result.success, show(result.error?.issues)).toBe(true); + expect(result.data).toEqual(filter); + } + }, + ); +}); + +// --------------------------------------------------------------------------- +// §7 Adopting the analytics carrier moves no published byte +// --------------------------------------------------------------------------- + +describe('#20116 §7 — the analytics carrier filter publishes exactly the bare condition', () => { + it('its JSON Schema projection is the optional FilterCondition\'s, byte for byte — the walk is a dropped refinement', () => { + // Why moving the dataset carriers' declaration into its own module, and + // making the dashboard widget's filter a carrier, changes no published + // body: the wrapper's refinement has no JSON form, so a carrier projects + // exactly as the plain slot it replaces (the build records it in + // `dropped-refinements.baseline.json` instead). + expect(JSON.stringify(z.toJSONSchema(analyticsCarrierFilter()))).toBe( + JSON.stringify(z.toJSONSchema(FilterConditionSchema.optional())), + ); + }); + + it('the widget filter keeps its published description and title', () => { + const widget = z.toJSONSchema(DashboardSchema) as { + properties?: { widgets?: { items?: { properties?: { filter?: { description?: string; title?: string } } } } }; + }; + const filter = widget.properties?.widgets?.items?.properties?.filter; + expect(filter?.description).toBe('Presentation-scope filter (runtimeFilter)'); + expect(filter?.title).toBe('Filter'); + }); + + it('the report runtimeFilter keeps its published description', () => { + const report = z.toJSONSchema(ReportSchema) as { properties?: { runtimeFilter?: { description?: string } } }; + expect(report.properties?.runtimeFilter?.description).toBe('Render-time scope filter'); }); }); diff --git a/packages/spec/src/data/filter-save-door-refusals.ts b/packages/spec/src/data/filter-save-door-refusals.ts index 2160a2f3321..4ee504ff5bf 100644 --- a/packages/spec/src/data/filter-save-door-refusals.ts +++ b/packages/spec/src/data/filter-save-door-refusals.ts @@ -10,9 +10,12 @@ * `$and` / `$or` / `$not` member — the shared comparand face's reach, which * every schema carrying a `FilterCondition` gets; * - the analytics carriers' nested-relation walk - * (`refuseNestedRelationComparands`, `../ui/dataset.zod.ts`), over the - * entries INSIDE a nested-relation condition, which the analytics `where` - * door flattens to dotted members and judges like any other entry. + * (`refuseNestedRelationComparands`, `../ui/analytics-carrier-filter.ts`), + * over the entries INSIDE a nested-relation condition, which the analytics + * `where` door flattens to dotted members and judges like any other entry. + * Every stored filter charted through that door declares it: a dataset + * `filter`, a measure `filter`, a dashboard widget `filter`, and a report's + * and a joined report block's `runtimeFilter`. * * So a slot is judged one way whichever reach finds it, and a rule added here * reaches both. @@ -32,6 +35,24 @@ * `driver-mongodb`, the read-scope compiler, the analytics `where` door): the * comparand is not a boolean (#5347 / #5369). * + * The comparand-TYPE face (`normalizeFilterComparandTypes`, + * `./filter-comparand-type.ts`, the #7872 ruling's accepted set `string | + * number | bigint | boolean | null | Date`) is called read-only the same way, + * on the field entry the query doors hand it: it refuses a plain object where + * a scalar belongs (`{ $eq: { a: 1 } }`), a `Map`, a class instance, a + * function, a Symbol, `undefined`, and a bigint beyond ±2^53, as a comparand + * and as a list member — and passes what it passes: the six accepted types, a + * `{ $field }` reference, and a bigint within ±2^53, which it would narrow to + * its number on a query and which the save door keeps as written. Before it, + * every save door accepted each of those and the analytics door refused each + * on chart. The document is judged, never rewritten. + * + * One slot raises ONE refusal: the first the query doors give, in their order + * — the shape face, then the type face, then the flag rule (`parseFilterAST`, + * the engine seam and the analytics door all run them in that order). So + * `$null: { a: 1 }` reads as the type face's refusal, as it does on chart, and + * never as two issues at one path. + * * ## The words * * - The equality and `$ne` slots: the face's own sentence, from the builders @@ -45,6 +66,9 @@ * wording for those shapes. * - A non-boolean flag: the query faces' sentence (see * {@link nonBooleanFlagComparandMessage}). + * - A comparand the type face refuses: the type face's own sentence, less its + * ` at ` clause (see {@link comparandTypeRefusalAtSave}). Nothing of it + * is restated here. * * None carries the face's ` at ` clause: the issue's own `path` carries * the location, which a refinement cannot see from inside the document. @@ -62,6 +86,7 @@ import type { z } from 'zod'; import { assertListComparandShapes } from './filter-comparand-shape'; +import { normalizeFilterComparandTypes } from './filter-comparand-type'; import { IN_OPERATOR_SPELLINGS, NIN_OPERATOR_SPELLINGS, @@ -269,11 +294,85 @@ function nonBooleanFlagComparandMessage(op: string, field: string, value: unknow } /** - * Raise, as `custom` issues under `slotPath`, every refusal the query faces - * give for ONE comparand slot: the comparand-shape face's verdict on the slot, - * and — for the two boolean flags, which that face does not judge — the flag - * rule. `op` is `undefined` for an implicit-equality comparand, and `slotPath` - * is then the field's own path. `slots` is `FieldOperatorsSchema`. + * Ask the comparand-TYPE face about one field entry (`{ stage: }`), the + * hand-over the analytics door and the engine seam make. Returns the face's + * refusal, or `undefined` when it accepts. Read-only: the face's narrowed copy + * (a bigint within ±2^53, as its number) is discarded, because the save door + * judges the document and never rewrites it. + * + * Only the face's own envelope (`INVALID_FILTER`) is read as a verdict, as in + * {@link comparandShapeFaceRefusal}. + */ +function comparandTypeFaceRefusal(entry: Record): Error | undefined { + try { + normalizeFilterComparandTypes(entry); + } catch (error) { + if ((error as { code?: unknown }).code === 'INVALID_FILTER') return error as Error; + throw error; + } + return undefined; +} + +/** + * The type face's refusal of ONE slot, located and in its own words, or + * `undefined` when the face passes the slot. + * + * - **The verdict** is the face's, twice over. On the whole field entry + * (`fieldSpec`) first, because the face classifies the entry before it + * judges an operator: a spec carrying a string `$field` is a field reference + * it steps around whole, whatever sits beside it. Then on the one-slot node, + * so each refused slot of an entry is reported at its own path. + * - **The location.** The face judges a list operator's members one by one and + * stops at the first it refuses; that member is found by asking the face + * about each member alone, and the issue sits at it (`stage.$in.1`), as the + * shape arms' null member does. Anything else sits at the slot. + * - **The words** are the face's message with its ` at where.` clause + * removed — the one clause this door cannot write truthfully, since a + * refinement cannot see where it sits in the document (the issue's `path` + * says). The clause removed is the one the face was handed (`where`, its + * default root, plus this slot), so nothing is parsed out of the text. Should + * the face ever spell its location differently, the clause is not found and + * the face's whole message is reported, location included, rather than a + * guess — `filter-save-door-face-parity.test.ts` fails on that text. + */ +function comparandTypeRefusalAtSave( + field: string, + op: string | undefined, + comparand: unknown, + fieldSpec: unknown, +): SaveDoorRefusal | undefined { + if (comparandTypeFaceRefusal({ [field]: fieldSpec }) === undefined) return undefined; + const slot = (value: unknown): Record => + (op === undefined ? { [field]: value } : { [field]: { [op]: value } }); + const face = comparandTypeFaceRefusal(slot(comparand)); + if (face === undefined) return undefined; + let at: number[] = []; + let location = op === undefined ? `where.${field}` : `where.${field}.${op}`; + if (op !== undefined && Array.isArray(comparand)) { + const member = comparand.findIndex((value) => comparandTypeFaceRefusal(slot([value])) !== undefined); + if (member !== -1) { + at = [member]; + location = `${location}[${member}]`; + } + } + const clause = ` at ${location} `; + const message = face.message.includes(clause) ? face.message.replace(clause, ' ') : face.message; + return { at, message }; +} + +/** + * Raise, as ONE `custom` issue under `slotPath`, the first refusal the query + * faces give for ONE comparand slot, in their order: the comparand-shape + * face's verdict on the slot, then the comparand-type face's, then — for the + * two boolean flags, which neither face judges as a flag — the flag rule. `op` + * is `undefined` for an implicit-equality comparand, and `slotPath` is then + * the field's own path. `slots` is `FieldOperatorsSchema`. `fieldSpec` is the + * whole value of the field entry the slot sits in — the operator map, or the + * implicit comparand itself (the default) — which the type face classifies + * before it judges the slot. + * + * Returns whether the slot was refused, so a walk with arms of its own on the + * same slot can stay silent rather than raise a second issue at one path. */ export function reportQueryFaceRefusals( ctx: z.RefinementCtx, @@ -282,14 +381,16 @@ export function reportQueryFaceRefusals( op: string | undefined, comparand: unknown, slots: OperatorSlots, -): void { - const refusals: SaveDoorRefusal[] = []; - const face = comparandShapeFaceRefusal(op === undefined ? { [field]: comparand } : { [field]: { [op]: comparand } }); - if (face) refusals.push(comparandShapeRefusalAtSave(slots, field, op, comparand, face)); - if (op !== undefined && BOOLEAN_FLAG_OPERATORS.has(op) && typeof comparand !== 'boolean') { - refusals.push({ at: [], message: nonBooleanFlagComparandMessage(op, field, comparand) }); - } - for (const refusal of refusals) { - ctx.addIssue({ code: 'custom', path: [...slotPath, ...refusal.at], message: refusal.message }); + fieldSpec: unknown = op === undefined ? comparand : { [op]: comparand }, +): boolean { + let refusal: SaveDoorRefusal | undefined; + const shapeFace = comparandShapeFaceRefusal(op === undefined ? { [field]: comparand } : { [field]: { [op]: comparand } }); + if (shapeFace) refusal = comparandShapeRefusalAtSave(slots, field, op, comparand, shapeFace); + refusal ??= comparandTypeRefusalAtSave(field, op, comparand, fieldSpec); + if (refusal === undefined && op !== undefined && BOOLEAN_FLAG_OPERATORS.has(op) && typeof comparand !== 'boolean') { + refusal = { at: [], message: nonBooleanFlagComparandMessage(op, field, comparand) }; } + if (refusal === undefined) return false; + ctx.addIssue({ code: 'custom', path: [...slotPath, ...refusal.at], message: refusal.message }); + return true; } diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index c0f484b4737..c27bbb374ee 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -1702,6 +1702,20 @@ function isPlainFilterNode(value: unknown): value is Record { ); } +/** + * [#20116] Is this object DATA rather than filter structure, as the + * comparand-type face classifies it? Structure is a PLAIN object only, + * prototype `Object.prototype` or `null` (the face's `isFilterNode`, the + * convention `driver-sql` shares since #5134, and the analytics door's + * nested-relation test). A `Map` or a class instance answers + * `typeof x === 'object'` while being data: `{ stage: new Map() }` is a + * comparand the type face refuses, never an empty nested relation. + */ +function isDataObject(value: object): boolean { + const proto = Object.getPrototypeOf(value); + return proto !== Object.prototype && proto !== null; +} + /** * Walk one condition node and report every comparand this authoring door * refuses — the bare date-range PRESET names in an ordering position (#8793), @@ -1732,6 +1746,20 @@ function isPlainFilterNode(value: unknown): value is Record { * `$in: []` / `$nin: []` and a whitespace endpoint all keep passing, because * the face passes them. The flags, which that face does not judge, use the * one predicate every flag face uses: `typeof comparand !== 'boolean'`. + * - **The comparand-TYPE face too** (`normalizeFilterComparandTypes`, the + * #7872 accepted set), asked by the same function after the shape face: a + * plain object where a literal belongs (`{ $eq: { a: 1 } }`), a `Map`, a + * class instance, a function, a Symbol, `undefined` or a bigint beyond ±2^53 + * — as the comparand or as a list member — is refused on save, as every + * query face refuses it. Measured on `origin/main` `17bd3187` before this + * arm: every save door accepted `{ stage: { $eq: { a: 1 } } }`, + * `{ stage: { $in: [{ a: 1 }] } }` and a `Map` comparand, while the type face + * and the analytics door refused each with `INVALID_FILTER` / 400. A `Date`, + * a `{ $field }` reference, a `{placeholder}` string resolved at request time + * and a bigint within ±2^53 keep passing, because the face passes them. So + * that a `Map` or a class instance reaches the face at all, a field value is + * a comparand unless it is a PLAIN object ({@link isDataObject}, the face's + * own structure test). * - **The words** are chosen in that module: the face's own sentence where * the two doors already share a builder, the enforced * operator slot's sentence where `FieldOperatorsSchema` already prints one for @@ -1742,7 +1770,7 @@ function isPlainFilterNode(value: unknown): value is Record { * with no `$` key. The drivers' flag checks stop at the same place. The * analytics `where` door does descend a nested relation (it flattens one to a * dotted member), so those positions belong to the analytics carriers' own - * walk (`refuseNestedRelationComparands`, `../ui/dataset.zod.ts`), which + * walk (`refuseNestedRelationComparands`, `../ui/analytics-carrier-filter.ts`), which * asks the same function — never to this shared walk, which every other * carrier reads. * @@ -1827,13 +1855,14 @@ function checkFilterConditionComparands( for (const [key, value] of Object.entries(node)) { if (key.startsWith('$')) continue; // $and/$or/$not re-parse; other $ keys stay unjudged - // [#19889, #20116] An IMPLICIT comparand — a scalar, a `Date` or an array - // (the equality slot's `{ field: [...] }`, the empty list included) — asked - // of the query faces. Judged on this node's OWN field entries only - // (`depth` 0), the face's exact reach: its walk never descends a field spec - // that has no `$` key, so nothing inside a nested-relation condition is - // refused there, and nothing is refused here. See the docblock. - if (!isPlainFilterNode(value)) { + // [#19889, #20116] An IMPLICIT comparand — a scalar, a `Date`, an array + // (the equality slot's `{ field: [...] }`, the empty list included), or a + // `Map` / class instance, which the type face calls data — asked of the + // query faces. Judged on this node's OWN field entries only (`depth` 0), + // the face's exact reach: its walk never descends a field spec that has no + // `$` key, so nothing inside a nested-relation condition is refused there, + // and nothing is refused here. See the docblock. + if (!isPlainFilterNode(value) || isDataObject(value)) { if (depth === 0) reportQueryFaceRefusals(ctx, [...path, key], key, undefined, value, FieldOperatorsSchema); continue; } @@ -1848,9 +1877,15 @@ function checkFilterConditionComparands( // [#20116] Every operator slot asked of the query faces, on the same // reach as the implicit form above: the comparand-shape face's verdict // (the equality and `$ne` slots, the ordering `null` carve-out, the list - // operators' shape, null-member and endpoint rules) and the boolean - // flags'. An operator neither judges passes through untouched. - if (depth === 0) reportQueryFaceRefusals(ctx, [...path, key, op], key, op, comparand, FieldOperatorsSchema); + // operators' shape, null-member and endpoint rules), the comparand-type + // face's (a plain object, `Map`, class instance, `undefined` … where a + // literal belongs), and the boolean flags'. An operator none of them + // judges passes through untouched. A slot they refuse raises that one + // issue, and this walk's own arms below stay silent on it: one defect, + // one issue at one path. + if (depth === 0 && reportQueryFaceRefusals(ctx, [...path, key, op], key, op, comparand, FieldOperatorsSchema, value)) { + continue; + } if (op === FILTER_TEXT_COMPARAND_OPERATOR && isRefusedTextComparand(comparand)) { ctx.addIssue({ code: 'custom', diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-comparand-types-and-widget-nested-slots-refused-at-save.ts b/packages/spec/src/migrations/entries/semantic/18.filter-comparand-types-and-widget-nested-slots-refused-at-save.ts new file mode 100644 index 00000000000..26fb56426f6 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.filter-comparand-types-and-widget-nested-slots-refused-at-save.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The second half of the schema door's parity with the query faces. The first +// (filter-query-face-comparands-refused-at-save) asked the comparand-SHAPE face +// and the flag rule; this one asks the comparand-TYPE face too, and makes a +// dashboard widget filter and both report runtimeFilters analytics carriers, so +// the slots inside a nested relation that the analytics where door judges are +// judged on save there as on a dataset filter. The faces stay the judges; the +// save door only asks them. +export const entry: SemanticMigration = { + id: 'filter-comparand-types-and-widget-nested-slots-refused-at-save', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'data.FilterCondition — a comparand the comparand-type face refuses, now refused when the ' + + 'document is PARSED: a plain object where a single value belongs (an $eq, $ne, ordering, ' + + 'text or flag comparand such as { a: 1 }, including a { $field } whose name is not a ' + + 'string), a Map, a class instance, a function, a Symbol, undefined, or a bigint beyond ' + + 'plus or minus 2^53, whether it is the comparand itself, an implicit-equality comparand ' + + 'or an $in / $nin / $between list member. On every schema that carries a FilterCondition, ' + + 'at the reach the save door already had (the field entries of a condition and of every ' + + '$and / $or / $not member); and, on a dataset filter, a dataset measure filter and now a ' + + 'dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter, ' + + 'INSIDE a nested-relation condition as well, for these shapes and for every shape the ' + + 'earlier entry names — so ui.DashboardWidget.filter, ui.Report.runtimeFilter and ' + + 'ui.JoinedReportBlock.runtimeFilter gain the nested-relation reach of the two dataset ' + + 'carriers', + replacement: + 'a value of one of the six accepted comparand types — a string, number, bigint within plus ' + + 'or minus 2^53, boolean, null or Date — or a { $field: "column" } reference where a column ' + + 'is meant. A value set belongs in $in; an absent value is the null predicate ($eq null / ' + + '$ne null) or an omitted key, never undefined; a bigint beyond 2^53 is compared as a ' + + 'string or within range. Inside a nested relation on a widget filter or a report ' + + 'runtimeFilter, write the same ' + + 'spelling the top-level refusal prescribes. A Date, a { $field } reference, a {placeholder} ' + + 'string resolved at request time (such as {current_user_id} or {today}) and a bigint ' + + 'within 2^53 are untouched, and the save door keeps a bigint as written', + reason: + 'The save door narrows to exactly what the query faces already refuse (#20116, stage 2 of ' + + 'the collector). The comparand-type face (normalizeFilterComparandTypes, the #7872 ' + + 'ruling\'s accepted set) refuses these values on every query: parseFilterAST, the engine ' + + 'seam, the analytics where door and the read-scope compiler all run it. Measured on ' + + 'origin/main 17bd3187 before the change: FilterConditionSchema, a dataset filter, a ' + + 'dataset measure filter, a dashboard widget filter, a report runtimeFilter and a joined ' + + 'report block runtimeFilter each parsed GREEN for { stage: { $eq: { a: 1 } } }, ' + + '{ stage: { $in: [{ a: 1 }] } } and a Map comparand, top level and nested, while the type ' + + 'face and the analytics where door refused each with INVALID_FILTER / 400. And a ' + + 'dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter ' + + 'parsed GREEN for { acct: { stage: { $in: ["won", null] } } } and for a list in a nested ' + + 'equality slot, which the analytics where door refuses when they are charted, because ' + + 'only the two dataset carriers had the nested-relation walk. ' + + 'The save door now asks the type face itself, read-only, after the shape face, so it ' + + 'refuses exactly what the face refuses and passes what it passes; one slot raises one ' + + 'refusal, in the query doors\' order (shape, then type, then the flag rule), in the face\'s ' + + 'own words less its location clause. At the top level of a filter and in its $and / $or / ' + + '$not members, a second issue on a slot a face already refused (the schema door\'s own ' + + '$icontains and date-preset arms) is no longer raised; inside a nested relation on an ' + + 'analytics carrier those two arms still judge the slot beside the faces, so a nested ' + + '$icontains with a refused comparand, or a nested one-bound $between of a preset name, can ' + + 'carry two issues. Neither moves a verdict. The widget filter and both report runtimeFilters declare the same ' + + 'analytics-carrier filter as the dataset carriers, so their nested-relation slots are ' + + 'judged by the same walk: every stored filter the analytics where door charts now refuses ' + + 'on save what that door refuses on chart. Metadata AT REST is not rewritten and this entry adds no D2 ' + + 'conversion: none of these values has a single honest meaning as a comparand, which is why ' + + 'each was refused. The read path does not re-validate stored rows, so a stored document ' + + 'keeps loading; re-saving it through the metadata protocol (422 INVALID_METADATA), ' + + 'defineStack or os validate is refused at the filter\'s path. A JSON document can carry ' + + 'only the plain-object cells; the others arrive only from TypeScript authoring. Such a ' + + 'filter has failed every query since the type face\'s ruling, so the refusal is a repair ' + + 'and not a loss. ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Validate every stack and re-save every stored document that carries a filter: os validate ' + + 'or defineStack, and a save through the metadata protocol, report each refused slot by ' + + 'path, for example widgets.0.filter.acct.stage.$in.1 or filter.stage.$eq, with the type ' + + 'face\'s sentence and the accepted set. A producer census before the change — a literal ' + + 'scan with a lit control per shape over examples, the non-test packages of this repository, ' + + 'the console repository at its pin and the cloud repository, plus a runtime walk of every ' + + 'filter in the example stacks — found no authored filter carrying one of these values and ' + + 'no widget filter or report runtimeFilter with a nested-relation condition holding a ' + + 'list or an operator map.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 41fe4dd0849..4d31e801035 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -9679,6 +9679,85 @@ const step18: MigrationStep = { + 'authoring schema door (SET_MEMBER_DESCRIPTION); they are outside THIS entry\'s transition ' + 'and are worth sweeping in the same pass.', }, + // The second half of the schema door's parity with the query faces. The first + // (filter-query-face-comparands-refused-at-save) asked the comparand-SHAPE face + // and the flag rule; this one asks the comparand-TYPE face too, and makes a + // dashboard widget filter and both report runtimeFilters analytics carriers, so + // the slots inside a nested relation that the analytics where door judges are + // judged on save there as on a dataset filter. The faces stay the judges; the + // save door only asks them. + { + id: 'filter-comparand-types-and-widget-nested-slots-refused-at-save', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'data.FilterCondition — a comparand the comparand-type face refuses, now refused when the ' + + 'document is PARSED: a plain object where a single value belongs (an $eq, $ne, ordering, ' + + 'text or flag comparand such as { a: 1 }, including a { $field } whose name is not a ' + + 'string), a Map, a class instance, a function, a Symbol, undefined, or a bigint beyond ' + + 'plus or minus 2^53, whether it is the comparand itself, an implicit-equality comparand ' + + 'or an $in / $nin / $between list member. On every schema that carries a FilterCondition, ' + + 'at the reach the save door already had (the field entries of a condition and of every ' + + '$and / $or / $not member); and, on a dataset filter, a dataset measure filter and now a ' + + 'dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter, ' + + 'INSIDE a nested-relation condition as well, for these shapes and for every shape the ' + + 'earlier entry names — so ui.DashboardWidget.filter, ui.Report.runtimeFilter and ' + + 'ui.JoinedReportBlock.runtimeFilter gain the nested-relation reach of the two dataset ' + + 'carriers', + replacement: + 'a value of one of the six accepted comparand types — a string, number, bigint within plus ' + + 'or minus 2^53, boolean, null or Date — or a { $field: "column" } reference where a column ' + + 'is meant. A value set belongs in $in; an absent value is the null predicate ($eq null / ' + + '$ne null) or an omitted key, never undefined; a bigint beyond 2^53 is compared as a ' + + 'string or within range. Inside a nested relation on a widget filter or a report ' + + 'runtimeFilter, write the same ' + + 'spelling the top-level refusal prescribes. A Date, a { $field } reference, a {placeholder} ' + + 'string resolved at request time (such as {current_user_id} or {today}) and a bigint ' + + 'within 2^53 are untouched, and the save door keeps a bigint as written', + reason: + 'The save door narrows to exactly what the query faces already refuse (#20116, stage 2 of ' + + 'the collector). The comparand-type face (normalizeFilterComparandTypes, the #7872 ' + + 'ruling\'s accepted set) refuses these values on every query: parseFilterAST, the engine ' + + 'seam, the analytics where door and the read-scope compiler all run it. Measured on ' + + 'origin/main 17bd3187 before the change: FilterConditionSchema, a dataset filter, a ' + + 'dataset measure filter, a dashboard widget filter, a report runtimeFilter and a joined ' + + 'report block runtimeFilter each parsed GREEN for { stage: { $eq: { a: 1 } } }, ' + + '{ stage: { $in: [{ a: 1 }] } } and a Map comparand, top level and nested, while the type ' + + 'face and the analytics where door refused each with INVALID_FILTER / 400. And a ' + + 'dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter ' + + 'parsed GREEN for { acct: { stage: { $in: ["won", null] } } } and for a list in a nested ' + + 'equality slot, which the analytics where door refuses when they are charted, because ' + + 'only the two dataset carriers had the nested-relation walk. ' + + 'The save door now asks the type face itself, read-only, after the shape face, so it ' + + 'refuses exactly what the face refuses and passes what it passes; one slot raises one ' + + 'refusal, in the query doors\' order (shape, then type, then the flag rule), in the face\'s ' + + 'own words less its location clause. At the top level of a filter and in its $and / $or / ' + + '$not members, a second issue on a slot a face already refused (the schema door\'s own ' + + '$icontains and date-preset arms) is no longer raised; inside a nested relation on an ' + + 'analytics carrier those two arms still judge the slot beside the faces, so a nested ' + + '$icontains with a refused comparand, or a nested one-bound $between of a preset name, can ' + + 'carry two issues. Neither moves a verdict. The widget filter and both report runtimeFilters declare the same ' + + 'analytics-carrier filter as the dataset carriers, so their nested-relation slots are ' + + 'judged by the same walk: every stored filter the analytics where door charts now refuses ' + + 'on save what that door refuses on chart. Metadata AT REST is not rewritten and this entry adds no D2 ' + + 'conversion: none of these values has a single honest meaning as a comparand, which is why ' + + 'each was refused. The read path does not re-validate stored rows, so a stored document ' + + 'keeps loading; re-saving it through the metadata protocol (422 INVALID_METADATA), ' + + 'defineStack or os validate is refused at the filter\'s path. A JSON document can carry ' + + 'only the plain-object cells; the others arrive only from TypeScript authoring. Such a ' + + 'filter has failed every query since the type face\'s ruling, so the refusal is a repair ' + + 'and not a loss. ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Validate every stack and re-save every stored document that carries a filter: os validate ' + + 'or defineStack, and a save through the metadata protocol, report each refused slot by ' + + 'path, for example widgets.0.filter.acct.stage.$in.1 or filter.stage.$eq, with the type ' + + 'face\'s sentence and the accepted set. A producer census before the change — a literal ' + + 'scan with a lit control per shape over examples, the non-test packages of this repository, ' + + 'the console repository at its pin and the cloud repository, plus a runtime walk of every ' + + 'filter in the example stacks — found no authored filter carrying one of these values and ' + + 'no widget filter or report runtimeFilter with a nested-relation condition holding a ' + + 'list or an operator map.', + }, // The RUNTIME door's half of the question the sibling entry // view-filter-rule-scalar-operator-array-refused answered at the view-rule // schema door: that entry refuses an array on a scalar view operator when the diff --git a/packages/spec/src/ui/analytics-carrier-filter.ts b/packages/spec/src/ui/analytics-carrier-filter.ts new file mode 100644 index 00000000000..5957efa928b --- /dev/null +++ b/packages/spec/src/ui/analytics-carrier-filter.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The filter slot of every ANALYTICS carrier — a stored filter that is charted + * through the analytics `where` door — declared once: `FilterConditionSchema` + * plus the nested-relation walk that door's reach needs (see + * {@link analyticsCarrierFilter}). Five carriers declare it: `DatasetSchema`'s + * `filter` and `DatasetMeasureSchema`'s `filter` (`./dataset.zod.ts`, #20080), + * a dashboard widget's `filter` (`./dashboard.zod.ts`), and a report's and a + * joined report block's `runtimeFilter` (`./report.zod.ts`) (#20116) — the + * presentation filters the dataset executor ANDs into the same query as the + * selection's `runtimeFilter`. + * + * A module of its own, and outside the `ui` barrel, so the carriers share one + * declaration without it becoming published API. It moved here verbatim from + * `./dataset.zod.ts` when the widget became its second file's carrier; the two + * dataset carriers' published JSON Schema did not move with it. + */ + +import type { z } from 'zod'; +import { FieldOperatorsSchema, FilterConditionSchema } from '../data/filter.zod'; +import { reportQueryFaceRefusals } from '../data/filter-save-door-refusals'; + +// ── [#20080] The analytics door's reach, on the analytics carriers ────────── + +/** + * A node the analytics `where` door walks: a plain object, not `null`, not an + * array and not a `Date` — that door's own `isFilterObject` + * (`service-analytics` `strategies/filter-normalizer.ts`), mirrored here. + */ +function isAnalyticsFilterObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date); +} + +/** + * A NESTED-RELATION field spec, as the analytics door decides it + * (`isNestedRelationSpec` in the same file): a plain object — prototype + * `Object.prototype` or `null`, so a `Map` or a class instance stays a + * comparand — carrying no `$` key. + */ +function isAnalyticsNestedRelationSpec(spec: unknown): spec is Record { + if (!isAnalyticsFilterObject(spec)) return false; + const proto = Object.getPrototypeOf(spec); + if (proto !== Object.prototype && proto !== null) return false; + return !Object.keys(spec).some((key) => key.startsWith('$')); +} + +/** + * [#20116] An object that is DATA, not filter structure, as the comparand-type + * face classifies it: its prototype is not `Object.prototype` or `null`. A + * field value the analytics door hands to the query faces as the COMPARAND of + * an implicit equality is anything but a PLAIN object — a `Map`, a class + * instance, a `Date`, an array or a scalar. A plain object that reaches that + * question carries a `$` key (one with none is a nested relation, taken first) + * and is an operator map. + */ +function isAnalyticsDataObject(value: object): boolean { + const proto = Object.getPrototypeOf(value); + return proto !== Object.prototype && proto !== null; +} + +/** + * [#20080] Refuse, when an analytics carrier's filter is SAVED, the list the + * analytics `where` door refuses when that filter is CHARTED: an ARRAY in the + * EQUALITY slot of a field inside a NESTED-RELATION condition — + * `{ account: { region: ['a'] } }` and `{ account: { region: { $eq: ['a'] } } }`. + * [#20116] And, since #20116, every other comparand slot that door refuses + * there: the whole of the query faces' verdict, asked of the one function + * `FilterConditionSchema`'s own walk asks (`reportQueryFaceRefusals`, + * `../data/filter-save-door-refusals.ts`). See the last section. + * + * ## Why this is a carrier refinement and not the shared schema's + * + * `FilterConditionSchema` refuses an equality-slot list on the field entries + * of a condition and of every `$and` / `$or` / `$not` member, and deliberately + * NOT inside a field spec with no `$` key: that is the shared comparand face's + * reach, and the engine reads such a spec as a deep-equality comparand (ruling + * A on #19889, record 5805248669). That ruling stands, and this function does + * not touch the shared schema. + * + * The analytics door reads the same spec differently. Its `fieldLeaves` + * flattens a nested relation to the dotted member `account.region`, so the + * entries inside are comparisons in their own right, and + * `assertNoListInEqualitySlot` hands each equality-slot list among them to the + * shared face, which refuses it with `INVALID_FILTER` / 400. A dataset `filter` + * and a measure `filter` are charted through that door on every path: the + * dataset executor hands them to the analytics query, and the native-SQL and + * ObjectQL strategies and the draft preview each lower them through it. So + * until this refinement such a dataset saved clean and every chart built on it + * failed, for somebody else, later. Triage routed the fix to these two + * carriers (record 5825670610, remedy A): the refusal moves to save, and no + * filter changes meaning. + * + * ## The reach is the analytics door's, and only the part the schema lacks + * + * The walk is `mapWhereFieldEntries`' traversal: `$and` / `$or` arrays and + * `$not` are descended; every other `$` key at node level is skipped; a + * nested-relation spec is descended, at any depth; every other field entry is + * judged as that door judges it — each slot handed to the query faces (see the + * last section), of which the equality-slot list is one arm. No depth bound: + * that door has none. + * + * Only an entry INSIDE a nested relation is reported. Every other entry the + * walk visits is a field entry of the condition or of a combinator member, + * which `FilterConditionSchema`'s own refinement already refuses with the same + * words — reporting it here as well would raise the one refusal twice. + * + * ## The words are the face's + * + * `arrayEqualityComparandMessage` is the one builder the shared face and the + * schema door both print from. The analytics door names the field it hands to + * the face — the leaf key (`region`), with the location `at + * where.account.region` appended. This refinement prints the same sentence and + * leaves the location out, as the schema door does, because the issue's own + * `path` carries it (`filter.account.region`, + * `measures.0.filter.account.region.$eq`). + * + * Nothing is stripped: the parse fails, and a filter that is refused is never + * a filter that is dropped. + * + * ## Every slot the door refuses inside a relation (#20116) + * + * The analytics door hands each entry inside a nested relation to the whole + * comparand-shape face and to its `$null` / `$exists` flag check, not only to + * the equality arm — so a `null` ordering comparand, a non-list `$in` / `$nin`, + * a `null` list member, a malformed `$between` or a `null`, blank or + * `{ $field }` endpoint, an array under `$ne` and a non-boolean flag inside a + * relation all failed on chart while this walk saved them. Each such entry is + * now asked of `reportQueryFaceRefusals`, the function `FilterConditionSchema`'s + * walk asks about the entries IT reaches, so one slot is judged one way + * whichever walk finds it, and a rule added there reaches this reach too. This + * walk still decides only WHERE (the analytics door's traversal); the verdict + * and the words are that function's. The equality lists above are two of its + * arms and keep their sentence and their path. The list operators keep their + * lists (`$in: []` / `$nin: []` included), and the null predicate, a + * `{ $field }` reference as the whole comparand and every scalar pass, because + * the face passes them. + * + * The analytics door hands the same entry to the comparand-TYPE face too, so + * that function asks it as well: a plain object where a literal belongs, a + * `Map` or a class instance, `undefined`, a function, a Symbol or a bigint + * beyond ±2^53 inside a relation is refused here as on chart. Which values + * are comparands is that face's classification — anything but a PLAIN object + * ({@link isAnalyticsDataObject}) — so a `Map` in a field's value position is + * judged as the implicit comparand it is, never walked as an operator map with + * no operators. + */ +function refuseNestedRelationComparands( + node: unknown, + ctx: z.RefinementCtx, + path: (string | number)[] = [], + insideRelation = false, +): void { + if (!isAnalyticsFilterObject(node)) return; + for (const [key, spec] of Object.entries(node)) { + if (key === '$and' || key === '$or') { + if (Array.isArray(spec)) { + spec.forEach((member, index) => + refuseNestedRelationComparands(member, ctx, [...path, key, index], insideRelation)); + } + continue; + } + if (key === '$not') { + refuseNestedRelationComparands(spec, ctx, [...path, key], insideRelation); + continue; + } + if (key.startsWith('$')) continue; + if (isAnalyticsNestedRelationSpec(spec)) { + refuseNestedRelationComparands(spec, ctx, [...path, key], true); + continue; + } + if (!insideRelation) continue; // `FilterConditionSchema` judges this entry itself + // [#20116] Every slot the analytics door hands to the query faces, asked of + // the one function `FilterConditionSchema`'s own walk asks: an implicit + // comparand, or each operator of an operator map (with the whole map, which + // the type face classifies before it judges an operator). + if (!isAnalyticsFilterObject(spec) || isAnalyticsDataObject(spec)) { + reportQueryFaceRefusals(ctx, [...path, key], key, undefined, spec, FieldOperatorsSchema); + continue; + } + for (const [op, comparand] of Object.entries(spec)) { + if (!op.startsWith('$')) continue; + reportQueryFaceRefusals(ctx, [...path, key, op], key, op, comparand, FieldOperatorsSchema, spec); + } + } +} + +/** + * The optional filter every analytics carrier declares — `DatasetSchema.filter`, + * `DatasetMeasureSchema.filter`, `DashboardWidgetSchema.filter`, + * `ReportSchema.runtimeFilter` and `JoinedReportBlockSchema.runtimeFilter` — + * which is `FilterConditionSchema` plus {@link refuseNestedRelationComparands}. + * Every other schema that carries a `FilterCondition` keeps the shared schema's + * reach. + * + * The check sits on the OPTIONAL wrapper, not on `FilterConditionSchema` + * itself: refining the recursive schema would clone it, and the published JSON + * Schema would then inline a second copy of the condition beside the `$ref` it + * carries today. On the wrapper the condition keeps its identity, so the + * published body of each carrier (`ui/Dataset`, `ui/DatasetMeasure`, + * `ui/DashboardWidget`, `ui/Report`, `ui/JoinedReportBlock`) is unchanged, and + * the rule is recorded as a dropped refinement at each + * carrier's `filter` in `dropped-refinements.baseline.json` + * (`z.toJSONSchema()` has no projection for it). An absent filter reaches the + * check as `undefined`, which the walk passes. + */ +export function analyticsCarrierFilter() { + return FilterConditionSchema.optional().superRefine((filter, ctx) => + refuseNestedRelationComparands(filter, ctx)); +} diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 919d93f07fa..6dffeea2a7c 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -6,6 +6,7 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { strictObject } from '../shared/strict-object'; import type { KeySetGuidance } from '../shared/suggestions.zod'; import { FilterConditionSchema } from '../data/filter.zod'; +import { analyticsCarrierFilter } from './analytics-carrier-filter'; import { DateGranularity } from '../data/query.zod'; import { DATE_MACRO_WRAPPED_RE, isDateMacroToken } from '../data/date-macros.zod'; import { DATE_RANGE_PRESETS } from '../data/date-range-presets'; @@ -868,8 +869,15 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ actionType: retiredKey(WIDGET_ACTION_RETIRED('actionType')), actionIcon: retiredKey(WIDGET_ACTION_RETIRED('actionIcon')), - /** Presentation-scope filter (MongoDB-style), ANDed into the dataset query as `runtimeFilter`. */ - filter: FilterConditionSchema.optional().describe('Presentation-scope filter (runtimeFilter)').meta({ title: 'Filter' }), + /** + * Presentation-scope filter (MongoDB-style), ANDed into the dataset query as + * `runtimeFilter`. [#20116] It is charted through the analytics `where` door + * like a dataset's own `filter`, so it is an analytics carrier: a comparand + * that door refuses INSIDE a nested relation + * (`{ acct: { stage: { $in: ['won', null] } } }`) is refused on save too — + * see {@link analyticsCarrierFilter} (`./analytics-carrier-filter.ts`). + */ + filter: analyticsCarrierFilter().describe('Presentation-scope filter (runtimeFilter)').meta({ title: 'Filter' }), /** * Period-over-period comparison window. diff --git a/packages/spec/src/ui/dataset.zod.ts b/packages/spec/src/ui/dataset.zod.ts index a9b21cb11b5..18a47d22dd9 100644 --- a/packages/spec/src/ui/dataset.zod.ts +++ b/packages/spec/src/ui/dataset.zod.ts @@ -5,8 +5,7 @@ import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; -import { FieldOperatorsSchema, FilterConditionSchema } from '../data/filter.zod'; -import { reportQueryFaceRefusals } from '../data/filter-save-door-refusals'; +import { analyticsCarrierFilter } from './analytics-carrier-filter'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { I18nLabelSchema } from './i18n.zod'; import { AggregationFunction, DateGranularity } from '../data/query.zod'; @@ -80,167 +79,6 @@ const DATASET_NO_SQL = + '`derived: { op, of: [...] }`, which combines OTHER measures in this dataset by name. ' + 'Joins are compiled from `Dataset.include` — you never write an ON clause.'; -// ── [#20080] The analytics door's reach, on the two analytics carriers ────── - -/** - * A node the analytics `where` door walks: a plain object, not `null`, not an - * array and not a `Date` — that door's own `isFilterObject` - * (`service-analytics` `strategies/filter-normalizer.ts`), mirrored here. - */ -function isAnalyticsFilterObject(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date); -} - -/** - * A NESTED-RELATION field spec, as the analytics door decides it - * (`isNestedRelationSpec` in the same file): a plain object — prototype - * `Object.prototype` or `null`, so a `Map` or a class instance stays a - * comparand — carrying no `$` key. - */ -function isAnalyticsNestedRelationSpec(spec: unknown): spec is Record { - if (!isAnalyticsFilterObject(spec)) return false; - const proto = Object.getPrototypeOf(spec); - if (proto !== Object.prototype && proto !== null) return false; - return !Object.keys(spec).some((key) => key.startsWith('$')); -} - -/** - * [#20080] Refuse, when a dataset or measure filter is SAVED, the list the - * analytics `where` door refuses when that filter is CHARTED: an ARRAY in the - * EQUALITY slot of a field inside a NESTED-RELATION condition — - * `{ account: { region: ['a'] } }` and `{ account: { region: { $eq: ['a'] } } }`. - * [#20116] And, since #20116, every other comparand slot that door refuses - * there: the whole of the query faces' verdict, asked of the one function - * `FilterConditionSchema`'s own walk asks (`reportQueryFaceRefusals`, - * `../data/filter-save-door-refusals.ts`). See the last section. - * - * ## Why this is a carrier refinement and not the shared schema's - * - * `FilterConditionSchema` refuses an equality-slot list on the field entries - * of a condition and of every `$and` / `$or` / `$not` member, and deliberately - * NOT inside a field spec with no `$` key: that is the shared comparand face's - * reach, and the engine reads such a spec as a deep-equality comparand (ruling - * A on #19889, record 5805248669). That ruling stands, and this function does - * not touch the shared schema. - * - * The analytics door reads the same spec differently. Its `fieldLeaves` - * flattens a nested relation to the dotted member `account.region`, so the - * entries inside are comparisons in their own right, and - * `assertNoListInEqualitySlot` hands each equality-slot list among them to the - * shared face, which refuses it with `INVALID_FILTER` / 400. A dataset `filter` - * and a measure `filter` are charted through that door on every path: the - * dataset executor hands them to the analytics query, and the native-SQL and - * ObjectQL strategies and the draft preview each lower them through it. So - * until this refinement such a dataset saved clean and every chart built on it - * failed, for somebody else, later. Triage routed the fix to these two - * carriers (record 5825670610, remedy A): the refusal moves to save, and no - * filter changes meaning. - * - * ## The reach is the analytics door's, and only the part the schema lacks - * - * The walk is `mapWhereFieldEntries`' traversal: `$and` / `$or` arrays and - * `$not` are descended; every other `$` key at node level is skipped; a - * nested-relation spec is descended, at any depth; every other field entry is - * judged as that door judges it — each slot handed to the query faces (see the - * last section), of which the equality-slot list is one arm. No depth bound: - * that door has none. - * - * Only an entry INSIDE a nested relation is reported. Every other entry the - * walk visits is a field entry of the condition or of a combinator member, - * which `FilterConditionSchema`'s own refinement already refuses with the same - * words — reporting it here as well would raise the one refusal twice. - * - * ## The words are the face's - * - * `arrayEqualityComparandMessage` is the one builder the shared face and the - * schema door both print from. The analytics door names the field it hands to - * the face — the leaf key (`region`), with the location `at - * where.account.region` appended. This refinement prints the same sentence and - * leaves the location out, as the schema door does, because the issue's own - * `path` carries it (`filter.account.region`, - * `measures.0.filter.account.region.$eq`). - * - * Nothing is stripped: the parse fails, and a filter that is refused is never - * a filter that is dropped. - * - * ## Every slot the door refuses inside a relation (#20116) - * - * The analytics door hands each entry inside a nested relation to the whole - * comparand-shape face and to its `$null` / `$exists` flag check, not only to - * the equality arm — so a `null` ordering comparand, a non-list `$in` / `$nin`, - * a `null` list member, a malformed `$between` or a `null`, blank or - * `{ $field }` endpoint, an array under `$ne` and a non-boolean flag inside a - * relation all failed on chart while this walk saved them. Each such entry is - * now asked of `reportQueryFaceRefusals`, the function `FilterConditionSchema`'s - * walk asks about the entries IT reaches, so one slot is judged one way - * whichever walk finds it, and a rule added there reaches this reach too. This - * walk still decides only WHERE (the analytics door's traversal); the verdict - * and the words are that function's. The equality lists above are two of its - * arms and keep their sentence and their path. The list operators keep their - * lists (`$in: []` / `$nin: []` included), and the null predicate, a - * `{ $field }` reference as the whole comparand and every scalar pass, because - * the face passes them. - */ -function refuseNestedRelationComparands( - node: unknown, - ctx: z.RefinementCtx, - path: (string | number)[] = [], - insideRelation = false, -): void { - if (!isAnalyticsFilterObject(node)) return; - for (const [key, spec] of Object.entries(node)) { - if (key === '$and' || key === '$or') { - if (Array.isArray(spec)) { - spec.forEach((member, index) => - refuseNestedRelationComparands(member, ctx, [...path, key, index], insideRelation)); - } - continue; - } - if (key === '$not') { - refuseNestedRelationComparands(spec, ctx, [...path, key], insideRelation); - continue; - } - if (key.startsWith('$')) continue; - if (isAnalyticsNestedRelationSpec(spec)) { - refuseNestedRelationComparands(spec, ctx, [...path, key], true); - continue; - } - if (!insideRelation) continue; // `FilterConditionSchema` judges this entry itself - // [#20116] Every slot the analytics door hands to the query faces, asked of - // the one function `FilterConditionSchema`'s own walk asks: an implicit - // comparand, or each operator of an operator map. - if (!isAnalyticsFilterObject(spec)) { - reportQueryFaceRefusals(ctx, [...path, key], key, undefined, spec, FieldOperatorsSchema); - continue; - } - for (const [op, comparand] of Object.entries(spec)) { - if (!op.startsWith('$')) continue; - reportQueryFaceRefusals(ctx, [...path, key, op], key, op, comparand, FieldOperatorsSchema); - } - } -} - -/** - * The optional filter both analytics carriers declare — `DatasetSchema.filter` - * and `DatasetMeasureSchema.filter` — which is `FilterConditionSchema` plus - * {@link refuseNestedRelationComparands}. Every other schema that carries a - * `FilterCondition` keeps the shared schema's reach. - * - * The check sits on the OPTIONAL wrapper, not on `FilterConditionSchema` - * itself: refining the recursive schema would clone it, and the published JSON - * Schema would then inline a second copy of the condition beside the `$ref` it - * carries today. On the wrapper the condition keeps its identity, so the - * published body of `ui/Dataset` and `ui/DatasetMeasure` is unchanged, and the - * new rule is recorded as a dropped refinement at each carrier's `filter` in - * `dropped-refinements.baseline.json` (`z.toJSONSchema()` has no projection for - * it). An absent filter reaches the check as `undefined`, which the walk - * passes. - */ -function analyticsCarrierFilter() { - return FilterConditionSchema.optional().superRefine((filter, ctx) => - refuseNestedRelationComparands(filter, ctx)); -} - /** * Dimension — a groupable axis (e.g. "region", "close_date by quarter"). */ @@ -353,7 +191,7 @@ export const DatasetMeasureSchema = lazySchema(() => strictObject({ * Measure-scoped filter (e.g. only won deals for "won_amount"). [#20080] A * list in the equality slot inside a nested relation is refused on save, as * the analytics door refuses it on chart — see - * {@link refuseNestedRelationComparands}. + * {@link analyticsCarrierFilter} (`./analytics-carrier-filter.ts`). */ filter: analyticsCarrierFilter().meta({ title: 'Filter' }), /** @@ -516,7 +354,7 @@ export const DatasetSchema = lazySchema(() => strictObject({ * Definition-level filter (the dataset's intrinsic scope, e.g. non-deleted). * [#20080] A list in the equality slot inside a nested relation is refused * on save, as the analytics door refuses it on chart — see - * {@link refuseNestedRelationComparands}. + * {@link analyticsCarrierFilter} (`./analytics-carrier-filter.ts`). */ filter: analyticsCarrierFilter().describe('Intrinsic dataset scope filter'), diff --git a/packages/spec/src/ui/report.zod.ts b/packages/spec/src/ui/report.zod.ts index f96fd4d6166..007fe3e539e 100644 --- a/packages/spec/src/ui/report.zod.ts +++ b/packages/spec/src/ui/report.zod.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; -import { FilterConditionSchema } from '../data/filter.zod'; +import { analyticsCarrierFilter } from './analytics-carrier-filter'; import { ChartConfigSchema } from './chart.zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { I18nLabelSchema } from './i18n.zod'; @@ -280,8 +280,14 @@ export const JoinedReportBlockSchema: z.ZodTypeAny = lazySchema(() => strictObje columns: z.array(z.string()).optional().describe('Dimension names across (matrix, dataset-bound)').meta({ title: 'Columns' }), /** Measure names (from the dataset) to display. Dataset-bound only. */ values: z.array(z.string()).optional().describe('Measure names to show (dataset-bound)').meta({ title: 'Values' }), - /** Render-time scope filter, ANDed at query time. Dataset-bound only. */ - runtimeFilter: FilterConditionSchema.optional().describe('Render-time scope filter (dataset-bound)').meta({ title: 'Runtime Filter' }), + /** + * Render-time scope filter, ANDed at query time. Dataset-bound only. + * [#20116] Charted through the analytics `where` door, so it is an analytics + * carrier: a comparand that door refuses INSIDE a nested relation is refused + * on save too — see {@link analyticsCarrierFilter} + * (`./analytics-carrier-filter.ts`). + */ + runtimeFilter: analyticsCarrierFilter().describe('Render-time scope filter (dataset-bound)').meta({ title: 'Runtime Filter' }), /** Result ordering for this block, most significant key first (framework#3916). */ order: z.array(ReportSortSchema).optional().describe('Result ordering, most significant key first').meta({ title: 'Order' }), }).superRefine(checkReportOrder)); @@ -409,8 +415,12 @@ export const ReportSchema = lazySchema(() => strictObject({ * `joined` report is refused — see `blocks[].values`. */ values: z.array(z.string()).optional().describe('Measure names to show'), - /** Render-time scope filter, ANDed at query time. */ - runtimeFilter: FilterConditionSchema.optional().describe('Render-time scope filter'), + /** + * Render-time scope filter, ANDed at query time. [#20116] An analytics + * carrier, like a block's — see {@link analyticsCarrierFilter} + * (`./analytics-carrier-filter.ts`). + */ + runtimeFilter: analyticsCarrierFilter().describe('Render-time scope filter'), /** * Result ordering — most significant key first (framework#3916). *