1use crate::config::{GcsFileFormat, GcsSourceConfig};
4use async_trait::async_trait;
5use faucet_common_gcs::{build_storage, build_storage_control};
6use faucet_core::shard::{HashShard, ShardSpec, parse_hash_shard, plan_hash_shards};
7use faucet_core::{FaucetError, Stream, StreamPage};
8use futures::stream::{self, StreamExt, TryStreamExt};
9use google_cloud_gax::paginator::ItemPaginator;
10use google_cloud_storage::client::{Storage, StorageControl};
11use serde_json::Value;
12use std::pin::Pin;
13use std::sync::Mutex;
14use tokio::io::AsyncBufReadExt;
15
16pub struct GcsSource {
18 config: GcsSourceConfig,
19 storage: Storage,
20 control: StorageControl,
21 applied_shard: Mutex<Option<HashShard>>,
25}
26
27impl GcsSource {
28 pub async fn new(config: GcsSourceConfig) -> Result<Self, FaucetError> {
31 let storage = build_storage(&config.auth, config.storage_host.as_deref()).await?;
32 let control = build_storage_control(&config.auth, config.storage_host.as_deref()).await?;
33 Ok(Self {
34 config,
35 storage,
36 control,
37 applied_shard: Mutex::new(None),
38 })
39 }
40
41 fn shard_filter(&self, keys: Vec<String>) -> Vec<String> {
44 filter_shard_keys(
45 keys,
46 *self.applied_shard.lock().expect("shard mutex poisoned"),
47 )
48 }
49
50 fn bucket_path(&self) -> String {
52 format!("projects/_/buckets/{}", self.config.bucket)
53 }
54
55 async fn list_object_names(
58 &self,
59 prefix_override: Option<&str>,
60 ) -> Result<Vec<String>, FaucetError> {
61 if let Some(ref keys) = self.config.object_keys {
62 return Ok(self.shard_filter(cap_keys(keys.clone(), self.config.max_objects)));
63 }
64
65 let effective_prefix = prefix_override.or(self.config.prefix.as_deref());
66 let mut req = self.control.list_objects().set_parent(self.bucket_path());
67 if let Some(p) = effective_prefix {
68 req = req.set_prefix(p.to_string());
69 }
70 req = req.set_page_size(1000_i32);
71
72 let mut paginator = req.by_item();
73 let mut names: Vec<String> = Vec::new();
74 while let Some(item) = paginator.next().await {
75 let object = item.map_err(|e| {
76 FaucetError::Source(format!(
77 "GCS list error for bucket '{}': {e}",
78 self.config.bucket
79 ))
80 })?;
81 if object.name.is_empty() {
82 continue;
83 }
84 names.push(object.name);
85 if let Some(max) = self.config.max_objects
86 && names.len() >= max
87 {
88 break;
89 }
90 }
91 Ok(self.shard_filter(names))
95 }
96
97 async fn read_object_text(&self, key: &str) -> Result<String, FaucetError> {
99 use tokio::io::AsyncReadExt as _;
105 let mut reader = self.open_object_reader(key).await?;
106 let mut text = String::new();
107 reader.read_to_string(&mut text).await.map_err(|e| {
108 FaucetError::Source(format!(
109 "GCS read/decode error for key '{key}' (not valid UTF-8?): {e}"
110 ))
111 })?;
112 Ok(text)
113 }
114
115 async fn open_object_reader(
120 &self,
121 key: &str,
122 ) -> Result<std::pin::Pin<Box<dyn tokio::io::AsyncBufRead + Send + Unpin>>, FaucetError> {
123 let resp = self
124 .storage
125 .read_object(self.bucket_path(), key.to_string())
126 .send()
127 .await
128 .map_err(|e| {
129 FaucetError::Source(format!(
130 "GCS get error for bucket '{}' key '{key}': {e}",
131 self.config.bucket
132 ))
133 })?;
134 let highlights = resp.object();
138 let mut checks: Vec<Box<dyn faucet_core::IntegrityCheck>> = Vec::new();
139 match crate::verify::length_check(
140 highlights.size,
141 &highlights.content_encoding,
142 self.config.verify_length,
143 ) {
144 Some(check) => checks.push(check),
145 None if self.config.verify_length => tracing::debug!(
146 key = %key,
147 size = highlights.size,
148 content_encoding = %highlights.content_encoding,
149 "GCS object length verification skipped (no size or transcoded encoding)"
150 ),
151 None => {}
152 }
153 if self.config.verify_checksum {
154 let (crc32c, md5) = match &highlights.checksums {
155 Some(c) => (c.crc32c, c.md5_hash.clone()),
156 None => (None, bytes::Bytes::new()),
157 };
158 match crate::verify::checksum_check(crc32c, &md5, &highlights.content_encoding) {
159 Some(check) => checks.push(check),
160 None if highlights.content_encoding.is_empty() => tracing::warn!(
161 key = %key,
162 "verify_checksum is enabled but GCS advertised no verifiable checksum for \
163 this object; relying on the length check only"
164 ),
165 None => {}
166 }
167 }
168
169 let bytes_stream = resp
170 .into_stream()
171 .map_err(|e| std::io::Error::other(e.to_string()));
172 let verified = faucet_core::VerifyingReader::new(
175 tokio_util::io::StreamReader::new(bytes_stream),
176 checks,
177 );
178 let buffered = tokio::io::BufReader::new(verified);
179 #[cfg(feature = "compression")]
180 {
181 let codec = self.config.compression.resolve(key);
182 faucet_core::compression::warn_mismatch(key, codec);
183 Ok(faucet_core::compression::wrap_async_reader(buffered, codec))
184 }
185 #[cfg(not(feature = "compression"))]
186 {
187 Ok(Box::pin(buffered))
188 }
189 }
190
191 fn parse_content(&self, key: &str, text: &str) -> Result<Vec<Value>, FaucetError> {
193 parse_file_content(&self.config.file_format, key, text)
194 }
195
196 #[cfg(feature = "arrow")]
202 async fn read_object_bytes(&self, key: &str) -> Result<bytes::Bytes, FaucetError> {
203 use tokio::io::AsyncReadExt as _;
204 let mut reader = self.open_object_reader(key).await?;
205 let mut buf = Vec::new();
206 reader
207 .read_to_end(&mut buf)
208 .await
209 .map_err(|e| FaucetError::Source(format!("GCS read error for key '{key}': {e}")))?;
210 Ok(bytes::Bytes::from(buf))
211 }
212
213 #[cfg(feature = "arrow")]
218 async fn read_object_parquet(
219 &self,
220 key: &str,
221 ) -> Result<(arrow::datatypes::SchemaRef, Vec<arrow::array::RecordBatch>), FaucetError> {
222 let data = self.read_object_bytes(key).await?;
223 let key_owned = key.to_string();
224 tokio::task::spawn_blocking(move || decode_parquet_bytes(data, &key_owned))
225 .await
226 .map_err(|e| {
227 FaucetError::Source(format!("parquet decode task for '{key}' panicked: {e}"))
228 })?
229 }
230}
231
232pub(crate) fn parse_file_content(
239 format: &GcsFileFormat,
240 key: &str,
241 text: &str,
242) -> Result<Vec<Value>, FaucetError> {
243 match format {
244 GcsFileFormat::JsonLines => {
245 let mut records = Vec::new();
246 for (line_num, line) in text.lines().enumerate() {
247 let trimmed = line.trim();
248 if trimmed.is_empty() {
249 continue;
250 }
251 let value: Value = serde_json::from_str(trimmed).map_err(|e| {
252 FaucetError::Source(format!(
253 "GCS JSON parse error in '{key}' at line {}: {e}",
254 line_num + 1
255 ))
256 })?;
257 records.push(value);
258 }
259 Ok(records)
260 }
261 GcsFileFormat::JsonArray => {
262 let value: Value = serde_json::from_str(text).map_err(|e| {
263 FaucetError::Source(format!("GCS JSON parse error in '{key}': {e}"))
264 })?;
265 match value {
266 Value::Array(arr) => Ok(arr),
267 other => Err(FaucetError::Source(format!(
268 "GCS expected JSON array in '{key}', got {}",
269 value_type_name(&other)
270 ))),
271 }
272 }
273 GcsFileFormat::RawText => Ok(vec![serde_json::json!({
274 "key": key,
275 "content": text,
276 })]),
277 #[cfg(feature = "arrow")]
281 GcsFileFormat::Parquet => Err(FaucetError::Source(format!(
282 "GCS parquet object '{key}' cannot be parsed as text (internal error: \
283 parquet must use the binary decode path)"
284 ))),
285 }
286}
287
288#[cfg(feature = "arrow")]
295fn decode_parquet_bytes(
296 data: bytes::Bytes,
297 key: &str,
298) -> Result<(arrow::datatypes::SchemaRef, Vec<arrow::array::RecordBatch>), FaucetError> {
299 use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
300
301 let builder = ParquetRecordBatchReaderBuilder::try_new(data).map_err(|e| {
302 FaucetError::Source(format!("failed to read parquet metadata for '{key}': {e}"))
303 })?;
304 let schema = builder.schema().clone();
305 let reader = builder.build().map_err(|e| {
306 FaucetError::Source(format!("failed to build parquet reader for '{key}': {e}"))
307 })?;
308
309 let mut batches = Vec::new();
310 for batch in reader {
311 batches.push(
312 batch.map_err(|e| {
313 FaucetError::Source(format!("parquet decode error in '{key}': {e}"))
314 })?,
315 );
316 }
317 Ok((schema, batches))
318}
319
320#[async_trait]
321impl faucet_core::Source for GcsSource {
322 async fn fetch_with_context(
323 &self,
324 context: &std::collections::HashMap<String, Value>,
325 ) -> Result<Vec<Value>, FaucetError> {
326 let substituted_prefix: Option<String> = if !context.is_empty() {
327 self.config
328 .prefix
329 .as_ref()
330 .map(|p| faucet_core::util::substitute_context(p, context))
331 } else {
332 None
333 };
334
335 let keys = self
336 .list_object_names(substituted_prefix.as_deref())
337 .await?;
338 tracing::info!(
339 bucket = %self.config.bucket,
340 objects = keys.len(),
341 "Listed GCS objects",
342 );
343
344 let concurrency = self.config.concurrency.max(1);
345 let results: Vec<Vec<Value>> = stream::iter(keys)
346 .map(|key| async move {
347 #[cfg(feature = "arrow")]
348 if matches!(self.config.file_format, GcsFileFormat::Parquet) {
349 let (_schema, batches) = self.read_object_parquet(&key).await?;
350 let mut records = Vec::new();
351 for batch in &batches {
352 records.extend(faucet_core::columnar::record_batch_to_values(batch)?);
353 }
354 tracing::debug!(key = %key, records = records.len(), "Read GCS parquet object");
355 return Ok::<Vec<Value>, FaucetError>(records);
356 }
357 let text = self.read_object_text(&key).await?;
358 let records = self.parse_content(&key, &text)?;
359 tracing::debug!(key = %key, records = records.len(), "Read GCS object");
360 Ok::<Vec<Value>, FaucetError>(records)
361 })
362 .buffer_unordered(concurrency)
363 .try_collect()
364 .await?;
365
366 let all_records: Vec<Value> = results.into_iter().flatten().collect();
367 tracing::info!(total_records = all_records.len(), "GCS fetch complete");
368 Ok(all_records)
369 }
370
371 fn stream_pages<'a>(
375 &'a self,
376 context: &'a std::collections::HashMap<String, Value>,
377 _batch_size: usize,
378 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
379 let batch_size = self.config.batch_size;
380
381 Box::pin(async_stream::try_stream! {
382 let substituted_prefix: Option<String> = if !context.is_empty() {
383 self.config
384 .prefix
385 .as_ref()
386 .map(|p| faucet_core::util::substitute_context(p, context))
387 } else {
388 None
389 };
390
391 let keys = self.list_object_names(substituted_prefix.as_deref()).await?;
392 tracing::info!(
393 bucket = %self.config.bucket,
394 objects = keys.len(),
395 "Listed GCS objects (stream)",
396 );
397
398 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
399 let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
400 let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
401 let mut total = 0usize;
402
403 for key in &keys {
404 match self.config.file_format {
405 GcsFileFormat::JsonLines => {
406 let reader = self.open_object_reader(key).await?;
407 let mut lines = reader.lines();
408 let mut line_num: usize = 0;
409 while let Some(line) = lines
410 .next_line()
411 .await
412 .map_err(|e| FaucetError::Source(format!(
413 "GCS read body error for key '{key}': {e}"
414 )))?
415 {
416 line_num += 1;
417 let trimmed = line.trim();
418 if trimmed.is_empty() { continue; }
419 let value: Value = serde_json::from_str(trimmed).map_err(|e| {
420 FaucetError::Source(format!(
421 "GCS JSON parse error in '{key}' at line {line_num}: {e}",
422 ))
423 })?;
424 buffer.push(value);
425 if batch_size != 0 && buffer.len() >= chunk {
426 let page = std::mem::replace(
427 &mut buffer,
428 Vec::with_capacity(initial_capacity),
429 );
430 total += page.len();
431 yield StreamPage { records: page, bookmark: None };
432 }
433 }
434 if batch_size == 0 && !buffer.is_empty() {
435 let page = std::mem::take(&mut buffer);
436 total += page.len();
437 yield StreamPage { records: page, bookmark: None };
438 }
439 }
440 GcsFileFormat::RawText => {
441 let text = self.read_object_text(key).await?;
442 let record = serde_json::json!({ "key": key, "content": text });
443 buffer.push(record);
444 if batch_size == 0 {
445 let page = std::mem::take(&mut buffer);
446 total += page.len();
447 yield StreamPage { records: page, bookmark: None };
448 } else if buffer.len() >= chunk {
449 let page = std::mem::replace(
450 &mut buffer,
451 Vec::with_capacity(initial_capacity),
452 );
453 total += page.len();
454 yield StreamPage { records: page, bookmark: None };
455 }
456 }
457 #[cfg(feature = "arrow")]
458 GcsFileFormat::Parquet => {
459 let (_schema, batches) = self.read_object_parquet(key).await?;
465 for batch in &batches {
466 let rows = faucet_core::columnar::record_batch_to_values(batch)?;
467 for record in rows {
468 buffer.push(record);
469 if batch_size != 0 && buffer.len() >= chunk {
470 let page = std::mem::replace(
471 &mut buffer,
472 Vec::with_capacity(initial_capacity),
473 );
474 total += page.len();
475 yield StreamPage { records: page, bookmark: None };
476 }
477 }
478 }
479 if batch_size == 0 && !buffer.is_empty() {
480 let page = std::mem::take(&mut buffer);
481 total += page.len();
482 yield StreamPage { records: page, bookmark: None };
483 }
484 }
485 GcsFileFormat::JsonArray => {
486 let text = self.read_object_text(key).await?;
487 let value: Value = serde_json::from_str(&text).map_err(|e| {
488 FaucetError::Source(format!("GCS JSON parse error in '{key}': {e}"))
489 })?;
490 let array = match value {
491 Value::Array(arr) => arr,
492 other => Err(FaucetError::Source(format!(
493 "GCS expected JSON array in '{key}', got {}",
494 value_type_name(&other)
495 )))?,
496 };
497 if batch_size == 0 {
498 if !buffer.is_empty() {
499 let page = std::mem::take(&mut buffer);
500 total += page.len();
501 yield StreamPage { records: page, bookmark: None };
502 }
503 total += array.len();
504 yield StreamPage { records: array, bookmark: None };
505 } else {
506 for record in array {
507 buffer.push(record);
508 if buffer.len() >= chunk {
509 let page = std::mem::replace(
510 &mut buffer,
511 Vec::with_capacity(initial_capacity),
512 );
513 total += page.len();
514 yield StreamPage { records: page, bookmark: None };
515 }
516 }
517 }
518 }
519 }
520 }
521
522 if !buffer.is_empty() {
523 let page = std::mem::take(&mut buffer);
524 total += page.len();
525 yield StreamPage { records: page, bookmark: None };
526 }
527
528 tracing::info!(
529 total_records = total,
530 batch_size,
531 objects = keys.len(),
532 "GCS source stream complete",
533 );
534 })
535 }
536
537 #[cfg(feature = "arrow")]
542 fn supports_columnar(&self) -> bool {
543 matches!(self.config.file_format, GcsFileFormat::Parquet)
544 }
545
546 #[cfg(feature = "arrow")]
554 fn stream_batches<'a>(
555 &'a self,
556 context: &'a std::collections::HashMap<String, Value>,
557 _batch_size: usize,
558 ) -> Pin<
559 Box<
560 dyn Stream<Item = Result<faucet_core::columnar::ColumnarPage, FaucetError>> + Send + 'a,
561 >,
562 > {
563 Box::pin(async_stream::try_stream! {
564 if !matches!(self.config.file_format, GcsFileFormat::Parquet) {
565 Err(FaucetError::Source(
566 "GCS source: stream_batches invoked for a non-parquet file_format".into(),
567 ))?;
568 }
569
570 let substituted_prefix: Option<String> = if !context.is_empty() {
571 self.config
572 .prefix
573 .as_ref()
574 .map(|p| faucet_core::util::substitute_context(p, context))
575 } else {
576 None
577 };
578
579 let keys = self.list_object_names(substituted_prefix.as_deref()).await?;
580 tracing::info!(
581 bucket = %self.config.bucket,
582 objects = keys.len(),
583 "Listed GCS objects (columnar stream)",
584 );
585
586 let mut reference: Option<arrow::datatypes::SchemaRef> = None;
587 let mut total_records = 0usize;
588 let mut total_pages = 0usize;
589 for key in &keys {
590 let (schema, batches) = self.read_object_parquet(key).await?;
591 match &reference {
592 Some(first) if first != &schema => {
593 Err(FaucetError::Source(format!(
594 "GCS source: parquet schema mismatch — object '{key}' diverges from \
595 the first object's schema"
596 )))?;
597 }
598 None => reference = Some(schema),
599 _ => {}
600 }
601 for batch in batches {
602 if batch.num_rows() == 0 {
603 continue;
604 }
605 total_records += batch.num_rows();
606 total_pages += 1;
607 yield faucet_core::columnar::ColumnarPage { batch, bookmark: None };
608 }
609 }
610
611 tracing::info!(
612 pages = total_pages,
613 total_records,
614 objects = keys.len(),
615 "GCS source columnar stream complete",
616 );
617 })
618 }
619
620 fn config_schema(&self) -> Value {
621 serde_json::to_value(faucet_core::schema_for!(GcsSourceConfig))
622 .expect("schema serialization")
623 }
624
625 fn connector_name(&self) -> &'static str {
626 "gcs"
627 }
628
629 fn dataset_uri(&self) -> String {
630 match &self.config.prefix {
631 Some(p) => format!("gs://{}/{}", self.config.bucket, p),
632 None => format!("gs://{}", self.config.bucket),
633 }
634 }
635
636 fn is_shardable(&self) -> bool {
640 true
641 }
642
643 async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
648 Ok(plan_hash_shards(target))
649 }
650
651 async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
654 *self.applied_shard.lock().expect("shard mutex poisoned") = parse_hash_shard(shard, "gcs")?;
655 Ok(())
656 }
657
658 fn supports_discover(&self) -> bool {
659 true
660 }
661
662 async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
671 let mut req = self
672 .control
673 .list_objects()
674 .set_parent(self.bucket_path())
675 .set_delimiter("/")
676 .set_page_size(DISCOVER_MAX_OBJECTS as i32);
677 if let Some(p) = self.config.prefix.as_deref() {
678 req = req.set_prefix(p.to_string());
679 }
680 let response = req
681 .send()
682 .await
683 .map_err(|e| FaucetError::Source(format!("gcs: catalog discovery failed: {e}")))?;
684
685 let objects: Vec<String> = response
686 .objects
687 .into_iter()
688 .map(|o| o.name)
689 .filter(|n| !n.is_empty())
690 .collect();
691 Ok(descriptors_from_listing(response.prefixes, objects))
692 }
693}
694
695const DISCOVER_MAX_OBJECTS: usize = 1000;
698
699fn descriptors_from_listing(
707 prefixes: Vec<String>,
708 objects: Vec<String>,
709) -> Vec<faucet_core::DatasetDescriptor> {
710 let prefixes: Vec<String> = prefixes.into_iter().filter(|p| !p.is_empty()).collect();
711 if !prefixes.is_empty() {
712 return prefixes
713 .into_iter()
714 .map(|p| {
715 let patch = serde_json::json!({ "prefix": p });
716 faucet_core::DatasetDescriptor::new(p, "prefix", patch)
717 })
718 .collect();
719 }
720 objects
721 .into_iter()
722 .take(DISCOVER_MAX_OBJECTS)
723 .map(|k| {
724 let patch = serde_json::json!({ "object_keys": [k] });
725 faucet_core::DatasetDescriptor::new(k, "object", patch)
726 })
727 .collect()
728}
729
730fn cap_keys(mut keys: Vec<String>, max: Option<usize>) -> Vec<String> {
737 if let Some(n) = max {
738 keys.truncate(n);
739 }
740 keys
741}
742
743fn filter_shard_keys(keys: Vec<String>, shard: Option<HashShard>) -> Vec<String> {
748 match shard {
749 Some(member) => keys.into_iter().filter(|k| member.contains(k)).collect(),
750 None => keys,
751 }
752}
753
754fn value_type_name(v: &Value) -> &'static str {
755 match v {
756 Value::Null => "null",
757 Value::Bool(_) => "boolean",
758 Value::Number(_) => "number",
759 Value::String(_) => "string",
760 Value::Array(_) => "array",
761 Value::Object(_) => "object",
762 }
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768 use serde_json::json;
769
770 #[cfg(feature = "compression")]
771 #[test]
772 fn compression_default_is_auto() {
773 let cfg = GcsSourceConfig::new("bucket");
774 assert_eq!(cfg.compression, faucet_core::CompressionConfig::Auto);
775 }
776
777 #[test]
778 fn value_type_name_covers_all_json_variants() {
779 assert_eq!(value_type_name(&Value::Null), "null");
780 assert_eq!(value_type_name(&json!(true)), "boolean");
781 assert_eq!(value_type_name(&json!(7)), "number");
782 assert_eq!(value_type_name(&json!("s")), "string");
783 assert_eq!(value_type_name(&json!([1, 2])), "array");
784 assert_eq!(value_type_name(&json!({"k": 1})), "object");
785 }
786
787 #[test]
788 fn parse_json_lines() {
789 let r =
790 parse_file_content(&GcsFileFormat::JsonLines, "t", "{\"id\":1}\n{\"id\":2}\n").unwrap();
791 assert_eq!(r.len(), 2);
792 assert_eq!(r[0]["id"], 1);
793 }
794
795 #[test]
796 fn parse_json_lines_skips_blanks() {
797 let r = parse_file_content(
798 &GcsFileFormat::JsonLines,
799 "t",
800 "{\"id\":1}\n\n{\"id\":2}\n\n",
801 )
802 .unwrap();
803 assert_eq!(r.len(), 2);
804 }
805
806 #[test]
807 fn parse_json_lines_reports_line_number() {
808 let err = parse_file_content(&GcsFileFormat::JsonLines, "t", "{\"id\":1}\nbad-line\n")
809 .unwrap_err();
810 let msg = err.to_string();
811 assert!(msg.contains("line 2"), "unexpected: {msg}");
812 }
813
814 #[test]
815 fn parse_json_array() {
816 let r = parse_file_content(
817 &GcsFileFormat::JsonArray,
818 "t.json",
819 "[{\"id\":1},{\"id\":2}]",
820 )
821 .unwrap();
822 assert_eq!(r.len(), 2);
823 }
824
825 #[test]
826 fn parse_json_array_rejects_non_array() {
827 let err =
828 parse_file_content(&GcsFileFormat::JsonArray, "t.json", "{\"id\":1}").unwrap_err();
829 assert!(err.to_string().contains("expected JSON array"));
830 }
831
832 #[test]
833 fn parse_raw_text_yields_single_record() {
834 let r = parse_file_content(&GcsFileFormat::RawText, "p/f.txt", "hello").unwrap();
835 assert_eq!(r, vec![json!({"key": "p/f.txt", "content": "hello"})]);
836 }
837
838 #[test]
839 fn cap_keys_truncates_explicit_list_to_max_objects() {
840 let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
841 let capped = cap_keys(keys, Some(2));
842 assert_eq!(capped, vec!["a".to_string(), "b".to_string()]);
843 }
844
845 #[test]
846 fn cap_keys_passes_through_when_no_max() {
847 let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
848 let capped = cap_keys(keys.clone(), None);
849 assert_eq!(capped, keys);
850 }
851
852 #[test]
853 fn cap_keys_noop_when_max_exceeds_len() {
854 let keys = vec!["a".to_string(), "b".to_string()];
855 let capped = cap_keys(keys.clone(), Some(10));
856 assert_eq!(capped, keys);
857 }
858
859 #[test]
864 fn shard_filter_partitions_keys_disjointly_and_completely() {
865 let keys: Vec<String> = (0..200).map(|i| format!("data/obj-{i}.jsonl")).collect();
866 let members: Vec<HashShard> = plan_hash_shards(4)
867 .iter()
868 .map(|s| HashShard::from_spec(s).expect("descriptor parses"))
869 .collect();
870 let mut union: Vec<String> = Vec::new();
871 for member in members {
872 union.extend(filter_shard_keys(keys.clone(), Some(member)));
873 }
874 union.sort();
875 let mut expected = keys.clone();
876 expected.sort();
877 assert_eq!(
878 union, expected,
879 "shards must union to the full key set, disjointly"
880 );
881 }
882
883 #[test]
884 fn no_applied_shard_reads_everything() {
885 let keys: Vec<String> = (0..20).map(|i| format!("k{i}")).collect();
886 assert_eq!(filter_shard_keys(keys.clone(), None), keys);
887 }
888
889 #[test]
892 fn descriptors_from_listing_maps_common_prefixes() {
893 let out = descriptors_from_listing(
894 vec!["raw/orders/".to_string(), "raw/users/".to_string()],
895 vec![],
896 );
897 assert_eq!(out.len(), 2);
898 assert_eq!(out[0].name, "raw/orders/");
899 assert_eq!(out[0].kind, "prefix");
900 assert_eq!(out[0].config_patch, json!({ "prefix": "raw/orders/" }));
901 assert!(out[0].schema.is_none());
902 assert!(out[0].estimated_rows.is_none());
903 assert_eq!(out[1].name, "raw/users/");
904 assert_eq!(out[1].config_patch, json!({ "prefix": "raw/users/" }));
905 }
906
907 #[test]
910 fn descriptors_from_listing_prefers_prefixes_over_objects() {
911 let out = descriptors_from_listing(
912 vec!["raw/orders/".to_string()],
913 vec!["raw/readme.txt".to_string()],
914 );
915 assert_eq!(out.len(), 1);
916 assert_eq!(out[0].kind, "prefix");
917 assert_eq!(out[0].name, "raw/orders/");
918 }
919
920 #[test]
921 fn descriptors_from_listing_falls_back_to_objects_via_object_keys() {
922 let out = descriptors_from_listing(
923 vec![],
924 vec!["raw/a.jsonl".to_string(), "raw/b.jsonl".to_string()],
925 );
926 assert_eq!(out.len(), 2);
927 assert_eq!(out[0].name, "raw/a.jsonl");
928 assert_eq!(out[0].kind, "object");
929 assert_eq!(
930 out[0].config_patch,
931 json!({ "object_keys": ["raw/a.jsonl"] })
932 );
933 assert!(out[0].schema.is_none());
934 assert!(out[0].estimated_rows.is_none());
935 }
936
937 #[test]
938 fn descriptors_from_listing_empty_listing_yields_no_datasets() {
939 assert!(descriptors_from_listing(vec![], vec![]).is_empty());
940 }
941
942 #[test]
943 fn descriptors_from_listing_skips_empty_prefixes() {
944 let out = descriptors_from_listing(vec![String::new(), "raw/orders/".to_string()], vec![]);
945 assert_eq!(out.len(), 1);
946 assert_eq!(out[0].name, "raw/orders/");
947 }
948
949 #[test]
950 fn descriptors_from_listing_caps_object_fallback() {
951 let objects: Vec<String> = (0..DISCOVER_MAX_OBJECTS + 500)
952 .map(|i| format!("obj-{i}.jsonl"))
953 .collect();
954 let out = descriptors_from_listing(vec![], objects);
955 assert_eq!(out.len(), DISCOVER_MAX_OBJECTS);
956 }
957
958 #[test]
961 fn dataset_uri_no_prefix_logic() {
962 let config = GcsSourceConfig::new("my-bucket");
963 let uri = match &config.prefix {
964 Some(p) => format!("gs://{}/{}", config.bucket, p),
965 None => format!("gs://{}", config.bucket),
966 };
967 assert_eq!(uri, "gs://my-bucket");
968 }
969
970 #[test]
971 fn dataset_uri_with_prefix_logic() {
972 let config = GcsSourceConfig::new("my-bucket").prefix("data/2026/");
973 let uri = match &config.prefix {
974 Some(p) => format!("gs://{}/{}", config.bucket, p),
975 None => format!("gs://{}", config.bucket),
976 };
977 assert_eq!(uri, "gs://my-bucket/data/2026/");
978 }
979
980 #[cfg(feature = "arrow")]
983 fn sample_parquet_bytes() -> bytes::Bytes {
984 use arrow::array::{Int32Array, RecordBatch, StringArray};
985 use arrow::datatypes::{DataType, Field, Schema};
986 use std::sync::Arc;
987
988 let schema = Arc::new(Schema::new(vec![
989 Field::new("id", DataType::Int32, false),
990 Field::new("name", DataType::Utf8, true),
991 ]));
992 let batch = RecordBatch::try_new(
993 schema.clone(),
994 vec![
995 Arc::new(Int32Array::from(vec![10, 20])),
996 Arc::new(StringArray::from(vec![Some("x"), None])),
997 ],
998 )
999 .unwrap();
1000 let mut buf: Vec<u8> = Vec::new();
1001 {
1002 let mut writer = parquet::arrow::ArrowWriter::try_new(&mut buf, schema, None).unwrap();
1003 writer.write(&batch).unwrap();
1004 writer.close().unwrap();
1005 }
1006 bytes::Bytes::from(buf)
1007 }
1008
1009 #[cfg(feature = "arrow")]
1010 #[test]
1011 fn decode_parquet_bytes_yields_schema_and_batches() {
1012 let (schema, batches) = decode_parquet_bytes(sample_parquet_bytes(), "t.parquet").unwrap();
1013 assert_eq!(schema.fields().len(), 2);
1014 let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1015 assert_eq!(total, 2);
1016 }
1017
1018 #[cfg(feature = "arrow")]
1019 #[test]
1020 fn parquet_batches_convert_to_rows_with_explicit_nulls() {
1021 let (_schema, batches) = decode_parquet_bytes(sample_parquet_bytes(), "t.parquet").unwrap();
1022 let mut rows = Vec::new();
1023 for b in &batches {
1024 rows.extend(faucet_core::columnar::record_batch_to_values(b).unwrap());
1025 }
1026 assert_eq!(rows.len(), 2);
1027 assert_eq!(rows[0]["id"], 10);
1028 assert_eq!(rows[0]["name"], "x");
1029 assert!(rows[1].as_object().unwrap().contains_key("name"));
1030 assert!(rows[1]["name"].is_null());
1031 }
1032
1033 #[cfg(feature = "arrow")]
1034 #[test]
1035 fn corrupt_parquet_bytes_error() {
1036 let err =
1037 decode_parquet_bytes(bytes::Bytes::from_static(b"nope"), "bad.parquet").unwrap_err();
1038 assert!(matches!(err, FaucetError::Source(_)));
1039 }
1040}