Skip to content

Commit a036fae

Browse files
authored
fix: respect ancestor nullability when folding IS NULL/NOT NULL predicates (#995)
A required field nested in an optional struct, list, or map can be null when its parent is null, so IS NULL and NOT NULL must not be folded for it. Only fold when the field and all of its ancestors are required, matching Java's UnboundPredicate#bindUnaryOperation.
1 parent 12a16bd commit a036fae

3 files changed

Lines changed: 81 additions & 7 deletions

File tree

‎src/iceberg/expression/predicate.cc‎

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@
2525
#include "iceberg/expression/expressions.h"
2626
#include "iceberg/expression/literal.h"
2727
#include "iceberg/result.h"
28+
#include "iceberg/schema.h"
2829
#include "iceberg/transform.h"
2930
#include "iceberg/type.h"
3031
#include "iceberg/util/checked_cast.h"
3132
#include "iceberg/util/formatter_internal.h"
3233
#include "iceberg/util/macros.h"
34+
#include "iceberg/util/type_util.h"
3335

3436
namespace iceberg {
3537

@@ -230,7 +232,7 @@ Result<std::shared_ptr<Expression>> UnboundPredicateImpl<B>::Bind(
230232
ICEBERG_ASSIGN_OR_RAISE(auto bound_term, BASE::term()->Bind(schema, case_sensitive));
231233

232234
if (values_.empty()) {
233-
return BindUnaryOperation(std::move(bound_term));
235+
return BindUnaryOperation(schema, std::move(bound_term));
234236
}
235237

236238
if (BASE::op() == Expression::Operation::kIn ||
@@ -257,30 +259,63 @@ bool StartsWith(const Literal& lhs, const Literal& rhs) {
257259
return false;
258260
}
259261

262+
/// \brief Returns true if all ancestors of the field with the given ID are required.
263+
///
264+
/// A required field can still produce null values if it is nested in an optional struct,
265+
/// list, or map.
266+
Result<bool> AllAncestorsAreRequired(const Schema& schema, int32_t field_id) {
267+
auto id_to_parent = IndexParents(schema);
268+
auto parent_it = id_to_parent.find(field_id);
269+
while (parent_it != id_to_parent.end()) {
270+
ICEBERG_ASSIGN_OR_RAISE(auto parent, schema.FindFieldById(parent_it->second));
271+
if (!parent.has_value()) {
272+
return InvalidSchema("Cannot find parent field {} of field {}", parent_it->second,
273+
field_id);
274+
}
275+
if (parent.value().get().optional()) {
276+
return false;
277+
}
278+
parent_it = id_to_parent.find(parent_it->second);
279+
}
280+
return true;
281+
}
282+
260283
} // namespace
261284

262285
template <typename B>
263286
Result<std::shared_ptr<Expression>> UnboundPredicateImpl<B>::BindUnaryOperation(
264-
std::shared_ptr<B> bound_term) const {
287+
const Schema& schema, std::shared_ptr<B> bound_term) const {
265288
switch (BASE::op()) {
266-
case Expression::Operation::kIsNull:
289+
case Expression::Operation::kIsNull: {
267290
if (!bound_term->MayProduceNull()) {
268-
return Expressions::AlwaysFalse();
291+
ICEBERG_ASSIGN_OR_RAISE(
292+
bool all_ancestors_required,
293+
AllAncestorsAreRequired(schema, bound_term->reference()->field_id()));
294+
if (all_ancestors_required) {
295+
return Expressions::AlwaysFalse();
296+
}
269297
}
270298
if (bound_term->type()->type_id() == TypeId::kUnknown) {
271299
return Expressions::AlwaysTrue();
272300
}
273301
return BoundUnaryPredicate::Make(Expression::Operation::kIsNull,
274302
std::move(bound_term));
275-
case Expression::Operation::kNotNull:
303+
}
304+
case Expression::Operation::kNotNull: {
276305
if (!bound_term->MayProduceNull()) {
277-
return Expressions::AlwaysTrue();
306+
ICEBERG_ASSIGN_OR_RAISE(
307+
bool all_ancestors_required,
308+
AllAncestorsAreRequired(schema, bound_term->reference()->field_id()));
309+
if (all_ancestors_required) {
310+
return Expressions::AlwaysTrue();
311+
}
278312
}
279313
if (bound_term->type()->type_id() == TypeId::kUnknown) {
280314
return Expressions::AlwaysFalse();
281315
}
282316
return BoundUnaryPredicate::Make(Expression::Operation::kNotNull,
283317
std::move(bound_term));
318+
}
284319
case Expression::Operation::kIsNan:
285320
case Expression::Operation::kNotNan:
286321
if (!IsFloatingType(bound_term->type()->type_id())) {

‎src/iceberg/expression/predicate.h‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ class ICEBERG_EXPORT UnboundPredicateImpl : public UnboundPredicate,
150150
std::vector<Literal> values);
151151

152152
Result<std::shared_ptr<Expression>> BindUnaryOperation(
153-
std::shared_ptr<B> bound_term) const;
153+
const Schema& schema, std::shared_ptr<B> bound_term) const;
154154
Result<std::shared_ptr<Expression>> BindLiteralOperation(
155155
std::shared_ptr<B> bound_term) const;
156156
Result<std::shared_ptr<Expression>> BindInOperation(

‎src/iceberg/test/predicate_test.cc‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,45 @@ TEST_F(PredicateTest, UnboundPredicateBindUnaryWithUnknownType) {
384384
EXPECT_EQ(bound_not_null->op(), Expression::Operation::kFalse);
385385
}
386386

387+
TEST_F(PredicateTest, UnboundPredicateBindUnaryWithNestedStructs) {
388+
// A required field nested in an optional struct can still produce null values
389+
auto nested_schema = std::make_shared<Schema>(
390+
std::vector<SchemaField>{
391+
SchemaField::MakeOptional(1, "optional_parent",
392+
std::make_shared<StructType>(std::vector<SchemaField>{
393+
SchemaField::MakeRequired(2, "leaf", int32())})),
394+
SchemaField::MakeRequired(3, "required_parent",
395+
std::make_shared<StructType>(std::vector<SchemaField>{
396+
SchemaField::MakeRequired(4, "leaf", int32())})),
397+
},
398+
/*schema_id=*/0);
399+
400+
auto is_null_optional_parent = Expressions::IsNull("optional_parent.leaf");
401+
auto bound_is_null_optional =
402+
is_null_optional_parent->Bind(*nested_schema, /*case_sensitive=*/true);
403+
ASSERT_THAT(bound_is_null_optional, IsOk());
404+
EXPECT_EQ(bound_is_null_optional.value()->op(), Expression::Operation::kIsNull);
405+
406+
auto not_null_optional_parent = Expressions::NotNull("optional_parent.leaf");
407+
auto bound_not_null_optional =
408+
not_null_optional_parent->Bind(*nested_schema, /*case_sensitive=*/true);
409+
ASSERT_THAT(bound_not_null_optional, IsOk());
410+
EXPECT_EQ(bound_not_null_optional.value()->op(), Expression::Operation::kNotNull);
411+
412+
// A required field with required ancestors can never produce null values
413+
auto is_null_required_parent = Expressions::IsNull("required_parent.leaf");
414+
auto bound_is_null_required =
415+
is_null_required_parent->Bind(*nested_schema, /*case_sensitive=*/true);
416+
ASSERT_THAT(bound_is_null_required, IsOk());
417+
EXPECT_EQ(bound_is_null_required.value()->op(), Expression::Operation::kFalse);
418+
419+
auto not_null_required_parent = Expressions::NotNull("required_parent.leaf");
420+
auto bound_not_null_required =
421+
not_null_required_parent->Bind(*nested_schema, /*case_sensitive=*/true);
422+
ASSERT_THAT(bound_not_null_required, IsOk());
423+
EXPECT_EQ(bound_not_null_required.value()->op(), Expression::Operation::kTrue);
424+
}
425+
387426
TEST_F(PredicateTest, UnboundPredicateBindLiteral) {
388427
auto equal_pred = Expressions::Equal("age", Literal::Int(25));
389428
auto bound_result = equal_pred->Bind(*schema_, /*case_sensitive=*/true);

0 commit comments

Comments
 (0)