Skip to content

Commit cea18c0

Browse files
authored
Merge branch 'trunk' into fix/server-side-parameter-binding
2 parents c3c8fa2 + 7b8c626 commit cea18c0

2 files changed

Lines changed: 92 additions & 5 deletions

File tree

src/client-common.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,35 @@ function convertArrowValue(value: any, field?: any): any {
203203
* - Timestamp types: Converts Date objects to ISO 8601 strings (without Z for timestamps without timezone)
204204
* - List types: Converts Arrow Vector objects to JavaScript arrays
205205
*/
206+
/**
207+
* Coerces a `/v1/nsql` payload into the documented {@link NsqlResponse} shape.
208+
*
209+
* The runtime omits `schema` entirely when the generated query returned no rows,
210+
* and a runtime that does not honor the `application/vnd.spiceai.nsql.v1+json`
211+
* Accept header answers with a bare array of rows. Both are normalized here so
212+
* callers can always read `sql`, `data`, `schema.fields` and `row_count`.
213+
*/
214+
function normalizeNsqlResponse(payload: unknown): NsqlResponse {
215+
if (Array.isArray(payload)) {
216+
return {
217+
row_count: payload.length,
218+
schema: { fields: [] },
219+
data: payload,
220+
sql: '',
221+
};
222+
}
223+
224+
const result = (payload ?? {}) as Partial<NsqlResponse>;
225+
const data = result.data ?? [];
226+
227+
return {
228+
row_count: result.row_count ?? data.length,
229+
schema: { fields: result.schema?.fields ?? [] },
230+
data,
231+
sql: result.sql ?? '',
232+
};
233+
}
234+
206235
function wrapTableForDecimalConversion(table: Table): Table {
207236
const originalToArray = table.toArray.bind(table);
208237

@@ -1387,6 +1416,9 @@ export class SpiceClient {
13871416
'/v1/nsql',
13881417
undefined,
13891418
JSON.stringify(request),
1419+
// Without this the runtime replies with a bare array of rows, which
1420+
// carries neither the generated SQL nor the schema.
1421+
{ Accept: 'application/vnd.spiceai.nsql.v1+json' },
13901422
);
13911423

13921424
if (!response.ok) {
@@ -1397,7 +1429,7 @@ export class SpiceClient {
13971429
}
13981430

13991431
const result = await response.json();
1400-
return result as NsqlResponse;
1432+
return normalizeNsqlResponse(result);
14011433
}
14021434

14031435
/**

test/browser/client.test.ts

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -438,11 +438,12 @@ describe('Browser SpiceClient', () => {
438438

439439
describe('NSQL (Natural Language SQL)', () => {
440440
test('should execute NSQL query', async () => {
441+
// Shape the runtime returns for application/vnd.spiceai.nsql.v1+json.
441442
const mockResponse = {
442-
sql: 'SELECT * FROM users LIMIT 10',
443-
schema: [{ name: 'id', data_type: 'Int32' }],
444-
rows: [[1]],
445443
row_count: 1,
444+
schema: { fields: [{ name: 'id', data_type: 'Int32' }] },
445+
data: [{ id: 1 }],
446+
sql: 'SELECT * FROM users LIMIT 10',
446447
};
447448

448449
(global.fetch as jest.Mock).mockResolvedValueOnce({
@@ -457,16 +458,70 @@ describe('Browser SpiceClient', () => {
457458

458459
const result = await client.nsql('show me users');
459460

460-
expect(result).toHaveProperty('sql');
461461
expect(result.sql).toBe('SELECT * FROM users LIMIT 10');
462+
expect(result.row_count).toBe(1);
463+
expect(result.data).toEqual([{ id: 1 }]);
464+
expect(result.schema.fields).toEqual([{ name: 'id', data_type: 'Int32' }]);
462465
expect(global.fetch).toHaveBeenCalledWith(
463466
'http://localhost:8090/v1/nsql',
464467
expect.objectContaining({
465468
method: 'POST',
469+
headers: expect.objectContaining({
470+
Accept: 'application/vnd.spiceai.nsql.v1+json',
471+
}),
466472
}),
467473
);
468474
});
469475

476+
test('should normalize a bare row array into the documented shape', async () => {
477+
// What the runtime sends when the Accept header is absent or stripped.
478+
const rows = [{ id: 1 }, { id: 2 }];
479+
480+
(global.fetch as jest.Mock).mockResolvedValueOnce({
481+
ok: true,
482+
status: 200,
483+
headers: {
484+
get: jest.fn(),
485+
},
486+
text: jest.fn().mockResolvedValue(JSON.stringify(rows)),
487+
json: jest.fn().mockResolvedValue(rows),
488+
});
489+
490+
const result = await client.nsql('show me users');
491+
492+
expect(result.data).toEqual(rows);
493+
expect(result.row_count).toBe(2);
494+
expect(result.schema.fields).toEqual([]);
495+
expect(result.sql).toBe('');
496+
});
497+
498+
test('should tolerate an empty result set', async () => {
499+
// The runtime omits schema fields entirely when no rows are returned.
500+
const mockResponse = {
501+
row_count: 0,
502+
schema: {},
503+
data: [],
504+
sql: 'SELECT * FROM users WHERE false',
505+
};
506+
507+
(global.fetch as jest.Mock).mockResolvedValueOnce({
508+
ok: true,
509+
status: 200,
510+
headers: {
511+
get: jest.fn(),
512+
},
513+
text: jest.fn().mockResolvedValue(JSON.stringify(mockResponse)),
514+
json: jest.fn().mockResolvedValue(mockResponse),
515+
});
516+
517+
const result = await client.nsql('show me users');
518+
519+
expect(result.schema.fields).toEqual([]);
520+
expect(result.data).toEqual([]);
521+
expect(result.row_count).toBe(0);
522+
expect(result.sql).toBe('SELECT * FROM users WHERE false');
523+
});
524+
470525
test('should handle NSQL errors', async () => {
471526
(global.fetch as jest.Mock).mockResolvedValueOnce({
472527
ok: false,

0 commit comments

Comments
 (0)