pgvector-haystack 6.4.1__tar.gz → 6.6.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.
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/CHANGELOG.md +21 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/LICENSE.txt +1 -1
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/PKG-INFO +4 -2
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/README.md +2 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/pyproject.toml +1 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/src/haystack_integrations/document_stores/pgvector/document_store.py +61 -15
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/src/haystack_integrations/document_stores/pgvector/filters.py +71 -1
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/tests/test_document_store.py +43 -149
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/tests/test_document_store_async.py +43 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/tests/test_filters.py +168 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/.gitignore +0 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/examples/embedding_retrieval.py +0 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/examples/hybrid_retrieval.py +0 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/pydoc/config_docusaurus.yml +0 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/src/haystack_integrations/components/retrievers/pgvector/__init__.py +0 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/src/haystack_integrations/components/retrievers/pgvector/embedding_retriever.py +0 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/src/haystack_integrations/components/retrievers/pgvector/keyword_retriever.py +0 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/src/haystack_integrations/components/retrievers/py.typed +0 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/src/haystack_integrations/document_stores/pgvector/__init__.py +0 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/src/haystack_integrations/document_stores/pgvector/converters.py +0 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/src/haystack_integrations/document_stores/py.typed +0 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/tests/__init__.py +0 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/tests/conftest.py +0 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/tests/test_converters.py +0 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/tests/test_retrieval.py +0 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/tests/test_retrieval_async.py +0 -0
- {pgvector_haystack-6.4.1 → pgvector_haystack-6.6.0}/tests/test_retrievers.py +0 -0
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [integrations/pgvector-v6.5.0] - 2026-08-03
|
|
4
|
+
|
|
5
|
+
### 🚀 Features
|
|
6
|
+
|
|
7
|
+
- Add array_contains and array_overlaps operators to pgvector filters (#3459)
|
|
8
|
+
|
|
9
|
+
### 🐛 Bug Fixes
|
|
10
|
+
|
|
11
|
+
- Fix new issues raised by ruff 0.16.0 (#3670)
|
|
12
|
+
- `PGVectorDocumentStore` get_metadata_field_unique_values setting list of values to return to `Any` (#3709)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
## [integrations/pgvector-v6.4.1] - 2026-07-23
|
|
16
|
+
|
|
17
|
+
### 🐛 Bug Fixes
|
|
18
|
+
|
|
19
|
+
- *(pgvector)* Parenthesize `not in` clause so it does not escape an AND filter (#3586)
|
|
20
|
+
- `PGVectorDocumentStore` match `search_term` against metadata field value, not content (#3648)
|
|
21
|
+
- Fix (pgvector): fix error messages to say pgvector instead of pinecone (#3654)
|
|
22
|
+
|
|
23
|
+
|
|
3
24
|
## [integrations/pgvector-v6.4.0] - 2026-07-23
|
|
4
25
|
|
|
5
26
|
### 🚀 Features
|
|
@@ -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
|
|
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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: pgvector-haystack
|
|
3
|
-
Version: 6.
|
|
3
|
+
Version: 6.6.0
|
|
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.
|
|
@@ -1926,7 +1926,12 @@ class PgvectorDocumentStore:
|
|
|
1926
1926
|
return {"min": min_value, "max": max_value}
|
|
1927
1927
|
|
|
1928
1928
|
def _build_unique_values_queries(
|
|
1929
|
-
self,
|
|
1929
|
+
self,
|
|
1930
|
+
normalized_field: str,
|
|
1931
|
+
search_term: str | None,
|
|
1932
|
+
from_: int,
|
|
1933
|
+
size: int,
|
|
1934
|
+
filters: dict[str, Any] | None = None,
|
|
1930
1935
|
) -> tuple[Composed, Composed, tuple]:
|
|
1931
1936
|
"""
|
|
1932
1937
|
Builds SQL queries for getting unique metadata field values.
|
|
@@ -1936,25 +1941,38 @@ class PgvectorDocumentStore:
|
|
|
1936
1941
|
against the metadata field's own value.
|
|
1937
1942
|
:param from_: The offset for pagination (0-based).
|
|
1938
1943
|
:param size: The number of unique values to return.
|
|
1944
|
+
:param filters: Optional filters to restrict the documents considered.
|
|
1939
1945
|
:returns: A tuple containing (count_query, select_query, params).
|
|
1940
1946
|
"""
|
|
1941
1947
|
field_literal = SQLLiteral(normalized_field)
|
|
1942
1948
|
|
|
1943
1949
|
# base query components
|
|
1944
|
-
|
|
1950
|
+
# Use the `->` (jsonb) operator rather than `->>` (text) so DISTINCT/ORDER BY operate on the
|
|
1951
|
+
# value's original JSON type (numbers sort numerically, not lexicographically) and psycopg
|
|
1952
|
+
# decodes the result to its native Python type instead of a string.
|
|
1953
|
+
#
|
|
1954
|
+
# Not fixable here: JSONB equality treats a whole-number float (1.0) as identical to a
|
|
1955
|
+
# numerically equal int (1), so DISTINCT still collapses them - see this method's docstring.
|
|
1956
|
+
sql_select = SQL("SELECT DISTINCT meta->{} AS value").format(field_literal)
|
|
1945
1957
|
sql_from = SQL(" FROM {schema_name}.{table_name}").format(
|
|
1946
1958
|
schema_name=Identifier(self.schema_name), table_name=Identifier(self.table_name)
|
|
1947
1959
|
)
|
|
1948
1960
|
sql_where = SQL(" WHERE meta->>{} IS NOT NULL").format(field_literal)
|
|
1949
1961
|
|
|
1950
1962
|
params: tuple = ()
|
|
1963
|
+
if filters:
|
|
1964
|
+
_validate_filters(filters)
|
|
1965
|
+
filters_where_clause, filters_params = _convert_filters_to_where_clause_and_params(filters, operator="AND")
|
|
1966
|
+
sql_where += filters_where_clause
|
|
1967
|
+
params += filters_params
|
|
1968
|
+
|
|
1951
1969
|
if search_term:
|
|
1952
|
-
# Case-insensitive substring match against the metadata field's own value.
|
|
1970
|
+
# Case-insensitive substring match against the metadata field's own (text) value.
|
|
1953
1971
|
sql_where += SQL(" AND meta->>{} ILIKE %s").format(field_literal)
|
|
1954
|
-
params
|
|
1972
|
+
params += (f"%{search_term}%",)
|
|
1955
1973
|
|
|
1956
1974
|
# count query
|
|
1957
|
-
sql_count = SQL("SELECT COUNT(DISTINCT meta
|
|
1975
|
+
sql_count = SQL("SELECT COUNT(DISTINCT meta->{} ) AS total").format(field_literal)
|
|
1958
1976
|
sql_count += sql_from + sql_where
|
|
1959
1977
|
|
|
1960
1978
|
# paginated select query
|
|
@@ -1968,7 +1986,7 @@ class PgvectorDocumentStore:
|
|
|
1968
1986
|
@staticmethod
|
|
1969
1987
|
def _process_unique_values_result(
|
|
1970
1988
|
count_result: dict[str, Any] | None, records: list[dict[str, Any]]
|
|
1971
|
-
) -> tuple[list[
|
|
1989
|
+
) -> tuple[list[Any], int]:
|
|
1972
1990
|
"""
|
|
1973
1991
|
Processes the results from unique values queries.
|
|
1974
1992
|
|
|
@@ -1977,26 +1995,40 @@ class PgvectorDocumentStore:
|
|
|
1977
1995
|
:returns: A tuple containing (unique_values, total_count).
|
|
1978
1996
|
"""
|
|
1979
1997
|
total_count = count_result.get("total", 0) if count_result else 0
|
|
1980
|
-
unique_values = [
|
|
1998
|
+
unique_values = [record.get("value") for record in records if record.get("value") is not None]
|
|
1981
1999
|
return unique_values, total_count
|
|
1982
2000
|
|
|
1983
2001
|
def get_metadata_field_unique_values(
|
|
1984
|
-
self,
|
|
1985
|
-
|
|
2002
|
+
self,
|
|
2003
|
+
metadata_field: str,
|
|
2004
|
+
search_term: str | None = None,
|
|
2005
|
+
from_: int = 0,
|
|
2006
|
+
size: int = 10,
|
|
2007
|
+
filters: dict[str, Any] | None = None,
|
|
2008
|
+
) -> tuple[list[Any], int]:
|
|
1986
2009
|
"""
|
|
1987
2010
|
Returns unique values for a given metadata field, optionally filtered by a search term.
|
|
1988
2011
|
|
|
2012
|
+
**Note**: values of different JSON type categories are kept distinct - a string, a number and
|
|
2013
|
+
a boolean never collapse into each other, even when they share a textual form (e.g. the string
|
|
2014
|
+
`"1"` and the number `1`). One exception: the ``meta`` column is JSONB, whose equality treats a
|
|
2015
|
+
whole-number float (`1.0`) as identical to a numerically equal int (`1`), so those two collapse
|
|
2016
|
+
into a single value. Floats with a fractional part (e.g. `1.5`) are unaffected.
|
|
2017
|
+
|
|
1989
2018
|
:param metadata_field: The name of the metadata field. Can include or omit the "meta." prefix.
|
|
1990
2019
|
:param search_term: Optional search term to filter unique values by a case-insensitive substring
|
|
1991
2020
|
match against the metadata field's own value. If None, all values are considered.
|
|
1992
2021
|
:param from_: The offset for pagination (0-based).
|
|
1993
2022
|
:param size: The number of unique values to return.
|
|
2023
|
+
:param filters: Optional filters to restrict the documents considered.
|
|
1994
2024
|
:returns: A tuple containing:
|
|
1995
|
-
- A list of unique values
|
|
2025
|
+
- A list of unique values in their original type
|
|
1996
2026
|
- The total count of unique values
|
|
1997
2027
|
"""
|
|
1998
2028
|
normalized_field = PgvectorDocumentStore._normalize_metadata_field_name(metadata_field)
|
|
1999
|
-
sql_count, sql_query, params = self._build_unique_values_queries(
|
|
2029
|
+
sql_count, sql_query, params = self._build_unique_values_queries(
|
|
2030
|
+
normalized_field, search_term, from_, size, filters
|
|
2031
|
+
)
|
|
2000
2032
|
|
|
2001
2033
|
self._ensure_db_setup()
|
|
2002
2034
|
assert self._dict_cursor is not None
|
|
@@ -2019,22 +2051,36 @@ class PgvectorDocumentStore:
|
|
|
2019
2051
|
return PgvectorDocumentStore._process_unique_values_result(count_result, records)
|
|
2020
2052
|
|
|
2021
2053
|
async def get_metadata_field_unique_values_async(
|
|
2022
|
-
self,
|
|
2023
|
-
|
|
2054
|
+
self,
|
|
2055
|
+
metadata_field: str,
|
|
2056
|
+
search_term: str | None = None,
|
|
2057
|
+
from_: int = 0,
|
|
2058
|
+
size: int = 10,
|
|
2059
|
+
filters: dict[str, Any] | None = None,
|
|
2060
|
+
) -> tuple[list[Any], int]:
|
|
2024
2061
|
"""
|
|
2025
2062
|
Asynchronously returns unique values for a given metadata field, optionally filtered by a search term.
|
|
2026
2063
|
|
|
2064
|
+
**Note**: values of different JSON type categories are kept distinct - a string, a number and
|
|
2065
|
+
a boolean never collapse into each other, even when they share a textual form (e.g. the string
|
|
2066
|
+
`"1"` and the number `1`). One exception: the ``meta`` column is JSONB, whose equality treats a
|
|
2067
|
+
whole-number float (`1.0`) as identical to a numerically equal int (`1`), so those two collapse
|
|
2068
|
+
into a single value. Floats with a fractional part (e.g. `1.5`) are unaffected.
|
|
2069
|
+
|
|
2027
2070
|
:param metadata_field: The name of the metadata field. Can include or omit the "meta." prefix.
|
|
2028
2071
|
:param search_term: Optional search term to filter unique values by a case-insensitive substring
|
|
2029
2072
|
match against the metadata field's own value. If None, all values are considered.
|
|
2030
2073
|
:param from_: The offset for pagination (0-based).
|
|
2031
2074
|
:param size: The number of unique values to return.
|
|
2075
|
+
:param filters: Optional filters to restrict the documents considered.
|
|
2032
2076
|
:returns: A tuple containing:
|
|
2033
|
-
- A list of unique values
|
|
2077
|
+
- A list of unique values in their original type
|
|
2034
2078
|
- The total count of unique values
|
|
2035
2079
|
"""
|
|
2036
2080
|
normalized_field = PgvectorDocumentStore._normalize_metadata_field_name(metadata_field)
|
|
2037
|
-
sql_count, sql_query, params = self._build_unique_values_queries(
|
|
2081
|
+
sql_count, sql_query, params = self._build_unique_values_queries(
|
|
2082
|
+
normalized_field, search_term, from_, size, filters
|
|
2083
|
+
)
|
|
2038
2084
|
|
|
2039
2085
|
await self._ensure_db_setup_async()
|
|
2040
2086
|
assert self._async_dict_cursor is not None
|
|
@@ -22,6 +22,11 @@ PYTHON_TYPES_TO_PG_TYPES = {
|
|
|
22
22
|
|
|
23
23
|
NO_VALUE = object() # unique sentinel; can never equal a user-supplied filter value
|
|
24
24
|
|
|
25
|
+
# Operators that match against a JSONB array stored in the meta field.
|
|
26
|
+
# They need the JSONB value (via the `->` accessor) as their left operand,
|
|
27
|
+
# unlike the scalar operators which access meta as text (via `->>`).
|
|
28
|
+
ARRAY_OPERATORS = ("array_contains", "array_overlaps")
|
|
29
|
+
|
|
25
30
|
|
|
26
31
|
def _validate_filters(filters: dict[str, Any] | None = None) -> None:
|
|
27
32
|
"""
|
|
@@ -106,7 +111,18 @@ def _parse_comparison_condition(condition: dict[str, Any]) -> tuple[Composed, li
|
|
|
106
111
|
value: Any = condition["value"]
|
|
107
112
|
|
|
108
113
|
if field.startswith("meta."):
|
|
109
|
-
|
|
114
|
+
# array operators need the JSONB value (->), the other operators access meta as text (->>)
|
|
115
|
+
sql_field: Composable = (
|
|
116
|
+
_treat_meta_field_as_jsonb(field) if operator in ARRAY_OPERATORS else _treat_meta_field(field, value)
|
|
117
|
+
)
|
|
118
|
+
elif operator in ARRAY_OPERATORS:
|
|
119
|
+
# top-level columns are never JSONB arrays, so these operators would render SQL that the
|
|
120
|
+
# database rejects with an opaque UndefinedFunction error. Fail with a FilterError instead.
|
|
121
|
+
msg = (
|
|
122
|
+
f"Operator '{operator}' is only supported on meta fields, but got field '{field}'. "
|
|
123
|
+
f"Prefix the field name with 'meta.' to filter on an array stored in a document's metadata."
|
|
124
|
+
)
|
|
125
|
+
raise FilterError(msg)
|
|
110
126
|
else:
|
|
111
127
|
sql_field = Identifier(field)
|
|
112
128
|
|
|
@@ -149,6 +165,24 @@ def _treat_meta_field(field: str, value: Any) -> Composed:
|
|
|
149
165
|
return composed
|
|
150
166
|
|
|
151
167
|
|
|
168
|
+
def _treat_meta_field_as_jsonb(field: str) -> Composed:
|
|
169
|
+
"""
|
|
170
|
+
Internal method that returns a psycopg Composed object accessing a meta key as a JSONB value.
|
|
171
|
+
|
|
172
|
+
Unlike `_treat_meta_field`, this uses the `->` operator (which returns a JSONB value) instead of
|
|
173
|
+
`->>` (which returns text) and applies no type cast. The array operators need a JSONB left operand
|
|
174
|
+
for their JSONB operators (`@>`, `?|`) to work.
|
|
175
|
+
|
|
176
|
+
Uses psycopg.sql.Literal to embed the field name, preventing SQL injection via metadata field names.
|
|
177
|
+
|
|
178
|
+
Example:
|
|
179
|
+
>>> _treat_meta_field_as_jsonb(field="meta.tags")
|
|
180
|
+
Composed([SQL('meta->'), Literal('tags')])
|
|
181
|
+
"""
|
|
182
|
+
field_name = field.split(".", 1)[-1]
|
|
183
|
+
return SQL("meta->") + SQLLiteral(field_name)
|
|
184
|
+
|
|
185
|
+
|
|
152
186
|
def _equal(field: Composable, value: Any) -> tuple[Composed, Any]:
|
|
153
187
|
if value is None:
|
|
154
188
|
return SQL("{} IS NULL").format(field), NO_VALUE
|
|
@@ -255,6 +289,40 @@ def _not_like(field: Composable, value: Any) -> tuple[Composed, Any]:
|
|
|
255
289
|
return SQL("{} NOT LIKE %s").format(field), value
|
|
256
290
|
|
|
257
291
|
|
|
292
|
+
def _validate_array_operator_value(field: Composable, value: Any, operator: str) -> None:
|
|
293
|
+
if not isinstance(value, list):
|
|
294
|
+
msg = f"{field}'s value must be a list when using '{operator}' comparator in PgVector"
|
|
295
|
+
raise FilterError(msg)
|
|
296
|
+
# an empty list has no useful match semantics: `@> '[]'` matches every array and `?| '{}'` matches
|
|
297
|
+
# nothing, so the same empty filter would mean "everything" for one operator and "nothing" for the
|
|
298
|
+
# other. Callers building the list dynamically should omit the condition instead.
|
|
299
|
+
if not value:
|
|
300
|
+
msg = f"{field}'s value must be a non-empty list when using '{operator}' comparator in PgVector"
|
|
301
|
+
raise FilterError(msg)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _array_contains(field: Composable, value: Any) -> tuple[Composed, Jsonb]:
|
|
305
|
+
_validate_array_operator_value(field, value, "array_contains")
|
|
306
|
+
# `jsonb @> jsonb` is true when the left array contains every element of the right array,
|
|
307
|
+
# so the meta array must contain all of the given values. Containment compares elements by value,
|
|
308
|
+
# so a non-array meta value (a bare string, an object) never matches a list of values.
|
|
309
|
+
return SQL("{} @> %s").format(field), Jsonb(value)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _array_overlaps(field: Composable, value: Any) -> tuple[Composed, list]:
|
|
313
|
+
_validate_array_operator_value(field, value, "array_overlaps")
|
|
314
|
+
# `jsonb ?| text[]` only matches string elements, so we reject non-string values to fail fast
|
|
315
|
+
# instead of silently returning no results.
|
|
316
|
+
if not all(isinstance(item, str) for item in value):
|
|
317
|
+
msg = f"{field}'s value must be a list of strings when using 'array_overlaps' comparator in PgVector"
|
|
318
|
+
raise FilterError(msg)
|
|
319
|
+
# `?|` is true when a given string exists as a top-level key OR array element, so on its own it
|
|
320
|
+
# also matches objects (by key) and bare strings. Restricting it to arrays with jsonb_typeof keeps
|
|
321
|
+
# it in agreement with array_contains. The parentheses stop this AND from being split by an
|
|
322
|
+
# enclosing OR (see _not_in for the same reason).
|
|
323
|
+
return SQL("(jsonb_typeof({}) = 'array' AND {} ?| %s)").format(field, field), [value]
|
|
324
|
+
|
|
325
|
+
|
|
258
326
|
COMPARISON_OPERATORS = {
|
|
259
327
|
"==": _equal,
|
|
260
328
|
"!=": _not_equal,
|
|
@@ -266,4 +334,6 @@ COMPARISON_OPERATORS = {
|
|
|
266
334
|
"not in": _not_in,
|
|
267
335
|
"like": _like,
|
|
268
336
|
"not like": _not_like,
|
|
337
|
+
"array_contains": _array_contains,
|
|
338
|
+
"array_overlaps": _array_overlaps,
|
|
269
339
|
}
|
|
@@ -66,6 +66,41 @@ class TestDocumentStore(
|
|
|
66
66
|
with pytest.raises(DuplicateDocumentError):
|
|
67
67
|
document_store.write_documents(docs, DuplicatePolicy.FAIL)
|
|
68
68
|
|
|
69
|
+
def test_get_metadata_field_unique_values_distinct_types(self, document_store: PgvectorDocumentStore):
|
|
70
|
+
"""
|
|
71
|
+
Override: the base mixin test stores int, float, str and bool under the *same* metadata field
|
|
72
|
+
name and expects all four back as distinct values. This store's ``meta`` column is JSONB, and
|
|
73
|
+
PostgreSQL's JSONB equality treats a whole-number float (e.g. 1.0) and a numerically equal int
|
|
74
|
+
(1) as the same value, so ``SELECT DISTINCT meta->'field'`` collapses them regardless of which
|
|
75
|
+
other values share that field.
|
|
76
|
+
|
|
77
|
+
This adapts the same intent - int, float, str and bool must come back as distinct, unmangled
|
|
78
|
+
types via get_metadata_field_unique_values() - using one field per type instead of one shared
|
|
79
|
+
field, which is what this store can actually support.
|
|
80
|
+
|
|
81
|
+
The float value is a non-whole number (1.5, not 1.0): a whole-number float would still collapse
|
|
82
|
+
with an int under PostgreSQL's JSONB numeric equality even in its own field, so a fractional
|
|
83
|
+
value is used to sidestep that ambiguity entirely.
|
|
84
|
+
"""
|
|
85
|
+
docs = [
|
|
86
|
+
Document(content="Doc 1", meta={"priority_int": 1}),
|
|
87
|
+
Document(content="Doc 2", meta={"priority_str": "1"}),
|
|
88
|
+
Document(content="Doc 3", meta={"priority_float": 1.5}),
|
|
89
|
+
Document(content="Doc 4", meta={"priority_bool": True}),
|
|
90
|
+
]
|
|
91
|
+
document_store.write_documents(docs)
|
|
92
|
+
|
|
93
|
+
int_values, int_count = document_store.get_metadata_field_unique_values(metadata_field="priority_int")
|
|
94
|
+
str_values, str_count = document_store.get_metadata_field_unique_values(metadata_field="priority_str")
|
|
95
|
+
float_values, float_count = document_store.get_metadata_field_unique_values(metadata_field="priority_float")
|
|
96
|
+
bool_values, bool_count = document_store.get_metadata_field_unique_values(metadata_field="priority_bool")
|
|
97
|
+
|
|
98
|
+
assert (int_count, str_count, float_count, bool_count) == (1, 1, 1, 1)
|
|
99
|
+
assert int_values == [1] and type(int_values[0]) is int
|
|
100
|
+
assert str_values == ["1"] and type(str_values[0]) is str
|
|
101
|
+
assert float_values == [1.5] and type(float_values[0]) is float
|
|
102
|
+
assert bool_values == [True] and type(bool_values[0]) is bool
|
|
103
|
+
|
|
69
104
|
def test_write_blob(self, document_store: PgvectorDocumentStore):
|
|
70
105
|
bytestream = ByteStream(b"test", meta={"meta_key": "meta_value"}, mime_type="mime_type")
|
|
71
106
|
docs = [Document(id="1", blob=bytestream)]
|
|
@@ -596,171 +631,30 @@ def test_get_metadata_field_min_max(document_store: PgvectorDocumentStore):
|
|
|
596
631
|
|
|
597
632
|
|
|
598
633
|
@pytest.mark.integration
|
|
599
|
-
def
|
|
600
|
-
|
|
634
|
+
def test_get_metadata_field_unique_values_pagination_edge_cases(document_store: PgvectorDocumentStore):
|
|
635
|
+
"""
|
|
636
|
+
Edge cases not covered by the shared GetMetadataFieldUniqueValuesTest mixin's pagination test:
|
|
637
|
+
size larger than the total count, from_ beyond the total count, and single-item-per-page paging.
|
|
638
|
+
"""
|
|
601
639
|
docs = [
|
|
602
|
-
Document(content="Python programming", meta={"category": "A"
|
|
603
|
-
Document(content="Java programming", meta={"category": "B"
|
|
604
|
-
Document(content="
|
|
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"}),
|
|
640
|
+
Document(content="Python programming", meta={"category": "A"}),
|
|
641
|
+
Document(content="Java programming", meta={"category": "B"}),
|
|
642
|
+
Document(content="JavaScript development", meta={"category": "C"}),
|
|
608
643
|
]
|
|
609
644
|
document_store.write_documents(docs)
|
|
610
645
|
|
|
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
646
|
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
647
|
assert set(unique_values_large) == {"A", "B", "C"}
|
|
648
648
|
assert total_large == 3
|
|
649
649
|
|
|
650
|
-
# Test pagination - from_ beyond total count (should return empty)
|
|
651
650
|
unique_values_beyond, total_beyond = document_store.get_metadata_field_unique_values("meta.category", None, 10, 10)
|
|
652
651
|
assert len(unique_values_beyond) == 0
|
|
653
652
|
assert total_beyond == 3
|
|
654
653
|
|
|
655
|
-
# Test pagination - single item per page
|
|
656
654
|
unique_values_single1, _ = document_store.get_metadata_field_unique_values("meta.category", None, 0, 1)
|
|
657
655
|
unique_values_single2, _ = document_store.get_metadata_field_unique_values("meta.category", None, 1, 1)
|
|
658
656
|
unique_values_single3, _ = document_store.get_metadata_field_unique_values("meta.category", None, 2, 1)
|
|
659
657
|
assert len(unique_values_single1) == 1
|
|
660
658
|
assert len(unique_values_single2) == 1
|
|
661
659
|
assert len(unique_values_single3) == 1
|
|
662
|
-
# All three pages should be different
|
|
663
660
|
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.
|
|
730
|
-
unique_priorities_filtered, total_priorities_filtered = document_store.get_metadata_field_unique_values(
|
|
731
|
-
"meta.priority", "1", 0, 10
|
|
732
|
-
)
|
|
733
|
-
assert set(unique_priorities_filtered) == {"1"}
|
|
734
|
-
assert total_priorities_filtered == 1
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
@pytest.mark.integration
|
|
738
|
-
def test_get_metadata_field_unique_values_no_search_term(document_store: PgvectorDocumentStore):
|
|
739
|
-
docs = [
|
|
740
|
-
Document(content="mentions tsvector and content in the body", meta={"category": "A"}),
|
|
741
|
-
Document(content="another document", meta={"category": "B"}),
|
|
742
|
-
]
|
|
743
|
-
document_store.write_documents(docs)
|
|
744
|
-
|
|
745
|
-
values, total = document_store.get_metadata_field_unique_values("meta.category")
|
|
746
|
-
|
|
747
|
-
assert set(values) == {"A", "B"}
|
|
748
|
-
assert total == 2
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
@pytest.mark.integration
|
|
752
|
-
def test_get_metadata_field_unique_values_search_term_matches_field_value_not_content(
|
|
753
|
-
document_store: PgvectorDocumentStore,
|
|
754
|
-
):
|
|
755
|
-
docs = [
|
|
756
|
-
# "python" appears in the content but NOT in the category value -> must be EXCLUDED
|
|
757
|
-
Document(content="Python programming guide", meta={"category": "Backend"}),
|
|
758
|
-
# "python" appears in the category value but NOT in the content -> must be INCLUDED
|
|
759
|
-
Document(content="General purpose scripting language", meta={"category": "Python-based"}),
|
|
760
|
-
]
|
|
761
|
-
document_store.write_documents(docs)
|
|
762
|
-
|
|
763
|
-
values, total = document_store.get_metadata_field_unique_values("meta.category", search_term="python")
|
|
764
|
-
|
|
765
|
-
assert values == ["Python-based"]
|
|
766
|
-
assert total == 1
|
|
@@ -71,6 +71,49 @@ 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_get_metadata_field_unique_values_distinct_types_async(self, document_store: PgvectorDocumentStore):
|
|
75
|
+
"""
|
|
76
|
+
Override: the base mixin test stores int, float, str and bool under the *same* metadata field
|
|
77
|
+
name and expects all four back as distinct values. This store's ``meta`` column is JSONB, and
|
|
78
|
+
PostgreSQL's JSONB equality treats a whole-number float (e.g. 1.0) and a numerically equal int
|
|
79
|
+
(1) as the same value, so ``SELECT DISTINCT meta->'field'`` collapses them regardless of which
|
|
80
|
+
other values share that field.
|
|
81
|
+
|
|
82
|
+
This adapts the same intent - int, float, str and bool must come back as distinct, unmangled
|
|
83
|
+
types via get_metadata_field_unique_values_async() - using one field per type instead of one
|
|
84
|
+
shared field, which is what this store can actually support.
|
|
85
|
+
|
|
86
|
+
The float value is a non-whole number (1.5, not 1.0): a whole-number float would still collapse
|
|
87
|
+
with an int under PostgreSQL's JSONB numeric equality even in its own field, so a fractional
|
|
88
|
+
value is used to sidestep that ambiguity entirely.
|
|
89
|
+
"""
|
|
90
|
+
docs = [
|
|
91
|
+
Document(content="Doc 1", meta={"priority_int": 1}),
|
|
92
|
+
Document(content="Doc 2", meta={"priority_str": "1"}),
|
|
93
|
+
Document(content="Doc 3", meta={"priority_float": 1.5}),
|
|
94
|
+
Document(content="Doc 4", meta={"priority_bool": True}),
|
|
95
|
+
]
|
|
96
|
+
await document_store.write_documents_async(docs)
|
|
97
|
+
|
|
98
|
+
int_values, int_count = await document_store.get_metadata_field_unique_values_async(
|
|
99
|
+
metadata_field="priority_int"
|
|
100
|
+
)
|
|
101
|
+
str_values, str_count = await document_store.get_metadata_field_unique_values_async(
|
|
102
|
+
metadata_field="priority_str"
|
|
103
|
+
)
|
|
104
|
+
float_values, float_count = await document_store.get_metadata_field_unique_values_async(
|
|
105
|
+
metadata_field="priority_float"
|
|
106
|
+
)
|
|
107
|
+
bool_values, bool_count = await document_store.get_metadata_field_unique_values_async(
|
|
108
|
+
metadata_field="priority_bool"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
assert (int_count, str_count, float_count, bool_count) == (1, 1, 1, 1)
|
|
112
|
+
assert int_values == [1] and type(int_values[0]) is int
|
|
113
|
+
assert str_values == ["1"] and type(str_values[0]) is str
|
|
114
|
+
assert float_values == [1.5] and type(float_values[0]) is float
|
|
115
|
+
assert bool_values == [True] and type(bool_values[0]) is bool
|
|
116
|
+
|
|
74
117
|
async def test_count_not_empty_async(self, document_store: PgvectorDocumentStore):
|
|
75
118
|
"""Override: mixin method is missing 'self', causing fixture injection to fail."""
|
|
76
119
|
await document_store.write_documents_async(
|
|
@@ -10,6 +10,7 @@ from haystack.testing.document_store import FilterDocumentsTest
|
|
|
10
10
|
from psycopg.adapt import Transformer
|
|
11
11
|
from psycopg.sql import SQL, Composed
|
|
12
12
|
from psycopg.sql import Literal as SQLLiteral
|
|
13
|
+
from psycopg.types.json import Jsonb
|
|
13
14
|
|
|
14
15
|
from haystack_integrations.document_stores.pgvector.filters import (
|
|
15
16
|
FilterError,
|
|
@@ -17,6 +18,7 @@ from haystack_integrations.document_stores.pgvector.filters import (
|
|
|
17
18
|
_parse_comparison_condition,
|
|
18
19
|
_parse_logical_condition,
|
|
19
20
|
_treat_meta_field,
|
|
21
|
+
_treat_meta_field_as_jsonb,
|
|
20
22
|
_validate_filters,
|
|
21
23
|
)
|
|
22
24
|
|
|
@@ -141,6 +143,86 @@ class TestFilters(FilterDocumentsTest):
|
|
|
141
143
|
],
|
|
142
144
|
)
|
|
143
145
|
|
|
146
|
+
@staticmethod
|
|
147
|
+
def _docs_with_array_meta():
|
|
148
|
+
return [
|
|
149
|
+
Document(content="doc with tag1 and tag2", meta={"tags": ["tag1", "tag2"]}),
|
|
150
|
+
Document(content="doc with tag1 and tag3", meta={"tags": ["tag1", "tag3"]}),
|
|
151
|
+
Document(content="doc with tag4", meta={"tags": ["tag4"]}),
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
def test_array_contains_operator(self, document_store):
|
|
155
|
+
docs = self._docs_with_array_meta()
|
|
156
|
+
document_store.write_documents(docs)
|
|
157
|
+
|
|
158
|
+
# array_contains requires ALL listed values to be present in the meta array
|
|
159
|
+
result = document_store.filter_documents(
|
|
160
|
+
filters={"field": "meta.tags", "operator": "array_contains", "value": ["tag1", "tag2"]}
|
|
161
|
+
)
|
|
162
|
+
assert {doc.content for doc in result} == {"doc with tag1 and tag2"}
|
|
163
|
+
|
|
164
|
+
def test_array_contains_operator_single_value(self, document_store):
|
|
165
|
+
docs = self._docs_with_array_meta()
|
|
166
|
+
document_store.write_documents(docs)
|
|
167
|
+
|
|
168
|
+
result = document_store.filter_documents(
|
|
169
|
+
filters={"field": "meta.tags", "operator": "array_contains", "value": ["tag1"]}
|
|
170
|
+
)
|
|
171
|
+
assert {doc.content for doc in result} == {"doc with tag1 and tag2", "doc with tag1 and tag3"}
|
|
172
|
+
|
|
173
|
+
def test_array_overlaps_operator(self, document_store):
|
|
174
|
+
docs = self._docs_with_array_meta()
|
|
175
|
+
document_store.write_documents(docs)
|
|
176
|
+
|
|
177
|
+
# array_overlaps requires AT LEAST ONE listed value to be present in the meta array
|
|
178
|
+
result = document_store.filter_documents(
|
|
179
|
+
filters={"field": "meta.tags", "operator": "array_overlaps", "value": ["tag1", "tag3"]}
|
|
180
|
+
)
|
|
181
|
+
assert {doc.content for doc in result} == {"doc with tag1 and tag2", "doc with tag1 and tag3"}
|
|
182
|
+
|
|
183
|
+
def test_array_overlaps_operator_no_match(self, document_store):
|
|
184
|
+
docs = self._docs_with_array_meta()
|
|
185
|
+
document_store.write_documents(docs)
|
|
186
|
+
|
|
187
|
+
result = document_store.filter_documents(
|
|
188
|
+
filters={"field": "meta.tags", "operator": "array_overlaps", "value": ["nonexistent"]}
|
|
189
|
+
)
|
|
190
|
+
assert result == []
|
|
191
|
+
|
|
192
|
+
def test_array_operators_combined(self, document_store):
|
|
193
|
+
docs = self._docs_with_array_meta()
|
|
194
|
+
document_store.write_documents(docs)
|
|
195
|
+
|
|
196
|
+
result = document_store.filter_documents(
|
|
197
|
+
filters={
|
|
198
|
+
"operator": "AND",
|
|
199
|
+
"conditions": [
|
|
200
|
+
{"field": "meta.tags", "operator": "array_contains", "value": ["tag1"]},
|
|
201
|
+
{"field": "meta.tags", "operator": "array_overlaps", "value": ["tag3"]},
|
|
202
|
+
],
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
assert {doc.content for doc in result} == {"doc with tag1 and tag3"}
|
|
206
|
+
|
|
207
|
+
@pytest.mark.parametrize("operator", ["array_contains", "array_overlaps"])
|
|
208
|
+
def test_array_operators_ignore_non_array_meta(self, document_store, operator):
|
|
209
|
+
# meta shapes vary across sources, so the same key can hold an array in one document and a
|
|
210
|
+
# bare string or an object in another. `?|` matches a top-level key or array element, so
|
|
211
|
+
# without the jsonb_typeof guard the object and the string below would both match.
|
|
212
|
+
document_store.write_documents(
|
|
213
|
+
[
|
|
214
|
+
Document(content="array meta", meta={"tags": ["tag1", "tag2"]}),
|
|
215
|
+
Document(content="string meta", meta={"tags": "tag1"}),
|
|
216
|
+
Document(content="object meta", meta={"tags": {"tag1": "value"}}),
|
|
217
|
+
Document(content="missing key", meta={"other": "tag1"}),
|
|
218
|
+
]
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
result = document_store.filter_documents(
|
|
222
|
+
filters={"field": "meta.tags", "operator": operator, "value": ["tag1"]}
|
|
223
|
+
)
|
|
224
|
+
assert {doc.content for doc in result} == {"array meta"}
|
|
225
|
+
|
|
144
226
|
|
|
145
227
|
def test_treat_meta_field():
|
|
146
228
|
cast_integer = SQL("(") + SQL("meta->>") + SQLLiteral("number") + SQL(")::integer")
|
|
@@ -165,6 +247,92 @@ def test_treat_meta_field_sql_injection_is_safely_escaped():
|
|
|
165
247
|
assert result == SQL("meta->>") + SQLLiteral("name' OR 1=1 --")
|
|
166
248
|
|
|
167
249
|
|
|
250
|
+
def test_treat_meta_field_as_jsonb():
|
|
251
|
+
# array operators access the meta value as JSONB (->) without a cast, unlike _treat_meta_field (->>)
|
|
252
|
+
assert _treat_meta_field_as_jsonb(field="meta.tags") == SQL("meta->") + SQLLiteral("tags")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_treat_meta_field_as_jsonb_sql_injection_is_safely_escaped():
|
|
256
|
+
result = _treat_meta_field_as_jsonb(field="meta.tags' OR 1=1 --")
|
|
257
|
+
assert isinstance(result, Composed)
|
|
258
|
+
assert result == SQL("meta->") + SQLLiteral("tags' OR 1=1 --")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_array_contains_condition():
|
|
262
|
+
condition = {"field": "meta.tags", "operator": "array_contains", "value": ["tag1", "tag2"]}
|
|
263
|
+
query, values = _parse_comparison_condition(condition)
|
|
264
|
+
|
|
265
|
+
assert _render(query) == "meta->'tags' @> %s"
|
|
266
|
+
# the value is wrapped as a single Jsonb param holding the original list
|
|
267
|
+
assert len(values) == 1
|
|
268
|
+
assert isinstance(values[0], Jsonb)
|
|
269
|
+
assert values[0].obj == ["tag1", "tag2"]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_array_overlaps_condition():
|
|
273
|
+
condition = {"field": "meta.tags", "operator": "array_overlaps", "value": ["tag1", "tag2"]}
|
|
274
|
+
query, values = _parse_comparison_condition(condition)
|
|
275
|
+
|
|
276
|
+
# the jsonb_typeof guard keeps `?|` from matching objects (by key) or bare strings
|
|
277
|
+
assert _render(query) == "(jsonb_typeof(meta->'tags') = 'array' AND meta->'tags' ?| %s)"
|
|
278
|
+
# the list is wrapped twice (once here, once by _parse_comparison_condition), exactly like 'in',
|
|
279
|
+
# so psycopg adapts it to a nested Postgres text[]; `?|` deconstructs it to the same elements
|
|
280
|
+
assert values == [[["tag1", "tag2"]]]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_array_contains_non_list_value_raises():
|
|
284
|
+
condition = {"field": "meta.tags", "operator": "array_contains", "value": "tag1"}
|
|
285
|
+
with pytest.raises(FilterError):
|
|
286
|
+
_parse_comparison_condition(condition)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_array_overlaps_non_list_value_raises():
|
|
290
|
+
condition = {"field": "meta.tags", "operator": "array_overlaps", "value": "tag1"}
|
|
291
|
+
with pytest.raises(FilterError):
|
|
292
|
+
_parse_comparison_condition(condition)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_array_overlaps_non_string_elements_raises():
|
|
296
|
+
condition = {"field": "meta.tags", "operator": "array_overlaps", "value": [1, 2]}
|
|
297
|
+
with pytest.raises(FilterError):
|
|
298
|
+
_parse_comparison_condition(condition)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@pytest.mark.parametrize("operator", ["array_contains", "array_overlaps"])
|
|
302
|
+
def test_array_operators_empty_list_raises(operator):
|
|
303
|
+
# `@> '[]'` matches every array and `?| '{}'` matches nothing, so an empty list would mean
|
|
304
|
+
# "everything" for one operator and "nothing" for the other. Reject it instead.
|
|
305
|
+
condition = {"field": "meta.tags", "operator": operator, "value": []}
|
|
306
|
+
with pytest.raises(FilterError, match="non-empty list"):
|
|
307
|
+
_parse_comparison_condition(condition)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@pytest.mark.parametrize("operator", ["array_contains", "array_overlaps"])
|
|
311
|
+
def test_array_operators_on_non_meta_field_raises(operator):
|
|
312
|
+
# top-level columns are never JSONB arrays, so this must fail before reaching the database
|
|
313
|
+
condition = {"field": "content", "operator": operator, "value": ["tag1"]}
|
|
314
|
+
with pytest.raises(FilterError, match="only supported on meta fields"):
|
|
315
|
+
_parse_comparison_condition(condition)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_convert_filters_array_contains():
|
|
319
|
+
filters = {"field": "meta.tags", "operator": "array_contains", "value": ["tag1", "tag2"]}
|
|
320
|
+
where_clause, params = _convert_filters_to_where_clause_and_params(filters)
|
|
321
|
+
|
|
322
|
+
assert _render(where_clause) == " WHERE meta->'tags' @> %s"
|
|
323
|
+
assert len(params) == 1
|
|
324
|
+
assert isinstance(params[0], Jsonb)
|
|
325
|
+
assert params[0].obj == ["tag1", "tag2"]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def test_convert_filters_array_overlaps():
|
|
329
|
+
filters = {"field": "meta.tags", "operator": "array_overlaps", "value": ["tag1", "tag2"]}
|
|
330
|
+
where_clause, params = _convert_filters_to_where_clause_and_params(filters)
|
|
331
|
+
|
|
332
|
+
assert _render(where_clause) == " WHERE (jsonb_typeof(meta->'tags') = 'array' AND meta->'tags' ?| %s)"
|
|
333
|
+
assert params == ([["tag1", "tag2"]],)
|
|
334
|
+
|
|
335
|
+
|
|
168
336
|
def test_comparison_condition_missing_operator():
|
|
169
337
|
condition = {"field": "meta.type", "value": "article"}
|
|
170
338
|
with pytest.raises(FilterError):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|