supabase-orm 0.0.0__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.0.0/.gitignore +114 -0
- supabase_orm-0.0.0/PKG-INFO +60 -0
- supabase_orm-0.0.0/README.md +49 -0
- supabase_orm-0.0.0/pyproject.toml +26 -0
- supabase_orm-0.0.0/src/supabase_orm/__init__.py +48 -0
- supabase_orm-0.0.0/src/supabase_orm/_base.py +264 -0
- supabase_orm-0.0.0/src/supabase_orm/_client.py +78 -0
- supabase_orm-0.0.0/src/supabase_orm/_embed.py +129 -0
- supabase_orm-0.0.0/src/supabase_orm/_exceptions.py +26 -0
- supabase_orm-0.0.0/src/supabase_orm/_filters.py +190 -0
- supabase_orm-0.0.0/src/supabase_orm/_query.py +460 -0
- supabase_orm-0.0.0/src/supabase_orm/_rpc.py +70 -0
- supabase_orm-0.0.0/src/supabase_orm/_serializers.py +54 -0
- supabase_orm-0.0.0/src/supabase_orm/_version.py +24 -0
- supabase_orm-0.0.0/uv.lock +1462 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Generated by hatch-vcs
|
|
2
|
+
src/supabase_orm/_version.py
|
|
3
|
+
|
|
4
|
+
# Byte-compiled / optimized / DLL files
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
*$py.class
|
|
8
|
+
|
|
9
|
+
# C extensions
|
|
10
|
+
*.so
|
|
11
|
+
|
|
12
|
+
# Distribution / packaging
|
|
13
|
+
.Python
|
|
14
|
+
build/
|
|
15
|
+
develop-eggs/
|
|
16
|
+
dist/
|
|
17
|
+
downloads/
|
|
18
|
+
eggs/
|
|
19
|
+
.eggs/
|
|
20
|
+
lib/
|
|
21
|
+
lib64/
|
|
22
|
+
parts/
|
|
23
|
+
sdist/
|
|
24
|
+
var/
|
|
25
|
+
wheels/
|
|
26
|
+
share/python-wheels/
|
|
27
|
+
*.egg-info/
|
|
28
|
+
.installed.cfg
|
|
29
|
+
*.egg
|
|
30
|
+
MANIFEST
|
|
31
|
+
|
|
32
|
+
# PyInstaller
|
|
33
|
+
*.manifest
|
|
34
|
+
*.spec
|
|
35
|
+
|
|
36
|
+
# Installer logs
|
|
37
|
+
pip-log.txt
|
|
38
|
+
pip-delete-this-directory.txt
|
|
39
|
+
|
|
40
|
+
# Unit test / coverage reports
|
|
41
|
+
htmlcov/
|
|
42
|
+
.tox/
|
|
43
|
+
.nox/
|
|
44
|
+
.coverage
|
|
45
|
+
.coverage.*
|
|
46
|
+
.cache
|
|
47
|
+
nosetests.xml
|
|
48
|
+
coverage.xml
|
|
49
|
+
*.cover
|
|
50
|
+
*.py,cover
|
|
51
|
+
.hypothesis/
|
|
52
|
+
.pytest_cache/
|
|
53
|
+
cover/
|
|
54
|
+
|
|
55
|
+
# Translations
|
|
56
|
+
*.mo
|
|
57
|
+
*.pot
|
|
58
|
+
|
|
59
|
+
# Sphinx documentation
|
|
60
|
+
docs/_build/
|
|
61
|
+
|
|
62
|
+
# PyBuilder
|
|
63
|
+
.pybuilder/
|
|
64
|
+
target/
|
|
65
|
+
|
|
66
|
+
# Jupyter Notebook
|
|
67
|
+
.ipynb_checkpoints
|
|
68
|
+
|
|
69
|
+
# IPython
|
|
70
|
+
profile_default/
|
|
71
|
+
ipython_config.py
|
|
72
|
+
|
|
73
|
+
# pyenv
|
|
74
|
+
.python-version
|
|
75
|
+
|
|
76
|
+
# pipenv / poetry / pdm / uv
|
|
77
|
+
Pipfile.lock
|
|
78
|
+
poetry.lock
|
|
79
|
+
pdm.lock
|
|
80
|
+
.pdm.toml
|
|
81
|
+
.pdm-python
|
|
82
|
+
.pdm-build/
|
|
83
|
+
__pypackages__/
|
|
84
|
+
|
|
85
|
+
# Environments
|
|
86
|
+
.env
|
|
87
|
+
.env.*
|
|
88
|
+
.venv
|
|
89
|
+
env/
|
|
90
|
+
venv/
|
|
91
|
+
ENV/
|
|
92
|
+
env.bak/
|
|
93
|
+
venv.bak/
|
|
94
|
+
|
|
95
|
+
# mypy / pyright / ruff
|
|
96
|
+
.mypy_cache/
|
|
97
|
+
.dmypy.json
|
|
98
|
+
dmypy.json
|
|
99
|
+
.pyre/
|
|
100
|
+
.pytype/
|
|
101
|
+
.ruff_cache/
|
|
102
|
+
|
|
103
|
+
# Cython debug symbols
|
|
104
|
+
cython_debug/
|
|
105
|
+
|
|
106
|
+
# IDEs
|
|
107
|
+
.idea/
|
|
108
|
+
.vscode/
|
|
109
|
+
*.swp
|
|
110
|
+
*.swo
|
|
111
|
+
|
|
112
|
+
# OS
|
|
113
|
+
.DS_Store
|
|
114
|
+
Thumbs.db
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: supabase-orm
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: Lightweight async ORM on top of supabase-py with Pydantic validation.
|
|
5
|
+
Author-email: Adnan Ahmad <viperadnan@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: pydantic>=2.13.4
|
|
9
|
+
Requires-Dist: supabase>=2.30.0
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# supabase-orm
|
|
13
|
+
|
|
14
|
+
Lightweight async ORM on top of `supabase-py` with Pydantic validation.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from uuid import UUID
|
|
18
|
+
from typing import Annotated
|
|
19
|
+
from supabase_orm import SupabaseModel, Relation, lifespan
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class User(SupabaseModel, table="users"):
|
|
23
|
+
id: UUID
|
|
24
|
+
name: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Pet(SupabaseModel, table="pets"):
|
|
28
|
+
id: UUID
|
|
29
|
+
name: str
|
|
30
|
+
species: str
|
|
31
|
+
adopted: bool
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PetWithOwner(SupabaseModel, table="pets"):
|
|
35
|
+
id: UUID
|
|
36
|
+
name: str
|
|
37
|
+
species: str
|
|
38
|
+
owner: Annotated[User, Relation(join="inner")]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# In FastAPI lifespan:
|
|
42
|
+
async with lifespan(SUPABASE_URL, SUPABASE_KEY):
|
|
43
|
+
# Read
|
|
44
|
+
rows = await Pet.query.eq("species", "cat").order_by("-created_at").limit(10).all()
|
|
45
|
+
one = await PetWithOwner.get(some_id)
|
|
46
|
+
|
|
47
|
+
# Write
|
|
48
|
+
p = await Pet.create(name="Whiskers", species="cat", adopted=False)
|
|
49
|
+
p.name = "Mr. Whiskers"
|
|
50
|
+
await p.save()
|
|
51
|
+
|
|
52
|
+
# Bulk
|
|
53
|
+
await Pet.query.eq("adopted", False).update(adopted=True)
|
|
54
|
+
await Pet.query.eq("species", "fish").lt("created_at", cutoff).delete()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Status
|
|
58
|
+
|
|
59
|
+
In-repo workspace package. Will be lifted to its own repo and published to
|
|
60
|
+
PyPI later. Until then, depend on it via `uv` workspace.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# supabase-orm
|
|
2
|
+
|
|
3
|
+
Lightweight async ORM on top of `supabase-py` with Pydantic validation.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
from supabase_orm import SupabaseModel, Relation, lifespan
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class User(SupabaseModel, table="users"):
|
|
12
|
+
id: UUID
|
|
13
|
+
name: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Pet(SupabaseModel, table="pets"):
|
|
17
|
+
id: UUID
|
|
18
|
+
name: str
|
|
19
|
+
species: str
|
|
20
|
+
adopted: bool
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PetWithOwner(SupabaseModel, table="pets"):
|
|
24
|
+
id: UUID
|
|
25
|
+
name: str
|
|
26
|
+
species: str
|
|
27
|
+
owner: Annotated[User, Relation(join="inner")]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# In FastAPI lifespan:
|
|
31
|
+
async with lifespan(SUPABASE_URL, SUPABASE_KEY):
|
|
32
|
+
# Read
|
|
33
|
+
rows = await Pet.query.eq("species", "cat").order_by("-created_at").limit(10).all()
|
|
34
|
+
one = await PetWithOwner.get(some_id)
|
|
35
|
+
|
|
36
|
+
# Write
|
|
37
|
+
p = await Pet.create(name="Whiskers", species="cat", adopted=False)
|
|
38
|
+
p.name = "Mr. Whiskers"
|
|
39
|
+
await p.save()
|
|
40
|
+
|
|
41
|
+
# Bulk
|
|
42
|
+
await Pet.query.eq("adopted", False).update(adopted=True)
|
|
43
|
+
await Pet.query.eq("species", "fish").lt("created_at", cutoff).delete()
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Status
|
|
47
|
+
|
|
48
|
+
In-repo workspace package. Will be lifted to its own repo and published to
|
|
49
|
+
PyPI later. Until then, depend on it via `uv` workspace.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "supabase-orm"
|
|
3
|
+
description = "Lightweight async ORM on top of supabase-py with Pydantic validation."
|
|
4
|
+
readme = "README.md"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
authors = [{ name = "Adnan Ahmad", email = "viperadnan@gmail.com" }]
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
dynamic = ["version"]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"pydantic>=2.13.4",
|
|
11
|
+
"supabase>=2.30.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
16
|
+
build-backend = "hatchling.build"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.version]
|
|
19
|
+
source = "vcs"
|
|
20
|
+
fallback-version = "0.0.0"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.hooks.vcs]
|
|
23
|
+
version-file = "src/supabase_orm/_version.py"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/supabase_orm"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""ORM public API.
|
|
2
|
+
|
|
3
|
+
from supabase_orm import SupabaseModel, Relation, lifespan, rpc
|
|
4
|
+
|
|
5
|
+
class Pet(SupabaseModel, table="pets"):
|
|
6
|
+
id: UUID
|
|
7
|
+
name: str
|
|
8
|
+
species: str
|
|
9
|
+
adopted: bool
|
|
10
|
+
|
|
11
|
+
Internals live in underscore-prefixed modules; only names re-exported here
|
|
12
|
+
are part of the supported surface.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ._base import SupabaseModel
|
|
16
|
+
from ._client import get_client, lifespan, set_client
|
|
17
|
+
from ._embed import Relation
|
|
18
|
+
from ._exceptions import (
|
|
19
|
+
SupabaseORMUsageError,
|
|
20
|
+
SupabaseORMDoesNotExist,
|
|
21
|
+
SupabaseORMError,
|
|
22
|
+
SupabaseORMMultipleObjectsReturned,
|
|
23
|
+
)
|
|
24
|
+
from ._filters import register_op
|
|
25
|
+
from ._query import QueryBuilder
|
|
26
|
+
from ._rpc import rpc, rpc_maybe_one, rpc_one, rpc_scalar
|
|
27
|
+
from ._serializers import register_serializer
|
|
28
|
+
from ._version import __version__
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"__version__",
|
|
32
|
+
"SupabaseModel",
|
|
33
|
+
"Relation",
|
|
34
|
+
"QueryBuilder",
|
|
35
|
+
"lifespan",
|
|
36
|
+
"get_client",
|
|
37
|
+
"set_client",
|
|
38
|
+
"rpc",
|
|
39
|
+
"rpc_one",
|
|
40
|
+
"rpc_maybe_one",
|
|
41
|
+
"rpc_scalar",
|
|
42
|
+
"register_op",
|
|
43
|
+
"register_serializer",
|
|
44
|
+
"SupabaseORMError",
|
|
45
|
+
"SupabaseORMDoesNotExist",
|
|
46
|
+
"SupabaseORMMultipleObjectsReturned",
|
|
47
|
+
"SupabaseORMUsageError",
|
|
48
|
+
]
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""SupabaseModel — Pydantic + PostgREST ORM base.
|
|
2
|
+
|
|
3
|
+
Subclass with ``table=``:
|
|
4
|
+
|
|
5
|
+
class Pet(SupabaseModel, table="pets"):
|
|
6
|
+
id: UUID
|
|
7
|
+
name: str
|
|
8
|
+
species: str
|
|
9
|
+
adopted: bool
|
|
10
|
+
|
|
11
|
+
Then chain queries off ``Model.query``:
|
|
12
|
+
|
|
13
|
+
rows = await Pet.query.eq("species", "cat").order_by("-created_at").limit(10).all()
|
|
14
|
+
one = await Pet.get(id)
|
|
15
|
+
p = await Pet.create(name="Whiskers", species="cat", adopted=False)
|
|
16
|
+
p.name = "Mr. Whiskers"; await p.save()
|
|
17
|
+
await p.delete()
|
|
18
|
+
await Pet.query.eq("adopted", False).delete() # bulk
|
|
19
|
+
await Pet.query.eq("adopted", False).update(adopted=True) # bulk
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any, ClassVar, Self, TypeVar
|
|
25
|
+
|
|
26
|
+
from pydantic import BaseModel, ConfigDict, TypeAdapter
|
|
27
|
+
|
|
28
|
+
from ._client import get_client
|
|
29
|
+
from ._embed import Relation, build_select, collect_relations
|
|
30
|
+
from ._exceptions import SupabaseORMUsageError, SupabaseORMDoesNotExist
|
|
31
|
+
from ._query import QueryBuilder
|
|
32
|
+
from ._serializers import serialize
|
|
33
|
+
|
|
34
|
+
_TABLE_REGISTRY: dict[str, list[type["SupabaseModel"]]] = {}
|
|
35
|
+
|
|
36
|
+
_T = TypeVar("_T", bound="SupabaseModel")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _QueryDescriptor:
|
|
40
|
+
"""Returns a fresh ``QueryBuilder[Owner]`` per access.
|
|
41
|
+
|
|
42
|
+
Typed via the ``owner`` parameter, so ``Pet.query`` is inferred as
|
|
43
|
+
``QueryBuilder[Pet]`` by static checkers — every operator on the chain
|
|
44
|
+
has a real signature and works with autocomplete.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __get__(
|
|
48
|
+
self, instance: Any, owner: type[_T]
|
|
49
|
+
) -> "QueryBuilder[_T]":
|
|
50
|
+
if not owner.__table__:
|
|
51
|
+
raise SupabaseORMUsageError(
|
|
52
|
+
f"{owner.__name__} has no __table__. Declare with "
|
|
53
|
+
f'`class {owner.__name__}(SupabaseModel, table="..."):`'
|
|
54
|
+
)
|
|
55
|
+
return QueryBuilder(owner)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SupabaseModel(BaseModel):
|
|
59
|
+
"""Base class for tables. Configure per-subclass via class kwargs.
|
|
60
|
+
|
|
61
|
+
Class kwargs:
|
|
62
|
+
table: PostgREST table / view name. Required on concrete subclasses.
|
|
63
|
+
pk: Primary key field name. Default ``"id"``.
|
|
64
|
+
select: Override the auto-derived select string (escape hatch).
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
model_config = ConfigDict(
|
|
68
|
+
validate_assignment=True,
|
|
69
|
+
extra="ignore",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
__table__: ClassVar[str] = ""
|
|
73
|
+
__pk__: ClassVar[str] = "id"
|
|
74
|
+
__select__: ClassVar[str] = ""
|
|
75
|
+
__select_override__: ClassVar[str | None] = None
|
|
76
|
+
# Populated by ``__pydantic_init_subclass__`` so hot paths don't reflect
|
|
77
|
+
# over ``model_fields`` on every query.
|
|
78
|
+
__relations__: ClassVar[dict[str, tuple[type, bool, Relation]]] = {}
|
|
79
|
+
__list_adapter__: ClassVar[TypeAdapter | None] = None
|
|
80
|
+
|
|
81
|
+
query: ClassVar[_QueryDescriptor] = _QueryDescriptor()
|
|
82
|
+
|
|
83
|
+
def __init_subclass__(
|
|
84
|
+
cls,
|
|
85
|
+
*,
|
|
86
|
+
table: str | None = None,
|
|
87
|
+
pk: str = "id",
|
|
88
|
+
select: str | None = None,
|
|
89
|
+
**kw: Any,
|
|
90
|
+
) -> None:
|
|
91
|
+
# Pydantic populates model_fields AFTER this hook runs, so we just
|
|
92
|
+
# capture the table-level kwargs here. ``__pydantic_init_subclass__``
|
|
93
|
+
# below builds the cached metadata once fields are available.
|
|
94
|
+
super().__init_subclass__(**kw)
|
|
95
|
+
if table is None:
|
|
96
|
+
return
|
|
97
|
+
cls.__table__ = table
|
|
98
|
+
cls.__pk__ = pk
|
|
99
|
+
cls.__select_override__ = select
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def __pydantic_init_subclass__(cls, **kw: Any) -> None:
|
|
103
|
+
if not cls.__table__:
|
|
104
|
+
return
|
|
105
|
+
cls.__select__ = (
|
|
106
|
+
cls.__select_override__
|
|
107
|
+
if cls.__select_override__ is not None
|
|
108
|
+
else build_select(cls)
|
|
109
|
+
)
|
|
110
|
+
cls.__relations__ = collect_relations(cls)
|
|
111
|
+
cls.__list_adapter__ = TypeAdapter(list[cls])
|
|
112
|
+
_TABLE_REGISTRY.setdefault(cls.__table__, []).append(cls)
|
|
113
|
+
|
|
114
|
+
# ─── Builder entry point ───────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def _validate_column(cls, name: str) -> None:
|
|
118
|
+
"""Surface typos at call time. Allows scalar fields and any
|
|
119
|
+
``relation.column`` form (PostgREST embed-filter syntax)."""
|
|
120
|
+
head, _, _ = name.partition(".")
|
|
121
|
+
if head in cls.model_fields:
|
|
122
|
+
return
|
|
123
|
+
raise AttributeError(
|
|
124
|
+
f"{cls.__name__} has no column {head!r}. "
|
|
125
|
+
f"Known: {sorted(cls.model_fields)}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# ─── PK shortcuts ──────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
async def get(cls, pk_value: Any) -> Self:
|
|
132
|
+
"""Fetch by primary key. Raises ``SupabaseORMDoesNotExist`` on miss."""
|
|
133
|
+
row = await cls.query.eq(cls.__pk__, pk_value).maybe_one()
|
|
134
|
+
if row is None:
|
|
135
|
+
raise SupabaseORMDoesNotExist(
|
|
136
|
+
f"{cls.__name__}({cls.__pk__}={pk_value!r}) not found"
|
|
137
|
+
)
|
|
138
|
+
return row
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
async def find(cls, pk_value: Any) -> Self | None:
|
|
142
|
+
"""Fetch by primary key. Returns ``None`` on miss."""
|
|
143
|
+
return await cls.query.eq(cls.__pk__, pk_value).maybe_one()
|
|
144
|
+
|
|
145
|
+
# ─── Writes ────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
async def create(cls, **values: Any) -> Self:
|
|
149
|
+
"""Insert a row in a single round-trip.
|
|
150
|
+
|
|
151
|
+
Uses postgrest's ``insert(...).execute()`` which returns the inserted
|
|
152
|
+
row. For models with relations we still need a second round-trip to
|
|
153
|
+
fetch embeds, since ``insert`` only returns columns from the
|
|
154
|
+
inserted table.
|
|
155
|
+
"""
|
|
156
|
+
payload = {k: serialize(v) for k, v in values.items()}
|
|
157
|
+
client = get_client()
|
|
158
|
+
ins = await client.table(cls.__table__).insert(payload).execute()
|
|
159
|
+
if not ins.data:
|
|
160
|
+
raise ValueError(f"{cls.__name__}.create returned no rows")
|
|
161
|
+
if cls.__relations__:
|
|
162
|
+
return await cls.get(ins.data[0][cls.__pk__])
|
|
163
|
+
return cls.model_validate(ins.data[0])
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
async def bulk_create(cls, rows: list[dict[str, Any]]) -> list[Self]:
|
|
167
|
+
if not rows:
|
|
168
|
+
return []
|
|
169
|
+
payload = [{k: serialize(v) for k, v in r.items()} for r in rows]
|
|
170
|
+
client = get_client()
|
|
171
|
+
ins = await client.table(cls.__table__).insert(payload).execute()
|
|
172
|
+
data = ins.data or []
|
|
173
|
+
if not data:
|
|
174
|
+
return []
|
|
175
|
+
if cls.__relations__:
|
|
176
|
+
ids = [r[cls.__pk__] for r in data]
|
|
177
|
+
return await cls.query.in_(cls.__pk__, ids).all()
|
|
178
|
+
adapter = cls.__list_adapter__
|
|
179
|
+
return (
|
|
180
|
+
adapter.validate_python(data)
|
|
181
|
+
if adapter
|
|
182
|
+
else [cls.model_validate(r) for r in data]
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
async def update(self, **values: Any) -> Self:
|
|
186
|
+
"""Assign the given fields and persist in one call.
|
|
187
|
+
|
|
188
|
+
Equivalent to setting each attribute and calling :meth:`save`. Reads
|
|
189
|
+
more naturally for short updates::
|
|
190
|
+
|
|
191
|
+
await user.update(email="new@example.com", name="New Name")
|
|
192
|
+
|
|
193
|
+
Each assignment runs through Pydantic's validator (because
|
|
194
|
+
``validate_assignment=True`` is set on ``SupabaseModel``), so
|
|
195
|
+
type errors surface before the round-trip. Raises
|
|
196
|
+
``SupabaseORMUsageError`` if no values are passed.
|
|
197
|
+
"""
|
|
198
|
+
if not values:
|
|
199
|
+
raise SupabaseORMUsageError(
|
|
200
|
+
"instance.update() requires at least one key=value."
|
|
201
|
+
)
|
|
202
|
+
cls = type(self)
|
|
203
|
+
for k in values:
|
|
204
|
+
if k == cls.__pk__:
|
|
205
|
+
raise SupabaseORMUsageError(
|
|
206
|
+
f"Cannot update primary key {cls.__pk__!r} via .update()."
|
|
207
|
+
)
|
|
208
|
+
if k in cls.__relations__:
|
|
209
|
+
raise SupabaseORMUsageError(
|
|
210
|
+
f"{k!r} is a relation, not a column on {cls.__table__!r}."
|
|
211
|
+
)
|
|
212
|
+
for k, v in values.items():
|
|
213
|
+
setattr(self, k, v)
|
|
214
|
+
return await self.save()
|
|
215
|
+
|
|
216
|
+
async def save(self) -> Self:
|
|
217
|
+
"""Persist dirty fields and refresh local state in one round-trip.
|
|
218
|
+
|
|
219
|
+
For flat models the UPDATE returns the new row directly. For models
|
|
220
|
+
with relations we still need a follow-up GET to populate embeds.
|
|
221
|
+
"""
|
|
222
|
+
cls = type(self)
|
|
223
|
+
dirty = self.__pydantic_fields_set__ - {cls.__pk__} - cls.__relations__.keys()
|
|
224
|
+
if not dirty:
|
|
225
|
+
return self
|
|
226
|
+
payload = {f: serialize(getattr(self, f)) for f in dirty}
|
|
227
|
+
client = get_client()
|
|
228
|
+
pk_val = serialize(getattr(self, cls.__pk__))
|
|
229
|
+
resp = await (
|
|
230
|
+
client.table(cls.__table__)
|
|
231
|
+
.update(payload)
|
|
232
|
+
.eq(cls.__pk__, pk_val)
|
|
233
|
+
.execute()
|
|
234
|
+
)
|
|
235
|
+
if not resp.data:
|
|
236
|
+
raise SupabaseORMDoesNotExist(
|
|
237
|
+
f"{cls.__name__}({cls.__pk__}={getattr(self, cls.__pk__)!r}) "
|
|
238
|
+
"not found during save"
|
|
239
|
+
)
|
|
240
|
+
if cls.__relations__:
|
|
241
|
+
fresh = await cls.get(getattr(self, cls.__pk__))
|
|
242
|
+
else:
|
|
243
|
+
fresh = cls.model_validate(resp.data[0])
|
|
244
|
+
self.__dict__.update(fresh.__dict__)
|
|
245
|
+
object.__setattr__(self, "__pydantic_fields_set__", set())
|
|
246
|
+
return self
|
|
247
|
+
|
|
248
|
+
async def delete(self) -> None:
|
|
249
|
+
"""Delete this single row by primary key."""
|
|
250
|
+
cls = type(self)
|
|
251
|
+
client = get_client()
|
|
252
|
+
await (
|
|
253
|
+
client.table(cls.__table__)
|
|
254
|
+
.delete()
|
|
255
|
+
.eq(cls.__pk__, serialize(getattr(self, cls.__pk__)))
|
|
256
|
+
.execute()
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
async def refresh(self) -> Self:
|
|
260
|
+
cls = type(self)
|
|
261
|
+
fresh = await cls.get(getattr(self, cls.__pk__))
|
|
262
|
+
self.__dict__.update(fresh.__dict__)
|
|
263
|
+
object.__setattr__(self, "__pydantic_fields_set__", set())
|
|
264
|
+
return self
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""AsyncClient lifecycle.
|
|
2
|
+
|
|
3
|
+
This module is framework-agnostic. It owns a single ``AsyncClient`` reference
|
|
4
|
+
that models read via ``get_client()``. Wiring into a web framework (FastAPI's
|
|
5
|
+
lifespan, Starlette events, a CLI entrypoint, a test fixture, ...) is the
|
|
6
|
+
caller's job.
|
|
7
|
+
|
|
8
|
+
Typical FastAPI use::
|
|
9
|
+
|
|
10
|
+
from contextlib import asynccontextmanager
|
|
11
|
+
from src.lib import orm
|
|
12
|
+
|
|
13
|
+
@asynccontextmanager
|
|
14
|
+
async def lifespan(app):
|
|
15
|
+
async with orm.lifespan(SUPABASE_URL, SUPABASE_KEY):
|
|
16
|
+
yield
|
|
17
|
+
|
|
18
|
+
app = FastAPI(lifespan=lifespan)
|
|
19
|
+
|
|
20
|
+
Tests / scripts::
|
|
21
|
+
|
|
22
|
+
orm.set_client(await acreate_client(url, key))
|
|
23
|
+
...
|
|
24
|
+
orm.set_client(None)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from contextlib import asynccontextmanager
|
|
30
|
+
from typing import AsyncIterator
|
|
31
|
+
|
|
32
|
+
from supabase import AsyncClient, acreate_client
|
|
33
|
+
|
|
34
|
+
from ._exceptions import SupabaseORMUsageError
|
|
35
|
+
|
|
36
|
+
_client: AsyncClient | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_client() -> AsyncClient:
|
|
40
|
+
if _client is None:
|
|
41
|
+
raise SupabaseORMUsageError(
|
|
42
|
+
"Supabase AsyncClient not initialized. "
|
|
43
|
+
"Wrap your app startup in `async with orm.lifespan(url, key):` "
|
|
44
|
+
"or call `orm.set_client(client)` directly."
|
|
45
|
+
)
|
|
46
|
+
return _client
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def set_client(client: AsyncClient | None) -> None:
|
|
50
|
+
global _client
|
|
51
|
+
_client = client
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@asynccontextmanager
|
|
55
|
+
async def lifespan(url: str, key: str) -> AsyncIterator[AsyncClient]:
|
|
56
|
+
"""Async context manager that owns one AsyncClient for its lifetime.
|
|
57
|
+
|
|
58
|
+
Yields the client so callers can stash it on app state if they want;
|
|
59
|
+
most code should just import ``get_client`` from inside request handlers.
|
|
60
|
+
"""
|
|
61
|
+
client = await acreate_client(url, key)
|
|
62
|
+
set_client(client)
|
|
63
|
+
try:
|
|
64
|
+
yield client
|
|
65
|
+
finally:
|
|
66
|
+
set_client(None)
|
|
67
|
+
# Best-effort teardown of the underlying httpx pools. supabase-py's
|
|
68
|
+
# AsyncClient doesn't expose a top-level close as of 2.x; we close
|
|
69
|
+
# each subclient that does so connection pools drain cleanly on
|
|
70
|
+
# graceful shutdown.
|
|
71
|
+
for sub in ("postgrest", "auth", "storage", "functions"):
|
|
72
|
+
obj = getattr(client, sub, None)
|
|
73
|
+
close = getattr(obj, "aclose", None)
|
|
74
|
+
if close is not None:
|
|
75
|
+
try:
|
|
76
|
+
await close()
|
|
77
|
+
except Exception: # noqa: BLE001 — shutdown is best-effort
|
|
78
|
+
pass
|