oracle-haystack 0.3.0__tar.gz → 0.5.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.5.0/CHANGELOG.md +35 -0
- oracle_haystack-0.5.0/LICENSE.txt +201 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/PKG-INFO +7 -2
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/README.md +4 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/pyproject.toml +1 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/src/haystack_integrations/components/retrievers/oracle/embedding_retriever.py +6 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/src/haystack_integrations/components/retrievers/oracle/keyword_retriever.py +6 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/src/haystack_integrations/document_stores/oracle/document_store.py +79 -27
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/tests/conftest.py +21 -16
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/tests/test_document_store.py +288 -77
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/tests/test_embedding_retriever.py +7 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/tests/test_keyword_retriever.py +7 -0
- oracle_haystack-0.3.0/CHANGELOG.md +0 -16
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/.gitignore +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/docker-compose.yml +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/init/01_vector_memory.sql +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/pydoc/config_docusaurus.yml +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/src/haystack_integrations/components/retrievers/oracle/__init__.py +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/src/haystack_integrations/components/retrievers/py.typed +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/src/haystack_integrations/document_stores/oracle/__about__.py +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/src/haystack_integrations/document_stores/oracle/__init__.py +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/src/haystack_integrations/document_stores/oracle/filters.py +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/src/haystack_integrations/document_stores/py.typed +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/tests/__init__.py +0 -0
- {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/tests/test_filter_translator.py +0 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [integrations/oracle-v0.4.0] - 2026-07-27
|
|
4
|
+
|
|
5
|
+
### 🚀 Features
|
|
6
|
+
|
|
7
|
+
- Oracle - make connection lazy, add closing methods and refactor tests (#3665)
|
|
8
|
+
|
|
9
|
+
### 🐛 Bug Fixes
|
|
10
|
+
|
|
11
|
+
- `Oracle` match `search_term` against metadata field value only, drop content match (#3645)
|
|
12
|
+
- Fix new issues raised by ruff 0.16.0 (#3670)
|
|
13
|
+
|
|
14
|
+
### 🧹 Chores
|
|
15
|
+
|
|
16
|
+
- Improve consistency of integrations folder structure (#3430)
|
|
17
|
+
|
|
18
|
+
### 🌀 Miscellaneous
|
|
19
|
+
|
|
20
|
+
- Validating meta fields to prevent SQL injection (#3511)
|
|
21
|
+
|
|
22
|
+
## [integrations/oracle-v0.2.0] - 2026-04-23
|
|
23
|
+
|
|
24
|
+
### 🚀 Features
|
|
25
|
+
|
|
26
|
+
- Complete `OracleDocumentStore` operations (#3179)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
## [integrations/oracle-v0.1.0] - 2026-04-16
|
|
30
|
+
|
|
31
|
+
### 🚀 Features
|
|
32
|
+
|
|
33
|
+
- Add Oracle AI Vector Search DocumentStore (oracle-haystack) (#3096)
|
|
34
|
+
|
|
35
|
+
<!-- generated by git-cliff -->
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2023-present deepset GmbH
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: oracle-haystack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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
|
|
7
7
|
Author-email: deepset GmbH <info@deepset.ai>
|
|
8
8
|
License-Expression: Apache-2.0
|
|
9
|
+
License-File: LICENSE.txt
|
|
9
10
|
Keywords: OCI,RAG,document store,haystack,oracle,vector search
|
|
10
11
|
Classifier: Development Status :: 3 - Alpha
|
|
11
12
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
@@ -32,6 +33,7 @@ Description-Content-Type: text/markdown
|
|
|
32
33
|
|
|
33
34
|
Haystack DocumentStore backed by [Oracle AI Vector Search](https://www.oracle.com/database/ai-vector-search/), available in Oracle Database 23ai and later.
|
|
34
35
|
|
|
36
|
+
- [Integration page](https://haystack.deepset.ai/integrations/oracle)
|
|
35
37
|
- [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/oracle/CHANGELOG.md)
|
|
36
38
|
|
|
37
39
|
---
|
|
@@ -39,3 +41,6 @@ Haystack DocumentStore backed by [Oracle AI Vector Search](https://www.oracle.co
|
|
|
39
41
|
## Contributing
|
|
40
42
|
|
|
41
43
|
Refer to the general [Contribution Guidelines](https://github.com/deepset-ai/haystack-core-integrations/blob/main/CONTRIBUTING.md).
|
|
44
|
+
|
|
45
|
+
To run integration tests locally, you need a Docker container running Oracle Database.
|
|
46
|
+
Use the provided `docker-compose.yml` file to start the container: `docker compose up -d`.
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
Haystack DocumentStore backed by [Oracle AI Vector Search](https://www.oracle.com/database/ai-vector-search/), available in Oracle Database 23ai and later.
|
|
7
7
|
|
|
8
|
+
- [Integration page](https://haystack.deepset.ai/integrations/oracle)
|
|
8
9
|
- [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/oracle/CHANGELOG.md)
|
|
9
10
|
|
|
10
11
|
---
|
|
@@ -12,3 +13,6 @@ Haystack DocumentStore backed by [Oracle AI Vector Search](https://www.oracle.co
|
|
|
12
13
|
## Contributing
|
|
13
14
|
|
|
14
15
|
Refer to the general [Contribution Guidelines](https://github.com/deepset-ai/haystack-core-integrations/blob/main/CONTRIBUTING.md).
|
|
16
|
+
|
|
17
|
+
To run integration tests locally, you need a Docker container running Oracle Database.
|
|
18
|
+
Use the provided `docker-compose.yml` file to start the container: `docker compose up -d`.
|
|
@@ -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.
|
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
import array as _array
|
|
6
6
|
import asyncio
|
|
7
|
+
import decimal
|
|
7
8
|
import json
|
|
8
9
|
import logging
|
|
9
10
|
import re
|
|
10
11
|
import threading
|
|
12
|
+
from contextlib import suppress
|
|
11
13
|
from dataclasses import dataclass
|
|
12
14
|
from typing import Any, Literal
|
|
13
15
|
|
|
@@ -177,11 +179,7 @@ class OracleDocumentStore:
|
|
|
177
179
|
|
|
178
180
|
self._pool: oracledb.ConnectionPool | None = None
|
|
179
181
|
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()
|
|
182
|
+
self._setup_done = False
|
|
185
183
|
|
|
186
184
|
def _get_pool(self) -> oracledb.ConnectionPool:
|
|
187
185
|
if self._pool is not None:
|
|
@@ -212,14 +210,33 @@ class OracleDocumentStore:
|
|
|
212
210
|
return self._pool
|
|
213
211
|
|
|
214
212
|
def _get_connection(self) -> oracledb.Connection:
|
|
213
|
+
self._ensure_setup()
|
|
215
214
|
return self._get_pool().acquire()
|
|
216
215
|
|
|
217
|
-
def
|
|
216
|
+
def _ensure_setup(self) -> None:
|
|
217
|
+
"""
|
|
218
|
+
Create the backing table and optional HNSW index once, on first use if needed.
|
|
219
|
+
"""
|
|
220
|
+
if self._setup_done:
|
|
221
|
+
return
|
|
222
|
+
self._setup_done = True
|
|
223
|
+
try:
|
|
224
|
+
if self.create_table_if_not_exists:
|
|
225
|
+
self._ensure_table()
|
|
226
|
+
if self.create_index:
|
|
227
|
+
self.create_hnsw_index()
|
|
228
|
+
except Exception:
|
|
229
|
+
self._setup_done = False
|
|
230
|
+
raise
|
|
231
|
+
|
|
232
|
+
def close(self) -> None:
|
|
233
|
+
"""
|
|
234
|
+
Release the associated synchronous resources.
|
|
235
|
+
"""
|
|
218
236
|
if self._pool is not None:
|
|
219
|
-
|
|
237
|
+
with suppress(Exception):
|
|
220
238
|
self._pool.close()
|
|
221
|
-
|
|
222
|
-
logger.warning("Failed to close Oracle connection pool during cleanup.", exc_info=True)
|
|
239
|
+
self._pool = None
|
|
223
240
|
|
|
224
241
|
def _ensure_table(self) -> None:
|
|
225
242
|
sql = f"""
|
|
@@ -766,35 +783,58 @@ class OracleDocumentStore:
|
|
|
766
783
|
return {"min": _try_parse_number(row[0]), "max": _try_parse_number(row[1])}
|
|
767
784
|
|
|
768
785
|
def get_metadata_field_unique_values(
|
|
769
|
-
self,
|
|
770
|
-
|
|
786
|
+
self,
|
|
787
|
+
metadata_field: str,
|
|
788
|
+
search_term: str | None = None,
|
|
789
|
+
from_: int = 0,
|
|
790
|
+
size: int | None = 10,
|
|
791
|
+
filters: dict[str, Any] | None = None,
|
|
792
|
+
) -> tuple[list[Any], int]:
|
|
771
793
|
"""
|
|
772
794
|
Return a paginated list of distinct values for a metadata field, plus the total distinct count.
|
|
773
795
|
|
|
796
|
+
**Note**: values of different JSON type categories are kept distinct - a string, a number and a
|
|
797
|
+
boolean never collapse into each other, even when they share a textual form (e.g. the string
|
|
798
|
+
`"1"` and the number `1`). One exception: the ``metadata`` column is Oracle's native ``JSON``
|
|
799
|
+
type, which canonicalizes numeric storage, so a whole-number float (`1.0`) and a numerically
|
|
800
|
+
equal int (`1`) collapse into the same value. Floats with a fractional part (e.g. `1.5`) are
|
|
801
|
+
unaffected.
|
|
802
|
+
|
|
774
803
|
:param metadata_field: Metadata field name. May be prefixed with ``"meta."``
|
|
775
804
|
(e.g. ``"meta.lang"`` or ``"lang"``).
|
|
776
|
-
:param search_term: Optional substring filter applied to
|
|
805
|
+
:param search_term: Optional case-insensitive substring filter applied to the metadata field's own value.
|
|
777
806
|
: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.
|
|
807
|
+
:param size: Maximum number of values to return. Defaults to ``10``. When ``None`` all values
|
|
808
|
+
from ``from_`` onward are returned.
|
|
809
|
+
:param filters: Optional filters to restrict the documents considered.
|
|
780
810
|
:returns: A tuple ``(values, total)`` where ``values`` is the paginated list of distinct field
|
|
781
|
-
values
|
|
811
|
+
values in their original type and ``total`` is the overall distinct count (before pagination).
|
|
782
812
|
:raises ValueError: If ``metadata_field`` contains characters outside ``[A-Za-z0-9_.]``.
|
|
783
813
|
"""
|
|
784
814
|
field_path = metadata_field[5:] if metadata_field.startswith("meta.") else metadata_field
|
|
785
815
|
_validate_field_path(field_path)
|
|
786
|
-
|
|
816
|
+
# JSON_VALUE dequotes strings and stringifies numbers/booleans, so a JSON string "1" and the
|
|
817
|
+
# number 1 both become the identical SQL text '1' - merging distinct values and, once re-parsed,
|
|
818
|
+
# corrupting a numeric-looking string into a number. JSON_QUERY on our native JSON column keeps
|
|
819
|
+
# the driver's own decoded Python value instead (str stays str, bool stays bool, numbers come
|
|
820
|
+
# back as Decimal - converted to int/float below), so a string is never confused with a number.
|
|
821
|
+
json_extract = f"JSON_QUERY(metadata, '$.{field_path}')"
|
|
822
|
+
base_sql = f"FROM {self.table_name} WHERE {json_extract} IS NOT NULL"
|
|
787
823
|
params: dict[str, Any] = {}
|
|
824
|
+
if filters:
|
|
825
|
+
counter = [0]
|
|
826
|
+
filters_fragment = FilterTranslator().translate(filters, params, counter)
|
|
827
|
+
base_sql += f" AND {filters_fragment}"
|
|
788
828
|
if search_term:
|
|
789
|
-
base_sql += f" AND (
|
|
829
|
+
base_sql += f" AND UPPER({json_extract}) LIKE UPPER(:search)"
|
|
790
830
|
params["search"] = f"%{search_term}%"
|
|
791
831
|
|
|
792
|
-
sql_count = f"SELECT COUNT(DISTINCT
|
|
832
|
+
sql_count = f"SELECT COUNT(DISTINCT {json_extract}) {base_sql}"
|
|
793
833
|
with self._get_connection() as conn, conn.cursor() as cur:
|
|
794
834
|
cur.execute(sql_count, params)
|
|
795
835
|
total = cur.fetchone()[0] or 0
|
|
796
836
|
|
|
797
|
-
sql_vals = f"SELECT DISTINCT
|
|
837
|
+
sql_vals = f"SELECT DISTINCT {json_extract} {base_sql} ORDER BY 1"
|
|
798
838
|
if size is not None:
|
|
799
839
|
sql_vals += " OFFSET :row_offset ROWS FETCH NEXT :row_limit ROWS ONLY"
|
|
800
840
|
params["row_offset"] = from_
|
|
@@ -804,7 +844,11 @@ class OracleDocumentStore:
|
|
|
804
844
|
params["row_offset"] = from_
|
|
805
845
|
cur.execute(sql_vals, params)
|
|
806
846
|
rows = cur.fetchall()
|
|
807
|
-
|
|
847
|
+
# oracledb decodes a JSON_QUERY result from our native JSON column into a Python value
|
|
848
|
+
# directly (str/bool stay as-is); JSON numbers decode as Decimal, so pick int vs float to
|
|
849
|
+
# match what write_documents() was originally given.
|
|
850
|
+
values: list[Any] = [_try_parse_number(r[0]) if isinstance(r[0], decimal.Decimal) else r[0] for r in rows]
|
|
851
|
+
return values, total
|
|
808
852
|
|
|
809
853
|
async def get_metadata_fields_info_async(self) -> dict[str, dict[str, str]]:
|
|
810
854
|
"""
|
|
@@ -834,22 +878,30 @@ class OracleDocumentStore:
|
|
|
834
878
|
return await asyncio.to_thread(self.get_metadata_field_min_max, metadata_field)
|
|
835
879
|
|
|
836
880
|
async def get_metadata_field_unique_values_async(
|
|
837
|
-
self,
|
|
838
|
-
|
|
881
|
+
self,
|
|
882
|
+
metadata_field: str,
|
|
883
|
+
search_term: str | None = None,
|
|
884
|
+
from_: int = 0,
|
|
885
|
+
size: int | None = 10,
|
|
886
|
+
filters: dict[str, Any] | None = None,
|
|
887
|
+
) -> tuple[list[Any], int]:
|
|
839
888
|
"""
|
|
840
889
|
Asynchronously returns a paginated list of distinct values for a metadata field, plus the total count.
|
|
841
890
|
|
|
842
891
|
:param metadata_field: Metadata field name. May be prefixed with ``"meta."``
|
|
843
892
|
(e.g. ``"meta.lang"`` or ``"lang"``).
|
|
844
|
-
:param search_term: Optional substring filter applied to
|
|
893
|
+
:param search_term: Optional case-insensitive substring filter applied to the metadata field's own value.
|
|
845
894
|
: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.
|
|
895
|
+
:param size: Maximum number of values to return. Defaults to ``10``. When ``None`` all values
|
|
896
|
+
from ``from_`` onward are returned.
|
|
897
|
+
:param filters: Optional filters to restrict the documents considered.
|
|
848
898
|
:returns: A tuple ``(values, total)`` where ``values`` is the paginated list of distinct field
|
|
849
|
-
values
|
|
899
|
+
values in their original type and ``total`` is the overall distinct count (before pagination).
|
|
850
900
|
:raises ValueError: If ``metadata_field`` contains characters outside ``[A-Za-z0-9_.]``.
|
|
851
901
|
"""
|
|
852
|
-
return await asyncio.to_thread(
|
|
902
|
+
return await asyncio.to_thread(
|
|
903
|
+
self.get_metadata_field_unique_values, metadata_field, search_term, from_, size, filters
|
|
904
|
+
)
|
|
853
905
|
|
|
854
906
|
def _embedding_retrieval(
|
|
855
907
|
self,
|
|
@@ -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")
|
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
#
|
|
3
3
|
# SPDX-License-Identifier: Apache-2.0
|
|
4
4
|
|
|
5
|
+
import decimal
|
|
5
6
|
import uuid
|
|
7
|
+
from unittest.mock import MagicMock
|
|
6
8
|
|
|
7
9
|
import oracledb as _oracledb
|
|
8
10
|
import pytest
|
|
9
11
|
from haystack.dataclasses import Document
|
|
10
|
-
from haystack.document_stores.errors import DuplicateDocumentError
|
|
12
|
+
from haystack.document_stores.errors import DocumentStoreError, DuplicateDocumentError
|
|
11
13
|
from haystack.document_stores.types import DuplicatePolicy
|
|
12
14
|
from haystack.testing.document_store import (
|
|
13
15
|
CountDocumentsByFilterTest,
|
|
@@ -25,15 +27,11 @@ from haystack.testing.document_store_async import (
|
|
|
25
27
|
CountDocumentsByFilterAsyncTest,
|
|
26
28
|
CountUniqueMetadataByFilterAsyncTest,
|
|
27
29
|
FilterableDocsFixtureMixin,
|
|
30
|
+
GetMetadataFieldUniqueValuesAsyncTest,
|
|
28
31
|
UpdateByFilterAsyncTest,
|
|
29
32
|
)
|
|
30
|
-
from haystack.utils import Secret
|
|
31
33
|
|
|
32
|
-
from haystack_integrations.document_stores.oracle import
|
|
33
|
-
|
|
34
|
-
_USER = "haystack"
|
|
35
|
-
_PASSWORD = "haystack"
|
|
36
|
-
_DSN = "localhost:1521/freepdb1"
|
|
34
|
+
from haystack_integrations.document_stores.oracle import OracleDocumentStore
|
|
37
35
|
|
|
38
36
|
|
|
39
37
|
def _doc(doc_id: str, content: str = "hello", meta: dict | None = None, embedding: list[float] | None = None):
|
|
@@ -47,72 +45,54 @@ def _uid(suffix: str = "") -> str:
|
|
|
47
45
|
return f"{base}{suffix.upper():>4}"[:32]
|
|
48
46
|
|
|
49
47
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
GetMetadataFieldMinMaxTest,
|
|
59
|
-
GetMetadataFieldsInfoTest,
|
|
60
|
-
GetMetadataFieldUniqueValuesTest,
|
|
61
|
-
UpdateByFilterTest,
|
|
62
|
-
):
|
|
48
|
+
def _lob(value: str) -> MagicMock:
|
|
49
|
+
"""Mock for an oracledb Large Object (LOB)"""
|
|
50
|
+
lob = MagicMock()
|
|
51
|
+
lob.read.return_value = value
|
|
52
|
+
return lob
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TestOracleDocumentStoreUnit:
|
|
63
56
|
@staticmethod
|
|
64
57
|
def _mock_doc(content="hello", embedding=None, doc_id="AABB" * 8):
|
|
65
58
|
"""Lightweight document builder for mock-based tests."""
|
|
66
59
|
return Document(id=doc_id, content=content, meta={"k": "v"}, embedding=embedding)
|
|
67
60
|
|
|
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"
|
|
61
|
+
@pytest.mark.parametrize(
|
|
62
|
+
("table_name", "embedding_dim", "match"),
|
|
63
|
+
[
|
|
64
|
+
pytest.param("1invalid", 4, "Invalid table_name", id="table_name_starts_with_digit"),
|
|
65
|
+
pytest.param("bad-name", 4, "Invalid table_name", id="table_name_illegal_char"),
|
|
66
|
+
pytest.param("valid", 0, "embedding_dim must be a positive integer", id="embedding_dim_zero"),
|
|
67
|
+
pytest.param("valid", -1, "embedding_dim must be a positive integer", id="embedding_dim_negative"),
|
|
68
|
+
],
|
|
114
69
|
)
|
|
115
|
-
def
|
|
70
|
+
def test_init_rejects_invalid_arguments(self, make_store, table_name, embedding_dim, match):
|
|
71
|
+
with pytest.raises(ValueError, match=match):
|
|
72
|
+
make_store(table_name, embedding_dim)
|
|
73
|
+
|
|
74
|
+
def test_init_opens_no_connection(self, make_store, mock_pool): # noqa: ARG002
|
|
75
|
+
store = make_store("lazy_docs")
|
|
76
|
+
assert store._pool is None
|
|
77
|
+
assert store._setup_done is False
|
|
78
|
+
|
|
79
|
+
def test_setup_is_deferred_and_runs_once(self, make_store, mock_pool):
|
|
80
|
+
store = make_store("lazy_docs")
|
|
81
|
+
store.count_documents()
|
|
82
|
+
store.count_documents()
|
|
83
|
+
assert store._pool is not None
|
|
84
|
+
assert store._setup_done is True
|
|
85
|
+
_, _, cursor = mock_pool
|
|
86
|
+
create_calls = [c for c in cursor.execute.call_args_list if "CREATE TABLE IF NOT EXISTS" in c[0][0]]
|
|
87
|
+
assert len(create_calls) == 1
|
|
88
|
+
|
|
89
|
+
def test_setup_failure_resets_flag(self, make_store, mock_pool):
|
|
90
|
+
_, _, cursor = mock_pool
|
|
91
|
+
cursor.execute.side_effect = RuntimeError("boom")
|
|
92
|
+
store = make_store("lazy_docs")
|
|
93
|
+
with pytest.raises(RuntimeError):
|
|
94
|
+
store.count_documents()
|
|
95
|
+
assert store._setup_done is False
|
|
116
96
|
|
|
117
97
|
def test_write_documents_none_policy_calls_insert(self, patched_store, mock_pool):
|
|
118
98
|
_, _, cursor = mock_pool
|
|
@@ -207,6 +187,92 @@ class TestOracleDocumentStore(
|
|
|
207
187
|
cursor.fetchone.return_value = (42,)
|
|
208
188
|
assert patched_store.count_documents() == 42
|
|
209
189
|
|
|
190
|
+
def test_get_metadata_field_unique_values_search_term_filters_value_only(self, patched_store, mock_pool):
|
|
191
|
+
"""search_term must filter on the metadata field's own JSON value, not on document text."""
|
|
192
|
+
_, _, cursor = mock_pool
|
|
193
|
+
cursor.fetchone.return_value = (1,)
|
|
194
|
+
cursor.fetchall.return_value = [("bar",)]
|
|
195
|
+
|
|
196
|
+
patched_store.get_metadata_field_unique_values("category", search_term="bar")
|
|
197
|
+
|
|
198
|
+
count_sql, count_params = cursor.execute.call_args_list[0][0]
|
|
199
|
+
vals_sql, vals_params = cursor.execute.call_args_list[1][0]
|
|
200
|
+
|
|
201
|
+
for sql in (count_sql, vals_sql):
|
|
202
|
+
assert "UPPER(JSON_QUERY(metadata, '$.category')) LIKE UPPER(:search)" in sql
|
|
203
|
+
|
|
204
|
+
assert count_params["search"] == "%bar%"
|
|
205
|
+
assert vals_params["search"] == "%bar%"
|
|
206
|
+
|
|
207
|
+
def test_get_metadata_field_unique_values_parses_json_scalar_types(self, patched_store, mock_pool):
|
|
208
|
+
"""oracledb decodes a JSON_QUERY result from our native JSON column directly into a Python
|
|
209
|
+
value - str and bool need no conversion, but a JSON number decodes as Decimal and must be
|
|
210
|
+
converted to int or float to match what write_documents() was originally given."""
|
|
211
|
+
_, _, cursor = mock_pool
|
|
212
|
+
cursor.fetchone.return_value = (3,)
|
|
213
|
+
cursor.fetchall.return_value = [(decimal.Decimal(1),), (True,), ("bar",)]
|
|
214
|
+
|
|
215
|
+
values, total = patched_store.get_metadata_field_unique_values("priority")
|
|
216
|
+
|
|
217
|
+
assert values == [1, True, "bar"]
|
|
218
|
+
assert all(type(v) in (int, bool, str) for v in values)
|
|
219
|
+
assert total == 3
|
|
220
|
+
|
|
221
|
+
def test_delete_table_executes_drop_and_index_sql(self, patched_store, mock_pool):
|
|
222
|
+
_, _, cursor = mock_pool
|
|
223
|
+
patched_store.delete_table()
|
|
224
|
+
executed = [c[0][0] for c in cursor.execute.call_args_list]
|
|
225
|
+
assert any("DROP TABLE" in sql and "PURGE" in sql for sql in executed)
|
|
226
|
+
assert any("DBMS_SEARCH.DROP_INDEX" in sql for sql in executed)
|
|
227
|
+
|
|
228
|
+
def test_keyword_retrieval_builds_contains_query(self, patched_store, mock_pool):
|
|
229
|
+
_, _, cursor = mock_pool
|
|
230
|
+
cursor.fetchall.return_value = [("AABB" * 8, "hello", '{"k": "v"}', 1.5)]
|
|
231
|
+
docs = patched_store._keyword_retrieval("hello world", top_k=5)
|
|
232
|
+
sql, params = cursor.execute.call_args[0]
|
|
233
|
+
assert "CONTAINS(DATA, :query, 1)" in sql
|
|
234
|
+
assert "SCORE(1)" in sql
|
|
235
|
+
assert params["query"] == "hello world"
|
|
236
|
+
assert params["top_k"] == 5
|
|
237
|
+
assert docs[0].score == 1.5
|
|
238
|
+
|
|
239
|
+
@pytest.mark.parametrize(
|
|
240
|
+
"operation",
|
|
241
|
+
[
|
|
242
|
+
pytest.param(lambda s: s._embedding_retrieval([0.1, 0.2, 0.3, 0.4]), id="embedding_retrieval"),
|
|
243
|
+
pytest.param(lambda s: s._keyword_retrieval("query"), id="keyword_retrieval"),
|
|
244
|
+
pytest.param(lambda s: s.delete_table(), id="delete_table"),
|
|
245
|
+
],
|
|
246
|
+
)
|
|
247
|
+
def test_query_methods_wrap_database_errors(self, patched_store, mock_pool, operation):
|
|
248
|
+
_, _, cursor = mock_pool
|
|
249
|
+
cursor.execute.side_effect = _oracledb.DatabaseError("ORA-00942")
|
|
250
|
+
with pytest.raises(DocumentStoreError):
|
|
251
|
+
operation(patched_store)
|
|
252
|
+
|
|
253
|
+
@pytest.mark.parametrize(
|
|
254
|
+
("text_raw", "metadata_raw", "expected_content", "expected_meta"),
|
|
255
|
+
[
|
|
256
|
+
pytest.param("plain", '{"k": "v"}', "plain", {"k": "v"}, id="str_text_and_json_metadata"),
|
|
257
|
+
pytest.param(_lob("from_lob"), _lob('{"k": "v"}'), "from_lob", {"k": "v"}, id="lob_text_and_metadata"),
|
|
258
|
+
pytest.param("plain", {"k": "v"}, "plain", {"k": "v"}, id="dict_metadata"),
|
|
259
|
+
pytest.param("plain", None, "plain", {}, id="unsupported_metadata_type"),
|
|
260
|
+
],
|
|
261
|
+
)
|
|
262
|
+
def test_row_to_document_reads_lob_and_maps_metadata(self, text_raw, metadata_raw, expected_content, expected_meta):
|
|
263
|
+
doc = OracleDocumentStore._row_to_document(("id1", text_raw, metadata_raw))
|
|
264
|
+
assert doc.content == expected_content
|
|
265
|
+
assert doc.meta == expected_meta
|
|
266
|
+
|
|
267
|
+
def test_create_hnsw_index_sql(self, patched_store, mock_pool):
|
|
268
|
+
_, _, cursor = mock_pool
|
|
269
|
+
patched_store.create_hnsw_index()
|
|
270
|
+
sql = cursor.execute.call_args[0][0]
|
|
271
|
+
assert "CREATE VECTOR INDEX" in sql
|
|
272
|
+
assert "HNSW" in sql
|
|
273
|
+
assert str(patched_store.hnsw_neighbors) in sql
|
|
274
|
+
assert str(patched_store.hnsw_ef_construction) in sql
|
|
275
|
+
|
|
210
276
|
def test_to_dict_does_not_expose_plain_password(self, patched_store):
|
|
211
277
|
d = patched_store.to_dict()
|
|
212
278
|
pw = d["init_parameters"]["connection_config"]["password"]
|
|
@@ -220,17 +286,109 @@ class TestOracleDocumentStore(
|
|
|
220
286
|
assert restored.embedding_dim == patched_store.embedding_dim
|
|
221
287
|
assert restored.distance_metric == patched_store.distance_metric
|
|
222
288
|
|
|
223
|
-
def
|
|
224
|
-
|
|
225
|
-
patched_store.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
assert
|
|
229
|
-
|
|
230
|
-
|
|
289
|
+
def test_close(self, patched_store):
|
|
290
|
+
pool = MagicMock()
|
|
291
|
+
patched_store._pool = pool
|
|
292
|
+
patched_store.close()
|
|
293
|
+
pool.close.assert_called_once()
|
|
294
|
+
assert patched_store._pool is None
|
|
295
|
+
patched_store.close()
|
|
296
|
+
pool.close.assert_called_once()
|
|
297
|
+
|
|
298
|
+
def test_close_is_exception_safe(self, patched_store):
|
|
299
|
+
pool = MagicMock()
|
|
300
|
+
pool.close.side_effect = RuntimeError("boom")
|
|
301
|
+
patched_store._pool = pool
|
|
302
|
+
patched_store.close()
|
|
303
|
+
assert patched_store._pool is None
|
|
231
304
|
|
|
232
|
-
|
|
233
|
-
|
|
305
|
+
|
|
306
|
+
@pytest.mark.integration
|
|
307
|
+
class TestOracleDocumentStore(
|
|
308
|
+
DocumentStoreBaseTests,
|
|
309
|
+
CountDocumentsByFilterTest,
|
|
310
|
+
CountUniqueMetadataByFilterTest,
|
|
311
|
+
DeleteAllTest,
|
|
312
|
+
DeleteByFilterTest,
|
|
313
|
+
DeleteDocumentsTest,
|
|
314
|
+
GetMetadataFieldMinMaxTest,
|
|
315
|
+
GetMetadataFieldsInfoTest,
|
|
316
|
+
GetMetadataFieldUniqueValuesTest,
|
|
317
|
+
UpdateByFilterTest,
|
|
318
|
+
):
|
|
319
|
+
@pytest.fixture
|
|
320
|
+
def document_store(self, make_store):
|
|
321
|
+
"""768-dim store — overrides the mixin's NotImplementedError stub."""
|
|
322
|
+
table = f"hs_sync_{uuid.uuid4().hex[:8]}"
|
|
323
|
+
s = make_store(table, 768)
|
|
324
|
+
yield s
|
|
325
|
+
with s._get_connection() as conn, conn.cursor() as cur:
|
|
326
|
+
cur.execute(f"DROP TABLE {table} PURGE")
|
|
327
|
+
conn.commit()
|
|
328
|
+
|
|
329
|
+
# Mixin override
|
|
330
|
+
def assert_documents_are_equal(self, received: list[Document], expected: list[Document]) -> None:
|
|
331
|
+
# filter_documents does not SELECT the embedding column — ignore it when comparing
|
|
332
|
+
assert len(received) == len(expected)
|
|
333
|
+
received_sorted = sorted(received, key=lambda d: d.id)
|
|
334
|
+
expected_sorted = sorted(expected, key=lambda d: d.id)
|
|
335
|
+
for r, e in zip(received_sorted, expected_sorted, strict=True):
|
|
336
|
+
assert r.id == e.id
|
|
337
|
+
assert r.content == e.content
|
|
338
|
+
assert r.meta == e.meta
|
|
339
|
+
|
|
340
|
+
# Mixin override
|
|
341
|
+
def test_write_documents(self, document_store: OracleDocumentStore) -> None:
|
|
342
|
+
# Default policy is NONE — a second write of the same doc raises DuplicateDocumentError
|
|
343
|
+
doc = Document(content="test doc")
|
|
344
|
+
assert document_store.write_documents([doc]) == 1
|
|
345
|
+
with pytest.raises(DuplicateDocumentError):
|
|
346
|
+
document_store.write_documents([doc])
|
|
347
|
+
self.assert_documents_are_equal(document_store.filter_documents(), [doc])
|
|
348
|
+
|
|
349
|
+
def test_get_metadata_field_unique_values_distinct_types(self, document_store: OracleDocumentStore):
|
|
350
|
+
"""
|
|
351
|
+
Override: the base mixin test stores int, float, str and bool under the *same* metadata field
|
|
352
|
+
name and expects all four back as distinct values. The ``metadata`` column is Oracle's native
|
|
353
|
+
``JSON`` type, which canonicalizes numeric storage, so a whole-number float (e.g. 1.0) and a
|
|
354
|
+
numerically equal int (1) collapse into the same value regardless of which other values share
|
|
355
|
+
that field.
|
|
356
|
+
|
|
357
|
+
This adapts the same intent - int, float, str and bool must come back as distinct, unmangled
|
|
358
|
+
types via get_metadata_field_unique_values() - using one field per type instead of one shared
|
|
359
|
+
field, which is what Oracle can actually support.
|
|
360
|
+
|
|
361
|
+
The float value is a non-whole number (1.5, not 1.0): a whole-number float would still collapse
|
|
362
|
+
with an int under Oracle's numeric canonicalization even in its own field, so a fractional value
|
|
363
|
+
is used to sidestep that ambiguity entirely.
|
|
364
|
+
"""
|
|
365
|
+
docs = [
|
|
366
|
+
Document(content="Doc 1", meta={"priority_int": 1}),
|
|
367
|
+
Document(content="Doc 2", meta={"priority_str": "1"}),
|
|
368
|
+
Document(content="Doc 3", meta={"priority_float": 1.5}),
|
|
369
|
+
Document(content="Doc 4", meta={"priority_bool": True}),
|
|
370
|
+
]
|
|
371
|
+
document_store.write_documents(docs)
|
|
372
|
+
|
|
373
|
+
int_values, int_count = document_store.get_metadata_field_unique_values(metadata_field="priority_int")
|
|
374
|
+
str_values, str_count = document_store.get_metadata_field_unique_values(metadata_field="priority_str")
|
|
375
|
+
float_values, float_count = document_store.get_metadata_field_unique_values(metadata_field="priority_float")
|
|
376
|
+
bool_values, bool_count = document_store.get_metadata_field_unique_values(metadata_field="priority_bool")
|
|
377
|
+
|
|
378
|
+
assert (int_count, str_count, float_count, bool_count) == (1, 1, 1, 1)
|
|
379
|
+
assert int_values == [1] and type(int_values[0]) is int
|
|
380
|
+
assert str_values == ["1"] and type(str_values[0]) is str
|
|
381
|
+
assert float_values == [1.5] and type(float_values[0]) is float
|
|
382
|
+
assert bool_values == [True] and type(bool_values[0]) is bool
|
|
383
|
+
|
|
384
|
+
# test_comparison_equal_with_none → IS NULL
|
|
385
|
+
# test_comparison_not_equal_with_none → IS NOT NULL
|
|
386
|
+
# test_comparison_not_equal → col != x OR IS NULL
|
|
387
|
+
# test_comparison_not_in → IS NULL OR NOT IN
|
|
388
|
+
@pytest.mark.skip(
|
|
389
|
+
reason="Oracle NULL propagation in NOT(...) cannot match Python 'not (None == x) is True' semantics"
|
|
390
|
+
)
|
|
391
|
+
def test_not_operator(self, document_store, filterable_docs): ...
|
|
234
392
|
|
|
235
393
|
def test_filter_documents_not_operator(self, document_store):
|
|
236
394
|
# Scoped to two fresh docs — no NULL-valued rows, so NOT works correctly.
|
|
@@ -311,16 +469,69 @@ class TestOracleDocumentStore(
|
|
|
311
469
|
"""Calling _ensure_table() a second time must not raise."""
|
|
312
470
|
document_store._ensure_table()
|
|
313
471
|
|
|
472
|
+
def test_close_and_reopen(self, document_store):
|
|
473
|
+
document_store.count_documents()
|
|
474
|
+
assert document_store._pool is not None
|
|
475
|
+
document_store.close()
|
|
476
|
+
assert document_store._pool is None
|
|
477
|
+
assert document_store.count_documents() == 0
|
|
478
|
+
assert document_store._pool is not None
|
|
479
|
+
|
|
314
480
|
|
|
315
481
|
@pytest.mark.integration
|
|
316
482
|
class TestOracleDocumentStoreAsync(
|
|
317
483
|
FilterableDocsFixtureMixin,
|
|
318
484
|
CountDocumentsByFilterAsyncTest,
|
|
319
485
|
CountUniqueMetadataByFilterAsyncTest,
|
|
486
|
+
GetMetadataFieldUniqueValuesAsyncTest,
|
|
320
487
|
UpdateByFilterAsyncTest,
|
|
321
488
|
):
|
|
322
489
|
"""Async API surface tests."""
|
|
323
490
|
|
|
491
|
+
@pytest.mark.asyncio
|
|
492
|
+
async def test_get_metadata_field_unique_values_distinct_types_async(self, document_store: OracleDocumentStore):
|
|
493
|
+
"""
|
|
494
|
+
Override: the base mixin test stores int, float, str and bool under the *same* metadata field
|
|
495
|
+
name and expects all four back as distinct values. The ``metadata`` column is Oracle's native
|
|
496
|
+
``JSON`` type, which canonicalizes numeric storage, so a whole-number float (e.g. 1.0) and a
|
|
497
|
+
numerically equal int (1) collapse into the same value regardless of which other values share
|
|
498
|
+
that field.
|
|
499
|
+
|
|
500
|
+
This adapts the same intent - int, float, str and bool must come back as distinct, unmangled
|
|
501
|
+
types via get_metadata_field_unique_values_async() - using one field per type instead of one
|
|
502
|
+
shared field, which is what Oracle can actually support.
|
|
503
|
+
|
|
504
|
+
The float value is a non-whole number (1.5, not 1.0): a whole-number float would still collapse
|
|
505
|
+
with an int under Oracle's numeric canonicalization even in its own field, so a fractional value
|
|
506
|
+
is used to sidestep that ambiguity entirely.
|
|
507
|
+
"""
|
|
508
|
+
docs = [
|
|
509
|
+
Document(content="Doc 1", meta={"priority_int": 1}),
|
|
510
|
+
Document(content="Doc 2", meta={"priority_str": "1"}),
|
|
511
|
+
Document(content="Doc 3", meta={"priority_float": 1.5}),
|
|
512
|
+
Document(content="Doc 4", meta={"priority_bool": True}),
|
|
513
|
+
]
|
|
514
|
+
await document_store.write_documents_async(docs)
|
|
515
|
+
|
|
516
|
+
int_values, int_count = await document_store.get_metadata_field_unique_values_async(
|
|
517
|
+
metadata_field="priority_int"
|
|
518
|
+
)
|
|
519
|
+
str_values, str_count = await document_store.get_metadata_field_unique_values_async(
|
|
520
|
+
metadata_field="priority_str"
|
|
521
|
+
)
|
|
522
|
+
float_values, float_count = await document_store.get_metadata_field_unique_values_async(
|
|
523
|
+
metadata_field="priority_float"
|
|
524
|
+
)
|
|
525
|
+
bool_values, bool_count = await document_store.get_metadata_field_unique_values_async(
|
|
526
|
+
metadata_field="priority_bool"
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
assert (int_count, str_count, float_count, bool_count) == (1, 1, 1, 1)
|
|
530
|
+
assert int_values == [1] and type(int_values[0]) is int
|
|
531
|
+
assert str_values == ["1"] and type(str_values[0]) is str
|
|
532
|
+
assert float_values == [1.5] and type(float_values[0]) is float
|
|
533
|
+
assert bool_values == [True] and type(bool_values[0]) is bool
|
|
534
|
+
|
|
324
535
|
@pytest.mark.asyncio
|
|
325
536
|
async def test_async_write_and_count(self, embedding_store):
|
|
326
537
|
doc = _doc(_uid("M001"))
|
|
@@ -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")
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## [integrations/oracle-v0.2.0] - 2026-04-23
|
|
4
|
-
|
|
5
|
-
### 🚀 Features
|
|
6
|
-
|
|
7
|
-
- Complete `OracleDocumentStore` operations (#3179)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
## [integrations/oracle-v0.1.0] - 2026-04-16
|
|
11
|
-
|
|
12
|
-
### 🚀 Features
|
|
13
|
-
|
|
14
|
-
- Add Oracle AI Vector Search DocumentStore (oracle-haystack) (#3096)
|
|
15
|
-
|
|
16
|
-
<!-- generated by git-cliff -->
|
|
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.5.0}/src/haystack_integrations/document_stores/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|