1268 lines
49 KiB
Python
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]
|