Skip to content

Commit 023c423

Browse files
authored
feat: Support StrEnum arguments (#37)
1 parent 797a0cd commit 023c423

2 files changed

Lines changed: 192 additions & 17 deletions

File tree

paradedb/sqlalchemy/search.py

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@
3737
_PDB_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
3838

3939

40+
def _text_literal(value: str) -> ClauseElement:
41+
return literal(value, type_=Text())
42+
43+
44+
def _text_array(values: Sequence[str]) -> ClauseElement:
45+
return array([str(value) for value in values], type_=Text())
46+
47+
4048
def _inline_string_literal(value: str) -> ClauseElement:
4149
return literal_column("'" + value.replace("'", "''") + "'", Text())
4250

@@ -46,8 +54,8 @@ def _to_term_payload(*terms: str) -> ClauseElement:
4654
raise InvalidArgumentError("at least one search term is required")
4755
require_non_empty_strings(terms, field_name="terms")
4856
if len(terms) == 1:
49-
return literal(terms[0])
50-
return array(list(terms), type_=Text())
57+
return _text_literal(terms[0])
58+
return _text_array(terms)
5159

5260

5361
def _apply_boost(expr: ClauseElement, boost: float | None) -> ClauseElement:
@@ -83,13 +91,13 @@ def _apply_tokenizer(expr: ClauseElement, tokenizer: str | None) -> ClauseElemen
8391
def _to_phrase_payload(value: str | Sequence[str]) -> ClauseElement:
8492
if isinstance(value, str):
8593
require_non_empty_string(value, field_name="value")
86-
return literal(value)
94+
return _text_literal(value)
8795
if not isinstance(value, Sequence):
8896
raise InvalidArgumentError("value must be a non-empty string or a sequence of non-empty strings")
8997
if not value:
9098
raise InvalidArgumentError("value must contain at least one token")
9199
require_non_empty_strings(value, field_name="value")
92-
return array(list(value), type_=Text())
100+
return _text_array(value)
93101

94102

95103
def _apply_score_tuning(
@@ -171,7 +179,7 @@ def term(
171179
tokenizer: str | None = None,
172180
) -> ColumnElement[bool]:
173181
require_non_empty_string(value, field_name="value")
174-
payload: ClauseElement = literal(value)
182+
payload: ClauseElement = _text_literal(value)
175183
payload = _apply_fuzzy(payload, distance=distance, prefix=prefix, transpose_cost_one=transpose_cost_one)
176184
payload = _apply_tokenizer(payload, tokenizer)
177185
payload = _apply_score_tuning(payload, boost=boost, const=const)
@@ -209,7 +217,7 @@ def regex(
209217
const: float | None = None,
210218
) -> ColumnElement[bool]:
211219
require_non_empty_string(pattern, field_name="pattern")
212-
payload: ClauseElement = func.pdb.regex(pattern)
220+
payload: ClauseElement = func.pdb.regex(_text_literal(pattern))
213221
payload = _apply_score_tuning(payload, boost=boost, const=const)
214222
return field.operate(_QUERY, payload)
215223

@@ -239,8 +247,8 @@ def prox_regex(pattern: str, max_expansions: int | None = None) -> ProximityExpr
239247
require_non_empty_string(pattern, field_name="pattern")
240248
if max_expansions is not None:
241249
require_non_negative(max_expansions, field_name="max_expansions")
242-
return ProximityExpr(func.pdb.prox_regex(pattern, max_expansions))
243-
return ProximityExpr(func.pdb.prox_regex(pattern))
250+
return ProximityExpr(func.pdb.prox_regex(_text_literal(pattern), max_expansions))
251+
return ProximityExpr(func.pdb.prox_regex(_text_literal(pattern)))
244252

245253

246254
def prox_array(*clauses: str | ProximityExpr) -> ProximityExpr:
@@ -294,7 +302,7 @@ def parse(
294302
field: ColumnElement, query: str, *, lenient: bool = False, conjunction_mode: bool = False
295303
) -> ColumnElement[bool]:
296304
require_non_empty_string(query, field_name="query")
297-
return field.operate(_QUERY, func.pdb.parse(query, lenient, conjunction_mode))
305+
return field.operate(_QUERY, func.pdb.parse(_text_literal(query), lenient, conjunction_mode))
298306

299307

300308
def phrase_prefix(field: ColumnElement, terms: list[str], *, max_expansions: int | None = None) -> ColumnElement[bool]:
@@ -303,9 +311,9 @@ def phrase_prefix(field: ColumnElement, terms: list[str], *, max_expansions: int
303311
require_non_empty_strings(terms, field_name="terms")
304312
if max_expansions is not None:
305313
require_positive(max_expansions, field_name="max_expansions")
306-
return field.operate(_QUERY, func.pdb.phrase_prefix(array(terms, type_=Text()), max_expansions))
314+
return field.operate(_QUERY, func.pdb.phrase_prefix(_text_array(terms), max_expansions))
307315
else:
308-
return field.operate(_QUERY, func.pdb.phrase_prefix(array(terms, type_=Text())))
316+
return field.operate(_QUERY, func.pdb.phrase_prefix(_text_array(terms)))
309317

310318

311319
def regex_phrase(
@@ -321,9 +329,9 @@ def regex_phrase(
321329
require_non_negative(slop, field_name="slop")
322330
if max_expansions is not None:
323331
require_positive(max_expansions, field_name="max_expansions")
324-
return field.operate(_QUERY, func.pdb.regex_phrase(array(terms, type_=Text()), slop, max_expansions))
332+
return field.operate(_QUERY, func.pdb.regex_phrase(_text_array(terms), slop, max_expansions))
325333
else:
326-
return field.operate(_QUERY, func.pdb.regex_phrase(array(terms, type_=Text()), slop))
334+
return field.operate(_QUERY, func.pdb.regex_phrase(_text_array(terms), slop))
327335

328336

329337
def range_term(
@@ -373,7 +381,7 @@ def range_term(
373381
escaped = bounds.replace("'", "''")
374382
range_bounds_arg: ClauseElement = literal_column(f"'{escaped}'::{range_type}")
375383
else:
376-
range_bounds_arg = literal(bounds)
384+
range_bounds_arg = _text_literal(bounds)
377385
return field.operate(_QUERY, func.pdb.range_term(range_bounds_arg, relation_arg))
378386

379387

@@ -461,12 +469,12 @@ def more_like_this(
461469
if boost_factor is not None:
462470
named_options.append(("boost_factor", boost_factor))
463471
if stopwords is not None:
464-
named_options.append(("stopwords", array(stopwords, type_=Text())))
472+
named_options.append(("stopwords", _text_array(stopwords)))
465473

466474
def _build_mlt_call(source_arg: ClauseElement, *, include_fields: bool) -> ClauseElement:
467475
positional_args: list[ClauseElement] = [source_arg]
468476
if include_fields and fields is not None:
469-
positional_args.append(array(fields, type_=Text()))
477+
positional_args.append(_text_array(fields))
470478
return PDBFunctionWithNamedArgs("more_like_this", positional_args, named_options)
471479

472480
if document_ids is not None:
@@ -479,4 +487,5 @@ def _build_mlt_call(source_arg: ClauseElement, *, include_fields: bool) -> Claus
479487
return field.operate(_QUERY, _build_mlt_call(literal(document_id), include_fields=True))
480488

481489
payload = document if isinstance(document, str) else json.dumps(document, separators=(",", ":"), sort_keys=True)
482-
return field.operate(_QUERY, _build_mlt_call(literal(payload), include_fields=False))
490+
payload_arg = _text_literal(payload) if isinstance(payload, str) else literal(payload)
491+
return field.operate(_QUERY, _build_mlt_call(payload_arg, include_fields=False))

tests/unit/test_sql_compilation_unit.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
from __future__ import annotations
22

3+
try:
4+
from enum import StrEnum
5+
except ImportError:
6+
# StrEnum was introduced in Python 3.11, so we use this fallback when testing against 3.10
7+
from enum import Enum
8+
9+
class StrEnum(str, Enum):
10+
def __str__(self) -> str:
11+
return str(self.value)
12+
13+
314
import pytest
415
from sqlalchemy import Integer, String, Text, and_, not_, or_, select
516
from sqlalchemy.dialects import postgresql
@@ -19,6 +30,15 @@
1930
)
2031

2132

33+
class SearchTerm(StrEnum):
34+
running = "running"
35+
shoes = "shoes"
36+
phrase = "running shoes"
37+
typo = "shose"
38+
regex = "run.*"
39+
range_bounds = "[3,9]"
40+
41+
2242
def _sql(stmt) -> str:
2343
sql = str(
2444
stmt.compile(
@@ -110,6 +130,28 @@ def test_regex_and_all_compile():
110130
)
111131

112132

133+
def test_regex_with_str_enum_compile():
134+
stmt = select(products.c.id).where(search.regex(products.c.description, SearchTerm.regex))
135+
sql = _sql(stmt)
136+
assert (
137+
sql
138+
== """SELECT products.id
139+
FROM products
140+
WHERE products.description @@@ pdb.regex('run.*')"""
141+
)
142+
143+
144+
def test_parse_with_str_enum_compile():
145+
stmt = select(products.c.id).where(search.parse(products.c.id, SearchTerm.phrase, lenient=True))
146+
sql = _sql(stmt)
147+
assert (
148+
sql
149+
== """SELECT products.id
150+
FROM products
151+
WHERE products.id @@@ pdb.parse('running shoes', true, false)"""
152+
)
153+
154+
113155
def test_match_any_with_tokenizer_compile():
114156
stmt = select(products.c.id).where(
115157
search.match_any(products.c.description, "running shoes", tokenizer="whitespace")
@@ -123,6 +165,58 @@ def test_match_any_with_tokenizer_compile():
123165
)
124166

125167

168+
def test_match_all_with_str_enum_and_tokenizer_compile():
169+
stmt = select(products.c.id).where(
170+
search.match_all(products.c.description, SearchTerm.phrase, tokenizer="whitespace")
171+
)
172+
sql = _sql(stmt)
173+
assert (
174+
sql
175+
== """SELECT products.id
176+
FROM products
177+
WHERE products.description &&& 'running shoes'::pdb.whitespace"""
178+
)
179+
180+
181+
def test_match_all_multiple_str_enum_with_fuzzy_compile():
182+
stmt = select(products.c.id).where(
183+
search.match_all(products.c.description, SearchTerm.running, SearchTerm.shoes, distance=1)
184+
)
185+
sql = _sql(stmt)
186+
assert (
187+
sql
188+
== """SELECT products.id
189+
FROM products
190+
WHERE products.description &&& ARRAY['running', 'shoes']::pdb.fuzzy(1)"""
191+
)
192+
193+
194+
def test_match_any_with_str_enum_and_tokenizer_compile():
195+
stmt = select(products.c.id).where(
196+
search.match_any(products.c.description, SearchTerm.phrase, tokenizer="whitespace")
197+
)
198+
sql = _sql(stmt)
199+
assert (
200+
sql
201+
== """SELECT products.id
202+
FROM products
203+
WHERE products.description ||| 'running shoes'::pdb.whitespace"""
204+
)
205+
206+
207+
def test_match_any_multiple_str_enum_with_fuzzy_compile():
208+
stmt = select(products.c.id).where(
209+
search.match_any(products.c.description, SearchTerm.running, SearchTerm.typo, distance=1, prefix=True)
210+
)
211+
sql = _sql(stmt)
212+
assert (
213+
sql
214+
== """SELECT products.id
215+
FROM products
216+
WHERE products.description ||| ARRAY['running', 'shose']::pdb.fuzzy(1, t)"""
217+
)
218+
219+
126220
def test_phrase_with_tokenizer_compile():
127221
stmt = select(products.c.id).where(search.phrase(products.c.description, "running shoes", tokenizer="whitespace"))
128222
sql = _sql(stmt)
@@ -134,6 +228,17 @@ def test_phrase_with_tokenizer_compile():
134228
)
135229

136230

231+
def test_phrase_with_str_enum_and_slop_compile():
232+
stmt = select(products.c.id).where(search.phrase(products.c.description, SearchTerm.phrase, slop=2))
233+
sql = _sql(stmt)
234+
assert (
235+
sql
236+
== """SELECT products.id
237+
FROM products
238+
WHERE products.description ### 'running shoes'::pdb.slop(2)"""
239+
)
240+
241+
137242
def test_phrase_pretokenized_with_slop_compile():
138243
stmt = select(products.c.id).where(search.phrase(products.c.description, ["shoes", "running"], slop=2))
139244
sql = _sql(stmt)
@@ -145,6 +250,32 @@ def test_phrase_pretokenized_with_slop_compile():
145250
)
146251

147252

253+
def test_phrase_prefix_with_str_enum_compile():
254+
stmt = select(products.c.id).where(
255+
search.phrase_prefix(products.c.description, [SearchTerm.running, SearchTerm.shoes])
256+
)
257+
sql = _sql(stmt)
258+
assert (
259+
sql
260+
== """SELECT products.id
261+
FROM products
262+
WHERE products.description @@@ pdb.phrase_prefix(ARRAY['running', 'shoes'])"""
263+
)
264+
265+
266+
def test_regex_phrase_with_str_enum_compile():
267+
stmt = select(products.c.id).where(
268+
search.regex_phrase(products.c.description, [SearchTerm.running, SearchTerm.shoes], slop=1)
269+
)
270+
sql = _sql(stmt)
271+
assert (
272+
sql
273+
== """SELECT products.id
274+
FROM products
275+
WHERE products.description @@@ pdb.regex_phrase(ARRAY['running', 'shoes'], 1)"""
276+
)
277+
278+
148279
def test_phrase_with_slop_and_const_compile():
149280
stmt = select(products.c.id).where(search.phrase(products.c.description, "running shoes", slop=2, const=1.0))
150281
sql = _sql(stmt)
@@ -156,6 +287,17 @@ def test_phrase_with_slop_and_const_compile():
156287
)
157288

158289

290+
def test_term_fuzzy_with_str_enum_compile():
291+
stmt = select(products.c.id).where(search.term(products.c.description, SearchTerm.typo, distance=1))
292+
sql = _sql(stmt)
293+
assert (
294+
sql
295+
== """SELECT products.id
296+
FROM products
297+
WHERE products.description === 'shose'::pdb.fuzzy(1)"""
298+
)
299+
300+
159301
def test_regex_boost_compile():
160302
stmt = select(products.c.id).where(search.regex(products.c.description, "key.*", boost=2.0))
161303
sql = _sql(stmt)
@@ -303,6 +445,17 @@ def test_parse_phrase_prefix_regex_phrase_compile():
303445
)
304446

305447

448+
def test_range_term_with_str_enum_compile():
449+
stmt = select(products.c.id).where(search.range_term(products.c.id, SearchTerm.range_bounds))
450+
sql = _sql(stmt)
451+
assert (
452+
sql
453+
== """SELECT products.id
454+
FROM products
455+
WHERE products.id @@@ pdb.range_term('[3,9]', 'Intersects')"""
456+
)
457+
458+
306459
def test_simple_proximity_query():
307460
prox_stmt = select(products.c.id).where(
308461
search.proximity(products.c.description, search.prox_str("running").within(2, "shoe"))
@@ -316,6 +469,19 @@ def test_simple_proximity_query():
316469
)
317470

318471

472+
def test_prox_regex_with_str_enum_compile():
473+
prox_stmt = select(products.c.id).where(
474+
search.proximity(products.c.description, search.prox_regex(SearchTerm.regex).within(1, SearchTerm.shoes))
475+
)
476+
477+
assert (
478+
_sql(prox_stmt)
479+
== """SELECT products.id
480+
FROM products
481+
WHERE products.description @@@ ((pdb.prox_regex('run.*') ## 1) ## 'shoes')"""
482+
)
483+
484+
319485
def test_proximity_query_with_boost():
320486
prox_stmt = select(products.c.id).where(
321487
search.proximity(products.c.description, search.prox_str("running").within(2, "shoe"), boost=1.24)

0 commit comments

Comments
 (0)