From 3b0259b9ad5853f062d1686a7fc6d65ae6bf6bc8 Mon Sep 17 00:00:00 2001 From: Predrag Gruevski Date: Tue, 5 Feb 2019 17:57:25 -0500 Subject: [PATCH 1/3] Add a few helpers for macro validation. Minor refactor of AST manipulation functions. --- graphql_compiler/ast_manipulation.py | 28 ++++++++ .../compiler/compiler_frontend.py | 8 +-- .../compiler/directive_helpers.py | 6 +- graphql_compiler/compiler/helpers.py | 20 ------ graphql_compiler/exceptions.py | 4 ++ .../macros/macro_edge/validation_helpers.py | 71 +++++++++++++++++++ 6 files changed, 109 insertions(+), 28 deletions(-) create mode 100644 graphql_compiler/ast_manipulation.py create mode 100644 graphql_compiler/macros/macro_edge/validation_helpers.py diff --git a/graphql_compiler/ast_manipulation.py b/graphql_compiler/ast_manipulation.py new file mode 100644 index 000000000..f2fac4847 --- /dev/null +++ b/graphql_compiler/ast_manipulation.py @@ -0,0 +1,28 @@ +from graphql.language.ast import InlineFragment + +from .schema import TYPENAME_META_FIELD_NAME + + +def get_ast_field_name(ast): + """Return the normalized field name for the given AST node.""" + replacements = { + # We always rewrite the following field names into their proper underlying counterparts. + TYPENAME_META_FIELD_NAME: '@class' + } + base_field_name = ast.name.value + normalized_name = replacements.get(base_field_name, base_field_name) + return normalized_name + + +def get_ast_field_name_or_none(ast): + """Return the field name for the AST node, or None if the AST is an InlineFragment.""" + if isinstance(ast, InlineFragment): + return None + return get_ast_field_name(ast) + + +def get_human_friendly_ast_field_name(ast): + """Return a human-friendly name for the AST node, suitable for error messages.""" + if isinstance(ast, InlineFragment): + return 'type coercion to {}'.format(ast.type_condition) + return get_ast_field_name(ast) diff --git a/graphql_compiler/compiler/compiler_frontend.py b/graphql_compiler/compiler/compiler_frontend.py index 232cdf95b..5dbcc0702 100644 --- a/graphql_compiler/compiler/compiler_frontend.py +++ b/graphql_compiler/compiler/compiler_frontend.py @@ -69,6 +69,7 @@ import six from . import blocks, expressions +from ..ast_manipulation import get_ast_field_name from ..exceptions import GraphQLCompilationError, GraphQLParsingError, GraphQLValidationError from ..schema import COUNT_META_FIELD_NAME, DIRECTIVES from .context_helpers import ( @@ -85,10 +86,9 @@ ) from .filters import process_filter_directive from .helpers import ( - FoldScopeLocation, Location, get_ast_field_name, get_edge_direction_and_name, - get_field_type_from_schema, get_uniquely_named_objects_by_name, get_vertex_field_type, - invert_dict, is_vertex_field_name, strip_non_null_from_type, validate_output_name, - validate_safe_string + FoldScopeLocation, Location, get_edge_direction_and_name, get_field_type_from_schema, + get_uniquely_named_objects_by_name, get_vertex_field_type, invert_dict, is_vertex_field_name, + strip_non_null_from_type, validate_output_name, validate_safe_string ) from .metadata import LocationInfo, QueryMetadataTable, RecurseInfo diff --git a/graphql_compiler/compiler/directive_helpers.py b/graphql_compiler/compiler/directive_helpers.py index 45695bda3..ee664fc5a 100644 --- a/graphql_compiler/compiler/directive_helpers.py +++ b/graphql_compiler/compiler/directive_helpers.py @@ -4,12 +4,10 @@ from graphql.language.ast import InlineFragment import six +from ..ast_manipulation import get_ast_field_name, get_ast_field_name_or_none from ..exceptions import GraphQLCompilationError from .filters import is_filter_with_outer_scope_vertex_field_operator -from .helpers import ( - FilterOperationInfo, get_ast_field_name, get_ast_field_name_or_none, get_vertex_field_type, - is_vertex_field_type -) +from .helpers import FilterOperationInfo, get_vertex_field_type, is_vertex_field_type ALLOWED_DUPLICATED_DIRECTIVES = frozenset({'filter'}) diff --git a/graphql_compiler/compiler/helpers.py b/graphql_compiler/compiler/helpers.py index 78287044c..43eff3742 100644 --- a/graphql_compiler/compiler/helpers.py +++ b/graphql_compiler/compiler/helpers.py @@ -7,12 +7,10 @@ import funcy from graphql import GraphQLList, GraphQLNonNull, GraphQLString, is_type -from graphql.language.ast import InlineFragment from graphql.type.definition import GraphQLInterfaceType, GraphQLObjectType, GraphQLUnionType import six from ..exceptions import GraphQLCompilationError -from ..schema import TYPENAME_META_FIELD_NAME # These are the Java (OrientDB) representations of the ISO-8601 standard date and datetime formats. @@ -42,24 +40,6 @@ def get_only_element_from_collection(one_element_collection): return funcy.first(one_element_collection) -def get_ast_field_name(ast): - """Return the normalized field name for the given AST node.""" - replacements = { - # We always rewrite the following field names into their proper underlying counterparts. - TYPENAME_META_FIELD_NAME: '@class' - } - base_field_name = ast.name.value - normalized_name = replacements.get(base_field_name, base_field_name) - return normalized_name - - -def get_ast_field_name_or_none(ast): - """Return the field name for the AST node, or None if the AST is an InlineFragment.""" - if isinstance(ast, InlineFragment): - return None - return get_ast_field_name(ast) - - def get_field_type_from_schema(schema_type, field_name): """Return the type of the field in the given type, accounting for field name normalization.""" if field_name == '@class': diff --git a/graphql_compiler/exceptions.py b/graphql_compiler/exceptions.py index b09cbcb26..27f1a0703 100644 --- a/graphql_compiler/exceptions.py +++ b/graphql_compiler/exceptions.py @@ -11,6 +11,10 @@ class GraphQLValidationError(GraphQLError): """Exception raised when the provided GraphQL does not validate against the provided schema.""" +class GraphQLInvalidMacroError(GraphQLError): + """Exception raised when the provided GraphQL macro fails to adhere to macro requirements.""" + + class GraphQLCompilationError(GraphQLError): """Exception raised when the provided GraphQL cannot be compiled. diff --git a/graphql_compiler/macros/macro_edge/validation_helpers.py b/graphql_compiler/macros/macro_edge/validation_helpers.py new file mode 100644 index 000000000..b0878edbd --- /dev/null +++ b/graphql_compiler/macros/macro_edge/validation_helpers.py @@ -0,0 +1,71 @@ +from graphql.language.ast import Field, InlineFragment, OperationDefinition + +from ...ast_manipulation import get_human_friendly_ast_field_name +from ...exceptions import GraphQLInvalidMacroError + + +def _yield_ast_nodes_with_directives(ast): + """Get the AST objects where directives appear, anywhere in the given AST. + + Args: + ast: GraphQL library AST object, such as a Field, InlineFragment, or OperationDefinition + + Returns: + Iterable[Tuple[AST object, Directive]], where each tuple describes an AST node together with + the directive it contains. If an AST node contains multiple directives, the AST node will be + returned as part of multiple tuples, in no particular order. + """ + for directive in ast.directives: + yield (ast, directive) + + if isinstance(ast, (Field, InlineFragment, OperationDefinition)): + if ast.selection_set is not None: + for sub_selection_set in ast.selection_set.selections: + # TODO(predrag): When we make the compiler py3-only, use a "yield from" here. + for entry in _yield_ast_nodes_with_directives(sub_selection_set): + yield entry + else: + raise AssertionError(u'Unexpected AST type received: {} {}'.format(type(ast), ast)) + + +def get_directives_for_ast(ast): + """Return a dict of directive name -> list of (ast, directive) where that directive is used. + + Args: + ast: GraphQL library AST object, such as a Field, InlineFragment, or OperationDefinition + + Returns: + Dict[str, List[Tuple[AST object, Directive]]], allowing the user to find the instances + in this AST object where a directive with a given name appears; for each of those instances, + we record and return the AST object where the directive was applied, together with the AST + Directive object describing it together with any arguments that might have been supplied. + """ + result = {} + + for ast, directive in _yield_ast_nodes_with_directives(ast): + directive_name = directive.name.value + result.setdefault(directive_name, []).append((ast, directive)) + + return result + + +def get_only_selection_from_ast(ast): + """Return the selected sub-ast, ensuring that there is precisely one.""" + if ast.selection_set is None: + ast_name = get_human_friendly_ast_field_name(ast) + raise GraphQLInvalidMacroError(u'Expected an AST with exactly one selection, but got one ' + u'with no selections. Error near AST node named: {}' + .format(ast_name)) + + selections = ast.selection_set.selections + if len(selections) != 1: + ast_name = get_human_friendly_ast_field_name(ast) + selection_names = [ + get_human_friendly_ast_field_name(selection_ast) + for selection_ast in selections + ] + raise GraphQLInvalidMacroError(u'Expected an AST with exactly one selection, but found ' + u'{} selections at AST node named {}: {}' + .format(len(selection_names), selection_names, ast_name)) + + return selections[0] From 1732e2c1838174e351eaeacb4c67bd528d3d73ce Mon Sep 17 00:00:00 2001 From: Predrag Gruevski Date: Tue, 5 Feb 2019 18:05:18 -0500 Subject: [PATCH 2/3] Add copyright lines. --- graphql_compiler/ast_manipulation.py | 1 + graphql_compiler/macros/macro_edge/validation_helpers.py | 1 + 2 files changed, 2 insertions(+) diff --git a/graphql_compiler/ast_manipulation.py b/graphql_compiler/ast_manipulation.py index f2fac4847..221eee4fd 100644 --- a/graphql_compiler/ast_manipulation.py +++ b/graphql_compiler/ast_manipulation.py @@ -1,3 +1,4 @@ +# Copyright 2019-present Kensho Technologies, LLC. from graphql.language.ast import InlineFragment from .schema import TYPENAME_META_FIELD_NAME diff --git a/graphql_compiler/macros/macro_edge/validation_helpers.py b/graphql_compiler/macros/macro_edge/validation_helpers.py index b0878edbd..6de60bd90 100644 --- a/graphql_compiler/macros/macro_edge/validation_helpers.py +++ b/graphql_compiler/macros/macro_edge/validation_helpers.py @@ -1,3 +1,4 @@ +# Copyright 2019-present Kensho Technologies, LLC. from graphql.language.ast import Field, InlineFragment, OperationDefinition from ...ast_manipulation import get_human_friendly_ast_field_name From aa31a7cfad39e1874129ede5d9dee8add00c3521 Mon Sep 17 00:00:00 2001 From: Predrag Gruevski Date: Wed, 6 Feb 2019 13:36:32 -0500 Subject: [PATCH 3/3] Add more helpers, rename the file. --- .../{validation_helpers.py => helpers.py} | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) rename graphql_compiler/macros/macro_edge/{validation_helpers.py => helpers.py} (61%) diff --git a/graphql_compiler/macros/macro_edge/validation_helpers.py b/graphql_compiler/macros/macro_edge/helpers.py similarity index 61% rename from graphql_compiler/macros/macro_edge/validation_helpers.py rename to graphql_compiler/macros/macro_edge/helpers.py index 6de60bd90..3f49529a7 100644 --- a/graphql_compiler/macros/macro_edge/validation_helpers.py +++ b/graphql_compiler/macros/macro_edge/helpers.py @@ -1,5 +1,9 @@ # Copyright 2019-present Kensho Technologies, LLC. -from graphql.language.ast import Field, InlineFragment, OperationDefinition +from copy import copy + +from graphql.language.ast import ( + Argument, Directive, Field, InlineFragment, Name, OperationDefinition, SelectionSet, StringValue +) from ...ast_manipulation import get_human_friendly_ast_field_name from ...exceptions import GraphQLInvalidMacroError @@ -70,3 +74,51 @@ def get_only_selection_from_ast(ast): .format(len(selection_names), selection_names, ast_name)) return selections[0] + + +def remove_directives_from_ast(ast, directive_names_to_omit): + """Return a copy of the AST but with instances of the named directives omitted. + + Args: + ast: GraphQL library AST object, such as a Field, InlineFragment, or OperationDefinition + directive_names_to_omit: set of strings describing the names of the directives to omit + + Returns: + GraphQL library AST object, equivalent to the input one, with all instances of + the named directives omitted. If the specified directives do not appear in the input AST, + the returned object is the exact same object as the input. + """ + if not isinstance(ast, (Field, InlineFragment, OperationDefinition)): + return ast + + made_changes = False + + new_selections = None + if ast.selection_set is not None: + new_selections = [] + for selection_ast in ast.selection_set.selections: + new_selection_ast = remove_directives_from_ast(selection_ast, directive_names_to_omit) + + if selection_ast is not new_selection_ast: + # Since we did not get the exact same object as the input, changes were made. + # That means this call will also need to make changes and return a new object. + made_changes = True + + new_selections.append(new_selection_ast) + + directives_to_keep = [ + directive + for directive in ast.directives + if directive.name.value not in directive_names_to_omit + ] + if len(directives_to_keep) != len(ast.directives): + made_changes = True + + if not made_changes: + # We didn't change anything, return the original input object. + return ast + + new_ast = copy(ast) + new_ast.selection_set = SelectionSet(new_selections) + new_ast.directives = directives_to_keep + return new_ast