diff --git a/python/semantic_kernel/connectors/chroma.py b/python/semantic_kernel/connectors/chroma.py index 2bffb64ecd5e..0e49014e3b83 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,17 @@ 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(): + # 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/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..527bea6ccbc5 100644 --- a/python/semantic_kernel/connectors/weaviate.py +++ b/python/semantic_kernel/connectors/weaviate.py @@ -477,6 +477,17 @@ 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(): + # 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/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..2cd3267ec166 100644 --- a/python/tests/unit/connectors/memory/test_chroma.py +++ b/python/tests/unit/connectors/memory/test_chroma.py @@ -113,3 +113,17 @@ 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"]}} + + +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/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..9d9ebc88c850 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,39 @@ 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" + + +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"