diff --git a/src/debug_toolbar/extras/strawberry/analyzers.py b/src/debug_toolbar/extras/strawberry/analyzers.py index 0c420e1..a2006a2 100644 --- a/src/debug_toolbar/extras/strawberry/analyzers.py +++ b/src/debug_toolbar/extras/strawberry/analyzers.py @@ -31,6 +31,9 @@ def __init__(self, threshold: int = 3) -> None: def analyze(self, operations: list[TrackedOperation]) -> list[dict[str, Any]]: """Analyze operations for N+1 patterns. + Default-resolved fields (plain attribute reads with no custom + resolver) are excluded since they never perform per-item I/O. + Args: operations: List of tracked operations. @@ -53,6 +56,8 @@ def analyze(self, operations: list[TrackedOperation]) -> list[dict[str, Any]]: for resolver in operation.resolvers: if resolver.parent_type.startswith("__"): continue + if resolver.is_default_resolver: + continue arg_sig = self._get_argument_signature(resolver.arguments) signature = f"{resolver.parent_type}.{resolver.field_name}({arg_sig})" diff --git a/src/debug_toolbar/extras/strawberry/extension.py b/src/debug_toolbar/extras/strawberry/extension.py index 5bee6e3..7f2166a 100644 --- a/src/debug_toolbar/extras/strawberry/extension.py +++ b/src/debug_toolbar/extras/strawberry/extension.py @@ -23,12 +23,17 @@ try: from strawberry.extensions import SchemaExtension as _SchemaExtension + from strawberry.resolvers import is_default_resolver STRAWBERRY_AVAILABLE = True except ImportError: STRAWBERRY_AVAILABLE = False _SchemaExtension = object # type: ignore[assignment, misc] + def is_default_resolver(_func: Callable[..., Any]) -> bool: # type: ignore[misc] + """Fallback when Strawberry is unavailable.""" + return False + class DebugToolbarExtension(_SchemaExtension): # type: ignore[misc] """Strawberry extension for tracking GraphQL operations and resolvers. @@ -206,6 +211,7 @@ def resolve( parent_type=parent_type_name, return_type=return_type_str, arguments=dict(kwargs), + is_default_resolver=is_default_resolver(_next), ) if self._capture_stacks: diff --git a/src/debug_toolbar/extras/strawberry/models.py b/src/debug_toolbar/extras/strawberry/models.py index 283ee8f..488d534 100644 --- a/src/debug_toolbar/extras/strawberry/models.py +++ b/src/debug_toolbar/extras/strawberry/models.py @@ -23,6 +23,7 @@ class TrackedResolver: duration_ms: Execution duration in milliseconds. stack_trace: Call stack trace (if captured). is_slow: Whether duration exceeds threshold. + is_default_resolver: Whether this is Strawberry's default attribute resolver. """ resolver_id: str @@ -37,6 +38,7 @@ class TrackedResolver: duration_ms: float = 0.0 stack_trace: list[dict[str, Any]] | None = None is_slow: bool = False + is_default_resolver: bool = False def to_dict(self) -> dict[str, Any]: """Convert to dictionary for serialization. @@ -57,6 +59,7 @@ def to_dict(self) -> dict[str, Any]: "duration_ms": self.duration_ms, "stack_trace": self.stack_trace, "is_slow": self.is_slow, + "is_default_resolver": self.is_default_resolver, } diff --git a/tests/unit/extras/strawberry/test_analyzers.py b/tests/unit/extras/strawberry/test_analyzers.py index 616c175..3819450 100644 --- a/tests/unit/extras/strawberry/test_analyzers.py +++ b/tests/unit/extras/strawberry/test_analyzers.py @@ -260,6 +260,64 @@ def test_ignores_introspection_fields(self) -> None: assert N1Analyzer(threshold=3).analyze([operation]) == [] + def test_ignores_default_resolvers(self) -> None: + """Should not flag plain attribute reads with no custom resolver.""" + resolvers = [ + TrackedResolver( + resolver_id=f"r{i}", + field_name="id", + field_path=f"Query.posts.{i}.id", + resolver_function="Post.id", + parent_type="Post", + return_type="String", + duration_ms=0.01, + is_default_resolver=True, + ) + for i in range(5) + ] + operation = TrackedOperation( + operation_id="op1", + query="query { posts { id } }", + variables={}, + operation_name=None, + operation_type="query", + start_time=0.0, + resolvers=resolvers, + ) + + assert N1Analyzer(threshold=3).analyze([operation]) == [] + + def test_flags_custom_resolver_called_per_item(self) -> None: + """Should still flag a custom (non-default) resolver run once per list item.""" + resolvers = [ + TrackedResolver( + resolver_id=f"r{i}", + field_name="author", + field_path=f"Query.posts.{i}.author", + resolver_function="Post.author", + parent_type="Post", + return_type="User", + duration_ms=10.0, + is_default_resolver=False, + ) + for i in range(3) + ] + operation = TrackedOperation( + operation_id="op1", + query="query { posts { author { name } } }", + variables={}, + operation_name=None, + operation_type="query", + start_time=0.0, + resolvers=resolvers, + ) + + patterns = N1Analyzer(threshold=3).analyze([operation]) + + assert len(patterns) == 1 + assert patterns[0]["field_name"] == "author" + assert patterns[0]["count"] == 3 + class TestDuplicateDetector: """Tests for DuplicateDetector."""