Skip to content

Commit f330a4b

Browse files
committed
Indexing
0 parents  commit f330a4b

21 files changed

Lines changed: 1195 additions & 0 deletions

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.venv/
2+
3+
# Python cache
4+
__pycache__/
5+
*.py[cod]
6+
*$py.class

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# sqlalchemy-paradedb
2+
3+
SQLAlchemy query helpers for ParadeDB.

paradedb/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from . import sqlalchemy
2+
3+
__all__ = ["sqlalchemy"]

paradedb/sqlalchemy/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from . import facets, indexing, pdb, search, select_with
2+
3+
__all__ = ["facets", "indexing", "pdb", "search", "select_with"]

paradedb/sqlalchemy/_functions.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Sequence
4+
from typing import Any
5+
6+
from sqlalchemy import literal
7+
from sqlalchemy.ext.compiler import compiles
8+
from sqlalchemy.sql.elements import ClauseElement, ColumnElement
9+
10+
11+
class PDBFunctionWithNamedArgs(ColumnElement[Any]):
12+
"""Render pdb.<name>(..., key => value) SQL."""
13+
14+
inherit_cache = True
15+
16+
def __init__(
17+
self,
18+
name: str,
19+
positional_args: Sequence[ClauseElement],
20+
named_args: Sequence[tuple[str, Any]],
21+
) -> None:
22+
self.name = name
23+
self.positional_args = tuple(positional_args)
24+
self.named_args = tuple(named_args)
25+
26+
27+
def _render_named_arg_value(value: Any, compiler, **kw: Any) -> str:
28+
if isinstance(value, ClauseElement):
29+
return compiler.process(value, **kw)
30+
return compiler.process(literal(value), **kw)
31+
32+
33+
@compiles(PDBFunctionWithNamedArgs, "postgresql")
34+
def _compile_pdb_function_with_named_args(element: PDBFunctionWithNamedArgs, compiler, **kw: Any) -> str:
35+
positional_sql = [compiler.process(arg, **kw) for arg in element.positional_args]
36+
named_sql = [f'{name} => {_render_named_arg_value(value, compiler, **kw)}' for name, value in element.named_args]
37+
args_sql = ", ".join([*positional_sql, *named_sql])
38+
return f"pdb.{element.name}({args_sql})"
39+
40+
41+
@compiles(PDBFunctionWithNamedArgs)
42+
def _compile_pdb_function_with_named_args_default(element: PDBFunctionWithNamedArgs, compiler, **kw: Any) -> str:
43+
raise NotImplementedError("ParadeDB function wrappers are only supported for PostgreSQL dialects")

paradedb/sqlalchemy/_pdb_cast.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Sequence
4+
from typing import Any
5+
6+
from sqlalchemy import literal
7+
from sqlalchemy.ext.compiler import compiles
8+
from sqlalchemy.sql.elements import ClauseElement, ColumnElement
9+
10+
11+
class PDBCast(ColumnElement[Any]):
12+
"""Render an expression cast to a ParadeDB pseudo type, e.g. expr::pdb.boost(2)."""
13+
14+
inherit_cache = True
15+
16+
def __init__(self, expr: ClauseElement, type_name: str, args: Sequence[Any] = ()) -> None:
17+
self.expr = expr
18+
self.type_name = type_name
19+
self.args = tuple(args)
20+
21+
22+
def _render_cast_arg(arg: Any, compiler, **kw: Any) -> str:
23+
if isinstance(arg, ClauseElement):
24+
return compiler.process(arg, **kw)
25+
if isinstance(arg, bool):
26+
return "t" if arg else "f"
27+
if isinstance(arg, (int, float)):
28+
return str(arg)
29+
if isinstance(arg, str):
30+
return "'" + arg.replace("'", "''") + "'"
31+
return compiler.process(literal(arg), **kw)
32+
33+
34+
@compiles(PDBCast, "postgresql")
35+
def _compile_pdb_cast(element: PDBCast, compiler, **kw: Any) -> str:
36+
expr_sql = compiler.process(element.expr, **kw)
37+
if element.args:
38+
args_sql = ", ".join(_render_cast_arg(arg, compiler, **kw) for arg in element.args)
39+
return f"{expr_sql}::pdb.{element.type_name}({args_sql})"
40+
return f"{expr_sql}::pdb.{element.type_name}()"
41+
42+
43+
@compiles(PDBCast)
44+
def _compile_pdb_cast_default(element: PDBCast, compiler, **kw: Any) -> str:
45+
raise NotImplementedError("ParadeDB casts are only supported for PostgreSQL dialects")

paradedb/sqlalchemy/facets.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from __future__ import annotations
2+
3+
4+
def value_count(*, field: str) -> dict[str, dict[str, str]]:
5+
return {"value_count": {"field": field}}
6+
7+
8+
def avg(*, field: str) -> dict[str, dict[str, str]]:
9+
return {"avg": {"field": field}}
10+
11+
12+
def terms(*, field: str, size: int | None = None) -> dict[str, dict[str, object]]:
13+
payload: dict[str, object] = {"field": field}
14+
if size is not None:
15+
payload["size"] = size
16+
return {"terms": payload}
17+
18+
19+
def multi(*aggs: dict[str, object]) -> dict[str, object]:
20+
merged: dict[str, object] = {}
21+
for agg in aggs:
22+
merged.update(agg)
23+
return merged

paradedb/sqlalchemy/indexing.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Any
5+
6+
from sqlalchemy import Index, event, text
7+
from sqlalchemy.engine import Engine
8+
from sqlalchemy.exc import CompileError
9+
from sqlalchemy.ext.compiler import compiles
10+
from sqlalchemy.sql.elements import ClauseElement, ColumnElement
11+
from sqlalchemy.sql.visitors import InternalTraversal
12+
13+
14+
@dataclass(frozen=True)
15+
class TokenizerSpec:
16+
name: str | None = None
17+
options: tuple[tuple[str, Any], ...] = ()
18+
raw_sql: str | None = None
19+
alias: str | None = None
20+
21+
def render(self) -> str:
22+
if self.raw_sql is not None:
23+
return self.raw_sql
24+
25+
if self.name is None:
26+
raise ValueError("tokenizer name is required unless raw_sql is provided")
27+
28+
if not self.options:
29+
return f"pdb.{self.name}()"
30+
31+
rendered_options = ",".join(f"{key}={_format_option_value(value)}" for key, value in self.options)
32+
escaped = rendered_options.replace("'", "''")
33+
return f"pdb.{self.name}('{escaped}')"
34+
35+
36+
def _format_option_value(value: Any) -> str:
37+
if isinstance(value, bool):
38+
return "true" if value else "false"
39+
return str(value)
40+
41+
42+
def _build_spec(name: str, *, alias: str | None = None, **kwargs: Any) -> TokenizerSpec:
43+
options: dict[str, Any] = {key: value for key, value in kwargs.items() if value is not None}
44+
if alias is not None:
45+
options["alias"] = alias
46+
return TokenizerSpec(name=name, options=tuple(sorted(options.items())), alias=alias)
47+
48+
49+
def unicode(*, alias: str | None = None, lowercase: bool | None = None, stemmer: str | None = None) -> TokenizerSpec:
50+
# ParadeDB currently exposes this tokenizer as `unicode_words`.
51+
return _build_spec("unicode_words", alias=alias, lowercase=lowercase, stemmer=stemmer)
52+
53+
54+
def literal(*, alias: str | None = None) -> TokenizerSpec:
55+
return _build_spec("literal", alias=alias)
56+
57+
58+
def literal_normalized(*, alias: str | None = None) -> TokenizerSpec:
59+
return _build_spec("literal_normalized", alias=alias)
60+
61+
62+
def ngram(
63+
*,
64+
alias: str | None = None,
65+
min_gram: int | None = None,
66+
max_gram: int | None = None,
67+
prefix_only: bool | None = None,
68+
) -> TokenizerSpec:
69+
return _build_spec(
70+
"ngram",
71+
alias=alias,
72+
min_gram=min_gram,
73+
max_gram=max_gram,
74+
prefix_only=prefix_only,
75+
)
76+
77+
78+
def raw(sql: str, *, alias: str | None = None) -> TokenizerSpec:
79+
return TokenizerSpec(raw_sql=sql, alias=alias)
80+
81+
82+
class _TokenizeNamespace:
83+
unicode = staticmethod(unicode)
84+
literal = staticmethod(literal)
85+
literal_normalized = staticmethod(literal_normalized)
86+
ngram = staticmethod(ngram)
87+
raw = staticmethod(raw)
88+
89+
90+
tokenize = _TokenizeNamespace()
91+
92+
93+
class BM25Field(ColumnElement[Any]):
94+
"""Represents a ParadeDB BM25 index field expression."""
95+
96+
inherit_cache = True
97+
_traverse_internals = [
98+
("expr", InternalTraversal.dp_clauseelement),
99+
("tokenizer", InternalTraversal.dp_plain_obj),
100+
]
101+
102+
def __init__(self, expr: ClauseElement, *, tokenizer: TokenizerSpec | None = None) -> None:
103+
self.expr = expr
104+
self.tokenizer = tokenizer
105+
106+
@property
107+
def table(self): # pragma: no cover - SQLAlchemy internals may use this dynamically
108+
return getattr(self.expr, "table", None)
109+
110+
111+
@compiles(BM25Field, "postgresql")
112+
def _compile_bm25_field(element: BM25Field, compiler, **kw: Any) -> str:
113+
expr_sql = compiler.process(element.expr, **kw)
114+
if element.tokenizer is None:
115+
return expr_sql
116+
return f"({expr_sql}::{element.tokenizer.render()})"
117+
118+
119+
@compiles(BM25Field)
120+
def _compile_bm25_field_default(element: BM25Field, compiler, **kw: Any) -> str:
121+
raise CompileError("BM25Field is only supported for PostgreSQL dialects")
122+
123+
124+
def _is_bm25_index(index: Index) -> bool:
125+
using = index.dialect_options["postgresql"].get("using")
126+
return bool(using and str(using).lower() == "bm25")
127+
128+
129+
def _bm25_field_name(field: BM25Field) -> str | None:
130+
return getattr(getattr(field, "expr", None), "name", None)
131+
132+
133+
def validate_bm25_index(index: Index) -> None:
134+
if not _is_bm25_index(index):
135+
return
136+
137+
if not index.expressions:
138+
raise ValueError("BM25 indexes must include at least one BM25Field")
139+
140+
if not all(isinstance(expr, BM25Field) for expr in index.expressions):
141+
raise ValueError("BM25 indexes must use BM25Field for every indexed field")
142+
143+
aliases: set[str] = set()
144+
for expr in index.expressions:
145+
tokenizer = expr.tokenizer
146+
if tokenizer is None or tokenizer.alias is None:
147+
continue
148+
if tokenizer.alias in aliases:
149+
raise ValueError(f"Duplicate tokenizer alias '{tokenizer.alias}' in BM25 index")
150+
aliases.add(tokenizer.alias)
151+
152+
with_options = index.dialect_options["postgresql"].get("with") or {}
153+
key_field = with_options.get("key_field")
154+
if not key_field:
155+
raise ValueError("BM25 indexes require postgresql_with={'key_field': '<column>'}")
156+
157+
field_names = {_bm25_field_name(expr) for expr in index.expressions}
158+
if key_field not in field_names:
159+
raise ValueError(f"BM25 key_field '{key_field}' must match one of the indexed BM25Field columns")
160+
161+
162+
@event.listens_for(Index, "before_create")
163+
def _validate_bm25_before_create(index: Index, connection, **kw: Any) -> None:
164+
validate_bm25_index(index)
165+
166+
167+
@dataclass(frozen=True)
168+
class IndexMeta:
169+
index_name: str
170+
key_field: str | None
171+
fields: tuple[str, ...]
172+
aliases: dict[str, str]
173+
174+
175+
def describe(engine: Engine, table) -> list[IndexMeta]:
176+
query = text(
177+
"""
178+
SELECT indexname, indexdef
179+
FROM pg_indexes
180+
WHERE schemaname = current_schema()
181+
AND tablename = :table_name
182+
AND indexdef ILIKE '%USING bm25%'
183+
ORDER BY indexname
184+
"""
185+
)
186+
187+
rows = engine.connect().execute(query, {"table_name": table.name}).fetchall()
188+
output: list[IndexMeta] = []
189+
for row in rows:
190+
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]
199+
200+
output.append(
201+
IndexMeta(
202+
index_name=row.indexname,
203+
key_field=key_field,
204+
fields=(),
205+
aliases={},
206+
)
207+
)
208+
return output

0 commit comments

Comments
 (0)