Skip to main content

helix_ast/
query.rs

1use std::collections::BTreeMap;
2
3use serde::de::{MapAccess, Visitor};
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5
6use crate::batch::{BatchQuery, ReadBatch, WriteBatch};
7/// Declared query parameter shape.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum QueryParamType {
11    /// Boolean.
12    Bool,
13    /// 64-bit integer.
14    I64,
15    /// 64-bit float.
16    F64,
17    /// 32-bit float.
18    F32,
19    /// String.
20    String,
21    /// Datetime.
22    DateTime,
23    /// Bytes.
24    Bytes,
25    /// Any property value.
26    Value,
27    /// Object.
28    Object,
29    /// Array.
30    Array(Box<QueryParamType>),
31}
32
33/// Query request type.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum QueryRequestType {
37    /// Read-only query.
38    Read,
39    /// Write-capable query.
40    Write,
41}
42
43/// JSON-compatible query parameter value.
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[serde(untagged)]
46pub enum QueryValue {
47    /// Null.
48    Null,
49    /// Boolean.
50    Bool(bool),
51    /// 64-bit signed integer.
52    I64(i64),
53    /// 64-bit float.
54    F64(f64),
55    /// 32-bit float.
56    F32(f32),
57    /// String.
58    String(String),
59    /// Array.
60    Array(Vec<QueryValue>),
61    /// Object.
62    Object(BTreeMap<String, QueryValue>),
63}
64
65/// Query serialization errors.
66#[derive(Debug)]
67pub enum QueryError {
68    /// JSON serialization error.
69    Serialize(sonic_rs::Error),
70    /// UTF-8 conversion error.
71    Utf8(std::string::FromUtf8Error),
72    /// Bytes cannot be represented safely in query parameters.
73    UnsupportedBytesParameter(String),
74    /// Datetime could not be rendered.
75    InvalidDateTimeParameter {
76        /// Parameter path.
77        path: String,
78        /// Raw millis.
79        millis: i64,
80    },
81    /// Parameter names must be non-empty.
82    InvalidParameterName,
83    /// Parameter names must be unique within one request.
84    DuplicateParameterName(String),
85    /// Typed and untyped parameters cannot be mixed.
86    MixedParameterModes,
87    /// A value does not satisfy its declared schema.
88    ParameterTypeMismatch {
89        /// Parameter path.
90        path: String,
91        /// Expected schema.
92        expected: QueryParamType,
93        /// Observed JSON value family.
94        actual: &'static str,
95    },
96    /// Typed parameter names must exactly match value names.
97    ParameterNameMismatch {
98        /// Declared names without values.
99        missing_values: Vec<String>,
100        /// Value names without declarations.
101        extra_values: Vec<String>,
102    },
103}
104
105impl QueryError {
106    /// Bytes parameter error.
107    pub fn unsupported_bytes(path: impl Into<String>) -> Self {
108        Self::UnsupportedBytesParameter(path.into())
109    }
110
111    /// Datetime parameter error.
112    pub fn invalid_datetime(path: impl Into<String>, millis: i64) -> Self {
113        Self::InvalidDateTimeParameter {
114            path: path.into(),
115            millis,
116        }
117    }
118}
119
120impl std::fmt::Display for QueryError {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        match self {
123            Self::Serialize(err) => write!(f, "json serialization error: {err}"),
124            Self::Utf8(err) => write!(f, "utf8 conversion error: {err}"),
125            Self::UnsupportedBytesParameter(path) => write!(
126                f,
127                "parameter '{path}' uses bytes, which the query JSON route cannot represent"
128            ),
129            Self::InvalidDateTimeParameter { path, millis } => write!(
130                f,
131                "parameter '{path}' uses datetime millis '{millis}', which cannot be rendered as RFC3339"
132            ),
133            Self::InvalidParameterName => write!(f, "parameter name must not be empty"),
134            Self::DuplicateParameterName(name) => {
135                write!(f, "parameter name '{name}' is duplicated")
136            }
137            Self::MixedParameterModes => {
138                write!(f, "typed and untyped query parameters cannot be mixed")
139            }
140            Self::ParameterTypeMismatch {
141                path,
142                expected,
143                actual,
144            } => write!(
145                f,
146                "parameter '{path}' expected {expected:?}, but received {actual}"
147            ),
148            Self::ParameterNameMismatch {
149                missing_values,
150                extra_values,
151            } => write!(
152                f,
153                "parameter schema names do not match values (missing values: {missing_values:?}, extra values: {extra_values:?})"
154            ),
155        }
156    }
157}
158
159impl std::error::Error for QueryError {}
160
161impl From<sonic_rs::Error> for QueryError {
162    fn from(value: sonic_rs::Error) -> Self {
163        Self::Serialize(value)
164    }
165}
166
167impl From<std::string::FromUtf8Error> for QueryError {
168    fn from(value: std::string::FromUtf8Error) -> Self {
169        Self::Utf8(value)
170    }
171}
172
173#[derive(Debug, Clone, PartialEq)]
174enum QueryParameters {
175    Untyped(BTreeMap<String, QueryValue>),
176    Typed {
177        values: BTreeMap<String, QueryValue>,
178        types: BTreeMap<String, QueryParamType>,
179    },
180}
181
182impl Default for QueryParameters {
183    fn default() -> Self {
184        Self::Untyped(BTreeMap::new())
185    }
186}
187
188/// Full query request.
189///
190/// The request kind is derived from the closed [`BatchQuery`] variant. The
191/// serializer retains the redundant legacy `request_type` wire field, while
192/// deserialization rejects disagreement between the two tags.
193#[derive(Debug, Clone, PartialEq)]
194pub struct QueryRequest {
195    /// Optional query name.
196    query_name: Option<String>,
197    /// Query AST payload.
198    query: BatchQuery,
199    parameters: QueryParameters,
200}
201
202impl QueryRequest {
203    fn new(query: BatchQuery) -> Self {
204        Self {
205            query_name: None,
206            query,
207            parameters: QueryParameters::default(),
208        }
209    }
210
211    /// Create a read request.
212    pub fn read(query: ReadBatch) -> Self {
213        Self::new(BatchQuery::Read(query))
214    }
215
216    /// Create a write request.
217    pub fn write(query: WriteBatch) -> Self {
218        Self::new(BatchQuery::Write(query))
219    }
220
221    /// Derived request kind.
222    pub const fn request_type(&self) -> QueryRequestType {
223        match self.query {
224            BatchQuery::Read(_) => QueryRequestType::Read,
225            BatchQuery::Write(_) => QueryRequestType::Write,
226        }
227    }
228
229    /// Closed query payload.
230    pub const fn query(&self) -> &BatchQuery {
231        &self.query
232    }
233
234    /// Optional query name.
235    pub fn query_name(&self) -> Option<&str> {
236        self.query_name.as_deref()
237    }
238
239    /// Runtime parameter values.
240    pub fn parameters(&self) -> Option<&BTreeMap<String, QueryValue>> {
241        let values = match &self.parameters {
242            QueryParameters::Untyped(values) | QueryParameters::Typed { values, .. } => values,
243        };
244        (!values.is_empty()).then_some(values)
245    }
246
247    /// Declared parameter schema, when the request uses typed parameters.
248    pub fn parameter_types(&self) -> Option<&BTreeMap<String, QueryParamType>> {
249        match &self.parameters {
250            QueryParameters::Untyped(_) => None,
251            QueryParameters::Typed { types, .. } => Some(types),
252        }
253    }
254
255    /// Consume the validated request into its closed query and runtime values.
256    pub fn into_query(self) -> (BatchQuery, BTreeMap<String, QueryValue>) {
257        let values = match self.parameters {
258            QueryParameters::Untyped(values) | QueryParameters::Typed { values, .. } => values,
259        };
260        (self.query, values)
261    }
262
263    /// Insert an explicitly untyped parameter.
264    pub fn try_insert_untyped_parameter(
265        &mut self,
266        name: impl Into<String>,
267        value: QueryValue,
268    ) -> Result<(), QueryError> {
269        let name = name.into();
270        validate_parameter_name(&name)?;
271        validate_json_value(&value, &name)?;
272        match &mut self.parameters {
273            QueryParameters::Untyped(values) => {
274                if values.contains_key(&name) {
275                    return Err(QueryError::DuplicateParameterName(name));
276                }
277                values.insert(name, value);
278                Ok(())
279            }
280            QueryParameters::Typed { .. } => Err(QueryError::MixedParameterModes),
281        }
282    }
283
284    /// Insert an explicitly untyped parameter.
285    ///
286    /// This compatibility builder cannot create an invalid request: invalid
287    /// names, values, or typed/untyped mixing panic at the call site.
288    pub fn insert_parameter_value(&mut self, name: impl Into<String>, value: QueryValue) {
289        self.try_insert_untyped_parameter(name, value)
290            .expect("untyped query parameter must be valid");
291    }
292
293    /// Atomically insert a typed parameter.
294    pub fn try_insert_typed_parameter(
295        &mut self,
296        name: impl Into<String>,
297        ty: QueryParamType,
298        value: QueryValue,
299    ) -> Result<(), QueryError> {
300        let name = name.into();
301        validate_parameter_name(&name)?;
302        let value = normalize_typed_value(&ty, value, &name)?;
303        if matches!(&self.parameters, QueryParameters::Untyped(values) if values.is_empty()) {
304            self.parameters = QueryParameters::Typed {
305                values: BTreeMap::new(),
306                types: BTreeMap::new(),
307            };
308        }
309        match &mut self.parameters {
310            QueryParameters::Untyped(_) => Err(QueryError::MixedParameterModes),
311            QueryParameters::Typed { values, types } => {
312                if values.contains_key(&name) {
313                    return Err(QueryError::DuplicateParameterName(name));
314                }
315                values.insert(name.clone(), value);
316                types.insert(name, ty);
317                Ok(())
318            }
319        }
320    }
321
322    /// Set query name.
323    pub fn set_query_name(&mut self, name: impl Into<String>) {
324        self.query_name = Some(name.into());
325    }
326
327    /// Clear query name.
328    pub fn clear_query_name(&mut self) {
329        self.query_name = None;
330    }
331
332    /// Add parameter value.
333    pub fn with_parameter_value(mut self, name: impl Into<String>, value: QueryValue) -> Self {
334        self.insert_parameter_value(name, value);
335        self
336    }
337
338    /// Add an atomic typed parameter.
339    pub fn with_typed_parameter(
340        mut self,
341        name: impl Into<String>,
342        ty: QueryParamType,
343        value: QueryValue,
344    ) -> Result<Self, QueryError> {
345        self.try_insert_typed_parameter(name, ty, value)?;
346        Ok(self)
347    }
348
349    /// Set query name.
350    pub fn with_query_name(mut self, name: impl Into<String>) -> Self {
351        self.set_query_name(name);
352        self
353    }
354
355    /// Serialize to JSON bytes.
356    pub fn to_json_bytes(&self) -> Result<Vec<u8>, QueryError> {
357        Ok(sonic_rs::to_vec(self)?)
358    }
359
360    /// Serialize to JSON string.
361    pub fn to_json_string(&self) -> Result<String, QueryError> {
362        Ok(String::from_utf8(self.to_json_bytes()?)?)
363    }
364}
365
366#[derive(Serialize)]
367struct QueryRequestRef<'a> {
368    request_type: QueryRequestType,
369    query_name: &'a Option<String>,
370    query: &'a BatchQuery,
371    #[serde(skip_serializing_if = "Option::is_none")]
372    parameters: Option<&'a BTreeMap<String, QueryValue>>,
373    #[serde(skip_serializing_if = "Option::is_none")]
374    parameter_types: Option<&'a BTreeMap<String, QueryParamType>>,
375}
376
377impl Serialize for QueryRequest {
378    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
379    where
380        S: Serializer,
381    {
382        QueryRequestRef {
383            request_type: self.request_type(),
384            query_name: &self.query_name,
385            query: &self.query,
386            parameters: self.parameters(),
387            parameter_types: self.parameter_types(),
388        }
389        .serialize(serializer)
390    }
391}
392
393#[derive(Deserialize)]
394struct RawQueryRequest {
395    request_type: QueryRequestType,
396    #[serde(default)]
397    query_name: Option<String>,
398    query: BatchQuery,
399    #[serde(default)]
400    parameters: Option<UniqueMap<QueryValue>>,
401    #[serde(default)]
402    parameter_types: Option<UniqueMap<QueryParamType>>,
403}
404
405struct UniqueMap<T>(BTreeMap<String, T>);
406
407impl<'de, T> Deserialize<'de> for UniqueMap<T>
408where
409    T: Deserialize<'de>,
410{
411    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
412    where
413        D: Deserializer<'de>,
414    {
415        struct UniqueMapVisitor<T>(std::marker::PhantomData<T>);
416
417        impl<'de, T> Visitor<'de> for UniqueMapVisitor<T>
418        where
419            T: Deserialize<'de>,
420        {
421            type Value = UniqueMap<T>;
422
423            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
424                formatter.write_str("an object with unique parameter names")
425            }
426
427            fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
428            where
429                M: MapAccess<'de>,
430            {
431                let mut values = BTreeMap::new();
432                while let Some((name, value)) = map.next_entry::<String, T>()? {
433                    if values.insert(name.clone(), value).is_some() {
434                        return Err(serde::de::Error::custom(
435                            QueryError::DuplicateParameterName(name),
436                        ));
437                    }
438                }
439                Ok(UniqueMap(values))
440            }
441        }
442
443        deserializer.deserialize_map(UniqueMapVisitor(std::marker::PhantomData))
444    }
445}
446
447impl<'de> Deserialize<'de> for QueryRequest {
448    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
449    where
450        D: Deserializer<'de>,
451    {
452        let raw = RawQueryRequest::deserialize(deserializer)?;
453        if !matches!(
454            (&raw.request_type, &raw.query),
455            (QueryRequestType::Read, BatchQuery::Read(_))
456                | (QueryRequestType::Write, BatchQuery::Write(_))
457        ) {
458            return Err(serde::de::Error::custom(
459                "request_type must match the query batch variant",
460            ));
461        }
462
463        let values = raw.parameters.map_or_else(BTreeMap::new, |values| values.0);
464        let parameters = match raw.parameter_types.map(|types| types.0) {
465            None => {
466                for (name, value) in &values {
467                    validate_parameter_name(name).map_err(serde::de::Error::custom)?;
468                    validate_json_value(value, name).map_err(serde::de::Error::custom)?;
469                }
470                QueryParameters::Untyped(values)
471            }
472            Some(types) => {
473                let missing_values = types
474                    .keys()
475                    .filter(|name| !values.contains_key(*name))
476                    .cloned()
477                    .collect::<Vec<_>>();
478                let extra_values = values
479                    .keys()
480                    .filter(|name| !types.contains_key(*name))
481                    .cloned()
482                    .collect::<Vec<_>>();
483                if !missing_values.is_empty() || !extra_values.is_empty() {
484                    return Err(serde::de::Error::custom(
485                        QueryError::ParameterNameMismatch {
486                            missing_values,
487                            extra_values,
488                        },
489                    ));
490                }
491                let values = values
492                    .into_iter()
493                    .map(|(name, value)| {
494                        validate_parameter_name(&name).map_err(serde::de::Error::custom)?;
495                        let ty = types
496                            .get(&name)
497                            .expect("schema and value names were proven equal");
498                        normalize_typed_value(ty, value, &name)
499                            .map(|value| (name, value))
500                            .map_err(serde::de::Error::custom)
501                    })
502                    .collect::<Result<BTreeMap<_, _>, _>>()?;
503                QueryParameters::Typed { values, types }
504            }
505        };
506
507        Ok(Self {
508            query_name: raw.query_name,
509            query: raw.query,
510            parameters,
511        })
512    }
513}
514
515fn validate_parameter_name(name: &str) -> Result<(), QueryError> {
516    if name.is_empty() {
517        Err(QueryError::InvalidParameterName)
518    } else {
519        Ok(())
520    }
521}
522
523fn validate_json_value(value: &QueryValue, path: &str) -> Result<(), QueryError> {
524    match value {
525        QueryValue::F64(value) if !value.is_finite() => Err(QueryError::ParameterTypeMismatch {
526            path: path.to_owned(),
527            expected: QueryParamType::Value,
528            actual: "non-finite f64",
529        }),
530        QueryValue::F32(value) if !value.is_finite() => Err(QueryError::ParameterTypeMismatch {
531            path: path.to_owned(),
532            expected: QueryParamType::Value,
533            actual: "non-finite f32",
534        }),
535        QueryValue::Array(values) => values
536            .iter()
537            .enumerate()
538            .try_for_each(|(index, value)| validate_json_value(value, &format!("{path}[{index}]"))),
539        QueryValue::Object(values) => values
540            .iter()
541            .try_for_each(|(name, value)| validate_json_value(value, &format!("{path}.{name}"))),
542        QueryValue::Null
543        | QueryValue::Bool(_)
544        | QueryValue::I64(_)
545        | QueryValue::F64(_)
546        | QueryValue::F32(_)
547        | QueryValue::String(_) => Ok(()),
548    }
549}
550
551fn normalize_typed_value(
552    ty: &QueryParamType,
553    value: QueryValue,
554    path: &str,
555) -> Result<QueryValue, QueryError> {
556    let actual = query_value_kind(&value);
557    match (ty, value) {
558        (QueryParamType::Bool, value @ QueryValue::Bool(_))
559        | (QueryParamType::I64, value @ QueryValue::I64(_))
560        | (QueryParamType::String, value @ QueryValue::String(_)) => Ok(value),
561        (QueryParamType::F64, QueryValue::F64(value)) if value.is_finite() => {
562            Ok(QueryValue::F64(value))
563        }
564        (QueryParamType::F64, QueryValue::F32(value)) if value.is_finite() => {
565            Ok(QueryValue::F64(value.into()))
566        }
567        (QueryParamType::F32, QueryValue::F32(value)) if value.is_finite() => {
568            Ok(QueryValue::F32(value))
569        }
570        (QueryParamType::F32, QueryValue::F64(value))
571            if value.is_finite()
572                && value >= f64::from(f32::MIN)
573                && value <= f64::from(f32::MAX) =>
574        {
575            Ok(QueryValue::F32(value as f32))
576        }
577        (QueryParamType::DateTime, QueryValue::String(datetime))
578            if chrono::DateTime::parse_from_rfc3339(&datetime).is_ok() =>
579        {
580            Ok(QueryValue::String(datetime))
581        }
582        (QueryParamType::Value, value) => {
583            validate_json_value(&value, path)?;
584            Ok(value)
585        }
586        (QueryParamType::Object, value @ QueryValue::Object(_)) => {
587            validate_json_value(&value, path)?;
588            Ok(value)
589        }
590        (QueryParamType::Array(inner), QueryValue::Array(values)) => values
591            .into_iter()
592            .enumerate()
593            .map(|(index, value)| normalize_typed_value(inner, value, &format!("{path}[{index}]")))
594            .collect::<Result<Vec<_>, _>>()
595            .map(QueryValue::Array),
596        (QueryParamType::Bytes, _) => Err(QueryError::unsupported_bytes(path)),
597        (expected, _) => Err(QueryError::ParameterTypeMismatch {
598            path: path.to_owned(),
599            expected: expected.clone(),
600            actual,
601        }),
602    }
603}
604
605fn query_value_kind(value: &QueryValue) -> &'static str {
606    match value {
607        QueryValue::Null => "null",
608        QueryValue::Bool(_) => "bool",
609        QueryValue::I64(_) => "i64",
610        QueryValue::F64(_) => "f64",
611        QueryValue::F32(_) => "f32",
612        QueryValue::String(_) => "string",
613        QueryValue::Array(_) => "array",
614        QueryValue::Object(_) => "object",
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use crate::batch::{read_batch, write_batch};
622
623    fn typed(ty: QueryParamType, value: QueryValue) -> Result<QueryRequest, QueryError> {
624        QueryRequest::read(read_batch()).with_typed_parameter("value", ty, value)
625    }
626
627    fn read_wire(parameters: &str, parameter_types: Option<&str>) -> String {
628        let parameter_types = parameter_types
629            .map(|types| format!(r#","parameter_types":{types}"#))
630            .unwrap_or_default();
631        format!(
632            r#"{{"request_type":"read","query_name":null,"query":{{"read":{{"entries":[],"returns":[]}}}},"parameters":{parameters}{parameter_types}}}"#
633        )
634    }
635
636    #[test]
637    fn request_serde_accepts_matching_tags_and_rejects_both_disagreements() {
638        let read = QueryRequest::read(read_batch())
639            .to_json_string()
640            .expect("read request should serialize");
641        let write = QueryRequest::write(write_batch())
642            .to_json_string()
643            .expect("write request should serialize");
644
645        let parsed_read =
646            sonic_rs::from_str::<QueryRequest>(&read).expect("read/read should deserialize");
647        let parsed_write =
648            sonic_rs::from_str::<QueryRequest>(&write).expect("write/write should deserialize");
649        assert_eq!(parsed_read.request_type(), QueryRequestType::Read);
650        assert_eq!(parsed_write.request_type(), QueryRequestType::Write);
651
652        let read_tagged_write =
653            write.replacen(r#""request_type":"write""#, r#""request_type":"read""#, 1);
654        let write_tagged_read =
655            read.replacen(r#""request_type":"read""#, r#""request_type":"write""#, 1);
656        assert!(sonic_rs::from_str::<QueryRequest>(&read_tagged_write).is_err());
657        assert!(sonic_rs::from_str::<QueryRequest>(&write_tagged_read).is_err());
658    }
659
660    #[test]
661    fn typed_parameter_schema_matrix_accepts_only_valid_shapes() {
662        assert!(typed(QueryParamType::Bool, QueryValue::Bool(true)).is_ok());
663        assert!(typed(QueryParamType::Bool, QueryValue::I64(1)).is_err());
664
665        assert!(typed(QueryParamType::I64, QueryValue::I64(i64::MAX)).is_ok());
666        assert!(typed(QueryParamType::I64, QueryValue::F64(1.0)).is_err());
667
668        assert!(typed(QueryParamType::F64, QueryValue::F64(1.25)).is_ok());
669        let f64_from_f32 = typed(QueryParamType::F64, QueryValue::F32(1.25)).unwrap();
670        assert!(matches!(
671            f64_from_f32.parameters().unwrap().get("value"),
672            Some(QueryValue::F64(value)) if *value == 1.25
673        ));
674        assert!(typed(QueryParamType::F64, QueryValue::F64(f64::NAN)).is_err());
675
676        let f32_from_json = typed(QueryParamType::F32, QueryValue::F64(1.25)).unwrap();
677        assert!(matches!(
678            f32_from_json.parameters().unwrap().get("value"),
679            Some(QueryValue::F32(value)) if *value == 1.25
680        ));
681        assert!(typed(QueryParamType::F32, QueryValue::F64(f64::MAX)).is_err());
682        assert!(typed(QueryParamType::F32, QueryValue::F32(f32::INFINITY)).is_err());
683
684        assert!(typed(QueryParamType::String, QueryValue::String("x".to_owned())).is_ok());
685        assert!(typed(QueryParamType::String, QueryValue::Null).is_err());
686
687        assert!(typed(
688            QueryParamType::DateTime,
689            QueryValue::String("2026-07-28T12:34:56Z".to_owned()),
690        )
691        .is_ok());
692        assert!(typed(
693            QueryParamType::DateTime,
694            QueryValue::String("28 July 2026".to_owned()),
695        )
696        .is_err());
697
698        assert!(matches!(
699            typed(QueryParamType::Bytes, QueryValue::String("AQID".to_owned())),
700            Err(QueryError::UnsupportedBytesParameter(path)) if path == "value"
701        ));
702
703        assert!(typed(
704            QueryParamType::Value,
705            QueryValue::Array(vec![QueryValue::Object(BTreeMap::from([(
706                "nested".to_owned(),
707                QueryValue::Null,
708            )]))]),
709        )
710        .is_ok());
711        assert!(typed(
712            QueryParamType::Value,
713            QueryValue::Array(vec![QueryValue::F64(f64::INFINITY)]),
714        )
715        .is_err());
716
717        assert!(typed(QueryParamType::Object, QueryValue::Object(BTreeMap::new())).is_ok());
718        assert!(typed(QueryParamType::Object, QueryValue::Array(Vec::new())).is_err());
719
720        assert!(typed(
721            QueryParamType::Array(Box::new(QueryParamType::Bool)),
722            QueryValue::Array(vec![QueryValue::Bool(true), QueryValue::Bool(false)]),
723        )
724        .is_ok());
725        assert!(typed(
726            QueryParamType::Array(Box::new(QueryParamType::Bool)),
727            QueryValue::Array(vec![QueryValue::Bool(true), QueryValue::I64(0)]),
728        )
729        .is_err());
730    }
731
732    #[test]
733    fn parameter_modes_names_and_duplicate_entries_are_closed() {
734        let mut untyped = QueryRequest::read(read_batch());
735        untyped
736            .try_insert_untyped_parameter("value", QueryValue::Bool(true))
737            .unwrap();
738        assert!(matches!(
739            untyped.try_insert_untyped_parameter("value", QueryValue::Bool(false)),
740            Err(QueryError::DuplicateParameterName(name)) if name == "value"
741        ));
742        assert!(matches!(
743            untyped.try_insert_typed_parameter(
744                "typed",
745                QueryParamType::Bool,
746                QueryValue::Bool(true),
747            ),
748            Err(QueryError::MixedParameterModes)
749        ));
750
751        let mut typed = QueryRequest::read(read_batch());
752        typed
753            .try_insert_typed_parameter("value", QueryParamType::Bool, QueryValue::Bool(true))
754            .unwrap();
755        assert!(matches!(
756            typed.try_insert_typed_parameter(
757                "value",
758                QueryParamType::Bool,
759                QueryValue::Bool(false),
760            ),
761            Err(QueryError::DuplicateParameterName(name)) if name == "value"
762        ));
763        assert!(matches!(
764            typed.try_insert_untyped_parameter("untyped", QueryValue::Bool(true)),
765            Err(QueryError::MixedParameterModes)
766        ));
767
768        assert!(matches!(
769            QueryRequest::read(read_batch()).with_typed_parameter(
770                "",
771                QueryParamType::Bool,
772                QueryValue::Bool(true),
773            ),
774            Err(QueryError::InvalidParameterName)
775        ));
776    }
777
778    #[test]
779    fn raw_parameter_dto_rejects_mismatched_empty_and_duplicate_names() {
780        let missing_value = read_wire(r#"{}"#, Some(r#"{"value":"bool"}"#));
781        let extra_value = read_wire(r#"{"value":true}"#, Some(r#"{}"#));
782        let empty_name = read_wire(r#"{"":true}"#, Some(r#"{"":"bool"}"#));
783        let duplicate_value = read_wire(
784            r#"{"value":true,"value":false}"#,
785            Some(r#"{"value":"bool"}"#),
786        );
787        let duplicate_type = read_wire(
788            r#"{"value":true}"#,
789            Some(r#"{"value":"bool","value":"bool"}"#),
790        );
791
792        for invalid in [
793            missing_value,
794            extra_value,
795            empty_name,
796            duplicate_value,
797            duplicate_type,
798        ] {
799            assert!(
800                sonic_rs::from_str::<QueryRequest>(&invalid).is_err(),
801                "invalid DTO should be rejected: {invalid}"
802            );
803        }
804    }
805
806    #[test]
807    fn raw_f32_is_normalized_and_untyped_parameters_remain_explicit() {
808        let raw = read_wire(r#"{"value":1.25}"#, Some(r#"{"value":"f32"}"#));
809        let typed = sonic_rs::from_str::<QueryRequest>(&raw).expect("valid typed f32 request");
810        assert!(matches!(
811            typed.parameters().unwrap().get("value"),
812            Some(QueryValue::F32(value)) if *value == 1.25
813        ));
814
815        let raw = read_wire(r#"{"value":{"nested":[true,1,"x"]}}"#, None);
816        let untyped = sonic_rs::from_str::<QueryRequest>(&raw).expect("valid untyped JSON request");
817        assert!(untyped.parameter_types().is_none());
818        assert!(matches!(
819            untyped.parameters().unwrap().get("value"),
820            Some(QueryValue::Object(_))
821        ));
822    }
823}