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.
- supabase_orm-0.1.1/.github/workflows/publish.yml +97 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/PKG-INFO +6 -6
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/README.md +1 -1
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/pyproject.toml +4 -4
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/src/supabase_orm/__init__.py +3 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/src/supabase_orm/_base.py +6 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/src/supabase_orm/_filters.py +22 -3
- supabase_orm-0.1.1/src/supabase_orm/_predicates.py +274 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/src/supabase_orm/_query.py +50 -10
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/src/supabase_orm/_version.py +2 -2
- supabase_orm-0.1.1/tests/integration/test_predicates.py +163 -0
- supabase_orm-0.1.1/tests/test_predicates.py +284 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/test_wire.py +3 -3
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/.env.example +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/.gitignore +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/LICENSE +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/src/supabase_orm/_client.py +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/src/supabase_orm/_embed.py +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/src/supabase_orm/_exceptions.py +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/src/supabase_orm/_rpc.py +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/src/supabase_orm/_serializers.py +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/__init__.py +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/conftest.py +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/integration/README.md +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/integration/__init__.py +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/integration/conftest.py +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/integration/schema.sql +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/integration/test_embeds.py +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/integration/test_filters.py +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/integration/test_rpc.py +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/integration/test_writes_and_terminals.py +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/test_base.py +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/test_client.py +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/test_embed.py +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/test_exceptions.py +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/test_filters.py +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/test_query.py +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/test_rpc.py +0 -0
- {supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/test_serializers.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
) -> Self:
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
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.
|
|
226
|
-
("contained_by", "tags", ["x"], "tags.
|
|
227
|
-
("overlaps", "tags", ["x"], "tags.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{supabase_orm-0.0.1.dev1 → supabase_orm-0.1.1}/tests/integration/test_writes_and_terminals.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|