Skip to main content

laminar_connectors/mongodb/
lookup.rs

1//! `MongoDB` on-demand lookup source for cache-miss fallback.
2//!
3//! Implements `LookupSource` via a multi-get on the indexed key
4//! (`find({ pk: { $in: [keys] } })`), so all missed keys of a probe fold into
5//! one round trip. `MongoDB` is schemaless, so the source projects each
6//! returned document into the table's **declared** Arrow schema (from
7//! `CREATE LOOKUP TABLE`); [`KeyAligner`] handles key decode and realignment.
8//!
9//! v1 limits: single-column key; declared column types Int32/Int64/Float64/
10//! Boolean/Utf8/LargeUtf8.
11
12#[cfg(feature = "mongodb-cdc")]
13use std::sync::Arc;
14#[cfg(feature = "mongodb-cdc")]
15use std::time::Duration;
16
17#[cfg(feature = "mongodb-cdc")]
18use arrow_array::{Array, RecordBatch};
19#[cfg(feature = "mongodb-cdc")]
20use arrow_row::SortField;
21#[cfg(feature = "mongodb-cdc")]
22use arrow_schema::{DataType, SchemaRef};
23#[cfg(feature = "mongodb-cdc")]
24use mongodb::bson::{doc, Bson, Document, RawDocumentBuf};
25#[cfg(feature = "mongodb-cdc")]
26use mongodb::{Client, IndexModel};
27
28#[cfg(feature = "mongodb-cdc")]
29use laminar_core::lookup::predicate::Predicate;
30#[cfg(feature = "mongodb-cdc")]
31use laminar_core::lookup::source::{
32    projection_names, ColumnId, LookupError, LookupSource, LookupSourceCapabilities,
33};
34#[cfg(feature = "mongodb-cdc")]
35use laminar_core::lookup::KeyAligner;
36
37#[cfg(feature = "mongodb-cdc")]
38const LOOKUP_SERVER_TIMEOUT: Duration = Duration::from_secs(25);
39#[cfg(feature = "mongodb-cdc")]
40const MAX_LOOKUP_KEYS: usize = 4_096;
41#[cfg(feature = "mongodb-cdc")]
42const MAX_LOOKUP_KEY_BYTES: usize = 4 * 1024 * 1024;
43#[cfg(feature = "mongodb-cdc")]
44const MAX_LOOKUP_COMMAND_BYTES: usize = 8 * 1024 * 1024;
45#[cfg(feature = "mongodb-cdc")]
46const MAX_LOOKUP_RESULT_BYTES: usize = 64 * 1024 * 1024;
47
48#[cfg(feature = "mongodb-cdc")]
49fn sharded_lookup_not_admitted() -> LookupError {
50    LookupError::Internal(
51        "mongodb sharded lookup through mongos/load-balanced topology is not admitted until \
52         shard-key routing and cluster-global uniqueness certification are implemented"
53            .into(),
54    )
55}
56
57#[cfg(feature = "mongodb-cdc")]
58fn validate_lookup_server_topology(hello: &Document) -> Result<(), LookupError> {
59    if matches!(hello.get_str("msg"), Ok("isdbgrid")) {
60        return Err(sharded_lookup_not_admitted());
61    }
62    Ok(())
63}
64
65/// Configuration for [`MongoLookupSource`].
66#[cfg(feature = "mongodb-cdc")]
67#[derive(Debug, Clone)]
68pub struct MongoLookupSourceConfig {
69    /// `MongoDB` connection URI.
70    pub connection_uri: String,
71    /// Database name.
72    pub database: String,
73    /// Collection name.
74    pub collection: String,
75    /// Primary key field (v1: exactly one).
76    pub primary_key_columns: Vec<String>,
77    /// Declared Arrow schema (the projection target).
78    pub schema: SchemaRef,
79}
80
81/// `MongoDB` lookup source for on-demand/partial cache mode.
82#[cfg(feature = "mongodb-cdc")]
83pub struct MongoLookupSource {
84    client: Client,
85    database: String,
86    collection: String,
87    pk_field: String,
88    schema: SchemaRef,
89    aligner: KeyAligner,
90}
91
92#[cfg(feature = "mongodb-cdc")]
93fn validate_lookup_keys(keys: &[&[u8]]) -> Result<(), LookupError> {
94    if keys.len() > MAX_LOOKUP_KEYS {
95        return Err(LookupError::Query(format!(
96            "mongodb lookup received {} keys, exceeding the fixed {MAX_LOOKUP_KEYS}-key batch limit",
97            keys.len()
98        )));
99    }
100    let bytes = keys.iter().try_fold(0_usize, |total, key| {
101        total
102            .checked_add(key.len())
103            .ok_or_else(|| LookupError::Query("mongodb lookup key byte count overflow".into()))
104    })?;
105    if bytes > MAX_LOOKUP_KEY_BYTES {
106        return Err(LookupError::Query(format!(
107            "mongodb lookup received {bytes} key bytes, exceeding the fixed {MAX_LOOKUP_KEY_BYTES}-byte batch limit"
108        )));
109    }
110    Ok(())
111}
112
113#[cfg(feature = "mongodb-cdc")]
114fn validate_lookup_command(
115    database: &str,
116    collection: &str,
117    filter: &Document,
118    projection: Option<&Document>,
119    limit: i64,
120) -> Result<(), LookupError> {
121    #[derive(serde::Serialize)]
122    #[serde(rename_all = "camelCase")]
123    struct FindCommand<'a> {
124        find: &'a str,
125        #[serde(rename = "$db")]
126        database: &'a str,
127        filter: &'a Document,
128        #[serde(skip_serializing_if = "Option::is_none")]
129        projection: Option<&'a Document>,
130        limit: i64,
131        #[serde(rename = "maxTimeMS")]
132        max_time_ms: u64,
133    }
134
135    let max_time_ms = u64::try_from(LOOKUP_SERVER_TIMEOUT.as_millis()).map_err(|_| {
136        LookupError::Internal("mongodb lookup server timeout exceeds u64 milliseconds".into())
137    })?;
138    let command = FindCommand {
139        find: collection,
140        database,
141        filter,
142        projection,
143        limit,
144        max_time_ms,
145    };
146    let bytes = mongodb::bson::to_vec(&command)
147        .map_err(|error| LookupError::Query(format!("serialize mongodb find command: {error}")))?;
148    if bytes.len() > MAX_LOOKUP_COMMAND_BYTES {
149        return Err(LookupError::Query(format!(
150            "mongodb lookup command is {} bytes, exceeding the fixed {MAX_LOOKUP_COMMAND_BYTES}-byte limit",
151            bytes.len()
152        )));
153    }
154    Ok(())
155}
156
157#[cfg(feature = "mongodb-cdc")]
158fn has_usable_unique_lookup_index(index: &IndexModel, pk_field: &str) -> bool {
159    if index.keys.len() != 1 {
160        return false;
161    }
162    let Some((field, direction)) = index.keys.iter().next() else {
163        return false;
164    };
165    let is_ascending_or_descending = match direction {
166        Bson::Int32(value) => *value == 1 || *value == -1,
167        Bson::Int64(value) => *value == 1 || *value == -1,
168        Bson::Double(value) => value.abs().total_cmp(&1.0).is_eq(),
169        _ => false,
170    };
171    if field != pk_field || !is_ascending_or_descending {
172        return false;
173    }
174
175    let Some(options) = index.options.as_ref() else {
176        return false;
177    };
178    if options.hidden == Some(true)
179        || options.partial_filter_expression.is_some()
180        || options.collation.is_some()
181    {
182        return false;
183    }
184
185    options.unique == Some(true) || (pk_field == "_id" && options.name.as_deref() == Some("_id_"))
186}
187
188#[cfg(feature = "mongodb-cdc")]
189async fn verify_unique_lookup_index(
190    client: Client,
191    database: String,
192    collection: String,
193    pk_field: String,
194) -> Result<(), LookupError> {
195    use tokio_stream::StreamExt;
196
197    let hello = client
198        .database("admin")
199        .run_command(doc! { "hello": 1 })
200        .await
201        .map_err(|error| {
202            LookupError::Connection(format!("inspect mongodb lookup topology: {error}"))
203        })?;
204    validate_lookup_server_topology(&hello)?;
205
206    let collection_handle = client
207        .database(&database)
208        .collection::<Document>(&collection);
209    let mut indexes = collection_handle
210        .list_indexes()
211        .max_time(LOOKUP_SERVER_TIMEOUT)
212        .await
213        .map_err(|error| {
214            LookupError::Connection(format!(
215                "inspect mongodb lookup indexes for {database}.{collection}: {error}"
216            ))
217        })?;
218    while let Some(next) = indexes.next().await {
219        let index = next.map_err(|error| {
220            LookupError::Connection(format!(
221                "read mongodb lookup indexes for {database}.{collection}: {error}"
222            ))
223        })?;
224        if has_usable_unique_lookup_index(&index, &pk_field) {
225            return Ok(());
226        }
227    }
228
229    Err(LookupError::Internal(format!(
230        "mongodb lookup requires a visible, non-partial, simple-collation unique single-field ascending/descending index on '{pk_field}' in {database}.{collection}"
231    )))
232}
233
234#[cfg(feature = "mongodb-cdc")]
235impl MongoLookupSource {
236    /// Connects to `MongoDB` and validates the declared key column and index.
237    ///
238    /// # Errors
239    ///
240    /// Returns `LookupError` if the client cannot be built, the key declaration
241    /// is invalid, or the collection has no usable unique index on that key.
242    pub async fn open(config: MongoLookupSourceConfig) -> Result<Self, LookupError> {
243        if config.connection_uri.trim().is_empty()
244            || config.database.trim().is_empty()
245            || config.collection.trim().is_empty()
246        {
247            return Err(LookupError::Internal(
248                "mongodb lookup requires non-empty connection_uri, database, and collection".into(),
249            ));
250        }
251        if config.collection == "*" {
252            return Err(LookupError::Internal(
253                "mongodb lookup does not support collection='*'".into(),
254            ));
255        }
256        if config.primary_key_columns.len() != 1 {
257            return Err(LookupError::Internal(format!(
258                "mongodb lookup requires exactly one primary key column, got {}",
259                config.primary_key_columns.len()
260            )));
261        }
262        let pk_field = config.primary_key_columns[0].clone();
263
264        let pk_idx = config.schema.index_of(&pk_field).map_err(|_| {
265            LookupError::Internal(format!("pk column not in declared schema: {pk_field}"))
266        })?;
267
268        for field in config.schema.fields() {
269            match field.data_type() {
270                DataType::Int32
271                | DataType::Int64
272                | DataType::Float64
273                | DataType::Boolean
274                | DataType::Utf8
275                | DataType::LargeUtf8 => {}
276                dt => {
277                    return Err(LookupError::Internal(format!(
278                        "unsupported field data type in schema for mongodb lookup: {dt}"
279                    )));
280                }
281            }
282        }
283
284        let pk_sort_fields = vec![SortField::new(
285            config.schema.field(pk_idx).data_type().clone(),
286        )];
287        let aligner = KeyAligner::new(pk_sort_fields, config.primary_key_columns)?;
288
289        let connection_uri = config.connection_uri.clone();
290        let mut client_options =
291            tokio::spawn(
292                async move { mongodb::options::ClientOptions::parse(&connection_uri).await },
293            )
294            .await
295            .map_err(|error| {
296                LookupError::Internal(format!("mongodb client-options task failed: {error}"))
297            })?
298            .map_err(|e| LookupError::Connection(format!("mongodb client options: {e}")))?;
299        if client_options.load_balanced == Some(true) {
300            return Err(sharded_lookup_not_admitted());
301        }
302        super::sink::harden_mongodb_tls(&mut client_options)
303            .map_err(|e| LookupError::Connection(e.to_string()))?;
304        client_options.connect_timeout = Some(
305            client_options
306                .connect_timeout
307                .unwrap_or(LOOKUP_SERVER_TIMEOUT)
308                .min(LOOKUP_SERVER_TIMEOUT),
309        );
310        client_options.server_selection_timeout = Some(
311            client_options
312                .server_selection_timeout
313                .unwrap_or(LOOKUP_SERVER_TIMEOUT)
314                .min(LOOKUP_SERVER_TIMEOUT),
315        );
316        let client = Client::with_options(client_options)
317            .map_err(|e| LookupError::Connection(format!("mongodb client: {e}")))?;
318
319        let index_client = client.clone();
320        let index_database = config.database.clone();
321        let index_collection = config.collection.clone();
322        let index_pk_field = pk_field.clone();
323        tokio::spawn(async move {
324            verify_unique_lookup_index(
325                index_client,
326                index_database,
327                index_collection,
328                index_pk_field,
329            )
330            .await
331        })
332        .await
333        .map_err(|error| {
334            LookupError::Internal(format!("mongodb index-check task failed: {error}"))
335        })??;
336
337        Ok(Self {
338            client,
339            database: config.database,
340            collection: config.collection,
341            pk_field,
342            schema: config.schema,
343            aligner,
344        })
345    }
346
347    /// Convert one decoded PK cell into a BSON value for the `$in` array, or
348    /// `None` when NULL (a NULL key is dropped — it can never match).
349    fn cell_to_bson(array: &dyn Array, row: usize) -> Result<Option<Bson>, LookupError> {
350        use arrow_array::{
351            BooleanArray, Float64Array, Int32Array, Int64Array, LargeStringArray, StringArray,
352        };
353
354        fn downcast<T: 'static>(array: &dyn Array) -> Result<&T, LookupError> {
355            array
356                .as_any()
357                .downcast_ref::<T>()
358                .ok_or_else(|| LookupError::Internal("pk column downcast failed".into()))
359        }
360
361        if array.is_null(row) {
362            return Ok(None);
363        }
364
365        let bson = match array.data_type() {
366            DataType::Int32 => Bson::Int32(downcast::<Int32Array>(array)?.value(row)),
367            DataType::Int64 => Bson::Int64(downcast::<Int64Array>(array)?.value(row)),
368            DataType::Float64 => Bson::Double(downcast::<Float64Array>(array)?.value(row)),
369            DataType::Boolean => Bson::Boolean(downcast::<BooleanArray>(array)?.value(row)),
370            DataType::Utf8 => Bson::String(downcast::<StringArray>(array)?.value(row).to_string()),
371            DataType::LargeUtf8 => {
372                Bson::String(downcast::<LargeStringArray>(array)?.value(row).to_string())
373            }
374            dt => {
375                return Err(LookupError::Internal(format!(
376                    "unsupported PK data type for mongodb lookup: {dt}"
377                )));
378            }
379        };
380        Ok(Some(bson))
381    }
382
383    /// Project fetched documents into one Arrow `RecordBatch` matching
384    /// `schema` (the full declared schema, or its projection). Missing or
385    /// incompatible fields become NULL.
386    fn docs_to_batch(schema: &SchemaRef, docs: &[Document]) -> Result<RecordBatch, LookupError> {
387        use arrow_array::builder::{
388            BooleanBuilder, Float64Builder, Int32Builder, Int64Builder, LargeStringBuilder,
389            StringBuilder,
390        };
391
392        let mut columns: Vec<Arc<dyn Array>> = Vec::with_capacity(schema.fields().len());
393        for field in schema.fields() {
394            let name = field.name().as_str();
395            let array: Arc<dyn Array> = match field.data_type() {
396                DataType::Int32 => {
397                    let mut b = Int32Builder::with_capacity(docs.len());
398                    for d in docs {
399                        b.append_option(
400                            bson_as_i64(d.get(name)).and_then(|v| i32::try_from(v).ok()),
401                        );
402                    }
403                    Arc::new(b.finish())
404                }
405                DataType::Int64 => {
406                    let mut b = Int64Builder::with_capacity(docs.len());
407                    for d in docs {
408                        b.append_option(bson_as_i64(d.get(name)));
409                    }
410                    Arc::new(b.finish())
411                }
412                DataType::Float64 => {
413                    let mut b = Float64Builder::with_capacity(docs.len());
414                    for d in docs {
415                        b.append_option(bson_as_f64(d.get(name)));
416                    }
417                    Arc::new(b.finish())
418                }
419                DataType::Boolean => {
420                    let mut b = BooleanBuilder::with_capacity(docs.len());
421                    for d in docs {
422                        b.append_option(d.get(name).and_then(Bson::as_bool));
423                    }
424                    Arc::new(b.finish())
425                }
426                DataType::LargeUtf8 => {
427                    let mut b = LargeStringBuilder::with_capacity(docs.len(), docs.len() * 16);
428                    for d in docs {
429                        match d.get(name) {
430                            None | Some(Bson::Null) => b.append_null(),
431                            Some(v) => b.append_value(bson_to_string(v)),
432                        }
433                    }
434                    Arc::new(b.finish())
435                }
436                DataType::Utf8 => {
437                    let mut b = StringBuilder::with_capacity(docs.len(), docs.len() * 16);
438                    for d in docs {
439                        match d.get(name) {
440                            None | Some(Bson::Null) => b.append_null(),
441                            Some(v) => b.append_value(bson_to_string(v)),
442                        }
443                    }
444                    Arc::new(b.finish())
445                }
446                _ => {
447                    return Err(LookupError::Internal(format!(
448                        "unsupported field data type: {:?}",
449                        field.data_type()
450                    )));
451                }
452            };
453            columns.push(array);
454        }
455        RecordBatch::try_new(Arc::clone(schema), columns)
456            .map_err(|e| LookupError::Internal(format!("arrow batch construction: {e}")))
457    }
458}
459
460#[cfg(feature = "mongodb-cdc")]
461impl LookupSource for MongoLookupSource {
462    async fn query(
463        &self,
464        keys: &[&[u8]],
465        _predicates: &[Predicate],
466        projection: &[ColumnId],
467    ) -> Result<Vec<Option<RecordBatch>>, LookupError> {
468        use tokio_stream::StreamExt;
469
470        if keys.is_empty() {
471            return Ok(Vec::new());
472        }
473        validate_lookup_keys(keys)?;
474
475        let pk_arrays = self.aligner.decode_keys(keys)?;
476        let pk_array = pk_arrays[0].as_ref();
477        let mut in_values: Vec<Bson> = Vec::with_capacity(keys.len());
478        for row in 0..pk_array.len() {
479            if let Some(b) = Self::cell_to_bson(pk_array, row)? {
480                in_values.push(b);
481            }
482        }
483        if in_values.is_empty() {
484            return Ok(vec![None; keys.len()]);
485        }
486        let result_limit = i64::try_from(in_values.len()).map_err(|_| {
487            LookupError::Internal("mongodb lookup key count exceeds i64::MAX".into())
488        })?;
489
490        let filter = doc! { &self.pk_field: doc! { "$in": in_values } };
491
492        // Projection pushdown: ask Mongo for only the requested fields (always
493        // incl. the key), and build the batch in the matching projected schema.
494        let mut project_needed = false;
495        let (out_schema, projection_document) =
496            if projection.is_empty() {
497                (Arc::clone(&self.schema), None)
498            } else {
499                let mut names = projection_names(&self.schema, projection)?;
500                let mut idx: Vec<usize> = projection.iter().map(|&c| c as usize).collect();
501                if !names.contains(&self.pk_field) {
502                    names.push(self.pk_field.clone());
503                    let pk_idx = self
504                        .schema
505                        .index_of(&self.pk_field)
506                        .map_err(|e| LookupError::Internal(format!("pk column index: {e}")))?;
507                    idx.push(pk_idx);
508                    project_needed = true;
509                }
510
511                let mut proj_doc = Document::new();
512                for name in &names {
513                    proj_doc.insert(name.clone(), 1);
514                }
515                (
516                    Arc::new(self.schema.project(&idx).map_err(|e| {
517                        LookupError::Internal(format!("project mongodb schema: {e}"))
518                    })?),
519                    Some(proj_doc),
520                )
521            };
522
523        validate_lookup_command(
524            &self.database,
525            &self.collection,
526            &filter,
527            projection_document.as_ref(),
528            result_limit,
529        )?;
530
531        // The runtime may time out and drop query(). Keep every cancellation-unsafe MongoDB
532        // driver future in an owned task so it is still polled to completion in that case.
533        let client = self.client.clone();
534        let database = self.database.clone();
535        let collection = self.collection.clone();
536        let task_schema = Arc::clone(&out_schema);
537        let batches = tokio::spawn(async move {
538            let collection = client
539                .database(&database)
540                .collection::<RawDocumentBuf>(&collection);
541            let mut find = collection
542                .find(filter)
543                .limit(result_limit)
544                .max_time(LOOKUP_SERVER_TIMEOUT);
545            if let Some(projection) = projection_document {
546                find = find.projection(projection);
547            }
548            let mut cursor = find
549                .await
550                .map_err(|e| LookupError::Query(format!("mongodb find: {e}")))?;
551            let mut docs: Vec<Document> = Vec::new();
552            let mut result_bytes = 0_usize;
553            while let Some(next) = cursor.next().await {
554                let raw = next.map_err(|e| LookupError::Query(format!("mongodb cursor: {e}")))?;
555                result_bytes = result_bytes
556                    .checked_add(raw.as_bytes().len())
557                    .ok_or_else(|| {
558                        LookupError::Query("mongodb lookup result byte count overflow".into())
559                    })?;
560                if result_bytes > MAX_LOOKUP_RESULT_BYTES {
561                    return Err(LookupError::Query(format!(
562                        "mongodb lookup result is at least {result_bytes} bytes, exceeding the fixed {MAX_LOOKUP_RESULT_BYTES}-byte limit"
563                    )));
564                }
565                docs.push(raw.to_document().map_err(|error| {
566                    LookupError::Query(format!("decode mongodb lookup result: {error}"))
567                })?);
568            }
569            if docs.is_empty() {
570                Ok(Vec::new())
571            } else {
572                Ok(vec![Self::docs_to_batch(&task_schema, &docs)?])
573            }
574        })
575        .await
576        .map_err(|error| LookupError::Internal(format!("mongodb lookup task failed: {error}")))??;
577        let aligned = self.aligner.align(keys, &batches)?;
578
579        if project_needed {
580            let orig_names = projection_names(&self.schema, projection)?;
581            let mut projected_aligned = Vec::with_capacity(aligned.len());
582            for maybe_batch in aligned {
583                if let Some(batch) = maybe_batch {
584                    let indices: Vec<usize> = orig_names
585                        .iter()
586                        .map(|name| {
587                            batch.schema().index_of(name).map_err(|e| {
588                                LookupError::Internal(format!(
589                                    "column not found in aligned schema: {e}"
590                                ))
591                            })
592                        })
593                        .collect::<Result<Vec<usize>, LookupError>>()?;
594                    let projected = batch.project(&indices).map_err(|e| {
595                        LookupError::Internal(format!("project aligned batch: {e}"))
596                    })?;
597                    projected_aligned.push(Some(projected));
598                } else {
599                    projected_aligned.push(None);
600                }
601            }
602            Ok(projected_aligned)
603        } else {
604            Ok(aligned)
605        }
606    }
607
608    fn capabilities(&self) -> LookupSourceCapabilities {
609        LookupSourceCapabilities {
610            supports_batch_lookup: true,
611            supports_projection_pushdown: true,
612            max_batch_size: MAX_LOOKUP_KEYS,
613            ..LookupSourceCapabilities::none()
614        }
615    }
616
617    fn source_name(&self) -> &'static str {
618        "mongodb"
619    }
620
621    fn schema(&self) -> SchemaRef {
622        Arc::clone(&self.schema)
623    }
624
625    async fn health_check(&self) -> Result<(), LookupError> {
626        let client = self.client.clone();
627        let database = self.database.clone();
628        tokio::spawn(async move {
629            client
630                .database(&database)
631                .run_command(doc! { "ping": 1 })
632                .await
633                .map(|_| ())
634                .map_err(|e| LookupError::Connection(format!("health check: {e}")))
635        })
636        .await
637        .map_err(|error| LookupError::Internal(format!("mongodb health task failed: {error}")))?
638    }
639}
640
641/// Extract an integer-valued BSON field as `i64` (Int32/Int64/Double).
642#[cfg(feature = "mongodb-cdc")]
643fn bson_as_i64(b: Option<&Bson>) -> Option<i64> {
644    match b? {
645        Bson::Int32(v) => Some(i64::from(*v)),
646        Bson::Int64(v) => Some(*v),
647        #[allow(clippy::cast_possible_truncation)]
648        Bson::Double(v) => Some(*v as i64),
649        _ => None,
650    }
651}
652
653/// Extract a float-valued BSON field as `f64` (Double/Int32/Int64).
654#[cfg(feature = "mongodb-cdc")]
655fn bson_as_f64(b: Option<&Bson>) -> Option<f64> {
656    match b? {
657        Bson::Double(v) => Some(*v),
658        Bson::Int32(v) => Some(f64::from(*v)),
659        #[allow(clippy::cast_precision_loss)]
660        Bson::Int64(v) => Some(*v as f64),
661        _ => None,
662    }
663}
664
665/// Render a BSON value as a string cell (scalars verbatim, others as JSON).
666#[cfg(feature = "mongodb-cdc")]
667fn bson_to_string(b: &Bson) -> String {
668    match b {
669        Bson::String(s) => s.clone(),
670        Bson::ObjectId(oid) => oid.to_hex(),
671        Bson::Int32(v) => v.to_string(),
672        Bson::Int64(v) => v.to_string(),
673        Bson::Double(v) => v.to_string(),
674        Bson::Boolean(v) => v.to_string(),
675        other => other.to_string(),
676    }
677}
678
679#[cfg(all(test, feature = "mongodb-cdc"))]
680mod tests {
681    use super::*;
682    use arrow_array::{Int64Array, StringArray};
683    use arrow_schema::{Field, Schema};
684    use mongodb::options::{Collation, IndexOptions};
685
686    #[test]
687    fn cell_to_bson_types_and_null() {
688        assert_eq!(
689            MongoLookupSource::cell_to_bson(&Int64Array::from(vec![7i64]), 0).unwrap(),
690            Some(Bson::Int64(7))
691        );
692        assert_eq!(
693            MongoLookupSource::cell_to_bson(&StringArray::from(vec!["k"]), 0).unwrap(),
694            Some(Bson::String("k".into()))
695        );
696        let nullable = Int64Array::from(vec![None, Some(1)]);
697        assert!(MongoLookupSource::cell_to_bson(&nullable, 0)
698            .unwrap()
699            .is_none());
700    }
701
702    #[test]
703    fn cell_to_bson_rejects_unsupported_type() {
704        assert!(
705            MongoLookupSource::cell_to_bson(&arrow_array::Date32Array::from(vec![1]), 0).is_err()
706        );
707    }
708
709    #[test]
710    fn bson_numeric_coercion() {
711        assert_eq!(bson_as_i64(Some(&Bson::Double(3.9))), Some(3));
712        assert_eq!(bson_as_f64(Some(&Bson::Int64(5))), Some(5.0));
713        assert_eq!(bson_as_i64(Some(&Bson::String("x".into()))), None);
714        assert_eq!(bson_as_i64(None), None);
715    }
716
717    #[test]
718    fn lookup_key_limits_are_enforced_before_decode() {
719        assert!(validate_lookup_keys(&[&b"a"[..], &b"bc"[..]]).is_ok());
720
721        let too_many = vec![&b""[..]; MAX_LOOKUP_KEYS + 1];
722        assert!(validate_lookup_keys(&too_many).is_err());
723
724        let oversized = vec![0_u8; MAX_LOOKUP_KEY_BYTES + 1];
725        assert!(validate_lookup_keys(&[oversized.as_slice()]).is_err());
726    }
727
728    #[test]
729    fn lookup_command_limit_leaves_bson_headroom() {
730        let small = doc! { "id": doc! { "$in": [1_i64, 2_i64] } };
731        assert!(validate_lookup_command("db", "items", &small, None, 2).is_ok());
732
733        let oversized = doc! {
734            "id": doc! { "$in": ["x".repeat(MAX_LOOKUP_COMMAND_BYTES)] }
735        };
736        let error = validate_lookup_command("db", "items", &oversized, None, 1)
737            .expect_err("oversized command must be rejected before I/O");
738        assert!(error.to_string().contains("lookup command"));
739    }
740
741    #[test]
742    fn lookup_index_must_uniquely_cover_the_key() {
743        let unique = IndexModel::builder()
744            .keys(doc! { "id": 1 })
745            .options(IndexOptions::builder().unique(true).build())
746            .build();
747        assert!(has_usable_unique_lookup_index(&unique, "id"));
748
749        let non_unique = IndexModel::builder().keys(doc! { "id": 1 }).build();
750        assert!(!has_usable_unique_lookup_index(&non_unique, "id"));
751
752        let compound = IndexModel::builder()
753            .keys(doc! { "id": 1, "tenant": 1 })
754            .options(IndexOptions::builder().unique(true).build())
755            .build();
756        assert!(!has_usable_unique_lookup_index(&compound, "id"));
757
758        let partial = IndexModel::builder()
759            .keys(doc! { "id": 1 })
760            .options(
761                IndexOptions::builder()
762                    .unique(true)
763                    .partial_filter_expression(doc! { "active": true })
764                    .build(),
765            )
766            .build();
767        assert!(!has_usable_unique_lookup_index(&partial, "id"));
768
769        let hidden = IndexModel::builder()
770            .keys(doc! { "id": 1 })
771            .options(IndexOptions::builder().unique(true).hidden(true).build())
772            .build();
773        assert!(!has_usable_unique_lookup_index(&hidden, "id"));
774
775        let collated = IndexModel::builder()
776            .keys(doc! { "id": 1 })
777            .options(
778                IndexOptions::builder()
779                    .unique(true)
780                    .collation(Collation::builder().locale("en").build())
781                    .build(),
782            )
783            .build();
784        assert!(!has_usable_unique_lookup_index(&collated, "id"));
785
786        let hashed = IndexModel::builder()
787            .keys(doc! { "id": "hashed" })
788            .options(IndexOptions::builder().unique(true).build())
789            .build();
790        assert!(!has_usable_unique_lookup_index(&hashed, "id"));
791
792        let implicit_id = IndexModel::builder()
793            .keys(doc! { "_id": 1 })
794            .options(
795                IndexOptions::builder()
796                    .name(Some("_id_".to_owned()))
797                    .build(),
798            )
799            .build();
800        assert!(has_usable_unique_lookup_index(&implicit_id, "_id"));
801    }
802
803    #[test]
804    fn mongos_topology_is_rejected_before_index_admission() {
805        let error = validate_lookup_server_topology(&doc! {
806            "msg": "isdbgrid",
807            "isWritablePrimary": true
808        })
809        .expect_err("mongos cannot prove cluster-global key uniqueness");
810        assert!(error.to_string().contains("shard-key routing"));
811        assert!(error.to_string().contains("cluster-global uniqueness"));
812
813        assert!(validate_lookup_server_topology(&doc! {
814            "setName": "rs0",
815            "isWritablePrimary": true
816        })
817        .is_ok());
818        assert!(validate_lookup_server_topology(&doc! {
819            "isWritablePrimary": true
820        })
821        .is_ok());
822    }
823
824    #[tokio::test]
825    async fn load_balanced_topology_is_rejected_before_client_construction() {
826        let config = MongoLookupSourceConfig {
827            connection_uri: "mongodb://localhost:27017/?loadBalanced=true&tls=false".into(),
828            database: "db".into(),
829            collection: "items".into(),
830            primary_key_columns: vec!["id".into()],
831            schema: Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])),
832        };
833        let error = MongoLookupSource::open(config)
834            .await
835            .err()
836            .expect("load-balanced lookup must fail before server selection");
837        assert!(error.to_string().contains("load-balanced topology"));
838        assert!(error.to_string().contains("cluster-global uniqueness"));
839    }
840
841    #[tokio::test]
842    async fn wildcard_collection_is_rejected_before_network_io() {
843        let config = MongoLookupSourceConfig {
844            connection_uri: "mongodb://localhost:27017".into(),
845            database: "db".into(),
846            collection: "*".into(),
847            primary_key_columns: vec!["id".into()],
848            schema: Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])),
849        };
850        let error = MongoLookupSource::open(config)
851            .await
852            .err()
853            .expect("wildcard lookup must be rejected");
854        assert!(error.to_string().contains("collection='*'"));
855    }
856}