supabase-orm 0.1.1__tar.gz → 0.1.2__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 (42) hide show
  1. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/PKG-INFO +210 -30
  2. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/README.md +209 -29
  3. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/src/supabase_orm/__init__.py +3 -3
  4. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/src/supabase_orm/_base.py +42 -7
  5. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/src/supabase_orm/_client.py +29 -51
  6. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/src/supabase_orm/_filters.py +38 -34
  7. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/src/supabase_orm/_predicates.py +36 -1
  8. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/src/supabase_orm/_query.py +232 -103
  9. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/src/supabase_orm/_serializers.py +15 -5
  10. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/src/supabase_orm/_version.py +2 -2
  11. supabase_orm-0.1.2/tests/integration/test_iter.py +91 -0
  12. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/integration/test_predicates.py +63 -0
  13. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/test_base.py +78 -0
  14. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/test_client.py +34 -76
  15. supabase_orm-0.1.2/tests/test_iter.py +231 -0
  16. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/test_predicates.py +95 -0
  17. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/test_query.py +92 -0
  18. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/uv.lock +3 -3
  19. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/.env.example +0 -0
  20. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/.github/workflows/publish.yml +0 -0
  21. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/.gitignore +0 -0
  22. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/LICENSE +0 -0
  23. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/pyproject.toml +0 -0
  24. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/src/supabase_orm/_embed.py +0 -0
  25. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/src/supabase_orm/_exceptions.py +0 -0
  26. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/src/supabase_orm/_rpc.py +0 -0
  27. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/__init__.py +0 -0
  28. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/conftest.py +0 -0
  29. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/integration/README.md +0 -0
  30. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/integration/__init__.py +0 -0
  31. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/integration/conftest.py +0 -0
  32. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/integration/schema.sql +0 -0
  33. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/integration/test_embeds.py +0 -0
  34. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/integration/test_filters.py +0 -0
  35. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/integration/test_rpc.py +0 -0
  36. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/integration/test_writes_and_terminals.py +0 -0
  37. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/test_embed.py +0 -0
  38. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/test_exceptions.py +0 -0
  39. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/test_filters.py +0 -0
  40. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/test_rpc.py +0 -0
  41. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/test_serializers.py +0 -0
  42. {supabase_orm-0.1.1 → supabase_orm-0.1.2}/tests/test_wire.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: supabase-orm
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Lightweight async ORM on top of supabase-py with Pydantic validation.
5
5
  Project-URL: Homepage, https://github.com/viperadnan-git/supabase-orm
6
6
  Project-URL: Repository, https://github.com/viperadnan-git/supabase-orm
@@ -48,19 +48,23 @@ Description-Content-Type: text/markdown
48
48
 
49
49
  - **Type-safe query builder.** Every operator on `Model.query` is a real method with a real signature — autocomplete works, typos surface as `AttributeError` at call time, not as silent server-side errors.
50
50
 
51
+ - **Typed predicate expressions.** `Pet.f.age >= 5` builds a composable `Predicate`. Combine with `|` / `&` / `~` and pass to `.or_()` / `.not_()`. Reads as boolean logic, type-checks as expressions.
52
+
53
+ - **Keyset iteration that scales.** `async for pet in Pet.query.eq(...).iter():` paginates by PK with constant-time-per-batch and no offset cliff. Works on tables of any size; race-safe under concurrent inserts and deletes.
54
+
51
55
  - **Pydantic v2 throughout.** Your model *is* the row schema, the response schema, and the request body schema. No DTO layer.
52
56
 
53
57
  - **Async-first.** Built for `asyncio` and FastAPI — every terminal call is `await`-able, every chain is a fresh builder.
54
58
 
55
59
  - **PostgREST embeds, declared at the type level.** Mark a field as `Annotated[Owner, Relation(...)]` and the ORM builds the right `select=` string for you, including `!inner` / FK hints / per-relation filters.
56
60
 
57
- - **Per-request RLS via `ContextVar`.** Attach a JWT in a FastAPI middleware and Postgres row-level security sees the user. No client-per-request overhead, no leakage between concurrent requests.
61
+ - **Per-request RLS via `ContextVar`.** Pair `use_client()` with a JWT-authenticated client in a FastAPI middleware Postgres row-level security sees the user, with zero leakage between concurrent requests.
58
62
 
59
63
  - **Typed RPC helpers.** Call `setof` functions with row validation, get a single row, or coerce a scalar — all with one line.
60
64
 
61
65
  - **Opt-in foot-gun guards.** Unfiltered bulk `delete()` / `update()` raise unless you pass `allow_unfiltered=True`.
62
66
 
63
- - **Tested both ways.** 166 mock tests cover the wire contract (every operator, every serializer, every shorthand). 34 integration tests run against a real Supabase project to confirm PostgREST actually interprets those calls the way we expect.
67
+ - **Tested both ways.** 230+ mock tests cover the wire contract (every operator, every serializer, every shorthand, predicate composition, keyset iteration). 60+ integration tests run against a real Supabase project to confirm PostgREST actually interprets those calls the way we expect.
64
68
 
65
69
  ---
66
70
 
@@ -105,10 +109,16 @@ class PetWithOwner(SupabaseModel, table="pets"):
105
109
 
106
110
 
107
111
  async with lifespan(SUPABASE_URL, SUPABASE_KEY):
108
- # Read
112
+ # Read — chain style for sequential AND
109
113
  cats = await Pet.query.eq("species", "cat").order_by("-created_at").limit(10).all()
110
114
  one = await PetWithOwner.get(some_id)
111
115
 
116
+ # Read — typed predicates for OR / NOT / nested boolean logic
117
+ rescues = await Pet.query.or_(
118
+ Pet.f.species == "cat",
119
+ (Pet.f.species == "dog") & (Pet.f.adopted == False),
120
+ ).all()
121
+
112
122
  # Write
113
123
  p = await Pet.create(name="Whiskers", species="cat", adopted=False)
114
124
  p.name = "Mr. Whiskers"
@@ -135,11 +145,12 @@ class Pet(SupabaseModel, table="pets"):
135
145
 
136
146
  ### Class kwargs
137
147
 
138
- | Kwarg | Default | What it does |
139
- |----------|---------|-----------------------------------------------------------------|
140
- | `table` | — | PostgREST table or view name. **Required.** |
141
- | `pk` | `"id"` | Primary-key field name. Used by `get()`, `save()`, `delete()`. |
142
- | `select` | auto | Override the auto-derived `select=` string. Escape hatch only. |
148
+ | Kwarg | Default | What it does |
149
+ |---------------|------------------|-----------------------------------------------------------------------------|
150
+ | `table` | — | PostgREST table or view name. **Required.** |
151
+ | `pk` | `"id"` | Primary-key field name. Used by `get()`, `save()`, `delete()`, `iter()`. |
152
+ | `select` | auto | Override the auto-derived `select=` string. Escape hatch only. |
153
+ | `query_class` | `QueryBuilder` | Custom `QueryBuilder` subclass for `.query`. MRO-inherited via base models. |
143
154
 
144
155
  ### Relations
145
156
 
@@ -164,24 +175,58 @@ class PetWithOwner(SupabaseModel, table="pets"):
164
175
 
165
176
  ### Lean projections
166
177
 
167
- Two models can point at the same table for full-detail vs. trimmed views:
178
+ Two models on the same table full-detail vs. trimmed view. Just query the lean one directly when its fields are enough:
168
179
 
169
180
  ```python
170
181
  class Pet(SupabaseModel, table="pets"):
171
182
  id: UUID
172
183
  name: str
173
184
  species: str
185
+ bio: str # heavy column you don't always need
174
186
  created_at: datetime
175
187
 
176
188
  class PetMini(SupabaseModel, table="pets"):
177
189
  id: UUID
178
190
  name: str
179
191
 
180
- # Build the full query, then rebind:
181
- mini_rows = await Pet.query.eq("adopted", False).as_(PetMini).all()
182
- # → list[PetMini]
192
+ # Half the wire bytes, fewer Pydantic fields to validate:
193
+ await PetMini.query.eq("species", "cat").all()
194
+ ```
195
+
196
+ For huge result sets, pair with `.iter()` so the lean projection runs per batch:
197
+
198
+ ```python
199
+ async for mini in PetMini.query.iter(batch_size=5000):
200
+ process(mini)
183
201
  ```
184
202
 
203
+ ### `.as_(target)` — rebind the response shape
204
+
205
+ Use `.as_()` when you need to **filter on source columns the lean model doesn't expose**, or to switch the response shape conditionally without rebuilding the chain. Two modes:
206
+
207
+ **Same-table SupabaseModel** — narrows the wire `select` AND validates against the target:
208
+
209
+ ```python
210
+ # Wire: ?select=id,name → list[PetMini]
211
+ await Pet.query.eq("adopted", False).as_(PetMini).all()
212
+ ```
213
+
214
+ **Plain Pydantic BaseModel** — validation only, wire `select` unchanged. The chain keeps using the source model's columns and predicates:
215
+
216
+ ```python
217
+ from pydantic import BaseModel
218
+
219
+ class PetCard(BaseModel):
220
+ id: UUID
221
+ name: str
222
+
223
+ # Filter on `bio` (only on Pet) but validate as PetCard:
224
+ await Pet.query.fts("bio", "fluffy").as_(PetCard).all()
225
+ # → list[PetCard], wire still selects all of Pet's columns
226
+ ```
227
+
228
+ If a same-table SupabaseModel target works, prefer it — you get the wire-narrowing optimization. Reach for the plain BaseModel form only when the lean schema doesn't have all the columns you need to filter on.
229
+
185
230
  ---
186
231
 
187
232
  ## Querying
@@ -196,12 +241,13 @@ mini_rows = await Pet.query.eq("adopted", False).as_(PetMini).all()
196
241
  | `.maybe_one()` | `T \| None` | At most one row. Raises if >1. |
197
242
  | `.count()` | `int` | Head-only request with `count="exact"`. |
198
243
  | `.all_with_count()` | `tuple[list[T], int]` | Rows + filtered total in one round-trip (for paginated endpoints). |
244
+ | `.iter(batch_size=)` | `AsyncIterator[T]` | Stream every matching row via PK keyset pagination. Scales to any size. |
199
245
  | `.values(*cols)` | `list[dict]` | Ad-hoc projection, raw dicts, no Pydantic validation. |
200
246
  | `.raw()` | postgrest builder | Escape hatch. |
201
247
 
202
248
  ### Filter operators
203
249
 
204
- Every operator below works as a method on `Model.query` **and** inside `or_()` / `not_()` predicate lambdas:
250
+ Every operator below works as a method on `Model.query` (chain style) and as a method/operator on `Model.f.<column>` (predicate style):
205
251
 
206
252
  ```python
207
253
  Pet.query.eq("species", "cat")
@@ -216,34 +262,127 @@ Pet.query.overlaps("tags", ["indoor"])
216
262
  Pet.query.fts("description", "fluffy") # plus plfts / phfts / wfts
217
263
  ```
218
264
 
219
- ### Compound predicates
265
+ ### Typed predicates (`Pet.f`)
266
+
267
+ For OR / NOT / nested boolean logic, use the `Model.f` namespace. Every column is a typed `Column[T]` with operator overloads — `==`, `!=`, `<`, `<=`, `>`, `>=` build atomic predicates, and `|` / `&` / `~` compose them.
268
+
269
+ ```python
270
+ from supabase_orm import SupabaseModel
271
+
272
+ class Pet(SupabaseModel, table="pets"):
273
+ id: UUID
274
+ name: str
275
+ species: str
276
+ age: int
277
+ adopted: bool
278
+ tags: list[str]
279
+
280
+ # Atomic predicates — what `==`, `>=` etc. return:
281
+ Pet.f.species == "cat" # column.eq.value
282
+ Pet.f.age >= 5
283
+ Pet.f.name.like("Mr%")
284
+ Pet.f.tags.contains(["indoor"])
285
+ Pet.f.owner_id.is_null()
286
+ ```
287
+
288
+ Compose with boolean operators:
289
+
290
+ ```python
291
+ # OR
292
+ await Pet.query.or_(
293
+ Pet.f.species == "cat",
294
+ Pet.f.species == "dog",
295
+ ).all()
296
+
297
+ # OR with AND inside a branch
298
+ await Pet.query.or_(
299
+ Pet.f.species == "cat",
300
+ (Pet.f.species == "dog") & (Pet.f.age >= 5),
301
+ ).all()
302
+
303
+ # NOT — negates any predicate, atomic or compound
304
+ await Pet.query.not_(Pet.f.adopted == True).all()
305
+ await Pet.query.not_((Pet.f.species == "cat") | (Pet.f.species == "dog")).all()
306
+ ```
307
+
308
+ The chain and predicates compose freely — chain handles sequential AND, predicates handle boolean logic:
309
+
310
+ ```python
311
+ await (
312
+ Pet.query.eq("owner_id", uid) # chain: AND
313
+ .or_(Pet.f.species == "cat", Pet.f.species == "dog") # predicate: OR
314
+ .order_by("-created_at")
315
+ .limit(20)
316
+ .all()
317
+ )
318
+ ```
319
+
320
+ #### Foot-gun guards
321
+
322
+ ```python
323
+ if Pet.f.age >= 5: # TypeError: Predicate is not a bool
324
+ ...
325
+ ```
326
+
327
+ A predicate is an expression that builds a query — it has no truth value at the call site. The runtime rejects `bool(predicate)` loudly so `if Pet.f.adopted == True:` mistakes fail at the first hit instead of always being truthy.
328
+
329
+ #### Lambda form
220
330
 
221
331
  ```python
222
- # OR across branches:
223
332
  await Pet.query.or_(
224
333
  lambda q: q.eq("species", "cat"),
225
334
  lambda q: q.eq("species", "dog").gte("age", 5),
226
335
  ).all()
227
-
228
- # NOT:
229
- await Pet.query.not_(lambda q: q.eq("adopted", True)).all()
230
336
  ```
231
337
 
338
+ Prefer the `Pet.f` form — it composes, reads as boolean logic, and gives operator-level type safety.
339
+
232
340
  ### `match()` — multi-column equality
233
341
 
234
342
  ```python
235
343
  await Pet.query.match({"species": "cat", "adopted": False}).all()
236
344
  ```
237
345
 
238
- PostgREST's `match` is multi-column by design and has no predicate-string form, so it isn't usable inside `or_()` / `not_()`.
346
+ `match` is a `postgrest-py` convenience it just expands to multiple `eq` filters on the wire, equivalent to chaining `.eq()` per pair or composing `(Pet.f.a == 1) & (Pet.f.b == 2)`. Use whichever reads best.
239
347
 
240
348
  ### Ordering & pagination
241
349
 
350
+ `order_by()` accepts strings (`"-col"` for descending) or typed `Pet.f.<col>.asc()` / `.desc()`. The typed form unlocks `nulls="first"` / `"last"` and gets autocomplete + column-existence checks.
351
+
242
352
  ```python
353
+ # String shorthand
243
354
  await Pet.query.order_by("-created_at", "name").limit(20).offset(40).all()
244
- await Pet.query.range(0, 9).all() # PostgREST-style inclusive range
355
+ await Pet.query.range(0, 9).all() # inclusive range
356
+
357
+ # Typed form — autocomplete on Pet.f, nulls position support
358
+ await Pet.query.order_by(Pet.f.created_at.desc()).all()
359
+ await Pet.query.order_by(Pet.f.last_login.desc(nulls="last")).all()
360
+
361
+ # Mix freely
362
+ await Pet.query.order_by("species", Pet.f.amount.desc()).all()
245
363
  ```
246
364
 
365
+ Typos in either form raise `AttributeError` at call time — never silent server errors.
366
+
367
+ ### Iteration over large result sets
368
+
369
+ `.iter()` streams every matching row using **PK keyset pagination** — `WHERE pk > :cursor ORDER BY pk LIMIT :batch_size` per batch. Constant time per batch regardless of position; scales to billions of rows.
370
+
371
+ ```python
372
+ async for pet in Pet.query.eq("species", "cat").iter():
373
+ process(pet)
374
+
375
+ # Tune batch size for the memory / round-trip tradeoff:
376
+ async for row in BigTable.query.iter(batch_size=5000):
377
+ ...
378
+
379
+ # Compose with projections to narrow wire bytes per batch:
380
+ async for mini in Pet.query.eq("species", "cat").as_(PetMini).iter():
381
+ ...
382
+ ```
383
+
384
+ `.iter()` owns ordering and pagination — chaining `.order_by()` / `.limit()` / `.offset()` / `.range()` before it raises `SupabaseORMUsageError`. Race-safe under concurrent inserts (new rows with `pk > cursor` get picked up) and deletes (deleted rows simply don't appear).
385
+
247
386
  ### Pagination with total
248
387
 
249
388
  ```python
@@ -363,19 +502,24 @@ app = FastAPI(lifespan=lifespan)
363
502
 
364
503
  ### Per-request RLS via JWT
365
504
 
366
- The client is stored in a `ContextVar`, so each FastAPI request runs in its own copied context `set_auth()` mutations are isolated to that request, even under concurrent load.
505
+ The client is stored in a `ContextVar`, so each FastAPI request runs in its own copied context. Pair `use_client()` with a per-request authenticated client to isolate the JWT and therefore the RLS identity — to that request only:
367
506
 
368
507
  ```python
369
- from supabase_orm import set_auth
508
+ from supabase import acreate_client
509
+ from supabase_orm import use_client
370
510
 
371
511
  @app.middleware("http")
372
- async def attach_jwt(request, call_next):
373
- if (auth := request.headers.get("authorization")):
374
- set_auth(auth.removeprefix("Bearer "))
375
- try:
512
+ async def per_request_client(request, call_next):
513
+ auth = request.headers.get("authorization")
514
+ if not auth:
515
+ return await call_next(request)
516
+
517
+ jwt = auth.removeprefix("Bearer ")
518
+ client = await acreate_client(SUPABASE_URL, SUPABASE_ANON_KEY)
519
+ client.postgrest.auth(jwt)
520
+
521
+ async with use_client(client):
376
522
  return await call_next(request)
377
- finally:
378
- set_auth(None) # back to the anon/service-role key from lifespan
379
523
  ```
380
524
 
381
525
  Inside a handler, just call the ORM as usual — Postgres RLS sees the user:
@@ -386,6 +530,9 @@ async def my_pets():
386
530
  return await Pet.query.order_by("-created_at").all()
387
531
  ```
388
532
 
533
+ > [!IMPORTANT]
534
+ > Don't mutate the app-wide client's auth headers across requests (e.g. `get_client().postgrest.auth(jwt)` directly). The underlying postgrest sub-client is shared, so the mutation leaks across overlapping requests. `ContextVar` isolates *references*, not the objects they point at — `use_client()` with a per-request client is the only safe pattern.
535
+
389
536
  ### Per-request override with a dedicated client
390
537
 
391
538
  For background tasks, scripts, or any place outside the request lifecycle:
@@ -428,6 +575,40 @@ class Money:
428
575
  register_serializer(Money, lambda v: v.cents)
429
576
  ```
430
577
 
578
+ ### Custom `QueryBuilder` methods
579
+
580
+ Subclass `QueryBuilder` to add your own chainable methods, then opt in either per-model or project-wide.
581
+
582
+ ```python
583
+ from supabase_orm import QueryBuilder, SupabaseModel
584
+
585
+ class PaginatedQB(QueryBuilder):
586
+ async def paginate(self, *, page: int, per_page: int):
587
+ return await self.range(
588
+ page * per_page, (page + 1) * per_page - 1
589
+ ).all_with_count()
590
+ ```
591
+
592
+ **Project-wide** — define a base model once, every subclass inherits via MRO:
593
+
594
+ ```python
595
+ class _AppModel(SupabaseModel):
596
+ __query_class__ = PaginatedQB
597
+
598
+ class User(_AppModel, table="users"): # gets PaginatedQB automatically
599
+ id: UUID
600
+ email: str
601
+
602
+ rows, total = await User.query.eq("is_active", True).paginate(page=0, per_page=20)
603
+ ```
604
+
605
+ **Per-model** — opt in inline, no base class needed:
606
+
607
+ ```python
608
+ class Audit(SupabaseModel, table="audit_log", query_class=PaginatedQB):
609
+ ...
610
+ ```
611
+
431
612
  ---
432
613
 
433
614
  ## Error handling
@@ -456,7 +637,6 @@ from supabase_orm import (
456
637
  - Unfiltered bulk `delete()` / `update()` without `allow_unfiltered=True`.
457
638
  - `as_()` to a model on a different table.
458
639
  - `instance.update()` trying to change the primary key or a relation field.
459
- - `set_auth(None)` with no recorded default key.
460
640
 
461
641
  Map them to HTTP responses in one place:
462
642