pgvector-haystack 6.5.0__tar.gz → 6.6.1__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 (27) hide show
  1. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/CHANGELOG.md +81 -16
  2. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/LICENSE.txt +1 -1
  3. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/PKG-INFO +4 -2
  4. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/README.md +2 -0
  5. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/src/haystack_integrations/document_stores/pgvector/document_store.py +57 -9
  6. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/src/haystack_integrations/document_stores/pgvector/filters.py +4 -0
  7. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/tests/test_document_store.py +57 -150
  8. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/tests/test_document_store_async.py +57 -16
  9. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/tests/test_filters.py +18 -0
  10. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/.gitignore +0 -0
  11. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/examples/embedding_retrieval.py +0 -0
  12. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/examples/hybrid_retrieval.py +0 -0
  13. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/pydoc/config_docusaurus.yml +0 -0
  14. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/pyproject.toml +0 -0
  15. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/src/haystack_integrations/components/retrievers/pgvector/__init__.py +0 -0
  16. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/src/haystack_integrations/components/retrievers/pgvector/embedding_retriever.py +0 -0
  17. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/src/haystack_integrations/components/retrievers/pgvector/keyword_retriever.py +0 -0
  18. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/src/haystack_integrations/components/retrievers/py.typed +0 -0
  19. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/src/haystack_integrations/document_stores/pgvector/__init__.py +0 -0
  20. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/src/haystack_integrations/document_stores/pgvector/converters.py +0 -0
  21. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/src/haystack_integrations/document_stores/py.typed +0 -0
  22. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/tests/__init__.py +0 -0
  23. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/tests/conftest.py +0 -0
  24. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/tests/test_converters.py +0 -0
  25. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/tests/test_retrieval.py +0 -0
  26. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/tests/test_retrieval_async.py +0 -0
  27. {pgvector_haystack-6.5.0 → pgvector_haystack-6.6.1}/tests/test_retrievers.py +0 -0
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [integrations/pgvector-v6.6.0] - 2026-08-26
4
+
5
+ ### 🐛 Bug Fixes
6
+
7
+ - PGVector adding optional parameter `filters` to `get_metadata_field_unique_values()` (#3735)
8
+ - Standardize license files (#3771)
9
+
10
+ ### 🧪 Testing
11
+
12
+ - `PGVectorDocumentStore` removing duplicated tests already covered by haystack Mixin tests (#3783)
13
+
14
+ ### 🧹 Chores
15
+
16
+ - Re-standardize READMEs (#3768)
17
+
18
+
19
+ ## [integrations/pgvector-v6.5.0] - 2026-08-03
20
+
21
+ ### 🚀 Features
22
+
23
+ - Add array_contains and array_overlaps operators to pgvector filters (#3459)
24
+
25
+ ### 🐛 Bug Fixes
26
+
27
+ - Fix new issues raised by ruff 0.16.0 (#3670)
28
+ - `PGVectorDocumentStore` get_metadata_field_unique_values setting list of values to return to `Any` (#3709)
29
+
30
+
3
31
  ## [integrations/pgvector-v6.4.1] - 2026-07-23
4
32
 
5
33
  ### 🐛 Bug Fixes
@@ -135,9 +163,14 @@
135
163
 
136
164
  ### 🧹 Chores
137
165
 
138
- - Remove black (#1985)
139
166
  - Pgvector - update docker image in examples (#1995)
140
167
 
168
+ ## [integrations/pgvector-v5.2.0] - 2025-06-26
169
+
170
+ ### 🧹 Chores
171
+
172
+ - Remove black (#1985)
173
+
141
174
 
142
175
  ## [integrations/pgvector-v3.4.1] - 2025-06-20
143
176
 
@@ -241,48 +274,75 @@
241
274
 
242
275
  ### 🚀 Features
243
276
 
244
- - Add filter_policy to pgvector integration (#820)
245
277
  - Add schema support to pgvector document store. (#1095)
246
278
  - Pgvector - recreate the connection if it is no longer valid (#1202)
247
279
 
248
- ### 🐛 Bug Fixes
249
-
250
- - `PgVector` - Fallback to default filter policy when deserializing retrievers without the init parameter (#900)
251
-
252
280
  ### 📚 Documentation
253
281
 
254
282
  - Explain different connection string formats in the docstring (#1132)
255
283
 
284
+ ### ⚙️ CI
285
+
286
+ - Adopt uv as installer (#1142)
287
+
288
+ ### 🧹 Chores
289
+
290
+ - Update changelog after removing legacy filters (#1083)
291
+ - Update ruff linting scripts and settings (#1105)
292
+
293
+ ## [integrations/pgvector-v1.0.0] - 2024-09-12
294
+
256
295
  ### 🧪 Testing
257
296
 
258
297
  - Do not retry tests in `hatch run test` command (#954)
259
298
 
299
+ ### 🧹 Chores
300
+
301
+ - PgVector - remove legacy filter support (#1068)
302
+
303
+ ### 🌀 Miscellaneous
304
+
305
+ - Chore: Update pgvector test for the new `apply_filter_policy` usage (#970)
306
+ - Chore: pgvector ruff update, don't ruff tests (#984)
307
+
308
+ ## [integrations/pgvector-v0.5.1] - 2024-07-17
309
+
310
+ ### 🐛 Bug Fixes
311
+
312
+ - `PgVector` - Fallback to default filter policy when deserializing retrievers without the init parameter (#900)
313
+
314
+ ## [integrations/pgvector-v0.5.0] - 2024-07-15
315
+
316
+ ### 🚀 Features
317
+
318
+ - Add filter_policy to pgvector integration (#820)
319
+
260
320
  ### ⚙️ CI
261
321
 
262
322
  - Retry tests to reduce flakyness (#836)
263
- - Adopt uv as installer (#1142)
264
323
 
265
324
  ### 🧹 Chores
266
325
 
267
326
  - Update ruff invocation to include check parameter (#853)
268
- - PgVector - remove legacy filter support (#1068)
269
- - Update changelog after removing legacy filters (#1083)
270
- - Update ruff linting scripts and settings (#1105)
271
327
 
272
328
  ### 🌀 Miscellaneous
273
329
 
274
330
  - Ci: install `pytest-rerunfailures` where needed; add retry config to `test-cov` script (#845)
275
331
  - Chore: Minor retriever pydoc fix (#884)
276
- - Chore: Update pgvector test for the new `apply_filter_policy` usage (#970)
277
- - Chore: pgvector ruff update, don't ruff tests (#984)
278
332
 
279
333
  ## [integrations/pgvector-v0.4.0] - 2024-06-20
280
334
 
281
335
  ### 🚀 Features
282
336
 
283
- - Defer the database connection to when it's needed (#773)
284
337
  - Add customizable index names for pgvector (#818)
285
338
 
339
+
340
+ ## [integrations/pgvector-v0.3.0] - 2024-05-30
341
+
342
+ ### 🚀 Features
343
+
344
+ - Defer the database connection to when it's needed (#773)
345
+
286
346
  ### 🌀 Miscellaneous
287
347
 
288
348
  - Docs: add missing api references (#728)
@@ -316,6 +376,14 @@
316
376
 
317
377
  ## [integrations/pgvector-v0.1.0] - 2024-02-14
318
378
 
379
+ ### 🌀 Miscellaneous
380
+
381
+ - Pgvector: generate API docs (#325)
382
+ - Pgvector: add an example (#334)
383
+ - Adopt `Secret` to pgvector (#402)
384
+
385
+ ## [integrations/pgvector-v0.0.1] - 2024-02-02
386
+
319
387
  ### 🐛 Bug Fixes
320
388
 
321
389
  - Pgvector: fix linting (#328)
@@ -326,8 +394,5 @@
326
394
  - Pgvector - filters (#257)
327
395
  - Pgvector - embedding retrieval (#298)
328
396
  - Pgvector - Embedding Retriever (#320)
329
- - Pgvector: generate API docs (#325)
330
- - Pgvector: add an example (#334)
331
- - Adopt `Secret` to pgvector (#402)
332
397
 
333
398
  <!-- generated by git-cliff -->
@@ -58,7 +58,7 @@ APPENDIX: How to apply the Apache License to your work.
58
58
 
59
59
  To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
60
60
 
61
- Copyright [yyyy] [name of copyright owner]
61
+ Copyright 2023-present deepset GmbH
62
62
 
63
63
  Licensed under the Apache License, Version 2.0 (the "License");
64
64
  you may not use this file except in compliance with the License.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: pgvector-haystack
3
- Version: 6.5.0
3
+ Version: 6.6.1
4
4
  Summary: An integration of pgvector (vector search extension for Postgres) with Haystack
5
5
  Project-URL: Source, https://github.com/deepset-ai/haystack-core-integrations
6
6
  Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/pgvector/README.md
@@ -34,6 +34,8 @@ Description-Content-Type: text/markdown
34
34
 
35
35
  ---
36
36
 
37
+ ## Contributing
38
+
37
39
  Refer to the general [Contribution Guidelines](https://github.com/deepset-ai/haystack-core-integrations/blob/main/CONTRIBUTING.md).
38
40
 
39
41
  To run integration tests locally, you need to have a PostgreSQL DB running with the `pgvector` extension.
@@ -8,6 +8,8 @@
8
8
 
9
9
  ---
10
10
 
11
+ ## Contributing
12
+
11
13
  Refer to the general [Contribution Guidelines](https://github.com/deepset-ai/haystack-core-integrations/blob/main/CONTRIBUTING.md).
12
14
 
13
15
  To run integration tests locally, you need to have a PostgreSQL DB running with the `pgvector` extension.
@@ -923,7 +923,7 @@ class PgvectorDocumentStore:
923
923
  )
924
924
  raise DocumentStoreError(error_msg) from e
925
925
 
926
- # get the number of the inserted documents, inspired by psycopg3 docs
926
+ # executemany(returning=True) yields one result set per document, so walk them all with nextset()
927
927
  # https://www.psycopg.org/psycopg3/docs/api/cursors.html#psycopg.Cursor.executemany
928
928
  written_docs = 0
929
929
  while True:
@@ -980,9 +980,14 @@ class PgvectorDocumentStore:
980
980
  )
981
981
  raise DocumentStoreError(error_msg) from e
982
982
 
983
+ # executemany(returning=True) yields one result set per document, so walk them all with nextset()
984
+ # https://www.psycopg.org/psycopg3/docs/api/cursors.html#psycopg.Cursor.executemany
983
985
  written_docs = 0
984
- async for _ in self._async_cursor:
985
- written_docs += 1
986
+ while True:
987
+ if await self._async_cursor.fetchone():
988
+ written_docs += 1
989
+ if not self._async_cursor.nextset():
990
+ break
986
991
 
987
992
  return written_docs
988
993
 
@@ -1926,7 +1931,12 @@ class PgvectorDocumentStore:
1926
1931
  return {"min": min_value, "max": max_value}
1927
1932
 
1928
1933
  def _build_unique_values_queries(
1929
- self, normalized_field: str, search_term: str | None, from_: int, size: int
1934
+ self,
1935
+ normalized_field: str,
1936
+ search_term: str | None,
1937
+ from_: int,
1938
+ size: int,
1939
+ filters: dict[str, Any] | None = None,
1930
1940
  ) -> tuple[Composed, Composed, tuple]:
1931
1941
  """
1932
1942
  Builds SQL queries for getting unique metadata field values.
@@ -1936,6 +1946,7 @@ class PgvectorDocumentStore:
1936
1946
  against the metadata field's own value.
1937
1947
  :param from_: The offset for pagination (0-based).
1938
1948
  :param size: The number of unique values to return.
1949
+ :param filters: Optional filters to restrict the documents considered.
1939
1950
  :returns: A tuple containing (count_query, select_query, params).
1940
1951
  """
1941
1952
  field_literal = SQLLiteral(normalized_field)
@@ -1944,6 +1955,9 @@ class PgvectorDocumentStore:
1944
1955
  # Use the `->` (jsonb) operator rather than `->>` (text) so DISTINCT/ORDER BY operate on the
1945
1956
  # value's original JSON type (numbers sort numerically, not lexicographically) and psycopg
1946
1957
  # decodes the result to its native Python type instead of a string.
1958
+ #
1959
+ # Not fixable here: JSONB equality treats a whole-number float (1.0) as identical to a
1960
+ # numerically equal int (1), so DISTINCT still collapses them - see this method's docstring.
1947
1961
  sql_select = SQL("SELECT DISTINCT meta->{} AS value").format(field_literal)
1948
1962
  sql_from = SQL(" FROM {schema_name}.{table_name}").format(
1949
1963
  schema_name=Identifier(self.schema_name), table_name=Identifier(self.table_name)
@@ -1951,10 +1965,16 @@ class PgvectorDocumentStore:
1951
1965
  sql_where = SQL(" WHERE meta->>{} IS NOT NULL").format(field_literal)
1952
1966
 
1953
1967
  params: tuple = ()
1968
+ if filters:
1969
+ _validate_filters(filters)
1970
+ filters_where_clause, filters_params = _convert_filters_to_where_clause_and_params(filters, operator="AND")
1971
+ sql_where += filters_where_clause
1972
+ params += filters_params
1973
+
1954
1974
  if search_term:
1955
1975
  # Case-insensitive substring match against the metadata field's own (text) value.
1956
1976
  sql_where += SQL(" AND meta->>{} ILIKE %s").format(field_literal)
1957
- params = (f"%{search_term}%",)
1977
+ params += (f"%{search_term}%",)
1958
1978
 
1959
1979
  # count query
1960
1980
  sql_count = SQL("SELECT COUNT(DISTINCT meta->{} ) AS total").format(field_literal)
@@ -1984,22 +2004,36 @@ class PgvectorDocumentStore:
1984
2004
  return unique_values, total_count
1985
2005
 
1986
2006
  def get_metadata_field_unique_values(
1987
- self, metadata_field: str, search_term: str | None = None, from_: int = 0, size: int = 10
2007
+ self,
2008
+ metadata_field: str,
2009
+ search_term: str | None = None,
2010
+ from_: int = 0,
2011
+ size: int = 10,
2012
+ filters: dict[str, Any] | None = None,
1988
2013
  ) -> tuple[list[Any], int]:
1989
2014
  """
1990
2015
  Returns unique values for a given metadata field, optionally filtered by a search term.
1991
2016
 
2017
+ **Note**: values of different JSON type categories are kept distinct - a string, a number and
2018
+ a boolean never collapse into each other, even when they share a textual form (e.g. the string
2019
+ `"1"` and the number `1`). One exception: the ``meta`` column is JSONB, whose equality treats a
2020
+ whole-number float (`1.0`) as identical to a numerically equal int (`1`), so those two collapse
2021
+ into a single value. Floats with a fractional part (e.g. `1.5`) are unaffected.
2022
+
1992
2023
  :param metadata_field: The name of the metadata field. Can include or omit the "meta." prefix.
1993
2024
  :param search_term: Optional search term to filter unique values by a case-insensitive substring
1994
2025
  match against the metadata field's own value. If None, all values are considered.
1995
2026
  :param from_: The offset for pagination (0-based).
1996
2027
  :param size: The number of unique values to return.
2028
+ :param filters: Optional filters to restrict the documents considered.
1997
2029
  :returns: A tuple containing:
1998
2030
  - A list of unique values in their original type
1999
2031
  - The total count of unique values
2000
2032
  """
2001
2033
  normalized_field = PgvectorDocumentStore._normalize_metadata_field_name(metadata_field)
2002
- sql_count, sql_query, params = self._build_unique_values_queries(normalized_field, search_term, from_, size)
2034
+ sql_count, sql_query, params = self._build_unique_values_queries(
2035
+ normalized_field, search_term, from_, size, filters
2036
+ )
2003
2037
 
2004
2038
  self._ensure_db_setup()
2005
2039
  assert self._dict_cursor is not None
@@ -2022,22 +2056,36 @@ class PgvectorDocumentStore:
2022
2056
  return PgvectorDocumentStore._process_unique_values_result(count_result, records)
2023
2057
 
2024
2058
  async def get_metadata_field_unique_values_async(
2025
- self, metadata_field: str, search_term: str | None = None, from_: int = 0, size: int = 10
2059
+ self,
2060
+ metadata_field: str,
2061
+ search_term: str | None = None,
2062
+ from_: int = 0,
2063
+ size: int = 10,
2064
+ filters: dict[str, Any] | None = None,
2026
2065
  ) -> tuple[list[Any], int]:
2027
2066
  """
2028
2067
  Asynchronously returns unique values for a given metadata field, optionally filtered by a search term.
2029
2068
 
2069
+ **Note**: values of different JSON type categories are kept distinct - a string, a number and
2070
+ a boolean never collapse into each other, even when they share a textual form (e.g. the string
2071
+ `"1"` and the number `1`). One exception: the ``meta`` column is JSONB, whose equality treats a
2072
+ whole-number float (`1.0`) as identical to a numerically equal int (`1`), so those two collapse
2073
+ into a single value. Floats with a fractional part (e.g. `1.5`) are unaffected.
2074
+
2030
2075
  :param metadata_field: The name of the metadata field. Can include or omit the "meta." prefix.
2031
2076
  :param search_term: Optional search term to filter unique values by a case-insensitive substring
2032
2077
  match against the metadata field's own value. If None, all values are considered.
2033
2078
  :param from_: The offset for pagination (0-based).
2034
2079
  :param size: The number of unique values to return.
2080
+ :param filters: Optional filters to restrict the documents considered.
2035
2081
  :returns: A tuple containing:
2036
2082
  - A list of unique values in their original type
2037
2083
  - The total count of unique values
2038
2084
  """
2039
2085
  normalized_field = PgvectorDocumentStore._normalize_metadata_field_name(metadata_field)
2040
- sql_count, sql_query, params = self._build_unique_values_queries(normalized_field, search_term, from_, size)
2086
+ sql_count, sql_query, params = self._build_unique_values_queries(
2087
+ normalized_field, search_term, from_, size, filters
2088
+ )
2041
2089
 
2042
2090
  await self._ensure_db_setup_async()
2043
2091
  assert self._async_dict_cursor is not None
@@ -71,6 +71,10 @@ def _parse_logical_condition(condition: dict[str, Any]) -> tuple[Composed, list[
71
71
  msg = f"Unknown logical operator '{operator}'. Valid operators are: 'AND', 'OR'"
72
72
  raise FilterError(msg)
73
73
 
74
+ if not condition["conditions"]:
75
+ msg = f"'conditions' must not be empty in {condition}"
76
+ raise FilterError(msg)
77
+
74
78
  # logical conditions can be nested, so we need to parse them recursively
75
79
  conditions = []
76
80
  for c in condition["conditions"]:
@@ -66,6 +66,55 @@ class TestDocumentStore(
66
66
  with pytest.raises(DuplicateDocumentError):
67
67
  document_store.write_documents(docs, DuplicatePolicy.FAIL)
68
68
 
69
+ def test_write_documents_counts_every_document(self, document_store: PgvectorDocumentStore):
70
+ docs = [Document(id="1"), Document(id="2"), Document(id="3")]
71
+ assert document_store.write_documents(docs) == 3
72
+
73
+ def test_write_documents_skip_counts_only_new_documents(self, document_store: PgvectorDocumentStore):
74
+ document_store.write_documents([Document(id="1")])
75
+ docs = [Document(id="1"), Document(id="2"), Document(id="3")]
76
+ assert document_store.write_documents(docs, DuplicatePolicy.SKIP) == 2
77
+
78
+ def test_write_documents_overwrite_counts_every_document(self, document_store: PgvectorDocumentStore):
79
+ document_store.write_documents([Document(id="1", content="old")])
80
+ docs = [Document(id="1", content="new"), Document(id="2"), Document(id="3")]
81
+ assert document_store.write_documents(docs, DuplicatePolicy.OVERWRITE) == 3
82
+
83
+ def test_get_metadata_field_unique_values_distinct_types(self, document_store: PgvectorDocumentStore):
84
+ """
85
+ Override: the base mixin test stores int, float, str and bool under the *same* metadata field
86
+ name and expects all four back as distinct values. This store's ``meta`` column is JSONB, and
87
+ PostgreSQL's JSONB equality treats a whole-number float (e.g. 1.0) and a numerically equal int
88
+ (1) as the same value, so ``SELECT DISTINCT meta->'field'`` collapses them regardless of which
89
+ other values share that field.
90
+
91
+ This adapts the same intent - int, float, str and bool must come back as distinct, unmangled
92
+ types via get_metadata_field_unique_values() - using one field per type instead of one shared
93
+ field, which is what this store can actually support.
94
+
95
+ The float value is a non-whole number (1.5, not 1.0): a whole-number float would still collapse
96
+ with an int under PostgreSQL's JSONB numeric equality even in its own field, so a fractional
97
+ value is used to sidestep that ambiguity entirely.
98
+ """
99
+ docs = [
100
+ Document(content="Doc 1", meta={"priority_int": 1}),
101
+ Document(content="Doc 2", meta={"priority_str": "1"}),
102
+ Document(content="Doc 3", meta={"priority_float": 1.5}),
103
+ Document(content="Doc 4", meta={"priority_bool": True}),
104
+ ]
105
+ document_store.write_documents(docs)
106
+
107
+ int_values, int_count = document_store.get_metadata_field_unique_values(metadata_field="priority_int")
108
+ str_values, str_count = document_store.get_metadata_field_unique_values(metadata_field="priority_str")
109
+ float_values, float_count = document_store.get_metadata_field_unique_values(metadata_field="priority_float")
110
+ bool_values, bool_count = document_store.get_metadata_field_unique_values(metadata_field="priority_bool")
111
+
112
+ assert (int_count, str_count, float_count, bool_count) == (1, 1, 1, 1)
113
+ assert int_values == [1] and type(int_values[0]) is int
114
+ assert str_values == ["1"] and type(str_values[0]) is str
115
+ assert float_values == [1.5] and type(float_values[0]) is float
116
+ assert bool_values == [True] and type(bool_values[0]) is bool
117
+
69
118
  def test_write_blob(self, document_store: PgvectorDocumentStore):
70
119
  bytestream = ByteStream(b"test", meta={"meta_key": "meta_value"}, mime_type="mime_type")
71
120
  docs = [Document(id="1", blob=bytestream)]
@@ -596,172 +645,30 @@ def test_get_metadata_field_min_max(document_store: PgvectorDocumentStore):
596
645
 
597
646
 
598
647
  @pytest.mark.integration
599
- def test_get_metadata_field_unique_values(document_store: PgvectorDocumentStore):
600
- # Test with string values
648
+ def test_get_metadata_field_unique_values_pagination_edge_cases(document_store: PgvectorDocumentStore):
649
+ """
650
+ Edge cases not covered by the shared GetMetadataFieldUniqueValuesTest mixin's pagination test:
651
+ size larger than the total count, from_ beyond the total count, and single-item-per-page paging.
652
+ """
601
653
  docs = [
602
- Document(content="Python programming", meta={"category": "A", "language": "Python"}),
603
- Document(content="Java programming", meta={"category": "B", "language": "Java"}),
604
- Document(content="Python scripting", meta={"category": "A", "language": "Python"}),
605
- Document(content="JavaScript development", meta={"category": "C", "language": "JavaScript"}),
606
- Document(content="Python data science", meta={"category": "A", "language": "Python"}),
607
- Document(content="Java backend", meta={"category": "B", "language": "Java"}),
654
+ Document(content="Python programming", meta={"category": "A"}),
655
+ Document(content="Java programming", meta={"category": "B"}),
656
+ Document(content="JavaScript development", meta={"category": "C"}),
608
657
  ]
609
658
  document_store.write_documents(docs)
610
659
 
611
- # Test getting all unique values without search term
612
- unique_values, total_count = document_store.get_metadata_field_unique_values("meta.category", None, 0, 10)
613
- assert set(unique_values) == {"A", "B", "C"}
614
- assert total_count == 3
615
-
616
- # Test with "meta." prefix
617
- unique_languages, total_languages = document_store.get_metadata_field_unique_values("meta.language", None, 0, 10)
618
- assert set(unique_languages) == {"Python", "Java", "JavaScript"}
619
- assert total_languages == 3
620
-
621
- # Test pagination - first page
622
- unique_values_page1, total_count_page1 = document_store.get_metadata_field_unique_values(
623
- "meta.category", None, 0, 2
624
- )
625
- assert len(unique_values_page1) == 2
626
- assert all(val in ["A", "B", "C"] for val in unique_values_page1)
627
- assert total_count_page1 == 3
628
-
629
- # Test pagination - second page
630
- unique_values_page2, total_count_page2 = document_store.get_metadata_field_unique_values(
631
- "meta.category", None, 2, 2
632
- )
633
- assert len(unique_values_page2) == 1
634
- assert unique_values_page2[0] in ["A", "B", "C"]
635
- assert total_count_page2 == 3
636
-
637
- # Test pagination - verify pages don't overlap
638
- assert not set(unique_values_page1).intersection(set(unique_values_page2))
639
-
640
- # Test pagination - verify all values are covered
641
- all_values = set(unique_values_page1) | set(unique_values_page2)
642
- assert all_values == {"A", "B", "C"}
643
-
644
- # Test pagination - size larger than total count
645
660
  unique_values_large, total_large = document_store.get_metadata_field_unique_values("meta.category", None, 0, 100)
646
- assert len(unique_values_large) == 3
647
661
  assert set(unique_values_large) == {"A", "B", "C"}
648
662
  assert total_large == 3
649
663
 
650
- # Test pagination - from_ beyond total count (should return empty)
651
664
  unique_values_beyond, total_beyond = document_store.get_metadata_field_unique_values("meta.category", None, 10, 10)
652
665
  assert len(unique_values_beyond) == 0
653
666
  assert total_beyond == 3
654
667
 
655
- # Test pagination - single item per page
656
668
  unique_values_single1, _ = document_store.get_metadata_field_unique_values("meta.category", None, 0, 1)
657
669
  unique_values_single2, _ = document_store.get_metadata_field_unique_values("meta.category", None, 1, 1)
658
670
  unique_values_single3, _ = document_store.get_metadata_field_unique_values("meta.category", None, 2, 1)
659
671
  assert len(unique_values_single1) == 1
660
672
  assert len(unique_values_single2) == 1
661
673
  assert len(unique_values_single3) == 1
662
- # All three pages should be different
663
674
  assert len(set(unique_values_single1 + unique_values_single2 + unique_values_single3)) == 3
664
-
665
- # Test with search term - case-insensitive substring match against the metadata field's OWN value
666
- # (not against document content).
667
- unique_values_filtered, total_filtered = document_store.get_metadata_field_unique_values(
668
- "meta.language", "Python", 0, 10
669
- )
670
- assert set(unique_values_filtered) == {"Python"}
671
- assert total_filtered == 1
672
-
673
- # Case-insensitivity: lowercase search term should still match "Python"
674
- unique_values_lower, total_lower = document_store.get_metadata_field_unique_values("meta.language", "python", 0, 10)
675
- assert set(unique_values_lower) == {"Python"}
676
- assert total_lower == 1
677
-
678
- # Substring matching: "Java" is a substring of both "Java" and "JavaScript"
679
- unique_values_java, total_java = document_store.get_metadata_field_unique_values("meta.language", "Java", 0, 10)
680
- assert set(unique_values_java) == {"Java", "JavaScript"}
681
- assert total_java == 2
682
-
683
- # Substring matching in the middle/end of a value
684
- unique_values_script, total_script = document_store.get_metadata_field_unique_values(
685
- "meta.language", "Script", 0, 10
686
- )
687
- assert set(unique_values_script) == {"JavaScript"}
688
- assert total_script == 1
689
-
690
- # Test pagination with search term
691
- unique_values_search_page1, total_search = document_store.get_metadata_field_unique_values(
692
- "meta.language", "Python", 0, 1
693
- )
694
- assert len(unique_values_search_page1) == 1
695
- assert unique_values_search_page1[0] == "Python"
696
- assert total_search == 1
697
-
698
- # Test pagination with search term - beyond results
699
- unique_values_search_empty, total_search_empty = document_store.get_metadata_field_unique_values(
700
- "meta.language", "Python", 10, 10
701
- )
702
- assert len(unique_values_search_empty) == 0
703
- assert total_search_empty == 1
704
-
705
- # Test that search_term matches metadata VALUE, not content
706
- content_vs_metadata_docs = [
707
- Document(content="This document mentions Python explicitly", meta={"topic": "cooking"}),
708
- Document(content="Unrelated content about recipes", meta={"topic": "python-tutorial"}),
709
- ]
710
- document_store.write_documents(content_vs_metadata_docs)
711
-
712
- unique_topics, total_topics = document_store.get_metadata_field_unique_values("meta.topic", "python", 0, 10)
713
- assert set(unique_topics) == {"python-tutorial"}
714
- assert total_topics == 1
715
-
716
- # Test with integer values
717
- int_docs = [
718
- Document(content="Doc 1", meta={"priority": 1}),
719
- Document(content="Doc 2", meta={"priority": 2}),
720
- Document(content="Doc 3", meta={"priority": 1}),
721
- Document(content="Doc 4", meta={"priority": 3}),
722
- ]
723
- document_store.write_documents(int_docs)
724
- unique_priorities, total_priorities = document_store.get_metadata_field_unique_values("meta.priority", None, 0, 10)
725
- assert set(unique_priorities) == {1, 2, 3}
726
- assert total_priorities == 3
727
-
728
- # Test with search term on integer field - substring match against the field's own (stringified)
729
- # value, e.g. "Doc 1" (content) no longer matches; "1" (the value itself) does. The returned
730
- # values themselves keep their original type (int here), only the match is textual.
731
- unique_priorities_filtered, total_priorities_filtered = document_store.get_metadata_field_unique_values(
732
- "meta.priority", "1", 0, 10
733
- )
734
- assert set(unique_priorities_filtered) == {1}
735
- assert total_priorities_filtered == 1
736
-
737
-
738
- @pytest.mark.integration
739
- def test_get_metadata_field_unique_values_no_search_term(document_store: PgvectorDocumentStore):
740
- docs = [
741
- Document(content="mentions tsvector and content in the body", meta={"category": "A"}),
742
- Document(content="another document", meta={"category": "B"}),
743
- ]
744
- document_store.write_documents(docs)
745
-
746
- values, total = document_store.get_metadata_field_unique_values("meta.category")
747
-
748
- assert set(values) == {"A", "B"}
749
- assert total == 2
750
-
751
-
752
- @pytest.mark.integration
753
- def test_get_metadata_field_unique_values_search_term_matches_field_value_not_content(
754
- document_store: PgvectorDocumentStore,
755
- ):
756
- docs = [
757
- # "python" appears in the content but NOT in the category value -> must be EXCLUDED
758
- Document(content="Python programming guide", meta={"category": "Backend"}),
759
- # "python" appears in the category value but NOT in the content -> must be INCLUDED
760
- Document(content="General purpose scripting language", meta={"category": "Python-based"}),
761
- ]
762
- document_store.write_documents(docs)
763
-
764
- values, total = document_store.get_metadata_field_unique_values("meta.category", search_term="python")
765
-
766
- assert values == ["Python-based"]
767
- assert total == 1
@@ -71,6 +71,63 @@ class TestDocumentStoreAsync(
71
71
  with pytest.raises(DuplicateDocumentError):
72
72
  await document_store.write_documents_async(docs, DuplicatePolicy.FAIL)
73
73
 
74
+ async def test_write_documents_async_counts_every_document(self, document_store: PgvectorDocumentStore):
75
+ docs = [Document(id="1"), Document(id="2"), Document(id="3")]
76
+ assert await document_store.write_documents_async(docs) == 3
77
+
78
+ async def test_write_documents_async_skip_counts_only_new_documents(self, document_store: PgvectorDocumentStore):
79
+ await document_store.write_documents_async([Document(id="1")])
80
+ docs = [Document(id="1"), Document(id="2"), Document(id="3")]
81
+ assert await document_store.write_documents_async(docs, DuplicatePolicy.SKIP) == 2
82
+
83
+ async def test_write_documents_async_overwrite_counts_every_document(self, document_store: PgvectorDocumentStore):
84
+ await document_store.write_documents_async([Document(id="1", content="old")])
85
+ docs = [Document(id="1", content="new"), Document(id="2"), Document(id="3")]
86
+ assert await document_store.write_documents_async(docs, DuplicatePolicy.OVERWRITE) == 3
87
+
88
+ async def test_get_metadata_field_unique_values_distinct_types_async(self, document_store: PgvectorDocumentStore):
89
+ """
90
+ Override: the base mixin test stores int, float, str and bool under the *same* metadata field
91
+ name and expects all four back as distinct values. This store's ``meta`` column is JSONB, and
92
+ PostgreSQL's JSONB equality treats a whole-number float (e.g. 1.0) and a numerically equal int
93
+ (1) as the same value, so ``SELECT DISTINCT meta->'field'`` collapses them regardless of which
94
+ other values share that field.
95
+
96
+ This adapts the same intent - int, float, str and bool must come back as distinct, unmangled
97
+ types via get_metadata_field_unique_values_async() - using one field per type instead of one
98
+ shared field, which is what this store can actually support.
99
+
100
+ The float value is a non-whole number (1.5, not 1.0): a whole-number float would still collapse
101
+ with an int under PostgreSQL's JSONB numeric equality even in its own field, so a fractional
102
+ value is used to sidestep that ambiguity entirely.
103
+ """
104
+ docs = [
105
+ Document(content="Doc 1", meta={"priority_int": 1}),
106
+ Document(content="Doc 2", meta={"priority_str": "1"}),
107
+ Document(content="Doc 3", meta={"priority_float": 1.5}),
108
+ Document(content="Doc 4", meta={"priority_bool": True}),
109
+ ]
110
+ await document_store.write_documents_async(docs)
111
+
112
+ int_values, int_count = await document_store.get_metadata_field_unique_values_async(
113
+ metadata_field="priority_int"
114
+ )
115
+ str_values, str_count = await document_store.get_metadata_field_unique_values_async(
116
+ metadata_field="priority_str"
117
+ )
118
+ float_values, float_count = await document_store.get_metadata_field_unique_values_async(
119
+ metadata_field="priority_float"
120
+ )
121
+ bool_values, bool_count = await document_store.get_metadata_field_unique_values_async(
122
+ metadata_field="priority_bool"
123
+ )
124
+
125
+ assert (int_count, str_count, float_count, bool_count) == (1, 1, 1, 1)
126
+ assert int_values == [1] and type(int_values[0]) is int
127
+ assert str_values == ["1"] and type(str_values[0]) is str
128
+ assert float_values == [1.5] and type(float_values[0]) is float
129
+ assert bool_values == [True] and type(bool_values[0]) is bool
130
+
74
131
  async def test_count_not_empty_async(self, document_store: PgvectorDocumentStore):
75
132
  """Override: mixin method is missing 'self', causing fixture injection to fail."""
76
133
  await document_store.write_documents_async(
@@ -124,22 +181,6 @@ class TestDocumentStoreAsync(
124
181
  filters={"field": "meta.category", "operator": "==", "value": "A"}, meta={}
125
182
  )
126
183
 
127
- async def test_get_metadata_field_unique_values_async_preserves_non_string_types(
128
- self, document_store: PgvectorDocumentStore
129
- ):
130
- """Non-string metadata values (e.g. ints) are returned in their original type, not stringified."""
131
- docs = [
132
- Document(content="Doc 1", meta={"priority": 1}),
133
- Document(content="Doc 2", meta={"priority": 2}),
134
- Document(content="Doc 3", meta={"priority": 1}),
135
- ]
136
- await document_store.write_documents_async(docs)
137
-
138
- values, total = await document_store.get_metadata_field_unique_values_async("meta.priority")
139
-
140
- assert set(values) == {1, 2}
141
- assert total == 2
142
-
143
184
  async def test_delete_table_async_first_call(self, document_store: PgvectorDocumentStore):
144
185
  """
145
186
  Test that delete_table_async can be executed as the initial operation on the Document Store
@@ -483,3 +483,21 @@ def test_validate_filters():
483
483
  invalid_filters = {"field": "meta.number", "value": "100"}
484
484
  with pytest.raises(ValueError):
485
485
  _validate_filters(invalid_filters)
486
+
487
+
488
+ @pytest.mark.parametrize("operator", ["AND", "OR"])
489
+ def test_logical_condition_empty_conditions_raises(operator):
490
+ with pytest.raises(FilterError):
491
+ _parse_logical_condition({"operator": operator, "conditions": []})
492
+
493
+
494
+ def test_logical_condition_nested_empty_conditions_raises():
495
+ filters = {
496
+ "operator": "AND",
497
+ "conditions": [
498
+ {"field": "meta.type", "operator": "==", "value": "article"},
499
+ {"operator": "OR", "conditions": []},
500
+ ],
501
+ }
502
+ with pytest.raises(FilterError):
503
+ _convert_filters_to_where_clause_and_params(filters)