Skip to content

Commit

Permalink
Merge 33708bb into 7df647f
Browse files Browse the repository at this point in the history
  • Loading branch information
bojanserafimov committed Sep 4, 2019
2 parents 7df647f + 33708bb commit ff7986d
Show file tree
Hide file tree
Showing 12 changed files with 2,614 additions and 80 deletions.
410 changes: 410 additions & 0 deletions graphql_compiler/macros/__init__.py

Large diffs are not rendered by default.

45 changes: 0 additions & 45 deletions graphql_compiler/macros/macro_edge/ast_rewriting.py
Expand Up @@ -238,51 +238,6 @@ def remove_directives_from_ast(ast, directive_names_to_omit):
return new_ast


def omit_ast_from_ast_selections(ast, ast_to_omit):
"""Return an equivalent AST to the input, but with the specified AST omitted if it appears.
Args:
ast: GraphQL library AST object, such as a Field, InlineFragment, or OperationDefinition
ast_to_omit: GraphQL library AST object, the *exact same* object that should be omitted.
This function uses reference equality, since deep equality can get expensive.
Returns:
GraphQL library AST object, equivalent to the input one, with all instances of
the specified AST omitted. If the specified AST does 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

if ast.selection_set is None:
return ast

made_changes = False

selections_to_keep = []
for selection_ast in ast.selection_set.selections:
if selection_ast is ast_to_omit:
# Drop the current selection.
made_changes = True
else:
new_selection_ast = omit_ast_from_ast_selections(selection_ast, ast_to_omit)
if new_selection_ast is not selection_ast:
# The current selection contained the AST to omit, and was altered as a result.
made_changes = True
selections_to_keep.append(new_selection_ast)

if not made_changes:
return ast

new_ast = copy(ast)
if not selections_to_keep:
new_ast.selection_set = None
else:
new_ast.selection_set = SelectionSet(selections_to_keep)

return new_ast


def find_target_and_copy_path_to_it(ast):
"""Copy the AST objects on the path to the target, returning the copied AST and the target AST.
Expand Down
24 changes: 13 additions & 11 deletions graphql_compiler/macros/macro_edge/descriptor.py
Expand Up @@ -8,25 +8,27 @@

MacroEdgeDescriptor = namedtuple(
'MacroEdgeDescriptor', (
'base_class_name', # str, name of GraphQL type where the macro edge is defined.
# The macro edge exists at this type and all of its subtypes.
'macro_edge_name', # str, name of the vertex field corresponding to this macro edge.
# Should start with "out_" or "in_", per GraphQL compiler convention.
'expansion_ast', # GraphQL AST object defining how the macro edge
# should be expanded starting from its base type. The
# selections must be merged (on both endpoints of the
# macro edge) with the user-supplied GraphQL input.
'macro_args', # Dict[str, Any] containing any arguments required by the macro
'base_class_name', # str, name of GraphQL type where the macro edge is defined.
# The macro edge exists at this type and all of its subtypes.
'target_class_name', # str, the name of the GraphQL type that the macro edge points to.
'macro_edge_name', # str, name of the vertex field corresponding to this macro edge.
# Should start with "out_" or "in_", per GraphQL compiler convention.
'expansion_ast', # GraphQL AST object defining how the macro edge
# should be expanded starting from its base type. The
# selections must be merged (on both endpoints of the
# macro edge) with the user-supplied GraphQL input.
'macro_args', # Dict[str, Any] containing any arguments required by the macro
)
)


def create_descriptor_from_ast_and_args(class_name, macro_edge_name,
def create_descriptor_from_ast_and_args(class_name, target_class_name, macro_edge_name,
macro_definition_ast, macro_edge_args):
"""Remove macro edge definition directive, and return a MacroEdgeDescriptor."""
if not is_vertex_field_name(macro_edge_name):
raise AssertionError(u'Received illegal macro edge name: {}'.format(macro_edge_name))

directives_to_remove = {MacroEdgeDefinitionDirective}
new_ast = remove_directives_from_ast(macro_definition_ast, directives_to_remove)
return MacroEdgeDescriptor(class_name, macro_edge_name, new_ast, macro_edge_args)
return MacroEdgeDescriptor(class_name, target_class_name, macro_edge_name,
new_ast, macro_edge_args)
239 changes: 239 additions & 0 deletions graphql_compiler/macros/macro_edge/expansion.py
@@ -0,0 +1,239 @@
# Copyright 2019-present Kensho Technologies, LLC.
from graphql.language.ast import Field, InlineFragment, SelectionSet
import six

from ...ast_manipulation import get_ast_field_name
from ...exceptions import GraphQLCompilationError
from ...schema import FilterDirective, is_vertex_field_name
from .ast_rewriting import find_target_and_copy_path_to_it, merge_selection_sets, replace_tag_names
from .ast_traversal import get_all_tag_names
from .directives import MacroEdgeTargetDirective
from .name_generation import generate_disambiguations


def _ensure_directives_on_macro_edge_are_supported(macro_edge_field):
"""Raise GraphQLCompilationError if an unsupported directive is used on the macro edge."""
macro_name = get_ast_field_name(macro_edge_field)
directives_supported_at_macro_expansion = frozenset({
FilterDirective.name,
})
for directive in macro_edge_field.directives:
directive_name = directive.name.value
if directive_name not in directives_supported_at_macro_expansion:
raise GraphQLCompilationError(
u'Encountered a {} directive applied to the {} macro edge, which is '
u'not currently supported by the macro system. Please alter your query to not use '
u'unsupported directives on macro edges. Supported directives are: {}'
.format(directive_name, macro_name,
set(directives_supported_at_macro_expansion)))


def _merge_selection_into_target(subclass_sets, target_ast, target_class_name, selection_ast):
"""Add the selections, directives, and coercions from the selection_ast to the target_ast.
Mutate the target_ast, merging into it everything from the selection_ast. If the target
is at a type coercion and the selection_ast starts with a type coercion, combine them
into one coercion that preserves the semantics while avoiding nested coercions,
which are disallowed.
For details on how fields and directives are merged, see merge_selection_sets().
Args:
subclass_sets: dict mapping class names to the set of names of their subclasses
target_ast: AST at the @macro_edge_target directive
target_class_name: str, the name of the GraphQL type to which the macro edge points
selection_ast: AST to merge inside the target. Required to have a nonempty selection set.
"""
if selection_ast.selection_set is None or not selection_ast.selection_set.selections:
raise AssertionError(u'Precondition violated. selection_ast is expected to be nonempty {}'
.format(selection_ast))

# Remove @macro_edge_target directive.
new_target_directives = [
directive
for directive in target_ast.directives
if directive.name.value != MacroEdgeTargetDirective.name
]
if len(target_ast.directives) != len(new_target_directives) + 1:
raise AssertionError(u'Expected the target_ast to contain a single @macro_edge_target '
u'directive, but that was unexpectedly not the case: '
u'{} {}'.format(target_ast, new_target_directives))
target_ast.directives = new_target_directives

# See if there's a type coercion in the selection_ast.
coercion = None
for selection in selection_ast.selection_set.selections:
if isinstance(selection, InlineFragment):
if len(selection_ast.selection_set.selections) != 1:
raise GraphQLCompilationError(u'Found selections outside type coercion. '
u'Please move them inside the coercion. '
u'Error near field name: {}'
.format(get_ast_field_name(selection_ast)))
else:
coercion = selection

continuation_ast = selection_ast

# Deal with type coercions immediately within the macro edge, if any.
if coercion is not None:
coercion_class = coercion.type_condition.name.value

# Ensure the coercion is valid. It may only go to a subtype of the type of the vertex field
# created by the macro edge, where we allow subtypes to be defined by subclass_sets
# to work around the limitations of the GraphQL type system. If the user's coercion
# is to a subtype of the macro edge target's type, then this is a narrowing conversion and
# we simply add the user's coercion, or replace any existing coercion if one is present.
if coercion_class != target_class_name:
if coercion_class not in subclass_sets.get(target_class_name, set()):
raise GraphQLCompilationError(
u'Attempting to use a type coercion to coerce a value of type {field_type} '
u'(from field named {field_name}) to incompatible type {coercion_type}, which '
u'is not a subtype of {field_type}. Only coercions to a subtype are allowed.'
.format(field_type=target_class_name,
coercion_type=coercion_class,
field_name=get_ast_field_name(selection_ast)))

continuation_ast = coercion
if isinstance(target_ast, InlineFragment):
# The macro edge definition has a type coercion as well, replace it with the user's one.
target_ast.type_condition = coercion.type_condition
else:
# No coercion in the macro edge definition,
# slip the user's type coercion inside the target AST.
new_coercion = InlineFragment(
coercion.type_condition, target_ast.selection_set, directives=[])
target_ast.selection_set = SelectionSet([new_coercion])
target_ast = new_coercion

# Merge the continuation into the target
target_ast.directives += continuation_ast.directives
target_ast.selection_set = merge_selection_sets(
target_ast.selection_set, continuation_ast.selection_set)


def _expand_specific_macro_edge(subclass_sets, target_class_name, macro_ast, selection_ast):
"""Produce a tuple containing the new replacement selection AST, and a list of extra selections.
Args:
subclass_sets: dict mapping class names to the set of names of its subclasses
target_class_name: str, the name of the GraphQL type to which the macro edge points
macro_ast: AST GraphQL object defining the macro edge. Originates from
the "expansion_ast" key from a MacroEdgeDescriptor, though potentially sanitized.
selection_ast: GraphQL AST object containing the selection that is relying on a macro edge.
Returns:
tuple of:
- replacement_selection_ast: GraphQL AST object to replace the given selection_ast
- sibling_prefix_selections: list of GraphQL AST objects describing the selections
to be added somewhere in the same scope but before the replacement_selection_ast.
- sibling_suffix_selections: list of GraphQL AST objects describing the selections
to be added somewhere in the same scope but after the replacement_selection_ast.
Since the replacemet_selection_ast is a vertex field, and vertex fields always
go after property fields, these selections are all vertex fields.
"""
replacement_selection_ast = None
sibling_prefix_selections = []
sibling_suffix_selections = []

# TODO(bojanserafimov): Remove macro tags if the user has tagged the same field.

for macro_selection in macro_ast.selection_set.selections:
new_ast, target_ast = find_target_and_copy_path_to_it(macro_selection)
if target_ast is None:
if replacement_selection_ast is None:
sibling_prefix_selections.append(macro_selection)
else:
sibling_suffix_selections.append(macro_selection)
else:
if replacement_selection_ast is not None:
raise AssertionError(u'Found multiple @macro_edge_target directives. This means '
u'the macro definition is invalid, and should never happen '
u'as it should have been caught during validation. Macro AST: '
u'{}'.format(macro_ast))
replacement_selection_ast = new_ast
_merge_selection_into_target(
subclass_sets, target_ast, target_class_name, selection_ast)

if replacement_selection_ast is None:
raise AssertionError(u'Found no @macro_edge_target directives in macro selection set. {}'
.format(macro_ast))

return replacement_selection_ast, sibling_prefix_selections, sibling_suffix_selections


def _merge_non_overlapping_dicts(merge_target, new_data):
"""Produce the merged result of two dicts that are supposed to not overlap."""
result = dict(merge_target)

for key, value in six.iteritems(new_data):
if key in merge_target:
raise AssertionError(u'Overlapping key "{}" found in dicts that are supposed '
u'to not overlap. Values: {} {}'
.format(key, merge_target[key], value))

result[key] = value

return result


# ############
# Public API #
# ############

def expand_potential_macro_edge(macro_registry, current_schema_type, ast, query_args, tag_names):
"""Expand the macro edge at the provided field, if it refers to a macro edge.
Args:
macro_registry: MacroRegistry, the registry of macro descriptors used for expansion
current_schema_type: GraphQL type object describing the current type at the given AST node
ast: GraphQL AST object that potentially requires macro expansion
query_args: dict mapping strings to any type, containing the arguments for the query
tag_names: set of names of tags currently in use. The set is mutated in this function.
Returns:
tuple (new_ast, new_query_args, sibling_prefix_selections, sibling_suffix_selections)
It contains a potentially-rewritten GraphQL AST object and its matching args, as well as
any sibling selections (lists of fields existing in the same scope as the new_ast)
that should be added either before (prefix) or after (suffix) the appearance of new_ast
in its scope. If no changes were made (e.g. if the AST was not a macro edge), the new_ast
and new_query_args values are guaranteed to be the exact same objects as the input ones,
whereas the prefix and suffix sibling selections values are guaranteed to be empty lists.
"""
no_op_result = (ast, query_args, [], [])

macro_edges_at_this_type = macro_registry.macro_edges_at_class.get(
current_schema_type.name, dict())

# If the input AST isn't a Field, it can't be a macro edge. Nothing to be done.
if not isinstance(ast, Field):
return no_op_result

# If the field isn't a vertex field, it can't be a macro edge. Nothing to be done.
field_name = get_ast_field_name(ast)
if not is_vertex_field_name(field_name):
return no_op_result

# If the vertex field isn't a macro edge, there's nothing to be done.
macro_edge_descriptor = macro_edges_at_this_type.get(field_name, None)
if macro_edge_descriptor is None:
return no_op_result

# We're dealing with a macro edge. Ensure its use is legal.
_ensure_directives_on_macro_edge_are_supported(ast)

# Sanitize the macro, making sure it doesn't use any taken tag names.
macro_tag_names = get_all_tag_names(macro_edge_descriptor.expansion_ast)
name_change_map = generate_disambiguations(tag_names, macro_tag_names)
tag_names.update(name_change_map.values())
sanitized_macro_ast = replace_tag_names(
name_change_map, macro_edge_descriptor.expansion_ast)
tag_names.update(name_change_map.values())

new_ast, prefix_selections, suffix_selections = _expand_specific_macro_edge(
macro_registry.subclass_sets, macro_edge_descriptor.target_class_name,
sanitized_macro_ast, ast)
# TODO(predrag): Write a test that makes sure we've chosen names for filter arguments that
# do not overlap with user's filter arguments.
new_query_args = _merge_non_overlapping_dicts(query_args, macro_edge_descriptor.macro_args)

return (new_ast, new_query_args, prefix_selections, suffix_selections)
21 changes: 21 additions & 0 deletions graphql_compiler/macros/macro_edge/reversal.py
@@ -0,0 +1,21 @@
# Copyright 2019-present Kensho Technologies, LLC.
from ...schema import INBOUND_EDGE_FIELD_PREFIX, OUTBOUND_EDGE_FIELD_PREFIX


# ############
# Public API #
# ############

def make_reverse_macro_edge_name(macro_edge_name):
if macro_edge_name.startswith(INBOUND_EDGE_FIELD_PREFIX):
raw_edge_name = macro_edge_name[len(INBOUND_EDGE_FIELD_PREFIX):]
prefix = OUTBOUND_EDGE_FIELD_PREFIX
elif macro_edge_name.startswith(OUTBOUND_EDGE_FIELD_PREFIX):
raw_edge_name = macro_edge_name[len(OUTBOUND_EDGE_FIELD_PREFIX):]
prefix = INBOUND_EDGE_FIELD_PREFIX
else:
raise AssertionError(u'Unreachable condition reached: {}'.format(macro_edge_name))

reversed_macro_edge_name = prefix + raw_edge_name

return reversed_macro_edge_name

0 comments on commit ff7986d

Please sign in to comment.