pgvector-haystack 6.2.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 (27) hide show
  1. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/CHANGELOG.md +16 -0
  2. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/PKG-INFO +1 -1
  3. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/document_stores/pgvector/filters.py +51 -58
  4. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/tests/test_filters.py +44 -21
  5. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/.gitignore +0 -0
  6. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/LICENSE.txt +0 -0
  7. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/README.md +0 -0
  8. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/examples/embedding_retrieval.py +0 -0
  9. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/examples/hybrid_retrieval.py +0 -0
  10. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/pydoc/config_docusaurus.yml +0 -0
  11. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/pyproject.toml +0 -0
  12. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/components/retrievers/pgvector/__init__.py +0 -0
  13. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/components/retrievers/pgvector/embedding_retriever.py +0 -0
  14. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/components/retrievers/pgvector/keyword_retriever.py +0 -0
  15. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/components/retrievers/py.typed +0 -0
  16. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/document_stores/pgvector/__init__.py +0 -0
  17. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/document_stores/pgvector/converters.py +0 -0
  18. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/document_stores/pgvector/document_store.py +0 -0
  19. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/document_stores/py.typed +0 -0
  20. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/tests/__init__.py +0 -0
  21. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/tests/conftest.py +0 -0
  22. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/tests/test_converters.py +0 -0
  23. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/tests/test_document_store.py +0 -0
  24. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/tests/test_document_store_async.py +0 -0
  25. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/tests/test_retrieval.py +0 -0
  26. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/tests/test_retrieval_async.py +0 -0
  27. {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/tests/test_retrievers.py +0 -0
@@ -1,5 +1,21 @@
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
+
3
19
  ## [integrations/pgvector-v6.1.0] - 2026-01-28
4
20
 
5
21
  ### 🚀 Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgvector-haystack
3
- Version: 6.2.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
@@ -2,13 +2,13 @@
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
- import re
6
5
  from datetime import datetime
7
6
  from itertools import chain
8
7
  from typing import Any, Literal
9
8
 
10
9
  from haystack.errors import FilterError
11
- from psycopg.sql import SQL, Composed
10
+ from psycopg.sql import SQL, Composable, Composed, Identifier
11
+ from psycopg.sql import Literal as SQLLiteral
12
12
  from psycopg.types.json import Jsonb
13
13
 
14
14
  # we need this mapping to cast meta values to the correct type,
@@ -21,7 +21,6 @@ PYTHON_TYPES_TO_PG_TYPES = {
21
21
  }
22
22
 
23
23
  NO_VALUE = "no_value"
24
- SAFE_META_FIELD_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
25
24
 
26
25
 
27
26
  def _validate_filters(filters: dict[str, Any] | None = None) -> None:
@@ -48,13 +47,13 @@ def _convert_filters_to_where_clause_and_params(
48
47
  else:
49
48
  query, values = _parse_logical_condition(filters)
50
49
 
51
- where_clause = SQL(f" {operator} ") + SQL(query)
50
+ where_clause = SQL(f" {operator} ") + query
52
51
  params = tuple(value for value in values if value != NO_VALUE)
53
52
 
54
53
  return where_clause, params
55
54
 
56
55
 
57
- 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]]:
58
57
  if "operator" not in condition:
59
58
  msg = f"'operator' key missing in {condition}"
60
59
  raise FilterError(msg)
@@ -84,17 +83,14 @@ def _parse_logical_condition(condition: dict[str, Any]) -> tuple[str, list[Any]]
84
83
  values = list(chain.from_iterable(values))
85
84
 
86
85
  if operator == "AND":
87
- sql_query = f"({' AND '.join(query_parts)})"
88
- elif operator == "OR":
89
- sql_query = f"({' OR '.join(query_parts)})"
90
- else:
91
- msg = f"Unknown logical operator '{operator}'"
92
- 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(")")
93
89
 
94
90
  return sql_query, values
95
91
 
96
92
 
97
- 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]]:
98
94
  field: str = condition["field"]
99
95
  if "operator" not in condition:
100
96
  msg = f"'operator' key missing in {condition}"
@@ -110,34 +106,37 @@ def _parse_comparison_condition(condition: dict[str, Any]) -> tuple[str, list[An
110
106
  value: Any = condition["value"]
111
107
 
112
108
  if field.startswith("meta."):
113
- field = _treat_meta_field(field, value)
109
+ sql_field: Composable = _treat_meta_field(field, value)
110
+ else:
111
+ sql_field = Identifier(field)
114
112
 
115
- field, value = COMPARISON_OPERATORS[operator](field, value)
116
- return field, [value]
113
+ sql_expr, value = COMPARISON_OPERATORS[operator](sql_field, value)
114
+ return sql_expr, [value]
117
115
 
118
116
 
119
- def _treat_meta_field(field: str, value: Any) -> str:
117
+ def _treat_meta_field(field: str, value: Any) -> Composed:
120
118
  """
121
- Internal method that modifies the field str
122
- 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.
123
126
 
124
127
  Examples:
125
128
  >>> _treat_meta_field(field="meta.number", value=9)
126
- "(meta->>'number')::integer"
129
+ Composed([SQL('(meta->>'), Literal('number'), SQL(')::integer')])
127
130
 
128
131
  >>> _treat_meta_field(field="meta.name", value="my_name")
129
- "meta->>'name'"
130
- """
132
+ Composed([SQL('meta->>'), Literal('name')])
131
133
 
132
- # use the ->> operator to access keys in the meta JSONB field
134
+ """
133
135
  field_name = field.split(".", 1)[-1]
134
- if not SAFE_META_FIELD_RE.match(field_name):
135
- msg = (
136
- f"Invalid metadata field name '{field_name}'. "
137
- "Only alphanumeric characters, dashes, and underscores are allowed."
138
- )
139
- raise FilterError(msg)
140
- 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)
141
140
 
142
141
  # meta fields are stored as strings in the JSONB field,
143
142
  # so we need to cast them to the correct type
@@ -146,25 +145,24 @@ def _treat_meta_field(field: str, value: Any) -> str:
146
145
  type_value = PYTHON_TYPES_TO_PG_TYPES.get(type(value[0]))
147
146
 
148
147
  if type_value:
149
- field = f"({field})::{type_value}"
148
+ composed = SQL("(") + composed + SQL(f")::{type_value}")
150
149
 
151
- return field
150
+ return composed
152
151
 
153
152
 
154
- def _equal(field: str, value: Any) -> tuple[str, Any]:
153
+ def _equal(field: Composable, value: Any) -> tuple[Composed, Any]:
155
154
  if value is None:
156
- # NO_VALUE is a placeholder that will be removed in _convert_filters_to_where_clause_and_params
157
- return f"{field} IS NULL", NO_VALUE
158
- return f"{field} = %s", value
155
+ return SQL("{} IS NULL").format(field), NO_VALUE
156
+ return SQL("{} = %s").format(field), value
159
157
 
160
158
 
161
- def _not_equal(field: str, value: Any) -> tuple[str, Any]:
159
+ def _not_equal(field: Composable, value: Any) -> tuple[Composed, Any]:
162
160
  # we use IS DISTINCT FROM to correctly handle NULL values
163
161
  # (not handled by !=)
164
- return f"{field} IS DISTINCT FROM %s", value
162
+ return SQL("{} IS DISTINCT FROM %s").format(field), value
165
163
 
166
164
 
167
- def _greater_than(field: str, value: Any) -> tuple[str, Any]:
165
+ def _greater_than(field: Composable, value: Any) -> tuple[Composed, Any]:
168
166
  if isinstance(value, str):
169
167
  try:
170
168
  datetime.fromisoformat(value)
@@ -177,11 +175,10 @@ def _greater_than(field: str, value: Any) -> tuple[str, Any]:
177
175
  if type(value) in [list, Jsonb]:
178
176
  msg = f"Filter value can't be of type {type(value)} using operators '>', '>=', '<', '<='"
179
177
  raise FilterError(msg)
178
+ return SQL("{} > %s").format(field), value
180
179
 
181
- return f"{field} > %s", value
182
180
 
183
-
184
- def _greater_than_equal(field: str, value: Any) -> tuple[str, Any]:
181
+ def _greater_than_equal(field: Composable, value: Any) -> tuple[Composed, Any]:
185
182
  if isinstance(value, str):
186
183
  try:
187
184
  datetime.fromisoformat(value)
@@ -194,11 +191,10 @@ def _greater_than_equal(field: str, value: Any) -> tuple[str, Any]:
194
191
  if type(value) in [list, Jsonb]:
195
192
  msg = f"Filter value can't be of type {type(value)} using operators '>', '>=', '<', '<='"
196
193
  raise FilterError(msg)
197
-
198
- return f"{field} >= %s", value
194
+ return SQL("{} >= %s").format(field), value
199
195
 
200
196
 
201
- def _less_than(field: str, value: Any) -> tuple[str, Any]:
197
+ def _less_than(field: Composable, value: Any) -> tuple[Composed, Any]:
202
198
  if isinstance(value, str):
203
199
  try:
204
200
  datetime.fromisoformat(value)
@@ -211,11 +207,10 @@ def _less_than(field: str, value: Any) -> tuple[str, Any]:
211
207
  if type(value) in [list, Jsonb]:
212
208
  msg = f"Filter value can't be of type {type(value)} using operators '>', '>=', '<', '<='"
213
209
  raise FilterError(msg)
210
+ return SQL("{} < %s").format(field), value
214
211
 
215
- return f"{field} < %s", value
216
212
 
217
-
218
- def _less_than_equal(field: str, value: Any) -> tuple[str, Any]:
213
+ def _less_than_equal(field: Composable, value: Any) -> tuple[Composed, Any]:
219
214
  if isinstance(value, str):
220
215
  try:
221
216
  datetime.fromisoformat(value)
@@ -228,39 +223,37 @@ def _less_than_equal(field: str, value: Any) -> tuple[str, Any]:
228
223
  if type(value) in [list, Jsonb]:
229
224
  msg = f"Filter value can't be of type {type(value)} using operators '>', '>=', '<', '<='"
230
225
  raise FilterError(msg)
231
-
232
- return f"{field} <= %s", value
226
+ return SQL("{} <= %s").format(field), value
233
227
 
234
228
 
235
- def _not_in(field: str, value: Any) -> tuple[str, list]:
229
+ def _not_in(field: Composable, value: Any) -> tuple[Composed, list]:
236
230
  if not isinstance(value, list):
237
231
  msg = f"{field}'s value must be a list when using 'not in' comparator in Pinecone"
238
232
  raise FilterError(msg)
239
-
240
- return f"{field} IS NULL OR {field} != ALL(%s)", [value]
233
+ return SQL("{} IS NULL OR {} != ALL(%s)").format(field, field), [value]
241
234
 
242
235
 
243
- def _in(field: str, value: Any) -> tuple[str, list]:
236
+ def _in(field: Composable, value: Any) -> tuple[Composed, list]:
244
237
  if not isinstance(value, list):
245
238
  msg = f"{field}'s value must be a list when using 'in' comparator in Pinecone"
246
239
  raise FilterError(msg)
247
240
 
248
241
  # see https://www.psycopg.org/psycopg3/docs/basic/adapt.html#lists-adaptation
249
- return f"{field} = ANY(%s)", [value]
242
+ return SQL("{} = ANY(%s)").format(field), [value]
250
243
 
251
244
 
252
- def _like(field: str, value: Any) -> tuple[str, Any]:
245
+ def _like(field: Composable, value: Any) -> tuple[Composed, Any]:
253
246
  if not isinstance(value, str):
254
247
  msg = f"{field}'s value must be a str when using 'LIKE' "
255
248
  raise FilterError(msg)
256
- return f"{field} LIKE %s", value
249
+ return SQL("{} LIKE %s").format(field), value
257
250
 
258
251
 
259
- def _not_like(field: str, value: Any) -> tuple[str, Any]:
252
+ def _not_like(field: Composable, value: Any) -> tuple[Composed, Any]:
260
253
  if not isinstance(value, str):
261
254
  msg = f"{field}'s value must be a str when using 'LIKE' "
262
255
  raise FilterError(msg)
263
- return f"{field} NOT LIKE %s", value
256
+ return SQL("{} NOT LIKE %s").format(field), value
264
257
 
265
258
 
266
259
  COMPARISON_OPERATORS = {
@@ -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,24 +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")
140
147
 
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'"
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
145
156
 
146
157
 
147
- def test_treat_meta_field_rejects_unsafe_metadata_key():
148
- with pytest.raises(FilterError, match="Invalid metadata field name"):
149
- _treat_meta_field(field="meta.name' OR 1=1 --", value="x")
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 --")
150
163
 
151
164
 
152
165
  def test_comparison_condition_missing_operator():
@@ -206,10 +219,16 @@ def test_logical_condition_nested():
206
219
  ],
207
220
  }
208
221
  query, values = _parse_logical_condition(condition)
209
- assert query == (
210
- "((meta->>'domain' IS DISTINCT FROM %s OR meta->>'chapter' = ANY(%s)) "
211
- "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
+ ")"
212
230
  )
231
+ assert _render(query) == expected_sql
213
232
  assert values == ["science", [["intro", "conclusion"]], 90, [["John", "Jane"]]]
214
233
 
215
234
 
@@ -222,7 +241,9 @@ def test_convert_filters_to_where_clause_and_params():
222
241
  ],
223
242
  }
224
243
  where_clause, params = _convert_filters_to_where_clause_and_params(filters)
225
- 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
226
247
  assert params == (100, "intro")
227
248
 
228
249
 
@@ -235,7 +256,9 @@ def test_convert_filters_to_where_clause_and_params_handle_null():
235
256
  ],
236
257
  }
237
258
  where_clause, params = _convert_filters_to_where_clause_and_params(filters)
238
- 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
239
262
  assert params == ("intro",)
240
263
 
241
264