Skip to main content

faucet_source_mongodb/
stream.rs

1//! MongoDB stream executor.
2
3use crate::config::MongoSourceConfig;
4use async_trait::async_trait;
5use faucet_core::{FaucetError, Stream, StreamPage};
6use mongodb::Client;
7use mongodb::bson::{self, Bson, Document};
8use mongodb::options::FindOptions;
9use serde_json::Value;
10use std::pin::Pin;
11
12/// Documents sampled per collection by [`Source::discover`] to infer a
13/// representative schema. Kept small — discovery must stay cheap.
14const DISCOVER_SAMPLE_SIZE: i64 = 10;
15
16/// A configured MongoDB source that connects to a collection and fetches documents.
17///
18/// The MongoDB `Client` is created once during construction and reused across
19/// all `fetch_all()` calls. It maintains an internal connection pool.
20pub struct MongoSource {
21    config: MongoSourceConfig,
22    client: Client,
23}
24
25impl MongoSource {
26    /// Create a new MongoDB source from the given configuration.
27    ///
28    /// This establishes the MongoDB client (with its internal connection pool)
29    /// immediately.
30    pub async fn new(config: MongoSourceConfig) -> Result<Self, FaucetError> {
31        faucet_core::validate_batch_size(config.batch_size)?;
32        let client = Client::with_uri_str(&config.connection_uri)
33            .await
34            .map_err(|e| FaucetError::Source(format!("MongoDB connection failed: {e}")))?;
35
36        Ok(Self { config, client })
37    }
38
39    /// Fetch all matching documents from the configured collection.
40    pub async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
41        let db = self.client.database(&self.config.database);
42        let collection = db.collection::<Document>(&self.config.collection);
43
44        let filter = self
45            .config
46            .filter
47            .as_ref()
48            .map(json_value_to_document)
49            .transpose()?;
50
51        let mut find_options = FindOptions::default();
52
53        if let Some(ref proj) = self.config.projection {
54            find_options.projection = Some(json_value_to_document(proj)?);
55        }
56        if let Some(ref sort) = self.config.sort {
57            find_options.sort = Some(json_value_to_document(sort)?);
58        }
59        if let Some(limit) = self.config.limit {
60            find_options.limit = Some(limit);
61        }
62        if let Some(cursor_batch_size) = self.config.cursor_batch_size {
63            find_options.batch_size = Some(cursor_batch_size);
64        }
65
66        let mut cursor = collection
67            .find(filter.unwrap_or_default())
68            .with_options(find_options)
69            .await
70            .map_err(|e| FaucetError::Source(format!("MongoDB find failed: {e}")))?;
71
72        let mut records = Vec::new();
73
74        while cursor
75            .advance()
76            .await
77            .map_err(|e| FaucetError::Source(format!("MongoDB cursor advance failed: {e}")))?
78        {
79            let doc = cursor
80                .deserialize_current()
81                .map_err(|e| FaucetError::Source(format!("MongoDB deserialization failed: {e}")))?;
82
83            let value = bson_document_to_json_value(&doc)?;
84            records.push(value);
85        }
86
87        tracing::info!(
88            records = records.len(),
89            database = %self.config.database,
90            collection = %self.config.collection,
91            "MongoDB fetch complete"
92        );
93
94        Ok(records)
95    }
96}
97
98#[async_trait]
99impl faucet_core::Source for MongoSource {
100    async fn fetch_with_context(
101        &self,
102        context: &std::collections::HashMap<String, serde_json::Value>,
103    ) -> Result<Vec<Value>, FaucetError> {
104        if context.is_empty() {
105            return MongoSource::fetch_all(self).await;
106        }
107
108        // Substitute context placeholders into filter, projection, and sort.
109        let filter = substitute_optional_value(&self.config.filter, context, "filter")?;
110        let projection = substitute_optional_value(&self.config.projection, context, "projection")?;
111        let sort = substitute_optional_value(&self.config.sort, context, "sort")?;
112
113        let db = self.client.database(&self.config.database);
114        let collection = db.collection::<Document>(&self.config.collection);
115
116        let filter_doc = filter.as_ref().map(json_value_to_document).transpose()?;
117
118        let mut find_options = FindOptions::default();
119        if let Some(ref proj) = projection {
120            find_options.projection = Some(json_value_to_document(proj)?);
121        }
122        if let Some(ref s) = sort {
123            find_options.sort = Some(json_value_to_document(s)?);
124        }
125        if let Some(limit) = self.config.limit {
126            find_options.limit = Some(limit);
127        }
128        if let Some(cursor_batch_size) = self.config.cursor_batch_size {
129            find_options.batch_size = Some(cursor_batch_size);
130        }
131
132        let mut cursor = collection
133            .find(filter_doc.unwrap_or_default())
134            .with_options(find_options)
135            .await
136            .map_err(|e| FaucetError::Source(format!("MongoDB find failed: {e}")))?;
137
138        let mut records = Vec::new();
139        while cursor
140            .advance()
141            .await
142            .map_err(|e| FaucetError::Source(format!("MongoDB cursor advance failed: {e}")))?
143        {
144            let doc = cursor
145                .deserialize_current()
146                .map_err(|e| FaucetError::Source(format!("MongoDB deserialization failed: {e}")))?;
147            records.push(bson_document_to_json_value(&doc)?);
148        }
149
150        tracing::info!(
151            records = records.len(),
152            database = %self.config.database,
153            collection = %self.config.collection,
154            "MongoDB fetch complete (with context)"
155        );
156
157        Ok(records)
158    }
159
160    /// Stream documents from the underlying MongoDB cursor without buffering
161    /// the full result set. Each emitted [`StreamPage`] holds up to
162    /// [`MongoSourceConfig::batch_size`] documents.
163    ///
164    /// The trait-level `batch_size` argument is ignored in favour of the
165    /// config field — the config is the user-facing knob the README
166    /// documents, and routing the pipeline-supplied hint through it would
167    /// silently override an explicit config value.
168    ///
169    /// `batch_size = 0` drains the entire cursor into a single page. The
170    /// MongoDB source has no incremental-replication mode today, so every
171    /// emitted page carries `bookmark: None`.
172    ///
173    /// Note: [`MongoSourceConfig::cursor_batch_size`] is independent — it
174    /// controls the driver's per-round-trip batch size, while `batch_size`
175    /// controls how many documents are buffered before a `StreamPage` is
176    /// yielded to the pipeline.
177    fn stream_pages<'a>(
178        &'a self,
179        context: &'a std::collections::HashMap<String, Value>,
180        _batch_size: usize,
181    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
182        let batch_size = self.config.batch_size;
183
184        Box::pin(async_stream::try_stream! {
185            // Substitute context placeholders into filter, projection, sort
186            // (matching fetch_with_context's behaviour).
187            let (filter, projection, sort) = if context.is_empty() {
188                (
189                    self.config.filter.clone(),
190                    self.config.projection.clone(),
191                    self.config.sort.clone(),
192                )
193            } else {
194                (
195                    substitute_optional_value(&self.config.filter, context, "filter")?,
196                    substitute_optional_value(&self.config.projection, context, "projection")?,
197                    substitute_optional_value(&self.config.sort, context, "sort")?,
198                )
199            };
200
201            let db = self.client.database(&self.config.database);
202            let collection = db.collection::<Document>(&self.config.collection);
203
204            let filter_doc = filter.as_ref().map(json_value_to_document).transpose()?;
205
206            let mut find_options = FindOptions::default();
207            if let Some(ref proj) = projection {
208                find_options.projection = Some(json_value_to_document(proj)?);
209            }
210            if let Some(ref s) = sort {
211                find_options.sort = Some(json_value_to_document(s)?);
212            }
213            if let Some(limit) = self.config.limit {
214                find_options.limit = Some(limit);
215            }
216            if let Some(cursor_batch_size) = self.config.cursor_batch_size {
217                find_options.batch_size = Some(cursor_batch_size);
218            }
219
220            let mut cursor = collection
221                .find(filter_doc.unwrap_or_default())
222                .with_options(find_options)
223                .await
224                .map_err(|e| FaucetError::Source(format!("MongoDB find failed: {e}")))?;
225
226            let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
227            let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
228            let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
229            let mut total = 0usize;
230
231            while cursor
232                .advance()
233                .await
234                .map_err(|e| FaucetError::Source(format!("MongoDB cursor advance failed: {e}")))?
235            {
236                let doc = cursor
237                    .deserialize_current()
238                    .map_err(|e| FaucetError::Source(format!("MongoDB deserialization failed: {e}")))?;
239                buffer.push(bson_document_to_json_value(&doc)?);
240                if buffer.len() >= chunk {
241                    let page = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
242                    total += page.len();
243                    yield StreamPage { records: page, bookmark: None };
244                }
245            }
246            if !buffer.is_empty() {
247                total += buffer.len();
248                yield StreamPage { records: buffer, bookmark: None };
249            }
250
251            tracing::info!(
252                records = total,
253                batch_size,
254                database = %self.config.database,
255                collection = %self.config.collection,
256                "MongoDB source stream complete",
257            );
258        })
259    }
260
261    fn connector_name(&self) -> &'static str {
262        "mongodb"
263    }
264
265    fn config_schema(&self) -> serde_json::Value {
266        serde_json::to_value(faucet_core::schema_for!(MongoSourceConfig))
267            .expect("schema serialization")
268    }
269
270    fn dataset_uri(&self) -> String {
271        format!(
272            "{}/{}/{}",
273            faucet_core::redact_uri_credentials(&self.config.connection_uri),
274            self.config.database,
275            self.config.collection
276        )
277    }
278
279    fn supports_discover(&self) -> bool {
280        true
281    }
282
283    /// Enumerate the collections in the configured database (excluding
284    /// `system.*`), with a row estimate from `estimated_document_count`
285    /// (collection metadata — no scan) and a schema inferred from a bounded
286    /// `DISCOVER_SAMPLE_SIZE`-document sample per collection.
287    async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
288        let db = self.client.database(&self.config.database);
289        let mut names: Vec<String> = db
290            .list_collection_names()
291            .await
292            .map_err(|e| FaucetError::Source(format!("mongodb: catalog discovery failed: {e}")))?
293            .into_iter()
294            .filter(|name| !name.starts_with("system."))
295            .collect();
296        names.sort();
297
298        let mut datasets = Vec::with_capacity(names.len());
299        for name in names {
300            let collection = db.collection::<Document>(&name);
301            let estimated = collection.estimated_document_count().await.map_err(|e| {
302                FaucetError::Source(format!(
303                    "mongodb: catalog discovery failed (count for {name:?}): {e}"
304                ))
305            })?;
306
307            // Bounded sample for schema inference — the same BSON→JSON
308            // conversion the fetch path uses, capped at DISCOVER_SAMPLE_SIZE.
309            let mut cursor = collection
310                .find(Document::new())
311                .limit(DISCOVER_SAMPLE_SIZE)
312                .await
313                .map_err(|e| {
314                    FaucetError::Source(format!(
315                        "mongodb: catalog discovery failed (sample for {name:?}): {e}"
316                    ))
317                })?;
318            let mut sample = Vec::new();
319            while cursor.advance().await.map_err(|e| {
320                FaucetError::Source(format!(
321                    "mongodb: catalog discovery failed (sample for {name:?}): {e}"
322                ))
323            })? {
324                let doc = cursor.deserialize_current().map_err(|e| {
325                    FaucetError::Source(format!(
326                        "mongodb: catalog discovery failed (decode for {name:?}): {e}"
327                    ))
328                })?;
329                sample.push(bson_document_to_json_value(&doc)?);
330            }
331
332            datasets.push(descriptor_for_collection(&name, &sample, Some(estimated)));
333        }
334        Ok(datasets)
335    }
336}
337
338/// Build a [`DatasetDescriptor`](faucet_core::DatasetDescriptor) for one
339/// collection from its name, a small JSON document sample, and a cheap
340/// metadata row estimate. An empty sample yields no schema (an empty
341/// collection has no shape to report). Pure — unit-testable without a live
342/// server.
343fn descriptor_for_collection(
344    name: &str,
345    sample: &[Value],
346    estimated_rows: Option<u64>,
347) -> faucet_core::DatasetDescriptor {
348    let mut descriptor = faucet_core::DatasetDescriptor::new(
349        name,
350        "collection",
351        serde_json::json!({ "collection": name }),
352    );
353    if !sample.is_empty() {
354        descriptor = descriptor.with_schema(faucet_core::schema::infer_schema(sample));
355    }
356    if let Some(rows) = estimated_rows {
357        descriptor = descriptor.with_estimated_rows(rows);
358    }
359    descriptor
360}
361
362/// Substitute context placeholders in an optional JSON value.
363///
364/// Serialises the value to a string, runs [`substitute_context_json`] (which
365/// properly escapes string values for JSON safety), then deserialises back.
366/// Returns `None` when the input is `None`.
367fn substitute_optional_value(
368    value: &Option<Value>,
369    context: &std::collections::HashMap<String, Value>,
370    field_name: &str,
371) -> Result<Option<Value>, FaucetError> {
372    match value {
373        Some(v) => {
374            let s = serde_json::to_string(v).map_err(|e| {
375                FaucetError::Config(format!("failed to serialize {field_name}: {e}"))
376            })?;
377            let s = faucet_core::util::substitute_context_json(&s, context);
378            let resolved = serde_json::from_str(&s).map_err(|e| {
379                FaucetError::Config(format!("failed to parse substituted {field_name}: {e}"))
380            })?;
381            Ok(Some(resolved))
382        }
383        None => Ok(None),
384    }
385}
386
387/// Convert a `serde_json::Value` to a `bson::Document`.
388///
389/// The value must be a JSON object; other types produce a `Config` error.
390fn json_value_to_document(val: &Value) -> Result<Document, FaucetError> {
391    let bson = bson::to_bson(val)
392        .map_err(|e| FaucetError::Config(format!("failed to convert JSON to BSON: {e}")))?;
393    match bson {
394        Bson::Document(doc) => Ok(doc),
395        other => Err(FaucetError::Config(format!(
396            "expected a JSON object, got BSON type: {other:?}"
397        ))),
398    }
399}
400
401/// Convert a `bson::Document` to a `serde_json::Value`.
402fn bson_document_to_json_value(doc: &Document) -> Result<Value, FaucetError> {
403    let bson = Bson::Document(doc.clone());
404    let relaxed = bson.into_relaxed_extjson();
405    Ok(relaxed)
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use serde_json::json;
412
413    #[test]
414    fn json_object_to_document() {
415        let val = json!({"name": "Alice", "age": 30});
416        let doc = json_value_to_document(&val).unwrap();
417        assert_eq!(doc.get_str("name").unwrap(), "Alice");
418        assert_eq!(doc.get_i64("age").unwrap(), 30);
419    }
420
421    #[test]
422    fn json_non_object_to_document_fails() {
423        let val = json!([1, 2, 3]);
424        let result = json_value_to_document(&val);
425        assert!(result.is_err());
426        assert!(matches!(result, Err(FaucetError::Config(_))));
427    }
428
429    #[test]
430    fn json_string_to_document_fails() {
431        let val = json!("not an object");
432        let result = json_value_to_document(&val);
433        assert!(result.is_err());
434    }
435
436    #[test]
437    fn bson_document_roundtrip() {
438        let mut doc = Document::new();
439        doc.insert("name", "Bob");
440        doc.insert("score", 42);
441        let value = bson_document_to_json_value(&doc).unwrap();
442        assert_eq!(value["name"], "Bob");
443        assert_eq!(value["score"], 42);
444    }
445
446    #[test]
447    fn nested_document_conversion() {
448        let val = json!({"user": {"name": "Alice", "tags": ["admin", "user"]}});
449        let doc = json_value_to_document(&val).unwrap();
450        let inner = doc.get_document("user").unwrap();
451        assert_eq!(inner.get_str("name").unwrap(), "Alice");
452
453        let back = bson_document_to_json_value(&doc).unwrap();
454        assert_eq!(back["user"]["name"], "Alice");
455        assert_eq!(back["user"]["tags"][0], "admin");
456    }
457
458    #[test]
459    fn empty_filter_converts() {
460        let val = json!({});
461        let doc = json_value_to_document(&val).unwrap();
462        assert!(doc.is_empty());
463    }
464
465    #[tokio::test]
466    async fn new_rejects_out_of_range_batch_size() {
467        let mut config = MongoSourceConfig::new("mongodb://localhost:27017", "db", "c");
468        config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
469        match MongoSource::new(config).await {
470            Err(faucet_core::FaucetError::Config(m)) => {
471                assert!(m.contains("batch_size"), "got: {m}")
472            }
473            _ => panic!("expected a batch_size Config error"),
474        }
475    }
476
477    // dataset_uri is a pure-config method; MongoSource requires an async
478    // constructor with a live server, so we verify the logic directly.
479    #[test]
480    fn dataset_uri_strips_credentials() {
481        let config = MongoSourceConfig::new("mongodb://u:p@h:27017", "mydb", "events");
482        let uri = format!(
483            "{}/{}/{}",
484            faucet_core::redact_uri_credentials(&config.connection_uri),
485            config.database,
486            config.collection
487        );
488        assert_eq!(uri, "mongodb://h:27017/mydb/events");
489    }
490
491    // --- substitute_optional_value (the private context-interpolation helper) ---
492
493    #[test]
494    fn substitute_optional_value_none_passthrough() {
495        let ctx: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
496        let out = substitute_optional_value(&None, &ctx, "filter").unwrap();
497        assert!(out.is_none(), "None input must yield None output");
498    }
499
500    #[test]
501    fn substitute_optional_value_replaces_string_placeholder() {
502        let mut ctx: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
503        // Placeholders use the flat `{key}` syntax over top-level context keys.
504        ctx.insert("user_id".into(), json!("abc-123"));
505        // The placeholder lives inside a JSON string value; substitution must
506        // preserve JSON validity (string stays a string).
507        let filter = Some(json!({"owner": "{user_id}"}));
508        let out = substitute_optional_value(&filter, &ctx, "filter")
509            .unwrap()
510            .expect("Some input yields Some output");
511        assert_eq!(out, json!({"owner": "abc-123"}));
512    }
513
514    #[test]
515    fn substitute_optional_value_no_placeholder_is_identity() {
516        let ctx: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
517        let proj = Some(json!({"_id": 0, "name": 1}));
518        let out = substitute_optional_value(&proj, &ctx, "projection")
519            .unwrap()
520            .unwrap();
521        assert_eq!(out, json!({"_id": 0, "name": 1}));
522    }
523
524    #[test]
525    fn substitute_optional_value_escapes_value_for_json_safety() {
526        // A context value containing a double-quote must be escaped so the
527        // re-parsed JSON is valid and the literal characters survive.
528        let mut ctx: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
529        ctx.insert("name".into(), json!("a\"b"));
530        let filter = Some(json!({"n": "{name}"}));
531        let out = substitute_optional_value(&filter, &ctx, "filter")
532            .unwrap()
533            .unwrap();
534        assert_eq!(out, json!({"n": "a\"b"}));
535    }
536
537    // --- bson_document_to_json_value: relaxed extended-JSON shapes ---
538
539    #[test]
540    fn bson_object_id_converts_to_oid_extjson() {
541        use mongodb::bson::oid::ObjectId;
542        let mut doc = Document::new();
543        let oid = ObjectId::parse_str("64ab00112233445566778899").unwrap();
544        doc.insert("_id", oid);
545        let value = bson_document_to_json_value(&doc).unwrap();
546        // Relaxed extended JSON renders an ObjectId as {"$oid": "<hex>"}.
547        assert_eq!(value["_id"]["$oid"], "64ab00112233445566778899");
548    }
549
550    #[test]
551    fn bson_datetime_converts_to_date_extjson() {
552        use mongodb::bson::DateTime;
553        let mut doc = Document::new();
554        // 1_000_000 ms past the epoch.
555        doc.insert("created_at", DateTime::from_millis(1_000_000));
556        let value = bson_document_to_json_value(&doc).unwrap();
557        // Relaxed extended JSON renders a post-1970 datetime as
558        // {"$date": "<RFC3339>"}.
559        assert!(
560            value["created_at"]["$date"].is_string(),
561            "expected $date string, got {value:?}"
562        );
563    }
564
565    #[test]
566    fn bson_null_and_array_and_nested_convert() {
567        use mongodb::bson::Bson;
568        let mut doc = Document::new();
569        doc.insert("missing", Bson::Null);
570        doc.insert("tags", vec!["a", "b"]);
571        let mut nested = Document::new();
572        nested.insert("k", 7i32);
573        doc.insert("nested", nested);
574        let value = bson_document_to_json_value(&doc).unwrap();
575        assert_eq!(value["missing"], Value::Null);
576        assert_eq!(value["tags"], json!(["a", "b"]));
577        assert_eq!(value["nested"]["k"], 7);
578    }
579
580    #[test]
581    fn bson_int64_converts_to_number() {
582        let mut doc = Document::new();
583        doc.insert("big", 9_000_000_000i64);
584        let value = bson_document_to_json_value(&doc).unwrap();
585        // Relaxed extended JSON renders an i64 as a bare JSON number.
586        assert_eq!(value["big"], json!(9_000_000_000i64));
587    }
588
589    // ── discover: pure descriptor building ──────────────────────────────────
590
591    #[test]
592    fn descriptor_infers_schema_from_sample() {
593        let sample = vec![
594            json!({"id": 1, "name": "alpha", "score": 1.5}),
595            json!({"id": 2, "name": "beta"}),
596        ];
597        let d = descriptor_for_collection("orders", &sample, Some(120));
598        assert_eq!(d.name, "orders");
599        assert_eq!(d.kind, "collection");
600        assert_eq!(d.config_patch, json!({"collection": "orders"}));
601        assert_eq!(d.estimated_rows, Some(120));
602        let schema = d.schema.as_ref().expect("schema from non-empty sample");
603        assert_eq!(schema["type"], "object");
604        assert_eq!(schema["properties"]["id"]["type"], "integer");
605        assert_eq!(schema["properties"]["name"]["type"], "string");
606        assert_eq!(
607            schema["properties"]["score"]["type"],
608            json!(["null", "number"]),
609            "field absent from one sampled doc is nullable"
610        );
611    }
612
613    #[test]
614    fn descriptor_empty_sample_has_no_schema() {
615        let d = descriptor_for_collection("empty", &[], Some(0));
616        assert_eq!(d.name, "empty");
617        assert_eq!(d.kind, "collection");
618        assert_eq!(d.config_patch, json!({"collection": "empty"}));
619        assert_eq!(d.estimated_rows, Some(0));
620        assert!(
621            d.schema.is_none(),
622            "empty collection has no shape to report"
623        );
624    }
625
626    #[test]
627    fn descriptor_without_estimate_omits_rows() {
628        let d = descriptor_for_collection("c", &[json!({"k": true})], None);
629        assert_eq!(d.estimated_rows, None);
630        let schema = d.schema.as_ref().unwrap();
631        assert_eq!(schema["properties"]["k"]["type"], "boolean");
632    }
633
634    #[tokio::test]
635    async fn source_advertises_discover() {
636        use faucet_core::Source as _;
637        // `Client::with_uri_str` does no I/O for a well-formed URI, so the
638        // capability flag is checkable offline; `discover()` against the
639        // unreachable server surfaces the typed discovery error.
640        let config = MongoSourceConfig::new(
641            "mongodb://127.0.0.1:1/?connectTimeoutMS=200&serverSelectionTimeoutMS=200",
642            "db",
643            "c",
644        );
645        let source = MongoSource::new(config).await.expect("client construct");
646        assert!(source.supports_discover());
647        let err = source.discover().await.unwrap_err();
648        assert!(
649            err.to_string().contains("catalog discovery failed"),
650            "typed error: {err}"
651        );
652    }
653}