use serde::{Deserialize, Serialize};
use snafu::Snafu;
#[derive(Debug, Snafu)]
pub enum NsqlError {
#[snafu(display("Invalid NSQL request: {message}"))]
InvalidRequest { message: String },
#[snafu(display("NSQL request failed (HTTP {status_code}): {response_body}"))]
NsqlFailed {
status_code: u16,
response_body: String,
},
#[snafu(display("NSQL request failed: {message}"))]
HttpError { message: String },
#[snafu(display("Failed to parse NSQL response: {message}"))]
ParseError { message: String },
}
pub(crate) const NSQL_JSON_MEDIA_TYPE: &str = "application/vnd.spiceai.nsql.v1+json";
pub const NSQL_CONTEXT_MAX_LIMIT: usize = 100;
pub(crate) const NSQL_SQL_MEDIA_TYPE: &str = "application/sql";
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct NsqlRequest {
pub query: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub datasets: Vec<String>,
#[serde(skip_serializing_if = "is_false")]
pub sample_data_enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NsqlContextRequest {
pub model: Option<String>,
pub datasets: Vec<String>,
pub include_sampling: bool,
pub sampling_limit: Option<usize>,
pub include_examples: Option<bool>,
pub examples_limit: Option<usize>,
}
impl NsqlContextRequest {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
#[must_use]
pub fn with_datasets<I, S>(mut self, datasets: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.datasets = datasets.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn with_sampling(mut self, include: bool) -> Self {
self.include_sampling = include;
self
}
#[must_use]
pub fn with_sampling_limit(mut self, limit: usize) -> Self {
self.sampling_limit = Some(limit);
self
}
#[must_use]
pub fn with_examples(mut self, include: bool) -> Self {
self.include_examples = Some(include);
self
}
#[must_use]
pub fn with_examples_limit(mut self, limit: usize) -> Self {
self.examples_limit = Some(limit);
self
}
pub(crate) fn validate(&self) -> Result<(), NsqlError> {
for (field, value) in [
("sampling_limit", self.sampling_limit),
("examples_limit", self.examples_limit),
] {
if let Some(limit) = value
&& limit > NSQL_CONTEXT_MAX_LIMIT
{
return Err(NsqlError::InvalidRequest {
message: format!(
"{field} must be at most {NSQL_CONTEXT_MAX_LIMIT}, got {limit}"
),
});
}
}
Ok(())
}
pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
let mut pairs = Vec::new();
if let Some(model) = &self.model {
pairs.push(("model", model.clone()));
}
for dataset in &self.datasets {
pairs.push(("datasets", dataset.clone()));
}
if self.include_sampling {
pairs.push(("include_sampling", "true".to_string()));
}
if let Some(limit) = self.sampling_limit {
pairs.push(("sampling_limit", limit.to_string()));
}
if let Some(include) = self.include_examples {
pairs.push(("include_examples", include.to_string()));
}
if let Some(limit) = self.examples_limit {
pairs.push(("examples_limit", limit.to_string()));
}
pairs
}
}
impl NsqlRequest {
#[must_use]
pub fn new(query: impl Into<String>) -> Self {
Self {
query: query.into(),
..Default::default()
}
}
#[must_use]
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
#[must_use]
pub fn with_datasets<I, S>(mut self, datasets: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.datasets = datasets.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn with_sample_data(mut self, enabled: bool) -> Self {
self.sample_data_enabled = enabled;
self
}
#[must_use]
pub fn with_prompt_cache_key(mut self, key: impl Into<String>) -> Self {
self.prompt_cache_key = Some(key.into());
self
}
pub(crate) fn validate(&self) -> Result<(), NsqlError> {
if self.query.trim().is_empty() {
return Err(NsqlError::InvalidRequest {
message: "query is required and must be a non-empty natural language query"
.to_string(),
});
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct NsqlField {
#[serde(default)]
pub name: String,
#[serde(default)]
pub data_type: serde_json::Value,
#[serde(default)]
pub nullable: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
pub struct NsqlSchema {
#[serde(default)]
pub fields: Vec<NsqlField>,
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
pub struct NsqlResponse {
#[serde(default)]
pub sql: String,
#[serde(default)]
pub row_count: usize,
#[serde(default)]
pub schema: NsqlSchema,
#[serde(default)]
pub data: Vec<serde_json::Map<String, serde_json::Value>>,
}
impl NsqlResponse {
#[must_use]
pub fn len(&self) -> usize {
self.data.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.data.is_empty()
}
}
impl IntoIterator for NsqlResponse {
type Item = serde_json::Map<String, serde_json::Value>;
type IntoIter = std::vec::IntoIter<Self::Item>;
fn into_iter(self) -> Self::IntoIter {
self.data.into_iter()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn body(request: &NsqlRequest) -> serde_json::Value {
serde_json::to_value(request).expect("serialize nsql request")
}
#[test]
fn test_query_only_request_omits_optional_fields() {
assert_eq!(
body(&NsqlRequest::new("how many orders")),
serde_json::json!({"query": "how many orders"})
);
}
#[test]
fn test_full_request_serialization() {
let request = NsqlRequest::new("top 5 customers by revenue")
.with_model("nsql-model")
.with_datasets(["sales"])
.with_sample_data(true)
.with_prompt_cache_key("sales-dashboard");
assert_eq!(
body(&request),
serde_json::json!({
"query": "top 5 customers by revenue",
"model": "nsql-model",
"datasets": ["sales"],
"sample_data_enabled": true,
"prompt_cache_key": "sales-dashboard",
})
);
}
#[test]
fn test_defaults_omitted() {
let request = NsqlRequest::new("how many orders")
.with_datasets(Vec::<String>::new())
.with_sample_data(false);
assert_eq!(
body(&request),
serde_json::json!({"query": "how many orders"})
);
}
#[test]
fn test_validate_rejects_empty_query() {
let err = NsqlRequest::new(" ")
.validate()
.expect_err("empty query should be rejected");
assert!(err.to_string().contains("non-empty"), "{err}");
}
#[test]
fn test_validate_accepts_minimal_request() {
NsqlRequest::new("how many orders")
.validate()
.expect("valid");
}
#[test]
fn test_response_deserialization() {
let response: NsqlResponse = serde_json::from_str(
r#"{
"row_count": 2,
"schema": {
"fields": [
{"name": "customer_id", "data_type": "Utf8", "nullable": false},
{"name": "ts", "data_type": {"Timestamp": ["Nanosecond", null]}, "nullable": true}
]
},
"data": [
{"customer_id": "12345", "ts": 1724716542},
{"customer_id": "67890", "ts": 1724716543}
],
"sql": "SELECT customer_id, ts FROM sales LIMIT 2"
}"#,
)
.expect("deserialize nsql response");
assert_eq!(response.sql, "SELECT customer_id, ts FROM sales LIMIT 2");
assert_eq!(response.row_count, 2);
assert_eq!(response.len(), 2);
assert!(!response.is_empty());
assert_eq!(response.data[0]["customer_id"], "12345");
assert_eq!(response.schema.fields.len(), 2);
assert_eq!(response.schema.fields[0].name, "customer_id");
assert_eq!(response.schema.fields[0].data_type, "Utf8");
assert_eq!(
response.schema.fields[1].data_type,
serde_json::json!({"Timestamp": ["Nanosecond", null]})
);
assert!(response.schema.fields[1].nullable);
}
#[test]
fn test_empty_result_set() {
let response: NsqlResponse = serde_json::from_str(
r#"{"row_count": 0, "schema": {}, "data": [], "sql": "SELECT 1 WHERE false"}"#,
)
.expect("deserialize");
assert!(response.is_empty());
assert!(response.schema.fields.is_empty());
assert_eq!(response.sql, "SELECT 1 WHERE false");
assert_eq!(response.into_iter().count(), 0);
}
}