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
Macro system #356
Macro system #356
Changes from 113 commits
e74ab48
06c672b
11c6705
3259797
690b435
b1480b5
03f7d9a
302760a
304a086
15c5c49
8026ed5
8b04e66
d853488
30d5460
ec471b1
6a71a00
5cf2564
cc9343c
3281eed
f9f5bb9
7a26634
a2c66a9
0f70206
44bb12d
eb32f95
a565504
190c6cd
0652e2d
2219f51
9c2aef4
8cab638
e335d00
02885b9
31daca1
a6a72bc
e2458c4
5049c64
6bceccb
96b5fad
bfe174f
8e4865e
05fb5bf
eb18e2f
515bda9
66a3c4f
2b92547
81a73c9
394241c
71d1cdc
8c9e5a2
b981548
8de182c
bdd74c6
c009b22
f9b7f72
37bd6a6
d09ef49
6c3d676
9eaaa36
48ef812
3472b15
e9a82b0
aefd4ec
23a8ab0
840100c
93a8123
de12d3b
6257975
344f4d5
aabdaba
4c0da02
5dca567
d0af3f6
a75a17c
e33e954
01bfe47
f728a8a
2ed4dda
aa11a36
8989492
ca64318
095e818
76d7908
9365587
75bfeba
62e22c6
9c3a496
2e7bbff
193bcb1
6420551
147acc5
3678436
2f59a36
2d87ce8
667a6ae
e474fd4
6fcf0e0
80b2efd
18e284d
bdb24bf
88193ba
860d6c0
1c75da0
1316dff
587e20a
fdc6dc8
d75dbba
d52614a
784d469
353e4f9
da2e97c
dca96f9
33708bb
5ff443b
14ac93f
7b914e5
9fb1e1b
b558389
0f677b5
4f05624
5384e33
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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. | ||
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. Storing the target class name made a lot of validation code simpler, and means that we don't have to keep recomputing this by walking the macro definition ASTs in situations like generating the schema with macro edges included. |
||
'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) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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): | ||
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. tech debt week: move to top-level utils |
||
"""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 | ||
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. Hoping to address this during our tech debt pass later this month. |
||
# 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) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
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.
This function was never used, so I deleted it.