diff --git a/.changeset/30006-dataset-filter-key-dimension-measure.md b/.changeset/30006-dataset-filter-key-dimension-measure.md new file mode 100644 index 00000000000..0ebd2c56f32 --- /dev/null +++ b/.changeset/30006-dataset-filter-key-dimension-measure.md @@ -0,0 +1,16 @@ +--- +'@objectstack/lint': patch +--- + +fix(lint): a widget filter keyed on a dataset dimension publishes, and a filter key naming a dataset measure is refused with its own message + +Clause-②: yes (widening) +Metadata-protocol: none +Skills-docs: updated here — content/docs/deployment/validating-metadata.mdx + +`widget-filter-field-unknown` and `report-filter-field-unknown` now judge a bare filter key against one accept set, held in `judgeDatasetFilterKeys`, so the two rules cannot drift: a field of the dataset's object (authored or registry-injected), then a dimension of the dataset. + +- **Widened:** a dashboard widget's own `filter` keyed on a dataset dimension's name (`{ state: 'open' }` for a dimension `state` over the field `status`) was refused at publish as an unknown field, although the dataset query binds it through the dimension's field. It now publishes, as the same key on a report's `runtimeFilter` already did. +- **Refused with its own message:** a bare key naming a dataset measure was already refused by both rules as an unknown field. It is now refused as a measure: a filter compares each row before aggregation, so the dataset query would compare the measure's raw column (`{ amount_sum: { $gt: 100 } }` compiles to `amount > 100`, not a filter on the total), or no column at all for a count, derived or duration measure. The fix line names the measure's column when it reads exactly one. A measure that shares a field's name is still the field, and accepted. + +No key that published before is refused now. diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 627a3b0a643..f30f3139d82 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -748,9 +748,10 @@ no package provenance (#9612); a provenance it cannot read narrows nothing at al A dashboard or report write also keeps the base object of each dataset it binds (a widget's `dataset`; a report's `dataset` and each joined block's), because the datasets are never narrowed and the dataset query resolves a filter against that -object whichever package ships it: a filter key the object lacks is refused there -just as it is on a dataset inside the closure (#30005). No other write type keeps -it, so a flow or a list view never reaches an outside object through a dataset. +object whichever package ships it: a filter key that neither the object nor the +dataset's dimensions name is refused there just as it is on a dataset inside the +closure (#30005). No other write type keeps it, so a flow or a list view never +reaches an outside object through a dataset. That changes what a rule can **resolve**, never which rules run, and nothing at this door walks `packages[]`. Two rules are told the closure is in force. A flow whose trigger object is outside it is refused with `flow-trigger-unknown-object` diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index c28f3226709..36fe92a6071 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -485,11 +485,16 @@ const WIDGET_FILTER_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = { 'renders successfully either way: nothing reports the miss.', 'Each key is resolved on the object graph against the dataset\'s object — a dotted ' + '`relationship.field` path hop by hop, a bare name against the authored and registry-injected ' + - 'columns — and the verdict names the hop that failed. This rule judges the KEYS; ' + - '`filter-token-unknown` judges the values in the same subtree. Not reported: an object this ' + - 'stack does not define, or one with no readable field map.', - 'To fix it, filter on a field of the dataset\'s object, or on a `relationship[.relationship].field` ' + - 'path: every hop must be a relationship field (lookup, master_detail, user or tree; ADR-0021 ' + + 'columns, then against the dataset\'s dimensions, which the dataset query binds through each ' + + 'dimension\'s field — and the verdict names the hop that failed. A bare key naming one of the ' + + 'dataset\'s measures is refused with its own message: a filter compares each row before ' + + 'aggregation, so the dataset query would compare the measure\'s raw column (or no column at ' + + 'all, for a count, derived or duration measure), never the aggregated value. ' + + 'This rule judges the KEYS; `filter-token-unknown` judges the values in the same subtree. ' + + 'Not reported: an object this stack does not define, or one with no readable field map.', + 'To fix it, filter on a field of the dataset\'s object, on a dimension of the dataset, or on a ' + + '`relationship[.relationship].field` path: every hop must be a relationship field ' + + '(lookup, master_detail, user or tree; ADR-0021 ' + 'derives every join from the object graph), and the prefix must be declared in the dataset\'s ' + '`include` (`widget-filter-field-not-included` otherwise). The verdict\'s "Did you mean" names ' + 'the nearest field of the object the miss landed on.', @@ -5570,8 +5575,9 @@ const REPORT_FILTER_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = { 'own.', 'Each key is judged as `widget-filter-field-unknown` judges a widget filter key — a dotted ' + '`relationship.field` path hop by hop, a bare name against the authored and registry-injected ' + - 'columns — and one more name is accepted: a bare key naming a dimension of the bound dataset, ' + - 'which the dataset query binds through that dimension. Not reported: a `dataset` that resolves to ' + + 'columns, then against the dimensions of the bound dataset, which the dataset query binds ' + + 'through each dimension\'s field; a bare key naming a measure of the dataset is refused with its ' + + 'own message, as the twin refuses it. Not reported: a `dataset` that resolves to ' + 'nothing (`chart-dataset-unknown` reports that once), an object this stack does not define, or ' + 'one with no readable field map.', 'To fix it, filter on a field of the dataset\'s object, on a dimension of the dataset, or on a ' + diff --git a/packages/lint/src/validate-report-runtime-filter.test.ts b/packages/lint/src/validate-report-runtime-filter.test.ts index ffa27c54aef..23d98633ea8 100644 --- a/packages/lint/src/validate-report-runtime-filter.test.ts +++ b/packages/lint/src/validate-report-runtime-filter.test.ts @@ -123,10 +123,23 @@ describe('report-filter-field-unknown (#29900)', () => { expect(run(s)).toEqual([]); }); + it('refuses a bare key naming a measure, with its own message (#30006)', () => { + const findings = run(stack({ runtimeFilter: { hours_sum: { $gt: 10 } } })); + expect(findings).toHaveLength(1); + const [f] = findings; + expect(f.rule).toBe(REPORT_FILTER_FIELD_UNKNOWN); + expect(f.path).toBe('reports[0].runtimeFilter.hours_sum'); + expect(f.message).toContain('names a measure of dataset "orch_ds2"'); + expect(f.hint).toContain('key the filter by the field "hours"'); + }); + it('accepts what the dashboard twin accepts on the same dataset, and refuses what it refuses', () => { - // The twin judges the field half; a dimension-name key is the report's one - // addition and is excluded here. - const keys = ['status', 'created_at', 'owner.region', 'no_such_field', 'owner.regionn', 'hours.total']; + // [#30006] One accept set: the dimension key and the measure key are in the + // list, and the two rules answer them alike. + const keys = [ + 'status', 'created_at', 'owner.region', 'state', + 'no_such_field', 'owner.regionn', 'hours.total', 'hours_sum', + ]; for (const key of keys) { const s = stack({ runtimeFilter: { [key]: 1 } }, { dashboards: [{ name: 'd', widgets: [{ id: 'w', type: 'metric', dataset: 'orch_ds2', values: ['hours_sum'], filter: { [key]: 1 } }] }], diff --git a/packages/lint/src/validate-report-runtime-filter.ts b/packages/lint/src/validate-report-runtime-filter.ts index 8d90379f1e3..70101c75e1b 100644 --- a/packages/lint/src/validate-report-runtime-filter.ts +++ b/packages/lint/src/validate-report-runtime-filter.ts @@ -17,7 +17,8 @@ * * - `report-filter-field-unknown` (`error`) — a key of the report's * `runtimeFilter` (or of a joined block's) resolves to no column on the bound - * dataset's object graph, and names no dimension of that dataset. + * dataset's object graph, and names no dimension of that dataset — or it names + * one of the dataset's MEASURES, which no filter can key (#30006). * * The judgement is {@link judgeDatasetFilterKeys}, the function the dashboard * twin calls, so the two positions accept the same keys: every authored filter @@ -25,11 +26,12 @@ * `[field, op, value]` triple), dotted relationship paths, and the three skips * every field-existence rule in this package takes (an object this stack does * not define, one with no readable field map, a registry-injected system - * column). One addition, never a refusal the twin does not make: a bare key - * that names a DIMENSION of the bound dataset is accepted, because the dataset - * door resolves a bare `where` key through the cube's members first - * (`dataset-filter-field-binding.ts`, «a key naming only a dimension still - * resolves through that dimension»). + * column). A bare key that names a DIMENSION of the bound dataset is + * accepted, because the dataset door resolves a bare `where` key through the + * cube's members first (`dataset-filter-field-binding.ts`, «a key naming only a + * dimension still resolves through that dimension»); a bare key that names a + * MEASURE is refused with its own message. Both live in the shared judgement + * since #30006, so the dashboard twin accepts and refuses the same keys. * * ## Which dataset a filter is judged against * @@ -51,7 +53,7 @@ import { indexServedObjectGraph, recordsOf } from './object-graph.js'; import type { AnyRec } from './page-walk.js'; -import { indexResolvableDatasets, judgeDatasetFilterKeys } from './validate-widget-bindings.js'; +import { indexResolvableDatasets, judgeDatasetFilterKeys, measureFilterKeyFix } from './validate-widget-bindings.js'; export const REPORT_FILTER_FIELD_UNKNOWN = 'report-filter-field-unknown'; @@ -91,23 +93,18 @@ export function validateReportRuntimeFilters(stack: AnyRec): ReportRuntimeFilter const dataset = dsName ? datasets.get(dsName) : undefined; // Unbound or unresolvable: the shape rule and `chart-dataset-unknown` own it. if (!dsName || !dataset) return; - const dimensions = new Set(); - for (const d of recordsOf(dataset.dimensions)) { - const n = strName(d.name); - if (n) dimensions.add(n); - } for (const verdict of judgeDatasetFilterKeys(graph, dataset, filter, path)) { - if (verdict.kind !== 'unknown') continue; - if (dimensions.has(verdict.field)) continue; // the cube binds a dimension name + if (verdict.kind === 'not-included') continue; // the twin's second id, not this rule's findings.push({ severity: 'error', rule: REPORT_FILTER_FIELD_UNKNOWN, where, path: verdict.path, message: verdict.message, - hint: - `Filter on a field of "${verdict.object}" (dataset "${dsName}"), a dimension of that dataset, ` + - `or a relationship path whose prefix its \`include\` declares.`, + hint: verdict.kind === 'measure' + ? measureFilterKeyFix(verdict) + : `Filter on a field of "${verdict.object}" (dataset "${dsName}"), a dimension of that dataset, ` + + `or a relationship path whose prefix its \`include\` declares.`, }); } }; diff --git a/packages/lint/src/validate-widget-bindings.test.ts b/packages/lint/src/validate-widget-bindings.test.ts index 295c2cd88af..36e982d05d5 100644 --- a/packages/lint/src/validate-widget-bindings.test.ts +++ b/packages/lint/src/validate-widget-bindings.test.ts @@ -1506,8 +1506,8 @@ describe('widget-filter-field-unknown (#14148 limb A)', () => { // alone, and the object's field list is no longer echoed. expect(f.message).toContain('Did you mean "due_date"?'); expect(f.hint).toBe( - 'Filter on a field of "duly_task" (dataset "duly_workload"), or on a relationship path whose prefix that ' + - "dataset's `include` declares.", + 'Filter on a field of "duly_task" (dataset "duly_workload"), a dimension of that dataset, or a relationship ' + + 'path whose prefix its `include` declares.', ); expect(f.hint).not.toContain('business_unit'); }); @@ -1596,6 +1596,63 @@ describe('widget-filter-field-unknown (#14148 limb A)', () => { }); }); +// [#30006] One accept set for a bare key, shared with `report-filter-field-unknown`: +// a field, then a dimension of the dataset; a measure is refused with its own +// message. The dimension `state` is named APART from the field it reads, so +// "a dimension key is accepted" is a different fact from "a field key is". +describe('widget-filter-field-unknown — dataset dimensions and measures (#30006)', () => { + const ds = { + dimensions: [{ name: 'state', field: 'business_unit' }], + measures: [ + { name: 'untouched_over_14d', aggregate: 'count' }, + { name: 'total_estimate', aggregate: 'sum', field: 'estimate' }, + { name: 'estimate', aggregate: 'avg', field: 'estimate' }, + { name: 'estimate_per_task', derived: { op: 'ratio', of: ['total_estimate', 'untouched_over_14d'] } }, + ], + }; + const judge = (filter: unknown) => + idsOf(validateWidgetBindings(widgetOwnStack({ filter }, ds)), WIDGET_FILTER_FIELD_UNKNOWN); + + it('accepts a bare key naming a dimension of the dataset, under `$` combinators too', () => { + expect(judge({ state: 'emea' })).toEqual([]); + expect(judge({ $or: [{ state: 'emea' }, { $not: { state: 'apac' } }] })).toEqual([]); + }); + + it('refuses a key naming a measure, with the measure message and its column as the fix', () => { + const findings = judge({ total_estimate: { $gt: 100 } }); + expect(findings).toHaveLength(1); + const [f] = findings; + expect(f.severity).toBe('error'); + expect(f.path).toBe('dashboards[0].widgets[0].filter.total_estimate'); + expect(f.message).toContain('names a measure of dataset "duly_workload"'); + expect(f.message).toContain('"estimate"'); + expect(f.message).not.toContain('Did you mean'); + expect(f.hint).toContain('key the filter by the field "estimate"'); + }); + + it('refuses a count and a derived measure, which read no single column', () => { + for (const key of ['untouched_over_14d', 'estimate_per_task']) { + const [f, ...rest] = judge({ [key]: { $gt: 1 } }); + expect(rest, key).toEqual([]); + expect(f.message, key).toContain('reads no single column'); + expect(f.hint, key).toContain('a dimension of the dataset'); + } + }); + + it('a measure sharing a field\'s name is the field: accepted, as the dataset door binds it', () => { + expect(judge({ estimate: { $gt: 3 } })).toEqual([]); + }); + + it('CONTROL — an unknown bare key and a dotted dimension-shaped key are still refused as unknown', () => { + const findings = judge({ $and: [{ no_such_key: 1 }, { 'owner.state': 'x' }] }); + expect(findings.map((f) => f.path)).toEqual([ + 'dashboards[0].widgets[0].filter.$and[0].no_such_key', + 'dashboards[0].widgets[0].filter.$and[1].owner.state', + ]); + expect(findings.every((f) => !f.message.includes('names a measure'))).toBe(true); + }); +}); + describe('widget-filter-field-not-included (#14148 limb A, the include clause)', () => { it('errors when a resolvable dotted key traverses an UNDECLARED relationship', () => { const findings = idsOf( diff --git a/packages/lint/src/validate-widget-bindings.ts b/packages/lint/src/validate-widget-bindings.ts index 98d9c050b4e..4b01b2bff32 100644 --- a/packages/lint/src/validate-widget-bindings.ts +++ b/packages/lint/src/validate-widget-bindings.ts @@ -159,7 +159,9 @@ import { * ── The widget's OWN two references (#14148) ───────────────────────────────── * * - `widget-filter-field-unknown` — a KEY of the widget's own `filter` resolves - * to no column on the bound dataset's object graph. + * to no column on the bound dataset's object graph and names no dimension of + * that dataset — or it names one of the dataset's MEASURES, which no filter + * can key (#30006; {@link judgeDatasetFilterKeys}). * - `widget-filter-field-not-included` — that key RESOLVES, but its relationship * prefix is not declared in the dataset's `include`, so ADR-0021 compiles no * join for it and the column is out of the query's reach. @@ -737,8 +739,22 @@ export function indexResolvableDatasets(stack: AnyRec): Map { /** One filter key of a dataset-bound filter that the dataset query cannot satisfy. */ export type DatasetFilterKeyVerdict = - /** The key resolves to no column on the dataset's object graph. */ + /** The key resolves to no column on the dataset's object graph and names no dimension. */ | { kind: 'unknown'; field: string; path: string; object: string; message: string } + /** + * [#30006] The bare key names no column but a MEASURE of the dataset. Not a + * filterable member: the dataset door would compile it to a row-level + * predicate on the measure's column, or to no column at all. + */ + | { + kind: 'measure'; + field: string; + path: string; + object: string; + /** The column the measure aggregates, when it reads exactly one. */ + column?: string; + message: string; + } /** The key resolves, but its relationship prefix is not in the dataset's `include`. */ | { kind: 'not-included'; @@ -757,6 +773,18 @@ export type DatasetFilterKeyVerdict = * (`unknown`), then joinability (`not-included`, an undeclared relationship * prefix the compiler's `assertDeclared` never sees). * + * [#30006] One accept set for both callers' bare keys, so the two positions + * cannot drift: a field of the object (authored or registry-injected) first — + * the dataset door binds a key that names a field to that field even where a + * member shares its name (`dataset-filter-field-binding.ts`) — then a + * DIMENSION of the dataset, which the door binds through that dimension's + * field. A bare key naming a MEASURE is answered with its own verdict + * (`measure`), never accepted: the door reads it as the measure's `sql`, so a + * `sum` measure filters each row on the raw column (`{ amount_sum: { $gt: 100 } }` + * compiles to `amount > 100`, not to a filter on the total), a `count` of rows + * compiles to no column at all, and a derived or duration measure is refused + * with `400 INVALID_FIELD`. + * * Holds no rule id, severity or wording beyond the verdict's own message: the * caller owns those. Returns nothing when the dataset's object cannot be judged * (skips 1 and 2: an object this stack does not define, or one with no readable @@ -776,12 +804,42 @@ export function judgeDatasetFilterKeys( // unknowable base binding would turn it into a finding per filter key. if (!object || !graph.get(object)) return out; const included = joinablePrefixes(dataset.include); + const dimensions = new Set(); + for (const d of recordsOf(dataset.dimensions)) { + if (typeof d.name === 'string') dimensions.add(d.name); + } + const measures = new Map(); + for (const m of recordsOf(dataset.measures)) { + if (typeof m.name === 'string') measures.set(m.name, m); + } + const dsName = typeof dataset.name === 'string' ? dataset.name : '(unnamed)'; walkFilterFieldKeys(filter, path, ({ field, path: at }) => { const verdict = resolveFieldPath(graph, object, field); if (isUnjudgeable(verdict) || !verdict) return; const account = describeFieldPathVerdict(verdict, field, 'filter key'); if (account) { + // A bare key the object lacks may still name a member of the dataset. + const bare = !field.includes('.'); + if (bare && dimensions.has(field)) return; // the door binds the dimension's field + const measure = bare ? measures.get(field) : undefined; + if (measure) { + const column = measureColumn(measure); + out.push({ + kind: 'measure', + field, + path: at, + object, + ...(column ? { column } : {}), + // [#22161] One line of at most 200 characters; why a measure is no + // filter key is the `os explain` entry's. + message: + `filter key "${field}" names a measure of dataset "${dsName}": a filter compares each row ` + + `before aggregation, ` + + (column ? `so it would compare "${column}", never the total` : `and this measure reads no single column`), + }); + return; + } out.push({ kind: 'unknown', field, path: at, object, message: account.message }); return; } @@ -798,6 +856,29 @@ export function judgeDatasetFilterKeys( return out; } +/** + * [#30006] The one-line fix for a filter key that names a measure, shared by + * `widget-filter-field-unknown` and `report-filter-field-unknown` so the two + * say the same thing about the same key. + */ +export function measureFilterKeyFix(verdict: Extract): string { + return verdict.column + ? `To keep only the rows whose "${verdict.column}" matches, key the filter by the field "${verdict.column}"; ` + + `a filter on the aggregated value is not supported.` + : `Filter on a field of "${verdict.object}" or a dimension of the dataset; ` + + `a filter on the aggregated value is not supported.`; +} + +/** + * [#30006] The one column a dataset measure aggregates — its `field` — or + * `undefined` for a measure that reads none or two: a `count` of rows, a + * `derived` combination of other measures, a `duration` between two columns. + */ +function measureColumn(measure: AnyRec): string | undefined { + if (measure.derived || measure.duration) return undefined; + return typeof measure.field === 'string' && measure.field !== '' ? measure.field : undefined; +} + /** * Validate every dashboard widget's dataset binding. Returns the list of * findings (empty = clean). Caller decides how to surface them: `error` @@ -1173,8 +1254,18 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { // the nearest field, so the field list (`account.detail`) is // not echoed; which fields traverse is the `os explain` entry's. hint: - `Filter on a field of "${verdict.object}" (dataset "${dsName}"), or on a relationship ` + - `path whose prefix that dataset's \`include\` declares.`, + `Filter on a field of "${verdict.object}" (dataset "${dsName}"), a dimension of that dataset, ` + + `or a relationship path whose prefix its \`include\` declares.`, + }); + continue; + } + if (verdict.kind === 'measure') { + push({ + severity: 'error', + rule: WIDGET_FILTER_FIELD_UNKNOWN, + path: verdict.path, + message: verdict.message, + hint: measureFilterKeyFix(verdict), }); continue; } diff --git a/packages/metadata-protocol/src/protocol.dashboard-dataset-publish-gate.test.ts b/packages/metadata-protocol/src/protocol.dashboard-dataset-publish-gate.test.ts index dbac1413073..7055ba24e81 100644 --- a/packages/metadata-protocol/src/protocol.dashboard-dataset-publish-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.dashboard-dataset-publish-gate.test.ts @@ -582,6 +582,73 @@ describe('#30005 — a filter key on another package\'s dataset, under the writt }); }); +// ───────────────────────────────────────────────────────────────────────────── +// #30006 — a bare filter key naming a dataset dimension or measure, same door +// ───────────────────────────────────────────────────────────────────────────── +// +// The dataset door binds a bare key naming a DIMENSION through that +// dimension's field (`state` → `status = ?`), so a widget filter keyed on it +// now publishes, as the report twin's already did. A key naming a MEASURE is +// refused by both rules with its own message: the door would compile +// `amount_sum > 100` to the row-level `amount > 100`, never a filter on the +// total. Same harness and `orders_states` dataset as the #29900 block. + +const stateBoard = (filter: Record) => ({ + name: 'orders_board', + label: 'Orders', + widgets: [{ id: 'w1', type: 'metric', title: 'T', dataset: 'orders_states', values: ['amount_sum'], filter }], +}); + +describe('#30006 — dataset dimension and measure filter keys at the publish door', () => { + let warn: ReturnType; + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + afterEach(() => { + warn.mockRestore(); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + + it('a widget filter keyed on a dimension name publishes', async () => { + const { draft, published } = await draftThenPublish('dashboard', 'orders_board', stateBoard({ state: 'open' })); + expect(draft.success).toBe(true); + expect(published.success, JSON.stringify(published?.issues ?? published)).toBe(true); + }); + + it('a report runtimeFilter keyed on a measure name is REFUSED 422 with the measure message', async () => { + const { published, rows } = await draftThenPublish( + 'report', 'orders_report', filteredReport({ amount_sum: { $gt: 100 } }), + ); + expect(published).toBeInstanceOf(Error); + expect(published.status).toBe(422); + expect(published.code).toBe('INVALID_METADATA'); + const issue = published.issues.find((i: any) => i.rule === 'report-filter-field-unknown'); + expect(issue, `issues: ${JSON.stringify(published.issues)}`).toBeDefined(); + expect(issue.path).toBe('reports[0].runtimeFilter.amount_sum'); + expect(issue.message).toContain('names a measure of dataset "orders_states"'); + expect(Array.from(rows.values()).filter((r) => r.type === 'report').map((r) => r.state)).not.toContain('active'); + }); + + it('the widget twin refuses the same measure key the same way', async () => { + const { published } = await draftThenPublish('dashboard', 'orders_board', stateBoard({ amount_sum: { $gt: 100 } })); + expect(published.status).toBe(422); + expect(published.code).toBe('INVALID_METADATA'); + const issue = published.issues.find((i: any) => i.rule === 'widget-filter-field-unknown'); + expect(issue?.path).toBe('dashboards[0].widgets[0].filter.amount_sum'); + expect(issue.message).toContain('names a measure of dataset "orders_states"'); + }); + + it('CONTROL — an unknown key on the dimension board is still refused as unknown', async () => { + const { published } = await draftThenPublish('dashboard', 'orders_board', stateBoard({ state: 'open', no_such_field: 'x' })); + expect(published.status).toBe(422); + expect(published.code).toBe('INVALID_METADATA'); + expect(published.issues.map((i: any) => [i.rule, i.path])).toEqual([ + ['widget-filter-field-unknown', 'dashboards[0].widgets[0].filter.no_such_field'], + ]); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // #16225 — the stub answers the table it was asked about, and only that one // ─────────────────────────────────────────────────────────────────────────────