1use std::{
4 collections::{BTreeMap, BTreeSet},
5 fmt,
6 path::PathBuf,
7 sync::Arc,
8};
9
10use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
11
12use super::{
13 diagnostics::{
14 LocalParquetDiagnosticColumns, PredicateDisposition, RejectedSourceFeature, SourceDiagnostics,
15 ZeroColumnCountDisposition, files_have_proven_direct_device_read, redacted_debug_value,
16 },
17 location::{ParquetSourceLocation, object_store_reader_path, parquet_source_location_for_original},
18 predicate::{
19 reader_predicate_expr_is_supported, reader_predicate_references_decimal_column,
20 reader_predicate_root_is_supported, reader_predicate_source_contract_shape_is_supported,
21 rewrite_fixed_prefix_substring_reader_predicate,
22 },
23 read_policy::{CredentialMode, ResolvedParquetSource},
24};
25use crate::{
26 backend::AnalysisContext,
27 expr::{Expr, LiteralValue},
28};
29
30pub const ZERO_COLUMN_COUNT_COLUMN: &str = "__algeon_native_zero_column_row_count";
35
36pub const GRAPH_VERTEX_UPPER_BOUND_PARQUET_METADATA_KEY: &str = "algeon.graph.vertex_upper_bound";
43
44pub const GRAPH_SIMPLE_DIRECTED_EDGE_TOPOLOGY_PARQUET_METADATA_KEY: &str = "algeon.graph.simple_directed_edge_topology";
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct GraphSimpleDirectedEdgeTopology {
61 pub(crate) source_column_index: usize,
63 pub(crate) destination_column_index: usize,
65}
66
67impl GraphSimpleDirectedEdgeTopology {
68 pub const fn new(source_column_index: usize, destination_column_index: usize) -> Option<Self> {
70 if source_column_index == destination_column_index {
71 return None;
72 }
73 Some(Self { source_column_index, destination_column_index })
74 }
75}
76
77fn schema_column_indices(schema: &SchemaRef, columns: &[String]) -> Vec<usize> {
78 columns.iter().filter_map(|column| schema.index_of(column).ok()).collect()
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct PartitionColumn {
88 pub name: String,
90 pub data_type: DataType,
92 pub nullable: bool,
94}
95
96impl PartitionColumn {
97 pub fn new(name: impl Into<String>, data_type: DataType, nullable: bool) -> Self {
99 Self { name: name.into(), data_type, nullable }
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct MissingColumn {
110 pub name: String,
112 pub data_type: DataType,
114 pub nullable: bool,
116}
117
118impl MissingColumn {
119 pub fn new(name: impl Into<String>, data_type: DataType, nullable: bool) -> Self {
122 Self { name: name.into(), data_type, nullable }
123 }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
128pub enum ParquetReaderDecimalTypeId {
129 Decimal32,
131 Decimal64,
133 Decimal128,
135}
136
137impl ParquetReaderDecimalTypeId {
138 pub const fn as_str(self) -> &'static str {
140 match self {
141 Self::Decimal32 => "decimal32",
142 Self::Decimal64 => "decimal64",
143 Self::Decimal128 => "decimal128",
144 }
145 }
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Hash)]
150pub struct ParquetReaderDecimalType {
151 pub column_name: String,
153 pub type_id: ParquetReaderDecimalTypeId,
155 pub cudf_scale: i32,
157}
158
159impl ParquetReaderDecimalType {
160 pub fn new(column_name: impl Into<String>, type_id: ParquetReaderDecimalTypeId, cudf_scale: i32) -> Self {
162 Self { column_name: column_name.into(), type_id, cudf_scale }
163 }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
171pub enum SourceColumnRef {
172 File(usize),
174 Partition(usize),
176 MissingNull(usize),
178}
179
180#[derive(Debug, Clone, PartialEq)]
185pub struct LocalParquetSource {
186 pub files: Vec<LocalParquetFile>,
188 pub schema: SchemaRef,
190 pub output_schema_override: Option<SchemaRef>,
193 pub read_columns: Vec<String>,
200 pub read_column_file_indices: Vec<usize>,
202 pub(crate) file_column_count: Option<usize>,
204 pub projected_output_columns: Vec<String>,
206 pub projected_output_column_source_refs: Vec<SourceColumnRef>,
208 pub pure_filter_columns: Vec<String>,
213 pub pure_filter_column_source_refs: Vec<SourceColumnRef>,
215 pub partition_columns: Vec<PartitionColumn>,
217 pub missing_columns: Vec<MissingColumn>,
219 pub scan_limit: Option<usize>,
221 pub reader_predicate: Option<Expr>,
227 pub max_rows: Option<usize>,
230 pub unique_key_columns: Vec<BTreeSet<String>>,
232 pub diagnostics: SourceDiagnostics,
234 pub(crate) count_star_probe: bool,
236}
237
238impl LocalParquetSource {
239 pub fn from_files(files: Vec<LocalParquetFile>, schema: SchemaRef, read_columns: Vec<String>) -> Self {
245 let read_column_file_indices = schema_column_indices(&schema, &read_columns);
246 let file_column_count = Some(schema.fields().len());
247 let projected_output_columns = read_columns.clone();
248 let projected_output_column_source_refs =
249 read_column_file_indices.iter().copied().map(SourceColumnRef::File).collect::<Vec<_>>();
250 let pure_filter_columns = Vec::new();
251 let pure_filter_column_source_refs = Vec::new();
252 let partition_columns = Vec::new();
253 let missing_columns = Vec::new();
254 let diagnostics = SourceDiagnostics::for_local_parquet_files(
255 &files,
256 LocalParquetDiagnosticColumns {
257 read_columns: &read_columns,
258 read_column_file_indices: &read_column_file_indices,
259 projected_output_columns: &projected_output_columns,
260 projected_output_column_source_refs: &projected_output_column_source_refs,
261 pure_filter_columns: &pure_filter_columns,
262 pure_filter_column_source_refs: &pure_filter_column_source_refs,
263 partition_columns: &partition_columns,
264 missing_columns: &missing_columns,
265 scan_limit: None,
266 },
267 );
268 Self {
269 files,
270 schema,
271 output_schema_override: None,
272 read_columns,
273 read_column_file_indices,
274 file_column_count,
275 projected_output_columns,
276 projected_output_column_source_refs,
277 pure_filter_columns,
278 pure_filter_column_source_refs,
279 partition_columns,
280 missing_columns,
281 scan_limit: None,
282 reader_predicate: None,
283 max_rows: None,
284 unique_key_columns: Vec::new(),
285 diagnostics,
286 count_star_probe: false,
287 }
288 }
289
290 pub fn new(path: impl Into<PathBuf>, schema: SchemaRef, read_columns: Vec<String>) -> Self {
292 Self::from_files(vec![LocalParquetFile::new(path)], schema, read_columns)
293 }
294
295 pub fn with_unique_key_columns(mut self, unique_key_columns: Vec<BTreeSet<String>>) -> Self {
298 self.unique_key_columns = unique_key_columns;
299 self
300 }
301
302 pub fn zero_column_count(mut files: Vec<LocalParquetFile>) -> Self {
309 for file in &mut files {
310 file.partition_values.clear();
311 }
312 let schema = Arc::new(Schema::new(vec![Field::new(ZERO_COLUMN_COUNT_COLUMN, DataType::Int64, false)]));
313 let mut source = Self::from_files(files, schema, Vec::new());
314 source.mark_zero_column_count_represented();
315 source
316 }
317
318 pub fn count_star_probe(files: Vec<LocalParquetFile>, read_schema: SchemaRef, read_columns: Vec<String>) -> Self {
321 let output_schema = Arc::new(Schema::new(vec![Field::new(ZERO_COLUMN_COUNT_COLUMN, DataType::Int64, false)]));
322 let mut source = Self::from_files(files, read_schema, read_columns);
323 source.output_schema_override = Some(output_schema);
324 source.count_star_probe = true;
325 source
326 }
327
328 pub fn with_column_roles(
337 mut self,
338 projected_output_columns: Vec<String>,
339 pure_filter_columns: Vec<String>,
340 ) -> Self {
341 let projected_output_column_source_refs = self.source_column_refs(&projected_output_columns);
342 let pure_filter_column_source_refs = self.source_column_refs(&pure_filter_columns);
343 self.projected_output_columns = projected_output_columns;
344 self.projected_output_column_source_refs = projected_output_column_source_refs;
345 self.pure_filter_columns = pure_filter_columns;
346 self.pure_filter_column_source_refs = pure_filter_column_source_refs;
347 if self.output_schema_override.is_none() {
348 let mut seen = BTreeSet::new();
349 let fields = self
350 .projected_output_columns
351 .iter()
352 .chain(&self.pure_filter_columns)
353 .filter(|column| seen.insert((*column).clone()))
354 .map(|column| self.schema.field_with_name(column).cloned())
355 .collect::<std::result::Result<Vec<_>, _>>();
356 if let Ok(fields) = fields {
357 self.output_schema_override = Some(Arc::new(Schema::new(fields)));
358 }
359 }
360 self.refresh_diagnostics();
361 self
362 }
363
364 pub fn with_column_source_refs(
369 mut self,
370 read_column_file_indices: Vec<usize>,
371 projected_output_column_source_refs: Vec<SourceColumnRef>,
372 pure_filter_column_source_refs: Vec<SourceColumnRef>,
373 ) -> Self {
374 self.read_column_file_indices = read_column_file_indices;
375 self.projected_output_column_source_refs = projected_output_column_source_refs;
376 self.pure_filter_column_source_refs = pure_filter_column_source_refs;
377 self.refresh_diagnostics();
378 self
379 }
380
381 pub fn with_column_file_indices(
383 self,
384 read_column_file_indices: Vec<usize>,
385 projected_output_column_file_indices: Vec<usize>,
386 pure_filter_column_file_indices: Vec<usize>,
387 ) -> Self {
388 self.with_column_source_refs(
389 read_column_file_indices,
390 projected_output_column_file_indices.into_iter().map(SourceColumnRef::File).collect(),
391 pure_filter_column_file_indices.into_iter().map(SourceColumnRef::File).collect(),
392 )
393 }
394
395 pub fn with_output_schema_override(mut self, output_schema: SchemaRef) -> Self {
404 self.output_schema_override = Some(output_schema);
405 self
406 }
407
408 pub fn with_file_column_count(mut self, file_column_count: usize) -> Self {
410 self.file_column_count = Some(file_column_count);
411 self
412 }
413
414 pub fn with_partition_columns(mut self, partition_columns: Vec<PartitionColumn>) -> Self {
416 self.partition_columns = partition_columns;
417 self.refresh_diagnostics();
418 self
419 }
420
421 pub fn with_missing_columns(mut self, missing_columns: Vec<MissingColumn>) -> Self {
423 self.missing_columns = missing_columns;
424 self.refresh_diagnostics();
425 self
426 }
427
428 pub fn with_scan_limit(mut self, scan_limit: Option<usize>) -> Self {
430 self.scan_limit = scan_limit;
431 self.refresh_diagnostics();
432 self
433 }
434
435 pub fn with_max_rows(mut self, max_rows: Option<usize>) -> Self {
437 self.max_rows = max_rows;
438 self
439 }
440
441 pub fn with_reader_predicate(mut self, predicate: Expr) -> Self {
447 self.reader_predicate = Some(predicate);
448 self.refresh_diagnostics();
449 self
450 }
451
452 pub fn reader_predicate_is_supported(&self, predicate: &Expr, ctx: AnalysisContext<'_>) -> bool {
455 reader_predicate_root_is_supported(predicate)
456 && reader_predicate_expr_is_supported(self, predicate, ctx)
457 && (!reader_predicate_references_decimal_column(self, predicate)
458 || reader_predicate_source_contract_shape_is_supported(self, predicate))
459 }
460
461 pub fn reader_predicate_supported_expression(&self, predicate: &Expr, ctx: AnalysisContext<'_>) -> Option<Expr> {
469 if self.reader_predicate_is_supported(predicate, ctx) {
470 return Some(predicate.clone());
471 }
472 let rewritten = rewrite_fixed_prefix_substring_reader_predicate(predicate)?;
473 self.reader_predicate_is_supported(&rewritten, ctx).then_some(rewritten)
474 }
475
476 pub fn reader_predicate_requires_source_contract(&self, predicate: &Expr) -> bool {
479 reader_predicate_references_decimal_column(self, predicate)
480 }
481
482 pub fn refresh_diagnostics(&mut self) {
485 let zero_column_count_represented = self.is_zero_column_count_source();
486 self.diagnostics = SourceDiagnostics::for_local_parquet_files(&self.files, self.diagnostic_columns());
487 if self.reader_predicate.is_some() {
488 self.diagnostics.predicate_disposition = PredicateDisposition::ReaderPushdown;
489 }
490 if zero_column_count_represented {
491 self.mark_zero_column_count_represented();
492 }
493 }
494
495 pub fn output_schema(&self) -> &SchemaRef {
497 self.output_schema_override.as_ref().unwrap_or(&self.schema)
498 }
499
500 pub fn is_zero_column_count_source(&self) -> bool {
502 self.diagnostics.zero_column_count_disposition == ZeroColumnCountDisposition::Represented
503 }
504
505 pub fn is_count_star_probe_source(&self) -> bool {
507 self.count_star_probe
508 }
509
510 fn mark_zero_column_count_represented(&mut self) {
511 self.read_columns.clear();
512 self.read_column_file_indices.clear();
513 self.projected_output_columns.clear();
514 self.projected_output_column_source_refs.clear();
515 self.pure_filter_columns.clear();
516 self.pure_filter_column_source_refs.clear();
517 self.partition_columns.clear();
518 self.missing_columns.clear();
519 self.diagnostics = SourceDiagnostics::for_local_parquet_files(&self.files, self.diagnostic_columns());
520 if self.reader_predicate.is_some() {
521 self.diagnostics.predicate_disposition = PredicateDisposition::ReaderPushdown;
522 }
523 self.diagnostics.zero_column_count_disposition = ZeroColumnCountDisposition::Represented;
524 self.diagnostics.rejected_features.retain(|feature| *feature != RejectedSourceFeature::ZeroColumnCount);
525 self.diagnostics.direct_device_read =
526 self.diagnostics.rejected_features.is_empty() && files_have_proven_direct_device_read(&self.files);
527 }
528
529 pub fn file_count(&self) -> usize {
531 self.files.len()
532 }
533
534 pub fn read_column_count(&self) -> usize {
536 self.read_columns.len()
537 }
538
539 pub fn projected_output_column_count(&self) -> usize {
542 self.projected_output_columns.len()
543 }
544
545 pub fn pure_filter_column_count(&self) -> usize {
547 self.pure_filter_columns.len()
548 }
549
550 pub fn selected_row_group_count(&self) -> usize {
552 self.files.iter().map(LocalParquetFile::selected_row_group_count).sum()
553 }
554
555 pub(crate) fn exact_selected_row_group_rows(&self) -> Option<Vec<u64>> {
556 self.files.iter().try_fold(Vec::with_capacity(self.selected_row_group_count()), |mut selected_rows, file| {
557 let row_group_rows = file.row_group_rows.as_ref()?;
558 match file.row_groups.as_ref() {
559 Some(row_groups) => {
560 for row_group in row_groups {
561 selected_rows.push(*row_group_rows.get(*row_group)?);
562 }
563 },
564 None => selected_rows.extend(row_group_rows.iter().copied()),
565 }
566 Some(selected_rows)
567 })
568 }
569
570 pub fn exact_selected_row_count(&self) -> Option<u64> {
577 self.exact_selected_row_group_rows()?.into_iter().try_fold(0_u64, |total, rows| total.checked_add(rows))
578 }
579
580 pub fn graph_vertex_upper_bound(&self) -> Option<u64> {
589 if !self.has_complete_untransformed_graph_rowset() {
590 return None;
591 }
592
593 self.files.iter().try_fold(0_u64, |total, file| total.checked_add(file.graph_vertex_upper_bound?))
594 }
595
596 pub(crate) fn graph_raw_edge_row_count(&self) -> Option<u64> {
604 self.has_complete_untransformed_graph_rowset().then(|| self.exact_selected_row_count()).flatten()
605 }
606
607 pub fn has_simple_directed_edge_topology(&self, source_column: &str, destination_column: &str) -> bool {
616 if !self.has_complete_untransformed_graph_rowset() || self.files.len() != 1 {
617 return false;
618 }
619 let (Some(SourceColumnRef::File(source_column_index)), Some(SourceColumnRef::File(destination_column_index))) =
620 (self.source_column_ref(source_column), self.source_column_ref(destination_column))
621 else {
622 return false;
623 };
624 self.files[0].graph_simple_directed_edge_topology
625 == GraphSimpleDirectedEdgeTopology::new(source_column_index, destination_column_index)
626 }
627
628 fn has_complete_untransformed_graph_rowset(&self) -> bool {
629 self.scan_limit.is_none()
630 && self.reader_predicate.is_none()
631 && self.files.iter().all(|file| file.row_groups.is_none())
632 }
633
634 pub fn is_direct_device_read(&self) -> bool {
636 self.diagnostics.direct_device_read
637 }
638
639 pub fn partition_column_count(&self) -> usize {
641 self.partition_columns.len()
642 }
643
644 pub(crate) fn selected_file_column_has_no_nulls(&self, file_column_index: usize) -> bool {
647 !self.files.is_empty()
648 && self.files.iter().all(|file| file.selected_row_groups_have_no_nulls(file_column_index))
649 }
650
651 fn source_column_refs(&self, columns: &[String]) -> Vec<SourceColumnRef> {
652 columns.iter().filter_map(|column| self.source_column_ref(column)).collect()
653 }
654
655 fn diagnostic_columns(&self) -> LocalParquetDiagnosticColumns<'_> {
656 LocalParquetDiagnosticColumns {
657 read_columns: &self.read_columns,
658 read_column_file_indices: &self.read_column_file_indices,
659 projected_output_columns: &self.projected_output_columns,
660 projected_output_column_source_refs: &self.projected_output_column_source_refs,
661 pure_filter_columns: &self.pure_filter_columns,
662 pure_filter_column_source_refs: &self.pure_filter_column_source_refs,
663 partition_columns: &self.partition_columns,
664 missing_columns: &self.missing_columns,
665 scan_limit: self.scan_limit,
666 }
667 }
668
669 pub fn source_column_ref(&self, column: &str) -> Option<SourceColumnRef> {
671 if let Some(index) = self.projected_output_columns.iter().position(|output_column| output_column == column) {
672 return self.projected_output_column_source_refs.get(index).copied();
673 }
674 if let Some(index) = self.pure_filter_columns.iter().position(|filter_column| filter_column == column) {
675 return self.pure_filter_column_source_refs.get(index).copied();
676 }
677 self.read_columns
678 .iter()
679 .position(|read_column| read_column == column)
680 .map(|index| SourceColumnRef::File(self.read_column_file_indices[index]))
681 .or_else(|| {
682 self.partition_columns
683 .iter()
684 .position(|partition_column| partition_column.name == column)
685 .map(SourceColumnRef::Partition)
686 .or_else(|| {
687 self.missing_columns
688 .iter()
689 .position(|missing_column| missing_column.name == column)
690 .map(SourceColumnRef::MissingNull)
691 })
692 })
693 }
694}
695
696#[derive(Clone, PartialEq)]
702pub struct LocalParquetFile {
703 pub location: ParquetSourceLocation,
705 pub path: PathBuf,
709 pub resolved_source: ResolvedParquetSource,
711 pub stable_id: String,
716 pub file_size_bytes: Option<u64>,
718 pub row_groups: Option<Vec<usize>>,
724 pub row_group_bytes: Option<Vec<u64>>,
726 pub projected_row_group_compressed_bytes: Option<Vec<u64>>,
728 pub projected_row_group_uncompressed_bytes: Option<Vec<u64>>,
735 pub pure_filter_row_group_compressed_bytes: Option<Vec<u64>>,
737 pub pure_filter_row_group_uncompressed_bytes: Option<Vec<u64>>,
739 pub row_group_decoded_bytes: Option<Vec<u64>>,
744 pub row_group_rows: Option<Vec<u64>>,
749 pub graph_vertex_upper_bound: Option<u64>,
756 pub(crate) graph_simple_directed_edge_topology: Option<GraphSimpleDirectedEdgeTopology>,
762 pub row_group_null_counts: BTreeMap<usize, Vec<Option<u64>>>,
768 pub reader_decimal_types: Vec<ParquetReaderDecimalType>,
770 pub partition_values: Vec<LiteralValue>,
772}
773
774impl fmt::Debug for LocalParquetFile {
775 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
776 f.debug_struct("LocalParquetFile")
777 .field("location", &self.location.diagnostic_location())
778 .field("path", &redacted_debug_value("path", self.path.display().to_string().as_str()))
779 .field("resolved_source", &self.resolved_source)
780 .field("stable_id", &self.location.diagnostic_stable_id(self.stable_id.as_str()))
781 .field("file_size_bytes", &self.file_size_bytes)
782 .field("row_groups", &self.row_groups)
783 .field("row_group_bytes", &self.row_group_bytes)
784 .field("projected_row_group_compressed_bytes", &self.projected_row_group_compressed_bytes)
785 .field("projected_row_group_uncompressed_bytes", &self.projected_row_group_uncompressed_bytes)
786 .field("pure_filter_row_group_compressed_bytes", &self.pure_filter_row_group_compressed_bytes)
787 .field("pure_filter_row_group_uncompressed_bytes", &self.pure_filter_row_group_uncompressed_bytes)
788 .field("row_group_decoded_bytes", &self.row_group_decoded_bytes)
789 .field("row_group_rows", &self.row_group_rows)
790 .field("graph_vertex_upper_bound", &self.graph_vertex_upper_bound)
791 .field("graph_simple_directed_edge_topology", &self.graph_simple_directed_edge_topology)
792 .field("row_group_null_counts", &self.row_group_null_counts)
793 .field("reader_decimal_types", &self.reader_decimal_types)
794 .field("partition_values", &self.partition_values)
795 .finish()
796 }
797}
798
799impl LocalParquetFile {
800 pub(crate) fn clear_column_byte_estimates(&mut self) {
805 self.projected_row_group_compressed_bytes = None;
806 self.projected_row_group_uncompressed_bytes = None;
807 self.pure_filter_row_group_compressed_bytes = None;
808 self.pure_filter_row_group_uncompressed_bytes = None;
809 self.row_group_decoded_bytes = None;
810 }
811
812 pub fn new(path: impl Into<PathBuf>) -> Self {
814 let path = path.into();
815 let stable_id = path.display().to_string();
816 let reader_location = path.display().to_string();
817 Self {
818 location: ParquetSourceLocation::local_path(path.clone()),
819 path,
820 resolved_source: ResolvedParquetSource::local_kvikio(reader_location),
821 stable_id,
822 file_size_bytes: None,
823 row_groups: None,
824 row_group_bytes: None,
825 projected_row_group_compressed_bytes: None,
826 projected_row_group_uncompressed_bytes: None,
827 pure_filter_row_group_compressed_bytes: None,
828 pure_filter_row_group_uncompressed_bytes: None,
829 row_group_decoded_bytes: None,
830 row_group_rows: None,
831 graph_vertex_upper_bound: None,
832 graph_simple_directed_edge_topology: None,
833 row_group_null_counts: BTreeMap::new(),
834 reader_decimal_types: Vec::new(),
835 partition_values: Vec::new(),
836 }
837 }
838
839 pub fn object_store_uri(uri: impl Into<String>) -> Result<Self, String> {
841 let uri = uri.into();
842 let location = ParquetSourceLocation::object_store_uri(uri.clone())?;
843 let stable_id = location.diagnostic_location();
844 Ok(Self {
845 location,
846 path: PathBuf::from(object_store_reader_path(&uri)),
847 resolved_source: ResolvedParquetSource::remote_kvikio(uri, CredentialMode::SharedProcessEnv),
848 stable_id,
849 file_size_bytes: None,
850 row_groups: None,
851 row_group_bytes: None,
852 projected_row_group_compressed_bytes: None,
853 projected_row_group_uncompressed_bytes: None,
854 pure_filter_row_group_compressed_bytes: None,
855 pure_filter_row_group_uncompressed_bytes: None,
856 row_group_decoded_bytes: None,
857 row_group_rows: None,
858 graph_vertex_upper_bound: None,
859 graph_simple_directed_edge_topology: None,
860 row_group_null_counts: BTreeMap::new(),
861 reader_decimal_types: Vec::new(),
862 partition_values: Vec::new(),
863 })
864 }
865
866 pub fn from_resolved_parquet_source(resolved_source: ResolvedParquetSource) -> Result<Self, String> {
868 let location = parquet_source_location_for_original(&resolved_source.original_location)?;
869 let stable_id = location.diagnostic_location();
870 Ok(Self {
871 location,
872 path: PathBuf::from(resolved_source.reader_location()),
873 resolved_source,
874 stable_id,
875 file_size_bytes: None,
876 row_groups: None,
877 row_group_bytes: None,
878 projected_row_group_compressed_bytes: None,
879 projected_row_group_uncompressed_bytes: None,
880 pure_filter_row_group_compressed_bytes: None,
881 pure_filter_row_group_uncompressed_bytes: None,
882 row_group_decoded_bytes: None,
883 row_group_rows: None,
884 graph_vertex_upper_bound: None,
885 graph_simple_directed_edge_topology: None,
886 row_group_null_counts: BTreeMap::new(),
887 reader_decimal_types: Vec::new(),
888 partition_values: Vec::new(),
889 })
890 }
891
892 pub fn with_resolved_parquet_source(mut self, resolved_source: ResolvedParquetSource) -> Result<Self, String> {
894 self.location = parquet_source_location_for_original(&resolved_source.original_location)?;
895 self.path = PathBuf::from(resolved_source.reader_location());
896 self.resolved_source = resolved_source;
897 Ok(self)
898 }
899
900 pub fn with_stable_id(mut self, stable_id: impl Into<String>) -> Self {
902 self.stable_id = stable_id.into();
903 self
904 }
905
906 pub fn with_file_size_bytes(mut self, file_size_bytes: u64) -> Self {
908 self.file_size_bytes = Some(file_size_bytes);
909 self
910 }
911
912 pub fn with_row_groups(mut self, row_groups: Vec<usize>) -> Self {
914 self.row_groups = Some(row_groups);
915 self
916 }
917
918 pub fn with_row_group_bytes(mut self, row_group_bytes: Vec<u64>) -> Self {
920 self.row_group_bytes = Some(row_group_bytes);
921 self
922 }
923
924 pub fn with_projected_row_group_bytes(mut self, compressed_bytes: Vec<u64>, uncompressed_bytes: Vec<u64>) -> Self {
926 self.projected_row_group_compressed_bytes = Some(compressed_bytes);
927 self.projected_row_group_uncompressed_bytes = Some(uncompressed_bytes);
928 self
929 }
930
931 pub fn with_pure_filter_row_group_bytes(
933 mut self,
934 compressed_bytes: Vec<u64>,
935 uncompressed_bytes: Vec<u64>,
936 ) -> Self {
937 self.pure_filter_row_group_compressed_bytes = Some(compressed_bytes);
938 self.pure_filter_row_group_uncompressed_bytes = Some(uncompressed_bytes);
939 self
940 }
941
942 pub fn with_row_group_decoded_bytes(mut self, decoded_bytes: Vec<u64>) -> Self {
944 self.row_group_decoded_bytes = Some(decoded_bytes);
945 self
946 }
947
948 pub fn with_row_group_rows(mut self, row_group_rows: Vec<u64>) -> Self {
950 self.row_group_rows = Some(row_group_rows);
951 self
952 }
953
954 pub fn with_graph_vertex_upper_bound(mut self, graph_vertex_upper_bound: u64) -> Self {
962 self.graph_vertex_upper_bound = Some(graph_vertex_upper_bound);
963 self
964 }
965
966 pub fn with_graph_simple_directed_edge_topology(
974 mut self,
975 graph_simple_directed_edge_topology: GraphSimpleDirectedEdgeTopology,
976 ) -> Self {
977 self.graph_simple_directed_edge_topology = Some(graph_simple_directed_edge_topology);
978 self
979 }
980
981 pub fn with_row_group_null_counts(mut self, file_column_index: usize, null_counts: Vec<Option<u64>>) -> Self {
983 self.row_group_null_counts.insert(file_column_index, null_counts);
984 self
985 }
986
987 pub(crate) fn selected_row_groups_have_no_nulls(&self, file_column_index: usize) -> bool {
990 let Some(null_counts) = self.row_group_null_counts.get(&file_column_index) else {
991 return false;
992 };
993 let selected = self.selected_or_all_row_groups(null_counts.len());
994 selected.iter().all(|row_group| null_counts.get(*row_group).and_then(|count| *count) == Some(0))
995 }
996
997 pub fn with_reader_decimal_types(mut self, reader_decimal_types: Vec<ParquetReaderDecimalType>) -> Self {
999 self.reader_decimal_types = reader_decimal_types;
1000 self
1001 }
1002
1003 pub fn with_partition_values(mut self, partition_values: Vec<LiteralValue>) -> Self {
1005 self.partition_values = partition_values;
1006 self
1007 }
1008
1009 pub fn selected_or_all_row_groups(&self, row_group_count: usize) -> Vec<usize> {
1014 self.row_groups.clone().unwrap_or_else(|| (0..row_group_count).collect())
1015 }
1016
1017 pub fn selected_row_group_count(&self) -> usize {
1019 self.row_groups.as_ref().map_or_else(
1020 || {
1021 self.row_group_bytes
1022 .as_ref()
1023 .or(self.projected_row_group_uncompressed_bytes.as_ref())
1024 .or(self.row_group_rows.as_ref())
1025 .map_or(0, Vec::len)
1026 },
1027 Vec::len,
1028 )
1029 }
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034 use std::path::Path;
1035
1036 use super::*;
1037 use crate::source::{
1038 LakehouseCacheStatus, ParquetReadStrategy, ResolvedParquetInput,
1039 diagnostics::{redacted_object_store_path_diagnostic, redacted_stable_id_diagnostic},
1040 };
1041
1042 #[test]
1043 fn column_roles_expose_projected_then_filter_only_fields_without_duplicates() {
1044 let schema = Arc::new(Schema::new(vec![
1045 Field::new("filter", DataType::Boolean, false),
1046 Field::new("shared", DataType::Utf8, true),
1047 Field::new("projected", DataType::Int64, false),
1048 ]));
1049 let source = LocalParquetSource::from_files(
1050 vec![LocalParquetFile::new("roles.parquet")],
1051 schema,
1052 vec!["filter".to_owned(), "shared".to_owned(), "projected".to_owned()],
1053 )
1054 .with_column_roles(
1055 vec!["projected".to_owned(), "shared".to_owned()],
1056 vec!["filter".to_owned(), "shared".to_owned()],
1057 );
1058
1059 assert_eq!(
1060 source.output_schema().fields().iter().map(|field| field.name().as_str()).collect::<Vec<_>>(),
1061 ["projected", "shared", "filter"]
1062 );
1063 assert_eq!(source.output_schema().field(0).data_type(), &DataType::Int64);
1064 assert_eq!(source.output_schema().field(1).data_type(), &DataType::Utf8);
1065 assert_eq!(source.output_schema().field(2).data_type(), &DataType::Boolean);
1066 }
1067
1068 #[test]
1069 fn resolved_local_file_uri_uses_filesystem_path_for_location_and_reader_path() {
1070 let mut source = ResolvedParquetSource::local_kvikio("/tmp/table.parquet");
1071 source.original_location = "file:/tmp/table.parquet".to_string();
1072 let file = LocalParquetFile::from_resolved_parquet_source(source).unwrap();
1073
1074 assert_eq!(file.location.as_local_path(), Some(Path::new("/tmp/table.parquet")));
1075 assert_eq!(file.path, PathBuf::from("/tmp/table.parquet"));
1076 }
1077
1078 #[test]
1079 fn resolved_local_file_uri_with_localhost_authority_is_local_path() {
1080 let mut source = ResolvedParquetSource::local_kvikio("/tmp/table.parquet");
1081 source.original_location = "file://localhost/tmp/table.parquet".to_string();
1082 let file = LocalParquetFile::from_resolved_parquet_source(source).unwrap();
1083
1084 assert_eq!(file.location.as_local_path(), Some(Path::new("/tmp/table.parquet")));
1085 }
1086
1087 #[test]
1088 fn remote_direct_source_diagnostics_are_not_proven_direct_device_reads() {
1089 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, true)]));
1090 let file = LocalParquetFile::from_resolved_parquet_source(ResolvedParquetSource::remote_kvikio(
1091 "s3://bucket/table/part-0.parquet",
1092 CredentialMode::SharedProcessEnv,
1093 ))
1094 .unwrap();
1095 let source = LocalParquetSource::from_files(vec![file], schema, vec!["id".to_string()]);
1096
1097 assert_eq!(source.diagnostics.source_read_strategy, "remote_direct");
1098 assert!(!source.diagnostics.direct_device_read);
1099 assert!(source.diagnostics.rejected_features.is_empty());
1100 }
1101
1102 #[test]
1103 fn object_store_uri_defaults_to_tagged_remote_kvikio_input() {
1104 let uri = "s3://bucket/table/part-0.parquet";
1105 let file = LocalParquetFile::object_store_uri(uri).unwrap();
1106
1107 assert!(matches!(
1108 &file.resolved_source.input,
1109 ResolvedParquetInput::RemoteKvikio {
1110 uri: resolved_uri,
1111 credential_mode: CredentialMode::SharedProcessEnv,
1112 storage_profile_id: None,
1113 } if resolved_uri == uri
1114 ));
1115 assert_eq!(file.resolved_source.reader_location(), uri);
1116 assert_eq!(file.resolved_source.read_strategy(), ParquetReadStrategy::RemoteDirect);
1117 }
1118
1119 #[test]
1120 fn remote_and_local_cache_object_store_identity_stays_with_its_tagged_access_path() {
1121 let remote =
1122 ResolvedParquetSource::remote_kvikio("s3://bucket/table/part-0.parquet", CredentialMode::ScopedResolved)
1123 .with_storage_profile_id("remote-profile");
1124 assert!(matches!(
1125 &remote.input,
1126 ResolvedParquetInput::RemoteKvikio {
1127 credential_mode: CredentialMode::ScopedResolved,
1128 storage_profile_id: Some(profile_id),
1129 ..
1130 } if profile_id == "remote-profile"
1131 ));
1132
1133 let cached = ResolvedParquetSource::local_cache_kvikio(
1134 "s3://bucket/table/part-0.parquet",
1135 "/tmp/cache/part-0.parquet",
1136 CredentialMode::ScopedResolved,
1137 LakehouseCacheStatus::Hit,
1138 )
1139 .with_storage_profile_id("maintenance-profile");
1140 assert!(matches!(
1141 &cached.input,
1142 ResolvedParquetInput::LocalKvikio { reader_path }
1143 if reader_path == "/tmp/cache/part-0.parquet"
1144 ));
1145 assert_eq!(cached.credential_mode(), CredentialMode::ScopedResolved);
1146 assert_eq!(cached.storage_profile_id(), Some("maintenance-profile"));
1147 }
1148
1149 #[test]
1150 fn object_store_default_stable_ids_are_redacted_hashes() {
1151 let uri = "s3://secret-bucket/private/key.parquet?X-Amz-Signature=abc";
1152 let file = LocalParquetFile::from_resolved_parquet_source(ResolvedParquetSource::remote_kvikio(
1153 uri,
1154 CredentialMode::SharedProcessEnv,
1155 ))
1156 .unwrap();
1157
1158 assert!(file.stable_id.starts_with("object_store:s3:fnv64:"));
1159 assert!(!file.stable_id.contains("secret-bucket"));
1160 assert!(!file.stable_id.contains("X-Amz-Signature"));
1161 assert_eq!(redacted_stable_id_diagnostic(uri), file.stable_id);
1162 }
1163
1164 #[test]
1165 fn source_debug_redacts_object_store_locations_and_stable_ids() {
1166 let uri = "s3://secret-bucket/private/key.parquet?X-Amz-Signature=abc";
1167 let source = ResolvedParquetSource::remote_kvikio(uri, CredentialMode::SharedProcessEnv)
1168 .with_lakehouse_cache_config("/tmp/cache/secret-bucket/private/key.parquet?token=abc", None)
1169 .with_lakehouse_cache_path("/tmp/cache/secret-bucket/private/key.parquet?token=abc");
1170 let file = LocalParquetFile::from_resolved_parquet_source(source)
1171 .unwrap()
1172 .with_stable_id("s3://secret-bucket/private/key.parquet?X-Amz-Signature=abc");
1173
1174 let rendered = format!("{file:?}");
1175 for sensitive in ["secret-bucket", "private/key", "X-Amz-Signature", "token=abc"] {
1176 assert!(!rendered.contains(sensitive), "debug output leaked {sensitive}: {rendered}");
1177 }
1178
1179 let location = ParquetSourceLocation::object_store_uri(uri).unwrap();
1180 let rendered_location = format!("{location:?}");
1181 for sensitive in ["secret-bucket", "private/key", "X-Amz-Signature", "abc"] {
1182 assert!(
1183 !rendered_location.contains(sensitive),
1184 "location debug output leaked {sensitive}: {rendered_location}"
1185 );
1186 }
1187 }
1188
1189 #[test]
1190 fn object_store_path_diagnostics_are_redacted_hashes() {
1191 let path = "secret-bucket/private/key.parquet?X-Amz-Signature=abc";
1192 let diagnostic = redacted_object_store_path_diagnostic(path);
1193
1194 assert!(diagnostic.starts_with("object_store_path:fnv64:"));
1195 for sensitive in ["secret-bucket", "private/key", "X-Amz-Signature", "abc"] {
1196 assert!(!diagnostic.contains(sensitive), "diagnostic leaked {sensitive}: {diagnostic}");
1197 }
1198 }
1199
1200 #[test]
1201 fn exact_selected_row_count_uses_only_footer_proven_selected_groups() {
1202 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
1203 let source = LocalParquetSource::from_files(
1204 vec![
1205 LocalParquetFile::new("first.parquet").with_row_groups(vec![0, 2]).with_row_group_rows(vec![3, 5, 7]),
1206 LocalParquetFile::new("second.parquet").with_row_group_rows(vec![11]),
1207 ],
1208 schema,
1209 vec!["id".to_string()],
1210 );
1211
1212 assert_eq!(source.exact_selected_row_count(), Some(21));
1213 }
1214
1215 #[test]
1216 fn graph_vertex_upper_bound_requires_complete_untransformed_file_rowsets() {
1217 let schema = Arc::new(Schema::new(vec![Field::new("src", DataType::Int64, false)]));
1218 let complete = LocalParquetSource::from_files(
1219 vec![
1220 LocalParquetFile::new("first.parquet").with_graph_vertex_upper_bound(7),
1221 LocalParquetFile::new("second.parquet").with_graph_vertex_upper_bound(11),
1222 ],
1223 Arc::clone(&schema),
1224 vec!["src".to_string()],
1225 );
1226
1227 assert_eq!(complete.graph_vertex_upper_bound(), Some(18));
1228 assert_eq!(complete.graph_raw_edge_row_count(), None);
1229 assert_eq!(complete.clone().with_scan_limit(Some(1)).graph_vertex_upper_bound(), None);
1230 assert_eq!(complete.with_scan_limit(Some(1)).graph_raw_edge_row_count(), None);
1231
1232 let selected_row_groups = LocalParquetSource::from_files(
1233 vec![LocalParquetFile::new("first.parquet").with_row_groups(vec![0]).with_graph_vertex_upper_bound(7)],
1234 schema,
1235 vec!["src".to_string()],
1236 );
1237 assert_eq!(selected_row_groups.graph_vertex_upper_bound(), None);
1238 assert_eq!(selected_row_groups.graph_raw_edge_row_count(), None);
1239 }
1240
1241 #[test]
1242 fn graph_raw_edge_row_count_requires_complete_footer_proven_rowsets() {
1243 let schema = Arc::new(Schema::new(vec![Field::new("src", DataType::Int64, false)]));
1244 let complete = LocalParquetSource::from_files(
1245 vec![
1246 LocalParquetFile::new("first.parquet").with_row_group_rows(vec![3, 5]),
1247 LocalParquetFile::new("second.parquet").with_row_group_rows(vec![7]),
1248 ],
1249 Arc::clone(&schema),
1250 vec!["src".to_string()],
1251 );
1252 assert_eq!(complete.graph_raw_edge_row_count(), Some(15));
1253
1254 let partial = LocalParquetSource::from_files(
1255 vec![LocalParquetFile::new("first.parquet").with_row_group_rows(vec![3, 5]).with_row_groups(vec![0])],
1256 schema,
1257 vec!["src".to_string()],
1258 );
1259 assert_eq!(partial.graph_raw_edge_row_count(), None);
1260 }
1261
1262 #[test]
1263 fn simple_directed_edge_topology_requires_one_complete_matching_file() {
1264 let schema = Arc::new(Schema::new(vec![
1265 Field::new("src", DataType::Int64, false),
1266 Field::new("dst", DataType::Int64, false),
1267 ]));
1268 let topology = GraphSimpleDirectedEdgeTopology::new(0, 1).expect("distinct endpoint columns");
1269 let complete = LocalParquetSource::from_files(
1270 vec![
1271 LocalParquetFile::new("edges.parquet")
1272 .with_row_group_rows(vec![3])
1273 .with_graph_simple_directed_edge_topology(topology),
1274 ],
1275 Arc::clone(&schema),
1276 vec!["src".to_string(), "dst".to_string()],
1277 );
1278
1279 assert!(complete.has_simple_directed_edge_topology("src", "dst"));
1280 assert!(!complete.has_simple_directed_edge_topology("dst", "src"));
1281 assert!(!complete.with_scan_limit(Some(1)).has_simple_directed_edge_topology("src", "dst"));
1282
1283 let multi_file = LocalParquetSource::from_files(
1284 vec![
1285 LocalParquetFile::new("first.parquet").with_graph_simple_directed_edge_topology(topology),
1286 LocalParquetFile::new("second.parquet").with_graph_simple_directed_edge_topology(topology),
1287 ],
1288 schema,
1289 vec!["src".to_string(), "dst".to_string()],
1290 );
1291 assert!(!multi_file.has_simple_directed_edge_topology("src", "dst"));
1292 }
1293}