supabase-orm 0.0.1.dev1__tar.gz → 0.1.1__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.
Files changed (40) hide show
  1. supabase_orm-0.1.1/.github/workflows/publish.yml +97 -0
  2. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/PKG-INFO +6 -6
  3. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/README.md +1 -1
  4. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/pyproject.toml +4 -4
  5. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/src/supabase_orm/__init__.py +3 -0
  6. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/src/supabase_orm/_base.py +6 -0
  7. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/src/supabase_orm/_filters.py +22 -3
  8. supabase_orm-0.1.1/src/supabase_orm/_predicates.py +274 -0
  9. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/src/supabase_orm/_query.py +50 -10
  10. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/src/supabase_orm/_version.py +2 -2
  11. supabase_orm-0.1.1/tests/integration/test_predicates.py +163 -0
  12. supabase_orm-0.1.1/tests/test_predicates.py +284 -0
  13. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/test_wire.py +3 -3
  14. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/.env.example +0 -0
  15. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/.gitignore +0 -0
  16. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/LICENSE +0 -0
  17. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/src/supabase_orm/_client.py +0 -0
  18. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/src/supabase_orm/_embed.py +0 -0
  19. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/src/supabase_orm/_exceptions.py +0 -0
  20. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/src/supabase_orm/_rpc.py +0 -0
  21. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/src/supabase_orm/_serializers.py +0 -0
  22. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/__init__.py +0 -0
  23. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/conftest.py +0 -0
  24. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/integration/README.md +0 -0
  25. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/integration/__init__.py +0 -0
  26. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/integration/conftest.py +0 -0
  27. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/integration/schema.sql +0 -0
  28. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/integration/test_embeds.py +0 -0
  29. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/integration/test_filters.py +0 -0
  30. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/integration/test_rpc.py +0 -0
  31. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/integration/test_writes_and_terminals.py +0 -0
  32. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/test_base.py +0 -0
  33. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/test_client.py +0 -0
  34. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/test_embed.py +0 -0
  35. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/test_exceptions.py +0 -0
  36. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/test_filters.py +0 -0
  37. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/test_query.py +0 -0
  38. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/test_rpc.py +0 -0
  39. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/test_serializers.py +0 -0
  40. {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/uv.lock +0 -0
@@ -0,0 +1,97 @@
1
+ name: Publish to PyPI
2
+
3
+ # Fires when a PEP 440 version tag is pushed (e.g. 0.1.0, 0.1.0a1, 0.1.0.dev0).
4
+ # We tag without a ``v`` prefix so hatch-vcs reads the tag as the version directly.
5
+ on:
6
+ push:
7
+ tags:
8
+ - "[0-9]+.[0-9]+.[0-9]+*"
9
+
10
+ concurrency:
11
+ group: ${{ github.workflow }}-${{ github.ref }}
12
+ cancel-in-progress: false
13
+
14
+ # Single source of truth for the PyPI distribution name. Used in the
15
+ # environment URL, the release-notes install snippet, and the PyPI link.
16
+ env:
17
+ PACKAGE_NAME: supabase-orm
18
+
19
+ jobs:
20
+ publish:
21
+ name: Build and publish
22
+ runs-on: ubuntu-latest
23
+
24
+ # Requires a "pypi" environment with trusted-publishing configured.
25
+ # See https://docs.pypi.org/trusted-publishers/
26
+ environment:
27
+ name: pypi
28
+ url: https://pypi.org/p/${{ env.PACKAGE_NAME }}
29
+
30
+ permissions:
31
+ id-token: write # trusted publishing
32
+ contents: write # create GitHub release
33
+
34
+ steps:
35
+ - name: Checkout
36
+ uses: actions/checkout@v6
37
+ with:
38
+ fetch-depth: 0 # hatch-vcs needs full history to resolve the tag
39
+
40
+ - name: Install uv
41
+ uses: astral-sh/setup-uv@v8.1.0
42
+
43
+ - name: Install dependencies
44
+ run: uv sync --dev
45
+
46
+ - name: Lint
47
+ run: uv run ruff check .
48
+
49
+ - name: Run mock tests
50
+ run: uv run pytest
51
+
52
+ - name: Build distribution
53
+ run: uv build
54
+
55
+ - name: Publish to PyPI
56
+ uses: pypa/gh-action-pypi-publish@release/v1
57
+
58
+ - name: Create GitHub release
59
+ env:
60
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
61
+ run: |
62
+ set -euo pipefail
63
+
64
+ VERSION="${GITHUB_REF_NAME}"
65
+
66
+ # PEP 440 pre-release? aN / bN / rcN / .devN
67
+ PRERELEASE_FLAG=""
68
+ if [[ "${VERSION}" =~ (a|b|rc|\.dev)[0-9] ]]; then
69
+ PRERELEASE_FLAG="--prerelease"
70
+ fi
71
+
72
+ # Categorised notes from GitHub's native generator
73
+ # (driven by .github/release.yml if present).
74
+ AUTO_NOTES=$(gh api \
75
+ -X POST \
76
+ "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
77
+ -f tag_name="${VERSION}" \
78
+ --jq .body)
79
+
80
+ {
81
+ echo "## Install"
82
+ echo
83
+ echo '```bash'
84
+ echo "pip install '${PACKAGE_NAME}==${VERSION}'"
85
+ echo '```'
86
+ echo
87
+ echo "PyPI: https://pypi.org/project/${PACKAGE_NAME}/${VERSION}/"
88
+ echo
89
+ echo "${AUTO_NOTES}"
90
+ } > /tmp/release-notes.md
91
+
92
+ gh release create "${VERSION}" \
93
+ --title "${VERSION}" \
94
+ --notes-file /tmp/release-notes.md \
95
+ --verify-tag \
96
+ ${PRERELEASE_FLAG} \
97
+ dist/*
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: supabase-orm
3
- Version: 0.0.1.dev1
3
+ Version: 0.1.1
4
4
  Summary: Lightweight async ORM on top of supabase-py with Pydantic validation.
5
- Project-URL: Homepage, https://github.com/viperadnan/supabase-orm
6
- Project-URL: Repository, https://github.com/viperadnan/supabase-orm
7
- Project-URL: Issues, https://github.com/viperadnan/supabase-orm/issues
8
- Project-URL: Documentation, https://github.com/viperadnan/supabase-orm#readme
5
+ Project-URL: Homepage, https://github.com/viperadnan-git/supabase-orm
6
+ Project-URL: Repository, https://github.com/viperadnan-git/supabase-orm
7
+ Project-URL: Issues, https://github.com/viperadnan-git/supabase-orm/issues
8
+ Project-URL: Documentation, https://github.com/viperadnan-git/supabase-orm#readme
9
9
  Author-email: Adnan Ahmad <viperadnan@gmail.com>
10
10
  License: Apache-2.0
11
11
  License-File: LICENSE
@@ -485,7 +485,7 @@ The integration suite needs a Supabase project with the test schema applied. See
485
485
 
486
486
  ## Contributing
487
487
 
488
- Issues and PRs welcome at [github.com/viperadnan/supabase-orm](https://github.com/viperadnan/supabase-orm). Run the test suite before sending a PR:
488
+ Issues and PRs welcome at [github.com/viperadnan-git/supabase-orm](https://github.com/viperadnan-git/supabase-orm). Run the test suite before sending a PR:
489
489
 
490
490
  ```bash
491
491
  uv run pytest # mock suite (fast, no network)
@@ -451,7 +451,7 @@ The integration suite needs a Supabase project with the test schema applied. See
451
451
 
452
452
  ## Contributing
453
453
 
454
- Issues and PRs welcome at [github.com/viperadnan/supabase-orm](https://github.com/viperadnan/supabase-orm). Run the test suite before sending a PR:
454
+ Issues and PRs welcome at [github.com/viperadnan-git/supabase-orm](https://github.com/viperadnan-git/supabase-orm). Run the test suite before sending a PR:
455
455
 
456
456
  ```bash
457
457
  uv run pytest # mock suite (fast, no network)
@@ -32,10 +32,10 @@ dependencies = [
32
32
  ]
33
33
 
34
34
  [project.urls]
35
- Homepage = "https://github.com/viperadnan/supabase-orm"
36
- Repository = "https://github.com/viperadnan/supabase-orm"
37
- Issues = "https://github.com/viperadnan/supabase-orm/issues"
38
- Documentation = "https://github.com/viperadnan/supabase-orm#readme"
35
+ Homepage = "https://github.com/viperadnan-git/supabase-orm"
36
+ Repository = "https://github.com/viperadnan-git/supabase-orm"
37
+ Issues = "https://github.com/viperadnan-git/supabase-orm/issues"
38
+ Documentation = "https://github.com/viperadnan-git/supabase-orm#readme"
39
39
 
40
40
  [build-system]
41
41
  requires = ["hatchling", "hatch-vcs"]
@@ -22,6 +22,7 @@ from ._exceptions import (
22
22
  SupabaseORMUsageError,
23
23
  )
24
24
  from ._filters import register_op
25
+ from ._predicates import Column, Predicate
25
26
  from ._query import QueryBuilder
26
27
  from ._rpc import rpc, rpc_maybe_one, rpc_one, rpc_scalar
27
28
  from ._serializers import register_serializer, serialize
@@ -32,6 +33,8 @@ __all__ = [
32
33
  "SupabaseModel",
33
34
  "Relation",
34
35
  "QueryBuilder",
36
+ "Column",
37
+ "Predicate",
35
38
  "lifespan",
36
39
  "get_client",
37
40
  "set_client",
@@ -28,6 +28,7 @@ from pydantic import BaseModel, ConfigDict, TypeAdapter
28
28
  from ._client import get_client
29
29
  from ._embed import Relation, build_select, collect_relations
30
30
  from ._exceptions import SupabaseORMDoesNotExist, SupabaseORMUsageError
31
+ from ._predicates import _FieldsAccess
31
32
  from ._query import QueryBuilder
32
33
  from ._serializers import serialize
33
34
 
@@ -77,6 +78,10 @@ class SupabaseModel(BaseModel):
77
78
  __list_adapter__: ClassVar[TypeAdapter | None] = None
78
79
 
79
80
  query: ClassVar[_QueryDescriptor] = _QueryDescriptor()
81
+ # Typed predicate namespace — ``Pet.f.age >= 5`` returns a Predicate.
82
+ # The actual ``_FieldsAccess`` instance is attached per subclass in
83
+ # ``__pydantic_init_subclass__``; declared here for type checkers.
84
+ f: ClassVar[_FieldsAccess]
80
85
 
81
86
  def __init_subclass__(
82
87
  cls,
@@ -107,6 +112,7 @@ class SupabaseModel(BaseModel):
107
112
  )
108
113
  cls.__relations__ = collect_relations(cls)
109
114
  cls.__list_adapter__ = TypeAdapter(list[cls])
115
+ cls.f = _FieldsAccess(cls)
110
116
  _TABLE_REGISTRY.setdefault(cls.__table__, []).append(cls)
111
117
 
112
118
  # ─── Builder entry point ───────────────────────────────────────────────
@@ -150,17 +150,36 @@ def _is(b, c, v):
150
150
  return b.is_(c, v)
151
151
 
152
152
 
153
- @register_op("contains")
153
+ # PostgREST array/range ops have two quirks vs. simple operators:
154
+ # * URL-tree wire names are short (``cs`` / ``cd`` / ``ov``); the long
155
+ # Python names map to supabase-py builder methods only.
156
+ # * Array literals use Postgres ``{a,b}`` braces inside predicate strings
157
+ # (``in`` uses ``(a,b)`` parens — different shape, same idea).
158
+ # Builder-side serialization is handled by supabase-py; we only need the
159
+ # predicate-string form here.
160
+ def _array_pred_value(val: Any) -> str:
161
+ val = serialize(val)
162
+ if isinstance(val, (list, tuple, set)):
163
+ return "{" + ",".join(_pred_value(v).strip('"') for v in val) + "}"
164
+ return _pred_value(val)
165
+
166
+
167
+ def _array_pred(wire: str) -> PredicateCompiler:
168
+ """Factory for the three array/range ops — same shape, different wire."""
169
+ return lambda c, v: f"{c}.{wire}.{_array_pred_value(v)}"
170
+
171
+
172
+ @register_op("contains", wire="cs", predicate=_array_pred("cs"))
154
173
  def _contains(b, c, v):
155
174
  return b.contains(c, serialize(v))
156
175
 
157
176
 
158
- @register_op("contained_by")
177
+ @register_op("contained_by", wire="cd", predicate=_array_pred("cd"))
159
178
  def _contained_by(b, c, v):
160
179
  return b.contained_by(c, serialize(v))
161
180
 
162
181
 
163
- @register_op("overlaps")
182
+ @register_op("overlaps", wire="ov", predicate=_array_pred("ov"))
164
183
  def _overlaps(b, c, v):
165
184
  return b.overlaps(c, serialize(v))
166
185
 
@@ -0,0 +1,274 @@
1
+ """Typed predicate builder.
2
+
3
+ Lets callers express filters as Python expressions instead of lambda
4
+ callbacks::
5
+
6
+ await Pet.query.or_(
7
+ Pet.f.species == "cat",
8
+ (Pet.f.species == "dog") & (Pet.f.age >= 5),
9
+ ).all()
10
+
11
+ Implementation:
12
+
13
+ * :class:`Column` — exposes every PostgREST operator as a typed method,
14
+ with ``==`` / ``!=`` / ``<`` / ``<=`` / ``>`` / ``>=`` overloads that
15
+ return a :class:`Predicate` instead of ``bool``.
16
+ * :class:`Predicate` — composable AST node. ``|`` builds an OR group,
17
+ ``&`` an AND group, ``~`` a NOT. Each node compiles to a single
18
+ PostgREST predicate string (``and(...)`` / ``or(...)`` / ``not.<x>``).
19
+ * :class:`_FieldsAccess` — runtime namespace exposed as
20
+ ``Model.f``. ``Model.f.<column>`` resolves to a typed
21
+ :class:`Column`. Statically declared with ``__getattr__`` so type
22
+ checkers accept any field name without losing operator return
23
+ types.
24
+
25
+ The ``_PredicateAtom``/``_PredicateAnd``/``_PredicateOr``/
26
+ ``_PredicateNot`` subclasses live in this module too. They're private
27
+ to the compile pipeline — callers should only ever see :class:`Predicate`.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from collections.abc import Sequence
33
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
34
+
35
+ from ._filters import compile_predicate
36
+
37
+ if TYPE_CHECKING:
38
+ from ._base import SupabaseModel
39
+
40
+ T = TypeVar("T")
41
+
42
+
43
+ # ─── Predicate AST ────────────────────────────────────────────────────────
44
+
45
+
46
+ class Predicate:
47
+ """Composable boolean expression that compiles to a PostgREST predicate.
48
+
49
+ Build with :class:`Column` operators (``==`` / ``>=`` / ``.in_()`` /
50
+ ``.like()`` / ...) and combine with ``|`` / ``&`` / ``~``::
51
+
52
+ (Pet.f.species == "cat") | (Pet.f.age >= 5)
53
+ ~(Pet.f.adopted == True)
54
+
55
+ Pass to :meth:`QueryBuilder.or_` or :meth:`QueryBuilder.not_`.
56
+ """
57
+
58
+ def _compile(self) -> str:
59
+ raise NotImplementedError # pragma: no cover — abstract
60
+
61
+ def __or__(self, other: Predicate) -> Predicate:
62
+ if not isinstance(other, Predicate):
63
+ return NotImplemented
64
+ return _PredicateOr(
65
+ _flatten(self, _PredicateOr) + _flatten(other, _PredicateOr)
66
+ )
67
+
68
+ def __and__(self, other: Predicate) -> Predicate:
69
+ if not isinstance(other, Predicate):
70
+ return NotImplemented
71
+ return _PredicateAnd(
72
+ _flatten(self, _PredicateAnd) + _flatten(other, _PredicateAnd)
73
+ )
74
+
75
+ def __invert__(self) -> Predicate:
76
+ return _PredicateNot(self)
77
+
78
+ # Predicates aren't hashable and aren't booleans. ``bool(pred)`` would be
79
+ # a foot-gun (``if Pet.f.age >= 5:`` reads natural but never runs the
80
+ # query) — fail loudly instead.
81
+ def __bool__(self) -> bool:
82
+ raise TypeError(
83
+ "Predicate is not a bool. Pass it to .or_() / .not_() instead of "
84
+ "evaluating it directly."
85
+ )
86
+
87
+
88
+ def _flatten(p: Predicate, kind: type[Predicate]) -> list[Predicate]:
89
+ """Flatten same-kind nesting so ``a | b | c`` compiles to
90
+ ``or(a,b,c)`` instead of ``or(or(a,b),c)``."""
91
+ if isinstance(p, kind):
92
+ # ``parts`` is set by the And/Or subclasses below; safe to read here.
93
+ return list(p.parts) # type: ignore[attr-defined]
94
+ return [p]
95
+
96
+
97
+ class _PredicateAtom(Predicate):
98
+ """Single ``column.op.value`` filter."""
99
+
100
+ __slots__ = ("op", "column", "value")
101
+
102
+ def __init__(self, op: str, column: str, value: Any) -> None:
103
+ self.op = op
104
+ self.column = column
105
+ self.value = value
106
+
107
+ def _compile(self) -> str:
108
+ return compile_predicate(self.op, self.column, self.value)
109
+
110
+
111
+ class _PredicateGroupBase(Predicate):
112
+ """Shared body for ``and(...)`` / ``or(...)`` AST nodes — same shape,
113
+ different keyword. Subclasses set :attr:`_kw` to ``"and"`` or ``"or"``."""
114
+
115
+ __slots__ = ("parts",)
116
+ _kw: str = ""
117
+
118
+ def __init__(self, parts: list[Predicate]) -> None:
119
+ self.parts = parts
120
+
121
+ def _compile(self) -> str:
122
+ return f"{self._kw}(" + ",".join(p._compile() for p in self.parts) + ")"
123
+
124
+
125
+ class _PredicateAnd(_PredicateGroupBase):
126
+ _kw = "and"
127
+
128
+
129
+ class _PredicateOr(_PredicateGroupBase):
130
+ _kw = "or"
131
+
132
+
133
+ class _PredicateNot(Predicate):
134
+ __slots__ = ("inner",)
135
+
136
+ def __init__(self, inner: Predicate) -> None:
137
+ self.inner = inner
138
+
139
+ def _compile(self) -> str:
140
+ # PostgREST logic trees accept ``not.and(...)`` / ``not.or(...)`` but
141
+ # *not* a bare ``not.col.op.val`` atom. Wrap atoms in a single-element
142
+ # ``and()`` so ``~(Pet.f.species == "cat")`` parses correctly inside
143
+ # ``or=(...)``.
144
+ if isinstance(self.inner, _PredicateAtom):
145
+ return "not.and(" + self.inner._compile() + ")"
146
+ return "not." + self.inner._compile()
147
+
148
+
149
+ # ─── Column ───────────────────────────────────────────────────────────────
150
+
151
+
152
+ class Column(Generic[T]):
153
+ """Typed column reference. Operators build :class:`Predicate` nodes.
154
+
155
+ Don't construct directly — access via ``Model.f.<column>``.
156
+ """
157
+
158
+ __slots__ = ("_name", "_model")
159
+
160
+ def __init__(self, name: str, model: type[SupabaseModel]) -> None:
161
+ self._name = name
162
+ self._model = model
163
+
164
+ def __repr__(self) -> str:
165
+ return f"<Column {self._model.__name__}.{self._name}>"
166
+
167
+ def _atom(self, op: str, value: Any) -> Predicate:
168
+ """Single source of truth for ``Column → Predicate`` construction."""
169
+ return _PredicateAtom(op, self._name, value)
170
+
171
+ # ─── Symbolic operators ────────────────────────────────────────────────
172
+
173
+ # ``__eq__`` / ``__ne__`` deliberately return Predicate, not bool — same
174
+ # trick SQLAlchemy uses. Type checkers want ``bool`` here, so we suppress
175
+ # the override warning per method.
176
+
177
+ def __eq__(self, other: T) -> Predicate: # type: ignore[override]
178
+ return self._atom("eq", other)
179
+
180
+ def __ne__(self, other: T) -> Predicate: # type: ignore[override]
181
+ return self._atom("neq", other)
182
+
183
+ def __lt__(self, other: T) -> Predicate:
184
+ return self._atom("lt", other)
185
+
186
+ def __le__(self, other: T) -> Predicate:
187
+ return self._atom("lte", other)
188
+
189
+ def __gt__(self, other: T) -> Predicate:
190
+ return self._atom("gt", other)
191
+
192
+ def __ge__(self, other: T) -> Predicate:
193
+ return self._atom("gte", other)
194
+
195
+ # Disable hashing — predicate-building objects mustn't be dict keys.
196
+ __hash__ = None # type: ignore[assignment]
197
+
198
+ # ─── Method-form operators (no symbol available) ───────────────────────
199
+
200
+ def in_(self, values: Sequence[T]) -> Predicate:
201
+ return self._atom("in_", values)
202
+
203
+ def is_(self, value: bool | None) -> Predicate:
204
+ return self._atom("is_", value)
205
+
206
+ def is_null(self) -> Predicate:
207
+ """Sugar for ``col.is_(None)`` — checks ``IS NULL``."""
208
+ return self._atom("is_", None)
209
+
210
+ def like(self, pattern: str) -> Predicate:
211
+ return self._atom("like", pattern)
212
+
213
+ def ilike(self, pattern: str) -> Predicate:
214
+ return self._atom("ilike", pattern)
215
+
216
+ def contains(self, value: Any) -> Predicate:
217
+ return self._atom("contains", value)
218
+
219
+ def contained_by(self, value: Any) -> Predicate:
220
+ return self._atom("contained_by", value)
221
+
222
+ def overlaps(self, value: Any) -> Predicate:
223
+ return self._atom("overlaps", value)
224
+
225
+ def fts(self, query: str) -> Predicate:
226
+ return self._atom("fts", query)
227
+
228
+ def plfts(self, query: str) -> Predicate:
229
+ return self._atom("plfts", query)
230
+
231
+ def phfts(self, query: str) -> Predicate:
232
+ return self._atom("phfts", query)
233
+
234
+ def wfts(self, query: str) -> Predicate:
235
+ return self._atom("wfts", query)
236
+
237
+
238
+ # ─── f namespace ──────────────────────────────────────────────────────────
239
+
240
+
241
+ class _FieldsAccess:
242
+ """Runtime namespace exposing typed :class:`Column`\\s for a model.
243
+
244
+ Created once per :class:`SupabaseModel` subclass and attached as
245
+ ``Model.f``. Statically typed with ``__getattr__`` so callers can
246
+ use any column name without triggering "unknown attribute" errors
247
+ from type checkers — the operators on the returned ``Column[Any]``
248
+ are still fully typed, so ``Pet.f.age >= 5`` resolves to
249
+ :class:`Predicate`, never ``bool``.
250
+ """
251
+
252
+ __slots__ = ("_model", "_columns")
253
+
254
+ def __init__(self, model: type[SupabaseModel]) -> None:
255
+ self._model = model
256
+ self._columns: dict[str, Column[Any]] = {
257
+ name: Column(name, model) for name in model.model_fields
258
+ }
259
+
260
+ def __getattr__(self, name: str) -> Column[Any]:
261
+ try:
262
+ return self._columns[name]
263
+ except KeyError:
264
+ raise AttributeError(
265
+ f"{self._model.__name__} has no column {name!r}. "
266
+ f"Known: {sorted(self._columns)}"
267
+ ) from None
268
+
269
+ def __repr__(self) -> str:
270
+ return f"<{self._model.__name__}.f: {sorted(self._columns)}>"
271
+
272
+ # Help IDEs that walk ``dir()`` for autocomplete.
273
+ def __dir__(self) -> list[str]:
274
+ return list(self._columns)
@@ -12,7 +12,7 @@ Don't reuse a builder after a terminal call; create a fresh one off ``Model.quer
12
12
  from __future__ import annotations
13
13
 
14
14
  from collections.abc import Sequence
15
- from typing import TYPE_CHECKING, Any, Callable, Generic, Self, TypeVar, cast
15
+ from typing import TYPE_CHECKING, Any, Callable, Generic, Self, TypeVar, cast, overload
16
16
 
17
17
  from ._client import get_client
18
18
  from ._exceptions import (
@@ -21,6 +21,7 @@ from ._exceptions import (
21
21
  SupabaseORMUsageError,
22
22
  )
23
23
  from ._filters import apply_op, compile_predicate
24
+ from ._predicates import Predicate
24
25
  from ._serializers import serialize
25
26
 
26
27
  if TYPE_CHECKING:
@@ -105,19 +106,58 @@ class _Filterable:
105
106
 
106
107
  # ─── Compound predicates ───────────────────────────────────────────────
107
108
 
109
+ # Two ways to compose OR/NOT branches:
110
+ #
111
+ # 1. Predicate objects (preferred, since 0.2.0) ─ typed & composable::
112
+ #
113
+ # Pet.query.or_(
114
+ # Pet.f.species == "cat",
115
+ # (Pet.f.species == "dog") & (Pet.f.age >= 5),
116
+ # )
117
+ #
118
+ # 2. Lambda callbacks (legacy, kept for backward compat with 0.1.x)::
119
+ #
120
+ # Pet.query.or_(lambda q: q.eq("species", "cat"))
121
+ #
122
+ # The two forms can't be mixed in a single call — we raise ``UsageError``
123
+ # rather than silently picking one. ``@overload`` gives type checkers
124
+ # the precise signature for each form.
125
+
126
+ @overload
127
+ def or_(self, *predicates: Predicate) -> Self: ...
128
+ @overload
108
129
  def or_(
109
- self,
110
- *branches: Callable[["_PredicateGroup"], "_PredicateGroup"],
111
- ) -> Self:
112
- compiled = _compile_branches(self._model, branches)
113
- return self._apply_predicate_group(f"or({compiled})")
130
+ self, *branches: Callable[["_PredicateGroup"], "_PredicateGroup"]
131
+ ) -> Self: ...
132
+ def or_(self, *args: Any) -> Self:
133
+ if not args:
134
+ raise SupabaseORMUsageError("or_() requires at least one branch.")
135
+ is_pred = [isinstance(a, Predicate) for a in args]
136
+ if all(is_pred):
137
+ compiled = ",".join(cast(Predicate, a)._compile() for a in args)
138
+ return self._apply_predicate_group(f"or({compiled})")
139
+ if not any(is_pred):
140
+ compiled = _compile_branches(self._model, args)
141
+ return self._apply_predicate_group(f"or({compiled})")
142
+ raise SupabaseORMUsageError(
143
+ "or_() can't mix Predicate args with lambda branches in a single call."
144
+ )
114
145
 
146
+ @overload
147
+ def not_(self, predicate: Predicate) -> Self: ...
148
+ @overload
115
149
  def not_(
116
- self,
117
- branch: Callable[["_PredicateGroup"], "_PredicateGroup"],
118
- ) -> Self:
150
+ self, branch: Callable[["_PredicateGroup"], "_PredicateGroup"]
151
+ ) -> Self: ...
152
+ def not_(self, arg: Any) -> Self:
153
+ if isinstance(arg, Predicate):
154
+ # Route through ``~`` (i.e. _PredicateNot._compile) so atoms get
155
+ # wrapped in ``not.and(...)`` — bare ``not.col.op.val`` doesn't
156
+ # parse inside PostgREST's logic tree.
157
+ return self._apply_predicate_group((~arg)._compile())
158
+ # Legacy lambda form.
119
159
  sub = _PredicateGroup(self._model)
120
- branch(sub)
160
+ arg(sub)
121
161
  return self._apply_predicate_group(f"not.and({sub._compile()})")
122
162
 
123
163
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.0.1.dev1'
22
- __version_tuple__ = version_tuple = (0, 0, 1, 'dev1')
21
+ __version__ = version = '0.1.1'
22
+ __version_tuple__ = version_tuple = (0, 1, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,163 @@
1
+ """Predicate API round-trips against real PostgREST.
2
+
3
+ The mock suite proves we emit the right predicate strings. These tests
4
+ prove PostgREST parses and applies them correctly — including the
5
+ ``not.or(...)``, nested ``and(or(...))``, and atom-form negation cases
6
+ that string-concatenation makes easy to get subtly wrong.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from uuid import uuid4
12
+
13
+ from .conftest import Owner, Pet
14
+
15
+
16
+ async def _seed_pets(n: int = 3) -> list[Pet]:
17
+ owner = await Owner.create(email=f"o-{uuid4()}@x.test")
18
+ pets = []
19
+ for i in range(n):
20
+ pets.append(
21
+ await Pet.create(
22
+ owner_id=owner.id,
23
+ name=f"pet-{i}",
24
+ species="cat" if i % 2 == 0 else "dog",
25
+ adopted=i == 0,
26
+ tags=["a", "b"] if i == 0 else ["b"],
27
+ amount=float(i),
28
+ )
29
+ )
30
+ return pets
31
+
32
+
33
+ # ─── Atomic predicates ────────────────────────────────────────────────────
34
+
35
+
36
+ async def test_eq_predicate_via_or_single_branch(clean):
37
+ pets = await _seed_pets()
38
+ rows = await Pet.query.or_(Pet.f.species == "cat").all()
39
+ assert {p.id for p in rows} == {p.id for p in pets if p.species == "cat"}
40
+
41
+
42
+ async def test_comparison_operators_round_trip(clean):
43
+ pets = await _seed_pets()
44
+ rows = await Pet.query.or_(Pet.f.amount >= 1).all()
45
+ assert {p.id for p in rows} == {p.id for p in pets if p.amount >= 1}
46
+
47
+ rows = await Pet.query.or_(Pet.f.amount < 2).all()
48
+ assert {p.id for p in rows} == {p.id for p in pets if p.amount < 2}
49
+
50
+
51
+ async def test_in_predicate(clean):
52
+ pets = await _seed_pets()
53
+ ids = [pets[0].id, pets[2].id]
54
+ rows = await Pet.query.or_(Pet.f.id.in_(ids)).all()
55
+ assert {p.id for p in rows} == set(ids)
56
+
57
+
58
+ async def test_like_predicate(clean):
59
+ await _seed_pets()
60
+ rows = await Pet.query.or_(Pet.f.name.like("pet-%")).all()
61
+ assert len(rows) == 3
62
+
63
+
64
+ async def test_is_null_predicate(clean):
65
+ pets = await _seed_pets()
66
+ await pets[0].update(owner_id=None)
67
+ rows = await Pet.query.or_(Pet.f.owner_id.is_null()).all()
68
+ assert [p.id for p in rows] == [pets[0].id]
69
+
70
+
71
+ async def test_contains_predicate(clean):
72
+ pets = await _seed_pets()
73
+ # Only pets[0] has tag "a".
74
+ rows = await Pet.query.or_(Pet.f.tags.contains(["a"])).all()
75
+ assert {p.id for p in rows} == {pets[0].id}
76
+
77
+
78
+ # ─── Composition: |, &, ~ ────────────────────────────────────────────────
79
+
80
+
81
+ async def test_or_composition_two_branches(clean):
82
+ pets = await _seed_pets()
83
+ rows = await Pet.query.or_(
84
+ Pet.f.name == pets[0].name,
85
+ Pet.f.name == pets[2].name,
86
+ ).all()
87
+ assert {p.id for p in rows} == {pets[0].id, pets[2].id}
88
+
89
+
90
+ async def test_and_inside_or_branch(clean):
91
+ pets = await _seed_pets()
92
+ rows = await Pet.query.or_(
93
+ Pet.f.name == pets[1].name,
94
+ (Pet.f.species == "cat") & (Pet.f.amount >= 2),
95
+ ).all()
96
+ expected = {pets[1].id} | {
97
+ p.id for p in pets if p.species == "cat" and p.amount >= 2
98
+ }
99
+ assert {p.id for p in rows} == expected
100
+
101
+
102
+ async def test_pipe_composition_flattens(clean):
103
+ """``a | b | c`` should match rows matching any of the three."""
104
+ pets = await _seed_pets()
105
+ p = (
106
+ (Pet.f.name == pets[0].name)
107
+ | (Pet.f.name == pets[1].name)
108
+ | (Pet.f.name == pets[2].name)
109
+ )
110
+ rows = await Pet.query.or_(p).all()
111
+ assert {p.id for p in rows} == {p.id for p in pets}
112
+
113
+
114
+ async def test_not_of_atom(clean):
115
+ pets = await _seed_pets()
116
+ rows = await Pet.query.not_(Pet.f.species == "cat").all()
117
+ assert {p.id for p in rows} == {p.id for p in pets if p.species != "cat"}
118
+
119
+
120
+ async def test_not_of_or_group(clean):
121
+ """``not.or(name=X, name=Y)`` must parse as "neither X nor Y"."""
122
+ pets = await _seed_pets()
123
+ rows = await Pet.query.not_(
124
+ (Pet.f.name == pets[0].name) | (Pet.f.name == pets[1].name)
125
+ ).all()
126
+ assert {p.id for p in rows} == {pets[2].id}
127
+
128
+
129
+ async def test_not_of_and_group(clean):
130
+ pets = await _seed_pets()
131
+ rows = await Pet.query.not_((Pet.f.species == "cat") & (Pet.f.amount == 0)).all()
132
+ # Exclude pets[0] (cat, amount=0) only; pets[2] is cat amount=2,
133
+ # pets[1] is dog amount=1 → both retained.
134
+ assert {p.id for p in rows} == {pets[1].id, pets[2].id}
135
+
136
+
137
+ # ─── Chain integration ───────────────────────────────────────────────────
138
+
139
+
140
+ async def test_chain_eq_plus_predicate_or(clean):
141
+ """Chain handles AND; predicate args handle OR. They mix freely."""
142
+ pets = await _seed_pets()
143
+ rows = await (
144
+ Pet.query.eq("adopted", False)
145
+ .or_(Pet.f.name == pets[1].name, Pet.f.name == pets[2].name)
146
+ .all()
147
+ )
148
+ # pets[1] and pets[2] are both adopted=False (only pets[0] is adopted).
149
+ assert {p.id for p in rows} == {pets[1].id, pets[2].id}
150
+
151
+
152
+ # ─── Backward compat — lambda form still works against the server ────────
153
+
154
+
155
+ async def test_legacy_lambda_or_still_works(clean):
156
+ """The old ``or_(lambda q: q.eq(...))`` form must keep working
157
+ end-to-end so 0.1.x callers aren't broken by 0.2.0."""
158
+ pets = await _seed_pets()
159
+ rows = await Pet.query.or_(
160
+ lambda q: q.eq("name", pets[0].name),
161
+ lambda q: q.eq("name", pets[2].name),
162
+ ).all()
163
+ assert {p.id for p in rows} == {pets[0].id, pets[2].id}
@@ -0,0 +1,284 @@
1
+ """Typed predicate builder — Column[T], Predicate, Model.f."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from uuid import UUID, uuid4
7
+
8
+ import pytest
9
+
10
+ from supabase_orm import Column, Predicate, SupabaseModel
11
+ from supabase_orm._predicates import (
12
+ _FieldsAccess,
13
+ _PredicateAnd,
14
+ _PredicateAtom,
15
+ _PredicateNot,
16
+ _PredicateOr,
17
+ )
18
+
19
+ from .conftest import FakeResponse
20
+
21
+
22
+ class Row(SupabaseModel, table="rows_pred"):
23
+ id: UUID
24
+ name: str
25
+ age: int
26
+ is_active: bool = True
27
+ tags: list[str] = []
28
+ created_at: datetime | None = None
29
+
30
+
31
+ # ─── Model.f namespace ────────────────────────────────────────────────────
32
+
33
+
34
+ def test_f_namespace_is_attached_per_subclass():
35
+ assert isinstance(Row.f, _FieldsAccess)
36
+ # Two different models get their own namespaces.
37
+ assert Row.f is not SupabaseModel.__dict__.get("f")
38
+
39
+
40
+ def test_f_returns_typed_column_per_field():
41
+ col = Row.f.age
42
+ assert isinstance(col, Column)
43
+ assert col._name == "age"
44
+ assert col._model is Row
45
+
46
+
47
+ def test_f_returns_same_column_object_each_access():
48
+ # Cached at construction time — useful so identity comparisons work
49
+ # and we don't allocate per access in tight loops.
50
+ assert Row.f.age is Row.f.age
51
+
52
+
53
+ def test_f_unknown_field_raises_attributeerror():
54
+ with pytest.raises(AttributeError, match="no column 'nope'"):
55
+ _ = Row.f.nope # type: ignore[attr-defined]
56
+
57
+
58
+ def test_f_dir_lists_columns_for_ide_autocomplete():
59
+ assert set(dir(Row.f)) == set(Row.model_fields)
60
+
61
+
62
+ # ─── Atomic predicates ────────────────────────────────────────────────────
63
+
64
+
65
+ def test_eq_builds_predicate_not_bool():
66
+ p = Row.f.name == "alice"
67
+ assert isinstance(p, Predicate)
68
+ assert isinstance(p, _PredicateAtom)
69
+ assert p._compile() == "name.eq.alice"
70
+
71
+
72
+ def test_ne_builds_neq_predicate():
73
+ p = Row.f.name != "alice"
74
+ assert isinstance(p, Predicate)
75
+ assert p._compile() == "name.neq.alice"
76
+
77
+
78
+ def test_comparison_operators():
79
+ assert (Row.f.age < 18)._compile() == "age.lt.18"
80
+ assert (Row.f.age <= 18)._compile() == "age.lte.18"
81
+ assert (Row.f.age > 18)._compile() == "age.gt.18"
82
+ assert (Row.f.age >= 18)._compile() == "age.gte.18"
83
+
84
+
85
+ def test_method_form_operators():
86
+ assert Row.f.age.in_([1, 2, 3])._compile() == "age.in.(1,2,3)"
87
+ assert Row.f.name.like("a%")._compile() == "name.like.a%"
88
+ assert Row.f.name.ilike("A%")._compile() == "name.ilike.A%"
89
+ # PostgREST's URL-tree wire names are short (cs/cd/ov) and array
90
+ # literals use ``{}`` curly braces, not the ``()`` parens that ``in`` uses.
91
+ assert Row.f.tags.contains(["x"])._compile() == "tags.cs.{x}"
92
+ assert Row.f.tags.contained_by(["x"])._compile() == "tags.cd.{x}"
93
+ assert Row.f.tags.overlaps(["x"])._compile() == "tags.ov.{x}"
94
+
95
+
96
+ def test_is_null_sugar():
97
+ assert Row.f.created_at.is_null()._compile() == "created_at.is.null"
98
+ assert Row.f.created_at.is_(None)._compile() == "created_at.is.null"
99
+ assert Row.f.is_active.is_(True)._compile() == "is_active.is.true"
100
+
101
+
102
+ def test_text_search_operators():
103
+ assert Row.f.name.fts("cat")._compile() == "name.fts.cat"
104
+ assert Row.f.name.plfts("cat")._compile() == "name.plfts.cat"
105
+ assert Row.f.name.phfts("cat")._compile() == "name.phfts.cat"
106
+ assert Row.f.name.wfts("cat")._compile() == "name.wfts.cat"
107
+
108
+
109
+ def test_predicate_serializes_values_via_filter_layer():
110
+ u = uuid4()
111
+ p = Row.f.id == u
112
+ assert p._compile() == f"id.eq.{u}"
113
+
114
+ dt = datetime(2024, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
115
+ assert (Row.f.created_at == dt)._compile() == f"created_at.eq.{dt.isoformat()}"
116
+
117
+
118
+ # ─── Composition: |, &, ~ ────────────────────────────────────────────────
119
+
120
+
121
+ def test_or_composition():
122
+ p = (Row.f.name == "a") | (Row.f.name == "b")
123
+ assert isinstance(p, _PredicateOr)
124
+ assert p._compile() == "or(name.eq.a,name.eq.b)"
125
+
126
+
127
+ def test_and_composition():
128
+ p = (Row.f.name == "a") & (Row.f.age > 5)
129
+ assert isinstance(p, _PredicateAnd)
130
+ assert p._compile() == "and(name.eq.a,age.gt.5)"
131
+
132
+
133
+ def test_not_composition():
134
+ p = ~(Row.f.is_active == True) # noqa: E712 (the orm cares about == not is)
135
+ assert isinstance(p, _PredicateNot)
136
+ # Atom-not wraps in single-element and() so it parses inside or=(...).
137
+ assert p._compile() == "not.and(is_active.eq.true)"
138
+
139
+
140
+ def test_or_flattens_same_kind():
141
+ """``a | b | c`` should be ``or(a,b,c)`` not ``or(or(a,b),c)``."""
142
+ p = (Row.f.name == "a") | (Row.f.name == "b") | (Row.f.name == "c")
143
+ assert p._compile() == "or(name.eq.a,name.eq.b,name.eq.c)"
144
+
145
+
146
+ def test_and_flattens_same_kind():
147
+ p = (Row.f.age > 1) & (Row.f.age < 10) & (Row.f.is_active == True) # noqa: E712
148
+ assert p._compile() == "and(age.gt.1,age.lt.10,is_active.eq.true)"
149
+
150
+
151
+ def test_mixed_and_or_keeps_nesting():
152
+ p = (Row.f.name == "a") | ((Row.f.age >= 5) & (Row.f.is_active == True)) # noqa: E712
153
+ assert p._compile() == "or(name.eq.a,and(age.gte.5,is_active.eq.true))"
154
+
155
+
156
+ def test_not_of_compound_negates_whole_group():
157
+ p = ~((Row.f.name == "a") | (Row.f.name == "b"))
158
+ assert p._compile() == "not.or(name.eq.a,name.eq.b)"
159
+
160
+
161
+ # ─── Foot-gun guards ──────────────────────────────────────────────────────
162
+
163
+
164
+ def test_predicate_is_not_bool():
165
+ """Catches ``if Pet.f.age >= 5:`` mistakes at runtime."""
166
+ p = Row.f.age >= 5
167
+ with pytest.raises(TypeError, match="not a bool"):
168
+ bool(p)
169
+
170
+
171
+ def test_or_with_non_predicate_returns_notimplemented():
172
+ p = Row.f.age >= 5
173
+ # ``__or__`` returning NotImplemented lets Python fall back to the
174
+ # other operand's ``__ror__`` (or raise TypeError if none exists).
175
+ assert p.__or__("x") is NotImplemented # type: ignore[arg-type]
176
+ assert p.__and__("x") is NotImplemented # type: ignore[arg-type]
177
+
178
+
179
+ # ─── Integration with QueryBuilder.or_ / not_ ────────────────────────────
180
+
181
+
182
+ async def test_or_accepts_predicate_args(fake_client):
183
+ fake_client.queue(FakeResponse(data=[]))
184
+ await Row.query.or_(
185
+ Row.f.name == "a",
186
+ Row.f.name == "b",
187
+ ).all()
188
+ or_call = next(c for c in fake_client.builders[0].calls if c[0] == "or_")
189
+ assert or_call[1] == ("name.eq.a,name.eq.b",)
190
+
191
+
192
+ async def test_or_predicate_with_and_branch(fake_client):
193
+ fake_client.queue(FakeResponse(data=[]))
194
+ await Row.query.or_(
195
+ Row.f.name == "a",
196
+ (Row.f.name == "b") & (Row.f.age >= 5),
197
+ ).all()
198
+ or_call = next(c for c in fake_client.builders[0].calls if c[0] == "or_")
199
+ assert or_call[1] == ("name.eq.a,and(name.eq.b,age.gte.5)",)
200
+
201
+
202
+ async def test_or_predicate_with_nested_or(fake_client):
203
+ fake_client.queue(FakeResponse(data=[]))
204
+ await Row.query.or_(
205
+ (Row.f.name == "a") | (Row.f.name == "b"),
206
+ Row.f.age >= 5,
207
+ ).all()
208
+ or_call = next(c for c in fake_client.builders[0].calls if c[0] == "or_")
209
+ # Inner ``or(...)`` stays a nested group — PostgREST accepts that.
210
+ assert or_call[1] == ("or(name.eq.a,name.eq.b),age.gte.5",)
211
+
212
+
213
+ async def test_not_accepts_predicate(fake_client):
214
+ fake_client.queue(FakeResponse(data=[]))
215
+ await Row.query.not_(Row.f.is_active == True).all() # noqa: E712
216
+ or_call = next(c for c in fake_client.builders[0].calls if c[0] == "or_")
217
+ assert or_call[1] == ("not.and(is_active.eq.true)",)
218
+
219
+
220
+ async def test_not_of_or_group(fake_client):
221
+ fake_client.queue(FakeResponse(data=[]))
222
+ await Row.query.not_((Row.f.name == "a") | (Row.f.name == "b")).all()
223
+ or_call = next(c for c in fake_client.builders[0].calls if c[0] == "or_")
224
+ assert or_call[1] == ("not.or(name.eq.a,name.eq.b)",)
225
+
226
+
227
+ async def test_or_requires_at_least_one_branch(fake_client):
228
+ from supabase_orm import SupabaseORMUsageError
229
+
230
+ with pytest.raises(SupabaseORMUsageError, match="at least one branch"):
231
+ Row.query.or_()
232
+
233
+
234
+ async def test_or_rejects_mixed_predicate_and_lambda(fake_client):
235
+ from supabase_orm import SupabaseORMUsageError
236
+
237
+ with pytest.raises(SupabaseORMUsageError, match="can't mix"):
238
+ Row.query.or_(
239
+ Row.f.name == "a",
240
+ lambda q: q.eq("name", "b"), # type: ignore[arg-type]
241
+ )
242
+
243
+
244
+ # ─── Backward compat — lambda API still works ────────────────────────────
245
+
246
+
247
+ async def test_or_legacy_lambda_path_still_works(fake_client):
248
+ fake_client.queue(FakeResponse(data=[]))
249
+ await Row.query.or_(
250
+ lambda q: q.eq("name", "a"),
251
+ lambda q: q.eq("name", "b"),
252
+ ).all()
253
+ or_call = next(c for c in fake_client.builders[0].calls if c[0] == "or_")
254
+ assert or_call[1] == ("name.eq.a,name.eq.b",)
255
+
256
+
257
+ async def test_not_legacy_lambda_path_still_works(fake_client):
258
+ fake_client.queue(FakeResponse(data=[]))
259
+ await Row.query.not_(lambda q: q.eq("name", "a")).all()
260
+ or_call = next(c for c in fake_client.builders[0].calls if c[0] == "or_")
261
+ assert or_call[1] == ("not.and(name.eq.a)",)
262
+
263
+
264
+ # ─── Chain integration ───────────────────────────────────────────────────
265
+
266
+
267
+ async def test_predicate_or_composes_with_chain_filters(fake_client):
268
+ """Chain handles AND, predicate-or_ handles OR — they mix freely."""
269
+ fake_client.queue(FakeResponse(data=[]))
270
+ await (
271
+ Row.query.eq("is_active", True)
272
+ .or_(Row.f.name == "a", Row.f.name == "b")
273
+ .order_by("-age")
274
+ .limit(10)
275
+ .all()
276
+ )
277
+ b = fake_client.builders[0]
278
+ calls = [c for c in b.calls if c[0] != "select"]
279
+ assert calls == [
280
+ ("eq", ("is_active", True), {}),
281
+ ("or_", ("name.eq.a,name.eq.b",), {}),
282
+ ("order", ("age",), {"desc": True}),
283
+ ("limit", (10,), {}),
284
+ ]
@@ -222,9 +222,9 @@ async def test_neq_lt_gte_serialize_values(fake_client):
222
222
  ("ilike", "name", "A%", "name.ilike.A%"),
223
223
  ("is_", "name", None, "name.is.null"),
224
224
  ("is_", "is_active", True, "is_active.is.true"),
225
- ("contains", "tags", ["x"], "tags.contains.(x)"),
226
- ("contained_by", "tags", ["x"], "tags.contained_by.(x)"),
227
- ("overlaps", "tags", ["x"], "tags.overlaps.(x)"),
225
+ ("contains", "tags", ["x"], "tags.cs.{x}"),
226
+ ("contained_by", "tags", ["x"], "tags.cd.{x}"),
227
+ ("overlaps", "tags", ["x"], "tags.ov.{x}"),
228
228
  ("fts", "name", "cat", "name.fts.cat"),
229
229
  ("plfts", "name", "cat", "name.plfts.cat"),
230
230
  ("phfts", "name", "cat", "name.phfts.cat"),
File without changes
File without changes