Skip to content

Commit c4c68ca

Browse files
feat(spec): page-component-filter-record-to-rule-array rewrites a filter on an inline-row block like any other (#20305) (#20660)
Closes #20305 The stored-filter conversion `page-component-filter-record-to-rule-array` rewrites a filter on a block whose rows are inline, as it does on any other block. The decline is removed, and so is the sentence describing it in the conversion's docblock and summary, in the two D3 entries and in the step-18 rationale. Clause-②: no 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6c11ef9 commit c4c68ca

6 files changed

Lines changed: 190 additions & 192 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): the stored-filter conversion rewrites a filter on a block whose rows are inline, as it does on any other block
6+
7+
The ADR-0087 D2 conversion `page-component-filter-record-to-rule-array` no longer leaves every filter of a page component whose rows are inline (`data: { provider: 'value', … }`, a `data` array, or `staticData`) as stored. Such a filter, the binding's `dataSource.filter` included, is now rewritten to the `[{ field, operator, value }, ...]` rule array exactly as it is on a block that queries an object. What still stays as stored, and is still reported as a TODO, is only a filter with a part that has no lossless rule spelling: a combinator, a null value, or an operator the rule vocabulary does not spell. That holds on any block.
8+
9+
Why the conversion declined, and why it no longer needs to: the `object-map`, `object-tree`, `object-calendar` and `object-gantt` blocks match that filter against their own rows in objectui's in-memory data source (`ValueDataSource.find`). The conversion was written against an objectui version whose `find` excluded every row for a rule array, so it left those filters alone and said so in the TODO. The objectui version this repository pins (`.objectui-sha`, the same pin the previous release shipped) lowers a rule array before it matches, and it selects the rows the stored form selected. That was measured over every operator the conversion maps: 114 filters on eight rows, null and missing values included. The same filters select no row on the objectui build just before that fix. So the decline was already protecting nothing: it only left convertible filters unconverted and reported TODOs that no longer needed to exist.
10+
11+
What an operator sees:
12+
13+
- `os migrate meta --stored` now lists such a page as a pending rewrite. It used to list it as a `skipped` row with a TODO. A preview over a database whose only legacy filters sat on inline-row blocks therefore exits 1 until `os migrate meta --stored --apply` rewrites them.
14+
- Until then, every stored-row read replays the same rewrite, so the block reads the rule array and shows the same rows.
15+
- Nothing an author writes is accepted or refused differently. The conversion stays retired from the authoring path, and no schema changes.
16+
17+
The migration entries `element-data-source-and-object-block-filter-rule-array` and `object-grid-default-filters-rule-array`, and the protocol-18 step rationale, no longer say that inline-row filters are left as stored.
18+
19+
ADR-0087 disposition: already registered. This changes the behaviour of the registered D2 conversion `page-component-filter-record-to-rule-array` and edits its two D3 entries. There is nothing new to register.
20+
21+
Clause-②: no

‎packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts‎

Lines changed: 123 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,10 @@
99
* record carrying `$and` / `$or` / `$not` through UNCHANGED; never flatten.
1010
* This file pins:
1111
*
12-
* §1 every ruled shape converts to the exact rule array, at every door kind;
12+
* §1 every ruled shape converts to the exact rule array, at every door kind,
13+
* whether the block queries an object or carries its rows inline;
1314
* §2 a combinator — record key or AST group — is left byte-identical, and so
14-
* is every other shape with no lossless rule spelling, and every filter of
15-
* a component whose rows are inline (the renderer's in-memory matcher reads
16-
* the record form and excludes every row for a rule array);
15+
* is every other shape with no lossless rule spelling;
1716
* §3 an already-converged rule array is the identity, and a second replay is
1817
* a no-op;
1918
* §4 LOSSLESS is a measured property, not a claim: for every operator the
@@ -209,6 +208,84 @@ describe('§1 the ruled subset converts to the exact rule array', () => {
209208
const nested = (((slot.properties as Dict).items as Dict[])[0]!.children as Dict[])[0]!;
210209
expect((nested.properties as Dict).filter).toEqual([{ field: 'a', operator: 'equals', value: 1 }]);
211210
});
211+
212+
describe('a component whose rows are INLINE converts like any other', () => {
213+
// Measured at the objectui pin `dd3f7e1be356`: object-map / -tree /
214+
// -calendar / -gantt hand `filter` to an in-memory ValueDataSource when
215+
// their rows are inline, and its `find` lowers a rule array through the
216+
// grid's own sink before matching — the same rows as the stored form, where
217+
// the fix's parent (no lowering) matched none. So the node's row source
218+
// moves no verdict, and the binding, composed into that same `filter`,
219+
// converts with it.
220+
const INLINE: ReadonlyArray<readonly [string, string, Dict]> = [
221+
['object-map', '`data: { provider: value }`', { data: { provider: 'value', items: [{ stage: 'open' }] } }],
222+
['object-tree', '`data: { provider: value }`', { data: { provider: 'value', items: [{ stage: 'open' }] } }],
223+
['object-gantt', '`data: { provider: value }`', { data: { provider: 'value', items: [{ stage: 'open' }] } }],
224+
['object-calendar', '`staticData`', { staticData: [{ stage: 'open' }] }],
225+
['object-map', 'an EMPTY `staticData` (still the value rung)', { staticData: [] }],
226+
['object-kanban', 'a bare `data` array', { data: [{ stage: 'open' }] }],
227+
];
228+
229+
it.each(INLINE)('%s with %s', (type, _shape, inline) => {
230+
const before = pageWith({
231+
type,
232+
dataSource: { object: 'deal', filter: { owner_id: 'u1' } },
233+
properties: { objectName: 'deal', ...inline, filter: { stage: 'open' } },
234+
});
235+
const { stack, notices, todos } = convert(before);
236+
const component = componentOf(stack);
237+
expect((component.properties as Dict).filter).toEqual([
238+
{ field: 'stage', operator: 'equals', value: 'open' },
239+
]);
240+
expect((component.dataSource as Dict).filter).toEqual([
241+
{ field: 'owner_id', operator: 'equals', value: 'u1' },
242+
]);
243+
// The rows themselves ride along untouched.
244+
for (const [key, value] of Object.entries(inline)) {
245+
expect((component.properties as Dict)[key]).toEqual(value);
246+
}
247+
expect(notices.map((n) => n.path)).toEqual([
248+
'pages[0].regions[0].components[0].dataSource.filter',
249+
'pages[0].regions[0].components[0].properties.filter',
250+
]);
251+
// Nothing left as stored, so nothing reported.
252+
expect(todos).toEqual([]);
253+
});
254+
255+
it('`defaultFilters` on an inline-row grid converts too', () => {
256+
const { value, notices, todos } = (() => {
257+
const { stack, notices: n, todos: t } = convert(pageWith({
258+
type: 'object-grid',
259+
properties: { data: { provider: 'value', items: [] }, defaultFilters: { stage: 'open' } },
260+
}));
261+
return { value: (componentOf(stack).properties as Dict).defaultFilters, notices: n, todos: t };
262+
})();
263+
expect(value).toEqual([{ field: 'stage', operator: 'equals', value: 'open' }]);
264+
expect(notices.map((n) => n.path)).toEqual(['pages[0].regions[0].components[0].properties.defaultFilters']);
265+
expect(todos).toEqual([]);
266+
});
267+
268+
it('control: the same filter on an object-bound block of the same type converts', () => {
269+
for (const data of [undefined, { provider: 'object', object: 'deal' }]) {
270+
const { stack, notices, todos } = convert(
271+
pageWith({
272+
type: 'object-map',
273+
dataSource: { object: 'deal', filter: { owner_id: 'u1' } },
274+
properties: { objectName: 'deal', ...(data ? { data } : {}), filter: { stage: 'open' } },
275+
}),
276+
);
277+
const component = componentOf(stack);
278+
expect((component.properties as Dict).filter).toEqual([
279+
{ field: 'stage', operator: 'equals', value: 'open' },
280+
]);
281+
expect((component.dataSource as Dict).filter).toEqual([
282+
{ field: 'owner_id', operator: 'equals', value: 'u1' },
283+
]);
284+
expect(notices).toHaveLength(2);
285+
expect(todos).toEqual([]);
286+
}
287+
});
288+
});
212289
});
213290

214291
describe('§2 what has no lossless rule spelling is left byte-identical', () => {
@@ -294,83 +371,6 @@ describe('§2 what has no lossless rule spelling is left byte-identical', () =>
294371
expect(todos).toEqual([]);
295372
});
296373

297-
describe('a component whose rows are INLINE keeps every filter as stored', () => {
298-
// Measured at the objectui pin `f8a9d0fb`: object-map / -tree / -calendar /
299-
// -gantt hand `filter` UNLOWERED to an in-memory ValueDataSource when their
300-
// rows are inline, and ValueDataSource matches the record form but excludes
301-
// EVERY row for a rule array. So there the rewrite is not lossless — and the
302-
// binding is composed into that same `filter`, so it stays as stored too.
303-
const INLINE: ReadonlyArray<readonly [string, string, Dict, string]> = [
304-
['object-map', '`data: { provider: value }`', { data: { provider: 'value', items: [{ stage: 'open' }] } }, "(`data: { provider: 'value' }`)"],
305-
['object-tree', '`data: { provider: value }`', { data: { provider: 'value', items: [{ stage: 'open' }] } }, "(`data: { provider: 'value' }`)"],
306-
['object-gantt', '`data: { provider: value }`', { data: { provider: 'value', items: [{ stage: 'open' }] } }, "(`data: { provider: 'value' }`)"],
307-
['object-calendar', '`staticData`', { staticData: [{ stage: 'open' }] }, '(`staticData`)'],
308-
['object-map', 'an EMPTY `staticData` (still the value rung)', { staticData: [] }, '(`staticData`)'],
309-
['object-kanban', 'a bare `data` array', { data: [{ stage: 'open' }] }, '(a `data` array)'],
310-
];
311-
312-
it.each(INLINE)('%s with %s', (type, _shape, inline, named) => {
313-
const before = pageWith({
314-
type,
315-
dataSource: { object: 'deal', filter: { owner_id: 'u1' } },
316-
properties: { objectName: 'deal', ...inline, filter: { stage: 'open' } },
317-
});
318-
const { stack, notices, todos } = convert(before);
319-
const component = componentOf(stack);
320-
expect((component.properties as Dict).filter).toEqual({ stage: 'open' });
321-
expect((component.dataSource as Dict).filter).toEqual({ owner_id: 'u1' });
322-
expect(notices).toEqual([]);
323-
// Both filters would have converted on an object-bound block; here each
324-
// is left as stored and reported, naming the inline shape.
325-
expect(todos.map((t) => t.path)).toEqual([
326-
'pages[0].regions[0].components[0].dataSource.filter',
327-
'pages[0].regions[0].components[0].properties.filter',
328-
]);
329-
for (const todo of todos) {
330-
expect(todo.reason).toContain(`sits on a block whose rows are inline ${named}`);
331-
// A renderer limit at the objectui pin, said as one — not a protocol fact.
332-
expect(todo.reason).toContain('the objectui renderer this release pins cannot match a rule array');
333-
expect(todo.reason).toContain(`the \`${type}\` block`);
334-
}
335-
const frozen = structuredClone(before);
336-
expect(collectConversionNotices(frozen, { includeRetired: true }).stack).toBe(frozen);
337-
});
338-
339-
it('`defaultFilters` on an inline-row grid stays as stored too', () => {
340-
const { value, notices, todos } = (() => {
341-
const { stack, notices: n, todos: t } = convert(pageWith({
342-
type: 'object-grid',
343-
properties: { data: { provider: 'value', items: [] }, defaultFilters: { stage: 'open' } },
344-
}));
345-
return { value: (componentOf(stack).properties as Dict).defaultFilters, notices: n, todos: t };
346-
})();
347-
expect(value).toEqual({ stage: 'open' });
348-
expect(notices).toEqual([]);
349-
expect(todos.map((t) => t.path)).toEqual(['pages[0].regions[0].components[0].properties.defaultFilters']);
350-
});
351-
352-
it('control: the same filter on an object-bound block of the same type converts', () => {
353-
for (const data of [undefined, { provider: 'object', object: 'deal' }]) {
354-
const { stack, notices, todos } = convert(
355-
pageWith({
356-
type: 'object-map',
357-
dataSource: { object: 'deal', filter: { owner_id: 'u1' } },
358-
properties: { objectName: 'deal', ...(data ? { data } : {}), filter: { stage: 'open' } },
359-
}),
360-
);
361-
const component = componentOf(stack);
362-
expect((component.properties as Dict).filter).toEqual([
363-
{ field: 'stage', operator: 'equals', value: 'open' },
364-
]);
365-
expect((component.dataSource as Dict).filter).toEqual([
366-
{ field: 'owner_id', operator: 'equals', value: 'u1' },
367-
]);
368-
expect(notices).toHaveLength(2);
369-
expect(todos).toEqual([]);
370-
}
371-
});
372-
});
373-
374374
it('`defaultFilters` is converted on the grid only', () => {
375375
const { stack, notices, todos } = convert(
376376
pageWith({ type: 'object-kanban', properties: { objectName: 'deal', defaultFilters: { a: 1 } } }),
@@ -481,6 +481,29 @@ describe('§5 what it writes, the doors accept', () => {
481481
expect(refused.error!.issues.some((i) => i.path[0] === 'filter')).toBe(true);
482482
},
483483
);
484+
485+
// The block doors on an inline-row node: what the rewrite writes there is
486+
// what the door takes, so moving these rows off the TODO list refuses nothing.
487+
it.each([
488+
['object-map', { data: { provider: 'value', items: [{ stage: 'open' }] } }],
489+
['object-tree', { data: { provider: 'value', items: [{ stage: 'open' }] } }],
490+
['object-gantt', { data: { provider: 'value', items: [{ stage: 'open' }] } }],
491+
['object-calendar', { staticData: [{ stage: 'open' }] }],
492+
['object-kanban', { data: [{ stage: 'open' }] }],
493+
] as const)('an inline-row `%s`: its block door accepts the conversion, and refuses the source', (type, inline) => {
494+
const source = { stage: 'open', amount: { $gt: 100 } };
495+
const { stack } = convert(pageWith({ type, properties: { objectName: 'deal', ...inline, filter: source } }));
496+
const converted = componentOf(stack).properties as Dict;
497+
const door = ComponentPropsMap[type as keyof typeof ComponentPropsMap] as unknown as {
498+
safeParse: (v: unknown) => { success: boolean; error?: { issues: Array<{ path: PropertyKey[] }> } };
499+
};
500+
const atFilter = (v: unknown): number =>
501+
door.safeParse(v).error?.issues.filter((i) => i.path[0] === 'filter').length ?? 0;
502+
expect(Array.isArray(converted.filter)).toBe(true);
503+
expect(atFilter(converted)).toBe(0);
504+
// Control: the door really judges this key — the unconverted source is refused there.
505+
expect(atFilter({ objectName: 'deal', ...inline, filter: source })).toBeGreaterThan(0);
506+
});
484507
});
485508

486509
describe('§6 the reach is the family, read off the schema', () => {
@@ -599,22 +622,28 @@ describe('§8 the TODO channel — every site left as stored is reported (ruling
599622
expect(todo!.message).toContain(todo!.reason);
600623
});
601624

602-
it('the inline-row branch: a filter that WOULD convert, left as stored because of the node', () => {
603-
const { value, todos } = (() => {
625+
it('an inline-row node is no decline branch: a filter that maps converts there, and reports no TODO', () => {
626+
const { value, notices, todos } = (() => {
604627
const r = convert(pageWith({ type: 'object-map', properties: { staticData: [], filter: { a: 1 } } }));
605-
return { value: (componentOf(r.stack).properties as Dict).filter, todos: r.todos };
628+
return { value: (componentOf(r.stack).properties as Dict).filter, notices: r.notices, todos: r.todos };
606629
})();
607-
expect(value).toEqual({ a: 1 });
608-
expect(todos).toHaveLength(1);
609-
expect(todos[0]!.reason).toContain('sits on a block whose rows are inline (`staticData`)');
630+
expect(value).toEqual([{ field: 'a', operator: 'equals', value: 1 }]);
631+
expect(notices).toHaveLength(1);
632+
expect(todos).toEqual([]);
610633
});
611634

612-
it('on an inline-row node the filter\'s own blocker wins — a combinator is still named', () => {
613-
const { todos } = convert(
635+
it('on an inline-row node a combinator is still a TODO, in the very words an object-bound block gets', () => {
636+
const inline = convert(
614637
pageWith({ type: 'object-map', properties: { staticData: [], filter: { $or: [{ a: 1 }] } } }),
615638
);
616-
expect(todos).toHaveLength(1);
617-
expect(todos[0]!.reason).toContain('carries the combinator `$or`');
639+
const bound = convert(
640+
pageWith({ type: 'object-map', properties: { objectName: 'deal', filter: { $or: [{ a: 1 }] } } }),
641+
);
642+
expect((componentOf(inline.stack).properties as Dict).filter).toEqual({ $or: [{ a: 1 }] });
643+
expect(inline.todos).toHaveLength(1);
644+
expect(inline.todos[0]!.reason).toContain('carries the combinator `$or`');
645+
// Nothing about the rows' source is said, because nothing about it decides.
646+
expect(inline.todos[0]!.reason).toBe(bound.todos[0]!.reason);
618647
});
619648

620649
it('names the block by its type, and by its `id` when it has one', () => {
@@ -674,13 +703,13 @@ describe('§8 the TODO channel — every site left as stored is reported (ruling
674703
expect(todos.map((t) => t.path)).toEqual(['pages[0].regions[0].components[1].properties.filter']);
675704
});
676705

677-
it('the fixture: its two stored-as-is sites are its two TODOs', () => {
706+
it('the fixture: its one stored-as-is site is its one TODO — the inline-row map converts', () => {
678707
const entry = ALL_CONVERSIONS.find((c) => c.id === ID)!;
679-
const { todos } = convert(entry.fixture.before);
708+
const { notices, todos } = convert(entry.fixture.before);
680709
expect(todos.map((t) => [t.path, t.reason.slice(0, 40)])).toEqual([
681710
['pages[0].regions[0].components[1].properties.filter', 'On the `object-kanban` block, this filte'],
682-
['pages[0].regions[0].components[2].properties.filter', 'On the `object-map` block, this filter s'],
683711
]);
712+
expect(notices.map((n) => n.path)).toContain('pages[0].regions[0].components[2].properties.filter');
684713
});
685714

686715
it('reporting writes nothing: every decline yields the same stack with or without a sink', () => {

0 commit comments

Comments
 (0)