@@ -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