adriangb opened a new pull request, #25211:
URL: https://github.com/apache/datafusion/pull/25211

   ## Which issue does this PR close?
   
   - Closes issue 13212.
   
   ## Rationale for this change
   
   A `Timestamp(unit, Some(tz))` value is an instant; `tz` is only a display 
label
   attached to it. A `Timestamp(unit, None)` value is a wall clock reading with 
no
   instant attached. Converting the second into the first has to pick a zone to
   read the wall clock in, and that choice decides which instant you get.
   
   PostgreSQL and DuckDB both read it in the **session** time zone at every
   implicit conversion. DataFusion reads it in whatever zone happens to label 
the
   *other operand*, because type coercion emits a plain
   `CAST(naive AS Timestamp(u, Some(tz)))` and arrow's cast reads the naive 
value
   in `tz`. So with `datafusion.execution.time_zone` set, DataFusion answers a
   query differently from both of them:
   
   ```sql
   SET TIME ZONE = '+08';
   -- PostgreSQL 17 and DuckDB 1.5.2: 08:00:00
   -- DataFusion before this PR:      00:00:00
   SELECT '2024-11-01T00:00:00+00:00'::timestamptz - 
'2024-11-01T00:00:00'::timestamp;
   ```
   
   The same disagreement shows up wherever a naive timestamp meets an aware one:
   comparisons, `-`, `IN`, `BETWEEN`, `CASE`, `UNION`, `coalesce`,
   `greatest`/`least`, `nullif`, the array functions, `date_bin`'s origin, 
`VALUES`
   and `INSERT`.
   
   ## What changes are included in this PR?
   
   Wherever the planner or the analyzer **inserts** a cast from a (possibly 
nested)
   naive timestamp to an aware type whose zone differs from
   `datafusion.execution.time_zone`, a two step cast is emitted instead of one:
   
   ```text
   CAST(ts AS Timestamp(ns, "America/New_York"))
   -- becomes, with datafusion.execution.time_zone = '+08:00'
   CAST(CAST(ts AS Timestamp(ns, "+08:00")) AS Timestamp(ns, 
"America/New_York"))
   ```
   
   The inner cast reads the wall clock in the session zone; the outer one only
   relabels, since an aware -> aware cast preserves the instant. The coerced
   *type* is therefore unchanged and only the instant differs. Nested naive
   timestamps (`List`, `LargeList`, `FixedSizeList`, `Struct`, `Map`, 
`Dictionary`)
   are re-zoned leaf by leaf.
   
   **Explicit** conversions keep arrow's semantics untouched, which is also what
   PostgreSQL and DuckDB do for their explicit forms: `AT TIME ZONE`, 
`arrow_cast`,
   the DataFrame API's `cast_to`/`cast`, and Substrait casts. SQL
   `CAST(x AS TIMESTAMPTZ)` already targeted the session time zone.
   
   The distinction is exact by construction, not by inspection: the two step 
form
   is built at the point the cast is inserted, never by looking for inserted 
casts
   afterwards. A cast is only ever split by the code that created it, and an
   expression that already has the target type is never cast at all, so a cast 
the
   user wrote can never be mistaken for one DataFusion inserted. The three 
library
   functions that insert a cast into a whole plan rather than into one 
expression
   each gained a session-time-zone aware sibling for that reason.
   
   The commits are:
   
   1. `Timestamp(u, Some(tz)) - Timestamp(u, None)` at *equal* units is the one
      mixed pair arrow can subtract directly, so `BinaryTypeCoercer` inserted no
      cast at all and the naive operand was read as UTC — disagreeing with `=` 
on
      the same values and with every other unit pairing. It now coerces like the
      rest.
   2. The `cast_to_with_session_time_zone` helper in `datafusion-expr`, plus a
      sibling for each of the three library functions that build a whole plan of
      casts rather than one cast: `coerce_plan_expr_for_schema`, `cast_subquery`
      and `LogicalPlanBuilder::values`/`values_with_schema`. Each sibling takes 
the
      session time zone and calls the helper at the exact point it inserts a 
cast;
      the existing functions become thin wrappers passing `None`, so their
      signatures and behaviour are unchanged.
   3. Every cast the analyzer inserts routed through it, opted into with the new
      `TypeCoercionRewriter::with_session_time_zone`. `ExprSimplifier::coerce`
      passes the session zone too, so `SessionContext::create_physical_expr`
      behaves like SQL. The two optimizer rules that build a rewriter over an
      already-coerced plan deliberately do not. A scalar function argument that 
is
      already a literal was folded to the coerced type outright rather than 
wrapped
      in a cast, which read its wall clock in the coerced type's own zone; it 
now
      keeps the split cast when there is one, and is folded as before when 
there is
      not.
   4. The SQL planner's own insertion points: `INSERT`, `UPDATE ... SET` and
      `VALUES`. `LogicalPlanBuilder`'s existing API is unchanged.
   5. Tests.
   6. Docs.
   
   `datafusion.execution.time_zone` is unset by default, and with no session 
time
   zone every plan is byte-identical to today's.
   
   ## What is the testing strategy for this PR?
   
   - Unit tests for the helper in
     `datafusion/expr/src/type_coercion/session_time_zone.rs`: each nested type 
and
     the explicit-cast and no-session-zone no-ops.
   - Snapshot tests in `datafusion/optimizer/src/analyzer/type_coercion.rs` 
pinning
     the analyzed plan for subtraction, `= ANY (<subquery>)`, `UNION`, `CASE` 
and a
     coerced function argument — each paired with the plan produced with no 
session
     time zone, which must stay exactly what DataFusion produced before. A 
`UNION`
     branch and a subquery projection that are *already* an explicit cast to the
     common type are pinned too, and must not be split.
   - Plan snapshots in `datafusion/sql/tests/sql_integration.rs` for a `VALUES`
     list under a session time zone: the naive cell carries the two step cast 
and
     the `AT TIME ZONE` cell is untouched.
   - A section in `datafusion/sqllogictest/test_files/datetime/timestamps.slt`
     covering every site end to end, with each expectation being the instant
     PostgreSQL and DuckDB return. It also pins the plans, so that the two 
casts are
     visible and provably survive `simplify_expressions`; the default behaviour 
with
     no session time zone; a named session zone across a DST boundary (EST and
     EDT); and `AT TIME ZONE` / `arrow_cast`, which must be unaffected — 
including
     an `AT TIME ZONE` at the top of a `UNION` branch, a subquery projection, a
     `VALUES` cell and a view.
   - `test_create_physical_expr_timestamp_subtraction_uses_session_timezone` in
     `datafusion/core/tests/expr_api/mod.rs` for the `create_physical_expr` 
path.
   - The full sqllogictest suite (516 files), `datafusion-substrait` and
     `datafusion-proto` all pass, the latter two confirming that plans carrying 
the
     nested casts round-trip.
   
   One pre-existing expectation was wrong and is corrected:
   `date_bin('1 day', TIMESTAMPTZ '2022-01-01 20:10:00Z', TIMESTAMP 
'2020-01-01')`
   under `+07` was pinned at `2022-01-01T07:00:00+07:00`, i.e. the naive origin 
read
   as UTC. PostgreSQL and DuckDB both return `2022-01-02T00:00:00+07:00`, which 
is
   also what the TIMESTAMPTZ-origin case immediately above it already expected.
   
   ## Are there any user-facing changes?
   
   Yes, and they are documented in the 56.0.0 upgrade guide.
   
   - With `datafusion.execution.time_zone` set, a timezone-naive timestamp is 
read
     in that zone wherever DataFusion implicitly converts it to a timezone-aware
     one. The default is unset, which keeps the previous behaviour.
   - `Timestamp(u, Some(tz)) - Timestamp(u, None)` at equal units now reads the
     naive operand in the aware operand's zone even with no session time zone,
     rather than as UTC.
   - Under a **named** session time zone, a naive wall clock value that falls 
in a
     daylight saving gap or fold currently errors with arrow's
     `Cannot cast timezone to different timezone` instead of resolving to an
     instant. This is a pre-existing arrow limitation that a named session time 
zone
     now reaches in more places; it is fixed by
     <https://github.com/apache/arrow-rs/pull/11038> and
     <https://github.com/apache/arrow-rs/pull/11054>, tracked on the DataFusion 
side
     at <https://github.com/apache/datafusion/issues/25084>. Fixed offsets are
     unaffected.
   
   New public API (no breaking changes):
   
   - 
`datafusion_expr::type_coercion::session_time_zone::cast_to_with_session_time_zone`
   - 
`datafusion_expr::expr_rewriter::coerce_plan_expr_for_schema_with_session_time_zone`
   - `datafusion_expr::expr_schema::cast_subquery_with_session_time_zone`
   - `datafusion_expr::LogicalPlanBuilder::values_with_session_time_zone`
   - `TypeCoercionRewriter::with_session_time_zone`. Applying the session time 
zone
     is opt in: `TypeCoercionRewriter::new` alone coerces exactly as it did 
before,
     so callers who coerce expressions themselves and want to match SQL planning
     need to call it.
   
   🤖 Generated with [Claude Code](https://claude.com/claude-code)
   


-- 
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