-
-
Notifications
You must be signed in to change notification settings - Fork 511
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement oneOf
#3429
Implement oneOf
#3429
Changes from 26 commits
4225e4f
e9d70c8
c1bdb99
a40f9db
3f35667
d707782
fe5f8a0
03b3f42
eba231b
1ed031a
82c890a
6d0cd58
500d1ac
b4fb169
ce0c61f
2c2fef7
d325cdd
a9b1edd
1a61ad3
385edc5
c54fd74
529a092
3466dd8
e80124c
393b31d
302d38f
2dc1a07
a79e961
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
Release type: minor | ||
|
||
This release adds support for `@oneOf` on input types! 🎉 You can use | ||
`one_of=True` on input types to create an input type that should only have one | ||
of the fields set. | ||
|
||
```python | ||
import strawberry | ||
|
||
|
||
@strawberry.input(one_of=True) | ||
class ExampleInputTagged: | ||
a: str | None = strawberry.UNSET | ||
b: int | None = strawberry.UNSET | ||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -31,6 +31,7 @@ def _impl_type( | |
*, | ||
name: Optional[str] = None, | ||
description: Optional[str] = None, | ||
one_of: Optional[bool] = None, | ||
directives: Iterable[object] = (), | ||
authenticated: bool = False, | ||
keys: Iterable[Union["Key", str]] = (), | ||
|
@@ -54,6 +55,7 @@ def _impl_type( | |
Shareable, | ||
Tag, | ||
) | ||
from strawberry.schema_directives import OneOf | ||
|
||
directives = list(directives) | ||
|
||
|
@@ -83,6 +85,9 @@ def _impl_type( | |
if is_interface_object: | ||
directives.append(InterfaceObject()) | ||
|
||
if one_of: | ||
directives.append(OneOf()) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not as familiar with dynamic imports but should Same question in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it is ok to have the import there, just to keep the code simple 😊 |
||
|
||
return base_type( # type: ignore | ||
cls, | ||
name=name, | ||
|
@@ -180,6 +185,7 @@ def input( | |
cls: T, | ||
*, | ||
name: Optional[str] = None, | ||
one_of: Optional[bool] = None, | ||
description: Optional[str] = None, | ||
directives: Sequence[object] = (), | ||
inaccessible: bool = UNSET, | ||
|
@@ -197,6 +203,7 @@ def input( | |
*, | ||
name: Optional[str] = None, | ||
description: Optional[str] = None, | ||
one_of: Optional[bool] = None, | ||
directives: Sequence[object] = (), | ||
inaccessible: bool = UNSET, | ||
tags: Iterable[str] = (), | ||
|
@@ -207,6 +214,7 @@ def input( | |
cls: Optional[T] = None, | ||
*, | ||
name: Optional[str] = None, | ||
one_of: Optional[bool] = None, | ||
description: Optional[str] = None, | ||
directives: Sequence[object] = (), | ||
inaccessible: bool = UNSET, | ||
|
@@ -219,6 +227,7 @@ def input( | |
directives=directives, | ||
inaccessible=inaccessible, | ||
is_input=True, | ||
one_of=one_of, | ||
tags=tags, | ||
) | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
from typing import Any | ||
|
||
from graphql import ( | ||
ExecutableDefinitionNode, | ||
GraphQLError, | ||
GraphQLNamedType, | ||
ObjectValueNode, | ||
ValidationContext, | ||
ValidationRule, | ||
VariableDefinitionNode, | ||
get_named_type, | ||
) | ||
|
||
|
||
class OneOfInputValidationRule(ValidationRule): | ||
def __init__(self, validation_context: ValidationContext) -> None: | ||
super().__init__(validation_context) | ||
|
||
def enter_operation_definition( | ||
self, node: ExecutableDefinitionNode, *_args: Any | ||
) -> None: | ||
self.variable_definitions: dict[str, VariableDefinitionNode] = {} | ||
|
||
def enter_variable_definition( | ||
self, node: VariableDefinitionNode, *_args: Any | ||
) -> None: | ||
self.variable_definitions[node.variable.name.value] = node | ||
|
||
def enter_object_value(self, node: ObjectValueNode, *_args: Any) -> None: | ||
type_ = get_named_type(self.context.get_input_type()) | ||
|
||
if not type_: | ||
return | ||
|
||
strawberry_type = type_.extensions.get("strawberry-definition") | ||
|
||
if strawberry_type and strawberry_type.is_one_of: | ||
self.validate_one_of(node, type_) | ||
|
||
def validate_one_of(self, node: ObjectValueNode, type: GraphQLNamedType) -> None: | ||
field_node_map = {field.name.value: field for field in node.fields} | ||
keys = list(field_node_map.keys()) | ||
is_not_exactly_one_field = len(keys) != 1 | ||
|
||
if is_not_exactly_one_field: | ||
self.report_error( | ||
GraphQLError( | ||
f"OneOf Input Object '{type.name}' must specify exactly one key.", | ||
nodes=[node], | ||
) | ||
) | ||
|
||
return | ||
|
||
value = field_node_map[keys[0]].value | ||
is_null_literal = not value or value.kind == "null_value" | ||
is_variable = value.kind == "variable" | ||
|
||
if is_null_literal: | ||
self.report_error( | ||
GraphQLError( | ||
f"Field '{type.name}.{keys[0]}' must be non-null.", | ||
nodes=[node], | ||
) | ||
) | ||
|
||
return | ||
|
||
if is_variable: | ||
variable_name = value.name.value # type: ignore | ||
definition = self.variable_definitions[variable_name] | ||
is_nullable_variable = definition.type.kind != "non_null_type" | ||
|
||
if is_nullable_variable: | ||
self.report_error( | ||
GraphQLError( | ||
f"Variable '{variable_name}' must be non-nullable to be used for OneOf Input Object '{type.name}'.", | ||
nodes=[node], | ||
) | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
from strawberry.schema_directive import Location, schema_directive | ||
|
||
|
||
@schema_directive(locations=[Location.INPUT_OBJECT], name="oneOf") | ||
class OneOf: ... | ||
|
||
|
||
__all__ = ["OneOf"] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
import textwrap | ||
from typing import Optional | ||
|
||
import strawberry | ||
|
||
|
||
def test_prints_one_of_directive(): | ||
@strawberry.federation.input(one_of=True, tags=["myTag", "anotherTag"]) | ||
class Input: | ||
a: Optional[str] = strawberry.UNSET | ||
b: Optional[int] = strawberry.UNSET | ||
|
||
@strawberry.federation.type | ||
class Query: | ||
hello: str | ||
|
||
schema = strawberry.federation.Schema( | ||
query=Query, types=[Input], enable_federation_2=True | ||
) | ||
|
||
expected = """ | ||
directive @oneOf on INPUT_OBJECT | ||
|
||
schema @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@tag"]) { | ||
query: Query | ||
} | ||
|
||
input Input @tag(name: "myTag") @tag(name: "anotherTag") @oneOf { | ||
a: String | ||
b: Int | ||
} | ||
|
||
type Query { | ||
_service: _Service! | ||
hello: String! | ||
} | ||
|
||
scalar _Any | ||
|
||
type _Service { | ||
sdl: String! | ||
} | ||
""" | ||
|
||
assert schema.as_str() == textwrap.dedent(expected).strip() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think supplying the
@strawberry.input(directives=[OneOf()])
directive example too would be nice. I could see some developers wanting to use the directive over the argument to keep the code more similar to the generated schema(or otherwise document the new
OneOf()
directive somewhere