pgvector-haystack 6.1.0__tar.gz → 6.3.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 (28) hide show
  1. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/CHANGELOG.md +23 -0
  2. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/PKG-INFO +2 -2
  3. pgvector_haystack-6.3.0/pydoc/config_docusaurus.yml +15 -0
  4. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/pyproject.toml +6 -2
  5. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/document_stores/pgvector/converters.py +5 -1
  6. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/document_stores/pgvector/filters.py +52 -50
  7. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/tests/test_converters.py +17 -0
  8. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/tests/test_document_store.py +18 -218
  9. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/tests/test_filters.py +46 -18
  10. pgvector_haystack-6.1.0/pydoc/config_docusaurus.yml +0 -30
  11. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/.gitignore +0 -0
  12. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/LICENSE.txt +0 -0
  13. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/README.md +0 -0
  14. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/examples/embedding_retrieval.py +0 -0
  15. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/examples/hybrid_retrieval.py +0 -0
  16. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/components/retrievers/pgvector/__init__.py +0 -0
  17. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/components/retrievers/pgvector/embedding_retriever.py +0 -0
  18. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/components/retrievers/pgvector/keyword_retriever.py +0 -0
  19. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/components/retrievers/py.typed +0 -0
  20. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/document_stores/pgvector/__init__.py +0 -0
  21. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/document_stores/pgvector/document_store.py +0 -0
  22. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/document_stores/py.typed +0 -0
  23. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/tests/__init__.py +0 -0
  24. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/tests/conftest.py +0 -0
  25. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/tests/test_document_store_async.py +0 -0
  26. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/tests/test_retrieval.py +0 -0
  27. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/tests/test_retrieval_async.py +0 -0
  28. {pgvector_haystack-6.1.0 → pgvector_haystack-6.3.0}/tests/test_retrievers.py +0 -0
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [integrations/pgvector-v6.2.0] - 2026-03-02
4
+
5
+ ### 🐛 Bug Fixes
6
+
7
+ - Add metadata field-name validation using regex in `PGVectorDocumentStore` filters to prevent SQL injection vectors. (#2881)
8
+ - Remove NUL bytes when converting from haystack to pg documents (#2892)
9
+
10
+ ### 📚 Documentation
11
+
12
+ - Simplify pydoc configs (#2855)
13
+
14
+ ### 🧪 Testing
15
+
16
+ - Replacing each `DocumentStore` specific tests and used the generalised ones from `haystack.testing.document_store` (#2812)
17
+
18
+
19
+ ## [integrations/pgvector-v6.1.0] - 2026-01-28
20
+
21
+ ### 🚀 Features
22
+
23
+ - Adding count, filtering and metadata related operations to `PGVectorDocumentStore` (#2768)
24
+
25
+
3
26
  ## [integrations/pgvector-v6.0.0] - 2026-01-12
4
27
 
5
28
  ### 🧹 Chores
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgvector-haystack
3
- Version: 6.1.0
3
+ Version: 6.3.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
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.13
18
18
  Classifier: Programming Language :: Python :: Implementation :: CPython
19
19
  Classifier: Programming Language :: Python :: Implementation :: PyPy
20
20
  Requires-Python: >=3.10
21
- Requires-Dist: haystack-ai>=2.22.0
21
+ Requires-Dist: haystack-ai>=2.24.0
22
22
  Requires-Dist: pgvector>=0.3.0
23
23
  Requires-Dist: psycopg[binary]
24
24
  Description-Content-Type: text/markdown
@@ -0,0 +1,15 @@
1
+ loaders:
2
+ - modules:
3
+ - haystack_integrations.components.retrievers.pgvector.embedding_retriever
4
+ - haystack_integrations.components.retrievers.pgvector.keyword_retriever
5
+ - haystack_integrations.document_stores.pgvector.document_store
6
+ search_path: [../src]
7
+ processors:
8
+ - type: filter
9
+ documented_only: true
10
+ skip_empty_modules: true
11
+ renderer:
12
+ description: Pgvector integration for Haystack
13
+ id: integrations-pgvector
14
+ filename: pgvector.md
15
+ title: Pgvector
@@ -22,7 +22,11 @@ classifiers = [
22
22
  "Programming Language :: Python :: Implementation :: CPython",
23
23
  "Programming Language :: Python :: Implementation :: PyPy",
24
24
  ]
25
- dependencies = ["haystack-ai>=2.22.0", "pgvector>=0.3.0", "psycopg[binary]"]
25
+ dependencies = [
26
+ "haystack-ai>=2.24.0",
27
+ "pgvector>=0.3.0",
28
+ "psycopg[binary]"
29
+ ]
26
30
 
27
31
  [project.urls]
28
32
  Source = "https://github.com/deepset-ai/haystack-core-integrations"
@@ -45,7 +49,7 @@ installer = "uv"
45
49
  dependencies = ["haystack-pydoc-tools", "ruff"]
46
50
 
47
51
  [tool.hatch.envs.default.scripts]
48
- docs = ["pydoc-markdown pydoc/config_docusaurus.yml"]
52
+ docs = ["haystack-pydoc pydoc/config_docusaurus.yml"]
49
53
  fmt = "ruff check --fix {args}; ruff format {args}"
50
54
  fmt-check = "ruff check {args} && ruff format --check {args}"
51
55
 
@@ -1,3 +1,4 @@
1
+ from dataclasses import replace
1
2
  from typing import Any
2
3
 
3
4
  from haystack import logging
@@ -22,6 +23,9 @@ def _from_haystack_to_pg_documents(documents: list[Document]) -> list[dict[str,
22
23
  db_document["blob_meta"] = Jsonb(blob.meta) if blob and blob.meta else None
23
24
  db_document["blob_mime_type"] = blob.mime_type if blob and blob.mime_type else None
24
25
  db_document["meta"] = Jsonb(db_document["meta"])
26
+ # PostgreSQL text fields cannot contain NUL (0x00) bytes, removing NUL bytes
27
+ if content := db_document["content"]:
28
+ db_document["content"] = content.replace("\x00", "")
25
29
 
26
30
  if "sparse_embedding" in db_document:
27
31
  sparse_embedding = db_document.pop("sparse_embedding", None)
@@ -65,7 +69,7 @@ def _from_pg_to_haystack_documents(documents: list[dict[str, Any]]) -> list[Docu
65
69
 
66
70
  if blob_data:
67
71
  blob = ByteStream(data=blob_data, meta=blob_meta, mime_type=blob_mime_type)
68
- haystack_document.blob = blob
72
+ haystack_document = replace(haystack_document, blob=blob)
69
73
 
70
74
  haystack_documents.append(haystack_document)
71
75
 
@@ -1,12 +1,14 @@
1
1
  # SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
+
4
5
  from datetime import datetime
5
6
  from itertools import chain
6
7
  from typing import Any, Literal
7
8
 
8
9
  from haystack.errors import FilterError
9
- from psycopg.sql import SQL, Composed
10
+ from psycopg.sql import SQL, Composable, Composed, Identifier
11
+ from psycopg.sql import Literal as SQLLiteral
10
12
  from psycopg.types.json import Jsonb
11
13
 
12
14
  # we need this mapping to cast meta values to the correct type,
@@ -45,13 +47,13 @@ def _convert_filters_to_where_clause_and_params(
45
47
  else:
46
48
  query, values = _parse_logical_condition(filters)
47
49
 
48
- where_clause = SQL(f" {operator} ") + SQL(query)
50
+ where_clause = SQL(f" {operator} ") + query
49
51
  params = tuple(value for value in values if value != NO_VALUE)
50
52
 
51
53
  return where_clause, params
52
54
 
53
55
 
54
- def _parse_logical_condition(condition: dict[str, Any]) -> tuple[str, list[Any]]:
56
+ def _parse_logical_condition(condition: dict[str, Any]) -> tuple[Composed, list[Any]]:
55
57
  if "operator" not in condition:
56
58
  msg = f"'operator' key missing in {condition}"
57
59
  raise FilterError(msg)
@@ -81,17 +83,14 @@ def _parse_logical_condition(condition: dict[str, Any]) -> tuple[str, list[Any]]
81
83
  values = list(chain.from_iterable(values))
82
84
 
83
85
  if operator == "AND":
84
- sql_query = f"({' AND '.join(query_parts)})"
85
- elif operator == "OR":
86
- sql_query = f"({' OR '.join(query_parts)})"
87
- else:
88
- msg = f"Unknown logical operator '{operator}'"
89
- raise FilterError(msg)
86
+ sql_query = SQL("(") + SQL(" AND ").join(query_parts) + SQL(")")
87
+ else: # operator == "OR"
88
+ sql_query = SQL("(") + SQL(" OR ").join(query_parts) + SQL(")")
90
89
 
91
90
  return sql_query, values
92
91
 
93
92
 
94
- def _parse_comparison_condition(condition: dict[str, Any]) -> tuple[str, list[Any]]:
93
+ def _parse_comparison_condition(condition: dict[str, Any]) -> tuple[Composed, list[Any]]:
95
94
  field: str = condition["field"]
96
95
  if "operator" not in condition:
97
96
  msg = f"'operator' key missing in {condition}"
@@ -107,28 +106,37 @@ def _parse_comparison_condition(condition: dict[str, Any]) -> tuple[str, list[An
107
106
  value: Any = condition["value"]
108
107
 
109
108
  if field.startswith("meta."):
110
- field = _treat_meta_field(field, value)
109
+ sql_field: Composable = _treat_meta_field(field, value)
110
+ else:
111
+ sql_field = Identifier(field)
111
112
 
112
- field, value = COMPARISON_OPERATORS[operator](field, value)
113
- return field, [value]
113
+ sql_expr, value = COMPARISON_OPERATORS[operator](sql_field, value)
114
+ return sql_expr, [value]
114
115
 
115
116
 
116
- def _treat_meta_field(field: str, value: Any) -> str:
117
+ def _treat_meta_field(field: str, value: Any) -> Composed:
117
118
  """
118
- Internal method that modifies the field str
119
- to make the meta JSONB field queryable.
119
+ Internal method that returns a psycopg Composed object
120
+ to make the meta JSONB field queryable safely.
121
+
122
+ Uses psycopg.sql.Literal to embed the field name, preventing SQL injection
123
+ via metadata field names without requiring regex validation.
124
+
125
+ Use the ->> operator to access keys in the meta JSONB field.
120
126
 
121
127
  Examples:
122
128
  >>> _treat_meta_field(field="meta.number", value=9)
123
- "(meta->>'number')::integer"
129
+ Composed([SQL('(meta->>'), Literal('number'), SQL(')::integer')])
124
130
 
125
131
  >>> _treat_meta_field(field="meta.name", value="my_name")
126
- "meta->>'name'"
127
- """
132
+ Composed([SQL('meta->>'), Literal('name')])
128
133
 
129
- # use the ->> operator to access keys in the meta JSONB field
134
+ """
130
135
  field_name = field.split(".", 1)[-1]
131
- field = f"meta->>'{field_name}'"
136
+
137
+ # Use SQLLiteral to safely embed the field name as a SQL string literal,
138
+ # preventing SQL injection via metadata field names.
139
+ composed: Composed = SQL("meta->>") + SQLLiteral(field_name)
132
140
 
133
141
  # meta fields are stored as strings in the JSONB field,
134
142
  # so we need to cast them to the correct type
@@ -137,25 +145,24 @@ def _treat_meta_field(field: str, value: Any) -> str:
137
145
  type_value = PYTHON_TYPES_TO_PG_TYPES.get(type(value[0]))
138
146
 
139
147
  if type_value:
140
- field = f"({field})::{type_value}"
148
+ composed = SQL("(") + composed + SQL(f")::{type_value}")
141
149
 
142
- return field
150
+ return composed
143
151
 
144
152
 
145
- def _equal(field: str, value: Any) -> tuple[str, Any]:
153
+ def _equal(field: Composable, value: Any) -> tuple[Composed, Any]:
146
154
  if value is None:
147
- # NO_VALUE is a placeholder that will be removed in _convert_filters_to_where_clause_and_params
148
- return f"{field} IS NULL", NO_VALUE
149
- return f"{field} = %s", value
155
+ return SQL("{} IS NULL").format(field), NO_VALUE
156
+ return SQL("{} = %s").format(field), value
150
157
 
151
158
 
152
- def _not_equal(field: str, value: Any) -> tuple[str, Any]:
159
+ def _not_equal(field: Composable, value: Any) -> tuple[Composed, Any]:
153
160
  # we use IS DISTINCT FROM to correctly handle NULL values
154
161
  # (not handled by !=)
155
- return f"{field} IS DISTINCT FROM %s", value
162
+ return SQL("{} IS DISTINCT FROM %s").format(field), value
156
163
 
157
164
 
158
- def _greater_than(field: str, value: Any) -> tuple[str, Any]:
165
+ def _greater_than(field: Composable, value: Any) -> tuple[Composed, Any]:
159
166
  if isinstance(value, str):
160
167
  try:
161
168
  datetime.fromisoformat(value)
@@ -168,11 +175,10 @@ def _greater_than(field: str, value: Any) -> tuple[str, Any]:
168
175
  if type(value) in [list, Jsonb]:
169
176
  msg = f"Filter value can't be of type {type(value)} using operators '>', '>=', '<', '<='"
170
177
  raise FilterError(msg)
178
+ return SQL("{} > %s").format(field), value
171
179
 
172
- return f"{field} > %s", value
173
180
 
174
-
175
- def _greater_than_equal(field: str, value: Any) -> tuple[str, Any]:
181
+ def _greater_than_equal(field: Composable, value: Any) -> tuple[Composed, Any]:
176
182
  if isinstance(value, str):
177
183
  try:
178
184
  datetime.fromisoformat(value)
@@ -185,11 +191,10 @@ def _greater_than_equal(field: str, value: Any) -> tuple[str, Any]:
185
191
  if type(value) in [list, Jsonb]:
186
192
  msg = f"Filter value can't be of type {type(value)} using operators '>', '>=', '<', '<='"
187
193
  raise FilterError(msg)
188
-
189
- return f"{field} >= %s", value
194
+ return SQL("{} >= %s").format(field), value
190
195
 
191
196
 
192
- def _less_than(field: str, value: Any) -> tuple[str, Any]:
197
+ def _less_than(field: Composable, value: Any) -> tuple[Composed, Any]:
193
198
  if isinstance(value, str):
194
199
  try:
195
200
  datetime.fromisoformat(value)
@@ -202,11 +207,10 @@ def _less_than(field: str, value: Any) -> tuple[str, Any]:
202
207
  if type(value) in [list, Jsonb]:
203
208
  msg = f"Filter value can't be of type {type(value)} using operators '>', '>=', '<', '<='"
204
209
  raise FilterError(msg)
210
+ return SQL("{} < %s").format(field), value
205
211
 
206
- return f"{field} < %s", value
207
212
 
208
-
209
- def _less_than_equal(field: str, value: Any) -> tuple[str, Any]:
213
+ def _less_than_equal(field: Composable, value: Any) -> tuple[Composed, Any]:
210
214
  if isinstance(value, str):
211
215
  try:
212
216
  datetime.fromisoformat(value)
@@ -219,39 +223,37 @@ def _less_than_equal(field: str, value: Any) -> tuple[str, Any]:
219
223
  if type(value) in [list, Jsonb]:
220
224
  msg = f"Filter value can't be of type {type(value)} using operators '>', '>=', '<', '<='"
221
225
  raise FilterError(msg)
222
-
223
- return f"{field} <= %s", value
226
+ return SQL("{} <= %s").format(field), value
224
227
 
225
228
 
226
- def _not_in(field: str, value: Any) -> tuple[str, list]:
229
+ def _not_in(field: Composable, value: Any) -> tuple[Composed, list]:
227
230
  if not isinstance(value, list):
228
231
  msg = f"{field}'s value must be a list when using 'not in' comparator in Pinecone"
229
232
  raise FilterError(msg)
230
-
231
- return f"{field} IS NULL OR {field} != ALL(%s)", [value]
233
+ return SQL("{} IS NULL OR {} != ALL(%s)").format(field, field), [value]
232
234
 
233
235
 
234
- def _in(field: str, value: Any) -> tuple[str, list]:
236
+ def _in(field: Composable, value: Any) -> tuple[Composed, list]:
235
237
  if not isinstance(value, list):
236
238
  msg = f"{field}'s value must be a list when using 'in' comparator in Pinecone"
237
239
  raise FilterError(msg)
238
240
 
239
241
  # see https://www.psycopg.org/psycopg3/docs/basic/adapt.html#lists-adaptation
240
- return f"{field} = ANY(%s)", [value]
242
+ return SQL("{} = ANY(%s)").format(field), [value]
241
243
 
242
244
 
243
- def _like(field: str, value: Any) -> tuple[str, Any]:
245
+ def _like(field: Composable, value: Any) -> tuple[Composed, Any]:
244
246
  if not isinstance(value, str):
245
247
  msg = f"{field}'s value must be a str when using 'LIKE' "
246
248
  raise FilterError(msg)
247
- return f"{field} LIKE %s", value
249
+ return SQL("{} LIKE %s").format(field), value
248
250
 
249
251
 
250
- def _not_like(field: str, value: Any) -> tuple[str, Any]:
252
+ def _not_like(field: Composable, value: Any) -> tuple[Composed, Any]:
251
253
  if not isinstance(value, str):
252
254
  msg = f"{field}'s value must be a str when using 'LIKE' "
253
255
  raise FilterError(msg)
254
- return f"{field} NOT LIKE %s", value
256
+ return SQL("{} NOT LIKE %s").format(field), value
255
257
 
256
258
 
257
259
  COMPARISON_OPERATORS = {
@@ -30,6 +30,13 @@ def test_from_haystack_to_pg_documents():
30
30
  embedding=[0.7, 0.8, 0.9],
31
31
  score=0.7,
32
32
  ),
33
+ Document(
34
+ id="4",
35
+ content="This is another text\x00",
36
+ meta={"meta_key": "meta_value"},
37
+ embedding=[0.7, 0.8, 0.9],
38
+ score=0.8,
39
+ ),
33
40
  ]
34
41
 
35
42
  pg_docs = _from_haystack_to_pg_documents(haystack_docs)
@@ -64,6 +71,16 @@ def test_from_haystack_to_pg_documents():
64
71
  assert pg_docs[2]["embedding"] == [0.7, 0.8, 0.9]
65
72
  assert "score" not in pg_docs[2]
66
73
 
74
+ assert pg_docs[3]["id"] == "4"
75
+ assert pg_docs[3]["content"] == "This is another text"
76
+ assert pg_docs[3]["blob_data"] is None
77
+ assert pg_docs[3]["blob_meta"] is None
78
+ assert pg_docs[3]["blob_mime_type"] is None
79
+ assert "dataframe" not in pg_docs[3]
80
+ assert pg_docs[3]["meta"].obj == {"meta_key": "meta_value"}
81
+ assert pg_docs[3]["embedding"] == [0.7, 0.8, 0.9]
82
+ assert "score" not in pg_docs[3]
83
+
67
84
 
68
85
  def test_from_pg_to_haystack_documents():
69
86
  pg_docs = [
@@ -9,14 +9,30 @@ import pytest
9
9
  from haystack.dataclasses.document import ByteStream, Document
10
10
  from haystack.document_stores.errors import DocumentStoreError, DuplicateDocumentError
11
11
  from haystack.document_stores.types import DuplicatePolicy
12
- from haystack.testing.document_store import CountDocumentsTest, DeleteDocumentsTest, WriteDocumentsTest
12
+ from haystack.testing.document_store import (
13
+ CountDocumentsTest,
14
+ DeleteAllTest,
15
+ DeleteByFilterTest,
16
+ DeleteDocumentsTest,
17
+ FilterableDocsFixtureMixin,
18
+ UpdateByFilterTest,
19
+ WriteDocumentsTest,
20
+ )
13
21
  from haystack.utils import Secret
14
22
 
15
23
  from haystack_integrations.document_stores.pgvector import PgvectorDocumentStore
16
24
 
17
25
 
18
26
  @pytest.mark.integration
19
- class TestDocumentStore(CountDocumentsTest, WriteDocumentsTest, DeleteDocumentsTest):
27
+ class TestDocumentStore(
28
+ CountDocumentsTest,
29
+ DeleteAllTest,
30
+ DeleteByFilterTest,
31
+ DeleteDocumentsTest,
32
+ FilterableDocsFixtureMixin,
33
+ UpdateByFilterTest,
34
+ WriteDocumentsTest,
35
+ ):
20
36
  def test_write_documents(self, document_store: PgvectorDocumentStore):
21
37
  docs = [Document(id="1")]
22
38
  assert document_store.write_documents(docs) == 1
@@ -53,13 +69,6 @@ class TestDocumentStore(CountDocumentsTest, WriteDocumentsTest, DeleteDocumentsT
53
69
  same_connection = document_store._connection
54
70
  assert same_connection is document_store._connection
55
71
 
56
- def test_delete_all_documents(self, document_store: PgvectorDocumentStore) -> None:
57
- document_store.write_documents([Document(id=str(i)) for i in range(10)])
58
- document_store.delete_all_documents()
59
- assert document_store.count_documents() == 0
60
- document_store.write_documents([Document(id="1")])
61
- assert document_store.count_documents() == 1
62
-
63
72
  def test_invalid_connection_string(self, monkeypatch):
64
73
  monkeypatch.setenv("PG_CONN_STR", "invalid_connection_string")
65
74
  document_store = PgvectorDocumentStore()
@@ -262,215 +271,6 @@ def test_delete_table_first_call(document_store):
262
271
  document_store.delete_table() # if throw error, test fails
263
272
 
264
273
 
265
- @pytest.mark.integration
266
- def test_delete_by_filter(document_store: PgvectorDocumentStore):
267
- docs = [
268
- Document(content="Doc 1", meta={"category": "A", "year": 2023}),
269
- Document(content="Doc 2", meta={"category": "B", "year": 2023}),
270
- Document(content="Doc 3", meta={"category": "A", "year": 2024}),
271
- ]
272
- document_store.write_documents(docs)
273
- assert document_store.count_documents() == 3
274
-
275
- deleted_count = document_store.delete_by_filter(filters={"field": "meta.category", "operator": "==", "value": "A"})
276
- assert deleted_count == 2
277
- assert document_store.count_documents() == 1
278
-
279
- remaining_docs = document_store.filter_documents()
280
- assert len(remaining_docs) == 1
281
- assert remaining_docs[0].meta["category"] == "B"
282
-
283
- deleted_count = document_store.delete_by_filter(filters={"field": "meta.year", "operator": "==", "value": 2023})
284
- assert deleted_count == 1
285
- assert document_store.count_documents() == 0
286
-
287
-
288
- @pytest.mark.integration
289
- def test_delete_by_filter_no_matches(document_store: PgvectorDocumentStore):
290
- docs = [
291
- Document(content="Doc 1", meta={"category": "A"}),
292
- Document(content="Doc 2", meta={"category": "B"}),
293
- ]
294
- document_store.write_documents(docs)
295
- assert document_store.count_documents() == 2
296
-
297
- deleted_count = document_store.delete_by_filter(filters={"field": "meta.category", "operator": "==", "value": "C"})
298
- assert deleted_count == 0
299
- assert document_store.count_documents() == 2
300
-
301
-
302
- @pytest.mark.integration
303
- def test_delete_by_filter_advanced_filters(document_store: PgvectorDocumentStore):
304
- docs = [
305
- Document(content="Doc 1", meta={"category": "A", "year": 2023, "status": "draft"}),
306
- Document(content="Doc 2", meta={"category": "A", "year": 2024, "status": "published"}),
307
- Document(content="Doc 3", meta={"category": "B", "year": 2023, "status": "draft"}),
308
- ]
309
- document_store.write_documents(docs)
310
- assert document_store.count_documents() == 3
311
-
312
- # AND condition
313
- deleted_count = document_store.delete_by_filter(
314
- filters={
315
- "operator": "AND",
316
- "conditions": [
317
- {"field": "meta.category", "operator": "==", "value": "A"},
318
- {"field": "meta.year", "operator": "==", "value": 2023},
319
- ],
320
- }
321
- )
322
- assert deleted_count == 1
323
- assert document_store.count_documents() == 2
324
-
325
- # OR condition
326
- deleted_count = document_store.delete_by_filter(
327
- filters={
328
- "operator": "OR",
329
- "conditions": [
330
- {"field": "meta.category", "operator": "==", "value": "B"},
331
- {"field": "meta.status", "operator": "==", "value": "published"},
332
- ],
333
- }
334
- )
335
- assert deleted_count == 2
336
- assert document_store.count_documents() == 0
337
-
338
-
339
- @pytest.mark.integration
340
- def test_update_by_filter(document_store: PgvectorDocumentStore):
341
- docs = [
342
- Document(content="Doc 1", meta={"category": "A", "status": "draft"}),
343
- Document(content="Doc 2", meta={"category": "B", "status": "draft"}),
344
- Document(content="Doc 3", meta={"category": "A", "status": "draft"}),
345
- ]
346
- document_store.write_documents(docs)
347
- assert document_store.count_documents() == 3
348
-
349
- # update status for category="A" documents
350
- updated_count = document_store.update_by_filter(
351
- filters={"field": "meta.category", "operator": "==", "value": "A"}, meta={"status": "published"}
352
- )
353
- assert updated_count == 2
354
-
355
- # verify
356
- published_docs = document_store.filter_documents(
357
- filters={"field": "meta.status", "operator": "==", "value": "published"}
358
- )
359
- assert len(published_docs) == 2
360
- for doc in published_docs:
361
- assert doc.meta["category"] == "A"
362
- assert doc.meta["status"] == "published"
363
-
364
- # Verify category B still has draft status
365
- draft_docs = document_store.filter_documents(filters={"field": "meta.status", "operator": "==", "value": "draft"})
366
- assert len(draft_docs) == 1
367
- assert draft_docs[0].meta["category"] == "B"
368
-
369
-
370
- @pytest.mark.integration
371
- def test_update_by_filter_multiple_fields(document_store: PgvectorDocumentStore):
372
- docs = [
373
- Document(content="Doc 1", meta={"category": "A", "year": 2023}),
374
- Document(content="Doc 2", meta={"category": "A", "year": 2023}),
375
- Document(content="Doc 3", meta={"category": "B", "year": 2024}),
376
- ]
377
- document_store.write_documents(docs)
378
- assert document_store.count_documents() == 3
379
-
380
- # update multiple fields for category="A" documents
381
- updated_count = document_store.update_by_filter(
382
- filters={"field": "meta.category", "operator": "==", "value": "A"},
383
- meta={"status": "published", "priority": "high", "reviewed": True},
384
- )
385
- assert updated_count == 2
386
-
387
- # verify
388
- published_docs = document_store.filter_documents(filters={"field": "meta.category", "operator": "==", "value": "A"})
389
- assert len(published_docs) == 2
390
- for doc in published_docs:
391
- assert doc.meta["status"] == "published"
392
- assert doc.meta["priority"] == "high"
393
- assert doc.meta["reviewed"] is True
394
- assert doc.meta["year"] == 2023 # Original field should still be present
395
-
396
- # verify category B was not updated
397
- b_docs = document_store.filter_documents(filters={"field": "meta.category", "operator": "==", "value": "B"})
398
- assert len(b_docs) == 1
399
- assert "status" not in b_docs[0].meta
400
- assert "priority" not in b_docs[0].meta
401
-
402
-
403
- @pytest.mark.integration
404
- def test_update_by_filter_no_matches(document_store: PgvectorDocumentStore):
405
- docs = [
406
- Document(content="Doc 1", meta={"category": "A"}),
407
- Document(content="Doc 2", meta={"category": "B"}),
408
- ]
409
- document_store.write_documents(docs)
410
- assert document_store.count_documents() == 2
411
-
412
- # update documents with category="C" (no matches)
413
- updated_count = document_store.update_by_filter(
414
- filters={"field": "meta.category", "operator": "==", "value": "C"}, meta={"status": "published"}
415
- )
416
- assert updated_count == 0
417
- assert document_store.count_documents() == 2
418
-
419
- # verify no documents were updated
420
- published_docs = document_store.filter_documents(
421
- filters={"field": "meta.status", "operator": "==", "value": "published"}
422
- )
423
- assert len(published_docs) == 0
424
-
425
-
426
- @pytest.mark.integration
427
- def test_update_by_filter_advanced_filters(document_store: PgvectorDocumentStore):
428
- docs = [
429
- Document(content="Doc 1", meta={"category": "A", "year": 2023, "status": "draft"}),
430
- Document(content="Doc 2", meta={"category": "A", "year": 2024, "status": "draft"}),
431
- Document(content="Doc 3", meta={"category": "B", "year": 2023, "status": "draft"}),
432
- ]
433
- document_store.write_documents(docs)
434
- assert document_store.count_documents() == 3
435
-
436
- # AND condition
437
- updated_count = document_store.update_by_filter(
438
- filters={
439
- "operator": "AND",
440
- "conditions": [
441
- {"field": "meta.category", "operator": "==", "value": "A"},
442
- {"field": "meta.year", "operator": "==", "value": 2023},
443
- ],
444
- },
445
- meta={"status": "published"},
446
- )
447
- assert updated_count == 1
448
-
449
- # verify only one document was updated
450
- published_docs = document_store.filter_documents(
451
- filters={"field": "meta.status", "operator": "==", "value": "published"}
452
- )
453
- assert len(published_docs) == 1
454
- assert published_docs[0].meta["category"] == "A"
455
- assert published_docs[0].meta["year"] == 2023
456
-
457
- # OR condition
458
- updated_count = document_store.update_by_filter(
459
- filters={
460
- "operator": "OR",
461
- "conditions": [
462
- {"field": "meta.category", "operator": "==", "value": "B"},
463
- {"field": "meta.year", "operator": "==", "value": 2024},
464
- ],
465
- },
466
- meta={"featured": True},
467
- )
468
- assert updated_count == 2
469
-
470
- featured_docs = document_store.filter_documents(filters={"field": "meta.featured", "operator": "==", "value": True})
471
- assert len(featured_docs) == 2
472
-
473
-
474
274
  @pytest.mark.integration
475
275
  def test_update_by_filter_empty_meta_raises_error(document_store: PgvectorDocumentStore):
476
276
  docs = [Document(content="Doc 1", meta={"category": "A"})]
@@ -1,7 +1,13 @@
1
+ # SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
1
5
  import pytest
2
6
  from haystack.dataclasses.document import Document
3
7
  from haystack.testing.document_store import FilterDocumentsTest
4
- from psycopg.sql import SQL
8
+ from psycopg.adapt import Transformer
9
+ from psycopg.sql import SQL, Composed
10
+ from psycopg.sql import Literal as SQLLiteral
5
11
 
6
12
  from haystack_integrations.document_stores.pgvector.filters import (
7
13
  FilterError,
@@ -13,6 +19,11 @@ from haystack_integrations.document_stores.pgvector.filters import (
13
19
  )
14
20
 
15
21
 
22
+ def _render(composed: Composed) -> str:
23
+ """Render a psycopg Composed object to a plain SQL string for assertions."""
24
+ return composed.as_string(Transformer())
25
+
26
+
16
27
  @pytest.mark.integration
17
28
  class TestFilters(FilterDocumentsTest):
18
29
  def assert_documents_are_equal(self, received: list[Document], expected: list[Document]):
@@ -129,19 +140,26 @@ class TestFilters(FilterDocumentsTest):
129
140
 
130
141
 
131
142
  def test_treat_meta_field():
132
- assert _treat_meta_field(field="meta.number", value=9) == "(meta->>'number')::integer"
133
- assert _treat_meta_field(field="meta.number", value=[1, 2, 3]) == "(meta->>'number')::integer"
134
- assert _treat_meta_field(field="meta.name", value="my_name") == "meta->>'name'"
135
- assert _treat_meta_field(field="meta.name", value=["my_name"]) == "meta->>'name'"
136
- assert _treat_meta_field(field="meta.number", value=1.1) == "(meta->>'number')::real"
137
- assert _treat_meta_field(field="meta.number", value=[1.1, 2.2, 3.3]) == "(meta->>'number')::real"
138
- assert _treat_meta_field(field="meta.bool", value=True) == "(meta->>'bool')::boolean"
139
- assert _treat_meta_field(field="meta.bool", value=[True, False, True]) == "(meta->>'bool')::boolean"
143
+ cast_integer = SQL("(") + SQL("meta->>") + SQLLiteral("number") + SQL(")::integer")
144
+ no_cast_name = SQL("meta->>") + SQLLiteral("name")
145
+ cast_real = SQL("(") + SQL("meta->>") + SQLLiteral("number") + SQL(")::real")
146
+ cast_boolean = SQL("(") + SQL("meta->>") + SQLLiteral("bool") + SQL(")::boolean")
147
+
148
+ assert _treat_meta_field(field="meta.number", value=9) == cast_integer
149
+ assert _treat_meta_field(field="meta.number", value=[1, 2, 3]) == cast_integer
150
+ assert _treat_meta_field(field="meta.name", value="my_name") == no_cast_name
151
+ assert _treat_meta_field(field="meta.name", value=["my_name"]) == no_cast_name
152
+ assert _treat_meta_field(field="meta.number", value=1.1) == cast_real
153
+ assert _treat_meta_field(field="meta.number", value=[1.1, 2.2]) == cast_real
154
+ assert _treat_meta_field(field="meta.bool", value=True) == cast_boolean
155
+ assert _treat_meta_field(field="meta.bool", value=[True, False]) == cast_boolean
140
156
 
141
- # do not cast the field if its value is not one of the known types, an empty list or None
142
- assert _treat_meta_field(field="meta.other", value={"a": 3, "b": "example"}) == "meta->>'other'"
143
- assert _treat_meta_field(field="meta.empty_list", value=[]) == "meta->>'empty_list'"
144
- assert _treat_meta_field(field="meta.name", value=None) == "meta->>'name'"
157
+
158
+ def test_treat_meta_field_sql_injection_is_safely_escaped():
159
+ # SQL injection attempts are safely escaped by SQLLiteral rather than rejected
160
+ result = _treat_meta_field(field="meta.name' OR 1=1 --", value="x")
161
+ assert isinstance(result, Composed)
162
+ assert result == SQL("meta->>") + SQLLiteral("name' OR 1=1 --")
145
163
 
146
164
 
147
165
  def test_comparison_condition_missing_operator():
@@ -201,10 +219,16 @@ def test_logical_condition_nested():
201
219
  ],
202
220
  }
203
221
  query, values = _parse_logical_condition(condition)
204
- assert query == (
205
- "((meta->>'domain' IS DISTINCT FROM %s OR meta->>'chapter' = ANY(%s)) "
206
- "AND ((meta->>'number')::integer >= %s OR meta->>'author' IS NULL OR meta->>'author' != ALL(%s)))"
222
+ assert isinstance(query, Composed)
223
+
224
+ expected_sql = (
225
+ "("
226
+ "(meta->>'domain' IS DISTINCT FROM %s OR meta->>'chapter' = ANY(%s))"
227
+ " AND "
228
+ "((meta->>'number')::integer >= %s OR meta->>'author' IS NULL OR meta->>'author' != ALL(%s))"
229
+ ")"
207
230
  )
231
+ assert _render(query) == expected_sql
208
232
  assert values == ["science", [["intro", "conclusion"]], 90, [["John", "Jane"]]]
209
233
 
210
234
 
@@ -217,7 +241,9 @@ def test_convert_filters_to_where_clause_and_params():
217
241
  ],
218
242
  }
219
243
  where_clause, params = _convert_filters_to_where_clause_and_params(filters)
220
- assert where_clause == SQL(" WHERE ") + SQL("((meta->>'number')::integer = %s AND meta->>'chapter' = %s)")
244
+
245
+ expected_sql = " WHERE ((meta->>'number')::integer = %s AND meta->>'chapter' = %s)"
246
+ assert _render(where_clause) == expected_sql
221
247
  assert params == (100, "intro")
222
248
 
223
249
 
@@ -230,7 +256,9 @@ def test_convert_filters_to_where_clause_and_params_handle_null():
230
256
  ],
231
257
  }
232
258
  where_clause, params = _convert_filters_to_where_clause_and_params(filters)
233
- assert where_clause == SQL(" WHERE ") + SQL("(meta->>'number' IS NULL AND meta->>'chapter' = %s)")
259
+
260
+ expected_sql = " WHERE (meta->>'number' IS NULL AND meta->>'chapter' = %s)"
261
+ assert _render(where_clause) == expected_sql
234
262
  assert params == ("intro",)
235
263
 
236
264
 
@@ -1,30 +0,0 @@
1
- loaders:
2
- - ignore_when_discovered:
3
- - __init__
4
- modules:
5
- - haystack_integrations.components.retrievers.pgvector.embedding_retriever
6
- - haystack_integrations.components.retrievers.pgvector.keyword_retriever
7
- - haystack_integrations.document_stores.pgvector.document_store
8
- search_path:
9
- - ../src
10
- type: haystack_pydoc_tools.loaders.CustomPythonLoader
11
- processors:
12
- - do_not_filter_modules: false
13
- documented_only: true
14
- expression: null
15
- skip_empty_modules: true
16
- type: filter
17
- - type: smart
18
- - type: crossref
19
- renderer:
20
- description: Pgvector integration for Haystack
21
- id: integrations-pgvector
22
- markdown:
23
- add_member_class_prefix: false
24
- add_method_class_prefix: true
25
- classdef_code_block: false
26
- descriptive_class_title: false
27
- descriptive_module_title: true
28
- filename: pgvector.md
29
- title: Pgvector
30
- type: haystack_pydoc_tools.renderers.DocusaurusRenderer