Skip to content

Commit

Permalink
Introduce complexity analysis phase (absinthe-graphql#260)
Browse files Browse the repository at this point in the history
* Introduce complexity analysis phase

By default adds 1 per field in the query. This can be overriden by
using a custom complexity analyser that is 2-arity function. The first
argument is the field's context and the second argument is the
complexity of the children.

Heavily influnced by Sangria.

* Provide info struct to arity-3 complexity funs

* Error for each operation or field that is too complex

* Fix typo in field docs

* Move complexity error handling to result phase

* Add nested error case complexity tests

Thank you to Bruce Williams for help with the tests

* Support non_neg_integer complexity

* Fix overwriting existing field complexity

* Fix complexity_t to include non_neg_integer

* Raise on negative complexity value

* Document 3-arity complexity analyser

* Add 3-arity fun and remote call to complexity_t type

* Add :flat flag to skip walking selections after flatten

* Only run complexity analysis when enabled

* Document complexity options and rename analyse -> analyze

* Fix complexity remote call definition
  • Loading branch information
fishcakez authored and benwilson512 committed Feb 5, 2017
1 parent 0dbc20b commit 7529731
Show file tree
Hide file tree
Showing 14 changed files with 525 additions and 4 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ erl_crash.dump
*.ez
src/*.erl
.tool-versions*
missing_rules.rb
missing_rules.rb
.DS_Store
13 changes: 13 additions & 0 deletions lib/absinthe.ex
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,13 @@ defmodule Absinthe do
defexception message: "execution failed"
end

defmodule AnalysisError do
@moduledoc """
An error during analysis.
"""
defexception message: "analysis failed"
end

@type result_selection_t :: %{
String.t =>
nil
Expand Down Expand Up @@ -182,6 +189,10 @@ defmodule Absinthe do
arguments in the provided query document.
* `:context` -> A map of the execution context.
* `:root_value` -> A root value to use as the source for toplevel fields.
* `:analyze_complexity` -> Whether to analyze the complexity before
executing an operation.
* `:max_complexity` -> An integer (or `:infinity`) for the maximum allowed
complexity for the operation being executed.
## Examples
Expand All @@ -204,6 +215,8 @@ defmodule Absinthe do
adapter: Absinthe.Adapter.t,
root_value: term,
operation_name: String.t,
analyze_complexity: boolean,
max_complexity: non_neg_integer | :infinity
]

@spec run(binary | Absinthe.Language.Source.t | Absinthe.Language.Document.t, Absinthe.Schema.t, run_opts) :: {:ok, result_t} | {:error, any}
Expand Down
4 changes: 3 additions & 1 deletion lib/absinthe/blueprint/document/field.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ defmodule Absinthe.Blueprint.Document.Field do
source_location: nil,
type_conditions: [],
schema_node: nil,
complexity: nil,
fields: [],
]

Expand All @@ -31,7 +32,8 @@ defmodule Absinthe.Blueprint.Document.Field do
fields: [Blueprint.Document.Field.t],
source_location: nil | Blueprint.Document.SourceLocation.t,
type_conditions: [Blueprint.TypeReference.Name],
schema_node: Type.t
schema_node: Type.t,
complexity: nil | non_neg_integer
}

end
2 changes: 2 additions & 0 deletions lib/absinthe/blueprint/document/operation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ defmodule Absinthe.Blueprint.Document.Operation do
# Populated by phases
flags: %{},
schema_node: nil,
complexity: nil,
provided_values: %{},
fields: [],
errors: [],
Expand All @@ -34,6 +35,7 @@ defmodule Absinthe.Blueprint.Document.Operation do
fragment_uses: [Blueprint.Document.Fragment.Named.Use.t],
source_location: nil | Blueprint.Document.SourceLocation.t,
schema_node: nil | Absinthe.Type.Object.t,
complexity: nil | non_neg_integer,
provided_values: %{String.t => nil | Blueprint.Input.t},
flags: Blueprint.flags_t,
fields: [Blueprint.Document.Field.t],
Expand Down
5 changes: 5 additions & 0 deletions lib/absinthe/blueprint/transform.ex
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ defmodule Absinthe.Blueprint.Transform do
def walk(blueprint, acc, pre, post)

for {node_name, children} <- nodes_with_children do
if :selections in children do
def walk(%unquote(node_name){flags: %{flat: _}} = node, acc, pre, post) do
node_with_children(node, unquote(children--[:selections]), acc, pre, post)
end
end
def walk(%unquote(node_name){} = node, acc, pre, post) do
node_with_children(node, unquote(children), acc, pre, post)
end
Expand Down
24 changes: 24 additions & 0 deletions lib/absinthe/complexity.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
defmodule Absinthe.Complexity do
@moduledoc """
Extra metadata passed to aid complexity analysis functions, describing the
current field's environment.
"""
alias Absinthe.{Blueprint, Schema}

@enforce_keys [:context, :root_value, :schema, :definition]
defstruct [:context, :root_value, :schema, :definition]

@typedoc """
- `:definition` - The Blueprint definition for this field.
- `:context` - The context passed to `Absinthe.run`.
- `:root_value` - The root value passed to `Absinthe.run`, if any.
- `:schema` - The current schema.
"""
@type t :: %__MODULE__{
definition: Blueprint.node_t,
context: map,
root_value: any,
schema: Schema.t
}

end
107 changes: 107 additions & 0 deletions lib/absinthe/phase/document/complexity/analysis.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
defmodule Absinthe.Phase.Document.Complexity.Analysis do
@moduledoc false

# Analyses document complexity.

alias Absinthe.{Blueprint, Phase, Complexity}

use Absinthe.Phase

@default_complexity 1

@doc """
Run complexity analysis.
"""
@spec run(Blueprint.t, Keyword.t) :: Phase.result_t
def run(input, options \\ []) do
if Keyword.get(options, :analyze_complexity, false) do
do_run(input, options)
else
{:ok, input}
end
end

defp do_run(input, options) do
info_data = info_boilerplate(input, options)
fun = &handle_node(&1, info_data)
{:ok, Blueprint.update_current(input, &Blueprint.postwalk(&1, fun))}
end

def handle_node(%Blueprint.Document.Field{complexity: nil,
fields: fields,
argument_data: args,
schema_node: schema_node} = node, info_data) do
child_complexity = sum_complexity(fields)
case field_complexity(schema_node, args, child_complexity, info_data, node) do
complexity when is_integer(complexity) and complexity >= 0 ->
%{node | complexity: complexity}
other ->
raise Absinthe.AnalysisError, field_value_error(node, other)
end
end
def handle_node(%Blueprint.Document.Operation{complexity: nil, fields: fields} = node, _) do
complexity = sum_complexity(fields)
%{node | complexity: complexity}
end
def handle_node(node, _) do
node
end

defp field_complexity(%{complexity: nil}, _, child_complexity, _, _) do
@default_complexity + child_complexity
end
defp field_complexity(%{complexity: complexity}, arg, child_complexity, _, _)
when is_function(complexity, 2) do
complexity.(arg, child_complexity)
end
defp field_complexity(%{complexity: complexity}, arg, child_complexity, info_data, node)
when is_function(complexity, 3) do
info = struct(Complexity, Map.put(info_data, :definition, node))
complexity.(arg, child_complexity, info)
end
defp field_complexity(%{complexity: {mod, fun}}, arg, child_complexity, info_data, node) do
info = struct(Complexity, Map.put(info_data, :definition, node))
apply(mod, fun, [arg, child_complexity, info])
end
defp field_complexity(%{complexity: complexity}, _, _, _, _) do
complexity
end

defp field_value_error(field, value) do
"""
Invalid value returned from complexity analyzer.
Analyzing field:
#{field.name}
Defined at:
#{field.schema_node.__reference__.location.file}:#{field.schema_node.__reference__.location.line}
Got value:
#{inspect value}
The complexity value must be a non negative integer.
"""
end

defp sum_complexity(fields) do
Enum.reduce(fields, 0, &sum_complexity/2)
end

defp sum_complexity(%{complexity: complexity}, acc) do
complexity + acc
end

# Execution context data that's common to all fields
defp info_boilerplate(bp_root, options) do
%{
context: Keyword.get(options, :context, %{}),
root_value: Keyword.get(options, :root_value, %{}),
schema: bp_root.schema
}
end

end
56 changes: 56 additions & 0 deletions lib/absinthe/phase/document/complexity/result.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
defmodule Absinthe.Phase.Document.Complexity.Result do
@moduledoc false

# Collects complexity errors into the result.

alias Absinthe.{Blueprint, Phase}

use Absinthe.Phase

@doc """
Run the validation.
"""
@spec run(Blueprint.t, Keyword.t) :: Phase.result_t
def run(input, options \\ []) do
max = Keyword.get(options, :max_complexity, :infinity)
operation = Blueprint.current_operation(input)
fun = &handle_node(&1, max, &2)
{operation, errors} = Blueprint.prewalk(operation, [], fun)
result = Blueprint.update_current(input, fn(_) -> operation end)
result = put_in(result.resolution.validation, errors)
case {errors, Map.new(options)} do
{[_|_], %{jump_phases: true, result_phase: abort_phase}} ->
{:jump, result, abort_phase}
_ ->
{:ok, result}
end
end

defp handle_node(%{complexity: complexity} = node, max, errors)
when is_integer(complexity) and complexity > max do
error = error(node, complexity, max)
node =
node
|> flag_invalid(:too_complex)
|> put_error(error)
{node, [error | errors]}
end
defp handle_node(%{complexity: _} = node, _, errors) do
{:halt, node, errors}
end
defp handle_node(node, _, errors) do
{node, errors}
end

defp error(%{name: name, source_location: location}, complexity, max) do
Phase.Error.new(
__MODULE__,
error_message(name, complexity, max),
location: location
)
end

def error_message(name, complexity, max) do
"#{name} is too complex: complexity is #{complexity} and maximum is #{max}"
end
end
2 changes: 1 addition & 1 deletion lib/absinthe/phase/document/flatten.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ defmodule Absinthe.Phase.Document.Flatten do
defp process(%{selections: selections} = node, fragments) do
fields = Enum.flat_map(selections, &selection_to_fields(&1, fragments))
|> inherit_type_condition(node)
%{node | fields: fields}
put_flag(%{node | fields: fields}, :flat)
end

@spec selection_to_fields(Blueprint.Document.selection_t, [Blueprint.Document.Fragment.Named.t]) :: [Blueprint.Document.Field.t]
Expand Down
3 changes: 3 additions & 0 deletions lib/absinthe/pipeline.ex
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ defmodule Absinthe.Pipeline do
# Prepare for Execution
Phase.Document.CascadeInvalid,
Phase.Document.Flatten,
# Analyse Complexity
{Phase.Document.Complexity.Analysis, options},
{Phase.Document.Complexity.Result, options},
# Execution
{Phase.Document.Execution.Resolution, options},
# Format Result
Expand Down
15 changes: 15 additions & 0 deletions lib/absinthe/schema/notation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,21 @@ defmodule Absinthe.Schema.Notation do
:ok
end

@placement {:complexity, [under: [:field]]}
defmacro complexity(func_ast) do
__CALLER__
|> recordable!(:complexity, @placement[:complexity])
|> record_complexity!(func_ast)
end

@doc false
# Record a complexity analyzer in the current scope
def record_complexity!(env, func_ast) do
Scope.put_attribute(env.module, :complexity, func_ast)
Scope.recorded!(env.module, :attr, :complexity)
:ok
end

@placement {:is_type_of, [under: [:object]]}
@doc """
Expand Down
Loading

0 comments on commit 7529731

Please sign in to comment.