Skip to content

Commit c596bff

Browse files
committed
refactor(datafusion): add table scan builder
1 parent 85b94e3 commit c596bff

5 files changed

Lines changed: 172 additions & 95 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ derive_builder = "0.20"
7373
dirs = "6"
7474
enum-ordinalize = "4.3.0"
7575
env_logger = "0.11.8"
76+
either = "1"
7677
expect-test = "1"
7778
faststr = "0.2.31"
7879
flate2 = "1.1.5"

crates/integrations/datafusion/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ anyhow = { workspace = true }
3333
async-trait = { workspace = true }
3434
dashmap = { workspace = true }
3535
datafusion = { workspace = true }
36+
either = { workspace = true }
3637
futures = { workspace = true }
3738
iceberg = { workspace = true }
3839
parquet = { workspace = true }

crates/integrations/datafusion/src/physical_plan/scan.rs

Lines changed: 151 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
2828
use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
2929
use datafusion::physical_plan::{DisplayAs, ExecutionPlan, Partitioning, PlanProperties};
3030
use datafusion::prelude::Expr;
31+
use either::Either;
3132
use futures::{Stream, TryStreamExt};
3233
use iceberg::expr::Predicate;
3334
use iceberg::scan::{FileScanTask, TableScan};
@@ -39,11 +40,9 @@ use crate::to_datafusion_error;
3940

4041
/// Iceberg [`Table`] scan as a DataFusion [`ExecutionPlan`].
4142
///
42-
/// Has three construction modes: [`new`][Self::new] for a lazy
43-
/// single-partition scan, [`new_with_tasks`][Self::new_with_tasks] for an
44-
/// eager scan over pre-planned [`FileScanTask`] buckets, and
45-
/// [`new_with_tasks_from_predicate`][Self::new_with_tasks_from_predicate] for
46-
/// rebuilding an eager scan from a [`Predicate`].
43+
/// Prefer [`IcebergTableScanBuilder`] when composing optional scan inputs. The
44+
/// constructor helpers are retained for direct lazy scans and eager scans over
45+
/// pre-planned [`FileScanTask`] buckets.
4746
///
4847
/// Note: in eager mode the underlying `TableScan` is rebuilt on every
4948
/// `execute(partition)` call. The per-build cost is bounded (no I/O) and
@@ -63,8 +62,8 @@ pub struct IcebergTableScan {
6362
table_schema: ArrowSchemaRef,
6463
/// Projection indices as received by `scan`.
6564
projection_indices: Option<Vec<usize>>,
66-
/// Filters to apply to the table scan.
67-
predicates: Option<Predicate>,
65+
/// Predicate to apply to the table scan.
66+
predicate: Option<Predicate>,
6867
/// Pre-planned file scan tasks per partition (eager mode), or `None` (lazy mode).
6968
buckets: Option<Vec<Vec<FileScanTask>>>,
7069
/// `None` when partitioning is `UnknownPartitioning`.
@@ -73,7 +72,117 @@ pub struct IcebergTableScan {
7372
limit: Option<usize>,
7473
}
7574

75+
/// Builder for [`IcebergTableScan`].
76+
#[derive(Debug)]
77+
pub struct IcebergTableScanBuilder {
78+
table: Table,
79+
snapshot_id: Option<i64>,
80+
schema: ArrowSchemaRef,
81+
projection: Option<Vec<usize>>,
82+
filter_source: Option<Either<Vec<Expr>, Option<Predicate>>>,
83+
limit: Option<usize>,
84+
buckets: Option<Vec<Vec<FileScanTask>>>,
85+
partitioning: Partitioning,
86+
partition_keys_kind: Option<PartitionKeysKind>,
87+
}
88+
89+
impl IcebergTableScanBuilder {
90+
pub fn new(table: Table, schema: ArrowSchemaRef) -> Self {
91+
Self {
92+
table,
93+
snapshot_id: None,
94+
schema,
95+
projection: None,
96+
filter_source: None,
97+
limit: None,
98+
buckets: None,
99+
partitioning: Partitioning::UnknownPartitioning(1),
100+
partition_keys_kind: None,
101+
}
102+
}
103+
104+
pub fn with_snapshot_id(mut self, snapshot_id: Option<i64>) -> Self {
105+
self.snapshot_id = snapshot_id;
106+
self
107+
}
108+
109+
pub fn with_projection(mut self, projection: Option<&[usize]>) -> Self {
110+
self.projection = projection.map(<[usize]>::to_vec);
111+
self
112+
}
113+
114+
/// Sets the filter source for the scan.
115+
///
116+
/// Use `Either::Left(filters)` for DataFusion filters, or
117+
/// `Either::Right(predicate)` when rebuilding from an Iceberg predicate.
118+
pub fn with_filter_source(mut self, filter_source: Either<&[Expr], Option<Predicate>>) -> Self {
119+
self.filter_source = Some(match filter_source {
120+
Either::Left(filters) => Either::Left(filters.to_vec()),
121+
Either::Right(predicate) => Either::Right(predicate),
122+
});
123+
self
124+
}
125+
126+
pub fn with_limit(mut self, limit: Option<usize>) -> Self {
127+
self.limit = limit;
128+
self
129+
}
130+
131+
pub fn with_tasks(
132+
mut self,
133+
buckets: Vec<Vec<FileScanTask>>,
134+
partitioning: Partitioning,
135+
) -> Self {
136+
self.buckets = Some(buckets);
137+
self.partitioning = partitioning;
138+
self
139+
}
140+
141+
pub(crate) fn with_partition_keys_kind(
142+
mut self,
143+
partition_keys_kind: Option<PartitionKeysKind>,
144+
) -> Self {
145+
self.partition_keys_kind = partition_keys_kind;
146+
self
147+
}
148+
149+
pub fn build(self) -> IcebergTableScan {
150+
let Self {
151+
table,
152+
snapshot_id,
153+
schema,
154+
projection,
155+
filter_source,
156+
limit,
157+
buckets,
158+
partitioning,
159+
partition_keys_kind,
160+
} = self;
161+
let predicate = match filter_source {
162+
Some(Either::Left(filters)) => convert_filters_to_predicate(&filters),
163+
Some(Either::Right(predicate)) => predicate,
164+
None => None,
165+
};
166+
167+
IcebergTableScan::new_inner(
168+
table,
169+
snapshot_id,
170+
schema,
171+
projection.as_deref(),
172+
predicate,
173+
limit,
174+
partitioning,
175+
buckets,
176+
)
177+
.with_partition_keys_kind(partition_keys_kind)
178+
}
179+
}
180+
76181
impl IcebergTableScan {
182+
pub fn builder(table: Table, schema: ArrowSchemaRef) -> IcebergTableScanBuilder {
183+
IcebergTableScanBuilder::new(table, schema)
184+
}
185+
77186
/// Creates a lazy single-partition scan that plans and reads all tasks
78187
/// inside `execute(0)`. Used by
79188
/// [`IcebergStaticTableProvider`][crate::table::IcebergStaticTableProvider].
@@ -85,16 +194,12 @@ impl IcebergTableScan {
85194
filters: &[Expr],
86195
limit: Option<usize>,
87196
) -> Self {
88-
Self::new_inner(
89-
table,
90-
snapshot_id,
91-
schema,
92-
projection,
93-
convert_filters_to_predicate(filters),
94-
limit,
95-
Partitioning::UnknownPartitioning(1),
96-
None,
97-
)
197+
Self::builder(table, schema)
198+
.with_snapshot_id(snapshot_id)
199+
.with_projection(projection.map(Vec::as_slice))
200+
.with_filter_source(Either::Left(filters))
201+
.with_limit(limit)
202+
.build()
98203
}
99204

100205
/// Creates an eager multi-partition scan over pre-planned task buckets.
@@ -112,48 +217,21 @@ impl IcebergTableScan {
112217
buckets: Vec<Vec<FileScanTask>>,
113218
partitioning: Partitioning,
114219
) -> Self {
115-
Self::new_inner(
116-
table,
117-
snapshot_id,
118-
schema,
119-
projection,
120-
convert_filters_to_predicate(filters),
121-
limit,
122-
partitioning,
123-
Some(buckets),
124-
)
125-
}
126-
127-
/// Creates an eager multi-partition scan from a [`Predicate`].
128-
#[allow(clippy::too_many_arguments)]
129-
pub fn new_with_tasks_from_predicate(
130-
table: Table,
131-
snapshot_id: Option<i64>,
132-
schema: ArrowSchemaRef,
133-
projection: Option<&Vec<usize>>,
134-
predicate: Option<Predicate>,
135-
limit: Option<usize>,
136-
buckets: Vec<Vec<FileScanTask>>,
137-
partitioning: Partitioning,
138-
) -> Self {
139-
Self::new_inner(
140-
table,
141-
snapshot_id,
142-
schema,
143-
projection,
144-
predicate,
145-
limit,
146-
partitioning,
147-
Some(buckets),
148-
)
220+
Self::builder(table, schema)
221+
.with_snapshot_id(snapshot_id)
222+
.with_projection(projection.map(Vec::as_slice))
223+
.with_filter_source(Either::Left(filters))
224+
.with_limit(limit)
225+
.with_tasks(buckets, partitioning)
226+
.build()
149227
}
150228

151229
#[allow(clippy::too_many_arguments)]
152230
fn new_inner(
153231
table: Table,
154232
snapshot_id: Option<i64>,
155233
schema: ArrowSchemaRef,
156-
projection: Option<&Vec<usize>>,
234+
projection: Option<&[usize]>,
157235
predicate: Option<Predicate>,
158236
limit: Option<usize>,
159237
partitioning: Partitioning,
@@ -170,7 +248,7 @@ impl IcebergTableScan {
170248
Boundedness::Bounded,
171249
));
172250
let table_schema = Arc::clone(&schema);
173-
let projection_indices = projection.cloned();
251+
let projection_indices = projection.map(<[usize]>::to_vec);
174252
let projection = get_column_names(schema, projection);
175253

176254
Self {
@@ -180,7 +258,7 @@ impl IcebergTableScan {
180258
projection,
181259
table_schema,
182260
projection_indices,
183-
predicates: predicate,
261+
predicate,
184262
buckets,
185263
partition_keys_kind: None,
186264
limit,
@@ -215,8 +293,8 @@ impl IcebergTableScan {
215293
self.projection_indices.as_deref()
216294
}
217295

218-
pub fn predicates(&self) -> Option<&Predicate> {
219-
self.predicates.as_ref()
296+
pub fn predicate(&self) -> Option<&Predicate> {
297+
self.predicate.as_ref()
220298
}
221299

222300
/// Returns the pre-planned file task buckets, or an empty slice in lazy mode.
@@ -290,7 +368,7 @@ impl ExecutionPlan for IcebergTableScan {
290368
self.table.clone(),
291369
self.snapshot_id,
292370
self.projection.clone(),
293-
self.predicates.clone(),
371+
self.predicate.clone(),
294372
bucket,
295373
);
296374
let stream = Box::pin(futures::stream::once(fut).try_flatten())
@@ -319,7 +397,7 @@ impl DisplayAs for IcebergTableScan {
319397
.as_deref()
320398
.map_or(String::new(), |v| v.join(","));
321399
let predicate = self
322-
.predicates
400+
.predicate
323401
.as_ref()
324402
.map_or(String::new(), |p| p.to_string());
325403

@@ -344,7 +422,7 @@ fn build_table_scan(
344422
table: Table,
345423
snapshot_id: Option<i64>,
346424
column_names: Option<Vec<String>>,
347-
predicates: Option<Predicate>,
425+
predicate: Option<Predicate>,
348426
) -> DFResult<TableScan> {
349427
let scan_builder = match snapshot_id {
350428
Some(id) => table.scan().snapshot_id(id),
@@ -354,7 +432,7 @@ fn build_table_scan(
354432
Some(names) => scan_builder.select(names),
355433
None => scan_builder.select_all(),
356434
};
357-
if let Some(pred) = predicates {
435+
if let Some(pred) = predicate {
358436
scan_builder = scan_builder.with_filter(pred);
359437
}
360438
scan_builder.build().map_err(to_datafusion_error)
@@ -367,10 +445,10 @@ async fn build_record_batch_stream(
367445
table: Table,
368446
snapshot_id: Option<i64>,
369447
column_names: Option<Vec<String>>,
370-
predicates: Option<Predicate>,
448+
predicate: Option<Predicate>,
371449
bucket: Option<Vec<FileScanTask>>,
372450
) -> DFResult<Pin<Box<dyn Stream<Item = DFResult<RecordBatch>> + Send>>> {
373-
let table_scan = build_table_scan(table, snapshot_id, column_names, predicates)?;
451+
let table_scan = build_table_scan(table, snapshot_id, column_names, predicate)?;
374452
let stream: Pin<Box<dyn Stream<Item = DFResult<RecordBatch>> + Send>> = match bucket {
375453
Some(bucket) => {
376454
let task_stream = Box::pin(futures::stream::iter(
@@ -416,7 +494,7 @@ fn apply_limit(
416494

417495
pub(super) fn get_column_names(
418496
schema: ArrowSchemaRef,
419-
projection: Option<&Vec<usize>>,
497+
projection: Option<&[usize]>,
420498
) -> Option<Vec<String>> {
421499
projection.map(|v| {
422500
v.iter()
@@ -462,22 +540,19 @@ mod tests {
462540
}
463541

464542
#[tokio::test]
465-
async fn test_predicate_constructor_exposes_rebuild_inputs() {
543+
async fn test_builder_exposes_rebuild_inputs() {
466544
let schema = create_test_arrow_schema();
467545
let projection = vec![0usize, 2];
468546
let predicate = Reference::new("x").greater_than(Datum::long(5));
469-
let scan = IcebergTableScan::new_with_tasks_from_predicate(
470-
get_test_table_from_metadata_file().await,
471-
None,
472-
schema.clone(),
473-
Some(&projection),
474-
Some(predicate.clone()),
475-
Some(100),
476-
vec![vec![], vec![]],
477-
Partitioning::UnknownPartitioning(2),
478-
);
479-
480-
assert_eq!(scan.predicates(), Some(&predicate));
547+
let scan =
548+
IcebergTableScan::builder(get_test_table_from_metadata_file().await, schema.clone())
549+
.with_projection(Some(&projection))
550+
.with_filter_source(Either::Right(Some(predicate.clone())))
551+
.with_limit(Some(100))
552+
.with_tasks(vec![vec![], vec![]], Partitioning::UnknownPartitioning(2))
553+
.build();
554+
555+
assert_eq!(scan.predicate(), Some(&predicate));
481556
assert_eq!(scan.table_schema().fields(), schema.fields());
482557
assert_eq!(scan.table_schema().fields().len(), 3);
483558
assert_eq!(scan.schema().fields().len(), 2);

0 commit comments

Comments
 (0)