oracle-haystack 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/CHANGELOG.md +10 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/PKG-INFO +1 -1
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/pyproject.toml +1 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/src/haystack_integrations/components/retrievers/oracle/embedding_retriever.py +6 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/src/haystack_integrations/components/retrievers/oracle/keyword_retriever.py +6 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/src/haystack_integrations/document_stores/oracle/document_store.py +34 -18
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/tests/conftest.py +21 -16
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/tests/test_document_store.py +216 -77
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/tests/test_embedding_retriever.py +7 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/tests/test_keyword_retriever.py +7 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/.gitignore +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/README.md +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/docker-compose.yml +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/init/01_vector_memory.sql +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/pydoc/config_docusaurus.yml +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/src/haystack_integrations/components/retrievers/oracle/__init__.py +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/src/haystack_integrations/components/retrievers/py.typed +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/src/haystack_integrations/document_stores/oracle/__about__.py +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/src/haystack_integrations/document_stores/oracle/__init__.py +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/src/haystack_integrations/document_stores/oracle/filters.py +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/src/haystack_integrations/document_stores/py.typed +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/tests/__init__.py +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/tests/test_filter_translator.py +0 -0
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [unreleased]
|
|
4
|
+
|
|
5
|
+
### 🧹 Chores
|
|
6
|
+
|
|
7
|
+
- Improve consistency of integrations folder structure (#3430)
|
|
8
|
+
|
|
9
|
+
### 🌀 Miscellaneous
|
|
10
|
+
|
|
11
|
+
- Validating meta fields to prevent SQL injection (#3511)
|
|
12
|
+
|
|
3
13
|
## [integrations/oracle-v0.2.0] - 2026-04-23
|
|
4
14
|
|
|
5
15
|
### 🚀 Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oracle-haystack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Oracle AI Vector Search DocumentStore integration for Haystack
|
|
5
5
|
Project-URL: Source Code, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/oracle
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/deepset-ai/haystack-core-integrations/issues
|
|
@@ -84,6 +84,12 @@ class OracleEmbeddingRetriever:
|
|
|
84
84
|
)
|
|
85
85
|
return {"documents": docs}
|
|
86
86
|
|
|
87
|
+
def close(self) -> None:
|
|
88
|
+
"""
|
|
89
|
+
Release the synchronous resources of the underlying Document Store.
|
|
90
|
+
"""
|
|
91
|
+
self.document_store.close()
|
|
92
|
+
|
|
87
93
|
def to_dict(self) -> dict[str, Any]:
|
|
88
94
|
"""
|
|
89
95
|
Serializes the component to a dictionary.
|
|
@@ -82,6 +82,12 @@ class OracleKeywordRetriever:
|
|
|
82
82
|
)
|
|
83
83
|
return {"documents": docs}
|
|
84
84
|
|
|
85
|
+
def close(self) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Release the synchronous resources of the underlying Document Store.
|
|
88
|
+
"""
|
|
89
|
+
self.document_store.close()
|
|
90
|
+
|
|
85
91
|
def to_dict(self) -> dict[str, Any]:
|
|
86
92
|
"""
|
|
87
93
|
Serializes the component to a dictionary.
|
|
@@ -8,6 +8,7 @@ import json
|
|
|
8
8
|
import logging
|
|
9
9
|
import re
|
|
10
10
|
import threading
|
|
11
|
+
from contextlib import suppress
|
|
11
12
|
from dataclasses import dataclass
|
|
12
13
|
from typing import Any, Literal
|
|
13
14
|
|
|
@@ -177,11 +178,7 @@ class OracleDocumentStore:
|
|
|
177
178
|
|
|
178
179
|
self._pool: oracledb.ConnectionPool | None = None
|
|
179
180
|
self._pool_lock = threading.Lock()
|
|
180
|
-
|
|
181
|
-
if create_table_if_not_exists:
|
|
182
|
-
self._ensure_table()
|
|
183
|
-
if create_index:
|
|
184
|
-
self.create_hnsw_index()
|
|
181
|
+
self._setup_done = False
|
|
185
182
|
|
|
186
183
|
def _get_pool(self) -> oracledb.ConnectionPool:
|
|
187
184
|
if self._pool is not None:
|
|
@@ -212,14 +209,33 @@ class OracleDocumentStore:
|
|
|
212
209
|
return self._pool
|
|
213
210
|
|
|
214
211
|
def _get_connection(self) -> oracledb.Connection:
|
|
212
|
+
self._ensure_setup()
|
|
215
213
|
return self._get_pool().acquire()
|
|
216
214
|
|
|
217
|
-
def
|
|
215
|
+
def _ensure_setup(self) -> None:
|
|
216
|
+
"""
|
|
217
|
+
Create the backing table and optional HNSW index once, on first use if needed.
|
|
218
|
+
"""
|
|
219
|
+
if self._setup_done:
|
|
220
|
+
return
|
|
221
|
+
self._setup_done = True
|
|
222
|
+
try:
|
|
223
|
+
if self.create_table_if_not_exists:
|
|
224
|
+
self._ensure_table()
|
|
225
|
+
if self.create_index:
|
|
226
|
+
self.create_hnsw_index()
|
|
227
|
+
except Exception:
|
|
228
|
+
self._setup_done = False
|
|
229
|
+
raise
|
|
230
|
+
|
|
231
|
+
def close(self) -> None:
|
|
232
|
+
"""
|
|
233
|
+
Release the associated synchronous resources.
|
|
234
|
+
"""
|
|
218
235
|
if self._pool is not None:
|
|
219
|
-
|
|
236
|
+
with suppress(Exception):
|
|
220
237
|
self._pool.close()
|
|
221
|
-
|
|
222
|
-
logger.warning("Failed to close Oracle connection pool during cleanup.", exc_info=True)
|
|
238
|
+
self._pool = None
|
|
223
239
|
|
|
224
240
|
def _ensure_table(self) -> None:
|
|
225
241
|
sql = f"""
|
|
@@ -766,17 +782,17 @@ class OracleDocumentStore:
|
|
|
766
782
|
return {"min": _try_parse_number(row[0]), "max": _try_parse_number(row[1])}
|
|
767
783
|
|
|
768
784
|
def get_metadata_field_unique_values(
|
|
769
|
-
self, metadata_field: str, search_term: str | None = None, from_: int = 0, size: int | None =
|
|
785
|
+
self, metadata_field: str, search_term: str | None = None, from_: int = 0, size: int | None = 10
|
|
770
786
|
) -> tuple[list[str], int]:
|
|
771
787
|
"""
|
|
772
788
|
Return a paginated list of distinct values for a metadata field, plus the total distinct count.
|
|
773
789
|
|
|
774
790
|
:param metadata_field: Metadata field name. May be prefixed with ``"meta."``
|
|
775
791
|
(e.g. ``"meta.lang"`` or ``"lang"``).
|
|
776
|
-
:param search_term: Optional substring filter applied to
|
|
792
|
+
:param search_term: Optional case-insensitive substring filter applied to the metadata field's own value.
|
|
777
793
|
:param from_: Zero-based offset for pagination. Defaults to ``0``.
|
|
778
|
-
:param size: Maximum number of values to return. When ``None`` all values
|
|
779
|
-
are returned.
|
|
794
|
+
:param size: Maximum number of values to return. Defaults to ``10``. When ``None`` all values
|
|
795
|
+
from ``from_`` onward are returned.
|
|
780
796
|
:returns: A tuple ``(values, total)`` where ``values`` is the paginated list of distinct field
|
|
781
797
|
values as strings and ``total`` is the overall distinct count (before pagination).
|
|
782
798
|
:raises ValueError: If ``metadata_field`` contains characters outside ``[A-Za-z0-9_.]``.
|
|
@@ -786,7 +802,7 @@ class OracleDocumentStore:
|
|
|
786
802
|
base_sql = f"FROM {self.table_name} WHERE JSON_VALUE(metadata, '$.{field_path}') IS NOT NULL"
|
|
787
803
|
params: dict[str, Any] = {}
|
|
788
804
|
if search_term:
|
|
789
|
-
base_sql += f" AND (
|
|
805
|
+
base_sql += f" AND UPPER(JSON_VALUE(metadata, '$.{field_path}')) LIKE UPPER(:search)"
|
|
790
806
|
params["search"] = f"%{search_term}%"
|
|
791
807
|
|
|
792
808
|
sql_count = f"SELECT COUNT(DISTINCT JSON_VALUE(metadata, '$.{field_path}')) {base_sql}"
|
|
@@ -834,17 +850,17 @@ class OracleDocumentStore:
|
|
|
834
850
|
return await asyncio.to_thread(self.get_metadata_field_min_max, metadata_field)
|
|
835
851
|
|
|
836
852
|
async def get_metadata_field_unique_values_async(
|
|
837
|
-
self, metadata_field: str, search_term: str | None = None, from_: int = 0, size: int | None =
|
|
853
|
+
self, metadata_field: str, search_term: str | None = None, from_: int = 0, size: int | None = 10
|
|
838
854
|
) -> tuple[list[str], int]:
|
|
839
855
|
"""
|
|
840
856
|
Asynchronously returns a paginated list of distinct values for a metadata field, plus the total count.
|
|
841
857
|
|
|
842
858
|
:param metadata_field: Metadata field name. May be prefixed with ``"meta."``
|
|
843
859
|
(e.g. ``"meta.lang"`` or ``"lang"``).
|
|
844
|
-
:param search_term: Optional substring filter applied to
|
|
860
|
+
:param search_term: Optional case-insensitive substring filter applied to the metadata field's own value.
|
|
845
861
|
:param from_: Zero-based offset for pagination. Defaults to ``0``.
|
|
846
|
-
:param size: Maximum number of values to return. When ``None`` all values
|
|
847
|
-
are returned.
|
|
862
|
+
:param size: Maximum number of values to return. Defaults to ``10``. When ``None`` all values
|
|
863
|
+
from ``from_`` onward are returned.
|
|
848
864
|
:returns: A tuple ``(values, total)`` where ``values`` is the paginated list of distinct field
|
|
849
865
|
values as strings and ``total`` is the overall distinct count (before pagination).
|
|
850
866
|
:raises ValueError: If ``metadata_field`` contains characters outside ``[A-Za-z0-9_.]``.
|
|
@@ -16,25 +16,30 @@ _PASSWORD = "haystack"
|
|
|
16
16
|
_DSN = "localhost:1521/freepdb1"
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def make_store():
|
|
21
|
+
"""Factory for a real OracleDocumentStore. Uses token secrets, so instances are not serialization-safe."""
|
|
22
|
+
|
|
23
|
+
def _make(table: str, embedding_dim: int = 4, *, create_table_if_not_exists: bool = True) -> OracleDocumentStore:
|
|
24
|
+
return OracleDocumentStore(
|
|
25
|
+
connection_config=OracleConnectionConfig(
|
|
26
|
+
user=Secret.from_token(_USER),
|
|
27
|
+
password=Secret.from_token(_PASSWORD),
|
|
28
|
+
dsn=Secret.from_token(_DSN),
|
|
29
|
+
),
|
|
30
|
+
table_name=table,
|
|
31
|
+
embedding_dim=embedding_dim,
|
|
32
|
+
create_table_if_not_exists=create_table_if_not_exists,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
return _make
|
|
31
36
|
|
|
32
37
|
|
|
33
38
|
@pytest.fixture
|
|
34
|
-
def document_store():
|
|
39
|
+
def document_store(make_store):
|
|
35
40
|
"""768-dim store required by the mixin's filterable_docs fixture."""
|
|
36
41
|
table = f"hs_sync_{uuid.uuid4().hex[:8]}"
|
|
37
|
-
s =
|
|
42
|
+
s = make_store(table, 768)
|
|
38
43
|
yield s
|
|
39
44
|
with s._get_connection() as conn, conn.cursor() as cur:
|
|
40
45
|
cur.execute(f"DROP TABLE {table} PURGE")
|
|
@@ -42,10 +47,10 @@ def document_store():
|
|
|
42
47
|
|
|
43
48
|
|
|
44
49
|
@pytest.fixture
|
|
45
|
-
def embedding_store():
|
|
50
|
+
def embedding_store(make_store):
|
|
46
51
|
"""4-dim store for embedding-retrieval, HNSW, and async tests."""
|
|
47
52
|
table = f"hs_emb_{uuid.uuid4().hex[:8]}"
|
|
48
|
-
s =
|
|
53
|
+
s = make_store(table)
|
|
49
54
|
yield s
|
|
50
55
|
with s._get_connection() as conn, conn.cursor() as cur:
|
|
51
56
|
cur.execute(f"DROP TABLE {table} PURGE")
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
# SPDX-License-Identifier: Apache-2.0
|
|
4
4
|
|
|
5
5
|
import uuid
|
|
6
|
+
from unittest.mock import MagicMock
|
|
6
7
|
|
|
7
8
|
import oracledb as _oracledb
|
|
8
9
|
import pytest
|
|
9
10
|
from haystack.dataclasses import Document
|
|
10
|
-
from haystack.document_stores.errors import DuplicateDocumentError
|
|
11
|
+
from haystack.document_stores.errors import DocumentStoreError, DuplicateDocumentError
|
|
11
12
|
from haystack.document_stores.types import DuplicatePolicy
|
|
12
13
|
from haystack.testing.document_store import (
|
|
13
14
|
CountDocumentsByFilterTest,
|
|
@@ -27,13 +28,8 @@ from haystack.testing.document_store_async import (
|
|
|
27
28
|
FilterableDocsFixtureMixin,
|
|
28
29
|
UpdateByFilterAsyncTest,
|
|
29
30
|
)
|
|
30
|
-
from haystack.utils import Secret
|
|
31
31
|
|
|
32
|
-
from haystack_integrations.document_stores.oracle import
|
|
33
|
-
|
|
34
|
-
_USER = "haystack"
|
|
35
|
-
_PASSWORD = "haystack"
|
|
36
|
-
_DSN = "localhost:1521/freepdb1"
|
|
32
|
+
from haystack_integrations.document_stores.oracle import OracleDocumentStore
|
|
37
33
|
|
|
38
34
|
|
|
39
35
|
def _doc(doc_id: str, content: str = "hello", meta: dict | None = None, embedding: list[float] | None = None):
|
|
@@ -47,72 +43,54 @@ def _uid(suffix: str = "") -> str:
|
|
|
47
43
|
return f"{base}{suffix.upper():>4}"[:32]
|
|
48
44
|
|
|
49
45
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
GetMetadataFieldMinMaxTest,
|
|
59
|
-
GetMetadataFieldsInfoTest,
|
|
60
|
-
GetMetadataFieldUniqueValuesTest,
|
|
61
|
-
UpdateByFilterTest,
|
|
62
|
-
):
|
|
46
|
+
def _lob(value: str) -> MagicMock:
|
|
47
|
+
"""Mock for an oracledb Large Object (LOB)"""
|
|
48
|
+
lob = MagicMock()
|
|
49
|
+
lob.read.return_value = value
|
|
50
|
+
return lob
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestOracleDocumentStoreUnit:
|
|
63
54
|
@staticmethod
|
|
64
55
|
def _mock_doc(content="hello", embedding=None, doc_id="AABB" * 8):
|
|
65
56
|
"""Lightweight document builder for mock-based tests."""
|
|
66
57
|
return Document(id=doc_id, content=content, meta={"k": "v"}, embedding=embedding)
|
|
67
58
|
|
|
68
|
-
@pytest.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
dsn=Secret.from_token(_DSN),
|
|
77
|
-
),
|
|
78
|
-
table_name=table,
|
|
79
|
-
embedding_dim=768,
|
|
80
|
-
distance_metric="COSINE",
|
|
81
|
-
create_table_if_not_exists=True,
|
|
82
|
-
)
|
|
83
|
-
yield s
|
|
84
|
-
with s._get_connection() as conn, conn.cursor() as cur:
|
|
85
|
-
cur.execute(f"DROP TABLE {table} PURGE")
|
|
86
|
-
conn.commit()
|
|
87
|
-
|
|
88
|
-
# Mixin override
|
|
89
|
-
def assert_documents_are_equal(self, received: list[Document], expected: list[Document]) -> None:
|
|
90
|
-
# filter_documents does not SELECT the embedding column — ignore it when comparing
|
|
91
|
-
assert len(received) == len(expected)
|
|
92
|
-
received_sorted = sorted(received, key=lambda d: d.id)
|
|
93
|
-
expected_sorted = sorted(expected, key=lambda d: d.id)
|
|
94
|
-
for r, e in zip(received_sorted, expected_sorted, strict=True):
|
|
95
|
-
assert r.id == e.id
|
|
96
|
-
assert r.content == e.content
|
|
97
|
-
assert r.meta == e.meta
|
|
98
|
-
|
|
99
|
-
# Mixin override
|
|
100
|
-
def test_write_documents(self, document_store: OracleDocumentStore) -> None:
|
|
101
|
-
# Default policy is NONE — a second write of the same doc raises DuplicateDocumentError
|
|
102
|
-
doc = Document(content="test doc")
|
|
103
|
-
assert document_store.write_documents([doc]) == 1
|
|
104
|
-
with pytest.raises(DuplicateDocumentError):
|
|
105
|
-
document_store.write_documents([doc])
|
|
106
|
-
self.assert_documents_are_equal(document_store.filter_documents(), [doc])
|
|
107
|
-
|
|
108
|
-
# test_comparison_equal_with_none → IS NULL
|
|
109
|
-
# test_comparison_not_equal_with_none → IS NOT NULL
|
|
110
|
-
# test_comparison_not_equal → col != x OR IS NULL
|
|
111
|
-
# test_comparison_not_in → IS NULL OR NOT IN
|
|
112
|
-
@pytest.mark.skip(
|
|
113
|
-
reason="Oracle NULL propagation in NOT(...) cannot match Python 'not (None == x) is True' semantics"
|
|
59
|
+
@pytest.mark.parametrize(
|
|
60
|
+
("table_name", "embedding_dim", "match"),
|
|
61
|
+
[
|
|
62
|
+
pytest.param("1invalid", 4, "Invalid table_name", id="table_name_starts_with_digit"),
|
|
63
|
+
pytest.param("bad-name", 4, "Invalid table_name", id="table_name_illegal_char"),
|
|
64
|
+
pytest.param("valid", 0, "embedding_dim must be a positive integer", id="embedding_dim_zero"),
|
|
65
|
+
pytest.param("valid", -1, "embedding_dim must be a positive integer", id="embedding_dim_negative"),
|
|
66
|
+
],
|
|
114
67
|
)
|
|
115
|
-
def
|
|
68
|
+
def test_init_rejects_invalid_arguments(self, make_store, table_name, embedding_dim, match):
|
|
69
|
+
with pytest.raises(ValueError, match=match):
|
|
70
|
+
make_store(table_name, embedding_dim)
|
|
71
|
+
|
|
72
|
+
def test_init_opens_no_connection(self, make_store, mock_pool): # noqa: ARG002
|
|
73
|
+
store = make_store("lazy_docs")
|
|
74
|
+
assert store._pool is None
|
|
75
|
+
assert store._setup_done is False
|
|
76
|
+
|
|
77
|
+
def test_setup_is_deferred_and_runs_once(self, make_store, mock_pool):
|
|
78
|
+
store = make_store("lazy_docs")
|
|
79
|
+
store.count_documents()
|
|
80
|
+
store.count_documents()
|
|
81
|
+
assert store._pool is not None
|
|
82
|
+
assert store._setup_done is True
|
|
83
|
+
_, _, cursor = mock_pool
|
|
84
|
+
create_calls = [c for c in cursor.execute.call_args_list if "CREATE TABLE IF NOT EXISTS" in c[0][0]]
|
|
85
|
+
assert len(create_calls) == 1
|
|
86
|
+
|
|
87
|
+
def test_setup_failure_resets_flag(self, make_store, mock_pool):
|
|
88
|
+
_, _, cursor = mock_pool
|
|
89
|
+
cursor.execute.side_effect = RuntimeError("boom")
|
|
90
|
+
store = make_store("lazy_docs")
|
|
91
|
+
with pytest.raises(RuntimeError):
|
|
92
|
+
store.count_documents()
|
|
93
|
+
assert store._setup_done is False
|
|
116
94
|
|
|
117
95
|
def test_write_documents_none_policy_calls_insert(self, patched_store, mock_pool):
|
|
118
96
|
_, _, cursor = mock_pool
|
|
@@ -207,6 +185,78 @@ class TestOracleDocumentStore(
|
|
|
207
185
|
cursor.fetchone.return_value = (42,)
|
|
208
186
|
assert patched_store.count_documents() == 42
|
|
209
187
|
|
|
188
|
+
def test_get_metadata_field_unique_values_search_term_filters_value_only(self, patched_store, mock_pool):
|
|
189
|
+
"""search_term must filter on the metadata field's own JSON value, not on document text."""
|
|
190
|
+
_, _, cursor = mock_pool
|
|
191
|
+
cursor.fetchone.return_value = (1,)
|
|
192
|
+
cursor.fetchall.return_value = [("bar",)]
|
|
193
|
+
|
|
194
|
+
patched_store.get_metadata_field_unique_values("category", search_term="bar")
|
|
195
|
+
|
|
196
|
+
count_sql, count_params = cursor.execute.call_args_list[0][0]
|
|
197
|
+
vals_sql, vals_params = cursor.execute.call_args_list[1][0]
|
|
198
|
+
|
|
199
|
+
for sql in (count_sql, vals_sql):
|
|
200
|
+
assert "UPPER(JSON_VALUE(metadata, '$.category')) LIKE UPPER(:search)" in sql
|
|
201
|
+
|
|
202
|
+
assert count_params["search"] == "%bar%"
|
|
203
|
+
assert vals_params["search"] == "%bar%"
|
|
204
|
+
|
|
205
|
+
def test_delete_table_executes_drop_and_index_sql(self, patched_store, mock_pool):
|
|
206
|
+
_, _, cursor = mock_pool
|
|
207
|
+
patched_store.delete_table()
|
|
208
|
+
executed = [c[0][0] for c in cursor.execute.call_args_list]
|
|
209
|
+
assert any("DROP TABLE" in sql and "PURGE" in sql for sql in executed)
|
|
210
|
+
assert any("DBMS_SEARCH.DROP_INDEX" in sql for sql in executed)
|
|
211
|
+
|
|
212
|
+
def test_keyword_retrieval_builds_contains_query(self, patched_store, mock_pool):
|
|
213
|
+
_, _, cursor = mock_pool
|
|
214
|
+
cursor.fetchall.return_value = [("AABB" * 8, "hello", '{"k": "v"}', 1.5)]
|
|
215
|
+
docs = patched_store._keyword_retrieval("hello world", top_k=5)
|
|
216
|
+
sql, params = cursor.execute.call_args[0]
|
|
217
|
+
assert "CONTAINS(DATA, :query, 1)" in sql
|
|
218
|
+
assert "SCORE(1)" in sql
|
|
219
|
+
assert params["query"] == "hello world"
|
|
220
|
+
assert params["top_k"] == 5
|
|
221
|
+
assert docs[0].score == 1.5
|
|
222
|
+
|
|
223
|
+
@pytest.mark.parametrize(
|
|
224
|
+
"operation",
|
|
225
|
+
[
|
|
226
|
+
pytest.param(lambda s: s._embedding_retrieval([0.1, 0.2, 0.3, 0.4]), id="embedding_retrieval"),
|
|
227
|
+
pytest.param(lambda s: s._keyword_retrieval("query"), id="keyword_retrieval"),
|
|
228
|
+
pytest.param(lambda s: s.delete_table(), id="delete_table"),
|
|
229
|
+
],
|
|
230
|
+
)
|
|
231
|
+
def test_query_methods_wrap_database_errors(self, patched_store, mock_pool, operation):
|
|
232
|
+
_, _, cursor = mock_pool
|
|
233
|
+
cursor.execute.side_effect = _oracledb.DatabaseError("ORA-00942")
|
|
234
|
+
with pytest.raises(DocumentStoreError):
|
|
235
|
+
operation(patched_store)
|
|
236
|
+
|
|
237
|
+
@pytest.mark.parametrize(
|
|
238
|
+
("text_raw", "metadata_raw", "expected_content", "expected_meta"),
|
|
239
|
+
[
|
|
240
|
+
pytest.param("plain", '{"k": "v"}', "plain", {"k": "v"}, id="str_text_and_json_metadata"),
|
|
241
|
+
pytest.param(_lob("from_lob"), _lob('{"k": "v"}'), "from_lob", {"k": "v"}, id="lob_text_and_metadata"),
|
|
242
|
+
pytest.param("plain", {"k": "v"}, "plain", {"k": "v"}, id="dict_metadata"),
|
|
243
|
+
pytest.param("plain", None, "plain", {}, id="unsupported_metadata_type"),
|
|
244
|
+
],
|
|
245
|
+
)
|
|
246
|
+
def test_row_to_document_reads_lob_and_maps_metadata(self, text_raw, metadata_raw, expected_content, expected_meta):
|
|
247
|
+
doc = OracleDocumentStore._row_to_document(("id1", text_raw, metadata_raw))
|
|
248
|
+
assert doc.content == expected_content
|
|
249
|
+
assert doc.meta == expected_meta
|
|
250
|
+
|
|
251
|
+
def test_create_hnsw_index_sql(self, patched_store, mock_pool):
|
|
252
|
+
_, _, cursor = mock_pool
|
|
253
|
+
patched_store.create_hnsw_index()
|
|
254
|
+
sql = cursor.execute.call_args[0][0]
|
|
255
|
+
assert "CREATE VECTOR INDEX" in sql
|
|
256
|
+
assert "HNSW" in sql
|
|
257
|
+
assert str(patched_store.hnsw_neighbors) in sql
|
|
258
|
+
assert str(patched_store.hnsw_ef_construction) in sql
|
|
259
|
+
|
|
210
260
|
def test_to_dict_does_not_expose_plain_password(self, patched_store):
|
|
211
261
|
d = patched_store.to_dict()
|
|
212
262
|
pw = d["init_parameters"]["connection_config"]["password"]
|
|
@@ -220,17 +270,74 @@ class TestOracleDocumentStore(
|
|
|
220
270
|
assert restored.embedding_dim == patched_store.embedding_dim
|
|
221
271
|
assert restored.distance_metric == patched_store.distance_metric
|
|
222
272
|
|
|
223
|
-
def
|
|
224
|
-
|
|
225
|
-
patched_store.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
assert
|
|
229
|
-
|
|
230
|
-
|
|
273
|
+
def test_close(self, patched_store):
|
|
274
|
+
pool = MagicMock()
|
|
275
|
+
patched_store._pool = pool
|
|
276
|
+
patched_store.close()
|
|
277
|
+
pool.close.assert_called_once()
|
|
278
|
+
assert patched_store._pool is None
|
|
279
|
+
patched_store.close()
|
|
280
|
+
pool.close.assert_called_once()
|
|
281
|
+
|
|
282
|
+
def test_close_is_exception_safe(self, patched_store):
|
|
283
|
+
pool = MagicMock()
|
|
284
|
+
pool.close.side_effect = RuntimeError("boom")
|
|
285
|
+
patched_store._pool = pool
|
|
286
|
+
patched_store.close()
|
|
287
|
+
assert patched_store._pool is None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@pytest.mark.integration
|
|
291
|
+
class TestOracleDocumentStore(
|
|
292
|
+
DocumentStoreBaseTests,
|
|
293
|
+
CountDocumentsByFilterTest,
|
|
294
|
+
CountUniqueMetadataByFilterTest,
|
|
295
|
+
DeleteAllTest,
|
|
296
|
+
DeleteByFilterTest,
|
|
297
|
+
DeleteDocumentsTest,
|
|
298
|
+
GetMetadataFieldMinMaxTest,
|
|
299
|
+
GetMetadataFieldsInfoTest,
|
|
300
|
+
GetMetadataFieldUniqueValuesTest,
|
|
301
|
+
UpdateByFilterTest,
|
|
302
|
+
):
|
|
303
|
+
@pytest.fixture
|
|
304
|
+
def document_store(self, make_store):
|
|
305
|
+
"""768-dim store — overrides the mixin's NotImplementedError stub."""
|
|
306
|
+
table = f"hs_sync_{uuid.uuid4().hex[:8]}"
|
|
307
|
+
s = make_store(table, 768)
|
|
308
|
+
yield s
|
|
309
|
+
with s._get_connection() as conn, conn.cursor() as cur:
|
|
310
|
+
cur.execute(f"DROP TABLE {table} PURGE")
|
|
311
|
+
conn.commit()
|
|
312
|
+
|
|
313
|
+
# Mixin override
|
|
314
|
+
def assert_documents_are_equal(self, received: list[Document], expected: list[Document]) -> None:
|
|
315
|
+
# filter_documents does not SELECT the embedding column — ignore it when comparing
|
|
316
|
+
assert len(received) == len(expected)
|
|
317
|
+
received_sorted = sorted(received, key=lambda d: d.id)
|
|
318
|
+
expected_sorted = sorted(expected, key=lambda d: d.id)
|
|
319
|
+
for r, e in zip(received_sorted, expected_sorted, strict=True):
|
|
320
|
+
assert r.id == e.id
|
|
321
|
+
assert r.content == e.content
|
|
322
|
+
assert r.meta == e.meta
|
|
231
323
|
|
|
232
|
-
|
|
233
|
-
|
|
324
|
+
# Mixin override
|
|
325
|
+
def test_write_documents(self, document_store: OracleDocumentStore) -> None:
|
|
326
|
+
# Default policy is NONE — a second write of the same doc raises DuplicateDocumentError
|
|
327
|
+
doc = Document(content="test doc")
|
|
328
|
+
assert document_store.write_documents([doc]) == 1
|
|
329
|
+
with pytest.raises(DuplicateDocumentError):
|
|
330
|
+
document_store.write_documents([doc])
|
|
331
|
+
self.assert_documents_are_equal(document_store.filter_documents(), [doc])
|
|
332
|
+
|
|
333
|
+
# test_comparison_equal_with_none → IS NULL
|
|
334
|
+
# test_comparison_not_equal_with_none → IS NOT NULL
|
|
335
|
+
# test_comparison_not_equal → col != x OR IS NULL
|
|
336
|
+
# test_comparison_not_in → IS NULL OR NOT IN
|
|
337
|
+
@pytest.mark.skip(
|
|
338
|
+
reason="Oracle NULL propagation in NOT(...) cannot match Python 'not (None == x) is True' semantics"
|
|
339
|
+
)
|
|
340
|
+
def test_not_operator(self, document_store, filterable_docs): ...
|
|
234
341
|
|
|
235
342
|
def test_filter_documents_not_operator(self, document_store):
|
|
236
343
|
# Scoped to two fresh docs — no NULL-valued rows, so NOT works correctly.
|
|
@@ -311,6 +418,38 @@ class TestOracleDocumentStore(
|
|
|
311
418
|
"""Calling _ensure_table() a second time must not raise."""
|
|
312
419
|
document_store._ensure_table()
|
|
313
420
|
|
|
421
|
+
def test_close_and_reopen(self, document_store):
|
|
422
|
+
document_store.count_documents()
|
|
423
|
+
assert document_store._pool is not None
|
|
424
|
+
document_store.close()
|
|
425
|
+
assert document_store._pool is None
|
|
426
|
+
assert document_store.count_documents() == 0
|
|
427
|
+
assert document_store._pool is not None
|
|
428
|
+
|
|
429
|
+
def test_get_metadata_field_unique_values_search_term_matches_value_not_content(self, document_store):
|
|
430
|
+
"""search_term filters on the metadata field's own value; document content is not considered."""
|
|
431
|
+
document_store.write_documents(
|
|
432
|
+
[
|
|
433
|
+
_doc(_uid("Q001"), content="this text mentions apple", meta={"category": "dessert"}),
|
|
434
|
+
_doc(_uid("Q002"), content="unrelated content", meta={"category": "apple-tart"}),
|
|
435
|
+
]
|
|
436
|
+
)
|
|
437
|
+
values, total = document_store.get_metadata_field_unique_values("category", search_term="apple")
|
|
438
|
+
# Q001: content contains "apple" but its category value ("dessert") does not -> excluded.
|
|
439
|
+
# Q002: category value ("apple-tart") contains "apple", content does not -> still included.
|
|
440
|
+
assert values == ["apple-tart"]
|
|
441
|
+
assert total == 1
|
|
442
|
+
|
|
443
|
+
def test_get_metadata_field_unique_values_search_term_case_insensitive(self, document_store):
|
|
444
|
+
document_store.write_documents(
|
|
445
|
+
[
|
|
446
|
+
_doc(_uid("Q003"), content="n/a", meta={"category": "Apple-Tart"}),
|
|
447
|
+
]
|
|
448
|
+
)
|
|
449
|
+
values, total = document_store.get_metadata_field_unique_values("category", search_term="APPLE")
|
|
450
|
+
assert values == ["Apple-Tart"]
|
|
451
|
+
assert total == 1
|
|
452
|
+
|
|
314
453
|
|
|
315
454
|
@pytest.mark.integration
|
|
316
455
|
class TestOracleDocumentStoreAsync(
|
|
@@ -67,6 +67,13 @@ def test_to_dict_from_dict_roundtrip(mock_store):
|
|
|
67
67
|
assert restored.document_store.embedding_dim == 4
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
def test_close(mock_store):
|
|
71
|
+
retriever = OracleEmbeddingRetriever(document_store=mock_store)
|
|
72
|
+
retriever.close()
|
|
73
|
+
mock_store.close.assert_called_once()
|
|
74
|
+
assert retriever.document_store is mock_store
|
|
75
|
+
|
|
76
|
+
|
|
70
77
|
def test_invalid_document_store_raises_type_error():
|
|
71
78
|
with pytest.raises(TypeError, match="must be an instance of OracleDocumentStore"):
|
|
72
79
|
OracleEmbeddingRetriever(document_store="not_a_store")
|
|
@@ -67,6 +67,13 @@ def test_to_dict_from_dict_roundtrip(mock_store):
|
|
|
67
67
|
assert restored.document_store.embedding_dim == 4
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
def test_close(mock_store):
|
|
71
|
+
retriever = OracleKeywordRetriever(document_store=mock_store)
|
|
72
|
+
retriever.close()
|
|
73
|
+
mock_store.close.assert_called_once()
|
|
74
|
+
assert retriever.document_store is mock_store
|
|
75
|
+
|
|
76
|
+
|
|
70
77
|
def test_invalid_document_store_raises_type_error():
|
|
71
78
|
with pytest.raises(TypeError, match="must be an instance of OracleDocumentStore"):
|
|
72
79
|
OracleKeywordRetriever(document_store="not_a_store")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{oracle_haystack-0.3.0 → oracle_haystack-0.4.0}/src/haystack_integrations/document_stores/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|