Skip to content

Commit 4e1519e

Browse files
committed
Improve from_utc_timestamp examples, benchmarks and SQL formatting
Conditional timezone evaluation requires SparkFromUtcTimestampExpr; registering the UDF still evaluates its arguments eagerly. Document the constructor's type requirements and add a runnable example that plans casts inside the children. Add a SQL/planner integration test showing that timezone errors are skipped for null timestamps and propagated when the timestamp is non-null. Extend the benchmark beyond the timezone-column fast path to include computed and literal timezones. Exercise batches with 2, 64 and 256 columns and no, sparse or 50% null timestamps to expose the costs of filtering unrelated columns and broadcasting scalar timezones. Format each child through fmt_sql so SQL output does not include physical column indices such as timestamp@0. The integration test checks that this output matches the ordinarily planned UDF's SQL representation. Validation: focused tests, the constructor doctest, all 27 benchmark cases, extended workspace tests (including 533 SQL logic files), Spark Clippy across all targets and features, and formatting checks passed. Full-workspace Clippy reports three mutable_key_type errors in unchanged datafusion/optimizer/src/push_down_filter.rs.
1 parent 977515f commit 4e1519e

2 files changed

Lines changed: 175 additions & 28 deletions

File tree

‎datafusion/spark/benches/from_utc_timestamp.rs‎

Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@
1515
// specific language governing permissions and limitations
1616
// under the License.
1717

18-
//! Measure the physical evaluator with a timezone column. The narrow and wide batches use
19-
//! identical timestamp and timezone columns; only the number of unused Int64 columns differs.
20-
//! Mixed-null timestamps expose any cost from filtering those unrelated columns.
18+
//! Measure column, computed, and literal timezone evaluation across batch widths and null
19+
//! densities. Computed timezones exercise selection and copying of unrelated columns, while
20+
//! literal timezones exercise scalar broadcasting. A physical expression benchmark is needed
21+
//! because ordinary SQL uses the eager UDF instead of the conditional evaluator.
2122
2223
use std::hint::black_box;
2324
use std::sync::Arc;
@@ -26,11 +27,13 @@ use arrow::array::{ArrayRef, Int64Array, StringArray, TimestampMicrosecondArray}
2627
use arrow::datatypes::{DataType, Field, Schema, TimeUnit};
2728
use arrow::record_batch::RecordBatch;
2829
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
29-
use datafusion::physical_expr::expressions::Column;
30+
use datafusion::prelude::{SessionContext, col, lit};
31+
use datafusion_common::DFSchema;
32+
use datafusion_expr::when;
3033
use datafusion_physical_expr_common::physical_expr::PhysicalExpr;
3134
use datafusion_spark::function::datetime::from_utc_timestamp::SparkFromUtcTimestampExpr;
3235

33-
fn batch(rows: usize, columns: usize, half_null: bool) -> RecordBatch {
36+
fn batch(rows: usize, columns: usize, null_every: Option<usize>) -> RecordBatch {
3437
let mut fields = vec![
3538
Field::new(
3639
"timestamp",
@@ -41,7 +44,9 @@ fn batch(rows: usize, columns: usize, half_null: bool) -> RecordBatch {
4144
];
4245
let mut arrays: Vec<ArrayRef> = vec![
4346
Arc::new(TimestampMicrosecondArray::from_iter((0..rows).map(|row| {
44-
(!half_null || row % 2 != 0).then_some(row as i64 * 86_400_000_000)
47+
null_every
48+
.is_none_or(|every| row % every != 0)
49+
.then_some(row as i64 * 86_400_000_000)
4550
}))),
4651
Arc::new(StringArray::from_iter_values(
4752
(0..rows).map(|row| if (row / 2) % 2 == 0 { "PST" } else { "EST" }),
@@ -64,30 +69,48 @@ fn batch(rows: usize, columns: usize, half_null: bool) -> RecordBatch {
6469

6570
fn criterion_benchmark(c: &mut Criterion) {
6671
let rows = 8192;
67-
let expr = SparkFromUtcTimestampExpr::new(
68-
Arc::new(Column::new("timestamp", 0)),
69-
Arc::new(Column::new("timezone", 1)),
70-
);
71-
let mut group = c.benchmark_group("from_utc_timestamp_column");
72-
group.throughput(Throughput::Elements(rows as u64));
72+
let ctx = SessionContext::new();
73+
let timezones = [
74+
("column", col("timezone")),
75+
(
76+
"computed",
77+
when(col("timezone").eq(lit("PST")), lit("PST"))
78+
.otherwise(lit("EST"))
79+
.unwrap(),
80+
),
81+
("literal", lit("PST")),
82+
];
7383

74-
for columns in [2, 64, 256] {
75-
for (half_null, name) in [(false, "no_nulls"), (true, "half_nulls")] {
76-
let batch = batch(rows, columns, half_null);
77-
let result = expr.evaluate(&batch).unwrap().into_array(rows).unwrap();
78-
assert_eq!(result.len(), rows);
79-
assert_eq!(result.null_count(), if half_null { rows / 2 } else { 0 });
84+
for (source, timezone) in timezones {
85+
let mut group = c.benchmark_group(format!("from_utc_timestamp_{source}"));
86+
group.throughput(Throughput::Elements(rows as u64));
87+
for columns in [2, 64, 256] {
88+
for (null_every, name) in [
89+
(None, "no_nulls"),
90+
(Some(100), "sparse_nulls"),
91+
(Some(2), "half_nulls"),
92+
] {
93+
let batch = batch(rows, columns, null_every);
94+
let schema = DFSchema::try_from(batch.schema().as_ref().clone()).unwrap();
95+
let expr = SparkFromUtcTimestampExpr::new(
96+
ctx.create_physical_expr(col("timestamp"), &schema).unwrap(),
97+
ctx.create_physical_expr(timezone.clone(), &schema).unwrap(),
98+
);
99+
let result = expr.evaluate(&batch).unwrap().into_array(rows).unwrap();
100+
assert_eq!(result.len(), rows);
101+
assert_eq!(result.null_count(), batch.column(0).null_count());
80102

81-
group.bench_with_input(
82-
BenchmarkId::new(format!("{columns}_columns"), name),
83-
&batch,
84-
|b, batch| {
85-
b.iter(|| black_box(expr.evaluate(black_box(batch)).unwrap()));
86-
},
87-
);
103+
group.bench_with_input(
104+
BenchmarkId::new(format!("{columns}_columns"), name),
105+
&batch,
106+
|b, batch| {
107+
b.iter(|| black_box(expr.evaluate(black_box(batch)).unwrap()));
108+
},
109+
);
110+
}
88111
}
112+
group.finish();
89113
}
90-
group.finish();
91114
}
92115

93116
criterion_group!(benches, criterion_benchmark);

‎datafusion/spark/src/function/datetime/from_utc_timestamp.rs‎

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,10 @@ fn parse_timezone(timezone: &str) -> Result<SparkTimeZone> {
228228
/// their values are ignored on null timestamp rows. Computed timezone expressions are evaluated
229229
/// only for rows with non-null timestamps. Empty batches evaluate neither child.
230230
///
231+
/// Registering [`SparkFromUtcTimestamp`] as a UDF does not use this expression: ordinary SQL
232+
/// evaluates the UDF's arguments eagerly. Consumers must explicitly construct this expression
233+
/// after planning and coercing its children to obtain conditional timezone evaluation.
234+
///
231235
/// This intentionally differs from Spark's generated evaluation of foldable timezones, which
232236
/// evaluates the timezone and validates a non-null value before processing timestamp rows. This
233237
/// expression can therefore skip an invalid timezone or an error in the timezone expression when
@@ -242,6 +246,50 @@ pub struct SparkFromUtcTimestampExpr {
242246

243247
impl SparkFromUtcTimestampExpr {
244248
/// Construct an expression that skips computed timezone evaluation for null timestamps.
249+
///
250+
/// Both children must already have supported types: a timestamp (any time unit, with or
251+
/// without timezone metadata) and a string (`Utf8`, `LargeUtf8`, or `Utf8View`). This
252+
/// constructor does not validate or coerce their types. Insert required casts during
253+
/// planning, keeping timezone casts inside the timezone child so they are also skipped
254+
/// for null timestamps.
255+
///
256+
/// # Example
257+
///
258+
/// A consumer can plan typed children before constructing the physical expression.
259+
/// The invalid integer cast in the timezone child is skipped for the null timestamp.
260+
///
261+
/// ```
262+
/// use std::sync::Arc;
263+
/// use arrow::array::{ArrayRef, StringArray, TimestampMicrosecondArray};
264+
/// use arrow::datatypes::{DataType, TimeUnit};
265+
/// use arrow::record_batch::RecordBatch;
266+
/// use datafusion::prelude::{SessionContext, col};
267+
/// use datafusion_common::DFSchema;
268+
/// use datafusion_expr::cast;
269+
/// use datafusion_physical_expr_common::physical_expr::PhysicalExpr;
270+
/// use datafusion_spark::function::datetime::from_utc_timestamp::SparkFromUtcTimestampExpr;
271+
///
272+
/// let batch = RecordBatch::try_from_iter([
273+
/// ("ts", Arc::new(StringArray::from(vec![Some("1970-01-01T00:00:00"), None])) as ArrayRef),
274+
/// ("value", Arc::new(StringArray::from(vec!["1", "invalid"])) as ArrayRef),
275+
/// ])?;
276+
/// let schema = DFSchema::try_from(batch.schema().as_ref().clone())?;
277+
/// let ctx = SessionContext::new();
278+
/// let timestamp = ctx.create_physical_expr(
279+
/// cast(col("ts"), DataType::Timestamp(TimeUnit::Microsecond, None)),
280+
/// &schema,
281+
/// )?;
282+
/// let timezone = ctx.parse_sql_expr(
283+
/// "CASE WHEN CAST(value AS INT) = 1 THEN 'GMT+1' ELSE 'UTC' END",
284+
/// &schema,
285+
/// )?;
286+
/// let timezone = ctx.create_physical_expr(cast(timezone, DataType::Utf8), &schema)?;
287+
/// let expr = SparkFromUtcTimestampExpr::new(timestamp, timezone);
288+
/// let result = expr.evaluate(&batch)?.into_array(batch.num_rows())?;
289+
/// let expected = TimestampMicrosecondArray::from(vec![Some(3_600_000_000), None]);
290+
/// assert_eq!(result.as_ref(), &expected);
291+
/// # Ok::<(), datafusion_common::DataFusionError>(())
292+
/// ```
245293
pub fn new(
246294
timestamp: Arc<dyn PhysicalExpr>,
247295
timezone: Arc<dyn PhysicalExpr>,
@@ -280,7 +328,11 @@ impl Hash for SparkFromUtcTimestampExpr {
280328

281329
impl PhysicalExpr for SparkFromUtcTimestampExpr {
282330
fn fmt_sql(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
283-
Display::fmt(self, f)
331+
write!(f, "from_utc_timestamp(")?;
332+
self.timestamp.fmt_sql(f)?;
333+
write!(f, ", ")?;
334+
self.timezone.fmt_sql(f)?;
335+
write!(f, ")")
284336
}
285337

286338
fn return_field(&self, input_schema: &Schema) -> Result<FieldRef> {
@@ -362,8 +414,10 @@ mod tests {
362414
use datafusion::physical_expr::expressions::{
363415
BinaryExpr, CaseExpr, CastExpr, Column, Literal,
364416
};
365-
use datafusion_common::{ScalarValue, config::ConfigOptions};
417+
use datafusion::prelude::SessionContext;
418+
use datafusion_common::{DFSchema, ScalarValue, config::ConfigOptions};
366419
use datafusion_expr::Operator;
420+
use datafusion_physical_expr_common::physical_expr::fmt_sql;
367421

368422
fn expression_batch(timestamps: Vec<Option<i64>>, values: Vec<&str>) -> RecordBatch {
369423
RecordBatch::try_new(
@@ -627,6 +681,76 @@ mod tests {
627681
assert!(error.to_string().contains("invalid"));
628682
}
629683

684+
#[tokio::test]
685+
async fn planned_udf_requires_explicit_conditional_expression() -> Result<()> {
686+
let ctx = SessionContext::new();
687+
ctx.register_udf(
688+
crate::function::datetime::from_utc_timestamp()
689+
.as_ref()
690+
.clone(),
691+
);
692+
let batch = RecordBatch::try_from_iter([
693+
(
694+
"timestamp_text",
695+
Arc::new(StringArray::from(vec![
696+
None,
697+
Some("1970-01-01T00:00:00"),
698+
Some("1970-01-01T00:00:00"),
699+
])) as ArrayRef,
700+
),
701+
(
702+
"value",
703+
Arc::new(StringArray::from(vec!["invalid", "0", "1"])) as ArrayRef,
704+
),
705+
])?;
706+
ctx.register_batch("input", batch.clone())?;
707+
let sql_expr = "from_utc_timestamp(timestamp_text, \
708+
CASE WHEN CAST(value AS INT) = 0 THEN 'UTC' ELSE 'PST' END)";
709+
710+
// Registering the UDF leaves ordinary SQL argument evaluation eager.
711+
let error = ctx
712+
.sql(&format!("SELECT {sql_expr} FROM input"))
713+
.await?
714+
.collect()
715+
.await
716+
.unwrap_err();
717+
assert!(error.to_string().contains("invalid"));
718+
719+
// Reuse the planner's children, including its implicit string-to-timestamp cast.
720+
let schema = DFSchema::try_from(batch.schema().as_ref().clone())?;
721+
let planned =
722+
ctx.create_physical_expr(ctx.parse_sql_expr(sql_expr, &schema)?, &schema)?;
723+
let children = planned.children();
724+
let conditional = SparkFromUtcTimestampExpr::new(
725+
Arc::clone(children[0]),
726+
Arc::clone(children[1]),
727+
);
728+
assert_eq!(
729+
conditional.data_type(batch.schema_ref())?,
730+
DataType::Timestamp(TimeUnit::Microsecond, None)
731+
);
732+
assert_eq!(
733+
fmt_sql(&conditional).to_string(),
734+
fmt_sql(planned.as_ref()).to_string()
735+
);
736+
let result = conditional.evaluate(&batch)?.into_array(batch.num_rows())?;
737+
let expected =
738+
TimestampMicrosecondArray::from(vec![None, Some(0), Some(-28_800_000_000)]);
739+
assert_eq!(result.as_ref(), &expected);
740+
741+
// The same computed timezone must fail when its timestamp is required.
742+
let non_null_batch = RecordBatch::try_new(
743+
batch.schema(),
744+
vec![
745+
Arc::new(StringArray::from(vec!["1970-01-01T00:00:00"; 3])),
746+
Arc::clone(batch.column(1)),
747+
],
748+
)?;
749+
let error = conditional.evaluate(&non_null_batch).unwrap_err();
750+
assert!(error.to_string().contains("invalid"));
751+
Ok(())
752+
}
753+
630754
#[test]
631755
fn timezone_column_ignores_values_on_null_timestamp_rows() {
632756
let summer = 1_719_792_000_123_456;

0 commit comments

Comments
 (0)