Skip to content
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

Merged
merged 28 commits into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4225e4f
Initial implementation of supporting oneOf
patrick91 Mar 31, 2024
e9d70c8
Validate one of
patrick91 Mar 31, 2024
c1bdb99
Add missing is_one_of
patrick91 Mar 31, 2024
a40f9db
Lint
patrick91 Mar 31, 2024
3f35667
Tests based on RFC
patrick91 Mar 31, 2024
d707782
No need to use `= UNSET` anymore
patrick91 Apr 1, 2024
fe5f8a0
Fix camel casing
patrick91 Apr 1, 2024
03b3f42
Fix validation rule
patrick91 Apr 1, 2024
eba231b
Remove unused code
patrick91 Apr 1, 2024
1ed031a
Merge branch 'main' into feature/one-of
patrick91 Apr 17, 2024
82c890a
Add test for directive
patrick91 Apr 17, 2024
6d0cd58
Extend introspection
patrick91 Apr 17, 2024
500d1ac
Ignore type
patrick91 Apr 17, 2024
b4fb169
Add missing type annotation
patrick91 Apr 17, 2024
ce0c61f
Add release notes
patrick91 Apr 17, 2024
2c2fef7
Add `one_of_input`
patrick91 Apr 18, 2024
d325cdd
Update release notes
patrick91 Apr 18, 2024
a9b1edd
Add docs
patrick91 Apr 18, 2024
1a61ad3
Safer assign
patrick91 Apr 18, 2024
385edc5
Replace new decorator with flag
patrick91 Apr 18, 2024
c54fd74
Test for federation
patrick91 Apr 18, 2024
529a092
Update strawberry/types/types.py
patrick91 Apr 20, 2024
3466dd8
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 20, 2024
e80124c
Merge branch 'main' into feature/one-of
patrick91 Apr 20, 2024
393b31d
Explicit unset
patrick91 Apr 20, 2024
302d38f
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 20, 2024
2dc1a07
Merge branch 'main' into feature/one-of
patrick91 May 21, 2024
a79e961
Add tweet file
patrick91 May 22, 2024
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
15 changes: 15 additions & 0 deletions RELEASE.md
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
```
23 changes: 23 additions & 0 deletions docs/types/input-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,26 @@ Creates an input type from a class definition.
generated by camel-casing the name of the class.
- `description`: this is the GraphQL description that will be returned when
introspecting the schema or when navigating the schema using GraphiQL.

## One Of Input Types

Strawberry also supports defining input types that can have only one field set.
This is based on the
[OneOf Input Objects RFC](https://github.com/graphql/graphql-spec/pull/825)

To define a one of input type you can use the `one_of` flag on the
`@strawberry.input` decorator:

```python+schema
import strawberry

@strawberry.input(one_of=True)
class SearchBy:
name: str | None = strawberry.UNSET
email: str | None = strawberry.UNSET
---
input SearchBy @oneOf {
name: String
email: String
}
```
Copy link
Contributor

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

9 changes: 9 additions & 0 deletions strawberry/federation/object_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = (),
Expand All @@ -54,6 +55,7 @@ def _impl_type(
Shareable,
Tag,
)
from strawberry.schema_directives import OneOf

directives = list(directives)

Expand Down Expand Up @@ -83,6 +85,9 @@ def _impl_type(
if is_interface_object:
directives.append(InterfaceObject())

if one_of:
directives.append(OneOf())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not as familiar with dynamic imports but should from strawberry.schema_directives import OneOf go in this conditional so it's only imported when used?

Same question in strawberry/object_type.py

Copy link
Member Author

Choose a reason for hiding this comment

The 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,
Expand Down Expand Up @@ -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,
Expand All @@ -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] = (),
Expand All @@ -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,
Expand All @@ -219,6 +227,7 @@ def input(
directives=directives,
inaccessible=inaccessible,
is_input=True,
one_of=one_of,
tags=tags,
)

Expand Down
8 changes: 8 additions & 0 deletions strawberry/object_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ def input(
cls: T,
*,
name: Optional[str] = None,
one_of: Optional[bool] = None,
description: Optional[str] = None,
directives: Optional[Sequence[object]] = (),
) -> T: ...
Expand All @@ -305,6 +306,7 @@ def input(
def input(
*,
name: Optional[str] = None,
one_of: Optional[bool] = None,
description: Optional[str] = None,
directives: Optional[Sequence[object]] = (),
) -> Callable[[T], T]: ...
Expand All @@ -314,6 +316,7 @@ def input(
cls: Optional[T] = None,
*,
name: Optional[str] = None,
one_of: Optional[bool] = None,
description: Optional[str] = None,
directives: Optional[Sequence[object]] = (),
):
Expand All @@ -324,6 +327,11 @@ def input(
>>> field_abc: str = "ABC"
"""

from strawberry.schema_directives import OneOf

if one_of:
directives = (*(directives or ()), OneOf())

return type( # type: ignore # not sure why mypy complains here
cls,
name=name,
Expand Down
5 changes: 5 additions & 0 deletions strawberry/schema/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

from strawberry.exceptions import MissingQueryError
from strawberry.extensions.runner import SchemaExtensionsRunner
from strawberry.schema.validation_rules.one_of import OneOfInputValidationRule
from strawberry.types import ExecutionResult

from .exceptions import InvalidOperationTypeError
Expand Down Expand Up @@ -55,6 +56,10 @@ def validate_document(
document: DocumentNode,
validation_rules: Tuple[Type[ASTValidationRule], ...],
) -> List[GraphQLError]:
validation_rules = (
*validation_rules,
OneOfInputValidationRule,
)
return validate(
schema,
document,
Expand Down
11 changes: 11 additions & 0 deletions strawberry/schema/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
)

from graphql import (
GraphQLBoolean,
GraphQLField,
GraphQLNamedType,
GraphQLNonNull,
GraphQLSchema,
Expand Down Expand Up @@ -168,6 +170,7 @@ def __init__(

self._warn_for_federation_directives()
self._resolve_node_ids()
self._extend_introspection()

# Validate schema early because we want developers to know about
# possible issues as soon as possible
Expand Down Expand Up @@ -375,6 +378,14 @@ def _warn_for_federation_directives(self):
stacklevel=3,
)

def _extend_introspection(self):
def _resolve_is_one_of(obj: Any, info: Any) -> bool:
return obj.extensions["strawberry-definition"].is_one_of

instrospection_type = self._schema.type_map["__Type"]
instrospection_type.fields["isOneOf"] = GraphQLField(GraphQLBoolean) # type: ignore[attr-defined]
instrospection_type.fields["isOneOf"].resolve = _resolve_is_one_of # type: ignore[attr-defined]

def as_str(self) -> str:
return print_schema(self)

Expand Down
23 changes: 23 additions & 0 deletions strawberry/schema/schema_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
GraphQLDirective,
GraphQLEnumType,
GraphQLEnumValue,
GraphQLError,
GraphQLField,
GraphQLInputField,
GraphQLInputObjectType,
Expand Down Expand Up @@ -409,13 +410,35 @@ def from_input_object(self, object_type: type) -> GraphQLInputObjectType:
assert isinstance(graphql_object_type, GraphQLInputObjectType) # For mypy
return graphql_object_type

def check_one_of(value: dict[str, Any]) -> dict[str, Any]:
if len(value) != 1:
raise GraphQLError(
f"OneOf Input Object '{type_name}' must specify exactly one key."
)

first_key, first_value = next(iter(value.items()))

if first_value is None or first_value is UNSET:
raise GraphQLError(
f"Value for member field '{first_key}' must be non-null"
)

return value

out_type = (
check_one_of
if type_definition.is_input and type_definition.is_one_of
else None
)

graphql_object_type = GraphQLInputObjectType(
name=type_name,
fields=lambda: self.get_graphql_input_fields(type_definition),
description=type_definition.description,
extensions={
GraphQLCoreConverter.DEFINITION_BACKREF: type_definition,
},
out_type=out_type,
)

self.type_map[type_name] = ConcreteType(
Expand Down
Empty file.
80 changes: 80 additions & 0 deletions strawberry/schema/validation_rules/one_of.py
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],
)
)
8 changes: 8 additions & 0 deletions strawberry/schema_directives.py
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"]
4 changes: 4 additions & 0 deletions strawberry/type.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ class StrawberryType(ABC):
def type_params(self) -> List[TypeVar]:
return []

@property
def is_one_of(self) -> bool:
return False

@abstractmethod
def copy_with(
self,
Expand Down
9 changes: 9 additions & 0 deletions strawberry/types/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,15 @@ def is_implemented_by(self, root: Type[WithStrawberryObjectDefinition]) -> bool:
# All field mappings succeeded. This is a match
return True

@property
def is_one_of(self) -> bool:
from strawberry.schema_directives import OneOf

if not self.is_input or not self.directives:
return False

return any(isinstance(directive, OneOf) for directive in self.directives)


# TODO: remove when deprecating _type_definition
if TYPE_CHECKING:
Expand Down
45 changes: 45 additions & 0 deletions tests/federation/printer/test_one_of.py
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()
Loading
Loading