From 211499c5c79567e4da5b2fabcc27a016649b1e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20M=C3=BCller?= Date: Thu, 28 May 2026 20:41:17 +0200 Subject: [PATCH] fix: treat -0.0 and +0.0 as equal in comparisons and IN list (#22490) --- Cargo.lock | 1 + datafusion/physical-expr-common/Cargo.toml | 1 + datafusion/physical-expr-common/src/datum.rs | 345 +++++++++++++++++- .../physical-expr/src/expressions/in_list.rs | 15 +- .../expressions/in_list/primitive_filter.rs | 26 +- .../sqllogictest/test_files/negative_zero.slt | 151 ++++++++ 6 files changed, 532 insertions(+), 7 deletions(-) create mode 100644 datafusion/sqllogictest/test_files/negative_zero.slt diff --git a/Cargo.lock b/Cargo.lock index 66aef04c92394..b88efadadc021 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2397,6 +2397,7 @@ dependencies = [ "datafusion-common", "datafusion-expr-common", "datafusion-proto-models", + "half", "hashbrown 0.17.1", "indexmap 2.14.0", "itertools 0.14.0", diff --git a/datafusion/physical-expr-common/Cargo.toml b/datafusion/physical-expr-common/Cargo.toml index d1ee7feb29db1..1bfa9e0770a60 100644 --- a/datafusion/physical-expr-common/Cargo.toml +++ b/datafusion/physical-expr-common/Cargo.toml @@ -52,6 +52,7 @@ chrono = { workspace = true } datafusion-common = { workspace = true } datafusion-expr-common = { workspace = true } datafusion-proto-models = { workspace = true, optional = true } +half = { workspace = true } hashbrown = { workspace = true } indexmap = { workspace = true } itertools = { workspace = true } diff --git a/datafusion/physical-expr-common/src/datum.rs b/datafusion/physical-expr-common/src/datum.rs index bd5790507f662..6032eea10f8fd 100644 --- a/datafusion/physical-expr-common/src/datum.rs +++ b/datafusion/physical-expr-common/src/datum.rs @@ -16,17 +16,23 @@ // under the License. use arrow::array::BooleanArray; -use arrow::array::{ArrayRef, Datum, make_comparator}; +use arrow::array::{Array, ArrayRef, AsArray, Datum, make_comparator}; use arrow::buffer::{BooleanBuffer, NullBuffer}; +use arrow::compute::kernels::arity::unary; use arrow::compute::kernels::cmp::{ distinct, eq, gt, gt_eq, lt, lt_eq, neq, not_distinct, }; use arrow::compute::{SortOptions, ilike, like, nilike, nlike}; +use arrow::datatypes::{ + DataType, Float16Type, Float32Type, Float64Type, Int16Type, Int32Type, Int64Type, +}; +use arrow::downcast_dictionary_array; use arrow::error::ArrowError; use datafusion_common::{Result, ScalarValue}; use datafusion_common::{arrow_datafusion_err, assert_or_internal_err, internal_err}; use datafusion_expr_common::columnar_value::ColumnarValue; use datafusion_expr_common::operator::Operator; +use half::f16; use std::sync::Arc; /// Applies a binary [`Datum`] kernel `f` to `lhs` and `rhs` @@ -84,7 +90,147 @@ pub fn apply_cmp( } }; - apply(lhs, rhs, |l, r| Ok(Arc::new(f(l, r)?))) + let lhs = normalize_neg_zero(lhs); + let rhs = normalize_neg_zero(rhs); + apply(&lhs, &rhs, |l, r| Ok(Arc::new(f(l, r)?))) + } +} + +/// Replace `-0.0` with `+0.0` on float inputs so that comparison operators +/// follow IEEE 754 default semantics (where `-0.0 == +0.0`). +/// +/// arrow-rs' comparison kernels intentionally use totalOrder semantics, which +/// treats `-0.0` as strictly less than `+0.0` and not equal to it +/// +/// See [`normalize_neg_zero_array`] / [`normalize_neg_zero_scalar`] for the +/// per-variant behavior, including dictionary- and run-end-encoded arrays. +pub fn normalize_neg_zero(value: &ColumnarValue) -> ColumnarValue { + match value { + ColumnarValue::Array(array) => { + ColumnarValue::Array(normalize_neg_zero_array(array)) + } + ColumnarValue::Scalar(scalar) => { + ColumnarValue::Scalar(normalize_neg_zero_scalar(scalar)) + } + } +} + +/// Array variant of [`normalize_neg_zero`]. Returns the input unchanged for +/// arrays that don't contain `-0.0` (no allocation) and for arrays whose +/// (transitive) value type is not floating-point. +/// +/// Dictionary- and run-end-encoded arrays are peeled to their inner values +/// and rebuilt with normalized values when needed; the keys/run-ends are +/// preserved. +pub fn normalize_neg_zero_array(array: &ArrayRef) -> ArrayRef { + if !data_type_contains_float(array.data_type()) { + return Arc::clone(array); + } + + match array.data_type() { + DataType::Float16 => { + let arr = array.as_primitive::(); + if arr + .values() + .iter() + .any(|v| v.is_sign_negative() && *v == f16::ZERO) + { + Arc::new(unary::(arr, |x| { + if x == f16::ZERO { f16::ZERO } else { x } + })) + } else { + Arc::clone(array) + } + } + DataType::Float32 => { + let arr = array.as_primitive::(); + if arr + .values() + .iter() + .any(|v| v.is_sign_negative() && *v == 0.0) + { + Arc::new(unary::(arr, |x| { + if x == 0.0 { 0.0 } else { x } + })) + } else { + Arc::clone(array) + } + } + DataType::Float64 => { + let arr = array.as_primitive::(); + if arr + .values() + .iter() + .any(|v| v.is_sign_negative() && *v == 0.0) + { + Arc::new(unary::(arr, |x| { + if x == 0.0 { 0.0 } else { x } + })) + } else { + Arc::clone(array) + } + } + DataType::Dictionary(_, _) => { + let dyn_array: &dyn Array = array.as_ref(); + downcast_dictionary_array!( + dyn_array => { + let inner = dyn_array.values(); + let normalized = normalize_neg_zero_array(inner); + if Arc::ptr_eq(&normalized, inner) { + Arc::clone(array) + } else { + Arc::new(dyn_array.with_values(normalized)) + } + } + _ => unreachable!("data_type matched Dictionary"), + ) + } + DataType::RunEndEncoded(run_ends_field, _) => match run_ends_field.data_type() { + DataType::Int16 => normalize_neg_zero_ree::(array), + DataType::Int32 => normalize_neg_zero_ree::(array), + DataType::Int64 => normalize_neg_zero_ree::(array), + _ => Arc::clone(array), + }, + _ => Arc::clone(array), + } +} + +/// Returns `true` if `dt` is, or transitively wraps, a floating-point type. +/// Used to short-circuit [`normalize_neg_zero_array`] for non-float inputs. +fn data_type_contains_float(dt: &DataType) -> bool { + match dt { + DataType::Float16 | DataType::Float32 | DataType::Float64 => true, + DataType::Dictionary(_, value_type) => data_type_contains_float(value_type), + DataType::RunEndEncoded(_, values_field) => { + data_type_contains_float(values_field.data_type()) + } + _ => false, + } +} + +fn normalize_neg_zero_ree( + array: &ArrayRef, +) -> ArrayRef { + let ree = array.as_ref().as_run::(); + let inner = ree.values(); + let normalized = normalize_neg_zero_array(inner); + if Arc::ptr_eq(&normalized, inner) { + Arc::clone(array) + } else { + Arc::new(ree.with_values(normalized)) + } +} + +/// Scalar variant of [`normalize_neg_zero`]. Returns the input unchanged for +/// non-float scalars and for non-zero float values. +pub fn normalize_neg_zero_scalar(scalar: &ScalarValue) -> ScalarValue { + match scalar { + ScalarValue::Float16(Some(v)) if *v == f16::ZERO => { + ScalarValue::Float16(Some(f16::ZERO)) + } + ScalarValue::Float32(Some(v)) if *v == 0.0 => ScalarValue::Float32(Some(0.0)), + ScalarValue::Float64(Some(v)) if *v == 0.0 => ScalarValue::Float64(Some(0.0)), + _ => scalar.clone(), } } @@ -205,3 +351,198 @@ pub fn compare_op_for_nested( Ok(BooleanArray::new(values, nulls)) } } + +#[cfg(test)] +mod tests { + use super::*; + use arrow::array::{ + DictionaryArray, Float16Array, Float32Array, Float64Array, Int32Array, RunArray, + }; + + #[test] + fn data_type_contains_float_detects_nested() { + use arrow::datatypes::Field; + + assert!(data_type_contains_float(&DataType::Float32)); + assert!(!data_type_contains_float(&DataType::Int64)); + + let dict_of_float = + DataType::Dictionary(Box::new(DataType::Int32), Box::new(DataType::Float64)); + assert!(data_type_contains_float(&dict_of_float)); + + let dict_of_int = + DataType::Dictionary(Box::new(DataType::Int32), Box::new(DataType::Int64)); + assert!(!data_type_contains_float(&dict_of_int)); + + let ree_of_float = DataType::RunEndEncoded( + Arc::new(Field::new("run_ends", DataType::Int32, false)), + Arc::new(Field::new("values", DataType::Float64, true)), + ); + assert!(data_type_contains_float(&ree_of_float)); + } + + fn has_neg_zero_f64(array: &dyn Array) -> bool { + array + .as_primitive::() + .values() + .iter() + .any(|v| v.is_sign_negative() && *v == 0.0) + } + + #[test] + fn normalize_float64_rewrites_neg_zero() { + let array: ArrayRef = Arc::new(Float64Array::from(vec![-0.0, 0.0, 1.5, -2.0])); + let normalized = normalize_neg_zero_array(&array); + + assert!(!Arc::ptr_eq(&normalized, &array)); + assert!(!has_neg_zero_f64(normalized.as_ref())); + let values = normalized.as_primitive::().values(); + assert_eq!(values[2], 1.5); + assert_eq!(values[3], -2.0); + } + + #[test] + fn normalize_float64_passthrough_when_no_neg_zero() { + let array: ArrayRef = Arc::new(Float64Array::from(vec![0.0, 1.0, 2.0])); + let normalized = normalize_neg_zero_array(&array); + assert!(Arc::ptr_eq(&normalized, &array)); + } + + #[test] + fn normalize_dict_of_float64_peels_and_rewrites() { + let values: ArrayRef = Arc::new(Float64Array::from(vec![-0.0, 1.0, 2.0])); + let keys = Int32Array::from(vec![0, 1, 0, 2]); + let array: ArrayRef = + Arc::new(DictionaryArray::::try_new(keys, values).unwrap()); + + let normalized = normalize_neg_zero_array(&array); + assert!(!Arc::ptr_eq(&normalized, &array)); + + let dict = normalized + .as_ref() + .as_any() + .downcast_ref::>() + .unwrap(); + assert!(!has_neg_zero_f64(dict.values().as_ref())); + } + + #[test] + fn normalize_dict_of_non_float_passes_through() { + let values: ArrayRef = Arc::new(Int32Array::from(vec![10, 20, 30])); + let keys = Int32Array::from(vec![0, 1, 2, 0]); + let array: ArrayRef = + Arc::new(DictionaryArray::::try_new(keys, values).unwrap()); + + let normalized = normalize_neg_zero_array(&array); + assert!(Arc::ptr_eq(&normalized, &array)); + } + + #[test] + fn normalize_ree_of_float32_peels_and_rewrites() { + let run_ends = Int32Array::from(vec![2, 3, 5]); + let values: ArrayRef = Arc::new(Float32Array::from(vec![-0.0, 1.0, 2.0])); + let array: ArrayRef = + Arc::new(RunArray::::try_new(&run_ends, values.as_ref()).unwrap()); + + let normalized = normalize_neg_zero_array(&array); + assert!(!Arc::ptr_eq(&normalized, &array)); + + let ree = normalized.as_ref().as_run::(); + let inner = ree.values().as_primitive::(); + assert!( + !inner + .values() + .iter() + .any(|v| v.is_sign_negative() && *v == 0.0) + ); + } + + #[test] + fn normalize_float16_rewrites_neg_zero() { + let array: ArrayRef = Arc::new(Float16Array::from(vec![ + f16::NEG_ZERO, + f16::ZERO, + f16::from_f32(1.5), + ])); + let normalized = normalize_neg_zero_array(&array); + + assert!(!Arc::ptr_eq(&normalized, &array)); + let values = normalized.as_primitive::().values(); + assert!( + !values + .iter() + .any(|v| v.is_sign_negative() && *v == f16::ZERO) + ); + assert_eq!(values[2], f16::from_f32(1.5)); + } + + #[test] + fn normalize_nested_dict_recurses() { + // Dictionary> — exercises the + // recursive peel through two layers of dictionary encoding. + let values: ArrayRef = Arc::new(Float64Array::from(vec![-0.0, 1.0])); + let inner_keys = Int32Array::from(vec![0, 1, 0]); + let inner_dict: ArrayRef = + Arc::new(DictionaryArray::::try_new(inner_keys, values).unwrap()); + let outer_keys = Int32Array::from(vec![0, 1, 2]); + let array: ArrayRef = Arc::new( + DictionaryArray::::try_new(outer_keys, inner_dict).unwrap(), + ); + + let normalized = normalize_neg_zero_array(&array); + assert!(!Arc::ptr_eq(&normalized, &array)); + + let outer = normalized + .as_ref() + .as_any() + .downcast_ref::>() + .unwrap(); + let inner = outer + .values() + .as_any() + .downcast_ref::>() + .unwrap(); + assert!(!has_neg_zero_f64(inner.values().as_ref())); + } + + #[test] + fn normalize_scalar_float64() { + assert_eq!( + normalize_neg_zero_scalar(&ScalarValue::Float64(Some(-0.0))), + ScalarValue::Float64(Some(0.0)) + ); + assert_eq!( + normalize_neg_zero_scalar(&ScalarValue::Float64(Some(0.0))), + ScalarValue::Float64(Some(0.0)) + ); + // Non-zero values are unchanged, including the sign of negative numbers. + assert_eq!( + normalize_neg_zero_scalar(&ScalarValue::Float64(Some(-1.5))), + ScalarValue::Float64(Some(-1.5)) + ); + assert_eq!( + normalize_neg_zero_scalar(&ScalarValue::Float64(None)), + ScalarValue::Float64(None) + ); + } + + #[test] + fn normalize_scalar_float32_and_float16() { + assert_eq!( + normalize_neg_zero_scalar(&ScalarValue::Float32(Some(-0.0))), + ScalarValue::Float32(Some(0.0)) + ); + assert_eq!( + normalize_neg_zero_scalar(&ScalarValue::Float16(Some(f16::NEG_ZERO))), + ScalarValue::Float16(Some(f16::ZERO)) + ); + } + + #[test] + fn normalize_scalar_non_float_passthrough() { + let s = ScalarValue::Int32(Some(0)); + assert_eq!(normalize_neg_zero_scalar(&s), s); + let s = ScalarValue::Utf8(Some("hello".into())); + assert_eq!(normalize_neg_zero_scalar(&s), s); + } +} diff --git a/datafusion/physical-expr/src/expressions/in_list.rs b/datafusion/physical-expr/src/expressions/in_list.rs index ea381a048320e..590300b6fde76 100644 --- a/datafusion/physical-expr/src/expressions/in_list.rs +++ b/datafusion/physical-expr/src/expressions/in_list.rs @@ -35,6 +35,9 @@ use datafusion_common::{ DFSchema, Result, ScalarValue, assert_or_internal_err, exec_err, }; use datafusion_expr::{ColumnarValue, expr_vec_fmt}; +use datafusion_physical_expr_common::datum::{ + normalize_neg_zero_array, normalize_neg_zero_scalar, +}; mod array_static_filter; mod primitive_filter; @@ -364,6 +367,10 @@ impl PhysicalExpr for InListExpr { // comparator for unsupported types (nested, RunEndEncoded, etc.). let value = value.into_array(num_rows)?; let lhs_supports_arrow_eq = supports_arrow_eq(value.data_type()); + // Normalize -0.0 to +0.0 in the LHS once up front so SQL equality + // semantics (where -0.0 == +0.0) hold against arrow's totalOrder + // comparison kernel. + let value_normalized = normalize_neg_zero_array(&value); // Helper: compare value against a single list expression let compare_one = |expr: &Arc| -> Result { @@ -372,7 +379,8 @@ impl PhysicalExpr for InListExpr { if lhs_supports_arrow_eq && supports_arrow_eq(array.data_type()) { - Ok(arrow_eq(&value, &array)?) + let rhs = normalize_neg_zero_array(&array); + Ok(arrow_eq(&value_normalized, &rhs)?) } else { let cmp = make_comparator( value.as_ref(), @@ -393,8 +401,9 @@ impl PhysicalExpr for InListExpr { // If scalar is null, all comparisons return null Ok(BooleanArray::from(vec![None; num_rows])) } else if lhs_supports_arrow_eq { - let scalar_datum = scalar.to_scalar()?; - Ok(arrow_eq(&value, &scalar_datum)?) + let rhs_scalar = normalize_neg_zero_scalar(&scalar); + let scalar_datum = rhs_scalar.to_scalar()?; + Ok(arrow_eq(&value_normalized, &scalar_datum)?) } else { // Convert scalar to 1-element array let array = scalar.to_array()?; diff --git a/datafusion/physical-expr/src/expressions/in_list/primitive_filter.rs b/datafusion/physical-expr/src/expressions/in_list/primitive_filter.rs index 2c084a1cb247b..c03b4a75c99c9 100644 --- a/datafusion/physical-expr/src/expressions/in_list/primitive_filter.rs +++ b/datafusion/physical-expr/src/expressions/in_list/primitive_filter.rs @@ -83,10 +83,20 @@ macro_rules! primitive_static_filter { $Name, $ArrowType, <$ArrowType as ArrowPrimitiveType>::Native, - |v| v + |v| v, + |_values, _v| {} ); }; ($Name:ident, $ArrowType:ty, $SetValueType:ty, $to_set_value:expr) => { + primitive_static_filter!( + $Name, + $ArrowType, + $SetValueType, + $to_set_value, + |_values, _v| {} + ); + }; + ($Name:ident, $ArrowType:ty, $SetValueType:ty, $to_set_value:expr, $extra_inserts:expr) => { pub(super) struct $Name { null_count: usize, values: HashSet<$SetValueType>, @@ -103,6 +113,7 @@ macro_rules! primitive_static_filter { for v in in_array.iter().flatten() { values.insert(($to_set_value)(v)); + ($extra_inserts)(&mut values, v); } Ok(Self { null_count, values }) @@ -224,7 +235,18 @@ primitive_static_filter!(UInt64StaticFilter, UInt64Type); // Floats require a wrapper type (OrderedFloat*) to implement Hash/Eq due to NaN semantics macro_rules! float_static_filter { ($Name:ident, $ArrowType:ty, $OrderedType:ty) => { - primitive_static_filter!($Name, $ArrowType, $OrderedType, <$OrderedType>::from); + primitive_static_filter!( + $Name, + $ArrowType, + $OrderedType, + <$OrderedType>::from, + |values: &mut HashSet<$OrderedType>, + v: <$ArrowType as ArrowPrimitiveType>::Native| { + if v == 0.0 { + values.insert(<$OrderedType>::from(-v)); + } + } + ); }; } diff --git a/datafusion/sqllogictest/test_files/negative_zero.slt b/datafusion/sqllogictest/test_files/negative_zero.slt new file mode 100644 index 0000000000000..454e3b03e0525 --- /dev/null +++ b/datafusion/sqllogictest/test_files/negative_zero.slt @@ -0,0 +1,151 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +## Per IEEE 754, +0.0 and -0.0 compare equal: +## -0.0 == 0.0 is true +## -0.0 < 0.0 is false +## -0.0 > 0.0 is false +## -0.0 >= 0.0 is true +## -0.0 <= 0.0 is true + +# ---- Float64 scalar literals ---- + +query BBBBB +SELECT + -0.0 = 0.0 AS eq, + -0.0 < 0.0 AS lt, + -0.0 <= 0.0 AS le, + -0.0 > 0.0 AS gt, + -0.0 >= 0.0 AS ge; +---- +true false true false true + +# ---- NotEq, IS NOT DISTINCT FROM, NULL interactions ---- + +query BBBBB +SELECT + -0.0 <> 0.0 AS neq, + -0.0 IS NOT DISTINCT FROM 0.0 AS not_distinct, + -0.0 = CAST(NULL AS DOUBLE) AS eq_null, + -0.0 IS DISTINCT FROM CAST(NULL AS DOUBLE) AS distinct_null, + -0.0 IS NOT DISTINCT FROM CAST(NULL AS DOUBLE) AS not_distinct_null; +---- +false true NULL true false + +# ---- Float32 scalar literals ---- + +query BBBBB +SELECT + CAST(-0.0 AS REAL) = CAST(0.0 AS REAL) AS eq, + CAST(-0.0 AS REAL) < CAST(0.0 AS REAL) AS lt, + CAST(-0.0 AS REAL) <= CAST(0.0 AS REAL) AS le, + CAST(-0.0 AS REAL) > CAST(0.0 AS REAL) AS gt, + CAST(-0.0 AS REAL) >= CAST(0.0 AS REAL) AS ge; +---- +true false true false true + +# ---- Negative zero produced by arithmetic on a column ---- + +statement ok +CREATE TABLE t AS VALUES (1, CAST(0.0 AS DOUBLE)); + +query IRRB +SELECT + column1 AS id, + column2 * -1 AS neg_zero, + (column2 * -1) AS neg_zero_again, + (column2 * -1) >= 0.0 AS cmp +FROM t; +---- +1 0 0 true + +# The filter below should remove the only row, since +# `(y * -1) >= 0.0` is true and `IS TRUE` is true. +query IR +SELECT column1 AS id, column2 * -1 AS m_0 +FROM t +WHERE NOT (((column2 * -1) >= 0.0) IS TRUE); +---- + +statement ok +DROP TABLE t; + +# ---- Column-vs-column comparison ---- + +statement ok +CREATE TABLE t2 AS VALUES (CAST(0.0 AS DOUBLE), CAST(-0.0 AS DOUBLE)); + +query BBBBB +SELECT + column1 = column2 AS eq, + column1 >= column2 AS ge_left, + column2 >= column1 AS ge_right, + column1 IS DISTINCT FROM column2 AS distinct_from, + column1 IS NOT DISTINCT FROM column2 AS not_distinct_from +FROM t2; +---- +true true true false true + +statement ok +DROP TABLE t2; + +# ---- IN list with -0.0 / +0.0 ---- +# +# Constant list -> static-filter path in InListExpr. + +query BBBB +SELECT + CAST(0.0 AS DOUBLE) IN (CAST(-0.0 AS DOUBLE), CAST(1.0 AS DOUBLE)) AS in_pos_in_neg, + CAST(-0.0 AS DOUBLE) IN (CAST(0.0 AS DOUBLE)) AS in_neg_in_pos, + CAST(0.0 AS DOUBLE) NOT IN (CAST(-0.0 AS DOUBLE)) AS not_in_pos_in_neg, + CAST(-0.0 AS DOUBLE) NOT IN (CAST(0.0 AS DOUBLE), CAST(1.0 AS DOUBLE)) AS not_in_neg_in_pos; +---- +true true false false + +# Non-constant list (column reference) -> no static filter, exercises the +# `value_normalized` / per-list-expression normalization path in in_list.rs. + +statement ok +CREATE TABLE t3 AS VALUES (CAST(0.0 AS DOUBLE), CAST(-0.0 AS DOUBLE)); + +query BB +SELECT + column1 IN (column2) AS in_col, + column1 NOT IN (column2) AS not_in_col +FROM t3; +---- +true false + +statement ok +DROP TABLE t3; + +# ---- NaN: arrow's totalOrder treats NaN == NaN as true (matches PostgreSQL). +# Normalizing -0.0 does not change NaN handling. +# +# Ordering comparisons are pinned here so that any future arrow/datafusion +# change in NaN ordering is caught by this test rather than silently drifting. + +query BBBBBB +SELECT + CAST('NaN' AS DOUBLE) = CAST('NaN' AS DOUBLE) AS nan_eq, + CAST('NaN' AS DOUBLE) <> CAST('NaN' AS DOUBLE) AS nan_neq, + CAST('NaN' AS DOUBLE) < CAST('NaN' AS DOUBLE) AS nan_lt, + CAST('NaN' AS DOUBLE) <= CAST('NaN' AS DOUBLE) AS nan_le, + CAST('NaN' AS DOUBLE) > CAST('NaN' AS DOUBLE) AS nan_gt, + CAST('NaN' AS DOUBLE) >= CAST('NaN' AS DOUBLE) AS nan_ge; +---- +true false false true false true