repositron 0.0.1__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.
- repositron-0.0.1/LICENSE +21 -0
- repositron-0.0.1/PKG-INFO +296 -0
- repositron-0.0.1/README.md +273 -0
- repositron-0.0.1/pyproject.toml +41 -0
- repositron-0.0.1/src/repositron/__init__.py +17 -0
- repositron-0.0.1/src/repositron/base.py +130 -0
- repositron-0.0.1/src/repositron/py.typed +0 -0
- repositron-0.0.1/src/repositron/sentinel.py +33 -0
- repositron-0.0.1/src/repositron/sql.py +433 -0
repositron-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Felipe Adeildo
|
|
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.
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: repositron
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A typed, generic repository base for SQLAlchemy 2.0. Full CRUD with no per-table boilerplate.
|
|
5
|
+
Keywords: sqlalchemy,repository,orm,crud,typed,generic
|
|
6
|
+
Author: Felipe Adeildo
|
|
7
|
+
Author-email: Felipe Adeildo <contato@felipeadeildo.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Database
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: sqlalchemy>=2.0.51
|
|
18
|
+
Requires-Python: >=3.13
|
|
19
|
+
Project-URL: Homepage, https://github.com/felipeadeildo/repositron
|
|
20
|
+
Project-URL: Repository, https://github.com/felipeadeildo/repositron
|
|
21
|
+
Project-URL: Issues, https://github.com/felipeadeildo/repositron/issues
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# repositron
|
|
25
|
+
|
|
26
|
+
A typed, generic repository base for SQLAlchemy 2.0. Declare a model (and
|
|
27
|
+
optionally a DTO and write payloads), inherit one generic class, and get
|
|
28
|
+
`get` / `first` / `list` / `list_paginated` / `count` / `exists` / `create` /
|
|
29
|
+
`update` / `delete` with no per-table boilerplate.
|
|
30
|
+
|
|
31
|
+
Every method is fully typed off the generic parameters, so your editor knows
|
|
32
|
+
that `repo.list()` returns `list[UserDTO]` and `repo.get(id)` takes an `int`
|
|
33
|
+
(or a `uuid.UUID`, your choice).
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
class UserRepository(Repository[User, UserDTO, UserCreate, UserUpdate]):
|
|
37
|
+
field_mapping = {"full_name": "name"}
|
|
38
|
+
|
|
39
|
+
repo.list(is_active=True, order_by=User.created_at.desc()) # -> list[UserDTO]
|
|
40
|
+
repo.update(1, UserUpdate(name="Ada")) # only name; others untouched
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv add repositron # or: pip install repositron
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Requires Python 3.13+ and `sqlalchemy>=2.0`. That is the only dependency; the
|
|
50
|
+
dataclass path pulls in nothing else.
|
|
51
|
+
|
|
52
|
+
## The before / after
|
|
53
|
+
|
|
54
|
+
Every SQLAlchemy project rewrites the same layer: a class per table wrapping
|
|
55
|
+
`session.query(...)`, the same `get` / `list` / `count`, the same pagination
|
|
56
|
+
math, the same "turn the ORM row into something light to return". It is
|
|
57
|
+
mechanical and easy to get subtly wrong.
|
|
58
|
+
|
|
59
|
+
### Before: hand-written, per table
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
class UserRepository:
|
|
63
|
+
def __init__(self, session: Session) -> None:
|
|
64
|
+
self.session = session
|
|
65
|
+
|
|
66
|
+
def get(self, id: int) -> UserDTO | None:
|
|
67
|
+
row = self.session.query(User).filter(User.id == id).first()
|
|
68
|
+
if row is None:
|
|
69
|
+
return None
|
|
70
|
+
return UserDTO(id=row.id, name=row.full_name, email=row.email)
|
|
71
|
+
|
|
72
|
+
def list(self, *, is_active: bool | None = None) -> list[UserDTO]:
|
|
73
|
+
query = self.session.query(User)
|
|
74
|
+
if is_active is not None:
|
|
75
|
+
query = query.filter(User.is_active == is_active)
|
|
76
|
+
return [
|
|
77
|
+
UserDTO(id=r.id, name=r.full_name, email=r.email)
|
|
78
|
+
for r in query.order_by(User.created_at.desc()).all()
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
def list_paginated(self, offset: int, limit: int = 20) -> tuple[list[UserDTO], int]:
|
|
82
|
+
query = self.session.query(User).order_by(User.created_at.desc())
|
|
83
|
+
total = query.order_by(None).count()
|
|
84
|
+
rows = query.offset(offset).limit(limit).all()
|
|
85
|
+
return [UserDTO(id=r.id, name=r.full_name, email=r.email) for r in rows], total
|
|
86
|
+
|
|
87
|
+
def count(self, *, is_active: bool | None = None) -> int:
|
|
88
|
+
query = self.session.query(User.id)
|
|
89
|
+
if is_active is not None:
|
|
90
|
+
query = query.filter(User.is_active == is_active)
|
|
91
|
+
return query.count()
|
|
92
|
+
|
|
93
|
+
def create(self, full_name: str, email: str) -> int:
|
|
94
|
+
user = User(full_name=full_name, email=email)
|
|
95
|
+
self.session.add(user)
|
|
96
|
+
self.session.flush()
|
|
97
|
+
return user.id
|
|
98
|
+
|
|
99
|
+
def update(self, id: int, *, full_name: str | None = None, email: str | None = None) -> bool:
|
|
100
|
+
user = self.session.query(User).filter(User.id == id).first()
|
|
101
|
+
if user is None:
|
|
102
|
+
return False
|
|
103
|
+
if full_name is not None: # but how do you set a column to NULL on purpose?
|
|
104
|
+
user.full_name = full_name
|
|
105
|
+
if email is not None:
|
|
106
|
+
user.email = email
|
|
107
|
+
self.session.flush()
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
# ...and delete, and first, and the same again for the next ten tables.
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### After: declare it once
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from dataclasses import dataclass
|
|
117
|
+
from repositron import Repository, UNSET, UnsetType
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True, slots=True)
|
|
121
|
+
class UserDTO: # light, detached, serializes straight to JSON
|
|
122
|
+
id: int
|
|
123
|
+
name: str # renamed from the model column `full_name`
|
|
124
|
+
email: str
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class UserCreate:
|
|
129
|
+
full_name: str
|
|
130
|
+
email: str
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class UserUpdate:
|
|
135
|
+
full_name: str | UnsetType = UNSET # absent = leave alone; None = SET NULL
|
|
136
|
+
email: str | UnsetType = UNSET
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class UserRepository(Repository[User, UserDTO, UserCreate, UserUpdate]):
|
|
140
|
+
field_mapping = {"full_name": "name"}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
That is the whole repository. Every method above comes for free, typed against
|
|
144
|
+
`UserDTO`:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
repo = UserRepository(session)
|
|
148
|
+
|
|
149
|
+
repo.get(1) # -> UserDTO | None
|
|
150
|
+
repo.list(is_active=True, order_by=User.created_at.desc())
|
|
151
|
+
repo.list_paginated(0, 20, order_by=User.created_at.desc()) # -> PaginatedResult[UserDTO]
|
|
152
|
+
repo.count(is_active=True)
|
|
153
|
+
repo.create(UserCreate(full_name="Ada Lovelace", email="ada@example.com")) # -> int (new id)
|
|
154
|
+
repo.update(1, UserUpdate(full_name="Ada L.")) # email left untouched
|
|
155
|
+
repo.delete(1)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## The sugar
|
|
159
|
+
|
|
160
|
+
### Two ways to filter, in one call
|
|
161
|
+
|
|
162
|
+
Equality is keyed by attribute name. Anything else is a plain SQLAlchemy
|
|
163
|
+
expression. They combine.
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
repo.list(
|
|
167
|
+
is_active=True, # equality
|
|
168
|
+
extra_filters=[User.age > 18], # any expression: >, IN, LIKE, OR, ...
|
|
169
|
+
order_by=[User.created_at.desc(), User.id],
|
|
170
|
+
)
|
|
171
|
+
# WHERE is_active = true AND age > 18 ORDER BY created_at DESC, id
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
A filter value of `None` means `IS NULL`; `UNSET` skips the filter entirely, so
|
|
175
|
+
you can pass through optional query params without branching.
|
|
176
|
+
|
|
177
|
+
### Partial updates that can actually write NULL
|
|
178
|
+
|
|
179
|
+
`UNSET` and `None` are different on purpose. `UNSET` says "don't touch this
|
|
180
|
+
column"; `None` says "set it to NULL". The hand-written `if x is not None`
|
|
181
|
+
pattern can't express the second one.
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
repo.update(1, UserUpdate(full_name="Ada")) # email stays whatever it was
|
|
185
|
+
repo.update(1, UserUpdate(email=None)) # email IS NULL now
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Column projection: load only what you need
|
|
189
|
+
|
|
190
|
+
Index the repo with a narrow DTO and it selects only those columns, returning
|
|
191
|
+
that shape, for the duration of the call. The injected repository is untouched.
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
@dataclass(frozen=True, slots=True)
|
|
195
|
+
class UserIdEmail:
|
|
196
|
+
id: int
|
|
197
|
+
email: str
|
|
198
|
+
|
|
199
|
+
repo[UserIdEmail].list(is_active=True) # SELECT id, email -> list[UserIdEmail]
|
|
200
|
+
repo[UserIdEmail].first(id=5)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Pagination is ordered, or it raises
|
|
204
|
+
|
|
205
|
+
Pagination over an unstable order silently drops and repeats rows across pages,
|
|
206
|
+
so `list_paginated` requires `order_by`. Forgetting it is a `ValueError`, not a
|
|
207
|
+
heisenbug in production.
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
repo.list_paginated(0, 20) # ValueError
|
|
211
|
+
repo.list_paginated(0, 20, order_by=User.id) # PaginatedResult(items=[...], total=...)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Three shapes of DTO
|
|
215
|
+
|
|
216
|
+
The DTO is an optimization, not a mandate. Pick the one that fits, and pay only
|
|
217
|
+
for what you use.
|
|
218
|
+
|
|
219
|
+
### 1. Dataclass DTO (the recommendation)
|
|
220
|
+
|
|
221
|
+
Lightest, detached from the session, and FastAPI serializes it natively, so the
|
|
222
|
+
same object is your repository return value and your `response_model`. No third
|
|
223
|
+
hand-written schema.
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
@app.get("/users")
|
|
227
|
+
def list_users(repo: Annotated[UserRepository, Depends(get_repo)]) -> list[UserDTO]:
|
|
228
|
+
return repo.list(order_by=User.created_at.desc())
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### 2. No DTO at all (model as DTO)
|
|
232
|
+
|
|
233
|
+
Leave the DTO parameter off and the repository returns the model itself. No
|
|
234
|
+
hydration, no dict round-trip. Set the id type when your keys aren't `int`:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
import uuid
|
|
238
|
+
from repositron import Repository
|
|
239
|
+
|
|
240
|
+
class AccountRepository(Repository[Account, Account, AccountCreate, AccountUpdate, uuid.UUID]):
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
repo.get(uuid.uuid4()) # -> Account | None, typed on UUID
|
|
244
|
+
repo.list(status="active") # -> list[Account]
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### 3. Pydantic DTO
|
|
248
|
+
|
|
249
|
+
If you already have a Pydantic response schema, it is the DTO. repositron
|
|
250
|
+
detects Pydantic and hydrates through `model_validate`.
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
class UserOut(BaseModel):
|
|
254
|
+
model_config = ConfigDict(from_attributes=True)
|
|
255
|
+
id: int
|
|
256
|
+
name: str
|
|
257
|
+
|
|
258
|
+
class UserRepository(Repository[User, UserOut]): ...
|
|
259
|
+
repo.list() # -> list[UserOut], ready for HTTP
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Public API
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
from repositron import (
|
|
266
|
+
Repository, # full CRUD generic base
|
|
267
|
+
ReadOnlyRepository, # read-only generic base
|
|
268
|
+
PaginatedResult, # {items, total} container
|
|
269
|
+
UNSET, UnsetType, # partial-update sentinel
|
|
270
|
+
)
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Type parameters: `Repository[ModelT, DTOT=ModelT, CreateT, UpdateT, IdT=int]`.
|
|
274
|
+
`ModelT` is required; everything else has a default, so
|
|
275
|
+
`Repository[Account]` is a valid read/write repository returning `Account` on
|
|
276
|
+
`int` keys.
|
|
277
|
+
|
|
278
|
+
| Class attribute | Purpose | Default |
|
|
279
|
+
| --------------- | --------------------------------------------- | ------- |
|
|
280
|
+
| `field_mapping` | `{model_field: dto_field}` for renamed fields | `{}` |
|
|
281
|
+
| `pk_column` | primary-key column name | `"id"` |
|
|
282
|
+
|
|
283
|
+
## Design notes
|
|
284
|
+
|
|
285
|
+
- The session is the caller's. The repository never opens, commits, or closes
|
|
286
|
+
it; writes `flush`, so transaction boundaries stay in the app.
|
|
287
|
+
- One source of truth per field name: declare a rename once in `field_mapping`
|
|
288
|
+
and it applies to both hydration and projection.
|
|
289
|
+
- Ordering is never implicit. `list` / `first` default to unordered; pagination
|
|
290
|
+
refuses to run without an order.
|
|
291
|
+
- `UNSET` is one canonical singleton, compared by identity. There is no
|
|
292
|
+
per-project override.
|
|
293
|
+
|
|
294
|
+
## License
|
|
295
|
+
|
|
296
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# repositron
|
|
2
|
+
|
|
3
|
+
A typed, generic repository base for SQLAlchemy 2.0. Declare a model (and
|
|
4
|
+
optionally a DTO and write payloads), inherit one generic class, and get
|
|
5
|
+
`get` / `first` / `list` / `list_paginated` / `count` / `exists` / `create` /
|
|
6
|
+
`update` / `delete` with no per-table boilerplate.
|
|
7
|
+
|
|
8
|
+
Every method is fully typed off the generic parameters, so your editor knows
|
|
9
|
+
that `repo.list()` returns `list[UserDTO]` and `repo.get(id)` takes an `int`
|
|
10
|
+
(or a `uuid.UUID`, your choice).
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
class UserRepository(Repository[User, UserDTO, UserCreate, UserUpdate]):
|
|
14
|
+
field_mapping = {"full_name": "name"}
|
|
15
|
+
|
|
16
|
+
repo.list(is_active=True, order_by=User.created_at.desc()) # -> list[UserDTO]
|
|
17
|
+
repo.update(1, UserUpdate(name="Ada")) # only name; others untouched
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv add repositron # or: pip install repositron
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Requires Python 3.13+ and `sqlalchemy>=2.0`. That is the only dependency; the
|
|
27
|
+
dataclass path pulls in nothing else.
|
|
28
|
+
|
|
29
|
+
## The before / after
|
|
30
|
+
|
|
31
|
+
Every SQLAlchemy project rewrites the same layer: a class per table wrapping
|
|
32
|
+
`session.query(...)`, the same `get` / `list` / `count`, the same pagination
|
|
33
|
+
math, the same "turn the ORM row into something light to return". It is
|
|
34
|
+
mechanical and easy to get subtly wrong.
|
|
35
|
+
|
|
36
|
+
### Before: hand-written, per table
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
class UserRepository:
|
|
40
|
+
def __init__(self, session: Session) -> None:
|
|
41
|
+
self.session = session
|
|
42
|
+
|
|
43
|
+
def get(self, id: int) -> UserDTO | None:
|
|
44
|
+
row = self.session.query(User).filter(User.id == id).first()
|
|
45
|
+
if row is None:
|
|
46
|
+
return None
|
|
47
|
+
return UserDTO(id=row.id, name=row.full_name, email=row.email)
|
|
48
|
+
|
|
49
|
+
def list(self, *, is_active: bool | None = None) -> list[UserDTO]:
|
|
50
|
+
query = self.session.query(User)
|
|
51
|
+
if is_active is not None:
|
|
52
|
+
query = query.filter(User.is_active == is_active)
|
|
53
|
+
return [
|
|
54
|
+
UserDTO(id=r.id, name=r.full_name, email=r.email)
|
|
55
|
+
for r in query.order_by(User.created_at.desc()).all()
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
def list_paginated(self, offset: int, limit: int = 20) -> tuple[list[UserDTO], int]:
|
|
59
|
+
query = self.session.query(User).order_by(User.created_at.desc())
|
|
60
|
+
total = query.order_by(None).count()
|
|
61
|
+
rows = query.offset(offset).limit(limit).all()
|
|
62
|
+
return [UserDTO(id=r.id, name=r.full_name, email=r.email) for r in rows], total
|
|
63
|
+
|
|
64
|
+
def count(self, *, is_active: bool | None = None) -> int:
|
|
65
|
+
query = self.session.query(User.id)
|
|
66
|
+
if is_active is not None:
|
|
67
|
+
query = query.filter(User.is_active == is_active)
|
|
68
|
+
return query.count()
|
|
69
|
+
|
|
70
|
+
def create(self, full_name: str, email: str) -> int:
|
|
71
|
+
user = User(full_name=full_name, email=email)
|
|
72
|
+
self.session.add(user)
|
|
73
|
+
self.session.flush()
|
|
74
|
+
return user.id
|
|
75
|
+
|
|
76
|
+
def update(self, id: int, *, full_name: str | None = None, email: str | None = None) -> bool:
|
|
77
|
+
user = self.session.query(User).filter(User.id == id).first()
|
|
78
|
+
if user is None:
|
|
79
|
+
return False
|
|
80
|
+
if full_name is not None: # but how do you set a column to NULL on purpose?
|
|
81
|
+
user.full_name = full_name
|
|
82
|
+
if email is not None:
|
|
83
|
+
user.email = email
|
|
84
|
+
self.session.flush()
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
# ...and delete, and first, and the same again for the next ten tables.
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### After: declare it once
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from dataclasses import dataclass
|
|
94
|
+
from repositron import Repository, UNSET, UnsetType
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class UserDTO: # light, detached, serializes straight to JSON
|
|
99
|
+
id: int
|
|
100
|
+
name: str # renamed from the model column `full_name`
|
|
101
|
+
email: str
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class UserCreate:
|
|
106
|
+
full_name: str
|
|
107
|
+
email: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class UserUpdate:
|
|
112
|
+
full_name: str | UnsetType = UNSET # absent = leave alone; None = SET NULL
|
|
113
|
+
email: str | UnsetType = UNSET
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class UserRepository(Repository[User, UserDTO, UserCreate, UserUpdate]):
|
|
117
|
+
field_mapping = {"full_name": "name"}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
That is the whole repository. Every method above comes for free, typed against
|
|
121
|
+
`UserDTO`:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
repo = UserRepository(session)
|
|
125
|
+
|
|
126
|
+
repo.get(1) # -> UserDTO | None
|
|
127
|
+
repo.list(is_active=True, order_by=User.created_at.desc())
|
|
128
|
+
repo.list_paginated(0, 20, order_by=User.created_at.desc()) # -> PaginatedResult[UserDTO]
|
|
129
|
+
repo.count(is_active=True)
|
|
130
|
+
repo.create(UserCreate(full_name="Ada Lovelace", email="ada@example.com")) # -> int (new id)
|
|
131
|
+
repo.update(1, UserUpdate(full_name="Ada L.")) # email left untouched
|
|
132
|
+
repo.delete(1)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## The sugar
|
|
136
|
+
|
|
137
|
+
### Two ways to filter, in one call
|
|
138
|
+
|
|
139
|
+
Equality is keyed by attribute name. Anything else is a plain SQLAlchemy
|
|
140
|
+
expression. They combine.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
repo.list(
|
|
144
|
+
is_active=True, # equality
|
|
145
|
+
extra_filters=[User.age > 18], # any expression: >, IN, LIKE, OR, ...
|
|
146
|
+
order_by=[User.created_at.desc(), User.id],
|
|
147
|
+
)
|
|
148
|
+
# WHERE is_active = true AND age > 18 ORDER BY created_at DESC, id
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
A filter value of `None` means `IS NULL`; `UNSET` skips the filter entirely, so
|
|
152
|
+
you can pass through optional query params without branching.
|
|
153
|
+
|
|
154
|
+
### Partial updates that can actually write NULL
|
|
155
|
+
|
|
156
|
+
`UNSET` and `None` are different on purpose. `UNSET` says "don't touch this
|
|
157
|
+
column"; `None` says "set it to NULL". The hand-written `if x is not None`
|
|
158
|
+
pattern can't express the second one.
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
repo.update(1, UserUpdate(full_name="Ada")) # email stays whatever it was
|
|
162
|
+
repo.update(1, UserUpdate(email=None)) # email IS NULL now
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Column projection: load only what you need
|
|
166
|
+
|
|
167
|
+
Index the repo with a narrow DTO and it selects only those columns, returning
|
|
168
|
+
that shape, for the duration of the call. The injected repository is untouched.
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
@dataclass(frozen=True, slots=True)
|
|
172
|
+
class UserIdEmail:
|
|
173
|
+
id: int
|
|
174
|
+
email: str
|
|
175
|
+
|
|
176
|
+
repo[UserIdEmail].list(is_active=True) # SELECT id, email -> list[UserIdEmail]
|
|
177
|
+
repo[UserIdEmail].first(id=5)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Pagination is ordered, or it raises
|
|
181
|
+
|
|
182
|
+
Pagination over an unstable order silently drops and repeats rows across pages,
|
|
183
|
+
so `list_paginated` requires `order_by`. Forgetting it is a `ValueError`, not a
|
|
184
|
+
heisenbug in production.
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
repo.list_paginated(0, 20) # ValueError
|
|
188
|
+
repo.list_paginated(0, 20, order_by=User.id) # PaginatedResult(items=[...], total=...)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Three shapes of DTO
|
|
192
|
+
|
|
193
|
+
The DTO is an optimization, not a mandate. Pick the one that fits, and pay only
|
|
194
|
+
for what you use.
|
|
195
|
+
|
|
196
|
+
### 1. Dataclass DTO (the recommendation)
|
|
197
|
+
|
|
198
|
+
Lightest, detached from the session, and FastAPI serializes it natively, so the
|
|
199
|
+
same object is your repository return value and your `response_model`. No third
|
|
200
|
+
hand-written schema.
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
@app.get("/users")
|
|
204
|
+
def list_users(repo: Annotated[UserRepository, Depends(get_repo)]) -> list[UserDTO]:
|
|
205
|
+
return repo.list(order_by=User.created_at.desc())
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### 2. No DTO at all (model as DTO)
|
|
209
|
+
|
|
210
|
+
Leave the DTO parameter off and the repository returns the model itself. No
|
|
211
|
+
hydration, no dict round-trip. Set the id type when your keys aren't `int`:
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
import uuid
|
|
215
|
+
from repositron import Repository
|
|
216
|
+
|
|
217
|
+
class AccountRepository(Repository[Account, Account, AccountCreate, AccountUpdate, uuid.UUID]):
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
repo.get(uuid.uuid4()) # -> Account | None, typed on UUID
|
|
221
|
+
repo.list(status="active") # -> list[Account]
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### 3. Pydantic DTO
|
|
225
|
+
|
|
226
|
+
If you already have a Pydantic response schema, it is the DTO. repositron
|
|
227
|
+
detects Pydantic and hydrates through `model_validate`.
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
class UserOut(BaseModel):
|
|
231
|
+
model_config = ConfigDict(from_attributes=True)
|
|
232
|
+
id: int
|
|
233
|
+
name: str
|
|
234
|
+
|
|
235
|
+
class UserRepository(Repository[User, UserOut]): ...
|
|
236
|
+
repo.list() # -> list[UserOut], ready for HTTP
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Public API
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
from repositron import (
|
|
243
|
+
Repository, # full CRUD generic base
|
|
244
|
+
ReadOnlyRepository, # read-only generic base
|
|
245
|
+
PaginatedResult, # {items, total} container
|
|
246
|
+
UNSET, UnsetType, # partial-update sentinel
|
|
247
|
+
)
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Type parameters: `Repository[ModelT, DTOT=ModelT, CreateT, UpdateT, IdT=int]`.
|
|
251
|
+
`ModelT` is required; everything else has a default, so
|
|
252
|
+
`Repository[Account]` is a valid read/write repository returning `Account` on
|
|
253
|
+
`int` keys.
|
|
254
|
+
|
|
255
|
+
| Class attribute | Purpose | Default |
|
|
256
|
+
| --------------- | --------------------------------------------- | ------- |
|
|
257
|
+
| `field_mapping` | `{model_field: dto_field}` for renamed fields | `{}` |
|
|
258
|
+
| `pk_column` | primary-key column name | `"id"` |
|
|
259
|
+
|
|
260
|
+
## Design notes
|
|
261
|
+
|
|
262
|
+
- The session is the caller's. The repository never opens, commits, or closes
|
|
263
|
+
it; writes `flush`, so transaction boundaries stay in the app.
|
|
264
|
+
- One source of truth per field name: declare a rename once in `field_mapping`
|
|
265
|
+
and it applies to both hydration and projection.
|
|
266
|
+
- Ordering is never implicit. `list` / `first` default to unordered; pagination
|
|
267
|
+
refuses to run without an order.
|
|
268
|
+
- `UNSET` is one canonical singleton, compared by identity. There is no
|
|
269
|
+
per-project override.
|
|
270
|
+
|
|
271
|
+
## License
|
|
272
|
+
|
|
273
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "repositron"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "A typed, generic repository base for SQLAlchemy 2.0. Full CRUD with no per-table boilerplate."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Felipe Adeildo", email = "contato@felipeadeildo.com" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.13"
|
|
12
|
+
keywords = ["sqlalchemy", "repository", "orm", "crud", "typed", "generic"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Topic :: Database",
|
|
19
|
+
"Topic :: Software Development :: Libraries",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"sqlalchemy>=2.0.51",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/felipeadeildo/repositron"
|
|
28
|
+
Repository = "https://github.com/felipeadeildo/repositron"
|
|
29
|
+
Issues = "https://github.com/felipeadeildo/repositron/issues"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["uv_build>=0.11.22,<0.12.0"]
|
|
33
|
+
build-backend = "uv_build"
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"prek>=0.4.5",
|
|
38
|
+
"ruff>=0.15.18",
|
|
39
|
+
"ty>=0.0.51",
|
|
40
|
+
"zensical>=0.0.45",
|
|
41
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""repositron: a typed, generic SQLAlchemy 2.0 repository base.
|
|
2
|
+
|
|
3
|
+
Declare a Model (and optionally a DTO and write payloads), inherit one generic
|
|
4
|
+
base, and get a typed repository with filtering, ordering, pagination, column
|
|
5
|
+
projection, and model-to-DTO hydration, without per-table CRUD boilerplate.
|
|
6
|
+
|
|
7
|
+
from repositron import Repository, UNSET, UnsetType
|
|
8
|
+
|
|
9
|
+
class TargetRepository(Repository[Target, TargetDTO, TargetCreate, TargetUpdate]):
|
|
10
|
+
field_mapping = {"mention_rank": "rank"}
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from repositron.base import PaginatedResult
|
|
14
|
+
from repositron.sentinel import UNSET, UnsetType
|
|
15
|
+
from repositron.sql import ReadOnlyRepository, Repository
|
|
16
|
+
|
|
17
|
+
__all__ = ["UNSET", "PaginatedResult", "ReadOnlyRepository", "Repository", "UnsetType"]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Backend-agnostic repository contracts and the pagination container.
|
|
2
|
+
|
|
3
|
+
These declare what a repository offers, independent of any database. The
|
|
4
|
+
SQLAlchemy implementation that a project actually inherits from lives in
|
|
5
|
+
`repositron.sql`. Import `Repository` from the package root, not from here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import datetime
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
14
|
+
|
|
15
|
+
from repositron.sentinel import UnsetType
|
|
16
|
+
|
|
17
|
+
_list = list # the list() method below shadows the builtin in class scope
|
|
18
|
+
|
|
19
|
+
# Equality-filter value: UNSET skips the filter, None filters by IS NULL.
|
|
20
|
+
type FilterValue = (
|
|
21
|
+
str
|
|
22
|
+
| int
|
|
23
|
+
| float
|
|
24
|
+
| bool
|
|
25
|
+
| datetime.datetime
|
|
26
|
+
| datetime.date
|
|
27
|
+
| Enum
|
|
28
|
+
| None
|
|
29
|
+
| UnsetType
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# order_by value: a column, a list of columns, or None for no ordering.
|
|
33
|
+
type OrderBy = ColumnElement | _list[ColumnElement] | None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class PaginatedResult[DTOT]:
|
|
38
|
+
"""One page of results plus the count the query would return unpaginated."""
|
|
39
|
+
|
|
40
|
+
items: list[DTOT]
|
|
41
|
+
total: int
|
|
42
|
+
"""Total matching rows ignoring offset/limit; for computing page counts."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ReadOnlyRepositoryABC[ModelT, DTOT = ModelT, IdT = int](ABC):
|
|
46
|
+
"""Read-only side of the repository contract (get, first, list, count, exists).
|
|
47
|
+
|
|
48
|
+
Backends implement this; consumers inherit the concrete `ReadOnlyRepository`
|
|
49
|
+
from `repositron.sql` instead.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def get(self, id: IdT) -> DTOT | None:
|
|
54
|
+
"""Get a single record by primary key, or None if absent."""
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def first(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
extra_filters: _list[ColumnElement[bool]] | None = None,
|
|
62
|
+
order_by: OrderBy = None,
|
|
63
|
+
**filters: FilterValue,
|
|
64
|
+
) -> DTOT | None:
|
|
65
|
+
"""Get the first record matching the filters, or None."""
|
|
66
|
+
raise NotImplementedError
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def list(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
extra_filters: _list[ColumnElement[bool]] | None = None,
|
|
73
|
+
order_by: OrderBy = None,
|
|
74
|
+
**filters: FilterValue,
|
|
75
|
+
) -> _list[DTOT]:
|
|
76
|
+
"""List records matching the filters."""
|
|
77
|
+
raise NotImplementedError
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
def list_paginated(
|
|
81
|
+
self,
|
|
82
|
+
offset: int,
|
|
83
|
+
limit: int = 20,
|
|
84
|
+
*,
|
|
85
|
+
extra_filters: _list[ColumnElement[bool]] | None = None,
|
|
86
|
+
order_by: OrderBy = None,
|
|
87
|
+
**filters: FilterValue,
|
|
88
|
+
) -> PaginatedResult[DTOT]:
|
|
89
|
+
"""List records with pagination. `order_by` is required."""
|
|
90
|
+
raise NotImplementedError
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def count(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
extra_filters: _list[ColumnElement[bool]] | None = None,
|
|
97
|
+
**filters: FilterValue,
|
|
98
|
+
) -> int:
|
|
99
|
+
"""Count records matching the filters."""
|
|
100
|
+
raise NotImplementedError
|
|
101
|
+
|
|
102
|
+
@abstractmethod
|
|
103
|
+
def exists(self, id: IdT) -> bool:
|
|
104
|
+
"""Check whether a record with this primary key exists."""
|
|
105
|
+
raise NotImplementedError
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class CRUDRepositoryABC[
|
|
109
|
+
ModelT,
|
|
110
|
+
DTOT = ModelT,
|
|
111
|
+
CreateT = object,
|
|
112
|
+
UpdateT = object,
|
|
113
|
+
IdT = int,
|
|
114
|
+
](ReadOnlyRepositoryABC[ModelT, DTOT, IdT]):
|
|
115
|
+
"""Adds create/update/delete to the read-only contract."""
|
|
116
|
+
|
|
117
|
+
@abstractmethod
|
|
118
|
+
def create(self, payload: CreateT) -> IdT:
|
|
119
|
+
"""Create a record from a dataclass payload. Returns the new primary key."""
|
|
120
|
+
raise NotImplementedError
|
|
121
|
+
|
|
122
|
+
@abstractmethod
|
|
123
|
+
def update(self, id: IdT, payload: UpdateT) -> bool:
|
|
124
|
+
"""Partial-update a record; UNSET fields are skipped. False if not found."""
|
|
125
|
+
raise NotImplementedError
|
|
126
|
+
|
|
127
|
+
@abstractmethod
|
|
128
|
+
def delete(self, id: IdT) -> bool:
|
|
129
|
+
"""Delete a record by primary key. False if not found."""
|
|
130
|
+
raise NotImplementedError
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The partial-update sentinel.
|
|
2
|
+
|
|
3
|
+
One canonical singleton, compared by identity. A field equal to `UNSET` on an
|
|
4
|
+
update payload is skipped (left untouched); `None` is a real value (SET NULL).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UnsetType:
|
|
11
|
+
"""Type of the `UNSET` sentinel; instances compare equal only by identity.
|
|
12
|
+
|
|
13
|
+
Annotate an optional update field as `int | UnsetType = UNSET` so it can be
|
|
14
|
+
distinguished from an explicit `None`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
_instance: "UnsetType | None" = None
|
|
18
|
+
|
|
19
|
+
def __new__(cls) -> "UnsetType":
|
|
20
|
+
# ponytail: enforce the singleton so identity comparison is always safe,
|
|
21
|
+
# even if someone calls UnsetType() directly.
|
|
22
|
+
if cls._instance is None:
|
|
23
|
+
cls._instance = super().__new__(cls)
|
|
24
|
+
return cls._instance
|
|
25
|
+
|
|
26
|
+
def __repr__(self) -> str:
|
|
27
|
+
return "UNSET"
|
|
28
|
+
|
|
29
|
+
def __bool__(self) -> bool:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
UNSET: Final[UnsetType] = UnsetType()
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Concrete SQLAlchemy repositories: `ReadOnlyRepository` and `Repository`.
|
|
2
|
+
|
|
3
|
+
The working classes a project inherits from. They implement the contracts in
|
|
4
|
+
`repositron.base` over a SQLAlchemy `Session`, adding model-to-DTO hydration,
|
|
5
|
+
column projection via `repo[DTO]`, and the equality/expression filter split.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
from dataclasses import fields, is_dataclass
|
|
10
|
+
from functools import cached_property
|
|
11
|
+
from typing import TYPE_CHECKING, ClassVar, cast, get_args, get_origin
|
|
12
|
+
|
|
13
|
+
from sqlalchemy.orm import Query, Session
|
|
14
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
15
|
+
|
|
16
|
+
from repositron.base import (
|
|
17
|
+
CRUDRepositoryABC,
|
|
18
|
+
FilterValue,
|
|
19
|
+
OrderBy,
|
|
20
|
+
PaginatedResult,
|
|
21
|
+
ReadOnlyRepositoryABC,
|
|
22
|
+
)
|
|
23
|
+
from repositron.sentinel import UnsetType
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from _typeshed import DataclassInstance
|
|
27
|
+
|
|
28
|
+
_list = list # avoids shadowing by the list() method in class scope
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ReadOnlyRepository[ModelT, DTOT = ModelT, IdT = int](
|
|
32
|
+
ReadOnlyRepositoryABC[ModelT, DTOT, IdT]
|
|
33
|
+
):
|
|
34
|
+
"""Typed read access to one table, parameterized by model, DTO, and id type.
|
|
35
|
+
|
|
36
|
+
Reads return the DTO `DTOT` (defaults to `ModelT`, i.e. the model itself with
|
|
37
|
+
no hydration). The instance holds no per-call state, so a single repository is
|
|
38
|
+
safe to share and inject.
|
|
39
|
+
|
|
40
|
+
Two filtering mechanisms combine in one call:
|
|
41
|
+
|
|
42
|
+
- `**filters`: equality only (`column == value`), keyed by model attribute
|
|
43
|
+
name, e.g. `name="Ale"`. `UNSET` skips that filter; `None` filters by
|
|
44
|
+
`IS NULL`.
|
|
45
|
+
- `extra_filters`: arbitrary SQLAlchemy expressions for what equality can't
|
|
46
|
+
express, like `age > 18`, `IN`, `LIKE`, `OR`. So
|
|
47
|
+
`list(name="Ale", extra_filters=[Model.age > 18])` is
|
|
48
|
+
`WHERE name = 'Ale' AND age > 18`.
|
|
49
|
+
|
|
50
|
+
The return type is resolved `repo[X]` (call site) > `DTOT` (class default) >
|
|
51
|
+
`ModelT` (fallback); see `__getitem__`.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
field_mapping: ClassVar[dict[str, str]] = {}
|
|
55
|
+
"""Renamed fields as `{model_field: dto_field}`, applied both when hydrating and when resolving projection columns.""" # noqa: E501
|
|
56
|
+
pk_column: ClassVar[str] = "id"
|
|
57
|
+
"""Primary-key column name; override for models keyed elsewhere (e.g. `"url_hash"`)."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, session: Session) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Args:
|
|
62
|
+
session: Caller-owned session. The repository never opens, commits, or
|
|
63
|
+
closes it; writes `flush` only.
|
|
64
|
+
"""
|
|
65
|
+
self.session = session
|
|
66
|
+
self._active_dto: type | None = None
|
|
67
|
+
"""DTO bound via `__getitem__` (call-site override); None uses the class default."""
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def _extract_type_arg(cls, index: int) -> type | None:
|
|
71
|
+
"""Extract a generic type argument from `__orig_bases__`, or None if absent.
|
|
72
|
+
|
|
73
|
+
Scans for the base parameterized off `ReadOnlyRepository` and
|
|
74
|
+
returns its argument at `index`. Robust to multiple inheritance (mixins
|
|
75
|
+
are skipped) and to base position in the MRO. Returns None when the
|
|
76
|
+
argument was omitted (e.g. DTOT left to its default) so callers can fall
|
|
77
|
+
back rather than crash.
|
|
78
|
+
"""
|
|
79
|
+
for base in getattr(cls, "__orig_bases__", ()):
|
|
80
|
+
origin = get_origin(base) or base
|
|
81
|
+
if isinstance(origin, type) and issubclass(origin, ReadOnlyRepository):
|
|
82
|
+
args = get_args(base)
|
|
83
|
+
if len(args) > index:
|
|
84
|
+
arg = args[index]
|
|
85
|
+
# A still-unbound TypeVar (default not supplied) is not a real type.
|
|
86
|
+
return arg if isinstance(arg, type) else None
|
|
87
|
+
return None
|
|
88
|
+
raise TypeError(
|
|
89
|
+
f"Cannot infer type arguments for {cls.__name__}. Inherit passing the generic "
|
|
90
|
+
f"parameters, e.g. class MyRepo(Repository[Model, DTO, Create, Update])."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@cached_property
|
|
94
|
+
def model_class(self) -> type[ModelT]:
|
|
95
|
+
"""SQLAlchemy model class, inferred from the `ModelT` generic parameter."""
|
|
96
|
+
model = self._extract_type_arg(0)
|
|
97
|
+
if model is None:
|
|
98
|
+
raise TypeError(
|
|
99
|
+
f"{type(self).__name__} must be parameterized with a model class."
|
|
100
|
+
)
|
|
101
|
+
return cast("type[ModelT]", model)
|
|
102
|
+
|
|
103
|
+
@cached_property
|
|
104
|
+
def dto_class(self) -> type[DTOT]:
|
|
105
|
+
"""DTO class: the `DTOT` generic if supplied, else `ModelT` (model-as-DTO)."""
|
|
106
|
+
dto = self._extract_type_arg(1)
|
|
107
|
+
return cast("type[DTOT]", dto if dto is not None else self.model_class)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def _dto(self) -> type:
|
|
111
|
+
"""Active DTO: the call-site override (`repo[X]`) if set, else the class default."""
|
|
112
|
+
return self._active_dto if self._active_dto is not None else self.dto_class
|
|
113
|
+
|
|
114
|
+
def __getitem__[S](self, dto: type[S]) -> "ReadOnlyRepository[ModelT, S, IdT]":
|
|
115
|
+
"""Return a lightweight clone bound to `dto` for this call.
|
|
116
|
+
|
|
117
|
+
The clone shares this repository's session and diverges only in its active
|
|
118
|
+
DTO, so the injected instance stays untouched and thread-safe. A narrow
|
|
119
|
+
dataclass DTO triggers column projection (loads only its fields).
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
repo[TargetIdOrg].list(is_active=True) # SELECT id, organization_id
|
|
123
|
+
"""
|
|
124
|
+
clone = copy.copy(self)
|
|
125
|
+
clone._active_dto = dto
|
|
126
|
+
return cast("ReadOnlyRepository[ModelT, S, IdT]", clone)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def _pk_col(self) -> ColumnElement:
|
|
130
|
+
"""The primary-key column element, configurable via `pk_column`."""
|
|
131
|
+
col = getattr(self.model_class, self.pk_column, None)
|
|
132
|
+
if col is None:
|
|
133
|
+
raise AttributeError(
|
|
134
|
+
f"{self.model_class.__name__} has no column '{self.pk_column}'"
|
|
135
|
+
)
|
|
136
|
+
return col # type: ignore[return-value]
|
|
137
|
+
|
|
138
|
+
def _project_columns(self, dto: type) -> _list[ColumnElement]:
|
|
139
|
+
"""Resolve a dataclass DTO's fields to model columns (in field order), honoring field_mapping.""" # noqa: E501
|
|
140
|
+
reverse = {v: k for k, v in self.field_mapping.items()}
|
|
141
|
+
names = [f.name for f in fields(cast("type[DataclassInstance]", dto))]
|
|
142
|
+
if not names:
|
|
143
|
+
raise ValueError(f"DTO {dto.__name__} has no fields")
|
|
144
|
+
columns: _list[ColumnElement] = []
|
|
145
|
+
for name in names:
|
|
146
|
+
model_field = reverse.get(name, name)
|
|
147
|
+
col = getattr(self.model_class, model_field, None)
|
|
148
|
+
if col is None:
|
|
149
|
+
raise AttributeError(
|
|
150
|
+
f"DTO {dto.__name__}: field '{name}' maps to no column on "
|
|
151
|
+
f"{self.model_class.__name__} (model_field='{model_field}')"
|
|
152
|
+
)
|
|
153
|
+
columns.append(col)
|
|
154
|
+
return columns
|
|
155
|
+
|
|
156
|
+
def _project(
|
|
157
|
+
self,
|
|
158
|
+
dto: type,
|
|
159
|
+
*,
|
|
160
|
+
extra_filters: _list[ColumnElement[bool]] | None,
|
|
161
|
+
order_by: OrderBy,
|
|
162
|
+
**filters: FilterValue,
|
|
163
|
+
) -> Query:
|
|
164
|
+
"""Build the column-projection query for a dataclass `dto` (rows in field order)."""
|
|
165
|
+
return self._select(
|
|
166
|
+
*self._project_columns(dto),
|
|
167
|
+
extra_filters=extra_filters,
|
|
168
|
+
order_by=order_by,
|
|
169
|
+
**filters,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def _hydrate(self, model: ModelT) -> DTOT:
|
|
173
|
+
"""Convert a model instance to the active DTO.
|
|
174
|
+
|
|
175
|
+
When the DTO is the model class the instance is returned unchanged. A
|
|
176
|
+
Pydantic DTO goes through `model_validate`, a dataclass DTO is built by
|
|
177
|
+
field name; both honor `field_mapping` for renames. Override for a DTO
|
|
178
|
+
the automatic path can't construct.
|
|
179
|
+
"""
|
|
180
|
+
dto = self._dto
|
|
181
|
+
if dto is self.model_class:
|
|
182
|
+
return cast("DTOT", model)
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
# ponytail: duck-type on model_validate instead of importing pydantic, so it
|
|
186
|
+
# stays a genuinely optional extra. from_attributes reads off the model; aliases rename.
|
|
187
|
+
validate = getattr(dto, "model_validate", None)
|
|
188
|
+
if validate is not None:
|
|
189
|
+
return cast("DTOT", validate(model))
|
|
190
|
+
|
|
191
|
+
model_dict = {
|
|
192
|
+
k: v for k, v in model.__dict__.items() if not k.startswith("_")
|
|
193
|
+
}
|
|
194
|
+
if is_dataclass(dto):
|
|
195
|
+
reverse = {v: k for k, v in self.field_mapping.items()}
|
|
196
|
+
kwargs = {
|
|
197
|
+
f.name: model_dict[reverse.get(f.name, f.name)]
|
|
198
|
+
for f in fields(dto)
|
|
199
|
+
if reverse.get(f.name, f.name) in model_dict
|
|
200
|
+
}
|
|
201
|
+
return cast("DTOT", dto(**kwargs))
|
|
202
|
+
return cast("DTOT", dto(**model_dict))
|
|
203
|
+
except (AttributeError, TypeError, IndexError) as e:
|
|
204
|
+
raise NotImplementedError(
|
|
205
|
+
f"Cannot automatically convert {type(model).__name__} to {dto.__name__}. "
|
|
206
|
+
f"Override _hydrate() in your repository subclass. Error: {e}"
|
|
207
|
+
) from e
|
|
208
|
+
|
|
209
|
+
def _apply_filters(
|
|
210
|
+
self,
|
|
211
|
+
query: Query,
|
|
212
|
+
*,
|
|
213
|
+
extra_filters: _list[ColumnElement[bool]] | None = None,
|
|
214
|
+
**filters: FilterValue,
|
|
215
|
+
) -> Query:
|
|
216
|
+
"""Apply equality `**filters` and arbitrary `extra_filters` (see class docstring).
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
ValueError: if a `**filters` key is not a model attribute.
|
|
220
|
+
"""
|
|
221
|
+
for key, value in filters.items():
|
|
222
|
+
if isinstance(value, UnsetType):
|
|
223
|
+
continue
|
|
224
|
+
if not hasattr(self.model_class, key):
|
|
225
|
+
raise ValueError(
|
|
226
|
+
f"{self.model_class.__name__} has no attribute '{key}'"
|
|
227
|
+
)
|
|
228
|
+
query = query.filter(getattr(self.model_class, key) == value)
|
|
229
|
+
if extra_filters:
|
|
230
|
+
query = query.filter(*extra_filters)
|
|
231
|
+
return query
|
|
232
|
+
|
|
233
|
+
def _apply_order(self, query: Query, order_by: OrderBy = None) -> Query:
|
|
234
|
+
"""Apply `order_by` to a query; `None` leaves it unordered."""
|
|
235
|
+
if order_by is None:
|
|
236
|
+
return query
|
|
237
|
+
if isinstance(order_by, list):
|
|
238
|
+
return query.order_by(*order_by)
|
|
239
|
+
return query.order_by(order_by)
|
|
240
|
+
|
|
241
|
+
def _select(
|
|
242
|
+
self,
|
|
243
|
+
*columns: ColumnElement,
|
|
244
|
+
extra_filters: _list[ColumnElement[bool]] | None = None,
|
|
245
|
+
order_by: OrderBy = None,
|
|
246
|
+
**filters: FilterValue,
|
|
247
|
+
) -> Query:
|
|
248
|
+
"""Build a query (over `columns`, or the whole model if none) with filters and order applied.""" # noqa: E501
|
|
249
|
+
target = (
|
|
250
|
+
self.session.query(*columns)
|
|
251
|
+
if columns
|
|
252
|
+
else self.session.query(self.model_class)
|
|
253
|
+
)
|
|
254
|
+
target = self._apply_filters(target, extra_filters=extra_filters, **filters)
|
|
255
|
+
return self._apply_order(target, order_by=order_by)
|
|
256
|
+
|
|
257
|
+
def _projecting(self) -> type | None:
|
|
258
|
+
"""The active DTO if it warrants column projection (a non-model dataclass), else None."""
|
|
259
|
+
dto = self._dto
|
|
260
|
+
if dto is self.model_class:
|
|
261
|
+
return None
|
|
262
|
+
return dto if is_dataclass(dto) else None
|
|
263
|
+
|
|
264
|
+
def get(self, id: IdT) -> DTOT | None:
|
|
265
|
+
"""Fetch one record by primary key, hydrated to the active DTO."""
|
|
266
|
+
model = self.session.query(self.model_class).filter(self._pk_col == id).first()
|
|
267
|
+
if model is None:
|
|
268
|
+
return None
|
|
269
|
+
return self._hydrate(model)
|
|
270
|
+
|
|
271
|
+
def first(
|
|
272
|
+
self,
|
|
273
|
+
*,
|
|
274
|
+
extra_filters: _list[ColumnElement[bool]] | None = None,
|
|
275
|
+
order_by: OrderBy = None,
|
|
276
|
+
**filters: FilterValue,
|
|
277
|
+
) -> DTOT | None:
|
|
278
|
+
"""Fetch the first matching record, hydrated to the active DTO, or None."""
|
|
279
|
+
dto = self._projecting()
|
|
280
|
+
if dto is not None:
|
|
281
|
+
row = self._project(
|
|
282
|
+
dto, extra_filters=extra_filters, order_by=order_by, **filters
|
|
283
|
+
).first()
|
|
284
|
+
return cast("DTOT", dto(*row)) if row is not None else None
|
|
285
|
+
model = self._select(
|
|
286
|
+
extra_filters=extra_filters, order_by=order_by, **filters
|
|
287
|
+
).first()
|
|
288
|
+
if model is None:
|
|
289
|
+
return None
|
|
290
|
+
return self._hydrate(model)
|
|
291
|
+
|
|
292
|
+
def list(
|
|
293
|
+
self,
|
|
294
|
+
*,
|
|
295
|
+
extra_filters: _list[ColumnElement[bool]] | None = None,
|
|
296
|
+
order_by: OrderBy = None,
|
|
297
|
+
**filters: FilterValue,
|
|
298
|
+
) -> _list[DTOT]:
|
|
299
|
+
"""List records matching the filters, each hydrated to the active DTO."""
|
|
300
|
+
dto = self._projecting()
|
|
301
|
+
if dto is not None:
|
|
302
|
+
rows = self._project(
|
|
303
|
+
dto, extra_filters=extra_filters, order_by=order_by, **filters
|
|
304
|
+
).all()
|
|
305
|
+
# Rows come back in the DTO's field order (see _project_columns); build positionally.
|
|
306
|
+
return cast("_list[DTOT]", [dto(*row) for row in rows])
|
|
307
|
+
models = self._select(
|
|
308
|
+
extra_filters=extra_filters, order_by=order_by, **filters
|
|
309
|
+
).all()
|
|
310
|
+
return [self._hydrate(m) for m in models]
|
|
311
|
+
|
|
312
|
+
def list_paginated(
|
|
313
|
+
self,
|
|
314
|
+
offset: int,
|
|
315
|
+
limit: int = 20,
|
|
316
|
+
*,
|
|
317
|
+
extra_filters: _list[ColumnElement[bool]] | None = None,
|
|
318
|
+
order_by: OrderBy = None,
|
|
319
|
+
**filters: FilterValue,
|
|
320
|
+
) -> PaginatedResult[DTOT]:
|
|
321
|
+
"""Return a page of records plus the unpaginated total.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
order_by: Required. Pagination over an unstable order silently drops
|
|
325
|
+
and repeats rows across pages, so a None order is rejected.
|
|
326
|
+
|
|
327
|
+
Raises:
|
|
328
|
+
ValueError: If `order_by` is None.
|
|
329
|
+
"""
|
|
330
|
+
if order_by is None:
|
|
331
|
+
raise ValueError(
|
|
332
|
+
"list_paginated requires order_by: pagination is unstable without a stable order"
|
|
333
|
+
)
|
|
334
|
+
dto = self._projecting()
|
|
335
|
+
if dto is not None:
|
|
336
|
+
query = self._project(
|
|
337
|
+
dto, extra_filters=extra_filters, order_by=order_by, **filters
|
|
338
|
+
)
|
|
339
|
+
total = query.order_by(None).count()
|
|
340
|
+
rows = query.offset(offset).limit(limit).all()
|
|
341
|
+
items = cast("_list[DTOT]", [dto(*row) for row in rows])
|
|
342
|
+
return PaginatedResult(items=items, total=total)
|
|
343
|
+
query = self._select(extra_filters=extra_filters, order_by=order_by, **filters)
|
|
344
|
+
total = query.order_by(None).count()
|
|
345
|
+
models = query.offset(offset).limit(limit).all()
|
|
346
|
+
return PaginatedResult(items=[self._hydrate(m) for m in models], total=total)
|
|
347
|
+
|
|
348
|
+
def count(
|
|
349
|
+
self,
|
|
350
|
+
*,
|
|
351
|
+
extra_filters: _list[ColumnElement[bool]] | None = None,
|
|
352
|
+
**filters: FilterValue,
|
|
353
|
+
) -> int:
|
|
354
|
+
"""Count records matching the filters."""
|
|
355
|
+
query = self.session.query(self._pk_col)
|
|
356
|
+
query = self._apply_filters(query, extra_filters=extra_filters, **filters)
|
|
357
|
+
return query.count()
|
|
358
|
+
|
|
359
|
+
def exists(self, id: IdT) -> bool:
|
|
360
|
+
"""Check whether a record with this primary key exists."""
|
|
361
|
+
return (
|
|
362
|
+
self.session.query(self._pk_col).filter(self._pk_col == id).first()
|
|
363
|
+
is not None
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class Repository[ModelT, DTOT = ModelT, CreateT = object, UpdateT = object, IdT = int](
|
|
368
|
+
ReadOnlyRepository[ModelT, DTOT, IdT],
|
|
369
|
+
CRUDRepositoryABC[ModelT, DTOT, CreateT, UpdateT, IdT],
|
|
370
|
+
):
|
|
371
|
+
"""Read access plus `create`/`update`/`delete` from dataclass payloads.
|
|
372
|
+
|
|
373
|
+
Writes `flush` so the caller still owns the transaction boundary (no
|
|
374
|
+
`commit`). `CreateT`/`UpdateT` are the payload dataclasses; `UNSET` fields are
|
|
375
|
+
skipped on write.
|
|
376
|
+
|
|
377
|
+
Example:
|
|
378
|
+
class TargetRepository(
|
|
379
|
+
Repository[Target, TargetDTO, TargetCreate, TargetUpdate]
|
|
380
|
+
):
|
|
381
|
+
field_mapping = {"mention_rank": "rank"}
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
def create(self, payload: CreateT) -> IdT:
|
|
385
|
+
"""Insert a record from a dataclass payload and flush.
|
|
386
|
+
|
|
387
|
+
UNSET fields are omitted, so the column or model default applies.
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
The new primary-key value, read back after the flush.
|
|
391
|
+
"""
|
|
392
|
+
kwargs = {
|
|
393
|
+
f.name: getattr(payload, f.name)
|
|
394
|
+
for f in fields(cast("DataclassInstance", payload))
|
|
395
|
+
if hasattr(self.model_class, f.name)
|
|
396
|
+
and not isinstance(getattr(payload, f.name), UnsetType)
|
|
397
|
+
}
|
|
398
|
+
model = self.model_class(**kwargs) # type: ignore[call-arg]
|
|
399
|
+
self.session.add(model)
|
|
400
|
+
self.session.flush()
|
|
401
|
+
return getattr(model, self.pk_column)
|
|
402
|
+
|
|
403
|
+
def update(self, id: IdT, payload: UpdateT) -> bool:
|
|
404
|
+
"""Apply a partial update from a dataclass payload and flush.
|
|
405
|
+
|
|
406
|
+
UNSET fields are left untouched; `None` is written as a real value
|
|
407
|
+
(SET NULL).
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
True on success, False if no record has that primary key.
|
|
411
|
+
"""
|
|
412
|
+
model = self.session.query(self.model_class).filter(self._pk_col == id).first()
|
|
413
|
+
if model is None:
|
|
414
|
+
return False
|
|
415
|
+
for f in fields(cast("DataclassInstance", payload)):
|
|
416
|
+
value = getattr(payload, f.name)
|
|
417
|
+
if not isinstance(value, UnsetType) and hasattr(model, f.name):
|
|
418
|
+
setattr(model, f.name, value)
|
|
419
|
+
self.session.flush()
|
|
420
|
+
return True
|
|
421
|
+
|
|
422
|
+
def delete(self, id: IdT) -> bool:
|
|
423
|
+
"""Delete a record by primary key and flush.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
True on success, False if no record has that primary key.
|
|
427
|
+
"""
|
|
428
|
+
model = self.session.query(self.model_class).filter(self._pk_col == id).first()
|
|
429
|
+
if model is None:
|
|
430
|
+
return False
|
|
431
|
+
self.session.delete(model)
|
|
432
|
+
self.session.flush()
|
|
433
|
+
return True
|