sqlakit 0.1.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.
- sqlakit-0.1.0/LICENSE +21 -0
- sqlakit-0.1.0/PKG-INFO +408 -0
- sqlakit-0.1.0/README.md +378 -0
- sqlakit-0.1.0/pyproject.toml +194 -0
- sqlakit-0.1.0/pyproject.toml.orig +193 -0
- sqlakit-0.1.0/sqlakit/__init__.py +99 -0
- sqlakit-0.1.0/sqlakit/_base.py +995 -0
- sqlakit-0.1.0/sqlakit/_db.py +540 -0
- sqlakit-0.1.0/sqlakit/_discovery.py +79 -0
- sqlakit-0.1.0/sqlakit/_model.py +275 -0
- sqlakit-0.1.0/sqlakit/_query.py +1156 -0
- sqlakit-0.1.0/sqlakit/_recording.py +385 -0
- sqlakit-0.1.0/sqlakit/_registry.py +69 -0
- sqlakit-0.1.0/sqlakit/_routing.py +43 -0
- sqlakit-0.1.0/sqlakit/_sql.py +408 -0
- sqlakit-0.1.0/sqlakit/asyncio/__init__.py +4 -0
- sqlakit-0.1.0/sqlakit/asyncio/_db.py +552 -0
- sqlakit-0.1.0/sqlakit/asyncio/_registry.py +57 -0
- sqlakit-0.1.0/sqlakit/asyncio/orm.py +662 -0
- sqlakit-0.1.0/sqlakit/asyncio/sql.py +222 -0
- sqlakit-0.1.0/sqlakit/exceptions.py +423 -0
- sqlakit-0.1.0/sqlakit/orm.py +645 -0
- sqlakit-0.1.0/sqlakit/py.typed +0 -0
- sqlakit-0.1.0/sqlakit/sql.py +211 -0
- sqlakit-0.1.0/sqlakit/testing.py +91 -0
- sqlakit-0.1.0/sqlakit/types.py +104 -0
sqlakit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anton Ruhlov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
sqlakit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlakit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A toolkit for SQLAlchemy applications.
|
|
5
|
+
Keywords: sqlalchemy,database,orm,sql,asyncio
|
|
6
|
+
Author: Anton Ruhlov
|
|
7
|
+
Author-email: Anton Ruhlov <antonruhlov@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Database
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.43
|
|
22
|
+
Requires-Dist: sqlparse>=0.6.0 ; extra == 'debug'
|
|
23
|
+
Requires-Dist: jinja2sql>=0.11.0 ; extra == 'sql'
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Project-URL: Repository, https://github.com/antonrh/sqlakit
|
|
26
|
+
Project-URL: Documentation, https://github.com/antonrh/sqlakit
|
|
27
|
+
Provides-Extra: debug
|
|
28
|
+
Provides-Extra: sql
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# SQLAKit
|
|
32
|
+
|
|
33
|
+
SQLAKit removes the boilerplate from `SQLAlchemy` applications. It manages
|
|
34
|
+
sessions and transactions for you, and adds a query builder with pagination
|
|
35
|
+
built in, `SQL` templates, an optional `Active Record` layer, debugging and
|
|
36
|
+
testing tools, etc. It supports both sync and async APIs and works with any
|
|
37
|
+
framework.
|
|
38
|
+
|
|
39
|
+
```console
|
|
40
|
+
$ pip install sqlakit
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## A quick example
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import sqlalchemy as sa
|
|
47
|
+
|
|
48
|
+
from sqlakit import Database
|
|
49
|
+
|
|
50
|
+
from app.models import User
|
|
51
|
+
|
|
52
|
+
db = Database("postgresql+psycopg://localhost/app")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_user(email: str) -> User | None:
|
|
56
|
+
return db.session.scalars(sa.select(User).where(User.email == email)).first()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@db.transaction
|
|
60
|
+
def get_or_create_user(email: str, name: str) -> User:
|
|
61
|
+
user = get_user(email)
|
|
62
|
+
if user is None:
|
|
63
|
+
user = User(email=email, name=name)
|
|
64
|
+
db.session.add(user)
|
|
65
|
+
return user
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Both functions use the same session without passing it around. The
|
|
69
|
+
`@db.transaction` decorator opens it, and commits when the function returns.
|
|
70
|
+
|
|
71
|
+
Outside a block there is no session: `db.session` raises `MissingSessionError`
|
|
72
|
+
instead of silently opening a connection. `db.connection` works the same way
|
|
73
|
+
and raises `MissingConnectionError`.
|
|
74
|
+
|
|
75
|
+
## Connections and transactions
|
|
76
|
+
|
|
77
|
+
All blocks work as context managers and as decorators:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
with db.connect(): # a connection, with no transaction of its own
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
with db.transaction(): # commits at the end, rolls back on an exception
|
|
84
|
+
...
|
|
85
|
+
|
|
86
|
+
with db.autocommit(): # AUTOCOMMIT, no transaction held open
|
|
87
|
+
...
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## SQL templates
|
|
91
|
+
|
|
92
|
+
Templates are `Jinja` files, so they can hold anything from a one-line query to
|
|
93
|
+
a report with window functions or a recursive CTE.
|
|
94
|
+
[jinja2sql](https://github.com/antonrh/jinja2sql) turns every `{{ name }}` into
|
|
95
|
+
a bound parameter (`:name__1`), so values never end up in the SQL text and
|
|
96
|
+
there is no way to inject anything. Requires the `sqlakit[sql]` extra.
|
|
97
|
+
|
|
98
|
+
### From a file
|
|
99
|
+
|
|
100
|
+
```sql
|
|
101
|
+
-- reports/by_team.sql
|
|
102
|
+
SELECT team, count(*) AS members
|
|
103
|
+
FROM users
|
|
104
|
+
WHERE joined_at > {{ since }}
|
|
105
|
+
GROUP BY team
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from pydantic import BaseModel
|
|
110
|
+
|
|
111
|
+
from sqlakit import Database
|
|
112
|
+
|
|
113
|
+
db = Database(DATABASE_URL, templates=BASE_DIR / "sql")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TeamReport(BaseModel):
|
|
117
|
+
team: str
|
|
118
|
+
members: int
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
db.sql("reports/by_team.sql", since=since).typed(TeamReport).all()
|
|
122
|
+
# [TeamReport(team='red', members=2)]
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`templates=` sets the directory to load templates from, and `typed()` sets the
|
|
126
|
+
type each row is returned as.
|
|
127
|
+
|
|
128
|
+
The template name is added to the SQL as a comment, so a slow query log shows
|
|
129
|
+
right away which file a query came from.
|
|
130
|
+
|
|
131
|
+
### From a string
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
db.sql.from_string("SELECT count(*) FROM users").scalars().one()
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The same templating, with no directory to configure.
|
|
138
|
+
|
|
139
|
+
## Query builder
|
|
140
|
+
|
|
141
|
+
The query builder wraps `select()`, so `where`, `join` and `order_by` work as
|
|
142
|
+
usual. On top of that it adds what `select` lacks: ordering by string,
|
|
143
|
+
limit-offset and cursor pagination, reading in batches, and bulk writes. It
|
|
144
|
+
works with any mapped class, with nothing to inherit from:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
db.query(User).where(User.is_active).order_by(User.name).all()
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Ordering by a string
|
|
151
|
+
|
|
152
|
+
`order_by` accepts a `field.direction` string, for example straight from a
|
|
153
|
+
query parameter. The field name is checked against the model before any SQL is
|
|
154
|
+
built, so an unknown field never reaches the database. Instead you get
|
|
155
|
+
`UnknownOrderFieldError`, and its message lists the fields the model allows:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
db.query(User).order_by("created_at.desc") # or "name", "name.asc.nulls_last"
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Limit-offset pagination
|
|
162
|
+
|
|
163
|
+
`page()` also counts the total, so you can show "page 3 of 12":
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
page = db.query(User).order_by("name").page(limit=20, offset=40)
|
|
167
|
+
|
|
168
|
+
page.items
|
|
169
|
+
page.total
|
|
170
|
+
page.has_next
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Cursor pagination
|
|
174
|
+
|
|
175
|
+
`cursor_page()` continues from a cursor, so it stays fast at any depth. There
|
|
176
|
+
is no total; instead you get cursors to the next and previous pages:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
feed = db.query(User).order_by("created_at.desc").cursor_page(limit=20)
|
|
180
|
+
|
|
181
|
+
feed.items
|
|
182
|
+
feed.next_cursor
|
|
183
|
+
feed.previous_cursor
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Active Record
|
|
187
|
+
|
|
188
|
+
An instance saves and deletes itself, and the query is available on the class:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
192
|
+
|
|
193
|
+
from sqlakit import Database
|
|
194
|
+
from sqlakit.orm import Model
|
|
195
|
+
|
|
196
|
+
db = Database("postgresql+psycopg://localhost/app")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Note(Model):
|
|
200
|
+
__tablename__ = "notes"
|
|
201
|
+
|
|
202
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
203
|
+
text: Mapped[str]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
Note.set_db(db)
|
|
207
|
+
|
|
208
|
+
with db.transaction():
|
|
209
|
+
note = Note(text="ada")
|
|
210
|
+
note.save()
|
|
211
|
+
|
|
212
|
+
Note.query.where(Note.text == "ada").all()
|
|
213
|
+
note.delete()
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`set_db()` binds a model to a database. Call it on a base class and every model
|
|
217
|
+
under it inherits the binding. With the global `db` from the section below you
|
|
218
|
+
don't need it at all: the model uses the global registry automatically.
|
|
219
|
+
|
|
220
|
+
This layer is optional. Everything else works on plain `SQLAlchemy` models, so
|
|
221
|
+
if saving belongs in your repositories or services, skip `sqlakit.orm`
|
|
222
|
+
entirely.
|
|
223
|
+
|
|
224
|
+
## Testing
|
|
225
|
+
|
|
226
|
+
A test runs inside a transaction that is rolled back at the end, so nothing
|
|
227
|
+
the code under test writes is actually committed. `assert_queries` checks how
|
|
228
|
+
many statements a block runs:
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
with db.transaction(rollback=True), db.assert_queries(2):
|
|
232
|
+
render(dashboard)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Debugging queries
|
|
236
|
+
|
|
237
|
+
`recording()` shows what ran, how long it took, and what ran more than once:
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
import logging
|
|
241
|
+
|
|
242
|
+
logger = logging.getLogger(__name__)
|
|
243
|
+
|
|
244
|
+
with db.recording("GET /users", logger=logger) as record:
|
|
245
|
+
list_users()
|
|
246
|
+
|
|
247
|
+
record.count
|
|
248
|
+
record.milliseconds
|
|
249
|
+
record.duplicates
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
With `logger=` one line is logged at the end of the block. The log level
|
|
253
|
+
depends on the numbers: more statements and more repeats mean a higher level.
|
|
254
|
+
|
|
255
|
+
With `echo=True` the block prints each statement, formatted and with repeats
|
|
256
|
+
marked:
|
|
257
|
+
|
|
258
|
+
```python
|
|
259
|
+
with db.recording(echo=True):
|
|
260
|
+
list_users()
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
```sql
|
|
264
|
+
3 queries in 0.0ms (2 repeated)
|
|
265
|
+
1 0.0ms
|
|
266
|
+
SELECT users.team_id
|
|
267
|
+
FROM users
|
|
268
|
+
ORDER BY users.name ASC
|
|
269
|
+
2 0.0ms ↑ same as 3 (2 times in all)
|
|
270
|
+
SELECT teams.id AS teams_id,
|
|
271
|
+
teams.name AS teams_name
|
|
272
|
+
FROM teams
|
|
273
|
+
WHERE teams.id = ?
|
|
274
|
+
3 0.0ms ↑ same as 2 (2 times in all)
|
|
275
|
+
SELECT teams.id AS teams_id,
|
|
276
|
+
teams.name AS teams_name
|
|
277
|
+
FROM teams
|
|
278
|
+
WHERE teams.id = ?
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
You can spot the N+1 right away: one query for the users and two identical
|
|
282
|
+
ones for the teams. Formatting needs the `sqlakit[debug]` extra, and if the
|
|
283
|
+
project has `rich`, the output is colored too.
|
|
284
|
+
|
|
285
|
+
## The registry
|
|
286
|
+
|
|
287
|
+
To avoid passing a `Database` from module to module, configure the registry
|
|
288
|
+
once at startup:
|
|
289
|
+
|
|
290
|
+
```python
|
|
291
|
+
# app/main.py
|
|
292
|
+
from sqlakit import db
|
|
293
|
+
|
|
294
|
+
db.configure("postgresql+psycopg://localhost/app")
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Any other module just imports it:
|
|
298
|
+
|
|
299
|
+
```python
|
|
300
|
+
# app/users.py
|
|
301
|
+
from sqlakit import db
|
|
302
|
+
|
|
303
|
+
from app.models import User
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def list_users() -> list[User]:
|
|
307
|
+
return db.query(User).order_by("name").all()
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
## More than one database
|
|
311
|
+
|
|
312
|
+
The registry can hold several databases. Configure them under aliases, and pick
|
|
313
|
+
one per block:
|
|
314
|
+
|
|
315
|
+
```python
|
|
316
|
+
from sqlakit import db
|
|
317
|
+
|
|
318
|
+
db.configure(
|
|
319
|
+
{
|
|
320
|
+
"default": {"url": PRIMARY_URL},
|
|
321
|
+
"replica": {"url": REPLICA_URL},
|
|
322
|
+
}
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
with db.using("replica").connect():
|
|
326
|
+
list_users() # the models read the replica
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## The async API
|
|
330
|
+
|
|
331
|
+
The async API is identical: the same classes, the same methods. Only the import
|
|
332
|
+
changes:
|
|
333
|
+
|
|
334
|
+
```python
|
|
335
|
+
from sqlakit.asyncio import Database
|
|
336
|
+
|
|
337
|
+
db = Database("postgresql+psycopg://localhost/app")
|
|
338
|
+
|
|
339
|
+
async with db.transaction():
|
|
340
|
+
page = await db.query(User).order_by("name").page(limit=20)
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
The builder itself stays synchronous: `where` and `order_by` run no SQL, so
|
|
344
|
+
there is nothing to await.
|
|
345
|
+
|
|
346
|
+
## `FastAPI` integration
|
|
347
|
+
|
|
348
|
+
```python
|
|
349
|
+
from collections.abc import AsyncIterator
|
|
350
|
+
from contextlib import asynccontextmanager
|
|
351
|
+
|
|
352
|
+
from fastapi import FastAPI
|
|
353
|
+
from pydantic import BaseModel
|
|
354
|
+
|
|
355
|
+
from app.models import User
|
|
356
|
+
from sqlakit.asyncio import Database
|
|
357
|
+
|
|
358
|
+
db = Database("postgresql+psycopg://localhost/app")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@asynccontextmanager
|
|
362
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
363
|
+
yield
|
|
364
|
+
await db.dispose() # close the pool on shutdown
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
app = FastAPI(lifespan=lifespan)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class UserCreate(BaseModel):
|
|
371
|
+
name: str
|
|
372
|
+
team: str = ""
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class UserResponse(BaseModel, from_attributes=True):
|
|
376
|
+
id: int
|
|
377
|
+
name: str
|
|
378
|
+
team: str
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@app.post("/users", status_code=201)
|
|
382
|
+
@db.transaction # one transaction, committed when the handler returns
|
|
383
|
+
async def create_user(payload: UserCreate) -> UserResponse:
|
|
384
|
+
user = User(name=payload.name, team=payload.team)
|
|
385
|
+
db.session.add(user)
|
|
386
|
+
await db.session.flush() # INSERT now, the id is needed for the response
|
|
387
|
+
return UserResponse.model_validate(user)
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
No `Depends(get_session)`, no session factories, and no `async with` in the
|
|
391
|
+
handler.
|
|
392
|
+
|
|
393
|
+
Use the `Database` from `sqlakit.asyncio` here. With the sync one the block
|
|
394
|
+
closes before the async handler runs, and the handler fails with
|
|
395
|
+
`MissingConnectionError`.
|
|
396
|
+
|
|
397
|
+
There is nothing to open at startup: the engine is created on first use. On
|
|
398
|
+
shutdown, `dispose()` closes the pool.
|
|
399
|
+
|
|
400
|
+
## Documentation
|
|
401
|
+
|
|
402
|
+
[Getting started](docs/getting-started.md) builds a database, a model and a
|
|
403
|
+
test from an empty file. The rest is under [`docs/`](docs/):
|
|
404
|
+
[queries](docs/queries.md), [SQL templates](docs/sql.md),
|
|
405
|
+
[models](docs/models.md), [testing](docs/testing.md),
|
|
406
|
+
[debugging](docs/debugging.md), [multiple databases](docs/routing.md) and
|
|
407
|
+
[the reference](docs/reference.md). Complete example apps live in
|
|
408
|
+
[`examples/`](examples/), and each one is run by the test suite.
|