Skip to content

Commit 86036b7

Browse files
fix: handle string arrays in materialization
Signed-off-by: Alan Gauthier <alan.gauthier@jobteaser.com>
1 parent f08b4e8 commit 86036b7

2 files changed

Lines changed: 155 additions & 2 deletions

File tree

sdk/python/feast/type_map.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -694,7 +694,7 @@ def _validate_collection_item_types(
694694
"""
695695
if sample is None:
696696
return
697-
if all(type(item) in valid_types for item in sample):
697+
if all(type(item) in valid_types for item in sample if item is not None):
698698
return
699699

700700
# to_numpy() upcasts INT32/INT64 with NULL to Float64 automatically
@@ -705,6 +705,8 @@ def _validate_collection_item_types(
705705
ValueType.INT64_SET,
706706
]
707707
for item in sample:
708+
if item is None:
709+
continue # None elements are sanitized downstream by _to_proto_safe_list
708710
if type(item) not in valid_types:
709711
if feast_value_type in int_collection_types:
710712
# Check if the float values are due to NULL upcast
@@ -823,6 +825,21 @@ def convert_set_to_list(value: Any) -> Any:
823825
]
824826

825827

828+
def _to_proto_safe_list(value: Any) -> Any:
829+
"""Convert an array/list column value to a proto-safe Python list.
830+
831+
Arrow/Athena returns Array(String) columns as numpy.ndarray (object dtype).
832+
Protobuf StringList rejects ndarrays and None elements, so we:
833+
1. Call .tolist() to get a plain Python list
834+
2. Replace any None elements with empty string
835+
"""
836+
if isinstance(value, np.ndarray):
837+
value = value.tolist()
838+
if isinstance(value, list):
839+
return [x if x is not None else "" for x in value]
840+
return value
841+
842+
826843
def _convert_list_values_to_proto(
827844
feast_value_type: ValueType,
828845
values: List[Any],
@@ -901,8 +918,11 @@ def _convert_list_values_to_proto(
901918
]
902919

903920
# Generic list conversion
921+
# Arrow/Athena deserializes Array(String) columns as numpy.ndarray (object dtype).
922+
# _to_proto_safe_list converts to a plain Python list and removes None elements,
923+
# both of which protobuf StringList rejects.
904924
return [
905-
ProtoValue(**{field_name: proto_type(val=value)}) # type: ignore[arg-type]
925+
ProtoValue(**{field_name: proto_type(val=_to_proto_safe_list(value))}) # type: ignore[arg-type]
906926
if value is not None
907927
else ProtoValue()
908928
for value in values

sdk/python/tests/unit/test_type_map.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1953,3 +1953,136 @@ def test_non_empty_array_treated_as_null_unix_timestamp(self):
19531953
"non-empty array in UNIX_TIMESTAMP scalar column should produce null"
19541954
)
19551955
assert result[1].unix_timestamp_val == int(ts.timestamp())
1956+
1957+
1958+
class TestArrowArrayStringListMaterialization:
1959+
"""Regression tests for Array(String) columns from Arrow/Athena materialization.
1960+
1961+
Arrow/Athena deserializes Array(String) feature columns as numpy.ndarray with
1962+
object dtype. Two bugs were triggered:
1963+
1964+
1. ValueError: "The truth value of an empty array is ambiguous"
1965+
— when an empty ndarray reached the scalar null-check `elif not pd.isnull(value)`.
1966+
1967+
2. TypeError: "bad argument type for built-in operation"
1968+
— when proto_type(val=<ndarray>) was called; protobuf rejects ndarrays.
1969+
1970+
Both are fixed by _to_proto_safe_list, which converts ndarrays to plain Python
1971+
lists and replaces any None elements (from nullable Arrow columns) with "".
1972+
"""
1973+
1974+
def test_to_proto_safe_list_ndarray(self):
1975+
"""ndarray is converted to a plain Python list."""
1976+
from feast.type_map import _to_proto_safe_list
1977+
1978+
arr = np.array(["foo", "bar"], dtype=object)
1979+
result = _to_proto_safe_list(arr)
1980+
assert result == ["foo", "bar"]
1981+
assert isinstance(result, list)
1982+
1983+
def test_to_proto_safe_list_empty_ndarray(self):
1984+
"""Empty ndarray is converted to an empty list."""
1985+
from feast.type_map import _to_proto_safe_list
1986+
1987+
arr = np.array([], dtype=object)
1988+
result = _to_proto_safe_list(arr)
1989+
assert result == []
1990+
assert isinstance(result, list)
1991+
1992+
def test_to_proto_safe_list_ndarray_with_none(self):
1993+
"""None elements inside an ndarray are replaced with empty string."""
1994+
from feast.type_map import _to_proto_safe_list
1995+
1996+
arr = np.array(["foo", None, "baz"], dtype=object)
1997+
result = _to_proto_safe_list(arr)
1998+
assert result == ["foo", "", "baz"]
1999+
2000+
def test_to_proto_safe_list_plain_list(self):
2001+
"""Plain Python lists pass through unchanged (no None replacement needed)."""
2002+
from feast.type_map import _to_proto_safe_list
2003+
2004+
lst = ["foo", "bar"]
2005+
result = _to_proto_safe_list(lst)
2006+
assert result == ["foo", "bar"]
2007+
2008+
def test_to_proto_safe_list_plain_list_with_none(self):
2009+
"""None elements in a plain list are also replaced."""
2010+
from feast.type_map import _to_proto_safe_list
2011+
2012+
lst = ["foo", None]
2013+
result = _to_proto_safe_list(lst)
2014+
assert result == ["foo", ""]
2015+
2016+
def test_to_proto_safe_list_scalar_passthrough(self):
2017+
"""Non-list, non-ndarray values are returned unchanged."""
2018+
from feast.type_map import _to_proto_safe_list
2019+
2020+
assert _to_proto_safe_list("hello") == "hello"
2021+
assert _to_proto_safe_list(None) is None
2022+
assert _to_proto_safe_list(42) == 42
2023+
2024+
def test_string_list_from_ndarray(self):
2025+
"""STRING_LIST column with ndarray values materializes without TypeError."""
2026+
values = [
2027+
np.array(["foo", "bar"], dtype=object),
2028+
np.array(["baz"], dtype=object),
2029+
]
2030+
protos = python_values_to_proto_values(values, ValueType.STRING_LIST)
2031+
assert len(protos) == 2
2032+
assert list(protos[0].string_list_val.val) == ["foo", "bar"]
2033+
assert list(protos[1].string_list_val.val) == ["baz"]
2034+
2035+
def test_string_list_from_empty_ndarray(self):
2036+
"""Empty ndarray in a STRING_LIST column must not raise ValueError."""
2037+
values = [
2038+
np.array([], dtype=object),
2039+
np.array(["foo"], dtype=object),
2040+
]
2041+
protos = python_values_to_proto_values(values, ValueType.STRING_LIST)
2042+
assert list(protos[0].string_list_val.val) == []
2043+
assert list(protos[1].string_list_val.val) == ["foo"]
2044+
2045+
def test_string_list_from_ndarray_with_none_elements(self):
2046+
"""None elements inside an ndarray must not cause TypeError in protobuf."""
2047+
values = [
2048+
np.array(["foo", None, "baz"], dtype=object),
2049+
]
2050+
protos = python_values_to_proto_values(values, ValueType.STRING_LIST)
2051+
# None is replaced with empty string
2052+
assert list(protos[0].string_list_val.val) == ["foo", "", "baz"]
2053+
2054+
def test_string_list_null_row_produces_empty_proto(self):
2055+
"""A None row (missing user) produces an empty ProtoValue."""
2056+
from feast.protos.feast.types.Value_pb2 import Value as ProtoValue
2057+
2058+
values = [
2059+
None,
2060+
np.array(["foo"], dtype=object),
2061+
]
2062+
protos = python_values_to_proto_values(values, ValueType.STRING_LIST)
2063+
assert protos[0] == ProtoValue()
2064+
assert list(protos[1].string_list_val.val) == ["foo"]
2065+
2066+
def test_mixed_batch_simulating_athena_chunk(self):
2067+
"""Simulate a real Athena chunk: mix of ndarray, empty ndarray, and None rows.
2068+
2069+
This is the exact scenario that triggered the TypeError during
2070+
string_list_features materialization.
2071+
"""
2072+
from feast.protos.feast.types.Value_pb2 import Value as ProtoValue
2073+
2074+
# tags / labels column from Athena
2075+
values = [
2076+
np.array(["foo", "bar"], dtype=object), # normal entity
2077+
np.array([], dtype=object), # entity with no values set
2078+
None, # missing entity (NULL row)
2079+
np.array(["baz"], dtype=object), # normal entity
2080+
np.array(["qux", None], dtype=object), # entity with partial null
2081+
]
2082+
protos = python_values_to_proto_values(values, ValueType.STRING_LIST)
2083+
2084+
assert list(protos[0].string_list_val.val) == ["foo", "bar"]
2085+
assert list(protos[1].string_list_val.val) == []
2086+
assert protos[2] == ProtoValue()
2087+
assert list(protos[3].string_list_val.val) == ["baz"]
2088+
assert list(protos[4].string_list_val.val) == ["qux", ""]

0 commit comments

Comments
 (0)