This is an automated email from the ASF dual-hosted git repository.

gangwu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git


The following commit(s) were added to refs/heads/main by this push:
     new e684be28 feat: add strict projection to transform (#387)
e684be28 is described below

commit e684be286509257ce00ad9d96382ac5a41f1c3a9
Author: Junwang Zhao <[email protected]>
AuthorDate: Fri Dec 5 10:35:39 2025 +0800

    feat: add strict projection to transform (#387)
---
 src/iceberg/test/predicate_test.cc          |  87 ++--
 src/iceberg/test/transform_test.cc          | 771 ++++++++++++++++++++++++----
 src/iceberg/transform.cc                    |  60 +++
 src/iceberg/transform.h                     |  12 +
 src/iceberg/util/projection_util_internal.h | 655 ++++++++++++++---------
 5 files changed, 1196 insertions(+), 389 deletions(-)

diff --git a/src/iceberg/test/predicate_test.cc 
b/src/iceberg/test/predicate_test.cc
index 532e908b..fab0b561 100644
--- a/src/iceberg/test/predicate_test.cc
+++ b/src/iceberg/test/predicate_test.cc
@@ -26,7 +26,6 @@
 #include "iceberg/schema.h"
 #include "iceberg/test/matchers.h"
 #include "iceberg/type.h"
-#include "iceberg/util/macros.h"
 
 namespace iceberg {
 
@@ -607,24 +606,24 @@ std::shared_ptr<BoundPredicate> 
AssertAndCastToBoundPredicate(
 }  // namespace
 
 TEST_F(PredicateTest, BoundUnaryPredicateTestIsNull) {
-  ICEBERG_ASSIGN_OR_THROW(auto is_null_pred, Expressions::IsNull("name")->Bind(
-                                                 *schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto is_null_pred, Expressions::IsNull("name")->Bind(
+                                                *schema_, 
/*case_sensitive=*/true));
   auto bound_pred = AssertAndCastToBoundPredicate(is_null_pred);
   EXPECT_THAT(bound_pred->Test(Literal::Null(string())), 
HasValue(testing::Eq(true)));
   EXPECT_THAT(bound_pred->Test(Literal::String("test")), 
HasValue(testing::Eq(false)));
 }
 
 TEST_F(PredicateTest, BoundUnaryPredicateTestNotNull) {
-  ICEBERG_ASSIGN_OR_THROW(auto not_null_pred, 
Expressions::NotNull("name")->Bind(
-                                                  *schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto not_null_pred, 
Expressions::NotNull("name")->Bind(
+                                                 *schema_, 
/*case_sensitive=*/true));
   auto bound_pred = AssertAndCastToBoundPredicate(not_null_pred);
   EXPECT_THAT(bound_pred->Test(Literal::String("test")), 
HasValue(testing::Eq(true)));
   EXPECT_THAT(bound_pred->Test(Literal::Null(string())), 
HasValue(testing::Eq(false)));
 }
 
 TEST_F(PredicateTest, BoundUnaryPredicateTestIsNaN) {
-  ICEBERG_ASSIGN_OR_THROW(auto is_nan_pred, Expressions::IsNaN("salary")->Bind(
-                                                *schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto is_nan_pred, Expressions::IsNaN("salary")->Bind(
+                                               *schema_, 
/*case_sensitive=*/true));
   auto bound_pred = AssertAndCastToBoundPredicate(is_nan_pred);
 
   // Test with NaN values
@@ -643,8 +642,8 @@ TEST_F(PredicateTest, BoundUnaryPredicateTestIsNaN) {
 }
 
 TEST_F(PredicateTest, BoundUnaryPredicateTestNotNaN) {
-  ICEBERG_ASSIGN_OR_THROW(auto not_nan_pred, 
Expressions::NotNaN("salary")->Bind(
-                                                 *schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto not_nan_pred, 
Expressions::NotNaN("salary")->Bind(
+                                                *schema_, 
/*case_sensitive=*/true));
   auto bound_pred = AssertAndCastToBoundPredicate(not_nan_pred);
 
   // Test with regular values
@@ -661,34 +660,34 @@ TEST_F(PredicateTest, BoundUnaryPredicateTestNotNaN) {
 
 TEST_F(PredicateTest, BoundLiteralPredicateTestComparison) {
   // Test less than
-  ICEBERG_ASSIGN_OR_THROW(auto lt_pred, Expressions::LessThan("age", 
Literal::Int(30))
-                                            ->Bind(*schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto lt_pred, Expressions::LessThan("age", 
Literal::Int(30))
+                                           ->Bind(*schema_, 
/*case_sensitive=*/true));
   auto bound_lt = AssertAndCastToBoundPredicate(lt_pred);
   EXPECT_THAT(bound_lt->Test(Literal::Int(20)), HasValue(testing::Eq(true)));
   EXPECT_THAT(bound_lt->Test(Literal::Int(30)), HasValue(testing::Eq(false)));
   EXPECT_THAT(bound_lt->Test(Literal::Int(40)), HasValue(testing::Eq(false)));
 
   // Test less than or equal
-  ICEBERG_ASSIGN_OR_THROW(auto lte_pred,
-                          Expressions::LessThanOrEqual("age", Literal::Int(30))
-                              ->Bind(*schema_, /*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto lte_pred,
+                         Expressions::LessThanOrEqual("age", Literal::Int(30))
+                             ->Bind(*schema_, /*case_sensitive=*/true));
   auto bound_lte = AssertAndCastToBoundPredicate(lte_pred);
   EXPECT_THAT(bound_lte->Test(Literal::Int(20)), HasValue(testing::Eq(true)));
   EXPECT_THAT(bound_lte->Test(Literal::Int(30)), HasValue(testing::Eq(true)));
   EXPECT_THAT(bound_lte->Test(Literal::Int(40)), HasValue(testing::Eq(false)));
 
   // Test greater than
-  ICEBERG_ASSIGN_OR_THROW(auto gt_pred, Expressions::GreaterThan("age", 
Literal::Int(30))
-                                            ->Bind(*schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto gt_pred, Expressions::GreaterThan("age", 
Literal::Int(30))
+                                           ->Bind(*schema_, 
/*case_sensitive=*/true));
   auto bound_gt = AssertAndCastToBoundPredicate(gt_pred);
   EXPECT_THAT(bound_gt->Test(Literal::Int(20)), HasValue(testing::Eq(false)));
   EXPECT_THAT(bound_gt->Test(Literal::Int(30)), HasValue(testing::Eq(false)));
   EXPECT_THAT(bound_gt->Test(Literal::Int(40)), HasValue(testing::Eq(true)));
 
   // Test greater than or equal
-  ICEBERG_ASSIGN_OR_THROW(auto gte_pred,
-                          Expressions::GreaterThanOrEqual("age", 
Literal::Int(30))
-                              ->Bind(*schema_, /*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto gte_pred,
+                         Expressions::GreaterThanOrEqual("age", 
Literal::Int(30))
+                             ->Bind(*schema_, /*case_sensitive=*/true));
   auto bound_gte = AssertAndCastToBoundPredicate(gte_pred);
   EXPECT_THAT(bound_gte->Test(Literal::Int(20)), HasValue(testing::Eq(false)));
   EXPECT_THAT(bound_gte->Test(Literal::Int(30)), HasValue(testing::Eq(true)));
@@ -697,16 +696,16 @@ TEST_F(PredicateTest, 
BoundLiteralPredicateTestComparison) {
 
 TEST_F(PredicateTest, BoundLiteralPredicateTestEquality) {
   // Test equal
-  ICEBERG_ASSIGN_OR_THROW(auto eq_pred, Expressions::Equal("age", 
Literal::Int(25))
-                                            ->Bind(*schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto eq_pred, Expressions::Equal("age", 
Literal::Int(25))
+                                           ->Bind(*schema_, 
/*case_sensitive=*/true));
   auto bound_eq = AssertAndCastToBoundPredicate(eq_pred);
   EXPECT_THAT(bound_eq->Test(Literal::Int(25)), HasValue(testing::Eq(true)));
   EXPECT_THAT(bound_eq->Test(Literal::Int(26)), HasValue(testing::Eq(false)));
   EXPECT_THAT(bound_eq->Test(Literal::Int(24)), HasValue(testing::Eq(false)));
 
   // Test not equal
-  ICEBERG_ASSIGN_OR_THROW(auto neq_pred, Expressions::NotEqual("age", 
Literal::Int(25))
-                                             ->Bind(*schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto neq_pred, Expressions::NotEqual("age", 
Literal::Int(25))
+                                            ->Bind(*schema_, 
/*case_sensitive=*/true));
   auto bound_neq = AssertAndCastToBoundPredicate(neq_pred);
   EXPECT_THAT(bound_neq->Test(Literal::Int(25)), HasValue(testing::Eq(false)));
   EXPECT_THAT(bound_neq->Test(Literal::Int(26)), HasValue(testing::Eq(true)));
@@ -715,18 +714,18 @@ TEST_F(PredicateTest, BoundLiteralPredicateTestEquality) {
 
 TEST_F(PredicateTest, BoundLiteralPredicateTestWithDifferentTypes) {
   // Test with double
-  ICEBERG_ASSIGN_OR_THROW(auto gt_pred,
-                          Expressions::GreaterThan("salary", 
Literal::Double(50000.0))
-                              ->Bind(*schema_, /*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto gt_pred,
+                         Expressions::GreaterThan("salary", 
Literal::Double(50000.0))
+                             ->Bind(*schema_, /*case_sensitive=*/true));
   auto bound_double = AssertAndCastToBoundPredicate(gt_pred);
   EXPECT_THAT(bound_double->Test(Literal::Double(60000.0)), 
HasValue(testing::Eq(true)));
   EXPECT_THAT(bound_double->Test(Literal::Double(40000.0)), 
HasValue(testing::Eq(false)));
   EXPECT_THAT(bound_double->Test(Literal::Double(50000.0)), 
HasValue(testing::Eq(false)));
 
   // Test with string
-  ICEBERG_ASSIGN_OR_THROW(auto str_eq_pred,
-                          Expressions::Equal("name", Literal::String("Alice"))
-                              ->Bind(*schema_, /*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto str_eq_pred,
+                         Expressions::Equal("name", Literal::String("Alice"))
+                             ->Bind(*schema_, /*case_sensitive=*/true));
   auto bound_string = AssertAndCastToBoundPredicate(str_eq_pred);
   EXPECT_THAT(bound_string->Test(Literal::String("Alice")), 
HasValue(testing::Eq(true)));
   EXPECT_THAT(bound_string->Test(Literal::String("Bob")), 
HasValue(testing::Eq(false)));
@@ -734,16 +733,16 @@ TEST_F(PredicateTest, 
BoundLiteralPredicateTestWithDifferentTypes) {
               HasValue(testing::Eq(false)));  // Case sensitive
 
   // Test with boolean
-  ICEBERG_ASSIGN_OR_THROW(auto bool_eq_pred,
-                          Expressions::Equal("active", Literal::Boolean(true))
-                              ->Bind(*schema_, /*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bool_eq_pred,
+                         Expressions::Equal("active", Literal::Boolean(true))
+                             ->Bind(*schema_, /*case_sensitive=*/true));
   auto bound_bool = AssertAndCastToBoundPredicate(bool_eq_pred);
   EXPECT_THAT(bound_bool->Test(Literal::Boolean(true)), 
HasValue(testing::Eq(true)));
   EXPECT_THAT(bound_bool->Test(Literal::Boolean(false)), 
HasValue(testing::Eq(false)));
 }
 
 TEST_F(PredicateTest, BoundLiteralPredicateTestStartsWith) {
-  ICEBERG_ASSIGN_OR_THROW(
+  ICEBERG_UNWRAP_OR_FAIL(
       auto starts_with_pred,
       Expressions::StartsWith("name", "Jo")->Bind(*schema_, 
/*case_sensitive=*/true));
   auto bound_pred = AssertAndCastToBoundPredicate(starts_with_pred);
@@ -759,7 +758,7 @@ TEST_F(PredicateTest, BoundLiteralPredicateTestStartsWith) {
   EXPECT_THAT(bound_pred->Test(Literal::String("")), 
HasValue(testing::Eq(false)));
 
   // Test empty prefix
-  ICEBERG_ASSIGN_OR_THROW(
+  ICEBERG_UNWRAP_OR_FAIL(
       auto empty_prefix_pred,
       Expressions::StartsWith("name", "")->Bind(*schema_, 
/*case_sensitive=*/true));
   auto bound_empty = AssertAndCastToBoundPredicate(empty_prefix_pred);
@@ -770,7 +769,7 @@ TEST_F(PredicateTest, BoundLiteralPredicateTestStartsWith) {
 }
 
 TEST_F(PredicateTest, BoundLiteralPredicateTestNotStartsWith) {
-  ICEBERG_ASSIGN_OR_THROW(
+  ICEBERG_UNWRAP_OR_FAIL(
       auto not_starts_with_pred,
       Expressions::NotStartsWith("name", "Jo")->Bind(*schema_, 
/*case_sensitive=*/true));
   auto bound_pred = AssertAndCastToBoundPredicate(not_starts_with_pred);
@@ -787,7 +786,7 @@ TEST_F(PredicateTest, 
BoundLiteralPredicateTestNotStartsWith) {
 }
 
 TEST_F(PredicateTest, BoundSetPredicateTestIn) {
-  ICEBERG_ASSIGN_OR_THROW(
+  ICEBERG_UNWRAP_OR_FAIL(
       auto in_pred,
       Expressions::In("age", {Literal::Int(10), Literal::Int(20), 
Literal::Int(30)})
           ->Bind(*schema_, /*case_sensitive=*/true));
@@ -805,7 +804,7 @@ TEST_F(PredicateTest, BoundSetPredicateTestIn) {
 }
 
 TEST_F(PredicateTest, BoundSetPredicateTestNotIn) {
-  ICEBERG_ASSIGN_OR_THROW(
+  ICEBERG_UNWRAP_OR_FAIL(
       auto not_in_pred,
       Expressions::NotIn("age", {Literal::Int(10), Literal::Int(20), 
Literal::Int(30)})
           ->Bind(*schema_, /*case_sensitive=*/true));
@@ -823,7 +822,7 @@ TEST_F(PredicateTest, BoundSetPredicateTestNotIn) {
 }
 
 TEST_F(PredicateTest, BoundSetPredicateTestWithStrings) {
-  ICEBERG_ASSIGN_OR_THROW(
+  ICEBERG_UNWRAP_OR_FAIL(
       auto in_pred,
       Expressions::In("name", {Literal::String("Alice"), 
Literal::String("Bob"),
                                Literal::String("Charlie")})
@@ -843,10 +842,10 @@ TEST_F(PredicateTest, BoundSetPredicateTestWithStrings) {
 }
 
 TEST_F(PredicateTest, BoundSetPredicateTestWithLongs) {
-  ICEBERG_ASSIGN_OR_THROW(auto in_pred,
-                          Expressions::In("id", {Literal::Long(100L), 
Literal::Long(200L),
-                                                 Literal::Long(300L)})
-                              ->Bind(*schema_, /*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto in_pred,
+                         Expressions::In("id", {Literal::Long(100L), 
Literal::Long(200L),
+                                                Literal::Long(300L)})
+                             ->Bind(*schema_, /*case_sensitive=*/true));
   auto bound_pred = AssertAndCastToBoundPredicate(in_pred);
 
   // Test longs in the set
@@ -860,8 +859,8 @@ TEST_F(PredicateTest, BoundSetPredicateTestWithLongs) {
 }
 
 TEST_F(PredicateTest, BoundSetPredicateTestSingleLiteral) {
-  ICEBERG_ASSIGN_OR_THROW(auto in_pred, Expressions::In("age", 
{Literal::Int(42)})
-                                            ->Bind(*schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto in_pred, Expressions::In("age", 
{Literal::Int(42)})
+                                           ->Bind(*schema_, 
/*case_sensitive=*/true));
 
   // Single element IN becomes Equal
   EXPECT_EQ(in_pred->op(), Expression::Operation::kEq);
diff --git a/src/iceberg/test/transform_test.cc 
b/src/iceberg/test/transform_test.cc
index 821edac5..7f0514df 100644
--- a/src/iceberg/test/transform_test.cc
+++ b/src/iceberg/test/transform_test.cc
@@ -36,7 +36,6 @@
 #include "iceberg/type.h"
 #include "iceberg/util/checked_cast.h"
 #include "iceberg/util/formatter.h"  // IWYU pragma: keep
-#include "iceberg/util/macros.h"
 
 namespace iceberg {
 
@@ -954,12 +953,12 @@ TEST_F(TransformProjectTest, IdentityProjectEquality) {
 
   // Test equality predicate
   auto unbound = Expressions::Equal("value", Literal::Int(100));
-  ICEBERG_ASSIGN_OR_THROW(auto bound,
-                          unbound->Bind(*int_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*int_schema_, /*case_sensitive=*/true));
   auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
   ASSERT_NE(bound_pred, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected, transform->Project("part", 
bound_pred));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->Project("part", 
bound_pred));
   ASSERT_NE(projected, nullptr);
   EXPECT_EQ(projected->op(), Expression::Operation::kEq);
 
@@ -977,23 +976,23 @@ TEST_F(TransformProjectTest, IdentityProjectComparison) {
 
   // Test less than predicate
   auto unbound_lt = Expressions::LessThan("value", Literal::Int(50));
-  ICEBERG_ASSIGN_OR_THROW(auto bound_lt,
-                          unbound_lt->Bind(*int_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound_lt,
+                         unbound_lt->Bind(*int_schema_, 
/*case_sensitive=*/true));
   auto bound_pred_lt = std::dynamic_pointer_cast<BoundPredicate>(bound_lt);
   ASSERT_NE(bound_pred_lt, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_lt, transform->Project("part", 
bound_pred_lt));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_lt, transform->Project("part", 
bound_pred_lt));
   ASSERT_NE(projected_lt, nullptr);
   EXPECT_EQ(projected_lt->op(), Expression::Operation::kLt);
 
   // Test greater than or equal predicate
   auto unbound_gte = Expressions::GreaterThanOrEqual("value", 
Literal::Int(100));
-  ICEBERG_ASSIGN_OR_THROW(auto bound_gte,
-                          unbound_gte->Bind(*int_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound_gte,
+                         unbound_gte->Bind(*int_schema_, 
/*case_sensitive=*/true));
   auto bound_pred_gte = std::dynamic_pointer_cast<BoundPredicate>(bound_gte);
   ASSERT_NE(bound_pred_gte, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_gte, transform->Project("part", 
bound_pred_gte));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_gte, transform->Project("part", 
bound_pred_gte));
   ASSERT_NE(projected_gte, nullptr);
   EXPECT_EQ(projected_gte->op(), Expression::Operation::kGtEq);
 }
@@ -1003,13 +1002,13 @@ TEST_F(TransformProjectTest, IdentityProjectUnary) {
 
   // Test IsNull predicate
   auto unbound_null = Expressions::IsNull("value");
-  ICEBERG_ASSIGN_OR_THROW(auto bound_null,
-                          unbound_null->Bind(*int_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound_null,
+                         unbound_null->Bind(*int_schema_, 
/*case_sensitive=*/true));
   auto bound_pred_null = std::dynamic_pointer_cast<BoundPredicate>(bound_null);
   ASSERT_NE(bound_pred_null, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_null,
-                          transform->Project("part", bound_pred_null));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_null,
+                         transform->Project("part", bound_pred_null));
   ASSERT_NE(projected_null, nullptr);
   EXPECT_EQ(projected_null->op(), Expression::Operation::kIsNull);
 }
@@ -1020,12 +1019,12 @@ TEST_F(TransformProjectTest, IdentityProjectSet) {
   // Test IN predicate
   auto unbound_in =
       Expressions::In("value", {Literal::Int(1), Literal::Int(2), 
Literal::Int(3)});
-  ICEBERG_ASSIGN_OR_THROW(auto bound_in,
-                          unbound_in->Bind(*int_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound_in,
+                         unbound_in->Bind(*int_schema_, 
/*case_sensitive=*/true));
   auto bound_pred_in = std::dynamic_pointer_cast<BoundPredicate>(bound_in);
   ASSERT_NE(bound_pred_in, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_in, transform->Project("part", 
bound_pred_in));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_in, transform->Project("part", 
bound_pred_in));
   ASSERT_NE(projected_in, nullptr);
   EXPECT_EQ(projected_in->op(), Expression::Operation::kIn);
   auto unbound_projected =
@@ -1046,12 +1045,12 @@ TEST_F(TransformProjectTest, BucketProjectEquality) {
 
   // Bucket can project equality predicates
   auto unbound = Expressions::Equal("value", Literal::Int(34));
-  ICEBERG_ASSIGN_OR_THROW(auto bound,
-                          unbound->Bind(*int_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*int_schema_, /*case_sensitive=*/true));
   auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
   ASSERT_NE(bound_pred, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected, transform->Project("part", 
bound_pred));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->Project("part", 
bound_pred));
   ASSERT_NE(projected, nullptr);
   EXPECT_EQ(projected->op(), Expression::Operation::kEq);
 
@@ -1070,8 +1069,8 @@ TEST_F(TransformProjectTest, 
BucketProjectWithMatchingTransformedChild) {
   // Create a predicate like: bucket(value, 16) = 5
   auto bucket_term = Expressions::Bucket("value", 16);
   auto unbound = Expressions::Equal<BoundTransform>(bucket_term, 
Literal::Int(5));
-  ICEBERG_ASSIGN_OR_THROW(auto bound,
-                          unbound->Bind(*int_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*int_schema_, /*case_sensitive=*/true));
   auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
   ASSERT_NE(bound_pred, nullptr);
 
@@ -1080,8 +1079,8 @@ TEST_F(TransformProjectTest, 
BucketProjectWithMatchingTransformedChild) {
 
   // When the transform matches, Project should use RemoveTransform and return 
the
   // predicate
-  ICEBERG_ASSIGN_OR_THROW(auto projected,
-                          partition_transform->Project("part", bound_pred));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected,
+                         partition_transform->Project("part", bound_pred));
   ASSERT_NE(projected, nullptr);
   EXPECT_EQ(projected->op(), Expression::Operation::kEq);
   auto unbound_projected =
@@ -1098,12 +1097,12 @@ TEST_F(TransformProjectTest, 
BucketProjectComparisonReturnsNull) {
 
   // Bucket cannot project comparison predicates (they return null)
   auto unbound_lt = Expressions::LessThan("value", Literal::Int(50));
-  ICEBERG_ASSIGN_OR_THROW(auto bound_lt,
-                          unbound_lt->Bind(*int_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound_lt,
+                         unbound_lt->Bind(*int_schema_, 
/*case_sensitive=*/true));
   auto bound_pred_lt = std::dynamic_pointer_cast<BoundPredicate>(bound_lt);
   ASSERT_NE(bound_pred_lt, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_lt, transform->Project("part", 
bound_pred_lt));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_lt, transform->Project("part", 
bound_pred_lt));
   EXPECT_EQ(projected_lt, nullptr);
 }
 
@@ -1113,12 +1112,12 @@ TEST_F(TransformProjectTest, BucketProjectInSet) {
   // Bucket can project IN predicates
   auto unbound_in =
       Expressions::In("value", {Literal::Int(1), Literal::Int(2), 
Literal::Int(3)});
-  ICEBERG_ASSIGN_OR_THROW(auto bound_in,
-                          unbound_in->Bind(*int_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound_in,
+                         unbound_in->Bind(*int_schema_, 
/*case_sensitive=*/true));
   auto bound_pred_in = std::dynamic_pointer_cast<BoundPredicate>(bound_in);
   ASSERT_NE(bound_pred_in, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_in, transform->Project("part", 
bound_pred_in));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_in, transform->Project("part", 
bound_pred_in));
   ASSERT_NE(projected_in, nullptr);
   EXPECT_EQ(projected_in->op(), Expression::Operation::kIn);
 }
@@ -1129,13 +1128,13 @@ TEST_F(TransformProjectTest, 
BucketProjectNotInReturnsNull) {
   // Bucket cannot project NOT IN predicates
   auto unbound_not_in =
       Expressions::NotIn("value", {Literal::Int(1), Literal::Int(2), 
Literal::Int(3)});
-  ICEBERG_ASSIGN_OR_THROW(auto bound_not_in,
-                          unbound_not_in->Bind(*int_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound_not_in,
+                         unbound_not_in->Bind(*int_schema_, 
/*case_sensitive=*/true));
   auto bound_pred_not_in = 
std::dynamic_pointer_cast<BoundPredicate>(bound_not_in);
   ASSERT_NE(bound_pred_not_in, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_not_in,
-                          transform->Project("part", bound_pred_not_in));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_not_in,
+                         transform->Project("part", bound_pred_not_in));
   EXPECT_EQ(projected_not_in, nullptr);
 }
 
@@ -1144,12 +1143,12 @@ TEST_F(TransformProjectTest, 
TruncateProjectIntEquality) {
 
   // Truncate can project equality predicates
   auto unbound = Expressions::Equal("value", Literal::Int(123));
-  ICEBERG_ASSIGN_OR_THROW(auto bound,
-                          unbound->Bind(*int_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*int_schema_, /*case_sensitive=*/true));
   auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
   ASSERT_NE(bound_pred, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected, transform->Project("part", 
bound_pred));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->Project("part", 
bound_pred));
   ASSERT_NE(projected, nullptr);
   EXPECT_EQ(projected->op(), Expression::Operation::kEq);
 
@@ -1167,12 +1166,12 @@ TEST_F(TransformProjectTest, 
TruncateProjectIntLessThan) {
 
   // Truncate projects LT as LTE
   auto unbound = Expressions::LessThan("value", Literal::Int(25));
-  ICEBERG_ASSIGN_OR_THROW(auto bound,
-                          unbound->Bind(*int_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*int_schema_, /*case_sensitive=*/true));
   auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
   ASSERT_NE(bound_pred, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected, transform->Project("part", 
bound_pred));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->Project("part", 
bound_pred));
   ASSERT_NE(projected, nullptr);
   EXPECT_EQ(projected->op(), Expression::Operation::kLtEq);
 }
@@ -1182,12 +1181,12 @@ TEST_F(TransformProjectTest, 
TruncateProjectIntGreaterThan) {
 
   // Truncate projects GT as GTE
   auto unbound = Expressions::GreaterThan("value", Literal::Int(25));
-  ICEBERG_ASSIGN_OR_THROW(auto bound,
-                          unbound->Bind(*int_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*int_schema_, /*case_sensitive=*/true));
   auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
   ASSERT_NE(bound_pred, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected, transform->Project("part", 
bound_pred));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->Project("part", 
bound_pred));
   ASSERT_NE(projected, nullptr);
   EXPECT_EQ(projected->op(), Expression::Operation::kGtEq);
 
@@ -1204,12 +1203,12 @@ TEST_F(TransformProjectTest, 
TruncateProjectStringEquality) {
   auto transform = Transform::Truncate(5);
 
   auto unbound = Expressions::Equal("value", Literal::String("Hello, World!"));
-  ICEBERG_ASSIGN_OR_THROW(auto bound,
-                          unbound->Bind(*string_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*string_schema_, 
/*case_sensitive=*/true));
   auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
   ASSERT_NE(bound_pred, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected, transform->Project("part", 
bound_pred));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->Project("part", 
bound_pred));
   ASSERT_NE(projected, nullptr);
   EXPECT_EQ(projected->op(), Expression::Operation::kEq);
 
@@ -1228,13 +1227,13 @@ TEST_F(TransformProjectTest, 
TruncateProjectStringStartsWith) {
 
   // StartsWith with shorter string than width
   auto unbound_short = Expressions::StartsWith("value", "Hi");
-  ICEBERG_ASSIGN_OR_THROW(auto bound_short,
-                          unbound_short->Bind(*string_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound_short,
+                         unbound_short->Bind(*string_schema_, 
/*case_sensitive=*/true));
   auto bound_pred_short = 
std::dynamic_pointer_cast<BoundPredicate>(bound_short);
   ASSERT_NE(bound_pred_short, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_short,
-                          transform->Project("part", bound_pred_short));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_short,
+                         transform->Project("part", bound_pred_short));
   ASSERT_NE(projected_short, nullptr);
   EXPECT_EQ(projected_short->op(), Expression::Operation::kStartsWith);
 
@@ -1249,13 +1248,13 @@ TEST_F(TransformProjectTest, 
TruncateProjectStringStartsWith) {
 
   // StartsWith with string equal to width
   auto unbound_equal = Expressions::StartsWith("value", "Hello");
-  ICEBERG_ASSIGN_OR_THROW(auto bound_equal,
-                          unbound_equal->Bind(*string_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound_equal,
+                         unbound_equal->Bind(*string_schema_, 
/*case_sensitive=*/true));
   auto bound_pred_equal = 
std::dynamic_pointer_cast<BoundPredicate>(bound_equal);
   ASSERT_NE(bound_pred_equal, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_equal,
-                          transform->Project("part", bound_pred_equal));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_equal,
+                         transform->Project("part", bound_pred_equal));
   ASSERT_NE(projected_equal, nullptr);
   EXPECT_EQ(projected_equal->op(), Expression::Operation::kEq);
 
@@ -1275,15 +1274,15 @@ TEST_F(TransformProjectTest, 
TruncateProjectStringStartsWithCodePointCountLessTh
   // Code point count < width (multi-byte UTF-8 characters)
   // "😜🧐" has 2 code points, width is 5
   auto unbound_emoji_short = Expressions::StartsWith("value", "😜🧐");
-  ICEBERG_ASSIGN_OR_THROW(
+  ICEBERG_UNWRAP_OR_FAIL(
       auto bound_emoji_short,
       unbound_emoji_short->Bind(*string_schema_, /*case_sensitive=*/true));
   auto bound_pred_emoji_short =
       std::dynamic_pointer_cast<BoundPredicate>(bound_emoji_short);
   ASSERT_NE(bound_pred_emoji_short, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_emoji_short,
-                          transform->Project("part", bound_pred_emoji_short));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_emoji_short,
+                         transform->Project("part", bound_pred_emoji_short));
   ASSERT_NE(projected_emoji_short, nullptr);
   EXPECT_EQ(projected_emoji_short->op(), Expression::Operation::kStartsWith);
 
@@ -1304,15 +1303,15 @@ TEST_F(TransformProjectTest, 
TruncateProjectStringStartsWithCodePointCountEqualT
   // Code point count == width (exactly 5 code points)
   // "πŸ˜œπŸ§πŸ€”πŸ€ͺπŸ₯³" has exactly 5 code points
   auto unbound_emoji_equal = Expressions::StartsWith("value", "πŸ˜œπŸ§πŸ€”πŸ€ͺπŸ₯³");
-  ICEBERG_ASSIGN_OR_THROW(
+  ICEBERG_UNWRAP_OR_FAIL(
       auto bound_emoji_equal,
       unbound_emoji_equal->Bind(*string_schema_, /*case_sensitive=*/true));
   auto bound_pred_emoji_equal =
       std::dynamic_pointer_cast<BoundPredicate>(bound_emoji_equal);
   ASSERT_NE(bound_pred_emoji_equal, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_emoji_equal,
-                          transform->Project("part", bound_pred_emoji_equal));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_emoji_equal,
+                         transform->Project("part", bound_pred_emoji_equal));
   ASSERT_NE(projected_emoji_equal, nullptr);
   EXPECT_EQ(projected_emoji_equal->op(), Expression::Operation::kEq);
 
@@ -1335,15 +1334,15 @@ TEST_F(TransformProjectTest,
   // "πŸ˜œπŸ§πŸ€”πŸ€ͺπŸ₯³πŸ˜΅β€πŸ’«πŸ˜‚" has 7 code points, should truncate to 5
   auto unbound_emoji_long =
       Expressions::StartsWith("value", "πŸ˜œπŸ§πŸ€”πŸ€ͺπŸ₯³πŸ˜΅β€πŸ’«πŸ˜‚");
-  ICEBERG_ASSIGN_OR_THROW(
+  ICEBERG_UNWRAP_OR_FAIL(
       auto bound_emoji_long,
       unbound_emoji_long->Bind(*string_schema_, /*case_sensitive=*/true));
   auto bound_pred_emoji_long =
       std::dynamic_pointer_cast<BoundPredicate>(bound_emoji_long);
   ASSERT_NE(bound_pred_emoji_long, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_emoji_long,
-                          transform->Project("part", bound_pred_emoji_long));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_emoji_long,
+                         transform->Project("part", bound_pred_emoji_long));
   ASSERT_NE(projected_emoji_long, nullptr);
   EXPECT_EQ(projected_emoji_long->op(), Expression::Operation::kStartsWith);
 
@@ -1364,15 +1363,15 @@ TEST_F(TransformProjectTest, 
TruncateProjectStringStartsWithMixedAsciiAndMultiBy
   // Mixed ASCII and multi-byte UTF-8 characters
   // "a😜b🧐c" has 5 code points (3 ASCII + 2 emojis)
   auto unbound_mixed_equal = Expressions::StartsWith("value", "a😜b🧐c");
-  ICEBERG_ASSIGN_OR_THROW(
+  ICEBERG_UNWRAP_OR_FAIL(
       auto bound_mixed_equal,
       unbound_mixed_equal->Bind(*string_schema_, /*case_sensitive=*/true));
   auto bound_pred_mixed_equal =
       std::dynamic_pointer_cast<BoundPredicate>(bound_mixed_equal);
   ASSERT_NE(bound_pred_mixed_equal, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_mixed_equal,
-                          transform->Project("part", bound_pred_mixed_equal));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_mixed_equal,
+                         transform->Project("part", bound_pred_mixed_equal));
   ASSERT_NE(projected_mixed_equal, nullptr);
   EXPECT_EQ(projected_mixed_equal->op(), Expression::Operation::kEq);
 
@@ -1393,15 +1392,15 @@ TEST_F(TransformProjectTest, 
TruncateProjectStringStartsWithChineseCharactersSho
   // Chinese characters (3-byte UTF-8)
   // "δ½ ε₯½δΈ–η•Œ" has 4 code points, width is 5
   auto unbound_chinese_short = Expressions::StartsWith("value", "δ½ ε₯½δΈ–η•Œ");
-  ICEBERG_ASSIGN_OR_THROW(
+  ICEBERG_UNWRAP_OR_FAIL(
       auto bound_chinese_short,
       unbound_chinese_short->Bind(*string_schema_, /*case_sensitive=*/true));
   auto bound_pred_chinese_short =
       std::dynamic_pointer_cast<BoundPredicate>(bound_chinese_short);
   ASSERT_NE(bound_pred_chinese_short, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_chinese_short,
-                          transform->Project("part", 
bound_pred_chinese_short));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_chinese_short,
+                         transform->Project("part", bound_pred_chinese_short));
   ASSERT_NE(projected_chinese_short, nullptr);
   EXPECT_EQ(projected_chinese_short->op(), Expression::Operation::kStartsWith);
 
@@ -1422,15 +1421,15 @@ TEST_F(TransformProjectTest, 
TruncateProjectStringStartsWithChineseCharactersEqu
   // Chinese characters exactly matching width
   // "δ½ ε₯½δΈ–η•Œε₯½" has exactly 5 code points
   auto unbound_chinese_equal = Expressions::StartsWith("value", "δ½ ε₯½δΈ–η•Œε₯½");
-  ICEBERG_ASSIGN_OR_THROW(
+  ICEBERG_UNWRAP_OR_FAIL(
       auto bound_chinese_equal,
       unbound_chinese_equal->Bind(*string_schema_, /*case_sensitive=*/true));
   auto bound_pred_chinese_equal =
       std::dynamic_pointer_cast<BoundPredicate>(bound_chinese_equal);
   ASSERT_NE(bound_pred_chinese_equal, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_chinese_equal,
-                          transform->Project("part", 
bound_pred_chinese_equal));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_chinese_equal,
+                         transform->Project("part", bound_pred_chinese_equal));
   ASSERT_NE(projected_chinese_equal, nullptr);
   EXPECT_EQ(projected_chinese_equal->op(), Expression::Operation::kEq);
 
@@ -1452,15 +1451,15 @@ TEST_F(TransformProjectTest,
   // NotStartsWith with code point count == width
   // Should convert to NotEq
   auto unbound_not_starts_equal = Expressions::NotStartsWith("value", "πŸ˜œπŸ§πŸ€”πŸ€ͺπŸ₯³");
-  ICEBERG_ASSIGN_OR_THROW(
+  ICEBERG_UNWRAP_OR_FAIL(
       auto bound_not_starts_equal,
       unbound_not_starts_equal->Bind(*string_schema_, 
/*case_sensitive=*/true));
   auto bound_pred_not_starts_equal =
       std::dynamic_pointer_cast<BoundPredicate>(bound_not_starts_equal);
   ASSERT_NE(bound_pred_not_starts_equal, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_not_starts_equal,
-                          transform->Project("part", 
bound_pred_not_starts_equal));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_not_starts_equal,
+                         transform->Project("part", 
bound_pred_not_starts_equal));
   ASSERT_NE(projected_not_starts_equal, nullptr);
   EXPECT_EQ(projected_not_starts_equal->op(), Expression::Operation::kNotEq);
 
@@ -1482,15 +1481,15 @@ TEST_F(TransformProjectTest,
   // NotStartsWith with code point count < width
   // Should remain NotStartsWith
   auto unbound_not_starts_short = Expressions::NotStartsWith("value", "😜🧐");
-  ICEBERG_ASSIGN_OR_THROW(
+  ICEBERG_UNWRAP_OR_FAIL(
       auto bound_not_starts_short,
       unbound_not_starts_short->Bind(*string_schema_, 
/*case_sensitive=*/true));
   auto bound_pred_not_starts_short =
       std::dynamic_pointer_cast<BoundPredicate>(bound_not_starts_short);
   ASSERT_NE(bound_pred_not_starts_short, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_not_starts_short,
-                          transform->Project("part", 
bound_pred_not_starts_short));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_not_starts_short,
+                         transform->Project("part", 
bound_pred_not_starts_short));
   ASSERT_NE(projected_not_starts_short, nullptr);
   EXPECT_EQ(projected_not_starts_short->op(), 
Expression::Operation::kNotStartsWith);
 
@@ -1514,15 +1513,15 @@ TEST_F(TransformProjectTest,
   // Should return nullptr (cannot project)
   auto unbound_not_starts_long =
       Expressions::NotStartsWith("value", "πŸ˜œπŸ§πŸ€”πŸ€ͺπŸ₯³πŸ˜΅β€πŸ’«πŸ˜‚");
-  ICEBERG_ASSIGN_OR_THROW(
+  ICEBERG_UNWRAP_OR_FAIL(
       auto bound_not_starts_long,
       unbound_not_starts_long->Bind(*string_schema_, /*case_sensitive=*/true));
   auto bound_pred_not_starts_long =
       std::dynamic_pointer_cast<BoundPredicate>(bound_not_starts_long);
   ASSERT_NE(bound_pred_not_starts_long, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_not_starts_long,
-                          transform->Project("part", 
bound_pred_not_starts_long));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_not_starts_long,
+                         transform->Project("part", 
bound_pred_not_starts_long));
   EXPECT_EQ(projected_not_starts_long, nullptr);
 }
 
@@ -1533,12 +1532,12 @@ TEST_F(TransformProjectTest, YearProjectEquality) {
   int32_t date_value =
       TemporalTestHelper::CreateDate({.year = 2021, .month = 6, .day = 1});
   auto unbound = Expressions::Equal("value", Literal::Date(date_value));
-  ICEBERG_ASSIGN_OR_THROW(auto bound,
-                          unbound->Bind(*date_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*date_schema_, 
/*case_sensitive=*/true));
   auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
   ASSERT_NE(bound_pred, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected, transform->Project("part", 
bound_pred));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->Project("part", 
bound_pred));
   ASSERT_NE(projected, nullptr);
   EXPECT_EQ(projected->op(), Expression::Operation::kEq);
 }
@@ -1551,23 +1550,23 @@ TEST_F(TransformProjectTest, YearProjectComparison) {
 
   // LT projects to LTE
   auto unbound_lt = Expressions::LessThan("value", Literal::Date(date_value));
-  ICEBERG_ASSIGN_OR_THROW(auto bound_lt,
-                          unbound_lt->Bind(*date_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound_lt,
+                         unbound_lt->Bind(*date_schema_, 
/*case_sensitive=*/true));
   auto bound_pred_lt = std::dynamic_pointer_cast<BoundPredicate>(bound_lt);
   ASSERT_NE(bound_pred_lt, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_lt, transform->Project("part", 
bound_pred_lt));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_lt, transform->Project("part", 
bound_pred_lt));
   ASSERT_NE(projected_lt, nullptr);
   EXPECT_EQ(projected_lt->op(), Expression::Operation::kLtEq);
 
   // GT projects to GTE
   auto unbound_gt = Expressions::GreaterThan("value", 
Literal::Date(date_value));
-  ICEBERG_ASSIGN_OR_THROW(auto bound_gt,
-                          unbound_gt->Bind(*date_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound_gt,
+                         unbound_gt->Bind(*date_schema_, 
/*case_sensitive=*/true));
   auto bound_pred_gt = std::dynamic_pointer_cast<BoundPredicate>(bound_gt);
   ASSERT_NE(bound_pred_gt, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_gt, transform->Project("part", 
bound_pred_gt));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_gt, transform->Project("part", 
bound_pred_gt));
   ASSERT_NE(projected_gt, nullptr);
   EXPECT_EQ(projected_gt->op(), Expression::Operation::kGtEq);
 }
@@ -1578,12 +1577,12 @@ TEST_F(TransformProjectTest, MonthProjectEquality) {
   int64_t ts_value =
       TemporalTestHelper::CreateTimestamp({.year = 2021, .month = 6, .day = 
1});
   auto unbound = Expressions::Equal("value", Literal::Timestamp(ts_value));
-  ICEBERG_ASSIGN_OR_THROW(auto bound,
-                          unbound->Bind(*timestamp_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*timestamp_schema_, 
/*case_sensitive=*/true));
   auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
   ASSERT_NE(bound_pred, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected, transform->Project("part", 
bound_pred));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->Project("part", 
bound_pred));
   ASSERT_NE(projected, nullptr);
   EXPECT_EQ(projected->op(), Expression::Operation::kEq);
 }
@@ -1594,12 +1593,12 @@ TEST_F(TransformProjectTest, DayProjectEquality) {
   int32_t date_value =
       TemporalTestHelper::CreateDate({.year = 2021, .month = 6, .day = 15});
   auto unbound = Expressions::Equal("value", Literal::Date(date_value));
-  ICEBERG_ASSIGN_OR_THROW(auto bound,
-                          unbound->Bind(*date_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*date_schema_, 
/*case_sensitive=*/true));
   auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
   ASSERT_NE(bound_pred, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected, transform->Project("part", 
bound_pred));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->Project("part", 
bound_pred));
   ASSERT_NE(projected, nullptr);
   EXPECT_EQ(projected->op(), Expression::Operation::kEq);
 }
@@ -1610,12 +1609,12 @@ TEST_F(TransformProjectTest, HourProjectEquality) {
   int64_t ts_value = TemporalTestHelper::CreateTimestamp(
       {.year = 2021, .month = 6, .day = 1, .hour = 14, .minute = 30});
   auto unbound = Expressions::Equal("value", Literal::Timestamp(ts_value));
-  ICEBERG_ASSIGN_OR_THROW(auto bound,
-                          unbound->Bind(*timestamp_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*timestamp_schema_, 
/*case_sensitive=*/true));
   auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
   ASSERT_NE(bound_pred, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected, transform->Project("part", 
bound_pred));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->Project("part", 
bound_pred));
   ASSERT_NE(projected, nullptr);
   EXPECT_EQ(projected->op(), Expression::Operation::kEq);
 }
@@ -1624,13 +1623,13 @@ TEST_F(TransformProjectTest, VoidProjectReturnsNull) {
   auto transform = Transform::Void();
 
   auto unbound = Expressions::Equal("value", Literal::Int(100));
-  ICEBERG_ASSIGN_OR_THROW(auto bound,
-                          unbound->Bind(*int_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*int_schema_, /*case_sensitive=*/true));
   auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
   ASSERT_NE(bound_pred, nullptr);
 
   // Void transform always returns null (no projection possible)
-  ICEBERG_ASSIGN_OR_THROW(auto projected, transform->Project("part", 
bound_pred));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->Project("part", 
bound_pred));
   EXPECT_EQ(projected, nullptr);
 }
 
@@ -1643,12 +1642,12 @@ TEST_F(TransformProjectTest, TemporalProjectInSet) {
 
   auto unbound_in = Expressions::In(
       "value", {Literal::Date(date1), Literal::Date(date2), 
Literal::Date(date3)});
-  ICEBERG_ASSIGN_OR_THROW(auto bound_in,
-                          unbound_in->Bind(*date_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound_in,
+                         unbound_in->Bind(*date_schema_, 
/*case_sensitive=*/true));
   auto bound_pred_in = std::dynamic_pointer_cast<BoundPredicate>(bound_in);
   ASSERT_NE(bound_pred_in, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected_in, transform->Project("part", 
bound_pred_in));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_in, transform->Project("part", 
bound_pred_in));
   ASSERT_NE(projected_in, nullptr);
   EXPECT_EQ(projected_in->op(), Expression::Operation::kIn);
 }
@@ -1663,12 +1662,12 @@ TEST_F(TransformProjectTest, DayTimestampProjectionFix) 
{
   // If we fix (for buggy writers), we project to day <= 0.
   auto unbound = Expressions::LessThan("value", Literal::Timestamp(0));
 
-  ICEBERG_ASSIGN_OR_THROW(auto bound,
-                          unbound->Bind(*timestamp_schema_, 
/*case_sensitive=*/true));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*timestamp_schema_, 
/*case_sensitive=*/true));
   auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
   ASSERT_NE(bound_pred, nullptr);
 
-  ICEBERG_ASSIGN_OR_THROW(auto projected, transform->Project("part", 
bound_pred));
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->Project("part", 
bound_pred));
   ASSERT_NE(projected, nullptr);
 
   auto unbound_projected =
@@ -1680,4 +1679,560 @@ TEST_F(TransformProjectTest, DayTimestampProjectionFix) 
{
   EXPECT_EQ(val, 0) << "Expected projected value to be 0 (fix applied), but 
got " << val;
 }
 
+// Test fixture for Transform::ProjectStrict tests
+class TransformProjectStrictTest : public ::testing::Test {
+ protected:
+  void SetUp() override {
+    // Create test schemas for different source types
+    int_schema_ = std::make_shared<Schema>(
+        std::vector<SchemaField>{SchemaField::MakeRequired(1, "value", 
int32())},
+        /*schema_id=*/0);
+    long_schema_ = std::make_shared<Schema>(
+        std::vector<SchemaField>{SchemaField::MakeRequired(1, "value", 
int64())},
+        /*schema_id=*/0);
+    string_schema_ = std::make_shared<Schema>(
+        std::vector<SchemaField>{SchemaField::MakeRequired(1, "value", 
string())},
+        /*schema_id=*/0);
+    date_schema_ = std::make_shared<Schema>(
+        std::vector<SchemaField>{SchemaField::MakeRequired(1, "value", 
date())},
+        /*schema_id=*/0);
+    timestamp_schema_ = std::make_shared<Schema>(
+        std::vector<SchemaField>{SchemaField::MakeRequired(1, "value", 
timestamp())},
+        /*schema_id=*/0);
+    decimal_schema_ = std::make_shared<Schema>(
+        std::vector<SchemaField>{SchemaField::MakeRequired(1, "value", 
decimal(9, 2))},
+        /*schema_id=*/0);
+  }
+
+  std::shared_ptr<Schema> int_schema_;
+  std::shared_ptr<Schema> long_schema_;
+  std::shared_ptr<Schema> string_schema_;
+  std::shared_ptr<Schema> date_schema_;
+  std::shared_ptr<Schema> timestamp_schema_;
+  std::shared_ptr<Schema> decimal_schema_;
+};
+
+TEST_F(TransformProjectStrictTest, IdentityStrictProjection) {
+  auto transform = Transform::Identity();
+
+  // Identity strict projection should behave the same as inclusive projection
+  auto unbound = Expressions::Equal("value", Literal::Int(100));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*int_schema_, /*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  ASSERT_NE(projected, nullptr);
+  EXPECT_EQ(projected->op(), Expression::Operation::kEq);
+
+  auto unbound_projected =
+      internal::checked_pointer_cast<UnboundPredicateImpl<BoundReference>>(
+          std::move(projected));
+  EXPECT_EQ(unbound_projected->op(), Expression::Operation::kEq);
+  EXPECT_EQ(unbound_projected->literals().size(), 1);
+  EXPECT_EQ(std::get<int32_t>(unbound_projected->literals().front().value()), 
100);
+}
+
+TEST_F(TransformProjectStrictTest, BucketStrictEqualityReturnsFalse) {
+  auto transform = Transform::Bucket(10);
+
+  // Bucket strict projection: equality should return FALSE (cannot guarantee 
equality)
+  auto unbound = Expressions::Equal("value", Literal::Int(100));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*int_schema_, /*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  EXPECT_EQ(projected, nullptr);
+}
+
+TEST_F(TransformProjectStrictTest, BucketStrictNotEqual) {
+  auto transform = Transform::Bucket(10);
+
+  // Bucket strict projection: notEqual can be projected
+  auto unbound = Expressions::NotEqual("value", Literal::Int(100));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*int_schema_, /*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  ASSERT_NE(projected, nullptr);
+  EXPECT_EQ(projected->op(), Expression::Operation::kNotEq);
+
+  auto unbound_projected =
+      internal::checked_pointer_cast<UnboundPredicateImpl<BoundReference>>(
+          std::move(projected));
+  EXPECT_EQ(unbound_projected->op(), Expression::Operation::kNotEq);
+  EXPECT_EQ(unbound_projected->literals().size(), 1);
+  // bucket(100, 10) = 6
+  EXPECT_EQ(std::get<int32_t>(unbound_projected->literals().front().value()), 
6);
+}
+
+TEST_F(TransformProjectStrictTest, BucketStrictComparisonReturnsNull) {
+  auto transform = Transform::Bucket(10);
+
+  // Bucket strict projection: comparison predicates return null
+  auto unbound_lt = Expressions::LessThan("value", Literal::Int(100));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound_lt,
+                         unbound_lt->Bind(*int_schema_, 
/*case_sensitive=*/true));
+  auto bound_pred_lt = std::dynamic_pointer_cast<BoundPredicate>(bound_lt);
+  ASSERT_NE(bound_pred_lt, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_lt,
+                         transform->ProjectStrict("part", bound_pred_lt));
+  EXPECT_EQ(projected_lt, nullptr);
+}
+
+TEST_F(TransformProjectStrictTest, BucketStrictNotIn) {
+  auto transform = Transform::Bucket(10);
+
+  // Bucket strict projection: NOT_IN can be projected
+  auto unbound_not_in = Expressions::NotIn(
+      "value", {Literal::Int(99), Literal::Int(100), Literal::Int(101)});
+  ICEBERG_UNWRAP_OR_FAIL(auto bound_not_in,
+                         unbound_not_in->Bind(*int_schema_, 
/*case_sensitive=*/true));
+  auto bound_pred_not_in = 
std::dynamic_pointer_cast<BoundPredicate>(bound_not_in);
+  ASSERT_NE(bound_pred_not_in, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_not_in,
+                         transform->ProjectStrict("part", bound_pred_not_in));
+  ASSERT_NE(projected_not_in, nullptr);
+  EXPECT_EQ(projected_not_in->op(), Expression::Operation::kNotIn);
+}
+
+TEST_F(TransformProjectStrictTest, BucketStrictInReturnsNull) {
+  auto transform = Transform::Bucket(10);
+
+  // Bucket strict projection: IN returns null (cannot guarantee)
+  auto unbound_in =
+      Expressions::In("value", {Literal::Int(99), Literal::Int(100), 
Literal::Int(101)});
+  ICEBERG_UNWRAP_OR_FAIL(auto bound_in,
+                         unbound_in->Bind(*int_schema_, 
/*case_sensitive=*/true));
+  auto bound_pred_in = std::dynamic_pointer_cast<BoundPredicate>(bound_in);
+  ASSERT_NE(bound_pred_in, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_in,
+                         transform->ProjectStrict("part", bound_pred_in));
+  EXPECT_EQ(projected_in, nullptr);
+}
+
+TEST_F(TransformProjectStrictTest, BucketStrictString) {
+  auto transform = Transform::Bucket(10);
+
+  // Bucket strict projection for string
+  auto unbound_not_eq = Expressions::NotEqual("value", 
Literal::String("abcdefg"));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound_not_eq,
+                         unbound_not_eq->Bind(*string_schema_, 
/*case_sensitive=*/true));
+  auto bound_pred_not_eq = 
std::dynamic_pointer_cast<BoundPredicate>(bound_not_eq);
+  ASSERT_NE(bound_pred_not_eq, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_not_eq,
+                         transform->ProjectStrict("part", bound_pred_not_eq));
+  ASSERT_NE(projected_not_eq, nullptr);
+  EXPECT_EQ(projected_not_eq->op(), Expression::Operation::kNotEq);
+}
+
+TEST_F(TransformProjectStrictTest, TruncateStrictIntEqualityReturnsNull) {
+  auto transform = Transform::Truncate(10);
+
+  // Truncate strict projection: equality returns null (cannot guarantee)
+  auto unbound = Expressions::Equal("value", Literal::Int(123));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*int_schema_, /*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  EXPECT_EQ(projected, nullptr);
+}
+
+TEST_F(TransformProjectStrictTest, TruncateStrictIntLessThan) {
+  auto transform = Transform::Truncate(10);
+
+  // Truncate strict projection: LT projects to LT
+  auto unbound = Expressions::LessThan("value", Literal::Int(100));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*int_schema_, /*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  ASSERT_NE(projected, nullptr);
+  EXPECT_EQ(projected->op(), Expression::Operation::kLt);
+
+  auto unbound_projected =
+      internal::checked_pointer_cast<UnboundPredicateImpl<BoundReference>>(
+          std::move(projected));
+  EXPECT_EQ(unbound_projected->op(), Expression::Operation::kLt);
+  EXPECT_EQ(unbound_projected->literals().size(), 1);
+  EXPECT_EQ(std::get<int32_t>(unbound_projected->literals().front().value()), 
100);
+}
+
+TEST_F(TransformProjectStrictTest, TruncateStrictIntLessThanOrEqual) {
+  auto transform = Transform::Truncate(10);
+
+  // Truncate strict projection: LTE projects to LT
+  auto unbound = Expressions::LessThanOrEqual("value", Literal::Int(100));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*int_schema_, /*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  ASSERT_NE(projected, nullptr);
+  EXPECT_EQ(projected->op(), Expression::Operation::kLt);
+
+  auto unbound_projected =
+      internal::checked_pointer_cast<UnboundPredicateImpl<BoundReference>>(
+          std::move(projected));
+  EXPECT_EQ(unbound_projected->op(), Expression::Operation::kLt);
+  EXPECT_EQ(unbound_projected->literals().size(), 1);
+  EXPECT_EQ(std::get<int32_t>(unbound_projected->literals().front().value()), 
100);
+}
+
+TEST_F(TransformProjectStrictTest, TruncateStrictIntGreaterThan) {
+  auto transform = Transform::Truncate(10);
+
+  // Truncate strict projection: GT projects to GT
+  auto unbound = Expressions::GreaterThan("value", Literal::Int(100));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*int_schema_, /*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  ASSERT_NE(projected, nullptr);
+  EXPECT_EQ(projected->op(), Expression::Operation::kGt);
+
+  auto unbound_projected =
+      internal::checked_pointer_cast<UnboundPredicateImpl<BoundReference>>(
+          std::move(projected));
+  EXPECT_EQ(unbound_projected->op(), Expression::Operation::kGt);
+  EXPECT_EQ(unbound_projected->literals().size(), 1);
+  EXPECT_EQ(std::get<int32_t>(unbound_projected->literals().front().value()), 
100);
+}
+
+TEST_F(TransformProjectStrictTest, 
TruncateStrictIntGreaterThanOrEqualLowerBound) {
+  auto transform = Transform::Truncate(10);
+
+  // Truncate strict projection: GTE projects to GT (lower bound, value = 100)
+  auto unbound = Expressions::GreaterThanOrEqual("value", Literal::Int(100));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*int_schema_, /*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  ASSERT_NE(projected, nullptr);
+  EXPECT_EQ(projected->op(), Expression::Operation::kGt);
+
+  auto unbound_projected =
+      internal::checked_pointer_cast<UnboundPredicateImpl<BoundReference>>(
+          std::move(projected));
+  EXPECT_EQ(unbound_projected->op(), Expression::Operation::kGt);
+  EXPECT_EQ(unbound_projected->literals().size(), 1);
+  // For GTE with value 100 and width 10, truncate(100) = 100, so GT should be 
90
+  EXPECT_EQ(std::get<int32_t>(unbound_projected->literals().front().value()), 
90);
+}
+
+TEST_F(TransformProjectStrictTest, 
TruncateStrictIntGreaterThanOrEqualUpperBound) {
+  auto transform = Transform::Truncate(10);
+
+  // Truncate strict projection: GTE projects to GT (upper bound, value = 99)
+  auto unbound = Expressions::GreaterThanOrEqual("value", Literal::Int(99));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*int_schema_, /*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  ASSERT_NE(projected, nullptr);
+  EXPECT_EQ(projected->op(), Expression::Operation::kGt);
+
+  auto unbound_projected =
+      internal::checked_pointer_cast<UnboundPredicateImpl<BoundReference>>(
+          std::move(projected));
+  EXPECT_EQ(unbound_projected->op(), Expression::Operation::kGt);
+  EXPECT_EQ(unbound_projected->literals().size(), 1);
+  // For GTE with value 99 and width 10, truncate(99) = 90, so GT should be 90
+  EXPECT_EQ(std::get<int32_t>(unbound_projected->literals().front().value()), 
90);
+}
+
+TEST_F(TransformProjectStrictTest, TruncateStrictIntNotEqual) {
+  auto transform = Transform::Truncate(10);
+
+  // Truncate strict projection: notEqual can be projected
+  auto unbound = Expressions::NotEqual("value", Literal::Int(100));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*int_schema_, /*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  ASSERT_NE(projected, nullptr);
+  EXPECT_EQ(projected->op(), Expression::Operation::kNotEq);
+
+  auto unbound_projected =
+      internal::checked_pointer_cast<UnboundPredicateImpl<BoundReference>>(
+          std::move(projected));
+  EXPECT_EQ(unbound_projected->op(), Expression::Operation::kNotEq);
+  EXPECT_EQ(unbound_projected->literals().size(), 1);
+  EXPECT_EQ(std::get<int32_t>(unbound_projected->literals().front().value()), 
100);
+}
+
+TEST_F(TransformProjectStrictTest, TruncateStrictIntNotIn) {
+  auto transform = Transform::Truncate(10);
+
+  // Truncate strict projection: NOT_IN can be projected
+  auto unbound_not_in = Expressions::NotIn(
+      "value", {Literal::Int(99), Literal::Int(100), Literal::Int(101)});
+  ICEBERG_UNWRAP_OR_FAIL(auto bound_not_in,
+                         unbound_not_in->Bind(*int_schema_, 
/*case_sensitive=*/true));
+  auto bound_pred_not_in = 
std::dynamic_pointer_cast<BoundPredicate>(bound_not_in);
+  ASSERT_NE(bound_pred_not_in, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_not_in,
+                         transform->ProjectStrict("part", bound_pred_not_in));
+  ASSERT_NE(projected_not_in, nullptr);
+  EXPECT_EQ(projected_not_in->op(), Expression::Operation::kNotIn);
+}
+
+TEST_F(TransformProjectStrictTest, TruncateStrictString) {
+  auto transform = Transform::Truncate(5);
+
+  // Truncate strict projection for string
+  auto unbound_lt = Expressions::LessThan("value", Literal::String("abcdefg"));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound_lt,
+                         unbound_lt->Bind(*string_schema_, 
/*case_sensitive=*/true));
+  auto bound_pred_lt = std::dynamic_pointer_cast<BoundPredicate>(bound_lt);
+  ASSERT_NE(bound_pred_lt, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected_lt,
+                         transform->ProjectStrict("part", bound_pred_lt));
+  ASSERT_NE(projected_lt, nullptr);
+  EXPECT_EQ(projected_lt->op(), Expression::Operation::kLt);
+
+  auto unbound_projected_lt =
+      internal::checked_pointer_cast<UnboundPredicateImpl<BoundReference>>(
+          std::move(projected_lt));
+  EXPECT_EQ(unbound_projected_lt->op(), Expression::Operation::kLt);
+  EXPECT_EQ(unbound_projected_lt->literals().size(), 1);
+  
EXPECT_EQ(std::get<std::string>(unbound_projected_lt->literals().front().value()),
+            "abcde");
+}
+
+TEST_F(TransformProjectStrictTest, YearStrictEqualityReturnsNull) {
+  auto transform = Transform::Year();
+
+  // Year strict projection: equality returns null (cannot guarantee)
+  int32_t date_value =
+      TemporalTestHelper::CreateDate({.year = 2021, .month = 6, .day = 1});
+  auto unbound = Expressions::Equal("value", Literal::Date(date_value));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*date_schema_, 
/*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  EXPECT_EQ(projected, nullptr);
+}
+
+TEST_F(TransformProjectStrictTest, YearStrictLessThan) {
+  auto transform = Transform::Year();
+
+  // Year strict projection: LT projects to LT
+  int32_t date_value =
+      TemporalTestHelper::CreateDate({.year = 2021, .month = 1, .day = 1});
+  auto unbound = Expressions::LessThan("value", Literal::Date(date_value));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*date_schema_, 
/*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  ASSERT_NE(projected, nullptr);
+  EXPECT_EQ(projected->op(), Expression::Operation::kLt);
+
+  auto unbound_projected =
+      internal::checked_pointer_cast<UnboundPredicateImpl<BoundReference>>(
+          std::move(projected));
+  EXPECT_EQ(unbound_projected->op(), Expression::Operation::kLt);
+  EXPECT_EQ(unbound_projected->literals().size(), 1);
+  EXPECT_EQ(std::get<int32_t>(unbound_projected->literals().front().value()), 
2021);
+}
+
+TEST_F(TransformProjectStrictTest, YearStrictGreaterThanOrEqual) {
+  auto transform = Transform::Year();
+
+  // Year strict projection: GTE projects to GT (lower bound)
+  int32_t date_value =
+      TemporalTestHelper::CreateDate({.year = 2021, .month = 1, .day = 1});
+  auto unbound = Expressions::GreaterThanOrEqual("value", 
Literal::Date(date_value));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*date_schema_, 
/*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  ASSERT_NE(projected, nullptr);
+  EXPECT_EQ(projected->op(), Expression::Operation::kGt);
+
+  auto unbound_projected =
+      internal::checked_pointer_cast<UnboundPredicateImpl<BoundReference>>(
+          std::move(projected));
+  EXPECT_EQ(unbound_projected->op(), Expression::Operation::kGt);
+  EXPECT_EQ(unbound_projected->literals().size(), 1);
+  EXPECT_EQ(std::get<int32_t>(unbound_projected->literals().front().value()), 
2020);
+}
+
+TEST_F(TransformProjectStrictTest, YearStrictNotEqual) {
+  auto transform = Transform::Year();
+
+  // Year strict projection: notEqual can be projected
+  int32_t date_value =
+      TemporalTestHelper::CreateDate({.year = 2021, .month = 1, .day = 1});
+  auto unbound = Expressions::NotEqual("value", Literal::Date(date_value));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*date_schema_, 
/*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  ASSERT_NE(projected, nullptr);
+  EXPECT_EQ(projected->op(), Expression::Operation::kNotEq);
+
+  auto unbound_projected =
+      internal::checked_pointer_cast<UnboundPredicateImpl<BoundReference>>(
+          std::move(projected));
+  EXPECT_EQ(unbound_projected->op(), Expression::Operation::kNotEq);
+  EXPECT_EQ(unbound_projected->literals().size(), 1);
+  EXPECT_EQ(std::get<int32_t>(unbound_projected->literals().front().value()), 
2021);
+}
+
+TEST_F(TransformProjectStrictTest, MonthStrictLessThan) {
+  auto transform = Transform::Month();
+
+  // Month strict projection: LT projects to LT
+  int64_t ts_value =
+      TemporalTestHelper::CreateTimestamp({.year = 2017, .month = 12, .day = 
1});
+  auto unbound = Expressions::LessThan("value", Literal::Timestamp(ts_value));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*timestamp_schema_, 
/*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  ASSERT_NE(projected, nullptr);
+  EXPECT_EQ(projected->op(), Expression::Operation::kLt);
+}
+
+TEST_F(TransformProjectStrictTest, DayStrictLessThan) {
+  auto transform = Transform::Day();
+
+  // Day strict projection: LT projects to LT
+  int64_t ts_value =
+      TemporalTestHelper::CreateTimestamp({.year = 2017, .month = 12, .day = 
1});
+  auto unbound = Expressions::LessThan("value", Literal::Timestamp(ts_value));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*timestamp_schema_, 
/*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  ASSERT_NE(projected, nullptr);
+  EXPECT_EQ(projected->op(), Expression::Operation::kLt);
+}
+
+TEST_F(TransformProjectStrictTest, HourStrictLessThan) {
+  auto transform = Transform::Hour();
+
+  // Hour strict projection: LT projects to LT
+  int64_t ts_value = TemporalTestHelper::CreateTimestamp(
+      {.year = 2017, .month = 12, .day = 1, .hour = 10, .minute = 0});
+  auto unbound = Expressions::LessThan("value", Literal::Timestamp(ts_value));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*timestamp_schema_, 
/*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  ASSERT_NE(projected, nullptr);
+  EXPECT_EQ(projected->op(), Expression::Operation::kLt);
+}
+
+TEST_F(TransformProjectStrictTest, DayStrictEpoch) {
+  auto transform = Transform::Day();
+
+  // Day strict projection at epoch: LT projects to LT
+  auto unbound = Expressions::LessThan("value", Literal::Timestamp(0));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*timestamp_schema_, 
/*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  ASSERT_NE(projected, nullptr);
+  EXPECT_EQ(projected->op(), Expression::Operation::kLt);
+}
+
+TEST_F(TransformProjectStrictTest, MonthStrictNotEqualNegative) {
+  auto transform = Transform::Month();
+
+  // Month strict projection: notEqual with negative dates may convert to 
NOT_IN
+  int64_t ts_value =
+      TemporalTestHelper::CreateTimestamp({.year = 1969, .month = 1, .day = 
1});
+  auto unbound = Expressions::NotEqual("value", Literal::Timestamp(ts_value));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*timestamp_schema_, 
/*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  ASSERT_NE(projected, nullptr);
+  // For negative dates, NOT_EQ may convert to NOT_IN
+  EXPECT_TRUE(projected->op() == Expression::Operation::kNotEq ||
+              projected->op() == Expression::Operation::kNotIn);
+}
+
+TEST_F(TransformProjectStrictTest, YearStrictUpperBound) {
+  auto transform = Transform::Year();
+
+  // Year strict projection: upper bound (end of year)
+  int32_t date_value =
+      TemporalTestHelper::CreateDate({.year = 2017, .month = 12, .day = 31});
+  auto unbound = Expressions::LessThanOrEqual("value", 
Literal::Date(date_value));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*date_schema_, 
/*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  ASSERT_NE(projected, nullptr);
+  EXPECT_EQ(projected->op(), Expression::Operation::kLt);
+
+  auto unbound_projected =
+      internal::checked_pointer_cast<UnboundPredicateImpl<BoundReference>>(
+          std::move(projected));
+  EXPECT_EQ(unbound_projected->op(), Expression::Operation::kLt);
+  EXPECT_EQ(unbound_projected->literals().size(), 1);
+  EXPECT_EQ(std::get<int32_t>(unbound_projected->literals().front().value()), 
2018);
+}
+
+TEST_F(TransformProjectStrictTest, VoidStrictReturnsNull) {
+  auto transform = Transform::Void();
+
+  // Void transform always returns null for strict projection
+  auto unbound = Expressions::Equal("value", Literal::Int(100));
+  ICEBERG_UNWRAP_OR_FAIL(auto bound,
+                         unbound->Bind(*int_schema_, /*case_sensitive=*/true));
+  auto bound_pred = std::dynamic_pointer_cast<BoundPredicate>(bound);
+  ASSERT_NE(bound_pred, nullptr);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto projected, transform->ProjectStrict("part", 
bound_pred));
+  EXPECT_EQ(projected, nullptr);
+}
+
 }  // namespace iceberg
diff --git a/src/iceberg/transform.cc b/src/iceberg/transform.cc
index f8d2f065..61448971 100644
--- a/src/iceberg/transform.cc
+++ b/src/iceberg/transform.cc
@@ -306,6 +306,66 @@ Result<std::unique_ptr<UnboundPredicate>> 
Transform::Project(
   std::unreachable();
 }
 
+Result<std::unique_ptr<UnboundPredicate>> Transform::ProjectStrict(
+    std::string_view name, const std::shared_ptr<BoundPredicate>& predicate) {
+  switch (transform_type_) {
+    case TransformType::kIdentity:
+      return ProjectionUtil::IdentityProject(name, predicate);
+    case TransformType::kBucket: {
+      // If the predicate has a transformed child that matches the given 
transform, return
+      // a predicate.
+      if (predicate->term()->kind() == Term::Kind::kTransform) {
+        const auto boundTransform =
+            internal::checked_pointer_cast<BoundTransform>(predicate->term());
+        if (*this == *boundTransform->transform()) {
+          return ProjectionUtil::RemoveTransform(name, predicate);
+        } else {
+          return nullptr;
+        }
+      }
+      ICEBERG_ASSIGN_OR_RAISE(auto func, Bind(predicate->term()->type()));
+      return ProjectionUtil::BucketProjectStrict(name, predicate, func);
+    }
+    case TransformType::kTruncate: {
+      // If the predicate has a transformed child that matches the given 
transform, return
+      // a predicate.
+      if (predicate->term()->kind() == Term::Kind::kTransform) {
+        const auto boundTransform =
+            internal::checked_pointer_cast<BoundTransform>(predicate->term());
+        if (*this == *boundTransform->transform()) {
+          return ProjectionUtil::RemoveTransform(name, predicate);
+        } else {
+          return nullptr;
+        }
+      }
+      ICEBERG_ASSIGN_OR_RAISE(auto func, Bind(predicate->term()->type()));
+      return ProjectionUtil::TruncateProjectStrict(name, predicate, func);
+    }
+    case TransformType::kYear:
+    case TransformType::kMonth:
+    case TransformType::kDay:
+    case TransformType::kHour: {
+      // If the predicate has a transformed child that matches the given 
transform, return
+      // a predicate.
+      if (predicate->term()->kind() == Term::Kind::kTransform) {
+        const auto boundTransform =
+            internal::checked_pointer_cast<BoundTransform>(predicate->term());
+        if (*this == *boundTransform->transform()) {
+          return ProjectionUtil::RemoveTransform(name, predicate);
+        } else {
+          return nullptr;
+        }
+      }
+      ICEBERG_ASSIGN_OR_RAISE(auto func, Bind(predicate->term()->type()));
+      return ProjectionUtil::TemporalProjectStrict(name, predicate, func);
+    }
+    case TransformType::kUnknown:
+    case TransformType::kVoid:
+      return nullptr;
+  }
+  std::unreachable();
+}
+
 bool TransformFunction::Equals(const TransformFunction& other) const {
   return transform_type_ == other.transform_type_ && *source_type_ == 
*other.source_type_;
 }
diff --git a/src/iceberg/transform.h b/src/iceberg/transform.h
index 64b85072..53993b4e 100644
--- a/src/iceberg/transform.h
+++ b/src/iceberg/transform.h
@@ -182,6 +182,18 @@ class ICEBERG_EXPORT Transform : public util::Formattable {
   Result<std::unique_ptr<UnboundPredicate>> Project(
       std::string_view name, const std::shared_ptr<BoundPredicate>& predicate);
 
+  /// \brief Transforms a BoundPredicate to a strict predicate on the 
partition values
+  /// produced by the transform.
+  ///
+  /// This strict transform guarantees that if Projected(transform(value)) is 
true, then
+  /// predicate->Test(value) is also true.
+  /// \param name The name of the partition column.
+  /// \param predicate The predicate to project.
+  /// \return A Result containing either a unique pointer to the projected 
predicate,
+  /// nullptr if the projection cannot be performed, or an Error if the 
projection fails.
+  Result<std::unique_ptr<UnboundPredicate>> ProjectStrict(
+      std::string_view name, const std::shared_ptr<BoundPredicate>& predicate);
+
   /// \brief Returns a string representation of this transform (e.g., 
"bucket[16]").
   std::string ToString() const override;
 
diff --git a/src/iceberg/util/projection_util_internal.h 
b/src/iceberg/util/projection_util_internal.h
index 3ce2dbf8..df4fe978 100644
--- a/src/iceberg/util/projection_util_internal.h
+++ b/src/iceberg/util/projection_util_internal.h
@@ -24,10 +24,12 @@
 #include <ranges>
 #include <string>
 #include <string_view>
+#include <unordered_set>
 #include <utility>
 
 #include "iceberg/expression/literal.h"
 #include "iceberg/expression/predicate.h"
+#include "iceberg/expression/term.h"
 #include "iceberg/result.h"
 #include "iceberg/transform.h"
 #include "iceberg/transform_function.h"
@@ -40,248 +42,230 @@ namespace iceberg {
 
 class ProjectionUtil {
  private:
+  static Result<Literal> AdjustLiteral(const Literal& literal, int adjustment) 
{
+    switch (literal.type()->type_id()) {
+      case TypeId::kInt:
+        return Literal::Int(std::get<int32_t>(literal.value()) + adjustment);
+      case TypeId::kLong:
+        return Literal::Long(std::get<int64_t>(literal.value()) + adjustment);
+      case TypeId::kDate:
+        return Literal::Date(std::get<int32_t>(literal.value()) + adjustment);
+      case TypeId::kTimestamp:
+        return Literal::Timestamp(std::get<int64_t>(literal.value()) + 
adjustment);
+      case TypeId::kTimestampTz:
+        return Literal::TimestampTz(std::get<int64_t>(literal.value()) + 
adjustment);
+      case TypeId::kDecimal: {
+        const auto& decimal_type =
+            internal::checked_cast<const DecimalType&>(*literal.type());
+        Decimal adjusted = std::get<Decimal>(literal.value()) + 
Decimal(adjustment);
+        return Literal::Decimal(adjusted.value(), decimal_type.precision(),
+                                decimal_type.scale());
+      }
+      default:
+        return NotSupported("{} is not a valid literal type for value 
adjustment",
+                            literal.type()->ToString());
+    }
+  }
+
+  static Result<Literal> PlusOne(const Literal& literal) {
+    return AdjustLiteral(literal, /*adjustment=*/+1);
+  }
+
+  static Result<Literal> MinusOne(const Literal& literal) {
+    return AdjustLiteral(literal, /*adjustment=*/-1);
+  }
+
+  static Result<std::unique_ptr<UnboundPredicate>> MakePredicate(
+      Expression::Operation op, std::string_view name,
+      const std::shared_ptr<TransformFunction>& func, const Literal& literal) {
+    ICEBERG_ASSIGN_OR_RAISE(auto ref, NamedReference::Make(std::string(name)));
+    ICEBERG_ASSIGN_OR_RAISE(auto lit, func->Transform(literal));
+    return UnboundPredicateImpl<BoundReference>::Make(op, std::move(ref), 
std::move(lit));
+  }
+
   static Result<std::unique_ptr<UnboundPredicate>> TransformSet(
-      std::string_view name, const std::shared_ptr<BoundSetPredicate>& 
predicate,
+      std::string_view name, const std::shared_ptr<BoundSetPredicate>& pred,
       const std::shared_ptr<TransformFunction>& func) {
     std::vector<Literal> transformed;
-    transformed.reserve(predicate->literal_set().size());
-    for (const auto& lit : predicate->literal_set()) {
+    transformed.reserve(pred->literal_set().size());
+    for (const auto& lit : pred->literal_set()) {
       ICEBERG_ASSIGN_OR_RAISE(auto transformed_lit, func->Transform(lit));
       transformed.push_back(std::move(transformed_lit));
     }
     ICEBERG_ASSIGN_OR_RAISE(auto ref, NamedReference::Make(std::string(name)));
-    return UnboundPredicateImpl<BoundReference>::Make(predicate->op(), 
std::move(ref),
+    return UnboundPredicateImpl<BoundReference>::Make(pred->op(), 
std::move(ref),
                                                       std::move(transformed));
   }
 
-  // General transform for all literal predicates.  This is used as a fallback 
for special
-  // cases that are not handled by the other transform functions.
-  static Result<std::unique_ptr<UnboundPredicate>> GenericTransform(
-      std::unique_ptr<NamedReference> ref,
-      const std::shared_ptr<BoundLiteralPredicate>& predicate,
+  static Result<std::unique_ptr<UnboundPredicate>> TruncateByteArray(
+      std::string_view name, const std::shared_ptr<BoundLiteralPredicate>& 
pred,
       const std::shared_ptr<TransformFunction>& func) {
-    ICEBERG_ASSIGN_OR_RAISE(auto transformed, 
func->Transform(predicate->literal()));
-    switch (predicate->op()) {
+    switch (pred->op()) {
       case Expression::Operation::kLt:
-      case Expression::Operation::kLtEq: {
-        return UnboundPredicateImpl<BoundReference>::Make(
-            Expression::Operation::kLtEq, std::move(ref), 
std::move(transformed));
-      }
+      case Expression::Operation::kLtEq:
+        return MakePredicate(Expression::Operation::kLtEq, name, func, 
pred->literal());
       case Expression::Operation::kGt:
-      case Expression::Operation::kGtEq: {
-        return UnboundPredicateImpl<BoundReference>::Make(
-            Expression::Operation::kGtEq, std::move(ref), 
std::move(transformed));
-      }
-      case Expression::Operation::kEq: {
-        return UnboundPredicateImpl<BoundReference>::Make(
-            Expression::Operation::kEq, std::move(ref), 
std::move(transformed));
-      }
+      case Expression::Operation::kGtEq:
+        return MakePredicate(Expression::Operation::kGtEq, name, func, 
pred->literal());
+      case Expression::Operation::kEq:
+      case Expression::Operation::kStartsWith:
+        return MakePredicate(pred->op(), name, func, pred->literal());
       default:
         return nullptr;
     }
   }
 
-  static Result<std::unique_ptr<UnboundPredicate>> TruncateByteArray(
-      std::string_view name, const std::shared_ptr<BoundLiteralPredicate>& 
predicate,
+  static Result<std::unique_ptr<UnboundPredicate>> TruncateByteArrayStrict(
+      std::string_view name, const std::shared_ptr<BoundLiteralPredicate>& 
pred,
       const std::shared_ptr<TransformFunction>& func) {
-    ICEBERG_ASSIGN_OR_RAISE(auto ref, NamedReference::Make(std::string(name)));
-    switch (predicate->op()) {
-      case Expression::Operation::kStartsWith: {
-        ICEBERG_ASSIGN_OR_RAISE(auto transformed, 
func->Transform(predicate->literal()));
-        return UnboundPredicateImpl<BoundReference>::Make(
-            Expression::Operation::kStartsWith, std::move(ref), 
std::move(transformed));
-      }
+    switch (pred->op()) {
+      case Expression::Operation::kLt:
+      case Expression::Operation::kLtEq:
+        return MakePredicate(Expression::Operation::kLt, name, func, 
pred->literal());
+      case Expression::Operation::kGt:
+      case Expression::Operation::kGtEq:
+        return MakePredicate(Expression::Operation::kGt, name, func, 
pred->literal());
+      case Expression::Operation::kNotEq:
+        return MakePredicate(Expression::Operation::kNotEq, name, func, 
pred->literal());
       default:
-        return GenericTransform(std::move(ref), predicate, func);
+        return nullptr;
     }
   }
 
-  template <typename T>
-    requires std::is_same_v<T, int32_t> || std::is_same_v<T, int64_t>
-  static Result<std::unique_ptr<UnboundPredicate>> TruncateInteger(
-      std::string_view name, const std::shared_ptr<BoundLiteralPredicate>& 
predicate,
+  // Apply to int32, int64, decimal, and temporal types
+  static Result<std::unique_ptr<UnboundPredicate>> TransformNumeric(
+      std::string_view name, const std::shared_ptr<BoundLiteralPredicate>& 
pred,
       const std::shared_ptr<TransformFunction>& func) {
-    const Literal& literal = predicate->literal();
-    ICEBERG_ASSIGN_OR_RAISE(auto ref, NamedReference::Make(std::string(name)));
+    switch (func->source_type()->type_id()) {
+      case TypeId::kInt:
+      case TypeId::kLong:
+      case TypeId::kDecimal:
+      case TypeId::kDate:
+      case TypeId::kTimestamp:
+      case TypeId::kTimestampTz:
+        break;
+      default:
+        return NotSupported("{} is not a valid input type for numeric 
transform",
+                            func->source_type()->ToString());
+    }
 
-    switch (predicate->op()) {
+    switch (pred->op()) {
       case Expression::Operation::kLt: {
         // adjust closed and then transform ltEq
-        if constexpr (std::is_same_v<T, int32_t>) {
-          ICEBERG_ASSIGN_OR_RAISE(
-              auto transformed,
-              func->Transform(Literal::Int(std::get<int32_t>(literal.value()) 
- 1)));
-          return UnboundPredicateImpl<BoundReference>::Make(
-              Expression::Operation::kLtEq, std::move(ref), 
std::move(transformed));
-        } else {
-          ICEBERG_ASSIGN_OR_RAISE(
-              auto transformed,
-              func->Transform(Literal::Long(std::get<int64_t>(literal.value()) 
- 1)));
-          return UnboundPredicateImpl<BoundReference>::Make(
-              Expression::Operation::kLtEq, std::move(ref), 
std::move(transformed));
-        }
+        ICEBERG_ASSIGN_OR_RAISE(auto adjusted, MinusOne(pred->literal()));
+        return MakePredicate(Expression::Operation::kLtEq, name, func, 
adjusted);
       }
       case Expression::Operation::kGt: {
         // adjust closed and then transform gtEq
-        if constexpr (std::is_same_v<T, int32_t>) {
-          ICEBERG_ASSIGN_OR_RAISE(
-              auto transformed,
-              func->Transform(Literal::Int(std::get<int32_t>(literal.value()) 
+ 1)));
-          return UnboundPredicateImpl<BoundReference>::Make(
-              Expression::Operation::kGtEq, std::move(ref), 
std::move(transformed));
-        } else {
-          ICEBERG_ASSIGN_OR_RAISE(
-              auto transformed,
-              func->Transform(Literal::Long(std::get<int64_t>(literal.value()) 
+ 1)));
-          return UnboundPredicateImpl<BoundReference>::Make(
-              Expression::Operation::kGtEq, std::move(ref), 
std::move(transformed));
-        }
+        ICEBERG_ASSIGN_OR_RAISE(auto adjusted, PlusOne(pred->literal()));
+        return MakePredicate(Expression::Operation::kGtEq, name, func, 
adjusted);
       }
+      case Expression::Operation::kLtEq:
+      case Expression::Operation::kGtEq:
+      case Expression::Operation::kEq:
+        return MakePredicate(pred->op(), name, func, pred->literal());
       default:
-        return GenericTransform(std::move(ref), predicate, func);
+        return nullptr;
     }
   }
 
-  static Result<std::unique_ptr<UnboundPredicate>> TransformTemporal(
-      std::string_view name, const std::shared_ptr<BoundLiteralPredicate>& 
predicate,
+  static Result<std::unique_ptr<UnboundPredicate>> TransformNumericStrict(
+      std::string_view name, const std::shared_ptr<BoundLiteralPredicate>& 
pred,
       const std::shared_ptr<TransformFunction>& func) {
-    const Literal& literal = predicate->literal();
-    ICEBERG_ASSIGN_OR_RAISE(auto ref, NamedReference::Make(std::string(name)));
-
     switch (func->source_type()->type_id()) {
-      case TypeId::kDate: {
-        switch (predicate->op()) {
-          case Expression::Operation::kLt: {
-            ICEBERG_ASSIGN_OR_RAISE(
-                auto transformed,
-                
func->Transform(Literal::Date(std::get<int32_t>(literal.value()) - 1)));
-            return UnboundPredicateImpl<BoundReference>::Make(
-                Expression::Operation::kLtEq, std::move(ref), 
std::move(transformed));
-          }
-          case Expression::Operation::kGt: {
-            ICEBERG_ASSIGN_OR_RAISE(
-                auto transformed,
-                
func->Transform(Literal::Date(std::get<int32_t>(literal.value()) + 1)));
-            return UnboundPredicateImpl<BoundReference>::Make(
-                Expression::Operation::kGtEq, std::move(ref), 
std::move(transformed));
-          }
-          default:
-            return GenericTransform(std::move(ref), predicate, func);
-        }
-      }
-      case TypeId::kTimestamp: {
-        switch (predicate->op()) {
-          case Expression::Operation::kLt: {
-            ICEBERG_ASSIGN_OR_RAISE(auto transformed,
-                                    func->Transform(Literal::Timestamp(
-                                        std::get<int64_t>(literal.value()) - 
1)));
-            return UnboundPredicateImpl<BoundReference>::Make(
-                Expression::Operation::kLtEq, std::move(ref), 
std::move(transformed));
-          }
-          case Expression::Operation::kGt: {
-            ICEBERG_ASSIGN_OR_RAISE(auto transformed,
-                                    func->Transform(Literal::Timestamp(
-                                        std::get<int64_t>(literal.value()) + 
1)));
-            return UnboundPredicateImpl<BoundReference>::Make(
-                Expression::Operation::kGtEq, std::move(ref), 
std::move(transformed));
-          }
-          default:
-            return GenericTransform(std::move(ref), predicate, func);
-        }
+      case TypeId::kInt:
+      case TypeId::kLong:
+      case TypeId::kDecimal:
+      case TypeId::kDate:
+      case TypeId::kTimestamp:
+      case TypeId::kTimestampTz:
+        break;
+      default:
+        return NotSupported("{} is not a valid input type for numeric 
transform",
+                            func->source_type()->ToString());
+    }
+
+    switch (pred->op()) {
+      case Expression::Operation::kLtEq: {
+        ICEBERG_ASSIGN_OR_RAISE(auto adjusted, PlusOne(pred->literal()));
+        return MakePredicate(Expression::Operation::kLt, name, func, adjusted);
       }
-      case TypeId::kTimestampTz: {
-        switch (predicate->op()) {
-          case Expression::Operation::kLt: {
-            ICEBERG_ASSIGN_OR_RAISE(auto transformed,
-                                    func->Transform(Literal::TimestampTz(
-                                        std::get<int64_t>(literal.value()) - 
1)));
-            return UnboundPredicateImpl<BoundReference>::Make(
-                Expression::Operation::kLtEq, std::move(ref), 
std::move(transformed));
-          }
-          case Expression::Operation::kGt: {
-            ICEBERG_ASSIGN_OR_RAISE(auto transformed,
-                                    func->Transform(Literal::TimestampTz(
-                                        std::get<int64_t>(literal.value()) + 
1)));
-            return UnboundPredicateImpl<BoundReference>::Make(
-                Expression::Operation::kGtEq, std::move(ref), 
std::move(transformed));
-          }
-          default:
-            return GenericTransform(std::move(ref), predicate, func);
-        }
+      case Expression::Operation::kGtEq: {
+        ICEBERG_ASSIGN_OR_RAISE(auto adjusted, MinusOne(pred->literal()));
+        return MakePredicate(Expression::Operation::kGt, name, func, adjusted);
       }
+      case Expression::Operation::kLt:
+      case Expression::Operation::kGt:
+      case Expression::Operation::kNotEq:
+        return MakePredicate(pred->op(), name, func, pred->literal());
       default:
-        return NotSupported("{} is not a valid input type for temporal 
transform",
-                            func->source_type()->ToString());
+        return nullptr;
     }
   }
 
-  static Result<std::unique_ptr<UnboundPredicate>> TruncateDecimal(
-      std::string_view name, const std::shared_ptr<BoundLiteralPredicate>& 
predicate,
+  static Result<std::unique_ptr<UnboundPredicate>> TruncateStringLiteral(
+      std::string_view name, const std::shared_ptr<BoundLiteralPredicate>& 
pred,
       const std::shared_ptr<TransformFunction>& func) {
-    const Literal& boundary = predicate->literal();
-    ICEBERG_ASSIGN_OR_RAISE(auto ref, NamedReference::Make(std::string(name)));
+    const auto op = pred->op();
+    if (op != Expression::Operation::kStartsWith &&
+        op != Expression::Operation::kNotStartsWith) {
+      return TruncateByteArray(name, pred, func);
+    }
 
-    // For boundary adjustments, extract type info once
-    auto make_adjusted_literal = [&boundary](int adjustment) {
-      const auto& type = 
internal::checked_pointer_cast<DecimalType>(boundary.type());
-      Decimal adjusted = std::get<Decimal>(boundary.value()) + 
Decimal(adjustment);
-      return Literal::Decimal(adjusted.value(), type->precision(), 
type->scale());
-    };
+    const auto& literal = pred->literal();
+    const auto length =
+        StringUtils::CodePointCount(std::get<std::string>(literal.value()));
+    const auto width = static_cast<size_t>(
+        internal::checked_pointer_cast<TruncateTransform>(func)->width());
 
-    switch (predicate->op()) {
-      case Expression::Operation::kLt: {
-        // adjust closed and then transform ltEq
-        ICEBERG_ASSIGN_OR_RAISE(auto transformed,
-                                func->Transform(make_adjusted_literal(-1)));
-        return UnboundPredicateImpl<BoundReference>::Make(
-            Expression::Operation::kLtEq, std::move(ref), 
std::move(transformed));
-      }
-      case Expression::Operation::kGt: {
-        // adjust closed and then transform gtEq
-        ICEBERG_ASSIGN_OR_RAISE(auto transformed,
-                                func->Transform(make_adjusted_literal(1)));
-        return UnboundPredicateImpl<BoundReference>::Make(
-            Expression::Operation::kGtEq, std::move(ref), 
std::move(transformed));
+    if (length < width) {
+      return MakePredicate(op, name, func, literal);
+    }
+
+    if (length == width) {
+      if (op == Expression::Operation::kStartsWith) {
+        return MakePredicate(Expression::Operation::kEq, name, func, literal);
+      } else {
+        return MakePredicate(Expression::Operation::kNotEq, name, func, 
literal);
       }
-      default:
-        return GenericTransform(std::move(ref), predicate, func);
     }
+
+    if (op == Expression::Operation::kStartsWith) {
+      return TruncateByteArray(name, pred, func);
+    }
+
+    return nullptr;
   }
 
-  static Result<std::unique_ptr<UnboundPredicate>> TruncateStringLiteral(
-      std::string_view name, const std::shared_ptr<BoundLiteralPredicate>& 
predicate,
+  static Result<std::unique_ptr<UnboundPredicate>> TruncateStringLiteralStrict(
+      std::string_view name, const std::shared_ptr<BoundLiteralPredicate>& 
pred,
       const std::shared_ptr<TransformFunction>& func) {
-    const auto op = predicate->op();
+    const auto op = pred->op();
     if (op != Expression::Operation::kStartsWith &&
         op != Expression::Operation::kNotStartsWith) {
-      return TruncateByteArray(name, predicate, func);
+      return TruncateByteArrayStrict(name, pred, func);
     }
 
-    const auto& truncate_transform =
-        internal::checked_pointer_cast<TruncateTransform>(func);
-    const auto& str_value = 
std::get<std::string>(predicate->literal().value());
-    const auto width = truncate_transform->width();
-    ICEBERG_ASSIGN_OR_RAISE(auto ref, NamedReference::Make(std::string(name)));
+    const auto& literal = pred->literal();
+    const auto length =
+        StringUtils::CodePointCount(std::get<std::string>(literal.value()));
+    const auto width = static_cast<size_t>(
+        internal::checked_pointer_cast<TruncateTransform>(func)->width());
 
-    if (StringUtils::CodePointCount(str_value) < width) {
-      return UnboundPredicateImpl<BoundReference>::Make(op, std::move(ref),
-                                                        predicate->literal());
+    if (length < width) {
+      return MakePredicate(op, name, func, literal);
     }
 
-    if (StringUtils::CodePointCount(str_value) == width) {
+    if (length == width) {
       if (op == Expression::Operation::kStartsWith) {
-        return UnboundPredicateImpl<BoundReference>::Make(
-            Expression::Operation::kEq, std::move(ref), predicate->literal());
+        return MakePredicate(Expression::Operation::kEq, name, func, literal);
       } else {
-        return UnboundPredicateImpl<BoundReference>::Make(
-            Expression::Operation::kNotEq, std::move(ref), 
predicate->literal());
+        return MakePredicate(Expression::Operation::kNotEq, name, func, 
literal);
       }
     }
 
-    if (op == Expression::Operation::kStartsWith) {
-      ICEBERG_ASSIGN_OR_RAISE(auto transformed, 
func->Transform(predicate->literal()));
-      return UnboundPredicateImpl<BoundReference>::Make(
-          Expression::Operation::kStartsWith, std::move(ref), 
std::move(transformed));
+    if (op == Expression::Operation::kNotStartsWith) {
+      return MakePredicate(Expression::Operation::kNotStartsWith, name, func, 
literal);
     }
 
     return nullptr;
@@ -304,14 +288,13 @@ class ProjectionUtil {
         const auto& literal = projected->literals().front();
         ICEBERG_DCHECK(std::holds_alternative<int32_t>(literal.value()),
                        "Expected int32_t");
-        auto value = std::get<int32_t>(literal.value());
-        if (value < 0) {
+        if (auto value = std::get<int32_t>(literal.value()); value < 0) {
           return 
UnboundPredicateImpl<BoundReference>::Make(Expression::Operation::kLt,
                                                             
std::move(projected->term()),
                                                             Literal::Int(value 
+ 1));
         }
 
-        return std::move(projected);
+        return projected;
       }
 
       case Expression::Operation::kLtEq: {
@@ -319,34 +302,33 @@ class ProjectionUtil {
         const auto& literal = projected->literals().front();
         ICEBERG_DCHECK(std::holds_alternative<int32_t>(literal.value()),
                        "Expected int32_t");
-        auto value = std::get<int32_t>(literal.value());
-        if (value < 0) {
+
+        if (auto value = std::get<int32_t>(literal.value()); value < 0) {
           return 
UnboundPredicateImpl<BoundReference>::Make(Expression::Operation::kLtEq,
                                                             
std::move(projected->term()),
                                                             Literal::Int(value 
+ 1));
         }
-        return std::move(projected);
+        return projected;
       }
 
       case Expression::Operation::kGt:
       case Expression::Operation::kGtEq:
         // incorrect projected values are already greater than the bound for 
GT, GT_EQ
-        return std::move(projected);
+        return projected;
 
       case Expression::Operation::kEq: {
         ICEBERG_DCHECK(!projected->literals().empty(), "Expected at least one 
literal");
         const auto& literal = projected->literals().front();
         ICEBERG_DCHECK(std::holds_alternative<int32_t>(literal.value()),
                        "Expected int32_t");
-        auto value = std::get<int32_t>(literal.value());
-        if (value < 0) {
+        if (auto value = std::get<int32_t>(literal.value()); value < 0) {
           // match either the incorrect value (projectedValue + 1) or the 
correct value
           // (projectedValue)
           return UnboundPredicateImpl<BoundReference>::Make(
               Expression::Operation::kIn, std::move(projected->term()),
               {literal, Literal::Int(value + 1)});
         }
-        return std::move(projected);
+        return projected;
       }
 
       case Expression::Operation::kIn: {
@@ -377,7 +359,7 @@ class ProjectionUtil {
                                                             
std::move(projected->term()),
                                                             std::move(values));
         }
-        return std::move(projected);
+        return projected;
       }
 
       case Expression::Operation::kNotIn:
@@ -386,30 +368,128 @@ class ProjectionUtil {
         return nullptr;
 
       default:
-        return std::move(projected);
+        return projected;
+    }
+  }
+
+  // Fixes a strict projection to account for incorrectly transformed values.
+  // align with Java implementation:
+  // 
https://github.com/apache/iceberg/blob/1.10.x/api/src/main/java/org/apache/iceberg/transforms/ProjectionUtil.java#L347
+  static Result<std::unique_ptr<UnboundPredicate>> FixStrictTimeProjection(
+      std::unique_ptr<UnboundPredicateImpl<BoundReference>> projected) {
+    if (projected == nullptr) {
+      return nullptr;
+    }
+
+    switch (projected->op()) {
+      case Expression::Operation::kLt:
+      case Expression::Operation::kLtEq:
+        // the correct bound is a correct strict projection for the incorrectly
+        // transformed values.
+        return projected;
+
+      case Expression::Operation::kGt: {
+        // GT and GT_EQ need to be adjusted because values that do not match 
the predicate
+        // may have been transformed into partition values that match the 
projected
+        // predicate.
+        ICEBERG_DCHECK(!projected->literals().empty(), "Expected at least one 
literal");
+        const auto& literal = projected->literals().front();
+        ICEBERG_DCHECK(std::holds_alternative<int32_t>(literal.value()),
+                       "Expected int32_t");
+        if (auto value = std::get<int32_t>(literal.value()); value <= 0) {
+          return 
UnboundPredicateImpl<BoundReference>::Make(Expression::Operation::kGt,
+                                                            
std::move(projected->term()),
+                                                            Literal::Int(value 
+ 1));
+        }
+        return projected;
+      }
+
+      case Expression::Operation::kGtEq: {
+        ICEBERG_DCHECK(!projected->literals().empty(), "Expected at least one 
literal");
+        const auto& literal = projected->literals().front();
+        ICEBERG_DCHECK(std::holds_alternative<int32_t>(literal.value()),
+                       "Expected int32_t");
+        if (auto value = std::get<int32_t>(literal.value()); value <= 0) {
+          return 
UnboundPredicateImpl<BoundReference>::Make(Expression::Operation::kGtEq,
+                                                            
std::move(projected->term()),
+                                                            Literal::Int(value 
+ 1));
+        }
+        return projected;
+      }
+
+      case Expression::Operation::kEq:
+      case Expression::Operation::kIn:
+        // there is no strict projection for EQ and IN
+        return nullptr;
+
+      case Expression::Operation::kNotEq: {
+        ICEBERG_DCHECK(!projected->literals().empty(), "Expected at least one 
literal");
+        const auto& literal = projected->literals().front();
+        ICEBERG_DCHECK(std::holds_alternative<int32_t>(literal.value()),
+                       "Expected int32_t");
+        if (auto value = std::get<int32_t>(literal.value()); value < 0) {
+          return UnboundPredicateImpl<BoundReference>::Make(
+              Expression::Operation::kNotIn, std::move(projected->term()),
+              {literal, Literal::Int(value + 1)});
+        }
+        return projected;
+      }
+
+      case Expression::Operation::kNotIn: {
+        ICEBERG_DCHECK(!projected->literals().empty(), "Expected at least one 
literal");
+        const auto& literals = projected->literals();
+        ICEBERG_DCHECK(
+            std::ranges::all_of(literals,
+                                [](const auto& lit) {
+                                  return 
std::holds_alternative<int32_t>(lit.value());
+                                }),
+            "Expected int32_t");
+        std::unordered_set<int32_t> value_set;
+        bool has_negative_value = false;
+        for (const auto& lit : literals) {
+          auto value = std::get<int32_t>(lit.value());
+          value_set.insert(value);
+          if (value < 0) {
+            value_set.insert(value + 1);
+            has_negative_value = true;
+          }
+        }
+        if (has_negative_value) {
+          auto values =
+              std::views::transform(value_set,
+                                    [](int32_t value) { return 
Literal::Int(value); }) |
+              std::ranges::to<std::vector>();
+          return 
UnboundPredicateImpl<BoundReference>::Make(Expression::Operation::kNotIn,
+                                                            
std::move(projected->term()),
+                                                            std::move(values));
+        }
+        return projected;
+      }
+
+      default:
+        return nullptr;
     }
   }
 
  public:
   static Result<std::unique_ptr<UnboundPredicate>> IdentityProject(
-      std::string_view name, const std::shared_ptr<BoundPredicate>& predicate) 
{
+      std::string_view name, const std::shared_ptr<BoundPredicate>& pred) {
     ICEBERG_ASSIGN_OR_RAISE(auto ref, NamedReference::Make(std::string(name)));
-    switch (predicate->kind()) {
+    switch (pred->kind()) {
       case BoundPredicate::Kind::kUnary: {
-        return UnboundPredicateImpl<BoundReference>::Make(predicate->op(),
-                                                          std::move(ref));
+        return UnboundPredicateImpl<BoundReference>::Make(pred->op(), 
std::move(ref));
       }
       case BoundPredicate::Kind::kLiteral: {
         const auto& literalPredicate =
-            internal::checked_pointer_cast<BoundLiteralPredicate>(predicate);
-        return UnboundPredicateImpl<BoundReference>::Make(predicate->op(), 
std::move(ref),
+            internal::checked_pointer_cast<BoundLiteralPredicate>(pred);
+        return UnboundPredicateImpl<BoundReference>::Make(pred->op(), 
std::move(ref),
                                                           
literalPredicate->literal());
       }
       case BoundPredicate::Kind::kSet: {
         const auto& setPredicate =
-            internal::checked_pointer_cast<BoundSetPredicate>(predicate);
+            internal::checked_pointer_cast<BoundSetPredicate>(pred);
         return UnboundPredicateImpl<BoundReference>::Make(
-            predicate->op(), std::move(ref),
+            pred->op(), std::move(ref),
             std::vector<Literal>(setPredicate->literal_set().begin(),
                                  setPredicate->literal_set().end()));
       }
@@ -418,30 +498,29 @@ class ProjectionUtil {
   }
 
   static Result<std::unique_ptr<UnboundPredicate>> BucketProject(
-      std::string_view name, const std::shared_ptr<BoundPredicate>& predicate,
+      std::string_view name, const std::shared_ptr<BoundPredicate>& pred,
       const std::shared_ptr<TransformFunction>& func) {
     ICEBERG_ASSIGN_OR_RAISE(auto ref, NamedReference::Make(std::string(name)));
-    switch (predicate->kind()) {
+    switch (pred->kind()) {
       case BoundPredicate::Kind::kUnary: {
-        return UnboundPredicateImpl<BoundReference>::Make(predicate->op(),
-                                                          std::move(ref));
+        return UnboundPredicateImpl<BoundReference>::Make(pred->op(), 
std::move(ref));
       }
       case BoundPredicate::Kind::kLiteral: {
-        if (predicate->op() == Expression::Operation::kEq) {
+        if (pred->op() == Expression::Operation::kEq) {
           const auto& literalPredicate =
-              internal::checked_pointer_cast<BoundLiteralPredicate>(predicate);
+              internal::checked_pointer_cast<BoundLiteralPredicate>(pred);
           ICEBERG_ASSIGN_OR_RAISE(auto transformed,
                                   
func->Transform(literalPredicate->literal()));
-          return UnboundPredicateImpl<BoundReference>::Make(
-              predicate->op(), std::move(ref), std::move(transformed));
+          return UnboundPredicateImpl<BoundReference>::Make(pred->op(), 
std::move(ref),
+                                                            
std::move(transformed));
         }
         break;
       }
       case BoundPredicate::Kind::kSet: {
         // notIn can't be projected
-        if (predicate->op() == Expression::Operation::kIn) {
+        if (pred->op() == Expression::Operation::kIn) {
           const auto& setPredicate =
-              internal::checked_pointer_cast<BoundSetPredicate>(predicate);
+              internal::checked_pointer_cast<BoundSetPredicate>(pred);
           return TransformSet(name, setPredicate, func);
         }
         break;
@@ -455,19 +534,19 @@ class ProjectionUtil {
   }
 
   static Result<std::unique_ptr<UnboundPredicate>> TruncateProject(
-      std::string_view name, const std::shared_ptr<BoundPredicate>& predicate,
+      std::string_view name, const std::shared_ptr<BoundPredicate>& pred,
       const std::shared_ptr<TransformFunction>& func) {
     ICEBERG_ASSIGN_OR_RAISE(auto ref, NamedReference::Make(std::string(name)));
     // Handle unary predicates uniformly for all types
-    if (predicate->kind() == BoundPredicate::Kind::kUnary) {
-      return UnboundPredicateImpl<BoundReference>::Make(predicate->op(), 
std::move(ref));
+    if (pred->kind() == BoundPredicate::Kind::kUnary) {
+      return UnboundPredicateImpl<BoundReference>::Make(pred->op(), 
std::move(ref));
     }
 
     // Handle set predicates (kIn) uniformly for all types
-    if (predicate->kind() == BoundPredicate::Kind::kSet) {
-      if (predicate->op() == Expression::Operation::kIn) {
+    if (pred->kind() == BoundPredicate::Kind::kSet) {
+      if (pred->op() == Expression::Operation::kIn) {
         const auto& setPredicate =
-            internal::checked_pointer_cast<BoundSetPredicate>(predicate);
+            internal::checked_pointer_cast<BoundSetPredicate>(pred);
         return TransformSet(name, setPredicate, func);
       }
       return nullptr;
@@ -475,15 +554,13 @@ class ProjectionUtil {
 
     // Handle literal predicates based on source type
     const auto& literalPredicate =
-        internal::checked_pointer_cast<BoundLiteralPredicate>(predicate);
+        internal::checked_pointer_cast<BoundLiteralPredicate>(pred);
 
     switch (func->source_type()->type_id()) {
       case TypeId::kInt:
-        return TruncateInteger<int32_t>(name, literalPredicate, func);
       case TypeId::kLong:
-        return TruncateInteger<int64_t>(name, literalPredicate, func);
       case TypeId::kDecimal:
-        return TruncateDecimal(name, literalPredicate, func);
+        return TransformNumeric(name, literalPredicate, func);
       case TypeId::kString:
         return TruncateStringLiteral(name, literalPredicate, func);
       case TypeId::kBinary:
@@ -495,16 +572,16 @@ class ProjectionUtil {
   }
 
   static Result<std::unique_ptr<UnboundPredicate>> TemporalProject(
-      std::string_view name, const std::shared_ptr<BoundPredicate>& predicate,
+      std::string_view name, const std::shared_ptr<BoundPredicate>& pred,
       const std::shared_ptr<TransformFunction>& func) {
     ICEBERG_ASSIGN_OR_RAISE(auto ref, NamedReference::Make(std::string(name)));
-    if (predicate->kind() == BoundPredicate::Kind::kUnary) {
-      return UnboundPredicateImpl<BoundReference>::Make(predicate->op(), 
std::move(ref));
-    } else if (predicate->kind() == BoundPredicate::Kind::kLiteral) {
+    if (pred->kind() == BoundPredicate::Kind::kUnary) {
+      return UnboundPredicateImpl<BoundReference>::Make(pred->op(), 
std::move(ref));
+    } else if (pred->kind() == BoundPredicate::Kind::kLiteral) {
       const auto& literalPredicate =
-          internal::checked_pointer_cast<BoundLiteralPredicate>(predicate);
+          internal::checked_pointer_cast<BoundLiteralPredicate>(pred);
       ICEBERG_ASSIGN_OR_RAISE(auto projected,
-                              TransformTemporal(name, literalPredicate, func));
+                              TransformNumeric(name, literalPredicate, func));
       if (func->transform_type() != TransformType::kDay ||
           func->source_type()->type_id() != TypeId::kDate) {
         return FixInclusiveTimeProjection(
@@ -512,10 +589,9 @@ class ProjectionUtil {
                 std::move(projected)));
       }
       return projected;
-    } else if (predicate->kind() == BoundPredicate::Kind::kSet &&
-               predicate->op() == Expression::Operation::kIn) {
-      const auto& setPredicate =
-          internal::checked_pointer_cast<BoundSetPredicate>(predicate);
+    } else if (pred->kind() == BoundPredicate::Kind::kSet &&
+               pred->op() == Expression::Operation::kIn) {
+      const auto& setPredicate = 
internal::checked_pointer_cast<BoundSetPredicate>(pred);
       ICEBERG_ASSIGN_OR_RAISE(auto projected, TransformSet(name, setPredicate, 
func));
       if (func->transform_type() != TransformType::kDay ||
           func->source_type()->type_id() != TypeId::kDate) {
@@ -530,30 +606,135 @@ class ProjectionUtil {
   }
 
   static Result<std::unique_ptr<UnboundPredicate>> RemoveTransform(
-      std::string_view name, const std::shared_ptr<BoundPredicate>& predicate) 
{
+      std::string_view name, const std::shared_ptr<BoundPredicate>& pred) {
     ICEBERG_ASSIGN_OR_RAISE(auto ref, NamedReference::Make(std::string(name)));
-    switch (predicate->kind()) {
+    switch (pred->kind()) {
       case BoundPredicate::Kind::kUnary: {
-        return UnboundPredicateImpl<BoundReference>::Make(predicate->op(),
-                                                          std::move(ref));
+        return UnboundPredicateImpl<BoundReference>::Make(pred->op(), 
std::move(ref));
       }
       case BoundPredicate::Kind::kLiteral: {
         const auto& literalPredicate =
-            internal::checked_pointer_cast<BoundLiteralPredicate>(predicate);
-        return UnboundPredicateImpl<BoundReference>::Make(predicate->op(), 
std::move(ref),
+            internal::checked_pointer_cast<BoundLiteralPredicate>(pred);
+        return UnboundPredicateImpl<BoundReference>::Make(pred->op(), 
std::move(ref),
                                                           
literalPredicate->literal());
       }
       case BoundPredicate::Kind::kSet: {
         const auto& setPredicate =
-            internal::checked_pointer_cast<BoundSetPredicate>(predicate);
+            internal::checked_pointer_cast<BoundSetPredicate>(pred);
         return UnboundPredicateImpl<BoundReference>::Make(
-            predicate->op(), std::move(ref),
+            pred->op(), std::move(ref),
             std::vector<Literal>(setPredicate->literal_set().begin(),
                                  setPredicate->literal_set().end()));
       }
     }
     std::unreachable();
   }
+
+  static Result<std::unique_ptr<UnboundPredicate>> BucketProjectStrict(
+      std::string_view name, const std::shared_ptr<BoundPredicate>& pred,
+      const std::shared_ptr<TransformFunction>& func) {
+    ICEBERG_ASSIGN_OR_RAISE(auto ref, NamedReference::Make(std::string(name)));
+    switch (pred->kind()) {
+      case BoundPredicate::Kind::kUnary: {
+        return UnboundPredicateImpl<BoundReference>::Make(pred->op(), 
std::move(ref));
+      }
+      case BoundPredicate::Kind::kLiteral: {
+        if (pred->op() == Expression::Operation::kNotEq) {
+          const auto& literalPredicate =
+              internal::checked_pointer_cast<BoundLiteralPredicate>(pred);
+          ICEBERG_ASSIGN_OR_RAISE(auto transformed,
+                                  
func->Transform(literalPredicate->literal()));
+          // TODO(anyone): need to translate not(eq(...)) into notEq in 
expressions
+          return UnboundPredicateImpl<BoundReference>::Make(pred->op(), 
std::move(ref),
+                                                            
std::move(transformed));
+        }
+        break;
+      }
+      case BoundPredicate::Kind::kSet: {
+        if (pred->op() == Expression::Operation::kNotIn) {
+          const auto& setPredicate =
+              internal::checked_pointer_cast<BoundSetPredicate>(pred);
+          return TransformSet(name, setPredicate, func);
+        }
+        break;
+      }
+    }
+
+    // no strict projection for comparison or equality
+    return nullptr;
+  }
+
+  static Result<std::unique_ptr<UnboundPredicate>> TruncateProjectStrict(
+      std::string_view name, const std::shared_ptr<BoundPredicate>& pred,
+      const std::shared_ptr<TransformFunction>& func) {
+    ICEBERG_ASSIGN_OR_RAISE(auto ref, NamedReference::Make(std::string(name)));
+    // Handle unary predicates uniformly for all types
+    if (pred->kind() == BoundPredicate::Kind::kUnary) {
+      return UnboundPredicateImpl<BoundReference>::Make(pred->op(), 
std::move(ref));
+    }
+
+    // Handle set predicates (kNotIn) uniformly for all types
+    if (pred->kind() == BoundPredicate::Kind::kSet) {
+      if (pred->op() == Expression::Operation::kNotIn) {
+        const auto& setPredicate =
+            internal::checked_pointer_cast<BoundSetPredicate>(pred);
+        return TransformSet(name, setPredicate, func);
+      }
+      return nullptr;
+    }
+
+    // Handle literal predicates based on source type
+    const auto& literalPredicate =
+        internal::checked_pointer_cast<BoundLiteralPredicate>(pred);
+
+    switch (func->source_type()->type_id()) {
+      case TypeId::kInt:
+      case TypeId::kLong:
+      case TypeId::kDecimal:
+        return TransformNumericStrict(name, literalPredicate, func);
+      case TypeId::kString:
+        return TruncateStringLiteralStrict(name, literalPredicate, func);
+      case TypeId::kBinary:
+        return TruncateByteArrayStrict(name, literalPredicate, func);
+      default:
+        return NotSupported("{} is not a valid input type for truncate 
transform",
+                            func->source_type()->ToString());
+    }
+  }
+
+  static Result<std::unique_ptr<UnboundPredicate>> TemporalProjectStrict(
+      std::string_view name, const std::shared_ptr<BoundPredicate>& pred,
+      const std::shared_ptr<TransformFunction>& func) {
+    ICEBERG_ASSIGN_OR_RAISE(auto ref, NamedReference::Make(std::string(name)));
+    if (pred->kind() == BoundPredicate::Kind::kUnary) {
+      return UnboundPredicateImpl<BoundReference>::Make(pred->op(), 
std::move(ref));
+    } else if (pred->kind() == BoundPredicate::Kind::kLiteral) {
+      const auto& literalPredicate =
+          internal::checked_pointer_cast<BoundLiteralPredicate>(pred);
+      ICEBERG_ASSIGN_OR_RAISE(auto projected,
+                              TransformNumericStrict(name, literalPredicate, 
func));
+      if (func->transform_type() != TransformType::kDay ||
+          func->source_type()->type_id() != TypeId::kDate) {
+        return FixStrictTimeProjection(
+            
internal::checked_pointer_cast<UnboundPredicateImpl<BoundReference>>(
+                std::move(projected)));
+      }
+      return projected;
+    } else if (pred->kind() == BoundPredicate::Kind::kSet &&
+               pred->op() == Expression::Operation::kNotIn) {
+      const auto& setPredicate = 
internal::checked_pointer_cast<BoundSetPredicate>(pred);
+      ICEBERG_ASSIGN_OR_RAISE(auto projected, TransformSet(name, setPredicate, 
func));
+      if (func->transform_type() != TransformType::kDay ||
+          func->source_type()->type_id() != TypeId::kDate) {
+        return FixStrictTimeProjection(
+            
internal::checked_pointer_cast<UnboundPredicateImpl<BoundReference>>(
+                std::move(projected)));
+      }
+      return projected;
+    }
+
+    return nullptr;
+  }
 };
 
 }  // namespace iceberg

Reply via email to