|
| 1 | +--- |
| 2 | +"@objectstack/spec": minor |
| 3 | +--- |
| 4 | + |
| 5 | +feat(spec): a view filter rule's `value` must have the shape its OPERATOR can execute (#6227) |
| 6 | + |
| 7 | +<!-- adr-0087: registered view-filter-rule-value-shaped-by-operator --> |
| 8 | + |
| 9 | +`ViewFilterRuleSchema.value` was declared |
| 10 | +`string | number | boolean | null | (string | number)[]` with **no coupling to |
| 11 | +`operator`**, so every operator accepted every shape. A set operator carrying a |
| 12 | +scalar — `{ field: 'stage', operator: 'not_in', value: 'won' }` — was a |
| 13 | +spec-valid view filter rule. It published cleanly, and then failed when someone |
| 14 | +opened the view. |
| 15 | + |
| 16 | +That made the failure two-stage. #5869 / PR #6209 had already closed the runtime |
| 17 | +half: `assertListComparandShapes` refuses the lowered `{ stage: { $nin: 'won' } }` |
| 18 | +with a named `400 INVALID_FILTER` (a `500 DATABASE_ERROR` before it). Correct |
| 19 | +refusal, wrong moment — by then the author is long gone, and the view had been |
| 20 | +sitting in the store looking valid. The authoring surface now refuses the same |
| 21 | +shapes at publish time, so the feedback reaches the person who can act on it. |
| 22 | + |
| 23 | +**The tightening mirrors the runtime gate exactly — three constraints, one for |
| 24 | +one, and deliberately nothing more:** |
| 25 | + |
| 26 | +| operator | `value` must be | why | |
| 27 | +|---|---|---| |
| 28 | +| `in` / `not_in` (and the `nin` / `notIn` / `notin` spellings) | an array, any length | lowers to `$in` / `$nin` | |
| 29 | +| `between` | exactly `[min, max]` | lowers to `$between` | |
| 30 | +| everything else | unchanged | the query path does not judge them | |
| 31 | + |
| 32 | +It goes no further on purpose. #5685 already ruled on the opposite error — a |
| 33 | +schema stricter than the runtime "in ways the runtime deliberately allows" was |
| 34 | +found to be the wrong side and was widened to match — so these all still parse: |
| 35 | + |
| 36 | +- `in: []` — an empty list is a declared predicate ("matches nothing" / |
| 37 | + "matches everything"), and both drivers say so. |
| 38 | +- `equals: ['a', 'b']` — lowers to a deep-equality comparand every backend answers. |
| 39 | +- `contains: 5` — no backend refuses it. |
| 40 | +- `is_empty: ''` — the null predicates take their direction from the operator |
| 41 | + **name**; `convertComparison` ignores the value position, and the ObjectUI |
| 42 | + client deliberately sends a truthy placeholder there. |
| 43 | + |
| 44 | +The refusal names the operator, the field, the shape received and the shape to |
| 45 | +write: |
| 46 | + |
| 47 | +``` |
| 48 | +Operator "not_in" on field "stage" requires an ARRAY of values. Received a |
| 49 | +string ("won"). "not_in" tests membership of a list — write ["won"] for a single |
| 50 | +value, or use "not_equals" to compare against it. An empty list [] is allowed and |
| 51 | +is a real predicate. This is refused at authoring time because the query path |
| 52 | +refuses it too (400 INVALID_FILTER, #5869). |
| 53 | +``` |
| 54 | + |
| 55 | +**Migration.** A filter rule whose operator is `in`, `not_in` or `between` and |
| 56 | +whose `value` is not an array of the right arity now fails to parse; `os validate` |
| 57 | +and `os lint` report each one by path. Wrap a single value in a list |
| 58 | +(`value: 'won'` → `value: ['won']`) or complete the range's second bound. |
| 59 | + |
| 60 | +Two checks are worth doing where they look unnecessary. A rule reading |
| 61 | +`operator: 'in', value: ''` is an **unfinished** row, not a filter — decide what |
| 62 | +it was meant to select rather than mechanically rewriting it to `[""]`, which is |
| 63 | +a real and different predicate. And a view that already carried one of these |
| 64 | +shapes **was never returning filtered rows**: it answered `400 INVALID_FILTER` on |
| 65 | +render, so re-check what the view is supposed to show rather than assuming the |
| 66 | +old result set was correct. |
| 67 | + |
| 68 | +**Metadata at rest is not rewritten, and there is no D2 conversion.** The read |
| 69 | +path does not re-validate stored rows, so no stored view becomes unreadable; what |
| 70 | +changes is that re-saving one is refused at the write gate, naming `value`. A |
| 71 | +conversion was considered and rejected: this shape was never written by any |
| 72 | +first-party producer (measured — every `in` / `not_in` rule across this repo, |
| 73 | +`objectui` and `cloud` already carries an array) and has never executed, so |
| 74 | +coercing it at load would be the platform guessing intent rather than replaying a |
| 75 | +rename — and it cannot guess honestly, since `between: 5` has no defensible |
| 76 | +second bound. |
| 77 | + |
| 78 | +Two operator vocabularies are now exported — |
| 79 | +`VIEW_FILTER_LIST_VALUE_OPERATORS` and `VIEW_FILTER_PAIR_VALUE_OPERATORS` — so a |
| 80 | +producer can ask the question the schema asks instead of keeping its own copy. |
0 commit comments