Skip to main content

query_planning/source/
parquet.rs

1//! Native Parquet source contracts.
2
3use 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
30/// Synthetic output column used for Parquet plans that only need a row count.
31///
32/// A zero-column count source reads no physical columns and produces row counts
33/// from file or row-group metadata. It projects no normal source columns.
34pub const ZERO_COLUMN_COUNT_COLUMN: &str = "__algeon_native_zero_column_row_count";
35
36/// Parquet-footer key for a graph vertex upper bound tied to the complete edge
37/// rowset in one file.
38///
39/// The producer must derive the value from the same edge artifact it writes.
40/// Consumers only use it when the native source still represents the complete,
41/// unfiltered file rowset; it is deliberately not a query/session override.
42pub const GRAPH_VERTEX_UPPER_BOUND_PARQUET_METADATA_KEY: &str = "algeon.graph.vertex_upper_bound";
43
44/// Parquet-footer key for a complete, simple directed edge rowset.
45///
46/// Its JSON value declares the physical root-column indices for the source and
47/// destination endpoints. The producer attests that both endpoints are
48/// non-null and each ordered endpoint pair appears exactly once. Consumers use
49/// it only for one complete, unfiltered file; a per-file proof cannot establish
50/// uniqueness across a multi-file source.
51pub const GRAPH_SIMPLE_DIRECTED_EDGE_TOPOLOGY_PARQUET_METADATA_KEY: &str = "algeon.graph.simple_directed_edge_topology";
52
53/// Footer-proven topology needed to skip Python-compatible edge normalization.
54///
55/// This is deliberately a narrow source contract. It does not cover graph
56/// algorithms or their workspace. It proves only that filtering null endpoints
57/// and deduplicating a directed edge list are semantic no-ops for these two
58/// physical Parquet columns.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct GraphSimpleDirectedEdgeTopology {
61    /// Zero-based physical Parquet root-column index for the source endpoint.
62    pub(crate) source_column_index: usize,
63    /// Zero-based physical Parquet root-column index for the destination endpoint.
64    pub(crate) destination_column_index: usize,
65}
66
67impl GraphSimpleDirectedEdgeTopology {
68    /// Creates a simple-directed topology proof for two distinct endpoint columns.
69    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/// Partition column materialized from frontend file metadata.
82///
83/// Partition columns are synthetic source columns: they are not present in the
84/// Parquet file schema, but they may be read, projected, or used by filters as
85/// if they came from the source.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct PartitionColumn {
88    /// Column name exposed to native expressions.
89    pub name: String,
90    /// Arrow type of the partition value.
91    pub data_type: DataType,
92    /// Whether the partition value may be null.
93    pub nullable: bool,
94}
95
96impl PartitionColumn {
97    /// Declares a synthetic partition column with its exposed Arrow type and nullability.
98    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/// Nullable logical column absent from the physical Parquet file schema.
104///
105/// Missing columns are explicit source facts. They are materialized as typed
106/// null GPU columns after the file/chunk row count is known and before
107/// source-local filters or projections run.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct MissingColumn {
110    /// Column name exposed to native expressions.
111    pub name: String,
112    /// Arrow type of the typed null column.
113    pub data_type: DataType,
114    /// Whether the logical table column is nullable.
115    pub nullable: bool,
116}
117
118impl MissingColumn {
119    /// Declares a missing logical column; source validation checks whether
120    /// typed-null materialization is permitted by its nullability.
121    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/// cuDF reader-side decimal storage type proven from Parquet metadata.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
128pub enum ParquetReaderDecimalTypeId {
129    /// 32-bit fixed-point decimal.
130    Decimal32,
131    /// 64-bit fixed-point decimal.
132    Decimal64,
133    /// 128-bit fixed-point decimal.
134    Decimal128,
135}
136
137impl ParquetReaderDecimalTypeId {
138    /// Stable label for the decimal storage width in reader diagnostics.
139    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/// Per-file Parquet decimal reader type for one decoded read column.
149#[derive(Debug, Clone, PartialEq, Eq, Hash)]
150pub struct ParquetReaderDecimalType {
151    /// Column name as it appears in the source read projection.
152    pub column_name: String,
153    /// cuDF storage width used by the Parquet reader.
154    pub type_id: ParquetReaderDecimalTypeId,
155    /// cuDF fixed-point scale. Parquet/Arrow scale 2 is represented as -2.
156    pub cudf_scale: i32,
157}
158
159impl ParquetReaderDecimalType {
160    /// Declares the reader storage width and cuDF scale for one decoded column.
161    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/// Reference to the source location for a logical column.
167///
168/// File columns are indexed against the Parquet file schema. Partition columns
169/// are indexed against `LocalParquetSource::partition_columns`.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
171pub enum SourceColumnRef {
172    /// Physical column in the Parquet file schema.
173    File(usize),
174    /// Synthetic column from partition metadata.
175    Partition(usize),
176    /// Nullable logical column missing from the physical file schema.
177    MissingNull(usize),
178}
179
180/// Frontend-neutral local Parquet scan contract.
181///
182/// Carries files, emitted schema, column roles, reader predicate, and IO
183/// diagnostics for native source execution.
184#[derive(Debug, Clone, PartialEq)]
185pub struct LocalParquetSource {
186    /// Local Parquet files to scan, including per-file metadata and pruning.
187    pub files: Vec<LocalParquetFile>,
188    /// Schema of the emitted source output. Read file columns come first, then partition columns.
189    pub schema: SchemaRef,
190    /// Optional schema exposed to the query plan when execution transforms the
191    /// physical read frame before yielding source output.
192    pub output_schema_override: Option<SchemaRef>,
193    /// Physical Parquet columns that the engine must read.
194    ///
195    /// These columns are emitted by the source. They include projected file
196    /// columns and file columns needed only by source-adjacent filters.
197    /// Partition columns are excluded because partition values come from file
198    /// metadata and are emitted after `read_columns`.
199    pub read_columns: Vec<String>,
200    /// File-schema indices for `read_columns`.
201    pub read_column_file_indices: Vec<usize>,
202    /// Total physical column count in the source Parquet file schema, when known.
203    pub(crate) file_column_count: Option<usize>,
204    /// Source columns expected to survive source-adjacent filters/projections.
205    pub projected_output_columns: Vec<String>,
206    /// Source references for `projected_output_columns`.
207    pub projected_output_column_source_refs: Vec<SourceColumnRef>,
208    /// Columns emitted only so source-adjacent native filters can evaluate.
209    ///
210    /// These should be removed by a later native projection when they are not
211    /// needed downstream.
212    pub pure_filter_columns: Vec<String>,
213    /// Source references for `pure_filter_columns`.
214    pub pure_filter_column_source_refs: Vec<SourceColumnRef>,
215    /// Synthetic source columns populated from file partition values.
216    pub partition_columns: Vec<PartitionColumn>,
217    /// Nullable logical source columns materialized as typed null columns.
218    pub missing_columns: Vec<MissingColumn>,
219    /// Source-level scan limit applied before source-adjacent native filters.
220    pub scan_limit: Option<usize>,
221    /// Reader-level Parquet predicate applied by the cuDF reader before source-local native filters.
222    ///
223    /// This predicate is expressed over `read_columns`; every referenced column
224    /// must resolve to a physical file column that is present in the read
225    /// projection. Partition and missing-null columns are not reader-visible.
226    pub reader_predicate: Option<Expr>,
227    /// Trusted source cardinality upper bound after source-level scan limit and
228    /// reader predicate have been applied.
229    pub max_rows: Option<usize>,
230    /// Source-owner unique keys expressed in emitted physical column names.
231    pub unique_key_columns: Vec<BTreeSet<String>>,
232    /// Source capability and rejection details captured during lowering.
233    pub diagnostics: SourceDiagnostics,
234    /// Read one physical row-presence column, then emit row-count weights.
235    pub(crate) count_star_probe: bool,
236}
237
238impl LocalParquetSource {
239    /// Builds a source from fully described local Parquet files.
240    ///
241    /// `schema` must describe the source node's emitted schema. By default,
242    /// every read file column is also marked as projected output metadata and no
243    /// partition columns or pure filter columns are represented.
244    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    /// Declares a single-file source with the supplied output schema and read columns.
291    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    /// Attaches sets of columns proven unique by the caller. No data scan is
296    /// performed to verify these claims.
297    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    /// Builds the metadata-only representation for count-only Parquet scans.
303    ///
304    /// Zero-column count sources read no physical or partition columns. They use
305    /// `ZERO_COLUMN_COUNT_COLUMN` as the synthetic output field and mark the
306    /// diagnostics as represented. The caller must have proven that the shape is
307    /// safe to answer from file or row-group metadata.
308    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    /// Builds a COUNT(*) fallback source that reads a physical row-presence
319    /// column but emits one Int64 row-count weight per read unit.
320    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    /// Sets projected and filter-only logical column roles.
329    ///
330    /// This does not change `schema`; it only records which emitted source
331    /// columns are expected to survive source-adjacent filtering/projection and
332    /// which emitted columns are only filter inputs.
333    ///
334    /// Source references are inferred from `read_columns` and
335    /// `partition_columns`.
336    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    /// Sets explicit source references for read, projected, and filter-only columns.
365    ///
366    /// Use this when frontend scan metadata already resolved logical columns to
367    /// file-schema positions or synthetic partition columns.
368    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    /// Sets explicit file-schema indices for column roles with no partition columns.
382    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    /// Sets the schema exposed to downstream query-plan nodes.
396    ///
397    /// This is used when the physical Parquet read schema contains file-local
398    /// names, but the source output has already been mapped back to logical
399    /// table names by explicit output-column refs. Non-count-probe callers must
400    /// still include every output override field in `schema`, because execution
401    /// schema coercion and source-contract lowering operate on that execution
402    /// source schema.
403    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    /// Sets the total physical column count in the source Parquet file schema.
409    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    /// Replaces the partition columns, which are represented as synthetic source columns.
415    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    /// Replaces the missing columns, which are represented as typed null source columns.
422    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    /// Sets an order-insensitive source-level scan limit.
429    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    /// Sets a trusted source cardinality upper bound.
436    pub fn with_max_rows(mut self, max_rows: Option<usize>) -> Self {
437        self.max_rows = max_rows;
438        self
439    }
440
441    /// Sets a reader-level Parquet predicate.
442    ///
443    /// Callers should only set predicates for expressions accepted by
444    /// [`Self::reader_predicate_is_supported`]. Validation also checks this
445    /// before execution.
446    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    /// Returns true if `predicate` can be lowered to the cuDF Parquet reader AST
453    /// using this source's read-column projection.
454    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    /// Returns a reader-level predicate expression accepted by the cuDF Parquet
462    /// reader contract.
463    ///
464    /// Most accepted predicates are returned unchanged. Fixed ASCII prefix
465    /// predicates of the form `SUBSTRING(column FROM 1 FOR n) IN (...)` are
466    /// rewritten to equivalent lexicographic string ranges because the cuDF
467    /// reader AST can express string comparisons but not string slicing.
468    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    /// Returns true when the reader predicate needs per-source-chunk metadata
477    /// to lower correctly.
478    pub fn reader_predicate_requires_source_contract(&self, predicate: &Expr) -> bool {
479        reader_predicate_references_decimal_column(self, predicate)
480    }
481
482    /// Recomputes source diagnostics from current files and column roles,
483    /// preserving reader-predicate and metadata-only count dispositions.
484    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    /// Returns the schema this source exposes to the native plan graph.
496    pub fn output_schema(&self) -> &SchemaRef {
497        self.output_schema_override.as_ref().unwrap_or(&self.schema)
498    }
499
500    /// Returns true when this source is the metadata-only zero-column count form.
501    pub fn is_zero_column_count_source(&self) -> bool {
502        self.diagnostics.zero_column_count_disposition == ZeroColumnCountDisposition::Represented
503    }
504
505    /// Returns true when this source reads a carrier column only to emit count weights.
506    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    /// Number of file entries represented by the source.
530    pub fn file_count(&self) -> usize {
531        self.files.len()
532    }
533
534    /// Number of physical columns selected for decoding.
535    pub fn read_column_count(&self) -> usize {
536        self.read_columns.len()
537    }
538
539    /// Number of source columns expected to remain after source-adjacent native
540    /// filtering/projection.
541    pub fn projected_output_column_count(&self) -> usize {
542        self.projected_output_columns.len()
543    }
544
545    /// Number of emitted source columns used only by source filter evaluation.
546    pub fn pure_filter_column_count(&self) -> usize {
547        self.pure_filter_columns.len()
548    }
549
550    /// Total selected row groups across files, or zero when unknown.
551    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    /// Returns the exact number of rows represented by the selected Parquet
571    /// row groups, when every selected group has footer-backed row metadata.
572    ///
573    /// This is deliberately narrower than the source's descriptive estimates:
574    /// callers that need an exact graph-input cardinality cannot substitute
575    /// file sizes, optimizer statistics, or an unselected row-group total.
576    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    /// Returns a footer-proven graph vertex upper bound when this source still
581    /// denotes complete, unfiltered edge-file rowsets.
582    ///
583    /// A graph vertex count belongs to the exact source rowset that produced
584    /// it. A scan limit, reader predicate, or selected row-group subset
585    /// changes that rowset, so the method returns `None` and does not carry the
586    /// file-level value across such a transformation. Multiple whole files are
587    /// safe to sum: duplicate vertices only make the result more conservative.
588    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    /// Returns the footer-proven raw edge-row count when this source still
597    /// denotes complete, unfiltered edge-file rowsets.
598    ///
599    /// Edge normalization retains this raw source frame while its normalized
600    /// graph frame is live. The count therefore belongs to the source rowset. It
601    /// does not transfer to a transformed graph operator or to a caller-provided
602    /// estimate.
603    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    /// Returns whether this source proves a simple directed edge relation for
608    /// the two requested physical file columns.
609    ///
610    /// A file footer cannot prove that endpoint pairs are unique *across*
611    /// several files, so this accepts a single complete local Parquet file.
612    /// Column roles must resolve directly to physical file columns, which
613    /// excludes aliases, partition columns, and materialized null columns from
614    /// the proof.
615    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    /// Whether diagnostics indicate that direct device read is supported.
635    pub fn is_direct_device_read(&self) -> bool {
636        self.diagnostics.direct_device_read
637    }
638
639    /// Number of synthetic partition columns in the source schema.
640    pub fn partition_column_count(&self) -> usize {
641        self.partition_columns.len()
642    }
643
644    /// Returns true when every file represented by this source proves a
645    /// physical file column has no nulls in its selected row groups.
646    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    /// Resolves a logical source column to a file, partition, or missing-null column reference.
670    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/// One local or object-store Parquet file plus optional row-group metadata.
697///
698/// Byte vectors are aligned with the selected row groups when `row_groups` is
699/// present, otherwise with the frontend's represented row-group order. `None`
700/// means the metadata is unavailable; it does not mean zero.
701#[derive(Clone, PartialEq)]
702pub struct LocalParquetFile {
703    /// Explicit source location for the Parquet file.
704    pub location: ParquetSourceLocation,
705    /// Path-shaped reader string retained for existing diagnostics and cuDF
706    /// reader APIs. Object-store URIs are represented as path-shaped strings
707    /// but must be created through `object_store_uri`.
708    pub path: PathBuf,
709    /// Resolved reader strategy and decode contract for this file.
710    pub resolved_source: ResolvedParquetSource,
711    /// Stable identifier for diagnostics, pruning, and executor bookkeeping.
712    ///
713    /// Frontends should choose a value that remains stable across equivalent
714    /// plans, commonly derived from path plus object metadata.
715    pub stable_id: String,
716    /// Known file size in bytes, when available from frontend metadata.
717    pub file_size_bytes: Option<u64>,
718    /// Selected Parquet row-group indices.
719    ///
720    /// `None` means no explicit row-group selection was represented. `Some`
721    /// limits the scan to the listed row groups. `Some(vec![])` selects zero row
722    /// groups, which marks a source proven empty.
723    pub row_groups: Option<Vec<usize>>,
724    /// Estimated or known byte sizes for selected row groups.
725    pub row_group_bytes: Option<Vec<u64>>,
726    /// Estimated or known compressed byte sizes for projected output file columns.
727    pub projected_row_group_compressed_bytes: Option<Vec<u64>>,
728    /// Estimated or known uncompressed byte sizes for projected output file columns.
729    ///
730    /// Bounded source chunk planning uses this as its primary chunk-size input
731    /// when available. Columns used only by source-adjacent filters are tracked
732    /// separately so they can contribute to read/decode cost without forcing
733    /// retained output sizing.
734    pub projected_row_group_uncompressed_bytes: Option<Vec<u64>>,
735    /// Estimated or known compressed byte sizes for pure-filter file columns.
736    pub pure_filter_row_group_compressed_bytes: Option<Vec<u64>>,
737    /// Estimated or known uncompressed byte sizes for pure-filter file columns.
738    pub pure_filter_row_group_uncompressed_bytes: Option<Vec<u64>>,
739    /// Estimated decoded device bytes of the source's read columns, in physical
740    /// Parquet row-group order. Chunk planning compares this estimate with the
741    /// source-byte target; frontends derive it with
742    /// [`row_group_decoded_bytes_from_footer`](crate::chunk::decoded::row_group_decoded_bytes_from_footer).
743    pub row_group_decoded_bytes: Option<Vec<u64>>,
744    /// Estimated or known row counts in physical Parquet row-group order.
745    ///
746    /// The vector covers every physical row group; `row_groups`, when present,
747    /// contains the physical IDs selected for this source.
748    pub row_group_rows: Option<Vec<u64>>,
749    /// Footer-proven upper bound on distinct graph vertices in this complete
750    /// edge file, when the fixture/data producer attached one.
751    ///
752    /// This is not applied to a partial row-group selection or a transformed
753    /// source. [`LocalParquetSource::graph_vertex_upper_bound`] enforces that
754    /// rowset-identity boundary before admission consumes it.
755    pub graph_vertex_upper_bound: Option<u64>,
756    /// Footer-proven simple directed-edge topology for this complete file.
757    ///
758    /// The proof is intentionally stored per file and becomes usable only
759    /// through [`LocalParquetSource::has_simple_directed_edge_topology`], which
760    /// refuses partial or multi-file sources.
761    pub(crate) graph_simple_directed_edge_topology: Option<GraphSimpleDirectedEdgeTopology>,
762    /// Per-file-column null counts by row group, keyed by physical root column index.
763    ///
764    /// Inner vectors are indexed by physical row-group number. They are not
765    /// indexed by selected row-group position. `None` means the footer did not
766    /// prove a null count for that column chunk.
767    pub row_group_null_counts: BTreeMap<usize, Vec<Option<u64>>>,
768    /// Per-read-column decimal storage types proven from Parquet metadata.
769    pub reader_decimal_types: Vec<ParquetReaderDecimalType>,
770    /// Values for `LocalParquetSource::partition_columns` in source order.
771    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    /// Clears the byte estimates that depend on the source's column roles, so
801    /// a rewrite that changes which columns are read or projected never keeps
802    /// figures measured for the old columns. The engine reloads them from the
803    /// footer.
804    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    /// Creates a file descriptor with no optional metadata.
813    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    /// Creates an explicit object-store file descriptor.
840    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    /// Creates a file descriptor from an already resolved reader contract.
867    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    /// Replaces the resolved reader contract and updates the reader path.
893    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    /// Sets the stable file identifier used by native diagnostics/execution.
901    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    /// Sets known file size in bytes.
907    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    /// Sets the selected row groups for this file.
913    pub fn with_row_groups(mut self, row_groups: Vec<usize>) -> Self {
914        self.row_groups = Some(row_groups);
915        self
916    }
917
918    /// Sets byte-size metadata for selected row groups.
919    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    /// Sets projected output column byte-size metadata for row groups.
925    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    /// Sets pure-filter column byte-size metadata for row groups.
932    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    /// Sets the decoded read-column byte estimate in physical Parquet row-group order.
943    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    /// Sets row-count metadata in physical Parquet row-group order.
949    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    /// Attaches a footer-proven graph vertex upper bound for this complete
955    /// edge-file rowset.
956    ///
957    /// Frontend code should populate this only by reading
958    /// [`GRAPH_VERTEX_UPPER_BOUND_PARQUET_METADATA_KEY`] from this file's
959    /// Parquet footer. The bound stays on the file descriptor, so it travels with
960    /// the file it describes and cannot come from a detached query setting.
961    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    /// Attaches a footer-proven simple-directed edge topology for this complete
967    /// Parquet file.
968    ///
969    /// Frontends must populate this only from
970    /// [`GRAPH_SIMPLE_DIRECTED_EDGE_TOPOLOGY_PARQUET_METADATA_KEY`]. The source
971    /// contract checks complete-rowset and single-file identity before graph
972    /// lowering can use it.
973    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    /// Sets row-group null-count metadata for one physical file column.
982    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    /// Returns true when footer stats prove this file column has no nulls in
988    /// every selected row group represented by this file descriptor.
989    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    /// Sets per-read-column decimal storage types proven from Parquet metadata.
998    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    /// Sets values for represented partition columns.
1004    pub fn with_partition_values(mut self, partition_values: Vec<LiteralValue>) -> Self {
1005        self.partition_values = partition_values;
1006        self
1007    }
1008
1009    /// Returns the selected row-group indices, or every index below
1010    /// `row_group_count` when no selection is represented.
1011    ///
1012    /// An explicit empty selection stays empty; see [`Self::row_groups`].
1013    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    /// Returns the represented selected row-group count, or zero if unknown.
1018    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}