Skip to content

Commit eb32a38

Browse files
Copilothotlong
andcommitted
fix: data adapter $expand support — use data.query() for find/findOne with expand
The @objectstack/client's data.find() and data.get() don't support expand in their API. When $expand is needed: - find() now uses data.query() (POST) which supports the full query AST including expand - findOne() now uses data.query() with an _id filter when $expand is present instead of ignoring the params argument entirely Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 1d0a531 commit eb32a38

2 files changed

Lines changed: 279 additions & 24 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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+
import { describe, it, expect, beforeEach, vi } from 'vitest';
10+
import { ObjectStackAdapter } from './index';
11+
12+
// We test the adapter's $expand handling by mocking the underlying ObjectStack client.
13+
// The key scenarios:
14+
// 1. find() with $expand should use data.query() (POST) instead of data.find() (GET)
15+
// 2. find() without $expand should use data.find() (GET) as before
16+
// 3. findOne() with $expand should use data.query() with an _id filter
17+
// 4. findOne() without $expand should use data.get() as before
18+
19+
describe('ObjectStackAdapter $expand support', () => {
20+
let adapter: ObjectStackAdapter;
21+
let mockClient: any;
22+
23+
beforeEach(() => {
24+
adapter = new ObjectStackAdapter({
25+
baseUrl: 'http://localhost:3000',
26+
autoReconnect: false,
27+
});
28+
29+
// Mock the internal client after construction
30+
mockClient = {
31+
data: {
32+
find: vi.fn().mockResolvedValue({ records: [], total: 0 }),
33+
query: vi.fn().mockResolvedValue({ records: [], total: 0 }),
34+
get: vi.fn().mockResolvedValue({ record: { _id: '1', name: 'Test' } }),
35+
},
36+
connect: vi.fn().mockResolvedValue(undefined),
37+
discover: vi.fn().mockResolvedValue({ status: 'ok' }),
38+
};
39+
40+
// Inject mock client and mark as connected to bypass connect()
41+
(adapter as any).client = mockClient;
42+
(adapter as any).connected = true;
43+
});
44+
45+
describe('find() with $expand', () => {
46+
it('should use data.query() when $expand is present', async () => {
47+
mockClient.data.query.mockResolvedValue({
48+
records: [{ _id: '1', name: 'Order 1', customer: { _id: '2', name: 'Alice' } }],
49+
total: 1,
50+
});
51+
52+
const result = await adapter.find('order', {
53+
$top: 10,
54+
$expand: ['customer', 'account'],
55+
});
56+
57+
expect(mockClient.data.query).toHaveBeenCalledWith('order', expect.objectContaining({
58+
expand: {
59+
customer: {},
60+
account: {},
61+
},
62+
limit: 10,
63+
}));
64+
expect(mockClient.data.find).not.toHaveBeenCalled();
65+
expect(result.data).toHaveLength(1);
66+
expect(result.data[0].customer).toEqual({ _id: '2', name: 'Alice' });
67+
});
68+
69+
it('should pass filters and sort to data.query()', async () => {
70+
mockClient.data.query.mockResolvedValue({ records: [], total: 0 });
71+
72+
await adapter.find('order', {
73+
$filter: [['status', '=', 'active']],
74+
$orderby: [{ field: 'name', order: 'asc' }],
75+
$top: 50,
76+
$skip: 10,
77+
$expand: ['customer'],
78+
});
79+
80+
expect(mockClient.data.query).toHaveBeenCalledWith('order', expect.objectContaining({
81+
filters: [['status', '=', 'active']],
82+
sort: ['name'],
83+
limit: 50,
84+
offset: 10,
85+
expand: { customer: {} },
86+
}));
87+
});
88+
89+
it('should use data.find() when $expand is not present', async () => {
90+
mockClient.data.find.mockResolvedValue({ records: [{ _id: '1', name: 'Test' }], total: 1 });
91+
92+
const result = await adapter.find('order', { $top: 10 });
93+
94+
expect(mockClient.data.find).toHaveBeenCalled();
95+
expect(mockClient.data.query).not.toHaveBeenCalled();
96+
expect(result.data).toHaveLength(1);
97+
});
98+
99+
it('should use data.find() when $expand is an empty array', async () => {
100+
mockClient.data.find.mockResolvedValue({ records: [], total: 0 });
101+
102+
await adapter.find('order', { $top: 10, $expand: [] });
103+
104+
expect(mockClient.data.find).toHaveBeenCalled();
105+
expect(mockClient.data.query).not.toHaveBeenCalled();
106+
});
107+
});
108+
109+
describe('findOne() with $expand', () => {
110+
it('should use data.query() with _id filter when $expand is present', async () => {
111+
mockClient.data.query.mockResolvedValue({
112+
records: [{ _id: 'order-1', name: 'Order 1', customer: { _id: '2', name: 'Alice' } }],
113+
});
114+
115+
const result = await adapter.findOne('order', 'order-1', {
116+
$expand: ['customer', 'account'],
117+
});
118+
119+
expect(mockClient.data.query).toHaveBeenCalledWith('order', expect.objectContaining({
120+
filters: [['_id', '=', 'order-1']],
121+
expand: {
122+
customer: {},
123+
account: {},
124+
},
125+
limit: 1,
126+
}));
127+
expect(mockClient.data.get).not.toHaveBeenCalled();
128+
expect(result).toEqual({ _id: 'order-1', name: 'Order 1', customer: { _id: '2', name: 'Alice' } });
129+
});
130+
131+
it('should return null when data.query() returns no records', async () => {
132+
mockClient.data.query.mockResolvedValue({ records: [] });
133+
134+
const result = await adapter.findOne('order', 'nonexistent', {
135+
$expand: ['customer'],
136+
});
137+
138+
expect(result).toBeNull();
139+
});
140+
141+
it('should use data.get() when $expand is not present', async () => {
142+
mockClient.data.get.mockResolvedValue({ record: { _id: '1', name: 'Test' } });
143+
144+
const result = await adapter.findOne('order', '1');
145+
146+
expect(mockClient.data.get).toHaveBeenCalledWith('order', '1');
147+
expect(mockClient.data.query).not.toHaveBeenCalled();
148+
expect(result).toEqual({ _id: '1', name: 'Test' });
149+
});
150+
151+
it('should return null for 404 errors without $expand', async () => {
152+
mockClient.data.get.mockRejectedValue({ status: 404 });
153+
154+
const result = await adapter.findOne('order', 'nonexistent');
155+
156+
expect(result).toBeNull();
157+
});
158+
});
159+
});

packages/data-objectstack/src/index.ts

Lines changed: 120 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -254,39 +254,49 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
254254
async find(resource: string, params?: QueryParams): Promise<QueryResult<T>> {
255255
await this.connect();
256256

257-
const queryOptions = this.convertQueryParams(params);
258-
const result: unknown = await this.client.data.find<T>(resource, queryOptions);
259-
260-
// Handle legacy/raw array response (e.g. from some mock servers or non-OData endpoints)
261-
if (Array.isArray(result)) {
262-
return {
263-
data: result,
264-
total: result.length,
265-
page: 1,
266-
pageSize: result.length,
267-
hasMore: false,
268-
};
257+
// When $expand is requested, use data.query() (POST) which supports the full
258+
// query AST including expand. The simpler data.find() (GET) does not support expand.
259+
if (params?.$expand && params.$expand.length > 0) {
260+
const queryBody = this.buildQueryAST(resource, params);
261+
const result: unknown = await this.client.data.query<T>(resource, queryBody);
262+
return this.normalizeQueryResult(result, params);
269263
}
270264

271-
const resultObj = result as { records?: T[]; total?: number; value?: T[]; count?: number };
272-
const records = resultObj.records || resultObj.value || [];
273-
const total = resultObj.total ?? resultObj.count ?? records.length;
274-
return {
275-
data: records,
276-
total,
277-
// Calculate page number safely
278-
page: params?.$skip && params.$top ? Math.floor(params.$skip / params.$top) + 1 : 1,
279-
pageSize: params?.$top,
280-
hasMore: params?.$top ? records.length === params.$top : false,
281-
};
265+
const queryOptions = this.convertQueryParams(params);
266+
const result: unknown = await this.client.data.find<T>(resource, queryOptions);
267+
return this.normalizeQueryResult(result, params);
282268
}
283269

284270
/**
285271
* Find a single record by ID.
286272
*/
287-
async findOne(resource: string, id: string | number, _params?: QueryParams): Promise<T | null> {
273+
async findOne(resource: string, id: string | number, params?: QueryParams): Promise<T | null> {
288274
await this.connect();
289275

276+
// When $expand is requested, use data.query() (POST) with an ID filter
277+
// because data.get() (GET) does not support expand through the client SDK.
278+
if (params?.$expand && params.$expand.length > 0) {
279+
try {
280+
const expand: Record<string, object> = {};
281+
for (const field of params.$expand) {
282+
expand[field] = {};
283+
}
284+
const result: unknown = await this.client.data.query<T>(resource, {
285+
filters: [['_id', '=', String(id)]],
286+
expand,
287+
limit: 1,
288+
});
289+
const resultObj = result as { records?: T[] };
290+
const records = resultObj.records || [];
291+
return records[0] || null;
292+
} catch (error: unknown) {
293+
if ((error as Record<string, unknown>)?.status === 404) {
294+
return null;
295+
}
296+
throw error;
297+
}
298+
}
299+
290300
try {
291301
const result = await this.client.data.get<T>(resource, String(id));
292302
return result.record;
@@ -490,6 +500,92 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
490500
}
491501
}
492502

503+
/**
504+
* Normalize the result from data.find() or data.query() into a consistent QueryResult.
505+
*/
506+
private normalizeQueryResult(result: unknown, params?: QueryParams): QueryResult<T> {
507+
// Handle legacy/raw array response (e.g. from some mock servers or non-OData endpoints)
508+
if (Array.isArray(result)) {
509+
return {
510+
data: result,
511+
total: result.length,
512+
page: 1,
513+
pageSize: result.length,
514+
hasMore: false,
515+
};
516+
}
517+
518+
const resultObj = result as { records?: T[]; total?: number; value?: T[]; count?: number };
519+
const records = resultObj.records || resultObj.value || [];
520+
const total = resultObj.total ?? resultObj.count ?? records.length;
521+
return {
522+
data: records,
523+
total,
524+
// Calculate page number safely
525+
page: params?.$skip && params.$top ? Math.floor(params.$skip / params.$top) + 1 : 1,
526+
pageSize: params?.$top,
527+
hasMore: params?.$top ? records.length === params.$top : false,
528+
};
529+
}
530+
531+
/**
532+
* Build a query AST for data.query() from ObjectUI QueryParams.
533+
* Used when $expand is required (data.find() does not support expand).
534+
*/
535+
private buildQueryAST(_resource: string, params: QueryParams): Record<string, unknown> {
536+
const query: Record<string, unknown> = {};
537+
538+
// Selection
539+
if (params.$select) {
540+
query.select = params.$select;
541+
}
542+
543+
// Filtering
544+
if (params.$filter) {
545+
if (Array.isArray(params.$filter)) {
546+
query.filters = params.$filter;
547+
} else {
548+
query.filters = convertFiltersToAST(params.$filter);
549+
}
550+
}
551+
552+
// Sorting - convert to AST sort nodes
553+
if (params.$orderby) {
554+
if (Array.isArray(params.$orderby)) {
555+
query.sort = params.$orderby.map((item: any) => {
556+
if (typeof item === 'string') return item;
557+
const field = item.field;
558+
const order = item.order || 'asc';
559+
return order === 'desc' ? `-${field}` : field;
560+
});
561+
} else {
562+
const sortArray = Object.entries(params.$orderby).map(([field, order]) => {
563+
return order === 'desc' ? `-${field}` : field;
564+
});
565+
query.sort = sortArray;
566+
}
567+
}
568+
569+
// Pagination
570+
if (params.$skip !== undefined) {
571+
query.offset = params.$skip;
572+
}
573+
if (params.$top !== undefined) {
574+
query.limit = params.$top;
575+
}
576+
577+
// Expand — build expand map for query AST
578+
if (params.$expand && params.$expand.length > 0) {
579+
const expand: Record<string, object> = {};
580+
for (const field of params.$expand) {
581+
expand[field] = {};
582+
}
583+
query.expand = expand;
584+
}
585+
586+
return query;
587+
}
588+
493589
/**
494590
* Convert ObjectUI QueryParams to ObjectStack QueryOptions.
495591
* Maps OData-style conventions to ObjectStack conventions.

0 commit comments

Comments
 (0)