1#[cfg(feature = "mongodb-cdc")]
13use std::sync::Arc;
14#[cfg(feature = "mongodb-cdc")]
15use std::time::Duration;
16
17#[cfg(feature = "mongodb-cdc")]
18use arrow_array::{Array, RecordBatch};
19#[cfg(feature = "mongodb-cdc")]
20use arrow_row::SortField;
21#[cfg(feature = "mongodb-cdc")]
22use arrow_schema::{DataType, SchemaRef};
23#[cfg(feature = "mongodb-cdc")]
24use mongodb::bson::{doc, Bson, Document, RawDocumentBuf};
25#[cfg(feature = "mongodb-cdc")]
26use mongodb::{Client, IndexModel};
27
28#[cfg(feature = "mongodb-cdc")]
29use laminar_core::lookup::predicate::Predicate;
30#[cfg(feature = "mongodb-cdc")]
31use laminar_core::lookup::source::{
32 projection_names, ColumnId, LookupError, LookupSource, LookupSourceCapabilities,
33};
34#[cfg(feature = "mongodb-cdc")]
35use laminar_core::lookup::KeyAligner;
36
37#[cfg(feature = "mongodb-cdc")]
38const LOOKUP_SERVER_TIMEOUT: Duration = Duration::from_secs(25);
39#[cfg(feature = "mongodb-cdc")]
40const MAX_LOOKUP_KEYS: usize = 4_096;
41#[cfg(feature = "mongodb-cdc")]
42const MAX_LOOKUP_KEY_BYTES: usize = 4 * 1024 * 1024;
43#[cfg(feature = "mongodb-cdc")]
44const MAX_LOOKUP_COMMAND_BYTES: usize = 8 * 1024 * 1024;
45#[cfg(feature = "mongodb-cdc")]
46const MAX_LOOKUP_RESULT_BYTES: usize = 64 * 1024 * 1024;
47
48#[cfg(feature = "mongodb-cdc")]
49fn sharded_lookup_not_admitted() -> LookupError {
50 LookupError::Internal(
51 "mongodb sharded lookup through mongos/load-balanced topology is not admitted until \
52 shard-key routing and cluster-global uniqueness certification are implemented"
53 .into(),
54 )
55}
56
57#[cfg(feature = "mongodb-cdc")]
58fn validate_lookup_server_topology(hello: &Document) -> Result<(), LookupError> {
59 if matches!(hello.get_str("msg"), Ok("isdbgrid")) {
60 return Err(sharded_lookup_not_admitted());
61 }
62 Ok(())
63}
64
65#[cfg(feature = "mongodb-cdc")]
67#[derive(Debug, Clone)]
68pub struct MongoLookupSourceConfig {
69 pub connection_uri: String,
71 pub database: String,
73 pub collection: String,
75 pub primary_key_columns: Vec<String>,
77 pub schema: SchemaRef,
79}
80
81#[cfg(feature = "mongodb-cdc")]
83pub struct MongoLookupSource {
84 client: Client,
85 database: String,
86 collection: String,
87 pk_field: String,
88 schema: SchemaRef,
89 aligner: KeyAligner,
90}
91
92#[cfg(feature = "mongodb-cdc")]
93fn validate_lookup_keys(keys: &[&[u8]]) -> Result<(), LookupError> {
94 if keys.len() > MAX_LOOKUP_KEYS {
95 return Err(LookupError::Query(format!(
96 "mongodb lookup received {} keys, exceeding the fixed {MAX_LOOKUP_KEYS}-key batch limit",
97 keys.len()
98 )));
99 }
100 let bytes = keys.iter().try_fold(0_usize, |total, key| {
101 total
102 .checked_add(key.len())
103 .ok_or_else(|| LookupError::Query("mongodb lookup key byte count overflow".into()))
104 })?;
105 if bytes > MAX_LOOKUP_KEY_BYTES {
106 return Err(LookupError::Query(format!(
107 "mongodb lookup received {bytes} key bytes, exceeding the fixed {MAX_LOOKUP_KEY_BYTES}-byte batch limit"
108 )));
109 }
110 Ok(())
111}
112
113#[cfg(feature = "mongodb-cdc")]
114fn validate_lookup_command(
115 database: &str,
116 collection: &str,
117 filter: &Document,
118 projection: Option<&Document>,
119 limit: i64,
120) -> Result<(), LookupError> {
121 #[derive(serde::Serialize)]
122 #[serde(rename_all = "camelCase")]
123 struct FindCommand<'a> {
124 find: &'a str,
125 #[serde(rename = "$db")]
126 database: &'a str,
127 filter: &'a Document,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 projection: Option<&'a Document>,
130 limit: i64,
131 #[serde(rename = "maxTimeMS")]
132 max_time_ms: u64,
133 }
134
135 let max_time_ms = u64::try_from(LOOKUP_SERVER_TIMEOUT.as_millis()).map_err(|_| {
136 LookupError::Internal("mongodb lookup server timeout exceeds u64 milliseconds".into())
137 })?;
138 let command = FindCommand {
139 find: collection,
140 database,
141 filter,
142 projection,
143 limit,
144 max_time_ms,
145 };
146 let bytes = mongodb::bson::to_vec(&command)
147 .map_err(|error| LookupError::Query(format!("serialize mongodb find command: {error}")))?;
148 if bytes.len() > MAX_LOOKUP_COMMAND_BYTES {
149 return Err(LookupError::Query(format!(
150 "mongodb lookup command is {} bytes, exceeding the fixed {MAX_LOOKUP_COMMAND_BYTES}-byte limit",
151 bytes.len()
152 )));
153 }
154 Ok(())
155}
156
157#[cfg(feature = "mongodb-cdc")]
158fn has_usable_unique_lookup_index(index: &IndexModel, pk_field: &str) -> bool {
159 if index.keys.len() != 1 {
160 return false;
161 }
162 let Some((field, direction)) = index.keys.iter().next() else {
163 return false;
164 };
165 let is_ascending_or_descending = match direction {
166 Bson::Int32(value) => *value == 1 || *value == -1,
167 Bson::Int64(value) => *value == 1 || *value == -1,
168 Bson::Double(value) => value.abs().total_cmp(&1.0).is_eq(),
169 _ => false,
170 };
171 if field != pk_field || !is_ascending_or_descending {
172 return false;
173 }
174
175 let Some(options) = index.options.as_ref() else {
176 return false;
177 };
178 if options.hidden == Some(true)
179 || options.partial_filter_expression.is_some()
180 || options.collation.is_some()
181 {
182 return false;
183 }
184
185 options.unique == Some(true) || (pk_field == "_id" && options.name.as_deref() == Some("_id_"))
186}
187
188#[cfg(feature = "mongodb-cdc")]
189async fn verify_unique_lookup_index(
190 client: Client,
191 database: String,
192 collection: String,
193 pk_field: String,
194) -> Result<(), LookupError> {
195 use tokio_stream::StreamExt;
196
197 let hello = client
198 .database("admin")
199 .run_command(doc! { "hello": 1 })
200 .await
201 .map_err(|error| {
202 LookupError::Connection(format!("inspect mongodb lookup topology: {error}"))
203 })?;
204 validate_lookup_server_topology(&hello)?;
205
206 let collection_handle = client
207 .database(&database)
208 .collection::<Document>(&collection);
209 let mut indexes = collection_handle
210 .list_indexes()
211 .max_time(LOOKUP_SERVER_TIMEOUT)
212 .await
213 .map_err(|error| {
214 LookupError::Connection(format!(
215 "inspect mongodb lookup indexes for {database}.{collection}: {error}"
216 ))
217 })?;
218 while let Some(next) = indexes.next().await {
219 let index = next.map_err(|error| {
220 LookupError::Connection(format!(
221 "read mongodb lookup indexes for {database}.{collection}: {error}"
222 ))
223 })?;
224 if has_usable_unique_lookup_index(&index, &pk_field) {
225 return Ok(());
226 }
227 }
228
229 Err(LookupError::Internal(format!(
230 "mongodb lookup requires a visible, non-partial, simple-collation unique single-field ascending/descending index on '{pk_field}' in {database}.{collection}"
231 )))
232}
233
234#[cfg(feature = "mongodb-cdc")]
235impl MongoLookupSource {
236 pub async fn open(config: MongoLookupSourceConfig) -> Result<Self, LookupError> {
243 if config.connection_uri.trim().is_empty()
244 || config.database.trim().is_empty()
245 || config.collection.trim().is_empty()
246 {
247 return Err(LookupError::Internal(
248 "mongodb lookup requires non-empty connection_uri, database, and collection".into(),
249 ));
250 }
251 if config.collection == "*" {
252 return Err(LookupError::Internal(
253 "mongodb lookup does not support collection='*'".into(),
254 ));
255 }
256 if config.primary_key_columns.len() != 1 {
257 return Err(LookupError::Internal(format!(
258 "mongodb lookup requires exactly one primary key column, got {}",
259 config.primary_key_columns.len()
260 )));
261 }
262 let pk_field = config.primary_key_columns[0].clone();
263
264 let pk_idx = config.schema.index_of(&pk_field).map_err(|_| {
265 LookupError::Internal(format!("pk column not in declared schema: {pk_field}"))
266 })?;
267
268 for field in config.schema.fields() {
269 match field.data_type() {
270 DataType::Int32
271 | DataType::Int64
272 | DataType::Float64
273 | DataType::Boolean
274 | DataType::Utf8
275 | DataType::LargeUtf8 => {}
276 dt => {
277 return Err(LookupError::Internal(format!(
278 "unsupported field data type in schema for mongodb lookup: {dt}"
279 )));
280 }
281 }
282 }
283
284 let pk_sort_fields = vec![SortField::new(
285 config.schema.field(pk_idx).data_type().clone(),
286 )];
287 let aligner = KeyAligner::new(pk_sort_fields, config.primary_key_columns)?;
288
289 let connection_uri = config.connection_uri.clone();
290 let mut client_options =
291 tokio::spawn(
292 async move { mongodb::options::ClientOptions::parse(&connection_uri).await },
293 )
294 .await
295 .map_err(|error| {
296 LookupError::Internal(format!("mongodb client-options task failed: {error}"))
297 })?
298 .map_err(|e| LookupError::Connection(format!("mongodb client options: {e}")))?;
299 if client_options.load_balanced == Some(true) {
300 return Err(sharded_lookup_not_admitted());
301 }
302 super::sink::harden_mongodb_tls(&mut client_options)
303 .map_err(|e| LookupError::Connection(e.to_string()))?;
304 client_options.connect_timeout = Some(
305 client_options
306 .connect_timeout
307 .unwrap_or(LOOKUP_SERVER_TIMEOUT)
308 .min(LOOKUP_SERVER_TIMEOUT),
309 );
310 client_options.server_selection_timeout = Some(
311 client_options
312 .server_selection_timeout
313 .unwrap_or(LOOKUP_SERVER_TIMEOUT)
314 .min(LOOKUP_SERVER_TIMEOUT),
315 );
316 let client = Client::with_options(client_options)
317 .map_err(|e| LookupError::Connection(format!("mongodb client: {e}")))?;
318
319 let index_client = client.clone();
320 let index_database = config.database.clone();
321 let index_collection = config.collection.clone();
322 let index_pk_field = pk_field.clone();
323 tokio::spawn(async move {
324 verify_unique_lookup_index(
325 index_client,
326 index_database,
327 index_collection,
328 index_pk_field,
329 )
330 .await
331 })
332 .await
333 .map_err(|error| {
334 LookupError::Internal(format!("mongodb index-check task failed: {error}"))
335 })??;
336
337 Ok(Self {
338 client,
339 database: config.database,
340 collection: config.collection,
341 pk_field,
342 schema: config.schema,
343 aligner,
344 })
345 }
346
347 fn cell_to_bson(array: &dyn Array, row: usize) -> Result<Option<Bson>, LookupError> {
350 use arrow_array::{
351 BooleanArray, Float64Array, Int32Array, Int64Array, LargeStringArray, StringArray,
352 };
353
354 fn downcast<T: 'static>(array: &dyn Array) -> Result<&T, LookupError> {
355 array
356 .as_any()
357 .downcast_ref::<T>()
358 .ok_or_else(|| LookupError::Internal("pk column downcast failed".into()))
359 }
360
361 if array.is_null(row) {
362 return Ok(None);
363 }
364
365 let bson = match array.data_type() {
366 DataType::Int32 => Bson::Int32(downcast::<Int32Array>(array)?.value(row)),
367 DataType::Int64 => Bson::Int64(downcast::<Int64Array>(array)?.value(row)),
368 DataType::Float64 => Bson::Double(downcast::<Float64Array>(array)?.value(row)),
369 DataType::Boolean => Bson::Boolean(downcast::<BooleanArray>(array)?.value(row)),
370 DataType::Utf8 => Bson::String(downcast::<StringArray>(array)?.value(row).to_string()),
371 DataType::LargeUtf8 => {
372 Bson::String(downcast::<LargeStringArray>(array)?.value(row).to_string())
373 }
374 dt => {
375 return Err(LookupError::Internal(format!(
376 "unsupported PK data type for mongodb lookup: {dt}"
377 )));
378 }
379 };
380 Ok(Some(bson))
381 }
382
383 fn docs_to_batch(schema: &SchemaRef, docs: &[Document]) -> Result<RecordBatch, LookupError> {
387 use arrow_array::builder::{
388 BooleanBuilder, Float64Builder, Int32Builder, Int64Builder, LargeStringBuilder,
389 StringBuilder,
390 };
391
392 let mut columns: Vec<Arc<dyn Array>> = Vec::with_capacity(schema.fields().len());
393 for field in schema.fields() {
394 let name = field.name().as_str();
395 let array: Arc<dyn Array> = match field.data_type() {
396 DataType::Int32 => {
397 let mut b = Int32Builder::with_capacity(docs.len());
398 for d in docs {
399 b.append_option(
400 bson_as_i64(d.get(name)).and_then(|v| i32::try_from(v).ok()),
401 );
402 }
403 Arc::new(b.finish())
404 }
405 DataType::Int64 => {
406 let mut b = Int64Builder::with_capacity(docs.len());
407 for d in docs {
408 b.append_option(bson_as_i64(d.get(name)));
409 }
410 Arc::new(b.finish())
411 }
412 DataType::Float64 => {
413 let mut b = Float64Builder::with_capacity(docs.len());
414 for d in docs {
415 b.append_option(bson_as_f64(d.get(name)));
416 }
417 Arc::new(b.finish())
418 }
419 DataType::Boolean => {
420 let mut b = BooleanBuilder::with_capacity(docs.len());
421 for d in docs {
422 b.append_option(d.get(name).and_then(Bson::as_bool));
423 }
424 Arc::new(b.finish())
425 }
426 DataType::LargeUtf8 => {
427 let mut b = LargeStringBuilder::with_capacity(docs.len(), docs.len() * 16);
428 for d in docs {
429 match d.get(name) {
430 None | Some(Bson::Null) => b.append_null(),
431 Some(v) => b.append_value(bson_to_string(v)),
432 }
433 }
434 Arc::new(b.finish())
435 }
436 DataType::Utf8 => {
437 let mut b = StringBuilder::with_capacity(docs.len(), docs.len() * 16);
438 for d in docs {
439 match d.get(name) {
440 None | Some(Bson::Null) => b.append_null(),
441 Some(v) => b.append_value(bson_to_string(v)),
442 }
443 }
444 Arc::new(b.finish())
445 }
446 _ => {
447 return Err(LookupError::Internal(format!(
448 "unsupported field data type: {:?}",
449 field.data_type()
450 )));
451 }
452 };
453 columns.push(array);
454 }
455 RecordBatch::try_new(Arc::clone(schema), columns)
456 .map_err(|e| LookupError::Internal(format!("arrow batch construction: {e}")))
457 }
458}
459
460#[cfg(feature = "mongodb-cdc")]
461impl LookupSource for MongoLookupSource {
462 async fn query(
463 &self,
464 keys: &[&[u8]],
465 _predicates: &[Predicate],
466 projection: &[ColumnId],
467 ) -> Result<Vec<Option<RecordBatch>>, LookupError> {
468 use tokio_stream::StreamExt;
469
470 if keys.is_empty() {
471 return Ok(Vec::new());
472 }
473 validate_lookup_keys(keys)?;
474
475 let pk_arrays = self.aligner.decode_keys(keys)?;
476 let pk_array = pk_arrays[0].as_ref();
477 let mut in_values: Vec<Bson> = Vec::with_capacity(keys.len());
478 for row in 0..pk_array.len() {
479 if let Some(b) = Self::cell_to_bson(pk_array, row)? {
480 in_values.push(b);
481 }
482 }
483 if in_values.is_empty() {
484 return Ok(vec![None; keys.len()]);
485 }
486 let result_limit = i64::try_from(in_values.len()).map_err(|_| {
487 LookupError::Internal("mongodb lookup key count exceeds i64::MAX".into())
488 })?;
489
490 let filter = doc! { &self.pk_field: doc! { "$in": in_values } };
491
492 let mut project_needed = false;
495 let (out_schema, projection_document) =
496 if projection.is_empty() {
497 (Arc::clone(&self.schema), None)
498 } else {
499 let mut names = projection_names(&self.schema, projection)?;
500 let mut idx: Vec<usize> = projection.iter().map(|&c| c as usize).collect();
501 if !names.contains(&self.pk_field) {
502 names.push(self.pk_field.clone());
503 let pk_idx = self
504 .schema
505 .index_of(&self.pk_field)
506 .map_err(|e| LookupError::Internal(format!("pk column index: {e}")))?;
507 idx.push(pk_idx);
508 project_needed = true;
509 }
510
511 let mut proj_doc = Document::new();
512 for name in &names {
513 proj_doc.insert(name.clone(), 1);
514 }
515 (
516 Arc::new(self.schema.project(&idx).map_err(|e| {
517 LookupError::Internal(format!("project mongodb schema: {e}"))
518 })?),
519 Some(proj_doc),
520 )
521 };
522
523 validate_lookup_command(
524 &self.database,
525 &self.collection,
526 &filter,
527 projection_document.as_ref(),
528 result_limit,
529 )?;
530
531 let client = self.client.clone();
534 let database = self.database.clone();
535 let collection = self.collection.clone();
536 let task_schema = Arc::clone(&out_schema);
537 let batches = tokio::spawn(async move {
538 let collection = client
539 .database(&database)
540 .collection::<RawDocumentBuf>(&collection);
541 let mut find = collection
542 .find(filter)
543 .limit(result_limit)
544 .max_time(LOOKUP_SERVER_TIMEOUT);
545 if let Some(projection) = projection_document {
546 find = find.projection(projection);
547 }
548 let mut cursor = find
549 .await
550 .map_err(|e| LookupError::Query(format!("mongodb find: {e}")))?;
551 let mut docs: Vec<Document> = Vec::new();
552 let mut result_bytes = 0_usize;
553 while let Some(next) = cursor.next().await {
554 let raw = next.map_err(|e| LookupError::Query(format!("mongodb cursor: {e}")))?;
555 result_bytes = result_bytes
556 .checked_add(raw.as_bytes().len())
557 .ok_or_else(|| {
558 LookupError::Query("mongodb lookup result byte count overflow".into())
559 })?;
560 if result_bytes > MAX_LOOKUP_RESULT_BYTES {
561 return Err(LookupError::Query(format!(
562 "mongodb lookup result is at least {result_bytes} bytes, exceeding the fixed {MAX_LOOKUP_RESULT_BYTES}-byte limit"
563 )));
564 }
565 docs.push(raw.to_document().map_err(|error| {
566 LookupError::Query(format!("decode mongodb lookup result: {error}"))
567 })?);
568 }
569 if docs.is_empty() {
570 Ok(Vec::new())
571 } else {
572 Ok(vec![Self::docs_to_batch(&task_schema, &docs)?])
573 }
574 })
575 .await
576 .map_err(|error| LookupError::Internal(format!("mongodb lookup task failed: {error}")))??;
577 let aligned = self.aligner.align(keys, &batches)?;
578
579 if project_needed {
580 let orig_names = projection_names(&self.schema, projection)?;
581 let mut projected_aligned = Vec::with_capacity(aligned.len());
582 for maybe_batch in aligned {
583 if let Some(batch) = maybe_batch {
584 let indices: Vec<usize> = orig_names
585 .iter()
586 .map(|name| {
587 batch.schema().index_of(name).map_err(|e| {
588 LookupError::Internal(format!(
589 "column not found in aligned schema: {e}"
590 ))
591 })
592 })
593 .collect::<Result<Vec<usize>, LookupError>>()?;
594 let projected = batch.project(&indices).map_err(|e| {
595 LookupError::Internal(format!("project aligned batch: {e}"))
596 })?;
597 projected_aligned.push(Some(projected));
598 } else {
599 projected_aligned.push(None);
600 }
601 }
602 Ok(projected_aligned)
603 } else {
604 Ok(aligned)
605 }
606 }
607
608 fn capabilities(&self) -> LookupSourceCapabilities {
609 LookupSourceCapabilities {
610 supports_batch_lookup: true,
611 supports_projection_pushdown: true,
612 max_batch_size: MAX_LOOKUP_KEYS,
613 ..LookupSourceCapabilities::none()
614 }
615 }
616
617 fn source_name(&self) -> &'static str {
618 "mongodb"
619 }
620
621 fn schema(&self) -> SchemaRef {
622 Arc::clone(&self.schema)
623 }
624
625 async fn health_check(&self) -> Result<(), LookupError> {
626 let client = self.client.clone();
627 let database = self.database.clone();
628 tokio::spawn(async move {
629 client
630 .database(&database)
631 .run_command(doc! { "ping": 1 })
632 .await
633 .map(|_| ())
634 .map_err(|e| LookupError::Connection(format!("health check: {e}")))
635 })
636 .await
637 .map_err(|error| LookupError::Internal(format!("mongodb health task failed: {error}")))?
638 }
639}
640
641#[cfg(feature = "mongodb-cdc")]
643fn bson_as_i64(b: Option<&Bson>) -> Option<i64> {
644 match b? {
645 Bson::Int32(v) => Some(i64::from(*v)),
646 Bson::Int64(v) => Some(*v),
647 #[allow(clippy::cast_possible_truncation)]
648 Bson::Double(v) => Some(*v as i64),
649 _ => None,
650 }
651}
652
653#[cfg(feature = "mongodb-cdc")]
655fn bson_as_f64(b: Option<&Bson>) -> Option<f64> {
656 match b? {
657 Bson::Double(v) => Some(*v),
658 Bson::Int32(v) => Some(f64::from(*v)),
659 #[allow(clippy::cast_precision_loss)]
660 Bson::Int64(v) => Some(*v as f64),
661 _ => None,
662 }
663}
664
665#[cfg(feature = "mongodb-cdc")]
667fn bson_to_string(b: &Bson) -> String {
668 match b {
669 Bson::String(s) => s.clone(),
670 Bson::ObjectId(oid) => oid.to_hex(),
671 Bson::Int32(v) => v.to_string(),
672 Bson::Int64(v) => v.to_string(),
673 Bson::Double(v) => v.to_string(),
674 Bson::Boolean(v) => v.to_string(),
675 other => other.to_string(),
676 }
677}
678
679#[cfg(all(test, feature = "mongodb-cdc"))]
680mod tests {
681 use super::*;
682 use arrow_array::{Int64Array, StringArray};
683 use arrow_schema::{Field, Schema};
684 use mongodb::options::{Collation, IndexOptions};
685
686 #[test]
687 fn cell_to_bson_types_and_null() {
688 assert_eq!(
689 MongoLookupSource::cell_to_bson(&Int64Array::from(vec![7i64]), 0).unwrap(),
690 Some(Bson::Int64(7))
691 );
692 assert_eq!(
693 MongoLookupSource::cell_to_bson(&StringArray::from(vec!["k"]), 0).unwrap(),
694 Some(Bson::String("k".into()))
695 );
696 let nullable = Int64Array::from(vec![None, Some(1)]);
697 assert!(MongoLookupSource::cell_to_bson(&nullable, 0)
698 .unwrap()
699 .is_none());
700 }
701
702 #[test]
703 fn cell_to_bson_rejects_unsupported_type() {
704 assert!(
705 MongoLookupSource::cell_to_bson(&arrow_array::Date32Array::from(vec![1]), 0).is_err()
706 );
707 }
708
709 #[test]
710 fn bson_numeric_coercion() {
711 assert_eq!(bson_as_i64(Some(&Bson::Double(3.9))), Some(3));
712 assert_eq!(bson_as_f64(Some(&Bson::Int64(5))), Some(5.0));
713 assert_eq!(bson_as_i64(Some(&Bson::String("x".into()))), None);
714 assert_eq!(bson_as_i64(None), None);
715 }
716
717 #[test]
718 fn lookup_key_limits_are_enforced_before_decode() {
719 assert!(validate_lookup_keys(&[&b"a"[..], &b"bc"[..]]).is_ok());
720
721 let too_many = vec![&b""[..]; MAX_LOOKUP_KEYS + 1];
722 assert!(validate_lookup_keys(&too_many).is_err());
723
724 let oversized = vec![0_u8; MAX_LOOKUP_KEY_BYTES + 1];
725 assert!(validate_lookup_keys(&[oversized.as_slice()]).is_err());
726 }
727
728 #[test]
729 fn lookup_command_limit_leaves_bson_headroom() {
730 let small = doc! { "id": doc! { "$in": [1_i64, 2_i64] } };
731 assert!(validate_lookup_command("db", "items", &small, None, 2).is_ok());
732
733 let oversized = doc! {
734 "id": doc! { "$in": ["x".repeat(MAX_LOOKUP_COMMAND_BYTES)] }
735 };
736 let error = validate_lookup_command("db", "items", &oversized, None, 1)
737 .expect_err("oversized command must be rejected before I/O");
738 assert!(error.to_string().contains("lookup command"));
739 }
740
741 #[test]
742 fn lookup_index_must_uniquely_cover_the_key() {
743 let unique = IndexModel::builder()
744 .keys(doc! { "id": 1 })
745 .options(IndexOptions::builder().unique(true).build())
746 .build();
747 assert!(has_usable_unique_lookup_index(&unique, "id"));
748
749 let non_unique = IndexModel::builder().keys(doc! { "id": 1 }).build();
750 assert!(!has_usable_unique_lookup_index(&non_unique, "id"));
751
752 let compound = IndexModel::builder()
753 .keys(doc! { "id": 1, "tenant": 1 })
754 .options(IndexOptions::builder().unique(true).build())
755 .build();
756 assert!(!has_usable_unique_lookup_index(&compound, "id"));
757
758 let partial = IndexModel::builder()
759 .keys(doc! { "id": 1 })
760 .options(
761 IndexOptions::builder()
762 .unique(true)
763 .partial_filter_expression(doc! { "active": true })
764 .build(),
765 )
766 .build();
767 assert!(!has_usable_unique_lookup_index(&partial, "id"));
768
769 let hidden = IndexModel::builder()
770 .keys(doc! { "id": 1 })
771 .options(IndexOptions::builder().unique(true).hidden(true).build())
772 .build();
773 assert!(!has_usable_unique_lookup_index(&hidden, "id"));
774
775 let collated = IndexModel::builder()
776 .keys(doc! { "id": 1 })
777 .options(
778 IndexOptions::builder()
779 .unique(true)
780 .collation(Collation::builder().locale("en").build())
781 .build(),
782 )
783 .build();
784 assert!(!has_usable_unique_lookup_index(&collated, "id"));
785
786 let hashed = IndexModel::builder()
787 .keys(doc! { "id": "hashed" })
788 .options(IndexOptions::builder().unique(true).build())
789 .build();
790 assert!(!has_usable_unique_lookup_index(&hashed, "id"));
791
792 let implicit_id = IndexModel::builder()
793 .keys(doc! { "_id": 1 })
794 .options(
795 IndexOptions::builder()
796 .name(Some("_id_".to_owned()))
797 .build(),
798 )
799 .build();
800 assert!(has_usable_unique_lookup_index(&implicit_id, "_id"));
801 }
802
803 #[test]
804 fn mongos_topology_is_rejected_before_index_admission() {
805 let error = validate_lookup_server_topology(&doc! {
806 "msg": "isdbgrid",
807 "isWritablePrimary": true
808 })
809 .expect_err("mongos cannot prove cluster-global key uniqueness");
810 assert!(error.to_string().contains("shard-key routing"));
811 assert!(error.to_string().contains("cluster-global uniqueness"));
812
813 assert!(validate_lookup_server_topology(&doc! {
814 "setName": "rs0",
815 "isWritablePrimary": true
816 })
817 .is_ok());
818 assert!(validate_lookup_server_topology(&doc! {
819 "isWritablePrimary": true
820 })
821 .is_ok());
822 }
823
824 #[tokio::test]
825 async fn load_balanced_topology_is_rejected_before_client_construction() {
826 let config = MongoLookupSourceConfig {
827 connection_uri: "mongodb://localhost:27017/?loadBalanced=true&tls=false".into(),
828 database: "db".into(),
829 collection: "items".into(),
830 primary_key_columns: vec!["id".into()],
831 schema: Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])),
832 };
833 let error = MongoLookupSource::open(config)
834 .await
835 .err()
836 .expect("load-balanced lookup must fail before server selection");
837 assert!(error.to_string().contains("load-balanced topology"));
838 assert!(error.to_string().contains("cluster-global uniqueness"));
839 }
840
841 #[tokio::test]
842 async fn wildcard_collection_is_rejected_before_network_io() {
843 let config = MongoLookupSourceConfig {
844 connection_uri: "mongodb://localhost:27017".into(),
845 database: "db".into(),
846 collection: "*".into(),
847 primary_key_columns: vec!["id".into()],
848 schema: Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])),
849 };
850 let error = MongoLookupSource::open(config)
851 .await
852 .err()
853 .expect("wildcard lookup must be rejected");
854 assert!(error.to_string().contains("collection='*'"));
855 }
856}