Skip to content

Commit b379316

Browse files
committed
feat: add nsql and nsql_generate_sql for the runtime's /v1/nsql endpoint
Text-to-SQL was reachable from spice.js but from no other SDK, so Rust callers had to hand-roll the HTTP call - including knowing to ask for application/vnd.spiceai.nsql.v1+json, without which the runtime returns a bare array of rows and drops the generated SQL. nsql runs the generated query and returns the rows alongside the SQL. nsql_generate_sql stops after generation, so the query can be inspected, edited, or run through sql() to get Arrow-typed results instead of decoded JSON.
1 parent 9566c18 commit b379316

5 files changed

Lines changed: 681 additions & 1 deletion

File tree

README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,69 @@ Adding `with_keywords([...])` runs a lexical pass alongside the vector pass, whi
245245

246246
Each `SearchMatch` carries `dataset`, `score` (higher is more similar), `matches` (matched values keyed by source column — a list per column, since one column can contribute several chunks to a match), `primary_key`, `data`, and `metadata`.
247247

248+
### Text-to-SQL (NSQL)
249+
250+
`nsql()` answers a question in natural language: the configured LLM generates SQL, the
251+
runtime runs it read-only, and both the rows and the generated query come back. It needs
252+
an LLM model in the Spicepod — see [Text to SQL](https://docs.spice.ai/features/text-to-sql)
253+
for how to configure one.
254+
255+
```rust,no_run
256+
use spiceai::{ClientBuilder, NsqlRequest};
257+
258+
#[tokio::main]
259+
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
260+
let client = ClientBuilder::new()
261+
.http_url("http://localhost:8090")
262+
.build()
263+
.await?;
264+
265+
let response = client
266+
.nsql(NsqlRequest::new("top 5 customers by revenue").with_datasets(["sales"]))
267+
.await?;
268+
269+
println!("generated SQL: {}", response.sql);
270+
for row in response {
271+
println!("{row:?}");
272+
}
273+
274+
Ok(())
275+
}
276+
```
277+
278+
`NsqlRequest` takes the question plus `with_model()` (needed only when the Spicepod
279+
configures more than one compatible model), `with_datasets()` (a hint about what to
280+
sample for the model's context — it does not restrict which tables the generated query
281+
may reference), `with_sample_data()`, and `with_prompt_cache_key()`.
282+
283+
Rows in `data` are decoded from JSON, so they carry JSON's types rather than the Arrow
284+
types named in `schema`. When Arrow types matter, generate the query and run it yourself
285+
— which is also how to inspect or edit a generated query before it runs:
286+
287+
```rust,no_run
288+
use spiceai::{ClientBuilder, NsqlRequest, StreamExt};
289+
290+
#[tokio::main]
291+
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
292+
let client = ClientBuilder::new()
293+
.http_url("http://localhost:8090")
294+
.build()
295+
.await?;
296+
297+
let sql = client
298+
.nsql_generate_sql(NsqlRequest::new("top 5 customers by revenue"))
299+
.await?;
300+
println!("{sql}");
301+
302+
let mut stream = client.sql(&sql).await?;
303+
while let Some(batch) = stream.next().await {
304+
println!("rows: {}", batch?.num_rows());
305+
}
306+
307+
Ok(())
308+
}
309+
```
310+
248311
### Runtime health and status
249312

250313
`is_ready()` is a single boolean for the whole runtime. When you need to know *which*

src/client.rs

Lines changed: 222 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use crate::{
77
config::{GenericError, SPICE_CLOUD_FLIGHT_ADDR, SPICE_LOCAL_FLIGHT_ADDR},
88
dataset::{DatasetError, DatasetRefreshRequest, DatasetRefreshResponse},
99
flight::{SqlFlightClient, is_connection_reset_generic_error},
10+
nsql::{NsqlError, NsqlRequest, NsqlResponse},
1011
search::{SearchError, SearchRequest, SearchResponse},
1112
status::{ConnectionDetails, StatusError},
1213
tls::{FlightChannelBuilder, ensure_crypto_provider, new_tls_flight_channel},
@@ -729,6 +730,94 @@ impl SpiceClient {
729730
http_client.search(&request).await
730731
}
731732

733+
/// Answers a natural-language question by generating SQL and running it.
734+
///
735+
/// Backed by `POST /v1/nsql`: the configured LLM translates the question,
736+
/// the runtime executes the result read-only, and both the rows and the
737+
/// generated SQL come back. Requires an LLM model in the Spicepod — see
738+
/// [Text to SQL](https://docs.spice.ai/features/text-to-sql) for how to
739+
/// configure one.
740+
///
741+
/// **Note:** Requires [`http_url()`](SpiceClientBuilder::http_url) to be configured.
742+
///
743+
/// # Example
744+
///
745+
/// ```no_run
746+
/// # use spiceai::{ClientBuilder, NsqlRequest};
747+
/// # #[tokio::main]
748+
/// # async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
749+
/// let client = ClientBuilder::new()
750+
/// .http_url("http://localhost:8090")
751+
/// .build()
752+
/// .await?;
753+
///
754+
/// let response = client
755+
/// .nsql(NsqlRequest::new("top 5 customers by revenue").with_datasets(["sales"]))
756+
/// .await?;
757+
///
758+
/// println!("generated SQL: {}", response.sql);
759+
/// for row in response {
760+
/// println!("{row:?}");
761+
/// }
762+
/// # Ok(())
763+
/// # }
764+
/// ```
765+
///
766+
/// # Errors
767+
///
768+
/// - [`NsqlError::InvalidRequest`] if the request has an empty query
769+
/// - [`NsqlError::HttpError`] if the HTTP endpoint is not configured or unreachable
770+
/// - [`NsqlError::NsqlFailed`] if the runtime rejects the request, which is
771+
/// what a missing or ambiguous model reports as
772+
pub async fn nsql(&self, request: NsqlRequest) -> Result<NsqlResponse, NsqlError> {
773+
let http_client = self.http_client.as_ref().ok_or(NsqlError::HttpError {
774+
message: "HTTP endpoint not configured. Use ClientBuilder::http_url() to set it."
775+
.to_string(),
776+
})?;
777+
778+
http_client.nsql(&request).await
779+
}
780+
781+
/// Translates a natural-language question into SQL without running it.
782+
///
783+
/// Use it to inspect or edit the query before running it, or to run it
784+
/// through [`query`](Self::query) or the Flight path so results arrive as
785+
/// Arrow rather than decoded JSON.
786+
///
787+
/// **Note:** Requires [`http_url()`](SpiceClientBuilder::http_url) to be configured.
788+
///
789+
/// # Example
790+
///
791+
/// ```no_run
792+
/// # use spiceai::{ClientBuilder, NsqlRequest};
793+
/// # #[tokio::main]
794+
/// # async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
795+
/// let client = ClientBuilder::new()
796+
/// .http_url("http://localhost:8090")
797+
/// .build()
798+
/// .await?;
799+
///
800+
/// let sql = client
801+
/// .nsql_generate_sql(NsqlRequest::new("top 5 customers by revenue"))
802+
/// .await?;
803+
///
804+
/// println!("{sql}");
805+
/// # Ok(())
806+
/// # }
807+
/// ```
808+
///
809+
/// # Errors
810+
///
811+
/// Same as [`nsql`](Self::nsql).
812+
pub async fn nsql_generate_sql(&self, request: NsqlRequest) -> Result<String, NsqlError> {
813+
let http_client = self.http_client.as_ref().ok_or(NsqlError::HttpError {
814+
message: "HTTP endpoint not configured. Use ClientBuilder::http_url() to set it."
815+
.to_string(),
816+
})?;
817+
818+
http_client.nsql_generate_sql(&request).await
819+
}
820+
732821
/// Returns the status of each runtime connection.
733822
///
734823
/// Backed by `GET /v1/status`. Where [`is_ready`](Self::is_ready) reports a single
@@ -1003,7 +1092,7 @@ mod tests {
10031092
use serde_json::json;
10041093
use std::time::Duration;
10051094
use tonic::transport::Endpoint;
1006-
use wiremock::matchers::{body_json, method, path, path_regex, query_param};
1095+
use wiremock::matchers::{body_json, header, method, path, path_regex, query_param};
10071096
use wiremock::{Mock, MockServer, ResponseTemplate};
10081097

10091098
fn test_client(http_base_url: Option<&str>) -> SpiceClient {
@@ -1720,6 +1809,138 @@ mod tests {
17201809
assert!(err.to_string().contains("http_url"), "{err}");
17211810
}
17221811

1812+
#[tokio::test]
1813+
async fn test_nsql_posts_request_and_parses_response() {
1814+
let server = MockServer::start().await;
1815+
1816+
Mock::given(method("POST"))
1817+
.and(path("/v1/nsql"))
1818+
// Without this media type the runtime answers with a bare array of
1819+
// rows and the generated SQL is lost, so pin it.
1820+
.and(header("accept", "application/vnd.spiceai.nsql.v1+json"))
1821+
.and(body_json(json!({
1822+
"query": "top 5 customers by revenue",
1823+
"datasets": ["sales"],
1824+
})))
1825+
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
1826+
"row_count": 2,
1827+
"schema": {
1828+
"fields": [
1829+
{"name": "customer_id", "data_type": "Utf8", "nullable": false},
1830+
{"name": "total", "data_type": "Int64", "nullable": false}
1831+
]
1832+
},
1833+
"data": [
1834+
{"customer_id": "12345", "total": 150_000},
1835+
{"customer_id": "67890", "total": 125_000}
1836+
],
1837+
"sql": "SELECT customer_id, sum(total) AS total FROM sales GROUP BY customer_id"
1838+
})))
1839+
.mount(&server)
1840+
.await;
1841+
1842+
let client = test_client(Some(&server.uri()));
1843+
1844+
let response = client
1845+
.nsql(NsqlRequest::new("top 5 customers by revenue").with_datasets(["sales"]))
1846+
.await
1847+
.expect("nsql succeeds");
1848+
1849+
assert_eq!(
1850+
response.sql,
1851+
"SELECT customer_id, sum(total) AS total FROM sales GROUP BY customer_id"
1852+
);
1853+
assert_eq!(response.row_count, 2);
1854+
assert_eq!(response.len(), 2);
1855+
assert_eq!(response.data[0]["customer_id"], "12345");
1856+
assert_eq!(response.schema.fields.len(), 2);
1857+
assert_eq!(response.schema.fields[0].data_type, "Utf8");
1858+
}
1859+
1860+
#[tokio::test]
1861+
async fn test_nsql_generate_sql_requests_the_sql_media_type() {
1862+
let server = MockServer::start().await;
1863+
1864+
Mock::given(method("POST"))
1865+
.and(path("/v1/nsql"))
1866+
.and(header("accept", "application/sql"))
1867+
.respond_with(
1868+
// This media type answers with the bare query text.
1869+
ResponseTemplate::new(200).set_body_string("\n SELECT count(*) FROM orders\n"),
1870+
)
1871+
.mount(&server)
1872+
.await;
1873+
1874+
let client = test_client(Some(&server.uri()));
1875+
1876+
let sql = client
1877+
.nsql_generate_sql(NsqlRequest::new("how many orders"))
1878+
.await
1879+
.expect("nsql_generate_sql succeeds");
1880+
1881+
assert_eq!(sql, "SELECT count(*) FROM orders");
1882+
}
1883+
1884+
#[tokio::test]
1885+
async fn test_nsql_surfaces_runtime_error_body() {
1886+
let server = MockServer::start().await;
1887+
1888+
// A missing or ambiguous model is the most common NSQL failure and the
1889+
// runtime explains it in the body.
1890+
Mock::given(method("POST"))
1891+
.and(path("/v1/nsql"))
1892+
.respond_with(
1893+
ResponseTemplate::new(400).set_body_string(
1894+
"No model specified and no compatible LLM model is configured.",
1895+
),
1896+
)
1897+
.mount(&server)
1898+
.await;
1899+
1900+
let client = test_client(Some(&server.uri()));
1901+
1902+
let err = client
1903+
.nsql(NsqlRequest::new("how many orders"))
1904+
.await
1905+
.expect_err("runtime rejects the request");
1906+
1907+
let message = err.to_string();
1908+
assert!(message.contains("No model specified"), "{message}");
1909+
assert!(message.contains("400"), "{message}");
1910+
}
1911+
1912+
#[tokio::test]
1913+
async fn test_nsql_validates_before_sending() {
1914+
let server = MockServer::start().await;
1915+
// No mock is mounted: a request reaching the server fails the test.
1916+
let client = test_client(Some(&server.uri()));
1917+
1918+
let err = client
1919+
.nsql(NsqlRequest::new(" "))
1920+
.await
1921+
.expect_err("empty query is rejected");
1922+
assert!(err.to_string().contains("non-empty"), "{err}");
1923+
1924+
assert!(
1925+
server
1926+
.received_requests()
1927+
.await
1928+
.unwrap_or_default()
1929+
.is_empty()
1930+
);
1931+
}
1932+
1933+
#[tokio::test]
1934+
async fn test_nsql_requires_http_url() {
1935+
let client = test_client(None);
1936+
1937+
let err = client
1938+
.nsql(NsqlRequest::new("how many orders"))
1939+
.await
1940+
.expect_err("nsql without an HTTP endpoint fails");
1941+
assert!(err.to_string().contains("http_url"), "{err}");
1942+
}
1943+
17231944
#[tokio::test]
17241945
async fn test_active_queries_lists_from_the_runtime() {
17251946
let server = MockServer::start().await;

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod client;
55
mod config;
66
mod dataset;
77
mod flight;
8+
pub mod nsql;
89
mod params;
910
pub mod query;
1011
mod redirect;
@@ -21,6 +22,7 @@ pub use client::SpiceClientBuilder as ClientBuilder;
2122
pub use dataset::{
2223
DatasetError, DatasetRefreshMode, DatasetRefreshRequest, DatasetRefreshResponse,
2324
};
25+
pub use nsql::{NsqlError, NsqlField, NsqlRequest, NsqlResponse, NsqlSchema};
2426
pub use params::{QueryParameter, QueryParameterError, QueryParameters};
2527
pub use query::{
2628
QueryError, QueryInfo, QueryJob, QueryListResponse, QueryResult, QueryResultStream,

0 commit comments

Comments
 (0)