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 all 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,252 @@ | ||
# Copyright 2019-present Kensho Technologies, LLC. | ||
from collections import namedtuple | ||
from copy import copy | ||
|
||
from graphql import parse | ||
from graphql.language.ast import ( | ||
Directive, FieldDefinition, InterfaceTypeDefinition, ListType, Name, NamedType, | ||
ObjectTypeDefinition | ||
) | ||
from graphql.language.printer import print_ast | ||
from graphql.utils.build_ast_schema import build_ast_schema | ||
from graphql.utils.schema_printer import print_schema | ||
import six | ||
|
||
from ..ast_manipulation import safe_parse_graphql | ||
from ..compiler.subclass import compute_subclass_sets | ||
from ..compiler.validation import validate_schema_and_query_ast | ||
from ..exceptions import GraphQLValidationError | ||
from ..schema import check_for_nondefault_directive_names | ||
from .macro_edge import make_macro_edge_descriptor | ||
from .macro_edge.directives import ( | ||
DIRECTIVES_ALLOWED_IN_MACRO_EDGE_DEFINITION, DIRECTIVES_REQUIRED_IN_MACRO_EDGE_DEFINITION, | ||
MacroEdgeDirective | ||
) | ||
from .macro_expansion import expand_macros_in_query_ast | ||
from .validation import ( | ||
check_macro_edge_for_definition_conflicts, check_macro_edge_for_reversal_definition_conflicts | ||
) | ||
|
||
|
||
MacroRegistry = namedtuple( | ||
'MacroRegistry', ( | ||
# GraphQLSchema, created using the GraphQL library | ||
'schema_without_macros', | ||
|
||
# Optional dict of GraphQL interface or type -> GraphQL union. | ||
# Used as a workaround for GraphQL's lack of support for | ||
# inheritance across "types" (i.e. non-interfaces), as well as a | ||
# workaround for Gremlin's total lack of inheritance-awareness. | ||
# The key-value pairs in the dict specify that the "key" type | ||
# is equivalent to the "value" type, i.e. that the GraphQL type or | ||
# interface in the key is the most-derived common supertype | ||
# of every GraphQL type in the "value" GraphQL union. | ||
# Recursive expansion of type equivalence hints is not performed, | ||
# and only type-level correctness of this argument is enforced. | ||
# See README.md for more details on everything this parameter does. | ||
# ***** | ||
# Be very careful with this option, as bad input here will | ||
# lead to incorrect output queries being generated. | ||
# ***** | ||
'type_equivalence_hints', | ||
|
||
# Dict[str, Set[str]] mapping class names to the set of its subclass names. | ||
# A class in this context means the name of a GraphQLObjectType, | ||
# GraphQLUnionType or GraphQLInterface. | ||
'subclass_sets', | ||
|
||
# ################# | ||
# Macro edge info # | ||
# ################# | ||
# List[MacroEdgeDescriptor] containing all defined macro edges | ||
'macro_edges', | ||
|
||
# Dict[str, Dict[str, MacroEdgeDescriptor]] mapping: | ||
# class name -> (macro edge name -> MacroEdgeDescriptor) | ||
# If a given macro edge is defined on a class X which has subclasses A and B, | ||
# then this dict will contain entries for that macro edge for all of [X, A, B]. | ||
'macro_edges_at_class', | ||
|
||
# Dict[str, Dict[str, MacroEdgeDescriptor]] mapping: | ||
# class name -> (macro edge name -> MacroEdgeDescriptor) | ||
# If a given macro edge has class X as a target, which has subclasses A and B, | ||
# then this dict will contain entries for that macro edge for all of [X, A, B]. | ||
'macro_edges_to_class', | ||
|
||
# ######################################################################## | ||
# Any other macro types we may add in the future belong under this line. # | ||
# ######################################################################## | ||
) | ||
) | ||
|
||
|
||
def create_macro_registry(schema, type_equivalence_hints=None, subclass_sets=None): | ||
"""Create and return a new empty macro registry.""" | ||
if subclass_sets is None: | ||
subclass_sets = compute_subclass_sets(schema, type_equivalence_hints=type_equivalence_hints) | ||
|
||
return MacroRegistry( | ||
schema_without_macros=schema, | ||
type_equivalence_hints=type_equivalence_hints, | ||
subclass_sets=subclass_sets, | ||
macro_edges=list(), | ||
macro_edges_at_class=dict(), | ||
macro_edges_to_class=dict()) | ||
|
||
|
||
def register_macro_edge(macro_registry, macro_edge_graphql, macro_edge_args): | ||
"""Add the new macro edge descriptor to the provided MacroRegistry object, mutating it. | ||
|
||
In order to register a new macro edge, the following properties must be true: | ||
- The macro edge, with the addition of any output value, must become a valid query. This ensures | ||
that it is compliant with the schema, supplies values for all runtime and tagged parameters, | ||
and obeys all other rules imposed by the compiler. | ||
- The macro edge must not contain any directives that are prohibited in macro edge definitions. | ||
- The macro edge will become a new vertex field on its base type, and therefore must be named | ||
a vertex field name (prefixed with "out_" or "in_"). | ||
- Any class together with its subclasses may have defined at most one macro edge with that name. | ||
- Any class together with its subclasses must be the target of at most one macro edge with | ||
that name. | ||
- For any macro edge named out_X (similarly, in_Y) defined at type A and with target type B, | ||
the reversed macro edge in_X (similarly, out_Y) defined at type B and with target type A | ||
either already exists, or could be defined without violating any of the above rules. | ||
|
||
Args: | ||
macro_registry: MacroRegistry object containing macro descriptors, where the new | ||
macro edge descriptor should be added. | ||
macro_edge_graphql: string, GraphQL defining how the new macro edge should be expanded | ||
macro_edge_args: dict mapping strings to any type, containing any arguments the macro edge | ||
requires in order to function. | ||
""" | ||
# The below function will validate that the macro edge in question is valid in isolation, | ||
# when considered only against the macro-less schema. After geting this result, | ||
# we simply need to check the macro edge descriptor against other artifacts in the macro system | ||
# that might also cause conflicts. | ||
macro_descriptor = make_macro_edge_descriptor( | ||
macro_registry.schema_without_macros, macro_registry.subclass_sets, | ||
macro_edge_graphql, macro_edge_args, | ||
type_equivalence_hints=macro_registry.type_equivalence_hints) | ||
|
||
# Ensure there's no conflict with macro edges defined on subclasses and superclasses. | ||
check_macro_edge_for_definition_conflicts(macro_registry, macro_descriptor) | ||
|
||
# Ensure there's no conflict between existing macro edges and the (hypothetical) reversed | ||
# macro edge of the one being defined. | ||
check_macro_edge_for_reversal_definition_conflicts(macro_registry, macro_descriptor) | ||
|
||
for subclass_name in macro_registry.subclass_sets[macro_descriptor.base_class_name]: | ||
macro_registry.macro_edges_at_class.setdefault( | ||
subclass_name, dict())[macro_descriptor.macro_edge_name] = macro_descriptor | ||
|
||
for subclass_name in macro_registry.subclass_sets[macro_descriptor.target_class_name]: | ||
macro_registry.macro_edges_to_class.setdefault( | ||
subclass_name, dict())[macro_descriptor.macro_edge_name] = macro_descriptor | ||
|
||
macro_registry.macro_edges.append(macro_descriptor) | ||
|
||
|
||
def get_schema_with_macros(macro_registry): | ||
"""Get a new GraphQLSchema with fields where macro edges can be used. | ||
|
||
Preconditions: | ||
1. No macro in the registry has the same name as a field on the vertex where it applies. | ||
2. Members of a union type do not have outgoing macros with the same name. | ||
|
||
An easy way to satisfy the preconditions is to create the macro_registry using | ||
create_macro_registry, and only update it with register_macro_edge, which does all | ||
the necessary validation. | ||
|
||
Postconditions: | ||
1. Every GraphQLQuery that uses macros from this registry appropriately should | ||
successfully type-check against the schema generated from this function. | ||
2. A GraphQLQuery that uses macros not present in the registry, or uses valid | ||
macros but on types they are not defined at should fail schema validation with | ||
the schema generated from this function. | ||
3. This function is total -- A valid macro registry should not fail to create a | ||
GraphQL schema with macros. | ||
|
||
Args: | ||
macro_registry: MacroRegistry object containing a schema and macro descriptors | ||
we want to add to the schema. | ||
|
||
Returns: | ||
GraphQLSchema with additional fields where macroe edges can be used. | ||
""" | ||
# The easiest way to manipulate the schema is through its AST. The easiest | ||
# way to get an AST is to print it and parse it. | ||
schema_ast = parse(print_schema(macro_registry.schema_without_macros)) | ||
|
||
definitions_by_name = {} | ||
for definition in schema_ast.definitions: | ||
if isinstance(definition, (ObjectTypeDefinition, InterfaceTypeDefinition)): | ||
definitions_by_name[definition.name.value] = definition | ||
|
||
for class_name, macros_for_class in six.iteritems(macro_registry.macro_edges_at_class): | ||
for macro_edge_name, macro_edge_descriptor in six.iteritems(macros_for_class): | ||
list_type_at_target = ListType(NamedType(Name(macro_edge_descriptor.target_class_name))) | ||
arguments = [] | ||
directives = [Directive(Name(MacroEdgeDirective.name))] | ||
definitions_by_name[class_name].fields.append(FieldDefinition( | ||
Name(macro_edge_name), arguments, list_type_at_target, directives=directives)) | ||
|
||
return build_ast_schema(schema_ast) | ||
|
||
|
||
def get_schema_for_macro_definition(schema): | ||
"""Return a schema with macro definition directives added in. | ||
|
||
Preconditions: | ||
1. All compiler-supported and GraphQL-default directives have their default behavior. | ||
|
||
This returned schema can be used to validate macro definitions, and support GraphQL | ||
macro editors, enabling them to autocomplete on the @macro_edge_definition and | ||
@macro_edge_target directives. Some directives that are disallowed in macro edge definitions, | ||
like @output and @output_source, will be removed from the directives list. | ||
|
||
Args: | ||
schema: GraphQLSchema over which we want to write macros | ||
|
||
Returns: | ||
GraphQLSchema usable for writing macros. Modifying this schema is undefined behavior. | ||
|
||
Raises: | ||
AssertionError, if the schema contains directive names that are non-default. | ||
""" | ||
macro_definition_schema = copy(schema) | ||
macro_definition_schema_directives = schema.get_directives() | ||
check_for_nondefault_directive_names(macro_definition_schema_directives) | ||
macro_definition_schema_directives += DIRECTIVES_REQUIRED_IN_MACRO_EDGE_DEFINITION | ||
# Remove disallowed directives from directives list | ||
macro_definition_schema_directives = list(set(macro_definition_schema_directives) & | ||
set(DIRECTIVES_ALLOWED_IN_MACRO_EDGE_DEFINITION)) | ||
|
||
# pylint: disable=protected-access | ||
macro_definition_schema._directives = macro_definition_schema_directives | ||
# pylint: enable=protected-access | ||
return macro_definition_schema | ||
|
||
|
||
def perform_macro_expansion(macro_registry, graphql_with_macro, graphql_args): | ||
"""Return a new GraphQL query string and args, after expanding any encountered macros. | ||
|
||
Args: | ||
macro_registry: MacroRegistry, the registry of macro descriptors used for expansion | ||
graphql_with_macro: string, GraphQL query that potentially requires macro expansion | ||
graphql_args: dict mapping strings to any type, containing the arguments for the query | ||
|
||
Returns: | ||
tuple (new_graphql_string, new_graphql_args) containing the rewritten GraphQL query and | ||
its new args, after macro expansion. If the input GraphQL query contained no macros, | ||
the returned values are guaranteed to be identical to the input query and args. | ||
""" | ||
query_ast = safe_parse_graphql(graphql_with_macro) | ||
schema_with_macros = get_schema_with_macros(macro_registry) | ||
validation_errors = validate_schema_and_query_ast(schema_with_macros, query_ast) | ||
if validation_errors: | ||
raise GraphQLValidationError(u'The provided GraphQL input does not validate: {} {}' | ||
.format(graphql_with_macro, validation_errors)) | ||
|
||
new_query_ast, new_args = expand_macros_in_query_ast(macro_registry, query_ast, graphql_args) | ||
new_graphql_string = print_ast(new_query_ast) | ||
|
||
return new_graphql_string, new_args |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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): | ||
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. This function was never used, so I deleted it. |
||
"""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. | ||
|
||
|
@@ -374,6 +329,20 @@ def merge_selection_sets(selection_set_a, selection_set_b): | |
u'same edge {} twice, which is disallowed.' | ||
.format(field_name)) | ||
|
||
# TODO(predrag): Find a way to avoid this situation by making the rewriting smarter. | ||
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. This is caught by tests marked |
||
field_a_has_tag_directive = any(( | ||
directive.name.value == TagDirective.name | ||
for directive in field_a.directives | ||
)) | ||
field_b_has_tag_directive = any(( | ||
directive.name.value == TagDirective.name | ||
for directive in field_b.directives | ||
)) | ||
if field_a_has_tag_directive and field_b_has_tag_directive: | ||
raise GraphQLCompilationError(u'Macro edge expansion results in field {} having two ' | ||
u'@tag directives, which is disallowed.' | ||
.format(field_name)) | ||
|
||
merged_field = copy(field_a) | ||
merged_field.directives = list(chain(field_a.directives, field_b.directives)) | ||
common_selection_dict[field_name] = merged_field | ||
|
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) |
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.
I added the explicit set of macro edge validity rules here. Let me know if I missed any.