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,3 @@
1
+ from .base import BaseRepository, OPERATORS
2
+
3
+ __all__ = ["BaseRepository", "OPERATORS"]
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ fastapi_repository