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 config_schema(&self) -> serde_json::Value {
262        serde_json::to_value(faucet_core::schema_for!(MongoSourceConfig))
263            .expect("schema serialization")
264    }
265
266    fn dataset_uri(&self) -> String {
267        format!(
268            "{}/{}/{}",
269            faucet_core::redact_uri_credentials(&self.config.connection_uri),
270            self.config.database,
271            self.config.collection
272        )
273    }
274
275    fn supports_discover(&self) -> bool {
276        true
277    }
278
279    /// Enumerate the collections in the configured database (excluding
280    /// `system.*`), with a row estimate from `estimated_document_count`
281    /// (collection metadata — no scan) and a schema inferred from a bounded
282    /// `DISCOVER_SAMPLE_SIZE`-document sample per collection.
283    async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
284        let db = self.client.database(&self.config.database);
285        let mut names: Vec<String> = db
286            .list_collection_names()
287            .await
288            .map_err(|e| FaucetError::Source(format!("mongodb: catalog discovery failed: {e}")))?
289            .into_iter()
290            .filter(|name| !name.starts_with("system."))
291            .collect();
292        names.sort();
293
294        let mut datasets = Vec::with_capacity(names.len());
295        for name in names {
296            let collection = db.collection::<Document>(&name);
297            let estimated = collection.estimated_document_count().await.map_err(|e| {
298                FaucetError::Source(format!(
299                    "mongodb: catalog discovery failed (count for {name:?}): {e}"
300                ))
301            })?;
302
303            // Bounded sample for schema inference — the same BSON→JSON
304            // conversion the fetch path uses, capped at DISCOVER_SAMPLE_SIZE.
305            let mut cursor = collection
306                .find(Document::new())
307                .limit(DISCOVER_SAMPLE_SIZE)
308                .await
309                .map_err(|e| {
310                    FaucetError::Source(format!(
311                        "mongodb: catalog discovery failed (sample for {name:?}): {e}"
312                    ))
313                })?;
314            let mut sample = Vec::new();
315            while cursor.advance().await.map_err(|e| {
316                FaucetError::Source(format!(
317                    "mongodb: catalog discovery failed (sample for {name:?}): {e}"
318                ))
319            })? {
320                let doc = cursor.deserialize_current().map_err(|e| {
321                    FaucetError::Source(format!(
322                        "mongodb: catalog discovery failed (decode for {name:?}): {e}"
323                    ))
324                })?;
325                sample.push(bson_document_to_json_value(&doc)?);
326            }
327
328            datasets.push(descriptor_for_collection(&name, &sample, Some(estimated)));
329        }
330        Ok(datasets)
331    }
332}
333
334/// Build a [`DatasetDescriptor`](faucet_core::DatasetDescriptor) for one
335/// collection from its name, a small JSON document sample, and a cheap
336/// metadata row estimate. An empty sample yields no schema (an empty
337/// collection has no shape to report). Pure — unit-testable without a live
338/// server.
339fn descriptor_for_collection(
340    name: &str,
341    sample: &[Value],
342    estimated_rows: Option<u64>,
343) -> faucet_core::DatasetDescriptor {
344    let mut descriptor = faucet_core::DatasetDescriptor::new(
345        name,
346        "collection",
347        serde_json::json!({ "collection": name }),
348    );
349    if !sample.is_empty() {
350        descriptor = descriptor.with_schema(faucet_core::schema::infer_schema(sample));
351    }
352    if let Some(rows) = estimated_rows {
353        descriptor = descriptor.with_estimated_rows(rows);
354    }
355    descriptor
356}
357
358/// Substitute context placeholders in an optional JSON value.
359///
360/// Serialises the value to a string, runs [`substitute_context_json`] (which
361/// properly escapes string values for JSON safety), then deserialises back.
362/// Returns `None` when the input is `None`.
363fn substitute_optional_value(
364    value: &Option<Value>,
365    context: &std::collections::HashMap<String, Value>,
366    field_name: &str,
367) -> Result<Option<Value>, FaucetError> {
368    match value {
369        Some(v) => {
370            let s = serde_json::to_string(v).map_err(|e| {
371                FaucetError::Config(format!("failed to serialize {field_name}: {e}"))
372            })?;
373            let s = faucet_core::util::substitute_context_json(&s, context);
374            let resolved = serde_json::from_str(&s).map_err(|e| {
375                FaucetError::Config(format!("failed to parse substituted {field_name}: {e}"))
376            })?;
377            Ok(Some(resolved))
378        }
379        None => Ok(None),
380    }
381}
382
383/// Convert a `serde_json::Value` to a `bson::Document`.
384///
385/// The value must be a JSON object; other types produce a `Config` error.
386fn json_value_to_document(val: &Value) -> Result<Document, FaucetError> {
387    let bson = bson::to_bson(val)
388        .map_err(|e| FaucetError::Config(format!("failed to convert JSON to BSON: {e}")))?;
389    match bson {
390        Bson::Document(doc) => Ok(doc),
391        other => Err(FaucetError::Config(format!(
392            "expected a JSON object, got BSON type: {other:?}"
393        ))),
394    }
395}
396
397/// Convert a `bson::Document` to a `serde_json::Value`.
398fn bson_document_to_json_value(doc: &Document) -> Result<Value, FaucetError> {
399    let bson = Bson::Document(doc.clone());
400    let relaxed = bson.into_relaxed_extjson();
401    Ok(relaxed)
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use serde_json::json;
408
409    #[test]
410    fn json_object_to_document() {
411        let val = json!({"name": "Alice", "age": 30});
412        let doc = json_value_to_document(&val).unwrap();
413        assert_eq!(doc.get_str("name").unwrap(), "Alice");
414        assert_eq!(doc.get_i64("age").unwrap(), 30);
415    }
416
417    #[test]
418    fn json_non_object_to_document_fails() {
419        let val = json!([1, 2, 3]);
420        let result = json_value_to_document(&val);
421        assert!(result.is_err());
422        assert!(matches!(result, Err(FaucetError::Config(_))));
423    }
424
425    #[test]
426    fn json_string_to_document_fails() {
427        let val = json!("not an object");
428        let result = json_value_to_document(&val);
429        assert!(result.is_err());
430    }
431
432    #[test]
433    fn bson_document_roundtrip() {
434        let mut doc = Document::new();
435        doc.insert("name", "Bob");
436        doc.insert("score", 42);
437        let value = bson_document_to_json_value(&doc).unwrap();
438        assert_eq!(value["name"], "Bob");
439        assert_eq!(value["score"], 42);
440    }
441
442    #[test]
443    fn nested_document_conversion() {
444        let val = json!({"user": {"name": "Alice", "tags": ["admin", "user"]}});
445        let doc = json_value_to_document(&val).unwrap();
446        let inner = doc.get_document("user").unwrap();
447        assert_eq!(inner.get_str("name").unwrap(), "Alice");
448
449        let back = bson_document_to_json_value(&doc).unwrap();
450        assert_eq!(back["user"]["name"], "Alice");
451        assert_eq!(back["user"]["tags"][0], "admin");
452    }
453
454    #[test]
455    fn empty_filter_converts() {
456        let val = json!({});
457        let doc = json_value_to_document(&val).unwrap();
458        assert!(doc.is_empty());
459    }
460
461    #[tokio::test]
462    async fn new_rejects_out_of_range_batch_size() {
463        let mut config = MongoSourceConfig::new("mongodb://localhost:27017", "db", "c");
464        config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
465        match MongoSource::new(config).await {
466            Err(faucet_core::FaucetError::Config(m)) => {
467                assert!(m.contains("batch_size"), "got: {m}")
468            }
469            _ => panic!("expected a batch_size Config error"),
470        }
471    }
472
473    // dataset_uri is a pure-config method; MongoSource requires an async
474    // constructor with a live server, so we verify the logic directly.
475    #[test]
476    fn dataset_uri_strips_credentials() {
477        let config = MongoSourceConfig::new("mongodb://u:p@h:27017", "mydb", "events");
478        let uri = format!(
479            "{}/{}/{}",
480            faucet_core::redact_uri_credentials(&config.connection_uri),
481            config.database,
482            config.collection
483        );
484        assert_eq!(uri, "mongodb://h:27017/mydb/events");
485    }
486
487    // --- substitute_optional_value (the private context-interpolation helper) ---
488
489    #[test]
490    fn substitute_optional_value_none_passthrough() {
491        let ctx: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
492        let out = substitute_optional_value(&None, &ctx, "filter").unwrap();
493        assert!(out.is_none(), "None input must yield None output");
494    }
495
496    #[test]
497    fn substitute_optional_value_replaces_string_placeholder() {
498        let mut ctx: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
499        // Placeholders use the flat `{key}` syntax over top-level context keys.
500        ctx.insert("user_id".into(), json!("abc-123"));
501        // The placeholder lives inside a JSON string value; substitution must
502        // preserve JSON validity (string stays a string).
503        let filter = Some(json!({"owner": "{user_id}"}));
504        let out = substitute_optional_value(&filter, &ctx, "filter")
505            .unwrap()
506            .expect("Some input yields Some output");
507        assert_eq!(out, json!({"owner": "abc-123"}));
508    }
509
510    #[test]
511    fn substitute_optional_value_no_placeholder_is_identity() {
512        let ctx: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
513        let proj = Some(json!({"_id": 0, "name": 1}));
514        let out = substitute_optional_value(&proj, &ctx, "projection")
515            .unwrap()
516            .unwrap();
517        assert_eq!(out, json!({"_id": 0, "name": 1}));
518    }
519
520    #[test]
521    fn substitute_optional_value_escapes_value_for_json_safety() {
522        // A context value containing a double-quote must be escaped so the
523        // re-parsed JSON is valid and the literal characters survive.
524        let mut ctx: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
525        ctx.insert("name".into(), json!("a\"b"));
526        let filter = Some(json!({"n": "{name}"}));
527        let out = substitute_optional_value(&filter, &ctx, "filter")
528            .unwrap()
529            .unwrap();
530        assert_eq!(out, json!({"n": "a\"b"}));
531    }
532
533    // --- bson_document_to_json_value: relaxed extended-JSON shapes ---
534
535    #[test]
536    fn bson_object_id_converts_to_oid_extjson() {
537        use mongodb::bson::oid::ObjectId;
538        let mut doc = Document::new();
539        let oid = ObjectId::parse_str("64ab00112233445566778899").unwrap();
540        doc.insert("_id", oid);
541        let value = bson_document_to_json_value(&doc).unwrap();
542        // Relaxed extended JSON renders an ObjectId as {"$oid": "<hex>"}.
543        assert_eq!(value["_id"]["$oid"], "64ab00112233445566778899");
544    }
545
546    #[test]
547    fn bson_datetime_converts_to_date_extjson() {
548        use mongodb::bson::DateTime;
549        let mut doc = Document::new();
550        // 1_000_000 ms past the epoch.
551        doc.insert("created_at", DateTime::from_millis(1_000_000));
552        let value = bson_document_to_json_value(&doc).unwrap();
553        // Relaxed extended JSON renders a post-1970 datetime as
554        // {"$date": "<RFC3339>"}.
555        assert!(
556            value["created_at"]["$date"].is_string(),
557            "expected $date string, got {value:?}"
558        );
559    }
560
561    #[test]
562    fn bson_null_and_array_and_nested_convert() {
563        use mongodb::bson::Bson;
564        let mut doc = Document::new();
565        doc.insert("missing", Bson::Null);
566        doc.insert("tags", vec!["a", "b"]);
567        let mut nested = Document::new();
568        nested.insert("k", 7i32);
569        doc.insert("nested", nested);
570        let value = bson_document_to_json_value(&doc).unwrap();
571        assert_eq!(value["missing"], Value::Null);
572        assert_eq!(value["tags"], json!(["a", "b"]));
573        assert_eq!(value["nested"]["k"], 7);
574    }
575
576    #[test]
577    fn bson_int64_converts_to_number() {
578        let mut doc = Document::new();
579        doc.insert("big", 9_000_000_000i64);
580        let value = bson_document_to_json_value(&doc).unwrap();
581        // Relaxed extended JSON renders an i64 as a bare JSON number.
582        assert_eq!(value["big"], json!(9_000_000_000i64));
583    }
584
585    // ── discover: pure descriptor building ──────────────────────────────────
586
587    #[test]
588    fn descriptor_infers_schema_from_sample() {
589        let sample = vec![
590            json!({"id": 1, "name": "alpha", "score": 1.5}),
591            json!({"id": 2, "name": "beta"}),
592        ];
593        let d = descriptor_for_collection("orders", &sample, Some(120));
594        assert_eq!(d.name, "orders");
595        assert_eq!(d.kind, "collection");
596        assert_eq!(d.config_patch, json!({"collection": "orders"}));
597        assert_eq!(d.estimated_rows, Some(120));
598        let schema = d.schema.as_ref().expect("schema from non-empty sample");
599        assert_eq!(schema["type"], "object");
600        assert_eq!(schema["properties"]["id"]["type"], "integer");
601        assert_eq!(schema["properties"]["name"]["type"], "string");
602        assert_eq!(
603            schema["properties"]["score"]["type"],
604            json!(["null", "number"]),
605            "field absent from one sampled doc is nullable"
606        );
607    }
608
609    #[test]
610    fn descriptor_empty_sample_has_no_schema() {
611        let d = descriptor_for_collection("empty", &[], Some(0));
612        assert_eq!(d.name, "empty");
613        assert_eq!(d.kind, "collection");
614        assert_eq!(d.config_patch, json!({"collection": "empty"}));
615        assert_eq!(d.estimated_rows, Some(0));
616        assert!(
617            d.schema.is_none(),
618            "empty collection has no shape to report"
619        );
620    }
621
622    #[test]
623    fn descriptor_without_estimate_omits_rows() {
624        let d = descriptor_for_collection("c", &[json!({"k": true})], None);
625        assert_eq!(d.estimated_rows, None);
626        let schema = d.schema.as_ref().unwrap();
627        assert_eq!(schema["properties"]["k"]["type"], "boolean");
628    }
629
630    #[tokio::test]
631    async fn source_advertises_discover() {
632        use faucet_core::Source as _;
633        // `Client::with_uri_str` does no I/O for a well-formed URI, so the
634        // capability flag is checkable offline; `discover()` against the
635        // unreachable server surfaces the typed discovery error.
636        let config = MongoSourceConfig::new(
637            "mongodb://127.0.0.1:1/?connectTimeoutMS=200&serverSelectionTimeoutMS=200",
638            "db",
639            "c",
640        );
641        let source = MongoSource::new(config).await.expect("client construct");
642        assert!(source.supports_discover());
643        let err = source.discover().await.unwrap_err();
644        assert!(
645            err.to_string().contains("catalog discovery failed"),
646            "typed error: {err}"
647        );
648    }
649}