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.
Files changed (25) hide show
  1. oracle_haystack-0.5.0/CHANGELOG.md +35 -0
  2. oracle_haystack-0.5.0/LICENSE.txt +201 -0
  3. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/PKG-INFO +7 -2
  4. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/README.md +4 -0
  5. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/pyproject.toml +1 -0
  6. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/src/haystack_integrations/components/retrievers/oracle/embedding_retriever.py +6 -0
  7. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/src/haystack_integrations/components/retrievers/oracle/keyword_retriever.py +6 -0
  8. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/src/haystack_integrations/document_stores/oracle/document_store.py +79 -27
  9. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/tests/conftest.py +21 -16
  10. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/tests/test_document_store.py +288 -77
  11. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/tests/test_embedding_retriever.py +7 -0
  12. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/tests/test_keyword_retriever.py +7 -0
  13. oracle_haystack-0.3.0/CHANGELOG.md +0 -16
  14. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/.gitignore +0 -0
  15. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/docker-compose.yml +0 -0
  16. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/init/01_vector_memory.sql +0 -0
  17. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/pydoc/config_docusaurus.yml +0 -0
  18. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/src/haystack_integrations/components/retrievers/oracle/__init__.py +0 -0
  19. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/src/haystack_integrations/components/retrievers/py.typed +0 -0
  20. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/src/haystack_integrations/document_stores/oracle/__about__.py +0 -0
  21. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/src/haystack_integrations/document_stores/oracle/__init__.py +0 -0
  22. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/src/haystack_integrations/document_stores/oracle/filters.py +0 -0
  23. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/src/haystack_integrations/document_stores/py.typed +0 -0
  24. {oracle_haystack-0.3.0 → oracle_haystack-0.5.0}/tests/__init__.py +0 -0
  25. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: oracle-haystack
3
- Version: 0.3.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`.
@@ -138,6 +138,7 @@ ignore = [
138
138
  "PLR0912",
139
139
  "PLR0913",
140
140
  "PLR0915",
141
+ "PLR0917",
141
142
  # Allow `Any` - used legitimately for dynamic types and SDK boundaries
142
143
  "ANN401",
143
144
  # SQL is built from developer-supplied, regex-validated identifiers (table_name,
@@ -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 __del__(self) -> None:
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
- try:
237
+ with suppress(Exception):
220
238
  self._pool.close()
221
- except Exception:
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, metadata_field: str, search_term: str | None = None, from_: int = 0, size: int | None = None
770
- ) -> tuple[list[str], int]:
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 both the document text and the field value.
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 from ``from_`` onward
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 as strings and ``total`` is the overall distinct count (before pagination).
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
- base_sql = f"FROM {self.table_name} WHERE JSON_VALUE(metadata, '$.{field_path}') IS NOT NULL"
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 (text LIKE :search OR JSON_VALUE(metadata, '$.{field_path}') LIKE :search)"
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 JSON_VALUE(metadata, '$.{field_path}')) {base_sql}"
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 JSON_VALUE(metadata, '$.{field_path}') {base_sql} ORDER BY 1"
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
- return [str(r[0]) for r in rows], total
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, metadata_field: str, search_term: str | None = None, from_: int = 0, size: int | None = None
838
- ) -> tuple[list[str], int]:
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 both the document text and the field value.
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 from ``from_`` onward
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 as strings and ``total`` is the overall distinct count (before pagination).
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(self.get_metadata_field_unique_values, metadata_field, search_term, from_, size)
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
- def _make_store(table: str, embedding_dim: int) -> OracleDocumentStore:
20
- return OracleDocumentStore(
21
- connection_config=OracleConnectionConfig(
22
- user=Secret.from_token(_USER),
23
- password=Secret.from_token(_PASSWORD),
24
- dsn=Secret.from_token(_DSN),
25
- ),
26
- table_name=table,
27
- embedding_dim=embedding_dim,
28
- distance_metric="COSINE",
29
- create_table_if_not_exists=True,
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 = _make_store(table, embedding_dim=768)
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 = _make_store(table, embedding_dim=4)
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 OracleConnectionConfig, OracleDocumentStore
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
- @pytest.mark.integration
51
- class TestOracleDocumentStore(
52
- DocumentStoreBaseTests,
53
- CountDocumentsByFilterTest,
54
- CountUniqueMetadataByFilterTest,
55
- DeleteAllTest,
56
- DeleteByFilterTest,
57
- DeleteDocumentsTest,
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.fixture
69
- def document_store(self):
70
- """768-dim store — overrides the mixin's NotImplementedError stub."""
71
- table = f"hs_sync_{uuid.uuid4().hex[:8]}"
72
- s = OracleDocumentStore(
73
- connection_config=OracleConnectionConfig(
74
- user=Secret.from_token(_USER),
75
- password=Secret.from_token(_PASSWORD),
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 test_not_operator(self, document_store, filterable_docs): ...
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 test_create_hnsw_index_sql(self, patched_store, mock_pool):
224
- _, _, cursor = mock_pool
225
- patched_store.create_hnsw_index()
226
- sql = cursor.execute.call_args[0][0]
227
- assert "CREATE VECTOR INDEX" in sql
228
- assert "HNSW" in sql
229
- assert str(patched_store.hnsw_neighbors) in sql
230
- assert str(patched_store.hnsw_ef_construction) in sql
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
- def test_write_documents_empty_list_returns_zero(self, document_store):
233
- assert document_store.write_documents([]) == 0
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 -->