Skip to content
Merged
42 changes: 42 additions & 0 deletions .changeset/10789-render-time-filter-refusals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
'@object-ui/core': patch
'@object-ui/react': patch
'@object-ui/fields': patch
'@object-ui/plugin-charts': patch
'@object-ui/plugin-dashboard': patch
'@object-ui/plugin-report': patch
'@object-ui/plugin-list': patch
---

A filter refused as malformed (`INVALID_FILTER`) no longer throws out of render or out of a click
handler at the sites that merge filters there. That holds whether this layer's filter converter
refused it or, at the drill seam, the spec's own `parseFilterAST`. Each site now surfaces the
refusal the way its siblings already do (objectui#10789). The filter stays **refused**: nothing
that was refused is accepted, and no site falls back to "no filter".

- **`ElementDataSourceGate` / `useElementDataSource`** (`@object-ui/react`): the gate's merge of
the component's `filter` with the composed binding, and the merge of a saved view's filter
with the binding's own, lowered through the throwing converter inside render. A refused
filter from either now makes the binding `missing` with a new optional `filterRefusal` on the
result (`error` carries its message). The gate then draws, in place of the block, the
malformed-filter notice the wrapped blocks draw: `view.malformedFilter`, naming the refused
operator or field. `element:record_picker`, which reads the same hook, shows its
configuration-error panel with the refusal.
- **`PeoplePicker` / `RecordPickerDialog`** (`@object-ui/fields`): the recents merge and the
dialog's `mergedFilter` lower through `toFilterNodeSafely`; a refusal is shown in the picker's
existing error state and no query runs without the filter.
- **Drill-downs** (`ObjectChart`, `ObjectPivotTable`, `DatasetWidget`, `DatasetReportRenderer`):
a widget or report filter refused at the drill seam made the drill click throw. The converter
refuses some (a spec `$not`, say). The spec's `parseFilterAST` refuses others, such as a scalar
on a list operator in either dialect: `[['stage', 'in', 'won']]` or `{ stage: { $in: 'won' } }`.
The drill is now not opened or emitted (never with the scope dropped), and the refusal is logged
with `console.warn`, naming the operator. `composeDrillFilter` (`@object-ui/core`) now throws
one refusal type: the spec's `INVALID_FILTER` error is re-raised as a `FilterOperatorError` with
its message and the same `INVALID_FILTER` / 400 envelope, and any other error passes through
unchanged.
- **`ListView` export** (`@object-ui/plugin-list`): the server export builds its filter inside
its `try`, so a refusal is named in the export menu instead of escaping the click.
- **`convertFiltersToAST`** (`@object-ui/core`): an `$or` member that is the TRUE identity no
longer stops the members after it from being read. `{ $or: [{}, { a: {} }] }` is now refused
like `{ $or: [{ a: {} }, {}] }` already was, instead of answering TRUE (every row); a TRUE
disjunct beside members that lower still absorbs its `$or`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* `element:record_picker` reports a refused binding filter instead of throwing
* out of render — objectui#10789.
*
* The picker composes its binding through `useElementDataSource`, whose render
* `useMemo` called `composeElementDataSource` — and that merge of the saved
* view's filter with the binding's own used the THROWING converter form. A
* malformed binding filter beside a view filter threw a `FilterOperatorError`
* into the nearest error boundary.
*
* The hook now reports it as `missing` with the refusal, which is the state
* this block already answers with its configuration-error panel — so the
* picker says what is wrong, and never queries without the filter.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { AdapterCtx, SchemaRenderer } from '@object-ui/react';
// Registers `element:record_picker` at module scope (the objectui#3010 rule).
import '../renderers';

const HOT_VIEW = { name: 'hot', filter: [['rating', '=', 'hot']] };

function makeAdapter() {
return {
find: vi.fn().mockResolvedValue({ data: [{ id: 'a1', name: 'Acme' }] }),
getObjectSchema: vi.fn().mockResolvedValue({ name: 'account', fields: {}, listViews: { hot: HOT_VIEW } }),
};
}

const renderPicker = (schema: Record<string, unknown>, adapter: ReturnType<typeof makeAdapter>) =>
render(
<AdapterCtx.Provider value={adapter as any}>
<SchemaRenderer schema={{ type: 'element:record_picker', id: 'picker', ...schema } as any} />
</AdapterCtx.Provider>,
);

describe('element:record_picker — a refused binding filter (objectui#10789)', () => {
it('renders the configuration-error panel carrying the refusal, and queries nothing', async () => {
const adapter = makeAdapter();
const { queryByTestId, getByTestId, container } = renderPicker(
{ dataSource: { object: 'account', view: 'hot', filter: { name: { $regex: '^A' } } } },
adapter,
);

await waitFor(() => expect(queryByTestId('record-picker-datasource-error')).not.toBeNull());
const panel = getByTestId('record-picker-datasource-error');
// The refusal's own sentence, naming the operator the author wrote.
expect(panel.textContent).toContain('$regex');
expect(panel.getAttribute('role')).toBe('alert');
// Not the error boundary's "failed to render" banner.
expect(container.textContent).not.toMatch(/failed to render/i);
// Refused, not dropped: no query without the view's filter.
expect(adapter.find).not.toHaveBeenCalled();
expect(queryByTestId('record-picker')).toBeNull();
});

it('CONTROL — a well-formed binding filter still queries with both filters', async () => {
const adapter = makeAdapter();
renderPicker(
{ dataSource: { object: 'account', view: 'hot', filter: { name: 'Acme' } } },
adapter,
);
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
const json = JSON.stringify(adapter.find.mock.calls[0][1].$filter);
expect(json).toContain('rating');
expect(json).toContain('Acme');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* `composeDrillFilter` throws ONE refusal type — objectui#10789.
*
* The seam refuses at two steps. `mergeFilterNodes` throws a
* `FilterOperatorError`; the spec's `parseFilterAST`, which judges comparand
* shapes the converter passes through, threw a plain `Error` carrying
* `code: 'INVALID_FILTER'` / `status: 400`. The drill callers catch the first
* type only, so an array-dialect widget filter such as
* `[['stage', 'in', 'won']]` (never judged by the converter) still threw the
* chart into its error boundary on a click.
*
* - §1 — both spec-refused spellings come out as a `FilterOperatorError`,
* asserted on the ENVELOPE (`code`, `httpStatus`) and on the refusal's own
* sentence naming the operator.
* - §2 — only `INVALID_FILTER` is re-raised; any other error from the spec
* passes through untouched (no broad catch).
* - §3 — CONTROL: a well-formed pair still composes.
*/

import { describe, it, expect, vi } from 'vitest';
import { composeDrillFilter } from '../drill-down';
import { FilterOperatorError } from '../filter-converter';

function refusalOf(call: () => unknown): unknown {
try {
call();
} catch (error) {
return error;
}
return undefined;
}

describe('objectui#10789 — the spec’s refusal leaves the drill seam as a FilterOperatorError', () => {
it.each([
['array dialect, scalar on a list operator', [['stage', 'in', 'won']]],
['object dialect, scalar on a list operator', { stage: { $in: 'won' } }],
])('%s', (_label, widgetFilter) => {
const thrown = refusalOf(() => composeDrillFilter(widgetFilter, { region: 'emea' }));
expect(thrown).toBeInstanceOf(FilterOperatorError);
const error = thrown as FilterOperatorError;
expect(error.code).toBe('INVALID_FILTER');
expect(error.httpStatus).toBe(400);
// The spec's own sentence, verbatim, naming the operator it refused.
expect(error.message).toContain('$in');
});
});

describe('objectui#10789 — only INVALID_FILTER is re-raised', () => {
it('any other error from the spec passes through untouched', async () => {
// A FRESH module graph with the spec's lowering replaced, undone in
// `finally`: the `unit` project runs with `isolate: false`, so a
// module-scope `vi.mock` would depend on whether an earlier file in the
// worker had already loaded this seam — and would leak into later files.
const defect = new TypeError('not a refusal');
vi.resetModules();
vi.doMock('@objectstack/spec/data', async (importOriginal) => {
const actual = await importOriginal<typeof import('@objectstack/spec/data')>();
return {
...actual,
parseFilterAST: (() => {
throw defect;
}) as typeof actual.parseFilterAST,
};
});
try {
const fresh = await import('../drill-down');
expect(refusalOf(() => fresh.composeDrillFilter({ region: 'emea' }, { stage: 'won' }))).toBe(defect);
} finally {
vi.doUnmock('@objectstack/spec/data');
vi.resetModules();
}
});
});

describe('objectui#10789 — CONTROL', () => {
it('a well-formed pair still composes to the object dialect', () => {
expect(composeDrillFilter({ region: 'emea' }, { stage: 'won' })).toEqual({
$and: [{ region: 'emea' }, { stage: 'won' }],
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* A TRUE disjunct no longer hides a refused sibling — objectui#10789, item 4.
*
* ## What was wrong
*
* `lowerLogicalGroup` returned `undefined` (the TRUE identity, objectui#5322)
* the moment an `$or` member lowered to nothing, so every member AFTER it was
* never read. The refusal of a malformed member therefore depended on MEMBER
* ORDER: `{ $or: [{ a: {} }, {}] }` was refused (objectui#9164), while
* `{ $or: [{}, { a: {} }] }` answered TRUE — every row — for the same two
* members. One authored filter, two fates, decided by the order the author
* happened to write the disjuncts in.
*
* ## What the assertions are
*
* - §1 — both member orders are refused, on the ENVELOPE (`code`,
* `httpStatus`, the `field` subject), never on `toThrow()` alone.
* - §2 — the absorption itself is unchanged: a TRUE disjunct beside members
* that lower still answers TRUE, in both orders.
*/

import { describe, it, expect } from 'vitest';
import { convertFiltersToAST, FilterOperatorError } from '../filter-converter';

function refusalOf(call: () => unknown): { thrown: unknown; returned: unknown } {
try {
return { thrown: undefined, returned: call() };
} catch (error) {
return { thrown: error, returned: undefined };
}
}

describe('objectui#10789 — a refused `$or` member is refused in EITHER member order', () => {
it.each([
['TRUE disjunct first', { $or: [{}, { a: {} }] }],
['TRUE disjunct last', { $or: [{ a: {} }, {}] }],
['TRUE-identity group first', { $or: [{ $and: [] }, { a: {} }] }],
])('%s — refused with the INVALID_FILTER envelope naming the field', (_label, filter) => {
const { thrown, returned } = refusalOf(() => convertFiltersToAST(filter));
expect(returned).toBeUndefined();
expect(thrown).toBeInstanceOf(FilterOperatorError);
const error = thrown as FilterOperatorError;
expect(error.code).toBe('INVALID_FILTER');
expect(error.httpStatus).toBe(400);
expect(error.field).toBe('a');
});

it('a non-object member after a TRUE disjunct is refused too', () => {
// The member-shape check sits in the same loop the early return cut short.
const { thrown } = refusalOf(() => convertFiltersToAST({ $or: [{}, 5] as unknown[] }));
expect(thrown).toBeInstanceOf(FilterOperatorError);
expect((thrown as FilterOperatorError).code).toBe('INVALID_FILTER');
expect((thrown as FilterOperatorError).operator).toBe('$or');
});
});

describe('objectui#10789 — CONTROL: a TRUE disjunct still absorbs its `$or`', () => {
it.each([
['TRUE disjunct first', { $or: [{}, { status: 'open' }] }],
['TRUE disjunct last', { $or: [{ status: 'open' }, {}] }],
])('%s — lowers to the TRUE identity', (_label, filter) => {
expect(convertFiltersToAST(filter)).toBeUndefined();
});
});
38 changes: 31 additions & 7 deletions packages/core/src/utils/drill-down.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
import { parseFilterAST } from '@objectstack/spec/data';
import type { DrillDownConfig } from '@object-ui/types';

import { mergeFilterNodes } from './filter-converter.js';
import { FilterOperatorError, mergeFilterNodes } from './filter-converter.js';

/**
* Generic click payload. Pivots provide row/col, charts provide
Expand Down Expand Up @@ -243,15 +243,39 @@ export function isDrillEnabled(config: DrillDownConfig | undefined): boolean {
* the `INVALID_FILTER` / 400 envelope. Such a filter already fails the widget's
* OWN query for the same reason, so the drill and the chart now agree instead of
* the drill quietly sending something the chart could not.
*
* ## One refusal type out of this seam (objectui#10789)
*
* The seam has TWO refusing steps, and they used to throw two different
* things. `mergeFilterNodes` throws a {@link FilterOperatorError}. The spec's
* `parseFilterAST`, which judges comparand shapes the converter passes
* through, throws a plain `Error` that carries the same envelope as own
* properties: `code: 'INVALID_FILTER'` and `status: 400`. An array-dialect
* widget filter holds no rule objects, so the converter never judges it, and
* `[['stage', 'in', 'won']]` (a scalar on a list operator) is refused only
* there. So is the object spelling `{ stage: { $in: 'won' } }`.
*
* Every caller catches the refusal as a `FilterOperatorError`: the chart,
* pivot and dataset drills report it and open nothing (never a filter-less
* drill). So the spec's refusal is re-raised here AS one, with its message
* verbatim and the same `INVALID_FILTER` / 400 envelope, rather than taught to
* each caller as a second shape. Only an error whose `code` is
* `INVALID_FILTER` is re-raised; anything else from `parseFilterAST` is a
* defect rather than a statement about the author's filter, and passes
* through untouched.
*/
export function composeDrillFilter(
widgetFilter: unknown,
drillFilter: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
// `FilterCondition` is the spec's object-dialect filter; the drill sinks type
// the same value as `Record<string, unknown>`, and this is the one seam where
// the two names meet.
return parseFilterAST(mergeFilterNodes(widgetFilter, drillFilter)) as
| Record<string, unknown>
| undefined;
const merged = mergeFilterNodes(widgetFilter, drillFilter);
try {
// `FilterCondition` is the spec's object-dialect filter; the drill sinks
// type the same value as `Record<string, unknown>`, and this is the one
// seam where the two names meet.
return parseFilterAST(merged) as Record<string, unknown> | undefined;
} catch (error) {
if ((error as { code?: unknown } | null)?.code !== 'INVALID_FILTER') throw error;
throw new FilterOperatorError((error as Error).message);
}
}
12 changes: 11 additions & 1 deletion packages/core/src/utils/filter-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,13 @@ function lowerLogicalGroup(
}

const children: FilterNode[] = [];
// Set when an `$or` member is the TRUE identity. Recorded, not returned
// on the spot (objectui#10789): the members AFTER it still have to be read,
// or a malformed one is refused only when it happens to come FIRST —
// `{ $or: [{ a: {} }, {}] }` refused while `{ $or: [{}, { a: {} }] }`
// answered TRUE, the same two members with two fates. The absorption itself
// is unchanged; it is decided after the loop, once every member was judged.
let absorbed = false;
for (const child of value) {
if (child === null || typeof child !== 'object' || Array.isArray(child)) {
throw new FilterOperatorError(
Expand All @@ -261,12 +268,15 @@ function lowerLogicalGroup(
// child either way: an object in AST child position makes `isFilterAST`
// false (measured), and the wire face answers `400 INVALID_FILTER` for
// the whole filter.
if (keyword === 'or') return undefined;
if (keyword === 'or') absorbed = true;
continue;
}
children.push(lowered as FilterNode);
}

// A TRUE disjunct absorbs its `$or` (#5322) — now that every member was read.
if (absorbed) return undefined;

// Every conjunct reduced to TRUE, so the `$and` constrains nothing.
if (children.length === 0) return undefined;

Expand Down
Loading
Loading