Skip to content

Commit 2c0eda8

Browse files
committed
feat!: add reader timezone to map-to-struct
1 parent 728aeb9 commit 2c0eda8

7 files changed

Lines changed: 607 additions & 39 deletions

File tree

datafusion-executor/src/expression.rs

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ use datafusion::logical_expr::{
1616
};
1717
use delta_kernel::engine::arrow_conversion::TryIntoArrow;
1818
use delta_kernel::engine::arrow_data::ArrowEngineData;
19+
use delta_kernel::engine::arrow_expression::evaluate_expression as kernel_expression;
1920
use delta_kernel::engine::parse_json;
2021
use delta_kernel::expressions::{
2122
BinaryExpression, BinaryExpressionOp, ColumnName as KernelColumnName,
2223
Expression as KernelExpression, ExpressionRef, ExpressionStructPatch, MapToStructExpression,
23-
ParseJsonExpression, UnaryExpressionOp, VariadicExpression, VariadicExpressionOp,
24+
MapToStructOptions, ParseJsonExpression, UnaryExpressionOp, VariadicExpression,
25+
VariadicExpressionOp,
2426
};
2527
use delta_kernel::schema::{
2628
DataType as KernelDataType, PrimitiveType, SchemaRef as KernelSchemaRef, StructField,
@@ -366,6 +368,11 @@ fn map_to_struct_to_df_expr(
366368
) -> DeltaResult<DFExpr> {
367369
let target = require_struct_output(output_type, "MapToStruct")?;
368370
let map = to_df_expr(&map_to_struct.map_expr, input_schema, None)?;
371+
if let Some(timestamp_timezone) = map_to_struct.options.timestamp_timezone() {
372+
let udf =
373+
KernelMapToStructUdf::try_new(Arc::new(target.clone()), timestamp_timezone.to_owned())?;
374+
return Ok(ScalarUDF::new_from_impl(udf).call(vec![map]));
375+
}
369376

370377
let mut args = Vec::with_capacity(target.num_fields() * 2);
371378
for field in target.fields() {
@@ -394,6 +401,70 @@ fn map_to_struct_to_df_expr(
394401
Ok(struct_null_when_not(map.is_not_null(), named_struct(args)))
395402
}
396403

404+
/// A DataFusion scalar UDF that delegates timezone-aware partition parsing to kernel's Arrow
405+
/// evaluator.
406+
#[derive(Debug, PartialEq, Eq)]
407+
struct KernelMapToStructUdf {
408+
output_schema: KernelSchemaRef,
409+
timestamp_timezone: String,
410+
return_type: ArrowDataType,
411+
signature: Signature,
412+
}
413+
414+
impl std::hash::Hash for KernelMapToStructUdf {
415+
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
416+
for field in self.output_schema.fields() {
417+
field.name().hash(state);
418+
field.data_type().to_string().hash(state);
419+
}
420+
self.timestamp_timezone.hash(state);
421+
}
422+
}
423+
424+
impl KernelMapToStructUdf {
425+
fn try_new(output_schema: KernelSchemaRef, timestamp_timezone: String) -> DeltaResult<Self> {
426+
let arrow_schema: ArrowSchema = output_schema
427+
.as_ref()
428+
.try_into_arrow()
429+
.map_err(Error::generic_err)?;
430+
Ok(Self {
431+
return_type: ArrowDataType::Struct(arrow_schema.fields().clone()),
432+
output_schema,
433+
timestamp_timezone,
434+
signature: Signature::any(1, Volatility::Immutable),
435+
})
436+
}
437+
}
438+
439+
impl ScalarUDFImpl for KernelMapToStructUdf {
440+
fn name(&self) -> &str {
441+
"kernel_map_to_struct_with_timestamp_timezone"
442+
}
443+
444+
fn signature(&self) -> &Signature {
445+
&self.signature
446+
}
447+
448+
fn return_type(&self, _arg_types: &[ArrowDataType]) -> Result<ArrowDataType, DataFusionError> {
449+
Ok(self.return_type.clone())
450+
}
451+
452+
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result<ColumnarValue, DataFusionError> {
453+
let num_rows = args.number_rows;
454+
let [map] = take_function_args(self.name(), args.args)?;
455+
let batch = RecordBatch::try_from_iter([("map", map.into_array(num_rows)?)])?;
456+
let expression = KernelExpression::map_to_struct_with_options(
457+
KernelExpression::column(["map"]),
458+
MapToStructOptions::default().with_timestamp_timezone(self.timestamp_timezone.clone()),
459+
);
460+
let output_type = KernelDataType::from(self.output_schema.as_ref().clone());
461+
let result =
462+
kernel_expression::evaluate_expression(&expression, &batch, Some(&output_type))
463+
.map_err(|e| DataFusionError::External(Box::new(e)))?;
464+
Ok(ColumnarValue::Array(result))
465+
}
466+
}
467+
397468
/// Lowers a `ParseJson` (parse a JSON-string column into a struct) to a call of the
398469
/// [`ParseJsonUdf`] scalar UDF, which delegates to kernel's own JSON parser. Unlike the
399470
/// struct-shaped arms, `ParseJson` is self-typed -- it carries its target `output_schema` -- so it
@@ -489,13 +560,17 @@ impl ScalarUDFImpl for ParseJsonUdf {
489560

490561
#[cfg(test)]
491562
mod tests {
492-
use datafusion::arrow::array::{Array, AsArray, StringArray};
563+
use datafusion::arrow::array::{
564+
Array, AsArray, MapBuilder, StringArray, StringBuilder, TimestampMicrosecondArray,
565+
};
566+
use datafusion::arrow::datatypes::Field as ArrowField;
493567
use datafusion::assert_batches_eq;
494568
use datafusion::common::DFSchema;
495569
use datafusion::physical_expr::create_physical_expr;
496570
use datafusion::physical_expr::execution_props::ExecutionProps;
497571
use delta_kernel::expressions::{
498572
col, lit, Expression as KernelExpr, ExpressionStructPatch, ExpressionStructPatchBuilder,
573+
MapToStructOptions,
499574
};
500575
use delta_kernel::schema::{ArrayType, DataType, MapType, StructField, StructType};
501576
use rstest::rstest;
@@ -1041,6 +1116,55 @@ mod tests {
10411116
assert!(err.contains(expected_message), "{err}");
10421117
}
10431118

1119+
#[test]
1120+
fn map_to_struct_with_timezone_lowers_to_kernel_udf() {
1121+
let target =
1122+
StructType::try_new([StructField::nullable("ts", DataType::TIMESTAMP)]).unwrap();
1123+
let map = KernelExpr::map_to_struct_with_options(
1124+
col!("pv"),
1125+
MapToStructOptions::default().with_timestamp_timezone("America/Los_Angeles"),
1126+
);
1127+
assert_eq!(
1128+
to_df_expr(&map, &pv_map_schema(), Some(&DataType::from(target)))
1129+
.unwrap()
1130+
.to_string(),
1131+
"kernel_map_to_struct_with_timestamp_timezone(pv)"
1132+
);
1133+
}
1134+
1135+
#[test]
1136+
fn map_to_struct_with_timezone_executes_in_reader_timezone() {
1137+
let mut maps = MapBuilder::new(None, StringBuilder::new(), StringBuilder::new());
1138+
maps.keys().append_value("ts");
1139+
maps.values().append_value("2024-06-15 09:30:00");
1140+
maps.append(true).unwrap();
1141+
let map = Arc::new(maps.finish()) as ArrayRef;
1142+
let arrow_schema =
1143+
ArrowSchema::new(vec![ArrowField::new("pv", map.data_type().clone(), true)]);
1144+
let batch = RecordBatch::try_new(Arc::new(arrow_schema.clone()), vec![map]).unwrap();
1145+
let target =
1146+
StructType::try_new([StructField::nullable("ts", DataType::TIMESTAMP)]).unwrap();
1147+
let logical = to_df_expr(
1148+
&KernelExpr::map_to_struct_with_options(
1149+
col!("pv"),
1150+
MapToStructOptions::default().with_timestamp_timezone("America/Los_Angeles"),
1151+
),
1152+
&pv_map_schema(),
1153+
Some(&DataType::from(target)),
1154+
)
1155+
.unwrap();
1156+
let df_schema = DFSchema::try_from(arrow_schema).unwrap();
1157+
let physical = create_physical_expr(&logical, &df_schema, &ExecutionProps::new()).unwrap();
1158+
let result = physical.evaluate(&batch).unwrap().into_array(1).unwrap();
1159+
let timestamps = result
1160+
.as_struct()
1161+
.column(0)
1162+
.as_any()
1163+
.downcast_ref::<TimestampMicrosecondArray>()
1164+
.unwrap();
1165+
assert_eq!(timestamps.value(0), 1_718_469_000_000_000);
1166+
}
1167+
10441168
// === ParseJson Shared Helpers ===
10451169

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

ffi/src/expressions/engine_visitor.rs

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -679,7 +679,16 @@ fn visit_expression_impl(
679679
schema_handle
680680
);
681681
}
682-
Expression::MapToStruct(MapToStructExpression { map_expr }) => {
682+
Expression::MapToStruct(map_to_struct)
683+
if map_to_struct.options.timestamp_timezone().is_some() =>
684+
{
685+
visit_unknown(
686+
visitor,
687+
sibling_list_id,
688+
"map_to_struct_with_timestamp_timezone",
689+
)
690+
}
691+
Expression::MapToStruct(MapToStructExpression { map_expr, .. }) => {
683692
let child_list_id = call!(visitor, make_field_list, 1);
684693
visit_expression_impl(visitor, map_expr, child_list_id);
685694
call!(visitor, visit_map_to_struct, sibling_list_id, child_list_id);
@@ -754,15 +763,26 @@ fn visit_predicate_internal(predicate: &Predicate, visitor: &mut EngineExpressio
754763

755764
#[cfg(test)]
756765
mod tests {
757-
use delta_kernel::expressions::{Expression, Scalar};
766+
use delta_kernel::expressions::{Expression, MapToStructOptions, Scalar};
758767
use rstest::rstest;
759768

760769
use super::*;
770+
use crate::TryFromStringSlice;
761771

762772
#[derive(Debug, PartialEq, Eq)]
763773
enum LiteralEvent {
764-
IntervalYearMonth { sibling_list_id: usize, value: i32 },
765-
IntervalDayTime { sibling_list_id: usize, value: i64 },
774+
IntervalYearMonth {
775+
sibling_list_id: usize,
776+
value: i32,
777+
},
778+
IntervalDayTime {
779+
sibling_list_id: usize,
780+
value: i64,
781+
},
782+
Unknown {
783+
sibling_list_id: usize,
784+
name: String,
785+
},
766786
}
767787

768788
#[derive(Default)]
@@ -802,6 +822,19 @@ mod tests {
802822
});
803823
}
804824

825+
extern "C" fn visit_unknown_name(
826+
data: *mut c_void,
827+
sibling_list_id: usize,
828+
name: KernelStringSlice,
829+
) {
830+
let builder = unsafe { &mut *(data as *mut TestExpressionBuilder) };
831+
let name = unsafe { String::try_from_slice(&name) }.unwrap();
832+
builder.events.push(LiteralEvent::Unknown {
833+
sibling_list_id,
834+
name,
835+
});
836+
}
837+
805838
macro_rules! ignore_fn {
806839
($fn_name:ident $(, $arg_type:ty)*) => {
807840
extern "C" fn $fn_name(
@@ -881,7 +914,7 @@ mod tests {
881914
visit_field_patch: ignore_field_patch,
882915
visit_opaque_expr: ignore_opaque_expr,
883916
visit_opaque_pred: ignore_opaque_pred,
884-
visit_unknown: ignore_column,
917+
visit_unknown: visit_unknown_name,
885918
}
886919
}
887920

@@ -930,4 +963,25 @@ mod tests {
930963
assert_eq!(top_level_id, 0);
931964
assert_eq!(builder.events, vec![expected]);
932965
}
966+
967+
#[test]
968+
fn timezone_aware_map_to_struct_visits_unknown() {
969+
let expression = Expression::map_to_struct_with_options(
970+
Expression::column(["partitionValues"]),
971+
MapToStructOptions::default().with_timestamp_timezone("America/Los_Angeles"),
972+
);
973+
let mut builder = TestExpressionBuilder::default();
974+
let mut visitor = test_visitor(&mut builder);
975+
976+
let top_level_id = visit_expression_internal(&expression, &mut visitor);
977+
978+
assert_eq!(top_level_id, 0);
979+
assert_eq!(
980+
builder.events,
981+
vec![LiteralEvent::Unknown {
982+
sibling_list_id: 0,
983+
name: "map_to_struct_with_timestamp_timezone".to_string(),
984+
}]
985+
);
986+
}
933987
}

kernel/proto/expressions.proto

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

153+
message MapToStructOptions {
154+
// Optional IANA timezone or fixed offset for interpreting offset-less TIMESTAMP values. An
155+
// explicit offset in a value takes precedence; absence preserves the default UTC behavior.
156+
optional string timestamp_timezone = 1;
157+
}
158+
153159
message MapToStructExpression {
154160
Expression map_expr = 1;
161+
MapToStructOptions options = 2;
155162
}
156163

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

0 commit comments

Comments
 (0)