Skip to content

Commit 12dfc39

Browse files
authored
Merge pull request #1 from paradedb/ankitml/querying
feat: bulk of querying
2 parents f330a4b + c1d5b8e commit 12dfc39

21 files changed

Lines changed: 1201 additions & 28 deletions

paradedb/sqlalchemy/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from . import facets, indexing, pdb, search, select_with
1+
from . import expr, facets, indexing, inspect, pdb, search, select_with
22

3-
__all__ = ["facets", "indexing", "pdb", "search", "select_with"]
3+
__all__ = ["expr", "facets", "indexing", "inspect", "pdb", "search", "select_with"]

paradedb/sqlalchemy/_pdb_cast.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def _compile_pdb_cast(element: PDBCast, compiler, **kw: Any) -> str:
3737
if element.args:
3838
args_sql = ", ".join(_render_cast_arg(arg, compiler, **kw) for arg in element.args)
3939
return f"{expr_sql}::pdb.{element.type_name}({args_sql})"
40-
return f"{expr_sql}::pdb.{element.type_name}()"
40+
return f"{expr_sql}::pdb.{element.type_name}"
4141

4242

4343
@compiles(PDBCast)

paradedb/sqlalchemy/alembic.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from __future__ import annotations
2+
3+
from alembic.operations import Operations
4+
from alembic.operations.ops import MigrateOperation
5+
6+
7+
def _quote_ident(name: str) -> str:
8+
return '"' + name.replace('"', '""') + '"'
9+
10+
11+
@Operations.register_operation("create_bm25_index")
12+
class CreateBM25IndexOp(MigrateOperation):
13+
def __init__(self, index_name: str, table_name: str, fields: list[str], key_field: str) -> None:
14+
self.index_name = index_name
15+
self.table_name = table_name
16+
self.fields = fields
17+
self.key_field = key_field
18+
19+
@classmethod
20+
def create_bm25_index(
21+
cls,
22+
operations: Operations,
23+
index_name: str,
24+
table_name: str,
25+
fields: list[str],
26+
*,
27+
key_field: str,
28+
) -> MigrateOperation:
29+
return operations.invoke(cls(index_name, table_name, fields, key_field))
30+
31+
32+
@Operations.implementation_for(CreateBM25IndexOp)
33+
def _create_bm25_index_impl(operations: Operations, operation: CreateBM25IndexOp) -> None:
34+
fields_sql = ", ".join(_quote_ident(field) for field in operation.fields)
35+
sql = (
36+
f"CREATE INDEX {_quote_ident(operation.index_name)} ON {_quote_ident(operation.table_name)} "
37+
f"USING bm25 ({fields_sql}) WITH (key_field='{operation.key_field}')"
38+
)
39+
operations.execute(sql)
40+
41+
42+
@Operations.register_operation("drop_bm25_index")
43+
class DropBM25IndexOp(MigrateOperation):
44+
def __init__(self, index_name: str, if_exists: bool = True) -> None:
45+
self.index_name = index_name
46+
self.if_exists = if_exists
47+
48+
@classmethod
49+
def drop_bm25_index(cls, operations: Operations, index_name: str, if_exists: bool = True) -> MigrateOperation:
50+
return operations.invoke(cls(index_name=index_name, if_exists=if_exists))
51+
52+
53+
@Operations.implementation_for(DropBM25IndexOp)
54+
def _drop_bm25_index_impl(operations: Operations, operation: DropBM25IndexOp) -> None:
55+
if_exists_sql = " IF EXISTS" if operation.if_exists else ""
56+
operations.execute(f"DROP INDEX{if_exists_sql} {_quote_ident(operation.index_name)}")
57+
58+
59+
@Operations.register_operation("reindex_bm25")
60+
class ReindexBM25Op(MigrateOperation):
61+
def __init__(self, index_name: str, concurrently: bool = False) -> None:
62+
self.index_name = index_name
63+
self.concurrently = concurrently
64+
65+
@classmethod
66+
def reindex_bm25(cls, operations: Operations, index_name: str, concurrently: bool = False) -> MigrateOperation:
67+
return operations.invoke(cls(index_name=index_name, concurrently=concurrently))
68+
69+
70+
@Operations.implementation_for(ReindexBM25Op)
71+
def _reindex_bm25_impl(operations: Operations, operation: ReindexBM25Op) -> None:
72+
concurrently_sql = " CONCURRENTLY" if operation.concurrently else ""
73+
operations.execute(f"REINDEX INDEX{concurrently_sql} {_quote_ident(operation.index_name)}")

paradedb/sqlalchemy/errors.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from __future__ import annotations
2+
3+
4+
class ParadeDBError(Exception):
5+
"""Base class for ParadeDB SQLAlchemy integration errors."""
6+
7+
8+
class InvalidArgumentError(ParadeDBError, ValueError):
9+
"""Raised when a helper receives invalid user arguments."""
10+
11+
12+
class BM25ValidationError(ParadeDBError, ValueError):
13+
"""Base class for BM25 index validation errors."""
14+
15+
16+
class MissingKeyFieldError(BM25ValidationError):
17+
"""Raised when a BM25 index is missing key_field option."""
18+
19+
20+
class InvalidKeyFieldError(BM25ValidationError):
21+
"""Raised when BM25 key_field is not part of index fields."""
22+
23+
24+
class DuplicateTokenizerAliasError(BM25ValidationError):
25+
"""Raised when tokenizer aliases are duplicated in one BM25 index."""
26+
27+
28+
class InvalidBM25FieldError(BM25ValidationError):
29+
"""Raised when non-BM25Field expressions are used in a BM25 index."""
30+
31+
32+
class RuntimeGuardError(ParadeDBError, ValueError):
33+
"""Base class for runtime guardrail violations on statement builders."""
34+
35+
36+
class SnippetWithFuzzyPredicateError(RuntimeGuardError):
37+
"""Raised when snippet/snippets helpers are used with fuzzy predicates."""

paradedb/sqlalchemy/expr.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from __future__ import annotations
2+
3+
from sqlalchemy import func, literal
4+
from sqlalchemy.sql.elements import ClauseElement
5+
6+
from .indexing import TokenizerSpec
7+
8+
9+
def json_text(json_expr: ClauseElement, key: str) -> ClauseElement:
10+
return json_expr.op("->>")(literal(key))
11+
12+
13+
def concat_ws(separator: str, *parts: ClauseElement) -> ClauseElement:
14+
return func.concat_ws(separator, *parts)
15+
16+
17+
def tokenizer_alias(tokenizer: TokenizerSpec | None) -> str | None:
18+
return None if tokenizer is None else tokenizer.alias

paradedb/sqlalchemy/indexing.py

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

3+
import re
34
from dataclasses import dataclass
45
from typing import Any
56

@@ -10,6 +11,14 @@
1011
from sqlalchemy.sql.elements import ClauseElement, ColumnElement
1112
from sqlalchemy.sql.visitors import InternalTraversal
1213

14+
from .errors import (
15+
DuplicateTokenizerAliasError,
16+
InvalidArgumentError,
17+
InvalidBM25FieldError,
18+
InvalidKeyFieldError,
19+
MissingKeyFieldError,
20+
)
21+
1322

1423
@dataclass(frozen=True)
1524
class TokenizerSpec:
@@ -23,7 +32,7 @@ def render(self) -> str:
2332
return self.raw_sql
2433

2534
if self.name is None:
26-
raise ValueError("tokenizer name is required unless raw_sql is provided")
35+
raise InvalidArgumentError("tokenizer name is required unless raw_sql is provided")
2736

2837
if not self.options:
2938
return f"pdb.{self.name}()"
@@ -135,28 +144,28 @@ def validate_bm25_index(index: Index) -> None:
135144
return
136145

137146
if not index.expressions:
138-
raise ValueError("BM25 indexes must include at least one BM25Field")
147+
raise InvalidBM25FieldError("BM25 indexes must include at least one BM25Field")
139148

140149
if not all(isinstance(expr, BM25Field) for expr in index.expressions):
141-
raise ValueError("BM25 indexes must use BM25Field for every indexed field")
150+
raise InvalidBM25FieldError("BM25 indexes must use BM25Field for every indexed field")
142151

143152
aliases: set[str] = set()
144153
for expr in index.expressions:
145154
tokenizer = expr.tokenizer
146155
if tokenizer is None or tokenizer.alias is None:
147156
continue
148157
if tokenizer.alias in aliases:
149-
raise ValueError(f"Duplicate tokenizer alias '{tokenizer.alias}' in BM25 index")
158+
raise DuplicateTokenizerAliasError(f"Duplicate tokenizer alias '{tokenizer.alias}' in BM25 index")
150159
aliases.add(tokenizer.alias)
151160

152161
with_options = index.dialect_options["postgresql"].get("with") or {}
153162
key_field = with_options.get("key_field")
154163
if not key_field:
155-
raise ValueError("BM25 indexes require postgresql_with={'key_field': '<column>'}")
164+
raise MissingKeyFieldError("BM25 indexes require postgresql_with={'key_field': '<column>'}")
156165

157166
field_names = {_bm25_field_name(expr) for expr in index.expressions}
158167
if key_field not in field_names:
159-
raise ValueError(f"BM25 key_field '{key_field}' must match one of the indexed BM25Field columns")
168+
raise InvalidKeyFieldError(f"BM25 key_field '{key_field}' must match one of the indexed BM25Field columns")
160169

161170

162171
@event.listens_for(Index, "before_create")
@@ -172,6 +181,99 @@ class IndexMeta:
172181
aliases: dict[str, str]
173182

174183

184+
_KEY_FIELD_RE = re.compile(r"key_field\s*=\s*'?\"?([^'\",)\s]+)\"?'?", re.IGNORECASE)
185+
_ALIAS_RE = re.compile(r"alias\s*=\s*([A-Za-z_][A-Za-z0-9_]*)", re.IGNORECASE)
186+
_CAST_FIELD_RE = re.compile(r"^\(*\"?([A-Za-z_][A-Za-z0-9_]*)\"?\)*\s*::\s*pdb\.", re.IGNORECASE)
187+
_PLAIN_FIELD_RE = re.compile(r'^\(*"?([A-Za-z_][A-Za-z0-9_]*)"?\)*$')
188+
189+
190+
def _split_top_level_csv(expr: str) -> list[str]:
191+
parts: list[str] = []
192+
current: list[str] = []
193+
depth = 0
194+
in_single = False
195+
in_double = False
196+
197+
for ch in expr:
198+
if ch == "'" and not in_double:
199+
in_single = not in_single
200+
current.append(ch)
201+
continue
202+
if ch == '"' and not in_single:
203+
in_double = not in_double
204+
current.append(ch)
205+
continue
206+
if not in_single and not in_double:
207+
if ch == "(":
208+
depth += 1
209+
elif ch == ")":
210+
depth = max(0, depth - 1)
211+
elif ch == "," and depth == 0:
212+
piece = "".join(current).strip()
213+
if piece:
214+
parts.append(piece)
215+
current = []
216+
continue
217+
current.append(ch)
218+
219+
tail = "".join(current).strip()
220+
if tail:
221+
parts.append(tail)
222+
return parts
223+
224+
225+
def _extract_bm25_field_list(indexdef: str) -> list[str]:
226+
marker = re.search(r"USING\s+bm25\s*\(", indexdef, re.IGNORECASE)
227+
if marker is None:
228+
return []
229+
230+
start = marker.end()
231+
depth = 1
232+
in_single = False
233+
in_double = False
234+
i = start
235+
while i < len(indexdef):
236+
ch = indexdef[i]
237+
if ch == "'" and not in_double:
238+
in_single = not in_single
239+
elif ch == '"' and not in_single:
240+
in_double = not in_double
241+
elif not in_single and not in_double:
242+
if ch == "(":
243+
depth += 1
244+
elif ch == ")":
245+
depth -= 1
246+
if depth == 0:
247+
return _split_top_level_csv(indexdef[start:i])
248+
i += 1
249+
return []
250+
251+
252+
def _extract_field_name(field_expr: str) -> str | None:
253+
expr = field_expr.strip()
254+
cast_match = _CAST_FIELD_RE.match(expr)
255+
if cast_match:
256+
return cast_match.group(1)
257+
plain_match = _PLAIN_FIELD_RE.match(expr)
258+
if plain_match:
259+
return plain_match.group(1)
260+
return None
261+
262+
263+
def _extract_key_field(indexdef: str) -> str | None:
264+
match = _KEY_FIELD_RE.search(indexdef)
265+
if match:
266+
return match.group(1)
267+
return None
268+
269+
270+
def _extract_alias(index_expr: str) -> str | None:
271+
match = _ALIAS_RE.search(index_expr)
272+
if match:
273+
return match.group(1)
274+
return None
275+
276+
175277
def describe(engine: Engine, table) -> list[IndexMeta]:
176278
query = text(
177279
"""
@@ -188,21 +290,26 @@ def describe(engine: Engine, table) -> list[IndexMeta]:
188290
output: list[IndexMeta] = []
189291
for row in rows:
190292
indexdef: str = row.indexdef
191-
key_field: str | None = None
192-
marker = "key_field='"
193-
marker_idx = indexdef.find(marker)
194-
if marker_idx != -1:
195-
key_start = marker_idx + len(marker)
196-
key_end = indexdef.find("'", key_start)
197-
if key_end != -1:
198-
key_field = indexdef[key_start:key_end]
293+
key_field = _extract_key_field(indexdef)
294+
raw_fields = _extract_bm25_field_list(indexdef)
295+
aliases: dict[str, str] = {}
296+
fields_ordered: list[str] = []
297+
for raw in raw_fields:
298+
field_name = _extract_field_name(raw)
299+
if field_name is None:
300+
continue
301+
if field_name not in fields_ordered:
302+
fields_ordered.append(field_name)
303+
alias = _extract_alias(raw)
304+
if alias is not None:
305+
aliases[alias] = field_name
199306

200307
output.append(
201308
IndexMeta(
202309
index_name=row.indexname,
203310
key_field=key_field,
204-
fields=(),
205-
aliases={},
311+
fields=tuple(fields_ordered),
312+
aliases=aliases,
206313
)
207314
)
208315
return output

paradedb/sqlalchemy/inspect.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from sqlalchemy.sql import visitors
6+
7+
from ._pdb_cast import PDBCast
8+
9+
_PARADEDB_PREDICATE_OPS = {"|||", "&&&", "###", "@@@", "==="}
10+
11+
12+
def collect_paradedb_operators(clause: Any) -> set[str]:
13+
operators: set[str] = set()
14+
15+
def visit_binary(binary) -> None:
16+
opstring = getattr(binary.operator, "opstring", None)
17+
if opstring in _PARADEDB_PREDICATE_OPS:
18+
operators.add(opstring)
19+
20+
visitors.traverse(clause, {}, {"binary": visit_binary})
21+
return operators
22+
23+
24+
def has_paradedb_predicate(clause: Any) -> bool:
25+
return bool(collect_paradedb_operators(clause))
26+
27+
28+
def _contains_fuzzy_cast(expr: Any) -> bool:
29+
if isinstance(expr, PDBCast):
30+
if expr.type_name == "fuzzy":
31+
return True
32+
return _contains_fuzzy_cast(expr.expr)
33+
return False
34+
35+
36+
def has_fuzzy_predicate(clause: Any) -> bool:
37+
found = False
38+
39+
def visit_binary(binary) -> None:
40+
nonlocal found
41+
if found:
42+
return
43+
opstring = getattr(binary.operator, "opstring", None)
44+
if opstring != "===":
45+
return
46+
right = getattr(binary, "right", None)
47+
if _contains_fuzzy_cast(right):
48+
found = True
49+
50+
visitors.traverse(clause, {}, {"binary": visit_binary})
51+
return found

0 commit comments

Comments
 (0)