From a147fdb57741d62d67d31cb8a1f23bd71f048cbd Mon Sep 17 00:00:00 2001 From: VANDRANKI Date: Tue, 6 Oct 2026 13:24:45 -0400 Subject: [PATCH 1/2] Python: Support list literals in Chroma, MongoDB, Pinecone and Weaviate filters --- python/semantic_kernel/connectors/chroma.py | 4 +++- python/semantic_kernel/connectors/mongodb.py | 2 ++ python/semantic_kernel/connectors/pinecone.py | 15 ++++++--------- python/semantic_kernel/connectors/weaviate.py | 2 ++ .../test_mongodb_atlas_collection.py | 19 +++++++++++++++++++ .../unit/connectors/memory/test_chroma.py | 8 ++++++++ .../unit/connectors/memory/test_pinecone.py | 17 +++++++++++++++++ .../weaviate/test_weaviate_collection.py | 17 +++++++++++++++++ 8 files changed, 74 insertions(+), 10 deletions(-) diff --git a/python/semantic_kernel/connectors/chroma.py b/python/semantic_kernel/connectors/chroma.py index 2bffb64ecd5e..4b133dfd287c 100644 --- a/python/semantic_kernel/connectors/chroma.py +++ b/python/semantic_kernel/connectors/chroma.py @@ -362,7 +362,7 @@ def _get_score_from_result(self, result: Any) -> float | None: return result["distance"] @override - def _lambda_parser(self, node: ast.AST) -> dict[str, Any] | str | int | float | bool | None: # type: ignore + def _lambda_parser(self, node: ast.AST) -> dict[str, Any] | list[Any] | str | int | float | bool | None: # type: ignore # Comparison operations match node: case ast.Compare(): @@ -430,6 +430,8 @@ def _lambda_parser(self, node: ast.AST) -> dict[str, Any] | str | int | float | if isinstance(value, (int, float, bool)) or value is None: return value raise VectorStoreOperationException(f"Unsupported constant type: {type(value)}") + case ast.List(): + return [self._lambda_parser(elt) for elt in node.elts] raise NotImplementedError(f"Unsupported AST node: {type(node)}") diff --git a/python/semantic_kernel/connectors/mongodb.py b/python/semantic_kernel/connectors/mongodb.py index 0727fb3d35e8..517fd5562ad4 100644 --- a/python/semantic_kernel/connectors/mongodb.py +++ b/python/semantic_kernel/connectors/mongodb.py @@ -508,6 +508,8 @@ def _lambda_parser(self, node: ast.AST) -> Any: return node.id case ast.Constant(): return node.value + case ast.List(): + return [self._lambda_parser(elt) for elt in node.elts] raise NotImplementedError(f"Unsupported AST node: {type(node)}") @override diff --git a/python/semantic_kernel/connectors/pinecone.py b/python/semantic_kernel/connectors/pinecone.py index 5f0dc9595116..448e9e0ca7a2 100644 --- a/python/semantic_kernel/connectors/pinecone.py +++ b/python/semantic_kernel/connectors/pinecone.py @@ -541,15 +541,10 @@ def _lambda_parser(self, node: ast.AST) -> Any: case ast.Not(): operand = self._lambda_parser(node.operand) # Pinecone only supports $not over $in (becomes $nin) - if ( - isinstance(operand, dict) - and len(operand) == 1 - and isinstance(next(operand.values()), dict) # type: ignore - and "$in" in next(operand.values()) # type: ignore - ): - field = next(operand.keys()) # type: ignore - values = next(operand.values())["$in"] # type: ignore - return {field: {"$nin": values}} + if isinstance(operand, dict) and len(operand) == 1: + field, condition = next(iter(operand.items())) + if isinstance(condition, dict) and "$in" in condition: + return {field: {"$nin": condition["$in"]}} raise NotImplementedError( "$not is only supported over $in (i.e., for ![...].contains(field)). " "Other NOT expressions are not supported by Pinecone." @@ -574,6 +569,8 @@ def _lambda_parser(self, node: ast.AST) -> Any: if node.value is None: raise NotImplementedError("Pinecone does not support null checks in vector search pre-filters.") return node.value + case ast.List(): + return [self._lambda_parser(elt) for elt in node.elts] raise NotImplementedError(f"Unsupported AST node: {type(node)}") @override diff --git a/python/semantic_kernel/connectors/weaviate.py b/python/semantic_kernel/connectors/weaviate.py index 2e04753b0011..4b284373da87 100644 --- a/python/semantic_kernel/connectors/weaviate.py +++ b/python/semantic_kernel/connectors/weaviate.py @@ -477,6 +477,8 @@ def _lambda_parser(self, node: ast.AST) -> "_Filters | FilterValues": if isinstance(value, (int, float, bool)) or value is None: return value raise VectorStoreOperationException(f"Unsupported constant type: {type(value)}") + case ast.List(): + return [self._lambda_parser(elt) for elt in node.elts] # type: ignore raise NotImplementedError(f"Unsupported AST node: {type(node)}") async def _inner_vectorized_search( diff --git a/python/tests/unit/connectors/memory/mongodb_atlas/test_mongodb_atlas_collection.py b/python/tests/unit/connectors/memory/mongodb_atlas/test_mongodb_atlas_collection.py index 8c6747d2f938..295ff945a9be 100644 --- a/python/tests/unit/connectors/memory/mongodb_atlas/test_mongodb_atlas_collection.py +++ b/python/tests/unit/connectors/memory/mongodb_atlas/test_mongodb_atlas_collection.py @@ -91,3 +91,22 @@ async def test_mongodb_atlas_collection_collection_exists(mongodb_atlas_unit_tes with patch.object(collection, "_get_database", new=mock_get_database) as mock_get: mock_get.return_value.list_collection_names.return_value = ["test_collection"] assert await collection.collection_exists() + + +@mark.parametrize( + "filter_str, expected", + [ + ("lambda x: x.content in ['a', 'b']", {"content": {"$in": ["a", "b"]}}), + ("lambda x: x.content not in ['a', 'b']", {"content": {"$nin": ["a", "b"]}}), + ], +) +def test_mongodb_atlas_collection_build_filter_list( + mongodb_atlas_unit_test_env, definition, mock_mongo_client, filter_str, expected +): + collection = MongoDBAtlasCollection( + record_type=dict, + definition=definition, + collection_name="test_collection", + mongo_client=mock_mongo_client, + ) + assert collection._build_filter(filter_str) == expected diff --git a/python/tests/unit/connectors/memory/test_chroma.py b/python/tests/unit/connectors/memory/test_chroma.py index 6f48fa6663af..154963054a02 100644 --- a/python/tests/unit/connectors/memory/test_chroma.py +++ b/python/tests/unit/connectors/memory/test_chroma.py @@ -113,3 +113,11 @@ async def test_chroma_collection_search(chroma_collection, mock_client, include_ async for res in results.results: assert res.record["id"] == "1" assert res.score == 0.1 + + +def test_chroma_collection_build_filter_in_list(chroma_collection): + assert chroma_collection._build_filter("lambda x: x.content in ['a', 'b']") == {"content": {"$in": ["a", "b"]}} + + +def test_chroma_collection_build_filter_not_in_list(chroma_collection): + assert chroma_collection._build_filter("lambda x: x.content not in ['a', 'b']") == {"content": {"$nin": ["a", "b"]}} diff --git a/python/tests/unit/connectors/memory/test_pinecone.py b/python/tests/unit/connectors/memory/test_pinecone.py index 52ee4f224404..dc9eb8b415f0 100644 --- a/python/tests/unit/connectors/memory/test_pinecone.py +++ b/python/tests/unit/connectors/memory/test_pinecone.py @@ -335,3 +335,20 @@ async def test_search_embed(collection): async for result in query_response.results: assert result.record == record assert result.score == 0.1 + + +async def test_build_filter_in_list(collection): + assert collection._build_filter("lambda x: x.content in ['a', 'b']") == {"content": {"$in": ["a", "b"]}} + + +async def test_build_filter_not_in_list(collection): + assert collection._build_filter("lambda x: x.content not in ['a', 'b']") == {"content": {"$nin": ["a", "b"]}} + + +async def test_build_filter_not_over_in_becomes_nin(collection): + assert collection._build_filter("lambda x: not (x.content in ['a', 'b'])") == {"content": {"$nin": ["a", "b"]}} + + +async def test_build_filter_not_over_other_expression_raises(collection): + with raises(NotImplementedError, match="only supported over"): + collection._build_filter("lambda x: not (x.content == 'a')") diff --git a/python/tests/unit/connectors/memory/weaviate/test_weaviate_collection.py b/python/tests/unit/connectors/memory/weaviate/test_weaviate_collection.py index c4ebd838aba6..975414b2d58e 100644 --- a/python/tests/unit/connectors/memory/weaviate/test_weaviate_collection.py +++ b/python/tests/unit/connectors/memory/weaviate/test_weaviate_collection.py @@ -427,3 +427,20 @@ async def test_weaviate_collection_deserialize_data( await collection.get(key=data.id) mock_inner_get.assert_called_once_with([data.id], include_vectors=False, options=None) + + +def test_weaviate_collection_build_filter_in_list(clear_weaviate_env, record_type, definition) -> None: + """A list literal on the right side of `in` is translated to a contains_any filter.""" + collection = WeaviateCollection( + record_type=record_type, + definition=definition, + collection_name="TestCollection", + async_client=AsyncMock(spec=WeaviateAsyncClient), + env_file_path="fake_env_file_path.env", + ) + + result = collection._build_filter("lambda x: x.content in ['a', 'b']") + + assert result.target == "content" + assert result.value == ["a", "b"] + assert result.operator.name == "CONTAINS_ANY" From 89346f500211ca962b44ba3845eb17a142b337ef Mon Sep 17 00:00:00 2001 From: VANDRANKI Date: Tue, 6 Oct 2026 19:08:14 -0400 Subject: [PATCH 2/2] Python: Keep string values in Chroma and Weaviate list filters as written List elements were parsed through the string constant branch, which doubles apostrophes, so x.content in ["O'Reilly"] filtered on O''Reilly. List elements now pass string and bytes constants through unchanged. --- python/semantic_kernel/connectors/chroma.py | 11 ++++++++++- python/semantic_kernel/connectors/weaviate.py | 11 ++++++++++- .../unit/connectors/memory/test_chroma.py | 6 ++++++ .../weaviate/test_weaviate_collection.py | 19 +++++++++++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/python/semantic_kernel/connectors/chroma.py b/python/semantic_kernel/connectors/chroma.py index 4b133dfd287c..0e49014e3b83 100644 --- a/python/semantic_kernel/connectors/chroma.py +++ b/python/semantic_kernel/connectors/chroma.py @@ -431,7 +431,16 @@ def _lambda_parser(self, node: ast.AST) -> dict[str, Any] | list[Any] | str | in return value raise VectorStoreOperationException(f"Unsupported constant type: {type(value)}") case ast.List(): - return [self._lambda_parser(elt) for elt in node.elts] + # Elements go into a typed value list, not a SQL string, so string literals are kept as written. + items: list[Any] = [] + for elt in node.elts: + if isinstance(elt, ast.Constant) and isinstance(elt.value, str): + items.append(elt.value) + elif isinstance(elt, ast.Constant) and isinstance(elt.value, bytes): + items.append(elt.value.decode("utf-8")) + else: + items.append(self._lambda_parser(elt)) + return items raise NotImplementedError(f"Unsupported AST node: {type(node)}") diff --git a/python/semantic_kernel/connectors/weaviate.py b/python/semantic_kernel/connectors/weaviate.py index 4b284373da87..527bea6ccbc5 100644 --- a/python/semantic_kernel/connectors/weaviate.py +++ b/python/semantic_kernel/connectors/weaviate.py @@ -478,7 +478,16 @@ def _lambda_parser(self, node: ast.AST) -> "_Filters | FilterValues": return value raise VectorStoreOperationException(f"Unsupported constant type: {type(value)}") case ast.List(): - return [self._lambda_parser(elt) for elt in node.elts] # type: ignore + # Elements go into a typed value list, not a SQL string, so string literals are kept as written. + items: list[Any] = [] + for elt in node.elts: + if isinstance(elt, ast.Constant) and isinstance(elt.value, str): + items.append(elt.value) + elif isinstance(elt, ast.Constant) and isinstance(elt.value, bytes): + items.append(elt.value.decode("utf-8")) + else: + items.append(self._lambda_parser(elt)) + return items raise NotImplementedError(f"Unsupported AST node: {type(node)}") async def _inner_vectorized_search( diff --git a/python/tests/unit/connectors/memory/test_chroma.py b/python/tests/unit/connectors/memory/test_chroma.py index 154963054a02..2cd3267ec166 100644 --- a/python/tests/unit/connectors/memory/test_chroma.py +++ b/python/tests/unit/connectors/memory/test_chroma.py @@ -121,3 +121,9 @@ def test_chroma_collection_build_filter_in_list(chroma_collection): def test_chroma_collection_build_filter_not_in_list(chroma_collection): assert chroma_collection._build_filter("lambda x: x.content not in ['a', 'b']") == {"content": {"$nin": ["a", "b"]}} + + +def test_chroma_collection_build_filter_in_list_keeps_apostrophes(chroma_collection): + assert chroma_collection._build_filter("lambda x: x.content in [\"O'Reilly\", 'b']") == { + "content": {"$in": ["O'Reilly", "b"]} + } diff --git a/python/tests/unit/connectors/memory/weaviate/test_weaviate_collection.py b/python/tests/unit/connectors/memory/weaviate/test_weaviate_collection.py index 975414b2d58e..9d9ebc88c850 100644 --- a/python/tests/unit/connectors/memory/weaviate/test_weaviate_collection.py +++ b/python/tests/unit/connectors/memory/weaviate/test_weaviate_collection.py @@ -444,3 +444,22 @@ def test_weaviate_collection_build_filter_in_list(clear_weaviate_env, record_typ assert result.target == "content" assert result.value == ["a", "b"] assert result.operator.name == "CONTAINS_ANY" + + +def test_weaviate_collection_build_filter_in_list_keeps_apostrophes( + clear_weaviate_env, record_type, definition +) -> None: + """String values in a list literal reach contains_any as written, without doubled apostrophes.""" + collection = WeaviateCollection( + record_type=record_type, + definition=definition, + collection_name="TestCollection", + async_client=AsyncMock(spec=WeaviateAsyncClient), + env_file_path="fake_env_file_path.env", + ) + + result = collection._build_filter("lambda x: x.content in [\"O'Reilly\", 'b']") + + assert result.target == "content" + assert result.value == ["O'Reilly", "b"] + assert result.operator.name == "CONTAINS_ANY"