From 67741085890f8f32a805a3c662312f4608f497e4 Mon Sep 17 00:00:00 2001 From: Youngwook Kim Date: Thu, 29 Jan 2026 18:43:10 +0900 Subject: [PATCH 01/18] feat(filters): add CrudFilter TypedDict definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refine 호환 filter 타입 정의 추가: - LogicalFilter: 단일 필드 필터 (field, operator, value) - ConditionalFilter: OR/AND 조합 필터 - CrudFilter: LogicalFilter | ConditionalFilter Co-Authored-By: Claude Opus 4.5 --- python/aioia_core/__init__.py | 13 ++++++++ python/aioia_core/filters.py | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 python/aioia_core/filters.py diff --git a/python/aioia_core/__init__.py b/python/aioia_core/__init__.py index b74b5ba..2523642 100644 --- a/python/aioia_core/__init__.py +++ b/python/aioia_core/__init__.py @@ -18,6 +18,13 @@ extract_error_code_from_exception, get_error_detail_from_exception, ) +from aioia_core.filters import ( + ConditionalFilter, + ConditionalOperator, + CrudFilter, + FilterOperator, + LogicalFilter, +) from aioia_core.factories.base_repository_factory import BaseRepositoryFactory from aioia_core.models import Base, BaseModel from aioia_core.protocols import ( @@ -53,6 +60,12 @@ "INTERNAL_SERVER_ERROR", "extract_error_code_from_exception", "get_error_detail_from_exception", + # Filters + "CrudFilter", + "LogicalFilter", + "ConditionalFilter", + "FilterOperator", + "ConditionalOperator", # Settings "DatabaseSettings", "OpenAIAPISettings", diff --git a/python/aioia_core/filters.py b/python/aioia_core/filters.py new file mode 100644 index 0000000..82f70f9 --- /dev/null +++ b/python/aioia_core/filters.py @@ -0,0 +1,58 @@ +""" +Filter type definitions for AIoIA projects. + +Provides TypedDict definitions for CRUD filter operations, +compatible with Refine's filter structure. +""" + +from __future__ import annotations + +from typing import Any, Literal, TypedDict + +FilterOperator = Literal[ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "contains", + "startswith", + "endswith", + "null", + "nnull", +] + +ConditionalOperator = Literal["or", "and"] + + +class LogicalFilter(TypedDict, total=False): + """ + Single field filter condition. + + Example: + {"field": "status", "operator": "eq", "value": "active"} + """ + + field: str + operator: FilterOperator + value: Any + + +class ConditionalFilter(TypedDict, total=False): + """ + OR/AND combination filter. + + Example: + {"operator": "or", "value": [ + {"field": "status", "operator": "eq", "value": "active"}, + {"field": "status", "operator": "eq", "value": "pending"} + ]} + """ + + operator: ConditionalOperator + value: list[CrudFilter] + + +CrudFilter = LogicalFilter | ConditionalFilter From 8580886a075c689457b442de0410d78e6a1ba661 Mon Sep 17 00:00:00 2001 From: Youngwook Kim Date: Thu, 29 Jan 2026 18:43:41 +0900 Subject: [PATCH 02/18] refactor(protocols): apply CrudFilter type to filters parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit filters 파라미터 타입을 list[dict[str, Any]]에서 list[CrudFilter]로 변경 Co-Authored-By: Claude Opus 4.5 --- python/aioia_core/protocols.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/python/aioia_core/protocols.py b/python/aioia_core/protocols.py index df73649..6d08c39 100644 --- a/python/aioia_core/protocols.py +++ b/python/aioia_core/protocols.py @@ -6,7 +6,9 @@ from __future__ import annotations -from typing import Any, Generic, Protocol, TypeVar +from typing import Generic, Protocol, TypeVar + +from aioia_core.filters import CrudFilter from pydantic import BaseModel from sqlalchemy.orm import Session @@ -51,7 +53,7 @@ def get_all( current: int = 1, page_size: int = 10, sort: list[tuple[str, str]] | None = None, - filters: list[dict[str, Any]] | None = None, + filters: list[CrudFilter] | None = None, ) -> tuple[list[ModelType], int]: """ Retrieve all items with pagination, sorting, and filtering. @@ -61,7 +63,7 @@ def get_all( page_size: Number of items per page sort: Sort criteria as [(field, order), ...] where order is 'asc' or 'desc' Example: [('created_at', 'desc'), ('name', 'asc')] - filters: Filter conditions as [{'field': str, 'operator': str, 'value': Any}, ...] + filters: Filter conditions as list of CrudFilter (LogicalFilter or ConditionalFilter) Supported operators: eq, ne, contains, gt, gte, lt, lte, in, null, nnull, or, and Example: [{'field': 'status', 'operator': 'eq', 'value': 'active'}] From c751e99b364d0f7cd5d776f3bd6eab3ad51fe2f7 Mon Sep 17 00:00:00 2001 From: Youngwook Kim Date: Thu, 29 Jan 2026 18:44:36 +0900 Subject: [PATCH 03/18] refactor(repositories): apply CrudFilter and ColumnElement types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - filters 파라미터: list[dict[str, Any]] → list[CrudFilter] - _build_filter_conditions 반환: list[Any] → list[ColumnElement[bool]] Co-Authored-By: Claude Opus 4.5 --- python/aioia_core/repositories.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/python/aioia_core/repositories.py b/python/aioia_core/repositories.py index 722e45a..c3aaf2e 100644 --- a/python/aioia_core/repositories.py +++ b/python/aioia_core/repositories.py @@ -9,13 +9,14 @@ from abc import ABC from collections.abc import Callable from datetime import datetime, timezone -from typing import Any, Generic, TypeVar +from typing import Any, Generic, TypeVar, cast from uuid import uuid4 from pydantic import BaseModel as PydanticBaseModel -from sqlalchemy import and_, desc, or_ +from sqlalchemy import ColumnElement, and_, desc, or_ from sqlalchemy.orm import Session +from aioia_core.filters import CrudFilter from aioia_core.models import BaseModel ModelType = TypeVar("ModelType", bound=PydanticBaseModel) @@ -76,7 +77,7 @@ def get_all( current: int = 1, page_size: int = 10, sort: list[tuple[str, str]] | None = None, - filters: list[dict[str, Any]] | None = None, + filters: list[CrudFilter] | None = None, load_options: list[Any] | None = None, ) -> tuple[list[ModelType], int]: """ @@ -134,9 +135,11 @@ def get_all( return [self.convert_to_model(item) for item in db_items], total - def _build_filter_conditions(self, filters: list[dict[str, Any]]) -> list[Any]: + def _build_filter_conditions( + self, filters: list[CrudFilter] + ) -> list[ColumnElement[bool]]: """Recursively builds SQLAlchemy filter conditions from filter criteria.""" - conditions = [] + conditions: list[ColumnElement[bool]] = [] for filter_item in filters: operator = filter_item.get("operator") @@ -146,7 +149,9 @@ def _build_filter_conditions(self, filters: list[dict[str, Any]]) -> list[Any]: and "value" in filter_item and isinstance(filter_item["value"], list) ): - nested_conditions = self._build_filter_conditions(filter_item["value"]) + nested_conditions = self._build_filter_conditions( + cast(list[CrudFilter], filter_item["value"]) + ) if nested_conditions: if operator == "or": conditions.append(or_(*nested_conditions)) From 3e65a3af10e5dafe5fe030e81a4a0a0bcb1d5851 Mon Sep 17 00:00:00 2001 From: Youngwook Kim Date: Thu, 29 Jan 2026 18:45:19 +0900 Subject: [PATCH 04/18] refactor(fastapi): apply CrudFilter and narrow DeleteResponse.data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _decamelize_filter_fields: list[dict[str, Any]] → list[CrudFilter] - DeleteResponse.data: dict[str, Any] → dict[str, bool] Co-Authored-By: Claude Opus 4.5 --- python/aioia_core/fastapi/base_crud_router.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/python/aioia_core/fastapi/base_crud_router.py b/python/aioia_core/fastapi/base_crud_router.py index a3a2a28..a82ea09 100644 --- a/python/aioia_core/fastapi/base_crud_router.py +++ b/python/aioia_core/fastapi/base_crud_router.py @@ -23,6 +23,7 @@ RESOURCE_UPDATE_FAILED, ErrorResponse, ) +from aioia_core.filters import CrudFilter from aioia_core.protocols import DatabaseRepositoryProtocol, ModelType, RepositoryType # TypeVar for _create_repository_dependency_from_factory method @@ -48,7 +49,7 @@ class SingleItemResponse(BaseModel, Generic[ModelType]): class DeleteResponse(BaseModel): """Delete operation response model""" - data: dict[str, Any] + data: dict[str, bool] # TypeVar design decision: No contravariance for concrete router implementation @@ -536,8 +537,8 @@ def _get_item_or_404(self, repository: RepositoryType, item_id: str) -> ModelTyp return item def _decamelize_filter_fields( - self, filters: list[dict[str, Any]] - ) -> list[dict[str, Any]]: + self, filters: list[CrudFilter] + ) -> list[CrudFilter]: """Recursively traverses the filter structure and decamelizes field names.""" processed_filters = [] for filter_item in filters: From 9ad48a367562397853887db51d5f010537704093 Mon Sep 17 00:00:00 2001 From: Youngwook Kim Date: Thu, 29 Jan 2026 18:47:55 +0900 Subject: [PATCH 05/18] fix(fastapi): revert DeleteResponse.data to dict[str, Any] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dict[str, bool] 변경 시 실제 반환 구조 {id, type, metadata}와 불일치 Co-Authored-By: Claude Opus 4.5 --- python/aioia_core/fastapi/base_crud_router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/aioia_core/fastapi/base_crud_router.py b/python/aioia_core/fastapi/base_crud_router.py index a82ea09..66a2ec0 100644 --- a/python/aioia_core/fastapi/base_crud_router.py +++ b/python/aioia_core/fastapi/base_crud_router.py @@ -49,7 +49,7 @@ class SingleItemResponse(BaseModel, Generic[ModelType]): class DeleteResponse(BaseModel): """Delete operation response model""" - data: dict[str, bool] + data: dict[str, Any] # TypeVar design decision: No contravariance for concrete router implementation From e14d17cd1f8a901da10552388a857f1e11db77fc Mon Sep 17 00:00:00 2001 From: Youngwook Kim Date: Thu, 29 Jan 2026 19:53:44 +0900 Subject: [PATCH 06/18] refactor(repositories): remove unnecessary cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pyright가 isinstance 체크만으로 타입을 추론함 Co-Authored-By: Claude Opus 4.5 --- python/aioia_core/repositories.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/python/aioia_core/repositories.py b/python/aioia_core/repositories.py index c3aaf2e..b13d612 100644 --- a/python/aioia_core/repositories.py +++ b/python/aioia_core/repositories.py @@ -9,7 +9,7 @@ from abc import ABC from collections.abc import Callable from datetime import datetime, timezone -from typing import Any, Generic, TypeVar, cast +from typing import Any, Generic, TypeVar from uuid import uuid4 from pydantic import BaseModel as PydanticBaseModel @@ -149,9 +149,7 @@ def _build_filter_conditions( and "value" in filter_item and isinstance(filter_item["value"], list) ): - nested_conditions = self._build_filter_conditions( - cast(list[CrudFilter], filter_item["value"]) - ) + nested_conditions = self._build_filter_conditions(filter_item["value"]) if nested_conditions: if operator == "or": conditions.append(or_(*nested_conditions)) From c97ed9560c9c2f54e221a254f8fdadd258bb965f Mon Sep 17 00:00:00 2001 From: Youngwook Kim Date: Thu, 29 Jan 2026 20:13:02 +0900 Subject: [PATCH 07/18] refactor: move CrudFilter types from filters.py to protocols.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 아키텍처 원칙 (DIP) 적용: - Protocol과 관련 타입이 같은 파일에 위치 - 올바른 의존성 방향: repositories.py → protocols.py Co-Authored-By: Claude Opus 4.5 --- python/aioia_core/__init__.py | 12 ++-- python/aioia_core/fastapi/base_crud_router.py | 8 ++- python/aioia_core/filters.py | 58 ----------------- python/aioia_core/protocols.py | 65 +++++++++++++++++-- python/aioia_core/repositories.py | 2 +- 5 files changed, 71 insertions(+), 74 deletions(-) delete mode 100644 python/aioia_core/filters.py diff --git a/python/aioia_core/__init__.py b/python/aioia_core/__init__.py index 2523642..24da735 100644 --- a/python/aioia_core/__init__.py +++ b/python/aioia_core/__init__.py @@ -18,18 +18,16 @@ extract_error_code_from_exception, get_error_detail_from_exception, ) -from aioia_core.filters import ( - ConditionalFilter, - ConditionalOperator, - CrudFilter, - FilterOperator, - LogicalFilter, -) from aioia_core.factories.base_repository_factory import BaseRepositoryFactory from aioia_core.models import Base, BaseModel from aioia_core.protocols import ( + ConditionalFilter, + ConditionalOperator, + CrudFilter, CrudRepositoryProtocol, DatabaseRepositoryProtocol, + FilterOperator, + LogicalFilter, ) from aioia_core.repositories import BaseRepository from aioia_core.settings import DatabaseSettings, JWTSettings, OpenAIAPISettings diff --git a/python/aioia_core/fastapi/base_crud_router.py b/python/aioia_core/fastapi/base_crud_router.py index 66a2ec0..8f31b54 100644 --- a/python/aioia_core/fastapi/base_crud_router.py +++ b/python/aioia_core/fastapi/base_crud_router.py @@ -23,8 +23,12 @@ RESOURCE_UPDATE_FAILED, ErrorResponse, ) -from aioia_core.filters import CrudFilter -from aioia_core.protocols import DatabaseRepositoryProtocol, ModelType, RepositoryType +from aioia_core.protocols import ( + CrudFilter, + DatabaseRepositoryProtocol, + ModelType, + RepositoryType, +) # TypeVar for _create_repository_dependency_from_factory method FactoryRepositoryType = TypeVar("FactoryRepositoryType", bound=DatabaseRepositoryProtocol) diff --git a/python/aioia_core/filters.py b/python/aioia_core/filters.py deleted file mode 100644 index 82f70f9..0000000 --- a/python/aioia_core/filters.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -Filter type definitions for AIoIA projects. - -Provides TypedDict definitions for CRUD filter operations, -compatible with Refine's filter structure. -""" - -from __future__ import annotations - -from typing import Any, Literal, TypedDict - -FilterOperator = Literal[ - "eq", - "ne", - "gt", - "gte", - "lt", - "lte", - "in", - "contains", - "startswith", - "endswith", - "null", - "nnull", -] - -ConditionalOperator = Literal["or", "and"] - - -class LogicalFilter(TypedDict, total=False): - """ - Single field filter condition. - - Example: - {"field": "status", "operator": "eq", "value": "active"} - """ - - field: str - operator: FilterOperator - value: Any - - -class ConditionalFilter(TypedDict, total=False): - """ - OR/AND combination filter. - - Example: - {"operator": "or", "value": [ - {"field": "status", "operator": "eq", "value": "active"}, - {"field": "status", "operator": "eq", "value": "pending"} - ]} - """ - - operator: ConditionalOperator - value: list[CrudFilter] - - -CrudFilter = LogicalFilter | ConditionalFilter diff --git a/python/aioia_core/protocols.py b/python/aioia_core/protocols.py index 6d08c39..768598d 100644 --- a/python/aioia_core/protocols.py +++ b/python/aioia_core/protocols.py @@ -1,18 +1,65 @@ """ -CRUD repository protocol definition for AIoIA projects. +CRUD repository protocol and type definitions for AIoIA projects. -Defines the interface for generic CRUD operations. +Defines the interface for generic CRUD operations and filter types. """ from __future__ import annotations -from typing import Generic, Protocol, TypeVar - -from aioia_core.filters import CrudFilter +from typing import Any, Generic, Literal, Protocol, TypedDict, TypeVar from pydantic import BaseModel from sqlalchemy.orm import Session +# Filter type definitions (compatible with Refine's filter structure) +FilterOperator = Literal[ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "contains", + "startswith", + "endswith", + "null", + "nnull", +] + +ConditionalOperator = Literal["or", "and"] + + +class LogicalFilter(TypedDict, total=False): + """ + Single field filter condition. + + Example: + {"field": "status", "operator": "eq", "value": "active"} + """ + + field: str + operator: FilterOperator + value: Any + + +class ConditionalFilter(TypedDict, total=False): + """ + OR/AND combination filter. + + Example: + {"operator": "or", "value": [ + {"field": "status", "operator": "eq", "value": "active"}, + {"field": "status", "operator": "eq", "value": "pending"} + ]} + """ + + operator: ConditionalOperator + value: list[CrudFilter] + + +CrudFilter = LogicalFilter | ConditionalFilter + ModelType = TypeVar("ModelType", bound=BaseModel) CreateSchemaType_contra = TypeVar( "CreateSchemaType_contra", bound=BaseModel, contravariant=True @@ -145,7 +192,13 @@ def __init__(self, db_session: Session) -> None: # For re-export compatibility, also export ModelType __all__ = [ - # New names (recommended) + # Filter types + "CrudFilter", + "LogicalFilter", + "ConditionalFilter", + "FilterOperator", + "ConditionalOperator", + # Protocols (recommended) "CrudRepositoryProtocol", "DatabaseRepositoryProtocol", "RepositoryType", diff --git a/python/aioia_core/repositories.py b/python/aioia_core/repositories.py index b13d612..443ce58 100644 --- a/python/aioia_core/repositories.py +++ b/python/aioia_core/repositories.py @@ -16,8 +16,8 @@ from sqlalchemy import ColumnElement, and_, desc, or_ from sqlalchemy.orm import Session -from aioia_core.filters import CrudFilter from aioia_core.models import BaseModel +from aioia_core.protocols import CrudFilter ModelType = TypeVar("ModelType", bound=PydanticBaseModel) DBModelType = TypeVar("DBModelType", bound=BaseModel) From b330cd068e4e13770eaab5077539b767afa6c450 Mon Sep 17 00:00:00 2001 From: Youngwook Kim Date: Thu, 29 Jan 2026 20:32:02 +0900 Subject: [PATCH 08/18] refactor: rename protocols.py to types.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python 커뮤니티 관례 (FastAPI, Pydantic) 준수 Co-Authored-By: Claude Opus 4.5 --- python/aioia_core/__init__.py | 4 ++-- python/aioia_core/factories/base_manager_factory.py | 2 +- python/aioia_core/factories/base_repository_factory.py | 2 +- python/aioia_core/fastapi/base_crud_router.py | 2 +- python/aioia_core/repositories.py | 2 +- python/aioia_core/testing/crud_fixtures.py | 2 +- python/aioia_core/{protocols.py => types.py} | 0 7 files changed, 7 insertions(+), 7 deletions(-) rename python/aioia_core/{protocols.py => types.py} (100%) diff --git a/python/aioia_core/__init__.py b/python/aioia_core/__init__.py index 24da735..233bd8c 100644 --- a/python/aioia_core/__init__.py +++ b/python/aioia_core/__init__.py @@ -20,7 +20,7 @@ ) from aioia_core.factories.base_repository_factory import BaseRepositoryFactory from aioia_core.models import Base, BaseModel -from aioia_core.protocols import ( +from aioia_core.types import ( ConditionalFilter, ConditionalOperator, CrudFilter, @@ -35,7 +35,7 @@ # Deprecated imports for backwards compatibility from aioia_core.factories.base_manager_factory import BaseManagerFactory from aioia_core.managers import BaseManager -from aioia_core.protocols import CrudManagerProtocol, DatabaseManagerProtocol +from aioia_core.types import CrudManagerProtocol, DatabaseManagerProtocol __all__ = [ # Database - New names (recommended) diff --git a/python/aioia_core/factories/base_manager_factory.py b/python/aioia_core/factories/base_manager_factory.py index db080fc..34962a5 100644 --- a/python/aioia_core/factories/base_manager_factory.py +++ b/python/aioia_core/factories/base_manager_factory.py @@ -20,7 +20,7 @@ # Re-export from base_repository_factory module from aioia_core.factories.base_repository_factory import BaseRepositoryFactory -from aioia_core.protocols import DatabaseRepositoryProtocol +from aioia_core.types import DatabaseRepositoryProtocol # TypeVar for backwards compatibility (cannot alias TypeVar directly) ManagerType = TypeVar("ManagerType", bound=DatabaseRepositoryProtocol) diff --git a/python/aioia_core/factories/base_repository_factory.py b/python/aioia_core/factories/base_repository_factory.py index c5e910e..f773684 100644 --- a/python/aioia_core/factories/base_repository_factory.py +++ b/python/aioia_core/factories/base_repository_factory.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Session, sessionmaker -from aioia_core.protocols import DatabaseRepositoryProtocol +from aioia_core.types import DatabaseRepositoryProtocol # RepositoryType을 DatabaseRepositoryProtocol에 바인딩 RepositoryType = TypeVar("RepositoryType", bound=DatabaseRepositoryProtocol) diff --git a/python/aioia_core/fastapi/base_crud_router.py b/python/aioia_core/fastapi/base_crud_router.py index 8f31b54..51fcf1d 100644 --- a/python/aioia_core/fastapi/base_crud_router.py +++ b/python/aioia_core/fastapi/base_crud_router.py @@ -23,7 +23,7 @@ RESOURCE_UPDATE_FAILED, ErrorResponse, ) -from aioia_core.protocols import ( +from aioia_core.types import ( CrudFilter, DatabaseRepositoryProtocol, ModelType, diff --git a/python/aioia_core/repositories.py b/python/aioia_core/repositories.py index 443ce58..12f1349 100644 --- a/python/aioia_core/repositories.py +++ b/python/aioia_core/repositories.py @@ -17,7 +17,7 @@ from sqlalchemy.orm import Session from aioia_core.models import BaseModel -from aioia_core.protocols import CrudFilter +from aioia_core.types import CrudFilter ModelType = TypeVar("ModelType", bound=PydanticBaseModel) DBModelType = TypeVar("DBModelType", bound=BaseModel) diff --git a/python/aioia_core/testing/crud_fixtures.py b/python/aioia_core/testing/crud_fixtures.py index 716be12..ae7c4b6 100644 --- a/python/aioia_core/testing/crud_fixtures.py +++ b/python/aioia_core/testing/crud_fixtures.py @@ -6,7 +6,7 @@ from sqlalchemy import DateTime, Integer, String, or_ from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column -from aioia_core.protocols import DatabaseRepositoryProtocol +from aioia_core.types import DatabaseRepositoryProtocol from aioia_core.factories.base_repository_factory import BaseRepositoryFactory diff --git a/python/aioia_core/protocols.py b/python/aioia_core/types.py similarity index 100% rename from python/aioia_core/protocols.py rename to python/aioia_core/types.py From c479185897a701568f618fc67d2baedb36ae27b4 Mon Sep 17 00:00:00 2001 From: Youngwook Kim Date: Thu, 29 Jan 2026 23:00:06 +0900 Subject: [PATCH 09/18] fix(repositories): add isinstance guard for field type narrowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Union TypedDict에서 한쪽에만 존재하는 키 접근 시 object로 추론되는 문제 해결. isinstance(field, str) 체크로 런타임 검증과 타입 narrowing 동시 수행. Co-Authored-By: Claude Opus 4.5 --- python/aioia_core/repositories.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/aioia_core/repositories.py b/python/aioia_core/repositories.py index 12f1349..58844fb 100644 --- a/python/aioia_core/repositories.py +++ b/python/aioia_core/repositories.py @@ -159,7 +159,7 @@ def _build_filter_conditions( # Logical Filter field = filter_item.get("field") - if not field: + if not isinstance(field, str): continue column = getattr(self.db_model, field, None) From 463156cd6dea5c539d72354ade13e2d9db9ec9c5 Mon Sep 17 00:00:00 2001 From: Youngwook Kim Date: Thu, 29 Jan 2026 23:04:23 +0900 Subject: [PATCH 10/18] fix(fastapi): use cast for TypedDict spread limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spread 연산({**filter_item, ...})이 TypedDict 타입 정보를 잃는 문제 해결. - processed_filters를 list[Any]로 처리 - 반환 시 cast(list[CrudFilter], ...)로 함수 계약 명시 - filter_item.get("field", "")로 Union TypedDict 키 접근 cast 필요 이유: TypedDict Union에서 spread 시 operator 타입이 FilterOperator | ConditionalOperator로 병합되어 정확한 멤버 결정 불가. Co-Authored-By: Claude Opus 4.5 --- python/aioia_core/fastapi/base_crud_router.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python/aioia_core/fastapi/base_crud_router.py b/python/aioia_core/fastapi/base_crud_router.py index 51fcf1d..956939a 100644 --- a/python/aioia_core/fastapi/base_crud_router.py +++ b/python/aioia_core/fastapi/base_crud_router.py @@ -2,7 +2,7 @@ import warnings from collections.abc import Callable, Sequence from datetime import datetime, timezone -from typing import Any, Generic, TypeVar +from typing import Any, Generic, TypeVar, cast import sentry_sdk from fastapi import APIRouter, Body, Depends, HTTPException, Query, status @@ -544,7 +544,7 @@ def _decamelize_filter_fields( self, filters: list[CrudFilter] ) -> list[CrudFilter]: """Recursively traverses the filter structure and decamelizes field names.""" - processed_filters = [] + processed_filters: list[Any] = [] for filter_item in filters: # Conditional Filter (or/and) if ( @@ -563,14 +563,14 @@ def _decamelize_filter_fields( processed_filters.append( { **filter_item, - "field": decamelize(str(filter_item["field"])), + "field": decamelize(str(filter_item.get("field", ""))), } ) # Unrecognized filter structure, append as is else: processed_filters.append(filter_item) - return processed_filters + return cast(list[CrudFilter], processed_filters) def _parse_query_params( self, sort_param: str | None, filters_param: str | None From 476983ee23625e6b676fd206f208363316d687af Mon Sep 17 00:00:00 2001 From: Youngwook Kim Date: Thu, 29 Jan 2026 23:06:49 +0900 Subject: [PATCH 11/18] fix(tests): add explicit CrudFilter type annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dict 리터럴이 TypedDict로 자동 추론되지 않는 문제 해결. filters 변수에 list[CrudFilter] 타입 주석 추가. Co-Authored-By: Claude Opus 4.5 --- python/tests/unit/test_base_repository.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/python/tests/unit/test_base_repository.py b/python/tests/unit/test_base_repository.py index e3ccde0..9dfedf2 100644 --- a/python/tests/unit/test_base_repository.py +++ b/python/tests/unit/test_base_repository.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Mapped, mapped_column from aioia_core.repositories import BaseRepository +from aioia_core.types import CrudFilter from aioia_core.models import BaseModel as DBBaseModel from aioia_core.testing.database_manager import TestDatabaseManager @@ -280,7 +281,7 @@ def test_conditional_filters(self): # OR 조건 테스트 # title이 'Apple' 이거나 content가 'Yellow fruit'인 경우 - filters = [ + or_filters: list[CrudFilter] = [ { "operator": "or", "value": [ @@ -289,7 +290,7 @@ def test_conditional_filters(self): ], } ] - items, total = self.repository.get_all(filters=filters) + items, total = self.repository.get_all(filters=or_filters) self.assertEqual(total, 3) titles = {item.title for item in items} self.assertIn("Apple", titles) @@ -297,7 +298,7 @@ def test_conditional_filters(self): # AND와 OR 중첩 조건 테스트 # (title이 'Apple' AND content가 'Red fruit') OR (title이 'Banana') - filters = [ + nested_filters: list[CrudFilter] = [ { "operator": "or", "value": [ @@ -316,7 +317,7 @@ def test_conditional_filters(self): ], } ] - items, total = self.repository.get_all(filters=filters) + items, total = self.repository.get_all(filters=nested_filters) self.assertEqual(total, 2) retrieved_titles = {item.title for item in items} self.assertEqual(retrieved_titles, {"Apple", "Banana"}) From 034d9854a192750134813e137fc9ac4d78739d64 Mon Sep 17 00:00:00 2001 From: Youngwook Kim Date: Thu, 29 Jan 2026 23:11:32 +0900 Subject: [PATCH 12/18] fix(testing): use .get() for optional TypedDict keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit total=False TypedDict에서 optional 키 접근 시 .get() 사용. pyright의 reportTypedDictNotRequiredAccess 에러 해결. Co-Authored-By: Claude Opus 4.5 --- python/aioia_core/testing/crud_fixtures.py | 32 +++++++++++++--------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/python/aioia_core/testing/crud_fixtures.py b/python/aioia_core/testing/crud_fixtures.py index ae7c4b6..3aaae49 100644 --- a/python/aioia_core/testing/crud_fixtures.py +++ b/python/aioia_core/testing/crud_fixtures.py @@ -63,24 +63,30 @@ def get_all(self, current=1, page_size=10, sort=None, filters=None): if filters: # Simplified filter handling for tests, not a full implementation for f in filters: - if f["operator"] == "eq": - column = getattr(TestDBModel, f["field"]) - value = f["value"] + op = f.get("operator") + field = f.get("field") + value = f.get("value") + + if op == "eq" and field: + column = getattr(TestDBModel, field) if isinstance(column.type, DateTime) and isinstance(value, str): value = datetime.fromisoformat(value) q = q.filter(column == value) - elif f["operator"] == "in": - q = q.filter(getattr(TestDBModel, f["field"]).in_(f["value"])) - elif f["operator"] == "null": - q = q.filter(getattr(TestDBModel, f["field"]).is_(None)) - elif f["operator"] == "nnull": - q = q.filter(getattr(TestDBModel, f["field"]).isnot(None)) - elif f["operator"] == "or": + elif op == "in" and field: + q = q.filter(getattr(TestDBModel, field).in_(value)) + elif op == "null" and field: + q = q.filter(getattr(TestDBModel, field).is_(None)) + elif op == "nnull" and field: + q = q.filter(getattr(TestDBModel, field).isnot(None)) + elif op == "or" and isinstance(value, list): or_conditions = [] - for or_f in f["value"]: - if or_f["operator"] == "eq": + for or_f in value: + or_op = or_f.get("operator") + or_field = or_f.get("field") + or_value = or_f.get("value") + if or_op == "eq" and or_field: or_conditions.append( - getattr(TestDBModel, or_f["field"]) == or_f["value"] + getattr(TestDBModel, or_field) == or_value ) q = q.filter(or_(*or_conditions)) From b88e19f4885fdf6cfb77da625479c24ae627a79d Mon Sep 17 00:00:00 2001 From: Youngwook Kim Date: Fri, 30 Jan 2026 08:02:16 +0900 Subject: [PATCH 13/18] refactor(types): use NotRequired for optional TypedDict keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit total=False 대신 NotRequired 패턴 사용으로 필수/선택 키 명확화. - LogicalFilter: field, operator 필수, value optional (null/nnull) - ConditionalFilter: operator, value 모두 필수 Co-Authored-By: Claude Opus 4.5 --- python/aioia_core/types.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/python/aioia_core/types.py b/python/aioia_core/types.py index 768598d..e5ccd13 100644 --- a/python/aioia_core/types.py +++ b/python/aioia_core/types.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import Any, Generic, Literal, Protocol, TypedDict, TypeVar +from typing import Any, Generic, Literal, NotRequired, Protocol, TypedDict, TypeVar from pydantic import BaseModel from sqlalchemy.orm import Session @@ -30,20 +30,21 @@ ConditionalOperator = Literal["or", "and"] -class LogicalFilter(TypedDict, total=False): +class LogicalFilter(TypedDict): """ Single field filter condition. Example: {"field": "status", "operator": "eq", "value": "active"} + {"field": "status", "operator": "null"} # value not required for null/nnull """ field: str operator: FilterOperator - value: Any + value: NotRequired[Any] -class ConditionalFilter(TypedDict, total=False): +class ConditionalFilter(TypedDict): """ OR/AND combination filter. From e07cf4c6387047199a325f7e058ea77d0c52ddce Mon Sep 17 00:00:00 2001 From: Youngwook Kim Date: Fri, 30 Jan 2026 10:13:45 +0900 Subject: [PATCH 14/18] feat(types): add TypeGuard functions for CrudFilter narrowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_logical_filter, is_conditional_filter TypeGuard 함수 추가. mypy가 in 체크로 TypedDict narrowing을 지원하지 않으므로 명시적 TypeGuard로 타입 좁히기 구현. Co-Authored-By: Claude Opus 4.5 --- python/aioia_core/__init__.py | 4 ++++ python/aioia_core/types.py | 22 +++++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/python/aioia_core/__init__.py b/python/aioia_core/__init__.py index 233bd8c..89856ce 100644 --- a/python/aioia_core/__init__.py +++ b/python/aioia_core/__init__.py @@ -28,6 +28,8 @@ DatabaseRepositoryProtocol, FilterOperator, LogicalFilter, + is_conditional_filter, + is_logical_filter, ) from aioia_core.repositories import BaseRepository from aioia_core.settings import DatabaseSettings, JWTSettings, OpenAIAPISettings @@ -64,6 +66,8 @@ "ConditionalFilter", "FilterOperator", "ConditionalOperator", + "is_logical_filter", + "is_conditional_filter", # Settings "DatabaseSettings", "OpenAIAPISettings", diff --git a/python/aioia_core/types.py b/python/aioia_core/types.py index e5ccd13..fc2fc81 100644 --- a/python/aioia_core/types.py +++ b/python/aioia_core/types.py @@ -6,7 +6,16 @@ from __future__ import annotations -from typing import Any, Generic, Literal, NotRequired, Protocol, TypedDict, TypeVar +from typing import ( + Any, + Generic, + Literal, + NotRequired, + Protocol, + TypedDict, + TypeGuard, + TypeVar, +) from pydantic import BaseModel from sqlalchemy.orm import Session @@ -61,6 +70,17 @@ class ConditionalFilter(TypedDict): CrudFilter = LogicalFilter | ConditionalFilter + +def is_logical_filter(f: CrudFilter) -> TypeGuard[LogicalFilter]: + """Type guard to narrow CrudFilter to LogicalFilter.""" + return "field" in f + + +def is_conditional_filter(f: CrudFilter) -> TypeGuard[ConditionalFilter]: + """Type guard to narrow CrudFilter to ConditionalFilter.""" + return "field" not in f and "operator" in f + + ModelType = TypeVar("ModelType", bound=BaseModel) CreateSchemaType_contra = TypeVar( "CreateSchemaType_contra", bound=BaseModel, contravariant=True From 59306068b99e11908055957e8ce60cdd1c0a47f8 Mon Sep 17 00:00:00 2001 From: Youngwook Kim Date: Fri, 30 Jan 2026 10:14:44 +0900 Subject: [PATCH 15/18] refactor(fastapi): apply TypeGuard for filter type narrowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_logical_filter, is_conditional_filter TypeGuard 사용으로 명시적 타입 좁히기 적용. .get() 암시적 회피 제거. Co-Authored-By: Claude Opus 4.5 --- python/aioia_core/fastapi/base_crud_router.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/python/aioia_core/fastapi/base_crud_router.py b/python/aioia_core/fastapi/base_crud_router.py index 956939a..a3c56e7 100644 --- a/python/aioia_core/fastapi/base_crud_router.py +++ b/python/aioia_core/fastapi/base_crud_router.py @@ -28,6 +28,8 @@ DatabaseRepositoryProtocol, ModelType, RepositoryType, + is_conditional_filter, + is_logical_filter, ) # TypeVar for _create_repository_dependency_from_factory method @@ -546,27 +548,20 @@ def _decamelize_filter_fields( """Recursively traverses the filter structure and decamelizes field names.""" processed_filters: list[Any] = [] for filter_item in filters: - # Conditional Filter (or/and) - if ( - filter_item.get("operator") in {"or", "and"} - and "value" in filter_item - and isinstance(filter_item["value"], list) - ): + if is_conditional_filter(filter_item): processed_filters.append( { **filter_item, "value": self._decamelize_filter_fields(filter_item["value"]), } ) - # Logical Filter - elif "field" in filter_item: + elif is_logical_filter(filter_item): processed_filters.append( { **filter_item, - "field": decamelize(str(filter_item.get("field", ""))), + "field": decamelize(filter_item["field"]), } ) - # Unrecognized filter structure, append as is else: processed_filters.append(filter_item) From fa365246cdbaec5be66092cbe409a9645f04e797 Mon Sep 17 00:00:00 2001 From: Youngwook Kim Date: Fri, 30 Jan 2026 10:15:44 +0900 Subject: [PATCH 16/18] refactor(repositories): apply TypeGuard for filter type narrowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_logical_filter, is_conditional_filter TypeGuard 사용으로 명시적 타입 좁히기 적용. isinstance 체크 및 .get() 암시적 회피 제거. Co-Authored-By: Claude Opus 4.5 --- python/aioia_core/repositories.py | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/python/aioia_core/repositories.py b/python/aioia_core/repositories.py index 58844fb..f8526e8 100644 --- a/python/aioia_core/repositories.py +++ b/python/aioia_core/repositories.py @@ -17,7 +17,7 @@ from sqlalchemy.orm import Session from aioia_core.models import BaseModel -from aioia_core.types import CrudFilter +from aioia_core.types import CrudFilter, is_conditional_filter, is_logical_filter ModelType = TypeVar("ModelType", bound=PydanticBaseModel) DBModelType = TypeVar("DBModelType", bound=BaseModel) @@ -141,27 +141,21 @@ def _build_filter_conditions( """Recursively builds SQLAlchemy filter conditions from filter criteria.""" conditions: list[ColumnElement[bool]] = [] for filter_item in filters: - operator = filter_item.get("operator") - - # Conditional Filter (or/and) - if ( - operator in {"or", "and"} - and "value" in filter_item - and isinstance(filter_item["value"], list) - ): + if is_conditional_filter(filter_item): nested_conditions = self._build_filter_conditions(filter_item["value"]) if nested_conditions: - if operator == "or": + if filter_item["operator"] == "or": conditions.append(or_(*nested_conditions)) else: conditions.append(and_(*nested_conditions)) continue - # Logical Filter - field = filter_item.get("field") - if not isinstance(field, str): + if not is_logical_filter(filter_item): continue + field = filter_item["field"] + operator = filter_item["operator"] + column = getattr(self.db_model, field, None) if column is None: continue From 5f8ec9719b6fa883d8d091760b8f243ed97cd581 Mon Sep 17 00:00:00 2001 From: Youngwook Kim Date: Fri, 30 Jan 2026 10:48:31 +0900 Subject: [PATCH 17/18] fix(types): add TypeGuard functions to __all__ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_logical_filter, is_conditional_filter를 __all__에 추가. Co-Authored-By: Claude Opus 4.5 --- python/aioia_core/types.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/python/aioia_core/types.py b/python/aioia_core/types.py index fc2fc81..598d899 100644 --- a/python/aioia_core/types.py +++ b/python/aioia_core/types.py @@ -219,6 +219,8 @@ def __init__(self, db_session: Session) -> None: "ConditionalFilter", "FilterOperator", "ConditionalOperator", + "is_logical_filter", + "is_conditional_filter", # Protocols (recommended) "CrudRepositoryProtocol", "DatabaseRepositoryProtocol", From 7fd42e4edfe52b693a7841fad39f21db8db02404 Mon Sep 17 00:00:00 2001 From: Youngwook Kim Date: Fri, 30 Jan 2026 11:50:25 +0900 Subject: [PATCH 18/18] refactor(types): remove __all__ to resolve duplicate-code warning Remove __all__ from types.py as it duplicates exports already defined in __init__.py. The module-level __all__ was unused (no wildcard imports found in the project) and caused pylint R0801 duplicate-code warning. Package-level API is controlled solely through __init__.py's __all__. Co-Authored-By: Claude Opus 4.5 --- python/aioia_core/types.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/python/aioia_core/types.py b/python/aioia_core/types.py index 598d899..902efaa 100644 --- a/python/aioia_core/types.py +++ b/python/aioia_core/types.py @@ -210,24 +210,3 @@ def __init__(self, db_session: Session) -> None: # TypeVar aliases need to be redefined (cannot alias TypeVar directly) ManagerType = TypeVar("ManagerType", bound=CrudRepositoryProtocol) - -# For re-export compatibility, also export ModelType -__all__ = [ - # Filter types - "CrudFilter", - "LogicalFilter", - "ConditionalFilter", - "FilterOperator", - "ConditionalOperator", - "is_logical_filter", - "is_conditional_filter", - # Protocols (recommended) - "CrudRepositoryProtocol", - "DatabaseRepositoryProtocol", - "RepositoryType", - "ModelType", - # Deprecated aliases (backwards compatibility) - "CrudManagerProtocol", - "DatabaseManagerProtocol", - "ManagerType", -]