Repository: strawberry-graphql/strawberry

Issue:
Codegen does not support non-inline `fragment`
<!--- Provide a general summary of the changes you want in the title above. -->
<!--- This template is entirely optional and can be removed, but is here to help both you and us. -->
<!--- Anything on lines wrapped in comments like these will not show up in the final text. -->

## Feature Request Type

- [ ] Core functionality
- [x] Alteration (**enhancement**/optimization) of existing feature(s)
- [ ] New behavior

## Description

Fragments in queries would be pretty difficult to support in general, however, I think that a usecase like:

```
fragment Point on Bar {
   id
   x
   y
}

query GetPoints {
  circlePoints {
    ...Point
  }
  squarePoints {
    ...Point
  }
}
```

Should be reasonable to support.  In this case, I'd expect to get some sort of type classes like:

```py
class Point:
    id: str
    x: float
    y: float

class GetPointsResult:
    circle_points: List[Point]
    square_points: List[Point]
```

Because it's obvious from the spread operator that `circlePoints` and `squarePoints` are the same type.

I don't think we should have to support any additional fields.  e.g. I think it would be Ok if the following would raise some kind of (hopefully helpful) error:

```
fragment Point on Bar {
   id
   x
   y
}

query GetPoints {
  circlePoints {
    ...Point
    z
  }
  squarePoints {
    ...Point
    z
  }
}
```

It would also be really nice if custom plugins would be able to tell whether a `GraphQLObjectType` comes from a fragment or not (I think it will have to be able to make that distinction in order for the print plugin to function properly anyway)

In my envisioned use-case, I plan on generating the queries via templates with a consistent set of fragments that I can inline -- then my client-generator can stick the fragments in separate files (based on the names) and my objects will be de-duplicated when my full client is finished being generated.

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



Discussion:

Resolving patch:
diff --git a/strawberry/codegen/plugins/print_operation.py b/strawberry/codegen/plugins/print_operation.py
--- a/strawberry/codegen/plugins/print_operation.py
+++ b/strawberry/codegen/plugins/print_operation.py
@@ -7,11 +7,15 @@
 from strawberry.codegen.types import (
     GraphQLBoolValue,
     GraphQLEnumValue,
+    GraphQLField,
     GraphQLFieldSelection,
+    GraphQLFragmentSpread,
+    GraphQLFragmentType,
     GraphQLInlineFragment,
     GraphQLIntValue,
     GraphQLList,
     GraphQLListValue,
+    GraphQLObjectType,
     GraphQLOptional,
     GraphQLStringValue,
     GraphQLVariableReference,
@@ -32,8 +36,15 @@ class PrintOperationPlugin(QueryCodegenPlugin):
     def generate_code(
         self, types: List[GraphQLType], operation: GraphQLOperation
     ) -> List[CodegenFile]:
+        code_lines = []
+        for t in types:
+            if not isinstance(t, GraphQLFragmentType):
+                continue
+            code_lines.append(self._print_fragment(t))
+
         code = "\n".join(
             [
+                *code_lines,
                 (
                     f"{operation.kind} {operation.name}"
                     f"{self._print_operation_variables(operation)}"
@@ -45,6 +56,28 @@ def generate_code(
         )
         return [CodegenFile("query.graphql", code)]
 
+    def _print_fragment_field(self, field: GraphQLField, indent: str = "") -> str:
+        code_lines = []
+        if isinstance(field.type, GraphQLObjectType):
+            code_lines.append(f"{indent}{field.name} {{")
+            for subfield in field.type.fields:
+                code_lines.append(
+                    self._print_fragment_field(subfield, indent=indent + "  ")
+                )
+            code_lines.append(f"{indent}}}")
+        else:
+            code_lines.append(f"{indent}{field.name}")
+        return "\n".join(code_lines)
+
+    def _print_fragment(self, fragment: GraphQLFragmentType) -> str:
+        code_lines = []
+        code_lines.append(f"fragment {fragment.name} on {fragment.on} {{")
+        for field in fragment.fields:
+            code_lines.append(self._print_fragment_field(field, indent="  "))
+        code_lines.append("}")
+        code_lines.append("")
+        return "\n".join(code_lines)
+
     def _print_operation_variables(self, operation: GraphQLOperation) -> str:
         if not operation.variables:
             return ""
@@ -143,6 +176,9 @@ def _print_inline_fragment(self, fragment: GraphQLInlineFragment) -> str:
             ]
         )
 
+    def _print_fragment_spread(self, fragment: GraphQLFragmentSpread) -> str:
+        return f"...{fragment.name}"
+
     def _print_selection(self, selection: GraphQLSelection) -> str:
         if isinstance(selection, GraphQLFieldSelection):
             return self._print_field_selection(selection)
@@ -150,6 +186,9 @@ def _print_selection(self, selection: GraphQLSelection) -> str:
         if isinstance(selection, GraphQLInlineFragment):
             return self._print_inline_fragment(selection)
 
+        if isinstance(selection, GraphQLFragmentSpread):
+            return self._print_fragment_spread(selection)
+
         raise ValueError(f"Unsupported selection: {selection}")  # pragma: no cover
 
     def _print_selections(self, selections: List[GraphQLSelection]) -> str:
diff --git a/strawberry/codegen/query_codegen.py b/strawberry/codegen/query_codegen.py
--- a/strawberry/codegen/query_codegen.py
+++ b/strawberry/codegen/query_codegen.py
@@ -18,6 +18,8 @@
     BooleanValueNode,
     EnumValueNode,
     FieldNode,
+    FragmentDefinitionNode,
+    FragmentSpreadNode,
     InlineFragmentNode,
     IntValueNode,
     ListTypeNode,
@@ -51,6 +53,8 @@
     GraphQLEnumValue,
     GraphQLField,
     GraphQLFieldSelection,
+    GraphQLFragmentSpread,
+    GraphQLFragmentType,
     GraphQLInlineFragment,
     GraphQLIntValue,
     GraphQLList,
@@ -169,6 +173,10 @@ def run(self, query: str) -> CodegenResult:
         if operation.name is None:
             raise NoOperationNameProvidedError()
 
+        # Look for any free-floating fragments and create types out of them
+        # These types can then be referenced and included later via the
+        # fragment spread operator.
+        self._populate_fragment_types(ast)
         self.operation = self._convert_operation(operation)
 
         result = self.generate_code()
@@ -182,6 +190,26 @@ def _collect_type(self, type_: GraphQLType) -> None:
 
         self.types.append(type_)
 
+    def _populate_fragment_types(self, ast: DocumentNode) -> None:
+        fragment_definitions = (
+            definition
+            for definition in ast.definitions
+            if isinstance(definition, FragmentDefinitionNode)
+        )
+        for fd in fragment_definitions:
+            query_type = self.schema.get_type_by_name("Query")
+            assert isinstance(query_type, TypeDefinition)
+            self._collect_types(
+                # The FragmentDefinitionNode has a non-Optional `SelectionSetNode` but the Protocol
+                # wants an `Optional[SelectionSetNode]` so this doesn't quite conform.
+                cast(HasSelectionSet, fd),
+                parent_type=query_type,
+                class_name=fd.name.value,
+                graph_ql_object_type_factory=lambda name: GraphQLFragmentType(
+                    name, on=fd.type_condition.name.value
+                ),
+            )
+
     def _convert_selection(self, selection: SelectionNode) -> GraphQLSelection:
         if isinstance(selection, FieldNode):
             return GraphQLFieldSelection(
@@ -198,6 +226,9 @@ def _convert_selection(self, selection: SelectionNode) -> GraphQLSelection:
                 self._convert_selection_set(selection.selection_set),
             )
 
+        if isinstance(selection, FragmentSpreadNode):
+            return GraphQLFragmentSpread(selection.name.value)
+
         raise ValueError(f"Unsupported type: {type(selection)}")  # pragma: no cover
 
     def _convert_selection_set(
@@ -525,6 +556,9 @@ def _collect_types(
         selection: HasSelectionSet,
         parent_type: TypeDefinition,
         class_name: str,
+        graph_ql_object_type_factory: Callable[
+            [str], GraphQLObjectType
+        ] = GraphQLObjectType,
     ) -> GraphQLType:
         assert selection.selection_set is not None
         selection_set = selection.selection_set
@@ -537,14 +571,32 @@ def _collect_types(
                 selection, parent_type, class_name
             )
 
-        current_type = GraphQLObjectType(class_name, [])
+        current_type = graph_ql_object_type_factory(class_name)
+        fields: List[Union[GraphQLFragmentSpread, GraphQLField]] = []
 
         for sub_selection in selection_set.selections:
+            if isinstance(sub_selection, FragmentSpreadNode):
+                fields.append(GraphQLFragmentSpread(sub_selection.name.value))
+                continue
             assert isinstance(sub_selection, FieldNode)
-
             field = self._get_field(sub_selection, class_name, parent_type)
 
-            current_type.fields.append(field)
+            fields.append(field)
+
+        if any(isinstance(f, GraphQLFragmentSpread) for f in fields):
+            if len(fields) > 1:
+                raise ValueError(
+                    "Queries with Fragments cannot currently include separate fields."
+                )
+            spread_field = fields[0]
+            assert isinstance(spread_field, GraphQLFragmentSpread)
+            return next(
+                t
+                for t in self.types
+                if isinstance(t, GraphQLObjectType) and t.name == spread_field.name
+            )
+
+        current_type.fields = cast(List[GraphQLField], fields)
 
         self._collect_type(current_type)
 
diff --git a/strawberry/codegen/types.py b/strawberry/codegen/types.py
--- a/strawberry/codegen/types.py
+++ b/strawberry/codegen/types.py
@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 from typing import TYPE_CHECKING, List, Optional, Type, Union
 
 if TYPE_CHECKING:
@@ -31,10 +31,31 @@ class GraphQLField:
     type: GraphQLType
 
 
+@dataclass
+class GraphQLFragmentSpread:
+    name: str
+
+
 @dataclass
 class GraphQLObjectType:
     name: str
-    fields: List[GraphQLField]
+    fields: List[GraphQLField] = field(default_factory=list)
+
+
+# Subtype of GraphQLObjectType.
+# Because dataclass inheritance is a little odd, the fields are
+# repeated here.
+@dataclass
+class GraphQLFragmentType(GraphQLObjectType):
+    name: str
+    fields: List[GraphQLField] = field(default_factory=list)
+    on: str = ""
+
+    def __post_init__(self) -> None:
+        if not self.on:
+            raise ValueError(
+                "GraphQLFragmentType must be constructed with a valid 'on'"
+            )
 
 
 @dataclass
@@ -75,7 +96,9 @@ class GraphQLInlineFragment:
     selections: List[GraphQLSelection]
 
 
-GraphQLSelection = Union[GraphQLFieldSelection, GraphQLInlineFragment]
+GraphQLSelection = Union[
+    GraphQLFieldSelection, GraphQLInlineFragment, GraphQLFragmentSpread
+]
 
 
 @dataclass


Test patch:
diff --git a/tests/codegen/queries/fragment.graphql b/tests/codegen/queries/fragment.graphql
new file mode 100644
--- /dev/null
+++ b/tests/codegen/queries/fragment.graphql
@@ -0,0 +1,18 @@
+fragment Fields on Query {
+  id
+  integer
+  float
+  boolean
+  uuid
+  date
+  datetime
+  time
+  decimal
+  lazy {
+    something
+  }
+}
+
+query OperationName {
+  ...Fields
+}
diff --git a/tests/codegen/queries/mutation-fragment.graphql b/tests/codegen/queries/mutation-fragment.graphql
new file mode 100644
--- /dev/null
+++ b/tests/codegen/queries/mutation-fragment.graphql
@@ -0,0 +1,9 @@
+fragment IdFragment on BlogPost {
+  id
+}
+
+mutation addBook($input: String!) {
+  addBook(input: $input) {
+    ...IdFragment
+  }
+}
diff --git a/tests/codegen/snapshots/python/fragment.py b/tests/codegen/snapshots/python/fragment.py
new file mode 100644
--- /dev/null
+++ b/tests/codegen/snapshots/python/fragment.py
@@ -0,0 +1,18 @@
+from uuid import UUID
+from datetime import date, datetime, time
+from decimal import Decimal
+
+class FieldsLazy:
+    something: bool
+
+class Fields:
+    id: str
+    integer: int
+    float: float
+    boolean: bool
+    uuid: UUID
+    date: date
+    datetime: datetime
+    time: time
+    decimal: Decimal
+    lazy: FieldsLazy
diff --git a/tests/codegen/snapshots/python/mutation-fragment.py b/tests/codegen/snapshots/python/mutation-fragment.py
new file mode 100644
--- /dev/null
+++ b/tests/codegen/snapshots/python/mutation-fragment.py
@@ -0,0 +1,8 @@
+class IdFragment:
+    id: str
+
+class addBookResult:
+    add_book: IdFragment
+
+class addBookVariables:
+    input: str
diff --git a/tests/codegen/snapshots/typescript/fragment.ts b/tests/codegen/snapshots/typescript/fragment.ts
new file mode 100644
--- /dev/null
+++ b/tests/codegen/snapshots/typescript/fragment.ts
@@ -0,0 +1,16 @@
+type FieldsLazy = {
+    something: boolean
+}
+
+type Fields = {
+    id: string
+    integer: number
+    float: number
+    boolean: boolean
+    uuid: string
+    date: string
+    datetime: string
+    time: string
+    decimal: string
+    lazy: FieldsLazy
+}
diff --git a/tests/codegen/snapshots/typescript/mutation-fragment.ts b/tests/codegen/snapshots/typescript/mutation-fragment.ts
new file mode 100644
--- /dev/null
+++ b/tests/codegen/snapshots/typescript/mutation-fragment.ts
@@ -0,0 +1,11 @@
+type IdFragment = {
+    id: string
+}
+
+type addBookResult = {
+    add_book: IdFragment
+}
+
+type addBookVariables = {
+    input: string
+}