Skip to content

Commit 7acc0dd

Browse files
authored
feat: Various improvements and clean ups (#30)
1 parent e6621ff commit 7acc0dd

20 files changed

Lines changed: 705 additions & 165 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ jobs:
5050
- name: Type check
5151
run: uv run --extra dev mypy paradedb
5252

53+
- name: Check API coverage
54+
run: uv run scripts/check_api_coverage.py
55+
5356
matrix-tests:
5457
name: Python ${{ matrix.python-version }}
5558
runs-on: ubuntu-latest

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
CLAUDE.md

CLAUDE.md

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,17 @@
22

33
## Unit Tests
44

5-
Run unit tests (no database required) before every push — this is what CI checks first:
5+
Run the tests to verify every change:
66

77
```bash
88
.venv/bin/python -m pytest -m "not integration"
9-
```
10-
11-
## Integration Tests
12-
13-
Integration tests require a running ParadeDB/Postgres instance. Use the provided script to start the container and set up the correct DSN:
14-
15-
```bash
16-
PARADEDB_PASSWORD=postgres scripts/run_integration_tests.sh
9+
scripts/run_integration_tests.sh
1710
```
1811

1912
To run a subset of tests, pass pytest selectors:
2013

2114
```bash
22-
PARADEDB_PASSWORD=postgres scripts/run_integration_tests.sh tests/integration/test_indexing_integration.py::test_bm25_partial_index_generates_where_clause
15+
scripts/run_integration_tests.sh tests/integration/test_indexing_integration.py::test_bm25_partial_index_generates_where_clause
2316
```
2417

2518
The script sets `PARADEDB_TEST_DSN` and `DATABASE_URL` automatically. The default container name is `paradedb-sqlalchemy-integration` on port `5443`.

README.md

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ base = (
9292
.limit(10)
9393
)
9494

95-
stmt, facet_plan = facets.with_rows(
95+
stmt = facets.with_rows(
9696
base,
9797
agg=facets.multi(
9898
facets.value_count(field="id"),
@@ -103,7 +103,7 @@ stmt, facet_plan = facets.with_rows(
103103

104104
with Session(engine) as session:
105105
rows = session.execute(stmt).all()
106-
facet_payload = facet_plan.extract(rows)
106+
facet_payload = facets.extract(rows)
107107
```
108108

109109
## Search Patterns
@@ -135,11 +135,7 @@ search.more_like_this(Product.id, document_id=1, fields=["description"])
135135
from sqlalchemy import select
136136
from paradedb.sqlalchemy import search
137137

138-
prox = search.prox_array("running").near(
139-
search.prox_regex("sho.*"),
140-
distance=1,
141-
ordered=True,
142-
)
138+
prox = search.prox_array("running").within(1, search.prox_regex("sho.*"), ordered=True)
143139
stmt = select(Product.id).where(search.proximity(Product.description, prox))
144140
```
145141

api.json

Lines changed: 36 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,57 @@
11
{
22
"operators": {
3-
"OP_SEARCH": "@@@",
4-
"OP_AND": "&&&",
5-
"OP_OR": "|||",
6-
"OP_PHRASE": "###",
7-
"OP_PROXIMITY": "##",
3+
"OP_SEARCH": "@@@",
4+
"OP_AND": "&&&",
5+
"OP_OR": "|||",
6+
"OP_PHRASE": "###",
7+
"OP_PROXIMITY": "##",
88
"OP_PROXIMITY_ORD": "##>",
9-
"OP_TERM": "==="
9+
"OP_TERM": "==="
1010
},
1111

1212
"functions": {
13-
"FN_ALL": "pdb.all",
14-
"FN_PARSE": "pdb.parse",
15-
"FN_PHRASE_PREFIX": "pdb.phrase_prefix",
16-
"FN_REGEX_PHRASE": "pdb.regex_phrase",
17-
"FN_RANGE_TERM": "pdb.range_term",
18-
"FN_REGEX": "pdb.regex",
19-
"FN_PROX_REGEX": "pdb.prox_regex",
20-
"FN_PROX_ARRAY": "pdb.prox_array",
21-
"FN_MORE_LIKE_THIS": "pdb.more_like_this",
22-
"FN_EXISTS": "pdb.exists",
13+
"FN_ALL": "pdb.all",
14+
"FN_PARSE": "pdb.parse",
15+
"FN_PHRASE_PREFIX": "pdb.phrase_prefix",
16+
"FN_REGEX_PHRASE": "pdb.regex_phrase",
17+
"FN_RANGE_TERM": "pdb.range_term",
18+
"FN_REGEX": "pdb.regex",
19+
"FN_PROX_REGEX": "pdb.prox_regex",
20+
"FN_PROX_ARRAY": "pdb.prox_array",
21+
"FN_MORE_LIKE_THIS": "pdb.more_like_this",
22+
"FN_EXISTS": "pdb.exists",
2323

24-
"FN_SCORE": "pdb.score",
25-
"FN_SNIPPET": "pdb.snippet",
26-
"FN_SNIPPETS": "pdb.snippets",
27-
"FN_SNIPPET_POSITIONS": "pdb.snippet_positions",
28-
"FN_AGG": "pdb.agg",
24+
"FN_SCORE": "pdb.score",
25+
"FN_SNIPPET": "pdb.snippet",
26+
"FN_SNIPPETS": "pdb.snippets",
27+
"FN_SNIPPET_POSITIONS": "pdb.snippet_positions",
28+
"FN_AGG": "pdb.agg",
2929

30-
"FN_INDEXES": "pdb.indexes",
31-
"FN_INDEX_SEGMENTS": "pdb.index_segments",
32-
"FN_VERIFY_INDEX": "pdb.verify_index",
33-
"FN_VERIFY_ALL_INDEXES": "pdb.verify_all_indexes"
30+
"FN_INDEX_SEGMENTS": "pdb.index_segments",
31+
"FN_INDEXES": "pdb.indexes",
32+
"FN_VERIFY_ALL_INDEXES": "pdb.verify_all_indexes",
33+
"FN_VERIFY_INDEX": "pdb.verify_index"
3434
},
3535

3636
"types": {
37-
"PDB_TYPE_BOOST": "pdb.boost",
38-
"PDB_TYPE_CONST": "pdb.const",
39-
"PDB_TYPE_FUZZY": "pdb.fuzzy",
40-
"PDB_TYPE_SLOP": "pdb.slop",
41-
"PDB_TYPE_QUERY": "pdb.query",
42-
"PDB_TYPE_PROXIMITYCLAUSE": "pdb.proximityclause",
37+
"PDB_TYPE_BOOST": "pdb.boost",
38+
"PDB_TYPE_CONST": "pdb.const",
39+
"PDB_TYPE_FUZZY": "pdb.fuzzy",
40+
"PDB_TYPE_SLOP": "pdb.slop",
41+
"PDB_TYPE_QUERY": "pdb.query",
4342

44-
"PDB_TYPE_TOKENIZER_ICU": "pdb.icu",
43+
"PDB_TYPE_TOKENIZER_ALIAS": "pdb.alias",
44+
"PDB_TYPE_TOKENIZER_CHINESE_COMPATIBLE": "pdb.chinese_compatible",
45+
"PDB_TYPE_TOKENIZER_JIEBA": "pdb.jieba",
4546
"PDB_TYPE_TOKENIZER_LINDERA": "pdb.lindera",
4647
"PDB_TYPE_TOKENIZER_LITERAL": "pdb.literal",
4748
"PDB_TYPE_TOKENIZER_LITERAL_NORMALIZED": "pdb.literal_normalized",
4849
"PDB_TYPE_TOKENIZER_NGRAM": "pdb.ngram",
4950
"PDB_TYPE_TOKENIZER_REGEX": "pdb.regex_pattern",
5051
"PDB_TYPE_TOKENIZER_SIMPLE": "pdb.simple",
52+
"PDB_TYPE_TOKENIZER_SOURCE_CODE": "pdb.source_code",
5153
"PDB_TYPE_TOKENIZER_UNICODE_WORDS": "pdb.unicode_words",
52-
"PDB_TYPE_TOKENIZER_WHITESPACE": "pdb.whitespace"
54+
"PDB_TYPE_TOKENIZER_WHITESPACE": "pdb.whitespace",
55+
"PDB_TYPE_TOKENIZER_ICU": "pdb.icu"
5356
}
5457
}

apiignore.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,6 @@
207207

208208
"types": [
209209
"pdb.alias",
210-
"pdb.chinese_compatible",
211-
"pdb.jieba",
212-
"pdb.source_code"
210+
"pdb.proximityclause"
213211
]
214212
}

paradedb/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
)
88
from .sqlalchemy.facets import with_rows
99
from .sqlalchemy.indexing import BM25Field, assert_indexed, describe, tokenize
10-
from .sqlalchemy.pdb import agg, score, snippet, snippet_positions, snippets
10+
from .sqlalchemy.pdb import agg, alias, score, snippet, snippet_positions, snippets
1111
from .sqlalchemy.search import (
1212
ProximityExpr,
1313
all,
@@ -30,6 +30,7 @@
3030
"BM25Field",
3131
"ProximityExpr",
3232
"agg",
33+
"alias",
3334
"all",
3435
"assert_indexed",
3536
"describe",

paradedb/sqlalchemy/facets.py

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

3-
from dataclasses import dataclass
43
from collections.abc import Sequence
54
from typing import Any
65

@@ -102,20 +101,16 @@ def ensure_operator(stmt: Select, *, key_field: ColumnElement) -> Select:
102101
return stmt.where(search.all(key_field))
103102

104103

105-
@dataclass(frozen=True)
106-
class FacetPlan:
107-
label: str = "facets"
108-
109-
def extract(self, rows: list[object]) -> Any | None:
110-
if not rows:
111-
return None
112-
first = rows[0]
113-
mapping = getattr(first, "_mapping", None)
114-
if mapping is not None and self.label in mapping:
115-
return mapping[self.label]
116-
if isinstance(first, Sequence) and not isinstance(first, (str, bytes)):
117-
return first[-1]
104+
def extract(rows: Sequence[object], *, label: str = "facets") -> Any | None:
105+
if not rows:
118106
return None
107+
first = rows[0]
108+
mapping = getattr(first, "_mapping", None)
109+
if mapping is not None and label in mapping:
110+
return mapping[label]
111+
if isinstance(first, Sequence) and not isinstance(first, (str, bytes)):
112+
return first[-1]
113+
return None
119114

120115

121116
def with_rows(
@@ -125,7 +120,7 @@ def with_rows(
125120
key_field: ColumnElement,
126121
label: str = "facets",
127122
ensure_predicate: bool = True,
128-
) -> tuple[Select, FacetPlan]:
123+
) -> Select:
129124
if not has_order_by(base_stmt):
130125
raise FacetRequiresOrderByError("with_rows requires ORDER BY")
131126
if not has_limit(base_stmt):
@@ -136,4 +131,4 @@ def with_rows(
136131
raise FacetRequiresParadeDBPredicateError("with_rows requires a ParadeDB predicate")
137132

138133
stmt = stmt.add_columns(pdb.agg(agg).over().label(label))
139-
return stmt, FacetPlan(label=label)
134+
return stmt

paradedb/sqlalchemy/indexing.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from sqlalchemy.sql.visitors import InternalTraversal
1313

1414
from ._select_introspection import has_limit, has_order_by
15+
from ._pdb_cast import PDBCast
1516
from .errors import (
1617
DuplicateTokenizerAliasError,
1718
FieldNotIndexedError,
@@ -198,6 +199,42 @@ def icu(
198199
)
199200

200201

202+
def chinese_compatible(
203+
*,
204+
alias: str | None = None,
205+
args: Sequence[Any] | None = None,
206+
named_args: Mapping[str, Any] | None = None,
207+
filters: Sequence[str] | None = None,
208+
stemmer: str | None = None,
209+
) -> TokenizerSpec:
210+
return _build_spec(
211+
"chinese_compatible",
212+
alias=alias,
213+
args=args,
214+
named_args=named_args,
215+
filters=filters,
216+
stemmer=stemmer,
217+
)
218+
219+
220+
def jieba(
221+
*,
222+
alias: str | None = None,
223+
args: Sequence[Any] | None = None,
224+
named_args: Mapping[str, Any] | None = None,
225+
filters: Sequence[str] | None = None,
226+
stemmer: str | None = None,
227+
) -> TokenizerSpec:
228+
return _build_spec(
229+
"jieba",
230+
alias=alias,
231+
args=args,
232+
named_args=named_args,
233+
filters=filters,
234+
stemmer=stemmer,
235+
)
236+
237+
201238
def literal(*, alias: str | None = None) -> TokenizerSpec:
202239
return _build_spec("literal", alias=alias)
203240

@@ -286,6 +323,24 @@ def regex_pattern(
286323
)
287324

288325

326+
def source_code(
327+
*,
328+
alias: str | None = None,
329+
args: Sequence[Any] | None = None,
330+
named_args: Mapping[str, Any] | None = None,
331+
filters: Sequence[str] | None = None,
332+
stemmer: str | None = None,
333+
) -> TokenizerSpec:
334+
return _build_spec(
335+
"source_code",
336+
alias=alias,
337+
args=args,
338+
named_args=named_args,
339+
filters=filters,
340+
stemmer=stemmer,
341+
)
342+
343+
289344
def raw(sql: str, *, alias: str | None = None) -> TokenizerSpec:
290345
return TokenizerSpec(raw_sql=sql, alias=alias)
291346

@@ -340,11 +395,14 @@ class _TokenizeNamespace:
340395
simple = staticmethod(simple)
341396
whitespace = staticmethod(whitespace)
342397
icu = staticmethod(icu)
398+
chinese_compatible = staticmethod(chinese_compatible)
399+
jieba = staticmethod(jieba)
343400
literal = staticmethod(literal)
344401
literal_normalized = staticmethod(literal_normalized)
345402
ngram = staticmethod(ngram)
346403
lindera = staticmethod(lindera)
347404
regex_pattern = staticmethod(regex_pattern)
405+
source_code = staticmethod(source_code)
348406
raw = staticmethod(raw)
349407
custom = staticmethod(custom)
350408
from_config = staticmethod(from_config)
@@ -375,6 +433,8 @@ def table(self): # pragma: no cover - SQLAlchemy internals may use this dynamic
375433
def _compile_bm25_field(element: BM25Field, compiler, **kw: Any) -> str:
376434
expr_sql = compiler.process(element.expr, **kw)
377435
if element.tokenizer is None:
436+
if isinstance(element.expr, PDBCast) or _bm25_field_name(element) is None:
437+
return f"({expr_sql})"
378438
return expr_sql
379439
return f"(({expr_sql})::{element.tokenizer.render()})"
380440

paradedb/sqlalchemy/pdb.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,28 @@
33
import json
44
from typing import Any
55

6-
from sqlalchemy import cast, func, literal
7-
from sqlalchemy.dialects.postgresql import JSONB
6+
from sqlalchemy import Text, func, literal, literal_column
87
from sqlalchemy.sql.elements import ClauseElement, ColumnElement
98

109
from .errors import InvalidArgumentError
1110
from ._functions import PDBFunctionWithNamedArgs
11+
from ._pdb_cast import PDBCast
1212
from .validation import require_non_empty_string, require_non_negative, require_positive
1313

1414

15+
def _inline_string_literal(value: str) -> ClauseElement:
16+
return literal_column("'" + value.replace("'", "''") + "'", Text())
17+
18+
1519
def score(field: ColumnElement) -> ClauseElement:
1620
return func.pdb.score(field)
1721

1822

23+
def alias(field: ColumnElement, name: str) -> ClauseElement:
24+
require_non_empty_string(name, field_name="name")
25+
return PDBCast(field.self_group(), "alias", (name,))
26+
27+
1928
def snippet(
2029
field: ColumnElement,
2130
*,
@@ -95,7 +104,7 @@ def agg(spec: dict[str, Any], *, approximate: bool | None = None) -> ClauseEleme
95104
if not isinstance(spec, dict) or not spec:
96105
raise InvalidArgumentError("spec must be a non-empty dict")
97106
payload = json.dumps(spec, separators=(",", ":"), sort_keys=True)
98-
payload_expr = cast(literal(payload), JSONB)
107+
payload_expr = _inline_string_literal(payload)
99108
if approximate is None:
100109
return func.pdb.agg(payload_expr)
101110
# pdb.agg() takes an optional second positional boolean: true = exact (default),

0 commit comments

Comments
 (0)