diff --git a/python/packages/oracle/README.md b/python/packages/oracle/README.md index a5e799e6888..6560a078fbe 100644 --- a/python/packages/oracle/README.md +++ b/python/packages/oracle/README.md @@ -1,9 +1,9 @@ # Agent Framework Oracle vector store An alpha connector for storing and searching native `VECTOR` columns in Oracle -Database 23ai or newer. `OracleCollection` implements async batch CRUD and -vector search; `OracleStore` shares a client across collections; `OracleSettings` -resolves connection configuration through Agent Framework. +AI Database 26ai. `OracleCollection` implements async batch CRUD and vector +search; `OracleStore` shares a client across collections; `OracleSettings` +describes the connection and pool settings resolved through Agent Framework. ## Installation and setup @@ -11,20 +11,56 @@ resolves connection configuration through Agent Framework. pip install agent-framework-oracle --pre ``` -Requires Python 3.10+, Oracle Database 23ai or newer with `COMPATIBLE` set to +Requires Python 3.10+, Oracle AI Database 26ai with `COMPATIBLE` set to **23.4.0 or higher**, `python-oracledb` 2.2.x or 3.x, and permission to create/drop tables in the connected user's schema. The connector uses the driver's async Thin-mode API; it does not initialize the Thick client or provision a database. It does not create or alter schemas, vector indexes, or existing tables. -Set `ORACLE_DSN` (for example `localhost:1521/FREEPDB1`), `ORACLE_USER`, and -`ORACLE_PASSWORD`, or pass `dsn`, `user`, and `password` to either constructor. -Credentials are resolved in order: **explicit argument > selected `.env` file -> process environment**. A `.env` file is read only when `env_file_path` is -specified; `env_file_encoding` overrides UTF-8. `password` accepts `str` or -Agent Framework `SecretString` and is unwrapped only when opening a connection. -Missing or empty credentials are errors. +For username/password authentication, set `ORACLE_DSN` (for example +`localhost:1521/FREEPDB1`), `ORACLE_USER`, and `ORACLE_PASSWORD`, or pass +`dsn`, `user`, and `password` to either constructor. Typed driver settings can +also be supplied with the `pool_parameters` mapping. Settings are resolved in +order: **named `dsn`/`user`/`password` argument > `pool_parameters` > selected +`.env` file > process environment > connector pool defaults**. A `.env` file is +read only when `env_file_path` is specified; the selected file must exist, and +`env_file_encoding` overrides UTF-8. `password` accepts `str` or Agent Framework +`SecretString` and is unwrapped only when opening a connection. + +Named connection values must be nonempty when supplied, but their presence is +not required by the connector. Authentication and connection-descriptor +completeness are validated by python-oracledb, allowing alternatives such as +`access_token`, `externalauth`, or `host` with `service_name`/`sid`. + +Explicit `pool_parameters` keys are forwarded when they are accepted by the +installed `python-oracledb` version, including newly added driver options not +yet declared by the open `OracleSettings` typed dictionary. This preserves +editor discovery for known options while allowing forward-compatible keys. +Unsupported names and typos raise an error; unrecognized `ORACLE_` environment +variables remain ignored. + +Recognized scalar `oracledb.create_pool_async()` options can also be supplied as +`ORACLE_` settings. The prefix is removed, the remainder is converted to the +driver's lowercase keyword name, and boolean, integer, and floating-point +values are converted before the pool is created. Password-like values remain +masked until they are passed to the driver. For example: + +```dotenv +ORACLE_DSN=localhost:1521/FREEPDB1 +ORACLE_USER=example +ORACLE_PASSWORD=database-password +ORACLE_WALLET_LOCATION=/path/to/wallet +ORACLE_WALLET_PASSWORD=wallet-password +ORACLE_MIN=1 +ORACLE_MAX=8 +ORACLE_HOMOGENEOUS=false +``` + +The connector uses `min=0` and `max=4` when those options are not configured. +Unrecognized `ORACLE_` variables are ignored. Options that require callbacks, +classes, collections, or SSL context objects are not loaded from text settings; +pass them through `pool_parameters` or use a configured client. Alternatively, pass a configured `oracledb.AsyncConnection` or `oracledb.AsyncConnectionPool` as `client` for advanced authentication. A @@ -130,8 +166,8 @@ creates and deletes a unique test table. The unit tests require no Oracle server. To run the live integration test, provide **all three** `ORACLE_TEST_DSN`, `ORACLE_TEST_USER`, and -`ORACLE_TEST_PASSWORD` for an explicitly designated disposable Oracle 23ai+ -schema where the user can create and drop tables: +`ORACLE_TEST_PASSWORD` for an explicitly designated disposable Oracle AI +Database 26ai schema where the user can create and drop tables: ```bash cd python @@ -145,6 +181,6 @@ deletes only that table. ## Documentation -- [Oracle Database AI Vector Search requirements](https://docs.oracle.com/en/database/oracle/oracle-database/26/vecse/overview-ai-vector-search.html) +- [Oracle AI Vector Search requirements](https://docs.oracle.com/en/database/oracle/oracle-database/26/vecse/overview-ai-vector-search.html) - [python-oracledb 2.2 vector types](https://python-oracledb.readthedocs.io/en/v2.2.0/user_guide/vector_data_type.html) - [Microsoft Agent Framework](https://learn.microsoft.com/agent-framework/) diff --git a/python/packages/oracle/agent_framework_oracle/__init__.py b/python/packages/oracle/agent_framework_oracle/__init__.py index d3b1af683c4..fef12f70a1d 100644 --- a/python/packages/oracle/agent_framework_oracle/__init__.py +++ b/python/packages/oracle/agent_framework_oracle/__init__.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. -"""Oracle Database 23ai native-vector collections and stores.""" +"""Oracle AI Database 26ai native-vector collections and stores.""" from __future__ import annotations diff --git a/python/packages/oracle/agent_framework_oracle/_vector_store.py b/python/packages/oracle/agent_framework_oracle/_vector_store.py index 465f8356ab5..6f7196e6c97 100644 --- a/python/packages/oracle/agent_framework_oracle/_vector_store.py +++ b/python/packages/oracle/agent_framework_oracle/_vector_store.py @@ -1,15 +1,16 @@ # Copyright (c) Microsoft. All rights reserved. -"""Oracle Database 23ai vector collections and stores.""" +"""Oracle AI Database 26ai vector collections and stores.""" from __future__ import annotations import asyncio import math from array import array -from collections.abc import AsyncGenerator, Collection, Mapping, Sequence +from collections.abc import AsyncGenerator, Callable, Collection, Mapping, Sequence from contextlib import asynccontextmanager from decimal import Decimal +from inspect import signature from typing import Any, ClassVar, Generic, Protocol, TypeAlias, cast from uuid import UUID @@ -64,12 +65,74 @@ def setinputsizes(self, **kwargs: Any) -> Any: ... _ORDER_OPERATORS = {"gt": ">", "gte": ">=", "lt": "<", "lte": "<="} -class OracleSettings(TypedDict, total=False): - """Connection settings resolved from arguments, a selected .env file, or ``ORACLE_`` variables.""" +class OracleSettings(TypedDict, total=False, extra_items=Any): + """Connection and pool settings resolved from arguments, a selected .env file, or ``ORACLE_`` variables.""" dsn: str | None user: str | None password: SecretString | None + pool_alias: str | None + min: int | None + max: int | None + increment: int | None + getmode: int | None + homogeneous: bool | None + timeout: int | None + wait_timeout: int | None + max_lifetime_session: int | None + max_sessions_per_shard: int | None + soda_metadata_cache: bool | None + ping_interval: int | None + ping_timeout: int | None + proxy_user: str | None + newpassword: SecretString | None + wallet_password: SecretString | None + access_token: SecretString | tuple[str, str] | Callable[[], str | tuple[str, str]] | None + host: str | None + port: int | None + protocol: str | None + https_proxy: str | None + https_proxy_port: int | None + service_name: str | None + instance_name: str | None + sid: str | None + server_type: str | None + cclass: str | None + purity: int | None + expire_time: int | None + retry_count: int | None + retry_delay: int | None + tcp_connect_timeout: float | None + ssl_server_dn_match: bool | None + ssl_server_cert_dn: str | None + wallet_location: str | None + events: bool | None + externalauth: bool | None + mode: int | None + disable_oob: bool | None + stmtcachesize: int | None + edition: str | None + tag: str | None + matchanytag: bool | None + config_dir: str | None + debug_jdwp: str | None + connection_id_prefix: str | None + sdu: int | None + pool_boundary: str | None + use_tcp_fast_open: bool | None + program: str | None + machine: str | None + terminal: str | None + osuser: str | None + driver_name: str | None + use_sni: bool | None + thick_mode_dsn_passthrough: bool | None + pool_name: str | None + + +_CREDENTIAL_SETTING_NAMES = {"dsn", "user", "password"} +_ORACLE_SETTING_NAMES = frozenset(OracleSettings.__annotations__) +_CREATE_POOL_PARAMETER_NAMES = frozenset(signature(oracledb.create_pool_async).parameters) def _quote_identifier(name: str) -> str: @@ -218,24 +281,26 @@ def __init__( dsn: str | None = None, user: str | None = None, password: SecretString | None = None, + pool_parameters: Mapping[str, Any] | None = None, client: OracleClient | None = None, ) -> None: if client is not None: if not isinstance(client, (oracledb.AsyncConnection, oracledb.AsyncConnectionPool)): raise TypeError("client must be an oracledb AsyncConnection or AsyncConnectionPool.") - if any(value is not None for value in (dsn, user, password)): + if any(value is not None for value in (dsn, user, password)) or pool_parameters: raise ValueError("An Oracle client cannot be combined with connection settings.") else: - if not isinstance(dsn, str) or not dsn.strip(): - raise ValueError("A nonempty Oracle DSN is required.") - if not isinstance(user, str) or not user.strip(): - raise ValueError("A nonempty Oracle user is required.") - if not isinstance(password, SecretString) or not password.get_secret_value(): - raise ValueError("A nonempty Oracle password is required.") + if dsn is not None and (not isinstance(dsn, str) or not dsn.strip()): + raise ValueError("Oracle dsn must be a nonempty string when provided.") + if user is not None and (not isinstance(user, str) or not user.strip()): + raise ValueError("Oracle user must be a nonempty string when provided.") + if password is not None and (not isinstance(password, SecretString) or not password.get_secret_value()): + raise ValueError("Oracle password must be a nonempty string when provided.") self._client = client self._dsn = dsn self._user = user self._password = password + self._pool_parameters = dict(pool_parameters or {}) self._pool: oracledb.AsyncConnectionPool | None = None self._lock = asyncio.Lock() self._closed = False @@ -251,11 +316,21 @@ async def _get_client(self) -> OracleClient: async with self._lock: self.ensure_open() if self._pool is None: - if self._user is None or self._dsn is None or self._password is None: - raise RuntimeError("Oracle connection settings are missing.") - self._pool = oracledb.create_pool_async( - user=self._user, password=self._password.get_secret_value(), dsn=self._dsn, min=0, max=4 - ) + named_parameters = { + name: value.get_secret_value() if isinstance(value, SecretString) else value + for name, value in (("user", self._user), ("password", self._password), ("dsn", self._dsn)) + if value is not None + } + pool_parameters = { + "min": 0, + "max": 4, + **{ + name: value.get_secret_value() if isinstance(value, SecretString) else value + for name, value in self._pool_parameters.items() + }, + **named_parameters, + } + self._pool = oracledb.create_pool_async(**pool_parameters) return self._pool @asynccontextmanager @@ -292,24 +367,44 @@ def _create_client( dsn: str | None, user: str | None, password: str | SecretString | None, + pool_parameters: OracleSettings | None, client: OracleClient | None, env_file_path: str | None, env_file_encoding: str | None, ) -> _Client: if client is not None: - if any(value is not None for value in (dsn, user, password, env_file_path, env_file_encoding)): - raise ValueError("client cannot be combined with dsn, user, password, or .env settings.") + if any(value is not None for value in (dsn, user, password, pool_parameters, env_file_path, env_file_encoding)): + raise ValueError("client cannot be combined with dsn, user, password, pool_parameters, or .env settings.") return _Client(client=client) + supplied_pool_parameters = dict(pool_parameters or {}) + overrides: dict[str, Any] = { + name: value for name, value in supplied_pool_parameters.items() if name in _ORACLE_SETTING_NAMES + } + overrides.update({ + name: value for name, value in (("dsn", dsn), ("user", user), ("password", password)) if value is not None + }) settings = load_settings( OracleSettings, env_prefix="ORACLE_", - dsn=dsn, - user=user, - password=password, env_file_path=env_file_path, env_file_encoding=env_file_encoding, + **overrides, + ) + resolved_pool_parameters: dict[str, Any] = { + name: value for name, value in settings.items() if name not in _CREDENTIAL_SETTING_NAMES and value is not None + } + resolved_pool_parameters.update({ + name: value for name, value in supplied_pool_parameters.items() if name not in _ORACLE_SETTING_NAMES + }) + if unsupported := resolved_pool_parameters.keys() - _CREATE_POOL_PARAMETER_NAMES: + names = ", ".join(sorted(unsupported)) + raise ValueError(f"Oracle pool setting(s) not supported by the installed python-oracledb version: {names}.") + return _Client( + dsn=settings.get("dsn"), + user=settings.get("user"), + password=settings.get("password"), + pool_parameters=resolved_pool_parameters, ) - return _Client(dsn=settings.get("dsn"), user=settings.get("user"), password=settings.get("password")) def _scalar_field(definition: VectorStoreCollectionDefinition, name: str) -> VectorStoreField: @@ -445,7 +540,7 @@ class OracleCollection( BaseVectorSearch[KeyT, ModelT], Generic[KeyT, ModelT], ): - """Store typed records and search Oracle Database 23ai native VECTOR columns.""" + """Store typed records and search Oracle AI Database 26ai native VECTOR columns.""" supported_key_types: ClassVar[set[str] | None] = {"str", "int", "UUID"} supported_vector_types: ClassVar[set[str] | None] = {"float", "float32", "float64", "int8"} @@ -461,12 +556,13 @@ def __init__( dsn: str | None = None, user: str | None = None, password: str | SecretString | None = None, + pool_parameters: OracleSettings | None = None, client: OracleClient | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, _shared_client: _Client | None = None, ) -> None: - """Initialize a collection with explicit, selected .env, or environment credentials. + """Initialize a collection with explicit, .env, or environment connection settings. Args: record_type: Registered application model, or dict with an explicit definition. @@ -476,12 +572,17 @@ def __init__( dsn: Oracle connect string; defaults to ``ORACLE_DSN``. user: Oracle database user; defaults to ``ORACLE_USER``. password: Database password or AF SecretString; defaults to ``ORACLE_PASSWORD``. + pool_parameters: Additional ``create_pool_async`` settings, including options supported by the installed + driver but not yet declared by ``OracleSettings``. Named ``dsn``, ``user``, and ``password`` arguments + take precedence, followed by this mapping, the selected .env file, process environment, and connector + pool defaults. client: Borrowed async connection or pool in place of connection settings. env_file_path: Optional selected .env file; no implicit discovery. env_file_encoding: Encoding of the selected .env file. """ if _shared_client is not None and any( - item is not None for item in (dsn, user, password, client, env_file_path, env_file_encoding) + item is not None + for item in (dsn, user, password, pool_parameters, client, env_file_path, env_file_encoding) ): raise ValueError("Shared Oracle clients cannot be combined with connection settings.") super().__init__( @@ -497,6 +598,7 @@ def __init__( dsn=dsn, user=user, password=password, + pool_parameters=pool_parameters, client=client, env_file_path=env_file_path, env_file_encoding=env_file_encoding, @@ -874,6 +976,7 @@ def __init__( dsn: str | None = None, user: str | None = None, password: str | SecretString | None = None, + pool_parameters: OracleSettings | None = None, client: OracleClient | None = None, embedding_generator: EmbeddingClient | None = None, env_file_path: str | None = None, @@ -885,6 +988,10 @@ def __init__( dsn: Oracle connect string; defaults to ``ORACLE_DSN``. user: Oracle database user; defaults to ``ORACLE_USER``. password: Database password or AF SecretString; defaults to ``ORACLE_PASSWORD``. + pool_parameters: Additional ``create_pool_async`` settings, including options supported by the installed + driver but not yet declared by ``OracleSettings``. Named ``dsn``, ``user``, and ``password`` arguments + take precedence, followed by this mapping, the selected .env file, process environment, and connector + pool defaults. client: Borrowed async connection or pool in place of connection settings. embedding_generator: Default local embedding client for child collections. env_file_path: Optional selected .env file; no implicit discovery. @@ -895,6 +1002,7 @@ def __init__( dsn=dsn, user=user, password=password, + pool_parameters=pool_parameters, client=client, env_file_path=env_file_path, env_file_encoding=env_file_encoding, diff --git a/python/packages/oracle/pyproject.toml b/python/packages/oracle/pyproject.toml index 31d4f992644..52b7931f505 100644 --- a/python/packages/oracle/pyproject.toml +++ b/python/packages/oracle/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-oracle" -description = "Oracle Database native vector stores for Microsoft Agent Framework." +description = "Oracle AI Database native vector stores for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com" }] readme = "README.md" requires-python = ">=3.10" @@ -43,7 +43,7 @@ addopts = "-ra -q -r fEX" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" timeout = 120 -markers = ["integration: requires an explicitly designated disposable Oracle Database 23ai+ schema"] +markers = ["integration: requires an explicitly designated disposable Oracle AI Database 26ai schema"] [tool.coverage.run] omit = ["**/__init__.py"] diff --git a/python/packages/oracle/samples/oracle_vectors.py b/python/packages/oracle/samples/oracle_vectors.py index 11769316678..e3673897e6c 100644 --- a/python/packages/oracle/samples/oracle_vectors.py +++ b/python/packages/oracle/samples/oracle_vectors.py @@ -12,10 +12,10 @@ from agent_framework_oracle import OracleStore """ -Use Oracle Database 23ai native vectors with local precomputed embeddings. +Use Oracle AI Database 26ai native vectors with local precomputed embeddings. Environment variables: - ORACLE_DSN - Oracle connect string for a disposable 23ai+ schema + ORACLE_DSN - Oracle connect string for a disposable Oracle AI Database 26ai schema ORACLE_USER - User permitted to create and drop tables in that schema ORACLE_PASSWORD - Password for that user diff --git a/python/packages/oracle/tests/oracle/test_integration.py b/python/packages/oracle/tests/oracle/test_integration.py index 18519721b89..bf799c79da6 100644 --- a/python/packages/oracle/tests/oracle/test_integration.py +++ b/python/packages/oracle/tests/oracle/test_integration.py @@ -20,7 +20,10 @@ def _oracle_test_database_configured() -> bool: pytest.mark.flaky, pytest.mark.skipif( not _oracle_test_database_configured(), - reason="Set ORACLE_TEST_DSN, ORACLE_TEST_USER, and ORACLE_TEST_PASSWORD for a disposable Oracle 23ai+ schema.", + reason=( + "Set ORACLE_TEST_DSN, ORACLE_TEST_USER, and ORACLE_TEST_PASSWORD " + "for a disposable Oracle AI Database 26ai schema." + ), ), ] diff --git a/python/packages/oracle/tests/oracle/test_settings.py b/python/packages/oracle/tests/oracle/test_settings.py index d34bd30cc7b..16ed85cad2d 100644 --- a/python/packages/oracle/tests/oracle/test_settings.py +++ b/python/packages/oracle/tests/oracle/test_settings.py @@ -2,8 +2,9 @@ from __future__ import annotations +from collections.abc import Callable from functools import partial -from typing import get_type_hints +from typing import Any, get_type_hints from unittest.mock import AsyncMock, MagicMock, patch import oracledb @@ -15,10 +16,14 @@ from agent_framework_oracle import _vector_store as module +@pytest.fixture(autouse=True) +def isolate_oracle_settings(monkeypatch): + for name in get_type_hints(OracleSettings): + monkeypatch.delenv(f"ORACLE_{name.upper()}", raising=False) + + @pytest.fixture(params=["store", "collection"]) -def constructor(request, definition_factory, monkeypatch): - for name in ("ORACLE_DSN", "ORACLE_USER", "ORACLE_PASSWORD"): - monkeypatch.delenv(name, raising=False) +def constructor(request, definition_factory): if request.param == "collection": return partial(OracleCollection, dict, definition=definition_factory()) return OracleStore @@ -28,17 +33,27 @@ def test_public_settings_mask_password(monkeypatch): monkeypatch.setenv("ORACLE_DSN", "localhost/service") monkeypatch.setenv("ORACLE_USER", "example") monkeypatch.setenv("ORACLE_PASSWORD", "private-value") - assert get_type_hints(OracleSettings) == { - "dsn": str | None, - "user": str | None, - "password": SecretString | None, - } + monkeypatch.setenv("ORACLE_WALLET_LOCATION", "/wallet") + monkeypatch.setenv("ORACLE_WALLET_PASSWORD", "wallet-private-value") + hints = get_type_hints(OracleSettings) + assert hints["dsn"] == str | None + assert hints["user"] == str | None + assert hints["password"] == SecretString | None + assert hints["wallet_location"] == str | None + assert hints["wallet_password"] == SecretString | None + assert hints["access_token"] == SecretString | tuple[str, str] | Callable[[], str | tuple[str, str]] | None + assert hints["min"] == int | None settings = load_settings(OracleSettings, env_prefix="ORACLE_") assert settings["dsn"] == "localhost/service" + assert settings["wallet_location"] == "/wallet" password = settings["password"] + wallet_password = settings["wallet_password"] assert isinstance(password, SecretString) + assert isinstance(wallet_password, SecretString) assert password.get_secret_value() == "private-value" + assert wallet_password.get_secret_value() == "wallet-private-value" assert "private-value" not in repr(settings) + assert "wallet-private-value" not in repr(settings) assert "private-value" not in str(password) @@ -54,6 +69,7 @@ def test_explicit_credentials_precede_file_and_environment(constructor, monkeypa dsn="explicit", user="explicit", password=secret, + pool_parameters={"dsn": "mapping", "user": "mapping", "password": SecretString("mapping")}, env_file_path=str(env_file), ) assert instance._client._dsn == instance._client._user == "explicit" @@ -63,13 +79,22 @@ def test_explicit_credentials_precede_file_and_environment(constructor, monkeypa def test_selected_file_precedes_environment_and_supports_encoding(constructor, monkeypatch, tmp_path): - for name in ("ORACLE_DSN", "ORACLE_USER", "ORACLE_PASSWORD"): + for name in ("ORACLE_DSN", "ORACLE_USER", "ORACLE_PASSWORD", "ORACLE_WALLET_PASSWORD"): monkeypatch.setenv(name, "environment") + monkeypatch.setenv("ORACLE_MIN", "1") env_file = tmp_path / "oracle.env" - env_file.write_text("ORACLE_DSN=file\nORACLE_USER=file\nORACLE_PASSWORD=file\n", encoding="utf-16") + env_file.write_text( + "ORACLE_DSN=file\nORACLE_USER=file\nORACLE_PASSWORD=file\nORACLE_WALLET_PASSWORD=file-wallet\nORACLE_MIN=2\n", + encoding="utf-16", + ) instance = constructor(env_file_path=str(env_file), env_file_encoding="utf-16") assert instance._client._dsn == instance._client._user == "file" assert instance._client._password.get_secret_value() == "file" + assert instance._client._pool_parameters["min"] == 2 + wallet_password = instance._client._pool_parameters["wallet_password"] + assert isinstance(wallet_password, SecretString) + assert wallet_password.get_secret_value() == "file-wallet" + assert "file-wallet" not in repr(instance._client._pool_parameters) def test_environment_without_implicit_dotenv(constructor, monkeypatch, tmp_path): @@ -83,10 +108,46 @@ def test_environment_without_implicit_dotenv(constructor, monkeypatch, tmp_path) assert instance._client._pool is None -@pytest.mark.parametrize("missing", ["dsn", "user", "password"]) -def test_missing_or_empty_credentials_fail_without_connecting(constructor, missing): +def test_invalid_pool_parameter_fails_without_connecting(monkeypatch): + monkeypatch.setenv("ORACLE_DSN", "dsn") + monkeypatch.setenv("ORACLE_USER", "user") + monkeypatch.setenv("ORACLE_PASSWORD", "password") + monkeypatch.setenv("ORACLE_MAX", "not-an-integer") + with ( + patch.object(module.oracledb, "create_pool_async") as create, + pytest.raises(ValueError, match="setting 'max' from environment variable 'ORACLE_MAX'"), + ): + OracleStore() + create.assert_not_called() + + +def test_pool_parameter_unsupported_by_installed_driver_fails(monkeypatch): + monkeypatch.setenv("ORACLE_DSN", "dsn") + monkeypatch.setenv("ORACLE_USER", "user") + monkeypatch.setenv("ORACLE_PASSWORD", "password") + monkeypatch.setenv("ORACLE_POOL_ALIAS", "example") + supported = module._CREATE_POOL_PARAMETER_NAMES - {"pool_alias"} + monkeypatch.setattr(module, "_CREATE_POOL_PARAMETER_NAMES", supported) + with pytest.raises(ValueError, match="pool_alias"): + OracleStore() + + +def test_unknown_explicit_pool_parameter_fails_without_connecting(): + pool_parameters: Any = {"maxx": 8} + with patch.object(module.oracledb, "create_pool_async") as create, pytest.raises(ValueError, match="maxx"): + OracleStore( + dsn="dsn", + user="user", + password="password", + pool_parameters=pool_parameters, + ) + create.assert_not_called() + + +@pytest.mark.parametrize("empty", ["dsn", "user", "password"]) +def test_empty_named_connection_values_fail_without_connecting(constructor, empty): settings = {"dsn": "dsn", "user": "user", "password": "password"} - settings[missing] = "" + settings[empty] = "" with patch.object(module.oracledb, "create_pool_async") as create, pytest.raises(ValueError): constructor(**settings) create.assert_not_called() @@ -97,11 +158,12 @@ def test_missing_selected_file_is_not_ignored(constructor, tmp_path): constructor(dsn="explicit", user="explicit", password="explicit", env_file_path=str(tmp_path / "missing.env")) -@pytest.mark.parametrize("option", ["dsn", "user", "password", "env_file_path", "env_file_encoding"]) +@pytest.mark.parametrize("option", ["dsn", "user", "password", "pool_parameters", "env_file_path", "env_file_encoding"]) def test_borrowed_client_rejects_connection_options(constructor, option): client = MagicMock(spec=oracledb.AsyncConnection) + value = {"min": 1} if option == "pool_parameters" else "not-read" with pytest.raises(ValueError, match="cannot be combined"): - constructor(client=client, **{option: "not-read"}) + constructor(client=client, **{option: value}) @pytest.mark.parametrize("client_type", [oracledb.AsyncConnection, oracledb.AsyncConnectionPool]) @@ -147,12 +209,151 @@ async def test_owned_pool_is_lazy_and_closed_once(): assert await store._client._get_client() is pool assert await store._client._get_client() is pool await store.close() - create.assert_called_once() + create.assert_called_once_with(dsn="dsn", user="user", password="password", min=0, max=4) pool.close.assert_awaited_once() with pytest.raises(RuntimeError, match="closed"): await store._client._get_client() +async def test_absent_named_connection_values_are_deferred_to_driver(): + pool = MagicMock(spec=oracledb.AsyncConnectionPool) + store = OracleStore() + with patch.object(module.oracledb, "create_pool_async", return_value=pool) as create: + assert await store._client._get_client() is pool + create.assert_called_once_with(min=0, max=4) + + +async def test_access_token_auth_does_not_require_user_or_password(monkeypatch): + monkeypatch.setenv("ORACLE_DSN", "dsn") + monkeypatch.setenv("ORACLE_ACCESS_TOKEN", "environment-token") + pool = MagicMock(spec=oracledb.AsyncConnectionPool) + store = OracleStore() + token = store._client._pool_parameters["access_token"] + assert isinstance(token, SecretString) + assert token.get_secret_value() == "environment-token" + with patch.object(module.oracledb, "create_pool_async", return_value=pool) as create: + assert await store._client._get_client() is pool + create.assert_called_once_with(dsn="dsn", access_token="environment-token", min=0, max=4) + + +async def test_explicit_access_token_preserves_tuple_and_callable_forms(): + token_tuple = ("token", "private-key") + + def token_provider() -> tuple[str, str]: + return token_tuple + + for access_token in (token_tuple, token_provider): + pool = MagicMock(spec=oracledb.AsyncConnectionPool) + store = OracleStore(dsn="dsn", pool_parameters={"access_token": access_token}) + with patch.object(module.oracledb, "create_pool_async", return_value=pool) as create: + assert await store._client._get_client() is pool + create.assert_called_once_with(dsn="dsn", access_token=access_token, min=0, max=4) + + +async def test_host_and_service_name_do_not_require_dsn(): + pool = MagicMock(spec=oracledb.AsyncConnectionPool) + store = OracleStore( + user="user", + password="password", + pool_parameters={"host": "db.example.com", "service_name": "service"}, + ) + with patch.object(module.oracledb, "create_pool_async", return_value=pool) as create: + assert await store._client._get_client() is pool + create.assert_called_once_with( + user="user", + password="password", + host="db.example.com", + service_name="service", + min=0, + max=4, + ) + + +async def test_public_pool_parameters_precedence(monkeypatch): + monkeypatch.setenv("ORACLE_DSN", "environment-dsn") + monkeypatch.setenv("ORACLE_USER", "environment-user") + monkeypatch.setenv("ORACLE_PASSWORD", "environment-password") + pool = MagicMock(spec=oracledb.AsyncConnectionPool) + store = OracleStore( + dsn="named-dsn", + user="named-user", + password="named-password", + pool_parameters={ + "dsn": "pool-dsn", + "user": "pool-user", + "password": SecretString("pool-password"), + "min": 1, + }, + ) + with patch.object(module.oracledb, "create_pool_async", return_value=pool) as create: + assert await store._client._get_client() is pool + create.assert_called_once_with( + dsn="named-dsn", + user="named-user", + password="named-password", + min=1, + max=4, + ) + + +async def test_pool_parameters_forward_installed_driver_options_not_in_settings(monkeypatch): + future_value = object() + pool_parameters: Any = {"future_option": future_value} + monkeypatch.setattr(module, "_CREATE_POOL_PARAMETER_NAMES", module._CREATE_POOL_PARAMETER_NAMES | {"future_option"}) + pool = MagicMock(spec=oracledb.AsyncConnectionPool) + store = OracleStore( + dsn="dsn", + user="user", + password="password", + pool_parameters=pool_parameters, + ) + with patch.object(module.oracledb, "create_pool_async", return_value=pool) as create: + assert await store._client._get_client() is pool + create.assert_called_once_with( + dsn="dsn", + user="user", + password="password", + min=0, + max=4, + future_option=future_value, + ) + + +async def test_owned_pool_forwards_typed_environment_parameters(monkeypatch): + values = { + "ORACLE_DSN": "dsn", + "ORACLE_USER": "user", + "ORACLE_PASSWORD": "password", + "ORACLE_MIN": "1", + "ORACLE_MAX": "8", + "ORACLE_HOMOGENEOUS": "false", + "ORACLE_TCP_CONNECT_TIMEOUT": "1.5", + "ORACLE_WALLET_LOCATION": "/wallet", + "ORACLE_WALLET_PASSWORD": "wallet-password", + "ORACLE_HOME": "/ignored", + } + for name, value in values.items(): + monkeypatch.setenv(name, value) + pool = MagicMock(spec=oracledb.AsyncConnectionPool) + pool.close = AsyncMock() + store = OracleStore() + with patch.object(module.oracledb, "create_pool_async", return_value=pool) as create: + assert await store._client._get_client() is pool + await store.close() + create.assert_called_once_with( + dsn="dsn", + user="user", + password="password", + min=1, + max=8, + homogeneous=False, + tcp_connect_timeout=1.5, + wallet_location="/wallet", + wallet_password="wallet-password", + ) + pool.close.assert_awaited_once() + + async def test_owned_pool_uses_driver_factory_without_connecting(): store = OracleStore(dsn="localhost:1521/unused", user="unused", password="unused") try: