Skip to main content

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