Skip to content

Commit a6978d3

Browse files
committed
feat!: add reader timezone to map-to-struct
1 parent 570861e commit a6978d3

13 files changed

Lines changed: 537 additions & 86 deletions

File tree

datafusion-executor/src/expression.rs

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ use delta_kernel::engine::arrow_data::ArrowEngineData;
1717
use delta_kernel::engine::parse_json;
1818
use delta_kernel::expressions::{
1919
BinaryExpression, BinaryExpressionOp, Expression as KernelExpression, ExpressionRef,
20-
ExpressionStructPatch, MapToStructExpression, ParseJsonExpression, UnaryExpressionOp,
21-
VariadicExpression, VariadicExpressionOp,
20+
ExpressionStructPatch, MapToStructExpression, MapToStructOptions, ParseJsonExpression,
21+
UnaryExpressionOp, VariadicExpression, VariadicExpressionOp,
2222
};
2323
use delta_kernel::schema::{
2424
DataType as KernelDataType, PrimitiveType, SchemaRef as KernelSchemaRef, StructField,
@@ -407,15 +407,22 @@ fn struct_columns_from_patch(
407407
/// - Decimal: arrow's cast silently rescales/rounds to the target scale, while kernel requires the
408408
/// value's scale to match the target's exactly (and hard-errors otherwise).
409409
///
410+
/// Configured options are rejected rather than silently lowered with default semantics.
411+
///
410412
/// # Errors
411-
/// Returns an error when `output_type` is absent, not a struct, or has a non-primitive field, or
412-
/// from lowering the map expression.
413+
/// Returns an error when options are configured, `output_type` is absent, not a struct, or has a
414+
/// non-primitive field, or from lowering the map expression.
413415
fn map_to_struct_to_df_expr(
414416
map_to_struct: &MapToStructExpression,
415417
input_schema: &StructType,
416418
output_type: Option<&KernelDataType>,
417419
) -> DeltaResult<DFExpr> {
418420
let target = require_struct_output(output_type, "MapToStruct")?;
421+
if map_to_struct.options != MapToStructOptions::default() {
422+
return Err(Error::unsupported(
423+
"DataFusion execution of MapToStruct with configured options",
424+
));
425+
}
419426
let map = to_df_expr(&map_to_struct.map_expr, input_schema, None)?;
420427

421428
let mut args = Vec::with_capacity(target.num_fields() * 2);
@@ -1001,7 +1008,7 @@ mod tests {
10011008
/// Lowers a `MapToStruct` over `pv` targeting `output_schema` and renders it as a `Display`
10021009
/// string.
10031010
fn lower_map_to_struct(output_schema: StructType) -> String {
1004-
let kernel = KernelExpr::map_to_struct(col!("pv"));
1011+
let kernel = KernelExpr::map_to_struct(col!("pv"), MapToStructOptions::default());
10051012
let target: DataType = output_schema.into();
10061013
to_df_expr(&kernel, &pv_map_schema(), Some(&target))
10071014
.unwrap()
@@ -1063,13 +1070,28 @@ mod tests {
10631070
#[case] output_type: Option<DataType>,
10641071
#[case] expected_message: &str,
10651072
) {
1066-
let kernel = KernelExpr::map_to_struct(col!("pv"));
1073+
let kernel = KernelExpr::map_to_struct(col!("pv"), MapToStructOptions::default());
10671074
let err = to_df_expr(&kernel, &pv_map_schema(), output_type.as_ref())
10681075
.unwrap_err()
10691076
.to_string();
10701077
assert!(err.contains(expected_message), "{err}");
10711078
}
10721079

1080+
#[test]
1081+
fn configured_map_to_struct_is_unsupported() {
1082+
let target = DataType::from(schema! { nullable "ts": TIMESTAMP });
1083+
let kernel = KernelExpr::map_to_struct(
1084+
col!("pv"),
1085+
MapToStructOptions::default().with_timestamp_timezone("America/Los_Angeles"),
1086+
);
1087+
1088+
let error = to_df_expr(&kernel, &pv_map_schema(), Some(&target))
1089+
.unwrap_err()
1090+
.to_string();
1091+
1092+
assert!(error.contains("MapToStruct with configured options"));
1093+
}
1094+
10731095
// === ParseJson Shared Helpers ===
10741096

10751097
/// Input schema for JSON tests: `{ j: string }`.

ffi/src/expressions/engine_visitor.rs

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ use std::ffi::c_void;
55
use delta_kernel::expressions::{
66
ArrayData, BinaryExpression, BinaryExpressionOp, BinaryPredicate, BinaryPredicateOp,
77
ColumnName, Expression, ExpressionRef, ExpressionStructPatch, JunctionPredicate,
8-
JunctionPredicateOp, MapData, MapToStructExpression, OpaqueExpression, OpaqueExpressionOpRef,
9-
OpaquePredicate, OpaquePredicateOpRef, ParseJsonExpression, Predicate, Scalar, StructData,
10-
UnaryExpression, UnaryExpressionOp, UnaryPredicate, UnaryPredicateOp, VariadicExpression,
11-
VariadicExpressionOp,
8+
JunctionPredicateOp, MapData, MapToStructExpression, MapToStructOptions, OpaqueExpression,
9+
OpaqueExpressionOpRef, OpaquePredicate, OpaquePredicateOpRef, ParseJsonExpression, Predicate,
10+
Scalar, StructData, UnaryExpression, UnaryExpressionOp, UnaryPredicate, UnaryPredicateOp,
11+
VariadicExpression, VariadicExpressionOp,
1212
};
1313

1414
use super::kernel_visitor::NullTypeTag;
@@ -174,9 +174,10 @@ pub struct EngineExpressionVisitor {
174174
/// `child_list_id`. The `output_schema` handle specifies the schema to parse the JSON
175175
/// into.
176176
pub visit_parse_json: VisitParseJsonFn,
177-
/// Visits the `MapToStruct` expression belonging to the list identified by `sibling_list_id`.
178-
/// The sub-expression (map column) will be in a _one_ item list identified by `child_list_id`.
179-
/// The output struct schema is determined by the evaluator's result type.
177+
/// Visits a `MapToStruct` expression with default options. The FFI does not currently
178+
/// represent [`MapToStructOptions`], so expressions with configured options are reported
179+
/// through `visit_unknown` without visiting the child expression. The sub-expression is in
180+
/// the one-item list identified by `child_list_id`.
180181
pub visit_map_to_struct: VisitUnaryFn,
181182
/// Visits the `LessThan` binary operator belonging to the list identified by
182183
/// `sibling_list_id`. The operands will be in a _two_ item list identified by
@@ -696,7 +697,13 @@ fn visit_expression_impl(
696697
schema_handle
697698
);
698699
}
699-
Expression::MapToStruct(MapToStructExpression { map_expr }) => {
700+
// TODO: Add a dedicated FFI representation for MapToStructOptions.
701+
Expression::MapToStruct(map_to_struct)
702+
if map_to_struct.options != MapToStructOptions::default() =>
703+
{
704+
visit_unknown(visitor, sibling_list_id, "map_to_struct")
705+
}
706+
Expression::MapToStruct(MapToStructExpression { map_expr, .. }) => {
700707
let child_list_id = call!(visitor, make_field_list, 1);
701708
visit_expression_impl(visitor, map_expr, child_list_id);
702709
call!(visitor, visit_map_to_struct, sibling_list_id, child_list_id);
@@ -771,7 +778,7 @@ fn visit_predicate_internal(predicate: &Predicate, visitor: &mut EngineExpressio
771778

772779
#[cfg(test)]
773780
mod tests {
774-
use delta_kernel::expressions::{lit, Expression, Scalar};
781+
use delta_kernel::expressions::{lit, Expression, MapToStructOptions, Scalar};
775782
use rstest::rstest;
776783

777784
use super::*;
@@ -791,6 +798,10 @@ mod tests {
791798
sibling_list_id: usize,
792799
parts: Vec<String>,
793800
},
801+
Unknown {
802+
sibling_list_id: usize,
803+
name: String,
804+
},
794805
}
795806

796807
#[derive(Default)]
@@ -847,6 +858,18 @@ mod tests {
847858
});
848859
}
849860

861+
extern "C" fn visit_unknown_name(
862+
data: *mut c_void,
863+
sibling_list_id: usize,
864+
name: KernelStringSlice,
865+
) {
866+
let builder = unsafe { &mut *(data as *mut TestExpressionBuilder) };
867+
let name = unsafe { String::try_from_slice(&name) }.unwrap();
868+
builder.events.push(LiteralEvent::Unknown {
869+
sibling_list_id,
870+
name,
871+
});
872+
}
850873
macro_rules! ignore_fn {
851874
($fn_name:ident $(, $arg_type:ty)*) => {
852875
extern "C" fn $fn_name(
@@ -925,7 +948,7 @@ mod tests {
925948
visit_field_patch: ignore_field_patch,
926949
visit_opaque_expr: ignore_opaque_expr,
927950
visit_opaque_pred: ignore_opaque_pred,
928-
visit_unknown: ignore_string_slice,
951+
visit_unknown: visit_unknown_name,
929952
}
930953
}
931954

@@ -992,4 +1015,25 @@ mod tests {
9921015
assert_eq!(top_level_id, 0);
9931016
assert_eq!(builder.events, vec![expected]);
9941017
}
1018+
1019+
#[test]
1020+
fn timezone_aware_map_to_struct_visits_unknown() {
1021+
let expression = Expression::map_to_struct(
1022+
Expression::column(["partitionValues"]),
1023+
MapToStructOptions::default().with_timestamp_timezone("America/Los_Angeles"),
1024+
);
1025+
let mut builder = TestExpressionBuilder::default();
1026+
let mut visitor = test_visitor(&mut builder);
1027+
1028+
let top_level_id = visit_expression_internal(&expression, &mut visitor);
1029+
1030+
assert_eq!(top_level_id, 0);
1031+
assert_eq!(
1032+
builder.events,
1033+
vec![LiteralEvent::Unknown {
1034+
sibling_list_id: 0,
1035+
name: "map_to_struct".to_string(),
1036+
}]
1037+
);
1038+
}
9951039
}

ffi/src/expressions/kernel_visitor.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use std::sync::Arc;
66
use delta_kernel::engine::arrow_expression::opaque::ArrowOpaquePredicate;
77
use delta_kernel::expressions::{
88
lit, null_lit, BinaryExpressionOp, BinaryPredicateOp, ColumnName, Expression,
9-
JunctionPredicateOp, Predicate, Scalar, UnaryPredicateOp,
9+
JunctionPredicateOp, MapToStructOptions, Predicate, Scalar, UnaryPredicateOp,
1010
};
1111
use delta_kernel::schema::{DataType, PrimitiveType};
1212
use delta_kernel::DeltaResult;
@@ -691,8 +691,12 @@ pub extern "C" fn visit_expression_map_to_struct(
691691
state: &mut KernelExpressionVisitorState,
692692
child_expr: usize,
693693
) -> usize {
694+
// TODO: Add a dedicated FFI representation for MapToStructOptions.
694695
unwrap_kernel_expression(state, child_expr).map_or(0, |expr| {
695-
wrap_expression(state, Expression::map_to_struct(expr))
696+
wrap_expression(
697+
state,
698+
Expression::map_to_struct(expr, MapToStructOptions::default()),
699+
)
696700
})
697701
}
698702

ffi/src/test_ffi.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ use std::sync::Arc;
66

77
use delta_kernel::expressions::{
88
col, column_name, column_pred, lit, null_lit, ArrayData, BinaryExpressionOp, BinaryPredicateOp,
9-
Expression as Expr, ExpressionStructPatchBuilder, MapData, OpaqueExpressionOp,
10-
OpaquePredicateOp, Predicate as Pred, Scalar, ScalarExpressionEvaluator, StructData,
9+
Expression as Expr, ExpressionStructPatchBuilder, MapData, MapToStructOptions,
10+
OpaqueExpressionOp, OpaquePredicateOp, Predicate as Pred, Scalar, ScalarExpressionEvaluator,
11+
StructData,
1112
};
1213
use delta_kernel::kernel_predicates::{
1314
DirectDataSkippingPredicateEvaluator, DirectPredicateEvaluator,
@@ -158,7 +159,7 @@ pub unsafe extern "C" fn get_testing_kernel_expression() -> Handle<SharedExpress
158159
Expr::struct_from([lit(5_i32), lit(20_i64)]),
159160
Expr::opaque(OpaqueTestOp("foo".to_string()), vec![lit(42), lit(1.111)]),
160161
Expr::unknown("mystery"),
161-
Expr::map_to_struct(col!("pv")),
162+
Expr::map_to_struct(col!("pv"), MapToStructOptions::default()),
162163
Expr::coalesce([col!("col"), lit(0_i32)]),
163164
Expr::array([lit(1_i32), lit(2_i32)]),
164165
];
@@ -254,7 +255,7 @@ pub unsafe extern "C" fn get_simple_testing_kernel_expression() -> Handle<Shared
254255
Expr::binary(BinaryExpressionOp::Multiply, lit(5), lit(6)),
255256
Expr::binary(BinaryExpressionOp::Divide, lit(100), lit(4)),
256257
Expr::struct_from([lit(1_i32), lit(2_i64), lit(3.0_f64)]),
257-
Expr::map_to_struct(col!("partitionValues")),
258+
Expr::map_to_struct(col!("partitionValues"), MapToStructOptions::default()),
258259
];
259260
Arc::new(Expr::struct_from(sub_exprs)).into()
260261
}

kernel/proto/expressions.proto

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,18 @@ message ParseJsonExpression {
150150
delta.kernel.schema.StructType output_schema = 2;
151151
}
152152

153+
message MapToStructOptions {
154+
// Optional recognized IANA timezone or normalized fixed offset (`+HH:MM`, `-HH:MM`,
155+
// `+HH:MM:SS`, or `-HH:MM:SS`) for offset-less TIMESTAMP values.
156+
// A timezone or offset in a value takes precedence; absence means UTC. For named timezones,
157+
// repeated local times use the earlier occurrence. Local times skipped by a forward clock change
158+
// use the prior offset, shifting the result forward by the size of the gap.
159+
optional string timestamp_timezone = 1;
160+
}
161+
153162
message MapToStructExpression {
154163
Expression map_expr = 1;
164+
MapToStructOptions options = 2;
155165
}
156166

157167
// `nullability_predicate` is optional: when set and it evaluates to false/null, the whole

kernel/src/checkpoint/checkpoint_transform.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
use std::sync::{Arc, LazyLock};
1717

1818
use crate::actions::{ADD_NAME, STATS_PARSED as STATS_PARSED_FIELD};
19-
use crate::expressions::{col, Expression, ExpressionRef, UnaryExpressionOp};
19+
use crate::expressions::{col, Expression, ExpressionRef, MapToStructOptions, UnaryExpressionOp};
2020
use crate::schema::{DataType, SchemaRef, SchemaStructPatchBuilder, StructField, StructType};
2121
use crate::struct_patch::ProjectionStructPatchBuilder;
2222
use crate::table_properties::TableProperties;
@@ -211,7 +211,12 @@ fn build_stats_parsed_expr(stats_schema: &SchemaRef) -> ExpressionRef {
211211
fn build_partition_values_parsed_expr() -> ExpressionRef {
212212
Arc::new(Expression::coalesce([
213213
col!(ADD_NAME, PARTITION_VALUES_PARSED_FIELD),
214-
Expression::map_to_struct(col!(ADD_NAME, PARTITION_VALUES_FIELD)),
214+
// Checkpoint construction has no caller-supplied timezone, so reconstruct with
215+
// deterministic UTC rather than guessing from the host environment.
216+
Expression::map_to_struct(
217+
col!(ADD_NAME, PARTITION_VALUES_FIELD),
218+
MapToStructOptions::default(),
219+
),
215220
]))
216221
}
217222

0 commit comments

Comments
 (0)