Skip to content
Draft
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
42 changes: 42 additions & 0 deletions .changeset/19751-view-filter-rule-absent-value-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
"@objectstack/spec": patch
---

fix(spec): a stored view filter rule with no value on a value-taking operator is now refused at save instead of failing every query (#19751)

**BREAKING** — an accept-set narrowing on a published authoring surface, pulling `ViewFilterRuleSchema` back to what its own `value` description already declares: every operator outside `in` / `not_in` / `between` and the four unary operators takes a scalar, and only the unary operators ignore the key. The level is `patch` because nothing widens; during the launch window breaking-ness is carried by this banner and the ADR-0087 disposition below, not by the level. The hand-migration prescription is registered under protocol major 18 as `view-filter-rule-absent-value-refused`.

## What changes

A filter rule that omits `value` (or carries `value: undefined`) on `equals`, `not_equals`, `contains`, `not_contains`, `icontains`, `starts_with`, `ends_with`, `greater_than`, `less_than`, `greater_than_or_equal`, `less_than_or_equal`, `before` or `after` used to parse green and then fail at query time: the rule lowers to `[field, operator]`, and the query path refuses that with `400 INVALID_FILTER` ("Filter comparand at … is undefined") — which failed the whole view, not just that rule. It is now refused when the view is saved, at the rule's `value` path, on every carrier of `ViewFilterRuleSchema` (`ListView.filter`, a tab filter, `Page.filterBy`, a related-list filter, a lookup picker filter):

```text
Filter comparand for operator "icontains" on field "name" is undefined. The rule carries no value, …
```

## What stays accepted

- The unary operators `is_empty` / `is_not_empty` / `is_null` / `is_not_null`, with or without a value.
- `in` / `not_in` / `between` refused an absent value before this change and still do, with their own wording.
- `value: null` on a scalar operator is a value (the null predicate), not an absent one, and still parses.

## Migration

For each refused rule, decide what it meant:

```ts
// FROM — no value on an operator that takes one
{ field: 'status', operator: 'equals' }

// TO — a comparison: write the value
{ field: 'status', operator: 'equals', value: 'open' }

// TO — a test for "no value": use an operator that takes none
{ field: 'status', operator: 'is_empty' }
```

A rule that was an unfinished row is deleted. The console's filter builder never saved this shape (it drops a half-filled row before saving), so the rules to look for are hand-authored or written by another tool.

Clause-②: no (narrowing) — no key is added, removed or renamed and no exported symbol moves; the accept set of `ViewFilterRule.value` narrows back to what its published description declares.

<!-- adr-0087: registered view-filter-rule-absent-value-refused -->
14 changes: 12 additions & 2 deletions packages/spec/src/data/filter-icontains-parse-door.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,11 +201,21 @@ describe('#19514 §2 — the view vocabulary refuses the same two comparands', (
expect(rule(value).success).toBe(true);
});

it('ABSENCE is left unjudged — this vocabulary HAS an absent, and no row is about it', () => {
it('ABSENCE is not the table\'s to judge — no row is about it, and this door stays out of it', () => {
// `isRefusedTextComparand(undefined)` answers TRUE, and its docblock hands
// the carve-out to callers with an "absent". `value` is optional on every
// view rule, so an omitted comparand is not a comparand the table judged.
expect(rule().success).toBe(true);
//
// [#19751] It IS refused now — by the value-shape check's absent-value arm,
// which refuses a missing value on every operator that takes one, because
// the query path refuses the lowered `[field, operator]` node. What this pin
// keeps is the half that is this door's: the refusal arrives ONCE and in the
// absent-value arm's words, never in the table's.
const issue = issueAt(rule(), 'value');
expect(issue.message).toContain('Filter comparand for operator "icontains" on field "name" is undefined.');
expect(issue.message).not.toContain('EMPTY STRING');
expect(issue.message).not.toContain('not a string');
expect(issue.message).not.toContain(`"${DOLLAR_SPELLING}"`);
});

it('an ARRAY is the SHAPE arm defect, reported once and with the shape wording', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

// The absent-value half of the coupling #6227 declared, recorded beside its
// array half (`view-filter-rule-scalar-operator-array-refused`) rather than
// amended onto it: that entry's own replacement prose told an upgrading author
// "an omitted value is still an omitted value", and an upgrade guide that
// quietly rewrites a shipped prescription leaves the reader who followed it with
// no trace of why their metadata now fails.
export const entry: SemanticMigration = {
id: 'view-filter-rule-absent-value-refused',
// No backticks in `surface` — build-upgrade-guide renders it inside a code
// span already, and a nested backtick would close it.
surface:
'ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, '
+ 'or present and undefined, on equals, not_equals, contains, not_contains, icontains, '
+ 'starts_with, ends_with, greater_than, less_than, greater_than_or_equal, '
+ 'less_than_or_equal, before or after (an alias spelling of any of them included), on '
+ 'every carrier of ViewFilterRuleSchema',
replacement:
'the value the rule compares against — value: "open" on equals, value: "2026-01-01" on '
+ 'after. A rule that meant "the field has no value" becomes one of the four operators that '
+ 'take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction '
+ 'from their name and still parse with or without a value. A rule that was an unfinished '
+ 'row is deleted. The list operators (in / not_in) and the range operator (between) '
+ 'refused an absent value before this change and still do, in their own words',
reason:
'#19751. The value key\'s own published description has declared since #6227 that every '
+ 'operator outside the list, range and unary sets takes a scalar, and that only the unary '
+ 'operators ignore the key; the refinement implementing the coupling returned early on an '
+ 'absent value for every operator, so a rule with no value parsed green on all thirteen '
+ 'scalar operators. The query path refuses the same rule: both lowerings of a stored rule — '
+ 'the console\'s and the REST lookup-picker route\'s — emit it as the two-element '
+ '[field, operator] node, which the filter-AST lowering reads as an undefined comparand '
+ 'and refuses with INVALID_FILTER / 400, measured for all thirteen operators. Nothing '
+ 'between storage and the query drops the rule, so one such rule failed every query that '
+ 'read its view, the view\'s other rules included. The first-party producer does not write '
+ 'the shape: the console filter builder drops a row whose operator takes a value and whose '
+ 'value is missing before it saves, and the drill-down save-as-view path checks each rule '
+ 'against this schema before persisting it (read at the pinned objectui commit). '
+ 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: '
+ 'there is no value to infer, and writing a value, switching to a unary operator and '
+ 'deleting the rule are three different predicates only the author can choose between. '
+ 'The read path does not re-validate stored rows (the reading the sibling entry '
+ 'view-filter-rule-scalar-operator-array-refused records), so a stored view keeps loading '
+ '— and keeps failing its queries, as it did before this change; what changes is that '
+ 'RE-SAVING it is refused at the value path, naming the operator and the field. '
+ 'ADR-0049 / ADR-0087 / ADR-0112.',
acceptanceCriteria:
'Grep your authored views, pages and object-* blocks for a filter rule that has no value '
+ 'key and whose operator is none of the four unary operators, then decide per rule which '
+ 'of three things it meant: a comparison (write the value), a test for emptiness (switch '
+ 'to is_empty / is_not_empty / is_null / is_not_null), or an unfinished row (delete it). '
+ 'os validate reports each one by path with the operator and the field, so the sweep is '
+ 'mechanical rather than by eye. A view carrying one of these rules was refusing every '
+ 'query before this change, so re-check what it is supposed to show rather than assuming '
+ 'any earlier result set.',
};
55 changes: 55 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13261,6 +13261,61 @@ const step18: MigrationStep = {
+ 'reports no `component-props-unknown-key` / `component-props-invalid` finding for the '
+ 'rail.',
},
// The absent-value half of the coupling #6227 declared, recorded beside its
// array half (`view-filter-rule-scalar-operator-array-refused`) rather than
// amended onto it: that entry's own replacement prose told an upgrading author
// "an omitted value is still an omitted value", and an upgrade guide that
// quietly rewrites a shipped prescription leaves the reader who followed it with
// no trace of why their metadata now fails.
{
id: 'view-filter-rule-absent-value-refused',
// No backticks in `surface` — build-upgrade-guide renders it inside a code
// span already, and a nested backtick would close it.
surface:
'ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, '
+ 'or present and undefined, on equals, not_equals, contains, not_contains, icontains, '
+ 'starts_with, ends_with, greater_than, less_than, greater_than_or_equal, '
+ 'less_than_or_equal, before or after (an alias spelling of any of them included), on '
+ 'every carrier of ViewFilterRuleSchema',
replacement:
'the value the rule compares against — value: "open" on equals, value: "2026-01-01" on '
+ 'after. A rule that meant "the field has no value" becomes one of the four operators that '
+ 'take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction '
+ 'from their name and still parse with or without a value. A rule that was an unfinished '
+ 'row is deleted. The list operators (in / not_in) and the range operator (between) '
+ 'refused an absent value before this change and still do, in their own words',
reason:
'#19751. The value key\'s own published description has declared since #6227 that every '
+ 'operator outside the list, range and unary sets takes a scalar, and that only the unary '
+ 'operators ignore the key; the refinement implementing the coupling returned early on an '
+ 'absent value for every operator, so a rule with no value parsed green on all thirteen '
+ 'scalar operators. The query path refuses the same rule: both lowerings of a stored rule — '
+ 'the console\'s and the REST lookup-picker route\'s — emit it as the two-element '
+ '[field, operator] node, which the filter-AST lowering reads as an undefined comparand '
+ 'and refuses with INVALID_FILTER / 400, measured for all thirteen operators. Nothing '
+ 'between storage and the query drops the rule, so one such rule failed every query that '
+ 'read its view, the view\'s other rules included. The first-party producer does not write '
+ 'the shape: the console filter builder drops a row whose operator takes a value and whose '
+ 'value is missing before it saves, and the drill-down save-as-view path checks each rule '
+ 'against this schema before persisting it (read at the pinned objectui commit). '
+ 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: '
+ 'there is no value to infer, and writing a value, switching to a unary operator and '
+ 'deleting the rule are three different predicates only the author can choose between. '
+ 'The read path does not re-validate stored rows (the reading the sibling entry '
+ 'view-filter-rule-scalar-operator-array-refused records), so a stored view keeps loading '
+ '— and keeps failing its queries, as it did before this change; what changes is that '
+ 'RE-SAVING it is refused at the value path, naming the operator and the field. '
+ 'ADR-0049 / ADR-0087 / ADR-0112.',
acceptanceCriteria:
'Grep your authored views, pages and object-* blocks for a filter rule that has no value '
+ 'key and whose operator is none of the four unary operators, then decide per rule which '
+ 'of three things it meant: a comparison (write the value), a test for emptiness (switch '
+ 'to is_empty / is_not_empty / is_null / is_not_null), or an unfinished row (delete it). '
+ 'os validate reports each one by path with the operator and the field, so the sweep is '
+ 'mechanical rather than by eye. A view carrying one of these rules was refusing every '
+ 'query before this change, so re-check what it is supposed to show rather than assuming '
+ 'any earlier result set.',
},
// The scalar half of the coupling #6227 declared and did not judge. Recorded
// here rather than amended onto `view-filter-rule-value-shaped-by-operator`
// because that entry's own prose states the OPPOSITE reading as accepted, and
Expand Down
Loading
Loading