Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: new input_mutation field (#2580)
* feat: new input_mutation field * refactor: use future annotations * fix: fix mypy issues * refactor: correct example in the release file * refactor: use field extensions instead of a StrawberryField subclass * Update docs/general/mutations.md Co-authored-by: Erik Wrede <erikwrede@users.noreply.github.com> * refactor: we don't need field_class argument anymore * Apply suggestions from code review Co-authored-by: Jonathan Kim <jkimbo@gmail.com> * refactor: modify arguments directly on the field * refactor: retrieve directives directly from the argument * refactor: remove input_mutation and move InputMutationExtension to a new module * Apply suggestions from code review Co-authored-by: Patrick Arminio <patrick.arminio@gmail.com> * refactor: use capitalize_first --------- Co-authored-by: Erik Wrede <erikwrede@users.noreply.github.com> Co-authored-by: Jonathan Kim <jkimbo@gmail.com> Co-authored-by: Patrick Arminio <patrick.arminio@gmail.com>
- Loading branch information
1 parent
2f47ac7
commit 9cb6aec
Showing
6 changed files
with
375 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
Release type: minor | ||
|
||
This release adds a new field extension called `InputMutationExtension`, which makes | ||
it easier to create mutations that receive a single input type called `input`, | ||
while still being able to define the arguments of that input on the resolver itself. | ||
|
||
The following example: | ||
|
||
```python | ||
import strawberry | ||
from strawberry.field_extensions import InputMutationExtension | ||
|
||
|
||
@strawberry.type | ||
class Fruit: | ||
id: strawberry.ID | ||
name: str | ||
weight: float | ||
|
||
|
||
@strawberry.type | ||
class Mutation: | ||
@strawberry.mutation(extensions=[InputMutationExtension()]) | ||
def update_fruit_weight( | ||
self, | ||
info: Info, | ||
id: strawberry.ID, | ||
weight: Annotated[ | ||
float, | ||
strawberry.argument(description="The fruit's new weight in grams"), | ||
], | ||
) -> Fruit: | ||
fruit = ... # retrieve the fruit with the given ID | ||
fruit.weight = weight | ||
... # maybe save the fruit in the database | ||
return fruit | ||
``` | ||
|
||
Would generate a schema like this: | ||
|
||
```graphql | ||
input UpdateFruitInput { | ||
id: ID! | ||
|
||
""" | ||
The fruit's new weight in grams | ||
""" | ||
weight: Float! | ||
} | ||
|
||
type Fruit { | ||
id: ID! | ||
name: String! | ||
weight: Float! | ||
} | ||
|
||
type Mutation { | ||
updateFruitWeight(input: UpdateFruitInput!): Fruit! | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
from .input_mutation import InputMutationExtension | ||
|
||
__all__ = [ | ||
"InputMutationExtension", | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
from __future__ import annotations | ||
|
||
from typing import ( | ||
TYPE_CHECKING, | ||
Any, | ||
Dict, | ||
TypeVar, | ||
) | ||
|
||
import strawberry | ||
from strawberry.annotation import StrawberryAnnotation | ||
from strawberry.arguments import StrawberryArgument | ||
from strawberry.extensions.field_extension import ( | ||
AsyncExtensionResolver, | ||
FieldExtension, | ||
SyncExtensionResolver, | ||
) | ||
from strawberry.field import StrawberryField | ||
from strawberry.utils.str_converters import capitalize_first, to_camel_case | ||
|
||
if TYPE_CHECKING: | ||
from strawberry.types.info import Info | ||
|
||
_T = TypeVar("_T") | ||
|
||
|
||
class InputMutationExtension(FieldExtension): | ||
def apply(self, field: StrawberryField) -> None: | ||
resolver = field.base_resolver | ||
assert resolver | ||
|
||
name = field.graphql_name or to_camel_case(resolver.name) | ||
type_dict: Dict[str, Any] = { | ||
"__doc__": f"Input data for `{name}` mutation", | ||
"__annotations__": {}, | ||
} | ||
annotations = resolver.wrapped_func.__annotations__ | ||
|
||
for arg in resolver.arguments: | ||
arg_field = StrawberryField( | ||
python_name=arg.python_name, | ||
graphql_name=arg.graphql_name, | ||
description=arg.description, | ||
default=arg.default, | ||
type_annotation=arg.type_annotation, | ||
directives=tuple(arg.directives), | ||
) | ||
type_dict[arg_field.python_name] = arg_field | ||
type_dict["__annotations__"][arg_field.python_name] = annotations[ | ||
arg.python_name | ||
] | ||
|
||
caps_name = capitalize_first(name) | ||
new_type = strawberry.input(type(f"{caps_name}Input", (), type_dict)) | ||
field.arguments = [ | ||
StrawberryArgument( | ||
python_name="input", | ||
graphql_name=None, | ||
type_annotation=StrawberryAnnotation( | ||
new_type, | ||
namespace=resolver._namespace, | ||
), | ||
description=type_dict["__doc__"], | ||
) | ||
] | ||
|
||
def resolve( | ||
self, | ||
next_: SyncExtensionResolver, | ||
source: Any, | ||
info: Info, | ||
**kwargs: Any, | ||
) -> Any: | ||
input_args = kwargs.pop("input") | ||
return next_( | ||
source, | ||
info, | ||
**kwargs, | ||
**vars(input_args), | ||
) | ||
|
||
async def resolve_async( | ||
self, | ||
next_: AsyncExtensionResolver, | ||
source: Any, | ||
info: Info, | ||
**kwargs: Any, | ||
) -> Any: | ||
input_args = kwargs.pop("input") | ||
return await next_( | ||
source, | ||
info, | ||
**kwargs, | ||
**vars(input_args), | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,149 @@ | ||
import textwrap | ||
from typing_extensions import Annotated | ||
|
||
import strawberry | ||
from strawberry.field_extensions import InputMutationExtension | ||
from strawberry.schema_directive import Location, schema_directive | ||
from strawberry.types import Info | ||
|
||
|
||
@schema_directive( | ||
locations=[Location.FIELD_DEFINITION], | ||
name="some_directive", | ||
) | ||
class SomeDirective: | ||
some: str | ||
directive: str | ||
|
||
|
||
@strawberry.type | ||
class Fruit: | ||
name: str | ||
color: str | ||
|
||
|
||
@strawberry.type | ||
class Query: | ||
@strawberry.mutation(extensions=[InputMutationExtension()]) | ||
def create_fruit( | ||
self, | ||
info: Info, | ||
name: str, | ||
color: Annotated[ | ||
str, | ||
strawberry.argument( | ||
description="The color of the fruit", | ||
directives=[SomeDirective(some="foo", directive="bar")], | ||
), | ||
], | ||
) -> Fruit: | ||
return Fruit( | ||
name=name, | ||
color=color, | ||
) | ||
|
||
@strawberry.mutation(extensions=[InputMutationExtension()]) | ||
async def create_fruit_async( | ||
self, | ||
info: Info, | ||
name: str, | ||
color: Annotated[str, object()], | ||
) -> Fruit: | ||
return Fruit( | ||
name=name, | ||
color=color, | ||
) | ||
|
||
|
||
schema = strawberry.Schema(query=Query) | ||
|
||
|
||
def test_schema(): | ||
expected = ''' | ||
directive @some_directive(some: String!, directive: String!) on FIELD_DEFINITION | ||
input CreateFruitAsyncInput { | ||
name: String! | ||
color: String! | ||
} | ||
input CreateFruitInput { | ||
name: String! | ||
"""The color of the fruit""" | ||
color: String! @some_directive(some: "foo", directive: "bar") | ||
} | ||
type Fruit { | ||
name: String! | ||
color: String! | ||
} | ||
type Query { | ||
createFruit( | ||
"""Input data for `createFruit` mutation""" | ||
input: CreateFruitInput! | ||
): Fruit! | ||
createFruitAsync( | ||
"""Input data for `createFruitAsync` mutation""" | ||
input: CreateFruitAsyncInput! | ||
): Fruit! | ||
} | ||
''' | ||
assert str(schema).strip() == textwrap.dedent(expected).strip() | ||
|
||
|
||
def test_input_mutation(): | ||
result = schema.execute_sync( | ||
""" | ||
query TestQuery ($input: CreateFruitInput!) { | ||
createFruit (input: $input) { | ||
... on Fruit { | ||
name | ||
color | ||
} | ||
} | ||
} | ||
""", | ||
variable_values={ | ||
"input": { | ||
"name": "Dragonfruit", | ||
"color": "red", | ||
} | ||
}, | ||
) | ||
assert result.errors is None | ||
assert result.data == { | ||
"createFruit": { | ||
"name": "Dragonfruit", | ||
"color": "red", | ||
}, | ||
} | ||
|
||
|
||
async def test_input_mutation_async(): | ||
result = await schema.execute( | ||
""" | ||
query TestQuery ($input: CreateFruitAsyncInput!) { | ||
createFruitAsync (input: $input) { | ||
... on Fruit { | ||
name | ||
color | ||
} | ||
} | ||
} | ||
""", | ||
variable_values={ | ||
"input": { | ||
"name": "Dragonfruit", | ||
"color": "red", | ||
} | ||
}, | ||
) | ||
assert result.errors is None | ||
assert result.data == { | ||
"createFruitAsync": { | ||
"name": "Dragonfruit", | ||
"color": "red", | ||
}, | ||
} |