fastapi-repository 0.0.1__py3-none-any.whl
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,341 @@
|
|
1
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
2
|
+
from sqlalchemy.exc import NoResultFound
|
3
|
+
from sqlalchemy.future import select
|
4
|
+
from uuid import UUID
|
5
|
+
from sqlalchemy.orm import joinedload, lazyload
|
6
|
+
from sqlalchemy import func, update, delete
|
7
|
+
from typing import Optional, List, Union, Dict, Any
|
8
|
+
|
9
|
+
OPERATORS = {
|
10
|
+
# 完全一致
|
11
|
+
"exact": lambda col, val: col == val,
|
12
|
+
"iexact": lambda col, val: col.ilike(val),
|
13
|
+
# 部分一致
|
14
|
+
"contains": lambda col, val: col.contains(val),
|
15
|
+
"icontains": lambda col, val: col.ilike(f"%{val}%"),
|
16
|
+
# in句
|
17
|
+
"in": lambda col, val: col.in_(val) if isinstance(val, list) else col.in_([val]),
|
18
|
+
# 大小比較
|
19
|
+
"gt": lambda col, val: col > val,
|
20
|
+
"gte": lambda col, val: col >= val,
|
21
|
+
"lt": lambda col, val: col < val,
|
22
|
+
"lte": lambda col, val: col <= val,
|
23
|
+
# 前方・後方一致
|
24
|
+
"startswith": lambda col, val: col.startswith(val),
|
25
|
+
"istartswith": lambda col, val: col.ilike(f"{val}%"),
|
26
|
+
"endswith": lambda col, val: col.endswith(val),
|
27
|
+
"iendswith": lambda col, val: col.ilike(f"%{val}"),
|
28
|
+
}
|
29
|
+
|
30
|
+
|
31
|
+
class BaseRepository:
|
32
|
+
default_scope: dict = {}
|
33
|
+
|
34
|
+
def __init__(self, session: AsyncSession, model=None):
|
35
|
+
self.session = session
|
36
|
+
if not model:
|
37
|
+
raise ValueError("Model is not set for this repository.")
|
38
|
+
self.model = model
|
39
|
+
|
40
|
+
async def find(
|
41
|
+
self,
|
42
|
+
id: Union[int, UUID],
|
43
|
+
sorted_by: Optional[str] = None,
|
44
|
+
sorted_order: str = "asc",
|
45
|
+
joinedload_models: Optional[List] = None,
|
46
|
+
lazyload_models: Optional[List] = None,
|
47
|
+
disable_default_scope: bool = False,
|
48
|
+
):
|
49
|
+
"""
|
50
|
+
Find a record by its ID. Raise an exception if not found.
|
51
|
+
"""
|
52
|
+
query = await self.__generate_query(
|
53
|
+
limit=1,
|
54
|
+
offset=0,
|
55
|
+
sorted_by=sorted_by,
|
56
|
+
sorted_order=sorted_order,
|
57
|
+
joinedload_models=joinedload_models,
|
58
|
+
lazyload_models=lazyload_models,
|
59
|
+
disable_default_scope=disable_default_scope,
|
60
|
+
id=id,
|
61
|
+
)
|
62
|
+
|
63
|
+
result = await self.session.execute(query)
|
64
|
+
instance = result.scalars().first()
|
65
|
+
|
66
|
+
if not instance:
|
67
|
+
raise NoResultFound(f"{self.model.__name__} with id {id} not found.")
|
68
|
+
|
69
|
+
return instance
|
70
|
+
|
71
|
+
async def find_by(
|
72
|
+
self,
|
73
|
+
sorted_by: Optional[str] = None,
|
74
|
+
sorted_order: str = "asc",
|
75
|
+
joinedload_models: Optional[List] = None,
|
76
|
+
lazyload_models: Optional[List] = None,
|
77
|
+
disable_default_scope: bool = False,
|
78
|
+
**search_params,
|
79
|
+
):
|
80
|
+
"""
|
81
|
+
Find a record by given attributes. Return None if not found.
|
82
|
+
"""
|
83
|
+
query = await self.__generate_query(
|
84
|
+
limit=1,
|
85
|
+
offset=0,
|
86
|
+
sorted_by=sorted_by,
|
87
|
+
sorted_order=sorted_order,
|
88
|
+
joinedload_models=joinedload_models,
|
89
|
+
lazyload_models=lazyload_models,
|
90
|
+
disable_default_scope=disable_default_scope,
|
91
|
+
**search_params,
|
92
|
+
)
|
93
|
+
result = await self.session.execute(query)
|
94
|
+
instance = result.scalars().first()
|
95
|
+
return instance
|
96
|
+
|
97
|
+
async def find_by_or_raise(
|
98
|
+
self,
|
99
|
+
sorted_by: Optional[str] = None,
|
100
|
+
sorted_order: str = "asc",
|
101
|
+
joinedload_models: Optional[List] = None,
|
102
|
+
lazyload_models: Optional[List] = None,
|
103
|
+
disable_default_scope: bool = False,
|
104
|
+
**search_params,
|
105
|
+
):
|
106
|
+
"""
|
107
|
+
Find a record by given attributes. Raise an exception if not found.
|
108
|
+
"""
|
109
|
+
instance = await self.find_by(
|
110
|
+
sorted_by=sorted_by,
|
111
|
+
sorted_order=sorted_order,
|
112
|
+
joinedload_models=joinedload_models,
|
113
|
+
lazyload_models=lazyload_models,
|
114
|
+
disable_default_scope=disable_default_scope,
|
115
|
+
**search_params,
|
116
|
+
)
|
117
|
+
if not instance:
|
118
|
+
raise NoResultFound(
|
119
|
+
f"{self.model.__name__} with attributes {search_params} not found."
|
120
|
+
)
|
121
|
+
return instance
|
122
|
+
|
123
|
+
async def where(
|
124
|
+
self,
|
125
|
+
limit: int = 100,
|
126
|
+
offset: int = 0,
|
127
|
+
sorted_by: Optional[str] = None,
|
128
|
+
sorted_order: str = "asc",
|
129
|
+
joinedload_models: Optional[List] = None,
|
130
|
+
lazyload_models: Optional[List] = None,
|
131
|
+
disable_default_scope: bool = False,
|
132
|
+
**search_params,
|
133
|
+
):
|
134
|
+
"""
|
135
|
+
Find records with optional filtering, sorting, and pagination.
|
136
|
+
"""
|
137
|
+
query = await self.__generate_query(
|
138
|
+
limit=limit,
|
139
|
+
offset=offset,
|
140
|
+
sorted_by=sorted_by,
|
141
|
+
sorted_order=sorted_order,
|
142
|
+
joinedload_models=joinedload_models,
|
143
|
+
lazyload_models=lazyload_models,
|
144
|
+
disable_default_scope=disable_default_scope,
|
145
|
+
**search_params,
|
146
|
+
)
|
147
|
+
result = await self.session.execute(query)
|
148
|
+
return result.unique().scalars().all()
|
149
|
+
|
150
|
+
async def count(self, disable_default_scope: bool = False, **search_params) -> int:
|
151
|
+
"""
|
152
|
+
Count records with optional filtering.
|
153
|
+
"""
|
154
|
+
conditions = []
|
155
|
+
if not disable_default_scope:
|
156
|
+
default_conditions = await self.__get_conditions(**self.default_scope)
|
157
|
+
conditions.extend(default_conditions)
|
158
|
+
|
159
|
+
conditions += await self.__get_conditions(**search_params)
|
160
|
+
query = select(func.count("*")).select_from(self.model).where(*conditions)
|
161
|
+
result = await self.session.execute(query)
|
162
|
+
return result.scalar() or 0
|
163
|
+
|
164
|
+
async def exists(
|
165
|
+
self, disable_default_scope: bool = False, **search_params
|
166
|
+
) -> bool:
|
167
|
+
"""
|
168
|
+
Check if any record exists with the given attributes.
|
169
|
+
"""
|
170
|
+
counted = await self.count(
|
171
|
+
disable_default_scope=disable_default_scope, **search_params
|
172
|
+
)
|
173
|
+
return counted > 0
|
174
|
+
|
175
|
+
async def __generate_query(
|
176
|
+
self,
|
177
|
+
limit: int = 100,
|
178
|
+
offset: int = 0,
|
179
|
+
sorted_by: Optional[str] = None,
|
180
|
+
sorted_order: str = "asc",
|
181
|
+
joinedload_models: Optional[List] = None,
|
182
|
+
lazyload_models: Optional[List] = None,
|
183
|
+
disable_default_scope: bool = False,
|
184
|
+
**search_params,
|
185
|
+
):
|
186
|
+
"""
|
187
|
+
Generate a query with optional filtering, sorting, and pagination.
|
188
|
+
Apply default scope if not disabled.
|
189
|
+
"""
|
190
|
+
conditions = []
|
191
|
+
if not disable_default_scope:
|
192
|
+
default_conditions = await self.__get_conditions(**self.default_scope)
|
193
|
+
conditions.extend(default_conditions)
|
194
|
+
|
195
|
+
conditions += await self.__get_conditions(**search_params)
|
196
|
+
|
197
|
+
query = select(self.model).where(*conditions)
|
198
|
+
|
199
|
+
if joinedload_models:
|
200
|
+
for model in joinedload_models:
|
201
|
+
query = query.options(joinedload(model))
|
202
|
+
if lazyload_models:
|
203
|
+
for model in lazyload_models:
|
204
|
+
query = query.options(lazyload(model))
|
205
|
+
|
206
|
+
if sorted_by:
|
207
|
+
query = self._apply_order_by(query, sorted_by, sorted_order)
|
208
|
+
|
209
|
+
return query.limit(limit).offset(offset)
|
210
|
+
|
211
|
+
def _apply_order_by(self, query, sorted_by: str, sorted_order: str):
|
212
|
+
"""
|
213
|
+
クエリに対して order_by を適用するヘルパー。
|
214
|
+
"""
|
215
|
+
column = getattr(self.model, sorted_by, None)
|
216
|
+
if not column:
|
217
|
+
raise AttributeError(
|
218
|
+
f"{self.model.__name__} has no attribute '{sorted_by}'"
|
219
|
+
)
|
220
|
+
|
221
|
+
if sorted_order.lower() == "asc":
|
222
|
+
query = query.order_by(column.asc())
|
223
|
+
else:
|
224
|
+
query = query.order_by(column.desc())
|
225
|
+
return query
|
226
|
+
|
227
|
+
async def __get_conditions(self, **search_params):
|
228
|
+
"""
|
229
|
+
Generate conditions for filtering based on provided keyword arguments.
|
230
|
+
Supports Ransack-like operators (field__operator=value).
|
231
|
+
"""
|
232
|
+
conditions = []
|
233
|
+
for key, value in search_params.items():
|
234
|
+
# keyに "__" が含まれていれば、フィールド名と演算子を分割する
|
235
|
+
if "__" in key:
|
236
|
+
parts = key.split("__")
|
237
|
+
op = "exact"
|
238
|
+
if parts[-1] in OPERATORS: # 末尾が演算子なら取り除く
|
239
|
+
op = parts.pop()
|
240
|
+
|
241
|
+
# 単純カラム: foo__icontains=bar
|
242
|
+
if len(parts) == 1:
|
243
|
+
column = getattr(self.model, parts[0], None)
|
244
|
+
if column is None:
|
245
|
+
raise AttributeError(
|
246
|
+
f"{self.model.__name__} has no attribute '{parts[0]}'"
|
247
|
+
)
|
248
|
+
conditions.append(OPERATORS[op](column, value))
|
249
|
+
continue
|
250
|
+
|
251
|
+
# 1ホップのリレーション: rel__field__op=value
|
252
|
+
rel_attr = getattr(self.model, parts[0], None)
|
253
|
+
if rel_attr is None or not hasattr(rel_attr, "property"):
|
254
|
+
raise AttributeError(
|
255
|
+
f"{self.model.__name__} has no relationship '{parts[0]}'"
|
256
|
+
)
|
257
|
+
target_cls = rel_attr.property.mapper.class_
|
258
|
+
target_column = getattr(target_cls, parts[1], None)
|
259
|
+
if target_column is None:
|
260
|
+
raise AttributeError(
|
261
|
+
f"{target_cls.__name__} has no attribute '{parts[1]}'"
|
262
|
+
)
|
263
|
+
conditions.append(rel_attr.any(OPERATORS[op](target_column, value)))
|
264
|
+
continue
|
265
|
+
else:
|
266
|
+
# "__"が含まれていない場合は eq (=) 比較とみなす
|
267
|
+
column = getattr(self.model, key, None)
|
268
|
+
if column is None:
|
269
|
+
raise AttributeError(
|
270
|
+
f"{self.model.__name__} has no attribute '{key}'"
|
271
|
+
)
|
272
|
+
conditions.append(column == value)
|
273
|
+
|
274
|
+
return conditions
|
275
|
+
|
276
|
+
async def create(self, **create_params):
|
277
|
+
"""
|
278
|
+
Generic create method that instantiates the model,
|
279
|
+
saves it, and returns the new instance.
|
280
|
+
"""
|
281
|
+
instance = self.model(**create_params)
|
282
|
+
self.session.add(instance)
|
283
|
+
await self.session.commit()
|
284
|
+
await self.session.refresh(instance)
|
285
|
+
return instance
|
286
|
+
|
287
|
+
async def update(self, id: Union[int, UUID], **update_params):
|
288
|
+
"""
|
289
|
+
Update a single record by its primary key.
|
290
|
+
Raises NoResultFound if the record doesn't exist.
|
291
|
+
|
292
|
+
Usage:
|
293
|
+
await repository.update(some_id, field1='value1', field2='value2')
|
294
|
+
"""
|
295
|
+
instance = await self.find(id)
|
296
|
+
for field, value in update_params.items():
|
297
|
+
setattr(instance, field, value)
|
298
|
+
await self.session.commit()
|
299
|
+
await self.session.refresh(instance)
|
300
|
+
return instance
|
301
|
+
|
302
|
+
async def update_all(self, updates: Dict[str, Any], **search_params) -> int:
|
303
|
+
"""
|
304
|
+
Update all records that match the given conditions in one query.
|
305
|
+
Returns the number of rows that were updated.
|
306
|
+
|
307
|
+
Usage:
|
308
|
+
await repository.update_all(
|
309
|
+
{"field1": "new_value", "field2": 123},
|
310
|
+
some_field__gte=10,
|
311
|
+
other_field="foo"
|
312
|
+
)
|
313
|
+
"""
|
314
|
+
conditions = await self.__get_conditions(**search_params)
|
315
|
+
stmt = update(self.model).where(*conditions).values(**updates)
|
316
|
+
result = await self.session.execute(stmt)
|
317
|
+
await self.session.commit()
|
318
|
+
return result.rowcount
|
319
|
+
|
320
|
+
async def destroy(self, id: Union[int, UUID]) -> None:
|
321
|
+
"""
|
322
|
+
Destroy (delete) a single record by its primary key.
|
323
|
+
Raises NoResultFound if the record doesn't exist.
|
324
|
+
"""
|
325
|
+
instance = await self.find(id) # Will raise NoResultFound if not found
|
326
|
+
await self.session.delete(instance)
|
327
|
+
await self.session.commit()
|
328
|
+
|
329
|
+
async def destroy_all(self, **search_params) -> int:
|
330
|
+
"""
|
331
|
+
Destroy (delete) all records that match the given conditions in one query.
|
332
|
+
Returns the number of rows that were deleted.
|
333
|
+
|
334
|
+
Usage:
|
335
|
+
await repository.destroy_all(field1="value1", field2__gte=10)
|
336
|
+
"""
|
337
|
+
conditions = await self.__get_conditions(**search_params)
|
338
|
+
stmt = delete(self.model).where(*conditions)
|
339
|
+
result = await self.session.execute(stmt)
|
340
|
+
await self.session.commit()
|
341
|
+
return result.rowcount
|
@@ -0,0 +1,36 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: fastapi-repository
|
3
|
+
Version: 0.0.1
|
4
|
+
Summary: A base repository for FastAPI projects, inspired by Ruby on Rails' Active Record and Ransack.
|
5
|
+
Author-email: Seiya Takeda <takedaseiya@gmail.com>
|
6
|
+
Project-URL: Homepage, https://github.com/seiyat/fastapi-repository
|
7
|
+
Project-URL: Bug Tracker, https://github.com/seiyat/fastapi-repository/issues
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
10
|
+
Classifier: Operating System :: OS Independent
|
11
|
+
Requires-Python: >=3.8
|
12
|
+
Description-Content-Type: text/markdown
|
13
|
+
Requires-Dist: sqlalchemy>=1.4.0
|
14
|
+
Requires-Dist: fastapi>=0.70.0
|
15
|
+
|
16
|
+
# FastAPI Repository
|
17
|
+
|
18
|
+
A base repository for FastAPI projects, inspired by Ruby on Rails' Active Record and Ransack.
|
19
|
+
|
20
|
+
## Installation
|
21
|
+
|
22
|
+
```bash
|
23
|
+
pip install fastapi-repository
|
24
|
+
```
|
25
|
+
|
26
|
+
## Usage
|
27
|
+
|
28
|
+
```python
|
29
|
+
from fastapi_repository import BaseRepository
|
30
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
31
|
+
from .models import User
|
32
|
+
|
33
|
+
class UserRepository(BaseRepository):
|
34
|
+
def __init__(self, session: AsyncSession):
|
35
|
+
super().__init__(session, model=User)
|
36
|
+
```
|
@@ -0,0 +1,6 @@
|
|
1
|
+
fastapi_repository/__init__.py,sha256=ymIcIyfYlbv-TudpI3UOU1f2y57tEnRIFIYQMjnw0bI,87
|
2
|
+
fastapi_repository/base.py,sha256=5RpkNKXnl3cozCiHkWhU7sGY0QIY_36BmT8AoGge0kM,12325
|
3
|
+
fastapi_repository-0.0.1.dist-info/METADATA,sha256=X-xa_Q5eCVrC4IPlenM8dl6LwEhFrp5RrnRoWleUkBc,1089
|
4
|
+
fastapi_repository-0.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
5
|
+
fastapi_repository-0.0.1.dist-info/top_level.txt,sha256=SSUZqBKCDo6XNjAhSFvpv4tmiPW1COl86ZR5B4ucBkU,19
|
6
|
+
fastapi_repository-0.0.1.dist-info/RECORD,,
|
@@ -0,0 +1 @@
|
|
1
|
+
fastapi_repository
|