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 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 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 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
334fn 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
358fn 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
383fn 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
397fn 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 #[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 #[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 ctx.insert("user_id".into(), json!("abc-123"));
501 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 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 #[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 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 doc.insert("created_at", DateTime::from_millis(1_000_000));
552 let value = bson_document_to_json_value(&doc).unwrap();
553 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 assert_eq!(value["big"], json!(9_000_000_000i64));
583 }
584
585 #[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 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}