diff --git a/demo/use_case/fastapi_auto.py b/demo/use_case/fastapi_auto.py index 3a7351d..cf04c4f 100644 --- a/demo/use_case/fastapi_auto.py +++ b/demo/use_case/fastapi_auto.py @@ -38,7 +38,6 @@ query, ) - # ────────────────────────────────────────────────── # Service with FromContext # ────────────────────────────────────────────────── diff --git a/demo/use_case/mcp_server.py b/demo/use_case/mcp_server.py index 171bf19..c3434b7 100644 --- a/demo/use_case/mcp_server.py +++ b/demo/use_case/mcp_server.py @@ -23,7 +23,6 @@ UseCaseService, build_dto_select, create_use_case_mcp_server, - mutation, query, ) diff --git a/skill/template/src/main.py b/skill/template/src/main.py index 7e57ce8..9f244f4 100644 --- a/skill/template/src/main.py +++ b/skill/template/src/main.py @@ -5,17 +5,17 @@ Phase 3: + REST + MCP + Voyager with services """ from contextlib import asynccontextmanager -from typing import Any, Optional +from typing import Any from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, PlainTextResponse from pydantic import BaseModel -from src.db import async_session +from sqlmodel_nexus import GraphQLHandler from src.database import init_db +from src.db import async_session from src.models import BaseEntity -from sqlmodel_nexus import GraphQLHandler # ── GraphQL handler ─────────────────────────────────────────────────── @@ -27,8 +27,8 @@ # ── MCP apps (must be created before lifespan to combine lifespans) ─── -from sqlmodel_nexus.mcp import create_mcp_server # noqa: E402 from sqlmodel_nexus import UseCaseAppConfig, create_use_case_mcp_server # noqa: E402 +from sqlmodel_nexus.mcp import create_mcp_server # noqa: E402 from src.models import er # noqa: E402 from src.service.sprint.service import SprintService # noqa: E402 from src.service.task.service import TaskService # noqa: E402 @@ -101,8 +101,8 @@ async def lifespan(app: FastAPI): class GraphQLRequest(BaseModel): query: str - variables: Optional[dict[str, Any]] = None - operation_name: Optional[str] = None + variables: dict[str, Any] | None = None + operation_name: str | None = None @app.get("/graphql", response_class=HTMLResponse) diff --git a/skill/template/src/models.py b/skill/template/src/models.py index 235bf38..3cb0ae2 100644 --- a/skill/template/src/models.py +++ b/skill/template/src/models.py @@ -11,7 +11,6 @@ from sqlmodel import Field, Relationship, SQLModel, select from sqlmodel_nexus import mutation, query - from src.db import async_session diff --git a/skill/template/src/service/sprint/dtos.py b/skill/template/src/service/sprint/dtos.py index 15f8e1a..82f8324 100644 --- a/skill/template/src/service/sprint/dtos.py +++ b/skill/template/src/service/sprint/dtos.py @@ -1,6 +1,5 @@ """Sprint-related DTOs — SprintSummary with derived fields.""" from sqlmodel_nexus import DefineSubset, SubsetConfig - from src.models import Sprint from src.service.task.dtos import TaskSummary diff --git a/skill/template/src/service/sprint/service.py b/skill/template/src/service/sprint/service.py index 5c256cf..071c36d 100644 --- a/skill/template/src/service/sprint/service.py +++ b/skill/template/src/service/sprint/service.py @@ -1,6 +1,5 @@ """Sprint UseCaseService — sprint management with task statistics.""" from sqlmodel_nexus import UseCaseService, build_dto_select, query - from src.database import async_session from src.models import Resolver, Sprint from src.service.sprint.dtos import SprintSummary diff --git a/skill/template/src/service/task/dtos.py b/skill/template/src/service/task/dtos.py index 0e17863..ff70c3c 100644 --- a/skill/template/src/service/task/dtos.py +++ b/skill/template/src/service/task/dtos.py @@ -1,6 +1,5 @@ """Task-related DTOs — UserSummary, TaskSummary.""" from sqlmodel_nexus import DefineSubset, SubsetConfig - from src.models import Task, User diff --git a/skill/template/src/service/task/service.py b/skill/template/src/service/task/service.py index 9045563..84ed3c5 100644 --- a/skill/template/src/service/task/service.py +++ b/skill/template/src/service/task/service.py @@ -1,6 +1,5 @@ """Task UseCaseService — task management with auto-loaded owner.""" from sqlmodel_nexus import UseCaseService, build_dto_select, query - from src.database import async_session from src.models import Resolver, Task from src.service.task.dtos import TaskSummary diff --git a/src/sqlmodel_nexus/voyager/type_helper.py b/src/sqlmodel_nexus/voyager/type_helper.py index 3079731..0c10c9e 100644 --- a/src/sqlmodel_nexus/voyager/type_helper.py +++ b/src/sqlmodel_nexus/voyager/type_helper.py @@ -285,17 +285,22 @@ def safe_issubclass(kls, target_kls): return False -def update_forward_refs(kls): +def update_forward_refs(kls, _visited: set | None = None): """Recursively update forward references in Pydantic models.""" + if _visited is None: + _visited = set() for shelled_type in get_core_types(kls): if safe_issubclass(shelled_type, BaseModel): + if shelled_type in _visited: + continue + _visited.add(shelled_type) try: shelled_type.model_rebuild() except Exception: pass # Recurse into fields for field in shelled_type.model_fields.values(): - update_forward_refs(field.annotation) + update_forward_refs(field.annotation, _visited) def is_generic_container(cls): diff --git a/src/sqlmodel_nexus/voyager/voyager_context.py b/src/sqlmodel_nexus/voyager/voyager_context.py index 5b0369d..1c6d47d 100644 --- a/src/sqlmodel_nexus/voyager/voyager_context.py +++ b/src/sqlmodel_nexus/voyager/voyager_context.py @@ -8,6 +8,7 @@ from pathlib import Path from sqlmodel_nexus.loader.registry import ErManager +from sqlmodel_nexus.use_case.business import UseCaseService from sqlmodel_nexus.voyager.er_diagram_dot import ErDiagramDotBuilder from sqlmodel_nexus.voyager.render import Renderer from sqlmodel_nexus.voyager.render_style import DEFAULT_PRIMARY diff --git a/tests/test_argument_types.py b/tests/test_argument_types.py index abbd76f..45fa6dd 100644 --- a/tests/test_argument_types.py +++ b/tests/test_argument_types.py @@ -98,7 +98,7 @@ async def get_filtered(cls, limit: int): registry = ErManager(entities=entities, session_factory=session_factory) executor = QueryExecutor(registry) - method = getattr(UserQuery, "get_filtered") + method = UserQuery.get_filtered query_methods = {"userGetFiltered": (FixtureUser, method)} parsed = QueryParser().parse("{ userGetFiltered(limit: 1) { id name } }") diff --git a/tests/test_introspection.py b/tests/test_introspection.py index 0214b01..6e2c5a0 100644 --- a/tests/test_introspection.py +++ b/tests/test_introspection.py @@ -61,7 +61,7 @@ def create_config( score: float = 1.0, is_active: bool = True, tag: str | None = None, - tags: list[str] = [], + tags: list[str] | None = None, ) -> "IntrospectionConfig": return cls(key=key, value=value) @@ -697,12 +697,12 @@ def test_bool_false_default_format(self, generator: IntrospectionGenerator): assert enabled_arg["defaultValue"] != "False" def test_list_default_format(self, generator: IntrospectionGenerator): - """List defaults must be valid GraphQL list literals.""" + """Optional list with None default renders as null in GraphQL.""" schema = generator.generate() field = self._get_mutation_field(schema, "introspectionConfigCreateConfig") tags_arg = next(a for a in field["args"] if a["name"] == "tags") - assert tags_arg["defaultValue"] == "[]" + assert tags_arg["defaultValue"] == "null" def test_build_client_schema_succeeds(self, generator: IntrospectionGenerator): """Full introspection result must be consumable by graphql build_client_schema. diff --git a/tests/test_voyager_selfref.py b/tests/test_voyager_selfref.py index 62934d8..0c1e45e 100644 --- a/tests/test_voyager_selfref.py +++ b/tests/test_voyager_selfref.py @@ -9,16 +9,17 @@ 3. Pass the DTO as a UseCaseService return type 4. Access Voyager endpoint → triggers update_forward_refs → RecursionError -Root cause: `update_forward_refs` has no visited-set, so self-referencing types -cause unbounded recursion through field annotations. +Root cause: `update_forward_refs` had no visited-set, so self-referencing types +caused unbounded recursion through field annotations. + +Fix: Added a `_visited` set parameter to track already-processed types and +skip them on re-encounter, breaking the recursion cycle. """ from sqlmodel import Field, SQLModel -import pytest from sqlmodel_nexus import DefineSubset, SubsetConfig from sqlmodel_nexus.voyager.type_helper import update_forward_refs - # ── Minimal self-referencing setup ───────────────────────────────────── @@ -37,10 +38,8 @@ class CommentDTO(DefineSubset): class TestVoyagerSelfReference: - def test_update_forward_refs_on_self_referencing_dto_raises_recursion(self): - """Directly calling update_forward_refs on a self-referencing DTO - triggers RecursionError. This documents the known bug — the fix - should add a visited-set to prevent re-processing seen types.""" + def test_update_forward_refs_on_self_referencing_dto_completes(self): + """update_forward_refs should handle self-referencing DTOs + without RecursionError by using a visited-set to avoid cycles.""" - with pytest.raises(RecursionError): - update_forward_refs(CommentDTO) + update_forward_refs(CommentDTO) # should not raise