Skip to main content

zerodds_dcps/
topic.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Topic — the typed rendezvous point between DataWriter and DataReader.
4//!
5//! Spec reference:
6//! - OMG DDS 1.4 §2.2.2.3.1 `TopicDescription` (base class for
7//!   `Topic`, `ContentFilteredTopic`, `MultiTopic`),
8//! - §2.2.2.3.2 `Topic` (concrete TopicDescription with type +
9//!   Topic QoS),
10//! - §2.2.2.2.1.12 `lookup_topicdescription` (untyped lookup),
11//! - §2.2.2.2.1.13 `create_contentfilteredtopic`.
12//!
13//! In v1.2 we stay simple: the `Topic<T>` is a handle that carries
14//! name + type_name and a generic `PhantomData<T>` for static type
15//! safety. The topic references its `DomainParticipant` so that
16//! `TopicDescription::get_participant` works faithfully to spec. The
17//! cycle is broken in the topic registry: it holds only
18//! `Arc<TopicInner>`, **not** the cloned `DomainParticipant` handle.
19
20extern 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
38/// `TopicDescription` trait — base interface for anything a
39/// `DataReader` (typed via `T`) can obtain samples from.
40///
41/// Spec reference: OMG DDS 1.4 §2.2.2.3.1 "TopicDescription is the
42/// most abstract description of a topic. It encapsulates the
43/// information that is common to all the kinds of topic that can be
44/// used: `Topic`, `ContentFilteredTopic`, `MultiTopic`."
45///
46/// The trait is **object-safe** — we want to be able to return it as
47/// `&dyn TopicDescription` from `lookup_topicdescription` and
48/// `find_topic`.
49pub trait TopicDescription {
50    /// Type name (e.g. `"std_msgs::msg::String"`).
51    /// Spec §2.2.2.3.1 `get_type_name`.
52    fn get_type_name(&self) -> &str;
53    /// Topic name (e.g. `"ChatterTopic"`).
54    /// Spec §2.2.2.3.1 `get_name`.
55    fn get_name(&self) -> &str;
56    /// Owning participant.
57    /// Spec §2.2.2.3.1 `get_participant`.
58    fn get_participant(&self) -> &DomainParticipant;
59}
60
61/// Typed topic handle.
62#[derive(Debug)]
63pub struct Topic<T: DdsType> {
64    inner: Arc<TopicInner>,
65    /// Owning participant (no cycle: in the topic registry the
66    /// participant holds only the inner, not the handle). `None` for
67    /// builtin topics constructed without a participant (see
68    /// [`Self::new_orphan`]).
69    participant: Option<DomainParticipant>,
70    _t: PhantomData<T>,
71}
72
73/// Inner state of a topic (shared via Arc, since handles are cloned).
74pub(crate) struct TopicInner {
75    /// Topic name (e.g. "ChatterTopic").
76    pub name: String,
77    /// Type name from `T::TYPE_NAME` (eager-copied so the inner has no
78    /// generic type — this simplifies the participant registry data
79    /// structure in .2a).
80    pub type_name: &'static str,
81    /// Topic QoS — mutable via `Entity::set_qos`.
82    #[cfg(feature = "std")]
83    pub qos: std::sync::Mutex<TopicQos>,
84    #[cfg(not(feature = "std"))]
85    pub qos: TopicQos,
86    /// Entity lifecycle (DCPS §2.2.2.1).
87    pub entity_state: Arc<crate::entity::EntityState>,
88    /// Optional `TopicListener` + StatusMask. Spec §2.2.2.3.2.x
89    /// set_listener / bubble-up §2.2.4.2.3.
90    #[cfg(feature = "std")]
91    pub listener: std::sync::Mutex<Option<(ArcTopicListener, StatusMask)>>,
92    /// Counter for InconsistentTopic detections (spec §2.2.4.2.5).
93    /// Incremented in `record_inconsistent_topic`, read out in
94    /// `inconsistent_topic_status` with delta detection.
95    #[cfg(feature = "std")]
96    pub inconsistent_topic_count: std::sync::atomic::AtomicI64,
97    /// Last seen value for the delta trigger.
98    #[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    /// Creates a new topic handle. Normally called by
118    /// `DomainParticipant::create_topic<T>(name, qos)`.
119    #[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    /// Creates a topic handle **without** a participant — for builtin
143    /// topics (DCPSParticipant/Topic/Publication/Subscription) that are
144    /// created in the BuiltinSubscriber constructor before the
145    /// DomainParticipant is ready (chicken-and-egg problem).
146    /// `get_participant()` panics on an orphan topic — builtin readers
147    /// avoid it.
148    #[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    /// Topic name.
172    #[must_use]
173    pub fn name(&self) -> &str {
174        &self.inner.name
175    }
176
177    /// Type name (from `T::TYPE_NAME`).
178    #[must_use]
179    pub fn type_name(&self) -> &'static str {
180        self.inner.type_name
181    }
182
183    /// Sets the `TopicListener` + StatusMask. `None` clears the slot.
184    /// Spec §2.2.2.3.2.x set_listener.
185    #[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    /// Current listener clone, if any.
194    #[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    /// Snapshot of the bubble-up chain (Topic → Participant) — for
205    /// hot-path listener dispatch (e.g. on_inconsistent_topic).
206    ///
207    /// The hot path currently has no inconsistent-topic trigger (that
208    /// requires cross-vendor type-mismatch detection in WP 2.x); the
209    /// snapshot API already exists so it can be wired in later without an
210    /// API change.
211    #[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    /// Records an InconsistentTopic detection (e.g. when a remote topic
228    /// with the same name but a different type name is discovered, or
229    /// when `create_topic` is called with the same name but a different
230    /// type). Spec §2.2.4.2.5.
231    #[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    /// Current `InconsistentTopicStatus` + trigger via bubble-up on a
239    /// delta versus the last call.
240    #[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        // Trigger the listener only on an actual delta — otherwise the
257        // listener would fire on every status read (idempotent).
258        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    /// Topic QoS (cloned). The inner mutex allows set_qos.
271    #[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    /// Internal access to the shared state (for Publisher/Subscriber).
284    #[allow(dead_code)]
285    pub(crate) fn inner(&self) -> Arc<TopicInner> {
286        Arc::clone(&self.inner)
287    }
288
289    /// Internal constructor for shared-handle rehydration in the
290    /// participant topic registry (`create_topic` second round with the
291    /// same name + type).
292    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    /// Returns the owning participant. Panics on builtin (orphan) topics
319    /// constructed via [`Topic::new_orphan`] — builtin readers do not
320    /// call `get_participant()`.
321    #[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// ============================================================================
333// Entity-Trait (DCPS §2.2.2.1) —
334// ============================================================================
335
336#[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    /// TopicQos. Spec §2.2.3: all topic policies (TOPIC_DATA, DURABILITY,
345    /// RELIABILITY, HISTORY, RESOURCE_LIMITS, ...) are **immutable**
346    /// after `enable()` — only TOPIC_DATA is Changeable=YES.
347    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                // Spec §2.2.3: DURABILITY and RELIABILITY are
352                // Changeable=NO post-enable.
353                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/// Untyped `TopicDescription` handle returned from
376/// `DomainParticipant::lookup_topicdescription` /
377/// `DomainParticipant::find_topic`.
378///
379/// The spec API returns the abstract base class because the caller
380/// generally does not yet know the type (e.g. after discovery). We pack
381/// name + type name + participant into a cloneable handle; the caller
382/// can read `get_type_name()` and then obtain a typed handle via
383/// `create_topic::<T>`.
384#[derive(Debug, Clone)]
385pub struct TopicDescriptionHandle {
386    name: String,
387    type_name: String,
388    participant: DomainParticipant,
389}
390
391impl TopicDescriptionHandle {
392    /// Constructs a handle (internal; mainly for tests + the lookup API).
393    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/// `ContentFilteredTopic<T>` — a sub-topic of a `Topic<T>` with a filter
415/// expression. Spec reference: OMG DDS 1.4 §2.2.2.3.3.
416///
417/// "ContentFilteredTopic is a specialization of TopicDescription that
418/// allows for content-based subscriptions. The selection of the
419/// content is done using a filter_expression with parameters
420/// (filter_parameters)."
421///
422/// The filter expression is a SQL subset (DDS-DCPS Annex B, BNF):
423/// `field op literal-or-param`, `AND`/`OR`/`NOT` composition, parens,
424/// `LIKE`. The concrete parser lives in `zerodds-sql-filter`.
425///
426/// **Lifecycle:** the CFT holds a clone of the related topic (shared
427/// `Arc<TopicInner>`) and is itself cloneable. Filter parameters are
428/// mutable at runtime (spec §2.2.2.3.3.7).
429#[derive(Debug)]
430pub struct ContentFilteredTopic<T: DdsType> {
431    name: String,
432    related_topic: Topic<T>,
433    /// Raw form of the expression (read-only — the spec is explicit that
434    /// only the `filter_parameters` are mutable, not the expression
435    /// itself).
436    filter_expression: String,
437    /// Pre-parsed AST. We parse once in the constructor so that
438    /// `evaluate` is on the hot path.
439    parsed: Arc<Expr>,
440    /// Filter parameters (`%0`, `%1`, ...). Passed as strings (spec
441    /// §2.2.2.3.3 — the parameters are strings substituted into the
442    /// expression). We keep them as strings + parsed `Value` in parallel
443    /// so `set_filter_parameters` does the String→Value conversion only
444    /// once.
445    #[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    /// Constructor (called internally by
461    /// `DomainParticipant::create_contentfilteredtopic`).
462    ///
463    /// # Errors
464    /// `BadParameter` if the expression does not parse or a referenced
465    /// `%N` index does not exist in the `filter_parameters` vec.
466    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        // Check parameter indices.
478        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    /// Spec §2.2.2.3.3.4 `get_filter_expression`.
509    #[must_use]
510    pub fn get_filter_expression(&self) -> &str {
511        &self.filter_expression
512    }
513
514    /// Spec §2.2.2.3.3.5 `get_filter_parameters`.
515    #[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    /// Spec §2.2.2.3.3.6 `set_filter_parameters`. Swaps the parameters
531    /// (equal count is not enforced — the spec says "should match the
532    /// number of `%n` tokens" as a recommendation; we verify it
533    /// strictly).
534    ///
535    /// # Errors
536    /// `BadParameter` if a `%N` index is outside the new vec.
537    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            // Not kept mutable in the no_std path — parameters are set in
564            // the constructor; this path is not active in the v1.2 MVP
565            // anyway (dcps requires std).
566            let _ = fp;
567            return Err(DdsError::PreconditionNotMet {
568                reason: "set_filter_parameters needs std feature",
569            });
570        }
571        Ok(())
572    }
573
574    /// Spec §2.2.2.3.3.3 `get_related_topic`.
575    #[must_use]
576    pub fn get_related_topic(&self) -> &Topic<T> {
577        &self.related_topic
578    }
579
580    /// Evaluates the filter against a decoded sample. Returns
581    /// `Ok(true)` if the sample should pass, `Ok(false)` if it is
582    /// filtered out, `Err` if the expression does not fit the row schema
583    /// (caller's decision: filter denies or error).
584    ///
585    /// # Errors
586    /// - `PreconditionNotMet` if the filter-parameter lock is poisoned.
587    /// - `BadParameter` if a field in the expression does not exist in
588    ///   the row or a type mismatch occurs.
589    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, &params)
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    /// The type name comes from the related topic — a CFT shares the
633    /// schema with the underlying topic.
634    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
645/// `MultiTopic<T>` — spec §2.2.2.3.4 (DDS 1.4 optional feature).
646///
647/// A MultiTopic combines several underlying topics into a single
648/// TopicDescription via a `subscription_expression` (SQL subset). The
649/// resulting type `T` is user-defined; the underlying topics may have
650/// different types.
651///
652/// **Cross-topic sample routing:** the join operator is live via
653/// [`hash_join_two`] and [`Self::evaluate_joined`]. The
654/// `subscription_expression` references fields as
655/// `<topic_name>.<field_path>` (dotted), and [`JoinedRow`] dispatches
656/// the lookups to the matching topic sources.
657pub struct MultiTopic<T: DdsType> {
658    name: String,
659    type_name: String,
660    /// List of related topics as untyped handles.
661    related_topic_names: Vec<String>,
662    subscription_expression: String,
663    /// Pre-parsed AST of the subscription expression.
664    parsed: Arc<Expr>,
665    /// Mutable filter parameters (spec §2.2.2.3.4.7
666    /// `set_expression_parameters`).
667    #[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    /// Constructor (called internally by
688    /// `DomainParticipant::create_multitopic`).
689    ///
690    /// # Errors
691    /// `BadParameter` on an empty name or empty expression, or if the
692    /// expression does not parse, or if a referenced `%N` parameter is
693    /// outside the `expression_parameters` vec, or if
694    /// `related_topic_names` is empty.
695    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    /// Spec §2.2.2.3.4.4 `get_subscription_expression`.
745    #[must_use]
746    pub fn get_subscription_expression(&self) -> &str {
747        &self.subscription_expression
748    }
749
750    /// Spec §2.2.2.3.4.5 `get_expression_parameters`.
751    #[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    /// Spec §2.2.2.3.4.6 `set_expression_parameters`.
767    ///
768    /// # Errors
769    /// `BadParameter` if a referenced `%N` parameter is outside the new
770    /// vec; `PreconditionNotMet` on lock poisoning.
771    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    /// Spec §2.2.2.3.4.3 `get_related_topic` (the PSM returns a sequence
806    /// — we return the names).
807    #[must_use]
808    pub fn get_related_topic_names(&self) -> &[String] {
809        &self.related_topic_names
810    }
811
812    /// Evaluates the `subscription_expression` against a [`JoinedRow`]
813    /// (cross-topic predicate). Spec §2.2.2.3.4.
814    ///
815    /// # Errors
816    /// `PreconditionNotMet` on lock poisoning or SQL eval error.
817    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
838/// `JoinedRow` — a `RowAccess` adapter over several named topic
839/// sources. Dotted paths `topic.field.sub` are split at the first `.`:
840/// the prefix matches the topic name, the rest is forwarded to its
841/// [`RowAccess::get`].
842///
843/// If no topic source knows the prefix, the whole path is forwarded to
844/// all sources (fallback for undotted field references).
845pub struct JoinedRow<'a> {
846    sources: Vec<(String, &'a dyn RowAccess)>,
847}
848
849impl<'a> JoinedRow<'a> {
850    /// Constructor.
851    #[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        // No prefix match -> query all sources, first answer wins.
867        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/// Hash-join helper for two typed streams. Iterates the left list,
877/// builds a HashMap `key -> [&L]` (build phase), then iterates the right
878/// list (probe phase) and produces a result via `combine` for each
879/// matching `(L, R)` pair. Optionally, an additional predicate via
880/// `predicate` (e.g. `MultiTopic::evaluate_joined`) is checked on each
881/// pair.
882///
883/// Spec: §2.2.2.3.4 (MultiTopic) — the hash join is the idiomatic
884/// O(n+m) implementation of the `subscription_expression`.
885#[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
957/// Heuristic conversion of a `filter_parameter` string into a `Value`:
958/// first Bool, then Int, then Float, otherwise String. We strip flanking
959/// `'...'` quotes because the spec examples often provide the strings
960/// that way.
961fn param_string_to_value(s: &str) -> Value {
962    let trimmed = s.trim();
963    // Bool.
964    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    // Int.
971    if let Ok(i) = trimmed.parse::<i64>() {
972        return Value::Int(i);
973    }
974    // Float.
975    if let Ok(f) = trimmed.parse::<f64>() {
976        return Value::Float(f);
977    }
978    // String (with optional ''-strip).
979    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        // Trait methods work.
1001        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        // Verify object safety: we can hold `&dyn TopicDescription` and
1021        // dispatch non-statically. Collect from several concrete
1022        // implementations.
1023        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        // §2.2.2.2.1.4 create_topic: empty topic name → BadParameter.
1038        // This is the only place where TopicDescription consumers
1039        // encounter an invalid TopicDescription construction attempt.
1040        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    // -------- MultiTopic (§2.2.2.3.4) --------
1050
1051    #[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        // Expression references %1, but params has only 1 entry.
1159        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        // Set with too few params → BadParameter.
1183        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        // An update on one clone is reflected in the other
1202        // (shared Arc<RwLock<FilterParams>>).
1203        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    // -------- MultiTopic Hash-Join (§2.2.2.3.4-r-cross-topic-join) --------
1212
1213    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); // prefix matched -> no field
1258    }
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        // "country" exists only in CustomerRow → found via fallback.
1272        assert_eq!(row.get("country"), Some(Value::String("DE".into())));
1273        // "amount" exists only in OrderRow.
1274        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        // Expect ids 1 and 3 (DE), not 2 (US).
1356        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        // Two orders with id=1, one customer with id=1
1406        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        // Predicate requires amount > 60.
1434        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        // Only amount=70 may pass.
1468        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        // get_participant() returns exactly the participant on which
1493        // create_topic was called — no other.
1494        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    // -------- ContentFilteredTopic --------
1507
1508    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        // CFT trait works.
1541        let td: &dyn TopicDescription = &cft;
1542        assert_eq!(td.get_name(), "ChatterFilt");
1543        assert_eq!(td.get_type_name(), RawBytes::TYPE_NAME);
1544
1545        // Filter: x > 10
1546        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        // Update the parameter.
1568        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        // Expression uses %0 + %1, but we provide only one.
1604        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        // Strings: the caller provides them without '' quotes (spec
1637        // examples); the value conversion interprets them as a string
1638        // default.
1639        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        // x in range, color irrelevant.
1661        let r1 = row(&[
1662            ("x", Value::Int(50)),
1663            ("color", Value::String("BLUE".into())),
1664        ]);
1665        assert_eq!(cft.evaluate(&r1), Ok(true));
1666        // x out, color RED.
1667        let r2 = row(&[("x", Value::Int(5)), ("color", Value::String("RED".into()))]);
1668        assert_eq!(cft.evaluate(&r2), Ok(true));
1669        // both fail.
1670        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        // Update via cft → visible in cft2 (Arc<RwLock> shared).
1702        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}