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.
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/CHANGELOG.md +16 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/PKG-INFO +1 -1
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/document_stores/pgvector/filters.py +51 -58
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/tests/test_filters.py +44 -21
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/.gitignore +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/LICENSE.txt +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/README.md +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/examples/embedding_retrieval.py +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/examples/hybrid_retrieval.py +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/pydoc/config_docusaurus.yml +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/pyproject.toml +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/components/retrievers/pgvector/__init__.py +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/components/retrievers/pgvector/embedding_retriever.py +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/components/retrievers/pgvector/keyword_retriever.py +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/components/retrievers/py.typed +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/document_stores/pgvector/__init__.py +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/document_stores/pgvector/converters.py +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/document_stores/pgvector/document_store.py +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/src/haystack_integrations/document_stores/py.typed +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/tests/__init__.py +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/tests/conftest.py +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/tests/test_converters.py +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/tests/test_document_store.py +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/tests/test_document_store_async.py +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/tests/test_retrieval.py +0 -0
- {pgvector_haystack-6.2.0 → pgvector_haystack-6.3.0}/tests/test_retrieval_async.py +0 -0
- {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.
|
|
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} ") +
|
|
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[
|
|
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 =
|
|
88
|
-
|
|
89
|
-
sql_query =
|
|
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[
|
|
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
|
-
|
|
109
|
+
sql_field: Composable = _treat_meta_field(field, value)
|
|
110
|
+
else:
|
|
111
|
+
sql_field = Identifier(field)
|
|
114
112
|
|
|
115
|
-
|
|
116
|
-
return
|
|
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) ->
|
|
117
|
+
def _treat_meta_field(field: str, value: Any) -> Composed:
|
|
120
118
|
"""
|
|
121
|
-
Internal method that
|
|
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
|
-
|
|
129
|
+
Composed([SQL('(meta->>'), Literal('number'), SQL(')::integer')])
|
|
127
130
|
|
|
128
131
|
>>> _treat_meta_field(field="meta.name", value="my_name")
|
|
129
|
-
|
|
130
|
-
"""
|
|
132
|
+
Composed([SQL('meta->>'), Literal('name')])
|
|
131
133
|
|
|
132
|
-
|
|
134
|
+
"""
|
|
133
135
|
field_name = field.split(".", 1)[-1]
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
148
|
+
composed = SQL("(") + composed + SQL(f")::{type_value}")
|
|
150
149
|
|
|
151
|
-
return
|
|
150
|
+
return composed
|
|
152
151
|
|
|
153
152
|
|
|
154
|
-
def _equal(field:
|
|
153
|
+
def _equal(field: Composable, value: Any) -> tuple[Composed, Any]:
|
|
155
154
|
if value is None:
|
|
156
|
-
|
|
157
|
-
|
|
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:
|
|
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
|
|
162
|
+
return SQL("{} IS DISTINCT FROM %s").format(field), value
|
|
165
163
|
|
|
166
164
|
|
|
167
|
-
def _greater_than(field:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
242
|
+
return SQL("{} = ANY(%s)").format(field), [value]
|
|
250
243
|
|
|
251
244
|
|
|
252
|
-
def _like(field:
|
|
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
|
|
249
|
+
return SQL("{} LIKE %s").format(field), value
|
|
257
250
|
|
|
258
251
|
|
|
259
|
-
def _not_like(field:
|
|
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
|
|
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.
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
142
|
-
assert _treat_meta_field(field="meta.
|
|
143
|
-
assert _treat_meta_field(field="meta.
|
|
144
|
-
assert _treat_meta_field(field="meta.name", value=
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|