Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/30006-dataset-filter-key-dimension-measure.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 4 additions & 3 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
20 changes: 13 additions & 7 deletions packages/lint/src/rule-explanations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -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 ' +
Expand Down
19 changes: 16 additions & 3 deletions packages/lint/src/validate-report-runtime-filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }] }],
Expand Down
31 changes: 14 additions & 17 deletions packages/lint/src/validate-report-runtime-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,21 @@
*
* - `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
* shape (`$and` / `$or` / `$not`, the `{ field, operator }` rule, the
* `[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
*
Expand All @@ -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';

Expand Down Expand Up @@ -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<string>();
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.`,
});
}
};
Expand Down
61 changes: 59 additions & 2 deletions packages/lint/src/validate-widget-bindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading