From 7a15bb87f7e665b2c9fe4e1400e4904caa2eac54 Mon Sep 17 00:00:00 2001 From: Darek Date: Sat, 17 Oct 2026 13:08:08 +0200 Subject: [PATCH] fix(storage): keep casts that change stats layout out of zone-map predicates A comparison of a column with a literal of another numeric type is bound as CAST(column) literal, and the zone-map push-down turned it into a predicate on the column carrying the literal's value. The chunk's min/max were then read as the literal's type: integer stats as doubles, double stats as integers, DECIMAL stats with their scale, DATE days as TIMESTAMP micros. Chunks with matching rows were skipped, so `n.i > 30.5` on an INT64 column returned nothing. Push a casted column down only when the cast keeps each value and its order in the same stats slot: integer widening (including unsigned to a wider signed type) and FLOAT to DOUBLE. Other casts stay in the filter. The DuckDB and Postgres scans share the conversion, so they no longer send a narrowing cast to the remote side as a comparison on the raw column. Co-Authored-By: Claude Opus 5.5 --- src/storage/predicate/column_predicate.cpp | 33 +++++- test/test_files/filter/cast.test | 118 +++++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/src/storage/predicate/column_predicate.cpp b/src/storage/predicate/column_predicate.cpp index 5e07e33abd..9f4c9af21a 100644 --- a/src/storage/predicate/column_predicate.cpp +++ b/src/storage/predicate/column_predicate.cpp @@ -36,12 +36,43 @@ static bool isColumnRef(ExpressionType type) { return type == ExpressionType::PROPERTY || type == ExpressionType::VARIABLE; } +static bool isIntegerUpTo64Bits(LogicalTypeID type) { + return LogicalTypeUtils::isIntegral(type) && type != LogicalTypeID::INT128 && + type != LogicalTypeID::UINT128; +} + +// The pushed-down predicate compares the column's stats, read as the cast's target type, with +// the literal. That is only right when the cast keeps every value and its order in the same +// stats slot: the same type, integer widening up to 64 bits (an unsigned value reads the same +// from the signed slot of a wider type) and FLOAT to DOUBLE. 128-bit targets are excluded because +// a negative 64-bit value is stored without sign extension into the high half. +static bool castKeepsStats(const LogicalType& from, const LogicalType& to) { + if (from == to) { + return true; + } + if (from.getLogicalTypeID() == LogicalTypeID::FLOAT) { + return to.getLogicalTypeID() == LogicalTypeID::DOUBLE; + } + if (!isIntegerUpTo64Bits(from.getLogicalTypeID()) || + !isIntegerUpTo64Bits(to.getLogicalTypeID())) { + return false; + } + auto fromSize = PhysicalTypeUtils::getFixedTypeSize(from.getPhysicalType()); + auto toSize = PhysicalTypeUtils::getFixedTypeSize(to.getPhysicalType()); + if (LogicalTypeUtils::isUnsigned(to)) { + return LogicalTypeUtils::isUnsigned(from) && fromSize <= toSize; + } + return LogicalTypeUtils::isUnsigned(from) ? fromSize < toSize : fromSize <= toSize; +} + static bool isCastedColumnRef(const Expression& expr) { if (expr.expressionType == ExpressionType::FUNCTION) { const auto& funcExpr = expr.constCast(); if (funcExpr.getFunction().name.starts_with("CAST")) { DASSERT(funcExpr.getNumChildren() > 0); - return isColumnRef(funcExpr.getChild(0)->expressionType); + const auto& child = *funcExpr.getChild(0); + return isColumnRef(child.expressionType) && + castKeepsStats(child.getDataType(), expr.getDataType()); } } return false; diff --git a/test/test_files/filter/cast.test b/test/test_files/filter/cast.test index f4eb0b0714..cc52cc3800 100644 --- a/test/test_files/filter/cast.test +++ b/test/test_files/filter/cast.test @@ -10,3 +10,121 @@ -STATEMENT match (c:Comment) where c.id = 687194777181 return c.browserUsed, c.content; ---- 1 Chrome|right + +-CASE ZoneMapCastedColumnNumericLiteral +-STATEMENT CREATE NODE TABLE T(id INT64 PRIMARY KEY, i INT64, i32 INT32, u32 UINT32, f DOUBLE, fl FLOAT, d DECIMAL(18,2), dt DATE, tms TIMESTAMP_MS); +---- ok +-STATEMENT CREATE (:T {id: 1, i: 30, i32: 30, u32: 30, f: 30.7, fl: 30.5, d: 5.00, dt: date('2020-01-01'), tms: CAST('2020-01-01 00:00:00' AS TIMESTAMP_MS)}); +---- ok +-STATEMENT CREATE (:T {id: 2, i: 40, i32: 40, u32: 4000000000, f: 40.2, fl: 40.5, d: 7.00, dt: date('2021-01-01'), tms: CAST('2021-01-01 00:00:00' AS TIMESTAMP_MS)}); +---- ok +-STATEMENT CREATE NODE TABLE N(id INT64 PRIMARY KEY, i INT64, i32 INT32); +---- ok +-STATEMENT CREATE (:N {id: 1, i: -5, i32: -20}); +---- ok +-STATEMENT CREATE (:N {id: 2, i: 40, i32: 40}); +---- ok +-LOG InMemory +-STATEMENT MATCH (n:T) WHERE n.i > 30.5 RETURN n.id; +---- 1 +2 +-STATEMENT MATCH (n:T) WHERE n.i = 40.0 RETURN n.id; +---- 1 +2 +-STATEMENT MATCH (n:T) WHERE n.i32 > 35.5 RETURN n.id; +---- 1 +2 +-STATEMENT MATCH (n:T) WHERE CAST(n.f AS INT64) = 31 RETURN n.id; +---- 1 +1 +-STATEMENT MATCH (n:T) WHERE CAST(n.d AS INT64) = 5 RETURN n.id; +---- 1 +1 +-STATEMENT MATCH (n:T) WHERE n.dt > timestamp('2020-06-01 00:00:00') RETURN n.id; +---- 1 +2 +-STATEMENT MATCH (n:T) WHERE n.tms > timestamp('2020-06-01 00:00:00') RETURN n.id; +---- 1 +2 +-STATEMENT MATCH (n:T) WHERE CAST(n.i32 AS INT64) = 40 RETURN n.id; +---- 1 +2 +-STATEMENT MATCH (n:T) WHERE n.u32 > -1 RETURN n.id ORDER BY n.id; +-CHECK_ORDER +---- 2 +1 +2 +-STATEMENT MATCH (n:T) WHERE n.u32 < 5000000000 RETURN n.id ORDER BY n.id; +-CHECK_ORDER +---- 2 +1 +2 +-STATEMENT MATCH (n:T) WHERE n.u32 > 5000000000 RETURN n.id; +---- 0 +-STATEMENT MATCH (n:T) WHERE n.fl > 40.25 RETURN n.id; +---- 1 +2 +-STATEMENT MATCH (n:N) WHERE n.i < 9223372036854775808 RETURN n.id ORDER BY n.id; +-CHECK_ORDER +---- 2 +1 +2 +-STATEMENT MATCH (n:N) WHERE CAST(n.i32 AS INT64) = -20 RETURN n.id; +---- 1 +1 +-STATEMENT MATCH (n:N) WHERE CAST(n.i32 AS INT64) <> 40 RETURN n.id; +---- 1 +1 +-STATEMENT CHECKPOINT; +---- ok +-LOG Checkpointed +-STATEMENT MATCH (n:T) WHERE n.i > 30.5 RETURN n.id; +---- 1 +2 +-STATEMENT MATCH (n:T) WHERE n.i = 40.0 RETURN n.id; +---- 1 +2 +-STATEMENT MATCH (n:T) WHERE n.i32 > 35.5 RETURN n.id; +---- 1 +2 +-STATEMENT MATCH (n:T) WHERE CAST(n.f AS INT64) = 31 RETURN n.id; +---- 1 +1 +-STATEMENT MATCH (n:T) WHERE CAST(n.d AS INT64) = 5 RETURN n.id; +---- 1 +1 +-STATEMENT MATCH (n:T) WHERE n.dt > timestamp('2020-06-01 00:00:00') RETURN n.id; +---- 1 +2 +-STATEMENT MATCH (n:T) WHERE n.tms > timestamp('2020-06-01 00:00:00') RETURN n.id; +---- 1 +2 +-STATEMENT MATCH (n:T) WHERE CAST(n.i32 AS INT64) = 40 RETURN n.id; +---- 1 +2 +-STATEMENT MATCH (n:T) WHERE n.u32 > -1 RETURN n.id ORDER BY n.id; +-CHECK_ORDER +---- 2 +1 +2 +-STATEMENT MATCH (n:T) WHERE n.u32 < 5000000000 RETURN n.id ORDER BY n.id; +-CHECK_ORDER +---- 2 +1 +2 +-STATEMENT MATCH (n:T) WHERE n.u32 > 5000000000 RETURN n.id; +---- 0 +-STATEMENT MATCH (n:T) WHERE n.fl > 40.25 RETURN n.id; +---- 1 +2 +-STATEMENT MATCH (n:N) WHERE n.i < 9223372036854775808 RETURN n.id ORDER BY n.id; +-CHECK_ORDER +---- 2 +1 +2 +-STATEMENT MATCH (n:N) WHERE CAST(n.i32 AS INT64) = -20 RETURN n.id; +---- 1 +1 +-STATEMENT MATCH (n:N) WHERE CAST(n.i32 AS INT64) <> 40 RETURN n.id; +---- 1 +1