Skip to content

Commit cf2f0c4

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

2 files changed

Lines changed: 201 additions & 2 deletions

File tree

sdk/python/feast/type_map.py

Lines changed: 50 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 in STRING_LIST are replaced with ""; for other types they are dropped
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,46 @@ def convert_set_to_list(value: Any) -> Any:
823825
]
824826

825827

828+
# Sentinel value used by _to_proto_safe_list to indicate that None elements
829+
# should simply be filtered (dropped) rather than replaced with a default.
830+
_DROP_NONE = object()
831+
832+
# Per-type default values substituted for None elements inside list columns.
833+
# Only STRING_LIST uses ""; numeric/bytes types drop None entirely because
834+
# there is no meaningful in-band sentinel (protobuf rejects wrong scalar types).
835+
_LIST_TYPE_NONE_REPLACEMENT: Dict[ValueType, Any] = {
836+
ValueType.STRING_LIST: "",
837+
}
838+
839+
840+
def _to_proto_safe_list(
841+
value: Any, feast_value_type: ValueType = ValueType.STRING_LIST
842+
) -> Any:
843+
"""Convert an array/list column value to a proto-safe Python list.
844+
845+
Arrow/Athena returns Array columns as numpy.ndarray (object dtype).
846+
Protobuf repeated fields reject ndarrays and (for non-string types) None
847+
elements, so we:
848+
1. Call .tolist() to convert any numpy.ndarray to a plain Python list.
849+
2. For STRING_LIST: replace None elements with "" (empty string).
850+
For all other list types: drop None elements, since there is no valid
851+
in-band default for numeric/bytes protobuf fields.
852+
853+
Args:
854+
value: The raw column value (ndarray, list, or scalar).
855+
feast_value_type: The Feast ValueType of the list column. Controls how
856+
None elements are handled. Defaults to STRING_LIST.
857+
"""
858+
if isinstance(value, np.ndarray):
859+
value = value.tolist()
860+
if isinstance(value, list):
861+
none_replacement = _LIST_TYPE_NONE_REPLACEMENT.get(feast_value_type, _DROP_NONE)
862+
if none_replacement is _DROP_NONE:
863+
return [x for x in value if x is not None]
864+
return [x if x is not None else none_replacement for x in value]
865+
return value
866+
867+
826868
def _convert_list_values_to_proto(
827869
feast_value_type: ValueType,
828870
values: List[Any],
@@ -901,8 +943,14 @@ def _convert_list_values_to_proto(
901943
]
902944

903945
# Generic list conversion
946+
# Arrow/Athena deserializes Array columns as numpy.ndarray (object dtype).
947+
# _to_proto_safe_list converts to a plain Python list and sanitizes None
948+
# elements in a type-appropriate way (replaced with "" for STRING_LIST,
949+
# dropped for numeric/bytes types).
904950
return [
905-
ProtoValue(**{field_name: proto_type(val=value)}) # type: ignore[arg-type]
951+
ProtoValue(
952+
**{field_name: proto_type(val=_to_proto_safe_list(value, feast_value_type))} # type: ignore[arg-type]
953+
)
906954
if value is not None
907955
else ProtoValue()
908956
for value in values

sdk/python/tests/unit/test_type_map.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1953,3 +1953,154 @@ 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 sanitizes None elements in a type-appropriate way:
1972+
- STRING_LIST: None → "" (empty string)
1973+
- All other list types: None elements are dropped (filtered out)
1974+
"""
1975+
1976+
def test_to_proto_safe_list_ndarray(self):
1977+
"""ndarray is converted to a plain Python list."""
1978+
from feast.type_map import _to_proto_safe_list
1979+
1980+
arr = np.array(["foo", "bar"], dtype=object)
1981+
result = _to_proto_safe_list(arr)
1982+
assert result == ["foo", "bar"]
1983+
assert isinstance(result, list)
1984+
1985+
def test_to_proto_safe_list_empty_ndarray(self):
1986+
"""Empty ndarray is converted to an empty list."""
1987+
from feast.type_map import _to_proto_safe_list
1988+
1989+
arr = np.array([], dtype=object)
1990+
result = _to_proto_safe_list(arr)
1991+
assert result == []
1992+
assert isinstance(result, list)
1993+
1994+
def test_to_proto_safe_list_ndarray_with_none(self):
1995+
"""None elements inside a STRING_LIST ndarray are replaced with empty string."""
1996+
from feast.type_map import _to_proto_safe_list
1997+
1998+
arr = np.array(["foo", None, "baz"], dtype=object)
1999+
result = _to_proto_safe_list(arr, ValueType.STRING_LIST)
2000+
assert result == ["foo", "", "baz"]
2001+
2002+
def test_to_proto_safe_list_plain_list(self):
2003+
"""Plain Python lists pass through unchanged (no None replacement needed)."""
2004+
from feast.type_map import _to_proto_safe_list
2005+
2006+
lst = ["foo", "bar"]
2007+
result = _to_proto_safe_list(lst)
2008+
assert result == ["foo", "bar"]
2009+
2010+
def test_to_proto_safe_list_plain_list_with_none(self):
2011+
"""None elements in a STRING_LIST plain list are replaced with empty string."""
2012+
from feast.type_map import _to_proto_safe_list
2013+
2014+
lst = ["foo", None]
2015+
result = _to_proto_safe_list(lst, ValueType.STRING_LIST)
2016+
assert result == ["foo", ""]
2017+
2018+
def test_to_proto_safe_list_numeric_list_none_dropped(self):
2019+
"""None elements in non-string lists are dropped, not replaced with a sentinel."""
2020+
from feast.type_map import _to_proto_safe_list
2021+
2022+
for vt in (
2023+
ValueType.FLOAT_LIST,
2024+
ValueType.DOUBLE_LIST,
2025+
ValueType.INT32_LIST,
2026+
ValueType.INT64_LIST,
2027+
ValueType.BYTES_LIST,
2028+
):
2029+
result = _to_proto_safe_list([1.0, None, 2.0], vt)
2030+
assert result == [1.0, 2.0], (
2031+
f"Expected None dropped for {vt}, got {result!r}"
2032+
)
2033+
2034+
def test_to_proto_safe_list_scalar_passthrough(self):
2035+
"""Non-list, non-ndarray values are returned unchanged."""
2036+
from feast.type_map import _to_proto_safe_list
2037+
2038+
assert _to_proto_safe_list("hello") == "hello"
2039+
assert _to_proto_safe_list(None) is None
2040+
assert _to_proto_safe_list(42) == 42
2041+
2042+
def test_string_list_from_ndarray(self):
2043+
"""STRING_LIST column with ndarray values materializes without TypeError."""
2044+
values = [
2045+
np.array(["foo", "bar"], dtype=object),
2046+
np.array(["baz"], dtype=object),
2047+
]
2048+
protos = python_values_to_proto_values(values, ValueType.STRING_LIST)
2049+
assert len(protos) == 2
2050+
assert list(protos[0].string_list_val.val) == ["foo", "bar"]
2051+
assert list(protos[1].string_list_val.val) == ["baz"]
2052+
2053+
def test_string_list_from_empty_ndarray(self):
2054+
"""Empty ndarray in a STRING_LIST column must not raise ValueError."""
2055+
values = [
2056+
np.array([], dtype=object),
2057+
np.array(["foo"], dtype=object),
2058+
]
2059+
protos = python_values_to_proto_values(values, ValueType.STRING_LIST)
2060+
assert list(protos[0].string_list_val.val) == []
2061+
assert list(protos[1].string_list_val.val) == ["foo"]
2062+
2063+
def test_string_list_from_ndarray_with_none_elements(self):
2064+
"""None elements inside an ndarray must not cause TypeError in protobuf."""
2065+
values = [
2066+
np.array(["foo", None, "baz"], dtype=object),
2067+
]
2068+
protos = python_values_to_proto_values(values, ValueType.STRING_LIST)
2069+
# None is replaced with empty string
2070+
assert list(protos[0].string_list_val.val) == ["foo", "", "baz"]
2071+
2072+
def test_string_list_null_row_produces_empty_proto(self):
2073+
"""A None row (missing user) produces an empty ProtoValue."""
2074+
from feast.protos.feast.types.Value_pb2 import Value as ProtoValue
2075+
2076+
values = [
2077+
None,
2078+
np.array(["foo"], dtype=object),
2079+
]
2080+
protos = python_values_to_proto_values(values, ValueType.STRING_LIST)
2081+
assert protos[0] == ProtoValue()
2082+
assert list(protos[1].string_list_val.val) == ["foo"]
2083+
2084+
def test_mixed_batch_simulating_athena_chunk(self):
2085+
"""Simulate a real Athena chunk: mix of ndarray, empty ndarray, and None rows.
2086+
2087+
This is the exact scenario that triggered the TypeError during
2088+
string_list_features materialization.
2089+
"""
2090+
from feast.protos.feast.types.Value_pb2 import Value as ProtoValue
2091+
2092+
# tags / labels column from Athena
2093+
values = [
2094+
np.array(["foo", "bar"], dtype=object), # normal entity
2095+
np.array([], dtype=object), # entity with no values set
2096+
None, # missing entity (NULL row)
2097+
np.array(["baz"], dtype=object), # normal entity
2098+
np.array(["qux", None], dtype=object), # entity with partial null
2099+
]
2100+
protos = python_values_to_proto_values(values, ValueType.STRING_LIST)
2101+
2102+
assert list(protos[0].string_list_val.val) == ["foo", "bar"]
2103+
assert list(protos[1].string_list_val.val) == []
2104+
assert protos[2] == ProtoValue()
2105+
assert list(protos[3].string_list_val.val) == ["baz"]
2106+
assert list(protos[4].string_list_val.val) == ["qux", ""]

0 commit comments

Comments
 (0)