Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/debug_toolbar/extras/strawberry/analyzers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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})"

Expand Down
6 changes: 6 additions & 0 deletions src/debug_toolbar/extras/strawberry/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/debug_toolbar/extras/strawberry/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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,
}


Expand Down
58 changes: 58 additions & 0 deletions tests/unit/extras/strawberry/test_analyzers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading