Skip to content
Merged
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
40 changes: 40 additions & 0 deletions .changeset/9196-objectview-url-filter-operator-suffix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@object-ui/app-shell': patch
---

Stop the plain object route swallowing a `filter[...]` operator suffix into the
FIELD NAME (objectui#9196). `ObjectView` was a THIRD reader of this URL family,
with its own greedy field capture, so `?filter[amount][gte]=100` on
`/apps/:app/:object` did not fail to match — it matched with the suffix inside
the field, emitting a condition against a field literally named `amount][gte`
that no object declares. The user got a silently WRONG query, not an ignored
parameter and not a refusal.

The route now reads through `drillUrlFilters`, the one module that owns this
family, via a new equality-only arm (`parseUrlEqualityFilterTriples`). Both arms
share ONE key grammar whose field slot excludes brackets, so the swallowing is
structurally impossible rather than fixed at one call site. An operator suffix
this route cannot execute is DROPPED — the posture the `/data` surface already
declares in as many words ("an unknown operator suffix is ignored, never
silently downgraded to equality"). Ignored and downgraded are two different
outcomes and only one is correct: answering `amount = 100` when the URL asked
for `amount >= 100` would be a wrong answer wearing a right answer's shape.

⚠️ Range operators are deliberately NOT added to this route. That would widen
the accepted set of an addressable public surface — a behaviour addition, not a
repair — and it stays with the maintainer. The operator arm remains on the
ADR-0055 `/data` surface only, which this change's tests pin on the same inputs
in the same run.

`patch`, justified by measurement rather than by intuition:

- Nothing an author authored changes — no spec key, no metadata key, no schema
property is touched.
- Nothing stops type-checking for a consumer: `drillUrlFilters` is an internal
module, not re-exported from `@object-ui/app-shell`'s only entry (`.` →
`dist/index.js`), so the new export adds no public signature.
- The `/data` reader was differenced against its previous implementation over a
254-case key corpus: 244 readings identical, 10 changed, and all 10 are keys
whose FIELD NAME contains a `[` (`filter[a[b][gte]`), which previously emitted
a condition against a field named `a[b`. Every delta is a narrowing that
removes a wrong answer; none widens what the surface accepts.
26 changes: 16 additions & 10 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { resolveFilterPlaceholders, DENSITY_MODE_TO_ROW_HEIGHT, normalizeListVie
import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState.js';
import { buildListFilterKey, readListFilterState, writeListFilterState } from './listFilterStorage.js';
import { VALUELESS_FILTER_OPERATORS } from './viewFilterFold.js';
import { parseUrlEqualityFilterTriples } from './drillUrlFilters.js';
import { narrowPersonalizationOverlay, isViewConfigPermissionDeniedError } from '@object-ui/data-objectstack';
const ObjectChart = lazy(() =>
import('@object-ui/plugin-charts').then((m) => ({ default: m.ObjectChart })),
Expand Down Expand Up @@ -2086,6 +2087,17 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
* to a single parent record. Emitted as ObjectQL triples (`[field, '=', value]`)
* which matches the shape consumed by the list view's data fetcher when
* merging base filters.
*
* Read through `drillUrlFilters` — the ONE module that owns this URL family
* — rather than a private regex here (objectui#9196). This route implements
* the EQUALITY arm only: an operator suffix it cannot execute
* (`?filter[amount][gte]=100`) is DROPPED, never downgraded to equality and
* never swallowed into the field name. The greedy capture this replaced did
* the last of those, emitting a condition against a field literally named
* `amount][gte` that no object declares — a silently wrong query, not an
* ignored parameter. Range operators live on the ADR-0055 `/data` surface
* (`parseUrlFilterTriples`); giving them to this route would widen an
* addressable public surface and is deliberately not done here.
*/
// Dep on the serialized `filter[...]` entries only — `uf_*` user-filter
// params also live in the URL and must not invalidate this memo (a new
Expand All @@ -2094,16 +2106,10 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
.filter(([k]) => k.startsWith('filter['))
.map(([k, v]) => `${k}=${v}`)
.join('&');
const urlFilters = useMemo(() => {
const out: Array<[string, string, any]> = [];
new URLSearchParams(filterParamsKey).forEach((value, key) => {
const m = /^filter\[(.+)\]$/.exec(key);
if (m && m[1] && value !== '') {
out.push([m[1], '=', value]);
}
});
return out;
}, [filterParamsKey]);
const urlFilters = useMemo(
() => parseUrlEqualityFilterTriples(new URLSearchParams(filterParamsKey)),
[filterParamsKey],
);

/**
* End-user filter selections restored from `uf_*` URL params (ADR-0047
Expand Down
212 changes: 212 additions & 0 deletions packages/app-shell/src/views/ObjectView.urlFilterSuffix-9196.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
/**
* 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.
*/

/**
* objectui#9196 — `ObjectView` was a THIRD, drifted reader of the `filter[...]`
* URL family, and its drift was NOT "the operator was ignored".
*
* The reader it carried had a GREEDY field capture, so an operator-suffixed key
* did not fail to match — it matched with the suffix swallowed INTO THE FIELD
* NAME. On `/apps/:app/:object`, `?filter[amount][gte]=100` emitted a condition
* against a field literally named `amount][gte`, which no object declares: a
* silently WRONG query, not an ignored parameter and not a refusal.
*
* ## What this file pins, and what it deliberately does not
*
* The ruling on the card takes ONE of the two candidate repairs:
*
* NOT taken — teaching this route the operator arm. That would ADD range
* operators to a route that has never had them, widening the accepted set
* of an addressable public surface. It is a behaviour addition, and it
* belongs to the maintainer.
* Taken — the route drops the operator suffix it cannot execute, which is the
* posture the `/data` surface already DECLARES in as many words: "An
* unknown operator suffix is ignored (never silently downgraded to
* equality)."
*
* The route now reads through `drillUrlFilters`, the one module that owns this
* family, so there is no longer a private reader here to drift. Both arms share
* ONE key grammar, which is what makes the swallowing structurally impossible
* rather than merely fixed at one call site.
*
* ## SUITE DIRECTION — stated before running
*
* RED on the unmodified base tree: every assertion in "the card's three rows"
* that reads the object route, both assertions in "IGNORED is not DOWNGRADED",
* the whole bracket battery, and both source-scan pins. On the base tree the
* object-route reader does not exist as an importable function at all, so the
* red arrives as a resolution failure; the behavioural before/after on the
* real base-tree bytes is recorded in the PR body, measured by lifting the
* live regex literal out of `ObjectView.tsx` rather than copying it.
*
* GREEN on the base tree, by design: nothing here. The `/data` column rows are
* green on both sides EXCEPT the bracketed-field row, and they are labelled
* below as non-discriminating for that reason — they are recorded because they
* are the evidence that the operator arm did not move, not because they pin
* this change.
*
* ## Every control here can fire
*
* - The LIT CONTROL is the plain, unsuffixed `filter[field]=value` row. It must
* read the same on BOTH readers and must be UNCHANGED by this repair — it is
* the only evidence that ordinary equality was not broken while the suffix was
* being dropped. It fires if the repair over-reaches.
* - The `/data` column is pinned on the SAME inputs in the SAME run. Its `gte`
* row staying `['amount', '>=', '100']` is what proves the operator arm was
* not moved onto the object route: it still executes there and ONLY there.
* - The source scan carries a positive control (`ANCHOR`), so a read of the
* wrong or an empty file fails loudly instead of reporting a clean sweep.
* - The bracket battery carries a non-vacuity control, so a battery that
* silently stopped emitting anything cannot pass by inspecting nothing.
*/

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseUrlFilterTriples, parseUrlEqualityFilterTriples } from './drillUrlFilters';

/** The ADR-0055 `/data` surface reader (`ObjectDataPage`). */
const dataSurface = (qs: string) => parseUrlFilterTriples(new URLSearchParams(qs));
/** The plain object route reader (`ObjectView`), after objectui#9196. */
const objectRoute = (qs: string) => parseUrlEqualityFilterTriples(new URLSearchParams(qs));

describe("objectui#9196 — the card's three rows, both readers, one run", () => {
it('row 1 — an unknown operator suffix is dropped by BOTH readers', () => {
expect(dataSurface('filter[owners][contains]=u1')).toEqual([]);
expect(objectRoute('filter[owners][contains]=u1')).toEqual([]);
});

it('row 2 — LIT CONTROL: plain equality is unchanged, and identical on both', () => {
expect(dataSurface('filter[owners]=u1')).toEqual([['owners', '=', 'u1']]);
expect(objectRoute('filter[owners]=u1')).toEqual([['owners', '=', 'u1']]);
});

it('row 3 — a range suffix executes on /data ONLY, and is dropped on the object route', () => {
// The `/data` half is the proof that the operator arm was NOT added here:
// it is exactly where it already was, and nowhere new.
expect(dataSurface('filter[amount][gte]=100')).toEqual([['amount', '>=', '100']]);
expect(objectRoute('filter[amount][gte]=100')).toEqual([]);
});

it('row 3, the defect itself — no condition is emitted against `amount][gte`', () => {
const fields = objectRoute('filter[amount][gte]=100').map(([field]) => field);
expect(fields).not.toContain('amount][gte');
});
});

describe('objectui#9196 — IGNORED is not DOWNGRADED TO EQUALITY', () => {
it('drops the whole key rather than filtering `amount = 100`', () => {
const triples = objectRoute('filter[amount][gte]=100');
expect(triples).toEqual([]);
// Stated the other way round too, because "ignored" and "downgraded" are
// two different outcomes and only one of them is correct: a route that
// cannot execute `>= 100` must not quietly answer the narrower `= 100`
// question. `toEqual([])` alone would also pass for a reader that refused
// the whole URL, so the field-level assertion names which one we mean.
expect(triples).not.toContainEqual(['amount', '=', '100']);
expect(triples.map(([field]) => field)).not.toContain('amount');
});

it('drops each bound of a two-bound range, not just one of them', () => {
expect(
objectRoute('filter[close_date][gte]=2026-04-01&filter[close_date][lt]=2026-07-01'),
).toEqual([]);
});

it('keeps the OTHER conditions in the same URL — only the suffixed key is dropped', () => {
expect(objectRoute('filter[amount][gte]=100&filter[status]=open')).toEqual([
['status', '=', 'open'],
]);
});
});

describe('objectui#9196 — no emitted field name ever carries a bracket', () => {
// The simplest success criterion on the card: a condition on a field name
// containing a bracket is a condition no object can answer.
const inputs = [
'filter[amount][gte]=100',
'filter[owners][contains]=u1',
'filter[a][b][c]=1',
'filter[x][GTE]=1',
'filter[weird[x]=1',
'filter[owners]=u1',
'filter[account.region]=NA',
];

it.each(inputs)('object route, %s', (qs) => {
for (const [field] of objectRoute(qs)) {
expect(field).not.toMatch(/[[\]]/);
}
});

it.each(inputs)('/data surface, %s', (qs) => {
for (const [field] of dataSurface(qs)) {
expect(field).not.toMatch(/[[\]]/);
}
});

it('the battery is not vacuous — it does emit conditions for the plain rows', () => {
// Instrument check: if every input above produced nothing, the loops would
// pass without ever inspecting a field name.
expect(inputs.flatMap((qs) => objectRoute(qs))).toEqual([
['owners', '=', 'u1'],
['account.region', '=', 'NA'],
]);
expect(inputs.flatMap((qs) => dataSurface(qs))).toEqual([
['amount', '>=', '100'],
['owners', '=', 'u1'],
['account.region', '=', 'NA'],
]);
});
});

describe('objectui#9196 — the equality arm this route DOES implement', () => {
it('keeps a relationship path intact', () => {
expect(objectRoute('filter[account.region]=NA')).toEqual([['account.region', '=', 'NA']]);
});

it('skips an empty value', () => {
expect(objectRoute('filter[status]=')).toEqual([]);
});

it('ignores params outside the family', () => {
expect(objectRoute('recordId=abc&uf_status=open')).toEqual([]);
});

it('reads every plain key in a multi-condition URL', () => {
expect(objectRoute('filter[stage]=won&filter[region]=emea')).toEqual([
['stage', '=', 'won'],
['region', '=', 'emea'],
]);
});
});

describe('objectui#9196 — `ObjectView` no longer keeps a private reader of this family', () => {
const here = path.dirname(fileURLToPath(import.meta.url));
const SOURCE = readFileSync(path.join(here, 'ObjectView.tsx'), 'utf8');
/** Positive control: a wrong or empty read fails here, not silently below. */
const ANCHOR = 'const urlFilters';

it('read the file it means to scan (control)', () => {
expect(SOURCE).toContain(ANCHOR);
});

it('parses the family through the shared module instead of its own regex', () => {
expect(SOURCE).toContain('parseUrlEqualityFilterTriples');
expect(SOURCE).toContain("from './drillUrlFilters.js'");
});

it('declares no `filter[` regex literal of its own', () => {
// A regex literal is the only place the escaped bracket occurs after
// `filter`. The surviving plain-string use (`key.startsWith('filter[')`,
// which builds the memo key) has no backslash, so it is not matched here —
// the scan is about a fourth PARSER appearing, not about the substring.
expect(SOURCE).not.toMatch(/filter\\\[/);
});
});
55 changes: 48 additions & 7 deletions packages/app-shell/src/views/drillUrlFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ export const URL_FILTER_OPS: Record<string, string> = { gte: '>=', lte: '<=', gt
* relevant `URL_FILTER_OPS` entries. */
export const RANGE_OP_PARAM: Record<string, string> = { $gte: 'gte', $lte: 'lte', $gt: 'gt', $lt: 'lt' };

/**
* The ONE grammar for a key in this family, so the two arms below cannot drift
* apart on what a field name is: `filter[<field>]`, with an OPTIONAL
* `[<suffix>]`. The field slot excludes both brackets, so a suffix can never be
* swallowed into the field name — the drift objectui#9196 measured, where a
* greedy field capture turned `filter[amount][gte]` into a condition against a
* field literally named `amount][gte` that no object declares.
*/
const FILTER_KEY = /^filter\[([^[\]]+)\](?:\[([^[\]]+)\])?$/;

/**
* Parse `filter[<field>]=<value>` (equality) and `filter[<field>][<op>]=<value>`
* (range/comparison) search params into ObjectQL triples. An unknown operator
Expand All @@ -38,15 +48,46 @@ export function parseUrlFilterTriples(searchParams: URLSearchParams): FilterTrip
const out: FilterTriple[] = [];
searchParams.forEach((value, key) => {
if (value === '') return;
// Operator form FIRST — the field capture must not swallow the `[op]` suffix.
const mOp = /^filter\[([^\]]+)\]\[([a-z]+)\]$/.exec(key);
if (mOp) {
const op = URL_FILTER_OPS[mOp[2]];
if (op) out.push([mOp[1], op, value]);
const m = FILTER_KEY.exec(key);
if (!m) return;
const [, field, suffix] = m;
if (suffix === undefined) {
out.push([field, '=', value]);
return;
}
const m = /^filter\[([^\]]+)\]$/.exec(key);
if (m && m[1]) out.push([m[1], '=', value]);
const op = URL_FILTER_OPS[suffix];
if (op) out.push([field, op, value]);
});
return out;
}

/**
* The EQUALITY-ONLY arm of the same grammar, for a surface that implements
* `filter[<field>]=<value>` and nothing else — today the plain object route
* (`/apps/:app/:object`, `ObjectView`), whose related-list "View All" buttons
* scope a destination list to one parent record.
*
* An operator suffix is DROPPED, exactly as `parseUrlFilterTriples` drops an
* operator it does not know: ignored, and in particular never silently
* downgraded to equality. Those are two different outcomes and only one is
* correct — answering the narrower `amount = 100` when the URL asked for
* `amount >= 100` is a wrong answer wearing a right answer's shape.
*
* ⚠️ This arm deliberately does NOT execute the operator suffix (objectui#9196).
* Teaching this route range operators would widen the accepted set of an
* addressable public surface — a behaviour addition, not a repair, and one that
* belongs to the maintainer rather than to a bug fix. The operator arm stays
* where it already is: `parseUrlFilterTriples`, on the ADR-0055 `/data` surface.
*/
export function parseUrlEqualityFilterTriples(searchParams: URLSearchParams): FilterTriple[] {
const out: FilterTriple[] = [];
searchParams.forEach((value, key) => {
if (value === '') return;
const m = FILTER_KEY.exec(key);
// `m[2] !== undefined` is the suffixed form — not executable here, so the
// whole condition is dropped rather than answered at the wrong operator.
if (!m || m[2] !== undefined) return;
out.push([m[1], '=', value]);
});
return out;
}
Expand Down
Loading