Skip to content

Commit 9251a83

Browse files
evantey14bojanserafimov
authored andcommitted
Raise error for unused @tag directives (kensho-technologies#224)
* Add tag usage validator * Update tests with unused @tags * Create unused tag test * Enable FoldScopeLocation filter recording * Fix lint * Add dummy filters to tag tests * Add TagInfo to metadata * Use only metadata to validate tag usage * Add validate tag usage docstring * Move tag and variable check to helpers * Nits * Create helper to get argument names * Improve unused tag error message * Fix metadata tag methods * Fix IR generation tests
1 parent 931e511 commit 9251a83

File tree

6 files changed

+119
-26
lines changed

6 files changed

+119
-26
lines changed

graphql_compiler/compiler/compiler_frontend.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,12 @@
8585
)
8686
from .filters import process_filter_directive
8787
from .helpers import (
88-
FoldScopeLocation, Location, get_ast_field_name, get_edge_direction_and_name,
89-
get_field_type_from_schema, get_uniquely_named_objects_by_name, get_vertex_field_type,
90-
invert_dict, is_vertex_field_name, strip_non_null_from_type, validate_output_name,
91-
validate_safe_string
88+
FoldScopeLocation, Location, get_ast_field_name, get_directive_argument_name,
89+
get_edge_direction_and_name, get_field_type_from_schema, get_uniquely_named_objects_by_name,
90+
get_vertex_field_type, invert_dict, is_tag_argument, is_vertex_field_name,
91+
strip_non_null_from_type, validate_output_name, validate_safe_string
9292
)
93-
from .metadata import LocationInfo, QueryMetadataTable, RecurseInfo
93+
from .metadata import LocationInfo, QueryMetadataTable, RecurseInfo, TagInfo
9494

9595

9696
# LocationStackEntry contains the following:
@@ -292,6 +292,7 @@ def _compile_property_ast(schema, current_schema_type, ast, location,
292292
'optional': is_in_optional_scope(context),
293293
'type': strip_non_null_from_type(current_schema_type),
294294
}
295+
context['metadata'].record_tag_info(tag_name, TagInfo(location=location))
295296

296297
output_directive = unique_local_directives.get('output', None)
297298
if output_directive:
@@ -718,6 +719,24 @@ def _compile_ast_node_to_ir(schema, current_schema_type, ast, location, context)
718719
return basic_blocks
719720

720721

722+
def _validate_all_tags_are_used(metadata):
723+
"""Ensure all tags are used in some filter."""
724+
tag_names = set([tag_name for tag_name, _ in metadata.tags])
725+
filter_arg_names = set()
726+
for location, _ in metadata.registered_locations:
727+
for filter_info in metadata.get_filter_infos(location):
728+
for filter_arg in filter_info.args:
729+
if is_tag_argument(filter_arg):
730+
filter_arg_names.add(get_directive_argument_name(filter_arg))
731+
732+
unused_tags = tag_names - filter_arg_names
733+
if unused_tags:
734+
raise GraphQLCompilationError(u'This GraphQL query contains @tag directives whose values '
735+
u'are not used: {}. This is not allowed. Please either use '
736+
u'them in a filter or remove them entirely.'
737+
.format(unused_tags))
738+
739+
721740
def _compile_root_ast_to_ir(schema, ast, type_equivalence_hints=None):
722741
"""Compile a full GraphQL abstract syntax tree (AST) to intermediate representation.
723742
@@ -819,6 +838,8 @@ def _compile_root_ast_to_ir(schema, ast, type_equivalence_hints=None):
819838
schema, current_schema_type, base_ast, location, context)
820839
basic_blocks.extend(new_basic_blocks)
821840

841+
_validate_all_tags_are_used(context['metadata'])
842+
822843
# All operations after this point affect the global query scope, and are not related to
823844
# the "current" location in the query produced by the sequence of Traverse/Backtrack blocks.
824845
basic_blocks.append(blocks.GlobalOperationsStart())

graphql_compiler/compiler/filters.py

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
from . import blocks, expressions
99
from ..exceptions import GraphQLCompilationError, GraphQLValidationError
1010
from .helpers import (
11-
get_uniquely_named_objects_by_name, is_vertex_field_name, is_vertex_field_type,
12-
strip_non_null_from_type, validate_safe_string
11+
get_uniquely_named_objects_by_name, is_tag_argument, is_variable_argument, is_vertex_field_name,
12+
is_vertex_field_type, strip_non_null_from_type, validate_safe_string
1313
)
1414
from .metadata import FilterInfo
1515

@@ -79,16 +79,6 @@ def wrapper(filter_operation_info, location, context, parameters, *args, **kwarg
7979
return decorator
8080

8181

82-
def _is_variable_argument(argument_name):
83-
"""Return True if the argument is a runtime variable, and False otherwise."""
84-
return argument_name.startswith('$')
85-
86-
87-
def _is_tag_argument(argument_name):
88-
"""Return True if the argument is a tagged value, and False otherwise."""
89-
return argument_name.startswith('%')
90-
91-
9282
def _represent_argument(directive_location, context, argument, inferred_type):
9383
"""Return a two-element tuple that represents the argument to the directive being processed.
9484
@@ -112,7 +102,7 @@ def _represent_argument(directive_location, context, argument, inferred_type):
112102
argument_name = argument[1:]
113103
validate_safe_string(argument_name)
114104

115-
if _is_variable_argument(argument):
105+
if is_variable_argument(argument):
116106
existing_type = context['inputs'].get(argument_name, inferred_type)
117107
if not inferred_type.is_same_type(existing_type):
118108
raise GraphQLCompilationError(u'Incompatible types inferred for argument {}. '
@@ -122,7 +112,7 @@ def _represent_argument(directive_location, context, argument, inferred_type):
122112
context['inputs'][argument_name] = inferred_type
123113

124114
return (expressions.Variable(argument, inferred_type), None)
125-
elif _is_tag_argument(argument):
115+
elif is_tag_argument(argument):
126116
argument_context = context['tags'].get(argument_name, None)
127117
if argument_context is None:
128118
raise GraphQLCompilationError(u'Undeclared argument used: {}'.format(argument))
@@ -246,7 +236,7 @@ def _process_has_edge_degree_filter_directive(filter_operation_info, location, c
246236
u'"has_edge_degree" filter: {}'.format(filter_operation_info))
247237

248238
argument = parameters[0]
249-
if not _is_variable_argument(argument):
239+
if not is_variable_argument(argument):
250240
raise GraphQLCompilationError(u'The "has_edge_degree" filter only supports runtime '
251241
u'variable arguments. Tagged values are not supported.'
252242
u'Argument name: {}'.format(argument))

graphql_compiler/compiler/helpers.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,21 @@ def invert_dict(invertible_dict):
242242
return inverted
243243

244244

245+
def is_variable_argument(argument):
246+
"""Return True if the directive argument is a runtime variable, and False otherwise."""
247+
return argument.startswith('$')
248+
249+
250+
def is_tag_argument(argument):
251+
"""Return True if the directive argument is a tagged value, and False otherwise."""
252+
return argument.startswith('%')
253+
254+
255+
def get_directive_argument_name(argument):
256+
"""Return the name of a variable or tag argument without the $ or %."""
257+
return argument[1:]
258+
259+
245260
@total_ordering
246261
@six.add_metaclass(ABCMeta)
247262
class BaseLocation(object):

graphql_compiler/compiler/metadata.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import six
66

7-
from .helpers import FoldScopeLocation, Location
7+
from .helpers import Location
88

99

1010
LocationInfo = namedtuple(
@@ -24,6 +24,13 @@
2424
)
2525

2626

27+
TagInfo = namedtuple(
28+
'TagInfo',
29+
(
30+
'location',
31+
)
32+
)
33+
2734
FilterInfo = namedtuple(
2835
'FilterInfo',
2936
(
@@ -154,11 +161,27 @@ def get_location_info(self, location):
154161
u'{}'.format(location))
155162
return location_info
156163

164+
@property
165+
def tags(self):
166+
"""Return an iterable of (tag_name, tag_info) tuples for all tags in the query."""
167+
for tag_name, tag_info in six.iteritems(self._tags):
168+
yield tag_name, tag_info
169+
170+
def record_tag_info(self, tag_name, tag_info):
171+
"""Record information about the tag."""
172+
old_info = self._tags.get(tag_name, None)
173+
if old_info is not None:
174+
raise AssertionError(u'Attempting to define an already-defined tag {}. '
175+
u'old info {}, new info {}'
176+
.format(tag_name, old_info, tag_info))
177+
self._tags[tag_name] = tag_info
178+
179+
def get_tag_info(self, tag_name):
180+
"""Get information about a tag."""
181+
return self._tags.get(tag_name, None)
182+
157183
def record_filter_info(self, location, filter_info):
158184
"""Record filter information about the location."""
159-
if isinstance(location, FoldScopeLocation):
160-
# NOTE(gurer): ignore filters inside the fold for now
161-
return
162185
record_location = location.at_vertex()
163186
self._filter_infos.setdefault(record_location, []).append(filter_info)
164187

graphql_compiler/tests/test_explain_info.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from . import test_input_data
55
from ..compiler.compiler_frontend import graphql_to_ir
6-
from ..compiler.helpers import Location
6+
from ..compiler.helpers import FoldScopeLocation, Location
77
from ..compiler.metadata import FilterInfo, RecurseInfo
88
from .test_helpers import get_schema
99

@@ -66,8 +66,14 @@ def test_complex_optional_traversal_variables(self):
6666
[])
6767

6868
def test_coercion_filters_and_multiple_outputs_within_fold_scope(self):
69+
loc = FoldScopeLocation(Location(('Animal',), None, 1), (('out', 'Entity_Related'),), None)
70+
filters = [
71+
FilterInfo(fields=('name',), op_name='has_substring', args=('$substring',)),
72+
FilterInfo(fields=('birthday',), op_name='<=', args=('$latest',)),
73+
]
74+
6975
self.check(test_input_data.coercion_filters_and_multiple_outputs_within_fold_scope,
70-
[],
76+
[(loc, filters)],
7177
[])
7278

7379
def test_multiple_filters(self):

graphql_compiler/tests/test_ir_generation_errors.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,9 @@ def test_tag_directive_constraints(self):
445445
out_Animal_ParentOf @tag(tag_name: "role") {
446446
uuid @output(out_name: "uuid")
447447
}
448+
in_Animal_ParentOf {
449+
name @filter(op_name: "!=", value: ["%role"])
450+
}
448451
}
449452
}''')
450453

@@ -462,6 +465,9 @@ def test_tag_directive_constraints(self):
462465
out_Animal_ParentOf {
463466
uuid @tag(tag_name: "uuid")
464467
}
468+
in_Animal_ParentOf {
469+
uuid @filter(op_name: "!=", value: ["%uuid"])
470+
}
465471
}
466472
}''')
467473

@@ -478,6 +484,9 @@ def test_tag_directive_constraints(self):
478484
name @tag(tag_name: "name")
479485
uuid @output(out_name: "uuid")
480486
}
487+
in_Animal_ParentOf {
488+
name @filter(op_name: "!=", value: ["%name"])
489+
}
481490
}
482491
}''')
483492

@@ -487,6 +496,16 @@ def test_tag_directive_constraints(self):
487496
out_Animal_ParentOf {
488497
_x_count @tag(tag_name: "count")
489498
}
499+
in_Animal_ParentOf {
500+
_x_count @filter(op_name: "=", value: ["%count"])
501+
}
502+
}
503+
}''')
504+
505+
unused_tag = (GraphQLCompilationError, '''{
506+
Animal {
507+
name @tag(tag_name: "name")
508+
uuid @output(out_name: "uuid")
490509
}
491510
}''')
492511

@@ -497,6 +516,7 @@ def test_tag_directive_constraints(self):
497516
tag_with_illegal_name,
498517
tag_within_fold_scope,
499518
tag_on_count_field,
519+
unused_tag,
500520
)
501521

502522
for expected_error, graphql in errors_and_inputs:
@@ -730,6 +750,7 @@ def test_duplicate_directive_on_same_field(self):
730750
Event {
731751
name @tag(tag_name: "name")
732752
event_date @output(out_name: "date")
753+
description @filter(op_name: "has_substring", value: ["%name"])
733754
}
734755
}''')
735756

@@ -738,6 +759,8 @@ def test_duplicate_directive_on_same_field(self):
738759
Event {
739760
name @tag(tag_name: "name") @tag(tag_name: "name2")
740761
event_date @output(out_name: "date")
762+
description @filter(op_name: "has_substring", value: ["%name"])
763+
@filter(op_name: "has_substring", value: ["%name2"])
741764
}
742765
}''')
743766

@@ -750,6 +773,11 @@ def test_property_field_after_vertex_field(self):
750773
in_Animal_FedAt {
751774
name @output(out_name: "animal")
752775
}
776+
in_Event_RelatedEvent {
777+
... on Event {
778+
event_date @filter(op_name: "=", value: ["%date"])
779+
}
780+
}
753781
}
754782
}''')
755783

@@ -761,6 +789,11 @@ def test_property_field_after_vertex_field(self):
761789
name @output(out_name: "animal")
762790
}
763791
event_date @tag(tag_name: "date")
792+
in_Event_RelatedEvent {
793+
... on Event {
794+
event_date @filter(op_name: "=", value: ["%date"])
795+
}
796+
}
764797
}
765798
}''', '''{
766799
FeedingEvent {
@@ -769,6 +802,11 @@ def test_property_field_after_vertex_field(self):
769802
}
770803
name @output(out_name: "name")
771804
event_date @tag(tag_name: "date")
805+
in_Event_RelatedEvent {
806+
... on Event {
807+
event_date @filter(op_name: "=", value: ["%date"])
808+
}
809+
}
772810
}
773811
}'''
774812
)

0 commit comments

Comments
 (0)