1extern crate alloc;
21use alloc::string::{String, ToString};
22use alloc::sync::Arc;
23use alloc::vec::Vec;
24use core::marker::PhantomData;
25
26#[cfg(feature = "std")]
27use std::sync::RwLock;
28
29use zerodds_sql_filter::{Expr, RowAccess, Value};
30
31use crate::dds_type::DdsType;
32use crate::entity::StatusMask;
33use crate::error::{DdsError, Result};
34use crate::listener::ArcTopicListener;
35use crate::participant::DomainParticipant;
36use crate::qos::TopicQos;
37
38pub trait TopicDescription {
50 fn get_type_name(&self) -> &str;
53 fn get_name(&self) -> &str;
56 fn get_participant(&self) -> &DomainParticipant;
59}
60
61#[derive(Debug)]
63pub struct Topic<T: DdsType> {
64 inner: Arc<TopicInner>,
65 participant: Option<DomainParticipant>,
70 _t: PhantomData<T>,
71}
72
73pub(crate) struct TopicInner {
75 pub name: String,
77 pub type_name: &'static str,
81 #[cfg(feature = "std")]
83 pub qos: std::sync::Mutex<TopicQos>,
84 #[cfg(not(feature = "std"))]
85 pub qos: TopicQos,
86 pub entity_state: Arc<crate::entity::EntityState>,
88 #[cfg(feature = "std")]
91 pub listener: std::sync::Mutex<Option<(ArcTopicListener, StatusMask)>>,
92 #[cfg(feature = "std")]
96 pub inconsistent_topic_count: std::sync::atomic::AtomicI64,
97 #[cfg(feature = "std")]
99 pub last_inconsistent_topic: std::sync::atomic::AtomicI64,
100}
101
102impl core::fmt::Debug for TopicInner {
103 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
104 #[cfg(feature = "std")]
105 let listener_present = self.listener.lock().map(|s| s.is_some()).unwrap_or(false);
106 #[cfg(not(feature = "std"))]
107 let listener_present = false;
108 f.debug_struct("TopicInner")
109 .field("name", &self.name)
110 .field("type_name", &self.type_name)
111 .field("listener_present", &listener_present)
112 .finish_non_exhaustive()
113 }
114}
115
116impl<T: DdsType> Topic<T> {
117 #[must_use]
120 pub fn new(name: String, qos: TopicQos, participant: DomainParticipant) -> Self {
121 Self {
122 inner: Arc::new(TopicInner {
123 name,
124 type_name: T::TYPE_NAME,
125 #[cfg(feature = "std")]
126 qos: std::sync::Mutex::new(qos),
127 #[cfg(not(feature = "std"))]
128 qos,
129 entity_state: crate::entity::EntityState::new(),
130 #[cfg(feature = "std")]
131 listener: std::sync::Mutex::new(None),
132 #[cfg(feature = "std")]
133 inconsistent_topic_count: std::sync::atomic::AtomicI64::new(0),
134 #[cfg(feature = "std")]
135 last_inconsistent_topic: std::sync::atomic::AtomicI64::new(-1),
136 }),
137 participant: Some(participant),
138 _t: PhantomData,
139 }
140 }
141
142 #[must_use]
149 pub fn new_orphan(name: String, qos: TopicQos) -> Self {
150 Self {
151 inner: Arc::new(TopicInner {
152 name,
153 type_name: T::TYPE_NAME,
154 #[cfg(feature = "std")]
155 qos: std::sync::Mutex::new(qos),
156 #[cfg(not(feature = "std"))]
157 qos,
158 entity_state: crate::entity::EntityState::new(),
159 #[cfg(feature = "std")]
160 listener: std::sync::Mutex::new(None),
161 #[cfg(feature = "std")]
162 inconsistent_topic_count: std::sync::atomic::AtomicI64::new(0),
163 #[cfg(feature = "std")]
164 last_inconsistent_topic: std::sync::atomic::AtomicI64::new(-1),
165 }),
166 participant: None,
167 _t: PhantomData,
168 }
169 }
170
171 #[must_use]
173 pub fn name(&self) -> &str {
174 &self.inner.name
175 }
176
177 #[must_use]
179 pub fn type_name(&self) -> &'static str {
180 self.inner.type_name
181 }
182
183 #[cfg(feature = "std")]
186 pub fn set_listener(&self, listener: Option<ArcTopicListener>, mask: StatusMask) {
187 if let Ok(mut slot) = self.inner.listener.lock() {
188 *slot = listener.map(|l| (l, mask));
189 }
190 self.inner.entity_state.set_listener_mask(mask);
191 }
192
193 #[cfg(feature = "std")]
195 #[must_use]
196 pub fn get_listener(&self) -> Option<ArcTopicListener> {
197 self.inner
198 .listener
199 .lock()
200 .ok()
201 .and_then(|s| s.as_ref().map(|(l, _)| Arc::clone(l)))
202 }
203
204 #[cfg(feature = "std")]
212 #[must_use]
213 pub(crate) fn listener_chain(&self) -> crate::listener_dispatch::TopicListenerChain {
214 let topic = self
215 .inner
216 .listener
217 .lock()
218 .ok()
219 .and_then(|s| s.as_ref().map(|(l, m)| (Arc::clone(l), *m)));
220 let participant = self
221 .participant
222 .as_ref()
223 .and_then(|p| p.snapshot_listener());
224 crate::listener_dispatch::TopicListenerChain { topic, participant }
225 }
226
227 #[cfg(feature = "std")]
232 pub fn record_inconsistent_topic(&self) {
233 self.inner
234 .inconsistent_topic_count
235 .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
236 }
237
238 #[cfg(feature = "std")]
241 #[must_use]
242 pub fn inconsistent_topic_status(&self) -> crate::status::InconsistentTopicStatus {
243 let curr = self
244 .inner
245 .inconsistent_topic_count
246 .load(std::sync::atomic::Ordering::Acquire);
247 let prev = self
248 .inner
249 .last_inconsistent_topic
250 .swap(curr, std::sync::atomic::Ordering::AcqRel);
251 let delta = if prev < 0 { curr } else { curr - prev };
252 let status = crate::status::InconsistentTopicStatus {
253 total_count: curr as i32,
254 total_count_change: delta as i32,
255 };
256 let actually_changed = if prev < 0 { curr != 0 } else { prev != curr };
259 if actually_changed {
260 let chain = self.listener_chain();
261 crate::listener_dispatch::dispatch_inconsistent_topic(
262 &chain,
263 self.inner.entity_state.instance_handle(),
264 status,
265 );
266 }
267 status
268 }
269
270 #[must_use]
272 pub fn qos(&self) -> TopicQos {
273 #[cfg(feature = "std")]
274 {
275 self.inner.qos.lock().map(|q| q.clone()).unwrap_or_default()
276 }
277 #[cfg(not(feature = "std"))]
278 {
279 self.inner.qos.clone()
280 }
281 }
282
283 #[allow(dead_code)]
285 pub(crate) fn inner(&self) -> Arc<TopicInner> {
286 Arc::clone(&self.inner)
287 }
288
289 pub(crate) fn _from_inner_impl(inner: Arc<TopicInner>, participant: DomainParticipant) -> Self {
293 Self {
294 inner,
295 participant: Some(participant),
296 _t: PhantomData,
297 }
298 }
299}
300
301impl<T: DdsType> Clone for Topic<T> {
302 fn clone(&self) -> Self {
303 Self {
304 inner: Arc::clone(&self.inner),
305 participant: self.participant.clone(),
306 _t: PhantomData,
307 }
308 }
309}
310
311impl<T: DdsType> TopicDescription for Topic<T> {
312 fn get_type_name(&self) -> &str {
313 self.inner.type_name
314 }
315 fn get_name(&self) -> &str {
316 &self.inner.name
317 }
318 #[allow(clippy::expect_used, clippy::panic)]
322 fn get_participant(&self) -> &DomainParticipant {
323 match &self.participant {
324 Some(p) => p,
325 None => panic!(
326 "get_participant on orphan (builtin) topic — builtin readers must not call this"
327 ),
328 }
329 }
330}
331
332#[cfg(feature = "std")]
337impl<T: DdsType> crate::entity::Entity for Topic<T> {
338 type Qos = TopicQos;
339
340 fn get_qos(&self) -> Self::Qos {
341 self.inner.qos.lock().map(|q| q.clone()).unwrap_or_default()
342 }
343
344 fn set_qos(&self, qos: Self::Qos) -> Result<()> {
348 let enabled = self.inner.entity_state.is_enabled();
349 if let Ok(mut current) = self.inner.qos.lock() {
350 if enabled {
351 if current.durability != qos.durability {
354 return Err(crate::entity::immutable_if_enabled("DURABILITY"));
355 }
356 if current.reliability != qos.reliability {
357 return Err(crate::entity::immutable_if_enabled("RELIABILITY"));
358 }
359 }
360 *current = qos;
361 }
362 Ok(())
363 }
364
365 fn enable(&self) -> Result<()> {
366 self.inner.entity_state.enable();
367 Ok(())
368 }
369
370 fn entity_state(&self) -> Arc<crate::entity::EntityState> {
371 Arc::clone(&self.inner.entity_state)
372 }
373}
374
375#[derive(Debug, Clone)]
385pub struct TopicDescriptionHandle {
386 name: String,
387 type_name: String,
388 participant: DomainParticipant,
389}
390
391impl TopicDescriptionHandle {
392 pub(crate) fn new(name: String, type_name: String, participant: DomainParticipant) -> Self {
394 Self {
395 name,
396 type_name,
397 participant,
398 }
399 }
400}
401
402impl TopicDescription for TopicDescriptionHandle {
403 fn get_type_name(&self) -> &str {
404 &self.type_name
405 }
406 fn get_name(&self) -> &str {
407 &self.name
408 }
409 fn get_participant(&self) -> &DomainParticipant {
410 &self.participant
411 }
412}
413
414#[derive(Debug)]
430pub struct ContentFilteredTopic<T: DdsType> {
431 name: String,
432 related_topic: Topic<T>,
433 filter_expression: String,
437 parsed: Arc<Expr>,
440 #[cfg(feature = "std")]
446 params: Arc<RwLock<FilterParams>>,
447 #[cfg(not(feature = "std"))]
448 params: FilterParams,
449 participant: DomainParticipant,
450 _t: PhantomData<T>,
451}
452
453#[derive(Debug, Clone)]
454struct FilterParams {
455 raw: Vec<String>,
456 values: Vec<Value>,
457}
458
459impl<T: DdsType> ContentFilteredTopic<T> {
460 pub(crate) fn new(
467 name: String,
468 related_topic: Topic<T>,
469 filter_expression: String,
470 filter_parameters: Vec<String>,
471 participant: DomainParticipant,
472 ) -> Result<Self> {
473 let parsed =
474 zerodds_sql_filter::parse(&filter_expression).map_err(|_| DdsError::BadParameter {
475 what: "filter expression syntax",
476 })?;
477 let used = parsed.collect_param_indices();
479 if let Some(max) = used.iter().max() {
480 if (*max as usize) >= filter_parameters.len() {
481 return Err(DdsError::BadParameter {
482 what: "filter parameter %N out of range",
483 });
484 }
485 }
486 let values: Vec<Value> = filter_parameters
487 .iter()
488 .map(|s| param_string_to_value(s))
489 .collect();
490 let fp = FilterParams {
491 raw: filter_parameters,
492 values,
493 };
494 Ok(Self {
495 name,
496 related_topic,
497 filter_expression,
498 parsed: Arc::new(parsed),
499 #[cfg(feature = "std")]
500 params: Arc::new(RwLock::new(fp)),
501 #[cfg(not(feature = "std"))]
502 params: fp,
503 participant,
504 _t: PhantomData,
505 })
506 }
507
508 #[must_use]
510 pub fn get_filter_expression(&self) -> &str {
511 &self.filter_expression
512 }
513
514 #[must_use]
516 pub fn get_filter_parameters(&self) -> Vec<String> {
517 #[cfg(feature = "std")]
518 {
519 self.params
520 .read()
521 .map(|p| p.raw.clone())
522 .unwrap_or_default()
523 }
524 #[cfg(not(feature = "std"))]
525 {
526 self.params.raw.clone()
527 }
528 }
529
530 pub fn set_filter_parameters(&self, params: Vec<String>) -> Result<()> {
538 let used = self.parsed.collect_param_indices();
539 if let Some(max) = used.iter().max() {
540 if (*max as usize) >= params.len() {
541 return Err(DdsError::BadParameter {
542 what: "filter parameter %N out of range",
543 });
544 }
545 }
546 let values: Vec<Value> = params.iter().map(|s| param_string_to_value(s)).collect();
547 let fp = FilterParams {
548 raw: params,
549 values,
550 };
551 #[cfg(feature = "std")]
552 {
553 let mut w = self
554 .params
555 .write()
556 .map_err(|_| DdsError::PreconditionNotMet {
557 reason: "filter params poisoned",
558 })?;
559 *w = fp;
560 }
561 #[cfg(not(feature = "std"))]
562 {
563 let _ = fp;
567 return Err(DdsError::PreconditionNotMet {
568 reason: "set_filter_parameters needs std feature",
569 });
570 }
571 Ok(())
572 }
573
574 #[must_use]
576 pub fn get_related_topic(&self) -> &Topic<T> {
577 &self.related_topic
578 }
579
580 pub fn evaluate<R: RowAccess>(&self, row: &R) -> Result<bool> {
590 #[cfg(feature = "std")]
591 let params = {
592 let r = self
593 .params
594 .read()
595 .map_err(|_| DdsError::PreconditionNotMet {
596 reason: "filter params poisoned",
597 })?;
598 r.values.clone()
599 };
600 #[cfg(not(feature = "std"))]
601 let params = self.params.values.clone();
602 self.parsed
603 .evaluate(row, ¶ms)
604 .map_err(|e| DdsError::BadParameter {
605 what: match e {
606 zerodds_sql_filter::EvalError::UnknownField(_) => "filter unknown field",
607 zerodds_sql_filter::EvalError::MissingParam(_) => "filter missing param",
608 zerodds_sql_filter::EvalError::TypeMismatch(_) => "filter type mismatch",
609 },
610 })
611 }
612}
613
614impl<T: DdsType> Clone for ContentFilteredTopic<T> {
615 fn clone(&self) -> Self {
616 Self {
617 name: self.name.clone(),
618 related_topic: self.related_topic.clone(),
619 filter_expression: self.filter_expression.clone(),
620 parsed: Arc::clone(&self.parsed),
621 #[cfg(feature = "std")]
622 params: Arc::clone(&self.params),
623 #[cfg(not(feature = "std"))]
624 params: self.params.clone(),
625 participant: self.participant.clone(),
626 _t: PhantomData,
627 }
628 }
629}
630
631impl<T: DdsType> TopicDescription for ContentFilteredTopic<T> {
632 fn get_type_name(&self) -> &str {
635 self.related_topic.type_name()
636 }
637 fn get_name(&self) -> &str {
638 &self.name
639 }
640 fn get_participant(&self) -> &DomainParticipant {
641 &self.participant
642 }
643}
644
645pub struct MultiTopic<T: DdsType> {
658 name: String,
659 type_name: String,
660 related_topic_names: Vec<String>,
662 subscription_expression: String,
663 parsed: Arc<Expr>,
665 #[cfg(feature = "std")]
668 params: Arc<RwLock<FilterParams>>,
669 #[cfg(not(feature = "std"))]
670 params: FilterParams,
671 participant: DomainParticipant,
672 _t: PhantomData<T>,
673}
674
675impl<T: DdsType> core::fmt::Debug for MultiTopic<T> {
676 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
677 f.debug_struct("MultiTopic")
678 .field("name", &self.name)
679 .field("type_name", &self.type_name)
680 .field("related_topic_names", &self.related_topic_names)
681 .field("subscription_expression", &self.subscription_expression)
682 .finish_non_exhaustive()
683 }
684}
685
686impl<T: DdsType> MultiTopic<T> {
687 pub(crate) fn new(
696 name: String,
697 type_name: String,
698 related_topic_names: Vec<String>,
699 subscription_expression: String,
700 expression_parameters: Vec<String>,
701 participant: DomainParticipant,
702 ) -> Result<Self> {
703 if related_topic_names.is_empty() {
704 return Err(DdsError::BadParameter {
705 what: "multitopic needs at least one related topic",
706 });
707 }
708 let parsed = zerodds_sql_filter::parse(&subscription_expression).map_err(|_| {
709 DdsError::BadParameter {
710 what: "multitopic subscription expression syntax",
711 }
712 })?;
713 let used = parsed.collect_param_indices();
714 if let Some(max) = used.iter().max() {
715 if (*max as usize) >= expression_parameters.len() {
716 return Err(DdsError::BadParameter {
717 what: "multitopic expression parameter %N out of range",
718 });
719 }
720 }
721 let values: Vec<Value> = expression_parameters
722 .iter()
723 .map(|s| param_string_to_value(s))
724 .collect();
725 let fp = FilterParams {
726 raw: expression_parameters,
727 values,
728 };
729 Ok(Self {
730 name,
731 type_name,
732 related_topic_names,
733 subscription_expression,
734 parsed: Arc::new(parsed),
735 #[cfg(feature = "std")]
736 params: Arc::new(RwLock::new(fp)),
737 #[cfg(not(feature = "std"))]
738 params: fp,
739 participant,
740 _t: PhantomData,
741 })
742 }
743
744 #[must_use]
746 pub fn get_subscription_expression(&self) -> &str {
747 &self.subscription_expression
748 }
749
750 #[must_use]
752 pub fn get_expression_parameters(&self) -> Vec<String> {
753 #[cfg(feature = "std")]
754 {
755 self.params
756 .read()
757 .map(|p| p.raw.clone())
758 .unwrap_or_default()
759 }
760 #[cfg(not(feature = "std"))]
761 {
762 self.params.raw.clone()
763 }
764 }
765
766 pub fn set_expression_parameters(&self, params: Vec<String>) -> Result<()> {
772 let used = self.parsed.collect_param_indices();
773 if let Some(max) = used.iter().max() {
774 if (*max as usize) >= params.len() {
775 return Err(DdsError::BadParameter {
776 what: "multitopic expression parameter %N out of range",
777 });
778 }
779 }
780 let values: Vec<Value> = params.iter().map(|s| param_string_to_value(s)).collect();
781 let fp = FilterParams {
782 raw: params,
783 values,
784 };
785 #[cfg(feature = "std")]
786 {
787 let mut w = self
788 .params
789 .write()
790 .map_err(|_| DdsError::PreconditionNotMet {
791 reason: "multitopic params poisoned",
792 })?;
793 *w = fp;
794 }
795 #[cfg(not(feature = "std"))]
796 {
797 let _ = fp;
798 return Err(DdsError::PreconditionNotMet {
799 reason: "set_expression_parameters needs std feature",
800 });
801 }
802 Ok(())
803 }
804
805 #[must_use]
808 pub fn get_related_topic_names(&self) -> &[String] {
809 &self.related_topic_names
810 }
811
812 pub fn evaluate_joined(&self, row: &JoinedRow<'_>) -> Result<bool> {
818 #[cfg(feature = "std")]
819 let values = {
820 let p = self
821 .params
822 .read()
823 .map_err(|_| DdsError::PreconditionNotMet {
824 reason: "multitopic params poisoned",
825 })?;
826 p.values.clone()
827 };
828 #[cfg(not(feature = "std"))]
829 let values = self.params.values.clone();
830 self.parsed
831 .evaluate(row, &values)
832 .map_err(|_| DdsError::PreconditionNotMet {
833 reason: "multitopic SQL evaluation failed",
834 })
835 }
836}
837
838pub struct JoinedRow<'a> {
846 sources: Vec<(String, &'a dyn RowAccess)>,
847}
848
849impl<'a> JoinedRow<'a> {
850 #[must_use]
852 pub fn new(sources: Vec<(String, &'a dyn RowAccess)>) -> Self {
853 Self { sources }
854 }
855}
856
857impl RowAccess for JoinedRow<'_> {
858 fn get(&self, path: &str) -> Option<Value> {
859 if let Some((prefix, rest)) = path.split_once('.') {
860 for (name, src) in &self.sources {
861 if name == prefix {
862 return src.get(rest);
863 }
864 }
865 }
866 for (_, src) in &self.sources {
868 if let Some(v) = src.get(path) {
869 return Some(v);
870 }
871 }
872 None
873 }
874}
875
876#[cfg(feature = "std")]
886#[allow(clippy::too_many_arguments)]
887pub fn hash_join_two<L, R, T, KL, KR, C, P>(
888 left: &[L],
889 left_topic: &str,
890 key_left: KL,
891 right: &[R],
892 right_topic: &str,
893 key_right: KR,
894 combine: C,
895 predicate: P,
896) -> Vec<T>
897where
898 L: RowAccess,
899 R: RowAccess,
900 KL: Fn(&L) -> String,
901 KR: Fn(&R) -> String,
902 C: Fn(&L, &R) -> T,
903 P: Fn(&JoinedRow<'_>) -> Result<bool>,
904{
905 use std::collections::HashMap;
906 let mut idx: HashMap<String, Vec<&L>> = HashMap::with_capacity(left.len());
907 for l in left {
908 idx.entry(key_left(l)).or_default().push(l);
909 }
910 let mut out = Vec::new();
911 for r in right {
912 let k = key_right(r);
913 let Some(matches) = idx.get(&k) else { continue };
914 for l in matches {
915 let row = JoinedRow::new(alloc::vec![
916 (left_topic.to_string(), *l as &dyn RowAccess),
917 (right_topic.to_string(), r as &dyn RowAccess),
918 ]);
919 if predicate(&row).unwrap_or(false) {
920 out.push(combine(l, r));
921 }
922 }
923 }
924 out
925}
926
927impl<T: DdsType> Clone for MultiTopic<T> {
928 fn clone(&self) -> Self {
929 Self {
930 name: self.name.clone(),
931 type_name: self.type_name.clone(),
932 related_topic_names: self.related_topic_names.clone(),
933 subscription_expression: self.subscription_expression.clone(),
934 parsed: Arc::clone(&self.parsed),
935 #[cfg(feature = "std")]
936 params: Arc::clone(&self.params),
937 #[cfg(not(feature = "std"))]
938 params: self.params.clone(),
939 participant: self.participant.clone(),
940 _t: PhantomData,
941 }
942 }
943}
944
945impl<T: DdsType> TopicDescription for MultiTopic<T> {
946 fn get_type_name(&self) -> &str {
947 &self.type_name
948 }
949 fn get_name(&self) -> &str {
950 &self.name
951 }
952 fn get_participant(&self) -> &DomainParticipant {
953 &self.participant
954 }
955}
956
957fn param_string_to_value(s: &str) -> Value {
962 let trimmed = s.trim();
963 if trimmed.eq_ignore_ascii_case("TRUE") {
965 return Value::Bool(true);
966 }
967 if trimmed.eq_ignore_ascii_case("FALSE") {
968 return Value::Bool(false);
969 }
970 if let Ok(i) = trimmed.parse::<i64>() {
972 return Value::Int(i);
973 }
974 if let Ok(f) = trimmed.parse::<f64>() {
976 return Value::Float(f);
977 }
978 if trimmed.len() >= 2 && trimmed.starts_with('\'') && trimmed.ends_with('\'') {
980 return Value::String(trimmed[1..trimmed.len() - 1].to_string());
981 }
982 Value::String(trimmed.to_string())
983}
984
985#[cfg(test)]
986#[allow(clippy::expect_used, clippy::unwrap_used)]
987mod tests {
988 use super::*;
989 use crate::dds_type::RawBytes;
990 use crate::factory::DomainParticipantFactory;
991 use crate::qos::DomainParticipantQos;
992
993 #[test]
994 fn topic_implements_topic_description() {
995 let p = DomainParticipantFactory::instance()
996 .create_participant_offline(0, DomainParticipantQos::default());
997 let t = p
998 .create_topic::<RawBytes>("Chatter", TopicQos::default())
999 .unwrap();
1000 let td: &dyn TopicDescription = &t;
1002 assert_eq!(td.get_name(), "Chatter");
1003 assert_eq!(td.get_type_name(), RawBytes::TYPE_NAME);
1004 assert_eq!(td.get_participant().domain_id(), 0);
1005 }
1006
1007 #[test]
1008 fn topic_description_handle_is_cloneable() {
1009 let p = DomainParticipantFactory::instance()
1010 .create_participant_offline(7, DomainParticipantQos::default());
1011 let h = TopicDescriptionHandle::new("X".into(), "T".into(), p.clone());
1012 let h2 = h.clone();
1013 assert_eq!(h2.get_name(), "X");
1014 assert_eq!(h2.get_type_name(), "T");
1015 assert_eq!(h2.get_participant().domain_id(), 7);
1016 }
1017
1018 #[test]
1019 fn topic_description_trait_is_object_safe() {
1020 let p = DomainParticipantFactory::instance()
1024 .create_participant_offline(8, DomainParticipantQos::default());
1025 let t = p
1026 .create_topic::<RawBytes>("DynA", TopicQos::default())
1027 .unwrap();
1028 let h = TopicDescriptionHandle::new("DynB".into(), "T".into(), p.clone());
1029 let descs: Vec<&dyn TopicDescription> = vec![&t, &h];
1030 assert_eq!(descs.len(), 2);
1031 assert_eq!(descs[0].get_name(), "DynA");
1032 assert_eq!(descs[1].get_name(), "DynB");
1033 }
1034
1035 #[test]
1036 fn topic_description_create_topic_rejects_empty_name() {
1037 let p = DomainParticipantFactory::instance()
1041 .create_participant_offline(9, DomainParticipantQos::default());
1042 let res = p.create_topic::<RawBytes>("", TopicQos::default());
1043 assert!(matches!(
1044 res,
1045 Err(crate::error::DdsError::BadParameter { .. })
1046 ));
1047 }
1048
1049 #[test]
1052 fn multitopic_compiles_and_implements_topic_description() {
1053 let p = DomainParticipantFactory::instance()
1054 .create_participant_offline(13, DomainParticipantQos::default());
1055 let mt = p
1056 .create_multitopic::<RawBytes>(
1057 "Combined",
1058 "MyResultType",
1059 alloc::vec!["TopicA".into(), "TopicB".into()],
1060 "x > %0",
1061 alloc::vec!["10".into()],
1062 )
1063 .unwrap();
1064 let td: &dyn TopicDescription = &mt;
1065 assert_eq!(td.get_name(), "Combined");
1066 assert_eq!(td.get_type_name(), "MyResultType");
1067 assert_eq!(td.get_participant().domain_id(), 13);
1068 assert_eq!(mt.get_subscription_expression(), "x > %0");
1069 assert_eq!(mt.get_related_topic_names().len(), 2);
1070 assert_eq!(mt.get_expression_parameters().len(), 1);
1071 }
1072
1073 #[test]
1074 fn multitopic_set_expression_parameters_roundtrip() {
1075 let p = DomainParticipantFactory::instance()
1076 .create_participant_offline(0, DomainParticipantQos::default());
1077 let mt = p
1078 .create_multitopic::<RawBytes>(
1079 "MT",
1080 "T",
1081 alloc::vec!["A".into()],
1082 "v = %0",
1083 alloc::vec!["100".into()],
1084 )
1085 .unwrap();
1086 assert_eq!(
1087 mt.get_expression_parameters(),
1088 alloc::vec!["100".to_string()]
1089 );
1090 mt.set_expression_parameters(alloc::vec!["200".into()])
1091 .unwrap();
1092 assert_eq!(
1093 mt.get_expression_parameters(),
1094 alloc::vec!["200".to_string()]
1095 );
1096 }
1097
1098 #[test]
1099 fn multitopic_rejects_empty_name() {
1100 let p = DomainParticipantFactory::instance()
1101 .create_participant_offline(0, DomainParticipantQos::default());
1102 let res = p.create_multitopic::<RawBytes>(
1103 "",
1104 "T",
1105 alloc::vec!["A".into()],
1106 "x > 0",
1107 alloc::vec::Vec::new(),
1108 );
1109 assert!(matches!(res, Err(DdsError::BadParameter { .. })));
1110 }
1111
1112 #[test]
1113 fn multitopic_rejects_empty_type_name() {
1114 let p = DomainParticipantFactory::instance()
1115 .create_participant_offline(0, DomainParticipantQos::default());
1116 let res = p.create_multitopic::<RawBytes>(
1117 "MT",
1118 "",
1119 alloc::vec!["A".into()],
1120 "x > 0",
1121 alloc::vec::Vec::new(),
1122 );
1123 assert!(matches!(res, Err(DdsError::BadParameter { .. })));
1124 }
1125
1126 #[test]
1127 fn multitopic_rejects_empty_related_topics() {
1128 let p = DomainParticipantFactory::instance()
1129 .create_participant_offline(0, DomainParticipantQos::default());
1130 let res = p.create_multitopic::<RawBytes>(
1131 "MT",
1132 "T",
1133 alloc::vec::Vec::new(),
1134 "x > 0",
1135 alloc::vec::Vec::new(),
1136 );
1137 assert!(matches!(res, Err(DdsError::BadParameter { .. })));
1138 }
1139
1140 #[test]
1141 fn multitopic_rejects_invalid_expression() {
1142 let p = DomainParticipantFactory::instance()
1143 .create_participant_offline(0, DomainParticipantQos::default());
1144 let res = p.create_multitopic::<RawBytes>(
1145 "MT",
1146 "T",
1147 alloc::vec!["A".into()],
1148 "x === bogus",
1149 alloc::vec::Vec::new(),
1150 );
1151 assert!(matches!(res, Err(DdsError::BadParameter { .. })));
1152 }
1153
1154 #[test]
1155 fn multitopic_rejects_param_index_out_of_range() {
1156 let p = DomainParticipantFactory::instance()
1157 .create_participant_offline(0, DomainParticipantQos::default());
1158 let res = p.create_multitopic::<RawBytes>(
1160 "MT",
1161 "T",
1162 alloc::vec!["A".into()],
1163 "x = %1",
1164 alloc::vec!["only_zero".into()],
1165 );
1166 assert!(matches!(res, Err(DdsError::BadParameter { .. })));
1167 }
1168
1169 #[test]
1170 fn multitopic_set_params_validates_index_range() {
1171 let p = DomainParticipantFactory::instance()
1172 .create_participant_offline(0, DomainParticipantQos::default());
1173 let mt = p
1174 .create_multitopic::<RawBytes>(
1175 "MT",
1176 "T",
1177 alloc::vec!["A".into()],
1178 "x = %0 OR y = %1",
1179 alloc::vec!["a".into(), "b".into()],
1180 )
1181 .unwrap();
1182 let res = mt.set_expression_parameters(alloc::vec!["only_zero".into()]);
1184 assert!(matches!(res, Err(DdsError::BadParameter { .. })));
1185 }
1186
1187 #[test]
1188 fn multitopic_clone_shares_params() {
1189 let p = DomainParticipantFactory::instance()
1190 .create_participant_offline(0, DomainParticipantQos::default());
1191 let mt = p
1192 .create_multitopic::<RawBytes>(
1193 "MT",
1194 "T",
1195 alloc::vec!["A".into()],
1196 "v = %0",
1197 alloc::vec!["init".into()],
1198 )
1199 .unwrap();
1200 let mt2 = mt.clone();
1201 mt.set_expression_parameters(alloc::vec!["updated".into()])
1204 .unwrap();
1205 assert_eq!(
1206 mt2.get_expression_parameters(),
1207 alloc::vec!["updated".to_string()]
1208 );
1209 }
1210
1211 struct OrderRow {
1214 id: i64,
1215 amount: i64,
1216 }
1217 impl RowAccess for OrderRow {
1218 fn get(&self, p: &str) -> Option<Value> {
1219 match p {
1220 "id" => Some(Value::Int(self.id)),
1221 "amount" => Some(Value::Int(self.amount)),
1222 _ => None,
1223 }
1224 }
1225 }
1226
1227 struct CustomerRow {
1228 id: i64,
1229 country: String,
1230 }
1231 impl RowAccess for CustomerRow {
1232 fn get(&self, p: &str) -> Option<Value> {
1233 match p {
1234 "id" => Some(Value::Int(self.id)),
1235 "country" => Some(Value::String(self.country.clone())),
1236 _ => None,
1237 }
1238 }
1239 }
1240
1241 #[test]
1242 fn joined_row_dispatches_dotted_paths_by_topic_prefix() {
1243 let o = OrderRow { id: 7, amount: 100 };
1244 let c = CustomerRow {
1245 id: 7,
1246 country: "DE".into(),
1247 };
1248 let row = JoinedRow::new(alloc::vec![
1249 ("Order".into(), &o as &dyn RowAccess),
1250 ("Customer".into(), &c as &dyn RowAccess),
1251 ]);
1252 assert_eq!(row.get("Order.amount"), Some(Value::Int(100)));
1253 assert_eq!(
1254 row.get("Customer.country"),
1255 Some(Value::String("DE".into()))
1256 );
1257 assert_eq!(row.get("Order.country"), None); }
1259
1260 #[test]
1261 fn joined_row_undotted_falls_back_to_first_match() {
1262 let o = OrderRow { id: 7, amount: 100 };
1263 let c = CustomerRow {
1264 id: 9,
1265 country: "DE".into(),
1266 };
1267 let row = JoinedRow::new(alloc::vec![
1268 ("Order".into(), &o as &dyn RowAccess),
1269 ("Customer".into(), &c as &dyn RowAccess),
1270 ]);
1271 assert_eq!(row.get("country"), Some(Value::String("DE".into())));
1273 assert_eq!(row.get("amount"), Some(Value::Int(100)));
1275 }
1276
1277 #[test]
1278 fn multitopic_evaluate_joined_uses_dotted_paths() {
1279 let p = DomainParticipantFactory::instance()
1280 .create_participant_offline(50, DomainParticipantQos::default());
1281 let mt = p
1282 .create_multitopic::<RawBytes>(
1283 "Sales",
1284 "Sale",
1285 alloc::vec!["Order".into(), "Customer".into()],
1286 "Order.id = Customer.id AND Customer.country = %0",
1287 alloc::vec!["DE".into()],
1288 )
1289 .unwrap();
1290 let o = OrderRow { id: 1, amount: 50 };
1291 let c = CustomerRow {
1292 id: 1,
1293 country: "DE".into(),
1294 };
1295 let row = JoinedRow::new(alloc::vec![
1296 ("Order".into(), &o as &dyn RowAccess),
1297 ("Customer".into(), &c as &dyn RowAccess),
1298 ]);
1299 assert!(mt.evaluate_joined(&row).unwrap());
1300
1301 let c_us = CustomerRow {
1302 id: 1,
1303 country: "US".into(),
1304 };
1305 let row2 = JoinedRow::new(alloc::vec![
1306 ("Order".into(), &o as &dyn RowAccess),
1307 ("Customer".into(), &c_us as &dyn RowAccess),
1308 ]);
1309 assert!(!mt.evaluate_joined(&row2).unwrap());
1310 }
1311
1312 #[test]
1313 fn hash_join_two_combines_matching_rows() {
1314 let p = DomainParticipantFactory::instance()
1315 .create_participant_offline(51, DomainParticipantQos::default());
1316 let mt = p
1317 .create_multitopic::<RawBytes>(
1318 "Sales",
1319 "Sale",
1320 alloc::vec!["Order".into(), "Customer".into()],
1321 "Customer.country = %0",
1322 alloc::vec!["DE".into()],
1323 )
1324 .unwrap();
1325 let orders = alloc::vec![
1326 OrderRow { id: 1, amount: 50 },
1327 OrderRow { id: 2, amount: 70 },
1328 OrderRow { id: 3, amount: 90 },
1329 ];
1330 let customers = alloc::vec![
1331 CustomerRow {
1332 id: 1,
1333 country: "DE".into(),
1334 },
1335 CustomerRow {
1336 id: 2,
1337 country: "US".into(),
1338 },
1339 CustomerRow {
1340 id: 3,
1341 country: "DE".into(),
1342 },
1343 ];
1344 let out: alloc::vec::Vec<(i64, i64, String)> = hash_join_two(
1345 &orders,
1346 "Order",
1347 |o| o.id.to_string(),
1348 &customers,
1349 "Customer",
1350 |c| c.id.to_string(),
1351 |o, c| (o.id, o.amount, c.country.clone()),
1352 |row| mt.evaluate_joined(row),
1353 );
1354 assert_eq!(out.len(), 2);
1355 assert!(out.iter().any(|(i, _, _)| *i == 1));
1357 assert!(out.iter().any(|(i, _, _)| *i == 3));
1358 assert!(out.iter().all(|(_, _, c)| c == "DE"));
1359 }
1360
1361 #[test]
1362 fn hash_join_two_returns_empty_when_no_keys_match() {
1363 let p = DomainParticipantFactory::instance()
1364 .create_participant_offline(52, DomainParticipantQos::default());
1365 let mt = p
1366 .create_multitopic::<RawBytes>(
1367 "Sales",
1368 "Sale",
1369 alloc::vec!["Order".into(), "Customer".into()],
1370 "Order.id = Customer.id",
1371 alloc::vec::Vec::new(),
1372 )
1373 .unwrap();
1374 let orders = alloc::vec![OrderRow { id: 1, amount: 50 }];
1375 let customers = alloc::vec![CustomerRow {
1376 id: 99,
1377 country: "DE".into(),
1378 }];
1379 let out: alloc::vec::Vec<i64> = hash_join_two(
1380 &orders,
1381 "Order",
1382 |o| o.id.to_string(),
1383 &customers,
1384 "Customer",
1385 |c| c.id.to_string(),
1386 |o, _| o.id,
1387 |row| mt.evaluate_joined(row),
1388 );
1389 assert!(out.is_empty());
1390 }
1391
1392 #[test]
1393 fn hash_join_two_emits_cartesian_for_duplicate_keys() {
1394 let p = DomainParticipantFactory::instance()
1395 .create_participant_offline(53, DomainParticipantQos::default());
1396 let mt = p
1397 .create_multitopic::<RawBytes>(
1398 "Sales",
1399 "Sale",
1400 alloc::vec!["Order".into(), "Customer".into()],
1401 "Order.id = Customer.id",
1402 alloc::vec::Vec::new(),
1403 )
1404 .unwrap();
1405 let orders = alloc::vec![
1407 OrderRow { id: 1, amount: 10 },
1408 OrderRow { id: 1, amount: 20 },
1409 ];
1410 let customers = alloc::vec![CustomerRow {
1411 id: 1,
1412 country: "DE".into(),
1413 }];
1414 let out: alloc::vec::Vec<i64> = hash_join_two(
1415 &orders,
1416 "Order",
1417 |o| o.id.to_string(),
1418 &customers,
1419 "Customer",
1420 |c| c.id.to_string(),
1421 |o, _| o.amount,
1422 |row| mt.evaluate_joined(row),
1423 );
1424 assert_eq!(out.len(), 2);
1425 assert!(out.contains(&10));
1426 assert!(out.contains(&20));
1427 }
1428
1429 #[test]
1430 fn hash_join_two_predicate_can_filter_pairs() {
1431 let p = DomainParticipantFactory::instance()
1432 .create_participant_offline(54, DomainParticipantQos::default());
1433 let mt = p
1435 .create_multitopic::<RawBytes>(
1436 "Sales",
1437 "Sale",
1438 alloc::vec!["Order".into(), "Customer".into()],
1439 "Order.amount > 60",
1440 alloc::vec::Vec::new(),
1441 )
1442 .unwrap();
1443 let orders = alloc::vec![
1444 OrderRow { id: 1, amount: 50 },
1445 OrderRow { id: 2, amount: 70 },
1446 ];
1447 let customers = alloc::vec![
1448 CustomerRow {
1449 id: 1,
1450 country: "DE".into(),
1451 },
1452 CustomerRow {
1453 id: 2,
1454 country: "DE".into(),
1455 },
1456 ];
1457 let out: alloc::vec::Vec<i64> = hash_join_two(
1458 &orders,
1459 "Order",
1460 |o| o.id.to_string(),
1461 &customers,
1462 "Customer",
1463 |c| c.id.to_string(),
1464 |o, _| o.amount,
1465 |row| mt.evaluate_joined(row),
1466 );
1467 assert_eq!(out, alloc::vec![70]);
1469 }
1470
1471 #[test]
1472 fn delete_multitopic_rejects_foreign_participant() {
1473 let p1 = DomainParticipantFactory::instance()
1474 .create_participant_offline(0, DomainParticipantQos::default());
1475 let p2 = DomainParticipantFactory::instance()
1476 .create_participant_offline(1, DomainParticipantQos::default());
1477 let mt = p1
1478 .create_multitopic::<RawBytes>(
1479 "MT",
1480 "T",
1481 alloc::vec!["A".into()],
1482 "x > 0",
1483 alloc::vec::Vec::new(),
1484 )
1485 .unwrap();
1486 let res = p2.delete_multitopic(&mt);
1487 assert!(matches!(res, Err(DdsError::BadParameter { .. })));
1488 }
1489
1490 #[test]
1491 fn topic_description_get_participant_returns_owning_participant() {
1492 let p1 = DomainParticipantFactory::instance()
1495 .create_participant_offline(11, DomainParticipantQos::default());
1496 let p2 = DomainParticipantFactory::instance()
1497 .create_participant_offline(12, DomainParticipantQos::default());
1498 let t = p1
1499 .create_topic::<RawBytes>("Owned", TopicQos::default())
1500 .unwrap();
1501 let td: &dyn TopicDescription = &t;
1502 assert_eq!(td.get_participant().domain_id(), 11);
1503 assert_ne!(td.get_participant().domain_id(), p2.domain_id());
1504 }
1505
1506 use alloc::collections::BTreeMap;
1509 use zerodds_sql_filter::{RowAccess, Value};
1510
1511 struct MapRow(BTreeMap<String, Value>);
1512 impl RowAccess for MapRow {
1513 fn get(&self, path: &str) -> Option<Value> {
1514 self.0.get(path).cloned()
1515 }
1516 }
1517
1518 fn row(pairs: &[(&str, Value)]) -> MapRow {
1519 let mut m = BTreeMap::new();
1520 for (k, v) in pairs {
1521 m.insert((*k).into(), v.clone());
1522 }
1523 MapRow(m)
1524 }
1525
1526 fn mk_p(domain: i32) -> DomainParticipant {
1527 DomainParticipantFactory::instance()
1528 .create_participant_offline(domain, DomainParticipantQos::default())
1529 }
1530
1531 #[test]
1532 fn cft_compiles_and_evaluates_filter() {
1533 let p = mk_p(0);
1534 let topic = p
1535 .create_topic::<RawBytes>("Chatter", TopicQos::default())
1536 .unwrap();
1537 let cft = p
1538 .create_contentfilteredtopic("ChatterFilt", &topic, "x > 10", alloc::vec::Vec::new())
1539 .unwrap();
1540 let td: &dyn TopicDescription = &cft;
1542 assert_eq!(td.get_name(), "ChatterFilt");
1543 assert_eq!(td.get_type_name(), RawBytes::TYPE_NAME);
1544
1545 let r_yes = row(&[("x", Value::Int(20))]);
1547 let r_no = row(&[("x", Value::Int(5))]);
1548 assert_eq!(cft.evaluate(&r_yes), Ok(true));
1549 assert_eq!(cft.evaluate(&r_no), Ok(false));
1550 }
1551
1552 #[test]
1553 fn cft_with_params_can_be_updated() {
1554 let p = mk_p(0);
1555 let topic = p
1556 .create_topic::<RawBytes>("T", TopicQos::default())
1557 .unwrap();
1558 let cft = p
1559 .create_contentfilteredtopic("Filt", &topic, "color = %0", alloc::vec!["RED".into()])
1560 .unwrap();
1561 assert_eq!(cft.get_filter_expression(), "color = %0");
1562 assert_eq!(cft.get_filter_parameters(), alloc::vec!["RED".to_string()]);
1563
1564 let r = row(&[("color", Value::String("RED".into()))]);
1565 assert_eq!(cft.evaluate(&r), Ok(true));
1566
1567 cft.set_filter_parameters(alloc::vec!["BLUE".into()])
1569 .unwrap();
1570 assert_eq!(cft.evaluate(&r), Ok(false));
1571 }
1572
1573 #[test]
1574 fn cft_get_related_topic() {
1575 let p = mk_p(0);
1576 let topic = p
1577 .create_topic::<RawBytes>("Base", TopicQos::default())
1578 .unwrap();
1579 let cft = p
1580 .create_contentfilteredtopic("CF", &topic, "x = 1", alloc::vec::Vec::new())
1581 .unwrap();
1582 assert_eq!(cft.get_related_topic().name(), "Base");
1583 }
1584
1585 #[test]
1586 fn cft_invalid_expression_rejected() {
1587 let p = mk_p(0);
1588 let topic = p
1589 .create_topic::<RawBytes>("T", TopicQos::default())
1590 .unwrap();
1591 let err = p
1592 .create_contentfilteredtopic("CF", &topic, "x === bogus", alloc::vec::Vec::new())
1593 .unwrap_err();
1594 assert!(matches!(err, DdsError::BadParameter { .. }));
1595 }
1596
1597 #[test]
1598 fn cft_param_index_out_of_range_rejected() {
1599 let p = mk_p(0);
1600 let topic = p
1601 .create_topic::<RawBytes>("T", TopicQos::default())
1602 .unwrap();
1603 let err = p
1605 .create_contentfilteredtopic("CF", &topic, "x = %0 AND y = %1", alloc::vec!["1".into()])
1606 .unwrap_err();
1607 assert!(matches!(err, DdsError::BadParameter { .. }));
1608 }
1609
1610 #[test]
1611 fn cft_set_filter_parameters_validates_count() {
1612 let p = mk_p(0);
1613 let topic = p
1614 .create_topic::<RawBytes>("T", TopicQos::default())
1615 .unwrap();
1616 let cft = p
1617 .create_contentfilteredtopic(
1618 "CF",
1619 &topic,
1620 "x = %0 AND y = %1",
1621 alloc::vec!["1".into(), "2".into()],
1622 )
1623 .unwrap();
1624 let err = cft
1625 .set_filter_parameters(alloc::vec!["1".into()])
1626 .unwrap_err();
1627 assert!(matches!(err, DdsError::BadParameter { .. }));
1628 }
1629
1630 #[test]
1631 fn cft_filter_with_string_param() {
1632 let p = mk_p(0);
1633 let topic = p
1634 .create_topic::<RawBytes>("T", TopicQos::default())
1635 .unwrap();
1636 let cft = p
1640 .create_contentfilteredtopic("CF", &topic, "name LIKE %0", alloc::vec!["foo%".into()])
1641 .unwrap();
1642 let r = row(&[("name", Value::String("foobar".into()))]);
1643 assert_eq!(cft.evaluate(&r), Ok(true));
1644 }
1645
1646 #[test]
1647 fn cft_filter_with_or_and_combination() {
1648 let p = mk_p(0);
1649 let topic = p
1650 .create_topic::<RawBytes>("T", TopicQos::default())
1651 .unwrap();
1652 let cft = p
1653 .create_contentfilteredtopic(
1654 "CF",
1655 &topic,
1656 "(x > 10 AND x < 100) OR color = 'RED'",
1657 alloc::vec::Vec::new(),
1658 )
1659 .unwrap();
1660 let r1 = row(&[
1662 ("x", Value::Int(50)),
1663 ("color", Value::String("BLUE".into())),
1664 ]);
1665 assert_eq!(cft.evaluate(&r1), Ok(true));
1666 let r2 = row(&[("x", Value::Int(5)), ("color", Value::String("RED".into()))]);
1668 assert_eq!(cft.evaluate(&r2), Ok(true));
1669 let r3 = row(&[
1671 ("x", Value::Int(5)),
1672 ("color", Value::String("BLUE".into())),
1673 ]);
1674 assert_eq!(cft.evaluate(&r3), Ok(false));
1675 }
1676
1677 #[test]
1678 fn cft_unknown_field_returns_bad_parameter() {
1679 let p = mk_p(0);
1680 let topic = p
1681 .create_topic::<RawBytes>("T", TopicQos::default())
1682 .unwrap();
1683 let cft = p
1684 .create_contentfilteredtopic("CF", &topic, "missing = 1", alloc::vec::Vec::new())
1685 .unwrap();
1686 let r = row(&[("x", Value::Int(1))]);
1687 let err = cft.evaluate(&r).unwrap_err();
1688 assert!(matches!(err, DdsError::BadParameter { .. }));
1689 }
1690
1691 #[test]
1692 fn cft_clone_shares_params() {
1693 let p = mk_p(0);
1694 let topic = p
1695 .create_topic::<RawBytes>("T", TopicQos::default())
1696 .unwrap();
1697 let cft = p
1698 .create_contentfilteredtopic("CF", &topic, "color = %0", alloc::vec!["RED".into()])
1699 .unwrap();
1700 let cft2 = cft.clone();
1701 cft.set_filter_parameters(alloc::vec!["BLUE".into()])
1703 .unwrap();
1704 assert_eq!(
1705 cft2.get_filter_parameters(),
1706 alloc::vec!["BLUE".to_string()]
1707 );
1708 }
1709
1710 #[test]
1711 fn param_string_to_value_heuristics() {
1712 assert_eq!(super::param_string_to_value("42"), Value::Int(42));
1713 assert_eq!(super::param_string_to_value("2.5"), Value::Float(2.5));
1714 assert_eq!(super::param_string_to_value("TRUE"), Value::Bool(true));
1715 assert_eq!(super::param_string_to_value("False"), Value::Bool(false));
1716 assert_eq!(
1717 super::param_string_to_value("'hello'"),
1718 Value::String("hello".into())
1719 );
1720 assert_eq!(
1721 super::param_string_to_value("plain"),
1722 Value::String("plain".into())
1723 );
1724 }
1725}