aiteamutils 0.2.0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,60 @@
1
+ from .base_model import Base
2
+ from .database import DatabaseManager
3
+ from .exceptions import (
4
+ CustomException,
5
+ ErrorCode,
6
+ custom_exception_handler,
7
+ request_validation_exception_handler,
8
+ sqlalchemy_exception_handler,
9
+ generic_exception_handler
10
+ )
11
+ from .security import (
12
+ verify_password,
13
+ hash_password,
14
+ create_jwt_token,
15
+ verify_jwt_token,
16
+ rate_limit,
17
+ RateLimitExceeded
18
+ )
19
+ from .base_service import BaseService
20
+ from .base_repository import BaseRepository
21
+ from .validators import validate_with
22
+ from .enums import ActivityType
23
+ from .cache import CacheManager
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ __all__ = [
28
+ # Base Models
29
+ "Base",
30
+ "BaseService",
31
+ "BaseRepository",
32
+
33
+ # Database
34
+ "DatabaseManager",
35
+
36
+ # Exceptions
37
+ "CustomException",
38
+ "ErrorCode",
39
+ "custom_exception_handler",
40
+ "request_validation_exception_handler",
41
+ "sqlalchemy_exception_handler",
42
+ "generic_exception_handler",
43
+
44
+ # Security
45
+ "verify_password",
46
+ "hash_password",
47
+ "create_jwt_token",
48
+ "verify_jwt_token",
49
+ "rate_limit",
50
+ "RateLimitExceeded",
51
+
52
+ # Validators
53
+ "validate_with",
54
+
55
+ # Enums
56
+ "ActivityType",
57
+
58
+ # Cache
59
+ "CacheManager"
60
+ ]
@@ -0,0 +1,81 @@
1
+ from datetime import datetime, timezone
2
+ from typing import Any, Dict, TypeVar, Generic, Optional
3
+ from ulid import ULID
4
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
5
+ from sqlalchemy import Column, String, PrimaryKeyConstraint, UniqueConstraint
6
+ from sqlalchemy.dialects.postgresql import TIMESTAMP
7
+ from pydantic import BaseModel, ConfigDict
8
+ from pydantic import Field
9
+
10
+ class Base(DeclarativeBase):
11
+ """SQLAlchemy 기본 모델"""
12
+ pass
13
+
14
+ class BaseColumn(Base):
15
+ """공통 설정 및 메서드를 제공하는 BaseColumn"""
16
+ __abstract__ = True
17
+
18
+ ulid: Mapped[str] = mapped_column(
19
+ String,
20
+ primary_key=True,
21
+ unique=True,
22
+ default=lambda: str(ULID()),
23
+ doc="ULID",
24
+ nullable=False
25
+ )
26
+ created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
27
+ updated_at: Mapped[datetime] = mapped_column(
28
+ default=datetime.utcnow,
29
+ onupdate=datetime.utcnow
30
+ )
31
+ is_deleted: Mapped[bool] = mapped_column(
32
+ default=False,
33
+ index=True
34
+ )
35
+
36
+ def to_dict(self) -> Dict[str, Any]:
37
+ """모델을 딕셔너리로 변환합니다.
38
+
39
+ Returns:
40
+ Dict[str, Any]: 모델의 속성을 포함하는 딕셔너리
41
+ """
42
+ result = {}
43
+
44
+ # 테이블 컬럼 처리
45
+ for column in self.__table__.columns:
46
+ value = getattr(self, column.name)
47
+ if isinstance(value, datetime):
48
+ value = value.isoformat()
49
+ result[column.name] = value
50
+
51
+ # Relationship 처리 (이미 로드된 관계만 처리)
52
+ for relationship in self.__mapper__.relationships:
53
+ if relationship.key == "organizations": # 순환 참조 방지
54
+ continue
55
+ try:
56
+ value = getattr(self, relationship.key)
57
+ if value is not None:
58
+ if isinstance(value, list):
59
+ result[relationship.key] = [item.to_dict() for item in value]
60
+ else:
61
+ result[relationship.key] = value.to_dict()
62
+ else:
63
+ result[relationship.key] = None
64
+ except Exception:
65
+ result[relationship.key] = None
66
+
67
+ return result
68
+
69
+ class BaseSchema(BaseModel):
70
+ """공통 설정 및 메서드를 제공하는 BaseSchema"""
71
+ model_config = ConfigDict(
72
+ str_strip_whitespace=True,
73
+ extra="allow",
74
+ from_attributes=True,
75
+ populate_by_name=True,
76
+ use_enum_values=True
77
+ )
78
+
79
+ def to_dict(self) -> Dict[str, Any]:
80
+ """모델을 딕셔너리로 변환"""
81
+ return self.model_dump()
@@ -0,0 +1,503 @@
1
+ """기본 레포지토리 모듈."""
2
+ from typing import TypeVar, Generic, Dict, Any, List, Optional, Type
3
+ from sqlalchemy.orm import DeclarativeBase
4
+ from sqlalchemy.exc import IntegrityError, SQLAlchemyError
5
+ from sqlalchemy import select, or_, and_
6
+ from .database import DatabaseService
7
+ from .exceptions import CustomException, ErrorCode
8
+ from sqlalchemy.orm import joinedload
9
+ from sqlalchemy.sql import Select
10
+
11
+ ModelType = TypeVar("ModelType", bound=DeclarativeBase)
12
+
13
+ class BaseRepository(Generic[ModelType]):
14
+ ##################
15
+ # 1. 초기화 영역 #
16
+ ##################
17
+ def __init__(self, db_service: DatabaseService, model: Type[ModelType]):
18
+ """
19
+ Args:
20
+ db_service (DatabaseService): 데이터베이스 서비스 인스턴스
21
+ model (Type[ModelType]): 모델 클래스
22
+ """
23
+ self.db_service = db_service
24
+ self.model = model
25
+
26
+ #######################
27
+ # 2. 쿼리 빌딩 #
28
+ #######################
29
+ def _build_base_query(self) -> Select:
30
+ """기본 쿼리를 생성합니다.
31
+
32
+ Returns:
33
+ Select: 기본 쿼리
34
+ """
35
+ return select(self.model)
36
+
37
+ #######################
38
+ # 3. 전차리 영역 #
39
+ #######################
40
+ def _apply_exact_match(self, stmt: Select, field_name: str, value: Any) -> Select:
41
+ """정확한 값 매칭 조건을 적용합니다.
42
+
43
+ Args:
44
+ stmt (Select): 쿼리문
45
+ field_name (str): 필드명
46
+ value (Any): 매칭할 값
47
+
48
+ Returns:
49
+ Select: 조건이 적용된 쿼리
50
+ """
51
+ return stmt.where(getattr(self.model, field_name) == value)
52
+
53
+ def _apply_like_match(self, stmt: Select, field_name: str, value: str) -> Select:
54
+ """LIKE 검색 조건을 적용합니다.
55
+
56
+ Args:
57
+ stmt (Select): 쿼리문
58
+ field_name (str): 필드명
59
+ value (str): 검색할 값
60
+
61
+ Returns:
62
+ Select: 조건이 적용된 쿼리
63
+ """
64
+ return stmt.where(getattr(self.model, field_name).ilike(f"%{value}%"))
65
+
66
+ def _apply_relation_match(self, stmt: Select, relations: List[str], field_name: str, operator: str, value: Any) -> Select:
67
+ """관계 테이블 검색 조건을 적용합니다."""
68
+ current = self.model
69
+
70
+ # 관계 체인 따라가기
71
+ for i in range(len(relations)-1):
72
+ current = getattr(current, relations[i]).property.mapper.class_
73
+
74
+ # 마지막 모델과 필드
75
+ final_model = getattr(current, relations[-1]).property.mapper.class_
76
+
77
+ # 중첩된 EXISTS 절 생성
78
+ current = self.model
79
+ subq = select(1)
80
+
81
+ # 첫 번째 관계
82
+ next_model = getattr(current, relations[0]).property.mapper.class_
83
+ subq = subq.where(getattr(next_model, 'ulid') == getattr(current, f"{relations[0]}_ulid"))
84
+
85
+ # 중간 관계들
86
+ for i in range(1, len(relations)):
87
+ prev_model = next_model
88
+ next_model = getattr(prev_model, relations[i]).property.mapper.class_
89
+ subq = subq.where(getattr(next_model, 'ulid') == getattr(prev_model, f"{relations[i]}_ulid"))
90
+
91
+ # 최종 검색 조건
92
+ subq = subq.where(getattr(final_model, field_name).__getattribute__(operator)(value))
93
+
94
+ return stmt.where(subq.exists())
95
+
96
+ def _apply_ordering(self, stmt: Select, order_by: List[str]) -> Select:
97
+ """정렬 조건을 적용합니다.
98
+
99
+ Args:
100
+ stmt (Select): 쿼리문
101
+ order_by (List[str]): 정렬 기준 필드 목록 (예: ["name", "-created_at"])
102
+
103
+ Returns:
104
+ Select: 정렬이 적용된 쿼리
105
+ """
106
+ for field in order_by:
107
+ if field.startswith("-"):
108
+ field_name = field[1:]
109
+ stmt = stmt.order_by(getattr(self.model, field_name).desc())
110
+ else:
111
+ stmt = stmt.order_by(getattr(self.model, field).asc())
112
+ return stmt
113
+
114
+ def _apply_pagination(self, stmt: Select, skip: int = 0, limit: int = 100) -> Select:
115
+ """페이징을 적용합니다.
116
+
117
+ Args:
118
+ stmt (Select): 쿼리문
119
+ skip (int): 건너뛸 레코드 수
120
+ limit (int): 조회할 최대 레코드 수
121
+
122
+ Returns:
123
+ Select: 페이징이 적용된 쿼리
124
+ """
125
+ return stmt.offset(skip).limit(limit)
126
+
127
+ def _apply_joins(self, stmt: Select, joins: List[str]) -> Select:
128
+ """조인을 적용합니다.
129
+
130
+ Args:
131
+ stmt (Select): 쿼리문
132
+ joins (List[str]): 조인할 관계명 목록
133
+
134
+ Returns:
135
+ Select: 조인이 적용된 쿼리
136
+ """
137
+ for join in joins:
138
+ stmt = stmt.options(joinedload(getattr(self.model, join)))
139
+ return stmt
140
+
141
+ def _build_jsonb_condition(self, model: Any, field_path: str, value: str) -> Any:
142
+ """JSONB 필드에 대한 검색 조건을 생성합니다.
143
+
144
+ Args:
145
+ model: 대상 모델
146
+ field_path (str): JSONB 키 경로 (예: "address", "name.first")
147
+ value (str): 검색할 값
148
+
149
+ Returns:
150
+ Any: SQLAlchemy 검색 조건
151
+ """
152
+ # JSONB 경로가 중첩된 경우 (예: "name.first")
153
+ if "." in field_path:
154
+ path_parts = field_path.split(".")
155
+ jsonb_path = "{" + ",".join(path_parts) + "}"
156
+ return model.extra_data[jsonb_path].astext.ilike(f"%{value}%")
157
+ # 단일 키인 경우
158
+ return model.extra_data[field_path].astext.ilike(f"%{value}%")
159
+
160
+ def _apply_jsonb_match(self, stmt: Select, relations: List[str], json_key: str, value: str) -> Select:
161
+ """JSONB 필드 검색 조건을 적용합니다.
162
+
163
+ Args:
164
+ stmt (Select): 쿼리문
165
+ relations (List[str]): 관계 테이블 경로
166
+ json_key (str): JSONB 키 경로
167
+ value (str): 검색할 값
168
+
169
+ Returns:
170
+ Select: 조건이 적용된 쿼리
171
+ """
172
+ current = self.model
173
+
174
+ # 단일 모델 검색
175
+ if not relations:
176
+ condition = self._build_jsonb_condition(current, json_key, value)
177
+ return stmt.where(condition)
178
+
179
+ # 관계 모델 검색
180
+ for i in range(len(relations)-1):
181
+ current = getattr(current, relations[i]).property.mapper.class_
182
+
183
+ final_model = getattr(current, relations[-1]).property.mapper.class_
184
+
185
+ # 관계 체인 구성
186
+ if len(relations) == 1:
187
+ condition = getattr(self.model, relations[0]).has(
188
+ self._build_jsonb_condition(final_model, json_key, value)
189
+ )
190
+ else:
191
+ condition = getattr(self.model, relations[0]).has(
192
+ getattr(final_model, relations[-1]).has(
193
+ self._build_jsonb_condition(final_model, json_key, value)
194
+ )
195
+ )
196
+
197
+ return stmt.where(condition)
198
+
199
+ def _apply_search_params(self, stmt, search_params: Dict[str, Any]):
200
+ """검색 파라미터를 적용합니다."""
201
+ if not search_params:
202
+ return stmt
203
+
204
+ for key, value in search_params.items():
205
+ if not value.get("value"):
206
+ continue
207
+
208
+ conditions = []
209
+ for field in value.get("fields", []):
210
+ parts = field.split('.')
211
+
212
+ if len(parts) == 1:
213
+ # 직접 필드 검색
214
+ condition = self._apply_like_match(stmt, parts[0], value["value"]).whereclause
215
+ elif 'extra_data' in parts:
216
+ # JSONB 필드 검색
217
+ extra_data_idx = parts.index('extra_data')
218
+ tables = parts[:extra_data_idx]
219
+ json_key = ".".join(parts[extra_data_idx + 1:])
220
+ condition = self._apply_jsonb_match(
221
+ stmt,
222
+ tables,
223
+ json_key,
224
+ value["value"]
225
+ ).whereclause
226
+ else:
227
+ # 관계 테이블 검색
228
+ condition = self._apply_relation_match(
229
+ stmt,
230
+ parts[:-1],
231
+ parts[-1],
232
+ "ilike",
233
+ f"%{value['value']}%"
234
+ ).whereclause
235
+
236
+ conditions.append(condition)
237
+
238
+ if conditions:
239
+ stmt = stmt.where(or_(*conditions))
240
+
241
+ return stmt
242
+
243
+ def _apply_filters(self, stmt, filters: Dict[str, Any]):
244
+ """일반 필터를 적용합니다."""
245
+ for key, value in filters.items():
246
+ if value is None:
247
+ continue
248
+
249
+ if "." in key:
250
+ # 관계 테이블 필터
251
+ relation, field = key.split(".")
252
+ stmt = self._apply_relation_match(stmt, relation, field, "__eq__", value)
253
+ else:
254
+ # 일반 필드 필터
255
+ stmt = stmt.where(getattr(self.model, key) == value)
256
+
257
+ return stmt
258
+
259
+ #######################
260
+ # 4. CRUD 작업 #
261
+ #######################
262
+ async def get(
263
+ self,
264
+ ulid: str
265
+ ) -> Optional[Dict[str, Any]]:
266
+ """ULID로 엔티티를 조회합니다.
267
+
268
+ Args:
269
+ ulid (str): 조회할 엔티티의 ULID
270
+
271
+ Returns:
272
+ Optional[Dict[str, Any]]: 조회된 엔티티, 없으면 None
273
+
274
+ Raises:
275
+ CustomException: 데이터베이스 작업 중 오류 발생 시
276
+ """
277
+ try:
278
+ stmt = select(self.model).filter_by(ulid=ulid, is_deleted=False)
279
+ result = await self.db_service.execute(stmt)
280
+ entity = result.scalars().unique().first()
281
+
282
+ if not entity:
283
+ raise CustomException(
284
+ ErrorCode.DB_NO_RESULT,
285
+ detail=f"{self.model.__tablename__}|ulid|{ulid}",
286
+ source_function=f"{self.__class__.__name__}.get"
287
+ )
288
+ return entity
289
+ except CustomException as e:
290
+ e.detail = f"Repository error for {self.model.__tablename__}: {e.detail}"
291
+ e.source_function = f"{self.__class__.__name__}.get -> {e.source_function}"
292
+ raise e
293
+ except SQLAlchemyError as e:
294
+ raise CustomException(
295
+ ErrorCode.DB_QUERY_ERROR,
296
+ detail=f"Database error in {self.model.__tablename__}: {str(e)}",
297
+ source_function=f"{self.__class__.__name__}.get",
298
+ original_error=e
299
+ )
300
+ except Exception as e:
301
+ raise CustomException(
302
+ ErrorCode.DB_QUERY_ERROR,
303
+ detail=f"Unexpected repository error in {self.model.__tablename__}: {str(e)}",
304
+ source_function=f"{self.__class__.__name__}.get",
305
+ original_error=e
306
+ )
307
+
308
+ async def list(
309
+ self,
310
+ skip: int = 0,
311
+ limit: int = 100,
312
+ filters: Dict[str, Any] | None = None,
313
+ search_params: Dict[str, Any] | None = None
314
+ ) -> List[Any]:
315
+ """엔티티 목록을 조회합니다."""
316
+ try:
317
+ stmt = select(self.model).where(self.model.is_deleted == False)
318
+
319
+ # 필터 적용
320
+ if filters:
321
+ stmt = self._apply_filters(stmt, filters)
322
+
323
+ # 검색 적용
324
+ if search_params:
325
+ stmt = self._apply_search_params(stmt, search_params)
326
+
327
+ # 페이지네이션 적용
328
+ stmt = stmt.limit(limit).offset(skip)
329
+
330
+ result = await self.db_service.db.execute(stmt)
331
+ return result.scalars().unique().all()
332
+
333
+ except SQLAlchemyError as e:
334
+ raise CustomException(
335
+ ErrorCode.DB_QUERY_ERROR,
336
+ detail=f"Unexpected repository list error in {self.model.__tablename__}: {str(e)}",
337
+ source_function=f"{self.__class__.__name__}.list",
338
+ original_error=e,
339
+ )
340
+
341
+ async def create(self, data: Dict[str, Any]) -> ModelType:
342
+ """새로운 엔티티를 생성합니다.
343
+
344
+ Args:
345
+ data (Dict[str, Any]): 생성할 엔티티 데이터
346
+
347
+ Returns:
348
+ ModelType: 생성된 엔티티
349
+
350
+ Raises:
351
+ CustomException: 데이터베이스 작업 중 오류 발생 시
352
+ """
353
+ try:
354
+ return await self.db_service.create_entity(self.model, data)
355
+ except CustomException as e:
356
+ e.detail = f"Repository create error for {self.model.__tablename__}: {e.detail}"
357
+ e.source_function = f"{self.__class__.__name__}.create -> {e.source_function}"
358
+ raise e
359
+ except IntegrityError as e:
360
+ self._handle_integrity_error(e, "create", data)
361
+ except SQLAlchemyError as e:
362
+ raise CustomException(
363
+ ErrorCode.DB_CREATE_ERROR,
364
+ detail=f"Database create error in {self.model.__tablename__}: {str(e)}",
365
+ source_function=f"{self.__class__.__name__}.create",
366
+ original_error=e
367
+ )
368
+ except Exception as e:
369
+ raise CustomException(
370
+ ErrorCode.DB_CREATE_ERROR,
371
+ detail=f"Unexpected repository create error in {self.model.__tablename__}: {str(e)}",
372
+ source_function=f"{self.__class__.__name__}.create",
373
+ original_error=e
374
+ )
375
+
376
+ async def update(self, ulid: str, data: Dict[str, Any]) -> Optional[ModelType]:
377
+ """기존 엔티티를 수정합니다.
378
+
379
+ Args:
380
+ ulid (str): 수정할 엔티티의 ULID
381
+ data (Dict[str, Any]): 수정할 데이터
382
+
383
+ Returns:
384
+ Optional[ModelType]: 수정된 엔티티, 없으면 None
385
+
386
+ Raises:
387
+ CustomException: 데이터베이스 작업 중 오류 발생 시
388
+ """
389
+ try:
390
+ entity = await self.db_service.update_entity(
391
+ self.model,
392
+ {"ulid": ulid, "is_deleted": False},
393
+ data
394
+ )
395
+ if not entity:
396
+ raise CustomException(
397
+ ErrorCode.DB_NO_RESULT,
398
+ detail=f"{self.model.__tablename__}|ulid|{ulid}",
399
+ source_function=f"{self.__class__.__name__}.update"
400
+ )
401
+ return entity
402
+ except CustomException as e:
403
+ e.detail = f"Repository update error for {self.model.__tablename__}: {e.detail}"
404
+ e.source_function = f"{self.__class__.__name__}.update -> {e.source_function}"
405
+ raise e
406
+ except IntegrityError as e:
407
+ self._handle_integrity_error(e, "update", data)
408
+ except SQLAlchemyError as e:
409
+ raise CustomException(
410
+ ErrorCode.DB_UPDATE_ERROR,
411
+ detail=f"Database update error in {self.model.__tablename__}: {str(e)}",
412
+ source_function=f"{self.__class__.__name__}.update",
413
+ original_error=e
414
+ )
415
+ except Exception as e:
416
+ raise CustomException(
417
+ ErrorCode.DB_UPDATE_ERROR,
418
+ detail=f"Unexpected repository update error in {self.model.__tablename__}: {str(e)}",
419
+ source_function=f"{self.__class__.__name__}.update",
420
+ original_error=e
421
+ )
422
+
423
+ async def delete(self, ulid: str) -> bool:
424
+ """엔티티를 소프트 삭제합니다 (is_deleted = True).
425
+
426
+ Args:
427
+ ulid (str): 삭제할 엔티티의 ULID
428
+
429
+ Returns:
430
+ bool: 삭제 성공 여부
431
+
432
+ Raises:
433
+ CustomException: 데이터베이스 작업 중 오류 발생 시
434
+ """
435
+ try:
436
+ entity = await self.db_service.soft_delete_entity(self.model, ulid)
437
+ if not entity:
438
+ raise CustomException(
439
+ ErrorCode.DB_NO_RESULT,
440
+ detail=f"{self.model.__tablename__}|ulid|{ulid}",
441
+ source_function=f"{self.__class__.__name__}.delete"
442
+ )
443
+ return True
444
+ except CustomException as e:
445
+ e.detail = f"Repository delete error for {self.model.__tablename__}: {e.detail}"
446
+ e.source_function = f"{self.__class__.__name__}.delete -> {e.source_function}"
447
+ raise e
448
+ except IntegrityError as e:
449
+ self._handle_integrity_error(e, "delete")
450
+ except SQLAlchemyError as e:
451
+ raise CustomException(
452
+ ErrorCode.DB_DELETE_ERROR,
453
+ detail=f"Database delete error in {self.model.__tablename__}: {str(e)}",
454
+ source_function=f"{self.__class__.__name__}.delete",
455
+ original_error=e
456
+ )
457
+ except Exception as e:
458
+ raise CustomException(
459
+ ErrorCode.DB_DELETE_ERROR,
460
+ detail=f"Unexpected repository delete error in {self.model.__tablename__}: {str(e)}",
461
+ source_function=f"{self.__class__.__name__}.delete",
462
+ original_error=e
463
+ )
464
+
465
+ async def real_row_delete(self, ulid: str) -> bool:
466
+ """엔티티를 실제로 삭제합니다.
467
+
468
+ Args:
469
+ ulid (str): 삭제할 엔티티의 ULID
470
+
471
+ Returns:
472
+ bool: 삭제 성공 여부
473
+
474
+ Raises:
475
+ CustomException: 데이터베이스 작업 중 오류 발생 시
476
+ """
477
+ try:
478
+ entity = await self.db_service.retrieve_entity(
479
+ self.model,
480
+ {"ulid": ulid}
481
+ )
482
+ if entity:
483
+ await self.db_service.delete_entity(entity)
484
+ return True
485
+ return False
486
+ except CustomException as e:
487
+ e.detail = f"Repository real delete error for {self.model.__tablename__}: {e.detail}"
488
+ e.source_function = f"{self.__class__.__name__}.real_row_delete -> {e.source_function}"
489
+ raise e
490
+ except SQLAlchemyError as e:
491
+ raise CustomException(
492
+ ErrorCode.DB_DELETE_ERROR,
493
+ detail=f"Database real delete error in {self.model.__tablename__}: {str(e)}",
494
+ source_function=f"{self.__class__.__name__}.real_row_delete",
495
+ original_error=e
496
+ )
497
+ except Exception as e:
498
+ raise CustomException(
499
+ ErrorCode.DB_DELETE_ERROR,
500
+ detail=f"Unexpected repository real delete error in {self.model.__tablename__}: {str(e)}",
501
+ source_function=f"{self.__class__.__name__}.real_row_delete",
502
+ original_error=e
503
+ )