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

pitrou pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow.git


The following commit(s) were added to refs/heads/main by this push:
     new e1dfbefe2e GH-50338: [C++] Add ComputeLogicalNullCount to Datum 
(#50347)
e1dfbefe2e is described below

commit e1dfbefe2ebfe610609f4d718f652b910c966521
Author: Rahul Goel <[email protected]>
AuthorDate: Wed Jul 29 03:29:58 2026 -0400

    GH-50338: [C++] Add ComputeLogicalNullCount to Datum (#50347)
    
    ### Rationale for this change
    
    `Datum::null_count()` only counts physical nulls, so it gives the wrong 
answer for union, run-end encoded and dictionary data. `Array`, `ArrayData`, 
`ArraySpan` and `ChunkedArray` already have `ComputeLogicalNullCount()` for 
this, but `Datum` doesn't.
    
    ### What changes are included in this PR?
    
    - `Datum::ComputeLogicalNullCount()`, which delegates to the existing 
`ArrayData`/`ChunkedArray` implementations for array-like data.
    - `Scalar::IsLogicalNull()`, which is `!is_valid` for most types. 
`DictionaryScalar` overrides it because a valid index can still refer to a null 
dictionary value. Ill-formed scalars (e.g. an out-of-bounds index) are not 
handled defensively — the result is undefined for them, and validation rejects 
them. Like the array path, it doesn't recurse into nested values.
    
    ### Are these changes tested?
    
    Yes, in datum_test.cc and scalar_test.cc: union (sparse and dense), run-end 
encoded, dictionary and chunked inputs, scalars obtained from 
`Array::GetScalar()`, and a check that the scalar and array paths agree element 
by element.
    
    ### Are there any user-facing changes?
    
    The two new APIs above. The new virtual on `Scalar` changes the C++ ABI, so 
this shouldn't be backported to a patch release.
    
    * GitHub Issue: #50338
    
    Authored-by: Rahul Goel <[email protected]>
    Signed-off-by: Antoine Pitrou <[email protected]>
---
 cpp/src/arrow/datum.cc       |  17 ++++++-
 cpp/src/arrow/datum.h        |  12 +++++
 cpp/src/arrow/datum_test.cc  | 114 +++++++++++++++++++++++++++++++++++++++++++
 cpp/src/arrow/scalar.cc      |  74 ++++++++++++++--------------
 cpp/src/arrow/scalar.h       |  24 ++++++++-
 cpp/src/arrow/scalar_test.cc |  12 +++++
 6 files changed, 213 insertions(+), 40 deletions(-)

diff --git a/cpp/src/arrow/datum.cc b/cpp/src/arrow/datum.cc
index 3990078098..2687db44ce 100644
--- a/cpp/src/arrow/datum.cc
+++ b/cpp/src/arrow/datum.cc
@@ -142,7 +142,22 @@ int64_t Datum::null_count() const {
     const auto& val = *std::get<std::shared_ptr<Scalar>>(this->value);
     return val.is_valid ? 0 : 1;
   } else {
-    DCHECK(false) << "This function only valid for array-like values";
+    DCHECK(false) << "This function only valid for scalar or array-like 
values";
+    return 0;
+  }
+}
+
+int64_t Datum::ComputeLogicalNullCount() const {
+  if (this->kind() == Datum::ARRAY) {
+    return 
std::get<std::shared_ptr<ArrayData>>(this->value)->ComputeLogicalNullCount();
+  } else if (this->kind() == Datum::CHUNKED_ARRAY) {
+    return std::get<std::shared_ptr<ChunkedArray>>(this->value)
+        ->ComputeLogicalNullCount();
+  } else if (this->kind() == Datum::SCALAR) {
+    const auto& val = *std::get<std::shared_ptr<Scalar>>(this->value);
+    return val.IsLogicalNull() ? 1 : 0;
+  } else {
+    DCHECK(false) << "This function only valid for scalar or array-like 
values";
     return 0;
   }
 }
diff --git a/cpp/src/arrow/datum.h b/cpp/src/arrow/datum.h
index 4a88e7a811..3fcd5e3111 100644
--- a/cpp/src/arrow/datum.h
+++ b/cpp/src/arrow/datum.h
@@ -276,6 +276,18 @@ struct ARROW_EXPORT Datum {
   /// Only valid for scalar and array-like data.
   int64_t null_count() const;
 
+  /// \brief Compute the logical null count.
+  ///
+  /// Only valid for scalar and array-like data. Unlike null_count(), this
+  /// accounts for types whose logical nulls are not captured by the top-level
+  /// validity bitmap, such as union, run-end encoded and dictionary types; for
+  /// those types the count is recomputed on every call.
+  ///
+  /// \see Scalar::IsLogicalNull
+  /// \see ArrayData::ComputeLogicalNullCount
+  /// \see ChunkedArray::ComputeLogicalNullCount
+  int64_t ComputeLogicalNullCount() const;
+
   /// \brief The value type of the variant, if any
   ///
   /// \return nullptr if no type
diff --git a/cpp/src/arrow/datum_test.cc b/cpp/src/arrow/datum_test.cc
index 909d2577e6..b4dab46de4 100644
--- a/cpp/src/arrow/datum_test.cc
+++ b/cpp/src/arrow/datum_test.cc
@@ -23,6 +23,8 @@
 
 #include "arrow/array/array_base.h"
 #include "arrow/array/array_binary.h"
+#include "arrow/array/builder_primitive.h"
+#include "arrow/array/builder_run_end.h"
 #include "arrow/chunked_array.h"
 #include "arrow/datum.h"
 #include "arrow/record_batch.h"
@@ -139,6 +141,118 @@ TEST(Datum, NullCount) {
   ASSERT_EQ(3, val3.null_count());
 }
 
+TEST(Datum, ComputeLogicalNullCount) {
+  // For most scalars, is_valid already reflects logical validity.
+  Datum valid_scalar(std::make_shared<Int8Scalar>(1));
+  ASSERT_EQ(0, valid_scalar.ComputeLogicalNullCount());
+
+  Datum null_scalar(MakeNullScalar(int8()));
+  ASSERT_EQ(1, null_scalar.ComputeLogicalNullCount());
+
+  // Arrays and scalars of type null() are entirely null.
+  Datum null_type_arr(ArrayFromJSON(null(), "[null, null, null]"));
+  ASSERT_EQ(3, null_type_arr.null_count());
+  ASSERT_EQ(3, null_type_arr.ComputeLogicalNullCount());
+
+  Datum null_type_scalar(MakeNullScalar(null()));
+  ASSERT_EQ(1, null_type_scalar.null_count());
+  ASSERT_EQ(1, null_type_scalar.ComputeLogicalNullCount());
+
+  // For arrays with a validity bitmap, the logical null count matches
+  // null_count().
+  Datum int8_arr(ArrayFromJSON(int8(), "[1, null, null, null]"));
+  ASSERT_EQ(3, int8_arr.null_count());
+  ASSERT_EQ(3, int8_arr.ComputeLogicalNullCount());
+
+  // Union arrays carry logical nulls in their children without a top-level
+  // validity bitmap, so null_count() is 0 while the logical null count is not.
+  auto union_type = sparse_union({field("a", int8()), field("b", boolean())});
+  auto union_arr =
+      ArrayFromJSON(union_type, R"([[0, null], [1, true], [0, 5], [1, 
null]])");
+  Datum union_datum(union_arr);
+  ASSERT_EQ(0, union_datum.null_count());
+  ASSERT_EQ(2, union_datum.ComputeLogicalNullCount());
+
+  // Scalars extracted from an array preserve its logical validity.
+  auto scalar_logical_null_count = [](const Array& arr,
+                                      int64_t index) -> Result<int64_t> {
+    ARROW_ASSIGN_OR_RAISE(auto scalar, arr.GetScalar(index));
+    return Datum(scalar).ComputeLogicalNullCount();
+  };
+  ASSERT_OK_AND_EQ(1, scalar_logical_null_count(*union_arr, 0));
+  ASSERT_OK_AND_EQ(0, scalar_logical_null_count(*union_arr, 1));
+
+  // Chunked arrays sum the logical null count over the chunks.
+  auto union_chunk = ArrayFromJSON(union_type, R"([[0, 1], [1, null]])");
+  ASSERT_OK_AND_ASSIGN(auto chunked, ChunkedArray::Make({union_arr, 
union_chunk}));
+  Datum chunked_datum(chunked);
+  ASSERT_EQ(0, chunked_datum.null_count());
+  ASSERT_EQ(3, chunked_datum.ComputeLogicalNullCount());
+
+  // Run-end encoded arrays carry logical nulls in their values child, also
+  // without a top-level validity bitmap.
+  auto pool = default_memory_pool();
+  auto ree_type = run_end_encoded(int32(), int32());
+  RunEndEncodedBuilder ree_builder(pool, std::make_shared<Int32Builder>(pool),
+                                   std::make_shared<Int32Builder>(pool), 
ree_type);
+  ASSERT_OK(ree_builder.AppendScalar(*MakeScalar<int32_t>(7), 2));
+  ASSERT_OK(ree_builder.AppendNulls(3));
+  ASSERT_OK_AND_ASSIGN(auto ree_arr, ree_builder.Finish());
+  Datum ree_datum(ree_arr);
+  ASSERT_EQ(0, ree_datum.null_count());
+  ASSERT_EQ(3, ree_datum.ComputeLogicalNullCount());
+  ASSERT_OK_AND_EQ(0, scalar_logical_null_count(*ree_arr, 0));
+  ASSERT_OK_AND_EQ(1, scalar_logical_null_count(*ree_arr, 2));
+
+  // Dictionary arrays have a validity bitmap on the indices, but a valid
+  // index referencing a null dictionary value is also a logical null.
+  auto dict_type = dictionary(int32(), utf8());
+  auto dict_arr = DictArrayFromJSON(dict_type, /*indices=*/"[0, 1, null, 1]",
+                                    /*dictionary=*/R"([null, "a"])");
+  Datum dict_datum(dict_arr);
+  ASSERT_EQ(1, dict_datum.null_count());
+  ASSERT_EQ(2, dict_datum.ComputeLogicalNullCount());
+
+  // A DictionaryScalar's is_valid only reflects index validity, but the
+  // logical null count also accounts for a valid index referencing a null
+  // dictionary value, consistently with the array path.
+  ASSERT_OK_AND_ASSIGN(auto dict_scalar, dict_arr->GetScalar(0));
+  Datum dict_scalar_datum(dict_scalar);
+  ASSERT_EQ(0, dict_scalar_datum.null_count());
+  ASSERT_EQ(1, dict_scalar_datum.ComputeLogicalNullCount());
+  ASSERT_OK_AND_EQ(0, scalar_logical_null_count(*dict_arr, 1));
+  ASSERT_OK_AND_EQ(1, scalar_logical_null_count(*dict_arr, 2));
+
+  // Same for chunked dictionary arrays.
+  auto dict_chunk = DictArrayFromJSON(dict_type, /*indices=*/"[0, 0]",
+                                      /*dictionary=*/R"([null, "a"])");
+  ASSERT_OK_AND_ASSIGN(auto dict_chunked, ChunkedArray::Make({dict_arr, 
dict_chunk}));
+  Datum dict_chunked_datum(dict_chunked);
+  ASSERT_EQ(1, dict_chunked_datum.null_count());
+  ASSERT_EQ(4, dict_chunked_datum.ComputeLogicalNullCount());
+
+  // Dense union arrays also carry logical nulls in their children.
+  auto dense_union_arr =
+      ArrayFromJSON(dense_union({field("a", int8()), field("b", boolean())}),
+                    R"([[0, null], [1, true], [0, 5], [1, null]])");
+  ASSERT_EQ(0, Datum(dense_union_arr).null_count());
+  ASSERT_EQ(2, Datum(dense_union_arr).ComputeLogicalNullCount());
+
+  // The scalar path agrees with the array path on every element.
+  auto check_scalar_array_parity = [](const std::shared_ptr<Array>& arr) {
+    ARROW_SCOPED_TRACE("array type = ", arr->type()->ToString());
+    int64_t sum = 0;
+    for (int64_t i = 0; i < arr->length(); ++i) {
+      ASSERT_OK_AND_ASSIGN(auto scalar, arr->GetScalar(i));
+      sum += Datum(std::move(scalar)).ComputeLogicalNullCount();
+    }
+    ASSERT_EQ(sum, Datum(arr).ComputeLogicalNullCount());
+  };
+  for (const auto& arr : {union_arr, dense_union_arr, ree_arr, dict_arr}) {
+    check_scalar_array_parity(arr);
+  }
+}
+
 TEST(Datum, MutableArray) {
   auto arr = ArrayFromJSON(int8(), "[1, 2, 3, 4]");
 
diff --git a/cpp/src/arrow/scalar.cc b/cpp/src/arrow/scalar.cc
index 71ac25e1c2..22cffae05c 100644
--- a/cpp/src/arrow/scalar.cc
+++ b/cpp/src/arrow/scalar.cc
@@ -770,6 +770,30 @@ 
DictionaryScalar::DictionaryScalar(std::shared_ptr<DataType> type)
                             0)
                 .ValueOrDie()} {}
 
+namespace {
+
+struct DictionaryIndexValueImpl {
+  int64_t value = 0;
+
+  Status Visit(const Scalar& scalar) {
+    return Status::TypeError("Invalid dictionary index type: ", 
scalar.type->ToString());
+  }
+
+  template <typename ScalarType, typename Type = typename 
ScalarType::TypeClass>
+  enable_if_integer<Type, Status> Visit(const ScalarType& scalar) {
+    value = static_cast<int64_t>(scalar.value);
+    return Status::OK();
+  }
+};
+
+Result<int64_t> DictionaryIndexValue(const Scalar& index) {
+  DictionaryIndexValueImpl impl;
+  RETURN_NOT_OK(VisitScalarInline(index, &impl));
+  return impl.value;
+}
+
+}  // namespace
+
 Result<std::shared_ptr<Scalar>> DictionaryScalar::GetEncodedValue() const {
   const auto& dict_type = checked_cast<DictionaryType&>(*type);
 
@@ -777,47 +801,21 @@ Result<std::shared_ptr<Scalar>> 
DictionaryScalar::GetEncodedValue() const {
     return MakeNullScalar(dict_type.value_type());
   }
 
-  int64_t index_value = 0;
-  switch (dict_type.index_type()->id()) {
-    case Type::UINT8:
-      index_value =
-          static_cast<int64_t>(checked_cast<const 
UInt8Scalar&>(*value.index).value);
-      break;
-    case Type::INT8:
-      index_value =
-          static_cast<int64_t>(checked_cast<const 
Int8Scalar&>(*value.index).value);
-      break;
-    case Type::UINT16:
-      index_value =
-          static_cast<int64_t>(checked_cast<const 
UInt16Scalar&>(*value.index).value);
-      break;
-    case Type::INT16:
-      index_value =
-          static_cast<int64_t>(checked_cast<const 
Int16Scalar&>(*value.index).value);
-      break;
-    case Type::UINT32:
-      index_value =
-          static_cast<int64_t>(checked_cast<const 
UInt32Scalar&>(*value.index).value);
-      break;
-    case Type::INT32:
-      index_value =
-          static_cast<int64_t>(checked_cast<const 
Int32Scalar&>(*value.index).value);
-      break;
-    case Type::UINT64:
-      index_value =
-          static_cast<int64_t>(checked_cast<const 
UInt64Scalar&>(*value.index).value);
-      break;
-    case Type::INT64:
-      index_value =
-          static_cast<int64_t>(checked_cast<const 
Int64Scalar&>(*value.index).value);
-      break;
-    default:
-      return Status::TypeError("Not implemented dictionary index type");
-      break;
-  }
+  ARROW_ASSIGN_OR_RAISE(int64_t index_value, 
DictionaryIndexValue(*value.index));
   return value.dictionary->GetScalar(index_value);
 }
 
+bool DictionaryScalar::IsLogicalNull() const {
+  if (!is_valid) {
+    return true;
+  }
+  const auto& dict = value.dictionary;
+  int64_t index_value = DictionaryIndexValue(*value.index).ValueOrDie();
+  DCHECK_GE(index_value, 0);
+  DCHECK_LT(index_value, dict->length());
+  return dict->IsNull(index_value);
+}
+
 std::shared_ptr<DictionaryScalar> 
DictionaryScalar::Make(std::shared_ptr<Scalar> index,
                                                          
std::shared_ptr<Array> dict) {
   auto type = dictionary(index->type, dict->type());
diff --git a/cpp/src/arrow/scalar.h b/cpp/src/arrow/scalar.h
index b96d930a44..3e79d25d62 100644
--- a/cpp/src/arrow/scalar.h
+++ b/cpp/src/arrow/scalar.h
@@ -59,8 +59,27 @@ struct ARROW_EXPORT Scalar : public 
std::enable_shared_from_this<Scalar>,
   std::shared_ptr<DataType> type;
 
   /// \brief Whether the value is valid (not null) or not
+  ///
+  /// Note that this may not reflect logical nullness for all types; see
+  /// IsLogicalNull().
   bool is_valid = false;
 
+  /// \brief Whether the value is logically null
+  ///
+  /// For most types this is simply !is_valid. For dictionary scalars, however,
+  /// is_valid only reflects the validity of the index: a valid index can still
+  /// refer to a null dictionary value, in which case the scalar is logically
+  /// null. The result is undefined for ill-formed scalars; Validate() catches
+  /// those, except for an out-of-bounds dictionary index, which only
+  /// ValidateFull() detects.
+  ///
+  /// Like the array-level logical null computation, this does not recurse into
+  /// nested values: a dictionary value wrapped in a union, run-end encoded or
+  /// extension scalar is reported according to the wrapper's own is_valid.
+  ///
+  /// \see ArrayData::ComputeLogicalNullCount
+  virtual bool IsLogicalNull() const { return !is_valid; }
+
   bool Equals(const Scalar& other,
               const EqualOptions& options = EqualOptions::Defaults()) const;
 
@@ -860,7 +879,8 @@ struct ARROW_EXPORT RunEndEncodedScalar
 /// \brief A Scalar value for DictionaryType
 ///
 /// `is_valid` denotes the validity of the `index`, regardless of
-/// the corresponding value in the `dictionary`.
+/// the corresponding value in the `dictionary`; use IsLogicalNull()
+/// to also account for the referenced dictionary value.
 struct ARROW_EXPORT DictionaryScalar : public internal::PrimitiveScalarBase {
   using TypeClass = DictionaryType;
   struct ValueType {
@@ -879,6 +899,8 @@ struct ARROW_EXPORT DictionaryScalar : public 
internal::PrimitiveScalarBase {
 
   Result<std::shared_ptr<Scalar>> GetEncodedValue() const;
 
+  bool IsLogicalNull() const override;
+
   const void* data() const override {
     return 
internal::checked_cast<internal::PrimitiveScalarBase&>(*value.index).data();
   }
diff --git a/cpp/src/arrow/scalar_test.cc b/cpp/src/arrow/scalar_test.cc
index c9e49ab66a..a08ea6aa96 100644
--- a/cpp/src/arrow/scalar_test.cc
+++ b/cpp/src/arrow/scalar_test.cc
@@ -1688,6 +1688,18 @@ TEST(TestDictionaryScalar, Basics) {
     ASSERT_OK(encoded_gamma->ValidateFull());
     ASSERT_TRUE(encoded_gamma->Equals(*MakeScalar("gamma")));
 
+    ASSERT_FALSE(scalar_alpha.IsLogicalNull());
+    ASSERT_FALSE(scalar_gamma.IsLogicalNull());
+    ASSERT_TRUE(scalar_null->IsLogicalNull());
+    // The index is valid but refers to a null dictionary value
+    ASSERT_TRUE(scalar_null_value.IsLogicalNull());
+
+    // Logical nullness does not recurse through wrapper scalars: the wrapper
+    // reports its own is_valid.
+    RunEndEncodedScalar 
ree_wrapper(std::make_shared<DictionaryScalar>(null_value, ty),
+                                    run_end_encoded(int32(), ty));
+    ASSERT_FALSE(ree_wrapper.IsLogicalNull());
+
     // test Array.GetScalar
     DictionaryArray arr(ty, ArrayFromJSON(index_ty, "[2, 0, 1, null]"), dict);
     ASSERT_OK_AND_ASSIGN(auto first, arr.GetScalar(0));

Reply via email to