From b5c670373ecc3c6028328d46aaeb59c939fc94de Mon Sep 17 00:00:00 2001 From: lirsacc Date: Sun, 12 Apr 2020 11:00:49 +0200 Subject: [PATCH 1/5] Add language support for repeatable keyword in directive definitions --- src/py_gql/lang/ast.py | 3 ++ src/py_gql/lang/parser.py | 24 ++++++++++- src/py_gql/lang/printer.py | 1 + tests/fixtures/schema-kitchen-sink.graphql | 4 ++ .../schema-kitchen-sink.printed.graphql | 2 + tests/test_lang/test_schema_parser.py | 40 +++++++++++++++++++ tests/test_lang/test_visitor.py | 6 +++ 7 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/py_gql/lang/ast.py b/src/py_gql/lang/ast.py index 05f4c988..62763603 100755 --- a/src/py_gql/lang/ast.py +++ b/src/py_gql/lang/ast.py @@ -944,6 +944,7 @@ class DirectiveDefinition(SupportDescription, TypeSystemDefinition): "description", "name", "arguments", + "repeatable", "locations", ) @@ -951,6 +952,7 @@ def __init__( self, name: Name, arguments: Optional[List[InputValueDefinition]] = None, + repeatable: bool = False, locations: Optional[List[Name]] = None, source: Optional[str] = None, loc: Optional[Tuple[int, int]] = None, @@ -958,6 +960,7 @@ def __init__( ): self.name = name self.arguments = arguments or [] # type: List[InputValueDefinition] + self.repeatable = repeatable self.locations = locations or [] # type: List[Name] self.source = source self.loc = loc diff --git a/src/py_gql/lang/parser.py b/src/py_gql/lang/parser.py index efaa376d..64674e8e 100755 --- a/src/py_gql/lang/parser.py +++ b/src/py_gql/lang/parser.py @@ -394,6 +394,25 @@ def skip(self, kind: Kind) -> bool: return True return False + def skip_keyword(self, keyword: str) -> bool: + """ + Conditionally advance the parser asserting over a given keyword. + + Args: + keyword (str): Expected keyword + + Returns: + ``True`` if the next token was the given keyword and we've advanced + the parser, ``False`` otherwise. + + """ + next_token = self.peek() + if next_token.__class__ is Name and next_token.value == keyword: + self.advance() + return True + + return False + def many( self, open_kind: Kind, parse_fn: Callable[[], N], close_kind: Kind ) -> List[N]: @@ -1430,7 +1449,8 @@ def parse_input_object_type_extension( def parse_directive_definition(self) -> _ast.DirectiveDefinition: """ DirectiveDefinition : - Description? directive @ Name ArgumentsDefinition? on DirectiveLocations + Description? directive @ Name ArgumentsDefinition? repeatable? + on DirectiveLocations """ start = self.peek() desc = self.parse_description() @@ -1438,11 +1458,13 @@ def parse_directive_definition(self) -> _ast.DirectiveDefinition: self.expect(At) name = self.parse_name() args = self.parse_argument_definitions() + repeatable = self.skip_keyword("repeatable") self.expect_keyword("on") return _ast.DirectiveDefinition( description=desc, name=name, arguments=args, + repeatable=repeatable, locations=self.parse_directive_locations(), loc=self._loc(start), source=self._source, diff --git a/src/py_gql/lang/printer.py b/src/py_gql/lang/printer.py index 795286d1..4e9095bb 100644 --- a/src/py_gql/lang/printer.py +++ b/src/py_gql/lang/printer.py @@ -484,6 +484,7 @@ def print_directive_definition(self, node: _ast.DirectiveDefinition) -> str: "directive @", node.name.value, self.print_argument_definitions(node), + " repeatable" if node.repeatable else "", " on ", _join(map(self, node.locations), " | "), ] diff --git a/tests/fixtures/schema-kitchen-sink.graphql b/tests/fixtures/schema-kitchen-sink.graphql index 2ea0eddb..f0252d74 100644 --- a/tests/fixtures/schema-kitchen-sink.graphql +++ b/tests/fixtures/schema-kitchen-sink.graphql @@ -125,6 +125,10 @@ directive @include2(if: Boolean!) on | FRAGMENT_SPREAD | INLINE_FRAGMENT +directive @myRepeatableDir(name: String!) repeatable on + | OBJECT + | INTERFACE + extend schema @onSchema extend schema @onSchema { diff --git a/tests/fixtures/schema-kitchen-sink.printed.graphql b/tests/fixtures/schema-kitchen-sink.printed.graphql index 3604a54c..1c67d7f7 100644 --- a/tests/fixtures/schema-kitchen-sink.printed.graphql +++ b/tests/fixtures/schema-kitchen-sink.printed.graphql @@ -108,6 +108,8 @@ directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT directive @include2(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +directive @myRepeatableDir(name: String!) repeatable on OBJECT | INTERFACE + extend schema @onSchema extend schema @onSchema { diff --git a/tests/test_lang/test_schema_parser.py b/tests/test_lang/test_schema_parser.py index 93d59cd0..b5161e50 100644 --- a/tests/test_lang/test_schema_parser.py +++ b/tests/test_lang/test_schema_parser.py @@ -734,6 +734,46 @@ def test_directive_with_incorrect_locations_fails(): assert exc_info.value.message == "Unexpected Name INCORRECT_LOCATION" +def test_directive_definition(): + assert ( + parse( + """ + directive @foo on FIELD | FRAGMENT_SPREAD + """, + allow_type_system=True, + no_location=True, + ).definitions[0] + == _ast.DirectiveDefinition( + _ast.Name(value="foo"), + locations=[ + _ast.Name(value="FIELD"), + _ast.Name(value="FRAGMENT_SPREAD"), + ], + repeatable=False, + ) + ) + + +def test_repeatable_directive_definition(): + assert ( + parse( + """ + directive @foo repeatable on FIELD | FRAGMENT_SPREAD + """, + allow_type_system=True, + no_location=True, + ).definitions[0] + == _ast.DirectiveDefinition( + _ast.Name(value="foo"), + locations=[ + _ast.Name(value="FIELD"), + _ast.Name(value="FRAGMENT_SPREAD"), + ], + repeatable=True, + ) + ) + + def test_it_parses_kitchen_sink(fixture_file): # assert doesn't raise assert parse( diff --git a/tests/test_lang/test_visitor.py b/tests/test_lang/test_visitor.py index 9985e726..4fe279d9 100644 --- a/tests/test_lang/test_visitor.py +++ b/tests/test_lang/test_visitor.py @@ -583,6 +583,12 @@ def test_it_processes_schema_kitchen_sink(fixture_file): ("leave", "NonNullType"), ("leave", "InputValueDefinition"), ("leave", "DirectiveDefinition"), + ("enter", "DirectiveDefinition"), + ("enter", "InputValueDefinition"), + ("enter", "NonNullType"), + ("leave", "NonNullType"), + ("leave", "InputValueDefinition"), + ("leave", "DirectiveDefinition"), ("enter", "SchemaExtension"), ("enter", "Directive"), ("leave", "Directive"), From 189809d08a05947b4242b002e9eb14f645ffb114 Mon Sep 17 00:00:00 2001 From: lirsacc Date: Sun, 12 Apr 2020 14:24:34 +0200 Subject: [PATCH 2/5] Support repeatable directives in the schema (Directive, build_schema, Schema.to_string, SchemaVisitor). --- src/py_gql/schema/schema_visitor.py | 1 + src/py_gql/schema/types.py | 30 +++++++--- src/py_gql/sdl/ast_schema_printer.py | 3 +- src/py_gql/sdl/ast_type_builder.py | 2 + src/py_gql/sdl/schema_directives.py | 2 +- tests/test_schema/test_schema_printer.py | 52 ++++++++++++----- .../test_build_schema_with_directives.py | 58 +++++++++++++++++++ 7 files changed, 125 insertions(+), 23 deletions(-) diff --git a/src/py_gql/schema/schema_visitor.py b/src/py_gql/schema/schema_visitor.py index 15117705..3e170558 100644 --- a/src/py_gql/schema/schema_visitor.py +++ b/src/py_gql/schema/schema_visitor.py @@ -178,6 +178,7 @@ def on_directive(self, directive: Directive) -> Optional[Directive]: directive.name, directive.locations, args=updated_args, + repeatable=directive.repeatable, description=directive.description, node=directive.node, ) diff --git a/src/py_gql/schema/types.py b/src/py_gql/schema/types.py index ec0e9f29..e386b912 100644 --- a/src/py_gql/schema/types.py +++ b/src/py_gql/schema/types.py @@ -1007,30 +1007,42 @@ class Directive: Type system creators will usually not create these directly. Args: - name: Directive name + name: Directive name. - locations: Possible locations for that directive + locations: Possible locations for that directive. - args: Argument definitions + args: Argument definitions. - description: Directive description + repeatable: Specify that the directive can be applied multiple times to + the same target. Repeatable directives are often useful when the + same directive should be used with different arguments at a single + location, especially in cases where additional information needs to + be provided to a type or schema extension via a directive. + + description: Directive description. node: Source node used when building type from the SDL. Attributes: - name (str): Directive name + name (str): Directive name. - description (Optional[str]): Directive description + description (Optional[str]): Directive description. - locations (List[str]): Possible locations for that directive + locations (List[str]): Possible locations for that directive. arguments (List[py_gql.schema.Argument]): Directive arguments. argument_map (Dict[str, py_gql.schema.Argument]): Directive arguments in indexed form. + repeatable: Specify that the directive can be applied multiple times to + the same target. Repeatable directives are often useful when the + same directive should be used with different arguments at a single + location, especially in cases where additional information needs to + be provided to a type or schema extension via a directive. + node (Optional[py_gql.lang.ast.DirectiveDefinition]): - Source node used when building type from the SDL + Source node used when building type from the SDL. """ ALL_LOCATIONS = DIRECTIVE_LOCATIONS @@ -1042,6 +1054,7 @@ def __init__( name: str, locations: Sequence[str], args: Optional[List[Argument]] = None, + repeatable: bool = False, description: Optional[str] = None, node: Optional[_ast.DirectiveDefinition] = None, ): @@ -1057,6 +1070,7 @@ def __init__( self.name = name self.description = description self.locations = locations + self.repeatable = repeatable self.arguments = args if args is not None else [] self.argument_map = {arg.name: arg for arg in self.arguments} self.node = node diff --git a/src/py_gql/sdl/ast_schema_printer.py b/src/py_gql/sdl/ast_schema_printer.py index 8eb37570..ad333236 100644 --- a/src/py_gql/sdl/ast_schema_printer.py +++ b/src/py_gql/sdl/ast_schema_printer.py @@ -342,10 +342,11 @@ def print_input_object_type(self, type_: InputObjectType) -> str: ) def print_directive_definition(self, directive: Directive) -> str: - return "%sdirective @%s%s on %s" % ( + return "%sdirective @%s%s%son %s" % ( self.print_description(directive), directive.name, self.print_arguments(directive.arguments, 0), + " repeatable " if directive.repeatable else " ", " | ".join(directive.locations), ) diff --git a/src/py_gql/sdl/ast_type_builder.py b/src/py_gql/sdl/ast_type_builder.py index 673c93f0..bd12386f 100644 --- a/src/py_gql/sdl/ast_type_builder.py +++ b/src/py_gql/sdl/ast_type_builder.py @@ -154,6 +154,7 @@ def build_directive( return Directive( name=directive_def.name.value, description=_desc(directive_def), + repeatable=directive_def.repeatable, locations=[loc.value for loc in directive_def.locations], args=( [self._build_argument(arg) for arg in directive_def.arguments] @@ -203,6 +204,7 @@ def extend_directive(self, directive: Directive) -> Directive: return Directive( directive.name, description=directive.description, + repeatable=directive.repeatable, locations=directive.locations, args=[self._extend_argument(a) for a in directive.arguments], node=directive.node, diff --git a/src/py_gql/sdl/schema_directives.py b/src/py_gql/sdl/schema_directives.py index e0731f6e..cbac5e86 100644 --- a/src/py_gql/sdl/schema_directives.py +++ b/src/py_gql/sdl/schema_directives.py @@ -177,7 +177,7 @@ def _collect_schema_directives( [node], ) - if name in applied: + if name in applied and not directive_def.repeatable: raise SDLError('Directive "@%s" already applied' % name, [node]) args = coerce_argument_values(directive_def, node) diff --git a/tests/test_schema/test_schema_printer.py b/tests/test_schema/test_schema_printer.py index ab9ac24c..3db66226 100644 --- a/tests/test_schema/test_schema_printer.py +++ b/tests/test_schema/test_schema_printer.py @@ -613,25 +613,14 @@ def test_custom_directives_from_sdl_are_included_if_set_to_True(): class CustomDirective1(SchemaDirective): - definition = Directive( - "custom1", - args=[Argument("arg", NonNullType(Int))], - locations=("SCHEMA",), - ) + definition = "custom1" class CustomDirective2(SchemaDirective): - definition = Directive( - "custom2", - args=[Argument("arg", NonNullType(Int))], - locations=("SCHEMA",), - ) + definition = "custom2" def test_custom_directives_whitelist(): - class CustomDirective(SchemaDirective): - pass - sdl = """ schema @custom1(arg: 1) @custom2(arg: 2) { query: Query @@ -665,3 +654,40 @@ class CustomDirective(SchemaDirective): } """ ) + + +def test_repeatable_directive(): + sdl = """ + schema @custom1(arg: 1) @custom2(arg: 2) @custom2(arg: 3) { + query: Query + } + + directive @custom1(arg: Int!) on SCHEMA + directive @custom2(arg: Int!) repeatable on SCHEMA + + type Query { + foo: Int + } + """ + + schema = build_schema( + sdl, schema_directives=(CustomDirective1, CustomDirective2,), + ) + + assert print_schema( + schema, include_custom_schema_directives=True, + ) == dedent( + """ + schema @custom1(arg: 1) @custom2(arg: 2) @custom2(arg: 3) { + query: Query + } + + directive @custom1(arg: Int!) on SCHEMA + + directive @custom2(arg: Int!) repeatable on SCHEMA + + type Query { + foo: Int + } + """ + ) diff --git a/tests/test_sdl/test_build_schema_with_directives.py b/tests/test_sdl/test_build_schema_with_directives.py index a44a5933..e477b4a5 100644 --- a/tests/test_sdl/test_build_schema_with_directives.py +++ b/tests/test_sdl/test_build_schema_with_directives.py @@ -574,3 +574,61 @@ def on_schema(self, schema): "locations": [{"column": 27, "line": 11}], "message": 'Directive "@onSchema" already applied', } + + +def test_schema_extension_repeatable_directive(mocker): + + mock_side_effect = mocker.Mock() + + class OnSchema(SchemaDirective): + definition = "onSchema" + + def on_schema(self, schema): + mock_side_effect(self.args["value"]) + return schema + + build_schema( + """ + directive @onSchema(value: Int!) repeatable on SCHEMA + + type Foo { foo: String } + type Bar { bar: String } + + schema @onSchema(value: 1) { + query: Foo + } + + extend schema @onSchema(value: 2) + """, + schema_directives=(OnSchema,), + ) + + mock_side_effect.assert_has_calls([((1,),), ((2,),)]) + + +def test_repeatable_directive(mocker): + + mock_side_effect = mocker.Mock() + + class OnSchema(SchemaDirective): + definition = "onSchema" + + def on_schema(self, schema): + mock_side_effect(self.args["value"]) + return schema + + build_schema( + """ + directive @onSchema(value: Int!) repeatable on SCHEMA + + type Foo { foo: String } + type Bar { bar: String } + + schema @onSchema(value: 1) @onSchema(value: 2) { + query: Foo + } + """, + schema_directives=(OnSchema,), + ) + + mock_side_effect.assert_has_calls([((1,),), ((2,),)]) From ddc27ff3c18cc0435d84d76b7c4d937972289561 Mon Sep 17 00:00:00 2001 From: lirsacc Date: Sun, 12 Apr 2020 14:31:31 +0200 Subject: [PATCH 3/5] Handle repeatable directives in UniqueDirectivesPerLocationChecker. --- src/py_gql/validation/rules/__init__.py | 2 +- tests/test_validation/conftest.py | 1 + .../rules/test_unique_directives_per_location.py | 12 ++++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/py_gql/validation/rules/__init__.py b/src/py_gql/validation/rules/__init__.py index 63c490c8..03fb50ce 100644 --- a/src/py_gql/validation/rules/__init__.py +++ b/src/py_gql/validation/rules/__init__.py @@ -731,7 +731,7 @@ def _validate_unique_directive_names(self, node): name = directive.name.value directive_def = self.schema.directives.get(name) - if directive_def is None: + if directive_def is None or directive_def.repeatable: continue if name in seen: diff --git a/tests/test_validation/conftest.py b/tests/test_validation/conftest.py index 7ad0fa5c..8b9ab69a 100755 --- a/tests/test_validation/conftest.py +++ b/tests/test_validation/conftest.py @@ -234,6 +234,7 @@ def _stringify(value): Directive("onSubscription", ["SUBSCRIPTION"]), Directive("onField", ["FIELD"]), Directive("onField2", ["FIELD"]), + Directive("onFieldRepeatable", ["FIELD"], repeatable=True), Directive("onFragmentDefinition", ["FRAGMENT_DEFINITION"]), Directive("onFragmentSpread", ["FRAGMENT_SPREAD"]), Directive("onInlineFragment", ["INLINE_FRAGMENT"]), diff --git a/tests/test_validation/rules/test_unique_directives_per_location.py b/tests/test_validation/rules/test_unique_directives_per_location.py index 1a0467b1..903efb4b 100644 --- a/tests/test_validation/rules/test_unique_directives_per_location.py +++ b/tests/test_validation/rules/test_unique_directives_per_location.py @@ -140,3 +140,15 @@ def test_duplicate_directives_in_many_locations(schema): ['Duplicate directive "@onObject"', 'Duplicate directive "@onField"'], [[(32, 41)], [(63, 71)]], ) + + +def test_duplicate_repeatable_directive(schema): + run_test( + UniqueDirectivesPerLocationChecker, + schema, + """ + fragment Test on Type { + field @onFieldRepeatable @onFieldRepeatable + } + """, + ) From cb5989b823b0d796ba15d827fa52020c20b57a40 Mon Sep 17 00:00:00 2001 From: lirsacc Date: Sun, 12 Apr 2020 15:32:46 +0200 Subject: [PATCH 4/5] Implement py_gql.utilities.all_directive_arguments(). Now that repeatable directives are a thing the semantics behind `directive_arguments()` was unclear. In order to not break existing code, `directive_arguments()` is maintained but is documented to only return arguments for the first occurrence of a given directive if it is repeatable. To support repeatable directives an new helper (`all_directive_arguments()`) has been added which returns a list of directive arguments in order of occurrence in the node. (This applies to `ResolveInfo.get_directive_arguments()` and `ResolveInfo.get_all_directive_arguments()`.) --- src/py_gql/execution/wrappers.py | 31 +++-- src/py_gql/utilities/__init__.py | 2 + src/py_gql/utilities/coerce_value.py | 38 ++++- tests/test_execution/test_directives.py | 25 ++++ .../test_directive_arguments.py | 131 +++++++++++++----- 5 files changed, 184 insertions(+), 43 deletions(-) diff --git a/src/py_gql/execution/wrappers.py b/src/py_gql/execution/wrappers.py index e2ac7576..dbc6a958 100755 --- a/src/py_gql/execution/wrappers.py +++ b/src/py_gql/execution/wrappers.py @@ -22,9 +22,9 @@ TYPE_NAME_INTROSPECTION_FIELD, ) from ..utilities import ( + all_directive_arguments, coerce_argument_values, collect_fields, - directive_arguments, selected_fields, ) from .runtime import Runtime @@ -192,6 +192,10 @@ class ResolveInfo: This is the 3rd positional argument provided to resolver functions and is constructed internally during query execution. + + Warning: + This class assumes that the document and schema have been validated for + execution and may break unexectedly if used outside of such a context. """ __slots__ = ( @@ -225,9 +229,7 @@ def __init__( self.runtime = runtime self._context = context - self._directive_arguments = ( - {} - ) # type: Dict[str, Optional[Dict[str, Any]]] + self._directive_arguments = {} # type: Dict[str, List[Dict[str, Any]]] @property def schema(self) -> Schema: # noqa: D401 @@ -254,9 +256,7 @@ def get_directive_arguments(self, name: str) -> Optional[Dict[str, Any]]: """ Extract arguments for a given directive on the current field. - Warning: - This method assumes the document has been validated and the - definition exists and is valid at this position. + This has the same semantics as `py_gql.utilities.directive_arguments`. Args: name: The name of the directive to extract. @@ -265,6 +265,21 @@ def get_directive_arguments(self, name: str) -> Optional[Dict[str, Any]]: ``None`` if the directive is not present on the current field and a dictionary of coerced arguments otherwise. """ + args = self.get_all_directive_arguments(name) + return args[0] if args else None + + def get_all_directive_arguments(self, name: str) -> List[Dict[str, Any]]: + """ + Extract arguments for a given directive on the current field. + + This has the same semantics as `py_gql.utilities.all_directive_arguments`. + + Args: + name: The name of the directive to extract. + + Returns: + List of directive arguments in order of occurrence. + """ try: return self._directive_arguments[name] except KeyError: @@ -273,7 +288,7 @@ def get_directive_arguments(self, name: str) -> Optional[Dict[str, Any]]: except KeyError: raise UnknownDirective(name) from None - args = self._directive_arguments[name] = directive_arguments( + args = self._directive_arguments[name] = all_directive_arguments( directive_def, self.nodes[0], self._context.variables, ) return args diff --git a/src/py_gql/utilities/__init__.py b/src/py_gql/utilities/__init__.py index 02bbfdf0..037a1de8 100755 --- a/src/py_gql/utilities/__init__.py +++ b/src/py_gql/utilities/__init__.py @@ -8,6 +8,7 @@ from .ast_node_from_value import ast_node_from_value from .coerce_value import ( + all_directive_arguments, coerce_argument_values, coerce_value, coerce_variable_values, @@ -33,6 +34,7 @@ "collect_fields_untyped", "selected_fields", "directive_arguments", + "all_directive_arguments", "introspection_query", "untyped_value_from_ast", "value_from_ast", diff --git a/src/py_gql/utilities/coerce_value.py b/src/py_gql/utilities/coerce_value.py index 6ed96d50..9f67dc83 100644 --- a/src/py_gql/utilities/coerce_value.py +++ b/src/py_gql/utilities/coerce_value.py @@ -264,13 +264,48 @@ def coerce_argument_values( return coerced_values +def all_directive_arguments( + definition: Directive, + node: _ast.SupportDirectives, + variables: Optional[Mapping[str, Any]] = None, +) -> List[Dict[str, Any]]: + """ + Extract all directive arguments given node and a directive definition. + + Args: + definition: Directive definition from which to extract arguments + node: Parse node + variables: Coerced variable values + + Returns: + List of coerced directive arguments for all occurrences of the directive. + If the directive is not present the list is empty, otherwise this returns + one or more (for repeatable directives) dictionnaries of arguments in + the order they appear on the node. + + Raises: + CoercionError: If any argument fails to coerce, is missing, etc. + + """ + return [ + coerce_argument_values(definition, directive, variables) + for directive in node.directives + if directive.name.value == definition.name + ] + + def directive_arguments( definition: Directive, node: _ast.SupportDirectives, variables: Optional[Mapping[str, Any]] = None, ) -> Optional[Dict[str, Any]]: """ - Extract directive argument given node and a directive definition. + Extract first directive arguments given node and a directive definition. + + Warning: + This extracts at most a single set of arguments which may not be + suitable for repeatable directives. In that case `py_gql.utilities. + all_directive_arguments`. should be preferred. Args: definition: Directive definition from which to extract arguments @@ -283,7 +318,6 @@ def directive_arguments( Raises: CoercionError: If any argument fails to coerce, is missing, etc. - """ directive = find_one( node.directives, lambda d: d.name.value == definition.name diff --git a/tests/test_execution/test_directives.py b/tests/test_execution/test_directives.py index 49f017ce..64d4ef60 100644 --- a/tests/test_execution/test_directives.py +++ b/tests/test_execution/test_directives.py @@ -230,3 +230,28 @@ async def test_get_directive_arguments_unknown(assert_execution, mocker): with pytest.raises(KeyError): info.get_directive_arguments("foo") + + +async def test_repeatable_directive(assert_execution, mocker): + CustomDirective = Directive( + "custom", + ["FIELD"], + [Argument("a", String), Argument("b", Int)], + repeatable=True, + ) + + resolver = mocker.Mock(return_value=42) + + await assert_execution( + Schema(test_type, directives=[CustomDirective]), + parse('{ a @custom(a: "foo", b: 42) @custom(a: "bar", b: 24) }'), + initial_value=_obj(a=resolver), + ) + + (_, info), _ = resolver.call_args + + assert info.get_directive_arguments("custom") == {"a": "foo", "b": 42} + assert info.get_all_directive_arguments("custom") == [ + {"a": "foo", "b": 42}, + {"a": "bar", "b": 24}, + ] diff --git a/tests/test_utilities/test_directive_arguments.py b/tests/test_utilities/test_directive_arguments.py index f4b89c3c..a99d0495 100644 --- a/tests/test_utilities/test_directive_arguments.py +++ b/tests/test_utilities/test_directive_arguments.py @@ -5,7 +5,7 @@ from py_gql.exc import CoercionError from py_gql.lang import parse from py_gql.schema import Argument, Directive, IncludeDirective, Int, String -from py_gql.utilities import directive_arguments +from py_gql.utilities import all_directive_arguments, directive_arguments CustomDirective = Directive( @@ -13,47 +13,112 @@ ) -def test_include(): - doc = parse("{ a @include(if: true) }") - assert directive_arguments( - IncludeDirective, - doc.definitions[0].selection_set.selections[0], # type: ignore - {}, - ) == {"if": True} +CustomRepeatableDirective = Directive( + "customRepeat", + ["FIELD"], + [Argument("a", String), Argument("b", Int)], + repeatable=True, +) + + +class TestDirectiveArguments: + def test_include(self): + doc = parse("{ a @include(if: true) }") + assert directive_arguments( + IncludeDirective, + doc.definitions[0].selection_set.selections[0], # type: ignore + {}, + ) == {"if": True} + def test_include_missing(self): + doc = parse("{ a @include(a: 42) }") + with pytest.raises(CoercionError): + directive_arguments( + IncludeDirective, + doc.definitions[0].selection_set.selections[0], # type: ignore + {}, + ) -def test_include_missing(): - doc = parse("{ a @include(a: 42) }") - with pytest.raises(CoercionError): - directive_arguments( + def test_include_extra(self): + doc = parse("{ a @include(a: 42, if: true) }") + assert directive_arguments( IncludeDirective, doc.definitions[0].selection_set.selections[0], # type: ignore {}, + ) == {"if": True} + + def test_custom_directive_field(self): + doc = parse('{ a @custom(a: "foo", b: 42) }') + assert directive_arguments( + CustomDirective, + doc.definitions[0].selection_set.selections[0], # type: ignore + {}, + ) == {"a": "foo", "b": 42} + + def test_custom_directive_field_variables(self): + doc = parse('{ a @custom(a: "foo", b: $b) }') + assert directive_arguments( + CustomDirective, + doc.definitions[0].selection_set.selections[0], # type: ignore + {"b": 42}, + ) == {"a": "foo", "b": 42} + + def test_repeatable_directive_missing(self): + doc = parse('{ a @custom(a: "foo", b: $b) }') + assert ( + directive_arguments( + CustomRepeatableDirective, + doc.definitions[0].selection_set.selections[0], # type: ignore + {"b": 42}, + ) + is None ) + def test_repeatable_directive_once(self): + doc = parse('{ a @customRepeat(a: "foo", b: $b) }') + assert directive_arguments( + CustomRepeatableDirective, + doc.definitions[0].selection_set.selections[0], # type: ignore + {"b": 42}, + ) == {"a": "foo", "b": 42} -def test_include_extra(): - doc = parse("{ a @include(a: 42, if: true) }") - assert directive_arguments( - IncludeDirective, - doc.definitions[0].selection_set.selections[0], # type: ignore - {}, - ) == {"if": True} + def test_repeatable_directive_multiple(self): + doc = parse( + '{ a @customRepeat(a: "foo", b: $b) @customRepeat(a: "bar", b: 41) }' + ) + assert directive_arguments( + CustomRepeatableDirective, + doc.definitions[0].selection_set.selections[0], # type: ignore + {"b": 42}, + ) == {"a": "foo", "b": 42} -def test_custom_directive_field(): - doc = parse('{ a @custom(a: "foo", b: 42) }') - assert directive_arguments( - CustomDirective, - doc.definitions[0].selection_set.selections[0], # type: ignore - {}, - ) == {"a": "foo", "b": 42} +class TestAllDirectiveArguments: + def test_repeatable_directive_missing(self): + doc = parse('{ a @custom(a: "foo", b: $b) }') + assert ( + all_directive_arguments( + CustomRepeatableDirective, + doc.definitions[0].selection_set.selections[0], # type: ignore + {"b": 42}, + ) + == [] + ) + def test_repeatable_directive_once(self): + doc = parse('{ a @customRepeat(a: "foo", b: $b) }') + assert all_directive_arguments( + CustomRepeatableDirective, + doc.definitions[0].selection_set.selections[0], # type: ignore + {"b": 42}, + ) == [{"a": "foo", "b": 42}] -def test_custom_directive_field_variables(): - doc = parse('{ a @custom(a: "foo", b: $b) }') - assert directive_arguments( - CustomDirective, - doc.definitions[0].selection_set.selections[0], # type: ignore - {"b": 42}, - ) == {"a": "foo", "b": 42} + def test_repeatable_directive_multiple(self): + doc = parse( + '{ a @customRepeat(a: "foo", b: $b) @customRepeat(a: "bar", b: 41) }' + ) + assert all_directive_arguments( + CustomRepeatableDirective, + doc.definitions[0].selection_set.selections[0], # type: ignore + {"b": 42}, + ) == [{"a": "foo", "b": 42}, {"a": "bar", "b": 41}] From 86d072c414ff660469d67d7d03d42f4deeec1400 Mon Sep 17 00:00:00 2001 From: lirsacc Date: Sun, 12 Apr 2020 15:47:44 +0200 Subject: [PATCH 5/5] Add changelog entry. --- CHANGES.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index a467c498..c10766d1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -20,6 +20,12 @@ Unreleased - Add `py_gql.exts.scalars.Base64String` scalar type. - Add support for schema description (see: [graphql/graphql-spec/pull/466](https://github.com/graphql/graphql-spec/pull/466)). - Add `py_gql.exc.UnknownDirective` for cleaner errors in `py_gql.ResolveInfo.get_directive_arguments`. +- Add support for repeatable directives (see: [graphql/graphql-spec/pull/472](https://github.com/graphql/graphql-spec/pull/472)). + + - Language support (parser, ast, SDL and schema definitions) + - Schema directives: repeatable directives applied multiple times when calling `build_schema()` will be called multiple times in order. + - `ResolveInfo.get_directive_arguments` has not been modified to not break exising code. It returns the first set of arguments for repeated directives. + - `ResolveInfo.get_all_directive_arguments` has been added to handle repeated directives. [0.6.1](https://github.com/lirsacc/py-gql/releases/tag/0.6.1) - 2020-04-01 --------------------------------------------------------------------------