1use 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
12const DISCOVER_SAMPLE_SIZE: i64 = 10;
15
16pub struct MongoSource {
21 config: MongoSourceConfig,
22 client: Client,
23}
24
25impl MongoSource {
26 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 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 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 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 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 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 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
338fn 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
362fn 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
387fn 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
401fn 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 #[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 #[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 ctx.insert("user_id".into(), json!("abc-123"));
505 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 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 #[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 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 doc.insert("created_at", DateTime::from_millis(1_000_000));
556 let value = bson_document_to_json_value(&doc).unwrap();
557 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 assert_eq!(value["big"], json!(9_000_000_000i64));
587 }
588
589 #[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 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}