sunchao commented on code in PR #5177:
URL: https://github.com/apache/datafusion-comet/pull/5177#discussion_r3918262116


##########
native/core/src/parquet/parquet_support.rs:
##########
@@ -195,6 +217,28 @@ fn parquet_convert_array(
                 list_arr.nulls().cloned(),
             )))
         }
+        (
+            Timestamp(TimeUnit::Millisecond, _),
+            Timestamp(TimeUnit::Microsecond, target_tz),
+        ) if top_level && parquet_options.checked_timestamp_overflow => {
+            // Spark's Parquet reader calls the checked `millisToMicros` 
conversion for both
+            // direct and dictionary values, independent of CAST evaluation 
mode:
+            // 
https://github.com/apache/spark/blob/v4.2.0/sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/ParquetVectorUpdaterFactory.java#L817-L833
+            // `millisToMicros` uses `Math.multiplyExact`:
+            // 
https://github.com/apache/spark/blob/v4.2.0/sql/api/src/main/scala/org/apache/spark/sql/catalyst/util/SparkDateTimeUtils.scala#L103-L108
+            //
+            // The checked conversion is limited to TOP-LEVEL columns. Spark 
only avoids the
+            // error for filtered-out values through row-group statistics 
pruning, and
+            // DataFusion's PruningPredicate does not support nested fields 
yet, so a checked
+            // conversion on a nested field would fail queries whose 
predicates Spark prunes
+            // (e.g. `WHERE s.ts < X` over an all-overflowing file). Nested 
fields keep the
+            // pre-existing safe-cast behavior below (overflow -> NULL).
+            let micros = array
+                .as_primitive::<TimestampMillisecondType>()
+                .try_unary::<_, TimestampMicrosecondType, _>(|value| 
value.mul_checked(1_000))?

Review Comment:
   **[P2] Preserve dictionary pruning before checked timestamp conversion**
   
   A row group can be excluded by Spark's dictionary filter even when its 
min/max statistics overlap the predicate. I reproduced this with one 32-row 
group containing `id: INT32` alternating between 1 and 3, and `ts: 
TIMESTAMP_MILLIS = 9223372036854776`. For `SELECT ts WHERE id = 2`, Spark 4.1.3 
and base `215ab706` return zero rows, but this head throws `Overflow happened 
on: 9223372036854776 * 1000` with 
`spark.comet.parquet.rowFilterPushdown.enabled=false` (the default).
   
   The footer verifies dictionary-only data pages and `id` min/max of 1/3; 
there is no schema widening. With plain encoding, both Spark and the head 
overflow, while enabling Comet row-filter pushdown makes the dictionary case 
succeed. This isolates a pruning gap beyond the timestamp-expression rewrite: 
checked conversion now runs on data Spark never decodes. Please preserve 
dictionary-based elimination, or use a fallback that preserves the successful 
query, before applying checked conversion to these scans; add the 
dictionary-only exclusion as a regression test.



##########
native/core/src/parquet/schema_adapter.rs:
##########
@@ -522,7 +534,220 @@ impl PhysicalExprAdapter for SparkPhysicalExprAdapter {
     }
 }
 
+/// The wrapped expression and the file column's timezone of a millis->micros 
cast.
+type MillisCastParts = (Arc<dyn PhysicalExpr>, Option<Arc<str>>);
+
+/// If `expr` is a checked millis->micros [`CometCastColumnExpr`], return its 
wrapped
+/// expression and the file column's timezone.
+fn as_millis_to_micros_cast(expr: &Arc<dyn PhysicalExpr>) -> 
Option<MillisCastParts> {
+    let cast = expr.downcast_ref::<CometCastColumnExpr>()?;
+    match (
+        cast.input_physical_field().data_type(),
+        cast.target_field().data_type(),
+    ) {
+        (
+            DataType::Timestamp(TimeUnit::Millisecond, file_tz),
+            DataType::Timestamp(TimeUnit::Microsecond, _),
+        ) => Some((Arc::clone(cast.expr()), file_tz.clone())),
+        _ => None,
+    }
+}
+
+/// If `expr` is a microsecond timestamp literal (null or not), return its 
value.
+fn as_micros_literal(expr: &Arc<dyn PhysicalExpr>) -> Option<Option<i64>> {
+    match expr.downcast_ref::<Literal>()?.value() {
+        ScalarValue::TimestampMicrosecond(micros, _) => Some(*micros),
+        _ => None,
+    }
+}
+
 impl SparkPhysicalExprAdapter {
+    /// Rewrite predicate expressions over a `TIMESTAMP_MILLIS` file column 
read as
+    /// microseconds into the millisecond domain — comparisons (including 
null-safe
+    /// `<=>`), `IN` lists, and null checks — mirroring Spark's 
`ParquetFilters`,
+    /// which pushes timestamp predicates down in the file's physical unit:
+    /// 
https://github.com/apache/spark/blob/v4.2.0/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFilters.scala#L192-L196
+    ///
+    /// The wrapped `CometCastColumnExpr` is opaque to DataFusion's 
pruning-predicate
+    /// analyzer, so leaving the conversion inside the predicate defeats 
row-group
+    /// statistics pruning — and the checked conversion then raises overflow 
errors for
+    /// values Spark never reads, because Spark prunes them from the 
millisecond
+    /// statistics. Comparing raw millisecond values against a rescaled 
literal is exact
+    /// (`m * 1000 OP lit` over the integers), never overflows, and DataFusion 
can prune
+    /// with it. Values that survive the filter still go through the checked 
conversion
+    /// when the scan output is adapted, so genuine reads of overflowing 
values keep
+    /// failing like Spark's `millisToMicros`.
+    fn rewrite_millis_timestamp_comparison(
+        &self,
+        expr: Arc<dyn PhysicalExpr>,
+    ) -> DataFusionResult<Transformed<Arc<dyn PhysicalExpr>>> {
+        // `ts IS NULL` / `ts IS NOT NULL`: the checked conversion preserves 
null-ness
+        // (it errors rather than producing nulls), so test the raw column 
directly.
+        if let Some(is_null) = expr.downcast_ref::<IsNullExpr>() {
+            if let Some((inner, _)) = as_millis_to_micros_cast(is_null.arg()) {
+                return Ok(Transformed::yes(Arc::new(IsNullExpr::new(inner))));
+            }
+        }
+        if let Some(is_not_null) = expr.downcast_ref::<IsNotNullExpr>() {
+            if let Some((inner, _)) = 
as_millis_to_micros_cast(is_not_null.arg()) {
+                return 
Ok(Transformed::yes(Arc::new(IsNotNullExpr::new(inner))));
+            }
+        }
+
+        // `ts IN (...)` / `ts NOT IN (...)`: rescale every microsecond 
literal in the
+        // list. DataFusion's pruning predicate understands `InListExpr` (up 
to 20
+        // elements), so keeping the raw column visible restores row-group 
pruning,
+        // mirroring Spark's `ParquetFilters` handling of `In`.
+        if let Some(in_list_expr) = expr.downcast_ref::<InListExpr>() {
+            if let Some((inner, file_tz)) = 
as_millis_to_micros_cast(in_list_expr.expr()) {
+                return self.rewrite_millis_in_list(&expr, in_list_expr, inner, 
file_tz);
+            }
+        }
+
+        let Some(binary) = expr.downcast_ref::<BinaryExpr>() else {
+            return Ok(Transformed::no(expr));
+        };
+        let matched = if let (Some(cast), Some(micros)) = (
+            as_millis_to_micros_cast(binary.left()),
+            as_micros_literal(binary.right()),
+        ) {
+            Some((cast, *binary.op(), micros))
+        } else if let (Some(micros), Some(cast)) = (
+            as_micros_literal(binary.left()),
+            as_millis_to_micros_cast(binary.right()),
+        ) {
+            binary.op().swap().map(|op| (cast, op, micros))
+        } else {
+            None
+        };
+        let Some(((inner, file_tz), op, micros)) = matched else {
+            return Ok(Transformed::no(expr));
+        };
+
+        let Some(micros) = micros else {
+            // NULL literal. The ordinary comparisons return NULL for every 
row on both
+            // sides of the rewrite, so rescaling the literal to a NULL 
millisecond
+            // literal is exact. The null-safe comparisons test null-ness of 
the value,
+            // which the checked conversion preserves, so probe the raw column.
+            let rewritten: Arc<dyn PhysicalExpr> = match op {
+                Operator::IsNotDistinctFrom => 
Arc::new(IsNullExpr::new(inner)),
+                Operator::IsDistinctFrom => 
Arc::new(IsNotNullExpr::new(inner)),
+                Operator::Lt
+                | Operator::LtEq
+                | Operator::Gt
+                | Operator::GtEq
+                | Operator::Eq
+                | Operator::NotEq => Arc::new(BinaryExpr::new(
+                    inner,
+                    op,
+                    Arc::new(Literal::new(ScalarValue::TimestampMillisecond(
+                        None, file_tz,
+                    ))),
+                )),
+                _ => return Ok(Transformed::no(expr)),
+            };
+            return Ok(Transformed::yes(rewritten));
+        };
+
+        // For a file value `m` (milliseconds) the logical value is exactly `m 
* 1000`
+        // microseconds, so `m * 1000 OP L` rewrites to an exact comparison on 
`m`.
+        let floor = micros.div_euclid(1_000);
+        let ceil = floor + i64::from(micros.rem_euclid(1_000) != 0);
+        let exact = micros % 1_000 == 0;
+        let (op, millis) = match op {
+            Operator::Lt => (Operator::Lt, ceil),
+            Operator::LtEq => (Operator::LtEq, floor),
+            Operator::Gt => (Operator::Gt, floor),
+            Operator::GtEq => (Operator::GtEq, ceil),
+            Operator::Eq if exact => (Operator::Eq, floor),
+            Operator::NotEq if exact => (Operator::NotEq, floor),
+            Operator::IsNotDistinctFrom if exact => 
(Operator::IsNotDistinctFrom, floor),
+            Operator::IsDistinctFrom if exact => (Operator::IsDistinctFrom, 
floor),
+            // A sub-millisecond literal can never equal `m * 1000`. `col < 
i64::MIN`
+            // (resp. `col >= i64::MIN`) is false (resp. true) for every 
non-null value
+            // and NULL for nulls, matching `=` / `!=` null semantics while 
remaining a
+            // plain, prunable comparison.
+            Operator::Eq => (Operator::Lt, i64::MIN),
+            Operator::NotEq => (Operator::GtEq, i64::MIN),
+            // The null-safe comparisons never return NULL, so an impossible 
literal
+            // folds to a constant. DataFusion's pruning predicate evaluates 
boolean
+            // literals, so `false` still prunes every row group.
+            Operator::IsNotDistinctFrom => {
+                return Ok(Transformed::yes(Arc::new(Literal::new(
+                    ScalarValue::Boolean(Some(false)),
+                ))));
+            }
+            Operator::IsDistinctFrom => {
+                return Ok(Transformed::yes(Arc::new(Literal::new(
+                    ScalarValue::Boolean(Some(true)),
+                ))));
+            }
+            _ => return Ok(Transformed::no(expr)),
+        };
+        let literal = Arc::new(Literal::new(ScalarValue::TimestampMillisecond(
+            Some(millis),
+            file_tz,
+        )));
+        Ok(Transformed::yes(Arc::new(BinaryExpr::new(
+            inner, op, literal,
+        ))))
+    }
+
+    /// Rewrite `ts IN (...)` / `ts NOT IN (...)` over a millis->micros 
conversion into
+    /// the millisecond domain. Divisible literals rescale exactly; 
sub-millisecond
+    /// literals can never equal `m * 1000` and drop out of the list, which 
preserves
+    /// IN's three-valued logic (a dropped element contributes `false` to the 
OR / `true`
+    /// to the AND for every non-null probe, and the null-probe result stays 
NULL either
+    /// way). A list emptied this way degenerates to the same always-false /
+    /// always-true-with-null-semantics sentinels the `=` / `!=` rewrite uses.
+    fn rewrite_millis_in_list(
+        &self,
+        original: &Arc<dyn PhysicalExpr>,
+        in_list_expr: &InListExpr,
+        inner: Arc<dyn PhysicalExpr>,
+        file_tz: Option<Arc<str>>,
+    ) -> DataFusionResult<Transformed<Arc<dyn PhysicalExpr>>> {
+        let mut millis_list: Vec<Arc<dyn PhysicalExpr>> = Vec::new();
+        for item in in_list_expr.list() {
+            // Any non-literal or non-timestamp element: leave the expression 
alone.
+            let Some(micros) = as_micros_literal(item) else {
+                return Ok(Transformed::no(Arc::clone(original)));
+            };
+            match micros {
+                None => millis_list.push(Arc::new(Literal::new(
+                    ScalarValue::TimestampMillisecond(None, file_tz.clone()),
+                ))),
+                Some(v) if v % 1_000 == 0 => 
millis_list.push(Arc::new(Literal::new(
+                    ScalarValue::TimestampMillisecond(Some(v / 1_000), 
file_tz.clone()),
+                ))),
+                Some(_) => {}
+            }
+        }
+        let negated = in_list_expr.negated();
+        if millis_list.is_empty() {
+            let (op, sentinel) = if negated {
+                (Operator::GtEq, i64::MIN)
+            } else {
+                (Operator::Lt, i64::MIN)
+            };
+            return Ok(Transformed::yes(Arc::new(BinaryExpr::new(
+                inner,
+                op,
+                Arc::new(Literal::new(ScalarValue::TimestampMillisecond(
+                    Some(sentinel),
+                    file_tz,
+                ))),
+            ))));
+        }
+        let rewritten = in_list(

Review Comment:
   **[P2] Preserve pruning for timestamp IN lists longer than 20 values**
   
   Returning a raw `InListExpr` only restores statistics pruning up to 
DataFusion 54.1's `MAX_LIST_VALUE_SIZE_REWRITE = 20`. I reproduced the boundary 
with 32 repeated `9223372036854776` millisecond values and `ts IN (...)` 
containing 21 timestamps from `1970-01-01 00:00:00` through `00:00:20`: Spark 
4.1.3 and base `215ab706` return zero rows, but this head throws checked 
timestamp overflow when row-filter pushdown is disabled (the default).
   
   All four plain/dictionary × ANSI combinations fail on the head; the 20-value 
and 2-value controls pass, and enabling row-filter pushdown avoids the error. 
The rewritten list is still not statistics-prunable above the dependency's 
cutoff. Please provide a prunable representation or a compatible fallback for 
longer lists, and cover the 20/21 boundary with the projected overflowing 
timestamp.



##########
native/core/src/parquet/parquet_support.rs:
##########
@@ -195,6 +217,28 @@ fn parquet_convert_array(
                 list_arr.nulls().cloned(),
             )))
         }
+        (
+            Timestamp(TimeUnit::Millisecond, _),
+            Timestamp(TimeUnit::Microsecond, target_tz),
+        ) if top_level && parquet_options.checked_timestamp_overflow => {

Review Comment:
   **[P2] Preserve pruning when an unrelated filter column is widened**
   
   The checked conversion also turns an existing pruning limitation on a 
different column into a query failure. With a file containing `id: INT32 = 1` 
and `ts: TIMESTAMP_MILLIS = 9223372036854776`, read `id` using `LongType` and 
run `SELECT ts WHERE id < 0`. Spark 4.1.3 and base `215ab706` return zero rows, 
but this head throws timestamp overflow with row-filter pushdown disabled (the 
default).
   
   The widening filter remains wrapped in an opaque `CometCastColumnExpr`, so 
DataFusion cannot use its statistics to discard the group before this new 
checked timestamp conversion. All four plain/dictionary × ANSI cases reproduce; 
reading `id` as its original IntegerType passes, as does enabling row-filter 
pushdown. Please preserve pruning for these supported widening casts, or fall 
back safely for affected scans, and add this unrelated-filter/schema-evolution 
case alongside the timestamp-predicate tests.



##########
native/core/src/parquet/schema_adapter.rs:
##########
@@ -522,7 +534,220 @@ impl PhysicalExprAdapter for SparkPhysicalExprAdapter {
     }
 }
 
+/// The wrapped expression and the file column's timezone of a millis->micros 
cast.
+type MillisCastParts = (Arc<dyn PhysicalExpr>, Option<Arc<str>>);
+
+/// If `expr` is a checked millis->micros [`CometCastColumnExpr`], return its 
wrapped
+/// expression and the file column's timezone.
+fn as_millis_to_micros_cast(expr: &Arc<dyn PhysicalExpr>) -> 
Option<MillisCastParts> {
+    let cast = expr.downcast_ref::<CometCastColumnExpr>()?;
+    match (
+        cast.input_physical_field().data_type(),
+        cast.target_field().data_type(),
+    ) {
+        (
+            DataType::Timestamp(TimeUnit::Millisecond, file_tz),
+            DataType::Timestamp(TimeUnit::Microsecond, _),
+        ) => Some((Arc::clone(cast.expr()), file_tz.clone())),
+        _ => None,
+    }
+}
+
+/// If `expr` is a microsecond timestamp literal (null or not), return its 
value.
+fn as_micros_literal(expr: &Arc<dyn PhysicalExpr>) -> Option<Option<i64>> {
+    match expr.downcast_ref::<Literal>()?.value() {
+        ScalarValue::TimestampMicrosecond(micros, _) => Some(*micros),
+        _ => None,
+    }
+}
+
 impl SparkPhysicalExprAdapter {
+    /// Rewrite predicate expressions over a `TIMESTAMP_MILLIS` file column 
read as
+    /// microseconds into the millisecond domain — comparisons (including 
null-safe
+    /// `<=>`), `IN` lists, and null checks — mirroring Spark's 
`ParquetFilters`,
+    /// which pushes timestamp predicates down in the file's physical unit:
+    /// 
https://github.com/apache/spark/blob/v4.2.0/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFilters.scala#L192-L196
+    ///
+    /// The wrapped `CometCastColumnExpr` is opaque to DataFusion's 
pruning-predicate
+    /// analyzer, so leaving the conversion inside the predicate defeats 
row-group
+    /// statistics pruning — and the checked conversion then raises overflow 
errors for
+    /// values Spark never reads, because Spark prunes them from the 
millisecond
+    /// statistics. Comparing raw millisecond values against a rescaled 
literal is exact
+    /// (`m * 1000 OP lit` over the integers), never overflows, and DataFusion 
can prune
+    /// with it. Values that survive the filter still go through the checked 
conversion
+    /// when the scan output is adapted, so genuine reads of overflowing 
values keep
+    /// failing like Spark's `millisToMicros`.
+    fn rewrite_millis_timestamp_comparison(
+        &self,
+        expr: Arc<dyn PhysicalExpr>,
+    ) -> DataFusionResult<Transformed<Arc<dyn PhysicalExpr>>> {
+        // `ts IS NULL` / `ts IS NOT NULL`: the checked conversion preserves 
null-ness
+        // (it errors rather than producing nulls), so test the raw column 
directly.
+        if let Some(is_null) = expr.downcast_ref::<IsNullExpr>() {
+            if let Some((inner, _)) = as_millis_to_micros_cast(is_null.arg()) {
+                return Ok(Transformed::yes(Arc::new(IsNullExpr::new(inner))));
+            }
+        }
+        if let Some(is_not_null) = expr.downcast_ref::<IsNotNullExpr>() {
+            if let Some((inner, _)) = 
as_millis_to_micros_cast(is_not_null.arg()) {
+                return 
Ok(Transformed::yes(Arc::new(IsNotNullExpr::new(inner))));
+            }
+        }
+
+        // `ts IN (...)` / `ts NOT IN (...)`: rescale every microsecond 
literal in the
+        // list. DataFusion's pruning predicate understands `InListExpr` (up 
to 20
+        // elements), so keeping the raw column visible restores row-group 
pruning,
+        // mirroring Spark's `ParquetFilters` handling of `In`.
+        if let Some(in_list_expr) = expr.downcast_ref::<InListExpr>() {
+            if let Some((inner, file_tz)) = 
as_millis_to_micros_cast(in_list_expr.expr()) {
+                return self.rewrite_millis_in_list(&expr, in_list_expr, inner, 
file_tz);
+            }
+        }
+
+        let Some(binary) = expr.downcast_ref::<BinaryExpr>() else {
+            return Ok(Transformed::no(expr));
+        };
+        let matched = if let (Some(cast), Some(micros)) = (
+            as_millis_to_micros_cast(binary.left()),
+            as_micros_literal(binary.right()),
+        ) {
+            Some((cast, *binary.op(), micros))
+        } else if let (Some(micros), Some(cast)) = (
+            as_micros_literal(binary.left()),
+            as_millis_to_micros_cast(binary.right()),
+        ) {
+            binary.op().swap().map(|op| (cast, op, micros))
+        } else {
+            None
+        };
+        let Some(((inner, file_tz), op, micros)) = matched else {
+            return Ok(Transformed::no(expr));
+        };
+
+        let Some(micros) = micros else {
+            // NULL literal. The ordinary comparisons return NULL for every 
row on both
+            // sides of the rewrite, so rescaling the literal to a NULL 
millisecond
+            // literal is exact. The null-safe comparisons test null-ness of 
the value,
+            // which the checked conversion preserves, so probe the raw column.
+            let rewritten: Arc<dyn PhysicalExpr> = match op {
+                Operator::IsNotDistinctFrom => 
Arc::new(IsNullExpr::new(inner)),
+                Operator::IsDistinctFrom => 
Arc::new(IsNotNullExpr::new(inner)),
+                Operator::Lt
+                | Operator::LtEq
+                | Operator::Gt
+                | Operator::GtEq
+                | Operator::Eq
+                | Operator::NotEq => Arc::new(BinaryExpr::new(
+                    inner,
+                    op,
+                    Arc::new(Literal::new(ScalarValue::TimestampMillisecond(
+                        None, file_tz,
+                    ))),
+                )),
+                _ => return Ok(Transformed::no(expr)),
+            };
+            return Ok(Transformed::yes(rewritten));
+        };
+
+        // For a file value `m` (milliseconds) the logical value is exactly `m 
* 1000`
+        // microseconds, so `m * 1000 OP L` rewrites to an exact comparison on 
`m`.
+        let floor = micros.div_euclid(1_000);
+        let ceil = floor + i64::from(micros.rem_euclid(1_000) != 0);
+        let exact = micros % 1_000 == 0;
+        let (op, millis) = match op {
+            Operator::Lt => (Operator::Lt, ceil),
+            Operator::LtEq => (Operator::LtEq, floor),
+            Operator::Gt => (Operator::Gt, floor),
+            Operator::GtEq => (Operator::GtEq, ceil),
+            Operator::Eq if exact => (Operator::Eq, floor),
+            Operator::NotEq if exact => (Operator::NotEq, floor),
+            Operator::IsNotDistinctFrom if exact => 
(Operator::IsNotDistinctFrom, floor),
+            Operator::IsDistinctFrom if exact => (Operator::IsDistinctFrom, 
floor),
+            // A sub-millisecond literal can never equal `m * 1000`. `col < 
i64::MIN`
+            // (resp. `col >= i64::MIN`) is false (resp. true) for every 
non-null value
+            // and NULL for nulls, matching `=` / `!=` null semantics while 
remaining a
+            // plain, prunable comparison.
+            Operator::Eq => (Operator::Lt, i64::MIN),
+            Operator::NotEq => (Operator::GtEq, i64::MIN),
+            // The null-safe comparisons never return NULL, so an impossible 
literal
+            // folds to a constant. DataFusion's pruning predicate evaluates 
boolean
+            // literals, so `false` still prunes every row group.
+            Operator::IsNotDistinctFrom => {
+                return Ok(Transformed::yes(Arc::new(Literal::new(
+                    ScalarValue::Boolean(Some(false)),
+                ))));
+            }
+            Operator::IsDistinctFrom => {
+                return Ok(Transformed::yes(Arc::new(Literal::new(
+                    ScalarValue::Boolean(Some(true)),
+                ))));
+            }
+            _ => return Ok(Transformed::no(expr)),
+        };
+        let literal = Arc::new(Literal::new(ScalarValue::TimestampMillisecond(
+            Some(millis),
+            file_tz,
+        )));
+        Ok(Transformed::yes(Arc::new(BinaryExpr::new(
+            inner, op, literal,
+        ))))
+    }
+
+    /// Rewrite `ts IN (...)` / `ts NOT IN (...)` over a millis->micros 
conversion into
+    /// the millisecond domain. Divisible literals rescale exactly; 
sub-millisecond
+    /// literals can never equal `m * 1000` and drop out of the list, which 
preserves
+    /// IN's three-valued logic (a dropped element contributes `false` to the 
OR / `true`
+    /// to the AND for every non-null probe, and the null-probe result stays 
NULL either
+    /// way). A list emptied this way degenerates to the same always-false /
+    /// always-true-with-null-semantics sentinels the `=` / `!=` rewrite uses.
+    fn rewrite_millis_in_list(
+        &self,
+        original: &Arc<dyn PhysicalExpr>,
+        in_list_expr: &InListExpr,
+        inner: Arc<dyn PhysicalExpr>,
+        file_tz: Option<Arc<str>>,
+    ) -> DataFusionResult<Transformed<Arc<dyn PhysicalExpr>>> {
+        let mut millis_list: Vec<Arc<dyn PhysicalExpr>> = Vec::new();
+        for item in in_list_expr.list() {
+            // Any non-literal or non-timestamp element: leave the expression 
alone.
+            let Some(micros) = as_micros_literal(item) else {
+                return Ok(Transformed::no(Arc::clone(original)));
+            };
+            match micros {
+                None => millis_list.push(Arc::new(Literal::new(
+                    ScalarValue::TimestampMillisecond(None, file_tz.clone()),
+                ))),
+                Some(v) if v % 1_000 == 0 => 
millis_list.push(Arc::new(Literal::new(
+                    ScalarValue::TimestampMillisecond(Some(v / 1_000), 
file_tz.clone()),
+                ))),
+                Some(_) => {}
+            }
+        }
+        let negated = in_list_expr.negated();
+        if millis_list.is_empty() {

Review Comment:
   **[P2] Preserve NULL rows for an originally empty NOT IN list**
   
   This branch conflates an originally empty list with a nonempty list emptied 
by dropping sub-millisecond literals. For original empty `NOT IN`, Spark's 
nonlegacy semantics and DataFusion return true even for a NULL probe; the 
generated `ts >= i64::MIN` instead returns NULL and can remove matching rows.
   
   I reproduced this on Spark 4.1.3 with `df.filter(!col("ts").isin())`, 
`spark.sql.legacy.nullInEmptyListBehavior=false`, and `OptimizeIn` plus 
`ConvertToLocalRelation` excluded so the empty expression survives 
optimization. For `[NULL, epoch]`, row-filter pushdown drops the NULL row; for 
an all-NULL group, the head returns zero rows with pushdown either on or off. 
Spark and base `215ab706` retain both rows in all cases. The direct expression 
check likewise changes `[true, true]` to `[NULL, true]`.
   
   Please distinguish an originally empty list before using the sentinel 
comparison, and add NULL-input coverage. The normal optimizer folds this case 
away, so the Spark exposure is conditional on the stated optimizer 
configuration.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to