sqla-fancy-core 1.0.2__tar.gz → 1.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.

Potentially problematic release.


This version of sqla-fancy-core might be problematic. Click here for more details.

@@ -7,7 +7,7 @@ jobs:
7
7
  runs-on: ubuntu-latest
8
8
  strategy:
9
9
  matrix:
10
- python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
10
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
11
11
 
12
12
  steps:
13
13
  - uses: actions/checkout@v3
@@ -0,0 +1,422 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqla-fancy-core
3
+ Version: 1.1.0
4
+ Summary: SQLAlchemy core, but fancier
5
+ Project-URL: Homepage, https://github.com/sayanarijit/sqla-fancy-core
6
+ Author-email: Arijit Basu <sayanarijit@gmail.com>
7
+ Maintainer-email: Arijit Basu <sayanarijit@gmail.com>
8
+ License: MIT License
9
+
10
+ Copyright (c) 2023 Arijit Basu
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Keywords: sql,sqlalchemy,sqlalchemy-core
31
+ Classifier: Intended Audience :: Developers
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Programming Language :: Python :: 3
34
+ Requires-Python: >=3.7
35
+ Requires-Dist: sqlalchemy
36
+ Provides-Extra: dev
37
+ Requires-Dist: build; extra == 'dev'
38
+ Requires-Dist: hatchling; extra == 'dev'
39
+ Requires-Dist: ipython; extra == 'dev'
40
+ Requires-Dist: twine; extra == 'dev'
41
+ Provides-Extra: test
42
+ Requires-Dist: aiosqlite; extra == 'test'
43
+ Requires-Dist: fastapi; extra == 'test'
44
+ Requires-Dist: flake8; extra == 'test'
45
+ Requires-Dist: httpx; extra == 'test'
46
+ Requires-Dist: pydantic; extra == 'test'
47
+ Requires-Dist: pytest; extra == 'test'
48
+ Requires-Dist: pytest-asyncio; extra == 'test'
49
+ Requires-Dist: python-multipart; extra == 'test'
50
+ Requires-Dist: sqlalchemy[asyncio]; extra == 'test'
51
+ Description-Content-Type: text/markdown
52
+
53
+ # sqla-fancy-core
54
+
55
+ There are plenty of ORMs to choose from in Python world, but not many sql query makers for folks who prefer to stay close to the original SQL syntax, without sacrificing security and code readability. The closest, most mature and most flexible query maker you can find is SQLAlchemy core.
56
+
57
+ But the syntax of defining tables and making queries has a lot of scope for improvement. For example, the `table.c.column` syntax is too dynamic, unreadable, and probably has performance impact too. It also doesn’t play along with static type checkers and linting tools.
58
+
59
+ So here I present one attempt at getting the best out of SQLAlchemy core by changing the way we define tables.
60
+
61
+ The table factory class it exposes, helps define tables in a way that eliminates the above drawbacks. Moreover, you can subclass it to add your preferred global defaults for columns (e.g. not null as default). Or specify custom column types with consistent naming (e.g. created_at).
62
+
63
+ ## Basic Usage
64
+
65
+ First, let's define a table using the `TableFactory`.
66
+
67
+ ```python
68
+ import sqlalchemy as sa
69
+ from sqla_fancy_core import TableFactory
70
+
71
+ tf = TableFactory()
72
+
73
+ class Author:
74
+ id = tf.auto_id()
75
+ name = tf.string("name")
76
+ created_at = tf.created_at()
77
+ updated_at = tf.updated_at()
78
+
79
+ Table = tf("author")
80
+ ```
81
+
82
+ The `TableFactory` provides a convenient way to define columns with common attributes. For more complex scenarios, you can define tables without losing type hints:
83
+
84
+ ```python
85
+ class Book:
86
+ id = tf(sa.Column("id", sa.Integer, primary_key=True, autoincrement=True))
87
+ title = tf(sa.Column("title", sa.String(255), nullable=False))
88
+ author_id = tf(sa.Column("author_id", sa.Integer, sa.ForeignKey(Author.id)))
89
+ created_at = tf(
90
+ sa.Column(
91
+ "created_at",
92
+ sa.DateTime,
93
+ nullable=False,
94
+ server_default=sa.func.now(),
95
+ )
96
+ )
97
+ updated_at = tf(
98
+ sa.Column(
99
+ "updated_at",
100
+ sa.DateTime,
101
+ nullable=False,
102
+ server_default=sa.func.now(),
103
+ onupdate=sa.func.now(),
104
+ )
105
+ )
106
+
107
+ Table = tf(sa.Table("book", sa.MetaData()))
108
+ ```
109
+
110
+ Now, let's create an engine and the tables.
111
+
112
+ ```python
113
+ from sqlalchemy.ext.asyncio import create_async_engine
114
+
115
+ # Create the engine
116
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:")
117
+
118
+ # Create the tables
119
+ async with engine.begin() as conn:
120
+ await conn.run_sync(tf.metadata.create_all)
121
+ ```
122
+
123
+ With the tables created, you can perform CRUD operations.
124
+
125
+ ### CRUD Operations
126
+
127
+ Here's how you can interact with the database using the defined tables.
128
+
129
+ ```python
130
+ async with engine.begin() as txn:
131
+ # Insert author
132
+ qry = (
133
+ sa.insert(Author.Table)
134
+ .values({Author.name: "John Doe"})
135
+ .returning(Author.id)
136
+ )
137
+ author = (await txn.execute(qry)).mappings().one()
138
+ author_id = author[Author.id]
139
+ assert author_id == 1
140
+
141
+ # Insert book
142
+ qry = (
143
+ sa.insert(Book.Table)
144
+ .values({Book.title: "My Book", Book.author_id: author_id})
145
+ .returning(Book.id)
146
+ )
147
+ book = (await txn.execute(qry)).mappings().one()
148
+ assert book[Book.id] == 1
149
+
150
+ # Query the data
151
+ qry = sa.select(Author.name, Book.title).join(
152
+ Book.Table,
153
+ Book.author_id == Author.id,
154
+ )
155
+ result = (await txn.execute(qry)).all()
156
+ assert result == [("John Doe", "My Book")], result
157
+ ```
158
+
159
+ ## Fancy Engine Wrappers
160
+
161
+ `sqla-fancy-core` provides `fancy` engine wrappers that simplify database interactions by automatically managing connections and transactions. The `fancy` function wraps a SQLAlchemy `Engine` or `AsyncEngine` and returns a wrapper object with two primary methods:
162
+
163
+ - `x(conn, query)`: Executes a query. It uses the provided `conn` if available, otherwise it creates a new connection.
164
+ - `tx(conn, query)`: Executes a query within a transaction. It uses the provided `conn` if available, otherwise it creates a new connection and begins a transaction.
165
+
166
+ This is particularly useful for writing connection-agnostic query functions.
167
+
168
+ **Sync Example:**
169
+
170
+ ```python
171
+ import sqlalchemy as sa
172
+ from sqla_fancy_core import fancy
173
+
174
+ engine = sa.create_engine("sqlite:///:memory:")
175
+ fancy_engine = fancy(engine)
176
+
177
+ def get_data(conn: sa.Connection | None = None):
178
+ return fancy_engine.tx(conn, sa.select(sa.literal(1))).scalar_one()
179
+
180
+ # Without an explicit transaction
181
+ assert get_data() == 1
182
+
183
+ # With an explicit transaction
184
+ with engine.begin() as conn:
185
+ assert get_data(conn) == 1
186
+ ```
187
+
188
+ **Async Example:**
189
+
190
+ ```python
191
+ import sqlalchemy as sa
192
+ from sqlalchemy.ext.asyncio import create_async_engine
193
+ from sqla_fancy_core import fancy
194
+
195
+ async def main():
196
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:")
197
+ fancy_engine = fancy(engine)
198
+
199
+ async def get_data(conn: sa.AsyncConnection | None = None):
200
+ result = await fancy_engine.x(conn, sa.select(sa.literal(1)))
201
+ return result.scalar_one()
202
+
203
+ # Without an explicit transaction
204
+ assert await get_data() == 1
205
+
206
+ # With an explicit transaction
207
+ async with engine.connect() as conn:
208
+ assert await get_data(conn) == 1
209
+ ```
210
+
211
+ ## Decorators: Inject, connect, transact
212
+
213
+ When writing plain SQLAlchemy Core code, you often pass connections around and manage transactions manually. The decorators in `sqla-fancy-core` help you keep functions connection-agnostic and composable, while remaining explicit and safe.
214
+
215
+ At the heart of it is `Inject(engine)`, a tiny marker used as a default parameter value to tell decorators where to inject a connection.
216
+
217
+ - `Inject(engine)`: marks which parameter should receive a connection derived from the given engine.
218
+ - `@connect`: ensures the injected parameter is a live connection. If you passed a connection explicitly, it will use that one as-is. Otherwise, it will open a new connection for the call and close it afterwards. No transaction is created by default.
219
+ - `@transact`: ensures the injected parameter is inside a transaction. If you pass a connection already in a transaction, it reuses it; if you pass a connection outside a transaction, it starts one; if you pass nothing, it opens a new connection and begins a transaction for the duration of the call.
220
+
221
+ All three work both for sync and async engines. The signatures remain the same — you only change the default value to `Inject(engine)`.
222
+
223
+ ### Quick reference
224
+
225
+ - Prefer `@connect` for read-only operations or when you want to control commit/rollback yourself.
226
+ - Prefer `@transact` to wrap a function in a transaction automatically and consistently.
227
+ - You can still pass `conn=...` explicitly to either decorator to reuse an existing connection/transaction.
228
+
229
+ ### Sync examples
230
+
231
+ ```python
232
+ import sqlalchemy as sa
233
+ from sqla_fancy_core.decorators import Inject, connect, transact
234
+
235
+ engine = sa.create_engine("sqlite:///:memory:")
236
+ metadata = sa.MetaData()
237
+ users = sa.Table(
238
+ "users",
239
+ metadata,
240
+ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
241
+ sa.Column("name", sa.String),
242
+ )
243
+ metadata.create_all(engine)
244
+
245
+ # 1) Ensure a connection is available (no implicit transaction)
246
+ @connect
247
+ def get_user_count(conn=Inject(engine)):
248
+ return conn.execute(sa.select(sa.func.count()).select_from(users)).scalar_one()
249
+
250
+ assert get_user_count() == 0
251
+
252
+ # 2) Wrap in a transaction automatically
253
+ @transact
254
+ def create_user(name: str, conn=Inject(engine)):
255
+ conn.execute(sa.insert(users).values(name=name))
256
+
257
+ create_user("alice")
258
+ assert get_user_count() == 1
259
+
260
+ # 3) Reuse an explicit connection or transaction
261
+ with engine.begin() as txn:
262
+ create_user("bob", conn=txn)
263
+ assert get_user_count(conn=txn) == 2
264
+
265
+ assert get_user_count() == 2
266
+ ```
267
+
268
+ ### Async examples
269
+
270
+ ```python
271
+ import sqlalchemy as sa
272
+ from sqlalchemy.ext.asyncio import create_async_engine, AsyncConnection
273
+ from sqla_fancy_core.decorators import Inject, connect, transact
274
+
275
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:")
276
+ metadata = sa.MetaData()
277
+ users = sa.Table(
278
+ "users",
279
+ metadata,
280
+ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
281
+ sa.Column("name", sa.String),
282
+ )
283
+
284
+ async with engine.begin() as conn:
285
+ await conn.run_sync(metadata.create_all)
286
+
287
+ @connect
288
+ async def get_user_count(conn=Inject(engine)):
289
+ result = await conn.execute(sa.select(sa.func.count()).select_from(users))
290
+ return result.scalar_one()
291
+
292
+ @transact
293
+ async def create_user(name: str, conn=Inject(engine)):
294
+ await conn.execute(sa.insert(users).values(name=name))
295
+
296
+ assert await get_user_count() == 0
297
+ await create_user("carol")
298
+ assert await get_user_count() == 1
299
+
300
+ async with engine.connect() as conn:
301
+ await create_user("dave", conn=conn)
302
+ assert await get_user_count(conn=conn) == 2
303
+ ```
304
+
305
+ ### Works with dependency injection frameworks
306
+
307
+ These decorators pair nicely with frameworks like FastAPI. You can keep a single function that works both inside DI (with an injected connection) and outside it (self-managed).
308
+
309
+ Sync example with FastAPI:
310
+
311
+ ```python
312
+ from typing import Annotated
313
+ from fastapi import Depends, FastAPI, Form
314
+ import sqlalchemy as sa
315
+ from sqla_fancy_core.decorators import Inject, transact
316
+
317
+ app = FastAPI()
318
+
319
+ def get_transaction():
320
+ with engine.begin() as conn:
321
+ yield conn
322
+
323
+ @transact
324
+ @app.post("/create-user")
325
+ def create_user(
326
+ name: Annotated[str, Form(...)],
327
+ conn: Annotated[sa.Connection, Depends(get_transaction)] = Inject(engine),
328
+ ):
329
+ conn.execute(sa.insert(users).values(name=name))
330
+
331
+ # Works outside FastAPI too — starts its own transaction
332
+ create_user(name="outside fastapi")
333
+ ```
334
+
335
+ Async example with FastAPI:
336
+
337
+ ```python
338
+ from typing import Annotated
339
+ from fastapi import Depends, FastAPI, Form
340
+ from sqlalchemy.ext.asyncio import AsyncConnection
341
+ import sqlalchemy as sa
342
+ from sqla_fancy_core.decorators import Inject, transact
343
+
344
+ app = FastAPI()
345
+
346
+ async def get_transaction():
347
+ async with engine.begin() as conn:
348
+ yield conn
349
+
350
+ @transact
351
+ @app.post("/create-user")
352
+ async def create_user(
353
+ name: Annotated[str, Form(...)],
354
+ conn: Annotated[AsyncConnection, Depends(get_transaction)] = Inject(engine),
355
+ ):
356
+ await conn.execute(sa.insert(users).values(name=name))
357
+ ```
358
+
359
+ Notes:
360
+
361
+ - `@connect` never starts a transaction by itself; `@transact` ensures one.
362
+ - Passing an explicit `conn` always wins — the decorators simply adapt to what you give them.
363
+ - The injection marker keeps your function signatures clean and type-checker friendly.
364
+
365
+ ## With Pydantic Validation
366
+
367
+ You can integrate `sqla-fancy-core` with Pydantic for data validation.
368
+
369
+ ```python
370
+ from typing import Any
371
+ import sqlalchemy as sa
372
+ from pydantic import BaseModel, Field
373
+ import pytest
374
+
375
+ from sqla_fancy_core import TableFactory
376
+
377
+ tf = TableFactory()
378
+
379
+ def field(col, default: Any = ...) -> Field:
380
+ return col.info["kwargs"]["field"](default)
381
+
382
+ # Define a table
383
+ class User:
384
+ name = tf(
385
+ sa.Column("name", sa.String),
386
+ field=lambda default: Field(default, max_length=5),
387
+ )
388
+ Table = tf("author")
389
+
390
+ # Define a pydantic schema
391
+ class CreateUser(BaseModel):
392
+ name: str = field(User.name)
393
+
394
+ # Define a pydantic schema
395
+ class UpdateUser(BaseModel):
396
+ name: str | None = field(User.name, None)
397
+
398
+ assert CreateUser(name="John").model_dump() == {"name": "John"}
399
+ assert UpdateUser(name="John").model_dump() == {"name": "John"}
400
+ assert UpdateUser().model_dump(exclude_unset=True) == {}
401
+
402
+ with pytest.raises(ValueError):
403
+ CreateUser()
404
+ with pytest.raises(ValueError):
405
+ UpdateUser(name="John Doe")
406
+ ```
407
+
408
+ ## Target audience
409
+
410
+ Production. For folks who prefer query maker over ORM, looking for a robust sync/async driver integration, wanting to keep code readable and secure.
411
+
412
+ ## Comparison with other projects:
413
+
414
+ **Peewee**: No type hints. Also, no official async support.
415
+
416
+ **Piccolo**: Tight integration with drivers. Very opinionated. Not as flexible or mature as sqlalchemy core.
417
+
418
+ **Pypika**: Doesn’t prevent sql injection by default. Hence can be considered insecure.
419
+
420
+ **Raw string queries with placeholders**: sacrifices code readability, and prone to sql injection if one forgets to use placeholders.
421
+
422
+ **Other ORMs**: They are full blown ORMs, not query makers.