1use std::path::{Path, PathBuf};
40use std::sync::Arc;
41
42use arrow_array::RecordBatch;
43use arrow_schema::{Field, Schema, SchemaRef};
44use parquet::arrow::arrow_reader::ParquetRecordBatchReader;
45use parquet::arrow::ProjectionMask;
46use parquet::file::metadata::{KeyValue, ParquetMetaData};
47
48#[cfg(feature = "remote")]
49use crate::input::is_remote_scheme;
50use crate::input::{url_scheme, FetchStats, InputError, InputSource};
51
52#[derive(Debug)]
55pub enum ConvertSource {
56 Single(SingleSource),
58 Multi(MultiSource),
60}
61
62#[derive(Debug)]
67pub struct SingleSource {
68 source: InputSource,
69 meta: std::sync::OnceLock<PartMeta>,
70 projection: std::sync::OnceLock<Vec<usize>>,
72}
73
74impl SingleSource {
75 fn new(source: InputSource) -> Self {
76 SingleSource {
77 source,
78 meta: std::sync::OnceLock::new(),
79 projection: std::sync::OnceLock::new(),
80 }
81 }
82
83 pub fn input(&self) -> &InputSource {
85 &self.source
86 }
87
88 fn meta(&self) -> Result<&PartMeta, InputError> {
90 if let Some(m) = self.meta.get() {
91 return Ok(m);
92 }
93 let m = load_part_meta(&self.source)?;
94 Ok(self.meta.get_or_init(|| m))
97 }
98}
99
100#[derive(Debug, Clone)]
102struct PartMeta {
103 schema: SchemaRef,
105 parquet: Arc<ParquetMetaData>,
107}
108
109#[derive(Debug)]
113pub struct MultiSource {
114 root: String,
116 parts: Vec<InputSource>,
119 metas: Vec<PartMeta>,
121 schema: SchemaRef,
124 projection: std::sync::OnceLock<Vec<usize>>,
126}
127
128#[derive(Debug, Clone)]
133pub struct RowGroupSelection(Vec<Vec<usize>>);
134
135impl RowGroupSelection {
136 pub fn from_parts(parts: Vec<Vec<usize>>) -> Self {
138 RowGroupSelection(parts)
139 }
140
141 pub fn parts(&self) -> &[Vec<usize>] {
143 &self.0
144 }
145
146 pub fn total_selected(&self) -> usize {
148 self.0.iter().map(Vec::len).sum()
149 }
150
151 pub fn intersect(&self, other: &RowGroupSelection) -> RowGroupSelection {
156 debug_assert_eq!(self.0.len(), other.0.len());
157 let parts = self
158 .0
159 .iter()
160 .zip(&other.0)
161 .map(|(a, b)| a.iter().copied().filter(|i| b.contains(i)).collect())
162 .collect();
163 RowGroupSelection(parts)
164 }
165}
166
167#[derive(Debug, Clone, Copy)]
171pub struct ReadPlan<'a> {
172 pub batch_size: usize,
174 pub projection: Option<&'a [usize]>,
176 pub row_groups: Option<&'a RowGroupSelection>,
178}
179
180pub struct SourceStream<'a> {
186 parts: &'a [InputSource],
187 projection: Option<Vec<usize>>,
188 row_groups: Option<Vec<Vec<usize>>>,
189 batch_size: usize,
190 part_idx: usize,
191 current: Option<ParquetRecordBatchReader>,
192 done: bool,
193}
194
195impl ConvertSource {
196 pub fn single(source: InputSource) -> Self {
198 ConvertSource::Single(SingleSource::new(source))
199 }
200
201 pub fn resolve(input: &str) -> Result<Self, InputError> {
220 if let Some(_scheme) = url_scheme(input) {
221 #[cfg(feature = "remote")]
228 if !_scheme.eq_ignore_ascii_case("file")
229 && is_remote_scheme(_scheme)
230 && remote_url_is_prefix(input)
231 {
232 if _scheme.eq_ignore_ascii_case("http") || _scheme.eq_ignore_ascii_case("https") {
233 return Err(InputError::RemotePrefixUnsupported {
234 url: input.to_string(),
235 });
236 }
237 return Self::from_remote_prefix(input);
238 }
239 return Ok(ConvertSource::single(InputSource::from_str_input(input)?));
243 }
244 let path = Path::new(input);
245 if path.is_file() {
246 return Ok(ConvertSource::single(InputSource::Local(
248 path.to_path_buf(),
249 )));
250 }
251 if path.is_dir() {
252 let files = list_parquet_files(path)?;
253 return Self::from_local_files(input, files);
254 }
255 if has_glob_meta(input) {
256 let files = expand_glob(input)?;
257 return Self::from_local_files(input, files);
258 }
259 Ok(ConvertSource::single(InputSource::Local(
262 path.to_path_buf(),
263 )))
264 }
265
266 fn from_local_files(input: &str, files: Vec<PathBuf>) -> Result<Self, InputError> {
268 Self::from_parts(input, files.into_iter().map(InputSource::Local).collect())
269 }
270
271 fn from_parts(input: &str, mut parts: Vec<InputSource>) -> Result<Self, InputError> {
275 match parts.len() {
276 0 => Err(InputError::NoParquetInputs {
277 input: input.to_string(),
278 }),
279 1 => Ok(ConvertSource::single(parts.remove(0))),
280 _ => Ok(ConvertSource::Multi(MultiSource::from_sources(
281 input.to_string(),
282 parts,
283 )?)),
284 }
285 }
286
287 #[cfg(feature = "remote")]
292 fn from_remote_prefix(input: &str) -> Result<Self, InputError> {
293 let sources = crate::input::remote::sources_under_prefix(input)?;
294 Self::from_parts(
295 input,
296 sources.into_iter().map(InputSource::Remote).collect(),
297 )
298 }
299
300 pub fn from_manifest(manifest: &Path) -> Result<Self, InputError> {
309 let text = std::fs::read_to_string(manifest).map_err(|e| InputError::ManifestRead {
310 path: manifest.display().to_string(),
311 source: e,
312 })?;
313 let entries: Vec<(String, String)> = manifest_entries(&text)
314 .into_iter()
315 .map(|(line, entry)| {
316 (
317 format!("line {line} of manifest {}", manifest.display()),
318 entry.to_string(),
319 )
320 })
321 .collect();
322 Self::from_explicit_list(&manifest.display().to_string(), &entries)
323 }
324
325 pub fn from_input_list<S: AsRef<str>>(inputs: &[S]) -> Result<Self, InputError> {
329 let entries: Vec<(String, String)> = inputs
330 .iter()
331 .enumerate()
332 .map(|(i, entry)| {
333 (
334 format!("input list entry {}", i + 1),
335 entry.as_ref().to_string(),
336 )
337 })
338 .collect();
339 Self::from_explicit_list("input list", &entries)
340 }
341
342 fn from_explicit_list(root: &str, entries: &[(String, String)]) -> Result<Self, InputError> {
350 let parts = resolve_list_entries(entries, |context, entry| {
351 let src = InputSource::from_str_input(entry)?;
352 #[cfg_attr(not(feature = "remote"), allow(irrefutable_let_patterns))]
354 if let InputSource::Local(p) = &src {
355 if !p.is_file() {
356 return Err(InputError::MissingListedInput {
357 context: context.to_string(),
358 input: entry.to_string(),
359 });
360 }
361 }
362 Ok(src)
363 })?;
364 Self::from_parts(root, parts)
365 }
366
367 pub fn resolve_path(path: &Path) -> Result<Self, InputError> {
370 match path.to_str() {
371 Some(s) => Self::resolve(s),
372 None => Ok(ConvertSource::single(InputSource::from_path(path)?)),
373 }
374 }
375
376 pub fn parts(&self) -> &[InputSource] {
378 match self {
379 ConvertSource::Single(s) => std::slice::from_ref(s.input()),
380 ConvertSource::Multi(m) => &m.parts,
381 }
382 }
383
384 pub fn is_remote(&self) -> bool {
386 self.parts().iter().any(InputSource::is_remote)
387 }
388
389 pub fn set_spill_dir(&self, dir: Option<&Path>) {
392 for part in self.parts() {
393 part.set_spill_dir(dir);
394 }
395 }
396
397 pub fn display_name(&self) -> String {
400 match self {
401 ConvertSource::Single(s) => s.input().display_name(),
402 ConvertSource::Multi(m) => {
403 format!("{} ({} partitions)", m.root, m.parts.len())
404 }
405 }
406 }
407
408 pub fn schema(&self) -> Result<SchemaRef, InputError> {
412 let full = match self {
413 ConvertSource::Single(s) => s.meta()?.schema.clone(),
414 ConvertSource::Multi(m) => m.schema.clone(),
415 };
416 Ok(match self.column_projection() {
417 None => full,
418 Some(keep) => Arc::new(full.project(keep)?),
419 })
420 }
421
422 pub fn file_schema(&self) -> Result<SchemaRef, InputError> {
425 match self {
426 ConvertSource::Single(s) => Ok(s.meta()?.schema.clone()),
427 ConvertSource::Multi(m) => Ok(m.schema.clone()),
428 }
429 }
430
431 pub fn restrict_columns(&self, keep: Vec<usize>) -> Result<(), InputError> {
439 let ncols = self.file_schema()?.fields().len();
440 if keep.windows(2).any(|w| w[0] >= w[1]) || keep.iter().any(|&i| i >= ncols) {
441 return Err(InputError::Arrow(arrow_schema::ArrowError::SchemaError(
442 format!("column restriction {keep:?} is not a sorted subset of 0..{ncols}"),
443 )));
444 }
445 let cell = match self {
446 ConvertSource::Single(s) => &s.projection,
447 ConvertSource::Multi(m) => &m.projection,
448 };
449 cell.set(keep).map_err(|_| {
450 InputError::Arrow(arrow_schema::ArrowError::SchemaError(
451 "column restriction already applied to this source".to_string(),
452 ))
453 })
454 }
455
456 pub fn column_projection(&self) -> Option<&[usize]> {
459 match self {
460 ConvertSource::Single(s) => s.projection.get().map(Vec::as_slice),
461 ConvertSource::Multi(m) => m.projection.get().map(Vec::as_slice),
462 }
463 }
464
465 pub fn key_value_metadata(&self) -> Result<Option<Vec<KeyValue>>, InputError> {
468 match self {
469 ConvertSource::Single(s) => Ok(s
470 .meta()?
471 .parquet
472 .file_metadata()
473 .key_value_metadata()
474 .cloned()),
475 ConvertSource::Multi(m) => Ok(m.metas[0]
476 .parquet
477 .file_metadata()
478 .key_value_metadata()
479 .cloned()),
480 }
481 }
482
483 pub fn num_row_groups_total(&self) -> Result<usize, InputError> {
485 Ok(self
486 .metas()?
487 .iter()
488 .map(|m| m.parquet.num_row_groups())
489 .sum())
490 }
491
492 pub fn select_row_groups(
496 &self,
497 bbox_units: &[f64; 4],
498 ) -> Result<RowGroupSelection, InputError> {
499 let per_part = self
500 .metas()?
501 .iter()
502 .map(|m| crate::overview::convert::select_input_row_groups(&m.parquet, bbox_units))
503 .collect();
504 Ok(RowGroupSelection(per_part))
505 }
506
507 pub fn select_row_groups_matching(
513 &self,
514 filter: &crate::overview::filter::BoundFilter,
515 ) -> Result<RowGroupSelection, InputError> {
516 let per_part = self
517 .metas()?
518 .iter()
519 .map(|m| filter.select_row_groups(&m.parquet))
520 .collect();
521 Ok(RowGroupSelection(per_part))
522 }
523
524 pub fn selected_input_bytes(
529 &self,
530 selection: Option<&RowGroupSelection>,
531 ) -> Result<u64, InputError> {
532 let metas = self.metas()?;
533 if let Some(sel) = selection {
534 debug_assert_eq!(metas.len(), sel.0.len());
535 }
536 Ok(metas
537 .iter()
538 .enumerate()
539 .map(|(pi, m)| {
540 crate::input::selected_compressed_bytes(
541 &m.parquet,
542 selection.map(|s| s.0[pi].as_slice()),
543 )
544 })
545 .sum())
546 }
547
548 pub fn fetch_stats(&self) -> Option<FetchStats> {
551 let mut total: Option<FetchStats> = None;
552 for part in self.parts() {
553 if let Some(s) = part.fetch_stats() {
554 let t = total.get_or_insert(FetchStats::default());
555 t.requests += s.requests;
556 t.bytes_fetched += s.bytes_fetched;
557 t.object_size += s.object_size;
558 }
559 }
560 total
561 }
562
563 pub fn stage_selected(&self, selection: Option<&RowGroupSelection>) -> Result<(), InputError> {
576 let parts = self.parts();
577 if let Some(sel) = selection {
578 debug_assert_eq!(
579 sel.0.len(),
580 parts.len(),
581 "row-group selection must cover every part"
582 );
583 }
584 for (i, part) in parts.iter().enumerate() {
585 part.stage_row_groups(selection.map(|s| s.0[i].as_slice()))?;
586 }
587 Ok(())
588 }
589
590 pub fn open_stream(&self, plan: &ReadPlan<'_>) -> Result<SourceStream<'_>, InputError> {
592 let parts = self.parts();
593 if let Some(sel) = plan.row_groups {
594 debug_assert_eq!(
595 sel.0.len(),
596 parts.len(),
597 "row-group selection must cover every part"
598 );
599 }
600 let projection = match (self.column_projection(), plan.projection) {
603 (None, None) => None,
604 (None, Some(cols)) => Some(cols.to_vec()),
605 (Some(base), None) => Some(base.to_vec()),
606 (Some(base), Some(cols)) => Some(cols.iter().map(|&c| base[c]).collect()),
607 };
608 Ok(SourceStream {
609 parts,
610 projection,
611 row_groups: plan.row_groups.map(|s| s.0.clone()),
612 batch_size: plan.batch_size.max(1),
613 part_idx: 0,
614 current: None,
615 done: false,
616 })
617 }
618
619 fn metas(&self) -> Result<std::borrow::Cow<'_, [PartMeta]>, InputError> {
622 match self {
623 ConvertSource::Single(s) => {
624 Ok(std::borrow::Cow::Borrowed(std::slice::from_ref(s.meta()?)))
625 }
626 ConvertSource::Multi(m) => Ok(std::borrow::Cow::Borrowed(&m.metas)),
627 }
628 }
629}
630
631fn load_part_meta(source: &InputSource) -> Result<PartMeta, InputError> {
635 let builder = source.open()?;
636 Ok(PartMeta {
637 schema: builder.schema().clone(),
638 parquet: builder.metadata().clone(),
639 })
640}
641
642const FOOTER_LOAD_CONCURRENCY: usize = 8;
648
649fn load_part_metas(parts: &[InputSource]) -> Result<Vec<PartMeta>, InputError> {
654 use std::sync::atomic::{AtomicUsize, Ordering};
655 let workers = FOOTER_LOAD_CONCURRENCY.min(parts.len());
656 if workers <= 1 {
657 return parts.iter().map(load_part_meta).collect();
658 }
659 let next = AtomicUsize::new(0);
660 let slots: Vec<std::sync::Mutex<Option<Result<PartMeta, InputError>>>> = (0..parts.len())
661 .map(|_| std::sync::Mutex::new(None))
662 .collect();
663 std::thread::scope(|scope| {
664 for _ in 0..workers {
665 scope.spawn(|| loop {
666 let i = next.fetch_add(1, Ordering::Relaxed);
667 if i >= parts.len() {
668 break;
669 }
670 let meta = load_part_meta(&parts[i]);
671 *slots[i].lock().expect("footer slot lock") = Some(meta);
672 });
673 }
674 });
675 slots
676 .into_iter()
677 .map(|slot| {
678 slot.into_inner()
679 .expect("footer slot lock")
680 .expect("every slot filled by a worker")
681 })
682 .collect()
683}
684
685impl MultiSource {
686 pub fn from_sources(root: String, parts: Vec<InputSource>) -> Result<Self, InputError> {
695 assert!(!parts.is_empty(), "MultiSource requires at least one part");
696 let metas = load_part_metas(&parts)?;
697
698 let first_name = parts[0].display_name();
699 let crs_of = |m: &PartMeta| {
705 crate::overview::convert::detect_crs_from_kv(
706 m.parquet.file_metadata().key_value_metadata(),
707 )
708 .ok()
709 };
710 let raw_crs_of = |m: &PartMeta| -> String {
715 crate::quality::crs_info_from_kv_metadata(
716 m.parquet.file_metadata().key_value_metadata(),
717 )
718 .ok()
719 .and_then(|info| info.identifier.or(info.name))
720 .unwrap_or_else(|| "unknown".to_string())
721 };
722 let first_crs = crs_of(&metas[0]);
723
724 for (part, meta) in parts.iter().zip(&metas).skip(1) {
725 let incompatible = |detail: String| InputError::IncompatiblePartition {
726 first: first_name.clone(),
727 offender: part.display_name(),
728 detail,
729 };
730 validate_schema_shape(&metas[0].schema, &meta.schema).map_err(&incompatible)?;
731 let crs = crs_of(meta);
732 if crs != first_crs {
733 return Err(incompatible(format!(
734 "CRS mismatch: partition declares {:?} but the first partition \
735 declares {:?} (all partitions must share one CRS)",
736 raw_crs_of(meta),
737 raw_crs_of(&metas[0]),
738 )));
739 }
740 if crs.is_none() {
741 let (a, b) = (raw_crs_of(&metas[0]), raw_crs_of(meta));
744 if a != b {
745 return Err(incompatible(format!(
746 "CRS mismatch: partition declares {b:?} but the first \
747 partition declares {a:?} (all partitions must share \
748 one CRS)"
749 )));
750 }
751 }
752 }
753
754 let schema = union_schema(&metas);
755 Ok(MultiSource {
756 root,
757 parts,
758 metas,
759 schema,
760 projection: std::sync::OnceLock::new(),
761 })
762 }
763}
764
765fn validate_schema_shape(first: &Schema, other: &Schema) -> Result<(), String> {
770 if first.fields().len() != other.fields().len() {
771 return Err(format!(
772 "column count differs: {} vs {} in the first partition",
773 other.fields().len(),
774 first.fields().len()
775 ));
776 }
777 for (ci, (f0, fi)) in first.fields().iter().zip(other.fields()).enumerate() {
778 if f0.name() != fi.name() {
779 return Err(format!(
780 "column {ci} is named {:?} but the first partition has {:?} \
781 (columns must match in name and order)",
782 fi.name(),
783 f0.name()
784 ));
785 }
786 if f0.data_type() != fi.data_type() {
787 return Err(format!(
788 "column {:?} has type {:?} but the first partition has {:?}",
789 fi.name(),
790 fi.data_type(),
791 f0.data_type()
792 ));
793 }
794 if f0.metadata() != fi.metadata() {
795 return Err(format!(
796 "column {:?} carries different field (extension) metadata than \
797 the first partition ({}) — geometry encoding/CRS metadata must \
798 match",
799 fi.name(),
800 describe_metadata_diff(f0.metadata(), fi.metadata())
801 ));
802 }
803 }
804 Ok(())
805}
806
807fn describe_metadata_diff(
813 first: &std::collections::HashMap<String, String>,
814 other: &std::collections::HashMap<String, String>,
815) -> String {
816 fn trunc(s: &str) -> String {
817 const MAX: usize = 80;
818 if s.chars().count() > MAX {
819 let cut: String = s.chars().take(MAX).collect();
820 format!("{cut:?}…")
821 } else {
822 format!("{s:?}")
823 }
824 }
825 let mut keys: Vec<&String> = first.keys().chain(other.keys()).collect();
826 keys.sort();
827 keys.dedup();
828 for key in keys {
829 match (first.get(key), other.get(key)) {
830 (Some(a), Some(b)) if a != b => {
831 return format!(
832 "key {key:?}: first partition has {} but this partition has {}",
833 trunc(a),
834 trunc(b)
835 );
836 }
837 (Some(_), None) => {
838 return format!("key {key:?} is present only in the first partition");
839 }
840 (None, Some(_)) => {
841 return format!("key {key:?} is present only in this partition");
842 }
843 _ => {}
844 }
845 }
846 "maps differ".to_string()
847}
848
849fn union_schema(metas: &[PartMeta]) -> SchemaRef {
852 let first = &metas[0].schema;
853 let fields: Vec<Field> = first
854 .fields()
855 .iter()
856 .enumerate()
857 .map(|(ci, f0)| {
858 let nullable = metas.iter().any(|m| m.schema.field(ci).is_nullable());
859 f0.as_ref().clone().with_nullable(nullable)
860 })
861 .collect();
862 Arc::new(Schema::new_with_metadata(fields, first.metadata().clone()))
863}
864
865impl SourceStream<'_> {
866 fn open_part(&self, i: usize) -> Result<ParquetRecordBatchReader, InputError> {
870 let mut builder = self.parts[i].open()?;
871 if let Some(cols) = &self.projection {
872 let mask = ProjectionMask::roots(builder.parquet_schema(), cols.iter().copied());
873 builder = builder.with_projection(mask);
874 }
875 if let Some(sel) = &self.row_groups {
876 builder = builder.with_row_groups(sel[i].clone());
877 }
878 Ok(builder.with_batch_size(self.batch_size).build()?)
879 }
880}
881
882impl Iterator for SourceStream<'_> {
883 type Item = Result<RecordBatch, InputError>;
884
885 fn next(&mut self) -> Option<Self::Item> {
886 if self.done {
887 return None;
888 }
889 loop {
890 if let Some(reader) = &mut self.current {
891 match reader.next() {
892 Some(Ok(batch)) => return Some(Ok(batch)),
893 Some(Err(e)) => {
894 self.done = true;
895 return Some(Err(InputError::Arrow(e)));
896 }
897 None => {
898 self.current = None;
904 if self.part_idx + 1 < self.parts.len() {
905 self.parts[self.part_idx].release_read_cache();
906 }
907 self.part_idx += 1;
908 }
909 }
910 continue;
911 }
912 if self.part_idx >= self.parts.len() {
913 self.done = true;
914 return None;
915 }
916 if let Some(sel) = &self.row_groups {
919 if sel[self.part_idx].is_empty() {
920 self.part_idx += 1;
921 continue;
922 }
923 }
924 match self.open_part(self.part_idx) {
925 Ok(reader) => self.current = Some(reader),
926 Err(e) => {
927 self.done = true;
928 return Some(Err(e));
929 }
930 }
931 }
932 }
933}
934
935fn has_glob_meta(input: &str) -> bool {
937 input.contains(['*', '?', '['])
938}
939
940const LIST_CONNECT_CONCURRENCY: usize = 8;
945
946fn resolve_list_entries<F>(
952 entries: &[(String, String)],
953 resolve: F,
954) -> Result<Vec<InputSource>, InputError>
955where
956 F: Fn(&str, &str) -> Result<InputSource, InputError> + Sync,
957{
958 use std::sync::atomic::{AtomicUsize, Ordering};
959 let workers = LIST_CONNECT_CONCURRENCY.min(entries.len());
960 if workers <= 1 {
961 return entries
962 .iter()
963 .map(|(context, entry)| resolve(context, entry))
964 .collect();
965 }
966 let next = AtomicUsize::new(0);
967 let slots: Vec<std::sync::Mutex<Option<Result<InputSource, InputError>>>> = (0..entries.len())
968 .map(|_| std::sync::Mutex::new(None))
969 .collect();
970 std::thread::scope(|scope| {
971 for _ in 0..workers {
972 scope.spawn(|| loop {
973 let i = next.fetch_add(1, Ordering::Relaxed);
974 if i >= entries.len() {
975 break;
976 }
977 let (context, entry) = &entries[i];
978 *slots[i].lock().expect("entry slot lock") = Some(resolve(context, entry));
979 });
980 }
981 });
982 slots
983 .into_iter()
984 .map(|slot| {
985 slot.into_inner()
986 .expect("entry slot lock")
987 .expect("every slot filled by a worker")
988 })
989 .collect()
990}
991
992fn manifest_entries(text: &str) -> Vec<(usize, &str)> {
997 text.lines()
998 .enumerate()
999 .filter_map(|(i, line)| {
1000 let entry = line.trim();
1001 (!entry.is_empty() && !entry.starts_with('#')).then_some((i + 1, entry))
1002 })
1003 .collect()
1004}
1005
1006#[cfg_attr(not(feature = "remote"), allow(dead_code))]
1013fn remote_url_is_prefix(url: &str) -> bool {
1014 let no_fragment = url.split('#').next().unwrap_or(url);
1015 let no_query = no_fragment.split('?').next().unwrap_or(no_fragment);
1016 no_query.ends_with('/')
1017}
1018
1019pub(crate) fn list_parquet_files(dir: &Path) -> Result<Vec<PathBuf>, InputError> {
1023 fn hidden(path: &Path) -> bool {
1024 path.file_name()
1025 .and_then(|n| n.to_str())
1026 .is_some_and(|n| n.starts_with('.') || n.starts_with('_'))
1027 }
1028 fn collect(dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), InputError> {
1029 for entry in std::fs::read_dir(dir)? {
1030 let path = entry?.path();
1031 if hidden(&path) {
1032 continue;
1033 }
1034 if path.is_dir() {
1035 collect(&path, files)?;
1036 } else if path.extension().is_some_and(|ext| ext == "parquet") {
1037 files.push(path);
1038 }
1039 }
1040 Ok(())
1041 }
1042 let mut files = Vec::new();
1043 collect(dir, &mut files)?;
1044 files.sort();
1045 Ok(files)
1046}
1047
1048pub(crate) fn expand_glob(pattern: &str) -> Result<Vec<PathBuf>, InputError> {
1051 let paths = glob::glob(pattern).map_err(|e| InputError::GlobPattern {
1052 pattern: pattern.to_string(),
1053 message: e.to_string(),
1054 })?;
1055 let mut files = Vec::new();
1056 for entry in paths {
1057 let path = entry.map_err(|e| InputError::Io(e.into()))?;
1061 if path.is_file() && path.extension().is_some_and(|ext| ext == "parquet") {
1062 files.push(path);
1063 }
1064 }
1065 files.sort();
1066 files.dedup();
1067 Ok(files)
1068}
1069
1070pub fn derive_layer_name(input: &str) -> String {
1087 const FALLBACK: &str = "layer";
1088 let stem_of = |p: &Path| p.file_stem().and_then(|s| s.to_str()).map(str::to_string);
1089
1090 let name = if url_scheme(input).is_some() {
1091 let no_fragment = input.split('#').next().unwrap_or(input);
1092 let no_query = no_fragment.split('?').next().unwrap_or(no_fragment);
1093 let rest = no_query
1094 .split_once("://")
1095 .map(|(_, rest)| rest)
1096 .unwrap_or(no_query);
1097 rest.split('/')
1098 .rev()
1099 .find(|seg| !seg.is_empty())
1100 .and_then(|seg| {
1101 if remote_url_is_prefix(input) {
1102 Some(seg.to_string())
1103 } else {
1104 stem_of(Path::new(seg))
1105 }
1106 })
1107 } else {
1108 let path = Path::new(input);
1109 if path.is_dir() {
1110 path.file_name()
1111 .and_then(|n| n.to_str())
1112 .map(str::to_string)
1113 } else if !path.is_file() && has_glob_meta(input) {
1114 let mut last_literal = None;
1116 for comp in path.components() {
1117 if let std::path::Component::Normal(seg) = comp {
1118 match seg.to_str() {
1119 Some(s) if !has_glob_meta(s) => last_literal = Some(s.to_string()),
1120 _ => break,
1122 }
1123 }
1124 }
1125 last_literal
1126 } else {
1127 stem_of(path)
1128 }
1129 };
1130 name.filter(|n| !n.is_empty())
1131 .unwrap_or_else(|| FALLBACK.to_string())
1132}
1133
1134#[cfg(test)]
1135mod tests {
1136 use super::*;
1137 use arrow_array::{ArrayRef, Int64Array, StringArray};
1138 use arrow_schema::DataType;
1139 use parquet::arrow::ArrowWriter;
1140 use parquet::file::properties::WriterProperties;
1141 use std::fs::File;
1142
1143 fn write_parquet(
1146 path: &Path,
1147 fields: Vec<Field>,
1148 columns: Vec<ArrayRef>,
1149 max_row_group_size: Option<usize>,
1150 ) {
1151 let schema = Arc::new(Schema::new(fields));
1152 let batch = RecordBatch::try_new(schema.clone(), columns).unwrap();
1153 let props = max_row_group_size.map(|n| {
1154 WriterProperties::builder()
1155 .set_max_row_group_row_count(Some(n))
1156 .build()
1157 });
1158 let file = File::create(path).unwrap();
1159 let mut writer = ArrowWriter::try_new(file, schema, props).unwrap();
1160 writer.write(&batch).unwrap();
1161 writer.close().unwrap();
1162 }
1163
1164 fn write_standard(path: &Path, ids: Vec<i64>, nullable_id: bool) {
1166 let n = ids.len();
1167 write_parquet(
1168 path,
1169 vec![
1170 Field::new("id", DataType::Int64, nullable_id),
1171 Field::new("name", DataType::Utf8, true),
1172 ],
1173 vec![
1174 Arc::new(Int64Array::from(ids)),
1175 Arc::new(StringArray::from(
1176 (0..n).map(|i| format!("r{i}")).collect::<Vec<_>>(),
1177 )),
1178 ],
1179 None,
1180 );
1181 }
1182
1183 fn tmpdir() -> tempfile::TempDir {
1184 tempfile::tempdir().unwrap()
1185 }
1186
1187 #[test]
1190 fn resolve_existing_file_is_single() {
1191 let dir = tmpdir();
1192 let f = dir.path().join("a.parquet");
1193 write_standard(&f, vec![1, 2], false);
1194 let src = ConvertSource::resolve(f.to_str().unwrap()).unwrap();
1195 assert!(matches!(src, ConvertSource::Single(_)));
1196 assert_eq!(src.display_name(), f.display().to_string());
1197 assert_eq!(src.parts().len(), 1);
1198 }
1199
1200 #[test]
1201 fn resolve_directory_recursive_sorted_multi() {
1202 let dir = tmpdir();
1203 let sub = dir.path().join("sub");
1204 std::fs::create_dir(&sub).unwrap();
1205 write_standard(&dir.path().join("b.parquet"), vec![1], false);
1206 write_standard(&sub.join("a.parquet"), vec![2], false);
1207 std::fs::write(dir.path().join("readme.txt"), "x").unwrap();
1208
1209 let src = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap();
1210 let ConvertSource::Multi(m) = &src else {
1211 panic!("directory with 2 parquet files must resolve to Multi");
1212 };
1213 assert_eq!(m.parts.len(), 2);
1214 assert!(src.parts()[0].display_name().ends_with("b.parquet"));
1216 assert!(src.parts()[1].display_name().ends_with("a.parquet"));
1217 assert!(src.display_name().contains("(2 partitions)"));
1218 }
1219
1220 #[test]
1221 fn list_parquet_files_skips_hidden_and_success_markers() {
1222 let dir = tmpdir();
1223 write_standard(&dir.path().join("part-0.parquet"), vec![1], false);
1224 write_standard(&dir.path().join("part-1.parquet"), vec![2], false);
1225 std::fs::write(dir.path().join("_SUCCESS"), "").unwrap();
1227 write_standard(&dir.path().join("_stale.parquet"), vec![9], false);
1228 write_standard(&dir.path().join(".hidden.parquet"), vec![9], false);
1229 let hidden_dir = dir.path().join("_temporary");
1230 std::fs::create_dir(&hidden_dir).unwrap();
1231 write_standard(&hidden_dir.join("x.parquet"), vec![9], false);
1232
1233 let files = list_parquet_files(dir.path()).unwrap();
1234 assert_eq!(files.len(), 2, "only visible .parquet files: {files:?}");
1235 assert!(files.windows(2).all(|w| w[0] <= w[1]), "sorted: {files:?}");
1236 assert!(files[0].ends_with("part-0.parquet"));
1237 assert!(files[1].ends_with("part-1.parquet"));
1238 }
1239
1240 #[test]
1241 fn resolve_empty_directory_errors_naming_input() {
1242 let dir = tmpdir();
1243 std::fs::write(dir.path().join("_SUCCESS"), "").unwrap();
1244 let err = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap_err();
1245 match err {
1246 InputError::NoParquetInputs { input } => {
1247 assert_eq!(input, dir.path().to_str().unwrap());
1248 }
1249 other => panic!("expected NoParquetInputs, got {other:?}"),
1250 }
1251 }
1252
1253 #[test]
1254 fn resolve_glob_sorted_dedup_and_single_collapse() {
1255 let dir = tmpdir();
1256 write_standard(&dir.path().join("p2.parquet"), vec![1], false);
1257 write_standard(&dir.path().join("p1.parquet"), vec![2], false);
1258 std::fs::write(dir.path().join("p3.txt"), "x").unwrap();
1259
1260 let pattern = format!("{}/p*.parquet", dir.path().display());
1261 let files = expand_glob(&pattern).unwrap();
1262 assert_eq!(files.len(), 2);
1263 assert!(files[0].ends_with("p1.parquet"));
1264 assert!(files[1].ends_with("p2.parquet"));
1265
1266 let src = ConvertSource::resolve(&pattern).unwrap();
1267 assert!(matches!(src, ConvertSource::Multi(_)));
1268
1269 let single = format!("{}/p1*.parquet", dir.path().display());
1271 let src = ConvertSource::resolve(&single).unwrap();
1272 assert!(matches!(src, ConvertSource::Single(_)));
1273
1274 let none = format!("{}/zzz*.parquet", dir.path().display());
1276 let err = ConvertSource::resolve(&none).unwrap_err();
1277 assert!(matches!(err, InputError::NoParquetInputs { .. }), "{err:?}");
1278 }
1279
1280 #[test]
1284 fn remote_prefix_is_trailing_slash_only() {
1285 assert!(remote_url_is_prefix("s3://bucket/dataset/"));
1286 assert!(remote_url_is_prefix("gs://bucket/prefix/"));
1287 assert!(remote_url_is_prefix("https://example.com/data/?list=1"));
1288 assert!(remote_url_is_prefix("https://example.com/data/#frag"));
1289 assert!(!remote_url_is_prefix("s3://bucket/key.parquet"));
1290 assert!(!remote_url_is_prefix(
1291 "https://h/k.parquet?X-Amz-Signature=abc"
1292 ));
1293 assert!(!remote_url_is_prefix(
1294 "https://host/api/datasets/42/download"
1295 ));
1296 assert!(!remote_url_is_prefix("s3://bucket/dataset"));
1297 }
1298
1299 #[cfg(feature = "remote")]
1304 #[test]
1305 fn resolve_https_prefix_points_at_files_from() {
1306 for url in ["https://example.com/data/", "http://example.com/data/"] {
1307 let err = ConvertSource::resolve(url).unwrap_err();
1308 assert!(
1309 matches!(err, InputError::RemotePrefixUnsupported { .. }),
1310 "{url} → {err:?}"
1311 );
1312 let msg = err.to_string();
1313 assert!(
1314 msg.contains("--files-from"),
1315 "error must point at --files-from: {msg}"
1316 );
1317 }
1318 }
1319
1320 #[cfg(not(feature = "remote"))]
1326 #[test]
1327 fn remote_disabled_behavior_unchanged() {
1328 for url in [
1329 "s3://bucket/prefix/",
1330 "s3://bucket/key.parquet",
1331 "https://host/api/datasets/42/download",
1332 ] {
1333 let err = ConvertSource::resolve(url).unwrap_err();
1334 assert!(
1335 matches!(err, InputError::RemoteDisabled(_)),
1336 "{url} → {err:?}"
1337 );
1338 }
1339 }
1340
1341 #[test]
1344 fn schema_mismatch_names_offending_partition() {
1345 let dir = tmpdir();
1346 write_standard(&dir.path().join("a.parquet"), vec![1], false);
1347 write_parquet(
1349 &dir.path().join("b.parquet"),
1350 vec![
1351 Field::new("id2", DataType::Int64, false),
1352 Field::new("name", DataType::Utf8, true),
1353 ],
1354 vec![
1355 Arc::new(Int64Array::from(vec![1i64])),
1356 Arc::new(StringArray::from(vec!["x"])),
1357 ],
1358 None,
1359 );
1360 let err = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap_err();
1361 match err {
1362 InputError::IncompatiblePartition {
1363 first,
1364 offender,
1365 detail,
1366 } => {
1367 assert!(first.ends_with("a.parquet"), "first: {first}");
1368 assert!(offender.ends_with("b.parquet"), "offender: {offender}");
1369 assert!(detail.contains("id2") || detail.contains("id"), "{detail}");
1370 }
1371 other => panic!("expected IncompatiblePartition, got {other:?}"),
1372 }
1373 }
1374
1375 #[test]
1376 fn type_mismatch_rejected() {
1377 let dir = tmpdir();
1378 write_standard(&dir.path().join("a.parquet"), vec![1], false);
1379 write_parquet(
1380 &dir.path().join("b.parquet"),
1381 vec![
1382 Field::new("id", DataType::Utf8, false), Field::new("name", DataType::Utf8, true),
1384 ],
1385 vec![
1386 Arc::new(StringArray::from(vec!["1"])),
1387 Arc::new(StringArray::from(vec!["x"])),
1388 ],
1389 None,
1390 );
1391 let err = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap_err();
1392 assert!(
1393 matches!(err, InputError::IncompatiblePartition { .. }),
1394 "{err:?}"
1395 );
1396 }
1397
1398 #[test]
1402 fn different_unsupported_crs_rejected_with_both_values() {
1403 let dir = tmpdir();
1404 let geo = |epsg: u32| {
1405 format!(
1406 r#"{{"version":"1.0.0","primary_column":"geometry","columns":{{"geometry":{{"crs":"EPSG:{epsg}"}}}}}}"#
1407 )
1408 };
1409 for (file, epsg) in [("a.parquet", 32633u32), ("b.parquet", 32634u32)] {
1410 let path = dir.path().join(file);
1411 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
1412 let batch = RecordBatch::try_new(
1413 schema.clone(),
1414 vec![Arc::new(Int64Array::from(vec![1i64])) as ArrayRef],
1415 )
1416 .unwrap();
1417 let file = File::create(&path).unwrap();
1418 let mut writer = ArrowWriter::try_new(file, schema, None).unwrap();
1419 writer.write(&batch).unwrap();
1420 writer.append_key_value_metadata(parquet::file::metadata::KeyValue::new(
1421 "geo".to_string(),
1422 geo(epsg),
1423 ));
1424 writer.close().unwrap();
1425 }
1426 let err = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap_err();
1427 match err {
1428 InputError::IncompatiblePartition {
1429 offender, detail, ..
1430 } => {
1431 assert!(offender.ends_with("b.parquet"), "offender: {offender}");
1432 assert!(
1433 detail.contains("32633") && detail.contains("32634"),
1434 "detail must name both raw CRS values: {detail}"
1435 );
1436 }
1437 other => panic!("expected IncompatiblePartition, got {other:?}"),
1438 }
1439 }
1440
1441 #[test]
1445 fn metadata_mismatch_detail_names_key_and_values() {
1446 let dir = tmpdir();
1447 for (file, val) in [("a.parquet", "value-one"), ("b.parquet", "value-two")] {
1448 let md: std::collections::HashMap<String, String> =
1449 [("ARROW:extension:name".to_string(), val.to_string())].into();
1450 write_parquet(
1451 &dir.path().join(file),
1452 vec![Field::new("id", DataType::Int64, false).with_metadata(md)],
1453 vec![Arc::new(Int64Array::from(vec![1i64]))],
1454 None,
1455 );
1456 }
1457 let err = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap_err();
1458 match err {
1459 InputError::IncompatiblePartition { detail, .. } => {
1460 assert!(
1461 detail.contains("ARROW:extension:name"),
1462 "detail must name the differing key: {detail}"
1463 );
1464 assert!(
1465 detail.contains("value-one") && detail.contains("value-two"),
1466 "detail must show both values: {detail}"
1467 );
1468 }
1469 other => panic!("expected IncompatiblePartition, got {other:?}"),
1470 }
1471 }
1472
1473 #[test]
1474 fn nullability_difference_unions_to_nullable() {
1475 let dir = tmpdir();
1476 write_standard(&dir.path().join("a.parquet"), vec![1], false); write_standard(&dir.path().join("b.parquet"), vec![2], true); let src = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap();
1479 let schema = src.schema().unwrap();
1480 let id = schema.field_with_name("id").unwrap();
1481 assert!(
1482 id.is_nullable(),
1483 "any-nullable must union to nullable: {schema:?}"
1484 );
1485 }
1486
1487 fn stream_ids(src: &ConvertSource, plan: &ReadPlan<'_>) -> Vec<i64> {
1491 let mut out = Vec::new();
1492 for batch in src.open_stream(plan).unwrap() {
1493 let batch = batch.unwrap();
1494 let ids = batch
1495 .column(batch.schema().index_of("id").unwrap())
1496 .as_any()
1497 .downcast_ref::<Int64Array>()
1498 .unwrap()
1499 .clone();
1500 out.extend(ids.values().iter().copied());
1501 }
1502 out
1503 }
1504
1505 #[test]
1506 fn stream_concatenates_parts_in_order() {
1507 let dir = tmpdir();
1508 write_standard(&dir.path().join("p0.parquet"), vec![0, 1, 2], false);
1509 write_standard(&dir.path().join("p1.parquet"), vec![3, 4], false);
1510 write_standard(&dir.path().join("p2.parquet"), vec![5], false);
1511 let src = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap();
1512 let plan = ReadPlan {
1513 batch_size: 2,
1514 projection: None,
1515 row_groups: None,
1516 };
1517 assert_eq!(stream_ids(&src, &plan), vec![0, 1, 2, 3, 4, 5]);
1518 }
1519
1520 #[test]
1521 fn stream_skips_zero_row_partition() {
1522 let dir = tmpdir();
1523 write_standard(&dir.path().join("p0.parquet"), vec![0, 1], false);
1524 write_standard(&dir.path().join("p1.parquet"), vec![], false); write_standard(&dir.path().join("p2.parquet"), vec![2, 3], false);
1526 let src = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap();
1527 let plan = ReadPlan {
1528 batch_size: 1024,
1529 projection: None,
1530 row_groups: None,
1531 };
1532 assert_eq!(stream_ids(&src, &plan), vec![0, 1, 2, 3]);
1533 }
1534
1535 #[test]
1536 fn stream_honors_per_part_row_group_selection() {
1537 let dir = tmpdir();
1538 let f0 = dir.path().join("p0.parquet");
1540 let f1 = dir.path().join("p1.parquet");
1541 for (f, base) in [(&f0, 0i64), (&f1, 10i64)] {
1542 write_parquet(
1543 f,
1544 vec![Field::new("id", DataType::Int64, false)],
1545 vec![Arc::new(Int64Array::from(vec![
1546 base,
1547 base + 1,
1548 base + 2,
1549 base + 3,
1550 ]))],
1551 Some(2),
1552 );
1553 }
1554 let src = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap();
1555 assert_eq!(src.num_row_groups_total().unwrap(), 4);
1556
1557 let sel = RowGroupSelection::from_parts(vec![vec![], vec![1]]);
1559 assert_eq!(sel.total_selected(), 1);
1560 let plan = ReadPlan {
1561 batch_size: 1024,
1562 projection: None,
1563 row_groups: Some(&sel),
1564 };
1565 assert_eq!(stream_ids(&src, &plan), vec![12, 13]);
1566
1567 let bytes = src.selected_input_bytes(Some(&sel)).unwrap();
1570 assert!(bytes > 0);
1571 let all = RowGroupSelection::from_parts(vec![vec![0, 1], vec![0, 1]]);
1572 let all_bytes = src.selected_input_bytes(Some(&all)).unwrap();
1573 assert!(all_bytes > bytes);
1574 assert_eq!(src.selected_input_bytes(None).unwrap(), all_bytes);
1575 }
1576
1577 #[test]
1578 fn stream_projection_applies_to_every_part() {
1579 let dir = tmpdir();
1580 write_standard(&dir.path().join("p0.parquet"), vec![0], false);
1581 write_standard(&dir.path().join("p1.parquet"), vec![1], false);
1582 let src = ConvertSource::resolve(dir.path().to_str().unwrap()).unwrap();
1583 let cols = [0usize]; let plan = ReadPlan {
1585 batch_size: 8,
1586 projection: Some(&cols),
1587 row_groups: None,
1588 };
1589 for batch in src.open_stream(&plan).unwrap() {
1590 let batch = batch.unwrap();
1591 assert_eq!(batch.num_columns(), 1);
1592 assert_eq!(batch.schema().field(0).name(), "id");
1593 }
1594 }
1595
1596 fn write_three(path: &Path, ids: Vec<i64>) {
1600 let n = ids.len();
1601 write_parquet(
1602 path,
1603 vec![
1604 Field::new("id", DataType::Int64, false),
1605 Field::new("name", DataType::Utf8, true),
1606 Field::new("extra", DataType::Int64, true),
1607 ],
1608 vec![
1609 Arc::new(Int64Array::from(ids)),
1610 Arc::new(StringArray::from(
1611 (0..n).map(|i| format!("r{i}")).collect::<Vec<_>>(),
1612 )),
1613 Arc::new(Int64Array::from((0..n as i64).collect::<Vec<_>>())),
1614 ],
1615 None,
1616 );
1617 }
1618
1619 #[test]
1620 fn restrict_columns_narrows_schema_and_every_read() {
1621 for multi in [false, true] {
1622 let dir = tmpdir();
1623 write_three(&dir.path().join("p0.parquet"), vec![0, 1]);
1624 if multi {
1625 write_three(&dir.path().join("p1.parquet"), vec![2]);
1626 }
1627 let input = if multi {
1628 dir.path().to_path_buf()
1629 } else {
1630 dir.path().join("p0.parquet")
1631 };
1632 let src = ConvertSource::resolve(input.to_str().unwrap()).unwrap();
1633 assert_eq!(src.schema().unwrap().fields().len(), 3);
1634
1635 src.restrict_columns(vec![0, 2]).unwrap();
1637 let names: Vec<String> = src
1638 .schema()
1639 .unwrap()
1640 .fields()
1641 .iter()
1642 .map(|f| f.name().clone())
1643 .collect();
1644 assert_eq!(names, vec!["id", "extra"], "multi={multi}");
1645 assert_eq!(src.file_schema().unwrap().fields().len(), 3);
1646
1647 let plan = ReadPlan {
1649 batch_size: 8,
1650 projection: None,
1651 row_groups: None,
1652 };
1653 let mut rows = 0;
1654 for batch in src.open_stream(&plan).unwrap() {
1655 let batch = batch.unwrap();
1656 assert_eq!(batch.num_columns(), 2);
1657 assert_eq!(batch.schema().field(1).name(), "extra");
1658 rows += batch.num_rows();
1659 }
1660 assert_eq!(rows, if multi { 3 } else { 2 });
1661
1662 let cols = [1usize];
1664 let plan = ReadPlan {
1665 batch_size: 8,
1666 projection: Some(&cols),
1667 row_groups: None,
1668 };
1669 for batch in src.open_stream(&plan).unwrap() {
1670 let batch = batch.unwrap();
1671 assert_eq!(batch.num_columns(), 1);
1672 assert_eq!(batch.schema().field(0).name(), "extra");
1673 }
1674
1675 assert!(src.restrict_columns(vec![0]).is_err());
1677 let fresh = ConvertSource::resolve(input.to_str().unwrap()).unwrap();
1678 assert!(fresh.restrict_columns(vec![2, 0]).is_err());
1679 assert!(fresh.restrict_columns(vec![0, 7]).is_err());
1680 }
1681 }
1682
1683 #[test]
1686 fn source_stream_is_send() {
1687 fn assert_send<T: Send>() {}
1688 assert_send::<SourceStream<'static>>();
1689 fn assert_sync<T: Sync>() {}
1691 assert_sync::<ConvertSource>();
1692 }
1693
1694 #[test]
1700 fn manifest_entries_skip_comments_and_preserve_order() {
1701 let text = "\
1702# heading comment
1703b.parquet
1704
1705 # indented comment
1706 a.parquet \n\nz/c.parquet\n";
1707 let entries = manifest_entries(text);
1708 assert_eq!(
1709 entries,
1710 vec![(2, "b.parquet"), (5, "a.parquet"), (7, "z/c.parquet")],
1711 "order verbatim (b before a), 1-based line numbers"
1712 );
1713 }
1714
1715 #[test]
1716 fn manifest_multi_source_preserves_line_order() {
1717 let dir = tmpdir();
1718 write_standard(&dir.path().join("b.parquet"), vec![0, 1], false);
1720 write_standard(&dir.path().join("a.parquet"), vec![2, 3], false);
1721 let manifest = dir.path().join("parts.txt");
1722 std::fs::write(
1723 &manifest,
1724 format!(
1725 "# two partitions, b first\n{}\n\n{}\n",
1726 dir.path().join("b.parquet").display(),
1727 dir.path().join("a.parquet").display()
1728 ),
1729 )
1730 .unwrap();
1731 let src = ConvertSource::from_manifest(&manifest).unwrap();
1732 assert!(matches!(src, ConvertSource::Multi(_)));
1733 let plan = ReadPlan {
1734 batch_size: 1024,
1735 projection: None,
1736 row_groups: None,
1737 };
1738 assert_eq!(stream_ids(&src, &plan), vec![0, 1, 2, 3]);
1739 }
1740
1741 #[test]
1744 fn manifest_single_entry_and_file_url() {
1745 let dir = tmpdir();
1746 let f = dir.path().join("only.parquet");
1747 write_standard(&f, vec![7], false);
1748 let manifest = dir.path().join("one.txt");
1749 std::fs::write(&manifest, format!("file://{}\n", f.display())).unwrap();
1750 let src = ConvertSource::from_manifest(&manifest).unwrap();
1751 assert!(matches!(src, ConvertSource::Single(_)));
1752 let plan = ReadPlan {
1753 batch_size: 8,
1754 projection: None,
1755 row_groups: None,
1756 };
1757 assert_eq!(stream_ids(&src, &plan), vec![7]);
1758 }
1759
1760 #[test]
1764 fn manifest_missing_file_names_line_and_path() {
1765 let dir = tmpdir();
1766 write_standard(&dir.path().join("ok.parquet"), vec![1], false);
1767 let manifest = dir.path().join("bad.txt");
1768 std::fs::write(
1769 &manifest,
1770 format!(
1771 "{}\n# comment\n{}\n",
1772 dir.path().join("ok.parquet").display(),
1773 dir.path().join("nope.parquet").display()
1774 ),
1775 )
1776 .unwrap();
1777 let err = ConvertSource::from_manifest(&manifest).unwrap_err();
1778 let msg = err.to_string();
1779 assert!(msg.contains("line 3"), "names the manifest line: {msg}");
1780 assert!(msg.contains("nope.parquet"), "names the path: {msg}");
1781
1782 let manifest2 = dir.path().join("dir.txt");
1784 std::fs::write(&manifest2, format!("{}\n", dir.path().display())).unwrap();
1785 let err = ConvertSource::from_manifest(&manifest2).unwrap_err();
1786 assert!(
1787 matches!(err, InputError::MissingListedInput { .. }),
1788 "directory lines are rejected: {err:?}"
1789 );
1790 }
1791
1792 #[test]
1793 fn manifest_empty_or_unreadable_is_a_clear_error() {
1794 let dir = tmpdir();
1795 let empty = dir.path().join("empty.txt");
1796 std::fs::write(&empty, "# only comments\n\n").unwrap();
1797 let err = ConvertSource::from_manifest(&empty).unwrap_err();
1798 assert!(matches!(err, InputError::NoParquetInputs { .. }), "{err:?}");
1799
1800 let err = ConvertSource::from_manifest(&dir.path().join("missing.txt")).unwrap_err();
1801 let msg = err.to_string();
1802 assert!(msg.contains("missing.txt"), "names the manifest: {msg}");
1803 }
1804
1805 #[test]
1808 fn input_list_preserves_order_and_validates() {
1809 let dir = tmpdir();
1810 write_standard(&dir.path().join("b.parquet"), vec![0], false);
1811 write_standard(&dir.path().join("a.parquet"), vec![1], false);
1812 let list = [
1813 dir.path().join("b.parquet").display().to_string(),
1814 dir.path().join("a.parquet").display().to_string(),
1815 ];
1816 let src = ConvertSource::from_input_list(&list).unwrap();
1817 let plan = ReadPlan {
1818 batch_size: 8,
1819 projection: None,
1820 row_groups: None,
1821 };
1822 assert_eq!(stream_ids(&src, &plan), vec![0, 1]);
1823
1824 let bad = [dir.path().join("nope.parquet").display().to_string()];
1825 let err = ConvertSource::from_input_list(&bad).unwrap_err();
1826 let msg = err.to_string();
1827 assert!(msg.contains("entry 1"), "names the entry: {msg}");
1828 assert!(msg.contains("nope.parquet"), "names the path: {msg}");
1829
1830 let none: [String; 0] = [];
1831 let err = ConvertSource::from_input_list(&none).unwrap_err();
1832 assert!(matches!(err, InputError::NoParquetInputs { .. }), "{err:?}");
1833 }
1834
1835 #[cfg(feature = "remote")]
1838 mod remote_listing {
1839 use super::*;
1840 use crate::input::remote::{list_parquet_under_prefix, RemoteSource};
1841 use object_store::memory::InMemory;
1842 use object_store::path::Path as ObjectPath;
1843 use object_store::{ObjectStore, ObjectStoreExt};
1844
1845 fn seeded_store(objects: &[(&str, Vec<u8>)]) -> Arc<InMemory> {
1847 let store = Arc::new(InMemory::new());
1848 let rt = tokio::runtime::Builder::new_current_thread()
1849 .enable_all()
1850 .build()
1851 .unwrap();
1852 for (key, bytes) in objects {
1853 rt.block_on(store.put(&ObjectPath::from(*key), bytes.clone().into()))
1854 .unwrap();
1855 }
1856 store
1857 }
1858
1859 fn parquet_bytes(ids: Vec<i64>) -> Vec<u8> {
1860 let batch = RecordBatch::try_new(
1861 Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])),
1862 vec![Arc::new(Int64Array::from(ids))],
1863 )
1864 .unwrap();
1865 let mut buf = Vec::new();
1866 let mut w = ArrowWriter::try_new(&mut buf, batch.schema(), None).unwrap();
1867 w.write(&batch).unwrap();
1868 w.close().unwrap();
1869 buf
1870 }
1871
1872 #[test]
1877 fn listing_filters_and_sorts() {
1878 let store = seeded_store(&[
1879 ("set/b.parquet", parquet_bytes(vec![1])),
1880 ("set/a.parquet", parquet_bytes(vec![2])),
1881 ("set/nested/c.parquet", parquet_bytes(vec![3])),
1882 ("set/_SUCCESS", vec![]),
1883 ("set/_delta_log/d.parquet", parquet_bytes(vec![4])),
1884 ("set/.hidden.parquet", parquet_bytes(vec![5])),
1885 ("set/zero.parquet", vec![]),
1886 ("set/readme.txt", b"hi".to_vec()),
1887 ("other/x.parquet", parquet_bytes(vec![6])),
1888 ]);
1889 let store: Arc<dyn ObjectStore> = store;
1890 let listed =
1891 list_parquet_under_prefix(&store, &ObjectPath::from("set"), "memory://set/")
1892 .unwrap();
1893 let keys: Vec<String> = listed
1894 .iter()
1895 .map(|p| p.location.as_ref().to_string())
1896 .collect();
1897 assert_eq!(
1898 keys,
1899 vec!["set/a.parquet", "set/b.parquet", "set/nested/c.parquet"],
1900 "exactly the visible .parquet keys, sorted"
1901 );
1902 assert!(listed.iter().all(|p| p.size > 0), "sizes from the listing");
1903 }
1904
1905 #[test]
1908 fn listed_parts_stream_in_order() {
1909 let store = seeded_store(&[
1910 ("set/p1.parquet", parquet_bytes(vec![10, 11])),
1911 ("set/p0.parquet", parquet_bytes(vec![0, 1])),
1912 ]);
1913 let store: Arc<dyn ObjectStore> = store;
1914 let listed =
1915 list_parquet_under_prefix(&store, &ObjectPath::from("set"), "memory://set/")
1916 .unwrap();
1917 let parts: Vec<InputSource> = listed
1918 .into_iter()
1919 .map(|p| {
1920 InputSource::Remote(RemoteSource::from_store_sized(
1921 Arc::clone(&store),
1922 p.location.clone(),
1923 format!("memory://{}", p.location),
1924 p.size,
1925 ))
1926 })
1927 .collect();
1928 let src = ConvertSource::Multi(
1929 MultiSource::from_sources("memory://set/".to_string(), parts).unwrap(),
1930 );
1931 let plan = ReadPlan {
1932 batch_size: 1024,
1933 projection: None,
1934 row_groups: None,
1935 };
1936 assert_eq!(stream_ids(&src, &plan), vec![0, 1, 10, 11]);
1937 let stats = src.fetch_stats().expect("remote parts have stats");
1938 assert!(stats.object_size > 0, "object_size sums the parts");
1939 }
1940
1941 #[test]
1946 fn explicit_list_parallel_connect_preserves_order() {
1947 use std::sync::atomic::{AtomicBool, Ordering};
1948 use std::sync::{Condvar, Mutex as StdMutex};
1949
1950 let store = seeded_store(&[
1951 ("m/a.parquet", parquet_bytes(vec![10])),
1952 ("m/b.parquet", parquet_bytes(vec![0, 1])),
1953 ("m/c.parquet", parquet_bytes(vec![20])),
1954 ]);
1955 let store: Arc<dyn ObjectStore> = store;
1956
1957 let entries: Vec<(String, String)> = ["m/c.parquet", "m/a.parquet", "m/b.parquet"]
1959 .into_iter()
1960 .enumerate()
1961 .map(|(i, key)| (format!("entry {}", i + 1), key.to_string()))
1962 .collect();
1963
1964 let in_flight = StdMutex::new(0usize);
1969 let cv = Condvar::new();
1970 let overlapped = AtomicBool::new(false);
1971
1972 let resolved = resolve_list_entries(&entries, |_context, entry| {
1973 {
1974 let mut n = in_flight.lock().unwrap();
1975 *n += 1;
1976 if *n >= 2 {
1977 overlapped.store(true, Ordering::SeqCst);
1978 cv.notify_all();
1979 } else {
1980 let (guard, _) = cv
1981 .wait_timeout_while(n, std::time::Duration::from_secs(10), |_| {
1982 !overlapped.load(Ordering::SeqCst)
1983 })
1984 .unwrap();
1985 drop(guard);
1986 }
1987 }
1988 Ok(InputSource::Remote(RemoteSource::from_store(
1989 Arc::clone(&store),
1990 ObjectPath::from(entry),
1991 format!("memory://{entry}"),
1992 )?))
1993 })
1994 .unwrap();
1995 assert!(
1996 overlapped.load(Ordering::SeqCst),
1997 "entry connects must overlap (bounded parallelism), not run serially"
1998 );
1999
2000 let src = ConvertSource::from_parts("list", resolved).unwrap();
2001 let plan = ReadPlan {
2002 batch_size: 1024,
2003 projection: None,
2004 row_groups: None,
2005 };
2006 assert_eq!(
2007 stream_ids(&src, &plan),
2008 vec![20, 10, 0, 1],
2009 "parts follow entry order verbatim"
2010 );
2011 }
2012
2013 #[test]
2016 fn explicit_list_first_error_by_index_wins() {
2017 let entries: Vec<(String, String)> = (1..=4)
2018 .map(|i| (format!("entry {i}"), format!("e{i}")))
2019 .collect();
2020 let err = resolve_list_entries(&entries, |context, entry| {
2021 if entry == "e2" || entry == "e4" {
2022 return Err(InputError::MissingListedInput {
2023 context: context.to_string(),
2024 input: entry.to_string(),
2025 });
2026 }
2027 Ok(InputSource::Local(PathBuf::from(entry)))
2028 })
2029 .unwrap_err();
2030 let msg = err.to_string();
2031 assert!(
2032 msg.contains("entry 2"),
2033 "first failing entry by index wins: {msg}"
2034 );
2035 }
2036 }
2037
2038 #[test]
2043 fn layer_name_single_file_is_stem() {
2044 assert_eq!(derive_layer_name("/data/buildings.parquet"), "buildings");
2045 assert_eq!(derive_layer_name("buildings.parquet"), "buildings");
2046 assert_eq!(
2048 derive_layer_name("/tmp/portland-roads.txt"),
2049 "portland-roads"
2050 );
2051 }
2052
2053 #[test]
2057 fn layer_name_directory_is_last_segment() {
2058 let dir = tmpdir();
2059 let parts = dir.path().join("nyc_buildings");
2060 std::fs::create_dir(&parts).unwrap();
2061 assert_eq!(derive_layer_name(parts.to_str().unwrap()), "nyc_buildings");
2062
2063 let dotted = dir.path().join("buildings.v2");
2064 std::fs::create_dir(&dotted).unwrap();
2065 assert_eq!(
2066 derive_layer_name(&format!("{}/", dotted.display())),
2067 "buildings.v2"
2068 );
2069 }
2070
2071 #[test]
2075 fn layer_name_glob_uses_last_literal_segment() {
2076 assert_eq!(derive_layer_name("/data/parts/*.parquet"), "parts");
2077 assert_eq!(derive_layer_name("/data/part-*.parquet"), "data");
2078 assert_eq!(derive_layer_name("/data/**/part-?.parquet"), "data");
2079 assert_eq!(derive_layer_name("*.parquet"), "layer");
2080 }
2081
2082 #[test]
2086 fn layer_name_remote_prefix_uses_last_segment() {
2087 assert_eq!(derive_layer_name("s3://bucket/datasets/roads/"), "roads");
2088 assert_eq!(derive_layer_name("gs://bucket/roads/"), "roads");
2089 assert_eq!(derive_layer_name("s3://bucket/roads/?list-type=2"), "roads");
2090 assert_eq!(derive_layer_name("s3://bucket/"), "bucket");
2091 }
2092
2093 #[test]
2096 fn layer_name_remote_object_is_stem() {
2097 assert_eq!(derive_layer_name("s3://bucket/data/roads.parquet"), "roads");
2098 assert_eq!(
2099 derive_layer_name("https://host/dl/roads.parquet?sig=abc"),
2100 "roads"
2101 );
2102 assert_eq!(derive_layer_name("https://host/api/download/42"), "42");
2104 }
2105
2106 #[test]
2108 fn layer_name_degenerate_falls_back() {
2109 assert_eq!(derive_layer_name(""), "layer");
2110 assert_eq!(derive_layer_name("s3://"), "layer");
2111 assert_eq!(derive_layer_name("/"), "layer");
2112 }
2113}