duo-orm 0.1.0__py3-none-any.whl
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.
- duo_orm/__init__.py +82 -0
- duo_orm/basemodel.py +146 -0
- duo_orm/db.py +251 -0
- duo_orm/exceptions.py +84 -0
- duo_orm/executor.py +282 -0
- duo_orm/migrations/__init__.py +1 -0
- duo_orm/migrations/cli.py +164 -0
- duo_orm/migrations/config.py +239 -0
- duo_orm/patch.py +73 -0
- duo_orm/query.py +630 -0
- duo_orm/session.py +36 -0
- duo_orm-0.1.0.dist-info/METADATA +396 -0
- duo_orm-0.1.0.dist-info/RECORD +16 -0
- duo_orm-0.1.0.dist-info/WHEEL +5 -0
- duo_orm-0.1.0.dist-info/entry_points.txt +2 -0
- duo_orm-0.1.0.dist-info/top_level.txt +1 -0
duo_orm/session.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# duo_orm/session.py
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from contextvars import ContextVar
|
|
5
|
+
from typing import Union
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.orm import Session
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
|
|
10
|
+
# This ContextVar is the heart of our context-aware session management.
|
|
11
|
+
# It acts as a task-safe (or thread-safe) global variable that holds the
|
|
12
|
+
# active session for the current unit of work (e.g., a web request or a
|
|
13
|
+
# transaction block).
|
|
14
|
+
#
|
|
15
|
+
# The rest of the ORM will use .get() on this variable to find the
|
|
16
|
+
# currently active session.
|
|
17
|
+
active_session_var: ContextVar[Union[Session, AsyncSession]] = ContextVar("active_session")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_async_context() -> bool:
|
|
21
|
+
"""
|
|
22
|
+
Checks if the code is currently running inside an asyncio event loop.
|
|
23
|
+
|
|
24
|
+
This is the primary mechanism our dispatcher functions use to decide
|
|
25
|
+
whether to run synchronous or asynchronous database logic.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
bool: True if inside an event loop, False otherwise.
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
# This will succeed if an event loop is running in the current thread.
|
|
32
|
+
asyncio.get_running_loop()
|
|
33
|
+
return True
|
|
34
|
+
except RuntimeError:
|
|
35
|
+
# get_running_loop() raises a RuntimeError if no loop is running.
|
|
36
|
+
return False
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: duo-orm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An opinionated, modern ORM for Python combining the power of SQLAlchemy 2.0 with a clean, symmetrical API for sync and async operations.
|
|
5
|
+
Author-email: Your Name <you@example.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/example/duo-orm
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/example/duo-orm/issues
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Topic :: Database
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
18
|
+
Requires-Dist: alembic>=1.8
|
|
19
|
+
Requires-Dist: click>=8.0
|
|
20
|
+
Requires-Dist: toml>=0.10
|
|
21
|
+
Requires-Dist: aiosqlite>=0.17
|
|
22
|
+
Provides-Extra: all
|
|
23
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == "all"
|
|
24
|
+
Requires-Dist: oracledb>=2.0; extra == "all"
|
|
25
|
+
Requires-Dist: pymysql>=1.1; extra == "all"
|
|
26
|
+
Requires-Dist: asyncmy>=0.2; extra == "all"
|
|
27
|
+
Requires-Dist: pyodbc>=5.0; extra == "all"
|
|
28
|
+
Requires-Dist: aioodbc>=0.5; extra == "all"
|
|
29
|
+
Requires-Dist: aiosqlite>=0.20; extra == "all"
|
|
30
|
+
Provides-Extra: postgresql
|
|
31
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == "postgresql"
|
|
32
|
+
Provides-Extra: oracle
|
|
33
|
+
Requires-Dist: oracledb>=2.0; extra == "oracle"
|
|
34
|
+
Provides-Extra: mysql
|
|
35
|
+
Requires-Dist: pymysql>=1.1; extra == "mysql"
|
|
36
|
+
Requires-Dist: asyncmy>=0.2; extra == "mysql"
|
|
37
|
+
Provides-Extra: mssql
|
|
38
|
+
Requires-Dist: pyodbc>=5.0; extra == "mssql"
|
|
39
|
+
Requires-Dist: aioodbc>=0.5; extra == "mssql"
|
|
40
|
+
Provides-Extra: sqlite
|
|
41
|
+
Requires-Dist: aiosqlite>=0.20; extra == "sqlite"
|
|
42
|
+
Provides-Extra: dev
|
|
43
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
44
|
+
Requires-Dist: pytest-asyncio>=0.20; extra == "dev"
|
|
45
|
+
Requires-Dist: ruff>=0.1; extra == "dev"
|
|
46
|
+
Requires-Dist: black>=23.0; extra == "dev"
|
|
47
|
+
|
|
48
|
+
# DuoORM
|
|
49
|
+
|
|
50
|
+
An opinionated, modern ORM for Python combining the power of SQLAlchemy 2.0 with a clean, symmetrical API for sync and async operations.
|
|
51
|
+
|
|
52
|
+
## Core Philosophy
|
|
53
|
+
|
|
54
|
+
DuoORM is built on a simple idea: you are in explicit control of the 'Unit of Work'. The API has two predictable modes and each `Database(...)` you instantiate manufactures its own `db.Model` base. Models from `db1.Model` and `db2.Model` stay isolated—even if they point to the same physical database—so you can safely manage multiple connections in one app (just keep each model hierarchy tied to its owning `db`).
|
|
55
|
+
|
|
56
|
+
* **Default Mode ("Statement-Driven"):** By default, every call (`.save()`, `.first()`) is its own "micro-transaction." The ORM creates a short-lived session that only knows about the single object you are operating on. It will predictably not see related objects, guaranteeing a simple, single-statement operation.
|
|
57
|
+
|
|
58
|
+
* **Transaction Mode ("State-Driven"):** When you use `async with db.transaction():`, you "opt-in" to a full-power, state-tracking Unit of Work. The ORM will use a single shared session for the entire block, allowing you to work with multiple objects, relationships, and cascades, all of which will be committed or rolled back together.
|
|
59
|
+
|
|
60
|
+
**Philosophy:** Clarity over cleverness. The 'magic' of state-tracking only happens when you explicitly ask for it.
|
|
61
|
+
|
|
62
|
+
## Session & Transaction Model
|
|
63
|
+
|
|
64
|
+
There is no autosession detection logic. By default each ORM call opens a connection, runs exactly one SQL statement, commits (for writes), and closes. When you need multi-step or related work to behave atomically, you wrap it in an explicit transaction block:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
async with db.transaction():
|
|
68
|
+
user = await User.where(User.id == 1).first()
|
|
69
|
+
post = Post(title="Hello", author_id=user.id)
|
|
70
|
+
await post.save()
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Inside the block your code automatically shares a single session. The block commits when it exits successfully and rolls back on error, giving you predictable Unit-of-Work semantics only when you opt-in.
|
|
74
|
+
|
|
75
|
+
## Getting Started
|
|
76
|
+
|
|
77
|
+
### 1. Installation
|
|
78
|
+
|
|
79
|
+
Drivers are managed automatically. Supply only base dialect URLs (e.g., `postgresql://...`, `mysql://...`, `sqlite:///file.db`) — do **not** include `+driver`; we inject the correct sync/async driver for you. If you pass extra query parameters that your DBAPI rejects, the error will surface directly from the driver so you can fix the URL.
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# Default: install with SQLite support (stdlib sqlite3 + aiosqlite)
|
|
83
|
+
pip install duo-orm
|
|
84
|
+
|
|
85
|
+
# Optional: install only the drivers you need
|
|
86
|
+
pip install duo-orm[postgresql] # psycopg (sync+async)
|
|
87
|
+
pip install duo-orm[mysql] # pymysql (sync) + asyncmy (async)
|
|
88
|
+
pip install duo-orm[mssql] # pyodbc (sync) + aioodbc (async)
|
|
89
|
+
pip install duo-orm[oracle] # oracledb (sync+async)
|
|
90
|
+
pip install duo-orm[all] # explicitly install everything
|
|
91
|
+
```
|
|
92
|
+
Need a stdlib SQLite fallback? See the SQLite note below:
|
|
93
|
+
|
|
94
|
+
> SQLite fallback (only if your Python lacks stdlib sqlite3, e.g., minimal Docker/Lambda):
|
|
95
|
+
> ```bash
|
|
96
|
+
> pip install pysqlite3-binary
|
|
97
|
+
> ```
|
|
98
|
+
> Then alias it once at startup:
|
|
99
|
+
> ```python
|
|
100
|
+
> import sys, pysqlite3
|
|
101
|
+
> sys.modules["sqlite3"] = pysqlite3
|
|
102
|
+
> ```
|
|
103
|
+
> This makes `import sqlite3` use the binary fallback.
|
|
104
|
+
|
|
105
|
+
Async derivation uses the async driver for your dialect (psycopg for Postgres, asyncmy for MySQL, aioodbc for MSSQL, aiosqlite for SQLite). If the async driver isn’t installed, async calls will fail—install the matching extra above or `duo-orm[all]`.
|
|
106
|
+
|
|
107
|
+
DuoORM rides on SQLAlchemy’s dialect support. Features are available per backend only if SQLAlchemy exposes them (e.g., full JSON/ARRAY helpers on PostgreSQL; other dialects allow JSON storage but not the richer operators, so related tests are skipped with a reason).
|
|
108
|
+
|
|
109
|
+
### 2. Initialization
|
|
110
|
+
|
|
111
|
+
Run the `init` command to create the basic structure:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
duo-orm init
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
By default, scaffolding lands under `<project-root>/db/`:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
db/
|
|
121
|
+
├── database.py
|
|
122
|
+
├── models/
|
|
123
|
+
│ └── __init__.py
|
|
124
|
+
└── migrations/
|
|
125
|
+
├── alembic.ini
|
|
126
|
+
├── env.py
|
|
127
|
+
├── script.py.mako
|
|
128
|
+
└── versions/
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`duo-orm init` also creates (or updates) `<project-root>/pyproject.toml` so it contains:
|
|
132
|
+
|
|
133
|
+
```toml
|
|
134
|
+
[tool.duo-orm]
|
|
135
|
+
duo_orm_dir = "db"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Need a different location? Pass `--dir` during init:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
duo-orm init --dir src/app/db_core
|
|
142
|
+
duo-orm migration create "add users"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The chosen path is written back to `pyproject.toml`, so future `duo-orm migration ...` commands can omit `--dir`. Edit that stanza (or re-run `init --dir ...`) whenever you want to move the database stack.
|
|
146
|
+
|
|
147
|
+
**Optional:** call `db.connect()` during application startup (after importing your generated `database.py`) if you want to surface misconfiguration or driver issues immediately rather than waiting for the first query/transaction.
|
|
148
|
+
|
|
149
|
+
### 3. Defining Models
|
|
150
|
+
|
|
151
|
+
Define your models in the `models` directory, inheriting from `db.Model`:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
# models/user.py
|
|
155
|
+
from ..database import db
|
|
156
|
+
from duo_orm import Mapped, mapped_column
|
|
157
|
+
|
|
158
|
+
class User(db.Model):
|
|
159
|
+
__tablename__ = "users"
|
|
160
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
161
|
+
name: Mapped[str]
|
|
162
|
+
age: Mapped[int]
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### 4. Creating Migrations
|
|
166
|
+
|
|
167
|
+
Once you have defined your models, create a migration to apply the schema to your database:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
duo-orm migration create "initial models"
|
|
171
|
+
duo-orm migration upgrade
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### 5. Basic Usage
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
import asyncio
|
|
178
|
+
from .database import db
|
|
179
|
+
from .models import User
|
|
180
|
+
|
|
181
|
+
async def main():
|
|
182
|
+
# Simple writes
|
|
183
|
+
user = User(name="Alice", age=30)
|
|
184
|
+
await user.save() # INSERT
|
|
185
|
+
|
|
186
|
+
# Simple reads with Pythonic operators
|
|
187
|
+
users = await User.where(User.age > 25).all()
|
|
188
|
+
alice = await User.where(User.name == "Alice").first()
|
|
189
|
+
|
|
190
|
+
# More complex queries
|
|
191
|
+
users = await User.where(
|
|
192
|
+
(User.age > 30) & User.name.startswith('A')
|
|
193
|
+
).all()
|
|
194
|
+
|
|
195
|
+
# Updates
|
|
196
|
+
alice.age = 31
|
|
197
|
+
await alice.save() # UPDATE
|
|
198
|
+
|
|
199
|
+
# Deletes
|
|
200
|
+
await alice.delete() # DELETE
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
asyncio.run(main())
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Framework Integration
|
|
207
|
+
|
|
208
|
+
DuoORM plays well with FastAPI (and any async framework) by wrapping each request in a dependency that opens a transaction block:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from fastapi import Depends, FastAPI
|
|
212
|
+
from .database import db
|
|
213
|
+
|
|
214
|
+
app = FastAPI()
|
|
215
|
+
|
|
216
|
+
async def db_session():
|
|
217
|
+
async with db.transaction():
|
|
218
|
+
yield
|
|
219
|
+
|
|
220
|
+
@app.get("/users/{user_id}")
|
|
221
|
+
async def read_user(user_id: int, _=Depends(db_session)):
|
|
222
|
+
return await User.where(User.id == user_id).first()
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
All queries issued inside the request share the same session; pending writes persist until the response and exceptions roll the block back automatically.
|
|
226
|
+
|
|
227
|
+
## Power User Access
|
|
228
|
+
|
|
229
|
+
When you need raw SQLAlchemy control, opt into a standalone session:
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
async with db.standalone_session() as session:
|
|
233
|
+
stmt = User.where(User.age > 30).alchemize()
|
|
234
|
+
rows = (await session.scalars(stmt)).all()
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
`db.standalone_session()` returns a plain `AsyncSession`, so you can use Core queries, streaming, or bulk inserts without leaving the DuoORM ecosystem.
|
|
238
|
+
|
|
239
|
+
## Behaviour Summary
|
|
240
|
+
|
|
241
|
+
| Context | Session lifetime | Commit model | Typical use |
|
|
242
|
+
| --- | --- | --- | --- |
|
|
243
|
+
| Default call (`await User.where(...).first()`) | Short-lived per call | Auto-commit / auto-close | Scripts, simple reads & writes |
|
|
244
|
+
| `db.transaction()` block | Shared for the block | Commit on exit, rollback on error | Web requests, multi-step workflows |
|
|
245
|
+
| `db.standalone_session()` | Manual | You commit / rollback | Power users, raw SQLAlchemy |
|
|
246
|
+
|
|
247
|
+
## Developer Experience Principles
|
|
248
|
+
|
|
249
|
+
* **Simple:** `await Model.query()` just works—no session juggling for one-off calls.
|
|
250
|
+
* **Safe:** One statement per operation unless you explicitly opt into a transaction.
|
|
251
|
+
* **Symmetric:** Sync and async APIs mirror each other (drop `await` for sync code).
|
|
252
|
+
* **Predictable:** No hidden flushes, dirty graphs, or autosession guessing.
|
|
253
|
+
* **Framework-agnostic:** Works the same in FastAPI, CLIs, scripts, or the REPL.
|
|
254
|
+
|
|
255
|
+
## Validation & Model Flags
|
|
256
|
+
|
|
257
|
+
* `Model.validate()` – override this hook to enforce business rules; raise `ValidationError(field="age", message)` to block `save()`/`bulk_create()`.
|
|
258
|
+
* `Model.fields()` – returns the tuple of column names defined on the model so you can introspect schemas at runtime.
|
|
259
|
+
* `Model.to_dict()` – serializes the instance into a plain dictionary of column values for JSON responses, logging, etc.
|
|
260
|
+
* `ValidationError` – now part of the public API and carries optional `field` and `detail` metadata so you can surface meaningful errors to clients.
|
|
261
|
+
* `info={"set_on": "create"}` / `info={"set_on": {"create","update"}}` – declaratively control when a column is stamped (insert only vs. insert + every save). For compatibility we still honor `auto_now_add` / `auto_now` flags.
|
|
262
|
+
```python
|
|
263
|
+
created_at = mapped_column(DateTime(timezone=True), info={"set_on": "create"})
|
|
264
|
+
updated_at = mapped_column(DateTime(timezone=True), info={"set_on": {"create", "update"}})
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## Query Enhancements
|
|
268
|
+
|
|
269
|
+
* `QueryBuilder.one()` – fetch exactly one record (raises `ObjectNotFoundError` / `MultipleObjectsFoundError` like SQLAlchemy).
|
|
270
|
+
* `QueryBuilder.exists()` – returns `True/False` without materializing rows.
|
|
271
|
+
* `QueryBuilder.paginate(limit, offset=0)` – oneliner to apply both LIMIT and OFFSET.
|
|
272
|
+
* `QueryBuilder.related(User.posts, where=[...], aggregate="exists", loader="selectin")` – single entry point for filtering/aggregating/eager-loading across one direct relationship. Calling `related()` multiple times or with multi-hop paths (e.g., `User.posts.comments`) isn’t supported; fall back to SQLAlchemy expressions for those cases.
|
|
273
|
+
* `json(User.profile)["flags"]["beta"].is_null()` – Pythonic JSON-path helper that compiles to regular SQLAlchemy expressions, so you can write complex JSON predicates inside `.where(...)` without juggling driver-specific operators.
|
|
274
|
+
* `array(User.tags).includes_any(["python", "orm"])` – expressive ARRAY helper for membership / superset / overlap checks without memorizing dialect-specific operators.
|
|
275
|
+
* All helpers work in sync and async contexts just like `.first()` and `.all()`.
|
|
276
|
+
|
|
277
|
+
## Why SQLAlchemy Underneath?
|
|
278
|
+
|
|
279
|
+
DuoORM stands on SQLAlchemy Core for query generation, schema metadata, type handling, and async engines. It deliberately avoids SQLAlchemy’s ORM layer so it stays lightweight, async-first, and free from invisible Unit-of-Work behavior. When you need to drop down, you already have a real `AsyncSession` in hand.
|
|
280
|
+
|
|
281
|
+
## Query Operators
|
|
282
|
+
|
|
283
|
+
### Basic Comparison
|
|
284
|
+
|
|
285
|
+
| Operator | Example |
|
|
286
|
+
| --- | --- |
|
|
287
|
+
| `==` | `User.name == "Alice"` |
|
|
288
|
+
| `!=` | `User.status != "inactive"` |
|
|
289
|
+
| `>` | `User.age > 25` |
|
|
290
|
+
| `<` | `User.age < 60` |
|
|
291
|
+
| `>=` | `User.salary >= 50000` |
|
|
292
|
+
| `<=` | `User.created_at <= date` |
|
|
293
|
+
|
|
294
|
+
### Membership & Pattern Matching
|
|
295
|
+
|
|
296
|
+
| Method | Example |
|
|
297
|
+
| --- | --- |
|
|
298
|
+
| `.in_([...])` | `User.country.in_(['IE', 'IN'])` |
|
|
299
|
+
| `.notin_([...])` | `User.role.notin_(['banned', 'test'])` |
|
|
300
|
+
| `.contains(value)` | `User.email.contains('@example.com')` |
|
|
301
|
+
| `.icontains(value)` | `User.email.icontains('@example.com')` |
|
|
302
|
+
| `.startswith(prefix)` | `User.name.startswith('Al')` |
|
|
303
|
+
| `.istartswith(prefix)` | `User.name.istartswith('al')` |
|
|
304
|
+
| `.iendswith(suffix)` | `User.slug.iendswith('-beta')` |
|
|
305
|
+
|
|
306
|
+
Case-insensitive helpers (`.icontains`, `.istartswith`, `.iendswith`) are provided by DuoORM and work on any column whose SQLAlchemy type derives from `String`. For custom wildcard patterns you can still fall back to SQLAlchemy’s `.like()` / `.ilike()`, but the ergonomic helpers cover 80% of real-world use.
|
|
307
|
+
|
|
308
|
+
### JSON Path Helper
|
|
309
|
+
|
|
310
|
+
Use the built-in `json()` helper to compose JSON predicates that drop straight into `where()`:
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
from duo_orm import json
|
|
314
|
+
|
|
315
|
+
await User.where(
|
|
316
|
+
json(User.profile)["flags"]["beta"].is_null() |
|
|
317
|
+
json(User.profile)["flags"]["beta"].equals("")
|
|
318
|
+
).all()
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
It mirrors normal Python dict access (`[...]`) and exposes fluent helpers that emit SQLAlchemy clauses:
|
|
322
|
+
|
|
323
|
+
| Helper | Purpose | Example |
|
|
324
|
+
| --- | --- | --- |
|
|
325
|
+
| `json(col)["key"]` | Navigate nested keys/indices | `json(User.profile)["flags"]["beta"]` |
|
|
326
|
+
| `.equals(value)` / `==` | Compare scalars (auto-casts to text unless you call `.as_integer()` etc.) | `json(User.profile)["plan"] == "pro"` |
|
|
327
|
+
| `.contains(fragment)` | JSON containment (`@>`-style) for dict/list fragments | `json(User.profile).contains({"plan": "enterprise"})` |
|
|
328
|
+
| `.has_key(key)` | Key existence (dialect-dependent; raises if unsupported) | `json(User.profile)["flags"].has_key("beta")` |
|
|
329
|
+
| `.is_null()` / `.is_not_null()` | Null checks on the selected path | `json(User.profile)["expires_at"].is_null()` |
|
|
330
|
+
| `.as_integer()` / `.as_float()` / `.as_boolean()` / `.as_text()` | Cast before comparisons | `json(User.profile)["quota"].as_integer() > 10` |
|
|
331
|
+
|
|
332
|
+
Because the helper returns ordinary SQLAlchemy expressions, you can combine them with `&`, `|`, `not_`, nest them alongside other filters, and even extract the raw expression via `.expression()`. Dialect support is enforced by SQLAlchemy—if the backend lacks a JSON operator (e.g., `has_key` on SQLite), you’ll get a clear error at query construction time.
|
|
333
|
+
|
|
334
|
+
> Practical note: full JSON path/operator helpers are available on PostgreSQL today. Other dialects store JSON fine but lack operator parity, so related tests are skipped with an explicit reason until dialect-specific compilers are added.
|
|
335
|
+
|
|
336
|
+
### Array Helper
|
|
337
|
+
|
|
338
|
+
For ARRAY columns, reach for the `array()` helper:
|
|
339
|
+
|
|
340
|
+
```python
|
|
341
|
+
from duo_orm import array
|
|
342
|
+
|
|
343
|
+
await User.where(
|
|
344
|
+
array(User.tags).includes("orm") &
|
|
345
|
+
array(User.tags).includes_all(["python", "asyncio"])
|
|
346
|
+
).all()
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Helper methods map directly to common set-style checks:
|
|
350
|
+
|
|
351
|
+
| Helper | Purpose | Example |
|
|
352
|
+
| --- | --- | --- |
|
|
353
|
+
| `.includes(value)` | True if the array contains that single value (`value = ANY(column)`). | `array(User.tags).includes("orm")` |
|
|
354
|
+
| `.includes_all(values)` | True if the array contains all provided values (`@>`). | `array(User.tags).includes_all(["python","orm"])` |
|
|
355
|
+
| `.includes_any(values)` | True if the array overlaps any provided value (`&&`). | `array(User.tags).includes_any(["java","go"])` |
|
|
356
|
+
| `.length()` | Number of elements (uses `cardinality()` when available, otherwise `array_length(..., 1)`). | `array(User.tags).length() > 2` |
|
|
357
|
+
|
|
358
|
+
These helpers return SQLAlchemy expressions, so they compose with the rest of your filters exactly like built-in clauses. SQLAlchemy/dialect support rules still apply—unsupported ARRAY operators raise the driver’s error (or the helper re-raises with a clearer message).
|
|
359
|
+
|
|
360
|
+
> Practical note: ARRAY helpers are PostgreSQL-only right now; other dialects do not expose compatible ARRAY operators. Tests are skipped with a reason when the backend lacks support.
|
|
361
|
+
|
|
362
|
+
### Logical Combinators
|
|
363
|
+
|
|
364
|
+
| Operator | Example |
|
|
365
|
+
| --- | --- |
|
|
366
|
+
| `&` | `(User.age > 30) & (User.status == 'active')` |
|
|
367
|
+
| `|` | `(User.is_staff == True) | (User.is_admin == True)` |
|
|
368
|
+
|
|
369
|
+
### Sync scripts or workers
|
|
370
|
+
|
|
371
|
+
The same `db.transaction()` helper can be used in synchronous code—just drop the `await` and use a plain `with`:
|
|
372
|
+
|
|
373
|
+
```python
|
|
374
|
+
from .database import db
|
|
375
|
+
from .models import User
|
|
376
|
+
|
|
377
|
+
with db.transaction():
|
|
378
|
+
user = User(name="sync-flow")
|
|
379
|
+
user.save()
|
|
380
|
+
|
|
381
|
+
with db.transaction():
|
|
382
|
+
assert User.where(User.name == "sync-flow").exists()
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Behind the scenes we detect whether you’re in an event loop and return either the async or sync context manager automatically, so you get one API for both worlds.
|
|
386
|
+
|
|
387
|
+
## Testing
|
|
388
|
+
|
|
389
|
+
The test suite expects an explicit `--db-url` and never falls back to a default. Run tests against the backend you care about, for example:
|
|
390
|
+
|
|
391
|
+
```bash
|
|
392
|
+
pytest --db-url "sqlite:///./test.sqlite"
|
|
393
|
+
pytest --db-url "postgresql://user:pass@host:5432/dbname"
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
JSON/ARRAY helper tests are skipped with explicit reasons on dialects that lack SQLAlchemy operator support.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
duo_orm/__init__.py,sha256=5ITYNXuKdLxOnAu5PN0uSeEGA4fEvlEyN4o449QJ3XE,1827
|
|
2
|
+
duo_orm/basemodel.py,sha256=9JbZzy-dQM1ZUnBK73G24HQeHb43532tP_m0zMEGJhk,4963
|
|
3
|
+
duo_orm/db.py,sha256=2GKK_bKz7k_mD9fs1Zs7xA-YYASqWAUGHgChhPSVuR0,8669
|
|
4
|
+
duo_orm/exceptions.py,sha256=5A0-RGZbqjkS664f2wCTmBZfcxjnDlZwnkZcwnnw9nM,2170
|
|
5
|
+
duo_orm/executor.py,sha256=r-BrpD39zUBxmcsqliFx_Qh6CALtR_UHreo6b-FMOs8,8338
|
|
6
|
+
duo_orm/patch.py,sha256=EXr2sy4icFfijbuOSfcQAzxhI4PWudRCzzkBxXSQjNc,2383
|
|
7
|
+
duo_orm/query.py,sha256=v1A7nWCdLFkO406GZN2ObFyL06h5QoOXz_jP9EXfXAo,21797
|
|
8
|
+
duo_orm/session.py,sha256=HlTDj5sgBMNbGUB-GnkePSoccS6RDv05rgzW3dz0VdE,1209
|
|
9
|
+
duo_orm/migrations/__init__.py,sha256=76nRed9J-lF5wCCB5XGNGO7wGODrk-x7ZnkJeepZ4wk,66
|
|
10
|
+
duo_orm/migrations/cli.py,sha256=kvpBJfI3apEKvfF8sBV_LKU6SP62GfQEtUPZFTT6h2I,5914
|
|
11
|
+
duo_orm/migrations/config.py,sha256=P4LReQvLN--edMWMEWqptNNwt5xeKnK3NI-rOXxfG0s,8834
|
|
12
|
+
duo_orm-0.1.0.dist-info/METADATA,sha256=QcP85Yz5ffRzeeAPikkZcGwx4i9rFyUZ6wUi4GOLwDM,17757
|
|
13
|
+
duo_orm-0.1.0.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
|
|
14
|
+
duo_orm-0.1.0.dist-info/entry_points.txt,sha256=63w_FPRM0-fT6CTR2oJLEEjcH6WTyUL1k8jxIKos83g,55
|
|
15
|
+
duo_orm-0.1.0.dist-info/top_level.txt,sha256=Xtl97wuxq3rnT--QMy2LYx1NcAWi5FS11-DuV9QjnnU,8
|
|
16
|
+
duo_orm-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
duo_orm
|