diff --git a/.changeset/28945-analytics-retired-filter-operator.md b/.changeset/28945-analytics-retired-filter-operator.md new file mode 100644 index 00000000000..32c658590ee --- /dev/null +++ b/.changeset/28945-analytics-retired-filter-operator.md @@ -0,0 +1,12 @@ +--- +'@objectstack/service-analytics': patch +--- + +fix(service-analytics): a retired filter operator (`$regex`, `$options`) in an analytics `where` or dataset `runtimeFilter` is refused with the retirement's prescription naming `$icontains`, as the data door refuses it (#28945) + +Clause-②: no +Metadata-protocol: none + +The analytics filter already refused `$regex` and `$options` with `INVALID_FILTER` / 400, but its message was the generic "Unsupported filter operator … Supported: …" list. The message now says the operator is RETIRED, says `Write "$icontains" instead.`, and then prints the retirement prescription from `RETIRED_FILTER_OPERATORS` word for word, the way the data door's refusal does. When one field constraint carries both keys, the message names both. Every analytics face answers it from one check: `/analytics/query`, `/analytics/dataset/query` (runtime, dataset and measure filters), `/analytics/sql` and the draft preview. The status, the error code and what is refused are unchanged, and an unknown operator keeps the generic list. + +What to do: nothing. A stored widget filter that still uses `$regex` was refused before this change and is still refused. The message now names the fix: write `$icontains`. diff --git a/packages/qa/dogfood/test/analytics-retired-filter-operator.dogfood.test.ts b/packages/qa/dogfood/test/analytics-retired-filter-operator.dogfood.test.ts new file mode 100644 index 00000000000..237aa77c109 --- /dev/null +++ b/packages/qa/dogfood/test/analytics-retired-filter-operator.dogfood.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// END-TO-END gate for #28945: a RETIRED filter operator in a dashboard +// widget's `runtimeFilter` is refused on `POST /analytics/dataset/query` with +// the retirement's prescription, the way the data door refuses it — proven on +// the stock showcase, through the real stack (HTTP → REST → analytics plugin → +// the analytics `where` door). +// +// The D3 retirement entry `filter-regex-options-retired` lists stored dashboard +// widget filters as a stored site and promises `$regex` / `$options` are +// "refused everywhere with INVALID_FILTER / 400 naming the replacement". The +// analytics door gave the envelope with a generic "Unsupported filter operator +// … Supported: …" list instead (measured on this file's base). It now answers +// "… is RETIRED … Write "$icontains" instead", then the spec table's +// prescription, word for word what the data door prints after its own lead-in. +// +// Controls: `$icontains` is served (200), and an unknown operator keeps the +// generic list. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { RETIRED_FILTER_OPERATORS } from '@objectstack/spec/data'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +const DATASET = 'showcase_project_metrics'; + +describe('dogfood: a retired filter operator in an analytics runtimeFilter names its replacement (#28945)', () => { + let stack: VerifyStack; + let token: string; + + beforeAll(async () => { + stack = await bootShowcase(); + token = await stack.signIn(); + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + async function datasetQuery(runtimeFilter: unknown): Promise<{ status: number; body: Record }> { + const res = await stack.apiAs(token, 'POST', '/analytics/dataset/query', { + datasetName: DATASET, + selection: { measures: ['project_count'], runtimeFilter }, + }); + return { status: res.status, body: (await res.json()) as Record }; + } + + /** The prescription the data door prints after its own lead-in. */ + const tail = (message: string): string => message.slice(message.indexOf(' Write "')); + + it('CONTROL: $icontains is served', async () => { + const res = await datasetQuery({ name: { $icontains: 'acme' } }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + }); + + for (const op of ['$regex', '$options'] as const) { + it(`${op} → 400 INVALID_FILTER, RETIRED, "Write "$icontains" instead", the table's prescription`, async () => { + const res = await datasetQuery({ name: { [op]: op === '$regex' ? 'acme' : 'i' } }); + expect(res.status, JSON.stringify(res.body)).toBe(400); + expect(res.body.code).toBe('INVALID_FILTER'); + const message = String(res.body.message); + expect(message).toContain(`Filter operator "${op}" on field "name" is RETIRED`); + expect(message).toContain(`Write "${RETIRED_FILTER_OPERATORS[op].to}" instead.`); + expect(message).not.toContain('Unsupported filter operator'); + // The opening of the prescription, inside what a client reads. + expect(message).toContain(RETIRED_FILTER_OPERATORS[op].why.slice(0, 80)); + }); + } + + it('the same prescription the data door gives for the same key', async () => { + const analytics = await datasetQuery({ name: { $regex: 'acme' } }); + const data = await stack.apiAs(token, 'POST', '/data/showcase_project/query', { where: { name: { $regex: 'acme' } } }); + const dataBody = (await data.json()) as Record; + expect(data.status, JSON.stringify(dataBody)).toBe(400); + expect(dataBody.code).toBe('INVALID_FILTER'); + const dataMessage = String(dataBody.error); + expect(dataMessage).toContain(' Write "$icontains" instead.'); + // The data door cuts its client message at 500 characters with `…`; the + // analytics door serves the message whole. What the data door shows is a + // prefix of what the analytics door shows, from the replacement on. + const shown = tail(dataMessage).replace(/…$/, ''); + expect(shown.length).toBeGreaterThan(200); + expect(tail(String(analytics.body.message)).startsWith(shown)).toBe(true); + }); + + it('CONTROL: an unknown operator keeps the generic list', async () => { + const res = await datasetQuery({ name: { $sounds_like: 'acme' } }); + expect(res.status, JSON.stringify(res.body)).toBe(400); + expect(res.body.code).toBe('INVALID_FILTER'); + expect(String(res.body.message)).toContain('Unsupported filter operator "$sounds_like"'); + }); +}); diff --git a/packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts b/packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts index 7cdd6ec4e29..1db41676a96 100644 --- a/packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts +++ b/packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts @@ -721,7 +721,9 @@ describe('[#5325] analytics `where` — NULL-safe `$not` and the boolean identit }); it('an unknown operator inside a `$not` still THROWS rather than being guarded', async () => { - await expect(ids({ $not: { stage: { $regex: '.*' } } })).rejects.toThrowError(/Unsupported filter operator/); + await expect(ids({ $not: { stage: { $sounds_like: '.*' } } })).rejects.toThrowError(/Unsupported filter operator/); + // [#28945] A RETIRED operator throws too, with the retirement's prescription. + await expect(ids({ $not: { stage: { $regex: '.*' } } })).rejects.toThrowError(/"\$regex" on field "stage" is RETIRED/); await expect(ids({ $not: { $nor: [{ stage: 'won' }] } })).rejects.toThrowError(/Unsupported top-level filter operator/); }); diff --git a/packages/services/service-analytics/src/__tests__/retired-filter-operator-refusal.test.ts b/packages/services/service-analytics/src/__tests__/retired-filter-operator-refusal.test.ts new file mode 100644 index 00000000000..3b331de6f87 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/retired-filter-operator-refusal.test.ts @@ -0,0 +1,354 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28945] A RETIRED filter operator (`$regex`, `$options`) in an analytics + * `where` / `runtimeFilter` is refused with the retirement's prescription, on + * every face, in the `where` door's envelope (`INVALID_FILTER` / 400). + * + * The D3 retirement entry `filter-regex-options-retired` promises the two keys + * are refused everywhere naming the replacement, and the data door says it: + * "… is RETIRED … Write "$icontains" instead", then + * `RETIRED_FILTER_OPERATORS[op].why` verbatim. This door gave the same + * envelope with a generic "Unsupported filter operator … Supported: …" list + * (measured on the showcase before the fix). The prescription now comes from + * the same table, built the way the data door's faces build it, by one pass + * every analytics face runs. + * + * Controls: `$icontains` is served; an unknown, never-declared operator keeps + * the generic list (and the draft preview its own sentence for it); a + * `FilterArray` spelling still refuses a retired name at `isFilterAST`. + * + * Every refusal asserts the ADR-0112 envelope (`code` + `status`); a bare + * `toThrow()` would be satisfied by any uncoded error. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { ObjectQL } from '@objectstack/objectql'; +import { markFilterSubtreeProvenance, RETIRED_FILTER_OPERATORS, type Cube, type FilterCondition } from '@objectstack/spec/data'; +import { DatasetSchema, type Dataset } from '@objectstack/spec/ui'; +import type { AnalyticsQuery, StrategyContext } from '@objectstack/spec/contracts'; + +import { normalizeAnalyticsFilterTree, NO_DATETIME_COLUMNS } from '../strategies/filter-normalizer.js'; +import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js'; +import { ObjectQLStrategy } from '../strategies/objectql-strategy.js'; +import { evaluateAnalyticsQueryOverRows } from '../preview-evaluator.js'; +import { AnalyticsService } from '../analytics-service.js'; + +interface Refusal extends Error { + code?: unknown; + status?: unknown; +} + +const OBJECT = 'acct'; +const ROWS = [ + { id: 'a1', name: 'Acme Corp', amt: 1 }, + { id: 'a2', name: 'ACME ltd', amt: 5 }, + { id: 'a3', name: 'beta', amt: 10 }, +]; +const ALL = ['a1', 'a2', 'a3']; +const FIELDS: Record = { + id: { type: 'text', name: 'id' }, + name: { type: 'text', name: 'name' }, + amt: { type: 'number', name: 'amt' }, +}; +const CUBE: Cube = { + name: 'accts', + sql: OBJECT, + measures: { n: { sql: '*', type: 'count', title: 'n' } }, + dimensions: Object.fromEntries( + [['id', 'string'], ['name', 'string'], ['amt', 'number']].map(([n, t]) => [n, { label: n, type: t, sql: n }]), + ), + public: true, +} as unknown as Cube; +/** What a client reads of a refusal: `CLIENT_MESSAGE_MAX` in `@objectstack/types` (module-private there). */ +const CLIENT_MESSAGE_MAX = 500; +const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } } as never; + +const tree = (where: unknown) => normalizeAnalyticsFilterTree({ where } as never, NO_DATETIME_COLUMNS); + +function refusalOf(run: () => unknown): Refusal { + let out: unknown; + try { + out = run(); + } catch (e) { + return e as Refusal; + } + throw new Error(`expected a refusal, but it answered ${JSON.stringify(out)}`); +} + +async function asyncRefusalOf(run: () => Promise): Promise { + let out: unknown; + try { + out = await run(); + } catch (e) { + return e as Refusal; + } + throw new Error(`expected a refusal, but it answered ${JSON.stringify(out)}`); +} + +/** The retirement's answer: the envelope, the replacement, and the table's prescription verbatim. */ +function expectRetiredRefusal(err: Refusal, op: '$regex' | '$options', field: string): void { + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message.startsWith(`[analytics] Filter operator "${op}" on field "${field}" is RETIRED`)).toBe(true); + expect(err.message).toContain(`Write "${RETIRED_FILTER_OPERATORS[op].to}" instead.`); + expect(err.message).toContain(RETIRED_FILTER_OPERATORS[op].why); + expect(err.message).not.toContain('Unsupported filter operator'); +} + +// ───────────────────────────────────────────────────────────────────────────── + +describe('[#28945] the `where` door answers a retired operator with the retirement prescription', () => { + it('$regex and a dangling $options, each naming $icontains', () => { + expectRetiredRefusal(refusalOf(() => tree({ name: { $regex: 'acme' } })), '$regex', 'name'); + expectRetiredRefusal(refusalOf(() => tree({ name: { $options: 'i' } })), '$options', 'name'); + }); + + it('{ $regex, $options } is ONE mistake: the first key answers and names the other', () => { + const err = refusalOf(() => tree({ name: { $regex: '^acme', $options: 'i' } })); + expectRetiredRefusal(err, '$regex', 'name'); + expect(err.message).toContain('also carries the retired "$options"'); + }); + + it('in every position: under $not, in an $or beside a TRUE arm, in an $and, and on a dotted member', () => { + expectRetiredRefusal(refusalOf(() => tree({ $not: { name: { $regex: 'a' } } })), '$regex', 'name'); + expectRetiredRefusal(refusalOf(() => tree({ $or: [{}, { name: { $regex: 'a' } }] })), '$regex', 'name'); + expectRetiredRefusal(refusalOf(() => tree({ $and: [{ amt: 1 }, { name: { $options: 'i' } }] })), '$options', 'name'); + expectRetiredRefusal(refusalOf(() => tree({ 'acct.name': { $regex: 'a' } })), '$regex', 'acct.name'); + }); + + it('everything the author must write sits inside the 500 characters a client reads', () => { + // [#27098] The table's `why` is ordered for this budget; the lead-in must not spend it. + const regex = refusalOf(() => tree({ $or: [{ id: 'a1' }, { opportunity_name: { $regex: 'a' } }] })).message; + for (const text of ['Write "$icontains" instead.', '`$contains` for a case-sensitive one', 'match in application code']) { + expect(regex.indexOf(text), text).toBeGreaterThan(0); + expect(regex.indexOf(text) + text.length, text).toBeLessThanOrEqual(CLIENT_MESSAGE_MAX); + } + const options = refusalOf(() => tree({ opportunity_name: { $options: 'i' } })).message; + for (const text of ['Delete `$options`', 'write `$icontains` for the case-insensitive match']) { + expect(options.indexOf(text), text).toBeGreaterThan(0); + expect(options.indexOf(text) + text.length, text).toBeLessThanOrEqual(CLIENT_MESSAGE_MAX); + } + }); + + it('a defect an earlier pass judges is still answered first (no existing refusal moves)', () => { + // The comparand-TYPE face judges a plain-object comparand in every position. + const err = refusalOf(() => tree({ amt: { $eq: { a: 1 } }, name: { $regex: 'a' } })); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.message).not.toContain('RETIRED'); + }); + + it('CONTROL: an unknown operator keeps the generic list', () => { + const err = refusalOf(() => tree({ name: { $sounds_like: 'acme' } })); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain('Unsupported filter operator "$sounds_like" on "name"'); + expect(err.message).not.toContain('RETIRED'); + }); + + it('CONTROL: the FilterArray spelling still refuses a retired name at isFilterAST, as before', () => { + const err = refusalOf(() => tree(['name', 'regex', 'acme'])); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + }); + + it('CONTROL: $icontains compiles to its leaf', () => { + expect(tree({ name: { $icontains: 'acme' } })).toEqual({ + kind: 'leaf', member: 'name', operator: 'icontains', values: ['acme'], + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe('[#28945] every analytics face over a real engine: one answer, before anything runs', () => { + let driver: SqliteWasmDriver; + let engine: ObjectQL; + let statements = 0; + let aggregates = 0; + + const runRawSql = async (sql: string, params: unknown[]): Promise[]> => { + statements++; + const result = await driver.execute(sql.replace(/\$\d+/g, '?'), params); + if (Array.isArray(result)) return result as Record[]; + if (result && typeof result === 'object' && 'rows' in (result as Record)) { + return (result as { rows: Record[] }).rows; + } + return []; + }; + const executeAggregate = async (objectName: string, options: Record) => { + aggregates++; + return (await engine.aggregate(objectName, { + where: options.filter, + groupBy: options.groupBy, + aggregations: options.aggregations?.map((a: any) => ({ function: a.method, field: a.field, alias: a.alias })), + context: options.context, + } as never)) as Record[]; + }; + const ctxFor = (nativeSql: boolean): StrategyContext => + ({ + getCube: (name: string) => (name === 'accts' ? CUBE : undefined), + queryCapabilities: () => ({ nativeSql, objectqlAggregate: !nativeSql, inMemory: false }), + getReadScope: () => undefined, + executeRawSql: (_object: string, sql: string, params: unknown[]) => runRawSql(sql, params), + executeAggregate, + declaredFieldType: (_object: string, field: string) => FIELDS[field]?.type, + sqlDialect: () => 'sqlite', + }) as unknown as StrategyContext; + const service = (nativeSql: boolean) => + new AnalyticsService({ + cubes: [CUBE], + logger: quiet, + queryCapabilities: () => ({ nativeSql, objectqlAggregate: !nativeSql, inMemory: false }), + getReadScope: () => undefined, + executeRawSql: (_object: string, sql: string, params: unknown[]) => runRawSql(sql, params), + executeAggregate: executeAggregate as never, + sqlDialect: () => 'sqlite', + }); + const q = (where?: unknown) => + ({ cube: 'accts', dimensions: ['id'], measures: ['n'], ...(where === undefined ? {} : { where }) }) as unknown as AnalyticsQuery; + const ids = (rows: Array>) => rows.map((r) => String(r.id)).sort(); + + beforeAll(async () => { + driver = new SqliteWasmDriver({ filename: ':memory:' }); + (driver as unknown as { logger: unknown }).logger = quiet; + await driver.initObjects([{ name: OBJECT, fields: FIELDS } as never]); + for (const row of ROWS) await driver.create(OBJECT, { ...row }); + engine = new ObjectQL({ logger: quiet }); + engine.registerDriver(driver as never, true); + await engine.init(); + engine.registerObject({ name: OBJECT, label: 'Acct', fields: FIELDS } as never); + }); + afterAll(async () => { + await driver?.disconnect?.(); + }); + + const FACES: Array<[string, (where: unknown) => Promise]> = [ + ['native execute', async (w) => ids((await new NativeSQLStrategy().execute(q(w), ctxFor(true))).rows)], + ['/analytics/sql echo', async (w) => { + const { sql, params } = await new ObjectQLStrategy().generateSql(q(w), ctxFor(false)); + return ids(await runRawSql(sql, params)); + }], + ['ObjectQL engine path', async (w) => ids((await new ObjectQLStrategy().execute(q(w), ctxFor(false))).rows)], + ['AnalyticsService.query (native)', async (w) => ids((await service(true).query(q(w))).rows as never)], + ['AnalyticsService.query (ObjectQL)', async (w) => ids((await service(false).query(q(w))).rows as never)], + ['AnalyticsService.generateSql (ObjectQL)', async (w) => { + const { sql, params } = await service(false).generateSql(q(w)); + return ids(await runRawSql(sql, params)); + }], + ['draft preview', async (w) => ids(evaluateAnalyticsQueryOverRows(q(w), CUBE, ROWS.map((r) => ({ ...r }))).rows as never)], + ]; + + it('CONTROL: the fixture is served, and $icontains gets its rows on every face', async () => { + for (const [face, run] of FACES) { + expect(await run(undefined), `${face}: no where`).toEqual(ALL); + } + for (const [face, run] of FACES.filter(([face]) => face !== 'draft preview')) { + expect(await run({ name: { $icontains: 'acme' } }), `${face}: $icontains 'acme'`).toEqual(['a1', 'a2']); + } + }); + + for (const where of [{ name: { $regex: 'acme' } }, { name: { $options: 'i' } }, { $not: { name: { $regex: '^acme', $options: 'i' } } }]) { + it(`${JSON.stringify(where)}: every face gives the door's sentence, and nothing reaches the database or the engine`, async () => { + const expected = refusalOf(() => tree(where)).message; + for (const [face, run] of FACES) { + statements = 0; + aggregates = 0; + const err = await asyncRefusalOf(() => run(where)); + expect(err.code, face).toBe('INVALID_FILTER'); + expect(err.status, face).toBe(400); + expect(err.message, face).toBe(expected); + expect(statements, `${face}: statements`).toBe(0); + expect(aggregates, `${face}: engine calls`).toBe(0); + } + }); + } + + it('CONTROL: the draft preview keeps its OWN sentence for an unknown operator', async () => { + const err = await asyncRefusalOf(() => FACES[FACES.length - 1][1]({ name: { $sounds_like: 'a' } })); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.message).toContain('is not evaluated by the draft-data preview'); + }); + + it('the prescription is the data door\'s: the engine refuses the same key with the same tail', async () => { + // The SQL driver shows a `where` refusal's own words only for a subtree + // marked as the caller's, as a deployment's SecurityPlugin marks the + // caller's verbatim `where` (#7929/#8220). + const where = markFilterSubtreeProvenance({ name: { $regex: 'acme' } }, 'author'); + const door = await asyncRefusalOf(() => engine.find(OBJECT, { where } as never)); + expect(door.code).toBe('INVALID_FILTER'); + expect(door.status).toBe(400); + const tail = (message: string) => message.slice(message.indexOf(' Write "')); + expect(door.message).toContain(' Write "$icontains" instead.'); + expect(tail(refusalOf(() => tree({ name: { $regex: 'acme' } })).message)).toBe(tail(door.message)); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe('[#28945] the dataset door: a stored or runtime filter carrying a retired key', () => { + const BASE = DatasetSchema.parse({ + name: 'acct_metrics', + label: 'Acct Metrics', + object: OBJECT, + dimensions: [{ name: 'name', label: 'Name', field: 'name', type: 'string' }], + measures: [{ name: 'acct_count', label: 'Accounts', aggregate: 'count' }], + }) as Dataset; + const withScope = (filter: unknown): Dataset => ({ ...BASE, filter } as Dataset); + const withMeasureFilter = (filter: unknown): Dataset => + ({ ...BASE, measures: [{ ...BASE.measures[0], filter }] } as Dataset); + + function svc(extra: Record = {}) { + const calls = { aggregate: 0, raw: 0 }; + const s = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async () => { + calls.raw++; + return []; + }, + executeAggregate: async () => { + calls.aggregate++; + return []; + }, + ...extra, + }); + return { s, calls }; + } + + it('queryDataset: runtimeFilter { name: { $regex } } → the retirement prescription, no statement', async () => { + const { s, calls } = svc(); + const err = await asyncRefusalOf(() => + s.queryDataset!(BASE, { measures: ['acct_count'], dimensions: ['name'], runtimeFilter: { name: { $regex: 'acme' } } as FilterCondition }), + ); + expectRetiredRefusal(err, '$regex', 'name'); + expect(calls.raw).toBe(0); + expect(calls.aggregate).toBe(0); + }); + + it('queryDataset with previewDrafts: the same answer from the draft preview', async () => { + const { s } = svc({ draftRowsResolver: vi.fn(async () => ROWS.map((r) => ({ ...r }))) }); + const err = await asyncRefusalOf(() => + s.queryDataset!( + BASE, + { measures: ['acct_count'], dimensions: ['name'], runtimeFilter: { name: { $options: 'i' } } as FilterCondition }, + undefined, + { previewDrafts: true }, + ), + ); + expectRetiredRefusal(err, '$options', 'name'); + }); + + for (const [label, dataset] of [ + ['the dataset scope filter', withScope({ name: { $regex: 'acme' } })], + ['a measure filter', withMeasureFilter({ name: { $regex: 'acme' } })], + ] as const) { + it(`queryDataset: ${label} → the retirement prescription, no statement`, async () => { + const { s, calls } = svc(); + const err = await asyncRefusalOf(() => s.queryDataset!(dataset, { measures: ['acct_count'], dimensions: ['name'] })); + expectRetiredRefusal(err, '$regex', 'name'); + expect(calls.raw).toBe(0); + }); + } +}); diff --git a/packages/services/service-analytics/src/strategies/filter-normalizer.ts b/packages/services/service-analytics/src/strategies/filter-normalizer.ts index b017b889f71..7ff23441235 100644 --- a/packages/services/service-analytics/src/strategies/filter-normalizer.ts +++ b/packages/services/service-analytics/src/strategies/filter-normalizer.ts @@ -443,6 +443,7 @@ import { isRefusedTextComparand, normalizeFilterComparandTypes, parseFilterAST, + RETIRED_FILTER_OPERATORS, textComparandRefusalReason, VALID_AST_OPERATORS, } from '@objectstack/spec/data'; @@ -1933,6 +1934,88 @@ function assertBooleanNullFlags(node: unknown, path = 'where'): void { }); } +/** + * [#28945] A RETIRED filter operator (`$regex`, `$options`, every key of + * `RETIRED_FILTER_OPERATORS` in `@objectstack/spec/data`) in a field constraint, + * refused in this door's envelope with the retirement's own prescription. + * + * The data door answers `{ name: { $regex: 'acme' } }` with "… is RETIRED … + * Write "$icontains" instead", and the D3 retirement entry + * `filter-regex-options-retired` promises that answer everywhere. This door + * fell through to {@link fieldLeaves}' unsupported-operator list instead: the + * right envelope, but a vocabulary list where the author needs the one-line + * fix, on the door that serves stored dashboard widget filters. + * + * The sentence is the data door's, built the way each of its faces builds it + * (`driver-memory`'s and `driver-sql`'s `retiredFilterOperatorError`, + * `driver-mongodb`, `driver-turso`'s remote transport, `objectql`'s `having`): + * a lead-in naming the operator and the field, ` Write "TO" instead.`, then + * `RETIRED_FILTER_OPERATORS[op].why` VERBATIM, then every OTHER retired key of + * the same constraint, because `{ $regex: '^acme', $options: 'i' }` is ONE + * mistake with ONE fix. No word of the prescription is this package's: the + * spec table is the only place it is written. None of those builders is + * exported from a package this one depends on (a driver is the wrong direction + * for a dependency), so the lead-in is composed here the way each of them + * composes its own: `driver-sql`'s (the data door on a SQL stack) word for + * word, less its "by this driver". + * + * The lead-in names no path, on purpose: a client reads at most 500 characters + * of a refusal (`CLIENT_MESSAGE_MAX`, `@objectstack/types`), and the table's + * `why` puts everything the author must WRITE in its opening sentences for + * exactly that budget (#27098). A path deep in an `$or` would push those + * sentences past the cut; the field name is what the author repairs. + * + * Returns `null` when `op` is not retired, so an unknown operator keeps the + * generic list {@link fieldLeaves} gives it. + */ +function retiredFilterOperatorError(op: string, field: string, siblings: readonly string[]): Error | null { + const guidance = RETIRED_FILTER_OPERATORS[op]; + if (!guidance) return null; + const replacement = guidance.to ? ` Write "${guidance.to}" instead.` : ''; + const alsoRetired = siblings.filter((key) => key !== op && RETIRED_FILTER_OPERATORS[key]); + const also = alsoRetired.length + ? ` The same field constraint also carries the retired ` + + `${alsoRetired.map((key) => `"${key}"`).join(', ')} — one "${guidance.to}" replaces the whole ` + + `shape, so this is ONE mistake with ONE fix, not one per key.` + : ''; + return invalidFilterError( + `[analytics] Filter operator "${op}" on field "${field}" is RETIRED and is no longer evaluated.` + + `${replacement} ${guidance.why}${also}`, + ); +} + +/** + * [#28945] Refuse every retired operator of an object-form `where`, at any + * depth, before any node is built — see {@link retiredFilterOperatorError} for + * the sentence. + * + * Over {@link forEachWhereFieldEntry}'s one traversal, so a retired key is + * found in exactly the positions the shared faces judge (`$and` / `$or` + * members, `$not`, field entries, a nested relation's entries). It is the LAST + * pass of + * {@link normalizeWhereComparands}, so every face of this package answers it + * from the one call: the native execute, the `/analytics/sql` echo, the + * ObjectQL engine path (all through {@link lowerAnalyticsWhere}) and the draft + * preview (which calls {@link normalizeWhereComparands} itself, ahead of its + * own operator vocabulary). Last, so no refusal this door already gave moves: + * a shape, type or flag defect anywhere in the same `where` is still answered + * first, and the only cell that changes is the one that fell to the + * unsupported-operator list. The `FilterArray` spelling needs no pass: it has + * no retired operator name (`isFilterAST` refuses one before lowering). + */ +function assertNoRetiredOperators(node: unknown, path = 'where'): void { + forEachWhereFieldEntry(node, path, (key, spec) => { + if (!isFilterObject(spec)) return; + // Only `$`-keys reach the table: none of them is an `Object.prototype` + // member, so the plain lookup the data door's builders use is exact here. + const ops = Object.keys(spec).filter((k) => k.startsWith('$')); + for (const op of ops) { + const refusal = retiredFilterOperatorError(op, key, ops); + if (refusal) throw refusal; + } + }); +} + /** * [#20035] The analytics `where` door's comparand gate: the shared * comparand-SHAPE face, then the shared comparand-TYPE face, on an object-form @@ -2024,6 +2107,9 @@ export function normalizeWhereComparands(node: T, path = 'where'): T { // [#20040] Then the null flags' boolean domain, before any node is built — // see {@link assertBooleanNullFlags} for the order and why it is here. assertBooleanNullFlags(node, path); + // [#28945] Then a retired operator, with the retirement's prescription — see + // {@link assertNoRetiredOperators} for why it is the last pass. + assertNoRetiredOperators(node, path); return narrowed; }