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