Repository navigation
Conversation
|
This PR looks like a very nice solution for the cast pattern. I'm comfortable proceeding with it, but please forgive me for briefly advocating an alternative approach (that I'm to happy to help reviewing or implementing): I believe the fundamental goal here is to enable pruning through nested expressions, and the propagation based approach could be a better long term solution. My concern with the preimage approach is that it requires introducing and maintaining an ever-growing set of reverse-transformation rules. Even with additional rules, there will likely still be cases that cannot be handled. If this becomes a supported pattern, I worry that the long-term maintenance burden could be significant. In contrast, the propagation approach seems both more general and easier to reason about. The key intuition is that it follows a forward-evaluation model, similar to normal expression evaluation, whereas the preimage approach attempts to reverse complex expressions back into a simpler form. In many cases, the latter is inherently more difficult and may require expression-specific logic. |
|
I think this idea shows promise -- I will review it more carefully shortly |
a292aab to
17d45ee
Compare
|
Hi @alamb, quick update: I rebased this PR onto latest main and all CI checks are green now. No rush, but when you get a chance I'd appreciate your review. |
|
Thank you -- I will try and review it shorlty. |
17d45ee to
574c724
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #22906 +/- ##
==========================================
+ Coverage 82.74% 82.95% +0.20%
==========================================
Files 1147 1155 +8
Lines 449755 459335 +9580
Branches 449755 459335 +9580
==========================================
+ Hits 372168 381029 +8861
- Misses 54915 55243 +328
- Partials 22672 23063 +391 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
574c724 to
afe8c38
Compare
|
One scope question before review: the latest push includes the ordered The motivating shape appears after mixed timestamp coercion, for example: The non-aligned literal has no singleton equality preimage, so equality and More generally, for widening ratio
The implementation uses Euclidean There is an important policy caveat: for extreme source values where widening So I see two reasonable choices:
I am happy to keep the latest commits or split them back out, depending on what |
3d87cdb to
70fa177
Compare
Signed-off-by: discord9 <discord9@163.com>
Signed-off-by: discord9 <discord9@163.com>
Signed-off-by: discord9 <discord9@163.com>
Signed-off-by: discord9 <discord9@163.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
70fa177 to
0835709
Compare
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
jayzhan211
left a comment
There was a problem hiding this comment.
Thanks @discord9 , I left some suggestions
| ) | ||
| } | ||
|
|
||
| fn maybe_range_preimage( |
There was a problem hiding this comment.
Casting a timezone-naive timestamp to a timezone-aware one isn't a pure unit change: arrow-cast calls adjust_timestamp_to_timezone for (None, Some(tz)), which reads the stored value as local time and shifts it to UTC. timestamp_narrowing_range_preimage builds the bucket from the raw literal and ignores this, so the range is off by the zone offset. On main this query wasn't rewritten. The exact path (is_exact_cast_safe, same-unit and widening) has the same hole, and #22142 lists it as "timezone silently dropped".
create table t(ts timestamp, l timestamp) as values
('2024-01-01T00:00:00.5'::timestamp, '2024-01-01T00:00:00.5'::timestamp);
-- literal moved into a column, no rewrite: 1
select count(*) from t where arrow_cast(ts, 'Timestamp(Millisecond, Some("+07:00"))')
= arrow_cast(l, 'Timestamp(Millisecond, Some("+07:00"))');
-- literal, rewritten to `ts >= 1704042000500000000 AND ts < 1704042000501000000`: 0
select count(*) from t where arrow_cast(ts, 'Timestamp(Millisecond, Some("+07:00"))')
= arrow_cast('2024-01-01T00:00:00.5'::timestamp, 'Timestamp(Millisecond, Some("+07:00"))');Fix: reject this timezone pair in both places (the second check also covers the IN-list path):
/// Arrow shifts values when casting a timezone-naive timestamp to a
/// timezone-aware one (local wall clock -> UTC), so the cast has no
/// literal-independent preimage.
fn is_naive_to_tz_timestamp_cast(source_type: &DataType, target_type: &DataType) -> bool {
matches!(
(source_type, target_type),
(DataType::Timestamp(_, None), DataType::Timestamp(_, Some(_)))
)
} pub fn cast_predicate_preimage(
@@
) -> Result<Option<CastPredicatePreimage>> {
+ if is_naive_to_tz_timestamp_cast(source_type, target_type) {
+ return Ok(None);
+ }
if let Some(preimage) = maybe_range_preimage(source_type, target_type, lit_value)? {
@@ fn is_exact_cast_safe
if is_timestamp_cast(src, tgt) {
- return !is_timestamp_precision_narrowing_cast(src, tgt);
+ return !is_timestamp_precision_narrowing_cast(src, tgt)
+ && !is_naive_to_tz_timestamp_cast(src, tgt);
}Please add a result-level slt for this case, not just EXPLAIN. With the fix, optimizer_ine) -> ms("UTC")) goes back to keeping the cast, unless you explicitly allow zero-offset"UTC"/"+00:00".
| @@ -156,16 +654,6 @@ pub fn is_timestamp_precision_narrowing_cast( | |||
| pub fn is_date_narrowing_cast(from_type: &DataType, to_type: &DataType) -> bool { | |||
There was a problem hiding this comment.
The is_date_narrowing_cast early returns here, at casts.rs:159 and at physical-expr/src/simplifier/unwrap_cast.rs:134 are dead code. is_exact_cast_safe already rejects Date64 -> Date32 (including the dictionary-wrapped form, which this bare-type check misses), and the range, int→string and widening paths can't match date types. Dropping all three leaves the allowlist as the single gate:
- if is_date_narrowing_cast(source_type, target_type) {
- return Ok(None);
- }| Operator::Or, | ||
| is_null(expr)?, | ||
| ), | ||
| _ => unreachable!("preimage only supports comparison operators"), |
There was a problem hiding this comment.
| _ => unreachable!("preimage only supports comparison operators"), | |
| _ => return internal_err!("Expect comparison operators, got {op}"), |
Merge Apache main at 9547b09 without rewriting the reviewed PR history. Reject naive-to-timezone-aware timestamp preimages in both comparison and direct exact-conversion paths, retaining Arrow's timezone adjustment. Remove redundant Date64 narrowing guards and return an internal error for unsupported physical comparison operators. Add result-level coverage for equal-unit, widening, narrowing and multi-item IN predicates. Correct the existing cross-timezone equality expectations using raw-instant and column-versus-column controls, while preserving a true matching positive case. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
Merge Apache main at 97c7593 after publishing the reviewed cast-predicate follow-up. Preserve the existing review history and timezone-aware cast safeguards without rebasing. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
jayzhan211
left a comment
There was a problem hiding this comment.
Thanks @discord9 , overall LGTM!
Deprecate the retained date-narrowing helper for 56.0.0 and keep its existing behavior covered. Extract input-duplication metadata onto CastPredicatePreimage and use it in the logical volatility guard. Preserve existing cast comparison, timezone and NULL semantics without changing physical rewrite logic or the supported cast allowlist. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
|
Thank you for opening this pull request! Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch). Details |
Merge Apache main 55b2f09, the base used by the failing PR CI checkout, without rewriting review history. Preserve the original decimal(38,2) = 4.00 query and its result and statistics assertions. Its retained decimal cast narrows integer capacity, prevents a literal guarantee, and does not evaluate Bloom filters. Account for that explicitly under the updated Bloom metrics. Add a source-typed decimal equality case that actually evaluates both surviving Bloom filters, and cover a representable comparison literal whose column cast must still be retained because valid source values can overflow the strict Arrow cast. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
@jayzhan211 fixed the CI(due to decimal overflow forbid pushdown hence prevent bloom filtering) |
jayzhan211
left a comment
There was a problem hiding this comment.
There are new issue found
Allow the existing decimal cast-preimage gate to unwrap a nondecreasing scale cast when source and target have the same Decimal32, Decimal64, or Decimal128 representation and both use its maximum precision. Preserve the original full-domain widening rule for all other pairs. This bounded type-level exception restores real Bloom evaluations for Decimal128(38,2) compared with the coerced Float64 literal 4.00, while retaining exact source-literal conversion and the existing lossy-cast checks. It does not identify whether a cast was implicit or explicit. As with the existing timestamp-widening policy, unwrapping can suppress CAST overflow errors or replace TRY_CAST overflow NULLs with Boolean results. Document this limitation instead of claiming full-domain error equivalence, and cover the admitted and still-blocked cases. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
jayzhan211
left a comment
There was a problem hiding this comment.
@discord9 , I found another one in latest commit
| } | ||
| let src_int = (p_src as i16) - (s_src as i16); | ||
| let tgt_int = (p_tgt as i16) - (s_tgt as i16); | ||
| let both_max_same_variant = match (src, tgt) { |
There was a problem hiding this comment.
Coercion caps the target at the variant's max precision whatever the source precision is, so requiring the source to be max too still misses the case. decimal(30,2) / (28,0) / (36,4) vs 4.00 all coerce to CAST(col AS Decimal128(38,15)), cast_predicate_preimage returns None, and the cast stays (main unwraps all three → Bloom pruning lost). The overflow hazard is the same as for a (38,2) source, so the source check adds no safety.
Fix (only the (37,0) -> (38,2) blocked assertion flips; move it to the admitted cases):
- let both_max_same_variant = match (src, tgt) {
- (DataType::Decimal32(p_src, _), DataType::Decimal32(p_tgt, _)) => {
- *p_src == DECIMAL32_MAX_PRECISION && *p_tgt == DECIMAL32_MAX_PRECISION
- }
- (DataType::Decimal64(p_src, _), DataType::Decimal64(p_tgt, _)) => {
- *p_src == DECIMAL64_MAX_PRECISION && *p_tgt == DECIMAL64_MAX_PRECISION
- }
- (DataType::Decimal128(p_src, _), DataType::Decimal128(p_tgt, _)) => {
- *p_src == DECIMAL128_MAX_PRECISION && *p_tgt == DECIMAL128_MAX_PRECISION
- }
- _ => false,
- };
- s_tgt >= s_src && (tgt_int >= src_int || both_max_same_variant)
+ // Coercion caps the target at the variant's maximum precision, which can
+ // lose integer digits; same overflow trade-off as timestamp widening.
+ let capped = match (src, tgt) {
+ (DataType::Decimal32(_, _), DataType::Decimal32(p, _)) => {
+ *p == DECIMAL32_MAX_PRECISION
+ }
+ (DataType::Decimal64(_, _), DataType::Decimal64(p, _)) => {
+ *p == DECIMAL64_MAX_PRECISION
+ }
+ (DataType::Decimal128(_, _), DataType::Decimal128(p, _)) => {
+ *p == DECIMAL128_MAX_PRECISION
+ }
+ _ => false,
+ };
+ s_tgt >= s_src && (tgt_int >= src_int || capped)Worth adding a decimal(30,2) column to the new simplify_expr.slt block (explain ... where price = 4.00 should show price = Decimal128(4.00,30,2)).
There was a problem hiding this comment.
Maybe we could try this 🤔
Requiring both sides at max precision still misses decimal(30,2) / (28,0) / (36,4) vs 4.00: they coerce to CAST(col AS Decimal128(38,15)), cast_predicate_preimage returns None, and the cast stays (main unwraps). Since this PR already accepts that overflowing values compare as booleans after the rewrite, precision adds nothing: a non-decreasing, nonnegative scale is exact and ordered for every value the cast accepts. One rule covers decimal and int → decimal and drops is_safe_decimal_widening, is_safe_integer_to_decimal, integer_decimal_digits (a differential check against arrow cast + cmp, 5.4M rows, found 0 mismatches on cast-accepted rows):
if is_decimal_type(tgt) && (is_decimal_type(src) || is_integer_type(src)) {
// A non-decreasing, nonnegative scale multiplies by 10^k: exact and
// ordered for every value the cast accepts. Precision only decides
// overflow, which follows the timestamp-widening policy above.
let s_src = if is_decimal_type(src) {
decimal_precision_scale(src).1
} else {
0
};
return s_src >= 0 && decimal_precision_scale(tgt).1 >= s_src;
}Keep the s_src >= 0 guard: a negative scale reaches 10_i128.pow(*scale as u32) in try_cast_numeric_literal and panics.
|
BTW, the LoC has grown quite a bit during review. It might be worth seeing whether there's a simpler way to get the same result — but that can be a follow-up. |
Use one nonnegative, nondecreasing-scale gate for supported decimal and integer-to-decimal casts. Remove the precision helpers that still blocked non-maximum source precisions such as decimal(30,2), (28,0), and (36,4). Keep the exact literal preimage requirement and reject negative source scales before identity handling. The documented overflow policy permits strict-cast errors and TRY_CAST overflow NULLs to become comparison results, including across decimal widths and for integer sources. Cover the motivating SQL plans and values, actual Parquet Bloom/statistics metrics, scale/literal boundaries, and Arrow overflow behavior. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
| /// semantics subject to the documented family-level overflow exceptions, and a | ||
| /// [`CastPredicatePreimage::Range`] for many-to-one casts with known preimages | ||
| /// such as timestamp precision narrowing. | ||
| pub fn cast_predicate_preimage( |
There was a problem hiding this comment.
The decimal change looks good, thanks for adding the Bloom-filter test. Every timestamp unit pair can use one interval. Narrowing takes the truncation bucket and widening takes [ceil(v/r), floor(v/r) + 1). A singleton becomes Exact, an empty interval with =/!=/IN returns None, and anything else becomes Range. That replaces maybe_range_preimage, timestamp_widening_ordered_preimage and the round-trip check, and in my local try it removed about 100 non-test lines. The dispatcher needs to strip one Dictionary level first so dictionary-encoded timestamps keep their rewrites.
let (lower, upper) = if source_scale > target_scale {
let Some(bucket) = trunc_toward_zero_bucket(value, source_scale / target_scale) else {
return Ok(None);
};
bucket
} else {
let ratio = target_scale / source_scale;
let floor = value.div_euclid(ratio);
let ceil = floor + i128::from(value.rem_euclid(ratio) != 0);
(ceil, floor + 1)
};
Which issue does this PR close?
Rationale for this change
The previous cast-unwrap path could only move the original comparison operator
from
CAST(expr AS target_type) OP literaltoexpr OP casted_literal. That isnot correct for many-to-one casts such as timestamp precision narrowing, where
the source-domain preimage of one target value is a range rather than a
singleton.
For example,
CAST(ts_ns AS Timestamp(ms)) > 1000msmust not becomets_ns > 1_000_000_000ns; its exact source boundary ists_ns >= 1_001_000_000ns.Timestamp precision widening has a related ordered-comparison case. A
non-aligned target literal has no singleton equality preimage, but it does have
an exact source-unit boundary for an ordered predicate. For example:
becomes:
This PR also makes exact cast rewrites closed-by-default: exact rewrites require
a supported value-preserving cast family. Many-to-one or source-domain-reducing
casts either use an explicit range/boundary preimage or remain unchanged.
The ordered timestamp-widening rewrite deliberately follows the existing
widening policy used by this work. At extreme source values where widening
overflows, ordinary
CASTcan error andTRY_CASTcan returnNULL, while therewritten source-unit comparison returns a Boolean. This is not a claim of
full-domain equivalence for those overflow cases; a guarded/error-aware
preimage representation is outside this PR's scope.
What changes are included in this PR?
CastPredicatePreimageabstraction indatafusion-expr-common:Exact(ScalarValue)for a same-operator source literal or boundary.Range(Interval)for a half-open source-domain interval.exact, and ordered timestamp-widening paths.
with truncation-toward-zero semantics, including negative timestamps.
floor/ceil arithmetic in
i128:>= Land< Luseceil(L / q).> Land<= Lusefloor(L / q).metadata, including
CAST,TRY_CAST, and literal-left comparisons.INpredicates unchanged.INrewrites because its preimageis a range rather than a singleton.
signedness, decimal precision/scale, and canonical integer/string checks.
and update the physical simplifier to use the same helper.
Behavior changes compared to
mainCAST(c1:Int32 AS Int64) < 10c1 < Int32(10)CAST(c2:Int64 AS Int32) = 5CAST(c1:Int32 AS UInt32) = 5CAST(c1:Int32 AS Utf8) = '123'c1 = Int32(123)CAST(c1:Int32 AS Utf8) = '0123''0123' -> 123 -> '123'does not round-trip.CAST(c1:Int32 AS Utf8) < '123'CAST(c1:Int32 AS Decimal(12,2)) = 123.00c1 = Int32(123)CAST(c1:Int32 AS Decimal(10,2)) = 123.00CAST(c3:Decimal(10,2) AS Decimal(18,4)) = 123.0000CAST(c3:Decimal(18,2) AS Decimal(18,1)) = 123.0CAST(ts_ns AS timestamp(ms)) = 1000msts_ns >= 1_000_000_000ns AND ts_ns < 1_001_000_000nsCAST(ts_ns AS timestamp(ms)) > 1000msts_ns >= 1_001_000_000nsCAST(ts_ns AS timestamp(ms)) <= 0msts_ns < 1_000_000nsCAST(ts_ns AS timestamp(ms)) = -1msts_ns >= -1_999_999ns AND ts_ns < -999_999nsCAST(ts_ns AS timestamp(ms)) != 1000msCAST(ts_ns AS timestamp(ms)) IN (1000ms)INcurrently supports only singleton exact preimages.CAST(ts_ms AS timestamp(ns)) = 123_000_000nsts_ms = 123msCAST(ts_ms AS timestamp(ns)) = 123_456_789nsCAST(ts_ms AS timestamp(ns)) >= 123_456_789nsts_ms >= 124ms123_456_789ns < TRY_CAST(ts_ms AS timestamp(ns))ts_ms > 123msCAST(Date64_col AS Date32) = ...CAST(Date32_col AS Date64) = aligned_midnightAre these changes tested?
Yes. Tests cover:
CAST,TRY_CAST, and literal-left forms,i64boundary literals,INremaining unchanged where required,Validated locally with:
Are there any user-facing changes?
There are no public API changes. Optimized plans may now use exact source-domain
ranges or boundaries for cast predicates, and previously unsafe exact rewrites
may remain unchanged. Ordered timestamp-widening comparisons also follow the
explicit overflow policy described above.