Skip to content

Commit b386caf

Browse files
committed
test(app-todo): the derive-route reverse test now pins the refusal envelope (#8296)
`derived-flag-removal.test.ts` registers a test-local formula-shaped object (`derived_task`, invented by that file) to record why #7226 removed two inert flags rather than deriving them, and it pinned the exact behaviour #8296 abolishes: filtering a formula answering 0 rows with no error. Its three filtering assertions now assert the rejection envelope (status 400, INVALID_FIELD, field, object) instead of an empty array, and the `it` title no longer claims "0 rows, no error". #7226's decision is unchanged and its reasoning is stronger: a formula field still materialises no column and still cannot carry a predicate, so the eight app filters that named those flags still could not have worked. Only the failure mode changed, from an invisible zero to a named 400 -- which is the exception this very docblock had named as the safe design. Both docblocks are rewritten to state that. The read/projection half (a formula COMPUTES both flags correctly) and the stored-column CONTROL assertions are untouched; nothing under examples/app-todo/src/ or objectstack.config.ts is touched, and that app declares no formula field at all. The changeset's blast-radius sentence is corrected in the same commit: the original sweep covered app source and missed test files, which is where current behaviour is pinned and therefore where a behaviour change lands first. No app metadata filters a formula field -- that half held. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
1 parent c8d18b0 commit b386caf

2 files changed

Lines changed: 75 additions & 23 deletions

File tree

‎.changeset/filter-formula-field-refusal.md‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,5 +65,19 @@ invent the stored column, and it must not filter post-hoc after the formulas are
6565
evaluated, because the driver has already applied `limit` / `offset`, so a
6666
post-hoc predicate would filter an arbitrary PAGE. Grep your saved reports,
6767
flows, dashboards and view filters for a filtered field whose object declares it
68-
as a `formula`. Every shipped example app in this repo was swept: none filters on
69-
one, so nothing in-tree needed changing.
68+
as a `formula`.
69+
70+
**In-tree sweep — source AND tests.** No shipped example app's *metadata* filters
71+
a formula field: the ones the examples declare (`crm_contact.full_name`,
72+
`crm_opportunity.expected_revenue` / `days_to_close`, `crm_lead.is_closed`,
73+
`showcase_project.budget_remaining`, `showcase_field_zoo.f_formula`) appear only
74+
as view columns, form fields, permission entries and record-level CEL
75+
predicates — never in a `where` / `filter`. One in-tree TEST did filter one and
76+
is updated in this change: `examples/app-todo/test/derived-flag-removal.test.ts`
77+
registers a test-local formula-shaped object to record *why* two inert flags were
78+
removed rather than derived, and pinned the behaviour this refusal abolishes —
79+
filtering a formula answering 0 rows with no error. It now asserts the
80+
`400 INVALID_FIELD` envelope instead; its conclusion is unchanged, because a
81+
formula still cannot be filtered. The first sweep read app source only, which is
82+
the wrong half: current behaviour is pinned in tests, so a behaviour change lands
83+
there first.

‎examples/app-todo/test/derived-flag-removal.test.ts‎

Lines changed: 59 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,24 @@
2121
* A formula computes both correctly — including the temporal one — so the
2222
* obvious repair looks available. It is not, and the reason is a STORAGE fact
2323
* rather than a taste judgment: a `formula` field is virtual, no driver
24-
* materialises a column for it, and so a FILTER naming one matches nothing.
25-
* That is measured here, not asserted — {@link REVERSE} registers the
26-
* formula-shaped object and shows `where { is_completed: false }` answering
27-
* **0 rows with no error** where the stored column answers every row. Deriving
28-
* would have silently emptied the "Due Today" view, the daily reminder flow and
29-
* both open-task reports: a wrong answer traded for an invisible one.
24+
* materialises a column for it, and so a FILTER naming one cannot be applied
25+
* as written. That is measured here, not asserted — {@link REVERSE} registers
26+
* the formula-shaped object and shows `where { is_completed: false }` failing
27+
* where the stored column answers every row. Deriving would have emptied the
28+
* "Due Today" view, the daily reminder flow and both open-task reports.
29+
*
30+
* Since **#8296** that failure is VISIBLE. The engine's filter seam refuses a
31+
* `where` naming a virtual `formula` field with `400 INVALID_FIELD` instead of
32+
* handing the predicate to a driver with no column behind it and answering
33+
* **0 rows with no error** — which is what this test measured when #7226 was
34+
* decided, and the invisible zero was the danger: a wrong answer traded for an
35+
* unobservable one.
36+
*
37+
* The storage fact that decided #7226 is unchanged, so the decision stands and
38+
* its reasoning is stronger, not weaker: a formula field still carries no
39+
* column, a filter naming one still could not have worked, and the eight app
40+
* filters that read these flags still had to move to stored columns. Only the
41+
* failure mode changed — a silent zero became a named 400.
3042
*
3143
* `status` and `due_date` are stored, indexed columns that already carry the
3244
* information, and both are declared dimensions on the `task_metrics` dataset,
@@ -229,10 +241,23 @@ describe('#7226 — the replacement filters really select, on BOTH sides of the
229241
* REVERSE VERIFICATION — the measurement that chose removal over derivation.
230242
*
231243
* Predicted direction, recorded BEFORE running it: the formula field READS
232-
* correctly (so "just derive it" looks right) but is UNFILTERABLE, and the
233-
* failure is silent — 0 rows, no error — rather than an exception. That
234-
* asymmetry is the whole argument: an exception would have been safe, because
235-
* someone would have seen it.
244+
* correctly (so "just derive it" looks right) but is UNFILTERABLE. When #7226
245+
* ran it the failure was silent — 0 rows, no error — rather than an exception,
246+
* and this docblock named that asymmetry as the whole argument: **an exception
247+
* would have been safe, because someone would have seen it.**
248+
*
249+
* **#8296 supplied that exception**, and the second `it` below therefore
250+
* asserts a rejection envelope (`400 INVALID_FIELD`, naming the field and the
251+
* object) where it used to assert an empty array. That is this file's own
252+
* argument being adopted platform-wide — the safe design it asked for is now
253+
* the shipped one — not a correction of it.
254+
*
255+
* The verdict on the derive route is UNCHANGED. A formula field still
256+
* materialises no column and still cannot carry a predicate, so the eight app
257+
* filters that named these flags still could not have worked; removal in
258+
* favour of the stored `status` / `due_date` columns remains the only repair.
259+
* What #8296 changed is that choosing the derive route now fails where someone
260+
* can see it, instead of quietly answering an empty set.
236261
*/
237262
describe('REVERSE — why the derive route was rejected, measured', () => {
238263
/** `todo_task` as it would look on the derive route. */
@@ -276,26 +301,39 @@ describe('REVERSE — why the derive route was rejected, measured', () => {
276301
expect(byId.d.is_overdue).toBe(false); // no due date at all
277302
});
278303

279-
it('...and is UNFILTERABLE: 0 rows, no error — which is why deriving was refused', async () => {
304+
it('...and is UNFILTERABLE: a `where` naming one is REFUSED, 400 INVALID_FIELD (#8296)', async () => {
280305
const ql = await bootEngine(DERIVED);
281306
await ql.insert('derived_task', { id: 'a', subject: 'done', status: 'completed', due_date: '2020-01-01' });
282307
await ql.insert('derived_task', { id: 'b', subject: 'late', status: 'in_progress', due_date: '2020-01-01' });
283308

284309
// A formula field materialises no column on any driver, so the predicate
285-
// matches nothing — and returns cleanly rather than throwing.
286-
expect(await ql.find('derived_task', { where: { is_completed: true } })).toEqual([]);
287-
expect(await ql.find('derived_task', { where: { is_overdue: true } })).toEqual([]);
310+
// cannot be applied as written. When #7226 measured this the engine handed
311+
// it to the driver anyway and answered 0 rows with no error; since #8296
312+
// the engine's filter seam refuses it by name. The full envelope is pinned,
313+
// not merely "it throws": a driver that happened to throw a bare `Error`
314+
// would satisfy a bare `.rejects` while proving nothing about the verdict.
315+
await expect(ql.find('derived_task', { where: { is_completed: true } })).rejects.toMatchObject({
316+
status: 400, code: 'INVALID_FIELD', field: 'is_completed', object: 'derived_task',
317+
});
318+
await expect(ql.find('derived_task', { where: { is_overdue: true } })).rejects.toMatchObject({
319+
status: 400, code: 'INVALID_FIELD', field: 'is_overdue', object: 'derived_task',
320+
});
288321

289322
// THE decisive one. On the old stored boolean this returned EVERY row; as a
290-
// formula it returns NONE. Eight filters in this app relied on exactly this
291-
// predicate ("Due Today", the reminder flow, both open-task reports, three
292-
// distribution charts), so the derive route would have silently emptied
293-
// every one of them.
294-
expect(await ql.find('derived_task', { where: { is_completed: false } })).toEqual([]);
323+
// formula it is not answerable at all. Eight filters in this app relied on
324+
// exactly this predicate ("Due Today", the reminder flow, both open-task
325+
// reports, three distribution charts), so the derive route would have
326+
// broken every one of them — before #8296 by silently emptying them, after
327+
// #8296 by failing loudly on the first query. Neither is a working app,
328+
// which is why these flags were removed rather than derived.
329+
await expect(ql.find('derived_task', { where: { is_completed: false } })).rejects.toMatchObject({
330+
status: 400, code: 'INVALID_FIELD', field: 'is_completed', object: 'derived_task',
331+
});
295332

296333
// CONTROL — the stored column answers correctly on the same rows and the
297-
// same engine, so the emptiness above is about the field being virtual, not
298-
// about the fixture or the driver.
334+
// same engine, so the refusal above is about the field being virtual, not
335+
// about the fixture or the driver. (Assertions unchanged from #7226: the
336+
// anti-vacuity arm never depended on the formula's failure mode.)
299337
expect((await ql.find('derived_task', { where: { status: 'completed' } })).map((r: any) => r.id)).toEqual(['a']);
300338
expect((await ql.find('derived_task', { where: { status: { $ne: 'completed' } } })).map((r: any) => r.id)).toEqual(['b']);
301339
});

0 commit comments

Comments
 (0)