Skip to content

Commit a17ef09

Browse files
os-zhuangclaude
andauthored
fix(data-objectstack): a string $orderby reaches the server as a sort, not a list of character indices (#3106) (#3109)
`QueryParams['$orderby']` declares four shapes — `string`, `string[]`, `SortNode[]`, `Record<field, direction>`. Both `find()` routes carried their own copy of the fold that serializes it (`convertQueryParams` for a plain read, `rawFindWithPopulate` for one carrying `$expand`/`$search`), and both copies handled the same three. The bare string fell through to the `Record` branch, where `Object.entries('name asc')` enumerates the string's character indices — so the request went out as `sort=0,1,2,3,4,5,6,7`. Since objectstack#4226 the server refuses a sort it cannot read (`400 INVALID_SORT`) rather than dropping it, so this was not a degraded ordering but a list that failed to load outright. `"${field} ${order}"` is exactly what `ObjectGrid` builds from its view metadata's `sort`, which made every standalone grid with a configured sort a broken one. One exported `serializeOrderBy` now backs both routes, for the same reason the filter path already shares one: two copies of a fold can only agree by inspection, and these two did not. The new suite runs every declared shape down both routes and asserts they agree. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent aebfa4f commit a17ef09

3 files changed

Lines changed: 194 additions & 35 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@object-ui/data-objectstack": patch
3+
---
4+
5+
fix(data-objectstack): a string `$orderby` reaches the server as a sort instead of a list of character indices — #3106
6+
7+
`QueryParams['$orderby']` declares four shapes — `string`, `string[]`,
8+
`SortNode[]`, `Record<field, direction>`. Both of this adapter's `find()` routes
9+
(`convertQueryParams` for a plain read, `rawFindWithPopulate` for one carrying
10+
`$expand`/`$search`) carried their own copy of the fold that serializes it, and
11+
both copies handled the same three. The bare string fell through to the
12+
`Record` branch, where `Object.entries('name asc')` enumerates the string's
13+
character indices — so the request went out as `sort=0,1,2,3,4,5,6,7`.
14+
15+
Since `objectstack#4226` the server refuses a sort it cannot read
16+
(`400 INVALID_SORT`) rather than dropping it silently, so this was not a
17+
degraded ordering but a list that failed to load outright — and `"${field}
18+
${order}"` is exactly the shape `ObjectGrid` builds from its view metadata's
19+
`sort`, making every standalone grid with a configured sort a broken one.
20+
21+
Both routes now share one exported `serializeOrderBy`, for the same reason the
22+
filter path already shares one: two copies of a fold can only agree by
23+
inspection, and these two did not.

packages/data-objectstack/src/index.ts

Lines changed: 56 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,55 @@ function translateFilterToAST(filter: unknown): unknown | undefined {
300300
return undefined;
301301
}
302302

303+
/**
304+
* Serialize a `$orderby` to the server's `sort` shorthand
305+
* (`field,-other_field`), for every shape `QueryParams['$orderby']` declares.
306+
*
307+
* The type declares four — `string`, `string[]`, `SortNode[]`,
308+
* `Record<field, direction>` — and the two `find()` routes each open-coded a
309+
* fold that handled three of them. The missing one was the bare string, and it
310+
* did not degrade quietly: `Object.entries('name asc')` enumerates a string's
311+
* character indices, so the request went out as `sort=0,1,2,3,4,5,6,7`. Against
312+
* a server that rejects an unreadable sort rather than ignoring it
313+
* (objectstack#4226), that is a `400 INVALID_SORT` and an empty list — so a
314+
* standalone `ObjectGrid` with a `sort` in its metadata, which is exactly the
315+
* shape it builds (`ObjectGrid.tsx`: `` `${field} ${order}` ``), failed to load
316+
* at all.
317+
*
318+
* One serializer for both routes, for the reason the filter path already has
319+
* one: two copies of a fold can only agree by inspection, and these two did not.
320+
*
321+
* Returns `undefined` when nothing is sortable, so callers skip the parameter
322+
* entirely rather than sending an empty one.
323+
*/
324+
export function serializeOrderBy(orderby: QueryParams['$orderby']): string | undefined {
325+
if (orderby === undefined || orderby === null) return undefined;
326+
327+
// `field asc` / `-field` / a comma-separated list of either — already the
328+
// wire shorthand the server parses, so it rides through untouched.
329+
if (typeof orderby === 'string') {
330+
const trimmed = orderby.trim();
331+
return trimmed.length > 0 ? trimmed : undefined;
332+
}
333+
334+
const shorthand = (field: string, order?: unknown) =>
335+
String(order).toLowerCase() === 'desc' ? `-${field}` : field;
336+
337+
if (Array.isArray(orderby)) {
338+
const parts = orderby
339+
.map((item) => (typeof item === 'string' ? item.trim() : shorthand(item.field, item.order)))
340+
.filter((s) => s.length > 0);
341+
return parts.length > 0 ? parts.join(',') : undefined;
342+
}
343+
344+
if (typeof orderby === 'object') {
345+
const parts = Object.entries(orderby).map(([field, order]) => shorthand(field, order));
346+
return parts.length > 0 ? parts.join(',') : undefined;
347+
}
348+
349+
return undefined;
350+
}
351+
303352
// Module-level discovery cache. Multiple ObjectStackAdapter instances pointed
304353
// at the same baseUrl (e.g. ConditionalAuthWrapper's throwaway adapter +
305354
// AdapterProvider's main adapter) would otherwise each fire `/discovery`. By
@@ -2057,22 +2106,8 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
20572106
}
20582107

20592108
// Sorting
2060-
if (params.$orderby) {
2061-
if (Array.isArray(params.$orderby)) {
2062-
const sortStr = params.$orderby.map(item => {
2063-
if (typeof item === 'string') return item;
2064-
const field = item.field;
2065-
const order = item.order || 'asc';
2066-
return order === 'desc' ? `-${field}` : field;
2067-
}).join(',');
2068-
queryParams.set('sort', sortStr);
2069-
} else {
2070-
const sortStr = Object.entries(params.$orderby)
2071-
.map(([field, order]) => order === 'desc' ? `-${field}` : field)
2072-
.join(',');
2073-
queryParams.set('sort', sortStr);
2074-
}
2075-
}
2109+
const sortStr = serializeOrderBy(params.$orderby);
2110+
if (sortStr) queryParams.set('sort', sortStr);
20762111

20772112
// Filter — translate ViewFilterRule[] (`[{field, operator, value}]`)
20782113
// and other shapes into AST tuples the server understands. Without this,
@@ -2229,25 +2264,11 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
22292264
}
22302265
}
22312266

2232-
// Sorting - convert to ObjectStack format
2233-
if (params.$orderby) {
2234-
if (Array.isArray(params.$orderby)) {
2235-
// Handle array format ['name', '-age'] or [{ field: 'name', order: 'asc' }]
2236-
options.sort = params.$orderby.map(item => {
2237-
if (typeof item === 'string') return item;
2238-
// Handle object format { field: 'name', order: 'desc' }
2239-
const field = item.field;
2240-
const order = item.order || 'asc';
2241-
return order === 'desc' ? `-${field}` : field;
2242-
});
2243-
} else {
2244-
// Handle Record format { name: 'asc', age: 'desc' }
2245-
const sortArray = Object.entries(params.$orderby).map(([field, order]) => {
2246-
return order === 'desc' ? `-${field}` : field;
2247-
});
2248-
options.sort = sortArray;
2249-
}
2250-
}
2267+
// Sorting — the same serializer the raw GET route uses, so the two `find()`
2268+
// paths cannot disagree about one stored sort. The client SDK's
2269+
// `QueryOptions.sort` accepts the shorthand string directly.
2270+
const sort = serializeOrderBy(params.$orderby);
2271+
if (sort) options.sort = sort;
22512272

22522273
// Pagination
22532274
if (params.$skip !== undefined) {
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* Every shape `QueryParams['$orderby']` declares reaches the wire as a sort.
11+
*
12+
* `find()` has TWO routes to the server — a plain read goes through
13+
* `convertQueryParams` and the client SDK, a read with `$expand`/`$search`
14+
* through `rawFindWithPopulate` — and each used to carry its own copy of the
15+
* "which `$orderby` shape is this?" fold. Both copies handled three of the four
16+
* declared shapes; both missed the bare string, and missed it the same
17+
* spectacular way: the string fell into the `Record<field, direction>` branch,
18+
* where `Object.entries('name asc')` enumerates character indices and the
19+
* request went out as `sort=0,1,2,3,4,5,6,7`.
20+
*
21+
* That is not a cosmetic mistranslation. Since objectstack#4226 the server
22+
* refuses a sort it cannot read (`400 INVALID_SORT`) rather than dropping it,
23+
* so the whole list fails to load — and `"${field} ${order}"` is exactly what a
24+
* standalone `ObjectGrid` builds from its view metadata's `sort`.
25+
*
26+
* Both routes are asserted for every shape, because "the two routes agree" is
27+
* the property that was actually missing; one serializer now backs both.
28+
*/
29+
30+
import { describe, it, expect, beforeEach, vi } from 'vitest';
31+
import { ObjectStackAdapter, clearSharedDiscoveryCache, serializeOrderBy } from './index';
32+
33+
function makeAdapter() {
34+
const calls: string[] = [];
35+
const fetchImpl = vi.fn(async (url: any) => {
36+
const u = String(url);
37+
calls.push(u);
38+
if (u.includes('/api/v1/discovery')) {
39+
return {
40+
ok: true, status: 200, statusText: 'OK',
41+
json: async () => ({ success: true, data: { version: 'v1', routes: {} } }),
42+
} as any;
43+
}
44+
return {
45+
ok: true, status: 200, statusText: 'OK',
46+
json: async () => ({ success: true, data: { object: 'account', records: [], total: 0 } }),
47+
} as any;
48+
});
49+
const adapter = new ObjectStackAdapter({
50+
baseUrl: 'http://localhost:3000', token: 't', autoReconnect: false, fetch: fetchImpl as any,
51+
});
52+
return { adapter, calls };
53+
}
54+
55+
/** The `sort=` this `$orderby` produced on the wire, or `undefined` if none was sent. */
56+
async function sortOnWire($orderby: unknown, route: 'plain' | 'expand'): Promise<string | undefined> {
57+
const { adapter, calls } = makeAdapter();
58+
await adapter.find('account', {
59+
$orderby,
60+
...(route === 'expand' ? { $expand: ['owner'] } : {}),
61+
} as any);
62+
const dataCall = calls.filter((u) => u.includes('/data/account')).pop();
63+
const raw = dataCall ? new URL(dataCall).searchParams.get('sort') : null;
64+
return raw === null ? undefined : raw;
65+
}
66+
67+
/** Run one input down both `find()` routes and assert they agree. */
68+
function bothRoutes(name: string, $orderby: unknown, expected: string | undefined) {
69+
for (const route of ['plain', 'expand'] as const) {
70+
it(`${name} (${route} route)`, async () => {
71+
expect(await sortOnWire($orderby, route)).toBe(expected);
72+
});
73+
}
74+
}
75+
76+
describe('$orderby reaches the wire for every declared shape', () => {
77+
beforeEach(() => clearSharedDiscoveryCache());
78+
79+
// The regression: ObjectGrid's own `"${field} ${order}"`.
80+
bothRoutes('a bare "field order" string', 'name asc', 'name asc');
81+
bothRoutes('a bare string with a descending key', 'created_at desc', 'created_at desc');
82+
bothRoutes('a bare string already in `-field` shorthand', '-created_at', '-created_at');
83+
bothRoutes('a bare multi-key string', 'status asc,-created_at', 'status asc,-created_at');
84+
85+
bothRoutes('a SortNode[]', [{ field: 'name', order: 'asc' }], 'name');
86+
bothRoutes(
87+
'a SortNode[] with a descending key',
88+
[{ field: 'status', order: 'asc' }, { field: 'created_at', order: 'desc' }],
89+
'status,-created_at',
90+
);
91+
bothRoutes('a string[]', ['name', '-age'], 'name,-age');
92+
bothRoutes('a Record<field, direction>', { name: 'asc', age: 'desc' }, 'name,-age');
93+
94+
bothRoutes('no sort at all', undefined, undefined);
95+
bothRoutes('an empty array', [], undefined);
96+
bothRoutes('an empty object', {}, undefined);
97+
bothRoutes('a whitespace-only string', ' ', undefined);
98+
});
99+
100+
describe('serializeOrderBy', () => {
101+
it('never enumerates a string as an object — the defect this replaced', () => {
102+
// `Object.entries('name asc')` → [['0','n'],['1','a'],…]. The old fold
103+
// reached that branch for every string input.
104+
expect(serializeOrderBy('name asc')).toBe('name asc');
105+
expect(serializeOrderBy('name asc')).not.toMatch(/^0,1,2/);
106+
});
107+
108+
it('reads `order` case-insensitively', () => {
109+
expect(serializeOrderBy([{ field: 'name', order: 'DESC' as any }])).toBe('-name');
110+
});
111+
112+
it('defaults a missing direction to ascending', () => {
113+
expect(serializeOrderBy([{ field: 'name' }])).toBe('name');
114+
});
115+
});

0 commit comments

Comments
 (0)