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