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
4 changes: 1 addition & 3 deletions .changeset/19778-preset-entry-carriers.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/spec': patch
---
Expand All @@ -8,14 +8,12 @@

No behaviour moves. No schema, accept set or lint rule is touched, and no export is added, removed or retyped. Every line this change edits in `registry.ts` is a string literal inside that one step-18 entry, which the exported `MIGRATIONS_BY_MAJOR` carries, so what moves in `dist` is prose.

- **The three groups the entry now draws.** They list the carriers measured, not a closed partition; the entry's grep sentence is the catch-all. Each was measured against the built `dist` with a preset comparand (`last_30_days`, and `today` as a `between` endpoint), and an ISO-date dark control reads green in every cell.
- **The groups the entry now draws.** They list the carriers measured, not a closed partition; the entry's grep sentence is the catch-all. Each was measured against the built `dist` with a preset comparand (`last_30_days`, and `today` as a `between` endpoint), and an ISO-date dark control reads green in every cell.
1. Slots typed `FilterConditionSchema`: `DashboardWidgetSchema.filter`, `GlobalFilterOptionsFromSchema.filter`, `DatasetSchema.filter`, `DatasetMeasureSchema.filter`, `ReportSchema.runtimeFilter`, `JoinedReportBlockSchema.runtimeFilter`, `FieldSchema.relatedListFilter` and `FieldSchema.summaryOperations.filter`. A parse of the declaring schema refuses each one at the comparand's own path, and the lint rule reports each one as well.
2. Filters under a key the lint walks whose declared type carries no preset check. These are `ViewFilterRuleSchema` rule arrays (a view's `filter`, a page element's `dataSource.filter`, a page component's `filter` prop) and a Mongo-shape record typed as a loose record rather than `FilterConditionSchema` (a flow `get_record` / `update_record` / `delete_record` node's `config.filter`). These parse green, and the lint rule alone refuses them.
3. A page's `interfaceConfig.filterBy` and a lookup field's `lookupFilters` parse green and also lint green, because the lint's filter walk descends neither key. Neither door refuses them at publish, so the entry now tells the upgrader to sweep both by hand. Two controls back the `filterBy` reading. A malformed `filterBy` value is refused at `interfaceConfig.filterBy.0.value`, so the slot is parsed. The same rule under `interfaceConfig.filter` is refused by the lint, so the key name is what decides. `lookupFilters` takes `gt`, `gte`, `lt` and `lte` as its only ordering spellings (`greater_than`, `after`, `between`, `>` and `$gt` are refused at `lookupFilters.0.operator`), and the by-hand clause names those four.
- **Two more false sentences are narrowed.**
- The `replacement` called the dashboard date-filter positions "the only place any layer ever resolved" a preset name. An analytics query's `timeDimensions[].dateRange` accepts and resolves the names too.
- The `reason` said equality and membership "are NOT judged". That holds for the schema door only. The lint rule refuses a preset in an equality or membership position on a field it can resolve to a declared `date` or `datetime`, while `this_quarter` on a `select` field stays green. Where the filter binds to no object, such as a widget whose `dataset` names no dataset, that arm does not fire.
- **Reach.** Counted over `dist/index.js`, `dist/index.mjs`, `dist/browser/index.js` and `dist/browser/index.mjs`:
- The removed carrier list `page filter, component filter, rollup filter` and each of the four other removed claims read 4 before and 0 after.
- Each of nine sentences unique to the corrected text reads 0 before and 4 after.
- The unchanged dark control `compared false against every row: HTTP 200` reads 4 on both sides.
11 changes: 11 additions & 0 deletions .changeset/19791-filter-walk-rule-array-carriers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/lint": patch
---

`filter-preset-comparand` now judges a list page's `interfaceConfig.filterBy` and a lookup field's `lookupFilters`, consumed filter carriers the shared filter walk never entered (#19791).

Both carriers are rule arrays (`{ field, operator, value }`) whose values reach the engine's `where` verbatim, and neither schema carries a preset check. So `{ field: 'close_date', operator: 'gt', value: 'last_30_days' }` in either one parsed green and linted green, then the engine refused it at query time (`INVALID_FILTER` / 400). The same rule on a component `dataSource.filter` or a view `filter` was already refused. `filterBy` and `lookupFilters` join `FILTER_KEYS`, so `os lint`, `os validate` and the runtime publish gate (for `page` and `object` writes) now refuse it where it is written. Each finding carries its path (`pages[0].interfaceConfig.filterBy[0].value`, `objects[2].fields.account.lookupFilters[0].value`).

- **Which object a condition addresses.** The field-typed arm, which refuses a preset under equality or membership on a `date` / `datetime` field, binds `filterBy` to `interfaceConfig.source`. Without a `source` it falls back to the page's `object`. It binds `lookupFilters` to the field's `reference` and never to the object that owns the field, because the picker queries the referenced object. A `relatedListFilter` on the same field still binds to the owner.
- **`filter-token-unknown` reaches the same two carriers.** An unresolvable placeholder such as `{current_user}` in `filterBy` or `lookupFilters` is now reported, as it already is in a view's `filter`. `{current_user_id}` and the date macros stay clean.
- **What you do:** in a `filterBy` or `lookupFilters` rule, replace a preset name with the `{date-macro}` window the message names (`{ operator: 'gte', value: '{30_days_ago}' }`) or with an ISO date.
7 changes: 7 additions & 0 deletions .changeset/19791-preset-entry-filterby-lookupfilters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@objectstack/spec': patch
---

The shipped ADR-0087 semantic entry `filter-preset-ordering-comparand-refused` drops, from its `surface` and its `reason`, the text that said a page's `interfaceConfig.filterBy` and a lookup field's `lookupFilters` are refused by neither door at publish, and drops, from its `acceptanceCriteria`, the by-hand search it prescribed for those two keys. The `@objectstack/lint` `filter-preset-comparand` rule now walks both keys, so `os lint`, `os validate` and the runtime publish gate (on `page` and `object` writes) refuse `{ field: 'close_date', operator: 'gt', value: 'last_30_days' }` in either one, while the schema parse still accepts it.

Clause-②: no
18 changes: 15 additions & 3 deletions packages/lint/src/filter-walk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,27 @@ type AnyRec = Record<string, unknown>;
/**
* Keys whose subtree is a filter. The one place a filter is authored.
*
* `relatedListFilter` (#8704) is the one member that does not spell the key
* `filter`: it sits flat on a FIELD beside its `relatedList`/`relatedListTitle`/
* `relatedListFilter` (#8704) sits flat on a FIELD beside its `relatedList`/`relatedListTitle`/
* `relatedListColumns` family, so the family naming wins over the filter-key
* convention. It carries a canonical Query-DSL `FilterCondition` (the schema
* door already judges it at parse), and listing it here is what extends the
* three walking rules — tokens, empty combinators, preset comparands — to the
* new position instead of leaving a per-rule hole.
*
* `filterBy` and `lookupFilters` (#19791) are consumed RULE-ARRAY
* carriers that do not spell the key `filter`: a list page's always-on
* base filter (`interfaceConfig.filterBy`, `z.array(ViewFilterRuleSchema)`,
* which the console spreads into the list query beside the view's own
* `filter`) and a lookup field's picker filter (`lookupFilters`, lowered by the
* console to a Mongo `$filter` on the REFERENCED object). Both values reach
* the engine's `where` verbatim. Their schemas carry no preset check, so
* before this entry an ordering preset in either parsed green, linted green.
* The binding half — which object a condition on each carrier
* addresses — is `validate-preset-comparands.ts`'s, not this walk's.
*/
export const FILTER_KEYS: ReadonlySet<string> = new Set(['filter', 'filters', 'runtimeFilter', 'relatedListFilter']);
export const FILTER_KEYS: ReadonlySet<string> = new Set([
'filter', 'filters', 'runtimeFilter', 'relatedListFilter', 'filterBy', 'lookupFilters',
]);

/** One stack collection a caller wants walked. */
export interface FilterSurface {
Expand Down
37 changes: 37 additions & 0 deletions packages/lint/src/validate-filter-tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,43 @@ describe('validateFilterTokens', () => {
).toEqual([]);
});

// [#19791] A list page's `interfaceConfig.filterBy` and a lookup field's
// `lookupFilters` reach the engine's `where` verbatim, where the same two
// placeholder vocabularies resolve.
it('reaches a page filterBy and a lookup field lookupFilters', () => {
const findings = validateFilterTokens({
objects: [{
name: 'invoice',
fields: {
account: {
type: 'lookup',
reference: 'account',
lookupFilters: [
{ field: 'owner', operator: 'eq', value: '{current_user}' },
{ field: 'owner', operator: 'ne', value: '{current_user_id}' },
],
},
},
}],
pages: [{
name: 'deals',
type: 'list',
interfaceConfig: {
source: 'deal',
filterBy: [
{ field: 'owner', operator: 'equals', value: '{user_id}' },
{ field: 'created_at', operator: 'greater_than', value: '{30_days_ago}' },
],
},
}],
});
expect(findings.map((f) => f.path).sort()).toEqual([
'objects[0].fields.account.lookupFilters[0].value',
'pages[0].interfaceConfig.filterBy[0].value',
]);
for (const f of findings) expect(f.rule).toBe(FILTER_TOKEN_UNKNOWN);
});

it('survives a cyclic metadata graph', () => {
const dash: Record<string, unknown> = { name: 'd', widgets: [] };
dash.self = dash;
Expand Down
113 changes: 113 additions & 0 deletions packages/lint/src/validate-preset-comparands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,3 +672,116 @@ describe('validatePresetComparands — arm 2, the FIELD-TYPED equality / members
]);
});
});

// ── [#19791] consumed rule-array carriers the walk never entered ────
//
// A list page's `interfaceConfig.filterBy` and a lookup field's
// `lookupFilters` both reach the engine's `where` verbatim, and neither schema
// carries a preset check. Before `FILTER_KEYS` named them, the card's rule
// `{ field: 'close_date', operator: 'gt', value: 'last_30_days' }` parsed
// green AND linted green on both, while the identical rule on a component
// `dataSource.filter` was refused.
describe('validatePresetComparands — page filterBy and lookup-field lookupFilters (#19791)', () => {
const card = { field: 'close_date', operator: 'gt', value: 'last_30_days' };
// `close_date` is a DATE on `crm_deal` and a SELECT on `crm_region`, whose
// option value collides with a preset name — the pair that tells a binding
// to the right object from a binding to the wrong one.
const carrierObjects = [
{ name: 'crm_deal', fields: { close_date: { type: 'date' }, name: { type: 'text' } } },
{ name: 'crm_region', fields: { close_date: { type: 'select', options: [{ label: 'This Quarter', value: 'this_quarter' }] } } },
];
const listPage = (interfaceConfig: Record<string, unknown>, over: Record<string, unknown> = {}) => ({
objects: carrierObjects,
pages: [{ name: 'deals', type: 'list', interfaceConfig, ...over }],
});
const lookupHolder = (account: Record<string, unknown>, owner: Record<string, unknown> = {}) => ({
objects: [
...carrierObjects,
{ name: 'crm_invoice', fields: { ...owner, account: { type: 'lookup', ...account } } },
],
});

it("refuses the card's rule in a page's filterBy, beside the lit control on the same page", () => {
const findings = validatePresetComparands({
objects: carrierObjects,
pages: [{
name: 'deals', type: 'list',
interfaceConfig: { source: 'crm_deal', filterBy: [card, { field: 'close_date', operator: 'between', value: ['this_week', '2026-01-01'] }] },
components: [{ type: 'list', dataSource: { object: 'crm_deal', filter: [card] } }],
}],
});
expect(findings.map((f) => f.path).sort()).toEqual([
'pages[0].components[0].dataSource.filter[0].value',
'pages[0].interfaceConfig.filterBy[0].value',
'pages[0].interfaceConfig.filterBy[1].value[0]',
]);
const onFilterBy = findings.find((f) => f.path === 'pages[0].interfaceConfig.filterBy[0].value')!;
expect(onFilterBy.severity).toBe('error');
expect(onFilterBy.rule).toBe(FILTER_PRESET_COMPARAND);
expect(onFilterBy.where).toBe('page "deals"');
expect(onFilterBy.message).toContain('"last_30_days" is a dashboard date-range PRESET name');
});

it("refuses the card's rule in a lookup field's lookupFilters, map-form and array-form fields alike", () => {
expect(validatePresetComparands(lookupHolder({ reference: 'crm_deal', lookupFilters: [card] }))
.map((f) => f.path)).toEqual(['objects[2].fields.account.lookupFilters[0].value']);
expect(validatePresetComparands({
objects: [{ name: 'crm_invoice', fields: [{ name: 'account', type: 'lookup', reference: 'crm_deal', lookupFilters: [card] }] }],
}).map((f) => f.path)).toEqual(['objects[0].fields[0].lookupFilters[0].value']);
});

it('judges equality on filterBy against the object interfaceConfig.source names, falling back to the page object', () => {
const eq = { field: 'close_date', operator: 'equals', value: 'this_quarter' };
// `source` is the date object: refused.
expect(validatePresetComparands(listPage({ source: 'crm_deal', filterBy: [eq] })).map((f) => f.path))
.toEqual(['pages[0].interfaceConfig.filterBy[0].value']);
// `source` is the select object while the page's own `object` is the date
// one: the list queries `source`, so the equality is the picklist case.
expect(validatePresetComparands(listPage({ source: 'crm_region', filterBy: [eq] }, { object: 'crm_deal' }))).toEqual([]);
// No `source`: the page's `object`, as validate-page-field-bindings reads it.
expect(validatePresetComparands(listPage({ filterBy: [eq] }, { object: 'crm_deal' })).map((f) => f.path))
.toEqual(['pages[0].interfaceConfig.filterBy[0].value']);
});

it("judges equality on lookupFilters against the field's reference, never the owning object", () => {
const eq = { field: 'close_date', operator: 'eq', value: 'this_quarter' };
// The referenced object declares `close_date` as a date: refused.
expect(validatePresetComparands(lookupHolder({ reference: 'crm_deal', lookupFilters: [eq] })).map((f) => f.path))
.toEqual(['objects[2].fields.account.lookupFilters[0].value']);
// The OWNING object declares a date `close_date`, the referenced one a
// select: binding to the owner would be the #16106 B1 false refusal.
const owner = { close_date: { type: 'date' } };
expect(validatePresetComparands(lookupHolder({ reference: 'crm_region', lookupFilters: [eq] }, owner))).toEqual([]);
// No `reference` to follow: unjudged by arm 2, never the owner.
expect(validatePresetComparands(lookupHolder({ lookupFilters: [eq] }, owner))).toEqual([]);
// A `relatedListFilter` on the SAME field keeps binding to the owner,
// whose rows it filters — one field, two filters, two objects.
const both = validatePresetComparands(lookupHolder(
{ reference: 'crm_region', lookupFilters: [eq], relatedListFilter: { close_date: 'this_quarter' } },
owner,
));
expect(both.map((f) => f.path)).toEqual(['objects[2].fields.account.relatedListFilter.close_date']);
});

it('stays quiet on every legal comparand in both carriers', () => {
const legal = [
{ field: 'close_date', operator: 'gt', value: '{30_days_ago}' },
{ field: 'close_date', operator: 'gte', value: '2026-01-15' },
{ field: 'close_date', operator: 'between', value: ['{week_start}', '{week_end}'] },
{ field: 'name', operator: 'equals', value: 'last_30_days' },
];
// The lookup picker's own flat operator vocabulary (`FieldSchema.lookupFilters`).
const legalLookup = [
{ field: 'close_date', operator: 'gt', value: '{30_days_ago}' },
{ field: 'close_date', operator: 'gte', value: '2026-01-15' },
{ field: 'name', operator: 'eq', value: 'last_30_days' },
];
expect(validatePresetComparands({
objects: [
...carrierObjects,
{ name: 'crm_invoice', fields: { account: { type: 'lookup', reference: 'crm_deal', lookupFilters: legalLookup } } },
],
pages: [{ name: 'deals', type: 'list', interfaceConfig: { source: 'crm_deal', filterBy: legal } }],
})).toEqual([]);
});
});
Loading
Loading