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
12pub struct MongoSource {
17 config: MongoSourceConfig,
18 client: Client,
19}
20
21impl MongoSource {
22 pub async fn new(config: MongoSourceConfig) -> Result<Self, FaucetError> {
27 faucet_core::validate_batch_size(config.batch_size)?;
28 let client = Client::with_uri_str(&config.connection_uri)
29 .await
30 .map_err(|e| FaucetError::Source(format!("MongoDB connection failed: {e}")))?;
31
32 Ok(Self { config, client })
33 }
34
35 pub async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
37 let db = self.client.database(&self.config.database);
38 let collection = db.collection::<Document>(&self.config.collection);
39
40 let filter = self
41 .config
42 .filter
43 .as_ref()
44 .map(json_value_to_document)
45 .transpose()?;
46
47 let mut find_options = FindOptions::default();
48
49 if let Some(ref proj) = self.config.projection {
50 find_options.projection = Some(json_value_to_document(proj)?);
51 }
52 if let Some(ref sort) = self.config.sort {
53 find_options.sort = Some(json_value_to_document(sort)?);
54 }
55 if let Some(limit) = self.config.limit {
56 find_options.limit = Some(limit);
57 }
58 if let Some(cursor_batch_size) = self.config.cursor_batch_size {
59 find_options.batch_size = Some(cursor_batch_size);
60 }
61
62 let mut cursor = collection
63 .find(filter.unwrap_or_default())
64 .with_options(find_options)
65 .await
66 .map_err(|e| FaucetError::Source(format!("MongoDB find failed: {e}")))?;
67
68 let mut records = Vec::new();
69
70 while cursor
71 .advance()
72 .await
73 .map_err(|e| FaucetError::Source(format!("MongoDB cursor advance failed: {e}")))?
74 {
75 let doc = cursor
76 .deserialize_current()
77 .map_err(|e| FaucetError::Source(format!("MongoDB deserialization failed: {e}")))?;
78
79 let value = bson_document_to_json_value(&doc)?;
80 records.push(value);
81 }
82
83 tracing::info!(
84 records = records.len(),
85 database = %self.config.database,
86 collection = %self.config.collection,
87 "MongoDB fetch complete"
88 );
89
90 Ok(records)
91 }
92}
93
94#[async_trait]
95impl faucet_core::Source for MongoSource {
96 async fn fetch_with_context(
97 &self,
98 context: &std::collections::HashMap<String, serde_json::Value>,
99 ) -> Result<Vec<Value>, FaucetError> {
100 if context.is_empty() {
101 return MongoSource::fetch_all(self).await;
102 }
103
104 let filter = substitute_optional_value(&self.config.filter, context, "filter")?;
106 let projection = substitute_optional_value(&self.config.projection, context, "projection")?;
107 let sort = substitute_optional_value(&self.config.sort, context, "sort")?;
108
109 let db = self.client.database(&self.config.database);
110 let collection = db.collection::<Document>(&self.config.collection);
111
112 let filter_doc = filter.as_ref().map(json_value_to_document).transpose()?;
113
114 let mut find_options = FindOptions::default();
115 if let Some(ref proj) = projection {
116 find_options.projection = Some(json_value_to_document(proj)?);
117 }
118 if let Some(ref s) = sort {
119 find_options.sort = Some(json_value_to_document(s)?);
120 }
121 if let Some(limit) = self.config.limit {
122 find_options.limit = Some(limit);
123 }
124 if let Some(cursor_batch_size) = self.config.cursor_batch_size {
125 find_options.batch_size = Some(cursor_batch_size);
126 }
127
128 let mut cursor = collection
129 .find(filter_doc.unwrap_or_default())
130 .with_options(find_options)
131 .await
132 .map_err(|e| FaucetError::Source(format!("MongoDB find failed: {e}")))?;
133
134 let mut records = Vec::new();
135 while cursor
136 .advance()
137 .await
138 .map_err(|e| FaucetError::Source(format!("MongoDB cursor advance failed: {e}")))?
139 {
140 let doc = cursor
141 .deserialize_current()
142 .map_err(|e| FaucetError::Source(format!("MongoDB deserialization failed: {e}")))?;
143 records.push(bson_document_to_json_value(&doc)?);
144 }
145
146 tracing::info!(
147 records = records.len(),
148 database = %self.config.database,
149 collection = %self.config.collection,
150 "MongoDB fetch complete (with context)"
151 );
152
153 Ok(records)
154 }
155
156 fn stream_pages<'a>(
174 &'a self,
175 context: &'a std::collections::HashMap<String, Value>,
176 _batch_size: usize,
177 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
178 let batch_size = self.config.batch_size;
179
180 Box::pin(async_stream::try_stream! {
181 let (filter, projection, sort) = if context.is_empty() {
184 (
185 self.config.filter.clone(),
186 self.config.projection.clone(),
187 self.config.sort.clone(),
188 )
189 } else {
190 (
191 substitute_optional_value(&self.config.filter, context, "filter")?,
192 substitute_optional_value(&self.config.projection, context, "projection")?,
193 substitute_optional_value(&self.config.sort, context, "sort")?,
194 )
195 };
196
197 let db = self.client.database(&self.config.database);
198 let collection = db.collection::<Document>(&self.config.collection);
199
200 let filter_doc = filter.as_ref().map(json_value_to_document).transpose()?;
201
202 let mut find_options = FindOptions::default();
203 if let Some(ref proj) = projection {
204 find_options.projection = Some(json_value_to_document(proj)?);
205 }
206 if let Some(ref s) = sort {
207 find_options.sort = Some(json_value_to_document(s)?);
208 }
209 if let Some(limit) = self.config.limit {
210 find_options.limit = Some(limit);
211 }
212 if let Some(cursor_batch_size) = self.config.cursor_batch_size {
213 find_options.batch_size = Some(cursor_batch_size);
214 }
215
216 let mut cursor = collection
217 .find(filter_doc.unwrap_or_default())
218 .with_options(find_options)
219 .await
220 .map_err(|e| FaucetError::Source(format!("MongoDB find failed: {e}")))?;
221
222 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
223 let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
224 let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
225 let mut total = 0usize;
226
227 while cursor
228 .advance()
229 .await
230 .map_err(|e| FaucetError::Source(format!("MongoDB cursor advance failed: {e}")))?
231 {
232 let doc = cursor
233 .deserialize_current()
234 .map_err(|e| FaucetError::Source(format!("MongoDB deserialization failed: {e}")))?;
235 buffer.push(bson_document_to_json_value(&doc)?);
236 if buffer.len() >= chunk {
237 let page = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
238 total += page.len();
239 yield StreamPage { records: page, bookmark: None };
240 }
241 }
242 if !buffer.is_empty() {
243 total += buffer.len();
244 yield StreamPage { records: buffer, bookmark: None };
245 }
246
247 tracing::info!(
248 records = total,
249 batch_size,
250 database = %self.config.database,
251 collection = %self.config.collection,
252 "MongoDB source stream complete",
253 );
254 })
255 }
256
257 fn config_schema(&self) -> serde_json::Value {
258 serde_json::to_value(faucet_core::schema_for!(MongoSourceConfig))
259 .expect("schema serialization")
260 }
261
262 fn dataset_uri(&self) -> String {
263 format!(
264 "{}/{}/{}",
265 faucet_core::redact_uri_credentials(&self.config.connection_uri),
266 self.config.database,
267 self.config.collection
268 )
269 }
270}
271
272fn substitute_optional_value(
278 value: &Option<Value>,
279 context: &std::collections::HashMap<String, Value>,
280 field_name: &str,
281) -> Result<Option<Value>, FaucetError> {
282 match value {
283 Some(v) => {
284 let s = serde_json::to_string(v).map_err(|e| {
285 FaucetError::Config(format!("failed to serialize {field_name}: {e}"))
286 })?;
287 let s = faucet_core::util::substitute_context_json(&s, context);
288 let resolved = serde_json::from_str(&s).map_err(|e| {
289 FaucetError::Config(format!("failed to parse substituted {field_name}: {e}"))
290 })?;
291 Ok(Some(resolved))
292 }
293 None => Ok(None),
294 }
295}
296
297fn json_value_to_document(val: &Value) -> Result<Document, FaucetError> {
301 let bson = bson::to_bson(val)
302 .map_err(|e| FaucetError::Config(format!("failed to convert JSON to BSON: {e}")))?;
303 match bson {
304 Bson::Document(doc) => Ok(doc),
305 other => Err(FaucetError::Config(format!(
306 "expected a JSON object, got BSON type: {other:?}"
307 ))),
308 }
309}
310
311fn bson_document_to_json_value(doc: &Document) -> Result<Value, FaucetError> {
313 let bson = Bson::Document(doc.clone());
314 let relaxed = bson.into_relaxed_extjson();
315 Ok(relaxed)
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use serde_json::json;
322
323 #[test]
324 fn json_object_to_document() {
325 let val = json!({"name": "Alice", "age": 30});
326 let doc = json_value_to_document(&val).unwrap();
327 assert_eq!(doc.get_str("name").unwrap(), "Alice");
328 assert_eq!(doc.get_i64("age").unwrap(), 30);
329 }
330
331 #[test]
332 fn json_non_object_to_document_fails() {
333 let val = json!([1, 2, 3]);
334 let result = json_value_to_document(&val);
335 assert!(result.is_err());
336 assert!(matches!(result, Err(FaucetError::Config(_))));
337 }
338
339 #[test]
340 fn json_string_to_document_fails() {
341 let val = json!("not an object");
342 let result = json_value_to_document(&val);
343 assert!(result.is_err());
344 }
345
346 #[test]
347 fn bson_document_roundtrip() {
348 let mut doc = Document::new();
349 doc.insert("name", "Bob");
350 doc.insert("score", 42);
351 let value = bson_document_to_json_value(&doc).unwrap();
352 assert_eq!(value["name"], "Bob");
353 assert_eq!(value["score"], 42);
354 }
355
356 #[test]
357 fn nested_document_conversion() {
358 let val = json!({"user": {"name": "Alice", "tags": ["admin", "user"]}});
359 let doc = json_value_to_document(&val).unwrap();
360 let inner = doc.get_document("user").unwrap();
361 assert_eq!(inner.get_str("name").unwrap(), "Alice");
362
363 let back = bson_document_to_json_value(&doc).unwrap();
364 assert_eq!(back["user"]["name"], "Alice");
365 assert_eq!(back["user"]["tags"][0], "admin");
366 }
367
368 #[test]
369 fn empty_filter_converts() {
370 let val = json!({});
371 let doc = json_value_to_document(&val).unwrap();
372 assert!(doc.is_empty());
373 }
374
375 #[tokio::test]
376 async fn new_rejects_out_of_range_batch_size() {
377 let mut config = MongoSourceConfig::new("mongodb://localhost:27017", "db", "c");
378 config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
379 match MongoSource::new(config).await {
380 Err(faucet_core::FaucetError::Config(m)) => {
381 assert!(m.contains("batch_size"), "got: {m}")
382 }
383 _ => panic!("expected a batch_size Config error"),
384 }
385 }
386
387 #[test]
390 fn dataset_uri_strips_credentials() {
391 let config = MongoSourceConfig::new("mongodb://u:p@h:27017", "mydb", "events");
392 let uri = format!(
393 "{}/{}/{}",
394 faucet_core::redact_uri_credentials(&config.connection_uri),
395 config.database,
396 config.collection
397 );
398 assert_eq!(uri, "mongodb://h:27017/mydb/events");
399 }
400
401 #[test]
404 fn substitute_optional_value_none_passthrough() {
405 let ctx: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
406 let out = substitute_optional_value(&None, &ctx, "filter").unwrap();
407 assert!(out.is_none(), "None input must yield None output");
408 }
409
410 #[test]
411 fn substitute_optional_value_replaces_string_placeholder() {
412 let mut ctx: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
413 ctx.insert("user_id".into(), json!("abc-123"));
415 let filter = Some(json!({"owner": "{user_id}"}));
418 let out = substitute_optional_value(&filter, &ctx, "filter")
419 .unwrap()
420 .expect("Some input yields Some output");
421 assert_eq!(out, json!({"owner": "abc-123"}));
422 }
423
424 #[test]
425 fn substitute_optional_value_no_placeholder_is_identity() {
426 let ctx: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
427 let proj = Some(json!({"_id": 0, "name": 1}));
428 let out = substitute_optional_value(&proj, &ctx, "projection")
429 .unwrap()
430 .unwrap();
431 assert_eq!(out, json!({"_id": 0, "name": 1}));
432 }
433
434 #[test]
435 fn substitute_optional_value_escapes_value_for_json_safety() {
436 let mut ctx: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
439 ctx.insert("name".into(), json!("a\"b"));
440 let filter = Some(json!({"n": "{name}"}));
441 let out = substitute_optional_value(&filter, &ctx, "filter")
442 .unwrap()
443 .unwrap();
444 assert_eq!(out, json!({"n": "a\"b"}));
445 }
446
447 #[test]
450 fn bson_object_id_converts_to_oid_extjson() {
451 use mongodb::bson::oid::ObjectId;
452 let mut doc = Document::new();
453 let oid = ObjectId::parse_str("64ab00112233445566778899").unwrap();
454 doc.insert("_id", oid);
455 let value = bson_document_to_json_value(&doc).unwrap();
456 assert_eq!(value["_id"]["$oid"], "64ab00112233445566778899");
458 }
459
460 #[test]
461 fn bson_datetime_converts_to_date_extjson() {
462 use mongodb::bson::DateTime;
463 let mut doc = Document::new();
464 doc.insert("created_at", DateTime::from_millis(1_000_000));
466 let value = bson_document_to_json_value(&doc).unwrap();
467 assert!(
470 value["created_at"]["$date"].is_string(),
471 "expected $date string, got {value:?}"
472 );
473 }
474
475 #[test]
476 fn bson_null_and_array_and_nested_convert() {
477 use mongodb::bson::Bson;
478 let mut doc = Document::new();
479 doc.insert("missing", Bson::Null);
480 doc.insert("tags", vec!["a", "b"]);
481 let mut nested = Document::new();
482 nested.insert("k", 7i32);
483 doc.insert("nested", nested);
484 let value = bson_document_to_json_value(&doc).unwrap();
485 assert_eq!(value["missing"], Value::Null);
486 assert_eq!(value["tags"], json!(["a", "b"]));
487 assert_eq!(value["nested"]["k"], 7);
488 }
489
490 #[test]
491 fn bson_int64_converts_to_number() {
492 let mut doc = Document::new();
493 doc.insert("big", 9_000_000_000i64);
494 let value = bson_document_to_json_value(&doc).unwrap();
495 assert_eq!(value["big"], json!(9_000_000_000i64));
497 }
498}