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.
@@ -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
+ )