Skip to content
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

Add support for repeatable directives #999

Merged
1 change: 1 addition & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ locals_without_parens = [
object: 3,
on: 1,
parse: 1,
repeatable: 1,
resolve: 1,
resolve_type: 1,
scalar: 2,
Expand Down
3 changes: 3 additions & 0 deletions lib/absinthe/blueprint/schema/directive_definition.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ defmodule Absinthe.Blueprint.Schema.DirectiveDefinition do
directives: [],
arguments: [],
locations: [],
repeatable: false,
source_location: nil,
expand: nil,
errors: [],
Expand All @@ -24,6 +25,7 @@ defmodule Absinthe.Blueprint.Schema.DirectiveDefinition do
description: nil,
arguments: [Blueprint.Schema.InputValueDefinition.t()],
locations: [String.t()],
repeatable: boolean(),
source_location: nil | Blueprint.SourceLocation.t(),
errors: [Absinthe.Phase.Error.t()]
}
Expand All @@ -36,6 +38,7 @@ defmodule Absinthe.Blueprint.Schema.DirectiveDefinition do
args: Blueprint.Schema.ObjectTypeDefinition.build_args(type_def, schema),
locations: type_def.locations |> Enum.sort(),
definition: type_def.module,
repeatable: type_def.repeatable,
expand: type_def.expand
}
end
Expand Down
1 change: 1 addition & 0 deletions lib/absinthe/language.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ defmodule Absinthe.Language do
Language.Argument.t()
| Language.BooleanValue.t()
| Language.Directive.t()
| Language.DirectiveDefinition.t()
| Language.Document.t()
| Language.EnumTypeDefinition.t()
| Language.EnumValue.t()
Expand Down
7 changes: 5 additions & 2 deletions lib/absinthe/language/directive_definition.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@ defmodule Absinthe.Language.DirectiveDefinition do
arguments: [],
directives: [],
locations: [],
loc: %{line: nil}
loc: %{line: nil},
repeatable: false

@type t :: %__MODULE__{
name: String.t(),
description: nil | String.t(),
directives: [Language.Directive.t()],
arguments: [Language.Argument.t()],
locations: [String.t()],
loc: Language.loc_t()
loc: Language.loc_t(),
repeatable: boolean()
}

defimpl Blueprint.Draft do
Expand All @@ -28,6 +30,7 @@ defmodule Absinthe.Language.DirectiveDefinition do
arguments: Absinthe.Blueprint.Draft.convert(node.arguments, doc),
directives: Absinthe.Blueprint.Draft.convert(node.directives, doc),
locations: node.locations,
repeatable: node.repeatable,
source_location: source_location(node)
}
end
Expand Down
1 change: 1 addition & 0 deletions lib/absinthe/lexer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ defmodule Absinthe.Lexer do
on
ON
query
repeatable
scalar
schema
subscription
Expand Down
86 changes: 86 additions & 0 deletions lib/absinthe/phase/document/validation/repeatable_directives.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
defmodule Absinthe.Phase.Document.Validation.RepeatableDirectives do
@moduledoc false

alias Absinthe.{Blueprint, Phase}

use Absinthe.Phase
use Absinthe.Phase.Validation

@doc """
Run the validation.
"""
@spec run(Blueprint.t(), Keyword.t()) :: Phase.result_t()
def run(input, _options \\ []) do
result = Blueprint.postwalk(input, &handle_node/1)
{:ok, result}
end

defp handle_node(%Blueprint.Directive{} = node) do
node
end

defp handle_node(%{directives: []} = node) do
node
end

defp handle_node(%{directives: _} = node) do
node
|> check_directives
|> inherit_invalid(node.directives, :bad_directive)
end

defp handle_node(node) do
node
end

defp check_directives(node) do
directives =
for directive <- node.directives do
case directive do
%{schema_node: nil} ->
directive

%{schema_node: %{repeatable: true}} ->
directive

directive ->
check_duplicates(
directive,
Enum.filter(
node.directives,
&compare_directive_schema_node(directive.schema_node, &1.schema_node)
)
)
end
end

%{node | directives: directives}
end

defp compare_directive_schema_node(_, nil), do: false

defp compare_directive_schema_node(%{identifier: identifier}, %{identifier: identifier}),
do: true

defp compare_directive_schema_node(_, _), do: false

# Generate the error for the node
@spec error_repeated(Blueprint.node_t()) :: Phase.Error.t()
defp error_repeated(node) do
%Phase.Error{
phase: __MODULE__,
message: "Directive `#{node.name}' cannot be applied repeatedly.",
locations: [node.source_location]
}
end

defp check_duplicates(directive, [_single]) do
directive
end

defp check_duplicates(directive, _multiple) do
directive
|> flag_invalid(:duplicate_directive)
|> put_error(error_repeated(directive))
end
end
1 change: 1 addition & 0 deletions lib/absinthe/pipeline.ex
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ defmodule Absinthe.Pipeline do
Phase.Document.Arguments.FlagInvalid,
# Validate Full Document
Phase.Document.Validation.KnownDirectives,
Phase.Document.Validation.RepeatableDirectives,
Phase.Document.Validation.ScalarLeafs,
Phase.Document.Validation.VariablesAreInputTypes,
Phase.Document.Validation.ArgumentsOfCorrectType,
Expand Down
22 changes: 22 additions & 0 deletions lib/absinthe/schema/notation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,23 @@ defmodule Absinthe.Schema.Notation do
|> record_expand!(func_ast)
end

@placement {:repeatable, [under: [:directive]]}
@doc """
Set whether the directive can be applied multiple times
an entity.

If omitted, defaults to `false`

## Placement

#{Utils.placement_docs(@placement)}
"""
defmacro repeatable(bool) do
__CALLER__
|> recordable!(:repeatable, @placement[:repeatable])
|> record_repeatable!(bool)
end

# INPUT OBJECTS

@placement {:input_object, [toplevel: true]}
Expand Down Expand Up @@ -1305,6 +1322,11 @@ defmodule Absinthe.Schema.Notation do
put_attr(env.module, {:expand, func_ast})
end

@doc false
def record_repeatable!(env, bool) do
put_attr(env.module, {:repeatable, bool})
end

@doc false
# Record directive AST nodes in the current scope
def record_locations!(env, locations) do
Expand Down
4 changes: 4 additions & 0 deletions lib/absinthe/schema/notation/sdl_render.ex
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ defmodule Absinthe.Schema.Notation.SDL.Render do
"directive ",
concat("@", string(directive.name)),
arguments(directive.arguments, type_definitions),
repeatable(directive.repeatable),
" on ",
join(locations, " | ")
])
Expand Down Expand Up @@ -441,6 +442,9 @@ defmodule Absinthe.Schema.Notation.SDL.Render do
defp block_string_line(["", _ | _]), do: nest(line(), :reset)
defp block_string_line(_), do: line()

defp repeatable(true), do: " repeatable"
defp repeatable(_), do: empty()

def join(docs, joiner) do
fold_doc(docs, fn doc, acc ->
concat([doc, concat(List.wrap(joiner)), acc])
Expand Down
4 changes: 4 additions & 0 deletions lib/absinthe/type/built_ins/directives.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ defmodule Absinthe.Type.BuiltIns.Directives do

on [:field, :fragment_spread, :inline_fragment]

repeatable false

expand fn
%{if: true}, node ->
Blueprint.put_flag(node, :include, __MODULE__)
Expand All @@ -27,6 +29,8 @@ defmodule Absinthe.Type.BuiltIns.Directives do
Directs the executor to skip this field or fragment when the `if` argument is true.
"""

repeatable false

arg :if, non_null(:boolean), description: "Skipped when true."

on [:field, :fragment_spread, :inline_fragment]
Expand Down
11 changes: 8 additions & 3 deletions lib/absinthe/type/built_ins/introspection.ex
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,17 @@ defmodule Absinthe.Type.BuiltIns.Introspection do
object :__directive do
description "Represents a directive"

field :name, :string
field :name, non_null(:string)

field :description, :string

field :is_repeatable, non_null(:boolean),
resolve: fn _, %{source: source} ->
{:ok, source.repeatable}
end

field :args,
type: list_of(:__inputvalue),
type: non_null(list_of(non_null(:__inputvalue))),
resolve: fn _, %{source: source} ->
args =
source.args
Expand Down Expand Up @@ -84,7 +89,7 @@ defmodule Absinthe.Type.BuiltIns.Introspection do
{:ok, Enum.member?(source.locations, :field)}
end

field :locations, list_of(:__directive_location)
field :locations, non_null(list_of(non_null(:__directive_location)))
end

enum :__directive_location,
Expand Down
3 changes: 3 additions & 0 deletions lib/absinthe/type/directive.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ defmodule Absinthe.Type.Directive do
* `:description` - A nice description for introspection.
* `:args` - A map of `Absinthe.Type.Argument` structs. See `Absinthe.Schema.Notation.arg/2`.
* `:locations` - A list of places the directives can be used.
* `:repeatable` - A directive may be defined as repeatable by including the “repeatable” keyword

The `:__reference__` key is for internal use.
"""
Expand All @@ -28,6 +29,7 @@ defmodule Absinthe.Type.Directive do
locations: [location],
expand: (map, Absinthe.Blueprint.node_t() -> atom),
definition: module,
repeatable: boolean,
__private__: Keyword.t(),
__reference__: Type.Reference.t()
}
Expand All @@ -42,6 +44,7 @@ defmodule Absinthe.Type.Directive do
locations: [],
expand: nil,
definition: nil,
repeatable: false,
__private__: [],
__reference__: nil

Expand Down
14 changes: 13 additions & 1 deletion src/absinthe_parser.yrl
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Nonterminals

Terminals
'{' '}' '(' ')' '[' ']' '!' ':' '@' '$' '=' '|' '...'
'query' 'mutation' 'subscription' 'fragment' 'on' 'directive'
'query' 'mutation' 'subscription' 'fragment' 'on' 'directive' 'repeatable'
'type' 'implements' 'interface' 'union' 'scalar' 'enum' 'input' 'extend' 'schema'
name int_value float_value string_value block_string_value boolean_value null.

Expand Down Expand Up @@ -194,6 +194,18 @@ DirectiveDefinition -> 'directive' '@' Name 'on' DirectiveDefinitionLocations Di
DirectiveDefinition -> 'directive' '@' Name ArgumentsDefinition 'on' DirectiveDefinitionLocations Directives :
build_ast_node('DirectiveDefinition', #{'name' => extract_binary('$3'), 'arguments' => '$4', 'directives' => '$7', 'locations' => extract_directive_locations('$6')}, extract_location('$1')).

DirectiveDefinition -> 'directive' '@' Name 'repeatable' 'on' DirectiveDefinitionLocations :
build_ast_node('DirectiveDefinition', #{'name' => extract_binary('$3'), 'locations' => extract_directive_locations('$6'), 'repeatable' => true}, extract_location('$1')).
DirectiveDefinition -> 'directive' '@' Name ArgumentsDefinition 'repeatable' 'on' DirectiveDefinitionLocations :
build_ast_node('DirectiveDefinition', #{'name' => extract_binary('$3'), 'arguments' => '$4', 'locations' => extract_directive_locations('$7'), 'repeatable' => true}, extract_location('$1')).

DirectiveDefinition -> 'directive' '@' Name 'repeatable' 'on' DirectiveDefinitionLocations Directives :
build_ast_node('DirectiveDefinition', #{'name' => extract_binary('$3'), 'directives' => '$7', 'locations' => extract_directive_locations('$6'), 'repeatable' => true}, extract_location('$1')).
DirectiveDefinition -> 'directive' '@' Name ArgumentsDefinition 'repeatable' 'on' DirectiveDefinitionLocations Directives :
build_ast_node('DirectiveDefinition', #{'name' => extract_binary('$3'), 'arguments' => '$4', 'directives' => '$8', 'locations' => extract_directive_locations('$7'), 'repeatable' => true}, extract_location('$1')).



SchemaDefinition -> 'schema' : build_ast_node('SchemaDeclaration', #{}, extract_location('$1')).
SchemaDefinition -> 'schema' Directives : build_ast_node('SchemaDeclaration', #{'directives' => '$2'}, extract_location('$1')).
SchemaDefinition -> 'schema' '{' FieldDefinitionList '}' : build_ast_node('SchemaDeclaration', #{'fields' => '$3'}, extract_location('$1')).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ defmodule Elixir.Absinthe.Integration.Execution.Introspection.DirectivesTest do
name
args { name type { kind ofType { name kind } } }
locations
isRepeatable
onField
onFragment
onOperation
Expand Down Expand Up @@ -36,7 +37,8 @@ defmodule Elixir.Absinthe.Integration.Execution.Introspection.DirectivesTest do
"name" => "include",
"onField" => true,
"onFragment" => true,
"onOperation" => false
"onOperation" => false,
"isRepeatable" => false
},
%{
"args" => [
Expand All @@ -52,7 +54,8 @@ defmodule Elixir.Absinthe.Integration.Execution.Introspection.DirectivesTest do
"name" => "skip",
"onField" => true,
"onFragment" => true,
"onOperation" => false
"onOperation" => false,
"isRepeatable" => false
}
]
}
Expand Down
15 changes: 13 additions & 2 deletions test/absinthe/language/directive_definition_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,19 @@ defmodule Absinthe.Language.DirectiveDefinitionTest do

describe "blueprint conversion" do
test "works, given a Blueprint Schema 'directive' definition without arguments" do
assert %Blueprint.Schema.DirectiveDefinition{name: "thingy", locations: [:field, :object]} =
from_input("directive @thingy on FIELD | OBJECT")
assert %Blueprint.Schema.DirectiveDefinition{
name: "thingy",
locations: [:field, :object],
repeatable: false
} = from_input("directive @thingy on FIELD | OBJECT")
end

test "works, given a Blueprint Schema 'repeatable' 'directive' definition without arguments" do
assert %Blueprint.Schema.DirectiveDefinition{
name: "thingy",
locations: [:field, :object],
repeatable: true
} = from_input("directive @thingy repeatable on FIELD | OBJECT")
end

test "works, given a Blueprint Schema 'directive' definition without arguments and with directives" do
Expand Down
Loading