Files
microsoft--semantic-kernel/python/semantic_kernel/connectors/oracle.py
T
wehub-resource-sync b957a53def
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:21:23 +08:00

1268 lines
49 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
# Copyright (c) 2025, Oracle Corporation. All rights reserved.
# Standard Library
import array
import ast
import datetime
import logging
import re
import sys
import uuid
from collections.abc import AsyncIterable, Mapping, Sequence
from typing import Any, ClassVar, Final, Generic, TypeVar
# Third-party Libraries
import numpy as np
import oracledb
from pydantic import Field, PrivateAttr, SecretStr
# Semantic Kernel AI and Data Abstractions
from semantic_kernel.connectors.ai.embedding_generator_base import (
EmbeddingGeneratorBase,
)
from semantic_kernel.data.vector import (
DistanceFunction,
GetFilteredRecordOptions,
IndexKind,
KernelSearchResults,
SearchType,
VectorSearch,
VectorSearchOptions,
VectorSearchResult,
VectorStore,
VectorStoreCollection,
VectorStoreCollectionDefinition,
VectorStoreField,
)
# Semantic Kernel Exceptions
from semantic_kernel.exceptions import (
MemoryConnectorConnectionException,
VectorSearchExecutionException,
VectorStoreOperationException,
)
# Semantic Kernel Utilities & Config
from semantic_kernel.kernel_pydantic import KernelBaseSettings
from semantic_kernel.kernel_types import OneOrMany
from semantic_kernel.utils.feature_stage_decorator import release_candidate
oracledb.defaults.fetch_lobs = False
# Compatibility: @override decorator
# Python 3.12+ has typing.override natively,
# for older versions use typing_extensions.
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
# Type variables for generics
TKey = TypeVar("TKey", bound=str | int | uuid.UUID)
TModel = TypeVar("TModel")
# Module-level logger
logger = logging.getLogger(__name__)
# Explicit module exports:
# Only expose high-level entry points; keep helpers internal.
__all__ = [
"OracleCollection",
"OracleSettings",
"OracleStore",
]
# Environment Variable keys for Oracle DB configuration
POOL_MIN_ENV_VAR: Final[str] = "ORACLE_POOL_MIN"
POOL_MAX_ENV_VAR: Final[str] = "ORACLE_POOL_MAX"
POOL_INCREMENT_ENV_VAR: Final[str] = "ORACLE_POOL_INCREMENT"
# Maps Semantic Kernel enums to Oracle SQL keywords
DISTANCE_FUNCTION_MAP: Final[dict[DistanceFunction, str]] = {
DistanceFunction.COSINE_DISTANCE: "COSINE",
DistanceFunction.EUCLIDEAN_DISTANCE: "EUCLIDEAN",
DistanceFunction.EUCLIDEAN_SQUARED_DISTANCE: "EUCLIDEAN_SQUARED",
DistanceFunction.DOT_PROD: "DOT",
DistanceFunction.HAMMING: "HAMMING",
DistanceFunction.MANHATTAN: "MANHATTAN",
DistanceFunction.DEFAULT: "COSINE",
}
# Maps index kind enums to Oracle SQL keywords
INDEX_KIND_MAP: Final[dict[IndexKind, str]] = {IndexKind.HNSW: "HNSW", IndexKind.IVF_FLAT: "IVF"}
# Maps dtype strings to NumPy types and array codes
KIND_MAP = {
"float32": (np.float32, "f"),
"float": (np.float64, "d"),
"float64": (np.float64, "d"),
"int8": (np.int8, "b"),
"uint8": (np.uint8, "B"),
"binary": (np.uint8, "B"),
}
VECTOR_TYPE_MAPPING: dict[str, str] = {
"uint8": "BINARY",
"int8": "INT8",
"float": "FLOAT64",
"float32": "FLOAT32",
"float64": "FLOAT64",
"binary": "BINARY",
}
# helper methods
def _map_scalar_field_type_to_oracle(field_type_str: str) -> str | None:
"""Map a Semantic Kernel record *scalar field* type (key or data).
This is used when generating table DDL for all non-vector fields,
including primary keys and data fields.
Args:
field_type_str: The field type as a string
(e.g., 'int', 'float', 'uuid', 'dict', 'bytes').
Returns:
The corresponding Oracle SQL column type as a string,
or None if there is no known mapping.
"""
type_mapping = {
"bool": "BOOLEAN",
"byte": "NUMBER(3)",
"int": "NUMBER(10)",
"long": "NUMBER(19)",
"float": "BINARY_FLOAT",
"double": "BINARY_DOUBLE",
"Decimal": "NUMBER",
"UUID": "RAW(16)",
"date": "DATE",
"datetime": "TIMESTAMP",
"timedelta": "INTERVAL DAY TO SECOND",
"bytes": "RAW(2000)",
"dict": "JSON",
"clob": "CLOB",
"blob": "BLOB",
}
list_pattern = re.compile(r"list\[(.*)\]")
if list_pattern.match(field_type_str):
return "JSON"
dict_pattern = re.compile(r"dict\[(.*?),\s*(.*?)\]")
if dict_pattern.match(field_type_str):
return "JSON"
str_match = re.match(r"str(?:\((\d+)\))?$", field_type_str)
if str_match:
size = str_match.group(1) or "4000"
return f"VARCHAR2({size})"
if field_type_str not in type_mapping:
raise VectorStoreOperationException(f"Unsupported scalar field type: {field_type_str}")
return type_mapping.get(field_type_str)
def _sk_vector_element_to_oracle(field_type_str: str) -> str | None:
"""Convert a Semantic Kernel vector element type string to an Oracle VECTOR element type string."""
list_pattern = re.compile(r"(?i)^list\[(.*)\]$")
field_type = field_type_str.strip()
# Iteratively unwrap list[...] until no longer matches
while True:
match = list_pattern.match(field_type)
if not match:
break
field_type = match.group(1).strip()
# Return final mapped type if available
return VECTOR_TYPE_MAPPING.get(field_type)
class BindCounter:
"""Helper class to generate unique bind variable names for SQL queries."""
def __init__(self, start: int = 1):
self._index = start
def next_bind(self) -> str:
name = f"bind_val{self._index}"
self._index += 1
return name
# region: Oracle Settings
@release_candidate
class OracleSettings(KernelBaseSettings):
"""Oracle connector settings.
This class is used to configure the Oracle connection pool
and related options for the Oracle vector or memory store connectors.
It supports both standard username/password authentication and
wallet-based secure connections, and is compatible with Oracle's
native async driver (python-oracledb) for efficient async operations.
The settings align with common Oracle client environment variables
such as ORACLE_USER, ORACLE_PASSWORD, and ORACLE_CONNECTION_STRING,
while following the Semantic Kernel convention for configuration
through environment variables or explicit keyword arguments.
Args:
user: Oracle database username.
(Env var ORACLE_USER)
password: Oracle database password.
(Env var ORACLE_PASSWORD)
connection_string: Full Oracle connection string, for example:
"host:port/service_name".
(Env var ORACLE_CONNECTION_STRING)
min: Minimum number of connections in the pool.
(Env var ORACLE_MIN)
max: Maximum number of connections in the pool.
(Env var ORACLE_MAX)
increment: Number of connections to add when the pool grows.
(Env var ORACLE_INCREMENT)
wallet_location: Path to the Oracle wallet directory for wallet-based authentication.
(Env var ORACLE_WALLET_LOCATION)
wallet_password: Password for the Oracle wallet.
(Env var ORACLE_WALLET_PASSWORD)
connection_pool: Optional preconfigured AsyncConnectionPool instance.
"""
env_prefix: ClassVar[str] = "ORACLE_"
user: str | None = None
password: SecretStr | None = None
connection_string: str | None = None
min: int | None = Field(default=None, validation_alias=POOL_MIN_ENV_VAR)
max: int | None = Field(default=None, validation_alias=POOL_MAX_ENV_VAR)
increment: int | None = Field(default=None, validation_alias=POOL_INCREMENT_ENV_VAR)
wallet_location: str | None = None
wallet_password: SecretStr | None = None
_connection_pool: oracledb.AsyncConnectionPool | None = PrivateAttr(default=None)
async def create_connection_pool(self, **kwargs: Any) -> oracledb.AsyncConnectionPool:
"""Creates an async Oracle connection pool."""
try:
# Create pool with extra user-supplied kwargs
self._connection_pool = oracledb.create_pool_async(
user=self.user,
password=self.password.get_secret_value() if self.password else None,
dsn=self.connection_string,
wallet_location=self.wallet_location,
wallet_password=self.wallet_password.get_secret_value() if self.wallet_password else None,
min=self.min,
max=self.max,
increment=self.increment,
**kwargs, # extra pool params
)
except Exception as err:
raise MemoryConnectorConnectionException("Error creating Oracle connection pool.") from err
return self._connection_pool
# region: Oracle Collections
@release_candidate
class OracleCollection(
VectorStoreCollection[TKey, TModel],
VectorSearch[TKey, TModel],
Generic[TKey, TModel],
):
"""Oracle implementation of VectorStoreCollection + VectorSearch."""
connection_pool: oracledb.AsyncConnectionPool | None = None
db_schema: str | None = None
pool_args: dict[str, Any] | None = None
supported_key_types: ClassVar[set[str] | None] = {"str", "int", "UUID"}
supported_vector_types: ClassVar[set[str] | None] = set(VECTOR_TYPE_MAPPING.keys())
supported_search_types: ClassVar[set[SearchType]] = {SearchType.VECTOR}
_distance_column_name: str = "SIMILARITY_SKOVS"
def __init__(
self,
record_type: type[TModel],
collection_name: str | None = None,
definition: VectorStoreCollectionDefinition | None = None,
embedding_generator: EmbeddingGeneratorBase | None = None,
connection_pool: oracledb.AsyncConnectionPool | None = None,
db_schema: str | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
settings: OracleSettings | None = None,
pool_args: dict[str, Any] | None = None,
**kwargs: Any,
):
"""Initialize the collection.
Args:
record_type: The type of the data model.
definition: The data model definition.
collection_name: The name of the collection, which corresponds to the table name.
embedding_generator: The embedding generator.
connection_pool: The connection pool.
db_schema: The database schema.
env_file_path: Use the environment settings file as a fallback to environment variables.
env_file_encoding: The encoding of the environment settings file.
settings: The settings for creating a new connection pool. If not provided, the settings will be created
from the environment.
pool_args: Optional dict of additional arguments to configure the connection pool
(e.g., timeout, ping_interval).
**kwargs: Additional arguments.
"""
# Build settings from env if we need to manage our own pool
settings = settings or OracleSettings(
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
managed_client = False
# Create pool if missing
if connection_pool is None:
try:
pool_args = pool_args or {}
connection_pool = oracledb.create_pool_async(
user=settings.user,
password=settings.password.get_secret_value() if settings.password else None,
dsn=settings.connection_string,
wallet_location=settings.wallet_location,
wallet_password=settings.wallet_password.get_secret_value() if settings.wallet_password else None,
min=settings.min,
max=settings.max,
increment=settings.increment,
**pool_args,
)
except Exception as err:
raise MemoryConnectorConnectionException("Error creating Oracle connection pool.") from err
managed_client = True
else:
managed_client = False
super().__init__(
collection_name=collection_name, # type: ignore
record_type=record_type,
definition=definition, # type: ignore
embedding_generator=embedding_generator,
connection_pool=connection_pool, # type: ignore
db_schema=db_schema, # type: ignore
settings=settings, # type: ignore
# This controls whether the connection pool is managed by the collection
# in the __aenter__ and __aexit__ methods.
managed_client=managed_client,
)
# Compute UUID field names once
self._uuid_fields = [
field.storage_name or field.name
for field in (*self.definition.data_fields, self.definition.key_field)
if field.type_ == "UUID"
]
# Validate key/data/vector field once per life-cycle
key_field = self.definition.key_field
key_field_name = key_field.storage_name or key_field.name
self._validate_identifiers(key_field_name)
for field in self.definition.data_fields:
data_field_name = field.storage_name or field.name
self._validate_identifiers(data_field_name)
for field in self.definition.vector_fields:
vector_field_name = field.storage_name or field.name
self._validate_identifiers(vector_field_name)
dtype = field.type_ or "float32"
if dtype not in KIND_MAP:
raise VectorStoreOperationException(
f"Unsupported dtype '{dtype}' for field '{field.name}'. "
f"Supported dtypes: {', '.join(KIND_MAP.keys())}"
)
@override
async def __aenter__(self) -> "OracleCollection":
return self
@override
async def __aexit__(self, *args: Any) -> None:
# Only close the connection pool if it was created by the collection itself.
if self.managed_client and self.connection_pool:
try:
await self.connection_pool.close()
except Exception as e:
logger.warning("Error closing Oracle connection pool: %s", e)
finally:
self.connection_pool = None
self.managed_client = False
def _check_pool(self) -> oracledb.AsyncConnectionPool:
"""Ensure that the connection pool is available, otherwise raise a consistent error."""
if self.connection_pool is None:
raise VectorStoreOperationException("Collection has no connection pool.")
return self.connection_pool
@override
def _deserialize_store_models_to_dicts(self, records: Sequence[Any], **kwargs: Any) -> Sequence[dict[str, Any]]:
"""Deserialize the store models to a list of dicts. Pass the records through without modification."""
return records
def _full_table_name(self) -> str:
"""Return the fully qualified table name with optional schema prefix, quoted."""
self._validate_identifiers(self.collection_name)
if self.db_schema:
self._validate_identifiers(self.db_schema)
return f'"{self.db_schema}"."{self.collection_name}"'
return f'"{self.collection_name}"'
async def _get_connection(self):
"""Acquire a connection from the pool, ensuring input/output type handlers are always set.
Raises VectorStoreOperationException if no connection pool is configured.
"""
pool = self._check_pool()
conn = await pool.acquire()
# if you only want to set these in certain circumstances you can add a parameter to
# the function for that purpose but it should be safe to set at all times
conn.inputtypehandler = self._input_type_handler
conn.outputtypehandler = self._output_type_handler
return conn
def _input_type_handler(self, cursor, value, arraysize):
"""Map Python types to Oracle bind variables with correct DB types."""
if isinstance(value, np.ndarray):
return cursor.var(oracledb.DB_TYPE_VECTOR, arraysize=arraysize, inconverter=self._numpy_converter_in)
if isinstance(value, uuid.UUID):
return cursor.var(oracledb.DB_TYPE_RAW, arraysize=arraysize, inconverter=lambda v: v.bytes)
if isinstance(value, (dict, list)):
return cursor.var(oracledb.DB_TYPE_JSON, arraysize=arraysize)
if isinstance(value, bytes):
return cursor.var(oracledb.DB_TYPE_RAW, arraysize=arraysize)
return None
def _numpy_converter_in(self, value):
"""Convert a NumPy array into a Python array.array compatible with Oracle DB_TYPE_VECTOR."""
dtype_name = value.dtype.name
np_dtype, code = KIND_MAP[dtype_name]
value = value.astype(np_dtype, copy=False)
return array.array(code, value)
def _output_type_handler(self, cursor, metadata):
"""Map Oracle DB column types to Python-native objects during fetch operations."""
# VECTOR columns to list
if metadata.type_code == oracledb.DB_TYPE_VECTOR:
return cursor.var(oracledb.DB_TYPE_VECTOR, arraysize=cursor.arraysize, outconverter=lambda arr: list(arr))
# RAW to UUID
if metadata.type_code == oracledb.DB_TYPE_RAW and metadata.name in self._uuid_fields:
return cursor.var(
oracledb.DB_TYPE_RAW,
arraysize=cursor.arraysize,
outconverter=lambda b: uuid.UUID(bytes=b) if b is not None else None,
)
return None
@override
def _serialize_dicts_to_store_models(self, records: Sequence[dict[str, Any]], **kwargs: Any) -> Sequence[Any]:
"""Serialize a list of dicts of the data to the store model. Pass the records through without modification."""
return records
def _validate_identifiers(self, name: str) -> None:
"""Validate Oracle identifier to disallow embedded double quotes.
Since quoted identifiers are not allowed, any double quote is invalid.
"""
if not name:
raise VectorStoreOperationException("Identifier cannot be empty")
if '"' in name:
raise VectorStoreOperationException(f"Invalid identifier with quotes: {name}")
def _build_check_table_exists_query(self) -> tuple[str, dict[str, str]]:
"""Build SQL + bind variables for checking table existence.
- If schema is provided, query ALL_TABLES.
- If no schema, query USER_TABLES.
"""
if self.db_schema:
sql = """
SELECT 1
FROM all_tables
WHERE owner = :owner
AND table_name = :tbl
"""
bind_vars = {
"owner": self.db_schema,
"tbl": self.collection_name,
}
else:
sql = """
SELECT 1
FROM user_tables
WHERE table_name = :tbl
"""
bind_vars = {"tbl": self.collection_name}
return sql, bind_vars
@override
async def collection_exists(self, **kwargs: Any) -> bool:
"""Return True if the table backing this collection exists."""
pool = self._check_pool()
sql, binds = self._build_check_table_exists_query()
async with pool.acquire() as conn:
result = await conn.fetchone(sql, binds)
return result is not None
@override
async def ensure_collection_deleted(self, **kwargs: Any) -> None:
"""Deletes collection if it exists."""
pool = self._check_pool()
tbl = self._full_table_name()
drop_sql = f"DROP TABLE IF EXISTS {tbl} CASCADE CONSTRAINTS PURGE"
async with pool.acquire() as conn:
await conn.execute(drop_sql)
logger.debug(f"Oracle table '{self.collection_name}' deleted successfully.")
def _build_create_table_query(
self,
*,
table: str,
key_field: VectorStoreField,
data_fields: list[VectorStoreField],
vector_fields: list[VectorStoreField],
) -> str:
col_lines: list[str] = []
if not key_field.type_:
raise VectorStoreOperationException(f"Type missing for key field '{key_field.name}'")
pk_name = key_field.storage_name or key_field.name
col_lines.append(f'"{pk_name}" {_map_scalar_field_type_to_oracle(key_field.type_)} PRIMARY KEY')
for f in data_fields:
if not f.type_:
raise VectorStoreOperationException(f"Type missing for data field '{f.name}'")
sql_type = _map_scalar_field_type_to_oracle(f.type_)
if sql_type is None:
raise VectorStoreOperationException(f'Unsupported Oracle type for field "{f.name}" ({f.type_})')
col_name = f.storage_name or f.name
col_lines.append(f'"{col_name}" {sql_type}')
for f in vector_fields:
if not f.type_ or f.dimensions is None:
raise VectorStoreOperationException(f"Vector field '{f.name}' missing type or dimensions")
col_name = f.storage_name or f.name
col_lines.append(f'"{col_name}" VECTOR({f.dimensions} , {_sk_vector_element_to_oracle(f.type_)})')
columns_sql = ",\n ".join(col_lines)
return f"CREATE TABLE IF NOT EXISTS {table} (\n {columns_sql}\n)"
def _create_vector_index(self, table_name: str, vector_field: VectorStoreField) -> str | None:
"""Build a CREATE VECTOR INDEX statement for an Oracle vector column using HNSW or IVF indexing."""
if vector_field.index_kind not in INDEX_KIND_MAP:
logger.warning(
f"Index kind '{vector_field.index_kind}' is not supported. "
"Please set the index kind in the vector field definition."
)
return None
if not vector_field.distance_function or vector_field.distance_function not in DISTANCE_FUNCTION_MAP:
raise VectorStoreOperationException(
f"Distance function '{vector_field.distance_function}' is not supported. "
"Please set the distance function in the vector field definition."
)
distance = DISTANCE_FUNCTION_MAP[vector_field.distance_function]
column = vector_field.storage_name or vector_field.name
last_token = table_name.split(".")[-1]
base_table = last_token.strip('"')
index_name = f'"{base_table}_{column}_idx"'
index_kind = vector_field.index_kind
if index_kind == IndexKind.HNSW or index_kind == IndexKind.DEFAULT:
index_subtype = f"ORGANIZATION INMEMORY NEIGHBOR GRAPH DISTANCE {distance} "
else:
# IndexKind.IVF_FLAT
index_subtype = f"ORGANIZATION NEIGHBOR PARTITIONS DISTANCE {distance} "
return f'CREATE VECTOR INDEX IF NOT EXISTS {index_name}\nON {table_name} ("{column}")\n{index_subtype}'
def _create_data_index(self, table_name: str, field) -> str | None:
"""Build a CREATE INDEX statement for a single data field if it is indexable.
Returns the SQL string or None if no index should be created.
"""
if not getattr(field, "is_indexed", False):
return None
oracle_type = _map_scalar_field_type_to_oracle(field.type_) if field.type_ else None
if oracle_type and oracle_type.lower() != "json":
col = field.storage_name or field.name
last_token = table_name.split(".")[-1]
base_table = last_token.strip('"')
index_name = f'"{base_table}_{col}_idx"'
return f'CREATE INDEX {index_name} ON {table_name} ("{col}" ASC)'
return None
@override
async def ensure_collection_exists(self, **kwargs: Any) -> None:
"""Create the table (and vector indexes) if not existing."""
pool = self._check_pool()
tbl = self._full_table_name()
create_sql = self._build_create_table_query(
table=tbl,
key_field=self.definition.key_field,
data_fields=self.definition.data_fields,
vector_fields=self.definition.vector_fields,
)
# Combine create table + vector + data indexes
statements = (
[create_sql]
+ [stmt for vf in self.definition.vector_fields if (stmt := self._create_vector_index(tbl, vf))]
+ [stmt for field in self.definition.data_fields if (stmt := self._create_data_index(tbl, field))]
)
async with pool.acquire() as conn:
for statement in statements:
await conn.execute(statement)
await conn.commit()
logger.info(f"Oracle table '{self.collection_name}' created successfully.")
@override
async def _inner_delete(self, keys: Sequence[TKey], **kwargs: Any) -> None:
"""Delete the records whose primary keys are in ``keys``."""
pool = self._check_pool()
if not keys:
return
tbl = self._full_table_name()
pk_col = self.definition.key_field.storage_name or self.definition.key_field.name
delete_sql = f'DELETE FROM {tbl} WHERE "{pk_col}" = :1' # nosec B608
async with pool.acquire() as conn:
binds = [(k,) for k in keys]
await conn.executemany(delete_sql, binds)
await conn.commit()
def _build_select_query(
self,
table: str,
key_field: VectorStoreField,
data_fields: list[VectorStoreField],
vector_fields: list[VectorStoreField],
keys: Sequence[Any] | None,
options: GetFilteredRecordOptions | None,
include_vectors: bool = False,
) -> tuple[str, list[Any]]:
# SELECT clause
all_fields = [key_field, *data_fields, *vector_fields] if include_vectors else [key_field, *data_fields]
field_lookup = {f.name: f for f in all_fields}
select_clause = ", ".join(f'"{f.storage_name or f.name}" AS "{f.name}"' for f in all_fields)
sql = f"SELECT {select_clause} FROM {table}" # nosec B608
bind_values: list[Any] = []
# WHERE clause by keys
if keys:
placeholders = ", ".join(f":{i + 1}" for i in range(len(keys)))
sql += f' WHERE "{key_field.storage_name or key_field.name}" IN ({placeholders})'
bind_values.extend(keys)
# ORDER BY
if options and options.order_by:
parts: list[str] = []
for logical_name, asc in options.order_by.items():
field = field_lookup[logical_name]
field_name = field.storage_name or field.name
direction = "ASC" if asc else "DESC"
parts.append(f'"{field_name}" {direction}')
sql += " ORDER BY " + ", ".join(parts)
# Pagination
if options:
if options.skip is not None:
sql += f" OFFSET {options.skip} ROWS"
if options.top is not None:
sql += f" FETCH NEXT {options.top} ROWS ONLY"
return sql, bind_values
@override
async def _inner_get(
self,
keys: Sequence[TKey] | None = None,
options: GetFilteredRecordOptions | None = None,
**kwargs: Any,
) -> OneOrMany[dict[str, Any]] | None:
"""Retrieve one or more records from the Oracle table.
Returns:
OneOrMany[dict[str, Any]] | None
- A single record (dict) when exactly one row matches
- A list of dicts when multiple rows match
- ``None`` when no rows match
"""
pool = self._check_pool()
if not keys and options is None:
return None
table = self._full_table_name()
include_vectors = kwargs.get("include_vectors", True)
q, binds = self._build_select_query(
table,
self.definition.key_field,
self.definition.data_fields,
self.definition.vector_fields,
keys,
options,
include_vectors,
)
async with pool.acquire() as conn:
conn.outputtypehandler = self._output_type_handler
rows = await conn.fetchall(q, binds)
# Build column list once: key, data, then vector fields
columns = [self.definition.key_field.name] + [f.name for f in self.definition.data_fields]
if include_vectors:
columns = columns + [f.name for f in self.definition.vector_fields]
if not rows:
return None
# build list of dict records
records: list[dict[str, Any]] = []
for row in rows:
record = {col: val for col, val in zip(columns, row)}
records.append(record)
return records[0] if len(records) == 1 else records
def _convert_dict_to_row(
self,
record: Mapping[str, Any],
fields: Sequence[VectorStoreField],
) -> tuple[Any, ...]:
"""Convert an in-memory record (dict) into a positional tuple ready for executemany() with Oracle."""
row: list[Any] = []
for field in fields:
column_name = field.name
value = record.get(column_name)
if value is None:
row.append(None)
continue
if field.field_type == "vector" and isinstance(value, (list)):
_, code = KIND_MAP[field.type_] # type: ignore[index]
value = array.array(code, value)
row.append(value)
continue
row.append(value)
return tuple(row)
def _build_single_merge_query(
self,
table_name: str,
key_field: VectorStoreField,
data_fields: list[VectorStoreField],
vector_fields: list[VectorStoreField],
) -> str:
"""Build a parameterised MERGE statement for Oracle.
One executemany() call executes this MERGE once per record.
"""
all_fields = [key_field, *data_fields, *vector_fields]
src_bindings = ",\n ".join(
f':{idx + 1} AS "{field.storage_name or field.name}"' for idx, field in enumerate(all_fields)
)
# When matched then update data
update_clause = ",\n ".join(
f't."{field.storage_name or field.name}" = s."{field.storage_name or field.name}"'
for field in data_fields + vector_fields
)
# When not matched then insert data
insert_columns = ", ".join(f'"{field.storage_name or field.name}"' for field in all_fields)
insert_values = ", ".join(f's."{field.storage_name or field.name}"' for field in all_fields)
merge_sql = f"""
MERGE INTO {table_name} t
USING (
SELECT {src_bindings}
FROM dual
) s
ON (
t."{key_field.storage_name or key_field.name}" = s."{key_field.storage_name or key_field.name}"
)
WHEN MATCHED THEN
UPDATE SET {update_clause}
WHEN NOT MATCHED THEN
INSERT ({insert_columns})
VALUES ({insert_values})
""" # nosec B608
return merge_sql.strip()
@override
async def _inner_upsert(
self,
records: Sequence[dict[str, Any]],
**kwargs: Any,
) -> Sequence[TKey]:
pool = self._check_pool()
if not records:
return []
key_field = self.definition.key_field
data_fields = self.definition.data_fields
vector_fields = self.definition.vector_fields
table_name = self._full_table_name()
ordered_fields = [key_field, *data_fields, *vector_fields]
query = self._build_single_merge_query(table_name, key_field, data_fields, vector_fields)
async with pool.acquire() as conn:
conn.inputtypehandler = self._input_type_handler
binds = [self._convert_dict_to_row(record, ordered_fields) for record in records]
await conn.executemany(query, binds)
await conn.commit()
return [record[key_field.name] for record in records]
def _build_search_query(
self,
table: str,
key_field: VectorStoreField,
data_fields: list[VectorStoreField],
vector_fields: list[VectorStoreField],
vector: Sequence[float | int],
vector_field: VectorStoreField,
options: VectorSearchOptions,
filter_clause: str | None,
) -> tuple[str, list[Any], list[str]]:
bind_values = []
bind_index = 1
# Vector bind placeholder
vector_placeholder = f":{bind_index}"
bind_values.append(vector)
bind_index += 1
# Build TO_VECTOR() expression from field metadata
dim = vector_field.dimensions or len(vector)
# Normalize user-provided dtype and map to Oracle-supported VECTOR types.
raw_dtype = (vector_field.type_ or "float32").lower()
dtype = VECTOR_TYPE_MAPPING.get(raw_dtype, "FLOAT32")
to_vector_expr = f"TO_VECTOR({vector_placeholder}, {dim}, {dtype})"
# Fields to SELECT
select_fields = [f'"{key_field.storage_name or key_field.name}"'] + [
f'"{field.storage_name or field.name}"' for field in data_fields
]
if options.include_vectors:
select_fields += [f'"{f.storage_name or f.name}"' for f in vector_fields]
select_clause = ", ".join(select_fields)
# Choose distance function
if not vector_field.distance_function or vector_field.distance_function not in DISTANCE_FUNCTION_MAP:
raise VectorStoreOperationException(
f"Distance function '{vector_field.distance_function}' is not supported. "
"Please set the distance function in the vector field definition."
)
distance_fn = DISTANCE_FUNCTION_MAP[vector_field.distance_function]
distance_expr = (
f'VECTOR_DISTANCE("{vector_field.storage_name or vector_field.name}", '
f'{to_vector_expr}, {distance_fn}) AS "{self._distance_column_name}"'
)
# Final SQL assembly
sql = f"""
SELECT {select_clause}, {distance_expr}
FROM {table}
""".strip() # nosec B608
if filter_clause:
sql += f"\nWHERE {filter_clause}"
sql += f'\nORDER BY "{self._distance_column_name}" ASC'
if options:
if options.skip is not None:
sql += f" OFFSET {options.skip} ROWS"
if options.top is not None:
sql += f" FETCH NEXT {options.top} ROWS ONLY"
return sql, bind_values, select_fields
async def _fetch_records(self, sql: str, binds: list[Any]) -> AsyncIterable[dict[str, Any]]:
"""Execute the SQL with binds and yield rows as dictionaries mapping column name to value.
Uses zip() for clean row-to-dict mapping.
"""
async with await self._get_connection() as conn:
conn.inputtypehandler = self._input_type_handler
conn.outputtypehandler = self._output_type_handler
with conn.cursor() as cur:
await cur.execute(sql, binds)
col_names = [d.name for d in cur.description]
async for row in cur:
yield dict(zip(col_names, row))
@override
def _get_record_from_result(self, result: dict[str, Any]) -> dict[str, Any]:
"""Pass-through: assumes result is already normalized and cleaned."""
return result
@override
def _get_score_from_result(self, result: Any) -> float | None:
return result.get("SIMILARITY_SKOVS", None)
@override
async def _inner_search(
self,
search_type: SearchType,
options: VectorSearchOptions,
values: Any | None = None,
vector: Sequence[float | int] | None = None,
**kwargs: Any,
) -> KernelSearchResults[VectorSearchResult[TModel]]:
# Oracle does not support accurate total_count
if options.include_total_count:
logger.warning("`include_total_count=True` is not supported in OracleVectorStore and will be ignored.")
# Build SQL & bind parameters
query, bind, _ = await self._inner_search_vector(options, values, vector, **kwargs)
# Always run streaming search (even if include_total_count=True)
stream: AsyncIterable[dict[str, Any]] = self._fetch_records(query, bind)
return KernelSearchResults(
results=self._get_vector_search_results_from_results(stream, options),
total_count=None, # always None in Oracle
)
async def _inner_search_vector(
self,
options: VectorSearchOptions,
values: Any | None,
vector: Sequence[float | int] | None,
**kwargs: Any,
) -> tuple[str, list[Any], list[str]]:
if vector is None:
vector = await self._generate_vector_from_values(values, options)
if vector is None or len(vector) == 0:
raise VectorSearchExecutionException("Vector cannot be None or empty.")
if options.vector_property_name is None:
raise VectorStoreOperationException("vector_property_name cannot be None")
vector_field = next(
field for field in self.definition.vector_fields if field.name == options.vector_property_name
)
dtype = vector_field.type_ if vector_field.type_ else "float32"
if isinstance(vector, (list)):
_, code = KIND_MAP[dtype]
vector = array.array(code, vector)
table = self._full_table_name()
# When building filter:
parsed_filter = self._build_filter(options.filter)
filter_clause: str | None = None
filter_binds: list[Any] = []
if parsed_filter is not None:
filter_clause = parsed_filter[0]
filter_binds.extend(parsed_filter[1].values())
query, bind_values, columns = self._build_search_query(
table,
self.definition.key_field,
self.definition.data_fields,
self.definition.vector_fields,
vector,
vector_field,
options,
filter_clause,
)
# Append filter binds after vector
bind_values.extend(filter_binds)
return query, bind_values, columns
@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).
Uses bind variables for all scalar values, including dates.
"""
if bind_counter is None:
bind_counter = BindCounter()
bind_dict: dict[str, Any] = {}
match node:
# Comparisons
case ast.Compare():
# IS / IS NOT NULL
if isinstance(node.ops[0], (ast.Is, ast.IsNot)):
left_sql, left_bind = self._lambda_parser(node.left, bind_counter)
bind_dict.update(left_bind)
right = node.comparators[0]
if isinstance(right, ast.Constant) and right.value is None:
op_str = "IS" if isinstance(node.ops[0], ast.Is) else "IS NOT"
return (f"{left_sql} {op_str} NULL", bind_dict)
raise NotImplementedError("IS comparison only supports None/NULL checks")
# Chained comparisons: a <= b < c
if len(node.ops) > 1:
values = []
for idx in range(len(node.ops)):
left_node = node.left if idx == 0 else node.comparators[idx - 1]
right_node = node.comparators[idx]
op = node.ops[idx]
expr, binds = self._lambda_parser(
ast.Compare(left=left_node, ops=[op], comparators=[right_node]), bind_counter
)
values.append(expr)
bind_dict.update(binds)
return (f"({' AND '.join(values)})", bind_dict)
# Single comparison
left_sql, left_bind = self._lambda_parser(node.left, bind_counter)
right_sql, right_bind = self._lambda_parser(node.comparators[0], bind_counter)
bind_dict.update(left_bind)
bind_dict.update(right_bind)
op = node.ops[0]
if isinstance(op, ast.Eq):
return (f"{left_sql} = {right_sql}", bind_dict)
if isinstance(op, ast.NotEq):
return (f"{left_sql} <> {right_sql}", bind_dict)
if isinstance(op, ast.Gt):
return (f"{left_sql} > {right_sql}", bind_dict)
if isinstance(op, ast.GtE):
return (f"{left_sql} >= {right_sql}", bind_dict)
if isinstance(op, ast.Lt):
return (f"{left_sql} < {right_sql}", bind_dict)
if isinstance(op, ast.LtE):
return (f"{left_sql} <= {right_sql}", bind_dict)
if isinstance(op, ast.In):
return (f"{left_sql} IN {right_sql}", bind_dict)
if isinstance(op, ast.NotIn):
return (f"{left_sql} NOT IN {right_sql}", bind_dict)
raise NotImplementedError(f"Unsupported comparison operator: {type(op)}")
# Boolean operations
case ast.BoolOp():
parts = []
for v in node.values:
sql, binds = self._lambda_parser(v, bind_counter)
parts.append(sql)
bind_dict.update(binds)
if isinstance(node.op, ast.And):
return (f"({' AND '.join(parts)})", bind_dict)
if isinstance(node.op, ast.Or):
return (f"({' OR '.join(parts)})", bind_dict)
raise NotImplementedError(f"Unsupported BoolOp: {type(node.op)}")
# Unary operations
case ast.UnaryOp():
if isinstance(node.op, ast.Not):
operand_sql, operand_bind = self._lambda_parser(node.operand, bind_counter)
bind_dict.update(operand_bind)
return (f"NOT ({operand_sql})", bind_dict)
raise NotImplementedError(f"Unsupported UnaryOp: {type(node.op)}")
# Handling attribute or name nodes (fields)
case ast.Attribute():
if node.attr not in self.definition.storage_names:
raise VectorStoreOperationException(f"Field '{node.attr}' not in data model.")
return (f'"{node.attr}"', {})
case ast.Name():
if node.id not in self.definition.storage_names:
raise VectorStoreOperationException(f"Field '{node.id}' not in data model.")
return (f'"{node.id}"', {})
# Constants (scalar values) used in this module
case ast.Constant():
val = node.value
if val is None:
return ("NULL", {})
bind_name = bind_counter.next_bind()
return (f":{bind_name}", {bind_name: val})
# Lists (for IN operator)
case ast.List():
parts = []
for elt in node.elts:
elt_sql, elt_bind = self._lambda_parser(elt, bind_counter)
parts.append(elt_sql)
bind_dict.update(elt_bind)
return (f"({', '.join(parts)})", bind_dict)
# Function calls
case ast.Call():
# Supported methods in this block: contains, startswith, endswith, between
if isinstance(node.func, ast.Attribute):
obj_sql, obj_bind = self._lambda_parser(node.func.value, bind_counter)
bind_dict.update(obj_bind)
sql_args = []
for arg in node.args:
arg_sql, arg_bind = self._lambda_parser(arg, bind_counter)
sql_args.append(arg_sql)
bind_dict.update(arg_bind)
method = node.func.attr
if method == "contains" and len(sql_args) == 1:
return (f"LOWER({obj_sql}) LIKE LOWER('%' || {sql_args[0]} || '%')", bind_dict)
if method == "startswith" and len(sql_args) == 1:
return (f"{obj_sql} LIKE {sql_args[0]} || '%'", bind_dict)
if method == "endswith" and len(sql_args) == 1:
return (f"{obj_sql} LIKE '%' || {sql_args[0]}", bind_dict)
if method == "between" and len(sql_args) == 2:
return (f"{obj_sql} BETWEEN {sql_args[0]} AND {sql_args[1]}", bind_dict)
# Handle datetime function with arguments (year, month, day)
if isinstance(node.func, ast.Name) and node.func.id == "datetime":
if not (3 <= len(node.args) <= 6):
raise NotImplementedError("datetime() only supports between 3 and 6 integer arguments")
def get_const(arg: ast.AST) -> int:
if isinstance(arg, ast.Constant) and isinstance(arg.value, int):
return arg.value
raise NotImplementedError("datetime() arguments must be int constants")
year = get_const(node.args[0])
month = get_const(node.args[1])
day = get_const(node.args[2])
hour = get_const(node.args[3]) if len(node.args) > 3 else 0
minute = get_const(node.args[4]) if len(node.args) > 4 else 0
second = get_const(node.args[5]) if len(node.args) > 5 else 0
dt = datetime.datetime(year, month, day, hour, minute, second)
bind_name = bind_counter.next_bind()
bind_dict[bind_name] = dt
return (f":{bind_name}", bind_dict)
# Handle date function with arguments (year, month, day)
if isinstance(node.func, ast.Name) and node.func.id == "date":
if len(node.args) != 3:
raise NotImplementedError("date() only supports year, month, day as int constants")
def get_const(arg: ast.AST) -> int:
if isinstance(arg, ast.Constant) and isinstance(arg.value, int):
return arg.value
raise NotImplementedError("date() arguments must be int constants")
year = get_const(node.args[0])
month = get_const(node.args[1])
day = get_const(node.args[2])
dt = datetime.date(year, month, day) # type: ignore[assignment]
bind_name = bind_counter.next_bind()
bind_dict[bind_name] = dt
return (f":{bind_name}", bind_dict)
raise NotImplementedError(f"Unsupported AST node: {type(node)}")
# region: Oracle Store
@release_candidate
class OracleStore(VectorStore):
"""VectorStore wrapper holding a shared Oracle connection-pool."""
connection_pool: oracledb.AsyncConnectionPool | None = None
db_schema: str | None = None
env_file_path: str | None = None
env_file_encoding: str | None = None
def _build_select_table_names_query(self) -> tuple[str, dict[str, str]]:
if self.db_schema:
sql = """
SELECT table_name
FROM all_tables
WHERE owner = :schema
ORDER BY table_name
"""
bind_vars = {"schema": self.db_schema}
else:
sql = """
SELECT table_name
FROM user_tables
ORDER BY table_name
"""
bind_vars = {}
return sql, bind_vars
@override
def get_collection(
self,
record_type: type[TModel],
*,
collection_name: str | None = None,
definition: VectorStoreCollectionDefinition | None = None,
embedding_generator: EmbeddingGeneratorBase | None = None,
pool_args: dict[str, Any] | None = None,
**kwargs: Any,
) -> OracleCollection:
"""Return an OracleCollection that shares this store's pool.
Args:
record_type: The type of the records that will be used.
collection_name: Name of the table (optional if `definition` supplied).
definition: VectorStoreCollectionDefinition describing schema/PK.
embedding_generator: Overrides store's default generator.
pool_args: Dict of connection-pool overrides (user, min, max, …).
**kwargs: Additional keyword arguments passed to OracleCollection.
Returns:
OracleCollection ready for use (optionally as an async context manager).
"""
return OracleCollection(
record_type=record_type,
collection_name=collection_name,
db_schema=self.db_schema,
definition=definition,
connection_pool=self.connection_pool,
env_file_path=self.env_file_path,
env_file_encoding=self.env_file_encoding,
embedding_generator=embedding_generator or self.embedding_generator,
pool_args=pool_args,
**kwargs,
)
@override
async def list_collection_names(self, **kwargs: Any) -> Sequence[str]:
"""Get the names of all collections."""
if self.connection_pool is None:
raise VectorStoreOperationException("Store has no connection pool.")
sql, binds = self._build_select_table_names_query()
async with self.connection_pool.acquire() as conn:
rows = await conn.fetchall(sql, binds)
return [row[0] for row in rows]