appkit-commons 0.13.1__py3-none-any.whl → 0.16.2__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
@@ -36,6 +36,7 @@ def _get_engine_kwargs() -> dict[str, Any]:
36
36
  "pool_size": db_config.pool_size,
37
37
  "max_overflow": db_config.max_overflow,
38
38
  "echo": db_config.echo,
39
+ "pool_pre_ping": True,
39
40
  }
40
41
 
41
42
  return {}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: appkit-commons
3
- Version: 0.13.1
3
+ Version: 0.16.2
4
4
  Summary: Add your description here
5
5
  Project-URL: Homepage, https://github.com/jenreh/appkit
6
6
  Project-URL: Documentation, https://github.com/jenreh/appkit/tree/main/docs
@@ -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
- appkit_commons/database/session.py,sha256=0P6qfae1ITmDGkKoDL4b5EnS1ZaDPH2OXn4y-RYhQv4,2253
14
+ appkit_commons/database/session.py,sha256=8pamOvbz7Vh7x3HvYPysdreCiMPavEtDWmbv2XWrXw0,2288
14
15
  appkit_commons/database/sessionmanager.py,sha256=fATEqMT1Ze0bM7oZXnC6HtzYrAH9kb5_LRP552nSS_8,1605
15
- appkit_commons-0.13.1.dist-info/METADATA,sha256=irVVWBQuZaKPljulsxlGgeFx0O9YDcDtbs42Y1pzbds,9850
16
- appkit_commons-0.13.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
17
- appkit_commons-0.13.1.dist-info/RECORD,,
16
+ appkit_commons-0.16.2.dist-info/METADATA,sha256=MsGFY6sY64wYrjC-ZBjwaZ59EFkPA69vcAvmOtNdoDE,9850
17
+ appkit_commons-0.16.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
18
+ appkit_commons-0.16.2.dist-info/RECORD,,