viirya commented on code in PR #25668:
URL: https://github.com/apache/datafusion/pull/25668#discussion_r4147165788
##########
datafusion/physical-expr/src/scalar_function.rs:
##########
@@ -377,18 +386,234 @@ mod tests {
}
fn return_type(&self, _arg_types: &[DataType]) -> Result<DataType> {
- Ok(DataType::Int32)
+ Ok(self.return_type.clone())
}
fn invoke_with_args(&self, _args: ScalarFunctionArgs) ->
Result<ColumnarValue> {
- Ok(ColumnarValue::Scalar(ScalarValue::Int32(Some(42))))
+ Ok(ColumnarValue::Scalar(ScalarValue::try_from(
+ &self.return_type,
+ )?))
+ }
+ }
+
+ fn default_bounds_expr(return_type: DataType) -> ScalarFunctionExpr {
+ ScalarFunctionExpr::try_new(
+ Arc::new(ScalarUDF::from(MockScalarUDF {
+ signature: Signature::exact(vec![], Volatility::Immutable),
+ return_type,
+ })),
+ vec![],
+ &Schema::empty(),
+ Arc::new(ConfigOptions::default()),
+ )
+ .unwrap()
+ }
+
+ #[test]
+ fn properties_recover_return_type_without_changing_bounds_evaluation() {
+ for data_type in [
+ DataType::Null,
+ DataType::Boolean,
+ DataType::Int32,
+ DataType::UInt64,
+ DataType::Float64,
+ DataType::Utf8,
+ DataType::Timestamp(TimeUnit::Second, None),
+ DataType::Timestamp(TimeUnit::Nanosecond,
Some("America/Goose_Bay".into())),
+ DataType::Time32(TimeUnit::Millisecond),
+ DataType::Time64(TimeUnit::Microsecond),
+ DataType::List(Arc::new(Field::new("item", DataType::Int32,
true))),
+ ] {
+ let expr = default_bounds_expr(data_type.clone());
+ let properties = expr.get_properties(&[]).unwrap();
+ assert_eq!(
+ properties.range,
+ Interval::make_unbounded(&data_type).unwrap()
+ );
+ assert_eq!(properties.sort_properties, SortProperties::Unordered);
+ assert!(!properties.preserves_lex_ordering);
+ assert!(!properties.strictly_order_preserving);
+ // The constraint solver calls this method directly, bypassing the
fallback.
Review Comment:
Same wording nit as in `udf.rs`: the cp_solver doesn't accept
`ScalarFunctionExpr`, so something like "Direct `evaluate_bounds` calls bypass
the fallback" would be more precise.
##########
datafusion/sqllogictest/test_files/datetime/arith_time_interval.slt:
##########
@@ -158,3 +158,58 @@ NULL
statement ok
DROP TABLE time_vals
+
+# Time arithmetic can wrap even when its input is an ordered function result.
Review Comment:
I don't think these two queries can catch a regression in the fallback.
SQL-planned `BinaryExpr`s have `fail_on_overflow = false`, and a function
output's range is unbounded, so `arithmetic_sort_properties` returns
`Unordered` through the overflow rule whether the left range is `Time64` or
`Null`. The sort stays either way, including with the fallback line removed.
The `wraps_in_domain` check, which is the part that actually depends on the
recovered type, is only reached with `with_fail_on_overflow(true)`. Could you
add a unit test that builds `date_trunc('hour', t) + INTERVAL '2 hours'` as a
`BinaryExpr` with `with_fail_on_overflow(true)` over an ordered `Time64` child
and asserts `Unordered`? The SLTs are still fine to keep as end-to-end result
checks.
##########
datafusion/physical-expr/src/scalar_function.rs:
##########
@@ -377,18 +386,234 @@ mod tests {
}
fn return_type(&self, _arg_types: &[DataType]) -> Result<DataType> {
- Ok(DataType::Int32)
+ Ok(self.return_type.clone())
}
fn invoke_with_args(&self, _args: ScalarFunctionArgs) ->
Result<ColumnarValue> {
- Ok(ColumnarValue::Scalar(ScalarValue::Int32(Some(42))))
+ Ok(ColumnarValue::Scalar(ScalarValue::try_from(
+ &self.return_type,
+ )?))
+ }
+ }
+
+ fn default_bounds_expr(return_type: DataType) -> ScalarFunctionExpr {
+ ScalarFunctionExpr::try_new(
+ Arc::new(ScalarUDF::from(MockScalarUDF {
+ signature: Signature::exact(vec![], Volatility::Immutable),
+ return_type,
+ })),
+ vec![],
+ &Schema::empty(),
+ Arc::new(ConfigOptions::default()),
+ )
+ .unwrap()
+ }
+
+ #[test]
+ fn properties_recover_return_type_without_changing_bounds_evaluation() {
+ for data_type in [
+ DataType::Null,
+ DataType::Boolean,
+ DataType::Int32,
+ DataType::UInt64,
+ DataType::Float64,
+ DataType::Utf8,
+ DataType::Timestamp(TimeUnit::Second, None),
+ DataType::Timestamp(TimeUnit::Nanosecond,
Some("America/Goose_Bay".into())),
+ DataType::Time32(TimeUnit::Millisecond),
+ DataType::Time64(TimeUnit::Microsecond),
+ DataType::List(Arc::new(Field::new("item", DataType::Int32,
true))),
+ ] {
+ let expr = default_bounds_expr(data_type.clone());
+ let properties = expr.get_properties(&[]).unwrap();
+ assert_eq!(
+ properties.range,
+ Interval::make_unbounded(&data_type).unwrap()
+ );
+ assert_eq!(properties.sort_properties, SortProperties::Unordered);
+ assert!(!properties.preserves_lex_ordering);
+ assert!(!properties.strictly_order_preserving);
+ // The constraint solver calls this method directly, bypassing the
fallback.
+ assert_eq!(
+ expr.evaluate_bounds(&[]).unwrap(),
+ Interval::make_unbounded(&DataType::Null).unwrap()
+ );
+ }
+ }
+
+ #[test]
+ fn properties_keep_unknown_bounds_for_unsupported_return_type() {
+ // This unit is not supported for Time32 by ScalarValue/Interval.
+ let data_type = DataType::Time32(TimeUnit::Nanosecond);
+ assert!(Interval::make_unbounded(&data_type).is_err());
+ let properties =
default_bounds_expr(data_type).get_properties(&[]).unwrap();
+ assert_eq!(
+ properties.range,
+ Interval::make_unbounded(&DataType::Null).unwrap()
+ );
+ }
+
+ #[test]
+ fn datetime_properties_use_resolved_return_types() {
+ use datafusion_functions::datetime::date_bin::DateBinFunc;
+ use datafusion_functions::datetime::from_unixtime::FromUnixtimeFunc;
+
+ for source_type in [
+ DataType::Timestamp(TimeUnit::Second, None),
+ DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
+ DataType::Time32(TimeUnit::Millisecond),
+ DataType::Time64(TimeUnit::Microsecond),
+ ] {
+ let expr = ScalarFunctionExpr::try_new(
+ Arc::new(ScalarUDF::from(DateBinFunc::new())),
+ vec![
+ Arc::new(Literal::new(ScalarValue::new_interval_mdn(
+ 0,
+ 0,
+ 60_000_000_000,
+ ))),
+ Arc::new(Column::new("ts", 0)),
+ ],
+ &Schema::new(vec![Field::new("ts", source_type.clone(),
true)]),
+ Arc::new(ConfigOptions::default()),
+ )
+ .unwrap();
+ let properties = expr
+ .get_properties(&[
+
ExprProperties::new_unknown().with_order(SortProperties::Singleton),
+ ExprProperties::new_unknown()
+
.with_range(Interval::make_unbounded(&source_type).unwrap()),
+ ])
+ .unwrap();
+ assert_eq!(
+ properties.range,
+ Interval::make_unbounded(&source_type).unwrap()
+ );
+ }
+
+ for (session_tz, explicit_tz, expected_tz) in [
+ (None, None, None),
+ (Some("America/Denver"), None, Some("America/Denver")),
+ (
+ Some("America/Denver"),
+ Some("America/Goose_Bay"),
+ Some("America/Goose_Bay"),
+ ),
+ ] {
+ let mut config = ConfigOptions::default();
+ config.execution.time_zone = session_tz.map(str::to_owned);
+ let udf = FromUnixtimeFunc::new_with_config(&config);
+ let mut args: Vec<Arc<dyn PhysicalExpr>> =
+ vec![Arc::new(Column::new("c", 0))];
+ if let Some(tz) = explicit_tz {
+ args.push(Arc::new(Literal::new(ScalarValue::from(tz))));
+ }
+ let children = vec![ExprProperties::new_unknown(); args.len()];
+ let expr = ScalarFunctionExpr::try_new(
+ Arc::new(ScalarUDF::from(udf)),
+ args,
+ &Schema::new(vec![Field::new("c", DataType::Int64, true)]),
+ Arc::new(config),
+ )
+ .unwrap();
+ // The literal's timezone was resolved during construction, even
+ // though the child intervals here contain no value information.
+ let expected =
+ DataType::Timestamp(TimeUnit::Second,
expected_tz.map(Into::into));
+ assert_eq!(
+ expr.get_properties(&children).unwrap().range,
+ Interval::make_unbounded(&expected).unwrap()
+ );
+ }
+ }
+
+ #[derive(Debug, PartialEq, Eq, Hash)]
+ struct BoundsUDF {
+ inner: MockScalarUDF,
+ fail: bool,
+ }
+
+ impl ScalarUDFImpl for BoundsUDF {
+ fn name(&self) -> &str {
+ "bounds_function"
+ }
+
+ fn signature(&self) -> &Signature {
+ self.inner.signature()
+ }
+
+ fn return_type(&self, arg_types: &[DataType]) -> Result<DataType> {
+ self.inner.return_type(arg_types)
+ }
+
+ fn invoke_with_args(&self, args: ScalarFunctionArgs) ->
Result<ColumnarValue> {
+ self.inner.invoke_with_args(args)
+ }
+
+ fn evaluate_bounds(&self, inputs: &[&Interval]) -> Result<Interval> {
+ if self.fail {
+ return internal_err!("bounds evaluation failed");
+ }
+ Ok(inputs[0].clone())
+ }
+ }
+
+ #[test]
+ fn properties_preserve_udf_bounds_and_errors() {
+ for fail in [false, true] {
Review Comment:
nit: the `fail` / non-`fail` branches share little besides setup; two
separate tests (bounds preserved, error propagated) would read more easily than
the loop plus `if fail`.
##########
datafusion/expr/src/udf.rs:
##########
@@ -908,15 +908,15 @@ pub trait ScalarUDFImpl: Debug + DynEq + DynHash + Send +
Sync + Any {
/// # Output type
///
/// The default implementation returns an unbounded [`DataType::Null`]
- /// interval because the output type cannot be inferred generically.
- /// Implementations should override this method when they can determine the
- /// output type, even if they cannot compute precise bounds. Returning an
- /// unbounded interval with the correct type preserves information used by
- /// downstream physical property analysis.
- ///
- /// If the output type depends on argument values, only report it when
those
- /// values are known, such as from singleton input intervals. Otherwise,
- /// keep the output type unknown.
+ /// interval because the output type cannot be inferred from input
intervals
+ /// alone. During physical property analysis, `ScalarFunctionExpr` uses its
+ /// resolved return type to replace this default with a typed unbounded
+ /// interval when supported. UDFs need not override this method solely to
+ /// preserve their output type for that analysis.
+ ///
+ /// Override this method to provide more precise bounds. Explicit bounds
+ /// are preserved. The fallback does not apply to direct bounds evaluation
Review Comment:
Two small wording suggestions:
- "direct bounds evaluation by the constraint solver": `check_support` in
`physical-expr/src/intervals/utils.rs` doesn't accept `ScalarFunctionExpr`, so
the cp_solver never reaches this path. Maybe "direct calls to
`PhysicalExpr::evaluate_bounds`" is more accurate.
- "Explicit bounds are preserved": it may help to spell out the exact rule,
i.e. *any* unbounded `Null` result is replaced (including one an override
returns on purpose), and any other interval is used as-is, even if its type
differs from the resolved return type.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]