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 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.