diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/AbstractNameMappingAdapter.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/AbstractNameMappingAdapter.java new file mode 100644 index 0000000000000..b093b434e2ce3 --- /dev/null +++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/AbstractNameMappingAdapter.java @@ -0,0 +1,96 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.analytics.spi; + +import org.apache.calcite.plan.RelOptCluster; +import org.apache.calcite.rex.RexBuilder; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.SqlOperator; +import org.apache.calcite.sql.type.SqlTypeName; + +import java.util.ArrayList; +import java.util.List; + +/** + * Reusable base for {@link ScalarFunctionAdapter}s that rewrite a Calcite call + * to a different named target, optionally prepending or appending literal + * operands. Pure shape rewriting — no decomposition into a different semantic + * function. For that use case (e.g. {@code ILIKE → LIKE(LOWER(a), LOWER(b))}) + * write a dedicated adapter instead. + * + *

Example use: + *

+ *   class YearAdapter extends AbstractNameMappingAdapter {
+ *       YearAdapter() {
+ *           super(SqlLibraryOperators.DATE_PART, List.of("year"), List.of());
+ *       }
+ *   }
+ * 
+ * rewrites {@code YEAR(ts)} to {@code date_part('year', ts)}. Paired with the + * {@code date_part} signature in a backend's extension catalog so the isthmus + * visitor resolves it against the backend's native date_part. + * + * @opensearch.internal + */ +public abstract class AbstractNameMappingAdapter implements ScalarFunctionAdapter { + + private final SqlOperator targetOperator; + private final List prependLiterals; + private final List appendLiterals; + + /** + * @param targetOperator the Calcite {@link SqlOperator} the rewritten call + * will use. The isthmus visitor resolves this to a + * Substrait invocation against the backend's loaded + * extension catalog. + * @param prependLiterals literals to prepend to the operand list (e.g. + * {@code List.of("year")} to prepend a string literal). + * Currently supports {@link String}, {@link Integer}, + * {@link Long}, {@link Double}, {@link Boolean}. + * @param appendLiterals literals to append to the operand list. + */ + protected AbstractNameMappingAdapter(SqlOperator targetOperator, List prependLiterals, List appendLiterals) { + this.targetOperator = targetOperator; + this.prependLiterals = List.copyOf(prependLiterals); + this.appendLiterals = List.copyOf(appendLiterals); + } + + @Override + public RexNode adapt(RexCall original, List fieldStorage, RelOptCluster cluster) { + RexBuilder rexBuilder = cluster.getRexBuilder(); + List operands = new ArrayList<>(original.getOperands().size() + prependLiterals.size() + appendLiterals.size()); + for (Object literal : prependLiterals) { + operands.add(rexBuilder.makeLiteral(literal, inferLiteralType(rexBuilder, literal), true)); + } + operands.addAll(original.getOperands()); + for (Object literal : appendLiterals) { + operands.add(rexBuilder.makeLiteral(literal, inferLiteralType(rexBuilder, literal), true)); + } + // Preserve the original call's return type. The enclosing operator (Project + // / Filter) caches its rowType from the pre-adaptation expression; if the + // rewritten call's Calcite-inferred type differs (e.g. PPL YEAR returns + // INTEGER but SqlLibraryOperators.DATE_PART is SqlExtractFunction → BIGINT), + // the downstream stripAnnotations path feeds the adapted expr into + // LogicalProject.create together with the cached rowType, and + // Project.isValid's compatibleTypes check throws an AssertionError that + // breaks fragment conversion. + return rexBuilder.makeCall(original.getType(), targetOperator, operands); + } + + private static org.apache.calcite.rel.type.RelDataType inferLiteralType(RexBuilder rexBuilder, Object literal) { + var typeFactory = rexBuilder.getTypeFactory(); + if (literal instanceof String) return typeFactory.createSqlType(SqlTypeName.VARCHAR); + if (literal instanceof Integer) return typeFactory.createSqlType(SqlTypeName.INTEGER); + if (literal instanceof Long) return typeFactory.createSqlType(SqlTypeName.BIGINT); + if (literal instanceof Double) return typeFactory.createSqlType(SqlTypeName.DOUBLE); + if (literal instanceof Boolean) return typeFactory.createSqlType(SqlTypeName.BOOLEAN); + throw new IllegalArgumentException("Unsupported literal type: " + literal.getClass()); + } +} diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/ScalarFunction.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/ScalarFunction.java index c8dd9b085961e..13cbc837a8056 100644 --- a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/ScalarFunction.java +++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/ScalarFunction.java @@ -77,7 +77,10 @@ public enum ScalarFunction { EXTRACT(Category.SCALAR, SqlKind.EXTRACT), // ── Datetime ──────────────────────────────────────────────────── - TIMESTAMP(Category.SCALAR, SqlKind.OTHER_FUNCTION); + TIMESTAMP(Category.SCALAR, SqlKind.OTHER_FUNCTION), + YEAR(Category.SCALAR, SqlKind.OTHER_FUNCTION), + CONVERT_TZ(Category.SCALAR, SqlKind.OTHER_FUNCTION), + UNIX_TIMESTAMP(Category.SCALAR, SqlKind.OTHER_FUNCTION); /** * Category of scalar function. @@ -87,7 +90,9 @@ public enum Category { FULL_TEXT, STRING, MATH, - /** Catch-all for functions that don't fit other categories (CAST, CASE, COALESCE, EXTRACT, etc.). */ + /** + * Catch-all for functions that don't fit other categories (CAST, CASE, COALESCE, EXTRACT, etc.). + */ SCALAR } @@ -121,7 +126,9 @@ public static ScalarFunction fromSqlKind(SqlKind kind) { return null; } - /** Maps a Calcite SqlFunction to a ScalarFunction by name, or throws if not recognized. */ + /** + * Maps a Calcite SqlFunction to a ScalarFunction by name, or null if not recognized. + */ public static ScalarFunction fromSqlFunction(SqlFunction function) { // TODO: Add an explicit functionName field per enum constant instead of relying on // valueOf(toUpperCase). This couples enum constant naming to SQL function naming convention. diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/Cargo.toml b/sandbox/plugins/analytics-backend-datafusion/rust/Cargo.toml index 1c5e72ea4e2ed..2a7a41a656557 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/Cargo.toml +++ b/sandbox/plugins/analytics-backend-datafusion/rust/Cargo.toml @@ -43,6 +43,9 @@ chrono = { workspace = true } roaring = "0.10" thiserror = { workspace = true } +# convert_tz UDF +chrono-tz = "0.10" + [dev-dependencies] criterion = { workspace = true } tempfile = { workspace = true } diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/api.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/api.rs index 815232ff4749d..4197f6355719d 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/api.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/api.rs @@ -443,6 +443,7 @@ pub unsafe fn sql_to_substrait( .with_default_features() .build(); let ctx = datafusion::prelude::SessionContext::new_with_state(state); + crate::udf::register_all(&ctx); let listing_options = ListingOptions::new(Arc::new(ParquetFormat::new())) .with_file_extension(".parquet") diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_executor.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_executor.rs index 12ce9342f5989..f22735cb06f0c 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_executor.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/indexed_executor.rs @@ -133,6 +133,7 @@ pub async fn execute_indexed_query( .build(); let ctx = SessionContext::new_with_state(state); ctx.register_udf(create_index_filter_udf()); + crate::udf::register_all(&ctx); // Resolve the object store for this shard's table URL (file://, s3://, // gs://, ... whatever the global runtime has registered). We pass this diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/lib.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/lib.rs index 5a8d8f36355ad..c883e8d5992d3 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/lib.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/lib.rs @@ -30,3 +30,4 @@ pub mod query_memory_pool_tracker; pub mod runtime_manager; pub mod session_context; pub mod statistics_cache; +pub mod udf; diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/local_executor.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/local_executor.rs index d62ec523b477f..90d267d71a771 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/local_executor.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/local_executor.rs @@ -70,9 +70,9 @@ impl LocalSession { .with_runtime_env(runtime_env) .with_default_features() .build(); - Self { - ctx: SessionContext::new_with_state(state), - } + let ctx = SessionContext::new_with_state(state); + crate::udf::register_all(&ctx); + Self { ctx } } /// Registers a streaming input on the session under `name` and returns the diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/query_executor.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/query_executor.rs index 8ba9c93b3caea..14c8d172add9a 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/query_executor.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/query_executor.rs @@ -100,6 +100,7 @@ pub async fn execute_query( .build(); let ctx = SessionContext::new_with_state(state); + crate::udf::register_all(&ctx); // Register table via ListingTable — all IO goes through object store let file_format = ParquetFormat::new(); diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/session_context.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/session_context.rs index 9de9caaa968a5..d10887be4c1a9 100644 --- a/sandbox/plugins/analytics-backend-datafusion/rust/src/session_context.rs +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/session_context.rs @@ -99,6 +99,7 @@ pub async unsafe fn create_session_context( .build(); let ctx = SessionContext::new_with_state(state); + crate::udf::register_all(&ctx); // Register default ListingTable for parquet scans let listing_options = ListingOptions::new(Arc::new(ParquetFormat::new())) diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/convert_tz.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/convert_tz.rs new file mode 100644 index 0000000000000..6ae7c4199640d --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/convert_tz.rs @@ -0,0 +1,519 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +//! `convert_tz(ts, from_tz, to_tz)` — shift a timestamp from one timezone to another. +//! +//! # Division of labor with the Java adapter +//! +//! Literal validation + canonicalization happens Java-side in +//! `ConvertTzAdapter` (see `.../be/datafusion/ConvertTzAdapter.java`), which +//! runs at plan time: +//! * bad literals (unknown IANA, malformed offset) surface as +//! `IllegalArgumentException` at plan time — users see the error instantly. +//! * literal tz operands arrive here already canonicalized (`+05:00`, not +//! `+5:00`; JDK-normalized IANA ids). +//! * identity cases (`from == to`) are short-circuited plan-side and never +//! reach this UDF. +//! +//! What stays here: +//! * **Per-row DST-correct shifting.** IANA offsets vary per instant; can't +//! be folded at plan time. +//! * **Column-valued tz operands.** Values aren't known until runtime; +//! unparseable entries yield NULL rows (matches MySQL's lenient +//! `CONVERT_TZ` behavior). +//! +//! Semantics (MySQL-compatible): +//! * `ts` is interpreted as a wall-clock time in `from_tz`. +//! * The return is the wall-clock time in `to_tz` for the same instant. +//! * Timezone strings may be IANA names (`'America/New_York'`) or ISO offsets +//! of the form `±HH:MM` with hours ∈ [0,14], minutes ∈ [0,59]. +//! * Any null input → null output (null propagation). +//! * Unparseable column-valued timezone → null output. + +use std::any::Any; +use std::sync::Arc; + +use chrono::{DateTime, NaiveDateTime, Offset, TimeZone, Utc}; +use chrono_tz::Tz; +use datafusion::arrow::array::{ + Array, ArrayRef, StringArray, TimestampMillisecondArray, TimestampMillisecondBuilder, +}; +use datafusion::arrow::datatypes::{DataType, TimeUnit}; +use datafusion::common::{plan_err, ScalarValue}; +use datafusion::error::{DataFusionError, Result}; +use datafusion::execution::context::SessionContext; +use datafusion::logical_expr::{ + ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, Volatility, +}; + +use super::{coerce_args, CoerceMode}; + +pub fn register_all(ctx: &SessionContext) { + ctx.register_udf(ScalarUDF::from(ConvertTzUdf::new())); +} + +#[derive(Debug, PartialEq, Eq, Hash)] +pub struct ConvertTzUdf { + signature: Signature, +} + +impl ConvertTzUdf { + pub fn new() -> Self { + // PPL emits `convert_tz(ts, from, to)` with ts typed as Utf8 (string + // literal), Date32, or Timestamp(any precision, any tz). Signature::exact + // only let through the Timestamp(Ms, None) variant; user_defined + + // coerce_types lets DF insert the right casts. + Self { + signature: Signature::user_defined(Volatility::Immutable), + } + } +} + +impl Default for ConvertTzUdf { + fn default() -> Self { + Self::new() + } +} + +impl ScalarUDFImpl for ConvertTzUdf { + fn as_any(&self) -> &dyn Any { + self + } + fn name(&self) -> &str { + "convert_tz" + } + fn signature(&self) -> &Signature { + &self.signature + } + + fn return_type(&self, arg_types: &[DataType]) -> Result { + if arg_types.len() != 3 { + return plan_err!("convert_tz expects 3 arguments, got {}", arg_types.len()); + } + Ok(DataType::Timestamp(TimeUnit::Millisecond, None)) + } + + fn coerce_types(&self, arg_types: &[DataType]) -> Result> { + coerce_args( + "convert_tz", + arg_types, + &[CoerceMode::TimestampMs, CoerceMode::Utf8, CoerceMode::Utf8], + ) + } + + fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result { + if args.args.len() != 3 { + return plan_err!("convert_tz expects 3 arguments, got {}", args.args.len()); + } + let n = args.number_rows; + + // Fast-path: scalar tz operands are parsed once up front, not per row. + // The Java adapter canonicalizes literal tz strings at plan time so + // bad-literal input can't reach this UDF — a None from parse_tz on a + // scalar therefore means the scalar was SQL NULL. + let from_scalar = scalar_tz(&args.args[1]); + let to_scalar = scalar_tz(&args.args[2]); + + let ts = args.args[0].clone().into_array(n)?; + let ts = ts + .as_any() + .downcast_ref::() + .ok_or_else(|| { + DataFusionError::Internal(format!( + "convert_tz: expected TimestampMillisecond, got {:?}", + ts.data_type() + )) + })?; + + // Only materialize column-valued tz operands; for scalars the parsed + // TzSpec is already in hand. Keep the ArrayRef alive alongside the + // downcast reference — StringArray borrows from the underlying buffer. + let from_arr_ref: Option = if from_scalar.is_none() && matches!(&args.args[1], ColumnarValue::Array(_)) { + Some(materialize_string_array(&args.args[1], n, "from_tz")?) + } else { + None + }; + let to_arr_ref: Option = if to_scalar.is_none() && matches!(&args.args[2], ColumnarValue::Array(_)) { + Some(materialize_string_array(&args.args[2], n, "to_tz")?) + } else { + None + }; + let from_array: Option<&StringArray> = from_arr_ref.as_ref().and_then(|a| a.as_any().downcast_ref::()); + let to_array: Option<&StringArray> = to_arr_ref.as_ref().and_then(|a| a.as_any().downcast_ref::()); + + let mut builder = TimestampMillisecondBuilder::with_capacity(n); + for i in 0..n { + if ts.is_null(i) { + builder.append_null(); + continue; + } + let from = match (&from_scalar, from_array) { + (Some(tz), _) => tz.clone(), + (None, Some(arr)) if !arr.is_null(i) => match parse_tz(arr.value(i)) { + Some(tz) => tz, + None => { + builder.append_null(); + continue; + } + }, + _ => { + builder.append_null(); + continue; + } + }; + let to = match (&to_scalar, to_array) { + (Some(tz), _) => tz.clone(), + (None, Some(arr)) if !arr.is_null(i) => match parse_tz(arr.value(i)) { + Some(tz) => tz, + None => { + builder.append_null(); + continue; + } + }, + _ => { + builder.append_null(); + continue; + } + }; + match shift_millis_parsed(ts.value(i), &from, &to) { + Some(v) => builder.append_value(v), + None => builder.append_null(), + } + } + Ok(ColumnarValue::Array(Arc::new(builder.finish()) as ArrayRef)) + } +} + +/// If `cv` is a non-NULL string scalar, parse it once. Returns None for NULL, +/// non-scalar, or an unparseable string (the latter unreachable from literal +/// paths — Java canonicalizes — but defensive for degenerate scalar inputs). +fn scalar_tz(cv: &ColumnarValue) -> Option { + if let ColumnarValue::Scalar(sv) = cv { + let s = match sv { + ScalarValue::Utf8(opt) | ScalarValue::LargeUtf8(opt) | ScalarValue::Utf8View(opt) => opt.as_deref(), + _ => None, + }; + return s.and_then(parse_tz); + } + None +} + +fn materialize_string_array(cv: &ColumnarValue, n: usize, label: &'static str) -> Result { + let arr = cv.clone().into_array(n)?; + if arr.as_any().downcast_ref::().is_none() { + return Err(DataFusionError::Internal(format!( + "convert_tz: {} expected Utf8, got {:?}", + label, + arr.data_type() + ))); + } + Ok(arr) +} + +/// Parse timezone string (IANA name or `±HH:MM` offset). +#[derive(Clone)] +enum TzSpec { + Iana(Tz), + /// Fixed offset in seconds east of UTC. + Offset(i32), +} + +fn parse_tz(s: &str) -> Option { + if let Some(off) = parse_offset_seconds(s) { + return Some(TzSpec::Offset(off)); + } + s.parse::().ok().map(TzSpec::Iana) +} + +/// Parse `±HH:MM` → seconds east of UTC; None if not an offset literal. +/// +/// Bounds ({@code hours ∈ [0,14], minutes ∈ [0,59]}) match the Java adapter's +/// {@code canonicalizeTz}. For literal-path inputs the Java side has already +/// validated and canonicalized, so the defensive checks here only fire for +/// column-valued tz — where a malformed entry yields a NULL row, matching the +/// documented lenient behavior. +fn parse_offset_seconds(s: &str) -> Option { + let bytes = s.as_bytes(); + if bytes.len() != 6 { + return None; + } + let sign = match bytes[0] { + b'+' => 1, + b'-' => -1, + _ => return None, + }; + if bytes[3] != b':' { + return None; + } + let hours: i32 = s.get(1..3)?.parse().ok()?; + let minutes: i32 = s.get(4..6)?.parse().ok()?; + if hours > 14 || minutes > 59 { + return None; + } + Some(sign * (hours * 3600 + minutes * 60)) +} + +/// Shift `ts_millis` from `from` to `to` using pre-parsed [`TzSpec`]s. The +/// stored timestamp has no tz attached — interpret its wall clock in `from`, +/// render that instant in `to`, then return the shifted millis as a tz-free +/// value the caller can continue to treat as naive. The shift is exactly +/// `to_offset(ts) - from_offset(ts)` milliseconds. +fn shift_millis_parsed(ts_millis: i64, from: &TzSpec, to: &TzSpec) -> Option { + let naive = DateTime::::from_timestamp_millis(ts_millis)?.naive_utc(); + let from_off = offset_seconds_at(from, &naive)?; + let to_off = offset_seconds_at_instant(to, ts_millis, from_off)?; + let delta_millis = (to_off - from_off) as i64 * 1_000; + ts_millis.checked_add(delta_millis) +} + +/// String-operand wrapper retained for direct-invocation tests that exercise +/// the full parse + shift flow in one call. +#[cfg(test)] +fn shift_millis(ts_millis: i64, from_tz: &str, to_tz: &str) -> Option { + let from = parse_tz(from_tz)?; + let to = parse_tz(to_tz)?; + shift_millis_parsed(ts_millis, &from, &to) +} + +/// Offset (seconds east of UTC) for `from_tz` at wall-clock `naive`. +fn offset_seconds_at(tz: &TzSpec, naive: &NaiveDateTime) -> Option { + match tz { + TzSpec::Offset(o) => Some(*o), + TzSpec::Iana(z) => { + // Use .from_local_datetime → pick the earliest resolution for ambiguous + // (DST-fall-back) wall times, which matches MySQL's behaviour. + match z.from_local_datetime(naive) { + chrono::LocalResult::Single(dt) => Some(dt.offset().fix().local_minus_utc()), + chrono::LocalResult::Ambiguous(dt, _) => Some(dt.offset().fix().local_minus_utc()), + chrono::LocalResult::None => None, // wall time in the DST "spring-forward" gap + } + } + } +} + +/// Offset (seconds east of UTC) for `to_tz` at the UTC *instant* represented by +/// the input. We reconstruct the instant from `ts_millis` + `from_offset` (since +/// `ts_millis` is a wall clock in from_tz), then look up to_tz's offset at that +/// instant — DST-correct even across transitions. +fn offset_seconds_at_instant( + tz: &TzSpec, + ts_millis: i64, + from_offset_seconds: i32, +) -> Option { + match tz { + TzSpec::Offset(o) => Some(*o), + TzSpec::Iana(z) => { + // instant_utc_millis = wall_millis - from_offset_millis + let instant_millis = + ts_millis.checked_sub((from_offset_seconds as i64) * 1_000)?; + let instant = DateTime::::from_timestamp_millis(instant_millis)?; + Some(z.offset_from_utc_datetime(&instant.naive_utc()).fix().local_minus_utc()) + } + } +} + +// ─── tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ±HH:MM offsets parse to the expected second counts. + #[test] + fn parse_offset_accepts_positive_and_negative() { + assert_eq!(parse_offset_seconds("+00:00"), Some(0)); + assert_eq!(parse_offset_seconds("+05:30"), Some(5 * 3600 + 30 * 60)); + assert_eq!(parse_offset_seconds("-08:00"), Some(-8 * 3600)); + assert_eq!(parse_offset_seconds("+14:00"), Some(14 * 3600)); + } + + #[test] + fn parse_offset_rejects_malformed() { + assert_eq!(parse_offset_seconds("bogus"), None); + assert_eq!(parse_offset_seconds("0500"), None); + // Hour >14 is beyond canonicalization bounds — Java rejects at plan time, + // we reject at runtime for column-valued paths. + assert_eq!(parse_offset_seconds("+15:00"), None); + assert_eq!(parse_offset_seconds("+05:60"), None); + } + + // Offset → offset: simple wall-clock delta, no calendar. + #[test] + fn fixed_offset_to_fixed_offset_shifts_by_delta() { + // 2024-01-05T12:00:00 in +00:00 → same wall clock in +05:30 means + // +5h30m = +19_800_000 ms added. + let ts = 1_704_456_000_000; // 2024-01-05T12:00:00Z (stored naive) + let out = shift_millis(ts, "+00:00", "+05:30").unwrap(); + assert_eq!(out - ts, 5 * 3600 * 1000 + 30 * 60 * 1000); + } + + // IANA ↔ IANA: DST-correct jump across a transition. + #[test] + fn iana_new_york_to_london_applies_correct_offset() { + // 2024-01-05T12:00:00 wall-clock in America/New_York (UTC-5 in winter) + // → 17:00 UTC → London (UTC+0 in winter) = 17:00 local. Delta = +5h. + let ts = 1_704_456_000_000; // treat as 2024-01-05T12:00:00 naive + let out = shift_millis(ts, "America/New_York", "Europe/London").unwrap(); + assert_eq!((out - ts) / 1000, 5 * 3600); + } + + #[test] + fn iana_dst_summer_offset_differs_from_winter() { + // Summer: NY is UTC-4, winter: NY is UTC-5. Pull data at both dates, + // confirm the two shifts to UTC (London+0 in winter, +1 in summer) produce + // the expected distinct deltas. + // 2024-01-05T12:00:00 (winter): NY→London → +5h. + let winter_ts = 1_704_456_000_000; + let winter_out = shift_millis(winter_ts, "America/New_York", "Europe/London").unwrap(); + assert_eq!((winter_out - winter_ts) / 1000, 5 * 3600); + // 2024-07-05T12:00:00 (summer): NY (UTC-4) → London (UTC+1) → +5h. + // Same delta because both shift to/from their summer offsets in lockstep. + let summer_ts = 1_720_180_800_000; // 2024-07-05T12:00:00Z naive + let summer_out = shift_millis(summer_ts, "America/New_York", "Europe/London").unwrap(); + assert_eq!((summer_out - summer_ts) / 1000, 5 * 3600); + } + + // When from_tz crosses DST boundary but to_tz doesn't, the delta changes. + #[test] + fn iana_to_utc_crosses_dst_in_source_tz() { + // 2024-01-05 in UTC (no DST there): NY winter = UTC-5, shift = +5h. + let winter_ts = 1_704_456_000_000; + let winter_out = shift_millis(winter_ts, "America/New_York", "UTC").unwrap(); + assert_eq!((winter_out - winter_ts) / 1000, 5 * 3600); + + // 2024-07-05: NY summer = UTC-4, shift = +4h. + let summer_ts = 1_720_180_800_000; + let summer_out = shift_millis(summer_ts, "America/New_York", "UTC").unwrap(); + assert_eq!((summer_out - summer_ts) / 1000, 4 * 3600); + } + + #[test] + fn unknown_tz_returns_none() { + assert_eq!(shift_millis(0, "Not/AZone", "UTC"), None); + assert_eq!(shift_millis(0, "UTC", "Not/AZone"), None); + } + + // Coercion: PPL may emit the ts arg as Utf8 (string literal), Date32, + // or Timestamp with a different precision/tz. coerce_types should + // normalize them all to Timestamp(Millisecond, None) + Utf8 + Utf8. + #[test] + fn coerce_types_accepts_utf8_ts() { + let udf = ConvertTzUdf::new(); + let out = udf + .coerce_types(&[DataType::Utf8, DataType::Utf8, DataType::Utf8]) + .unwrap(); + assert_eq!( + out, + vec![ + DataType::Timestamp(TimeUnit::Millisecond, None), + DataType::Utf8, + DataType::Utf8, + ] + ); + } + + #[test] + fn coerce_types_accepts_date32_ts() { + let udf = ConvertTzUdf::new(); + let out = udf + .coerce_types(&[DataType::Date32, DataType::Utf8, DataType::Utf8]) + .unwrap(); + assert_eq!(out[0], DataType::Timestamp(TimeUnit::Millisecond, None)); + } + + #[test] + fn coerce_types_accepts_other_ts_precisions() { + let udf = ConvertTzUdf::new(); + // Nanosecond with tz → should coerce down to Millisecond, None. + let out = udf + .coerce_types(&[ + DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())), + DataType::Utf8, + DataType::Utf8, + ]) + .unwrap(); + assert_eq!(out[0], DataType::Timestamp(TimeUnit::Millisecond, None)); + } + + #[test] + fn coerce_types_passes_through_exact_match() { + let udf = ConvertTzUdf::new(); + let ts = DataType::Timestamp(TimeUnit::Millisecond, None); + let out = udf + .coerce_types(&[ts.clone(), DataType::Utf8, DataType::Utf8]) + .unwrap(); + assert_eq!(out, vec![ts, DataType::Utf8, DataType::Utf8]); + } + + #[test] + fn coerce_types_rejects_unsupported_ts_type() { + let udf = ConvertTzUdf::new(); + // A boolean in the ts slot is clearly wrong — must error explicitly. + let err = udf + .coerce_types(&[DataType::Boolean, DataType::Utf8, DataType::Utf8]) + .unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("convert_tz") && msg.contains("Boolean"), + "unexpected error: {msg}" + ); + } + + #[test] + fn coerce_types_rejects_wrong_arity() { + let udf = ConvertTzUdf::new(); + assert!(udf.coerce_types(&[DataType::Utf8]).is_err()); + assert!(udf + .coerce_types(&[DataType::Utf8, DataType::Utf8, DataType::Utf8, DataType::Utf8]) + .is_err()); + } + + // Batch / null handling through the full UDF. + #[test] + fn invoke_nulls_and_bad_tz_propagate() { + let udf = ConvertTzUdf::new(); + let ts = TimestampMillisecondArray::from(vec![ + Some(1_704_456_000_000), + None, + Some(0), + ]); + let from = StringArray::from(vec![ + Some("+00:00"), + Some("UTC"), + Some("Mars/Olympus"), // unknown column-valued entry → null + ]); + let to = StringArray::from(vec![Some("+05:30"), Some("UTC"), Some("UTC")]); + let args = ScalarFunctionArgs { + args: vec![ + ColumnarValue::Array(Arc::new(ts)), + ColumnarValue::Array(Arc::new(from)), + ColumnarValue::Array(Arc::new(to)), + ], + number_rows: 3, + arg_fields: vec![], + return_field: Arc::new(datafusion::arrow::datatypes::Field::new( + "out", + DataType::Timestamp(TimeUnit::Millisecond, None), + true, + )), + config_options: Arc::new(datafusion::config::ConfigOptions::new()), + }; + let out = udf.invoke_with_args(args).unwrap(); + let arr = match out { + ColumnarValue::Array(a) => a, + _ => panic!("expected array"), + }; + let arr = arr + .as_any() + .downcast_ref::() + .unwrap(); + assert!(!arr.is_null(0)); + assert!(arr.is_null(1)); + assert!(arr.is_null(2)); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/mod.rs b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/mod.rs new file mode 100644 index 0000000000000..3d13de51967b6 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/rust/src/udf/mod.rs @@ -0,0 +1,272 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +//! OpenSearch scalar UDFs that aren't in DataFusion's built-in registry. Each +//! must have a matching YAML entry in `extensions/opensearch_scalar.yaml` so +//! the substrait converter on the Java side can route to it by name. +//! +//! Functions registered here: +//! - `convert_tz(ts, from_tz, to_tz)` — DST-aware timezone shift (chrono-tz) + +use datafusion::arrow::datatypes::{DataType, TimeUnit}; +use datafusion::common::plan_err; +use datafusion::error::Result; +use datafusion::execution::context::SessionContext; + +/// Categories of input type a UDF slot can accept. Each mode declares a +/// canonical target arrow type plus the set of sources that coerce to it. +/// UDFs use `Signature::user_defined()` and call [`coerce_slot`] per +/// argument position to produce the `coerce_types` output. +/// +/// Invalid sources produce an explicit `plan_err!` — no silent fallback. The +/// failure message names the UDF, the slot index, the observed type and the +/// expected canonical type so planning errors are actionable. +#[derive(Clone, Copy, Debug)] +#[allow(dead_code)] +pub(crate) enum CoerceMode { + /// Accept Utf8 / Date32 / Timestamp(any precision, any tz) → canonicalize + /// to Timestamp(Millisecond, None). DF has built-in casts for each source. + TimestampMs, + /// Accept Utf8 / Date32 / Timestamp(any, any) → canonicalize to Date32. + Date32, + /// Accept any integer or float → Int64. + Int64, + /// Accept any integer or float → Float64. + Float64, + /// Accept Utf8 / LargeUtf8 / Utf8View → Utf8. + Utf8, +} + +/// Coerce a single argument slot. Returns the canonical target type for this +/// slot when the input is compatible, or a planning error otherwise. +pub(crate) fn coerce_slot( + udf_name: &str, + slot_index: usize, + observed: &DataType, + mode: CoerceMode, +) -> Result { + use DataType::*; + match mode { + CoerceMode::TimestampMs => match observed { + Timestamp(_, _) | Date32 | Date64 | Utf8 | LargeUtf8 | Utf8View => { + Ok(Timestamp(TimeUnit::Millisecond, None)) + } + other => plan_err!( + "{udf_name}: arg {slot_index} expected timestamp/date/string, got {other:?}" + ), + }, + CoerceMode::Date32 => match observed { + Date32 | Date64 | Timestamp(_, _) | Utf8 | LargeUtf8 | Utf8View => Ok(Date32), + other => plan_err!( + "{udf_name}: arg {slot_index} expected date/timestamp/string, got {other:?}" + ), + }, + CoerceMode::Int64 => match observed { + Int8 | Int16 | Int32 | Int64 | UInt8 | UInt16 | UInt32 | UInt64 | Float32 | Float64 => { + Ok(Int64) + } + other => plan_err!( + "{udf_name}: arg {slot_index} expected integer or float, got {other:?}" + ), + }, + CoerceMode::Float64 => match observed { + Int8 | Int16 | Int32 | Int64 | UInt8 | UInt16 | UInt32 | UInt64 | Float32 | Float64 => { + Ok(Float64) + } + other => plan_err!( + "{udf_name}: arg {slot_index} expected integer or float, got {other:?}" + ), + }, + CoerceMode::Utf8 => match observed { + Utf8 | LargeUtf8 | Utf8View => Ok(Utf8), + other => plan_err!( + "{udf_name}: arg {slot_index} expected string, got {other:?}" + ), + }, + } +} + +/// Coerce an entire argument vector against a fixed template. Enforces arity +/// and delegates per-slot coercion to [`coerce_slot`]. +pub(crate) fn coerce_args( + udf_name: &str, + observed: &[DataType], + template: &[CoerceMode], +) -> Result> { + if observed.len() != template.len() { + return plan_err!( + "{udf_name} expects {} arguments, got {}", + template.len(), + observed.len() + ); + } + template + .iter() + .enumerate() + .map(|(i, mode)| coerce_slot(udf_name, i, &observed[i], *mode)) + .collect() +} + +pub mod convert_tz; + +pub fn register_all(ctx: &SessionContext) { + convert_tz::register_all(ctx); + log::info!("OpenSearch UDF register_all: convert_tz registered"); +} + +#[cfg(test)] +mod tests { + //! Direct tests for the [`CoerceMode`] helper library. `convert_tz` exercises + //! `TimestampMs` and `Utf8` through its public `coerce_types`; these tests + //! cover every mode's accept + reject paths so future UDFs that pick up + //! `Date32`, `Int64`, or `Float64` inherit a proven helper rather than being + //! the first caller. + use super::{coerce_args, coerce_slot, CoerceMode}; + use datafusion::arrow::datatypes::{DataType, TimeUnit}; + + fn ts_ms() -> DataType { + DataType::Timestamp(TimeUnit::Millisecond, None) + } + + // ── TimestampMs ──────────────────────────────────────────────────────── + #[test] + fn timestampms_accepts_every_temporal_source() { + for observed in [ + DataType::Utf8, + DataType::LargeUtf8, + DataType::Utf8View, + DataType::Date32, + DataType::Date64, + DataType::Timestamp(TimeUnit::Second, None), + DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())), + ] { + let result = coerce_slot("t", 0, &observed, CoerceMode::TimestampMs).unwrap(); + assert_eq!(result, ts_ms(), "TimestampMs should canonicalize {observed:?}"); + } + } + + #[test] + fn timestampms_rejects_numeric() { + let err = coerce_slot("t", 0, &DataType::Int64, CoerceMode::TimestampMs).unwrap_err(); + assert!(err.to_string().contains("expected timestamp/date/string")); + } + + // ── Date32 ───────────────────────────────────────────────────────────── + #[test] + fn date32_accepts_date_and_string_sources() { + for observed in [ + DataType::Date32, + DataType::Date64, + DataType::Utf8, + DataType::LargeUtf8, + DataType::Utf8View, + DataType::Timestamp(TimeUnit::Millisecond, None), + ] { + let result = coerce_slot("d", 0, &observed, CoerceMode::Date32).unwrap(); + assert_eq!(result, DataType::Date32); + } + } + + #[test] + fn date32_rejects_numeric() { + let err = coerce_slot("d", 0, &DataType::Float64, CoerceMode::Date32).unwrap_err(); + assert!(err.to_string().contains("expected date/timestamp/string")); + } + + // ── Int64 ────────────────────────────────────────────────────────────── + #[test] + fn int64_accepts_every_number() { + for observed in [ + DataType::Int8, + DataType::Int16, + DataType::Int32, + DataType::Int64, + DataType::UInt8, + DataType::UInt16, + DataType::UInt32, + DataType::UInt64, + DataType::Float32, + DataType::Float64, + ] { + let result = coerce_slot("i", 0, &observed, CoerceMode::Int64).unwrap(); + assert_eq!(result, DataType::Int64); + } + } + + #[test] + fn int64_rejects_strings() { + let err = coerce_slot("i", 0, &DataType::Utf8, CoerceMode::Int64).unwrap_err(); + assert!(err.to_string().contains("expected integer or float")); + } + + // ── Float64 ──────────────────────────────────────────────────────────── + #[test] + fn float64_accepts_every_number() { + for observed in [ + DataType::Int32, + DataType::Int64, + DataType::UInt32, + DataType::Float32, + DataType::Float64, + ] { + let result = coerce_slot("f", 0, &observed, CoerceMode::Float64).unwrap(); + assert_eq!(result, DataType::Float64); + } + } + + #[test] + fn float64_rejects_strings() { + let err = coerce_slot("f", 0, &DataType::Utf8, CoerceMode::Float64).unwrap_err(); + assert!(err.to_string().contains("expected integer or float")); + } + + // ── Utf8 ─────────────────────────────────────────────────────────────── + #[test] + fn utf8_accepts_every_string_variant() { + for observed in [DataType::Utf8, DataType::LargeUtf8, DataType::Utf8View] { + let result = coerce_slot("s", 0, &observed, CoerceMode::Utf8).unwrap(); + assert_eq!(result, DataType::Utf8); + } + } + + #[test] + fn utf8_rejects_numeric_and_temporal() { + for observed in [DataType::Int64, DataType::Float64, DataType::Date32] { + let err = coerce_slot("s", 0, &observed, CoerceMode::Utf8).unwrap_err(); + assert!(err.to_string().contains("expected string")); + } + } + + // ── coerce_args ──────────────────────────────────────────────────────── + #[test] + fn coerce_args_maps_each_slot_through_its_mode() { + let observed = [DataType::Utf8, DataType::Int32]; + let template = [CoerceMode::TimestampMs, CoerceMode::Int64]; + let result = coerce_args("multi", &observed, &template).unwrap(); + assert_eq!(result, vec![ts_ms(), DataType::Int64]); + } + + #[test] + fn coerce_args_rejects_arity_mismatch() { + let observed = [DataType::Utf8]; + let template = [CoerceMode::Utf8, CoerceMode::Utf8]; + let err = coerce_args("arity", &observed, &template).unwrap_err(); + assert!(err.to_string().contains("expects 2 arguments, got 1")); + } + + #[test] + fn coerce_args_propagates_slot_errors() { + let observed = [DataType::Utf8, DataType::Utf8]; + let template = [CoerceMode::Utf8, CoerceMode::Int64]; + let err = coerce_args("slot", &observed, &template).unwrap_err(); + assert!( + err.to_string().contains("arg 1"), + "error must name the failing slot index, got: {err}" + ); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/BaseScalarFunctionIT.java b/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/BaseScalarFunctionIT.java new file mode 100644 index 0000000000000..6dd4a9116aaee --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/BaseScalarFunctionIT.java @@ -0,0 +1,204 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.datafusion; + +import org.opensearch.Version; +import org.opensearch.action.admin.indices.create.CreateIndexResponse; +import org.opensearch.analytics.AnalyticsPlugin; +import org.opensearch.arrow.flight.transport.FlightStreamPlugin; +import org.opensearch.be.lucene.LucenePlugin; +import org.opensearch.cluster.metadata.IndexMetadata; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.FeatureFlags; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.composite.CompositeDataFormatPlugin; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.parquet.ParquetDataFormatPlugin; +import org.opensearch.plugins.Plugin; +import org.opensearch.plugins.PluginInfo; +import org.opensearch.ppl.TestPPLPlugin; +import org.opensearch.ppl.action.PPLRequest; +import org.opensearch.ppl.action.PPLResponse; +import org.opensearch.ppl.action.UnifiedPPLExecuteAction; +import org.opensearch.test.OpenSearchIntegTestCase; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * Shared fixture + scalar-result assert helpers for end-to-end PPL → Calcite → + * Substrait → DataFusion scalar-function tests. + * + *

Each subclass declares its functions as test methods using the + * {@code assertScalarXxx(expr, expected)} helpers. The query template is fixed: + * {@code source=bank | eval x = | fields x | head 1}. Inputs are + * literals so assertions don't depend on the bank fixture's data — the test + * exercises the function's name lookup, type inference, and runtime, not + * arithmetic on rows. + * + * @opensearch.internal + */ +// TEST-scope cluster per method — slower but eliminates cluster-reuse degradation that +// surfaces as cascading NodeDisconnectedException when many test methods share a SUITE cluster. +@OpenSearchIntegTestCase.ClusterScope(scope = OpenSearchIntegTestCase.Scope.SUITE, numDataNodes = 1) +public abstract class BaseScalarFunctionIT extends OpenSearchIntegTestCase { + + protected static final String BANK_INDEX = "bank"; + + @Override + protected Collection> nodePlugins() { + return List.of(TestPPLPlugin.class, FlightStreamPlugin.class, CompositeDataFormatPlugin.class, LucenePlugin.class); + } + + @Override + protected Collection additionalNodePlugins() { + return List.of( + classpathPlugin(AnalyticsPlugin.class, Collections.emptyList()), + classpathPlugin(ParquetDataFormatPlugin.class, Collections.emptyList()), + classpathPlugin(DataFusionPlugin.class, List.of(AnalyticsPlugin.class.getName())) + ); + } + + private static PluginInfo classpathPlugin(Class pluginClass, List extendedPlugins) { + return new PluginInfo( + pluginClass.getName(), + "classpath plugin", + "NA", + Version.CURRENT, + "1.8", + pluginClass.getName(), + null, + extendedPlugins, + false + ); + } + + @Override + protected Settings nodeSettings(int nodeOrdinal) { + return Settings.builder() + .put(super.nodeSettings(nodeOrdinal)) + .put(FeatureFlags.PLUGGABLE_DATAFORMAT_EXPERIMENTAL_FLAG, true) + .build(); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + // SUITE-scoped cluster is reused across test methods — only create/index once. + if (!indexExists(BANK_INDEX)) { + createBankIndex(); + indexBankDocs(); + ensureGreen(BANK_INDEX); + refresh(BANK_INDEX); + } + } + + private void createBankIndex() throws Exception { + XContentBuilder mapping = XContentFactory.jsonBuilder() + .startObject() + .startObject("properties") + .startObject("account_number") + .field("type", "long") + .endObject() + .startObject("firstname") + .field("type", "keyword") + .endObject() + .startObject("balance") + .field("type", "long") + .endObject() + .startObject("created_at") + .field("type", "date") + .endObject() + .endObject() + .endObject(); + + Settings indexSettings = Settings.builder() + .put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, 1) + .put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, 0) + .put("index.pluggable.dataformat.enabled", true) + .put("index.pluggable.dataformat", "composite") + .put("index.composite.primary_data_format", "parquet") + .putList("index.composite.secondary_data_formats") + .build(); + + CreateIndexResponse response = client().admin() + .indices() + .prepareCreate(BANK_INDEX) + .setSettings(indexSettings) + .setMapping(mapping) + .get(); + assertTrue("bank index creation must be acknowledged", response.isAcknowledged()); + } + + private void indexBankDocs() { + client().prepareIndex(BANK_INDEX) + .setId("1") + .setSource("account_number", 1, "firstname", "Amber", "balance", 39225L, "created_at", "2024-06-15T10:30:00Z") + .get(); + client().prepareIndex(BANK_INDEX) + .setId("6") + .setSource("account_number", 6, "firstname", "Hattie", "balance", 5686L, "created_at", "2024-01-20T14:45:30Z") + .get(); + } + + // ---- Assert helpers ---- + + /** + * Runs the given expression against the single bank row with + * {@code account_number=1} (firstname='Amber', balance=39225) and returns + * the resulting cell. Pinning the row makes assertions deterministic and + * lets tests reference {@code firstname} / {@code balance} as fields — + * which prevents Calcite's constant-folding from optimizing the function + * away at plan time. Tests must therefore use field references to truly + * exercise the Substrait + DataFusion runtime path. + */ + protected Object evalScalar(String expr) { + PPLRequest request = new PPLRequest( + "source=" + BANK_INDEX + " | where account_number = 1 | eval x = " + expr + " | fields x | head 1" + ); + PPLResponse response = client().execute(UnifiedPPLExecuteAction.INSTANCE, request).actionGet(); + assertNotNull("PPLResponse must not be null", response); + assertEquals("schema columns", List.of("x"), response.getColumns()); + assertEquals("head 1 → exactly 1 row", 1, response.getRows().size()); + return response.getRows().get(0)[0]; + } + + protected void assertScalarLong(String expr, long expected) { + Object cell = evalScalar(expr); + assertNotNull(expr + " result must not be null", cell); + assertTrue(expr + " result must be Number, got " + cell.getClass(), cell instanceof Number); + assertEquals(expr, expected, ((Number) cell).longValue()); + } + + protected void assertScalarDouble(String expr, double expected, double delta) { + Object cell = evalScalar(expr); + assertNotNull(expr + " result must not be null", cell); + assertTrue(expr + " result must be Number, got " + cell.getClass(), cell instanceof Number); + assertEquals(expr, expected, ((Number) cell).doubleValue(), delta); + } + + protected void assertScalarString(String expr, String expected) { + Object cell = evalScalar(expr); + assertNotNull(expr + " result must not be null", cell); + assertEquals(expr, expected, cell.toString()); + } + + protected void assertScalarBoolean(String expr, boolean expected) { + Object cell = evalScalar(expr); + assertNotNull(expr + " result must not be null", cell); + assertTrue(expr + " result must be Boolean, got " + cell.getClass(), cell instanceof Boolean); + assertEquals(expr, expected, cell); + } + + protected void assertScalarNull(String expr) { + Object cell = evalScalar(expr); + assertNull(expr + " result must be null but was " + cell, cell); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/ScalarDateTimeFunctionIT.java b/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/ScalarDateTimeFunctionIT.java new file mode 100644 index 0000000000000..4729a89663b58 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/internalClusterTest/java/org/opensearch/be/datafusion/ScalarDateTimeFunctionIT.java @@ -0,0 +1,42 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.datafusion; + +/** + * End-to-end smoke tests for scalar date/time functions routed through PPL → Calcite → + * Substrait → DataFusion. Bank fixture row 1: created_at='2024-06-15T10:30:00Z'. + * + *

Two representative cases: + *

    + *
  • {@link #testYear()} — YAML alias with literal-arg injection + * ({@code YEAR(ts) → date_part('year', ts)}).
  • + *
  • {@link #testConvertTz()} — custom Rust UDF registered with DataFusion + * ({@code convert_tz(ts, from_tz, to_tz)}).
  • + *
+ */ +public class ScalarDateTimeFunctionIT extends BaseScalarFunctionIT { + + public void testYear() { + Object cell = evalScalar("year(created_at)"); + assertNotNull("year() must not be null", cell); + assertEquals(2024L, ((Number) cell).longValue()); + } + + public void testConvertTz() { + // row 1: created_at = 2024-06-15T10:30:00Z (UTC). + // Shifted UTC → +10:00 = 2024-06-15T20:30:00Z, unix seconds = 1718483400. + // ConvertTzAdapter rewrites PPL's bespoke CONVERT_TZ to our locally-declared + // SqlFunction("convert_tz") whose Sig is in ADDITIONAL_SCALAR_SIGS; + // UnixTimestampAdapter does the same to to_unixtime. Isthmus resolves both, + // DataFusion runs convert_tz via the Rust UDF and to_unixtime natively. + Object cell = evalScalar("unix_timestamp(convert_tz(created_at, '+00:00', '+10:00'))"); + assertNotNull("convert_tz must not be null", cell); + assertEquals(1718483400L, ((Number) cell).longValue()); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/ConvertTzAdapter.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/ConvertTzAdapter.java new file mode 100644 index 0000000000000..cb460333dbdaa --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/ConvertTzAdapter.java @@ -0,0 +1,191 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.datafusion; + +import org.apache.calcite.plan.RelOptCluster; +import org.apache.calcite.rex.RexBuilder; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.rex.RexLiteral; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.SqlFunction; +import org.apache.calcite.sql.SqlFunctionCategory; +import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.SqlOperator; +import org.apache.calcite.sql.type.OperandTypes; +import org.apache.calcite.sql.type.ReturnTypes; +import org.apache.calcite.sql.type.SqlTypeName; +import org.opensearch.analytics.spi.AbstractNameMappingAdapter; +import org.opensearch.analytics.spi.FieldStorageInfo; +import org.opensearch.analytics.spi.ScalarFunctionAdapter; + +import java.time.DateTimeException; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Cat-3b adapter for PPL's {@code CONVERT_TZ(ts, from_tz, to_tz)}. Two jobs in + * priority order: + * + *
    + *
  1. Identity short-circuit: when both tz operands are string + * literals and canonicalize to the same value, the call reduces to its + * timestamp operand. No UDF invocation, no wire traffic.
  2. + *
  3. UDF fallback with canonicalized literal operands: every other + * case rewrites to {@link #LOCAL_CONVERT_TZ_OP} whose + * {@code FunctionMappings.Sig} in {@link DataFusionFragmentConvertor} + * resolves to the {@code convert_tz} Rust UDF. Literal tz operands are + * validated + canonicalized via {@link #canonicalizeTz(String)} at plan + * time so bad literals surface with a clear error rather than silent + * per-row NULL at runtime.
  4. + *
+ * + *

Why no offset+offset → interval fold: building an interval literal at + * Calcite's level requires {@code org.apache.calcite.avatica.util.TimeUnit}, + * which lives in avatica and is a {@code runtimeOnly} dep of this module. + * Pulling it in just for the fixed-offset case doesn't pay for itself; IANA + * pairs dominate real-world {@code CONVERT_TZ} usage and must go through the + * UDF anyway (per-row DST lookup). + * + *

The fallback preserves the original call's return type via + * {@code rexBuilder.makeCall(original.getType(), ...)} so the enclosing + * {@code Project} / {@code Filter} rowType cache stays consistent (see + * {@link AbstractNameMappingAdapter} javadoc for background). + * + * @opensearch.internal + */ +class ConvertTzAdapter implements ScalarFunctionAdapter { + + /** + * Locally-declared target operator for the rewrite. {@link SqlKind#OTHER_FUNCTION} + * so it doesn't collide with any Calcite built-in. + * {@link OperandTypes#ANY_STRING_STRING} keeps validation permissive on the + * timestamp slot — real argument vetting happens inside the UDF's + * {@code coerce_types} and {@code invoke_with_args}. + */ + static final SqlOperator LOCAL_CONVERT_TZ_OP = new SqlFunction( + "convert_tz", + SqlKind.OTHER_FUNCTION, + ReturnTypes.ARG0_NULLABLE, + null, + OperandTypes.ANY_STRING_STRING, + SqlFunctionCategory.TIMEDATE + ); + + /** Matches {@code ±H:MM} / {@code ±HH:MM} with hours [0,14] and minutes [0,59]. */ + private static final Pattern OFFSET_PATTERN = Pattern.compile("^([+-])(\\d{1,2}):(\\d{2})$"); + + @Override + public RexNode adapt(RexCall original, List fieldStorage, RelOptCluster cluster) { + RexBuilder rexBuilder = cluster.getRexBuilder(); + List operands = new ArrayList<>(original.getOperands()); + // Slot 0 is the timestamp; slots 1 and 2 are from_tz / to_tz. + for (int slot : new int[] { 1, 2 }) { + operands.set(slot, canonicalizeTzOperand(operands.get(slot), rexBuilder)); + } + + // Identity short-circuit: both operands resolve to the same canonical + // string → the conversion is a no-op. + String fromLiteral = tzLiteralValue(operands.get(1)); + String toLiteral = tzLiteralValue(operands.get(2)); + if (fromLiteral != null && toLiteral != null && fromLiteral.equals(toLiteral)) { + return operands.get(0); + } + + // UDF fallback. Preserve the original call's return type — see + // AbstractNameMappingAdapter for why (Project.isValid compatibleTypes check). + return rexBuilder.makeCall(original.getType(), LOCAL_CONVERT_TZ_OP, operands); + } + + /** + * Returns the string value of a canonicalized tz literal operand, or null + * when the operand is not a VARCHAR/CHAR {@link RexLiteral} (column refs, + * NULL literals, other expressions). + */ + private static String tzLiteralValue(RexNode operand) { + if (!(operand instanceof RexLiteral literal)) return null; + SqlTypeName typeName = literal.getType().getSqlTypeName(); + if (typeName != SqlTypeName.CHAR && typeName != SqlTypeName.VARCHAR) return null; + return literal.getValueAs(String.class); + } + + /** + * If {@code operand} is a string {@link RexLiteral}, canonicalize it and + * return a new literal with the canonical form (or the original if already + * canonical). Non-literal operands (column references, function results) + * pass through untouched — their runtime values can't be validated until + * the UDF runs. + * + *

Throws {@link IllegalArgumentException} for literals that don't match + * either the {@code ±HH:MM} offset pattern or a known IANA zone id. + */ + private static RexNode canonicalizeTzOperand(RexNode operand, RexBuilder rexBuilder) { + if (!(operand instanceof RexLiteral literal)) { + return operand; + } + SqlTypeName typeName = literal.getType().getSqlTypeName(); + if (typeName != SqlTypeName.CHAR && typeName != SqlTypeName.VARCHAR) { + return operand; + } + String raw = literal.getValueAs(String.class); + if (raw == null) { + // NULL literal — UDF handles null operand at runtime. + return operand; + } + String canonical = canonicalizeTz(raw); + if (canonical.equals(raw)) { + return operand; + } + return rexBuilder.makeLiteral( + canonical, + rexBuilder.getTypeFactory().createSqlType(SqlTypeName.VARCHAR), + literal.getType().isNullable() + ); + } + + /** + * Canonicalize a timezone string. Accepts either: + *

    + *
  • {@code ±H:MM} / {@code ±HH:MM} where hours ∈ [0,14] and minutes ∈ [0,59]; + * returned zero-padded as {@code ±HH:MM}.
  • + *
  • IANA zone id recognized by {@link ZoneId#of(String)}; returned as the + * JDK-normalized form. {@code ZoneId.of} rejects unknown ids, so invalid + * IANA names surface here as {@link IllegalArgumentException}.
  • + *
+ * + *

The {@code ±HH:MM} bounds match the Rust UDF's {@code parse_offset_seconds} + * (rust/src/udf/convert_tz.rs) — `+14:59` is the maximum offset anywhere on + * Earth (Kiribati is +14:00; the extra minute tolerance matches existing + * UDF behavior). + */ + static String canonicalizeTz(String raw) { + Matcher offset = OFFSET_PATTERN.matcher(raw); + if (offset.matches()) { + String sign = offset.group(1); + int hours = Integer.parseInt(offset.group(2)); + int minutes = Integer.parseInt(offset.group(3)); + if (hours > 14 || minutes > 59) { + throw new IllegalArgumentException( + "convert_tz: invalid offset [" + raw + "] — hours must be in [0, 14] and minutes in [0, 59]" + ); + } + return String.format(Locale.ROOT, "%s%02d:%02d", sign, hours, minutes); + } + try { + // ZoneId.of() throws for unknown ids; the returned ZoneId.getId() + // is the JDK's canonical form (same id for equivalent inputs). + return ZoneId.of(raw).getId(); + } catch (DateTimeException e) { + throw new IllegalArgumentException("convert_tz: invalid timezone [" + raw + "] — expected IANA zone id or ±HH:MM offset", e); + } + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java index 221838a98dece..1119f8d8fa17a 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java @@ -100,7 +100,10 @@ public class DataFusionAnalyticsBackendPlugin implements AnalyticsSearchBackendP ScalarFunction.MINUS, ScalarFunction.TIMES, ScalarFunction.DIVIDE, - ScalarFunction.MOD + ScalarFunction.MOD, + ScalarFunction.YEAR, + ScalarFunction.CONVERT_TZ, + ScalarFunction.UNIX_TIMESTAMP ); private static final Set AGG_FUNCTIONS = Set.of( @@ -178,7 +181,10 @@ public Map scalarFunctionAdapters() { Map.entry(ScalarFunction.SARG_PREDICATE, new SargAdapter()), Map.entry(ScalarFunction.DIVIDE, new StdOperatorRewriteAdapter("DIVIDE", SqlStdOperatorTable.DIVIDE)), Map.entry(ScalarFunction.MOD, new StdOperatorRewriteAdapter("MOD", SqlStdOperatorTable.MOD)), - Map.entry(ScalarFunction.LIKE, new LikeAdapter()) + Map.entry(ScalarFunction.LIKE, new LikeAdapter()), + Map.entry(ScalarFunction.YEAR, new YearAdapter()), + Map.entry(ScalarFunction.CONVERT_TZ, new ConvertTzAdapter()), + Map.entry(ScalarFunction.UNIX_TIMESTAMP, new UnixTimestampAdapter()) ); } }; diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionFragmentConvertor.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionFragmentConvertor.java index fe072c85a2e99..e7d67bb5879cf 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionFragmentConvertor.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionFragmentConvertor.java @@ -89,7 +89,11 @@ public class DataFusionFragmentConvertor implements FragmentConvertor { */ private static final List ADDITIONAL_SCALAR_SIGS = List.of( FunctionMappings.s(DelegatedPredicateFunction.FUNCTION, DelegatedPredicateFunction.NAME), - FunctionMappings.s(SqlLibraryOperators.ILIKE, "ilike") + FunctionMappings.s(SqlLibraryOperators.ILIKE, "ilike"), + FunctionMappings.s(DelegatedPredicateFunction.FUNCTION, DelegatedPredicateFunction.NAME), + FunctionMappings.s(SqlLibraryOperators.DATE_PART, "date_part"), + FunctionMappings.s(ConvertTzAdapter.LOCAL_CONVERT_TZ_OP, "convert_tz"), + FunctionMappings.s(UnixTimestampAdapter.LOCAL_TO_UNIXTIME_OP, "to_unixtime") ); private final SimpleExtension.ExtensionCollection extensions; diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/UnixTimestampAdapter.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/UnixTimestampAdapter.java new file mode 100644 index 0000000000000..2f7056ac92c55 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/UnixTimestampAdapter.java @@ -0,0 +1,60 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.datafusion; + +import org.apache.calcite.sql.SqlFunction; +import org.apache.calcite.sql.SqlFunctionCategory; +import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.SqlOperator; +import org.apache.calcite.sql.type.OperandTypes; +import org.apache.calcite.sql.type.ReturnTypes; +import org.opensearch.analytics.spi.AbstractNameMappingAdapter; + +import java.util.List; + +/** + * Cat-3a rename adapter for PPL's {@code UNIX_TIMESTAMP(ts)}. Rewrites to a + * locally-declared {@link SqlFunction} named {@code to_unixtime} — the name + * DataFusion's substrait consumer recognizes for its native + * {@code ToUnixtimeFunc} (no UDF registration required on the Rust side). + * + *

Same machinery as {@link ConvertTzAdapter}: locally-declared operator is + * the referent of the {@link io.substrait.isthmus.expression.FunctionMappings.Sig} + * in {@link DataFusionFragmentConvertor#ADDITIONAL_SCALAR_SIGS}. + * + *

Type note. PPL's {@code UNIX_TIMESTAMP} returns + * {@code DOUBLE_FORCE_NULLABLE}; DataFusion's {@code to_unixtime} returns + * {@code Int64}. {@link AbstractNameMappingAdapter} preserves the PPL-declared + * return type on the rewritten call so Calcite's {@code Project.isValid} + * assertion holds. The downstream substrait consumer (DataFusion) re-resolves + * {@code to_unixtime} by name and applies its own {@code coerce_types}, so the + * Calcite-inferred type is purely plan-validity bookkeeping. + * + * @opensearch.internal + */ +class UnixTimestampAdapter extends AbstractNameMappingAdapter { + + /** + * Locally-declared target operator. Name matches DataFusion's native + * {@code to_unixtime}. Return-type inference is irrelevant — the adapter + * clones with the original PPL return type. + */ + static final SqlOperator LOCAL_TO_UNIXTIME_OP = new SqlFunction( + "to_unixtime", + SqlKind.OTHER_FUNCTION, + ReturnTypes.BIGINT_NULLABLE, + null, + OperandTypes.ANY, + SqlFunctionCategory.TIMEDATE + ); + + UnixTimestampAdapter() { + super(LOCAL_TO_UNIXTIME_OP, List.of(), List.of()); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/YearAdapter.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/YearAdapter.java new file mode 100644 index 0000000000000..5ad28fc0ba13a --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/YearAdapter.java @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.datafusion; + +import org.apache.calcite.sql.fun.SqlLibraryOperators; +import org.opensearch.analytics.spi.AbstractNameMappingAdapter; + +import java.util.List; + +/** + * Representative {@link AbstractNameMappingAdapter} for Calcite {@code YEAR(ts)}. + * Rewrites to {@code date_part('year', ts)} so isthmus resolves it against + * DataFusion's native date_part (see the {@code date_part} signature in + * {@code opensearch_scalar.yaml}). Demonstrates the reusable rename + + * literal-arg-injection adapter pattern for cat-3 PPL functions. + * + *

Follow-up PRs extend the pattern to MONTH/DAY/HOUR/etc. each as a + * one-line concrete subclass — identical shape, different unit literal. + * + * @opensearch.internal + */ +class YearAdapter extends AbstractNameMappingAdapter { + + YearAdapter() { + super(SqlLibraryOperators.DATE_PART, List.of("year"), List.of()); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/resources/opensearch_scalar_functions.yaml b/sandbox/plugins/analytics-backend-datafusion/src/main/resources/opensearch_scalar_functions.yaml index 4889f35c11b6a..8f5d778e543b3 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/resources/opensearch_scalar_functions.yaml +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/resources/opensearch_scalar_functions.yaml @@ -20,3 +20,21 @@ scalar_functions: - value: "string" name: "match" return: boolean + - name: "date_part" + impls: + - args: [{ value: string, name: "part" }, { value: "any1", name: "value" }] + return: any1 + + - name: "convert_tz" + description: "Shift a timestamp from one timezone to another. IANA names and +/-HH:MM offsets." + impls: + - args: + - { value: "any1", name: "ts" } + - { value: string, name: "from_tz" } + - { value: string, name: "to_tz" } + return: any1 + - name: "to_unixtime" + description: "Return a timestamp as Unix epoch seconds." + impls: + - args: [{ value: "any1", name: "ts" }] + return: any1 diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/ConvertTzAdapterTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/ConvertTzAdapterTests.java new file mode 100644 index 0000000000000..19eb0df9ad578 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/ConvertTzAdapterTests.java @@ -0,0 +1,228 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.datafusion; + +import org.apache.calcite.jdbc.JavaTypeFactoryImpl; +import org.apache.calcite.plan.RelOptCluster; +import org.apache.calcite.plan.hep.HepPlanner; +import org.apache.calcite.plan.hep.HepProgramBuilder; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.rex.RexBuilder; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.rex.RexLiteral; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.SqlFunction; +import org.apache.calcite.sql.SqlFunctionCategory; +import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.type.OperandTypes; +import org.apache.calcite.sql.type.ReturnTypes; +import org.apache.calcite.sql.type.SqlTypeName; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.List; + +/** + * Unit tests for {@link ConvertTzAdapter}. The adapter has three jobs in + * priority order: identity short-circuit when both tz operands canonicalize to + * the same value, plan-time validation/canonicalization of literal tz operands, + * and rewrite to the locally-declared UDF operator otherwise. DST-correct + * per-row shifting stays in the Rust UDF since IANA offsets vary per instant. + */ +public class ConvertTzAdapterTests extends OpenSearchTestCase { + + private RelDataTypeFactory typeFactory; + private RexBuilder rexBuilder; + private RelOptCluster cluster; + + @Override + public void setUp() throws Exception { + super.setUp(); + typeFactory = new JavaTypeFactoryImpl(); + rexBuilder = new RexBuilder(typeFactory); + HepPlanner planner = new HepPlanner(new HepProgramBuilder().build()); + cluster = RelOptCluster.create(planner, rexBuilder); + } + + private SqlFunction convertTzOp(RelDataType returnType) { + return new SqlFunction( + "CONVERT_TZ", + SqlKind.OTHER_FUNCTION, + ReturnTypes.explicit(returnType), + null, + OperandTypes.ANY_STRING_STRING, + SqlFunctionCategory.TIMEDATE + ); + } + + private RexCall buildConvertTz(String fromLit, String toLit) { + RelDataType tsType = typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.TIMESTAMP), true); + RexNode tsRef = rexBuilder.makeInputRef(tsType, 0); + // 2-arg makeLiteral returns a bare RexLiteral; the 3-arg form with a + // nullable type wraps in a CAST, which the adapter must then peel back + // to inspect the string value. PPL's frontend emits the 2-arg form, so + // we match that here. + RexNode fromNode = rexBuilder.makeLiteral(fromLit); + RexNode toNode = rexBuilder.makeLiteral(toLit); + return (RexCall) rexBuilder.makeCall(convertTzOp(tsType), List.of(tsRef, fromNode, toNode)); + } + + // ── Canonicalization (unit tests on the static helper) ──────────────── + + public void testCanonicalizeTzPadsOffsetDigits() { + assertEquals("+05:30", ConvertTzAdapter.canonicalizeTz("+5:30")); + assertEquals("-08:00", ConvertTzAdapter.canonicalizeTz("-8:00")); + assertEquals("+14:00", ConvertTzAdapter.canonicalizeTz("+14:00")); + } + + public void testCanonicalizeTzAcceptsIanaNames() { + // ZoneId.of passes through canonical ids unchanged. + assertEquals("America/New_York", ConvertTzAdapter.canonicalizeTz("America/New_York")); + assertEquals("Europe/London", ConvertTzAdapter.canonicalizeTz("Europe/London")); + assertEquals("UTC", ConvertTzAdapter.canonicalizeTz("UTC")); + } + + public void testCanonicalizeTzRejectsInvalidOffsetBounds() { + // Hours > 14 is beyond any real-world zone. + IllegalArgumentException ex = expectThrows(IllegalArgumentException.class, () -> ConvertTzAdapter.canonicalizeTz("+15:00")); + assertTrue("error must include the bad value: " + ex.getMessage(), ex.getMessage().contains("+15:00")); + + // Minutes > 59 is malformed. + expectThrows(IllegalArgumentException.class, () -> ConvertTzAdapter.canonicalizeTz("+05:60")); + } + + public void testCanonicalizeTzRejectsUnknownIana() { + IllegalArgumentException ex = expectThrows(IllegalArgumentException.class, () -> ConvertTzAdapter.canonicalizeTz("Mars/Olympus")); + assertTrue("error must include the bad value for UX: " + ex.getMessage(), ex.getMessage().contains("Mars/Olympus")); + } + + // ── adapt() behavior ────────────────────────────────────────────────── + + /** + * Identity fold: when both tz literals canonicalize to the same value, the + * call reduces to its timestamp operand. No UDF invocation. + */ + public void testAdaptIdentityFoldReturnsTimestampUnchanged() { + RexCall original = buildConvertTz("UTC", "UTC"); + RexNode adapted = new ConvertTzAdapter().adapt(original, List.of(), cluster); + + assertSame("identity fold must return the original timestamp operand", original.getOperands().get(0), adapted); + } + + /** + * Identity fold must apply *after* canonicalization — `+5:00` and `+05:00` + * are the same zone but different strings; the adapter must canonicalize + * first, then compare. + */ + public void testAdaptIdentityFoldAppliesAfterCanonicalization() { + RexCall original = buildConvertTz("+5:00", "+05:00"); + RexNode adapted = new ConvertTzAdapter().adapt(original, List.of(), cluster); + + assertSame("identity fold must compare canonical forms", original.getOperands().get(0), adapted); + } + + /** + * When literals can't be collapsed (IANA pairs, mixed IANA + offset), the + * call rewrites to the local UDF operator with canonicalized string + * operands. The tz strings passed to the UDF are the canonical form. + */ + public void testAdaptIanaPairRoutesThroughUdfWithCanonicalLiterals() { + RexCall original = buildConvertTz("America/New_York", "Europe/London"); + RexNode adapted = new ConvertTzAdapter().adapt(original, List.of(), cluster); + + assertTrue("adapted node must be a RexCall, got " + adapted.getClass(), adapted instanceof RexCall); + RexCall call = (RexCall) adapted; + assertSame( + "adapted call must target LOCAL_CONVERT_TZ_OP so FunctionMappings.Sig binds", + ConvertTzAdapter.LOCAL_CONVERT_TZ_OP, + call.getOperator() + ); + assertEquals(3, call.getOperands().size()); + assertEquals("America/New_York", ((RexLiteral) call.getOperands().get(1)).getValueAs(String.class)); + assertEquals("Europe/London", ((RexLiteral) call.getOperands().get(2)).getValueAs(String.class)); + } + + /** + * When literal operands need canonicalization (e.g. `+5:00` → `+05:00`), + * the UDF-bound call sees the canonical form so the Rust side doesn't need + * to do the padding. + */ + public void testAdaptPassesCanonicalizedLiteralsToUdf() { + // Pair of distinct-canonical offsets so the fold path doesn't fire. + RexCall original = buildConvertTz("+5:00", "+10:00"); + RexNode adapted = new ConvertTzAdapter().adapt(original, List.of(), cluster); + + assertTrue(adapted instanceof RexCall); + RexCall call = (RexCall) adapted; + assertSame(ConvertTzAdapter.LOCAL_CONVERT_TZ_OP, call.getOperator()); + assertEquals("+05:00", ((RexLiteral) call.getOperands().get(1)).getValueAs(String.class)); + assertEquals("+10:00", ((RexLiteral) call.getOperands().get(2)).getValueAs(String.class)); + } + + /** + * Adapter preserves the original call's return type — matches the + * {@code AbstractNameMappingAdapter} regression guard. If the rewritten + * call's Calcite-inferred type differs from the original, the enclosing + * {@code Project.isValid} compatibleTypes check breaks at fragment + * conversion. + */ + public void testAdaptedCallPreservesOriginalReturnType() { + RelDataType originalType = typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.TIMESTAMP, 0), true); + RexNode tsRef = rexBuilder.makeInputRef(originalType, 0); + RexNode fromLit = rexBuilder.makeLiteral("America/New_York"); + RexNode toLit = rexBuilder.makeLiteral("Europe/London"); + RexCall original = (RexCall) rexBuilder.makeCall(convertTzOp(originalType), List.of(tsRef, fromLit, toLit)); + assertEquals(originalType, original.getType()); + + RexNode adapted = new ConvertTzAdapter().adapt(original, List.of(), cluster); + + assertEquals( + "adapted call's return type must equal the original — otherwise Project.rowType assertion fails", + original.getType(), + adapted.getType() + ); + } + + /** + * Invalid literal tz operand surfaces at plan time as + * {@link IllegalArgumentException} with the offending value in the message, + * rather than silently producing per-row NULL at runtime. + */ + public void testAdaptInvalidLiteralErrorsAtPlanTime() { + RexCall original = buildConvertTz("Mars/Olympus", "UTC"); + IllegalArgumentException ex = expectThrows( + IllegalArgumentException.class, + () -> new ConvertTzAdapter().adapt(original, List.of(), cluster) + ); + assertTrue("error must name the offending literal for user UX: " + ex.getMessage(), ex.getMessage().contains("Mars/Olympus")); + } + + /** + * Column-valued tz operands are not validated at plan time — per-row + * values can't be inspected until runtime, so they pass through into the + * UDF which handles them leniently (unparseable → NULL row). + */ + public void testAdaptColumnValuedTzOperandsPassThroughToUdf() { + RelDataType tsType = typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.TIMESTAMP), true); + RelDataType stringType = typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.VARCHAR), true); + RexNode tsRef = rexBuilder.makeInputRef(tsType, 0); + // Column refs for the tz slots — not literals, so no canonicalization. + RexNode fromCol = rexBuilder.makeInputRef(stringType, 1); + RexNode toCol = rexBuilder.makeInputRef(stringType, 2); + RexCall original = (RexCall) rexBuilder.makeCall(convertTzOp(tsType), List.of(tsRef, fromCol, toCol)); + + RexNode adapted = new ConvertTzAdapter().adapt(original, List.of(), cluster); + + assertTrue(adapted instanceof RexCall); + RexCall call = (RexCall) adapted; + assertSame(ConvertTzAdapter.LOCAL_CONVERT_TZ_OP, call.getOperator()); + assertSame("column-valued from_tz must pass through unmodified", fromCol, call.getOperands().get(1)); + assertSame("column-valued to_tz must pass through unmodified", toCol, call.getOperands().get(2)); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/UnixTimestampAdapterTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/UnixTimestampAdapterTests.java new file mode 100644 index 0000000000000..e27216f8ee28d --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/UnixTimestampAdapterTests.java @@ -0,0 +1,112 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.datafusion; + +import org.apache.calcite.jdbc.JavaTypeFactoryImpl; +import org.apache.calcite.plan.RelOptCluster; +import org.apache.calcite.plan.hep.HepPlanner; +import org.apache.calcite.plan.hep.HepProgramBuilder; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.rex.RexBuilder; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.SqlFunction; +import org.apache.calcite.sql.SqlFunctionCategory; +import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.type.OperandTypes; +import org.apache.calcite.sql.type.ReturnTypes; +import org.apache.calcite.sql.type.SqlTypeName; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.List; + +/** + * Unit tests for {@link UnixTimestampAdapter} — the cat-3a rename adapter that + * rewrites PPL's bespoke {@code UNIX_TIMESTAMP} operator to a locally-declared + * {@code to_unixtime} {@link SqlFunction} whose {@code FunctionMappings.Sig} we + * own. Target name {@code to_unixtime} matches DataFusion's native function; no + * UDF registration required on the Rust side. + */ +public class UnixTimestampAdapterTests extends OpenSearchTestCase { + + public void testUnixTimestampRewritesToLocalToUnixtimeOperator() { + RelDataTypeFactory typeFactory = new JavaTypeFactoryImpl(); + RexBuilder rexBuilder = new RexBuilder(typeFactory); + HepPlanner planner = new HepPlanner(new HepProgramBuilder().build()); + RelOptCluster cluster = RelOptCluster.create(planner, rexBuilder); + + // Synthesize UNIX_TIMESTAMP(ts) with PPL's return type (DOUBLE_FORCE_NULLABLE). + RelDataType tsType = typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.TIMESTAMP), true); + RelDataType doubleNullable = typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.DOUBLE), true); + SqlFunction unixTimestampOp = new SqlFunction( + "UNIX_TIMESTAMP", + SqlKind.OTHER_FUNCTION, + ReturnTypes.explicit(doubleNullable), + null, + OperandTypes.ANY, + SqlFunctionCategory.TIMEDATE + ); + RexNode tsRef = rexBuilder.makeInputRef(tsType, 0); + RexCall original = (RexCall) rexBuilder.makeCall(unixTimestampOp, List.of(tsRef)); + + RexNode adapted = new UnixTimestampAdapter().adapt(original, List.of(), cluster); + + assertTrue("adapted node must be a RexCall, got " + adapted.getClass(), adapted instanceof RexCall); + RexCall call = (RexCall) adapted; + assertSame( + "adapted call must target UnixTimestampAdapter.LOCAL_TO_UNIXTIME_OP so the " + + "FunctionMappings.Sig in DataFusionFragmentConvertor can bind by reference", + UnixTimestampAdapter.LOCAL_TO_UNIXTIME_OP, + call.getOperator() + ); + assertEquals("to_unixtime is a pure rename — 1 operand preserved", 1, call.getOperands().size()); + assertSame("arg 0 must be the original timestamp operand", tsRef, call.getOperands().get(0)); + } + + /** + * Regression guard mirroring {@code YearAdapterTests.testAdaptedCallPreservesOriginalReturnType}. + * PPL's {@code UNIX_TIMESTAMP} is typed {@code DOUBLE_FORCE_NULLABLE}; DF's + * {@code to_unixtime} is typed {@code Int64}. The adapter must preserve the + * original DOUBLE type so the enclosing Project / Filter's cached rowType + * doesn't mismatch during fragment conversion. (DataFusion's substrait + * consumer re-resolves {@code to_unixtime} by name at plan time and applies + * its own coerce_types pass — the Calcite-inferred return type at isthmus + * time is purely a plan-validity artifact.) + */ + public void testAdaptedCallPreservesOriginalReturnType() { + RelDataTypeFactory typeFactory = new JavaTypeFactoryImpl(); + RexBuilder rexBuilder = new RexBuilder(typeFactory); + HepPlanner planner = new HepPlanner(new HepProgramBuilder().build()); + RelOptCluster cluster = RelOptCluster.create(planner, rexBuilder); + + RelDataType tsType = typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.TIMESTAMP), true); + RelDataType doubleNullable = typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.DOUBLE), true); + SqlFunction unixTimestampOp = new SqlFunction( + "UNIX_TIMESTAMP", + SqlKind.OTHER_FUNCTION, + ReturnTypes.explicit(doubleNullable), + null, + OperandTypes.ANY, + SqlFunctionCategory.TIMEDATE + ); + RexNode tsRef = rexBuilder.makeInputRef(tsType, 0); + RexCall original = (RexCall) rexBuilder.makeCall(unixTimestampOp, List.of(tsRef)); + assertEquals(doubleNullable, original.getType()); + + RexNode adapted = new UnixTimestampAdapter().adapt(original, List.of(), cluster); + + assertEquals( + "adapted call's return type must equal the original — otherwise the enclosing Project.rowType " + + "assertion fails during fragment conversion", + original.getType(), + adapted.getType() + ); + } +} diff --git a/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/YearAdapterTests.java b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/YearAdapterTests.java new file mode 100644 index 0000000000000..a101f74994151 --- /dev/null +++ b/sandbox/plugins/analytics-backend-datafusion/src/test/java/org/opensearch/be/datafusion/YearAdapterTests.java @@ -0,0 +1,116 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.be.datafusion; + +import org.apache.calcite.jdbc.JavaTypeFactoryImpl; +import org.apache.calcite.plan.RelOptCluster; +import org.apache.calcite.plan.hep.HepPlanner; +import org.apache.calcite.plan.hep.HepProgramBuilder; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.rex.RexBuilder; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.rex.RexLiteral; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.SqlFunction; +import org.apache.calcite.sql.SqlFunctionCategory; +import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.fun.SqlLibraryOperators; +import org.apache.calcite.sql.type.OperandTypes; +import org.apache.calcite.sql.type.ReturnTypes; +import org.apache.calcite.sql.type.SqlTypeName; +import org.opensearch.analytics.spi.AbstractNameMappingAdapter; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.List; + +/** + * Unit tests for {@link YearAdapter} exercising the reusable rename + + * literal-arg injection adapter pattern via {@link AbstractNameMappingAdapter}. + */ +public class YearAdapterTests extends OpenSearchTestCase { + + public void testYearRewritesToDatePartWithYearLiteral() { + RelDataTypeFactory typeFactory = new JavaTypeFactoryImpl(); + RexBuilder rexBuilder = new RexBuilder(typeFactory); + HepPlanner planner = new HepPlanner(new HepProgramBuilder().build()); + RelOptCluster cluster = RelOptCluster.create(planner, rexBuilder); + + // Synthesize YEAR(ts) — a one-arg Calcite call of our own SqlFunction + // so the test doesn't depend on any specific builtin. + RelDataType tsType = typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.TIMESTAMP), true); + SqlFunction yearOp = new SqlFunction( + "YEAR", + SqlKind.OTHER_FUNCTION, + ReturnTypes.explicit(typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.BIGINT), true)), + null, + OperandTypes.ANY, + SqlFunctionCategory.TIMEDATE + ); + RexNode tsRef = rexBuilder.makeInputRef(tsType, 0); + RexCall original = (RexCall) rexBuilder.makeCall(yearOp, List.of(tsRef)); + + RexNode adapted = new YearAdapter().adapt(original, List.of(), cluster); + + assertTrue("adapted node must be a RexCall, got " + adapted.getClass(), adapted instanceof RexCall); + RexCall call = (RexCall) adapted; + assertEquals("adapted call must target DATE_PART", SqlLibraryOperators.DATE_PART, call.getOperator()); + assertEquals("date_part(unit, value) must have 2 operands after year-literal prepend", 2, call.getOperands().size()); + assertTrue( + "arg 0 must be a string literal, got " + call.getOperands().get(0).getClass(), + call.getOperands().get(0) instanceof RexLiteral + ); + RexLiteral unitLit = (RexLiteral) call.getOperands().get(0); + assertEquals("year", unitLit.getValueAs(String.class)); + assertSame("arg 1 must be the original operand", tsRef, call.getOperands().get(1)); + } + + /** + * The adapter MUST preserve the Calcite {@link RelDataType} of the original call. + * Otherwise the enclosing Project's cached {@code rowType} (derived from the pre- + * adaptation expression) mismatches the adapted expression's type, tripping + * {@code Project.isValid}'s {@code RexUtil.compatibleTypes} assertion during + * fragment conversion. Regression guard for the PR10 IT hang where + * {@code DATE_PART} produced a different Calcite-inferred type than {@code YEAR}. + */ + public void testAdaptedCallPreservesOriginalReturnType() { + RelDataTypeFactory typeFactory = new JavaTypeFactoryImpl(); + RexBuilder rexBuilder = new RexBuilder(typeFactory); + HepPlanner planner = new HepPlanner(new HepProgramBuilder().build()); + RelOptCluster cluster = RelOptCluster.create(planner, rexBuilder); + + RelDataType tsType = typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.TIMESTAMP), true); + // PPL's YEAR operator is registered with INTEGER_FORCE_NULLABLE — distinct + // from Calcite's SqlLibraryOperators.DATE_PART (which returns BIGINT via + // SqlExtractFunction). If the adapter didn't clone with the original's type, + // the Project's cached rowType (derived from INTEGER) would clash with the + // adapted DATE_PART's inferred BIGINT, tripping Project.isValid. + RelDataType integerNullable = typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.INTEGER), true); + SqlFunction yearOp = new SqlFunction( + "YEAR", + SqlKind.OTHER_FUNCTION, + ReturnTypes.explicit(integerNullable), + null, + OperandTypes.ANY, + SqlFunctionCategory.TIMEDATE + ); + RexNode tsRef = rexBuilder.makeInputRef(tsType, 0); + RexCall original = (RexCall) rexBuilder.makeCall(yearOp, List.of(tsRef)); + assertEquals(integerNullable, original.getType()); + + RexNode adapted = new YearAdapter().adapt(original, List.of(), cluster); + + assertEquals( + "adapted call's return type must equal the original call's return type, " + + "otherwise the enclosing Project.rowType assertion fails in fragment conversion", + original.getType(), + adapted.getType() + ); + } +}