coding-client 1.21.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.
- coding_client/__init__.py +1 -0
- coding_client/api/__init__.py +19 -0
- coding_client/api/exception_handlers.py +158 -0
- coding_client/api/logging_setup.py +44 -0
- coding_client/api/metrics.py +40 -0
- coding_client/api/openapi_config.py +124 -0
- coding_client/api/request_logging.py +60 -0
- coding_client/api/validation.py +150 -0
- coding_client/auth/__init__.py +78 -0
- coding_client/auth/_default_tenant.py +132 -0
- coding_client/auth/_header_trust.py +98 -0
- coding_client/auth/_onbehalf.py +315 -0
- coding_client/auth/_trusted_networks.py +94 -0
- coding_client/auth/dependencies.py +61 -0
- coding_client/auth/middleware.py +445 -0
- coding_client/auth/models.py +83 -0
- coding_client/auth/outbound.py +137 -0
- coding_client/auth/rbac.py +21 -0
- coding_client/auth/rbac_coding.py +5 -0
- coding_client/auth/tenant.py +650 -0
- coding_client/auth/websocket.py +148 -0
- coding_client/clients/__init__.py +24 -0
- coding_client/clients/base.py +131 -0
- coding_client/clients/coding_ai.py +33 -0
- coding_client/clients/coding_analysis.py +36 -0
- coding_client/clients/coding_containers.py +45 -0
- coding_client/clients/coding_github.py +127 -0
- coding_client/clients/coding_realtime.py +127 -0
- coding_client/clients/coding_security.py +51 -0
- coding_client/clients/coding_testing.py +31 -0
- coding_client/clients/coding_workspaces.py +265 -0
- coding_client/config/__init__.py +12 -0
- coding_client/config/base_settings.py +61 -0
- coding_client/config/yaml_config_loader.py +172 -0
- coding_client/db/__init__.py +42 -0
- coding_client/db/db_types.py +122 -0
- coding_client/db/exceptions.py +59 -0
- coding_client/db/postgres_connection.py +572 -0
- coding_client/db/tenant_query_helper.py +417 -0
- coding_client/db_init.py +49 -0
- coding_client/exceptions/__init__.py +67 -0
- coding_client/exceptions/api_exceptions.py +395 -0
- coding_client/exceptions/pydantic_ai_exceptions.py +533 -0
- coding_client/interfaces/__init__.py +22 -0
- coding_client/interfaces/cache_interface.py +179 -0
- coding_client/interfaces/container_interface.py +49 -0
- coding_client/interfaces/docker_interface.py +283 -0
- coding_client/interfaces/graph_interface.py +28 -0
- coding_client/interfaces/security_interface.py +72 -0
- coding_client/interfaces/webhook_interface.py +67 -0
- coding_client/llm/__init__.py +54 -0
- coding_client/llm/base_agent.py +195 -0
- coding_client/llm/context.py +121 -0
- coding_client/llm/responses.py +102 -0
- coding_client/llm/routing.py +82 -0
- coding_client/llm/settings.py +102 -0
- coding_client/models/__init__.py +62 -0
- coding_client/models/agent.py +38 -0
- coding_client/models/ai_test_failure.py +745 -0
- coding_client/models/analysis.py +330 -0
- coding_client/models/architecture.py +685 -0
- coding_client/models/bug.py +128 -0
- coding_client/models/code_graph.py +317 -0
- coding_client/models/container.py +241 -0
- coding_client/models/core.py +878 -0
- coding_client/models/docker_container.py +104 -0
- coding_client/models/github.py +293 -0
- coding_client/models/logging.py +29 -0
- coding_client/models/package_manager.py +257 -0
- coding_client/models/pr_validation.py +440 -0
- coding_client/models/pydantic_ai.py +395 -0
- coding_client/models/repo_scanner.py +249 -0
- coding_client/models/responses.py +431 -0
- coding_client/models/security.py +185 -0
- coding_client/models/static_analysis.py +508 -0
- coding_client/models/system.py +48 -0
- coding_client/models/test_executor.py +204 -0
- coding_client/models/testing.py +673 -0
- coding_client/models/testing_config.py +200 -0
- coding_client/models/validation.py +127 -0
- coding_client/models_init.py +272 -0
- coding_client/telemetry.py +195 -0
- coding_client/testing.py +180 -0
- coding_client/types/__init__.py +130 -0
- coding_client/types/constraint_types.py +276 -0
- coding_client/types/core_types.py +112 -0
- coding_client/types/state_machine.py +443 -0
- coding_client/utils/__init__.py +86 -0
- coding_client/utils/archive_utils.py +387 -0
- coding_client/utils/ast_debug.py +45 -0
- coding_client/utils/async_timeout.py +364 -0
- coding_client/utils/constants.py +518 -0
- coding_client/utils/content_processing.py +326 -0
- coding_client/utils/database.py +916 -0
- coding_client/utils/encoding_helper.py +222 -0
- coding_client/utils/enum_helpers.py +166 -0
- coding_client/utils/file_operations.py +177 -0
- coding_client/utils/filesystem_ops.py +528 -0
- coding_client/utils/http_client.py +562 -0
- coding_client/utils/path_utils.py +471 -0
- coding_client/utils/profiling.py +421 -0
- coding_client/utils/response_builder.py +240 -0
- coding_client/utils/retry_helper.py +712 -0
- coding_client/utils/security_validation.py +423 -0
- coding_client-1.21.2.dist-info/METADATA +234 -0
- coding_client-1.21.2.dist-info/RECORD +109 -0
- coding_client-1.21.2.dist-info/WHEEL +5 -0
- coding_client-1.21.2.dist-info/licenses/LICENSE +201 -0
- coding_client-1.21.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
"""Database abstraction layer for common database operations.
|
|
2
|
+
|
|
3
|
+
This module provides reusable database utilities and patterns to reduce
|
|
4
|
+
code duplication across services and repositories.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
import logging
|
|
11
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
12
|
+
from functools import wraps
|
|
13
|
+
from typing import (
|
|
14
|
+
Any,
|
|
15
|
+
TypeVar,
|
|
16
|
+
cast,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
from sqlalchemy import and_, func, select, update
|
|
21
|
+
from sqlalchemy.exc import DataError, IntegrityError, OperationalError
|
|
22
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
23
|
+
from sqlalchemy.orm import Query, Session
|
|
24
|
+
from sqlalchemy.sql import Select
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Type variables for generic operations
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
ModelType = TypeVar("ModelType")
|
|
31
|
+
SchemaType = TypeVar("SchemaType", bound=BaseModel)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DatabaseError(Exception):
|
|
35
|
+
"""Base exception for database operations."""
|
|
36
|
+
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RecordNotFoundError(DatabaseError):
|
|
41
|
+
"""Raised when a requested record is not found."""
|
|
42
|
+
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DuplicateRecordError(DatabaseError):
|
|
47
|
+
"""Raised when trying to create a duplicate record."""
|
|
48
|
+
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DatabaseOperationError(DatabaseError):
|
|
53
|
+
"""Raised when a database operation fails."""
|
|
54
|
+
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class BaseDatabaseOperations:
|
|
59
|
+
"""Base class for common database operations."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, model: type[ModelType]):
|
|
62
|
+
"""Initialize with a SQLAlchemy model.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
model: SQLAlchemy model class
|
|
66
|
+
"""
|
|
67
|
+
self.model = model
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def handle_database_error(func):
|
|
71
|
+
"""Decorator to handle common database errors."""
|
|
72
|
+
|
|
73
|
+
@wraps(func)
|
|
74
|
+
def wrapper(*args, **kwargs):
|
|
75
|
+
try:
|
|
76
|
+
return func(*args, **kwargs)
|
|
77
|
+
except IntegrityError as e:
|
|
78
|
+
logger.error(f"Database integrity error: {e}")
|
|
79
|
+
if "UNIQUE constraint" in str(e) or "duplicate key" in str(e).lower():
|
|
80
|
+
raise DuplicateRecordError("Record already exists") from e
|
|
81
|
+
raise DatabaseOperationError(f"Database integrity error: {e}") from e
|
|
82
|
+
except DataError as e:
|
|
83
|
+
logger.error(f"Database data error: {e}")
|
|
84
|
+
raise DatabaseOperationError(f"Invalid data: {e}") from e
|
|
85
|
+
except OperationalError as e:
|
|
86
|
+
logger.error(f"Database operational error: {e}")
|
|
87
|
+
raise DatabaseOperationError(f"Database operation failed: {e}") from e
|
|
88
|
+
except Exception as e:
|
|
89
|
+
logger.error(f"Unexpected database error: {e}")
|
|
90
|
+
raise DatabaseError(f"Unexpected error: {e}") from e
|
|
91
|
+
|
|
92
|
+
@wraps(func)
|
|
93
|
+
async def async_wrapper(*args, **kwargs):
|
|
94
|
+
try:
|
|
95
|
+
return await func(*args, **kwargs)
|
|
96
|
+
except IntegrityError as e:
|
|
97
|
+
logger.error(f"Database integrity error: {e}")
|
|
98
|
+
if "UNIQUE constraint" in str(e) or "duplicate key" in str(e).lower():
|
|
99
|
+
raise DuplicateRecordError("Record already exists") from e
|
|
100
|
+
raise DatabaseOperationError(f"Database integrity error: {e}") from e
|
|
101
|
+
except DataError as e:
|
|
102
|
+
logger.error(f"Database data error: {e}")
|
|
103
|
+
raise DatabaseOperationError(f"Invalid data: {e}") from e
|
|
104
|
+
except OperationalError as e:
|
|
105
|
+
logger.error(f"Database operational error: {e}")
|
|
106
|
+
raise DatabaseOperationError(f"Database operation failed: {e}") from e
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.error(f"Unexpected database error: {e}")
|
|
109
|
+
raise DatabaseError(f"Unexpected error: {e}") from e
|
|
110
|
+
|
|
111
|
+
if inspect.iscoroutinefunction(func):
|
|
112
|
+
return async_wrapper
|
|
113
|
+
return wrapper
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class SyncDatabaseOperations[ModelType](BaseDatabaseOperations):
|
|
117
|
+
"""Synchronous database operations."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, model: type[ModelType], session: Session):
|
|
120
|
+
"""Initialize with model and session.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
model: SQLAlchemy model class
|
|
124
|
+
session: SQLAlchemy session
|
|
125
|
+
"""
|
|
126
|
+
super().__init__(model)
|
|
127
|
+
self.session = session
|
|
128
|
+
|
|
129
|
+
@BaseDatabaseOperations.handle_database_error
|
|
130
|
+
def get_by_id(self, id: Any) -> ModelType | None:
|
|
131
|
+
"""Get a record by ID.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
id: Record ID
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Model instance or None if not found
|
|
138
|
+
"""
|
|
139
|
+
return cast(
|
|
140
|
+
ModelType | None,
|
|
141
|
+
self.session.query(self.model).filter(getattr(self.model, "id") == id).first(),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def get_by_id_or_404(self, id: Any) -> ModelType:
|
|
145
|
+
"""Get a record by ID or raise RecordNotFoundError.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
id: Record ID
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Model instance
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
RecordNotFoundError: If record not found
|
|
155
|
+
"""
|
|
156
|
+
record = self.get_by_id(id)
|
|
157
|
+
if not record:
|
|
158
|
+
raise RecordNotFoundError(f"{self.model.__name__} with id {id} not found")
|
|
159
|
+
return record
|
|
160
|
+
|
|
161
|
+
@BaseDatabaseOperations.handle_database_error
|
|
162
|
+
def get_all(
|
|
163
|
+
self,
|
|
164
|
+
filters: dict[str, Any] | None = None,
|
|
165
|
+
order_by: str | None = None,
|
|
166
|
+
limit: int | None = None,
|
|
167
|
+
offset: int | None = None,
|
|
168
|
+
) -> list[ModelType]:
|
|
169
|
+
"""Get all records with optional filtering and pagination.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
filters: Dictionary of field:value filters
|
|
173
|
+
order_by: Field name to order by (prefix with - for DESC)
|
|
174
|
+
limit: Maximum number of records
|
|
175
|
+
offset: Number of records to skip
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
List of model instances
|
|
179
|
+
"""
|
|
180
|
+
query = self.session.query(self.model)
|
|
181
|
+
|
|
182
|
+
# Apply filters
|
|
183
|
+
if filters:
|
|
184
|
+
for field, value in filters.items():
|
|
185
|
+
if hasattr(self.model, field):
|
|
186
|
+
if isinstance(value, list):
|
|
187
|
+
query = query.filter(getattr(self.model, field).in_(value))
|
|
188
|
+
else:
|
|
189
|
+
query = query.filter(getattr(self.model, field) == value)
|
|
190
|
+
|
|
191
|
+
# Apply ordering
|
|
192
|
+
if order_by:
|
|
193
|
+
if order_by.startswith("-"):
|
|
194
|
+
field = order_by[1:]
|
|
195
|
+
if hasattr(self.model, field):
|
|
196
|
+
query = query.order_by(getattr(self.model, field).desc())
|
|
197
|
+
else:
|
|
198
|
+
if hasattr(self.model, order_by):
|
|
199
|
+
query = query.order_by(getattr(self.model, order_by))
|
|
200
|
+
|
|
201
|
+
# Apply pagination
|
|
202
|
+
if offset:
|
|
203
|
+
query = query.offset(offset)
|
|
204
|
+
if limit:
|
|
205
|
+
query = query.limit(limit)
|
|
206
|
+
|
|
207
|
+
return cast(list[ModelType], query.all())
|
|
208
|
+
|
|
209
|
+
@BaseDatabaseOperations.handle_database_error
|
|
210
|
+
def get_one(self, **kwargs: Any) -> ModelType | None:
|
|
211
|
+
"""Get a single record by field values.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
**kwargs: Field:value pairs to filter by
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Model instance or None if not found
|
|
218
|
+
"""
|
|
219
|
+
return cast(
|
|
220
|
+
ModelType | None,
|
|
221
|
+
self.session.query(self.model).filter_by(**kwargs).first(),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def get_one_or_404(self, **kwargs: Any) -> ModelType:
|
|
225
|
+
"""Get a single record by field values or raise RecordNotFoundError.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
**kwargs: Field:value pairs to filter by
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Model instance
|
|
232
|
+
|
|
233
|
+
Raises:
|
|
234
|
+
RecordNotFoundError: If record not found
|
|
235
|
+
"""
|
|
236
|
+
record = self.get_one(**kwargs)
|
|
237
|
+
if not record:
|
|
238
|
+
raise RecordNotFoundError(f"{self.model.__name__} not found with filters: {kwargs}")
|
|
239
|
+
return record
|
|
240
|
+
|
|
241
|
+
@BaseDatabaseOperations.handle_database_error
|
|
242
|
+
def create(self, data: dict[str, Any] | BaseModel, commit: bool = True) -> ModelType:
|
|
243
|
+
"""Create a new record.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
data: Dictionary or Pydantic schema with record data
|
|
247
|
+
commit: Whether to commit the transaction
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Created model instance
|
|
251
|
+
"""
|
|
252
|
+
data_dict: dict[str, Any]
|
|
253
|
+
if isinstance(data, BaseModel):
|
|
254
|
+
data_dict = data.model_dump(exclude_unset=True)
|
|
255
|
+
else:
|
|
256
|
+
data_dict = data
|
|
257
|
+
|
|
258
|
+
instance = cast(ModelType, self.model(**data_dict))
|
|
259
|
+
self.session.add(instance)
|
|
260
|
+
|
|
261
|
+
if commit:
|
|
262
|
+
self.session.commit()
|
|
263
|
+
self.session.refresh(instance)
|
|
264
|
+
|
|
265
|
+
return instance
|
|
266
|
+
|
|
267
|
+
@BaseDatabaseOperations.handle_database_error
|
|
268
|
+
def bulk_create(
|
|
269
|
+
self, data_list: list[dict[str, Any] | BaseModel], commit: bool = True
|
|
270
|
+
) -> list[ModelType]:
|
|
271
|
+
"""Create multiple records.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
data_list: List of dictionaries or Pydantic schemas
|
|
275
|
+
commit: Whether to commit the transaction
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
List of created model instances
|
|
279
|
+
"""
|
|
280
|
+
instances: list[ModelType] = []
|
|
281
|
+
for data in data_list:
|
|
282
|
+
data_dict: dict[str, Any]
|
|
283
|
+
if isinstance(data, BaseModel):
|
|
284
|
+
data_dict = data.model_dump(exclude_unset=True)
|
|
285
|
+
else:
|
|
286
|
+
data_dict = data
|
|
287
|
+
instance = cast(ModelType, self.model(**data_dict))
|
|
288
|
+
instances.append(instance)
|
|
289
|
+
self.session.add(instance)
|
|
290
|
+
|
|
291
|
+
if commit:
|
|
292
|
+
self.session.commit()
|
|
293
|
+
for instance in instances:
|
|
294
|
+
self.session.refresh(instance)
|
|
295
|
+
|
|
296
|
+
return instances
|
|
297
|
+
|
|
298
|
+
@BaseDatabaseOperations.handle_database_error
|
|
299
|
+
def update(
|
|
300
|
+
self, id: Any, data: dict[str, Any] | BaseModel, commit: bool = True
|
|
301
|
+
) -> ModelType | None:
|
|
302
|
+
"""Update a record by ID.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
id: Record ID
|
|
306
|
+
data: Dictionary or Pydantic schema with update data
|
|
307
|
+
commit: Whether to commit the transaction
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Updated model instance or None if not found
|
|
311
|
+
"""
|
|
312
|
+
instance = self.get_by_id(id)
|
|
313
|
+
if not instance:
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
data_dict: dict[str, Any]
|
|
317
|
+
if isinstance(data, BaseModel):
|
|
318
|
+
data_dict = data.model_dump(exclude_unset=True)
|
|
319
|
+
else:
|
|
320
|
+
data_dict = data
|
|
321
|
+
|
|
322
|
+
for key, value in data_dict.items():
|
|
323
|
+
if hasattr(instance, key):
|
|
324
|
+
setattr(instance, key, value)
|
|
325
|
+
|
|
326
|
+
if commit:
|
|
327
|
+
self.session.commit()
|
|
328
|
+
self.session.refresh(instance)
|
|
329
|
+
|
|
330
|
+
return instance
|
|
331
|
+
|
|
332
|
+
@BaseDatabaseOperations.handle_database_error
|
|
333
|
+
def update_or_create(
|
|
334
|
+
self, defaults: dict[str, Any], commit: bool = True, **kwargs: Any
|
|
335
|
+
) -> tuple[ModelType, bool]:
|
|
336
|
+
"""Update existing record or create new one.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
defaults: Dictionary of default values for creation/update
|
|
340
|
+
commit: Whether to commit the transaction
|
|
341
|
+
**kwargs: Fields to search by
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
Tuple of (instance, created) where created is True if a new record was created
|
|
345
|
+
"""
|
|
346
|
+
existing = self.get_one(**kwargs)
|
|
347
|
+
created = False
|
|
348
|
+
|
|
349
|
+
if existing:
|
|
350
|
+
# Update existing
|
|
351
|
+
for key, value in defaults.items():
|
|
352
|
+
if hasattr(existing, key):
|
|
353
|
+
setattr(existing, key, value)
|
|
354
|
+
instance = existing
|
|
355
|
+
else:
|
|
356
|
+
# Create new
|
|
357
|
+
data = {**kwargs, **defaults}
|
|
358
|
+
instance = cast(ModelType, self.model(**data))
|
|
359
|
+
self.session.add(instance)
|
|
360
|
+
created = True
|
|
361
|
+
|
|
362
|
+
if commit:
|
|
363
|
+
self.session.commit()
|
|
364
|
+
self.session.refresh(instance)
|
|
365
|
+
|
|
366
|
+
return instance, created
|
|
367
|
+
|
|
368
|
+
@BaseDatabaseOperations.handle_database_error
|
|
369
|
+
def delete(self, id: Any, commit: bool = True) -> bool:
|
|
370
|
+
"""Delete a record by ID.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
id: Record ID
|
|
374
|
+
commit: Whether to commit the transaction
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
True if deleted, False if not found
|
|
378
|
+
"""
|
|
379
|
+
instance = self.get_by_id(id)
|
|
380
|
+
if not instance:
|
|
381
|
+
return False
|
|
382
|
+
|
|
383
|
+
self.session.delete(instance)
|
|
384
|
+
|
|
385
|
+
if commit:
|
|
386
|
+
self.session.commit()
|
|
387
|
+
|
|
388
|
+
return True
|
|
389
|
+
|
|
390
|
+
@BaseDatabaseOperations.handle_database_error
|
|
391
|
+
def delete_many(self, filters: dict[str, Any], commit: bool = True) -> int:
|
|
392
|
+
"""Delete multiple records matching filters.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
filters: Dictionary of field:value filters
|
|
396
|
+
commit: Whether to commit the transaction
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
Number of deleted records
|
|
400
|
+
"""
|
|
401
|
+
query = self.session.query(self.model)
|
|
402
|
+
|
|
403
|
+
for field, value in filters.items():
|
|
404
|
+
if hasattr(self.model, field):
|
|
405
|
+
if isinstance(value, list):
|
|
406
|
+
query = query.filter(getattr(self.model, field).in_(value))
|
|
407
|
+
else:
|
|
408
|
+
query = query.filter(getattr(self.model, field) == value)
|
|
409
|
+
|
|
410
|
+
count = query.count()
|
|
411
|
+
query.delete()
|
|
412
|
+
|
|
413
|
+
if commit:
|
|
414
|
+
self.session.commit()
|
|
415
|
+
|
|
416
|
+
return count
|
|
417
|
+
|
|
418
|
+
@BaseDatabaseOperations.handle_database_error
|
|
419
|
+
def count(self, filters: dict[str, Any] | None = None) -> int:
|
|
420
|
+
"""Count records with optional filtering.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
filters: Dictionary of field:value filters
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
Count of matching records
|
|
427
|
+
"""
|
|
428
|
+
query = self.session.query(func.count(getattr(self.model, "id")))
|
|
429
|
+
|
|
430
|
+
if filters:
|
|
431
|
+
for field, value in filters.items():
|
|
432
|
+
if hasattr(self.model, field):
|
|
433
|
+
if isinstance(value, list):
|
|
434
|
+
query = query.filter(getattr(self.model, field).in_(value))
|
|
435
|
+
else:
|
|
436
|
+
query = query.filter(getattr(self.model, field) == value)
|
|
437
|
+
|
|
438
|
+
result = query.scalar()
|
|
439
|
+
return result if result is not None else 0
|
|
440
|
+
|
|
441
|
+
@BaseDatabaseOperations.handle_database_error
|
|
442
|
+
def exists(self, **kwargs: Any) -> bool:
|
|
443
|
+
"""Check if a record exists.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
**kwargs: Field:value pairs to filter by
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
True if exists, False otherwise
|
|
450
|
+
"""
|
|
451
|
+
return self.session.query(
|
|
452
|
+
self.session.query(self.model).filter_by(**kwargs).exists()
|
|
453
|
+
).scalar()
|
|
454
|
+
|
|
455
|
+
def transaction(self):
|
|
456
|
+
"""Context manager for database transactions."""
|
|
457
|
+
return database_transaction(self.session)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class AsyncDatabaseOperations[ModelType](BaseDatabaseOperations):
|
|
461
|
+
"""Asynchronous database operations."""
|
|
462
|
+
|
|
463
|
+
def __init__(self, model: type[ModelType], session: AsyncSession):
|
|
464
|
+
"""Initialize with model and async session.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
model: SQLAlchemy model class
|
|
468
|
+
session: SQLAlchemy async session
|
|
469
|
+
"""
|
|
470
|
+
super().__init__(model)
|
|
471
|
+
self.session = session
|
|
472
|
+
|
|
473
|
+
@BaseDatabaseOperations.handle_database_error
|
|
474
|
+
async def get_by_id(self, id: Any) -> ModelType | None:
|
|
475
|
+
"""Get a record by ID.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
id: Record ID
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
Model instance or None if not found
|
|
482
|
+
"""
|
|
483
|
+
result = await self.session.execute(
|
|
484
|
+
select(self.model).where(getattr(self.model, "id") == id)
|
|
485
|
+
)
|
|
486
|
+
return cast(ModelType | None, result.scalar_one_or_none())
|
|
487
|
+
|
|
488
|
+
@BaseDatabaseOperations.handle_database_error
|
|
489
|
+
async def get_by_id_or_404(self, id: Any) -> ModelType:
|
|
490
|
+
"""Get a record by ID or raise RecordNotFoundError.
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
id: Record ID
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
Model instance
|
|
497
|
+
|
|
498
|
+
Raises:
|
|
499
|
+
RecordNotFoundError: If record not found
|
|
500
|
+
"""
|
|
501
|
+
record = await self.get_by_id(id)
|
|
502
|
+
if not record:
|
|
503
|
+
raise RecordNotFoundError(f"{self.model.__name__} with id {id} not found")
|
|
504
|
+
return record
|
|
505
|
+
|
|
506
|
+
@BaseDatabaseOperations.handle_database_error
|
|
507
|
+
async def get_all(
|
|
508
|
+
self,
|
|
509
|
+
filters: dict[str, Any] | None = None,
|
|
510
|
+
order_by: str | None = None,
|
|
511
|
+
limit: int | None = None,
|
|
512
|
+
offset: int | None = None,
|
|
513
|
+
) -> list[ModelType]:
|
|
514
|
+
"""Get all records with optional filtering and pagination.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
filters: Dictionary of field:value filters
|
|
518
|
+
order_by: Field name to order by (prefix with - for DESC)
|
|
519
|
+
limit: Maximum number of records
|
|
520
|
+
offset: Number of records to skip
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
List of model instances
|
|
524
|
+
"""
|
|
525
|
+
query = select(self.model)
|
|
526
|
+
|
|
527
|
+
# Apply filters
|
|
528
|
+
if filters:
|
|
529
|
+
conditions = []
|
|
530
|
+
for field, value in filters.items():
|
|
531
|
+
if hasattr(self.model, field):
|
|
532
|
+
if isinstance(value, list):
|
|
533
|
+
conditions.append(getattr(self.model, field).in_(value))
|
|
534
|
+
else:
|
|
535
|
+
conditions.append(getattr(self.model, field) == value)
|
|
536
|
+
if conditions:
|
|
537
|
+
query = query.where(and_(*conditions))
|
|
538
|
+
|
|
539
|
+
# Apply ordering
|
|
540
|
+
if order_by:
|
|
541
|
+
if order_by.startswith("-"):
|
|
542
|
+
field = order_by[1:]
|
|
543
|
+
if hasattr(self.model, field):
|
|
544
|
+
query = query.order_by(getattr(self.model, field).desc())
|
|
545
|
+
else:
|
|
546
|
+
if hasattr(self.model, order_by):
|
|
547
|
+
query = query.order_by(getattr(self.model, order_by))
|
|
548
|
+
|
|
549
|
+
# Apply pagination
|
|
550
|
+
if offset:
|
|
551
|
+
query = query.offset(offset)
|
|
552
|
+
if limit:
|
|
553
|
+
query = query.limit(limit)
|
|
554
|
+
|
|
555
|
+
result = await self.session.execute(query)
|
|
556
|
+
return cast(list[ModelType], list(result.scalars().all()))
|
|
557
|
+
|
|
558
|
+
@BaseDatabaseOperations.handle_database_error
|
|
559
|
+
async def get_one(self, **kwargs: Any) -> ModelType | None:
|
|
560
|
+
"""Get a single record by field values.
|
|
561
|
+
|
|
562
|
+
Args:
|
|
563
|
+
**kwargs: Field:value pairs to filter by
|
|
564
|
+
|
|
565
|
+
Returns:
|
|
566
|
+
Model instance or None if not found
|
|
567
|
+
"""
|
|
568
|
+
conditions = []
|
|
569
|
+
for field, value in kwargs.items():
|
|
570
|
+
if hasattr(self.model, field):
|
|
571
|
+
conditions.append(getattr(self.model, field) == value)
|
|
572
|
+
|
|
573
|
+
if not conditions:
|
|
574
|
+
return None
|
|
575
|
+
|
|
576
|
+
query = select(self.model).where(and_(*conditions))
|
|
577
|
+
result = await self.session.execute(query)
|
|
578
|
+
return cast(ModelType | None, result.scalar_one_or_none())
|
|
579
|
+
|
|
580
|
+
async def get_one_or_404(self, **kwargs: Any) -> ModelType:
|
|
581
|
+
"""Get a single record by field values or raise RecordNotFoundError.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
**kwargs: Field:value pairs to filter by
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
Model instance
|
|
588
|
+
|
|
589
|
+
Raises:
|
|
590
|
+
RecordNotFoundError: If record not found
|
|
591
|
+
"""
|
|
592
|
+
record = await self.get_one(**kwargs)
|
|
593
|
+
if not record:
|
|
594
|
+
raise RecordNotFoundError(f"{self.model.__name__} not found with filters: {kwargs}")
|
|
595
|
+
return record
|
|
596
|
+
|
|
597
|
+
@BaseDatabaseOperations.handle_database_error
|
|
598
|
+
async def create(self, data: dict[str, Any] | BaseModel, commit: bool = True) -> ModelType:
|
|
599
|
+
"""Create a new record.
|
|
600
|
+
|
|
601
|
+
Args:
|
|
602
|
+
data: Dictionary or Pydantic schema with record data
|
|
603
|
+
commit: Whether to commit the transaction
|
|
604
|
+
|
|
605
|
+
Returns:
|
|
606
|
+
Created model instance
|
|
607
|
+
"""
|
|
608
|
+
data_dict: dict[str, Any]
|
|
609
|
+
if isinstance(data, BaseModel):
|
|
610
|
+
data_dict = data.model_dump(exclude_unset=True)
|
|
611
|
+
else:
|
|
612
|
+
data_dict = data
|
|
613
|
+
|
|
614
|
+
instance = cast(ModelType, self.model(**data_dict))
|
|
615
|
+
self.session.add(instance)
|
|
616
|
+
|
|
617
|
+
if commit:
|
|
618
|
+
await self.session.commit()
|
|
619
|
+
await self.session.refresh(instance)
|
|
620
|
+
|
|
621
|
+
return instance
|
|
622
|
+
|
|
623
|
+
@BaseDatabaseOperations.handle_database_error
|
|
624
|
+
async def update(
|
|
625
|
+
self, id: Any, data: dict[str, Any] | BaseModel, commit: bool = True
|
|
626
|
+
) -> ModelType | None:
|
|
627
|
+
"""Update a record by ID.
|
|
628
|
+
|
|
629
|
+
Args:
|
|
630
|
+
id: Record ID
|
|
631
|
+
data: Dictionary or Pydantic schema with update data
|
|
632
|
+
commit: Whether to commit the transaction
|
|
633
|
+
|
|
634
|
+
Returns:
|
|
635
|
+
Updated model instance or None if not found
|
|
636
|
+
"""
|
|
637
|
+
instance = await self.get_by_id(id)
|
|
638
|
+
if not instance:
|
|
639
|
+
return None
|
|
640
|
+
|
|
641
|
+
data_dict: dict[str, Any]
|
|
642
|
+
if isinstance(data, BaseModel):
|
|
643
|
+
data_dict = data.model_dump(exclude_unset=True)
|
|
644
|
+
else:
|
|
645
|
+
data_dict = data
|
|
646
|
+
|
|
647
|
+
for key, value in data_dict.items():
|
|
648
|
+
if hasattr(instance, key):
|
|
649
|
+
setattr(instance, key, value)
|
|
650
|
+
|
|
651
|
+
if commit:
|
|
652
|
+
await self.session.commit()
|
|
653
|
+
await self.session.refresh(instance)
|
|
654
|
+
|
|
655
|
+
return instance
|
|
656
|
+
|
|
657
|
+
@BaseDatabaseOperations.handle_database_error
|
|
658
|
+
async def delete(self, id: Any, commit: bool = True) -> bool:
|
|
659
|
+
"""Delete a record by ID.
|
|
660
|
+
|
|
661
|
+
Args:
|
|
662
|
+
id: Record ID
|
|
663
|
+
commit: Whether to commit the transaction
|
|
664
|
+
|
|
665
|
+
Returns:
|
|
666
|
+
True if deleted, False if not found
|
|
667
|
+
"""
|
|
668
|
+
instance = await self.get_by_id(id)
|
|
669
|
+
if not instance:
|
|
670
|
+
return False
|
|
671
|
+
|
|
672
|
+
await self.session.delete(instance)
|
|
673
|
+
|
|
674
|
+
if commit:
|
|
675
|
+
await self.session.commit()
|
|
676
|
+
|
|
677
|
+
return True
|
|
678
|
+
|
|
679
|
+
async def transaction(self):
|
|
680
|
+
"""Context manager for async database transactions."""
|
|
681
|
+
return async_database_transaction(self.session)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# Transaction context managers
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
@contextmanager
|
|
688
|
+
def database_transaction(session: Session):
|
|
689
|
+
"""Context manager for database transactions.
|
|
690
|
+
|
|
691
|
+
Usage:
|
|
692
|
+
with database_transaction(session) as trans:
|
|
693
|
+
# Perform operations
|
|
694
|
+
trans.commit() # Explicit commit
|
|
695
|
+
# Auto-rollback if no commit
|
|
696
|
+
"""
|
|
697
|
+
try:
|
|
698
|
+
yield session
|
|
699
|
+
except Exception:
|
|
700
|
+
session.rollback()
|
|
701
|
+
raise
|
|
702
|
+
else:
|
|
703
|
+
# If no explicit commit was called, rollback
|
|
704
|
+
if session.new or session.dirty or session.deleted:
|
|
705
|
+
session.rollback()
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
@asynccontextmanager
|
|
709
|
+
async def async_database_transaction(session: AsyncSession):
|
|
710
|
+
"""Async context manager for database transactions.
|
|
711
|
+
|
|
712
|
+
Usage:
|
|
713
|
+
async with async_database_transaction(session) as trans:
|
|
714
|
+
# Perform operations
|
|
715
|
+
await trans.commit() # Explicit commit
|
|
716
|
+
# Auto-rollback if no commit
|
|
717
|
+
"""
|
|
718
|
+
try:
|
|
719
|
+
yield session
|
|
720
|
+
except Exception:
|
|
721
|
+
await session.rollback()
|
|
722
|
+
raise
|
|
723
|
+
else:
|
|
724
|
+
# If no explicit commit was called, rollback
|
|
725
|
+
if session.new or session.dirty or session.deleted:
|
|
726
|
+
await session.rollback()
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
# Utility functions
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def paginate_query(
|
|
733
|
+
query: Query, page: int = 1, per_page: int = 20, max_per_page: int = 100
|
|
734
|
+
) -> dict[str, Any]:
|
|
735
|
+
"""Add pagination to a query and return paginated results.
|
|
736
|
+
|
|
737
|
+
Args:
|
|
738
|
+
query: SQLAlchemy query
|
|
739
|
+
page: Page number (1-indexed)
|
|
740
|
+
per_page: Items per page
|
|
741
|
+
max_per_page: Maximum allowed items per page
|
|
742
|
+
|
|
743
|
+
Returns:
|
|
744
|
+
Dictionary with items, total, page, per_page, pages
|
|
745
|
+
"""
|
|
746
|
+
per_page = min(per_page, max_per_page)
|
|
747
|
+
page = max(1, page)
|
|
748
|
+
|
|
749
|
+
total = query.count()
|
|
750
|
+
items = query.limit(per_page).offset((page - 1) * per_page).all()
|
|
751
|
+
|
|
752
|
+
return {
|
|
753
|
+
"items": items,
|
|
754
|
+
"total": total,
|
|
755
|
+
"page": page,
|
|
756
|
+
"per_page": per_page,
|
|
757
|
+
"pages": (total + per_page - 1) // per_page, # Ceiling division
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
async def async_paginate_query(
|
|
762
|
+
session: AsyncSession,
|
|
763
|
+
query: Select[Any],
|
|
764
|
+
model: type[Any],
|
|
765
|
+
page: int = 1,
|
|
766
|
+
per_page: int = 20,
|
|
767
|
+
max_per_page: int = 100,
|
|
768
|
+
) -> dict[str, Any]:
|
|
769
|
+
"""Add pagination to an async query and return paginated results.
|
|
770
|
+
|
|
771
|
+
Args:
|
|
772
|
+
session: Async SQLAlchemy session
|
|
773
|
+
query: SQLAlchemy select statement
|
|
774
|
+
model: Model class for counting
|
|
775
|
+
page: Page number (1-indexed)
|
|
776
|
+
per_page: Items per page
|
|
777
|
+
max_per_page: Maximum allowed items per page
|
|
778
|
+
|
|
779
|
+
Returns:
|
|
780
|
+
Dictionary with items, total, page, per_page, pages
|
|
781
|
+
"""
|
|
782
|
+
per_page = min(per_page, max_per_page)
|
|
783
|
+
page = max(1, page)
|
|
784
|
+
|
|
785
|
+
# Get total count
|
|
786
|
+
count_query = select(func.count()).select_from(model)
|
|
787
|
+
# Apply same where conditions as main query
|
|
788
|
+
whereclause = getattr(query, "whereclause", None)
|
|
789
|
+
if whereclause is not None:
|
|
790
|
+
count_query = count_query.where(whereclause)
|
|
791
|
+
|
|
792
|
+
total_result = await session.execute(count_query)
|
|
793
|
+
total = total_result.scalar() or 0
|
|
794
|
+
|
|
795
|
+
# Get paginated items
|
|
796
|
+
paginated_query = query.limit(per_page).offset((page - 1) * per_page)
|
|
797
|
+
result = await session.execute(paginated_query)
|
|
798
|
+
items = result.scalars().all()
|
|
799
|
+
|
|
800
|
+
return {
|
|
801
|
+
"items": items,
|
|
802
|
+
"total": total,
|
|
803
|
+
"page": page,
|
|
804
|
+
"per_page": per_page,
|
|
805
|
+
"pages": (total + per_page - 1) // per_page if total > 0 else 0,
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def bulk_update_mappings(
|
|
810
|
+
session: Session,
|
|
811
|
+
model: type[Any],
|
|
812
|
+
mappings: list[dict[str, Any]],
|
|
813
|
+
commit: bool = True,
|
|
814
|
+
) -> None:
|
|
815
|
+
"""Bulk update records using mappings.
|
|
816
|
+
|
|
817
|
+
Args:
|
|
818
|
+
session: SQLAlchemy session
|
|
819
|
+
model: Model class
|
|
820
|
+
mappings: List of dictionaries with updates (must include id)
|
|
821
|
+
commit: Whether to commit the transaction
|
|
822
|
+
"""
|
|
823
|
+
if not mappings:
|
|
824
|
+
return
|
|
825
|
+
|
|
826
|
+
# Use core update for each mapping since bulk_update_mappings expects a Mapper
|
|
827
|
+
for mapping in mappings:
|
|
828
|
+
mapping_copy = dict(mapping)
|
|
829
|
+
record_id = mapping_copy.pop("id", None)
|
|
830
|
+
if record_id is None:
|
|
831
|
+
continue
|
|
832
|
+
stmt = update(model).where(getattr(model, "id") == record_id).values(**mapping_copy)
|
|
833
|
+
session.execute(stmt)
|
|
834
|
+
|
|
835
|
+
if commit:
|
|
836
|
+
session.commit()
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
async def async_bulk_update_mappings(
|
|
840
|
+
session: AsyncSession,
|
|
841
|
+
model: type[Any],
|
|
842
|
+
mappings: list[dict[str, Any]],
|
|
843
|
+
commit: bool = True,
|
|
844
|
+
) -> None:
|
|
845
|
+
"""Async bulk update records using mappings.
|
|
846
|
+
|
|
847
|
+
Args:
|
|
848
|
+
session: Async SQLAlchemy session
|
|
849
|
+
model: Model class
|
|
850
|
+
mappings: List of dictionaries with updates (must include id)
|
|
851
|
+
commit: Whether to commit the transaction
|
|
852
|
+
"""
|
|
853
|
+
if not mappings:
|
|
854
|
+
return
|
|
855
|
+
|
|
856
|
+
# For async, we need to use core update statements
|
|
857
|
+
for mapping in mappings:
|
|
858
|
+
mapping_copy = dict(mapping)
|
|
859
|
+
record_id = mapping_copy.pop("id", None)
|
|
860
|
+
if record_id is None:
|
|
861
|
+
continue
|
|
862
|
+
|
|
863
|
+
stmt = update(model).where(getattr(model, "id") == record_id).values(**mapping_copy)
|
|
864
|
+
await session.execute(stmt)
|
|
865
|
+
|
|
866
|
+
if commit:
|
|
867
|
+
await session.commit()
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
# Repository base classes
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
class BaseRepository[ModelType]:
|
|
874
|
+
"""Base repository class for common patterns."""
|
|
875
|
+
|
|
876
|
+
db: SyncDatabaseOperations[ModelType] | AsyncDatabaseOperations[ModelType]
|
|
877
|
+
|
|
878
|
+
def __init__(self, model: type[ModelType], session: Session | AsyncSession):
|
|
879
|
+
"""Initialize repository.
|
|
880
|
+
|
|
881
|
+
Args:
|
|
882
|
+
model: SQLAlchemy model class
|
|
883
|
+
session: Database session
|
|
884
|
+
"""
|
|
885
|
+
self.model = model
|
|
886
|
+
self.session = session
|
|
887
|
+
|
|
888
|
+
# Initialize appropriate operations class
|
|
889
|
+
if isinstance(session, AsyncSession):
|
|
890
|
+
self.db = AsyncDatabaseOperations[ModelType](model, session)
|
|
891
|
+
else:
|
|
892
|
+
self.db = SyncDatabaseOperations[ModelType](model, session)
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
class SyncRepository(BaseRepository[ModelType]):
|
|
896
|
+
"""Base class for synchronous repositories."""
|
|
897
|
+
|
|
898
|
+
db: SyncDatabaseOperations[ModelType]
|
|
899
|
+
|
|
900
|
+
def __init__(self, model: type[ModelType], session: Session):
|
|
901
|
+
"""Initialize sync repository."""
|
|
902
|
+
self.model = model
|
|
903
|
+
self.session = session
|
|
904
|
+
self.db = SyncDatabaseOperations[ModelType](model, session)
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
class AsyncRepository(BaseRepository[ModelType]):
|
|
908
|
+
"""Base class for asynchronous repositories."""
|
|
909
|
+
|
|
910
|
+
db: AsyncDatabaseOperations[ModelType]
|
|
911
|
+
|
|
912
|
+
def __init__(self, model: type[ModelType], session: AsyncSession):
|
|
913
|
+
"""Initialize async repository."""
|
|
914
|
+
self.model = model
|
|
915
|
+
self.session = session
|
|
916
|
+
self.db = AsyncDatabaseOperations[ModelType](model, session)
|