aiteamutils 0.2.0__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.
- aiteamutils/__init__.py +60 -0
- aiteamutils/base_model.py +81 -0
- aiteamutils/base_repository.py +503 -0
- aiteamutils/base_service.py +668 -0
- aiteamutils/cache.py +48 -0
- aiteamutils/config.py +26 -0
- aiteamutils/database.py +823 -0
- aiteamutils/dependencies.py +158 -0
- aiteamutils/enums.py +23 -0
- aiteamutils/exceptions.py +333 -0
- aiteamutils/security.py +396 -0
- aiteamutils/validators.py +188 -0
- aiteamutils-0.2.0.dist-info/METADATA +72 -0
- aiteamutils-0.2.0.dist-info/RECORD +15 -0
- aiteamutils-0.2.0.dist-info/WHEEL +4 -0
aiteamutils/database.py
ADDED
@@ -0,0 +1,823 @@
|
|
1
|
+
from typing import Any, Dict, Optional, Type, AsyncGenerator, TypeVar, List, Union
|
2
|
+
from sqlalchemy import select, update, and_, Table
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, AsyncEngine
|
4
|
+
from sqlalchemy.orm import sessionmaker, Load, joinedload
|
5
|
+
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
6
|
+
from sqlalchemy.pool import QueuePool
|
7
|
+
from contextlib import asynccontextmanager
|
8
|
+
from sqlalchemy import or_
|
9
|
+
from fastapi import Request
|
10
|
+
from ulid import ULID
|
11
|
+
from sqlalchemy.sql import Select
|
12
|
+
|
13
|
+
from .exceptions import ErrorCode, CustomException
|
14
|
+
from .base_model import Base, BaseColumn
|
15
|
+
from .enums import ActivityType
|
16
|
+
from .config import settings
|
17
|
+
|
18
|
+
T = TypeVar("T", bound=BaseColumn)
|
19
|
+
|
20
|
+
class DatabaseService:
|
21
|
+
def __init__(self, db_url: str):
|
22
|
+
"""DatabaseService 초기화.
|
23
|
+
|
24
|
+
Args:
|
25
|
+
db_url: 데이터베이스 URL
|
26
|
+
"""
|
27
|
+
self.engine = create_async_engine(
|
28
|
+
db_url,
|
29
|
+
echo=settings.DB_ECHO,
|
30
|
+
pool_size=settings.DB_POOL_SIZE,
|
31
|
+
max_overflow=settings.DB_MAX_OVERFLOW,
|
32
|
+
pool_timeout=settings.DB_POOL_TIMEOUT,
|
33
|
+
pool_recycle=settings.DB_POOL_RECYCLE,
|
34
|
+
pool_pre_ping=True,
|
35
|
+
poolclass=QueuePool,
|
36
|
+
)
|
37
|
+
|
38
|
+
self.async_session = sessionmaker(
|
39
|
+
bind=self.engine,
|
40
|
+
class_=AsyncSession,
|
41
|
+
expire_on_commit=False
|
42
|
+
)
|
43
|
+
|
44
|
+
@asynccontextmanager
|
45
|
+
async def get_db(self) -> AsyncGenerator[AsyncSession, None]:
|
46
|
+
"""데이터베이스 세션을 생성하고 반환하는 비동기 제너레이터."""
|
47
|
+
async with self.async_session() as session:
|
48
|
+
try:
|
49
|
+
yield session
|
50
|
+
finally:
|
51
|
+
await session.close()
|
52
|
+
|
53
|
+
def preprocess_data(self, model: Type[Base], input_data: Dict[str, Any], existing_data: Dict[str, Any] = None) -> Dict[str, Any]:
|
54
|
+
"""입력 데이터를 전처리하여 extra_data로 분리
|
55
|
+
|
56
|
+
Args:
|
57
|
+
model (Type[Base]): SQLAlchemy 모델 클래스
|
58
|
+
input_data (Dict[str, Any]): 입력 데이터
|
59
|
+
existing_data (Dict[str, Any], optional): 기존 데이터. Defaults to None.
|
60
|
+
|
61
|
+
Returns:
|
62
|
+
Dict[str, Any]: 전처리된 데이터
|
63
|
+
"""
|
64
|
+
model_attrs = {
|
65
|
+
attr for attr in dir(model)
|
66
|
+
if not attr.startswith('_') and not callable(getattr(model, attr))
|
67
|
+
}
|
68
|
+
model_data = {}
|
69
|
+
extra_data = {}
|
70
|
+
|
71
|
+
# 기존 extra_data가 있으면 복사
|
72
|
+
if existing_data and "extra_data" in existing_data:
|
73
|
+
extra_data = existing_data["extra_data"].copy()
|
74
|
+
|
75
|
+
# 스웨거 자동생성 필드 패턴
|
76
|
+
swagger_patterns = {"additionalProp1", "additionalProp2", "additionalProp3"}
|
77
|
+
|
78
|
+
# 모든 필드와 extra_data 분리
|
79
|
+
for key, value in input_data.items():
|
80
|
+
# 스웨거 자동생성 필드는 무시
|
81
|
+
if key in swagger_patterns:
|
82
|
+
continue
|
83
|
+
|
84
|
+
if key in model_attrs:
|
85
|
+
model_data[key] = value
|
86
|
+
else:
|
87
|
+
extra_data[key] = value
|
88
|
+
|
89
|
+
# extra_data가 있고, 모델이 extra_data 속성을 가지고 있으면 추가
|
90
|
+
if extra_data and "extra_data" in model_attrs:
|
91
|
+
model_data["extra_data"] = extra_data
|
92
|
+
|
93
|
+
return model_data
|
94
|
+
|
95
|
+
############################
|
96
|
+
# 2. 트랜잭션 및 세션 관리 #
|
97
|
+
############################
|
98
|
+
@asynccontextmanager
|
99
|
+
async def transaction(self):
|
100
|
+
"""트랜잭션 컨텍스트 매니저
|
101
|
+
|
102
|
+
트랜잭션 범위를 명시적으로 관리합니다.
|
103
|
+
with 문을 벗어날 때 자동으로 commit 또는 rollback됩니다.
|
104
|
+
|
105
|
+
Example:
|
106
|
+
async with db_service.transaction():
|
107
|
+
await db_service.create_entity(...)
|
108
|
+
await db_service.update_entity(...)
|
109
|
+
"""
|
110
|
+
try:
|
111
|
+
yield
|
112
|
+
await self.db.commit()
|
113
|
+
except Exception as e:
|
114
|
+
await self.db.rollback()
|
115
|
+
raise e
|
116
|
+
|
117
|
+
#######################
|
118
|
+
# 3. 데이터 처리 #
|
119
|
+
#######################
|
120
|
+
async def create_entity(self, model: Type[Base], entity_data: Dict[str, Any]) -> Any:
|
121
|
+
"""새로운 엔티티를 생성합니다.
|
122
|
+
|
123
|
+
Args:
|
124
|
+
model (Type[Base]): 생성할 모델 클래스
|
125
|
+
entity_data (Dict[str, Any]): 엔티티 데이터
|
126
|
+
|
127
|
+
Returns:
|
128
|
+
Any: 생성된 엔티티
|
129
|
+
|
130
|
+
Raises:
|
131
|
+
CustomException: 데이터베이스 오류 발생
|
132
|
+
"""
|
133
|
+
try:
|
134
|
+
# 데이터 전처리 및 모델 인스턴스 생성
|
135
|
+
processed_data = self.preprocess_data(model, entity_data)
|
136
|
+
|
137
|
+
# 외래 키 필드 검증
|
138
|
+
foreign_key_fields = {
|
139
|
+
field: value for field, value in processed_data.items()
|
140
|
+
if any(fk.parent.name == field for fk in model.__table__.foreign_keys)
|
141
|
+
}
|
142
|
+
if foreign_key_fields:
|
143
|
+
await self.validate_foreign_key_fields(model, foreign_key_fields)
|
144
|
+
|
145
|
+
entity = model(**processed_data)
|
146
|
+
|
147
|
+
self.db.add(entity)
|
148
|
+
await self.db.flush()
|
149
|
+
await self.db.commit()
|
150
|
+
await self.db.refresh(entity)
|
151
|
+
return entity
|
152
|
+
except IntegrityError as e:
|
153
|
+
await self.db.rollback()
|
154
|
+
error_str = str(e)
|
155
|
+
|
156
|
+
if "duplicate key" in error_str.lower():
|
157
|
+
# 중복 키 에러 처리
|
158
|
+
field = None
|
159
|
+
value = None
|
160
|
+
|
161
|
+
# 에러 메시지에서 필드와 값 추출
|
162
|
+
if "Key (" in error_str and ") already exists" in error_str:
|
163
|
+
field_value = error_str.split("Key (")[1].split(") already exists")[0]
|
164
|
+
if "=" in field_value:
|
165
|
+
field, value = field_value.split("=")
|
166
|
+
field = field.strip("() ")
|
167
|
+
value = value.strip("() ")
|
168
|
+
|
169
|
+
if not field:
|
170
|
+
field = "id" # 기본값
|
171
|
+
value = str(entity_data.get(field, ""))
|
172
|
+
|
173
|
+
raise CustomException(
|
174
|
+
ErrorCode.DUPLICATE_ERROR,
|
175
|
+
detail=f"{model.__tablename__}|{field}|{value}",
|
176
|
+
source_function="DatabaseService.create_entity",
|
177
|
+
original_error=e
|
178
|
+
)
|
179
|
+
elif "violates foreign key constraint" in error_str.lower():
|
180
|
+
# 외래키 위반 에러는 validate_foreign_key_fields에서 이미 처리됨
|
181
|
+
raise CustomException(
|
182
|
+
ErrorCode.FOREIGN_KEY_VIOLATION,
|
183
|
+
detail=error_str,
|
184
|
+
source_function="DatabaseService.create_entity",
|
185
|
+
original_error=e
|
186
|
+
)
|
187
|
+
else:
|
188
|
+
raise CustomException(
|
189
|
+
ErrorCode.DB_CREATE_ERROR,
|
190
|
+
detail=f"Failed to create {model.__name__}: {str(e)}",
|
191
|
+
source_function="DatabaseService.create_entity",
|
192
|
+
original_error=e
|
193
|
+
)
|
194
|
+
except CustomException as e:
|
195
|
+
await self.db.rollback()
|
196
|
+
raise e
|
197
|
+
except Exception as e:
|
198
|
+
await self.db.rollback()
|
199
|
+
raise CustomException(
|
200
|
+
ErrorCode.UNEXPECTED_ERROR,
|
201
|
+
detail=f"Unexpected error while creating {model.__name__}: {str(e)}",
|
202
|
+
source_function="DatabaseService.create_entity",
|
203
|
+
original_error=e
|
204
|
+
)
|
205
|
+
|
206
|
+
async def retrieve_entity(
|
207
|
+
self,
|
208
|
+
model: Type[T],
|
209
|
+
conditions: Dict[str, Any],
|
210
|
+
join_options: Optional[Union[Load, List[Load]]] = None
|
211
|
+
) -> Optional[T]:
|
212
|
+
"""조건에 맞는 단일 엔티티를 조회합니다.
|
213
|
+
|
214
|
+
Args:
|
215
|
+
model: 모델 클래스
|
216
|
+
conditions: 조회 조건
|
217
|
+
join_options: SQLAlchemy의 joinedload 옵션 또는 옵션 리스트
|
218
|
+
|
219
|
+
Returns:
|
220
|
+
Optional[T]: 조회된 엔티티 또는 None
|
221
|
+
|
222
|
+
Raises:
|
223
|
+
CustomException: 데이터베이스 작업 중 오류 발생 시
|
224
|
+
"""
|
225
|
+
try:
|
226
|
+
stmt = select(model)
|
227
|
+
|
228
|
+
# Join 옵션 적용
|
229
|
+
if join_options is not None:
|
230
|
+
if isinstance(join_options, list):
|
231
|
+
stmt = stmt.options(*join_options)
|
232
|
+
else:
|
233
|
+
stmt = stmt.options(join_options)
|
234
|
+
|
235
|
+
# 조건 적용
|
236
|
+
for key, value in conditions.items():
|
237
|
+
stmt = stmt.where(getattr(model, key) == value)
|
238
|
+
|
239
|
+
result = await self.execute_query(stmt)
|
240
|
+
return result.unique().scalar_one_or_none()
|
241
|
+
|
242
|
+
except Exception as e:
|
243
|
+
raise CustomException(
|
244
|
+
ErrorCode.DB_QUERY_ERROR,
|
245
|
+
detail=str(e),
|
246
|
+
source_function="DatabaseService.retrieve_entity",
|
247
|
+
original_error=e
|
248
|
+
)
|
249
|
+
|
250
|
+
async def update_entity(
|
251
|
+
self,
|
252
|
+
model: Type[Base],
|
253
|
+
conditions: Dict[str, Any],
|
254
|
+
update_data: Dict[str, Any]
|
255
|
+
) -> Optional[Base]:
|
256
|
+
"""엔티티를 업데이트합니다.
|
257
|
+
|
258
|
+
Args:
|
259
|
+
model (Type[Base]): 엔티티 모델 클래스
|
260
|
+
conditions (Dict[str, Any]): 업데이트할 엔티티 조회 조건
|
261
|
+
update_data (Dict[str, Any]): 업데이트할 데이터
|
262
|
+
|
263
|
+
Returns:
|
264
|
+
Optional[Base]: 업데이트된 엔티티
|
265
|
+
|
266
|
+
Raises:
|
267
|
+
CustomException: 데이터베이스 오류 발생
|
268
|
+
"""
|
269
|
+
try:
|
270
|
+
# 엔티티 조회
|
271
|
+
stmt = select(model)
|
272
|
+
for key, value in conditions.items():
|
273
|
+
stmt = stmt.where(getattr(model, key) == value)
|
274
|
+
|
275
|
+
result = await self.db.execute(stmt)
|
276
|
+
entity = result.scalar_one_or_none()
|
277
|
+
|
278
|
+
if not entity:
|
279
|
+
return None
|
280
|
+
|
281
|
+
# 기존 데이터를 딕셔너리로 변환
|
282
|
+
existing_data = {
|
283
|
+
column.name: getattr(entity, column.name)
|
284
|
+
for column in entity.__table__.columns
|
285
|
+
}
|
286
|
+
|
287
|
+
# 데이터 전처리
|
288
|
+
processed_data = self.preprocess_data(model, update_data, existing_data)
|
289
|
+
|
290
|
+
# UPDATE 문 생성 및 실행
|
291
|
+
update_stmt = (
|
292
|
+
update(model)
|
293
|
+
.where(and_(*[getattr(model, key) == value for key, value in conditions.items()]))
|
294
|
+
.values(**processed_data)
|
295
|
+
.returning(model)
|
296
|
+
)
|
297
|
+
|
298
|
+
result = await self.db.execute(update_stmt)
|
299
|
+
await self.db.commit()
|
300
|
+
|
301
|
+
# 업데이트된 엔티티 반환
|
302
|
+
updated_entity = result.scalar_one()
|
303
|
+
return updated_entity
|
304
|
+
|
305
|
+
except SQLAlchemyError as e:
|
306
|
+
await self.db.rollback()
|
307
|
+
raise CustomException(
|
308
|
+
ErrorCode.DB_UPDATE_ERROR,
|
309
|
+
detail=f"Failed to update {model.__name__}: {str(e)}",
|
310
|
+
source_function="DatabaseService.update_entity",
|
311
|
+
original_error=e
|
312
|
+
)
|
313
|
+
except Exception as e:
|
314
|
+
await self.db.rollback()
|
315
|
+
raise CustomException(
|
316
|
+
ErrorCode.UNEXPECTED_ERROR,
|
317
|
+
detail=f"Unexpected error while updating {model.__name__}: {str(e)}",
|
318
|
+
source_function="DatabaseService.update_entity",
|
319
|
+
original_error=e
|
320
|
+
)
|
321
|
+
|
322
|
+
async def delete_entity(self, entity: T) -> None:
|
323
|
+
"""엔티티를 실제로 삭제합니다.
|
324
|
+
|
325
|
+
Args:
|
326
|
+
entity (T): 삭제할 엔티티
|
327
|
+
|
328
|
+
Raises:
|
329
|
+
CustomException: 데이터베이스 오류 발생
|
330
|
+
"""
|
331
|
+
try:
|
332
|
+
await self.db.delete(entity)
|
333
|
+
await self.db.flush()
|
334
|
+
await self.db.commit()
|
335
|
+
except SQLAlchemyError as e:
|
336
|
+
await self.db.rollback()
|
337
|
+
raise CustomException(
|
338
|
+
ErrorCode.DB_DELETE_ERROR,
|
339
|
+
detail=f"Failed to delete entity: {str(e)}",
|
340
|
+
source_function="DatabaseService.delete_entity",
|
341
|
+
original_error=e
|
342
|
+
)
|
343
|
+
except Exception as e:
|
344
|
+
await self.db.rollback()
|
345
|
+
raise CustomException(
|
346
|
+
ErrorCode.UNEXPECTED_ERROR,
|
347
|
+
detail=f"Unexpected error while deleting entity: {str(e)}",
|
348
|
+
source_function="DatabaseService.delete_entity",
|
349
|
+
original_error=e
|
350
|
+
)
|
351
|
+
|
352
|
+
async def soft_delete_entity(self, model: Type[T], ulid: str) -> Optional[T]:
|
353
|
+
"""엔티티를 소프트 삭제합니다 (is_deleted = True).
|
354
|
+
|
355
|
+
Args:
|
356
|
+
model (Type[T]): 엔티티 모델
|
357
|
+
ulid (str): 삭제할 엔티티의 ULID
|
358
|
+
|
359
|
+
Returns:
|
360
|
+
Optional[T]: 삭제된 엔티티, 없으면 None
|
361
|
+
|
362
|
+
Raises:
|
363
|
+
CustomException: 데이터베이스 오류 발생
|
364
|
+
"""
|
365
|
+
try:
|
366
|
+
# 1. 엔티티 조회
|
367
|
+
stmt = select(model).where(
|
368
|
+
and_(
|
369
|
+
model.ulid == ulid,
|
370
|
+
model.is_deleted == False
|
371
|
+
)
|
372
|
+
)
|
373
|
+
result = await self.db.execute(stmt)
|
374
|
+
entity = result.scalar_one_or_none()
|
375
|
+
|
376
|
+
if not entity:
|
377
|
+
return None
|
378
|
+
|
379
|
+
# 2. 소프트 삭제 처리
|
380
|
+
stmt = update(model).where(
|
381
|
+
model.ulid == ulid
|
382
|
+
).values(
|
383
|
+
is_deleted=True
|
384
|
+
)
|
385
|
+
await self.db.execute(stmt)
|
386
|
+
await self.db.commit()
|
387
|
+
|
388
|
+
# 3. 업데이트된 엔티티 반환
|
389
|
+
return entity
|
390
|
+
except SQLAlchemyError as e:
|
391
|
+
await self.db.rollback()
|
392
|
+
raise CustomException(
|
393
|
+
ErrorCode.DB_DELETE_ERROR,
|
394
|
+
detail=f"Failed to soft delete {model.__name__}: {str(e)}",
|
395
|
+
source_function="DatabaseService.soft_delete_entity",
|
396
|
+
original_error=e
|
397
|
+
)
|
398
|
+
except Exception as e:
|
399
|
+
await self.db.rollback()
|
400
|
+
raise CustomException(
|
401
|
+
ErrorCode.UNEXPECTED_ERROR,
|
402
|
+
detail=f"Unexpected error while soft deleting {model.__name__}: {str(e)}",
|
403
|
+
source_function="DatabaseService.soft_delete_entity",
|
404
|
+
original_error=e
|
405
|
+
)
|
406
|
+
|
407
|
+
async def list_entities(
|
408
|
+
self,
|
409
|
+
model: Type[T],
|
410
|
+
skip: int = 0,
|
411
|
+
limit: int = 100,
|
412
|
+
filters: Optional[Dict[str, Any]] = None,
|
413
|
+
joins: Optional[List[Any]] = None
|
414
|
+
) -> List[T]:
|
415
|
+
"""엔티티 목록을 조회합니다.
|
416
|
+
|
417
|
+
Args:
|
418
|
+
model (Type[T]): 엔티티 모델
|
419
|
+
skip (int): 건너뛸 레코드 수
|
420
|
+
limit (int): 조회할 최대 레코드 수
|
421
|
+
filters (Optional[Dict[str, Any]]): 필터 조건
|
422
|
+
- field: value -> field = value
|
423
|
+
- field__ilike: value -> field ILIKE value
|
424
|
+
- search: [(field, pattern), ...] -> OR(field ILIKE pattern, ...)
|
425
|
+
joins (Optional[List[Any]]): 조인할 관계들 (joinedload 객체 리스트)
|
426
|
+
|
427
|
+
Returns:
|
428
|
+
List[T]: 조회된 엔티티 목록
|
429
|
+
|
430
|
+
Raises:
|
431
|
+
CustomException: 데이터베이스 오류 발생
|
432
|
+
"""
|
433
|
+
try:
|
434
|
+
query = select(model)
|
435
|
+
conditions = []
|
436
|
+
|
437
|
+
if filters:
|
438
|
+
for key, value in filters.items():
|
439
|
+
if key == "search" and isinstance(value, list):
|
440
|
+
# 전체 검색 조건
|
441
|
+
search_conditions = []
|
442
|
+
for field_name, pattern in value:
|
443
|
+
field = getattr(model, field_name)
|
444
|
+
search_conditions.append(field.ilike(pattern))
|
445
|
+
if search_conditions:
|
446
|
+
conditions.append(or_(*search_conditions))
|
447
|
+
elif "__ilike" in key:
|
448
|
+
# ILIKE 검색
|
449
|
+
field_name = key.replace("__ilike", "")
|
450
|
+
field = getattr(model, field_name)
|
451
|
+
conditions.append(field.ilike(value))
|
452
|
+
else:
|
453
|
+
# 일반 필터
|
454
|
+
field = getattr(model, key)
|
455
|
+
conditions.append(field == value)
|
456
|
+
|
457
|
+
if conditions:
|
458
|
+
query = query.where(and_(*conditions))
|
459
|
+
|
460
|
+
if joins:
|
461
|
+
for join_option in joins:
|
462
|
+
query = query.options(join_option)
|
463
|
+
|
464
|
+
query = query.offset(skip).limit(limit)
|
465
|
+
result = await self.db.execute(query)
|
466
|
+
return result.scalars().unique().all()
|
467
|
+
except SQLAlchemyError as e:
|
468
|
+
raise CustomException(
|
469
|
+
ErrorCode.DB_READ_ERROR,
|
470
|
+
detail=f"Failed to list {model.__name__}: {str(e)}",
|
471
|
+
source_function="DatabaseService.list_entities",
|
472
|
+
original_error=e
|
473
|
+
)
|
474
|
+
except Exception as e:
|
475
|
+
raise CustomException(
|
476
|
+
ErrorCode.UNEXPECTED_ERROR,
|
477
|
+
detail=f"Unexpected error while listing {model.__name__}: {str(e)}",
|
478
|
+
source_function="DatabaseService.list_entities",
|
479
|
+
original_error=e
|
480
|
+
)
|
481
|
+
|
482
|
+
######################
|
483
|
+
# 4. 검증 #
|
484
|
+
######################
|
485
|
+
async def validate_unique_fields(
|
486
|
+
self,
|
487
|
+
table_or_model: Union[Table, Type[Any]],
|
488
|
+
fields: Dict[str, Any],
|
489
|
+
source_function: str,
|
490
|
+
error_code: ErrorCode = ErrorCode.DUPLICATE_ERROR
|
491
|
+
) -> None:
|
492
|
+
"""
|
493
|
+
데이터베이스에서 필드의 유일성을 검증합니다.
|
494
|
+
|
495
|
+
Args:
|
496
|
+
table_or_model: 검증할 테이블 또는 모델 클래스
|
497
|
+
fields: 검증할 필드와 값의 딕셔너리 {"field_name": value}
|
498
|
+
source_function: 호출한 함수명
|
499
|
+
error_code: 사용할 에러 코드 (기본값: DUPLICATE_ERROR)
|
500
|
+
|
501
|
+
Raises:
|
502
|
+
CustomException: 중복된 값이 존재할 경우
|
503
|
+
"""
|
504
|
+
try:
|
505
|
+
conditions = []
|
506
|
+
for field_name, value in fields.items():
|
507
|
+
conditions.append(getattr(table_or_model, field_name) == value)
|
508
|
+
|
509
|
+
query = select(table_or_model).where(or_(*conditions))
|
510
|
+
result = await self.db.execute(query)
|
511
|
+
existing = result.scalar_one_or_none()
|
512
|
+
|
513
|
+
if existing:
|
514
|
+
table_name = table_or_model.name if hasattr(table_or_model, 'name') else table_or_model.__tablename__
|
515
|
+
# 단일 필드인 경우
|
516
|
+
if len(fields) == 1:
|
517
|
+
field_name, value = next(iter(fields.items()))
|
518
|
+
detail = f"{table_name}|{field_name}|{value}"
|
519
|
+
# 복수 필드인 경우
|
520
|
+
else:
|
521
|
+
fields_str = "|".join(f"{k}:{v}" for k, v in fields.items())
|
522
|
+
detail = f"{table_name}|{fields_str}"
|
523
|
+
|
524
|
+
raise CustomException(
|
525
|
+
error_code,
|
526
|
+
detail=detail,
|
527
|
+
source_function="DatabaseService.validate_unique_fields"
|
528
|
+
)
|
529
|
+
|
530
|
+
except CustomException as e:
|
531
|
+
raise CustomException(
|
532
|
+
e.error_code,
|
533
|
+
detail=e.detail,
|
534
|
+
source_function="DatabaseService.validate_unique_fields",
|
535
|
+
original_error=e.original_error,
|
536
|
+
parent_source_function=e.source_function
|
537
|
+
)
|
538
|
+
except Exception as e:
|
539
|
+
raise CustomException(
|
540
|
+
ErrorCode.DB_QUERY_ERROR,
|
541
|
+
detail=str(e),
|
542
|
+
source_function="DatabaseService.validate_unique_fields",
|
543
|
+
original_error=e
|
544
|
+
)
|
545
|
+
|
546
|
+
async def validate_foreign_key_fields(
|
547
|
+
self,
|
548
|
+
model: Type[T],
|
549
|
+
fields: Dict[str, Any]
|
550
|
+
) -> None:
|
551
|
+
"""외래 키 필드를 검증합니다.
|
552
|
+
|
553
|
+
Args:
|
554
|
+
model (Type[T]): 검증할 모델 클래스
|
555
|
+
fields (Dict[str, Any]): 검증할 외래 키 필드와 값
|
556
|
+
|
557
|
+
Raises:
|
558
|
+
CustomException: 참조하는 레코드가 존재하지 않는 경우
|
559
|
+
"""
|
560
|
+
for field, value in fields.items():
|
561
|
+
# 외래 키 관계 정보 가져오기
|
562
|
+
foreign_key = next(
|
563
|
+
(fk for fk in model.__table__.foreign_keys if fk.parent.name == field),
|
564
|
+
None
|
565
|
+
)
|
566
|
+
if foreign_key and value:
|
567
|
+
# 참조하는 테이블에서 레코드 존재 여부 확인
|
568
|
+
referenced_table = foreign_key.column.table
|
569
|
+
query = select(referenced_table).where(
|
570
|
+
and_(
|
571
|
+
foreign_key.column == value,
|
572
|
+
getattr(referenced_table.c, 'is_deleted', None) == False
|
573
|
+
)
|
574
|
+
)
|
575
|
+
result = await self.db.execute(query)
|
576
|
+
if not result.scalar_one_or_none():
|
577
|
+
raise CustomException(
|
578
|
+
ErrorCode.FOREIGN_KEY_VIOLATION,
|
579
|
+
detail=f"{referenced_table.name}|{field}|{value}",
|
580
|
+
source_function="DatabaseService.validate_foreign_key_fields"
|
581
|
+
)
|
582
|
+
|
583
|
+
#######################
|
584
|
+
# 5. 쿼리 실행 #
|
585
|
+
#######################
|
586
|
+
async def create_log(self, model: Type[Base], log_data: Dict[str, Any], request: Request = None) -> None:
|
587
|
+
"""로그를 생성합니다.
|
588
|
+
|
589
|
+
Args:
|
590
|
+
model: 로그 모델 클래스
|
591
|
+
log_data: 로그 데이터
|
592
|
+
request: FastAPI 요청 객체
|
593
|
+
|
594
|
+
Returns:
|
595
|
+
생성된 로그 엔티티
|
596
|
+
"""
|
597
|
+
# 공통 필드 추가 (ULID를 문자열로 변환)
|
598
|
+
log_data["ulid"] = str(ULID())
|
599
|
+
|
600
|
+
# request가 있는 경우 user-agent와 ip 정보 추가
|
601
|
+
if request:
|
602
|
+
log_data["user_agent"] = request.headers.get("user-agent")
|
603
|
+
log_data["ip_address"] = request.headers.get("x-forwarded-for")
|
604
|
+
|
605
|
+
return await self.create_entity(model, log_data)
|
606
|
+
|
607
|
+
async def soft_delete(
|
608
|
+
self,
|
609
|
+
model: Type[T],
|
610
|
+
entity_id: str,
|
611
|
+
source_function: str = None,
|
612
|
+
request: Request = None
|
613
|
+
) -> None:
|
614
|
+
"""엔티티를 소프트 삭제합니다.
|
615
|
+
|
616
|
+
Args:
|
617
|
+
model: 모델 클래스
|
618
|
+
entity_id: 삭제할 엔티티의 ID
|
619
|
+
source_function: 호출한 함수명
|
620
|
+
request: FastAPI 요청 객체
|
621
|
+
|
622
|
+
Raises:
|
623
|
+
CustomException: 데이터베이스 작업 실패 시
|
624
|
+
"""
|
625
|
+
try:
|
626
|
+
# 1. 엔티티 조회
|
627
|
+
stmt = select(model).where(
|
628
|
+
and_(
|
629
|
+
model.ulid == entity_id,
|
630
|
+
model.is_deleted == False
|
631
|
+
)
|
632
|
+
)
|
633
|
+
result = await self.db.execute(stmt)
|
634
|
+
entity = result.scalar_one_or_none()
|
635
|
+
|
636
|
+
if not entity:
|
637
|
+
raise CustomException(
|
638
|
+
ErrorCode.NOT_FOUND,
|
639
|
+
detail=f"{model.__name__}|{entity_id}",
|
640
|
+
source_function=source_function or "DatabaseService.soft_delete"
|
641
|
+
)
|
642
|
+
|
643
|
+
# 2. 소프트 삭제 처리
|
644
|
+
stmt = update(model).where(
|
645
|
+
model.ulid == entity_id
|
646
|
+
).values(
|
647
|
+
is_deleted=True
|
648
|
+
)
|
649
|
+
await self.db.execute(stmt)
|
650
|
+
await self.db.commit()
|
651
|
+
|
652
|
+
# 3. 삭제 로그 생성
|
653
|
+
if request:
|
654
|
+
activity_type = f"{model.__tablename__.upper()}_DELETED"
|
655
|
+
await self.create_log({
|
656
|
+
"type": activity_type,
|
657
|
+
"fk_table": model.__tablename__,
|
658
|
+
"extra_data": {
|
659
|
+
f"{model.__tablename__}_ulid": entity_id
|
660
|
+
}
|
661
|
+
}, request)
|
662
|
+
|
663
|
+
except CustomException as e:
|
664
|
+
await self.db.rollback()
|
665
|
+
raise CustomException(
|
666
|
+
e.error_code,
|
667
|
+
detail=e.detail,
|
668
|
+
source_function=source_function or "DatabaseService.soft_delete",
|
669
|
+
original_error=e.original_error,
|
670
|
+
parent_source_function=e.source_function
|
671
|
+
)
|
672
|
+
except Exception as e:
|
673
|
+
await self.db.rollback()
|
674
|
+
raise CustomException(
|
675
|
+
ErrorCode.DB_DELETE_ERROR,
|
676
|
+
detail=str(e),
|
677
|
+
source_function=source_function or "DatabaseService.soft_delete",
|
678
|
+
original_error=e
|
679
|
+
)
|
680
|
+
|
681
|
+
async def get_entity(self, model: Type[T], ulid: str) -> Optional[T]:
|
682
|
+
"""ULID로 엔티티를 조회합니다.
|
683
|
+
|
684
|
+
Args:
|
685
|
+
model (Type[T]): 엔티티 모델
|
686
|
+
ulid (str): 조회할 엔티티의 ULID
|
687
|
+
|
688
|
+
Returns:
|
689
|
+
Optional[T]: 조회된 엔티티 또는 None
|
690
|
+
|
691
|
+
Raises:
|
692
|
+
CustomException: 데이터베이스 오류 발생
|
693
|
+
"""
|
694
|
+
try:
|
695
|
+
query = select(model).where(
|
696
|
+
and_(
|
697
|
+
model.ulid == ulid,
|
698
|
+
model.is_deleted == False
|
699
|
+
)
|
700
|
+
)
|
701
|
+
result = await self.db.execute(query)
|
702
|
+
return result.scalar_one_or_none()
|
703
|
+
except SQLAlchemyError as e:
|
704
|
+
raise CustomException(
|
705
|
+
ErrorCode.DB_READ_ERROR,
|
706
|
+
detail=f"Failed to get {model.__name__}: {str(e)}",
|
707
|
+
source_function="DatabaseService.get_entity",
|
708
|
+
original_error=e
|
709
|
+
)
|
710
|
+
except Exception as e:
|
711
|
+
raise CustomException(
|
712
|
+
ErrorCode.UNEXPECTED_ERROR,
|
713
|
+
detail=f"Unexpected error while getting {model.__name__}: {str(e)}",
|
714
|
+
source_function="DatabaseService.get_entity",
|
715
|
+
original_error=e
|
716
|
+
)
|
717
|
+
|
718
|
+
async def execute_query(self, query: Select) -> Any:
|
719
|
+
"""SQL 쿼리를 실행하고 결과를 반환합니다.
|
720
|
+
|
721
|
+
Args:
|
722
|
+
query (Select): 실행할 SQLAlchemy 쿼리
|
723
|
+
|
724
|
+
Returns:
|
725
|
+
Any: 쿼리 실행 결과
|
726
|
+
|
727
|
+
Raises:
|
728
|
+
CustomException: 데이터베이스 작업 중 오류 발생 시
|
729
|
+
"""
|
730
|
+
try:
|
731
|
+
async with self.db as session:
|
732
|
+
result = await session.execute(query)
|
733
|
+
return result
|
734
|
+
except Exception as e:
|
735
|
+
raise CustomException(
|
736
|
+
ErrorCode.DB_QUERY_ERROR,
|
737
|
+
detail=str(e),
|
738
|
+
source_function=f"{self.__class__.__name__}.execute_query",
|
739
|
+
original_error=e
|
740
|
+
)
|
741
|
+
|
742
|
+
async def execute(self, stmt):
|
743
|
+
"""SQL 문을 실행합니다.
|
744
|
+
|
745
|
+
Args:
|
746
|
+
stmt: 실행할 SQL 문
|
747
|
+
|
748
|
+
Returns:
|
749
|
+
Result: 실행 결과
|
750
|
+
|
751
|
+
Raises:
|
752
|
+
CustomException: 데이터베이스 오류 발생
|
753
|
+
"""
|
754
|
+
try:
|
755
|
+
return await self.db.execute(stmt)
|
756
|
+
except SQLAlchemyError as e:
|
757
|
+
raise CustomException(
|
758
|
+
ErrorCode.DB_QUERY_ERROR,
|
759
|
+
detail=str(e),
|
760
|
+
source_function="DatabaseService.execute",
|
761
|
+
original_error=e
|
762
|
+
)
|
763
|
+
except Exception as e:
|
764
|
+
raise CustomException(
|
765
|
+
ErrorCode.UNEXPECTED_ERROR,
|
766
|
+
detail=str(e),
|
767
|
+
source_function="DatabaseService.execute",
|
768
|
+
original_error=e
|
769
|
+
)
|
770
|
+
|
771
|
+
async def commit(self) -> None:
|
772
|
+
"""현재 세션의 변경사항을 데이터베이스에 커밋합니다."""
|
773
|
+
try:
|
774
|
+
await self.db.commit()
|
775
|
+
except SQLAlchemyError as e:
|
776
|
+
await self.db.rollback()
|
777
|
+
raise CustomException(
|
778
|
+
ErrorCode.DB_QUERY_ERROR,
|
779
|
+
detail=str(e),
|
780
|
+
source_function="DatabaseService.commit",
|
781
|
+
original_error=e
|
782
|
+
)
|
783
|
+
|
784
|
+
async def rollback(self) -> None:
|
785
|
+
"""현재 세션의 변경사항을 롤백합니다."""
|
786
|
+
try:
|
787
|
+
await self.db.rollback()
|
788
|
+
except SQLAlchemyError as e:
|
789
|
+
raise CustomException(
|
790
|
+
ErrorCode.DB_QUERY_ERROR,
|
791
|
+
detail=str(e),
|
792
|
+
source_function="DatabaseService.rollback",
|
793
|
+
original_error=e
|
794
|
+
)
|
795
|
+
|
796
|
+
async def flush(self) -> None:
|
797
|
+
"""현재 세션의 변경사항을 데이터베이스에 플러시합니다."""
|
798
|
+
try:
|
799
|
+
await self.db.flush()
|
800
|
+
except SQLAlchemyError as e:
|
801
|
+
await self.db.rollback()
|
802
|
+
raise CustomException(
|
803
|
+
ErrorCode.DB_QUERY_ERROR,
|
804
|
+
detail=str(e),
|
805
|
+
source_function="DatabaseService.flush",
|
806
|
+
original_error=e
|
807
|
+
)
|
808
|
+
|
809
|
+
async def refresh(self, entity: Any) -> None:
|
810
|
+
"""엔티티를 데이터베이스의 최신 상태로 리프레시합니다.
|
811
|
+
|
812
|
+
Args:
|
813
|
+
entity: 리프레시할 엔티티
|
814
|
+
"""
|
815
|
+
try:
|
816
|
+
await self.db.refresh(entity)
|
817
|
+
except SQLAlchemyError as e:
|
818
|
+
raise CustomException(
|
819
|
+
ErrorCode.DB_QUERY_ERROR,
|
820
|
+
detail=str(e),
|
821
|
+
source_function="DatabaseService.refresh",
|
822
|
+
original_error=e
|
823
|
+
)
|