@@ -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 ;
0 commit comments