Repository: strawberry-graphql/strawberry

Issue:
NameError with lazy types
## Describe the Bug

When I use lazy types as described in https://strawberry.rocks/docs/types/lazy I get a NameError like the following:
```
NameError: name 'Attribute' is not defined
```

Relevant code:
```
from typing import TYPE_CHECKING, Annotated
from strawberry_django_plus import gql
...

if TYPE_CHECKING:
    from api.attribute.types.attribute import Attribute
    
...
@gql.django.type(models.ProductType)
class ProductType(gql.relay.Node):
    @gql.relay.connection
    def attributes(self) -> list[Annotated["Attribute", gql.lazy("api.attribute.types.attribute")]]:
        pass
```

The error goes away if I use a regular forward reference without `Annotation` and `lazy`, like so:
```
    @gql.relay.connection
    def attributes(self) -> list["Attribute"]:
        pass
```
(Although I'm guessing this would prevent the schema from being generated correctly. By the way... Is it actually necessary to use `Annotated` and `lazy`? It feels weird to write out the import path twice... Why not treat all forward references as lazy types?)

## System Information

Python 3.10.7


<!-- POLAR PLEDGE BADGE START -->
## Upvote & Fund

- We're using [Polar.sh](https://polar.sh/strawberry-graphql) so you can upvote and help fund this issue.
- We receive the funding once the issue is completed & confirmed by you.
- Thank you in advance for helping prioritize & fund our backlog.

<a href="https://polar.sh/strawberry-graphql/strawberry/issues/2833">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/strawberry-graphql/strawberry/issues/2833/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/strawberry-graphql/strawberry/issues/2833/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:
https://github.com/blb-ventures/strawberry-django-plus/issues/234
I originally noticed this issue while working with the relay implementation in `strawberry_django_plus`. Recently, the relay implementation was contributed to the main strawberry library, so I guess this issue would still be relevant here.

I seem to run into trouble when a type inheriting from the `Node` interface has a field that is annotated with a lazy type.

Example error:
```
File "/Users/jacob/code/saleor/api/api/webhook/types.py", line 99, in <module>
    class Webhook(relay.Node):
  File "/Users/jacob/code/saleor/api/.venv/lib/python3.10/site-packages/strawberry/relay/types.py", line 364, in __init_subclass__
    annotations = get_type_hints(cls, include_extras=True)
  File "/Users/jacob/code/saleor/api/.venv/lib/python3.10/site-packages/typing_extensions.py", line 1183, in get_type_hints
    hint = typing.get_type_hints(
  File "/usr/local/Cellar/python@3.10/3.10.7/Frameworks/Python.framework/Versions/3.10/lib/python3.10/typing.py", line 1833, in get_type_hints
    value = _eval_type(value, base_globals, base_locals)
  File "/usr/local/Cellar/python@3.10/3.10.7/Frameworks/Python.framework/Versions/3.10/lib/python3.10/typing.py", line 329, in _eval_type
    ev_args = tuple(_eval_type(a, globalns, localns, recursive_guard) for a in t.__args__)
  File "/usr/local/Cellar/python@3.10/3.10.7/Frameworks/Python.framework/Versions/3.10/lib/python3.10/typing.py", line 329, in <genexpr>
    ev_args = tuple(_eval_type(a, globalns, localns, recursive_guard) for a in t.__args__)
  File "/usr/local/Cellar/python@3.10/3.10.7/Frameworks/Python.framework/Versions/3.10/lib/python3.10/typing.py", line 327, in _eval_type
    return t._evaluate(globalns, localns, recursive_guard)
  File "/usr/local/Cellar/python@3.10/3.10.7/Frameworks/Python.framework/Versions/3.10/lib/python3.10/typing.py", line 694, in _evaluate
    eval(self.__forward_code__, globalns, localns),
  File "<string>", line 1, in <module>
NameError: name 'App' is not defined
```

Code:
```
from typing import TYPE_CHECKING, Annotated

if TYPE_CHECKING:
    from api.app.types import App
    
...

class Webhook(relay.Node):
    id: relay.NodeID
    app: Annotated["App", gql.lazy("api.app.types")]
```

Resolving patch:
diff --git a/strawberry/relay/types.py b/strawberry/relay/types.py
--- a/strawberry/relay/types.py
+++ b/strawberry/relay/types.py
@@ -30,7 +30,6 @@
     TypeAlias,
     get_args,
     get_origin,
-    get_type_hints,
 )
 
 from strawberry.field import field
@@ -42,6 +41,7 @@
 from strawberry.types.types import TypeDefinition
 from strawberry.utils.aio import aenumerate, aislice, resolve_awaitable
 from strawberry.utils.inspect import in_async_context
+from strawberry.utils.typing import eval_type
 
 from .utils import from_base64, to_base64
 
@@ -358,36 +358,7 @@ class Node:
 
     """
 
-    _id_attr: ClassVar[str]
-
-    def __init_subclass__(cls, **kwargs: Any):
-        annotations = get_type_hints(cls, include_extras=True)
-        candidates = [
-            attr
-            for attr, annotation in annotations.items()
-            if (
-                get_origin(annotation) is Annotated
-                and any(
-                    isinstance(argument, NodeIDPrivate)
-                    for argument in get_args(annotation)
-                )
-            )
-        ]
-
-        if len(candidates) == 0:
-            raise NodeIDAnnotationError(
-                f'No field annotated with `nodeid` found in "{cls.__name__}"', cls
-            )
-        if len(candidates) > 1:
-            raise NodeIDAnnotationError(
-                (
-                    "More than one field annotated with `NodeID` "
-                    f'found in "{cls.__name__}"'
-                ),
-                cls,
-            )
-
-        cls._id_attr = candidates[0]
+    _id_attr: ClassVar[Optional[str]] = None
 
     @field(name="id", description="The Globally Unique ID of this object")
     @classmethod
@@ -426,6 +397,45 @@ def _id(cls, root: Node, info: Info) -> GlobalID:
         # If node_id is not str, GlobalID will raise an error for us
         return GlobalID(type_name=type_name, node_id=str(node_id))
 
+    @classmethod
+    def resolve_id_attr(cls) -> str:
+        if cls._id_attr is not None:
+            return cls._id_attr
+
+        candidates: list[str] = []
+        for base in cls.__mro__:
+            base_namespace = sys.modules[base.__module__].__dict__
+
+            for attr_name, attr in getattr(base, "__annotations__", {}).items():
+                evaled = eval_type(attr, globalns=base_namespace)
+
+                if get_origin(evaled) is Annotated and any(
+                    isinstance(a, NodeIDPrivate) for a in get_args(evaled)
+                ):
+                    candidates.append(attr_name)
+
+            # If we found candidates in this base, stop looking for more
+            # This is to support subclasses to define something else than
+            # its superclass as a NodeID
+            if candidates:
+                break
+
+        if len(candidates) == 0:
+            raise NodeIDAnnotationError(
+                f'No field annotated with `NodeID` found in "{cls.__name__}"', cls
+            )
+        if len(candidates) > 1:
+            raise NodeIDAnnotationError(
+                (
+                    "More than one field annotated with `NodeID` "
+                    f'found in "{cls.__name__}"'
+                ),
+                cls,
+            )
+
+        cls._id_attr = candidates[0]
+        return cls._id_attr
+
     @classmethod
     def resolve_id(
         cls,
@@ -450,7 +460,7 @@ def resolve_id(
             The resolved id (which is expected to be str)
 
         """
-        return getattr(root, cls._id_attr)
+        return getattr(root, cls.resolve_id_attr())
 
     @classmethod
     def resolve_typename(cls, root: Self, info: Info) -> str:
diff --git a/strawberry/schema/schema.py b/strawberry/schema/schema.py
--- a/strawberry/schema/schema.py
+++ b/strawberry/schema/schema.py
@@ -26,6 +26,7 @@
 from graphql.subscription import subscribe
 from graphql.type.directives import specified_directives
 
+from strawberry import relay
 from strawberry.annotation import StrawberryAnnotation
 from strawberry.extensions.directives import (
     DirectivesExtension,
@@ -163,6 +164,7 @@ def __init__(
         self._schema._strawberry_schema = self  # type: ignore
 
         self._warn_for_federation_directives()
+        self._resolve_node_ids()
 
         # Validate schema early because we want developers to know about
         # possible issues as soon as possible
@@ -306,6 +308,28 @@ async def subscribe(
             operation_name=operation_name,
         )
 
+    def _resolve_node_ids(self):
+        for concrete_type in self.schema_converter.type_map.values():
+            type_def = concrete_type.definition
+
+            # This can be a TypeDefinition, EnumDefinition, ScalarDefinition
+            # or UnionDefinition
+            if not isinstance(type_def, TypeDefinition):
+                continue
+
+            # Do not validate id_attr for interfaces. relay.Node itself and
+            # any other interfdace that implements it are not required to
+            # provide a NodeID annotation, only the concrete type implementing
+            # them needs to do that.
+            if type_def.is_interface:
+                continue
+
+            # Call resolve_id_attr in here to make sure we raise provide
+            # early feedback for missing NodeID annotations
+            origin = type_def.origin
+            if issubclass(origin, relay.Node):
+                origin.resolve_id_attr()
+
     def _warn_for_federation_directives(self):
         """Raises a warning if the schema has any federation directives."""
         from strawberry.federation.schema_directives import FederationDirective


Test patch:
diff --git a/tests/relay/test_exceptions.py b/tests/relay/test_exceptions.py
--- a/tests/relay/test_exceptions.py
+++ b/tests/relay/test_exceptions.py
@@ -18,13 +18,21 @@ class NonNodeType:
 
 @pytest.mark.raises_strawberry_exception(
     NodeIDAnnotationError,
-    match='No field annotated with `nodeid` found in "Fruit"',
+    match='No field annotated with `NodeID` found in "Fruit"',
 )
 def test_raises_error_on_missing_node_id_annotation():
     @strawberry.type
     class Fruit(relay.Node):
         code: str
 
+    @strawberry.type
+    class Query:
+        @relay.connection(relay.ListConnection[Fruit])
+        def fruits(self) -> List[Fruit]:
+            ...
+
+    strawberry.Schema(query=Query)
+
 
 @pytest.mark.raises_strawberry_exception(
     NodeIDAnnotationError,
@@ -36,6 +44,14 @@ class Fruit(relay.Node):
         pk: relay.NodeID[str]
         code: relay.NodeID[str]
 
+    @strawberry.type
+    class Query:
+        @relay.connection(relay.ListConnection[Fruit])
+        def fruits(self) -> List[Fruit]:
+            ...
+
+    strawberry.Schema(query=Query)
+
 
 @pytest.mark.raises_strawberry_exception(
     RelayWrongAnnotationError,
diff --git a/tests/relay/test_schema.py b/tests/relay/test_schema.py
--- a/tests/relay/test_schema.py
+++ b/tests/relay/test_schema.py
@@ -1,4 +1,13 @@
 import pathlib
+import textwrap
+from typing import List
+
+from pytest_mock import MockerFixture
+
+import strawberry
+from strawberry import relay
+from strawberry.relay.utils import to_base64
+from strawberry.schema.types.scalar import DEFAULT_SCALAR_REGISTRY
 
 from .schema import schema
 
@@ -14,3 +23,332 @@ def test_schema():
         expected = f.read().strip("\n").strip(" ")
 
     assert schema_output == expected
+
+
+def test_node_id_annotation(mocker: MockerFixture):
+    # Avoid E501 errors
+    mocker.patch.object(
+        DEFAULT_SCALAR_REGISTRY[relay.GlobalID],
+        "description",
+        "__GLOBAL_ID_DESC__",
+    )
+
+    @strawberry.type
+    class Fruit(relay.Node):
+        code: relay.NodeID[int]
+
+    @strawberry.type
+    class Query:
+        @relay.connection(relay.ListConnection[Fruit])
+        def fruits(self) -> List[Fruit]:
+            return [Fruit(code=i) for i in range(10)]
+
+    schema = strawberry.Schema(query=Query)
+    expected = textwrap.dedent(
+        '''
+        type Fruit implements Node {
+          """The Globally Unique ID of this object"""
+          id: GlobalID!
+        }
+
+        """A connection to a list of items."""
+        type FruitConnection {
+          """Pagination data for this connection"""
+          pageInfo: PageInfo!
+
+          """Contains the nodes in this connection"""
+          edges: [FruitEdge!]!
+        }
+
+        """An edge in a connection."""
+        type FruitEdge {
+          """A cursor for use in pagination"""
+          cursor: String!
+
+          """The item at the end of the edge"""
+          node: Fruit!
+        }
+
+        """__GLOBAL_ID_DESC__"""
+        scalar GlobalID @specifiedBy(url: "https://relay.dev/graphql/objectidentification.htm")
+
+        """An object with a Globally Unique ID"""
+        interface Node {
+          """The Globally Unique ID of this object"""
+          id: GlobalID!
+        }
+
+        """Information to aid in pagination."""
+        type PageInfo {
+          """When paginating forwards, are there more items?"""
+          hasNextPage: Boolean!
+
+          """When paginating backwards, are there more items?"""
+          hasPreviousPage: Boolean!
+
+          """When paginating backwards, the cursor to continue."""
+          startCursor: String
+
+          """When paginating forwards, the cursor to continue."""
+          endCursor: String
+        }
+
+        type Query {
+          fruits(
+            """Returns the items in the list that come before the specified cursor."""
+            before: String = null
+
+            """Returns the items in the list that come after the specified cursor."""
+            after: String = null
+
+            """Returns the first n items from the list."""
+            first: Int = null
+
+            """Returns the items in the list that come after the specified cursor."""
+            last: Int = null
+          ): FruitConnection!
+        }
+        '''
+    ).strip()
+    assert str(schema) == expected
+    result = schema.execute_sync(
+        """
+        query {
+          fruits {
+            edges {
+              node {
+                id
+              }
+            }
+          }
+        }
+        """
+    )
+    assert result.errors is None
+    assert result.data == {
+        "fruits": {
+            "edges": [{"node": {"id": to_base64("Fruit", i)}} for i in range(10)]
+        }
+    }
+
+
+def test_node_id_annotation_in_superclass(mocker: MockerFixture):
+    # Avoid E501 errors
+    mocker.patch.object(
+        DEFAULT_SCALAR_REGISTRY[relay.GlobalID],
+        "description",
+        "__GLOBAL_ID_DESC__",
+    )
+
+    @strawberry.type
+    class BaseFruit(relay.Node):
+        code: relay.NodeID[int]
+
+    @strawberry.type
+    class Fruit(BaseFruit):
+        ...
+
+    @strawberry.type
+    class Query:
+        @relay.connection(relay.ListConnection[Fruit])
+        def fruits(self) -> List[Fruit]:
+            return [Fruit(code=i) for i in range(10)]
+
+    schema = strawberry.Schema(query=Query)
+    expected = textwrap.dedent(
+        '''
+        type Fruit implements Node {
+          """The Globally Unique ID of this object"""
+          id: GlobalID!
+        }
+
+        """A connection to a list of items."""
+        type FruitConnection {
+          """Pagination data for this connection"""
+          pageInfo: PageInfo!
+
+          """Contains the nodes in this connection"""
+          edges: [FruitEdge!]!
+        }
+
+        """An edge in a connection."""
+        type FruitEdge {
+          """A cursor for use in pagination"""
+          cursor: String!
+
+          """The item at the end of the edge"""
+          node: Fruit!
+        }
+
+        """__GLOBAL_ID_DESC__"""
+        scalar GlobalID @specifiedBy(url: "https://relay.dev/graphql/objectidentification.htm")
+
+        """An object with a Globally Unique ID"""
+        interface Node {
+          """The Globally Unique ID of this object"""
+          id: GlobalID!
+        }
+
+        """Information to aid in pagination."""
+        type PageInfo {
+          """When paginating forwards, are there more items?"""
+          hasNextPage: Boolean!
+
+          """When paginating backwards, are there more items?"""
+          hasPreviousPage: Boolean!
+
+          """When paginating backwards, the cursor to continue."""
+          startCursor: String
+
+          """When paginating forwards, the cursor to continue."""
+          endCursor: String
+        }
+
+        type Query {
+          fruits(
+            """Returns the items in the list that come before the specified cursor."""
+            before: String = null
+
+            """Returns the items in the list that come after the specified cursor."""
+            after: String = null
+
+            """Returns the first n items from the list."""
+            first: Int = null
+
+            """Returns the items in the list that come after the specified cursor."""
+            last: Int = null
+          ): FruitConnection!
+        }
+        '''
+    ).strip()
+    assert str(schema) == expected
+    result = schema.execute_sync(
+        """
+        query {
+          fruits {
+            edges {
+              node {
+                id
+              }
+            }
+          }
+        }
+        """
+    )
+    assert result.errors is None
+    assert result.data == {
+        "fruits": {
+            "edges": [{"node": {"id": to_base64("Fruit", i)}} for i in range(10)]
+        }
+    }
+
+
+def test_node_id_annotation_in_superclass_and_subclass(mocker: MockerFixture):
+    # Avoid E501 errors
+    mocker.patch.object(
+        DEFAULT_SCALAR_REGISTRY[relay.GlobalID],
+        "description",
+        "__GLOBAL_ID_DESC__",
+    )
+
+    @strawberry.type
+    class BaseFruit(relay.Node):
+        code: relay.NodeID[int]
+
+    @strawberry.type
+    class Fruit(BaseFruit):
+        other_code: relay.NodeID[int]
+
+    @strawberry.type
+    class Query:
+        @relay.connection(relay.ListConnection[Fruit])
+        def fruits(self) -> List[Fruit]:
+            return [Fruit(code=i, other_code=i) for i in range(10)]
+
+    schema = strawberry.Schema(query=Query)
+    expected = textwrap.dedent(
+        '''
+        type Fruit implements Node {
+          """The Globally Unique ID of this object"""
+          id: GlobalID!
+        }
+
+        """A connection to a list of items."""
+        type FruitConnection {
+          """Pagination data for this connection"""
+          pageInfo: PageInfo!
+
+          """Contains the nodes in this connection"""
+          edges: [FruitEdge!]!
+        }
+
+        """An edge in a connection."""
+        type FruitEdge {
+          """A cursor for use in pagination"""
+          cursor: String!
+
+          """The item at the end of the edge"""
+          node: Fruit!
+        }
+
+        """__GLOBAL_ID_DESC__"""
+        scalar GlobalID @specifiedBy(url: "https://relay.dev/graphql/objectidentification.htm")
+
+        """An object with a Globally Unique ID"""
+        interface Node {
+          """The Globally Unique ID of this object"""
+          id: GlobalID!
+        }
+
+        """Information to aid in pagination."""
+        type PageInfo {
+          """When paginating forwards, are there more items?"""
+          hasNextPage: Boolean!
+
+          """When paginating backwards, are there more items?"""
+          hasPreviousPage: Boolean!
+
+          """When paginating backwards, the cursor to continue."""
+          startCursor: String
+
+          """When paginating forwards, the cursor to continue."""
+          endCursor: String
+        }
+
+        type Query {
+          fruits(
+            """Returns the items in the list that come before the specified cursor."""
+            before: String = null
+
+            """Returns the items in the list that come after the specified cursor."""
+            after: String = null
+
+            """Returns the first n items from the list."""
+            first: Int = null
+
+            """Returns the items in the list that come after the specified cursor."""
+            last: Int = null
+          ): FruitConnection!
+        }
+        '''
+    ).strip()
+    assert str(schema) == expected
+    result = schema.execute_sync(
+        """
+        query {
+          fruits {
+            edges {
+              node {
+                id
+              }
+            }
+          }
+        }
+        """
+    )
+    assert result.errors is None
+    assert result.data == {
+        "fruits": {
+            "edges": [{"node": {"id": to_base64("Fruit", i)}} for i in range(10)]
+        }
+    }