From 88a1c332f1d480526445a539b86e93c81abf9899 Mon Sep 17 00:00:00 2001 From: VANDRANKI Date: Sun, 4 Oct 2026 16:44:15 -0400 Subject: [PATCH] Python: Fix OracleCollection search with multiple filters --- python/semantic_kernel/connectors/oracle.py | 29 +++++++++++++- .../unit/connectors/memory/test_oracle.py | 40 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/python/semantic_kernel/connectors/oracle.py b/python/semantic_kernel/connectors/oracle.py index 6f55c23aa20a..ae886120ef80 100644 --- a/python/semantic_kernel/connectors/oracle.py +++ b/python/semantic_kernel/connectors/oracle.py @@ -9,7 +9,8 @@ import re import sys import uuid -from collections.abc import AsyncIterable, Mapping, Sequence +from collections.abc import AsyncIterable, Callable, Mapping, Sequence +from inspect import getsource from typing import Any, ClassVar, Final, Generic, TypeVar # Third-party Libraries @@ -26,6 +27,7 @@ GetFilteredRecordOptions, IndexKind, KernelSearchResults, + LambdaVisitor, SearchType, VectorSearch, VectorSearchOptions, @@ -45,7 +47,7 @@ # Semantic Kernel Utilities & Config from semantic_kernel.kernel_pydantic import KernelBaseSettings -from semantic_kernel.kernel_types import OneOrMany +from semantic_kernel.kernel_types import OneOrMany, OptionalOneOrMany from semantic_kernel.utils.feature_stage_decorator import release_candidate oracledb.defaults.fetch_lobs = False @@ -1010,6 +1012,29 @@ async def _inner_search_vector( bind_values.extend(filter_binds) return query, bind_values, columns + @override + def _build_filter(self, search_filter: OptionalOneOrMany[Callable | str] | None) -> OptionalOneOrMany[Any]: + """Build one (sql_expression, bind_values_dict) tuple from one or more filters. + + Multiple filters are combined with AND. All filters share one bind counter, so the generated + bind variable names stay unique across the combined WHERE clause. + """ + if not search_filter: + return None + filters = search_filter if isinstance(search_filter, list) else [search_filter] + bind_counter = BindCounter() + visitor = LambdaVisitor(lambda node: self._lambda_parser(node, bind_counter)) + for filter_ in filters: + visitor.visit(ast.parse(filter_ if isinstance(filter_, str) else getsource(filter_).strip())) # type: ignore[arg-type] + if not visitor.output_filters: + raise VectorStoreOperationException("No filter strings found.") + if len(visitor.output_filters) == 1: + return visitor.output_filters[0] + combined_binds: dict[str, Any] = {} + for _, binds in visitor.output_filters: + combined_binds.update(binds) + return (" AND ".join(f"({sql})" for sql, _ in visitor.output_filters), combined_binds) + @override def _lambda_parser(self, node: ast.AST, bind_counter: BindCounter | None = None) -> Any: """Parse a lambda AST node and return a tuple: (sql_expression, bind_values_dict). diff --git a/python/tests/unit/connectors/memory/test_oracle.py b/python/tests/unit/connectors/memory/test_oracle.py index ac97113a2ea2..5b9bee85ef84 100644 --- a/python/tests/unit/connectors/memory/test_oracle.py +++ b/python/tests/unit/connectors/memory/test_oracle.py @@ -15,6 +15,7 @@ from semantic_kernel.data.vector import ( DistanceFunction, IndexKind, + VectorSearchOptions, VectorStoreCollectionDefinition, VectorStoreField, vectorstoremodel, @@ -419,3 +420,42 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): assert len(results) == 1 assert results[0].record.id == 1 assert results[0].record.vector == [0.1, 0.2, 0.3] + + +@vectorstoremodel +@dataclass +class FilterModel: + id: Annotated[int, VectorStoreField("key")] + color: Annotated[str, VectorStoreField("data", is_indexed=True)] + size: Annotated[int, VectorStoreField("data", is_indexed=True)] + vector: Annotated[ + list[float] | None, + VectorStoreField("vector", type="float", dimensions=3, distance_function=DistanceFunction.COSINE_DISTANCE), + ] = None + + +@pytest.mark.asyncio +async def test_search_multiple_filters_are_combined_with_and(oracle_store): + collection = oracle_store.get_collection( + model=FilterModel, record_type=FilterModel, collection_name="MY_COLLECTION" + ) + options = VectorSearchOptions( + vector_property_name="vector", + filter=["lambda x: x.color == 'red'", "lambda x: x.size > 5 or x.size < 2"], + ) + sql, binds, _ = await collection._inner_search_vector(options, None, [0.1, 0.2, 0.3]) + + assert 'WHERE ("color" = :bind_val1) AND (("size" > :bind_val2 OR "size" < :bind_val3))' in sql + assert binds[1:] == ["red", 5, 2] + + +@pytest.mark.asyncio +async def test_search_single_filter_is_unchanged(oracle_store): + collection = oracle_store.get_collection( + model=FilterModel, record_type=FilterModel, collection_name="MY_COLLECTION" + ) + options = VectorSearchOptions(vector_property_name="vector", filter="lambda x: x.color == 'red'") + sql, binds, _ = await collection._inner_search_vector(options, None, [0.1, 0.2, 0.3]) + + assert 'WHERE "color" = :bind_val1' in sql + assert binds[1:] == ["red"]