Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,21 @@ The response includes:
- `data`: Array of row objects
- `sql`: The SQL query generated by the AI model

#### `nsqlGenerateSql(query, options)` - Generate SQL without running it

`nsqlGenerateSql()` takes the same arguments as `nsql()`, but only generates the SQL — it never runs it. Use it to inspect or edit the query before running it, or to run it through `sql()`/`sqlJson()` for Arrow-typed results instead of `nsql()`'s decoded JSON rows.

```js
const generatedSql = await spiceClient.nsqlGenerateSql(
'Show me the top 5 customers by total sales',
);

console.log(generatedSql); // "SELECT ... FROM ... ORDER BY ... LIMIT 5"

// Run it yourself once you're happy with it
const table = await spiceClient.sql(generatedSql);
```

#### `query(sql: string, onData?: callback)` - Legacy query method

The `query()` method is the legacy API for executing SQL queries. It's still supported but `sql()` is recommended for new code.
Expand Down
41 changes: 41 additions & 0 deletions src/client-common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1440,6 +1440,47 @@ export class SpiceClient {
return normalizeNsqlResponse(result);
}

/**
* Translate a natural language query into SQL without running it.
*
* Use this to inspect or edit the generated query before running it, or to
* run it through {@link sql}/{@link sqlJson} for Arrow-typed results
* instead of the JSON rows `nsql()` returns.
*
* @param query - The natural language query to convert to SQL
* @param options - Optional configuration for the NSQL request
* @returns Promise resolving to the generated SQL string
*/
async nsqlGenerateSql(query: string, options?: NsqlOptions): Promise<string> {
if (!this._httpUrl) {
throw new Error('HTTP URL is required for NSQL operation');
}

const request = {
query,
...options,
};

const response = await this.fetchInternal(
'POST',
'/v1/nsql',
undefined,
JSON.stringify(request),
// Asks the runtime to only generate SQL, not run it. Without this the
// runtime defaults to the JSON envelope nsql() consumes.
{ Accept: 'application/sql' },
);

if (!response.ok) {
const errorText = await response.text();
throw new Error(
`NSQL request failed: ${response.status} ${response.statusText} - ${errorText}`,
);
}

return (await response.text()).trim();
}

/**
* Perform a hybrid search operation on a dataset.
*
Expand Down
182 changes: 182 additions & 0 deletions test/nsql.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* Unit tests for the nsql() and nsqlGenerateSql() functions
*/

import { SpiceClient } from '../src';
import type { NsqlResponse } from '../src';

// Mock fetch for testing
const mockFetch = jest.fn();

describe('SpiceClient.nsql()', () => {
let client: SpiceClient;

beforeEach(() => {
client = new SpiceClient({
apiKey: 'test-api-key',
httpUrl: 'http://localhost:8090',
});

(client as any)._platform = {
fetch: mockFetch,
};

mockFetch.mockClear();
});

afterEach(() => {
jest.clearAllMocks();
});

it('should make a POST request to /v1/nsql with the JSON envelope Accept header', async () => {
const mockResponse: NsqlResponse = {
row_count: 1,
schema: {
fields: [
{
name: 'id',
data_type: 'Int64',
nullable: false,
dict_id: 0,
dict_is_ordered: false,
},
],
},
data: [{ id: 1 }],
sql: 'SELECT id FROM taxi_trips LIMIT 1',
};

mockFetch.mockResolvedValue({
ok: true,
json: async () => mockResponse,
text: async () => JSON.stringify(mockResponse),
});

const result = await client.nsql('one taxi trip', { model: 'my-model' });

expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8090/v1/nsql',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
Accept: 'application/vnd.spiceai.nsql.v1+json',
'X-API-Key': 'test-api-key',
}),
body: JSON.stringify({ query: 'one taxi trip', model: 'my-model' }),
}),
);

expect(result).toEqual(mockResponse);
});

it('should throw error if HTTP URL is not configured', async () => {
const clientNoHttp = new SpiceClient({
flightUrl: 'grpc://localhost:50051',
});
(clientNoHttp as any)._httpUrl = undefined;

await expect(clientNoHttp.nsql('a query')).rejects.toThrow(
'HTTP URL is required for NSQL operation',
);
});

it('should handle HTTP errors properly', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 400,
statusText: 'Bad Request',
text: async () => 'no LLM model configured',
});

await expect(client.nsql('a query')).rejects.toThrow(
'NSQL request failed: 400 Bad Request - no LLM model configured',
);
});
});

describe('SpiceClient.nsqlGenerateSql()', () => {
let client: SpiceClient;

beforeEach(() => {
client = new SpiceClient({
apiKey: 'test-api-key',
httpUrl: 'http://localhost:8090',
});

(client as any)._platform = {
fetch: mockFetch,
};

mockFetch.mockClear();
});

afterEach(() => {
jest.clearAllMocks();
});

it('should make a POST request to /v1/nsql with the application/sql Accept header', async () => {
const generatedSql = 'SELECT * FROM taxi_trips LIMIT 1';

mockFetch.mockResolvedValue({
ok: true,
text: async () => generatedSql,
});

const result = await client.nsqlGenerateSql('one taxi trip', {
model: 'my-model',
});

expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8090/v1/nsql',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
Accept: 'application/sql',
'X-API-Key': 'test-api-key',
}),
body: JSON.stringify({ query: 'one taxi trip', model: 'my-model' }),
}),
);

expect(result).toBe(generatedSql);
});

it('should trim surrounding whitespace from the response body', async () => {
mockFetch.mockResolvedValue({
ok: true,
text: async () => '\nSELECT 1\n',
});

const result = await client.nsqlGenerateSql('trivial query');

expect(result).toBe('SELECT 1');
});

it('should throw error if HTTP URL is not configured', async () => {
const clientNoHttp = new SpiceClient({
flightUrl: 'grpc://localhost:50051',
});
(clientNoHttp as any)._httpUrl = undefined;

await expect(clientNoHttp.nsqlGenerateSql('a query')).rejects.toThrow(
'HTTP URL is required for NSQL operation',
);
});

it('should handle HTTP errors properly', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 400,
statusText: 'Bad Request',
text: async () => 'no LLM model configured',
});

await expect(client.nsqlGenerateSql('a query')).rejects.toThrow(
'NSQL request failed: 400 Bad Request - no LLM model configured',
);
});
});
Loading