Skip to content

Commit 6c87cc9

Browse files
authored
fix(data): a filter the server cannot apply is rejected, not silently ignored (#4181) (#4209)
`?filter={status:done` — one missing quote — answered 200 with the UNFILTERED page. The JSON-parse tolerance (`catch { /* keep as-is */ }`) left the raw string on `where`, a shape no driver consumes, so the filter was dropped whole and the response was byte-for-byte a successful unfiltered query. Worst failure direction in this family: #4134 returned nothing, #4164 dropped one predicate, this returned everything. The sibling `GET /data/:object/export` route had rejected the same input since it was written — the list path was the outlier. The guard moves into the shared normalizer so the list route, `POST /data/:object/query` and the runtime dispatcher inherit one answer: - Unparseable JSON, and JSON that parses to a non-filter (`5`, `"done"`, `null`) → 400 INVALID_FILTER, stating the filter was NOT applied. - Blank `?filter=` stays absent, not malformed. - `filter`/`filters`/`$filter`/`where` are four spellings of one slot; different values are now 400 INVALID_REQUEST (the request is ambiguous, not the filter). Identical redundant spellings pass. - Export's `orderby` gets the same rule, and the test it never had. Reconciled with #4121, which landed malformed-filter-ARRAY rejection on this same code path in flight: both halves now share INVALID_FILTER, pinned by a test that four differently-broken filters produce exactly one wire code. #4164's `typeof where === 'object'` merge guard — added because this tolerance could leave a raw string on `where` — is unreachable and removed with it.
1 parent 011b386 commit 6c87cc9

8 files changed

Lines changed: 449 additions & 54 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
"@objectstack/rest": patch
4+
---
5+
6+
fix(data): a filter the server cannot apply is rejected, not silently ignored (#4181)
7+
8+
`GET /api/v1/data/:object?filter={status:done` — one missing quote — answered
9+
`200` with the **unfiltered** page. The JSON-parse tolerance
10+
(`catch { /* keep as-is */ }`) left the raw string on `where`, a shape no
11+
driver consumes, so the filter was dropped whole and the response was
12+
byte-for-byte a successful unfiltered query. The worst failure direction in
13+
this family: #4134 returned nothing, #4164 dropped one predicate, this
14+
returned everything.
15+
16+
The sibling `GET /data/:object/export` route had rejected the same input since
17+
it was written — the list path was the outlier. That guard now lives in the
18+
shared normalizer, so `GET /data/:object`, `POST /data/:object/query` and the
19+
runtime dispatcher all give one answer:
20+
21+
- Unparseable JSON → `400 INVALID_FILTER`, naming the parameter and stating the
22+
filter was not applied.
23+
- Parses but is not a filter (`?filter=5`, `?filter="done"`, `?filter=null`) →
24+
same rejection; usable JSON is not a usable filter.
25+
- Blank `?filter=` → treated as absent, as before. No error.
26+
- `filter` / `filters` / `$filter` / `where` are four spellings of ONE slot.
27+
Sending two with **different** values used to run one and discard the rest
28+
silently; it is now `400 INVALID_REQUEST` (each value is a valid filter — the
29+
*request* is ambiguous, so it does not share the malformed-filter code).
30+
Redundant identical spellings pass.
31+
- `orderby` on the export route gets the same treatment — a sort that cannot be
32+
parsed is refused rather than dropped (lower stakes than a filter: the row set
33+
is unchanged, but a caller taking "latest N" got an arbitrary N).
34+
35+
**One wire code for one condition.** #4121 landed `400 INVALID_FILTER` for
36+
malformed filter *arrays* on this same code path while this fix was in flight;
37+
the non-array rejections above use that code too, so a caller asking "did my
38+
filter run?" never has to know which branch caught it. The export route's
39+
filter guard moves from `INVALID_REQUEST` to `INVALID_FILTER` to match — a wire
40+
change on an existing route, and the reason it is worth making is that a client
41+
otherwise has to handle two codes for one condition depending on which URL it
42+
called. The route's `orderby` guard keeps `INVALID_REQUEST` (it is not a
43+
filter).
44+
45+
**What changes for callers:** requests carrying a malformed filter now fail
46+
loudly instead of receiving every record. Every valid filter shape — JSON
47+
string, live object, `FilterCondition` AST array, and all four alias spellings
48+
used alone — is unaffected.

content/docs/api/data-api.mdx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Query records with filtering, sorting, selection, and pagination.
1919
|:----------|:---------|:------------|
2020
| `object` | path | Object name |
2121
| `select` | query | Comma-separated field names |
22-
| `filter` | query | Filter expression (JSON). `filters` also accepted for backward compatibility. |
22+
| `filter` | query | Filter expression (JSON). `filters` also accepted for backward compatibility. Malformed JSON is rejected with `400 INVALID_FILTER` — never ignored. |
2323
| `sort` | query | Sort expression (e.g. `name asc` or `-created_at`) |
2424
| `top` | query | Max records to return. No default — omitting it returns all matching records. |
2525
| `skip` | query | Offset |
@@ -60,6 +60,24 @@ Reserved names cannot double as implicit filters. An object with a field
6060
literally called `count`, `cursor`, `distinct`, `object`, `search` or `top`
6161
filters it through the explicit form (`?filter={"count":3}`).
6262

63+
#### A filter either applies or fails — it is never ignored
64+
65+
`filter`, `filters`, `$filter` and `where` are four spellings of one slot. A
66+
value the server cannot turn into a filter is rejected with
67+
`400 INVALID_FILTER` rather than dropped, because a dropped filter would
68+
return the **unfiltered** result set — a response indistinguishable from a
69+
successful query:
70+
71+
| Request | Result |
72+
|:---|:---|
73+
| `?filter={"status":"done"}` | filter applies |
74+
| `?filter={status:done` (invalid JSON) | `400``filter must be valid JSON` |
75+
| `?filter=5`, `?filter="done"`, `?filter=null` | `400` — parses, but is not a filter |
76+
| `?filter=` (blank) | treated as absent — no filter, no error |
77+
| `where` and `filter` sent with **different** values | `400` — aliases for one slot; send exactly one |
78+
79+
The same rule applies to `orderby` on `GET /data/:object/export`.
80+
6381
**Response**:
6482
```json
6583
{

content/docs/api/error-catalog.mdx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,15 @@ substitute. See the [Data API](/docs/api/data-api).
122122
**Retry:** `no_retry`
123123

124124
### `INVALID_FILTER`
125-
**Cause:** A filter operator or field in the `where` clause is invalid.
126-
**Fix:** Use valid filter operators (`$eq`, `$ne`, `$gt`, `$lt`, `$in`, `$contains`, etc.).
125+
**Cause:** The server could not turn the request's filter into something it can
126+
run. Covers an invalid operator or field in the `where` clause, a `$filter`
127+
array that is not a filter AST (a bad operator, a lone `["and"]`), a `filter`
128+
query parameter that is not valid JSON, and JSON that parses to something that
129+
is not a filter (`5`, `"done"`, `null`).
130+
**Fix:** Use valid filter operators (`$eq`, `$ne`, `$gt`, `$lt`, `$in`, `$contains`, etc.) and a filter shape the [Data API](/docs/api/data-api) accepts — an object, or an array of `[field, operator, value]` conditions. The `error` message names the offending element.
131+
**Why it is an error and not an empty result:** a filter the server cannot run
132+
is never silently skipped, because skipping it would return the **unfiltered**
133+
result set — a response indistinguishable from a successful query.
127134
**Retry:** `no_retry`
128135

129136
### `INVALID_SORT`

packages/metadata-protocol/src/protocol.ts

Lines changed: 127 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -301,17 +301,19 @@ function resolveOverlaySchema(type: string, _item: unknown): z.ZodTypeAny | null
301301
}
302302

303303
/**
304-
* A 400 for a `$filter` that looks like a filter AST but is not one.
304+
* A 400 for a `$filter` ARRAY that looks like a filter AST but is not one.
305305
*
306306
* The message has to be *actionable from the request*, which is the whole point
307307
* of rejecting here rather than letting a driver fail later: the caller sent a
308308
* query parameter, so the error names the offending element and the vocabulary
309309
* it was checked against — not a driver-internal builder state.
310310
*
311311
* Diagnoses the three shapes `isFilterAST` refuses, in the order they occur in
312-
* practice. #4121.
312+
* practice. #4121. Sibling of {@link unusableFilterError}, which covers the
313+
* non-array ways a filter fails to become one (#4181); both emit
314+
* `INVALID_FILTER` so the condition has one wire code however it was reached.
313315
*/
314-
function malformedFilterError(filter: unknown[]): Error {
316+
function malformedFilterArrayError(filter: unknown[]): Error {
315317
const detail = describeMalformedFilter(filter);
316318
const err: any = new Error(
317319
`Malformed $filter: ${detail} A filter array is a comparison ` +
@@ -853,6 +855,33 @@ const ODATA_SPELLING: Readonly<Record<string, string>> = {
853855
filter: '$filter', select: '$select', expand: '$expand',
854856
};
855857

858+
/**
859+
* [#4181] A filter the normalizer cannot turn into a usable `FilterCondition`
860+
* by any route other than the array shapes {@link malformedFilterArrayError}
861+
* already diagnoses: unparseable JSON, or JSON that parses to something no
862+
* driver can read (a number, a bare string, `null`).
863+
*
864+
* Carries `INVALID_FILTER` — the standard-catalog code (`errors.zod.ts`,
865+
* "Invalid filter expression") that #4121 introduced on this same code path for
866+
* the array case. One condition, one wire code, however the caller reached it:
867+
* a `$filter` array with a bad operator and a `?filter=` that is not JSON are
868+
* the same answer to the same question ("this filter cannot run").
869+
*
870+
* The message states the filter was NOT APPLIED, because that is the part a
871+
* caller cannot infer: the pre-#4181 behavior was an ordinary-looking 200 over
872+
* the unfiltered set.
873+
*/
874+
function unusableFilterError(param: string, detail: string): Error {
875+
const err: any = new Error(
876+
`Query parameter '${param}' ${detail}. It was not applied, and an unapplied `
877+
+ 'filter would have returned the unfiltered result set.',
878+
);
879+
err.status = 400;
880+
err.code = 'INVALID_FILTER';
881+
err.param = param;
882+
return err;
883+
}
884+
856885
/** Fold a parameter name to its near-miss lookup key. */
857886
function nearMissKey(name: string): string {
858887
return name.toLowerCase().replace(/[_-]/g, '');
@@ -3161,37 +3190,103 @@ export class ObjectStackProtocolImplementation implements
31613190
}
31623191
delete options.sort;
31633192

3164-
// Filter/filters/$filter → where: normalize all filter aliases
3193+
// Filter/filters/$filter → where: normalize all filter aliases.
3194+
//
3195+
// [#4181] These four names are FOUR SPELLINGS OF ONE SLOT (`filters` is
3196+
// documented as a deprecated alias of `filter`), so `??` picking the
3197+
// first non-null silently discarded the others: a body carrying both
3198+
// `where` and a different `filter` ran the `filter` and dropped the
3199+
// `where` with no signal. Two different values for one slot cannot be
3200+
// reconciled — merging them would invent an intent the caller never
3201+
// expressed, and picking one is the silent drop itself — so an
3202+
// ambiguous request is refused. Redundant identical spellings are
3203+
// harmless and pass.
3204+
const filterAliases = (['filter', 'filters', '$filter', 'where'] as const)
3205+
.filter((k) => options[k] !== undefined)
3206+
.map((k) => ({ key: k, value: options[k] }));
3207+
if (filterAliases.length > 1) {
3208+
const distinct = new Set(filterAliases.map((a) => JSON.stringify(a.value)));
3209+
if (distinct.size > 1) {
3210+
const err: any = new Error(
3211+
`Conflicting filter parameters: ${filterAliases.map((a) => `'${a.key}'`).join(', ')} `
3212+
+ 'are aliases for the same filter and were given different values. Send exactly one.',
3213+
);
3214+
err.status = 400;
3215+
err.code = 'INVALID_REQUEST';
3216+
throw err;
3217+
}
3218+
}
3219+
31653220
const filterValue = options.filter ?? options.filters ?? options.$filter ?? options.where;
3221+
const filterKey = filterAliases[0]?.key ?? 'filter';
31663222
delete options.filter;
31673223
delete options.filters;
31683224
delete options.$filter;
31693225

31703226
if (filterValue !== undefined) {
31713227
let parsedFilter = filterValue;
3172-
// JSON string → object
3173-
if (typeof parsedFilter === 'string') {
3174-
try { parsedFilter = JSON.parse(parsedFilter); } catch { /* keep as-is */ }
3175-
}
3176-
// Filter AST array → FilterCondition object
3177-
if (isFilterAST(parsedFilter)) {
3178-
parsedFilter = parseFilterAST(parsedFilter);
3179-
} else if (Array.isArray(parsedFilter) && parsedFilter.length > 0) {
3180-
// `isFilterAST` was being read as a *conversion* gate, so an array
3181-
// it refused was assigned to `where` unconverted — an opaque value
3182-
// the driver then had to make sense of. Every driver now fails on
3183-
// it, so this is not a narrowing; it moves the failure to where the
3184-
// malformed filter actually arrived, with the request's own
3185-
// vocabulary in the message instead of a driver-internal one.
3228+
// A blank `?filter=` is ABSENT, not malformed — the same `length > 0`
3229+
// guard the export route applies before parsing. Deleting `where`
3230+
// here (rather than leaving `''` on it) is what lets every consumer
3231+
// below test presence with a plain falsy check.
3232+
if (typeof parsedFilter === 'string' && parsedFilter.trim() === '') {
3233+
delete options.where;
3234+
} else {
3235+
// [#4181] JSON string → object. Parse failure is a REJECTION, not
3236+
// a fallback. The `catch { /* keep as-is */ }` this replaces left
3237+
// the raw string on `where`, a shape no driver consumes — so the
3238+
// filter was dropped whole and `?filter={status:done` (one missing
3239+
// quote) answered 200 with the UNFILTERED page. Worst member of
3240+
// the #3948 family: #4134 zeroed, #4164 dropped one predicate,
3241+
// this returned everything.
31863242
//
3187-
// It also closes what the driver-side fix could not: a lone
3188-
// `['and']` / `['or']` sets the join mode, matches no element, and
3189-
// emits NO predicate — the last shape that still returned every row
3190-
// silently after #3948. An empty `[]` is left alone: it means "no
3191-
// filter", and every path already treats it that way. #4121.
3192-
throw malformedFilterError(parsedFilter);
3243+
// The sibling `GET /data/:object/export` route has rejected this
3244+
// exact input since it was written (`400 INVALID_REQUEST`,
3245+
// "filter must be JSON"); the list path was the outlier. The
3246+
// guard lives HERE, in the shared normalizer, so `GET
3247+
// /data/:object`, `POST /data/:object/query` and the runtime
3248+
// dispatcher all inherit one answer instead of three.
3249+
if (typeof parsedFilter === 'string') {
3250+
try {
3251+
parsedFilter = JSON.parse(parsedFilter);
3252+
} catch {
3253+
throw unusableFilterError(filterKey, 'must be valid JSON');
3254+
}
3255+
}
3256+
// Filter AST array → FilterCondition object
3257+
if (isFilterAST(parsedFilter)) {
3258+
parsedFilter = parseFilterAST(parsedFilter);
3259+
} else if (Array.isArray(parsedFilter) && parsedFilter.length > 0) {
3260+
// [#4121] `isFilterAST` was being read as a *conversion* gate,
3261+
// so an array it refused was assigned to `where` unconverted —
3262+
// an opaque value the driver then had to make sense of. Every
3263+
// driver now fails on it, so this is not a narrowing; it moves
3264+
// the failure to where the malformed filter actually arrived,
3265+
// with the request's own vocabulary in the message instead of a
3266+
// driver-internal one.
3267+
//
3268+
// It also closes what the driver-side fix could not: a lone
3269+
// `['and']` / `['or']` sets the join mode, matches no element,
3270+
// and emits NO predicate — the last shape that still returned
3271+
// every row silently after #3948. An empty `[]` is left alone:
3272+
// it means "no filter", and every path already treats it that
3273+
// way — so it falls through to the shape check below, which
3274+
// passes it (an array IS an object).
3275+
throw malformedFilterArrayError(parsedFilter);
3276+
}
3277+
// [#4181] Parsed-but-unusable is the same failure one step later:
3278+
// `?filter=5` / `?filter="open"` / `?filter=null` all yield a
3279+
// non-object `where` that no driver reads. #4121 above catches the
3280+
// array shapes; this catches the scalar ones, and together they are
3281+
// what lets the #4164 merge below trust `where` to be an object.
3282+
if (parsedFilter === null || typeof parsedFilter !== 'object') {
3283+
throw unusableFilterError(
3284+
filterKey,
3285+
`must be a filter object or condition array, received ${parsedFilter === null ? 'null' : typeof parsedFilter}`,
3286+
);
3287+
}
3288+
options.where = parsedFilter;
31933289
}
3194-
options.where = parsedFilter;
31953290
}
31963291

31973292
// Populate/expand/$expand → expand (Record<string, QueryAST>)
@@ -3289,14 +3384,14 @@ export class ObjectStackProtocolImplementation implements
32893384
// stray top-level AST junk, and count() below reads the same
32903385
// `options.where`, so pagination totals see the merged predicate too.
32913386
//
3292-
// Deliberately NOT merged: a non-object truthy `where` — the parse
3293-
// tolerance above keeps an unparseable `filter` JSON string as-is
3294-
// (#4181), and folding real predicates into that garbage would neither
3295-
// apply them nor surface the actual bug. That path keeps its pre-#4164
3296-
// shape until the tolerance itself is fixed at the source.
3387+
// #4164 shipped with a `typeof explicitWhere === 'object'` guard here,
3388+
// because the parse tolerance above could leave a raw unparseable string
3389+
// on `where` and folding real predicates into that garbage would neither
3390+
// apply them nor surface the bug. #4181 fixed that at the source — a
3391+
// filter now either parses to an object or 400s — so `where` is an
3392+
// object or absent by construction and the guard is gone with it.
32973393
const explicitWhere = options.where;
3298-
const whereIsMergeable = !explicitWhere || typeof explicitWhere === 'object';
3299-
if (leftoverParams.length > 0 && whereIsMergeable) {
3394+
if (leftoverParams.length > 0) {
33003395
const implicitFilters: Record<string, unknown> = {};
33013396
for (const key of leftoverParams) {
33023397
implicitFilters[key] = options[key];

0 commit comments

Comments
 (0)