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