use std::error::Error; use std::fmt; use serde_json::Value; use crate::error::NanoError; use crate::ir::ParamMap; use crate::json_output::{JS_MAX_SAFE_INTEGER_U64, is_js_safe_integer_i64}; use crate::query::ast::{Literal, Param, QueryDecl}; use crate::query::parser::parse_query; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum JsonParamMode { Standard, JavaScript, } #[derive(Debug)] pub enum RunInputError { Core(NanoError), Message(String), } impl RunInputError { fn message(message: impl Into) -> Self { Self::Message(message.into()) } } impl fmt::Display for RunInputError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Core(err) => err.fmt(f), Self::Message(message) => f.write_str(message), } } } impl Error for RunInputError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { Self::Core(err) => Some(err), Self::Message(_) => None, } } } impl From for RunInputError { fn from(value: NanoError) -> Self { Self::Core(value) } } pub type RunInputResult = std::result::Result; pub trait ToParam { fn to_param(self) -> crate::error::Result; } impl ToParam for Literal { fn to_param(self) -> crate::error::Result { Ok(self) } } impl ToParam for &Literal { fn to_param(self) -> crate::error::Result { Ok(self.clone()) } } impl ToParam for String { fn to_param(self) -> crate::error::Result { Ok(Literal::String(self)) } } impl ToParam for &String { fn to_param(self) -> crate::error::Result { Ok(Literal::String(self.clone())) } } impl ToParam for &str { fn to_param(self) -> crate::error::Result { Ok(Literal::String(self.to_string())) } } impl ToParam for bool { fn to_param(self) -> crate::error::Result { Ok(Literal::Bool(self)) } } impl ToParam for i8 { fn to_param(self) -> crate::error::Result { Ok(Literal::Integer(i64::from(self))) } } impl ToParam for i16 { fn to_param(self) -> crate::error::Result { Ok(Literal::Integer(i64::from(self))) } } impl ToParam for i32 { fn to_param(self) -> crate::error::Result { Ok(Literal::Integer(i64::from(self))) } } impl ToParam for i64 { fn to_param(self) -> crate::error::Result { Ok(Literal::Integer(self)) } } impl ToParam for isize { fn to_param(self) -> crate::error::Result { let value = i64::try_from(self).map_err(|_| { NanoError::Execution(format!( "param value {} exceeds current engine range for numeric literals (max {})", self, i64::MAX )) })?; Ok(Literal::Integer(value)) } } impl ToParam for u8 { fn to_param(self) -> crate::error::Result { Ok(Literal::Integer(i64::from(self))) } } impl ToParam for u16 { fn to_param(self) -> crate::error::Result { Ok(Literal::Integer(i64::from(self))) } } impl ToParam for u32 { fn to_param(self) -> crate::error::Result { Ok(Literal::Integer(i64::from(self))) } } impl ToParam for u64 { fn to_param(self) -> crate::error::Result { let value = i64::try_from(self).map_err(|_| { NanoError::Execution(format!( "param value {} exceeds current engine range for numeric literals (max {})", self, i64::MAX )) })?; Ok(Literal::Integer(value)) } } impl ToParam for usize { fn to_param(self) -> crate::error::Result { let value = i64::try_from(self).map_err(|_| { NanoError::Execution(format!( "param value {} exceeds current engine range for numeric literals (max {})", self, i64::MAX )) })?; Ok(Literal::Integer(value)) } } impl ToParam for f32 { fn to_param(self) -> crate::error::Result { if !self.is_finite() { return Err(NanoError::Execution(format!( "invalid float parameter {}", self ))); } Ok(Literal::Float(f64::from(self))) } } impl ToParam for f64 { fn to_param(self) -> crate::error::Result { if !self.is_finite() { return Err(NanoError::Execution(format!( "invalid float parameter {}", self ))); } Ok(Literal::Float(self)) } } impl ToParam for Vec where T: ToParam, { fn to_param(self) -> crate::error::Result { let mut out = Vec::with_capacity(self.len()); for value in self { out.push(value.to_param()?); } Ok(Literal::List(out)) } } impl ToParam for &[T] where T: Clone + ToParam, { fn to_param(self) -> crate::error::Result { let mut out = Vec::with_capacity(self.len()); for value in self { out.push(value.clone().to_param()?); } Ok(Literal::List(out)) } } impl ToParam for [T; N] where T: ToParam, { fn to_param(self) -> crate::error::Result { let mut out = Vec::with_capacity(N); for value in self { out.push(value.to_param()?); } Ok(Literal::List(out)) } } #[macro_export] macro_rules! params { () => { ::std::result::Result::Ok($crate::ParamMap::new()) }; ($($key:expr => $value:expr),+ $(,)?) => {{ (|| -> $crate::error::Result<$crate::ParamMap> { let mut map = $crate::ParamMap::new(); $( map.insert(::std::convert::Into::::into($key), $crate::ToParam::to_param($value)?); )+ Ok(map) })() }}; } pub fn find_named_query(query_source: &str, query_name: &str) -> RunInputResult { let queries = parse_query(query_source)?; queries .queries .into_iter() .find(|query| query.name == query_name) .ok_or_else(|| RunInputError::message(format!("query '{}' not found", query_name))) } pub fn json_params_to_param_map( params: Option<&Value>, query_params: &[Param], mode: JsonParamMode, ) -> RunInputResult { let mut map = ParamMap::new(); let object = match params { Some(Value::Object(object)) => object, Some(Value::Null) | None => return Ok(map), Some(other) => { let message = match mode { JsonParamMode::Standard => "params must be a JSON object".to_string(), JsonParamMode::JavaScript => { format!("params must be an object, got {}", json_type_name(other)) } }; return Err(RunInputError::message(message)); } }; for (key, value) in object { let decl = query_params.iter().find(|param| param.name == *key); if let Some(decl) = decl { if matches!(value, Value::Null) { if decl.nullable { map.insert(key.clone(), Literal::Null); } else { return Err(RunInputError::message(format!( "param '{}': null is not accepted for non-nullable parameter", key ))); } } else { let literal = json_value_to_literal_typed(key, value, &decl.type_name, mode)?; map.insert(key.clone(), literal); } } else { let literal = json_value_to_literal_inferred(key, value, mode)?; map.insert(key.clone(), literal); }; } // Fill in Literal::Null for declared nullable params that were omitted. for param in query_params { if param.nullable && !map.contains_key(¶m.name) { map.insert(param.name.clone(), Literal::Null); } } Ok(map) } fn json_value_to_literal_typed( key: &str, value: &Value, type_name: &str, mode: JsonParamMode, ) -> RunInputResult { match type_name { "String" => match value { Value::String(value) => Ok(Literal::String(value.clone())), other => Err(RunInputError::message(format!( "param '{}': expected string, got {}", key, json_type_name(other) ))), }, "I32" => match mode { JsonParamMode::Standard => { let value = parse_i64_param(key, value, mode)?; let value = i32::try_from(value).map_err(|_| { RunInputError::message(format!("param '{}': value {} exceeds I32", key, value)) })?; Ok(Literal::Integer(i64::from(value))) } JsonParamMode::JavaScript => { let value = parse_i64_param(key, value, mode)?; let value = i32::try_from(value).map_err(|_| { RunInputError::message(format!( "param '{}': value {} exceeds I32 range", key, value )) })?; Ok(Literal::Integer(i64::from(value))) } }, "I64" => Ok(Literal::Integer(parse_i64_param(key, value, mode)?)), "U32" => { let value = parse_u64_param(key, value, mode)?; let value = match mode { JsonParamMode::Standard => u32::try_from(value).map_err(|_| { RunInputError::message(format!("param '{}': value {} exceeds U32", key, value)) })?, JsonParamMode::JavaScript => u32::try_from(value).map_err(|_| { RunInputError::message(format!( "param '{}': value {} exceeds U32 range", key, value )) })?, }; Ok(Literal::Integer(i64::from(value))) } "U64" => { let value = parse_u64_param(key, value, mode)?; let value = match mode { JsonParamMode::Standard => i64::try_from(value).map_err(|_| { RunInputError::message(format!( "param '{}': value {} exceeds current engine range for U64 (max {})", key, value, i64::MAX )) })?, JsonParamMode::JavaScript => i64::try_from(value).map_err(|_| { RunInputError::message(format!( "param '{}': value {} exceeds current engine range for U64 parameters (max {})", key, value, i64::MAX )) })?, }; Ok(Literal::Integer(value)) } "F32" | "F64" => { let value = value.as_f64().ok_or_else(|| match mode { JsonParamMode::Standard => { RunInputError::message(format!("param '{}': expected float", key)) } JsonParamMode::JavaScript => RunInputError::message(format!( "param '{}': expected float, got {}", key, json_type_name(value) )), })?; Ok(Literal::Float(value)) } "Bool" => { let value = value.as_bool().ok_or_else(|| match mode { JsonParamMode::Standard => { RunInputError::message(format!("param '{}': expected boolean", key)) } JsonParamMode::JavaScript => RunInputError::message(format!( "param '{}': expected boolean, got {}", key, json_type_name(value) )), })?; Ok(Literal::Bool(value)) } "Date" => match value { Value::String(value) => Ok(Literal::Date(value.clone())), other => Err(match mode { JsonParamMode::Standard => { RunInputError::message(format!("param '{}': expected date string", key)) } JsonParamMode::JavaScript => RunInputError::message(format!( "param '{}': expected date string, got {}", key, json_type_name(other) )), }), }, "DateTime" => match value { Value::String(value) => Ok(Literal::DateTime(value.clone())), other => Err(match mode { JsonParamMode::Standard => { RunInputError::message(format!("param '{}': expected datetime string", key)) } JsonParamMode::JavaScript => RunInputError::message(format!( "param '{}': expected datetime string, got {}", key, json_type_name(other) )), }), }, "Blob" => match value { Value::String(value) => Ok(Literal::String(value.clone())), other => Err(RunInputError::message(format!( "param '{}': expected blob URI string, got {}", key, json_type_name(other) ))), }, other if parse_list_item_type(other).is_some() => { let item_type = parse_list_item_type(other).unwrap(); let items = value.as_array().ok_or_else(|| match mode { JsonParamMode::Standard => { RunInputError::message(format!("param '{}': expected array for {}", key, other)) } JsonParamMode::JavaScript => RunInputError::message(format!( "param '{}': expected array for {}, got {}", key, other, json_type_name(value) )), })?; let mut out = Vec::with_capacity(items.len()); for item in items { out.push(json_value_to_literal_typed(key, item, item_type, mode)?); } Ok(Literal::List(out)) } other if other.starts_with("Vector(") => { let expected_dim = parse_vector_dim(other).ok_or_else(|| match mode { JsonParamMode::Standard => RunInputError::message(format!( "param '{}': invalid vector type '{}'", key, other )), JsonParamMode::JavaScript => RunInputError::message(format!( "param '{}': invalid vector type '{}' (expected Vector(N))", key, other )), })?; let items = value.as_array().ok_or_else(|| match mode { JsonParamMode::Standard => { RunInputError::message(format!("param '{}': expected array for {}", key, other)) } JsonParamMode::JavaScript => RunInputError::message(format!( "param '{}': expected array for {}, got {}", key, other, json_type_name(value) )), })?; if items.len() != expected_dim { return Err(RunInputError::message(format!( "param '{}': expected {} values for {}, got {}", key, expected_dim, other, items.len() ))); } let mut out = Vec::with_capacity(items.len()); for item in items { let value = item.as_f64().ok_or_else(|| match mode { JsonParamMode::Standard => RunInputError::message(format!( "param '{}': vector element is not numeric", key )), JsonParamMode::JavaScript => RunInputError::message(format!( "param '{}': vector element '{}' is not numeric", key, item )), })?; out.push(Literal::Float(value)); } Ok(Literal::List(out)) } _ => match value { Value::String(value) => Ok(Literal::String(value.clone())), other => Err(RunInputError::message(format!( "param '{}': expected string for type '{}', got {}", key, type_name, json_type_name(other) ))), }, } } fn json_value_to_literal_inferred( key: &str, value: &Value, mode: JsonParamMode, ) -> RunInputResult { match value { Value::String(value) => Ok(Literal::String(value.clone())), Value::Bool(value) => Ok(Literal::Bool(*value)), Value::Number(number) => match mode { JsonParamMode::Standard => { if let Some(value) = number.as_i64() { Ok(Literal::Integer(value)) } else if let Some(value) = number.as_f64() { Ok(Literal::Float(value)) } else { Err(RunInputError::message(format!( "param '{}': unsupported numeric value", key ))) } } JsonParamMode::JavaScript => { if let Some(value) = number.as_i64() { if !is_js_safe_integer_i64(value) { return Err(RunInputError::message(format!( "param '{}': integer {} exceeds JS safe integer range; use a decimal string and a typed query parameter for exact values", key, value ))); } Ok(Literal::Integer(value)) } else if let Some(value) = number.as_u64() { if value > JS_MAX_SAFE_INTEGER_U64 { return Err(RunInputError::message(format!( "param '{}': integer {} exceeds JS safe integer range; use a decimal string and a typed query parameter for exact values", key, value ))); } let value = i64::try_from(value).map_err(|_| { RunInputError::message(format!( "param '{}': integer {} exceeds supported range (max {})", key, value, i64::MAX )) })?; Ok(Literal::Integer(value)) } else if let Some(value) = number.as_f64() { Ok(Literal::Float(value)) } else { Err(RunInputError::message(format!( "param '{}': unsupported number value", key ))) } } }, Value::Array(values) => { let mut out = Vec::with_capacity(values.len()); for value in values { out.push(json_value_to_literal_inferred(key, value, mode)?); } Ok(Literal::List(out)) } Value::Null => Ok(Literal::Null), Value::Object(_) => Err(match mode { JsonParamMode::Standard => { RunInputError::message(format!("param '{}': object is not supported", key)) } JsonParamMode::JavaScript => RunInputError::message(format!( "param '{}': object values are not supported as query parameters", key )), }), } } fn parse_i64_param(key: &str, value: &Value, mode: JsonParamMode) -> RunInputResult { match mode { JsonParamMode::Standard => match value { Value::Number(number) => number.as_i64().ok_or_else(|| { RunInputError::message(format!("param '{}': expected integer number", key)) }), Value::String(value) => value.parse::().map_err(|_| { RunInputError::message(format!( "param '{}': expected integer string, got '{}'", key, value )) }), _ => Err(RunInputError::message(format!( "param '{}': expected integer", key ))), }, JsonParamMode::JavaScript => match value { Value::Number(number) => { let parsed = if let Some(parsed) = number.as_i64() { parsed } else if let Some(parsed) = number.as_f64() { if !parsed.is_finite() || parsed.fract() != 0.0 { return Err(RunInputError::message(format!( "param '{}': expected integer, got number", key ))); } if parsed < i64::MIN as f64 || parsed > i64::MAX as f64 { return Err(RunInputError::message(format!( "param '{}': integer {} is outside i64 range", key, parsed ))); } parsed as i64 } else { return Err(RunInputError::message(format!( "param '{}': expected integer, got number", key ))); }; if !is_js_safe_integer_i64(parsed) { return Err(RunInputError::message(format!( "param '{}': integer {} exceeds JS safe integer range; pass a decimal string for exact values", key, parsed ))); } Ok(parsed) } Value::String(value) => value.parse::().map_err(|_| { RunInputError::message(format!( "param '{}': expected integer string, got '{}'", key, value )) }), other => Err(RunInputError::message(format!( "param '{}': expected integer, got {}", key, json_type_name(other) ))), }, } } fn parse_u64_param(key: &str, value: &Value, mode: JsonParamMode) -> RunInputResult { match mode { JsonParamMode::Standard => match value { Value::Number(number) => number.as_u64().ok_or_else(|| { RunInputError::message(format!("param '{}': expected unsigned integer number", key)) }), Value::String(value) => value.parse::().map_err(|_| { RunInputError::message(format!( "param '{}': expected unsigned integer string, got '{}'", key, value )) }), _ => Err(RunInputError::message(format!( "param '{}': expected unsigned integer", key ))), }, JsonParamMode::JavaScript => match value { Value::Number(number) => { let parsed = if let Some(parsed) = number.as_u64() { parsed } else if let Some(parsed) = number.as_f64() { if !parsed.is_finite() || parsed.fract() != 0.0 || parsed < 0.0 { return Err(RunInputError::message(format!( "param '{}': expected unsigned integer, got number", key ))); } if parsed > u64::MAX as f64 { return Err(RunInputError::message(format!( "param '{}': integer {} is outside u64 range", key, parsed ))); } parsed as u64 } else { return Err(RunInputError::message(format!( "param '{}': expected unsigned integer, got number", key ))); }; if parsed > JS_MAX_SAFE_INTEGER_U64 { return Err(RunInputError::message(format!( "param '{}': integer {} exceeds JS safe integer range; pass a decimal string for exact values", key, parsed ))); } Ok(parsed) } Value::String(value) => value.parse::().map_err(|_| { RunInputError::message(format!( "param '{}': expected unsigned integer string, got '{}'", key, value )) }), other => Err(RunInputError::message(format!( "param '{}': expected unsigned integer, got {}", key, json_type_name(other) ))), }, } } fn parse_vector_dim(type_name: &str) -> Option { let dim = type_name .strip_prefix("Vector(")? .strip_suffix(')')? .parse::() .ok()?; if dim == 0 { None } else { Some(dim) } } fn parse_list_item_type(type_name: &str) -> Option<&str> { Some(type_name.strip_prefix('[')?.strip_suffix(']')?.trim()) } fn json_type_name(value: &Value) -> &'static str { match value { Value::Null => "null", Value::Bool(_) => "boolean", Value::Number(_) => "number", Value::String(_) => "string", Value::Array(_) => "array", Value::Object(_) => "object", } } #[cfg(test)] mod tests { use serde_json::json; use super::{JsonParamMode, ToParam, find_named_query, json_params_to_param_map}; use crate::query::ast::Literal; #[test] fn js_mode_rejects_unsafe_integer_numbers() { let query = find_named_query( "query find($id: U64) { match { $u: User } return { $u } }", "find", ) .expect("query should parse"); let error = json_params_to_param_map( Some(&json!({ "id": 9_007_199_254_740_992u64 })), &query.params, JsonParamMode::JavaScript, ) .expect_err("unsafe integer should fail"); assert_eq!( error.to_string(), "param 'id': integer 9007199254740992 exceeds JS safe integer range; pass a decimal string for exact values" ); } #[test] fn standard_mode_preserves_ffi_param_object_error() { let error = json_params_to_param_map(Some(&json!(["nope"])), &[], JsonParamMode::Standard) .expect_err("non-object params should fail"); assert_eq!(error.to_string(), "params must be a JSON object"); } #[test] fn to_param_supports_lists_and_explicit_date_literals() { let vector = vec![1_i32, 2_i32, 3_i32].to_param().expect("vector param"); match vector { Literal::List(values) => { assert!(matches!(values.first(), Some(Literal::Integer(1)))); assert!(matches!(values.get(1), Some(Literal::Integer(2)))); assert!(matches!(values.get(2), Some(Literal::Integer(3)))); } other => panic!("expected list param, got {:?}", other), } let date = Literal::Date("2026-03-06".to_string()) .to_param() .expect("date param"); assert!(matches!(date, Literal::Date(ref value) if value == "2026-03-06")); } #[test] fn to_param_rejects_unsigned_values_outside_engine_range() { let error = u64::MAX.to_param().expect_err("oversized u64 should fail"); assert_eq!( error.to_string(), format!( "execution error: param value {} exceeds current engine range for numeric literals (max {})", u64::MAX, i64::MAX ) ); } #[test] fn params_macro_builds_param_map() { let params = params! { "name" => "Alice", "age" => 41_i32, "scores" => [1_u8, 2_u8, 3_u8], "published_at" => Literal::DateTime("2026-03-06T12:00:00Z".to_string()), } .expect("params"); assert!(matches!( params.get("name"), Some(Literal::String(value)) if value == "Alice" )); assert!(matches!(params.get("age"), Some(Literal::Integer(41)))); match params.get("scores") { Some(Literal::List(values)) => { assert!(matches!(values.first(), Some(Literal::Integer(1)))); assert!(matches!(values.get(1), Some(Literal::Integer(2)))); assert!(matches!(values.get(2), Some(Literal::Integer(3)))); } other => panic!("expected list param, got {:?}", other), } assert!(matches!( params.get("published_at"), Some(Literal::DateTime(value)) if value == "2026-03-06T12:00:00Z" )); } #[test] fn typed_json_params_support_list_and_datetime_types() { let query = find_named_query( r#" query q($tags: [String], $days: [Date]?, $due_at: DateTime) { match { $t: Task } return { $t.slug } } "#, "q", ) .expect("query"); let params = json_params_to_param_map( Some(&json!({ "tags": ["launch", "priority"], "days": ["2026-04-01", "2026-04-02"], "due_at": "2026-04-03T10:15:00Z" })), &query.params, JsonParamMode::Standard, ) .expect("typed params"); assert!(matches!( params.get("due_at"), Some(Literal::DateTime(value)) if value == "2026-04-03T10:15:00Z" )); match params.get("tags") { Some(Literal::List(values)) => { assert!( matches!(values.first(), Some(Literal::String(value)) if value == "launch") ); assert!( matches!(values.get(1), Some(Literal::String(value)) if value == "priority") ); } other => panic!("expected string list param, got {:?}", other), } match params.get("days") { Some(Literal::List(values)) => { assert!( matches!(values.first(), Some(Literal::Date(value)) if value == "2026-04-01") ); assert!( matches!(values.get(1), Some(Literal::Date(value)) if value == "2026-04-02") ); } other => panic!("expected date list param, got {:?}", other), } } #[test] fn nullable_param_omitted_becomes_null() { let query = find_named_query( "query q($name: String, $bio: String?) { match { $u: User } return { $u } }", "q", ) .expect("query"); let params = json_params_to_param_map( Some(&json!({ "name": "Alice" })), &query.params, JsonParamMode::Standard, ) .expect("should accept omitted nullable param"); assert!(matches!(params.get("name"), Some(Literal::String(v)) if v == "Alice")); assert!(matches!(params.get("bio"), Some(Literal::Null))); } #[test] fn nullable_param_explicit_null_becomes_null() { let query = find_named_query( "query q($name: String, $bio: String?) { match { $u: User } return { $u } }", "q", ) .expect("query"); let params = json_params_to_param_map( Some(&json!({ "name": "Alice", "bio": null })), &query.params, JsonParamMode::Standard, ) .expect("should accept explicit null for nullable param"); assert!(matches!(params.get("name"), Some(Literal::String(v)) if v == "Alice")); assert!(matches!(params.get("bio"), Some(Literal::Null))); } #[test] fn non_nullable_param_rejects_null() { let query = find_named_query( "query q($name: String) { match { $u: User } return { $u } }", "q", ) .expect("query"); let error = json_params_to_param_map( Some(&json!({ "name": null })), &query.params, JsonParamMode::Standard, ) .expect_err("null for non-nullable param should fail"); assert!( error .to_string() .contains("null is not accepted for non-nullable parameter"), "unexpected error: {}", error ); } #[test] fn nullable_param_with_value_works_normally() { let query = find_named_query( "query q($bio: String?) { match { $u: User } return { $u } }", "q", ) .expect("query"); let params = json_params_to_param_map( Some(&json!({ "bio": "hello" })), &query.params, JsonParamMode::Standard, ) .expect("should accept string value for nullable param"); assert!(matches!(params.get("bio"), Some(Literal::String(v)) if v == "hello")); } #[test] fn inferred_null_param_becomes_literal_null() { let params = json_params_to_param_map( Some(&json!({ "extra": null })), &[], JsonParamMode::Standard, ) .expect("inferred null should succeed"); assert!(matches!(params.get("extra"), Some(Literal::Null))); } }