appkit-commons 0.13.1__py3-none-any.whl → 0.15.4__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,226 @@
|
|
|
1
|
+
"""Abstract base repository with generic CRUD operations.
|
|
2
|
+
|
|
3
|
+
Inspired by org.springframework.data.repository.CrudRepository
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import Any, Protocol, TypeVar, cast
|
|
8
|
+
|
|
9
|
+
from sqlmodel import delete, select
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HasId(Protocol):
|
|
13
|
+
"""Protocol for entities with an id attribute."""
|
|
14
|
+
|
|
15
|
+
id: int | None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T", bound=HasId)
|
|
19
|
+
S = TypeVar("S")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BaseRepository[T, S](ABC):
|
|
23
|
+
"""Generic asynchronous repository base class for CRUD operations.
|
|
24
|
+
|
|
25
|
+
This abstract base class provides a set of common Create/Read/Update/Delete
|
|
26
|
+
operations for working with SQLModel/SQLAlchemy model objects in an
|
|
27
|
+
asynchronous context. It is intended to be subclassed for concrete model
|
|
28
|
+
repositories.
|
|
29
|
+
|
|
30
|
+
Type parameters
|
|
31
|
+
- T: The model/entity type managed by the repository. The model is expected
|
|
32
|
+
to have an integer "id" attribute (or a comparable primary key).
|
|
33
|
+
- S: The asynchronous session type (e.g., SQLAlchemy AsyncSession or a
|
|
34
|
+
compatible wrapper). The session must implement async methods used
|
|
35
|
+
below: add(), flush(), refresh(), execute(), get(), delete().
|
|
36
|
+
|
|
37
|
+
Design and behavior
|
|
38
|
+
- This class does NOT commit transactions. Methods call session.flush() and
|
|
39
|
+
session.refresh() where appropriate to synchronize in-memory objects with
|
|
40
|
+
the database session, but the caller is responsible for transaction
|
|
41
|
+
boundaries (commit/rollback) and session lifecycle.
|
|
42
|
+
- Methods operate asynchronously and must be awaited.
|
|
43
|
+
- Methods use SQLAlchemy select() and session.get() under the hood. The
|
|
44
|
+
session must support SQLAlchemy Core/ORM patterns for execution and retrieval.
|
|
45
|
+
- The default implementations favor correctness and clarity over bulk
|
|
46
|
+
performance: bulk operations (save_all, delete_all, etc.) iterate and
|
|
47
|
+
perform individual operations; subclasses should override these methods
|
|
48
|
+
to implement more efficient bulk SQL if needed.
|
|
49
|
+
|
|
50
|
+
Error handling
|
|
51
|
+
- ValueError is raised for invalid or missing IDs in update-like operations
|
|
52
|
+
and when an expected existing entity cannot be found during update.
|
|
53
|
+
- Other exceptions raised by the underlying session/engine (e.g., integrity
|
|
54
|
+
errors) are not swallowed—callers should handle DB errors and transaction
|
|
55
|
+
rollback as appropriate.
|
|
56
|
+
|
|
57
|
+
Subclassing example
|
|
58
|
+
class MyModelRepository(BaseRepository[MyModel, AsyncSession]):
|
|
59
|
+
def model_class(self) -> type[MyModel]:
|
|
60
|
+
return MyModel
|
|
61
|
+
|
|
62
|
+
Notes and recommendations
|
|
63
|
+
- For models with relationships or complex state, prefer using dedicated
|
|
64
|
+
merge/copy patterns or explicit field mapping rather than the simple
|
|
65
|
+
attribute copy used in update().
|
|
66
|
+
- For large batches or high-performance use cases, override save_all,
|
|
67
|
+
count, delete_all, and other methods to use efficient SQL bulk operations
|
|
68
|
+
(e.g., INSERT ... ON CONFLICT, bulk DELETE, or SELECT COUNT()).
|
|
69
|
+
- Keep transaction boundaries (commit/rollback) at a higher level (service
|
|
70
|
+
layer) rather than inside repository methods to allow grouping multiple
|
|
71
|
+
operations into a single transaction."""
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def model_class(self) -> type[T]:
|
|
76
|
+
"""Return the model class this repository manages."""
|
|
77
|
+
|
|
78
|
+
# Create/Update operations
|
|
79
|
+
async def create(self, session: S, entity: T) -> T:
|
|
80
|
+
"""Create a new entity."""
|
|
81
|
+
session.add(entity)
|
|
82
|
+
await session.flush()
|
|
83
|
+
await session.refresh(entity)
|
|
84
|
+
return entity
|
|
85
|
+
|
|
86
|
+
async def update(self, session: S, entity: T) -> T:
|
|
87
|
+
"""Update an existing entity."""
|
|
88
|
+
entity_id = getattr(entity, "id", None)
|
|
89
|
+
if entity_id is None:
|
|
90
|
+
raise ValueError("Entity must have an ID to be updated")
|
|
91
|
+
|
|
92
|
+
existing = await session.get(self.model_class, entity_id)
|
|
93
|
+
if not existing:
|
|
94
|
+
# For update, we expect the entity to exist
|
|
95
|
+
raise ValueError(f"Entity with id {entity_id} not found")
|
|
96
|
+
|
|
97
|
+
# Update existing - merge entity data into existing
|
|
98
|
+
for key, value in vars(entity).items():
|
|
99
|
+
if not key.startswith("_"):
|
|
100
|
+
setattr(existing, key, value)
|
|
101
|
+
session.add(existing)
|
|
102
|
+
await session.flush()
|
|
103
|
+
await session.refresh(existing)
|
|
104
|
+
return existing
|
|
105
|
+
|
|
106
|
+
async def save(self, session: S, entity: T) -> T:
|
|
107
|
+
"""Save (create or update) an entity.
|
|
108
|
+
|
|
109
|
+
If entity has an ID and exists, updates it.
|
|
110
|
+
Otherwise, creates a new entity.
|
|
111
|
+
"""
|
|
112
|
+
entity_id = getattr(entity, "id", None)
|
|
113
|
+
# Check if entity exists
|
|
114
|
+
if entity_id is not None and await self.exists_by_id(session, entity_id):
|
|
115
|
+
return await self.update(session, entity)
|
|
116
|
+
|
|
117
|
+
return await self.create(session, entity)
|
|
118
|
+
|
|
119
|
+
async def save_all(self, session: S, entities: list[T]) -> list[T]:
|
|
120
|
+
"""Save (create or update) multiple entities.
|
|
121
|
+
|
|
122
|
+
For each entity: if it has an ID and exists, updates it.
|
|
123
|
+
Otherwise, creates a new entity.
|
|
124
|
+
"""
|
|
125
|
+
# Separate entities with potentially existing IDs
|
|
126
|
+
ids_to_check = [e.id for e in entities if getattr(e, "id", None) is not None]
|
|
127
|
+
|
|
128
|
+
existing_map = {}
|
|
129
|
+
if ids_to_check:
|
|
130
|
+
found_entities = await self.find_all_by_ids(session, ids_to_check)
|
|
131
|
+
existing_map = {e.id: e for e in found_entities if e.id is not None}
|
|
132
|
+
|
|
133
|
+
results = []
|
|
134
|
+
for entity in entities:
|
|
135
|
+
entity_id = getattr(entity, "id", None)
|
|
136
|
+
|
|
137
|
+
# Update path
|
|
138
|
+
if entity_id is not None and entity_id in existing_map:
|
|
139
|
+
existing = existing_map[entity_id]
|
|
140
|
+
for key, value in vars(entity).items():
|
|
141
|
+
if not key.startswith("_"):
|
|
142
|
+
setattr(existing, key, value)
|
|
143
|
+
session.add(existing)
|
|
144
|
+
results.append(existing)
|
|
145
|
+
else:
|
|
146
|
+
# Create path
|
|
147
|
+
session.add(entity)
|
|
148
|
+
results.append(entity)
|
|
149
|
+
|
|
150
|
+
await session.flush()
|
|
151
|
+
for result in results:
|
|
152
|
+
await session.refresh(result)
|
|
153
|
+
return results
|
|
154
|
+
|
|
155
|
+
# Read operations
|
|
156
|
+
async def find_by_id(self, session: S, item_id: int) -> T | None:
|
|
157
|
+
"""Find an instance by ID."""
|
|
158
|
+
model_with_id = cast(Any, self.model_class)
|
|
159
|
+
result = await session.execute(
|
|
160
|
+
select(self.model_class).where(model_with_id.id == item_id)
|
|
161
|
+
)
|
|
162
|
+
return result.scalars().first()
|
|
163
|
+
|
|
164
|
+
async def find_all(self, session: S) -> list[T]:
|
|
165
|
+
"""Find all instances."""
|
|
166
|
+
result = await session.execute(select(self.model_class))
|
|
167
|
+
return list(result.scalars().all())
|
|
168
|
+
|
|
169
|
+
async def find_all_by_ids(self, session: S, ids: list[int]) -> list[T]:
|
|
170
|
+
"""Find all instances by IDs."""
|
|
171
|
+
model_with_id = cast(Any, self.model_class)
|
|
172
|
+
result = await session.execute(
|
|
173
|
+
select(self.model_class).where(model_with_id.id.in_(ids))
|
|
174
|
+
)
|
|
175
|
+
return list(result.scalars().all())
|
|
176
|
+
|
|
177
|
+
async def exists_by_id(self, session: S, item_id: int) -> bool:
|
|
178
|
+
"""Check if an instance exists by ID."""
|
|
179
|
+
model_with_id = cast(Any, self.model_class)
|
|
180
|
+
result = await session.execute(
|
|
181
|
+
select(self.model_class).where(model_with_id.id == item_id)
|
|
182
|
+
)
|
|
183
|
+
return result.scalars().first() is not None
|
|
184
|
+
|
|
185
|
+
async def count(self, session: S) -> int:
|
|
186
|
+
"""Count all instances."""
|
|
187
|
+
result = await session.execute(select(self.model_class))
|
|
188
|
+
return len(list(result.scalars().all()))
|
|
189
|
+
|
|
190
|
+
# Delete operations
|
|
191
|
+
async def delete_by_id(self, session: S, item_id: int) -> bool:
|
|
192
|
+
"""Delete an instance by ID."""
|
|
193
|
+
instance = await session.get(self.model_class, item_id)
|
|
194
|
+
if not instance:
|
|
195
|
+
return False
|
|
196
|
+
await session.delete(instance)
|
|
197
|
+
await session.flush()
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
async def delete(self, session: S, entity: T) -> bool:
|
|
201
|
+
"""Delete an entity."""
|
|
202
|
+
# Re-fetch to ensure we have the right instance in this session
|
|
203
|
+
item_id = getattr(entity, "id", None)
|
|
204
|
+
if item_id is None:
|
|
205
|
+
return False
|
|
206
|
+
instance = await session.get(self.model_class, item_id)
|
|
207
|
+
if not instance:
|
|
208
|
+
return False
|
|
209
|
+
await session.delete(instance)
|
|
210
|
+
await session.flush()
|
|
211
|
+
return True
|
|
212
|
+
|
|
213
|
+
async def delete_all(self, session: S) -> int:
|
|
214
|
+
"""Delete all instances. Returns count of deleted items."""
|
|
215
|
+
stmt = delete(self.model_class)
|
|
216
|
+
result = await session.execute(stmt)
|
|
217
|
+
await session.flush()
|
|
218
|
+
return result.rowcount
|
|
219
|
+
|
|
220
|
+
async def delete_all_by_ids(self, session: S, ids: list[int]) -> int:
|
|
221
|
+
"""Delete all instances by IDs. Returns count of deleted items."""
|
|
222
|
+
model_with_id = cast(Any, self.model_class)
|
|
223
|
+
stmt = delete(self.model_class).where(model_with_id.id.in_(ids))
|
|
224
|
+
result = await session.execute(stmt)
|
|
225
|
+
await session.flush()
|
|
226
|
+
return result.rowcount
|
|
@@ -8,10 +8,11 @@ appkit_commons/configuration/configuration.py,sha256=CTSZ-nHUz152t_FNbOqOcvvgT5A
|
|
|
8
8
|
appkit_commons/configuration/logging.py,sha256=zy1G9-wbxkP0JuatlK-sHRrEOklAiBf_PqQOZ-xA1kU,852
|
|
9
9
|
appkit_commons/configuration/secret_provider.py,sha256=082bD2AMXB-AZDKrCFuvSVCbdxI4jWLMW4ia4tnPFRU,2663
|
|
10
10
|
appkit_commons/configuration/yaml.py,sha256=X0Ra-3xqxtkyg_-4NVdIikq1_hh73RS_zSobxofC_1c,5914
|
|
11
|
+
appkit_commons/database/base_repository.py,sha256=Im28SxeEi5R8iwQ2iKkeRawFt71Q0ISNgxmJFkA_dX0,8985
|
|
11
12
|
appkit_commons/database/configuration.py,sha256=C8xcQk8OgwPviF10KhKNkX-aKZT_YgSynFbSfwXv14w,1505
|
|
12
13
|
appkit_commons/database/entities.py,sha256=lUJBUNWUqXjq0dFffqZObhgkmWYUG0lcCNlmM5fxHls,1807
|
|
13
14
|
appkit_commons/database/session.py,sha256=0P6qfae1ITmDGkKoDL4b5EnS1ZaDPH2OXn4y-RYhQv4,2253
|
|
14
15
|
appkit_commons/database/sessionmanager.py,sha256=fATEqMT1Ze0bM7oZXnC6HtzYrAH9kb5_LRP552nSS_8,1605
|
|
15
|
-
appkit_commons-0.
|
|
16
|
-
appkit_commons-0.
|
|
17
|
-
appkit_commons-0.
|
|
16
|
+
appkit_commons-0.15.4.dist-info/METADATA,sha256=2lr_cOumEiFLAlk5SpqIPGeYKxnAxk18wKmC-SpyeK8,9850
|
|
17
|
+
appkit_commons-0.15.4.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
18
|
+
appkit_commons-0.15.4.dist-info/RECORD,,
|
|
File without changes
|