This is an automated email from the ASF dual-hosted git repository.
thisisnic 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 8b617feafb GH-50514: [R] read_ipc_stream fails to unify nested Enum
fields inside a Struct array across record batches (#51153)
8b617feafb is described below
commit 8b617feafb6ff2b497688025b1ae0ea3941fc473
Author: Nic Crane <[email protected]>
AuthorDate: Wed Sep 16 16:34:23 2026 -0500
GH-50514: [R] read_ipc_stream fails to unify nested Enum fields inside a
Struct array across record batches (#51153)
### Rationale for this change
When converting list columns to R, each list element was converted
independently, so decisions like factor levels (and uint32/int64
integer-vs-double sizing) could differ between elements and from the column's
`vctrs_list_of` ptype, producing malformed factors when unnesting.
### What changes are included in this PR?
List, large list, fixed-size list, and map converters now build one
converter over the values of all chunks, so factor levels are unified and
integer sizing is decided once for the whole column; the ptype is derived from
that converter instead of an empty array. Also fixes a missing `return()` when
inferring a list type from a ptype.
### Are these changes tested?
Yes, new tests covering dictionaries (including ordered, nested in structs,
and across chunks), int64/uint32 consistency, sliced arrays, extension types,
and empty/all-null columns.
### Are there any user-facing changes?
Yes: elements of converted list columns now all share one R type (e.g.
factors with unified levels), documented in NEWS.md.
* GitHub Issue: #50514
Authored-by: Nic Crane <[email protected]>
Signed-off-by: Nic Crane <[email protected]>
---
r/NEWS.md | 8 ++
r/R/type.R | 4 +-
r/man/data-type.Rd | 4 +-
r/src/array_to_vector.cpp | 167 ++++++++++++++++++++-----
r/src/type_infer.cpp | 2 +-
r/tests/testthat/test-Array.R | 221 ++++++++++++++++++++++++++++++++++
r/tests/testthat/test-chunked-array.R | 15 +++
7 files changed, 385 insertions(+), 36 deletions(-)
diff --git a/r/NEWS.md b/r/NEWS.md
index 37ed6c6de6..185316beb8 100644
--- a/r/NEWS.md
+++ b/r/NEWS.md
@@ -19,6 +19,14 @@
# arrow 25.0.1.9000
+## Minor improvements and fixes
+
+- Factor levels inside list columns are now unified across the whole column
+ when converting to R, so data read in multiple batches (e.g. via
+ `read_ipc_stream()` or `open_dataset()`) produces valid factors that can be
+ unnested. Similarly, `int64` and `uint32` values inside list columns are
+ converted to a single R type across the column (#50514).
+
# arrow 25.0.1
## Minor improvements and fixes
diff --git a/r/R/type.R b/r/R/type.R
index da60b027de..26eda942f0 100644
--- a/r/R/type.R
+++ b/r/R/type.R
@@ -356,7 +356,9 @@ NestedType <- R6Class("NestedType", inherit = DataType)
#' to `double` ("numeric") and `int64` is converted to `bit64::integer64`. For
#' `int64` types, this conversion can be disabled (so that `int64` always
yields
#' a `bit64::integer64` object) by setting `options(arrow.int64_downcast =
-#' FALSE)`.
+#' FALSE)`. For values nested inside a list type, this decision is made once
+#' for all values in the column, so every element of the list uses the same R
+#' type.
#'
#' `decimal128()` creates a `Decimal128Type`. Arrow decimals are fixed-point
#' decimal numbers encoded as a scalar integer. The `precision` is the number
of
diff --git a/r/man/data-type.Rd b/r/man/data-type.Rd
index 29fb667ed6..67bea0d542 100644
--- a/r/man/data-type.Rd
+++ b/r/man/data-type.Rd
@@ -178,7 +178,9 @@ signed integer) types may contain values that exceed the
range of R's
\code{integer} type (32-bit signed integer). When they do, \code{uint32} is
converted
to \code{double} ("numeric") and \code{int64} is converted to
\code{bit64::integer64}. For
\code{int64} types, this conversion can be disabled (so that \code{int64}
always yields
-a \code{bit64::integer64} object) by setting
\code{options(arrow.int64_downcast = FALSE)}.
+a \code{bit64::integer64} object) by setting
\code{options(arrow.int64_downcast = FALSE)}. For values nested inside a list
type, this decision is made once
+for all values in the column, so every element of the list uses the same R
+type.
\code{decimal128()} creates a \code{Decimal128Type}. Arrow decimals are
fixed-point
decimal numbers encoded as a scalar integer. The \code{precision} is the
number of
diff --git a/r/src/array_to_vector.cpp b/r/src/array_to_vector.cpp
index 8b617e6709..f5f16433f2 100644
--- a/r/src/array_to_vector.cpp
+++ b/r/src/array_to_vector.cpp
@@ -48,6 +48,12 @@ class Converter {
// Allocate a vector of the right R type for this converter
virtual SEXP Allocate(R_xlen_t n) const = 0;
+ // Allocate a vector of the right R type for a slice of the data this
converter
+ // was built for, i.e. never as an altrep vector shadowing the whole chunked
array.
+ // Only converters whose Allocate() may hand out altrep vectors (e.g.
Converter_Struct)
+ // need to override this.
+ virtual SEXP AllocateSlice(R_xlen_t n) const { return Allocate(n); }
+
// data[ start:(start + n) ] = NA
virtual Status Ingest_all_nulls(SEXP data, R_xlen_t start, R_xlen_t n) const
= 0;
@@ -97,6 +103,34 @@ class Converter {
return out;
}
+ // Convert a slice of the data this converter was built for (e.g. one
element of a
+ // list array) to a standalone R vector, reusing the decisions this
converter made
+ // over all chunks (factor levels, integer vs double, ...).
+ //
+ // chunk_index is the index of the chunk that `slice` was taken from, so that
+ // per-chunk state (e.g. dictionary transposition) is applied correctly.
+ virtual SEXP ConvertSlice(const std::shared_ptr<Array>& slice,
+ size_t chunk_index) const {
+ // Dictionary slices must go through this converter so that they get the
+ // unified levels; other types can shadow the slice with altrep as before
+ if (slice->type_id() != Type::DICTIONARY) {
+ SEXP alt =
altrep::MakeAltrepVector(std::make_shared<ChunkedArray>(slice));
+ if (!Rf_isNull(alt)) {
+ return alt;
+ }
+ }
+
+ R_xlen_t n = slice->length();
+ SEXP out = PROTECT(AllocateSlice(n));
+ if (slice->null_count() == n) {
+ StopIfNotOk(Ingest_all_nulls(out, 0, n));
+ } else {
+ StopIfNotOk(Ingest_some_nulls(out, slice, 0, n, chunk_index));
+ }
+ UNPROTECT(1);
+ return out;
+ }
+
// Converter factory
static std::shared_ptr<Converter> Make(
const std::shared_ptr<ChunkedArray>& chunked_array);
@@ -761,24 +795,36 @@ class Converter_Struct : public Converter {
SEXP Allocate(R_xlen_t n) const {
// allocate a data frame column to host each array
// If possible, a column is dealt with directly with altrep
- auto type =
- checked_cast<const
arrow::StructType*>(this->chunked_array_->type().get());
- auto out =
- arrow::r::to_r_list(converters, [n](const std::shared_ptr<Converter>&
converter) {
- SEXP out = converter->MaybeAltrep();
- if (Rf_isNull(out)) {
- out = converter->Allocate(n);
- }
- return out;
- });
- auto colnames = arrow::r::to_r_strings(
- type->fields(),
- [](const std::shared_ptr<Field>& field) { return field->name(); });
- out.attr(symbols::row_names) =
arrow::r::short_row_names(static_cast<int>(n));
- out.attr(R_NamesSymbol) = colnames;
- out.attr(R_ClassSymbol) = arrow::r::data::classes_tbl_df;
+ return AllocateDataFrame(n, [n](const std::shared_ptr<Converter>&
converter) {
+ SEXP out = converter->MaybeAltrep();
+ if (Rf_isNull(out)) {
+ out = converter->Allocate(n);
+ }
+ return out;
+ });
+ }
- return out;
+ SEXP AllocateSlice(R_xlen_t n) const {
+ // altrep would shadow the whole chunked array rather than the slice
+ return AllocateDataFrame(n, [n](const std::shared_ptr<Converter>&
converter) {
+ return converter->AllocateSlice(n);
+ });
+ }
+
+ // Convert each child of the slice through its own converter, rather than
+ // allocating then ingesting: that lets children that can only convert whole
+ // arrays (extension types) work, and children that can be altrep be altrep
+ SEXP ConvertSlice(const std::shared_ptr<Array>& slice, size_t chunk_index)
const {
+ const auto& struct_array = checked_cast<const arrow::StructArray&>(*slice);
+ // Flatten() deals with merging of nulls
+ auto arrays = ValueOrStop(struct_array.Flatten(gc_memory_pool()));
+ int nf = static_cast<int>(converters.size());
+
+ cpp11::writable::list out(nf);
+ for (int i = 0; i < nf; i++) {
+ out[i] = converters[i]->ConvertSlice(arrays[i], chunk_index);
+ }
+ return FinishDataFrame(out, slice->length());
}
Status Ingest_all_nulls(SEXP data, R_xlen_t start, R_xlen_t n) const {
@@ -824,6 +870,27 @@ class Converter_Struct : public Converter {
private:
std::vector<std::shared_ptr<Converter>> converters;
+
+ template <typename AllocateColumn>
+ SEXP AllocateDataFrame(R_xlen_t n, AllocateColumn&& allocate_column) const {
+ auto out =
+ arrow::r::to_r_list(converters,
std::forward<AllocateColumn>(allocate_column));
+ return FinishDataFrame(out, n);
+ }
+
+ // set the names, row names and class of a list of columns
+ SEXP FinishDataFrame(cpp11::writable::list& out, R_xlen_t n) const {
+ auto type =
+ checked_cast<const
arrow::StructType*>(this->chunked_array_->type().get());
+ auto colnames = arrow::r::to_r_strings(
+ type->fields(),
+ [](const std::shared_ptr<Field>& field) { return field->name(); });
+ out.attr(symbols::row_names) =
arrow::r::short_row_names(static_cast<int>(n));
+ out.attr(R_NamesSymbol) = colnames;
+ out.attr(R_ClassSymbol) = arrow::r::data::classes_tbl_df;
+
+ return out;
+ }
};
double ms_to_seconds(int64_t ms) { return static_cast<double>(ms) / 1000; }
@@ -1021,15 +1088,36 @@ class Converter_Decimal : public Converter {
}
};
+// Build a converter for the values of all chunks of a list-like chunked
array, so
+// that decisions such as factor levels or whether integers fit are made once
for
+// the whole column rather than once per list element (GH-50514, GH-50339)
+template <typename ListArrayType>
+std::shared_ptr<Converter> MakeListValuesConverter(
+ const std::shared_ptr<ChunkedArray>& chunked_array,
+ const std::shared_ptr<arrow::DataType>& value_type) {
+ ArrayVector values;
+ values.reserve(chunked_array->num_chunks());
+ for (const auto& chunk : chunked_array->chunks()) {
+ // Flatten() rather than values() so that only the values that are
logically
+ // part of the list (respecting the offset of a sliced array and null
lists)
+ // take part in the decisions
+ values.push_back(ValueOrStop(
+ checked_cast<const ListArrayType&>(*chunk).Flatten(gc_memory_pool())));
+ }
+ return Converter::Make(std::make_shared<ChunkedArray>(std::move(values),
value_type));
+}
+
template <typename ListArrayType>
class Converter_List : public Converter {
private:
- std::shared_ptr<arrow::DataType> value_type_;
+ std::shared_ptr<Converter> values_converter_;
public:
explicit Converter_List(const std::shared_ptr<ChunkedArray>& chunked_array,
const std::shared_ptr<arrow::DataType>& value_type)
- : Converter(chunked_array), value_type_(value_type) {}
+ : Converter(chunked_array),
+ values_converter_(
+ MakeListValuesConverter<ListArrayType>(chunked_array, value_type))
{}
SEXP Allocate(R_xlen_t n) const {
cpp11::writable::list res(n);
@@ -1042,10 +1130,8 @@ class Converter_List : public Converter {
res.attr(R_ClassSymbol) = arrow::r::data::classes_arrow_large_list;
}
- std::shared_ptr<arrow::Array> array = CreateEmptyArray(value_type_);
-
- // convert to an R object to store as the list' ptype
- res.attr(arrow::r::symbols::ptype) = Converter::Convert(array);
+ // an empty R object of the type of the elements, stored as the list's
ptype
+ res.attr(arrow::r::symbols::ptype) = values_converter_->AllocateSlice(0);
return res;
}
@@ -1058,11 +1144,11 @@ class Converter_List : public Converter {
Status Ingest_some_nulls(SEXP data, const std::shared_ptr<arrow::Array>&
array,
R_xlen_t start, R_xlen_t n, size_t chunk_index)
const {
auto list_array = checked_cast<const ListArrayType*>(array.get());
- auto values_array = list_array->values();
auto ingest_one = [&](R_xlen_t i) {
auto slice = list_array->value_slice(i);
- SET_VECTOR_ELT(data, i + start, Converter::Convert(slice));
+ SET_VECTOR_ELT(data, i + start,
+ values_converter_->ConvertSlice(slice, chunk_index));
return Status::OK();
};
@@ -1074,24 +1160,25 @@ class Converter_List : public Converter {
class Converter_FixedSizeList : public Converter {
private:
- std::shared_ptr<arrow::DataType> value_type_;
+ std::shared_ptr<Converter> values_converter_;
int list_size_;
public:
explicit Converter_FixedSizeList(const std::shared_ptr<ChunkedArray>&
chunked_array,
const std::shared_ptr<arrow::DataType>&
value_type,
int list_size)
- : Converter(chunked_array), value_type_(value_type),
list_size_(list_size) {}
+ : Converter(chunked_array),
+ values_converter_(
+ MakeListValuesConverter<FixedSizeListArray>(chunked_array,
value_type)),
+ list_size_(list_size) {}
SEXP Allocate(R_xlen_t n) const {
cpp11::writable::list res(n);
Rf_classgets(res, arrow::r::data::classes_arrow_fixed_size_list);
res.attr(arrow::r::symbols::list_size) = Rf_ScalarInteger(list_size_);
- std::shared_ptr<arrow::Array> array = CreateEmptyArray(value_type_);
-
- // convert to an R object to store as the list' ptype
- res.attr(arrow::r::symbols::ptype) = Converter::Convert(array);
+ // an empty R object of the type of the elements, stored as the list's
ptype
+ res.attr(arrow::r::symbols::ptype) = values_converter_->AllocateSlice(0);
return res;
}
@@ -1104,11 +1191,11 @@ class Converter_FixedSizeList : public Converter {
Status Ingest_some_nulls(SEXP data, const std::shared_ptr<arrow::Array>&
array,
R_xlen_t start, R_xlen_t n, size_t chunk_index)
const {
const auto& fixed_size_list_array = checked_cast<const
FixedSizeListArray&>(*array);
- auto values_array = fixed_size_list_array.values();
auto ingest_one = [&](R_xlen_t i) {
auto slice = fixed_size_list_array.value_slice(i);
- SET_VECTOR_ELT(data, i + start, Converter::Convert(slice));
+ SET_VECTOR_ELT(data, i + start,
+ values_converter_->ConvertSlice(slice, chunk_index));
return Status::OK();
};
return IngestSome(array, n, ingest_one);
@@ -1198,6 +1285,20 @@ class Converter_Extension : public Converter {
return extension_type->Convert(chunked_array_);
}
+ // The conversion happens in Allocate() over the whole chunked array, so a
slice
+ // can't reuse it: convert an empty slice / the slice itself on its own
instead.
+ // Non-empty slices always go through ConvertSlice() below.
+ SEXP AllocateSlice(R_xlen_t n) const {
+ if (n != 0) {
+ cpp11::stop("Cannot allocate a non-empty slice of an extension array");
+ }
+ return Converter::Convert(chunked_array_->Slice(0, 0), false);
+ }
+
+ SEXP ConvertSlice(const std::shared_ptr<Array>& slice, size_t chunk_index)
const {
+ return Converter::Convert(slice);
+ }
+
// At this point we have already done the conversion
Status Ingest_all_nulls(SEXP data, R_xlen_t start, R_xlen_t n) const {
return Status::OK();
diff --git a/r/src/type_infer.cpp b/r/src/type_infer.cpp
index b492cfde1c..125b7e9a01 100644
--- a/r/src/type_infer.cpp
+++ b/r/src/type_infer.cpp
@@ -165,7 +165,7 @@ std::shared_ptr<arrow::DataType>
InferArrowTypeFromVector<VECSXP>(SEXP x) {
// Check attr(x, "ptype") for an appropriate R prototype
SEXP ptype = Rf_getAttrib(x, symbols::ptype);
if (!Rf_isNull(ptype)) {
- arrow::list(InferArrowType(ptype));
+ return arrow::list(InferArrowType(ptype));
}
// If unspecified, iterate through the vector until we get a non-null
result
diff --git a/r/tests/testthat/test-Array.R b/r/tests/testthat/test-Array.R
index e7a6ce5d24..ea3bff7826 100644
--- a/r/tests/testthat/test-Array.R
+++ b/r/tests/testthat/test-Array.R
@@ -1466,3 +1466,224 @@ test_that("uint64 inside list columns always converts
to double (GH-50339)", {
expect_type(result[[1]], "double")
expect_type(result[[2]], "double")
})
+
+test_that("list of dictionary: ptype and elements share the dictionary's
levels (GH-50514)", {
+ arr <- arrow_array(
+ list(factor(c("a", "b"), levels = c("a", "b", "c")), factor("c", levels =
c("a", "b", "c"))),
+ type = list_of(dictionary(int8(), utf8()))
+ )
+ result <- as.vector(arr)
+
+ ptype <- attr(result, "ptype")
+ expect_s3_class(ptype, "factor")
+ expect_identical(levels(ptype), c("a", "b", "c"))
+ expect_identical(levels(result[[1]]), c("a", "b", "c"))
+ expect_identical(levels(result[[2]]), c("a", "b", "c"))
+ expect_identical(result[[1]], factor(c("a", "b"), levels = c("a", "b", "c")))
+})
+
+test_that("list<struct<dictionary>> unifies factor levels across chunks
(GH-50514)", {
+ batch1 <- record_batch(
+ id = 1:2,
+ resources = list(
+ data.frame(type = factor(c("river", "lake"))),
+ data.frame(type = factor("river"))
+ )
+ )
+ batch2 <- record_batch(
+ id = 3L,
+ resources = list(data.frame(type = factor(c("sea", "lake"))))
+ )
+ # Each batch carries its own dictionary for resources$type
+ dict1 <- batch1$resources$values()$GetFieldByName("type")$dictionary()
+ dict2 <- batch2$resources$values()$GetFieldByName("type")$dictionary()
+ expect_false(dict1$Equals(dict2))
+
+ buf <- write_to_raw(Table$create(batch1, batch2), format = "stream")
+ df <- as.data.frame(read_ipc_stream(buf))
+
+ ptype_levels <- levels(attr(df$resources, "ptype")$type)
+ expect_identical(sort(ptype_levels), c("lake", "river", "sea"))
+ expect_identical(
+ lapply(df$resources, function(element) levels(element$type)),
+ rep(list(ptype_levels), 3)
+ )
+ expect_identical(as.character(df$resources[[3]]$type), c("sea", "lake"))
+
+ # tidyr::unnest() combines the elements via vctrs using the list's ptype
+ unnested <- vctrs::vec_rbind(!!!df$resources, .ptype = attr(df$resources,
"ptype"))
+ expect_s3_class(unnested$type, "factor")
+ expect_identical(
+ as.character(unnested$type),
+ c("river", "lake", "river", "sea", "lake")
+ )
+})
+
+test_that("list of ordered dictionary keeps ordered class across chunks
(GH-50514)", {
+ arr1 <- arrow_array(list(factor("lo", levels = c("lo", "hi"), ordered =
TRUE)))
+ arr2 <- arrow_array(list(factor("hi", levels = c("hi", "max"), ordered =
TRUE)))
+ result <- as.vector(chunked_array(arr1, arr2))
+
+ expect_s3_class(attr(result, "ptype"), "ordered")
+ expect_s3_class(result[[1]], "ordered")
+ expect_identical(levels(result[[1]]), levels(result[[2]]))
+ expect_identical(as.character(result[[1]]), "lo")
+ expect_identical(as.character(result[[2]]), "hi")
+})
+
+test_that("large_list, fixed_size_list and map of dictionary unify levels
across chunks (GH-50514)", {
+ f1 <- list(factor(c("a", "b")))
+ f2 <- list(factor(c("c", "b")))
+
+ large <- chunked_array(
+ arrow_array(f1, type = large_list_of(dictionary())),
+ arrow_array(f2, type = large_list_of(dictionary()))
+ )
+ result <- as.vector(large)
+ expect_identical(levels(result[[1]]), levels(result[[2]]))
+ expect_identical(levels(attr(result, "ptype")), levels(result[[1]]))
+ expect_identical(as.character(result[[2]]), c("c", "b"))
+
+ fixed <- chunked_array(
+ arrow_array(f1, type = fixed_size_list_of(dictionary(), 2L)),
+ arrow_array(f2, type = fixed_size_list_of(dictionary(), 2L))
+ )
+ result <- as.vector(fixed)
+ expect_identical(levels(result[[1]]), levels(result[[2]]))
+ expect_identical(levels(attr(result, "ptype")), levels(result[[1]]))
+ expect_identical(as.character(result[[2]]), c("c", "b"))
+
+ map_type <- map_of(utf8(), dictionary())
+ m1 <- arrow_array(list(data.frame(key = "k", value = factor("a"))), type =
map_type)
+ m2 <- arrow_array(list(data.frame(key = "k", value = factor("b"))), type =
map_type)
+ result <- as.vector(chunked_array(m1, m2))
+ expect_identical(levels(result[[1]]$value), levels(result[[2]]$value))
+ expect_identical(levels(attr(result, "ptype")$value),
levels(result[[1]]$value))
+ expect_identical(as.character(result[[2]]$value), "b")
+})
+
+test_that("int64 inside list columns converts to one type for the whole column
(GH-50514)", {
+ small <- arrow_array(list(bit64::as.integer64(1:2)), type = list_of(int64()))
+ big <- arrow_array(list(bit64::as.integer64(2)^40), type = list_of(int64()))
+
+ # all values fit: integer, including the ptype
+ result <- as.vector(small)
+ expect_type(result[[1]], "integer")
+ expect_type(attr(result, "ptype"), "integer")
+
+ # one element doesn't fit: integer64 everywhere, including the ptype
+ result <- as.vector(chunked_array(small, big))
+ expect_s3_class(result[[1]], "integer64")
+ expect_s3_class(result[[2]], "integer64")
+ expect_s3_class(attr(result, "ptype"), "integer64")
+ expect_identical(result[[1]], bit64::as.integer64(1:2))
+
+ # and the option is respected inside lists
+ withr::with_options(list(arrow.int64_downcast = FALSE), {
+ result <- as.vector(small)
+ expect_s3_class(result[[1]], "integer64")
+ expect_s3_class(attr(result, "ptype"), "integer64")
+ })
+})
+
+test_that("uint32 inside list columns converts to one type for the whole
column (GH-50514)", {
+ small <- arrow_array(list(1:2), type = list_of(uint32()))
+ big <- arrow_array(list(.Machine$integer.max + 1), type = list_of(uint32()))
+
+ result <- as.vector(small)
+ expect_type(result[[1]], "integer")
+ expect_type(attr(result, "ptype"), "integer")
+
+ result <- as.vector(chunked_array(small, big))
+ expect_type(result[[1]], "double")
+ expect_type(result[[2]], "double")
+ expect_type(attr(result, "ptype"), "double")
+ expect_identical(result[[2]], .Machine$integer.max + 1)
+})
+
+test_that("nested lists unify inner element types across chunks (GH-50514)", {
+ # list<list<dictionary>>
+ a <- arrow_array(list(list(factor("a"))), type =
list_of(list_of(dictionary())))
+ b <- arrow_array(list(list(factor("b"))), type =
list_of(list_of(dictionary())))
+ result <- as.vector(chunked_array(a, b))
+ expect_identical(levels(result[[1]][[1]]), levels(result[[2]][[1]]))
+ expect_identical(levels(attr(attr(result, "ptype"), "ptype")),
levels(result[[1]][[1]]))
+ expect_identical(as.character(result[[2]][[1]]), "b")
+
+ # list<struct<list<int64>>>
+ type <- list_of(struct(a = list_of(int64())))
+ a <- arrow_array(list(data.frame(a = I(list(bit64::as.integer64(1))))), type
= type)
+ b <- arrow_array(list(data.frame(a = I(list(bit64::as.integer64(2)^40)))),
type = type)
+ result <- as.vector(chunked_array(a, b))
+ expect_s3_class(result[[1]]$a[[1]], "integer64")
+ expect_s3_class(result[[2]]$a[[1]], "integer64")
+ expect_s3_class(attr(attr(result, "ptype")$a, "ptype"), "integer64")
+})
+
+test_that("empty and all-null list of dictionary columns still convert
(GH-50514)", {
+ type <- list_of(dictionary())
+
+ empty <- as.vector(arrow_array(list(), type = type))
+ expect_length(empty, 0)
+ expect_s3_class(attr(empty, "ptype"), "factor")
+
+ nulls <- as.vector(arrow_array(list(NULL, NULL), type = type))
+ expect_identical(nulls[[1]], NULL)
+ expect_s3_class(attr(nulls, "ptype"), "factor")
+
+ one_empty_chunk <- ChunkedArray$create(type = type)
+ result <- as.vector(one_empty_chunk)
+ expect_length(result, 0)
+ expect_s3_class(attr(result, "ptype"), "factor")
+
+ zero_chunks <- one_empty_chunk$Filter(ChunkedArray$create(type = bool()))
+ expect_equal(zero_chunks$num_chunks, 0)
+ result <- as.vector(zero_chunks)
+ expect_length(result, 0)
+ expect_s3_class(attr(result, "ptype"), "factor")
+})
+
+test_that("string list elements are still altrep vectors (GH-50514)", {
+ skip_if_not(getOption("arrow.use_altrep", TRUE))
+ result <- as.vector(arrow_array(list(c("a", "b"), "c")))
+ expect_true(is_arrow_altrep(result[[1]]))
+ expect_identical(result[[1]], c("a", "b"))
+})
+
+test_that("list of extension type still converts (GH-50514)", {
+ vctr <- vctrs::new_vctr(1:3, class = "custom_vctr")
+ # list<extension> can't be built directly from R, but can be cast to
+ arr <- arrow_array(list(1:3, 1L))$cast(list_of(vctrs_extension_type(vctr)))
+ expect_r6_class(arr$type$value_type, "ExtensionType")
+
+ result <- as.vector(arr)
+ expect_identical(result[[1]], vctr)
+ expect_identical(result[[2]], vctr[1])
+ expect_identical(attr(result, "ptype"), vctr[0])
+})
+
+test_that("only the logical values of a sliced list array take part in
conversion decisions (GH-50514)", {
+ arr <- arrow_array(
+ list(bit64::as.integer64(2)^40, bit64::as.integer64(1:2)),
+ type = list_of(int64())
+ )
+ # the whole array needs integer64
+ expect_s3_class(as.vector(arr)[[2]], "integer64")
+
+ # but a slice that leaves out the large value doesn't
+ sliced <- as.vector(arr$Slice(1))
+ expect_type(sliced[[1]], "integer")
+ expect_type(attr(sliced, "ptype"), "integer")
+})
+
+test_that("list of struct with an extension type column converts (GH-50514)", {
+ vctr <- vctrs::new_vctr(1:3, class = "custom_vctr")
+ arr <- arrow_array(list(data.frame(x = 1:3), data.frame(x = 1L)))$cast(
+ list_of(struct(x = vctrs_extension_type(vctr)))
+ )
+
+ result <- as.vector(arr)
+ expect_identical(result[[1]]$x, vctr)
+ expect_identical(result[[2]]$x, vctr[1])
+ expect_identical(attr(result, "ptype")$x, vctr[0])
+})
diff --git a/r/tests/testthat/test-chunked-array.R
b/r/tests/testthat/test-chunked-array.R
index e5fcfefe90..d654b3a92d 100644
--- a/r/tests/testthat/test-chunked-array.R
+++ b/r/tests/testthat/test-chunked-array.R
@@ -561,3 +561,18 @@ test_that("float16 values roundtrip to R correctly", {
expect_as_vector(a$chunk(1), x[5:7])
expect_as_vector(a$Slice(1), x[-1])
})
+
+test_that("Converting a chunked array of lists unifies nested factors
(GH-50514)", {
+ a <- chunked_array(
+ list(factor(c("a", "b"))),
+ list(factor("c")),
+ list(factor("d"), factor("a"))
+ )
+ expect_r6_class(a$type$value_type, "DictionaryType")
+
+ result <- as.vector(a)
+ unified <- c("a", "b", "c", "d")
+ expect_identical(levels(attr(result, "ptype")), unified)
+ expect_identical(lapply(result, levels), rep(list(unified), 4))
+ expect_identical(lapply(result, as.character), list(c("a", "b"), "c", "d",
"a"))
+})