Skip to main content

omnigraph_compiler/
query_input.rs

1use std::error::Error;
2use std::fmt;
3
4use serde_json::Value;
5
6use crate::error::NanoError;
7use crate::ir::ParamMap;
8use crate::json_output::{JS_MAX_SAFE_INTEGER_U64, is_js_safe_integer_i64};
9use crate::query::ast::{Literal, Param, QueryDecl};
10use crate::query::parser::parse_query;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum JsonParamMode {
14    Standard,
15    JavaScript,
16}
17
18#[derive(Debug)]
19pub enum RunInputError {
20    Core(NanoError),
21    Message(String),
22}
23
24impl RunInputError {
25    fn message(message: impl Into<String>) -> Self {
26        Self::Message(message.into())
27    }
28}
29
30impl fmt::Display for RunInputError {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        match self {
33            Self::Core(err) => err.fmt(f),
34            Self::Message(message) => f.write_str(message),
35        }
36    }
37}
38
39impl Error for RunInputError {
40    fn source(&self) -> Option<&(dyn Error + 'static)> {
41        match self {
42            Self::Core(err) => Some(err),
43            Self::Message(_) => None,
44        }
45    }
46}
47
48impl From<NanoError> for RunInputError {
49    fn from(value: NanoError) -> Self {
50        Self::Core(value)
51    }
52}
53
54pub type RunInputResult<T> = std::result::Result<T, RunInputError>;
55
56pub trait ToParam {
57    fn to_param(self) -> crate::error::Result<Literal>;
58}
59
60impl ToParam for Literal {
61    fn to_param(self) -> crate::error::Result<Literal> {
62        Ok(self)
63    }
64}
65
66impl ToParam for &Literal {
67    fn to_param(self) -> crate::error::Result<Literal> {
68        Ok(self.clone())
69    }
70}
71
72impl ToParam for String {
73    fn to_param(self) -> crate::error::Result<Literal> {
74        Ok(Literal::String(self))
75    }
76}
77
78impl ToParam for &String {
79    fn to_param(self) -> crate::error::Result<Literal> {
80        Ok(Literal::String(self.clone()))
81    }
82}
83
84impl ToParam for &str {
85    fn to_param(self) -> crate::error::Result<Literal> {
86        Ok(Literal::String(self.to_string()))
87    }
88}
89
90impl ToParam for bool {
91    fn to_param(self) -> crate::error::Result<Literal> {
92        Ok(Literal::Bool(self))
93    }
94}
95
96impl ToParam for i8 {
97    fn to_param(self) -> crate::error::Result<Literal> {
98        Ok(Literal::Integer(i64::from(self)))
99    }
100}
101
102impl ToParam for i16 {
103    fn to_param(self) -> crate::error::Result<Literal> {
104        Ok(Literal::Integer(i64::from(self)))
105    }
106}
107
108impl ToParam for i32 {
109    fn to_param(self) -> crate::error::Result<Literal> {
110        Ok(Literal::Integer(i64::from(self)))
111    }
112}
113
114impl ToParam for i64 {
115    fn to_param(self) -> crate::error::Result<Literal> {
116        Ok(Literal::Integer(self))
117    }
118}
119
120impl ToParam for isize {
121    fn to_param(self) -> crate::error::Result<Literal> {
122        let value = i64::try_from(self).map_err(|_| {
123            NanoError::Execution(format!(
124                "param value {} exceeds current engine range for numeric literals (max {})",
125                self,
126                i64::MAX
127            ))
128        })?;
129        Ok(Literal::Integer(value))
130    }
131}
132
133impl ToParam for u8 {
134    fn to_param(self) -> crate::error::Result<Literal> {
135        Ok(Literal::Integer(i64::from(self)))
136    }
137}
138
139impl ToParam for u16 {
140    fn to_param(self) -> crate::error::Result<Literal> {
141        Ok(Literal::Integer(i64::from(self)))
142    }
143}
144
145impl ToParam for u32 {
146    fn to_param(self) -> crate::error::Result<Literal> {
147        Ok(Literal::Integer(i64::from(self)))
148    }
149}
150
151impl ToParam for u64 {
152    fn to_param(self) -> crate::error::Result<Literal> {
153        let value = i64::try_from(self).map_err(|_| {
154            NanoError::Execution(format!(
155                "param value {} exceeds current engine range for numeric literals (max {})",
156                self,
157                i64::MAX
158            ))
159        })?;
160        Ok(Literal::Integer(value))
161    }
162}
163
164impl ToParam for usize {
165    fn to_param(self) -> crate::error::Result<Literal> {
166        let value = i64::try_from(self).map_err(|_| {
167            NanoError::Execution(format!(
168                "param value {} exceeds current engine range for numeric literals (max {})",
169                self,
170                i64::MAX
171            ))
172        })?;
173        Ok(Literal::Integer(value))
174    }
175}
176
177impl ToParam for f32 {
178    fn to_param(self) -> crate::error::Result<Literal> {
179        if !self.is_finite() {
180            return Err(NanoError::Execution(format!(
181                "invalid float parameter {}",
182                self
183            )));
184        }
185        Ok(Literal::Float(f64::from(self)))
186    }
187}
188
189impl ToParam for f64 {
190    fn to_param(self) -> crate::error::Result<Literal> {
191        if !self.is_finite() {
192            return Err(NanoError::Execution(format!(
193                "invalid float parameter {}",
194                self
195            )));
196        }
197        Ok(Literal::Float(self))
198    }
199}
200
201impl<T> ToParam for Vec<T>
202where
203    T: ToParam,
204{
205    fn to_param(self) -> crate::error::Result<Literal> {
206        let mut out = Vec::with_capacity(self.len());
207        for value in self {
208            out.push(value.to_param()?);
209        }
210        Ok(Literal::List(out))
211    }
212}
213
214impl<T> ToParam for &[T]
215where
216    T: Clone + ToParam,
217{
218    fn to_param(self) -> crate::error::Result<Literal> {
219        let mut out = Vec::with_capacity(self.len());
220        for value in self {
221            out.push(value.clone().to_param()?);
222        }
223        Ok(Literal::List(out))
224    }
225}
226
227impl<T, const N: usize> ToParam for [T; N]
228where
229    T: ToParam,
230{
231    fn to_param(self) -> crate::error::Result<Literal> {
232        let mut out = Vec::with_capacity(N);
233        for value in self {
234            out.push(value.to_param()?);
235        }
236        Ok(Literal::List(out))
237    }
238}
239
240#[macro_export]
241macro_rules! params {
242    () => {
243        ::std::result::Result::Ok($crate::ParamMap::new())
244    };
245    ($($key:expr => $value:expr),+ $(,)?) => {{
246        (|| -> $crate::error::Result<$crate::ParamMap> {
247            let mut map = $crate::ParamMap::new();
248            $(
249                map.insert(::std::convert::Into::<String>::into($key), $crate::ToParam::to_param($value)?);
250            )+
251            Ok(map)
252        })()
253    }};
254}
255
256pub fn find_named_query(query_source: &str, query_name: &str) -> RunInputResult<QueryDecl> {
257    let queries = parse_query(query_source)?;
258    queries
259        .queries
260        .into_iter()
261        .find(|query| query.name == query_name)
262        .ok_or_else(|| RunInputError::message(format!("query '{}' not found", query_name)))
263}
264
265pub fn json_params_to_param_map(
266    params: Option<&Value>,
267    query_params: &[Param],
268    mode: JsonParamMode,
269) -> RunInputResult<ParamMap> {
270    let mut map = ParamMap::new();
271    let object = match params {
272        Some(Value::Object(object)) => object,
273        Some(Value::Null) | None => {
274            // Still fill in Literal::Null for declared nullable params.
275            for param in query_params {
276                if param.nullable {
277                    map.insert(param.name.clone(), Literal::Null);
278                }
279            }
280            return Ok(map);
281        }
282        Some(other) => {
283            let message = match mode {
284                JsonParamMode::Standard => "params must be a JSON object".to_string(),
285                JsonParamMode::JavaScript => {
286                    format!("params must be an object, got {}", json_type_name(other))
287                }
288            };
289            return Err(RunInputError::message(message));
290        }
291    };
292
293    for (key, value) in object {
294        let decl = query_params.iter().find(|param| param.name == *key);
295        if let Some(decl) = decl {
296            if matches!(value, Value::Null) {
297                if decl.nullable {
298                    map.insert(key.clone(), Literal::Null);
299                } else {
300                    return Err(RunInputError::message(format!(
301                        "param '{}': null is not accepted for non-nullable parameter",
302                        key
303                    )));
304                }
305            } else {
306                let literal = json_value_to_literal_typed(key, value, &decl.type_name, mode)?;
307                map.insert(key.clone(), literal);
308            }
309        } else {
310            let literal = json_value_to_literal_inferred(key, value, mode)?;
311            map.insert(key.clone(), literal);
312        };
313    }
314
315    // Fill in Literal::Null for declared nullable params that were omitted.
316    for param in query_params {
317        if param.nullable && !map.contains_key(&param.name) {
318            map.insert(param.name.clone(), Literal::Null);
319        }
320    }
321
322    Ok(map)
323}
324
325fn json_value_to_literal_typed(
326    key: &str,
327    value: &Value,
328    type_name: &str,
329    mode: JsonParamMode,
330) -> RunInputResult<Literal> {
331    match type_name {
332        "String" => match value {
333            Value::String(value) => Ok(Literal::String(value.clone())),
334            other => Err(RunInputError::message(format!(
335                "param '{}': expected string, got {}",
336                key,
337                json_type_name(other)
338            ))),
339        },
340        "I32" => match mode {
341            JsonParamMode::Standard => {
342                let value = parse_i64_param(key, value, mode)?;
343                let value = i32::try_from(value).map_err(|_| {
344                    RunInputError::message(format!("param '{}': value {} exceeds I32", key, value))
345                })?;
346                Ok(Literal::Integer(i64::from(value)))
347            }
348            JsonParamMode::JavaScript => {
349                let value = parse_i64_param(key, value, mode)?;
350                let value = i32::try_from(value).map_err(|_| {
351                    RunInputError::message(format!(
352                        "param '{}': value {} exceeds I32 range",
353                        key, value
354                    ))
355                })?;
356                Ok(Literal::Integer(i64::from(value)))
357            }
358        },
359        "I64" => Ok(Literal::Integer(parse_i64_param(key, value, mode)?)),
360        "U32" => {
361            let value = parse_u64_param(key, value, mode)?;
362            let value = match mode {
363                JsonParamMode::Standard => u32::try_from(value).map_err(|_| {
364                    RunInputError::message(format!("param '{}': value {} exceeds U32", key, value))
365                })?,
366                JsonParamMode::JavaScript => u32::try_from(value).map_err(|_| {
367                    RunInputError::message(format!(
368                        "param '{}': value {} exceeds U32 range",
369                        key, value
370                    ))
371                })?,
372            };
373            Ok(Literal::Integer(i64::from(value)))
374        }
375        "U64" => {
376            let value = parse_u64_param(key, value, mode)?;
377            let value = match mode {
378                JsonParamMode::Standard => i64::try_from(value).map_err(|_| {
379                    RunInputError::message(format!(
380                        "param '{}': value {} exceeds current engine range for U64 (max {})",
381                        key,
382                        value,
383                        i64::MAX
384                    ))
385                })?,
386                JsonParamMode::JavaScript => i64::try_from(value).map_err(|_| {
387                    RunInputError::message(format!(
388                        "param '{}': value {} exceeds current engine range for U64 parameters (max {})",
389                        key,
390                        value,
391                        i64::MAX
392                    ))
393                })?,
394            };
395            Ok(Literal::Integer(value))
396        }
397        "F32" | "F64" => {
398            let value = value.as_f64().ok_or_else(|| match mode {
399                JsonParamMode::Standard => {
400                    RunInputError::message(format!("param '{}': expected float", key))
401                }
402                JsonParamMode::JavaScript => RunInputError::message(format!(
403                    "param '{}': expected float, got {}",
404                    key,
405                    json_type_name(value)
406                )),
407            })?;
408            Ok(Literal::Float(value))
409        }
410        "Bool" => {
411            let value = value.as_bool().ok_or_else(|| match mode {
412                JsonParamMode::Standard => {
413                    RunInputError::message(format!("param '{}': expected boolean", key))
414                }
415                JsonParamMode::JavaScript => RunInputError::message(format!(
416                    "param '{}': expected boolean, got {}",
417                    key,
418                    json_type_name(value)
419                )),
420            })?;
421            Ok(Literal::Bool(value))
422        }
423        "Date" => match value {
424            Value::String(value) => Ok(Literal::Date(value.clone())),
425            other => Err(match mode {
426                JsonParamMode::Standard => {
427                    RunInputError::message(format!("param '{}': expected date string", key))
428                }
429                JsonParamMode::JavaScript => RunInputError::message(format!(
430                    "param '{}': expected date string, got {}",
431                    key,
432                    json_type_name(other)
433                )),
434            }),
435        },
436        "DateTime" => match value {
437            Value::String(value) => Ok(Literal::DateTime(value.clone())),
438            other => Err(match mode {
439                JsonParamMode::Standard => {
440                    RunInputError::message(format!("param '{}': expected datetime string", key))
441                }
442                JsonParamMode::JavaScript => RunInputError::message(format!(
443                    "param '{}': expected datetime string, got {}",
444                    key,
445                    json_type_name(other)
446                )),
447            }),
448        },
449        "Blob" => match value {
450            Value::String(value) => Ok(Literal::String(value.clone())),
451            other => Err(RunInputError::message(format!(
452                "param '{}': expected blob URI string, got {}",
453                key,
454                json_type_name(other)
455            ))),
456        },
457        other if parse_list_item_type(other).is_some() => {
458            let item_type = parse_list_item_type(other).unwrap();
459            let items = value.as_array().ok_or_else(|| match mode {
460                JsonParamMode::Standard => {
461                    RunInputError::message(format!("param '{}': expected array for {}", key, other))
462                }
463                JsonParamMode::JavaScript => RunInputError::message(format!(
464                    "param '{}': expected array for {}, got {}",
465                    key,
466                    other,
467                    json_type_name(value)
468                )),
469            })?;
470            let mut out = Vec::with_capacity(items.len());
471            for item in items {
472                out.push(json_value_to_literal_typed(key, item, item_type, mode)?);
473            }
474            Ok(Literal::List(out))
475        }
476        other if other.starts_with("Vector(") => {
477            let expected_dim = parse_vector_dim(other).ok_or_else(|| match mode {
478                JsonParamMode::Standard => RunInputError::message(format!(
479                    "param '{}': invalid vector type '{}'",
480                    key, other
481                )),
482                JsonParamMode::JavaScript => RunInputError::message(format!(
483                    "param '{}': invalid vector type '{}' (expected Vector(N))",
484                    key, other
485                )),
486            })?;
487            let items = value.as_array().ok_or_else(|| match mode {
488                JsonParamMode::Standard => {
489                    RunInputError::message(format!("param '{}': expected array for {}", key, other))
490                }
491                JsonParamMode::JavaScript => RunInputError::message(format!(
492                    "param '{}': expected array for {}, got {}",
493                    key,
494                    other,
495                    json_type_name(value)
496                )),
497            })?;
498            if items.len() != expected_dim {
499                return Err(RunInputError::message(format!(
500                    "param '{}': expected {} values for {}, got {}",
501                    key,
502                    expected_dim,
503                    other,
504                    items.len()
505                )));
506            }
507            let mut out = Vec::with_capacity(items.len());
508            for item in items {
509                let value = item.as_f64().ok_or_else(|| match mode {
510                    JsonParamMode::Standard => RunInputError::message(format!(
511                        "param '{}': vector element is not numeric",
512                        key
513                    )),
514                    JsonParamMode::JavaScript => RunInputError::message(format!(
515                        "param '{}': vector element '{}' is not numeric",
516                        key, item
517                    )),
518                })?;
519                out.push(Literal::Float(value));
520            }
521            Ok(Literal::List(out))
522        }
523        _ => match value {
524            Value::String(value) => Ok(Literal::String(value.clone())),
525            other => Err(RunInputError::message(format!(
526                "param '{}': expected string for type '{}', got {}",
527                key,
528                type_name,
529                json_type_name(other)
530            ))),
531        },
532    }
533}
534
535fn json_value_to_literal_inferred(
536    key: &str,
537    value: &Value,
538    mode: JsonParamMode,
539) -> RunInputResult<Literal> {
540    match value {
541        Value::String(value) => Ok(Literal::String(value.clone())),
542        Value::Bool(value) => Ok(Literal::Bool(*value)),
543        Value::Number(number) => match mode {
544            JsonParamMode::Standard => {
545                if let Some(value) = number.as_i64() {
546                    Ok(Literal::Integer(value))
547                } else if let Some(value) = number.as_f64() {
548                    Ok(Literal::Float(value))
549                } else {
550                    Err(RunInputError::message(format!(
551                        "param '{}': unsupported numeric value",
552                        key
553                    )))
554                }
555            }
556            JsonParamMode::JavaScript => {
557                if let Some(value) = number.as_i64() {
558                    if !is_js_safe_integer_i64(value) {
559                        return Err(RunInputError::message(format!(
560                            "param '{}': integer {} exceeds JS safe integer range; use a decimal string and a typed query parameter for exact values",
561                            key, value
562                        )));
563                    }
564                    Ok(Literal::Integer(value))
565                } else if let Some(value) = number.as_u64() {
566                    if value > JS_MAX_SAFE_INTEGER_U64 {
567                        return Err(RunInputError::message(format!(
568                            "param '{}': integer {} exceeds JS safe integer range; use a decimal string and a typed query parameter for exact values",
569                            key, value
570                        )));
571                    }
572                    let value = i64::try_from(value).map_err(|_| {
573                        RunInputError::message(format!(
574                            "param '{}': integer {} exceeds supported range (max {})",
575                            key,
576                            value,
577                            i64::MAX
578                        ))
579                    })?;
580                    Ok(Literal::Integer(value))
581                } else if let Some(value) = number.as_f64() {
582                    Ok(Literal::Float(value))
583                } else {
584                    Err(RunInputError::message(format!(
585                        "param '{}': unsupported number value",
586                        key
587                    )))
588                }
589            }
590        },
591        Value::Array(values) => {
592            let mut out = Vec::with_capacity(values.len());
593            for value in values {
594                out.push(json_value_to_literal_inferred(key, value, mode)?);
595            }
596            Ok(Literal::List(out))
597        }
598        Value::Null => Ok(Literal::Null),
599        Value::Object(_) => Err(match mode {
600            JsonParamMode::Standard => {
601                RunInputError::message(format!("param '{}': object is not supported", key))
602            }
603            JsonParamMode::JavaScript => RunInputError::message(format!(
604                "param '{}': object values are not supported as query parameters",
605                key
606            )),
607        }),
608    }
609}
610
611fn parse_i64_param(key: &str, value: &Value, mode: JsonParamMode) -> RunInputResult<i64> {
612    match mode {
613        JsonParamMode::Standard => match value {
614            Value::Number(number) => number.as_i64().ok_or_else(|| {
615                RunInputError::message(format!("param '{}': expected integer number", key))
616            }),
617            Value::String(value) => value.parse::<i64>().map_err(|_| {
618                RunInputError::message(format!(
619                    "param '{}': expected integer string, got '{}'",
620                    key, value
621                ))
622            }),
623            _ => Err(RunInputError::message(format!(
624                "param '{}': expected integer",
625                key
626            ))),
627        },
628        JsonParamMode::JavaScript => match value {
629            Value::Number(number) => {
630                let parsed = if let Some(parsed) = number.as_i64() {
631                    parsed
632                } else if let Some(parsed) = number.as_f64() {
633                    if !parsed.is_finite() || parsed.fract() != 0.0 {
634                        return Err(RunInputError::message(format!(
635                            "param '{}': expected integer, got number",
636                            key
637                        )));
638                    }
639                    if parsed < i64::MIN as f64 || parsed > i64::MAX as f64 {
640                        return Err(RunInputError::message(format!(
641                            "param '{}': integer {} is outside i64 range",
642                            key, parsed
643                        )));
644                    }
645                    parsed as i64
646                } else {
647                    return Err(RunInputError::message(format!(
648                        "param '{}': expected integer, got number",
649                        key
650                    )));
651                };
652                if !is_js_safe_integer_i64(parsed) {
653                    return Err(RunInputError::message(format!(
654                        "param '{}': integer {} exceeds JS safe integer range; pass a decimal string for exact values",
655                        key, parsed
656                    )));
657                }
658                Ok(parsed)
659            }
660            Value::String(value) => value.parse::<i64>().map_err(|_| {
661                RunInputError::message(format!(
662                    "param '{}': expected integer string, got '{}'",
663                    key, value
664                ))
665            }),
666            other => Err(RunInputError::message(format!(
667                "param '{}': expected integer, got {}",
668                key,
669                json_type_name(other)
670            ))),
671        },
672    }
673}
674
675fn parse_u64_param(key: &str, value: &Value, mode: JsonParamMode) -> RunInputResult<u64> {
676    match mode {
677        JsonParamMode::Standard => match value {
678            Value::Number(number) => number.as_u64().ok_or_else(|| {
679                RunInputError::message(format!("param '{}': expected unsigned integer number", key))
680            }),
681            Value::String(value) => value.parse::<u64>().map_err(|_| {
682                RunInputError::message(format!(
683                    "param '{}': expected unsigned integer string, got '{}'",
684                    key, value
685                ))
686            }),
687            _ => Err(RunInputError::message(format!(
688                "param '{}': expected unsigned integer",
689                key
690            ))),
691        },
692        JsonParamMode::JavaScript => match value {
693            Value::Number(number) => {
694                let parsed = if let Some(parsed) = number.as_u64() {
695                    parsed
696                } else if let Some(parsed) = number.as_f64() {
697                    if !parsed.is_finite() || parsed.fract() != 0.0 || parsed < 0.0 {
698                        return Err(RunInputError::message(format!(
699                            "param '{}': expected unsigned integer, got number",
700                            key
701                        )));
702                    }
703                    if parsed > u64::MAX as f64 {
704                        return Err(RunInputError::message(format!(
705                            "param '{}': integer {} is outside u64 range",
706                            key, parsed
707                        )));
708                    }
709                    parsed as u64
710                } else {
711                    return Err(RunInputError::message(format!(
712                        "param '{}': expected unsigned integer, got number",
713                        key
714                    )));
715                };
716                if parsed > JS_MAX_SAFE_INTEGER_U64 {
717                    return Err(RunInputError::message(format!(
718                        "param '{}': integer {} exceeds JS safe integer range; pass a decimal string for exact values",
719                        key, parsed
720                    )));
721                }
722                Ok(parsed)
723            }
724            Value::String(value) => value.parse::<u64>().map_err(|_| {
725                RunInputError::message(format!(
726                    "param '{}': expected unsigned integer string, got '{}'",
727                    key, value
728                ))
729            }),
730            other => Err(RunInputError::message(format!(
731                "param '{}': expected unsigned integer, got {}",
732                key,
733                json_type_name(other)
734            ))),
735        },
736    }
737}
738
739fn parse_vector_dim(type_name: &str) -> Option<usize> {
740    let dim = type_name
741        .strip_prefix("Vector(")?
742        .strip_suffix(')')?
743        .parse::<usize>()
744        .ok()?;
745    if dim == 0 { None } else { Some(dim) }
746}
747
748fn parse_list_item_type(type_name: &str) -> Option<&str> {
749    Some(type_name.strip_prefix('[')?.strip_suffix(']')?.trim())
750}
751
752fn json_type_name(value: &Value) -> &'static str {
753    match value {
754        Value::Null => "null",
755        Value::Bool(_) => "boolean",
756        Value::Number(_) => "number",
757        Value::String(_) => "string",
758        Value::Array(_) => "array",
759        Value::Object(_) => "object",
760    }
761}
762
763#[cfg(test)]
764mod tests {
765    use serde_json::json;
766
767    use super::{JsonParamMode, ToParam, find_named_query, json_params_to_param_map};
768    use crate::query::ast::Literal;
769
770    #[test]
771    fn js_mode_rejects_unsafe_integer_numbers() {
772        let query = find_named_query(
773            "query find($id: U64) { match { $u: User } return { $u } }",
774            "find",
775        )
776        .expect("query should parse");
777
778        let error = json_params_to_param_map(
779            Some(&json!({ "id": 9_007_199_254_740_992u64 })),
780            &query.params,
781            JsonParamMode::JavaScript,
782        )
783        .expect_err("unsafe integer should fail");
784
785        assert_eq!(
786            error.to_string(),
787            "param 'id': integer 9007199254740992 exceeds JS safe integer range; pass a decimal string for exact values"
788        );
789    }
790
791    #[test]
792    fn standard_mode_preserves_ffi_param_object_error() {
793        let error = json_params_to_param_map(Some(&json!(["nope"])), &[], JsonParamMode::Standard)
794            .expect_err("non-object params should fail");
795
796        assert_eq!(error.to_string(), "params must be a JSON object");
797    }
798
799    #[test]
800    fn to_param_supports_lists_and_explicit_date_literals() {
801        let vector = vec![1_i32, 2_i32, 3_i32].to_param().expect("vector param");
802        match vector {
803            Literal::List(values) => {
804                assert!(matches!(values.first(), Some(Literal::Integer(1))));
805                assert!(matches!(values.get(1), Some(Literal::Integer(2))));
806                assert!(matches!(values.get(2), Some(Literal::Integer(3))));
807            }
808            other => panic!("expected list param, got {:?}", other),
809        }
810
811        let date = Literal::Date("2026-03-06".to_string())
812            .to_param()
813            .expect("date param");
814        assert!(matches!(date, Literal::Date(ref value) if value == "2026-03-06"));
815    }
816
817    #[test]
818    fn to_param_rejects_unsigned_values_outside_engine_range() {
819        let error = u64::MAX.to_param().expect_err("oversized u64 should fail");
820
821        assert_eq!(
822            error.to_string(),
823            format!(
824                "execution error: param value {} exceeds current engine range for numeric literals (max {})",
825                u64::MAX,
826                i64::MAX
827            )
828        );
829    }
830
831    #[test]
832    fn params_macro_builds_param_map() {
833        let params = params! {
834            "name" => "Alice",
835            "age" => 41_i32,
836            "scores" => [1_u8, 2_u8, 3_u8],
837            "published_at" => Literal::DateTime("2026-03-06T12:00:00Z".to_string()),
838        }
839        .expect("params");
840
841        assert!(matches!(
842            params.get("name"),
843            Some(Literal::String(value)) if value == "Alice"
844        ));
845        assert!(matches!(params.get("age"), Some(Literal::Integer(41))));
846        match params.get("scores") {
847            Some(Literal::List(values)) => {
848                assert!(matches!(values.first(), Some(Literal::Integer(1))));
849                assert!(matches!(values.get(1), Some(Literal::Integer(2))));
850                assert!(matches!(values.get(2), Some(Literal::Integer(3))));
851            }
852            other => panic!("expected list param, got {:?}", other),
853        }
854        assert!(matches!(
855            params.get("published_at"),
856            Some(Literal::DateTime(value)) if value == "2026-03-06T12:00:00Z"
857        ));
858    }
859
860    #[test]
861    fn typed_json_params_support_list_and_datetime_types() {
862        let query = find_named_query(
863            r#"
864query q($tags: [String], $days: [Date]?, $due_at: DateTime) {
865    match { $t: Task }
866    return { $t.slug }
867}
868"#,
869            "q",
870        )
871        .expect("query");
872
873        let params = json_params_to_param_map(
874            Some(&json!({
875                "tags": ["launch", "priority"],
876                "days": ["2026-04-01", "2026-04-02"],
877                "due_at": "2026-04-03T10:15:00Z"
878            })),
879            &query.params,
880            JsonParamMode::Standard,
881        )
882        .expect("typed params");
883
884        assert!(matches!(
885            params.get("due_at"),
886            Some(Literal::DateTime(value)) if value == "2026-04-03T10:15:00Z"
887        ));
888        match params.get("tags") {
889            Some(Literal::List(values)) => {
890                assert!(
891                    matches!(values.first(), Some(Literal::String(value)) if value == "launch")
892                );
893                assert!(
894                    matches!(values.get(1), Some(Literal::String(value)) if value == "priority")
895                );
896            }
897            other => panic!("expected string list param, got {:?}", other),
898        }
899        match params.get("days") {
900            Some(Literal::List(values)) => {
901                assert!(
902                    matches!(values.first(), Some(Literal::Date(value)) if value == "2026-04-01")
903                );
904                assert!(
905                    matches!(values.get(1), Some(Literal::Date(value)) if value == "2026-04-02")
906                );
907            }
908            other => panic!("expected date list param, got {:?}", other),
909        }
910    }
911
912    #[test]
913    fn nullable_param_omitted_becomes_null() {
914        let query = find_named_query(
915            "query q($name: String, $bio: String?) { match { $u: User } return { $u } }",
916            "q",
917        )
918        .expect("query");
919
920        let params = json_params_to_param_map(
921            Some(&json!({ "name": "Alice" })),
922            &query.params,
923            JsonParamMode::Standard,
924        )
925        .expect("should accept omitted nullable param");
926
927        assert!(matches!(params.get("name"), Some(Literal::String(v)) if v == "Alice"));
928        assert!(matches!(params.get("bio"), Some(Literal::Null)));
929    }
930
931    #[test]
932    fn nullable_param_explicit_null_becomes_null() {
933        let query = find_named_query(
934            "query q($name: String, $bio: String?) { match { $u: User } return { $u } }",
935            "q",
936        )
937        .expect("query");
938
939        let params = json_params_to_param_map(
940            Some(&json!({ "name": "Alice", "bio": null })),
941            &query.params,
942            JsonParamMode::Standard,
943        )
944        .expect("should accept explicit null for nullable param");
945
946        assert!(matches!(params.get("name"), Some(Literal::String(v)) if v == "Alice"));
947        assert!(matches!(params.get("bio"), Some(Literal::Null)));
948    }
949
950    #[test]
951    fn non_nullable_param_rejects_null() {
952        let query = find_named_query(
953            "query q($name: String) { match { $u: User } return { $u } }",
954            "q",
955        )
956        .expect("query");
957
958        let error = json_params_to_param_map(
959            Some(&json!({ "name": null })),
960            &query.params,
961            JsonParamMode::Standard,
962        )
963        .expect_err("null for non-nullable param should fail");
964
965        assert!(
966            error
967                .to_string()
968                .contains("null is not accepted for non-nullable parameter"),
969            "unexpected error: {}",
970            error
971        );
972    }
973
974    #[test]
975    fn nullable_param_with_value_works_normally() {
976        let query = find_named_query(
977            "query q($bio: String?) { match { $u: User } return { $u } }",
978            "q",
979        )
980        .expect("query");
981
982        let params = json_params_to_param_map(
983            Some(&json!({ "bio": "hello" })),
984            &query.params,
985            JsonParamMode::Standard,
986        )
987        .expect("should accept string value for nullable param");
988
989        assert!(matches!(params.get("bio"), Some(Literal::String(v)) if v == "hello"));
990    }
991
992    #[test]
993    fn inferred_null_param_becomes_literal_null() {
994        let params = json_params_to_param_map(
995            Some(&json!({ "extra": null })),
996            &[],
997            JsonParamMode::Standard,
998        )
999        .expect("inferred null should succeed");
1000
1001        assert!(matches!(params.get("extra"), Some(Literal::Null)));
1002    }
1003
1004    #[test]
1005    fn nullable_params_filled_when_params_is_none() {
1006        let query = find_named_query(
1007            "query q($bio: String?) { match { $u: User } return { $u } }",
1008            "q",
1009        )
1010        .expect("query");
1011
1012        let params = json_params_to_param_map(None, &query.params, JsonParamMode::Standard)
1013            .expect("None params should succeed with nullable declarations");
1014
1015        assert!(matches!(params.get("bio"), Some(Literal::Null)));
1016    }
1017}