Skip to content

Commit feb1142

Browse files
committed
feat(metadata-protocol): reject unknown $-prefixed query params with 400 (#2926 ⑩)
Unsupported $ parameters (e.g. $foo, $inlinecount) used to fall into the implicit-filter bucket and silently match zero rows — and before the $filter alias existed, were dropped entirely, returning the unfiltered first page to callers that believed they had a filtered result set. All supported aliases are consumed before the guard, so anything $-prefixed that remains is a hard 400 UNSUPPORTED_QUERY_PARAM naming the offending keys and the supported list. Bare-key implicit equality filters are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe
1 parent 0041d1b commit feb1142

3 files changed

Lines changed: 59 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): findData now rejects unknown `$`-prefixed query parameters with 400 `UNSUPPORTED_QUERY_PARAM` instead of silently treating them as implicit field-equality filters that match zero rows (#2926 ⑩). A `$`-prefixed key can never be a field name, so this is loud-failure only for the unsupported-alias class; bare-key implicit equality filtering is unchanged. The error message lists the supported aliases ($top, $skip, $orderby, $select, $count, $search, $searchFields, $filter, $expand).

packages/metadata-protocol/src/protocol.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2404,7 +2404,26 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
24042404
if (options[key] === 'true') options[key] = true;
24052405
else if (options[key] === 'false') options[key] = false;
24062406
}
2407-
2407+
2408+
// [#2926 ⑩] Every supported OData-style `$` alias has been consumed and
2409+
// deleted above ($top/$skip/$orderby/$select/$count/$search/
2410+
// $searchFields/$filter/$expand). A `$`-prefixed key can never be a
2411+
// field name, so anything left is an unsupported query parameter —
2412+
// fail loudly instead of letting it fall into the implicit-filter
2413+
// bucket below, where it silently matched zero rows (or, before the
2414+
// $filter alias existed, was dropped entirely and returned the
2415+
// UNFILTERED page — a footgun for scripts resolving ids by name).
2416+
const unsupportedDollarParams = Object.keys(options).filter((k) => k.startsWith('$'));
2417+
if (unsupportedDollarParams.length > 0) {
2418+
const err: any = new Error(
2419+
`Unsupported query parameter(s): ${unsupportedDollarParams.join(', ')}. ` +
2420+
'Supported $-prefixed parameters: $top, $skip, $orderby, $select, $count, $search, $searchFields, $filter, $expand.',
2421+
);
2422+
err.status = 400;
2423+
err.code = 'UNSUPPORTED_QUERY_PARAM';
2424+
throw err;
2425+
}
2426+
24082427
// Flat field filters: REST-style query params like ?id=abc&status=open
24092428
// After extracting all known query parameters, any remaining keys are
24102429
// treated as implicit field-level equality filters merged into `where`.

packages/objectql/src/protocol-data.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,40 @@ describe('ObjectStackProtocolImplementation - Data Operations', () => {
3838
expect(opts.where?.searchFields).toBeUndefined();
3939
});
4040

41+
// [#2926 ⑩] Unknown `$`-prefixed params must fail loudly instead of
42+
// silently matching zero rows via the implicit-filter bucket (or —
43+
// pre-$filter alias — being dropped and returning the unfiltered page).
44+
it('rejects an unknown $-prefixed query param with 400 UNSUPPORTED_QUERY_PARAM', async () => {
45+
await expect(
46+
protocol.findData({ object: 'task', query: { $foo: '1' } }),
47+
).rejects.toMatchObject({ status: 400, code: 'UNSUPPORTED_QUERY_PARAM' });
48+
expect(mockEngine.find).not.toHaveBeenCalled();
49+
});
50+
51+
it('names the offending params and the supported list in the rejection message', async () => {
52+
await expect(
53+
protocol.findData({ object: 'task', query: { $inlinecount: 'allpages', $format: 'json' } }),
54+
).rejects.toThrow(/\$inlinecount.*\$format|\$format.*\$inlinecount/s);
55+
});
56+
57+
it('still accepts every supported $ alias after the unknown-$ guard', async () => {
58+
await protocol.findData({
59+
object: 'task',
60+
query: { $top: 5, $skip: 2, $orderby: 'name', $select: 'id,name', $search: 'x', $filter: { status: 'open' } },
61+
});
62+
expect(mockEngine.find).toHaveBeenCalledTimes(1);
63+
const opts = mockEngine.find.mock.calls[0][1];
64+
expect(opts.limit).toBe(5);
65+
expect(opts.offset).toBe(2);
66+
expect(opts.where).toEqual({ status: 'open' });
67+
});
68+
69+
it('keeps bare unknown params as implicit field-equality filters (unchanged behavior)', async () => {
70+
await protocol.findData({ object: 'task', query: { status: 'open' } });
71+
const opts = mockEngine.find.mock.calls[0][1];
72+
expect(opts.where).toEqual({ status: 'open' });
73+
});
74+
4175
it('should normalize $expand (OData) string to expand Record', async () => {
4276
await protocol.findData({ object: 'order_item', query: { $expand: 'order,product' } });
4377

0 commit comments

Comments
 (0)