diff --git a/.travis.yml b/.travis.yml index a24138197f..5585a15d5b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,6 +20,7 @@ matrix: include: - env: - DISPLAY=':99.0' + - TESTING_INTERPRETER=yes rvm: 2.4.3 addons: apt: @@ -36,6 +37,10 @@ matrix: gemfile: spec/dummy/Gemfile script: - cd spec/dummy && bundle exec rails test:system + - env: + - TESTING_INTERPRETER=yes + rvm: 2.4.3 + gemfile: gemfiles/rails_5.2.gemfile - rvm: 2.3.8 gemfile: gemfiles/rails_3.2.gemfile - rvm: 2.3.8 diff --git a/Rakefile b/Rakefile index d50ce5d97c..782d1b0777 100644 --- a/Rakefile +++ b/Rakefile @@ -92,6 +92,12 @@ namespace :bench do prepare_benchmark GraphQLBenchmark.profile end + + desc "Run benchmarks on a very large result" + task :profile_large_result do + prepare_benchmark + GraphQLBenchmark.profile_large_result + end end namespace :test do diff --git a/benchmark/run.rb b/benchmark/run.rb index 33d473e86b..3afc758d03 100644 --- a/benchmark/run.rb +++ b/benchmark/run.rb @@ -1,26 +1,26 @@ # frozen_string_literal: true -require "dummy/schema" +TESTING_INTERPRETER = true +require "graphql" +require "jazz" require "benchmark/ips" -require 'ruby-prof' -require 'memory_profiler' +require "ruby-prof" +require "memory_profiler" module GraphQLBenchmark QUERY_STRING = GraphQL::Introspection::INTROSPECTION_QUERY DOCUMENT = GraphQL.parse(QUERY_STRING) - SCHEMA = Dummy::Schema + SCHEMA = Jazz::Schema BENCHMARK_PATH = File.expand_path("../", __FILE__) CARD_SCHEMA = GraphQL::Schema.from_definition(File.read(File.join(BENCHMARK_PATH, "schema.graphql"))) ABSTRACT_FRAGMENTS = GraphQL.parse(File.read(File.join(BENCHMARK_PATH, "abstract_fragments.graphql"))) ABSTRACT_FRAGMENTS_2 = GraphQL.parse(File.read(File.join(BENCHMARK_PATH, "abstract_fragments_2.graphql"))) - BIG_SCHEMA = GraphQL::Schema.from_definition(File.join(BENCHMARK_PATH, "big_schema.graphql")) BIG_QUERY = GraphQL.parse(File.read(File.join(BENCHMARK_PATH, "big_query.graphql"))) module_function def self.run(task) - Benchmark.ips do |x| case task when "query" @@ -40,16 +40,103 @@ def self.profile # Warm up any caches: SCHEMA.execute(document: DOCUMENT) # CARD_SCHEMA.validate(ABSTRACT_FRAGMENTS) - + res = nil result = RubyProf.profile do # CARD_SCHEMA.validate(ABSTRACT_FRAGMENTS) - SCHEMA.execute(document: DOCUMENT) + res = SCHEMA.execute(document: DOCUMENT) + end + # printer = RubyProf::FlatPrinter.new(result) + # printer = RubyProf::GraphHtmlPrinter.new(result) + printer = RubyProf::FlatPrinterWithLineNumbers.new(result) + + printer.print(STDOUT, {}) + end + + # Adapted from https://github.com/rmosolgo/graphql-ruby/issues/861 + def self.profile_large_result + schema = ProfileLargeResult::Schema + document = ProfileLargeResult::ALL_FIELDS + Benchmark.ips do |x| + x.report("Querying for #{ProfileLargeResult::DATA.size} objects") { + schema.execute(document: document) + } end + result = RubyProf.profile do + schema.execute(document: document) + end printer = RubyProf::FlatPrinter.new(result) # printer = RubyProf::GraphHtmlPrinter.new(result) # printer = RubyProf::FlatPrinterWithLineNumbers.new(result) - printer.print(STDOUT, {}) + + report = MemoryProfiler.report do + schema.execute(document: document) + end + + report.pretty_print + end + + module ProfileLargeResult + DATA = 1000.times.map { + { + id: SecureRandom.uuid, + int1: SecureRandom.random_number(100000), + int2: SecureRandom.random_number(100000), + string1: SecureRandom.base64, + string2: SecureRandom.base64, + boolean1: SecureRandom.random_number(1) == 0, + boolean2: SecureRandom.random_number(1) == 0, + int_array: 10.times.map { SecureRandom.random_number(100000) }, + string_array: 10.times.map { SecureRandom.base64 }, + boolean_array: 10.times.map { SecureRandom.random_number(1) == 0 }, + } + } + + + class FooType < GraphQL::Schema::Object + field :id, ID, null: false + field :int1, Integer, null: false + field :int2, Integer, null: false + field :string1, String, null: false + field :string2, String, null: false + field :boolean1, Boolean, null: false + field :boolean2, Boolean, null: false + field :string_array, [String], null: false + field :int_array, [Integer], null: false + field :boolean_array, [Boolean], null: false + end + + class QueryType < GraphQL::Schema::Object + description "Query root of the system" + field :foos, [FooType], null: false, description: "Return a list of Foo objects" + def foos + DATA + end + end + + class Schema < GraphQL::Schema + query QueryType + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end + end + + ALL_FIELDS = GraphQL.parse <<-GRAPHQL + { + foos { + id + int1 + int2 + string1 + string2 + boolean1 + boolean2 + stringArray + intArray + booleanArray + } + } + GRAPHQL end end diff --git a/guides/authorization/accessibility.md b/guides/authorization/accessibility.md index b362c3181d..381c6d815b 100644 --- a/guides/authorization/accessibility.md +++ b/guides/authorization/accessibility.md @@ -7,10 +7,23 @@ desc: Reject queries from unauthorized users if they access certain parts of the index: 2 --- +__NOTE:__ This kind of authorization is deprecated and isn't supported by the {% internal_link "forthcoming GraphQL runtime", "/queries/interpreter" %} because it's too slow. + With GraphQL-Ruby, you can inspect an incoming query, and return a custom error if that query accesses some unauthorized parts of the schema. This is different from {% internal_link "visibility", "/authorization/visibility" %}, where unauthorized parts of the schema are treated as non-existent. It's also different from {% internal_link "authorization", "/authorization/authorization" %}, which makes checks _while running_, instead of _before running_. +## Opt-In + +To use this kind of authorization, you must add a query analyzer: + +```ruby +class MySchema < GraphQL::Schema + # Set up ahead-of-time `accessible?` authorization + query_analyzer GraphQL::Authorization::Analyzer +end +``` + ## Preventing Access You can override some `.accessible?(context)` methods to prevent access to certain members of the schema: diff --git a/guides/fields/introduction.md b/guides/fields/introduction.md index 5ffeec3032..073cc9b655 100644 --- a/guides/fields/introduction.md +++ b/guides/fields/introduction.md @@ -108,6 +108,23 @@ field :players, [User], null: false, hash_key: "allPlayers" ``` +To pass-through the underlying object without calling a method on it, you can use `method: :itself`: + +```ruby +field :player, User, null: false, + method: :itself +``` + +This is equivalent to: + +```ruby +field :player, User, null: false + +def player + object +end +``` + If you don't want to delegate to the underlying object, you can define a method for each field: ```ruby @@ -137,6 +154,24 @@ def current_winning_streak(include_ties:) end ``` +As the examples above show, by default the custom method name must match the field name. If you want to use a different custom method, the `resolver_method` option is available: + +```ruby +# Use the custom method with a non-default name below to resolve this field +field :total_games_played, Integer, null: false, resolver_method: :games_played + +def games_played + object.games.count +end +``` + +`resolver_method` has two main use cases: + +1. resolver re-use between multiple fields +2. dealing with method conflicts (specifically if you have fields named `context` or `object`) + +Note that `resolver_method` _cannot_ be used in combination with `method` or `hash_key`. + ### Field Arguments _Arguments_ allow fields to take input to their resolution. For example: diff --git a/guides/guides.html b/guides/guides.html index b4745782ef..adfbba4e4d 100644 --- a/guides/guides.html +++ b/guides/guides.html @@ -13,6 +13,7 @@ - name: GraphQL Pro - name: GraphQL Pro - OperationStore - name: JavaScript Client + - name: Language Tools - name: Other --- diff --git a/guides/language_tools/visitor.md b/guides/language_tools/visitor.md new file mode 100644 index 0000000000..f6cb3ce6bd --- /dev/null +++ b/guides/language_tools/visitor.md @@ -0,0 +1,143 @@ +--- +layout: guide +doc_stub: false +search: true +section: Language Tools +title: AST Visitor +desc: Analyze and modify parsed GraphQL code +index: 0 +--- + +GraphQL code is usually contained in a string, for example: + +```ruby +query_string = "query { user(id: \"1\") { userName } }" +``` + +You can perform programmatic analysis and modifications to GraphQL code using a three-step process: + +- __Parse__ the code into an abstract syntax tree +- __Analyze/Modify__ the code with a visitor +- __Print__ the code back to a string + +## Parse + +{{ "GraphQL.parse" | api_doc }} turns a string into a GraphQL document: + +```ruby +parsed_doc = GraphQL.parse("{ user(id: \"1\") { userName } }") +# => # +``` + +Also, {{ "GraphQL.parse_file" | api_doc }} parses the contents of the named file and includes a `filename` in the parsed document. + +#### AST Nodes + +The parsed document is a tree of nodes, called an _abstract syntax tree_ (AST). This tree is _immutable_: once a document has been parsed, those Ruby objects can't be changed. Modifications are performed by _copying_ existing nodes, applying changes to the copy, then making a new tree to hold the copied node. Where possible, unmodified nodes are retained in the new tree (it's _persistent_). + +The copy-and-modify workflow is supported by a few methods on the AST nodes: + +- `.merge(new_attrs)` returns a copy of the node with `new_attrs` applied. This new copy can replace the original node. +- `.add_{child}(new_child_attrs)` makes a new node with `new_child_attrs`, adds it to the array specified by `{child}`, and returns a copy whose `{children}` array contains the newly created node. + +For example, to rename a field and add an argument to it, you could: + +```ruby +modified_node = field_node + # Apply a new name + .merge(name: "newName") + # Add an argument to this field's arguments + .add_argument(name: "newArgument", value: "newValue") +``` + +Above, `field_node` is unmodified, but `modified_node` reflects the new name and new argument. + +## Analyze/Modify + +To inspect or modify a parsed document, extend {{ "GraphQL::Language::Visitor" | api_doc }} and implement its various hooks. It's an implementation of the [visitor pattern](https://en.wikipedia.org/wiki/Visitor_pattern). In short, each node of the tree will be "visited" by calling a method, and those methods can gather information and perform modifications. + +In the visitor, each node class has a hook, for example: + +- {{ "GraphQL::Language::Nodes::Field" | api_doc }}s are routed to `#on_field` +- {{ "GraphQL::Language::Nodes::Argument" | api_doc }}s are routed to `#on_argument` + +See the {{ "GraphQL::Language::Visitor" | api_doc }} API docs for a full list of methods. + +Each method is called with `(node, parent)`, where: + +- `node` is the AST node currently visited +- `parent` is the AST node above this node in the tree + +The method has a few options for analyzing or modifying the AST: + +#### Continue/Halt + +To continue visiting, the hook should call `super`. This allows the visit to continue to `node`'s children in the tree, for example: + +```ruby +def on_field(_node, _parent) + # Do nothing, this is the default behavior: + super +end +``` + +To _halt_ the visit, a method may skip the call to `super`. For example, if the visitor encountered an error, it might want to return early instead of continuing to visit. + +#### Modify a Node + +Visitor hooks are expected to return the `(node, parent)` they are called with. If they return a different node, then that node will replace the original `node`. When you call `super(node, parent)`, the `node` is returned. So, to modify a node and continue visiting: + +- Make a modified copy of `node` +- Pass the modified copy to `super(new_node, parent)` + +For example, to rename an argument: + +```ruby +def on_argument(node, parent) + # make a copy of `node` with a new name + modified_node = node.merge(name: "renamed") + # continue visiting with the modified node and parent + super(modified_node, parent) +end +``` + +#### Delete a Node + +To delete the currently-visited `node`, don't pass `node` to `super(...)`. Instead, pass a magic constant, `DELETE_NODE`, in place of `node`. + +For example, to delete a directive: + +```ruby +def on_directive(node, parent) + # Don't pass `node` to `super`, + # instead, pass `DELETE_NODE` + super(DELETE_NODE, parent) +end +``` + +#### Insert a Node + +Inserting nodes is similar to modifying nodes. To insert a new child into `node`, call one of its `.add_` helpers. This returns a copied node with a new child added. For example, to add a selection to a field's selection set: + +```ruby +def on_field(node, parent) + node_with_selection = node.add_selection(name: "emailAddress") + super(node_with_selection, parent) +end +``` + +This will add `emailAddress` the fields selection on `node`. + + +(These `.add_*` helpers are wrappers around {{ "GraphQL::Language::Nodes::AbstractNode#merge" | api_doc }}.) + +## Print + +The easiest way to turn an AST back into a string of GraphQL is {{ "GraphQL::Language::Nodes::AbstractNode#to_query_string" | api_doc }}, for example: + +```ruby +parsed_doc.to_query_string +# => '{ user(id: "1") { userName } }' +``` + +You can also create a subclass of {{ "GraphQL::Language::Printer" | api_doc }} to customize how nodes are printed. diff --git a/guides/queries/ast_analysis.md b/guides/queries/ast_analysis.md new file mode 100644 index 0000000000..c0bdc7a768 --- /dev/null +++ b/guides/queries/ast_analysis.md @@ -0,0 +1,137 @@ +--- +layout: guide +doc_stub: false +search: true +section: Queries +title: Ahead-of-Time AST Analysis +desc: Check incoming query strings and reject them if they don't pass your checks +index: 13 +--- + +GraphQL-Ruby 1.9.0 includes a new way to do Ahead-of-Time analysis for your queries. Eventually, it will become the +default. + +The new analysis runs on query ASTs instead of the GraphQL Ruby internal representation, which means some of the things you used to get for free need to be done in analyzers instead. + +The new primitive for analysis is {{ "GraphQL::Analysis::AST::Analyzer" | api_doc }}. New analyzers must inherit from this base class and implement the desired methods for analysis. + +## Analyzer API + +Analyzers respond to methods similar to AST visitors: + +```ruby +class BasicCounterAnalyzer < GraphQL::Analysis::AST::Analyzer + def initialize(query_or_multiplex) + super + @fields = Set.new + @arguments = Set.new + end + + # Visitor are all defined on the AST::Analyzer base class + # We override them for custom analyzers. + def on_leave_field(node, _parent, _visitor) + @fields.add(node.name) + end + + def on_leave_argument(node, _parent, _visitor) + @arguments.add(node.name) + end + + def result + [@fields, @arguments] + end +end +``` + +In this example, we counted every field and argument, no matter if they were on fragment definitions +or if they were skipped by directives. In the old API, this used to be handled automatically because +the internal representation of the query took care of these concerns. With the new API, we can use helper +methods to help us achieve this: + +```ruby +class BasicFieldAnalyzer < GraphQL::Analysis::AST::Analyzer + def initialize(query_or_multiplex) + super + @fields = Set.new + end + + # Visitor are all defined on the AST::Analyzer base class + # We override them for custom analyzers. + def on_leave_field(node, _parent, visitor) + if visitor.skipping? || visitor.visiting_fragment_definition? + # We don't want to count skipped fields or fields + # inside fragment definitions + else + @fields.add(node.name) + end + end + + # We want to visit fragment spreads as soon as we hit them + # instead of visiting the definitions. The visitor provides helper + # methods to achieve that. + def on_enter_fragment_spread(node, parent, visitor) + visitor.enter_fragment_spread_inline(node) + end + + def on_leave_fragment_definition(node, parent, visitor) + visitor.leave_fragment_spread_inline(node) + end + + def result + @fields + end +end +``` + +### Errors + +It is still possible to return errors from an analyzer. To reject a query and halt its execution, you may return {{ "GraphQL::AnalysisError" | api_doc }} in the `result` method: + +```ruby +class NoFieldsCalledHello < GraphQL::Analysis::AST::Analyzer + def on_leave_field(node, _parent, visitor) + if node.name == "hello" + @field_called_hello = true + end + end + + def result + GraphQL::AnalysisError.new("A field called `hello` was found.") if @field_called_hello + end +end +``` + +### Conditional Analysis + +Some analyzers might only make sense in certain context, or some might be too expensive to run for every query. To handle these scenarios, your analyzers may answer to an `analyze?` method: + +```ruby +class BasicFieldAnalyzer < GraphQL::Analysis::AST::Analyzer + # Use the analyze? method to enable or disable a certain analyzer + # at query time. + def analyze? + !!query.context[:should_analyze] + end + + def on_leave_field(node, _parent, visitor) + # ... + end + + def result + # ... + end +end +``` + +### Using Analyzers + +The new query analyzers are added to the schema the same one as before with `query_analyzer`. However, to use the new analysis engine, you must opt in by using `use GraphQL::Analysis::AST`, for example: + +```ruby +class MySchema < GraphQL::Schema + use GraphQL::Analysis::AST + query_analyzer MyQueryAnalyzer +end +``` + +**Make sure you pass the class and not an instance of your analyzer. The new analysis engine will take care of instantiating your analyzers with the query**. diff --git a/guides/queries/interpreter.md b/guides/queries/interpreter.md new file mode 100644 index 0000000000..895c06eb8a --- /dev/null +++ b/guides/queries/interpreter.md @@ -0,0 +1,100 @@ +--- +title: Interpreter +layout: guide +doc_stub: false +search: true +section: Queries +desc: A New Runtime for GraphQL-Ruby +experimental: true +index: 11 +--- + +GraphQL-Ruby 1.9.0 includes a new runtime module which you may use for your schema. Eventually, it will become the default. + +It's called `GraphQL::Execute::Interpreter` and you can hook it up with `use ...` in your schema class: + +```ruby +class MySchema < GraphQL::Schema + use GraphQL::Execute::Interpreter +end +``` + +Read on to learn more! + +## Rationale + +The new runtime was added to address a few specific concerns: + +- __Validation Performance__: The previous runtime depended on a preparation step (`GraphQL::InternalRepresentation::Rewrite`) which could be very slow in some cases. In many cases, the overhead of that step provided no value. +- __Runtime Performance__: For very large results, the previous runtime was slow because it allocated a new `ctx` object for every field, even very simple fields that didn't need any special tracking. +- __Extensibility__: Although the GraphQL specification supports custom directives, GraphQL-Ruby didn't have a good way to build them. + +## Installation + +You can opt in to the interpreter in your schema class: + +```ruby +class MySchema < GraphQL::Schema + use GraphQL::Execution::Interpreter +end +``` + +If you have a subscription root type, it will also need an update. Extend this new module: + +```ruby +class Types::Subscription < Types::BaseObject + # Extend this module to support subscription root fields with Interpreter + extend GraphQL::Subscriptions::SubscriptionRoot +end +``` + +Some Relay configurations must be updated too. For example: + +```diff +- field :node, field: GraphQL::Relay::Node.field ++ add_field(GraphQL::Types::Relay::NodeField) +``` + +(Alternatively, consider implementing `Query.node` in your own app, using `NodeField` as inspiration.) + +## Compatibility + +The new runtime works with class-based schemas only. Several features are no longer supported: + +- Proc-dependent field features: + + - Field Instrumentation + - Middleware + - Resolve procs + + All these depend on the memory- and time-hungry per-field `ctx` object. To improve performance, only method-based resolves are supported. If need something from `ctx`, you can get it with the `extras: [...]` configuration option. To wrap resolve behaviors, try {% internal_link "Field Extensions", "/type_definitions/field_extensions" %}. + +- Query analyzers and `irep_node`s + + These depend on the now-removed `Rewrite` step, which wasted a lot of time making often-unneeded preparation. Most of the attributes you might need from an `irep_node` are available with `extras: [...]`. Query analyzers can be refactored to be static checks (custom validation rules) or dynamic checks, made at runtime. The built-in analyzers have been refactored to run as validators. + + `irep_node`-based lookahead is not supported. Stay tuned for a replacement. + +- `rescue_from` + + This was built on middleware, which is not supported anymore. Stay tuned for a replacement. + +- `.graphql_definition` and `def to_graphql` + + The interpreter uses class-based schema definitions only, and never converts them to legacy GraphQL definition objects. Any custom definitions to GraphQL objects should be re-implemented on custom base classes. + +Maybe this section should have been called _incompatibility_ 🤔. + +## Extending the Runtime + +🚧 👷🚧 + +The internals aren't clean enough to build on yet. Stay tuned. + +## Implementation Notes + +Instead of a tree of `irep_nodes`, the interpreter consumes the AST directly. This removes a complicated concept from GraphQL-Ruby (`irep_node`s) and simplifies the query lifecycle. The main difference relates to how fragment spreads are resolved. In the previous runtime, the possible combinations of fields for a given object were calculated ahead of time, then some of those combinations were used during runtime, but many of them may not have been. In the new runtime, no precalculation is made; instead each object is checked against each fragment at runtime. + +Instead of creating a `GraphQL::Query::Context::FieldResolutionContext` for _every_ field in the response, the interpreter uses long-lived, mutable objects for execution bookkeeping. This is more complicated to manage, since the changes to those objects can be hard to predict, but it's worth it for the performance gain. When needed, those bookkeeping objects can be "forked", so that two parts of an operation can be resolved independently. + +Instead of calling `.to_graphql` internally to convert class-based definitions to `.define`-based definitions, the interpreter operates on class-based definitions directly. This simplifies the workflow for creating custom configurations and using them at runtime. diff --git a/guides/type_definitions/field_extensions.md b/guides/type_definitions/field_extensions.md new file mode 100644 index 0000000000..de8cdc09de --- /dev/null +++ b/guides/type_definitions/field_extensions.md @@ -0,0 +1,104 @@ +--- +layout: guide +doc_stub: false +search: true +section: Type Definitions +title: Field Extensions +desc: Programmatically modify field configuration and resolution +index: 10 +class_based_api: true +--- + +{{ "GraphQL::Schema::FieldExtension" | api_doc }} provides a way to modify user-defined fields in a programmatic way. For example, Relay connections are implemented as a field extension ({{ "GraphQL::Schema::Field::ConnectionExtension" | api_doc }}). + +### Making a new extension + +Field extensions are subclasses of {{ "GraphQL::Schema::FieldExtension" | api_doc }}: + +```ruby +class MyExtension < GraphQL::Schema::FieldExtension +end +``` + +### Using an extension + +Defined extensions can be added to fields using the `extensions: [...]` option or the `extension(...)` method: + +```ruby +field :name, String, null: false, extensions: [UpcaseExtension] +# or: +field :description, String, null: false do + extension(UpcaseExtension) +end +``` + +See below for how extensions may modify fields. + +### Modifying field configuration + +When extensions are attached, they are initialized with a `field:` and `options:`. Then, `#apply` is called, when they may extend the field they're attached to. For example: + +```ruby +class SearchableExtension < GraphQL::Schema::FieldExtension + def apply + # add an argument to this field: + field.argument(:query, String, required: false, description: "A search query") + end +end +``` + +This way, an extension can encapsulate a behavior requiring several configuration options. + +### Modifying field execution + +Extensions have two hooks that wrap field resolution. Since GraphQL-Ruby supports deferred execution, these hooks _might not_ be called back-to-back. + +First, {{ "GraphQL::Schema::FieldExtension#before_resolve" | api_doc }} is called. `before_resolve` should `yield(object, arguments)` to continue execution. If it doesn't `yield`, then the field won't resolve, and the methods return value will be returned to GraphQL instead. + +After resolution, {{ "GraphQL::Schema::FieldExtension#after_resolve" | api_doc }} is called. Whatever that method returns will be used as the field's return value. + +See the linked API docs for the parameters of those methods. + +#### Execution "memo" + +One parameter to `after_resolve` deserves special attention: `memo:`. `before_resolve` _may_ yield a third value. For example: + +```ruby +def before_resolve(object:, arguments:, **rest) + # yield the current time as `memo` + yield(object, arguments, Time.now.to_i) +end +``` + +If a third value is yielded, it will be passed to `after_resolve` as `memo:`, for example: + +```ruby +def after_resolve(value:, memo:, **rest) + puts "Elapsed: #{Time.now.to_i - memo}" + # Return the original value + value +end +``` + +This allows the `before_resolve` hook to pass data to `after_resolve`. + +Instance variables may not be used because, in a given GraphQL query, the same field may be resolved several times concurrently, and that would result in overriding the instance variable in an unpredictable way. (In fact, extensions are frozen to prevent instance variable writes.) + +### Extension options + +The `extension(...)` method takes an optional second argument, for example: + +```ruby +extension(LimitExtension, limit: 20) +``` + +In this case, `{limit: 20}` will be passed as `options:` to `#initialize` and `options[:limit]` will be `20`. + +For example, options can be used for modifying execution: + +```ruby +def after_resolve(value:, **rest) + # Apply the limit from the options + value.limit(options[:limit]) +end +``` diff --git a/javascript_client/yarn.lock b/javascript_client/yarn.lock new file mode 100644 index 0000000000..8303eede9b --- /dev/null +++ b/javascript_client/yarn.lock @@ -0,0 +1,2419 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@types/zen-observable@0.5.3": + version "0.5.3" + resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.5.3.tgz#91b728599544efbb7386d8b6633693a3c2e7ade5" + integrity sha512-aDvGDAHcVfUqNmd8q4//cHAP+HGxsbChbBbuk3+kMVk5TTxfWLpQWvVN3+UPjohLnwMYN7jr6BWNn2cYNqdm7g== + +abab@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/abab/-/abab-1.0.3.tgz#b81de5f7274ec4e756d797cd834f303642724e5d" + integrity sha1-uB3l9ydOxOdW15fNg08wNkJyTl0= + +acorn-globals@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-3.1.0.tgz#fd8270f71fbb4996b004fa880ee5d46573a731bf" + integrity sha1-/YJw9x+7SZawBPqIDuXUZXOnMb8= + dependencies: + acorn "^4.0.4" + +acorn@^4.0.4: + version "4.0.13" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.13.tgz#105495ae5361d697bd195c825192e1ad7f253787" + integrity sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c= + +ajv@^4.9.1: + version "4.11.8" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-4.11.8.tgz#82ffb02b29e662ae53bdc20af15947706739c536" + integrity sha1-gv+wKynmYq5TvcIK8VlHcGc5xTY= + dependencies: + co "^4.6.0" + json-stable-stringify "^1.0.1" + +align-text@^0.1.1, align-text@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117" + integrity sha1-DNkKVhCT810KmSVsIrcGlDP60Rc= + dependencies: + kind-of "^3.0.2" + longest "^1.0.1" + repeat-string "^1.5.2" + +amdefine@>=0.0.4: + version "1.0.1" + resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" + integrity sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU= + +ansi-escapes@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e" + integrity sha1-06ioOzGapneTZisT52HHkRQiMG4= + +ansi-regex@^2.0.0, ansi-regex@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" + integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= + +ansi-styles@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" + integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4= + +ansi-styles@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.1.0.tgz#09c202d5c917ec23188caa5c9cb9179cd9547750" + integrity sha1-CcIC1ckX7CMYjKpcnLkXnNlUd1A= + dependencies: + color-convert "^1.0.0" + +anymatch@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.0.tgz#a3e52fa39168c825ff57b0248126ce5a8ff95507" + integrity sha1-o+Uvo5FoyCX/V7AkgSbOWo/5VQc= + dependencies: + arrify "^1.0.0" + micromatch "^2.1.5" + +apollo-link@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/apollo-link/-/apollo-link-1.0.3.tgz#759c36abeeb99e227eca45f919ee07fb8fee911e" + integrity sha512-JVFLl+Y5OmwwPqrnf12q9Em85uwSm/QFYR9ZUvFNb1dyv+7IF+9inS6D2m/cVy03O4D32kFzLo8D34D3tY664w== + dependencies: + "@types/zen-observable" "0.5.3" + apollo-utilities "^1.0.0" + zen-observable "^0.6.0" + +apollo-utilities@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.0.2.tgz#bcf348a7e613e82e2624ddb5be2b9f6bf1259c6d" + integrity sha512-tCiJexDJQr9He6TW9E1/sba0WdeRW4/mmaA1JJBOwyLoPSAvCgBOJ6fo4L3iwWvadR1X1723JGojzhYFrUHMhg== + +append-transform@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-0.4.0.tgz#d76ebf8ca94d276e247a36bad44a4b74ab611991" + integrity sha1-126/jKlNJ24keja61EpLdKthGZE= + dependencies: + default-require-extensions "^1.0.0" + +argparse@^1.0.7: + version "1.0.9" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.9.tgz#73d83bc263f86e97f8cc4f6bae1b0e90a7d22c86" + integrity sha1-c9g7wmP4bpf4zE9rrhsOkKfSLIY= + dependencies: + sprintf-js "~1.0.2" + +arr-diff@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf" + integrity sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8= + dependencies: + arr-flatten "^1.0.1" + +arr-flatten@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" + integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg== + +array-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93" + integrity sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM= + +array-unique@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53" + integrity sha1-odl8yvy8JiXMcPrc6zalDFiwGlM= + +arrify@^1.0.0, arrify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" + integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0= + +asn1@~0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.3.tgz#dac8787713c9966849fc8180777ebe9c1ddf3b86" + integrity sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y= + +assert-plus@1.0.0, assert-plus@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= + +assert-plus@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-0.2.0.tgz#d74e1b87e7affc0db8aadb7021f3fe48101ab234" + integrity sha1-104bh+ev/A24qttwIfP+SBAasjQ= + +assertion-error@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.0.2.tgz#13ca515d86206da0bac66e834dd397d87581094c" + integrity sha1-E8pRXYYgbaC6xm6DTdOX2HWBCUw= + +async@^1.4.0: + version "1.5.2" + resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" + integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo= + +async@^2.1.4: + version "2.5.0" + resolved "https://registry.yarnpkg.com/async/-/async-2.5.0.tgz#843190fd6b7357a0b9e1c956edddd5ec8462b54d" + integrity sha512-e+lJAJeNWuPCNyxZKOBdaJGyLGHugXVQtrAwtuAe2vhxTYxFTKE73p8JuTmdH0qdQZtDvI4dhJwjZc5zsfIsYw== + dependencies: + lodash "^4.14.0" + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + +aws-sign2@~0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f" + integrity sha1-FDQt0428yU0OW4fXY81jYSwOeU8= + +aws4@^1.2.1: + version "1.6.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e" + integrity sha1-g+9cqGCysy5KDe7e6MdxudtXRx4= + +babel-code-frame@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.22.0.tgz#027620bee567a88c32561574e7fd0801d33118e4" + integrity sha1-AnYgvuVnqIwyVhV05/0IAdMxGOQ= + dependencies: + chalk "^1.1.0" + esutils "^2.0.2" + js-tokens "^3.0.0" + +babel-core@^6.0.0, babel-core@^6.24.1: + version "6.25.0" + resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.25.0.tgz#7dd42b0463c742e9d5296deb3ec67a9322dad729" + integrity sha1-fdQrBGPHQunVKW3rPsZ6kyLa1yk= + dependencies: + babel-code-frame "^6.22.0" + babel-generator "^6.25.0" + babel-helpers "^6.24.1" + babel-messages "^6.23.0" + babel-register "^6.24.1" + babel-runtime "^6.22.0" + babel-template "^6.25.0" + babel-traverse "^6.25.0" + babel-types "^6.25.0" + babylon "^6.17.2" + convert-source-map "^1.1.0" + debug "^2.1.1" + json5 "^0.5.0" + lodash "^4.2.0" + minimatch "^3.0.2" + path-is-absolute "^1.0.0" + private "^0.1.6" + slash "^1.0.0" + source-map "^0.5.0" + +babel-generator@^6.18.0, babel-generator@^6.25.0: + version "6.25.0" + resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.25.0.tgz#33a1af70d5f2890aeb465a4a7793c1df6a9ea9fc" + integrity sha1-M6GvcNXyiQrrRlpKd5PB32qeqfw= + dependencies: + babel-messages "^6.23.0" + babel-runtime "^6.22.0" + babel-types "^6.25.0" + detect-indent "^4.0.0" + jsesc "^1.3.0" + lodash "^4.2.0" + source-map "^0.5.0" + trim-right "^1.0.1" + +babel-helpers@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helpers/-/babel-helpers-6.24.1.tgz#3471de9caec388e5c850e597e58a26ddf37602b2" + integrity sha1-NHHenK7DiOXIUOWX5Yom3fN2ArI= + dependencies: + babel-runtime "^6.22.0" + babel-template "^6.24.1" + +babel-jest@^20.0.3: + version "20.0.3" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-20.0.3.tgz#e4a03b13dc10389e140fc645d09ffc4ced301671" + integrity sha1-5KA7E9wQOJ4UD8ZF0J/8TO0wFnE= + dependencies: + babel-core "^6.0.0" + babel-plugin-istanbul "^4.0.0" + babel-preset-jest "^20.0.3" + +babel-messages@^6.23.0: + version "6.23.0" + resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e" + integrity sha1-8830cDhYA1sqKVHG7F7fbGLyYw4= + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-istanbul@^4.0.0: + version "4.1.4" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-4.1.4.tgz#18dde84bf3ce329fddf3f4103fae921456d8e587" + integrity sha1-GN3oS/POMp/d8/QQP66SFFbY5Yc= + dependencies: + find-up "^2.1.0" + istanbul-lib-instrument "^1.7.2" + test-exclude "^4.1.1" + +babel-plugin-jest-hoist@^20.0.3: + version "20.0.3" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-20.0.3.tgz#afedc853bd3f8dc3548ea671fbe69d03cc2c1767" + integrity sha1-r+3IU70/jcNUjqZx++adA8wsF2c= + +babel-preset-jest@^20.0.3: + version "20.0.3" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-20.0.3.tgz#cbacaadecb5d689ca1e1de1360ebfc66862c178a" + integrity sha1-y6yq3stdaJyh4d4TYOv8ZoYsF4o= + dependencies: + babel-plugin-jest-hoist "^20.0.3" + +babel-register@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-register/-/babel-register-6.24.1.tgz#7e10e13a2f71065bdfad5a1787ba45bca6ded75f" + integrity sha1-fhDhOi9xBlvfrVoXh7pFvKbe118= + dependencies: + babel-core "^6.24.1" + babel-runtime "^6.22.0" + core-js "^2.4.0" + home-or-tmp "^2.0.0" + lodash "^4.2.0" + mkdirp "^0.5.1" + source-map-support "^0.4.2" + +babel-runtime@^6.22.0: + version "6.23.0" + resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.23.0.tgz#0a9489f144de70efb3ce4300accdb329e2fc543b" + integrity sha1-CpSJ8UTecO+zzkMArM2zKeL8VDs= + dependencies: + core-js "^2.4.0" + regenerator-runtime "^0.10.0" + +babel-template@^6.16.0, babel-template@^6.24.1, babel-template@^6.25.0: + version "6.25.0" + resolved "https://registry.yarnpkg.com/babel-template/-/babel-template-6.25.0.tgz#665241166b7c2aa4c619d71e192969552b10c071" + integrity sha1-ZlJBFmt8KqTGGdceGSlpVSsQwHE= + dependencies: + babel-runtime "^6.22.0" + babel-traverse "^6.25.0" + babel-types "^6.25.0" + babylon "^6.17.2" + lodash "^4.2.0" + +babel-traverse@^6.18.0, babel-traverse@^6.25.0: + version "6.25.0" + resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.25.0.tgz#2257497e2fcd19b89edc13c4c91381f9512496f1" + integrity sha1-IldJfi/NGbie3BPEyROB+VEklvE= + dependencies: + babel-code-frame "^6.22.0" + babel-messages "^6.23.0" + babel-runtime "^6.22.0" + babel-types "^6.25.0" + babylon "^6.17.2" + debug "^2.2.0" + globals "^9.0.0" + invariant "^2.2.0" + lodash "^4.2.0" + +babel-types@^6.18.0, babel-types@^6.25.0: + version "6.25.0" + resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.25.0.tgz#70afb248d5660e5d18f811d91c8303b54134a18e" + integrity sha1-cK+ySNVmDl0Y+BHZHIMDtUE0oY4= + dependencies: + babel-runtime "^6.22.0" + esutils "^2.0.2" + lodash "^4.2.0" + to-fast-properties "^1.0.1" + +babylon@^6.17.2, babylon@^6.17.4: + version "6.17.4" + resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.17.4.tgz#3e8b7402b88d22c3423e137a1577883b15ff869a" + integrity sha512-kChlV+0SXkjE0vUn9OZ7pBMWRFd8uq3mZe8x1K6jhuNcAFAtEnjchFAqB+dYEXKyd+JpT6eppRR78QAr5gTsUw== + +balanced-match@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" + integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= + +bcrypt-pbkdf@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz#63bc5dcb61331b92bc05fd528953c33462a06f8d" + integrity sha1-Y7xdy2EzG5K8Bf1SiVPDNGKgb40= + dependencies: + tweetnacl "^0.14.3" + +boom@2.x.x: + version "2.10.1" + resolved "https://registry.yarnpkg.com/boom/-/boom-2.10.1.tgz#39c8918ceff5799f83f9492a848f625add0c766f" + integrity sha1-OciRjO/1eZ+D+UkqhI9iWt0Mdm8= + dependencies: + hoek "2.x.x" + +brace-expansion@^1.1.7: + version "1.1.8" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.8.tgz#c07b211c7c952ec1f8efd51a77ef0d1d3990a292" + integrity sha1-wHshHHyVLsH479Uad+8NHTmQopI= + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@^1.8.2: + version "1.8.5" + resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7" + integrity sha1-uneWLhLf+WnWt2cR6RS3N4V79qc= + dependencies: + expand-range "^1.8.1" + preserve "^0.2.0" + repeat-element "^1.1.2" + +browser-resolve@^1.11.2: + version "1.11.2" + resolved "https://registry.yarnpkg.com/browser-resolve/-/browser-resolve-1.11.2.tgz#8ff09b0a2c421718a1051c260b32e48f442938ce" + integrity sha1-j/CbCixCFxihBRwmCzLkj0QpOM4= + dependencies: + resolve "1.1.7" + +bser@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bser/-/bser-1.0.2.tgz#381116970b2a6deea5646dd15dd7278444b56169" + integrity sha1-OBEWlwsqbe6lZG3RXdcnhES1YWk= + dependencies: + node-int64 "^0.4.0" + +bser@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/bser/-/bser-2.0.0.tgz#9ac78d3ed5d915804fd87acb158bc797147a1719" + integrity sha1-mseNPtXZFYBP2HrLFYvHlxR6Fxk= + dependencies: + node-int64 "^0.4.0" + +builtin-modules@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" + integrity sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8= + +callsites@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50" + integrity sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA= + +camelcase@^1.0.2: + version "1.2.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-1.2.1.tgz#9bb5304d2e0b56698b2c758b08a3eaa9daa58a39" + integrity sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk= + +camelcase@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-3.0.0.tgz#32fc4b9fcdaf845fcdf7e73bb97cac2261f0ab0a" + integrity sha1-MvxLn82vhF/N9+c7uXysImHwqwo= + +caseless@~0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= + +center-align@^0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/center-align/-/center-align-0.1.3.tgz#aa0d32629b6ee972200411cbd4461c907bc2b7ad" + integrity sha1-qg0yYptu6XIgBBHL1EYckHvCt60= + dependencies: + align-text "^0.1.3" + lazy-cache "^1.0.3" + +"chai@>=1.9.2 <4.0.0": + version "3.5.0" + resolved "https://registry.yarnpkg.com/chai/-/chai-3.5.0.tgz#4d02637b067fe958bdbfdd3a40ec56fef7373247" + integrity sha1-TQJjewZ/6Vi9v906QOxW/vc3Mkc= + dependencies: + assertion-error "^1.0.1" + deep-eql "^0.1.3" + type-detect "^1.0.0" + +chalk@^1.1.0, chalk@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" + integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg= + dependencies: + ansi-styles "^2.2.1" + escape-string-regexp "^1.0.2" + has-ansi "^2.0.0" + strip-ansi "^3.0.0" + supports-color "^2.0.0" + +ci-info@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.0.0.tgz#dc5285f2b4e251821683681c381c3388f46ec534" + integrity sha1-3FKF8rTiUYIWg2gcOBwziPRuxTQ= + +cliui@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1" + integrity sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE= + dependencies: + center-align "^0.1.1" + right-align "^0.1.1" + wordwrap "0.0.2" + +cliui@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d" + integrity sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0= + dependencies: + string-width "^1.0.1" + strip-ansi "^3.0.1" + wrap-ansi "^2.0.0" + +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ= + +code-point-at@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" + integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= + +color-convert@^1.0.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.0.tgz#1accf97dd739b983bf994d56fec8f95853641b7a" + integrity sha1-Gsz5fdc5uYO/mU1W/sj5WFNkG3o= + dependencies: + color-name "^1.1.1" + +color-name@^1.1.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + +combined-stream@^1.0.5, combined-stream@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.5.tgz#938370a57b4a51dea2c77c15d5c5fdf895164009" + integrity sha1-k4NwpXtKUd6ix3wV1cX9+JUWQAk= + dependencies: + delayed-stream "~1.0.0" + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + +content-type-parser@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/content-type-parser/-/content-type-parser-1.0.1.tgz#c3e56988c53c65127fb46d4032a3a900246fdc94" + integrity sha1-w+VpiMU8ZRJ/tG1AMqOpACRv3JQ= + +convert-source-map@^1.1.0, convert-source-map@^1.4.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.5.0.tgz#9acd70851c6d5dfdd93d9282e5edf94a03ff46b5" + integrity sha1-ms1whRxtXf3ZPZKC5e35SgP/RrU= + +core-js@^2.4.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.4.1.tgz#4de911e667b0eae9124e34254b53aea6fc618d3e" + integrity sha1-TekR5mew6ukSTjQlS1OupvxhjT4= + +cryptiles@2.x.x: + version "2.0.5" + resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-2.0.5.tgz#3bdfecdc608147c1c67202fa291e7dca59eaa3b8" + integrity sha1-O9/s3GCBR8HGcgL6KR59ylnqo7g= + dependencies: + boom "2.x.x" + +cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0": + version "0.3.2" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.2.tgz#b8036170c79f07a90ff2f16e22284027a243848b" + integrity sha1-uANhcMefB6kP8vFuIihAJ6JDhIs= + +"cssstyle@>= 0.2.37 < 0.3.0": + version "0.2.37" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-0.2.37.tgz#541097234cb2513c83ceed3acddc27ff27987d54" + integrity sha1-VBCXI0yyUTyDzu06zdwn/yeYfVQ= + dependencies: + cssom "0.3.x" + +dashdash@^1.12.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" + integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= + dependencies: + assert-plus "^1.0.0" + +debug@^2.1.1, debug@^2.2.0, debug@^2.6.3: + version "2.6.8" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.8.tgz#e731531ca2ede27d188222427da17821d68ff4fc" + integrity sha1-5zFTHKLt4n0YgiJCfaF4IdaP9Pw= + dependencies: + ms "2.0.0" + +decamelize@^1.0.0, decamelize@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= + +deep-eql@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-0.1.3.tgz#ef558acab8de25206cd713906d74e56930eb69f2" + integrity sha1-71WKyrjeJSBs1xOQbXTlaTDrafI= + dependencies: + type-detect "0.1.1" + +deep-equal@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" + integrity sha1-9dJgKStmDghO/0zbyfCK0yR0SLU= + +deep-is@~0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" + integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= + +default-require-extensions@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/default-require-extensions/-/default-require-extensions-1.0.0.tgz#f37ea15d3e13ffd9b437d33e1a75b5fb97874cb8" + integrity sha1-836hXT4T/9m0N9M+GnW1+5eHTLg= + dependencies: + strip-bom "^2.0.0" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + +detect-indent@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208" + integrity sha1-920GQ1LN9Docts5hnE7jqUdd4gg= + dependencies: + repeating "^2.0.0" + +diff@^3.2.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-3.3.0.tgz#056695150d7aa93237ca7e378ac3b1682b7963b9" + integrity sha512-w0XZubFWn0Adlsapj9EAWX0FqWdO4tz8kc3RiYdWLh4k/V8PTb6i0SMgXt0vRM3zyKnT8tKO7mUlieRQHIjMNg== + +ecc-jsbn@~0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz#0fc73a9ed5f0d53c38193398523ef7e543777505" + integrity sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU= + dependencies: + jsbn "~0.1.0" + +errno@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.4.tgz#b896e23a9e5e8ba33871fc996abd3635fc9a1c7d" + integrity sha1-uJbiOp5ei6M4cfyZar02NfyaHH0= + dependencies: + prr "~0.0.0" + +error-ex@^1.2.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.1.tgz#f855a86ce61adc4e8621c3cda21e7a7612c3a8dc" + integrity sha1-+FWobOYa3E6GIcPNoh56dhLDqNw= + dependencies: + is-arrayish "^0.2.1" + +escape-string-regexp@^1.0.2: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + +escodegen@^1.6.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.8.1.tgz#5a5b53af4693110bebb0867aa3430dd3b70a1018" + integrity sha1-WltTr0aTEQvrsIZ6o0MN07cKEBg= + dependencies: + esprima "^2.7.1" + estraverse "^1.9.1" + esutils "^2.0.2" + optionator "^0.8.1" + optionalDependencies: + source-map "~0.2.0" + +esprima@^2.7.1: + version "2.7.3" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581" + integrity sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE= + +esprima@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.0.tgz#4499eddcd1110e0b218bacf2fa7f7f59f55ca804" + integrity sha512-oftTcaMu/EGrEIu904mWteKIv8vMuOgGYo7EhVJJN00R/EED9DCua/xxHRdYnKtcECzVg7xOWhflvJMnqcFZjw== + +estraverse@^1.9.1: + version "1.9.3" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-1.9.3.tgz#af67f2dc922582415950926091a4005d29c9bb44" + integrity sha1-r2fy3JIlgkFZUJJgkaQAXSnJu0Q= + +esutils@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" + integrity sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs= + +exec-sh@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.2.0.tgz#14f75de3f20d286ef933099b2ce50a90359cef10" + integrity sha1-FPdd4/INKG75MwmbLOUKkDWc7xA= + dependencies: + merge "^1.1.3" + +expand-brackets@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b" + integrity sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s= + dependencies: + is-posix-bracket "^0.1.0" + +expand-range@^1.8.1: + version "1.8.2" + resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337" + integrity sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc= + dependencies: + fill-range "^2.1.0" + +extend@~3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444" + integrity sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ= + +extglob@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1" + integrity sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE= + dependencies: + is-extglob "^1.0.0" + +extsprintf@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.0.2.tgz#e1080e0658e300b06294990cc70e1502235fd550" + integrity sha1-4QgOBljjALBilJkMxw4VAiNf1VA= + +fast-levenshtein@~2.0.4: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= + +fb-watchman@^1.8.0: + version "1.9.2" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-1.9.2.tgz#a24cf47827f82d38fb59a69ad70b76e3b6ae7383" + integrity sha1-okz0eCf4LTj7Waaa1wt247auc4M= + dependencies: + bser "1.0.2" + +fb-watchman@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.0.tgz#54e9abf7dfa2f26cd9b1636c588c1afc05de5d58" + integrity sha1-VOmr99+i8mzZsWNsWIwa/AXeXVg= + dependencies: + bser "^2.0.0" + +filename-regex@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26" + integrity sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY= + +fileset@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/fileset/-/fileset-2.0.3.tgz#8e7548a96d3cc2327ee5e674168723a333bba2a0" + integrity sha1-jnVIqW08wjJ+5eZ0FocjozO7oqA= + dependencies: + glob "^7.0.3" + minimatch "^3.0.3" + +fill-range@^2.1.0: + version "2.2.3" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.3.tgz#50b77dfd7e469bc7492470963699fe7a8485a723" + integrity sha1-ULd9/X5Gm8dJJHCWNpn+eoSFpyM= + dependencies: + is-number "^2.1.0" + isobject "^2.0.0" + randomatic "^1.1.3" + repeat-element "^1.1.2" + repeat-string "^1.5.2" + +find-up@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" + integrity sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8= + dependencies: + path-exists "^2.0.0" + pinkie-promise "^2.0.0" + +find-up@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" + integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c= + dependencies: + locate-path "^2.0.0" + +for-in@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" + integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= + +for-own@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce" + integrity sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4= + dependencies: + for-in "^1.0.1" + +forever-agent@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" + integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= + +form-data@~2.1.1: + version "2.1.4" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.1.4.tgz#33c183acf193276ecaa98143a69e94bfee1750d1" + integrity sha1-M8GDrPGTJ27KqYFDpp6Uv+4XUNE= + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.5" + mime-types "^2.1.12" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + +get-caller-file@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.2.tgz#f702e63127e7e231c160a80c1554acb70d5047e5" + integrity sha1-9wLmMSfn4jHBYKgMFVSstw1QR+U= + +getpass@^0.1.1: + version "0.1.7" + resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" + integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= + dependencies: + assert-plus "^1.0.0" + +glob-base@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" + integrity sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q= + dependencies: + glob-parent "^2.0.0" + is-glob "^2.0.0" + +glob-parent@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28" + integrity sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg= + dependencies: + is-glob "^2.0.0" + +glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" + integrity sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +globals@^9.0.0: + version "9.18.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a" + integrity sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ== + +graceful-fs@^4.1.11, graceful-fs@^4.1.2: + version "4.1.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" + integrity sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg= + +graphql@^0.10.5: + version "0.10.5" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-0.10.5.tgz#c9be17ca2bdfdbd134077ffd9bbaa48b8becd298" + integrity sha512-Q7cx22DiLhwHsEfUnUip1Ww/Vfx7FS0w6+iHItNuN61+XpegHSa3k5U0+6M5BcpavQImBwFiy0z3uYwY7cXMLQ== + dependencies: + iterall "^1.1.0" + +growly@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" + integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE= + +handlebars@^4.0.3: + version "4.0.10" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.10.tgz#3d30c718b09a3d96f23ea4cc1f403c4d3ba9ff4f" + integrity sha1-PTDHGLCaPZbyPqTMH0A8TTup/08= + dependencies: + async "^1.4.0" + optimist "^0.6.1" + source-map "^0.4.4" + optionalDependencies: + uglify-js "^2.6" + +har-schema@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-1.0.5.tgz#d263135f43307c02c602afc8fe95970c0151369e" + integrity sha1-0mMTX0MwfALGAq/I/pWXDAFRNp4= + +har-validator@~4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-4.2.1.tgz#33481d0f1bbff600dd203d75812a6a5fba002e2a" + integrity sha1-M0gdDxu/9gDdID11gSpqX7oALio= + dependencies: + ajv "^4.9.1" + har-schema "^1.0.5" + +has-ansi@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" + integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE= + dependencies: + ansi-regex "^2.0.0" + +has-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa" + integrity sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo= + +hawk@~3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/hawk/-/hawk-3.1.3.tgz#078444bd7c1640b0fe540d2c9b73d59678e8e1c4" + integrity sha1-B4REvXwWQLD+VA0sm3PVlnjo4cQ= + dependencies: + boom "2.x.x" + cryptiles "2.x.x" + hoek "2.x.x" + sntp "1.x.x" + +hoek@2.x.x: + version "2.16.3" + resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed" + integrity sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0= + +home-or-tmp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8" + integrity sha1-42w/LSyufXRqhX440Y1fMqeILbg= + dependencies: + os-homedir "^1.0.0" + os-tmpdir "^1.0.1" + +hosted-git-info@^2.1.4: + version "2.5.0" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.5.0.tgz#6d60e34b3abbc8313062c3b798ef8d901a07af3c" + integrity sha512-pNgbURSuab90KbTqvRPsseaTxOJCZBD0a7t+haSN33piP9cCM4l0CqdzAif2hUqm716UovKB2ROmiabGAKVXyg== + +html-encoding-sniffer@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-1.0.1.tgz#79bf7a785ea495fe66165e734153f363ff5437da" + integrity sha1-eb96eF6klf5mFl5zQVPzY/9UN9o= + dependencies: + whatwg-encoding "^1.0.1" + +http-signature@~1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.1.1.tgz#df72e267066cd0ac67fb76adf8e134a8fbcf91bf" + integrity sha1-33LiZwZs0Kxn+3at+OE0qPvPkb8= + dependencies: + assert-plus "^0.2.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + +iconv-lite@0.4.13: + version "0.4.13" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.13.tgz#1f88aba4ab0b1508e8312acc39345f36e992e2f2" + integrity sha1-H4irpKsLFQjoMSrMOTRfNumS4vI= + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= + +invariant@^2.2.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.2.tgz#9e1f56ac0acdb6bf303306f338be3b204ae60360" + integrity sha1-nh9WrArNtr8wMwbzOL47IErmA2A= + dependencies: + loose-envify "^1.0.0" + +invert-kv@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" + integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY= + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= + +is-buffer@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.5.tgz#1f3b26ef613b214b88cbca23cc6c01d87961eecc" + integrity sha1-Hzsm72E7IUuIy8ojzGwB2Hlh7sw= + +is-builtin-module@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-1.0.0.tgz#540572d34f7ac3119f8f76c30cbc1b1e037affbe" + integrity sha1-VAVy0096wxGfj3bDDLwbHgN6/74= + dependencies: + builtin-modules "^1.0.0" + +is-ci@^1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.0.10.tgz#f739336b2632365061a9d48270cd56ae3369318e" + integrity sha1-9zkzayYyNlBhqdSCcM1WrjNpMY4= + dependencies: + ci-info "^1.0.0" + +is-dotfile@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1" + integrity sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE= + +is-equal-shallow@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534" + integrity sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ= + dependencies: + is-primitive "^2.0.0" + +is-extendable@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" + integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik= + +is-extglob@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0" + integrity sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA= + +is-finite@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa" + integrity sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko= + dependencies: + number-is-nan "^1.0.0" + +is-fullwidth-code-point@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" + integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= + dependencies: + number-is-nan "^1.0.0" + +is-glob@^2.0.0, is-glob@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863" + integrity sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM= + dependencies: + is-extglob "^1.0.0" + +is-number@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" + integrity sha1-Afy7s5NGOlSPL0ZszhbezknbkI8= + dependencies: + kind-of "^3.0.2" + +is-number@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" + integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU= + dependencies: + kind-of "^3.0.2" + +is-posix-bracket@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4" + integrity sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q= + +is-primitive@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575" + integrity sha1-IHurkWOEmcB7Kt8kCkGochADRXU= + +is-typedarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= + +is-utf8@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" + integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI= + +isarray@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= + +isobject@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" + integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk= + dependencies: + isarray "1.0.0" + +isstream@~0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= + +istanbul-api@^1.1.1: + version "1.1.11" + resolved "https://registry.yarnpkg.com/istanbul-api/-/istanbul-api-1.1.11.tgz#fcc0b461e2b3bda71e305155138238768257d9de" + integrity sha1-/MC0YeKzvaceMFFVE4I4doJX2d4= + dependencies: + async "^2.1.4" + fileset "^2.0.2" + istanbul-lib-coverage "^1.1.1" + istanbul-lib-hook "^1.0.7" + istanbul-lib-instrument "^1.7.4" + istanbul-lib-report "^1.1.1" + istanbul-lib-source-maps "^1.2.1" + istanbul-reports "^1.1.1" + js-yaml "^3.7.0" + mkdirp "^0.5.1" + once "^1.4.0" + +istanbul-lib-coverage@^1.0.1, istanbul-lib-coverage@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-1.1.1.tgz#73bfb998885299415c93d38a3e9adf784a77a9da" + integrity sha512-0+1vDkmzxqJIn5rcoEqapSB4DmPxE31EtI2dF2aCkV5esN9EWHxZ0dwgDClivMXJqE7zaYQxq30hj5L0nlTN5Q== + +istanbul-lib-hook@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-1.0.7.tgz#dd6607f03076578fe7d6f2a630cf143b49bacddc" + integrity sha512-3U2HB9y1ZV9UmFlE12Fx+nPtFqIymzrqCksrXujm3NVbAZIJg/RfYgO1XiIa0mbmxTjWpVEVlkIZJ25xVIAfkQ== + dependencies: + append-transform "^0.4.0" + +istanbul-lib-instrument@^1.4.2, istanbul-lib-instrument@^1.7.2, istanbul-lib-instrument@^1.7.4: + version "1.7.4" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-1.7.4.tgz#e9fd920e4767f3d19edc765e2d6b3f5ccbd0eea8" + integrity sha1-6f2SDkdn89Ge3HZeLWs/XMvQ7qg= + dependencies: + babel-generator "^6.18.0" + babel-template "^6.16.0" + babel-traverse "^6.18.0" + babel-types "^6.18.0" + babylon "^6.17.4" + istanbul-lib-coverage "^1.1.1" + semver "^5.3.0" + +istanbul-lib-report@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-1.1.1.tgz#f0e55f56655ffa34222080b7a0cd4760e1405fc9" + integrity sha512-tvF+YmCmH4thnez6JFX06ujIA19WPa9YUiwjc1uALF2cv5dmE3It8b5I8Ob7FHJ70H9Y5yF+TDkVa/mcADuw1Q== + dependencies: + istanbul-lib-coverage "^1.1.1" + mkdirp "^0.5.1" + path-parse "^1.0.5" + supports-color "^3.1.2" + +istanbul-lib-source-maps@^1.1.0, istanbul-lib-source-maps@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-1.2.1.tgz#a6fe1acba8ce08eebc638e572e294d267008aa0c" + integrity sha512-mukVvSXCn9JQvdJl8wP/iPhqig0MRtuWuD4ZNKo6vB2Ik//AmhAKe3QnPN02dmkRe3lTudFk3rzoHhwU4hb94w== + dependencies: + debug "^2.6.3" + istanbul-lib-coverage "^1.1.1" + mkdirp "^0.5.1" + rimraf "^2.6.1" + source-map "^0.5.3" + +istanbul-reports@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-1.1.1.tgz#042be5c89e175bc3f86523caab29c014e77fee4e" + integrity sha512-P8G873A0kW24XRlxHVGhMJBhQ8gWAec+dae7ZxOBzxT4w+a9ATSPvRVK3LB1RAJ9S8bg2tOyWHAGW40Zd2dKfw== + dependencies: + handlebars "^4.0.3" + +iterall@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.1.1.tgz#f7f0af11e9a04ec6426260f5019d9fcca4d50214" + integrity sha1-9/CvEemgTsZCYmD1AZ2fzKTVAhQ= + +jest-changed-files@^20.0.3: + version "20.0.3" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-20.0.3.tgz#9394d5cc65c438406149bef1bf4d52b68e03e3f8" + integrity sha1-k5TVzGXEOEBhSb7xv01Sto4D4/g= + +jest-cli@^20.0.4: + version "20.0.4" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-20.0.4.tgz#e532b19d88ae5bc6c417e8b0593a6fe954b1dc93" + integrity sha1-5TKxnYiuW8bEF+iwWTpv6VSx3JM= + dependencies: + ansi-escapes "^1.4.0" + callsites "^2.0.0" + chalk "^1.1.3" + graceful-fs "^4.1.11" + is-ci "^1.0.10" + istanbul-api "^1.1.1" + istanbul-lib-coverage "^1.0.1" + istanbul-lib-instrument "^1.4.2" + istanbul-lib-source-maps "^1.1.0" + jest-changed-files "^20.0.3" + jest-config "^20.0.4" + jest-docblock "^20.0.3" + jest-environment-jsdom "^20.0.3" + jest-haste-map "^20.0.4" + jest-jasmine2 "^20.0.4" + jest-message-util "^20.0.3" + jest-regex-util "^20.0.3" + jest-resolve-dependencies "^20.0.3" + jest-runtime "^20.0.4" + jest-snapshot "^20.0.3" + jest-util "^20.0.3" + micromatch "^2.3.11" + node-notifier "^5.0.2" + pify "^2.3.0" + slash "^1.0.0" + string-length "^1.0.1" + throat "^3.0.0" + which "^1.2.12" + worker-farm "^1.3.1" + yargs "^7.0.2" + +jest-config@^20.0.4: + version "20.0.4" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-20.0.4.tgz#e37930ab2217c913605eff13e7bd763ec48faeea" + integrity sha1-43kwqyIXyRNgXv8T5712PsSPruo= + dependencies: + chalk "^1.1.3" + glob "^7.1.1" + jest-environment-jsdom "^20.0.3" + jest-environment-node "^20.0.3" + jest-jasmine2 "^20.0.4" + jest-matcher-utils "^20.0.3" + jest-regex-util "^20.0.3" + jest-resolve "^20.0.4" + jest-validate "^20.0.3" + pretty-format "^20.0.3" + +jest-diff@^20.0.3: + version "20.0.3" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-20.0.3.tgz#81f288fd9e675f0fb23c75f1c2b19445fe586617" + integrity sha1-gfKI/Z5nXw+yPHXxwrGURf5YZhc= + dependencies: + chalk "^1.1.3" + diff "^3.2.0" + jest-matcher-utils "^20.0.3" + pretty-format "^20.0.3" + +jest-docblock@^20.0.3: + version "20.0.3" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-20.0.3.tgz#17bea984342cc33d83c50fbe1545ea0efaa44712" + integrity sha1-F76phDQswz2DxQ++FUXqDvqkRxI= + +jest-environment-jsdom@^20.0.3: + version "20.0.3" + resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-20.0.3.tgz#048a8ac12ee225f7190417713834bb999787de99" + integrity sha1-BIqKwS7iJfcZBBdxODS7mZeH3pk= + dependencies: + jest-mock "^20.0.3" + jest-util "^20.0.3" + jsdom "^9.12.0" + +jest-environment-node@^20.0.3: + version "20.0.3" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-20.0.3.tgz#d488bc4612af2c246e986e8ae7671a099163d403" + integrity sha1-1Ii8RhKvLCRumG6K52caCZFj1AM= + dependencies: + jest-mock "^20.0.3" + jest-util "^20.0.3" + +jest-haste-map@^20.0.4: + version "20.0.5" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-20.0.5.tgz#abad74efb1a005974a7b6517e11010709cab9112" + integrity sha512-0IKAQjUvuZjMCNi/0VNQQF74/H9KB67hsHJqGiwTWQC6XO5Azs7kLWm+6Q/dwuhvDUvABDOBMFK2/FwZ3sZ07Q== + dependencies: + fb-watchman "^2.0.0" + graceful-fs "^4.1.11" + jest-docblock "^20.0.3" + micromatch "^2.3.11" + sane "~1.6.0" + worker-farm "^1.3.1" + +jest-jasmine2@^20.0.4: + version "20.0.4" + resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-20.0.4.tgz#fcc5b1411780d911d042902ef1859e852e60d5e1" + integrity sha1-/MWxQReA2RHQQpAu8YWehS5g1eE= + dependencies: + chalk "^1.1.3" + graceful-fs "^4.1.11" + jest-diff "^20.0.3" + jest-matcher-utils "^20.0.3" + jest-matchers "^20.0.3" + jest-message-util "^20.0.3" + jest-snapshot "^20.0.3" + once "^1.4.0" + p-map "^1.1.1" + +jest-matcher-utils@^20.0.3: + version "20.0.3" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-20.0.3.tgz#b3a6b8e37ca577803b0832a98b164f44b7815612" + integrity sha1-s6a443yld4A7CDKpixZPRLeBVhI= + dependencies: + chalk "^1.1.3" + pretty-format "^20.0.3" + +jest-matchers@^20.0.3: + version "20.0.3" + resolved "https://registry.yarnpkg.com/jest-matchers/-/jest-matchers-20.0.3.tgz#ca69db1c32db5a6f707fa5e0401abb55700dfd60" + integrity sha1-ymnbHDLbWm9wf6XgQBq7VXAN/WA= + dependencies: + jest-diff "^20.0.3" + jest-matcher-utils "^20.0.3" + jest-message-util "^20.0.3" + jest-regex-util "^20.0.3" + +jest-message-util@^20.0.3: + version "20.0.3" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-20.0.3.tgz#6aec2844306fcb0e6e74d5796c1006d96fdd831c" + integrity sha1-auwoRDBvyw5udNV5bBAG2W/dgxw= + dependencies: + chalk "^1.1.3" + micromatch "^2.3.11" + slash "^1.0.0" + +jest-mock@^20.0.3: + version "20.0.3" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-20.0.3.tgz#8bc070e90414aa155c11a8d64c869a0d5c71da59" + integrity sha1-i8Bw6QQUqhVcEajWTIaaDVxx2lk= + +jest-regex-util@^20.0.3: + version "20.0.3" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-20.0.3.tgz#85bbab5d133e44625b19faf8c6aa5122d085d762" + integrity sha1-hburXRM+RGJbGfr4xqpRItCF12I= + +jest-resolve-dependencies@^20.0.3: + version "20.0.3" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-20.0.3.tgz#6e14a7b717af0f2cb3667c549de40af017b1723a" + integrity sha1-bhSntxevDyyzZnxUneQK8Bexcjo= + dependencies: + jest-regex-util "^20.0.3" + +jest-resolve@^20.0.4: + version "20.0.4" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-20.0.4.tgz#9448b3e8b6bafc15479444c6499045b7ffe597a5" + integrity sha1-lEiz6La6/BVHlETGSZBFt//ll6U= + dependencies: + browser-resolve "^1.11.2" + is-builtin-module "^1.0.0" + resolve "^1.3.2" + +jest-runtime@^20.0.4: + version "20.0.4" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-20.0.4.tgz#a2c802219c4203f754df1404e490186169d124d8" + integrity sha1-osgCIZxCA/dU3xQE5JAYYWnRJNg= + dependencies: + babel-core "^6.0.0" + babel-jest "^20.0.3" + babel-plugin-istanbul "^4.0.0" + chalk "^1.1.3" + convert-source-map "^1.4.0" + graceful-fs "^4.1.11" + jest-config "^20.0.4" + jest-haste-map "^20.0.4" + jest-regex-util "^20.0.3" + jest-resolve "^20.0.4" + jest-util "^20.0.3" + json-stable-stringify "^1.0.1" + micromatch "^2.3.11" + strip-bom "3.0.0" + yargs "^7.0.2" + +jest-snapshot@^20.0.3: + version "20.0.3" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-20.0.3.tgz#5b847e1adb1a4d90852a7f9f125086e187c76566" + integrity sha1-W4R+GtsaTZCFKn+fElCG4YfHZWY= + dependencies: + chalk "^1.1.3" + jest-diff "^20.0.3" + jest-matcher-utils "^20.0.3" + jest-util "^20.0.3" + natural-compare "^1.4.0" + pretty-format "^20.0.3" + +jest-util@^20.0.3: + version "20.0.3" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-20.0.3.tgz#0c07f7d80d82f4e5a67c6f8b9c3fe7f65cfd32ad" + integrity sha1-DAf32A2C9OWmfG+LnD/n9lz9Mq0= + dependencies: + chalk "^1.1.3" + graceful-fs "^4.1.11" + jest-message-util "^20.0.3" + jest-mock "^20.0.3" + jest-validate "^20.0.3" + leven "^2.1.0" + mkdirp "^0.5.1" + +jest-validate@^20.0.3: + version "20.0.3" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-20.0.3.tgz#d0cfd1de4f579f298484925c280f8f1d94ec3cab" + integrity sha1-0M/R3k9XnymEhJJcKA+PHZTsPKs= + dependencies: + chalk "^1.1.3" + jest-matcher-utils "^20.0.3" + leven "^2.1.0" + pretty-format "^20.0.3" + +jest@^20.0.4: + version "20.0.4" + resolved "https://registry.yarnpkg.com/jest/-/jest-20.0.4.tgz#3dd260c2989d6dad678b1e9cc4d91944f6d602ac" + integrity sha1-PdJgwpidba1nix6cxNkZRPbWAqw= + dependencies: + jest-cli "^20.0.4" + +js-tokens@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" + integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= + +js-yaml@^3.7.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.9.0.tgz#4ffbbf25c2ac963b8299dc74da7e3740de1c18ce" + integrity sha512-0LoUNELX4S+iofCT8f4uEHIiRBR+c2AINyC8qRWfC6QNruLtxVZRJaPcu/xwMgFIgDxF25tGHaDjvxzJCNE9yw== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +jsbn@~0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" + integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= + +jsdom@^9.12.0: + version "9.12.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-9.12.0.tgz#e8c546fffcb06c00d4833ca84410fed7f8a097d4" + integrity sha1-6MVG//ywbADUgzyoRBD+1/igl9Q= + dependencies: + abab "^1.0.3" + acorn "^4.0.4" + acorn-globals "^3.1.0" + array-equal "^1.0.0" + content-type-parser "^1.0.1" + cssom ">= 0.3.2 < 0.4.0" + cssstyle ">= 0.2.37 < 0.3.0" + escodegen "^1.6.1" + html-encoding-sniffer "^1.0.1" + nwmatcher ">= 1.3.9 < 2.0.0" + parse5 "^1.5.1" + request "^2.79.0" + sax "^1.2.1" + symbol-tree "^3.2.1" + tough-cookie "^2.3.2" + webidl-conversions "^4.0.0" + whatwg-encoding "^1.0.1" + whatwg-url "^4.3.0" + xml-name-validator "^2.0.1" + +jsesc@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b" + integrity sha1-RsP+yMGJKxKwgz25vHYiF226s0s= + +json-schema@0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" + integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= + +json-stable-stringify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz#9a759d39c5f2ff503fd5300646ed445f88c4f9af" + integrity sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8= + dependencies: + jsonify "~0.0.0" + +json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= + +json5@^0.5.0: + version "0.5.1" + resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821" + integrity sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE= + +jsonify@~0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" + integrity sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM= + +jsprim@^1.2.2: + version "1.4.0" + resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.0.tgz#a3b87e40298d8c380552d8cc7628a0bb95a22918" + integrity sha1-o7h+QCmNjDgFUtjMdiigu5WiKRg= + dependencies: + assert-plus "1.0.0" + extsprintf "1.0.2" + json-schema "0.2.3" + verror "1.3.6" + +kind-of@^3.0.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" + integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ= + dependencies: + is-buffer "^1.1.5" + +kind-of@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" + integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc= + dependencies: + is-buffer "^1.1.5" + +lazy-cache@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" + integrity sha1-odePw6UEdMuAhF07O24dpJpEbo4= + +lcid@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" + integrity sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU= + dependencies: + invert-kv "^1.0.0" + +leven@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-2.1.0.tgz#c2e7a9f772094dee9d34202ae8acce4687875580" + integrity sha1-wuep93IJTe6dNCAq6KzORoeHVYA= + +levn@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" + integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= + dependencies: + prelude-ls "~1.1.2" + type-check "~0.3.2" + +load-json-file@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" + integrity sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA= + dependencies: + graceful-fs "^4.1.2" + parse-json "^2.2.0" + pify "^2.0.0" + pinkie-promise "^2.0.0" + strip-bom "^2.0.0" + +locate-path@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" + integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4= + dependencies: + p-locate "^2.0.0" + path-exists "^3.0.0" + +lodash@^4.14.0, lodash@^4.2.0, lodash@~4.17.2: + version "4.17.4" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" + integrity sha1-eCA6TRwyiuHYbcpkYONptX9AVa4= + +longest@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" + integrity sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc= + +loose-envify@^1.0.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848" + integrity sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg= + dependencies: + js-tokens "^3.0.0" + +makeerror@1.0.x: + version "1.0.11" + resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c" + integrity sha1-4BpckQnyr3lmDk6LlYd5AYT1qWw= + dependencies: + tmpl "1.0.x" + +merge@^1.1.3: + version "1.2.0" + resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.0.tgz#7531e39d4949c281a66b8c5a6e0265e8b05894da" + integrity sha1-dTHjnUlJwoGma4xabgJl6LBYlNo= + +micromatch@^2.1.5, micromatch@^2.3.11: + version "2.3.11" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565" + integrity sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU= + dependencies: + arr-diff "^2.0.0" + array-unique "^0.2.1" + braces "^1.8.2" + expand-brackets "^0.1.4" + extglob "^0.3.1" + filename-regex "^2.0.0" + is-extglob "^1.0.0" + is-glob "^2.0.1" + kind-of "^3.0.2" + normalize-path "^2.0.1" + object.omit "^2.0.0" + parse-glob "^3.0.4" + regex-cache "^0.4.2" + +mime-db@~1.27.0: + version "1.27.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.27.0.tgz#820f572296bbd20ec25ed55e5b5de869e5436eb1" + integrity sha1-gg9XIpa70g7CXtVeW13oaeVDbrE= + +mime-types@^2.1.12, mime-types@~2.1.7: + version "2.1.15" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.15.tgz#a4ebf5064094569237b8cf70046776d09fc92aed" + integrity sha1-pOv1BkCUVpI3uM9wBGd20J/JKu0= + dependencies: + mime-db "~1.27.0" + +minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== + dependencies: + brace-expansion "^1.1.7" + +minimist@0.0.8, minimist@~0.0.1: + version "0.0.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" + integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= + +minimist@^1.1.1, minimist@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" + integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ= + +mkdirp@^0.5.0, mkdirp@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" + integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= + dependencies: + minimist "0.0.8" + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= + +nock@^9.0.14: + version "9.0.14" + resolved "https://registry.yarnpkg.com/nock/-/nock-9.0.14.tgz#2211550253173ce298bcd89fca825e83813ca72b" + integrity sha1-IhFVAlMXPOKYvNifyoJeg4E8pys= + dependencies: + chai ">=1.9.2 <4.0.0" + debug "^2.2.0" + deep-equal "^1.0.0" + json-stringify-safe "^5.0.1" + lodash "~4.17.2" + mkdirp "^0.5.0" + propagate "0.4.0" + qs "^6.0.2" + semver "^5.3.0" + +node-int64@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" + integrity sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs= + +node-notifier@^5.0.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-5.1.2.tgz#2fa9e12605fa10009d44549d6fcd8a63dde0e4ff" + integrity sha1-L6nhJgX6EACdRFSdb82KY93g5P8= + dependencies: + growly "^1.3.0" + semver "^5.3.0" + shellwords "^0.1.0" + which "^1.2.12" + +normalize-package-data@^2.3.2: + version "2.4.0" + resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.4.0.tgz#12f95a307d58352075a04907b84ac8be98ac012f" + integrity sha512-9jjUFbTPfEy3R/ad/2oNbKtW9Hgovl5O1FvFWKkKblNXoN/Oou6+9+KKohPK13Yc3/TyunyWhJp6gvRNR/PPAw== + dependencies: + hosted-git-info "^2.1.4" + is-builtin-module "^1.0.0" + semver "2 || 3 || 4 || 5" + validate-npm-package-license "^3.0.1" + +normalize-path@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" + integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= + dependencies: + remove-trailing-separator "^1.0.1" + +number-is-nan@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" + integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= + +"nwmatcher@>= 1.3.9 < 2.0.0": + version "1.4.1" + resolved "https://registry.yarnpkg.com/nwmatcher/-/nwmatcher-1.4.1.tgz#7ae9b07b0ea804db7e25f05cb5fe4097d4e4949f" + integrity sha1-eumwew6oBNt+JfBctf5Al9TklJ8= + +oauth-sign@~0.8.1: + version "0.8.2" + resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" + integrity sha1-Rqarfwrq2N6unsBWV4C31O/rnUM= + +object-assign@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= + +object.omit@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa" + integrity sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo= + dependencies: + for-own "^0.1.4" + is-extendable "^0.1.1" + +once@^1.3.0, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + dependencies: + wrappy "1" + +optimist@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686" + integrity sha1-2j6nRob6IaGaERwybpDrFaAZZoY= + dependencies: + minimist "~0.0.1" + wordwrap "~0.0.2" + +optionator@^0.8.1: + version "0.8.2" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64" + integrity sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q= + dependencies: + deep-is "~0.1.3" + fast-levenshtein "~2.0.4" + levn "~0.3.0" + prelude-ls "~1.1.2" + type-check "~0.3.2" + wordwrap "~1.0.0" + +os-homedir@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" + integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= + +os-locale@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-1.4.0.tgz#20f9f17ae29ed345e8bde583b13d2009803c14d9" + integrity sha1-IPnxeuKe00XoveWDsT0gCYA8FNk= + dependencies: + lcid "^1.0.0" + +os-tmpdir@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" + integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= + +p-limit@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.1.0.tgz#b07ff2d9a5d88bec806035895a2bab66a27988bc" + integrity sha1-sH/y2aXYi+yAYDWJWiurZqJ5iLw= + +p-locate@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" + integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM= + dependencies: + p-limit "^1.1.0" + +p-map@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.1.1.tgz#05f5e4ae97a068371bc2a5cc86bfbdbc19c4ae7a" + integrity sha1-BfXkrpegaDcbwqXMhr+9vBnErno= + +parse-glob@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c" + integrity sha1-ssN2z7EfNVE7rdFz7wu246OIORw= + dependencies: + glob-base "^0.3.0" + is-dotfile "^1.0.0" + is-extglob "^1.0.0" + is-glob "^2.0.0" + +parse-json@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9" + integrity sha1-9ID0BDTvgHQfhGkJn43qGPVaTck= + dependencies: + error-ex "^1.2.0" + +parse5@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-1.5.1.tgz#9b7f3b0de32be78dc2401b17573ccaf0f6f59d94" + integrity sha1-m387DeMr543CQBsXVzzK8Pb1nZQ= + +path-exists@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b" + integrity sha1-D+tsZPD8UY2adU3V77YscCJ2H0s= + dependencies: + pinkie-promise "^2.0.0" + +path-exists@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" + integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + +path-parse@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.5.tgz#3c1adf871ea9cd6c9431b6ea2bd74a0ff055c4c1" + integrity sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME= + +path-type@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" + integrity sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE= + dependencies: + graceful-fs "^4.1.2" + pify "^2.0.0" + pinkie-promise "^2.0.0" + +performance-now@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5" + integrity sha1-M+8wxcd9TqIcWlOGnZG1bY8lVeU= + +pify@^2.0.0, pify@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= + +pinkie-promise@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" + integrity sha1-ITXW36ejWMBprJsXh3YogihFD/o= + dependencies: + pinkie "^2.0.0" + +pinkie@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" + integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA= + +prelude-ls@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" + integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= + +preserve@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" + integrity sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks= + +pretty-format@^20.0.3: + version "20.0.3" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-20.0.3.tgz#020e350a560a1fe1a98dc3beb6ccffb386de8b14" + integrity sha1-Ag41ClYKH+GpjcO+tsz/s4beixQ= + dependencies: + ansi-regex "^2.1.1" + ansi-styles "^3.0.0" + +private@^0.1.6: + version "0.1.7" + resolved "https://registry.yarnpkg.com/private/-/private-0.1.7.tgz#68ce5e8a1ef0a23bb570cc28537b5332aba63ef1" + integrity sha1-aM5eih7woju1cMwoU3tTMqumPvE= + +propagate@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/propagate/-/propagate-0.4.0.tgz#f3fcca0a6fe06736a7ba572966069617c130b481" + integrity sha1-8/zKCm/gZzanulcpZgaWF8EwtIE= + +prr@~0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/prr/-/prr-0.0.0.tgz#1a84b85908325501411853d0081ee3fa86e2926a" + integrity sha1-GoS4WQgyVQFBGFPQCB7j+obikmo= + +punycode@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= + +qs@^6.0.2, qs@~6.4.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" + integrity sha1-E+JtKK1rD/qpExLNO/cI7TUecjM= + +randomatic@^1.1.3: + version "1.1.7" + resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-1.1.7.tgz#c7abe9cc8b87c0baa876b19fde83fd464797e38c" + integrity sha512-D5JUjPyJbaJDkuAazpVnSfVkLlpeO3wDlPROTMLGKG1zMFNFRgrciKo1ltz/AzNTkqE0HzDx655QOL51N06how== + dependencies: + is-number "^3.0.0" + kind-of "^4.0.0" + +read-pkg-up@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" + integrity sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI= + dependencies: + find-up "^1.0.0" + read-pkg "^1.0.0" + +read-pkg@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28" + integrity sha1-9f+qXs0pyzHAR0vKfXVra7KePyg= + dependencies: + load-json-file "^1.0.0" + normalize-package-data "^2.3.2" + path-type "^1.0.0" + +regenerator-runtime@^0.10.0: + version "0.10.5" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz#336c3efc1220adcedda2c9fab67b5a7955a33658" + integrity sha1-M2w+/BIgrc7dosn6tntaeVWjNlg= + +regex-cache@^0.4.2: + version "0.4.3" + resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.3.tgz#9b1a6c35d4d0dfcef5711ae651e8e9d3d7114145" + integrity sha1-mxpsNdTQ3871cRrmUejp09cRQUU= + dependencies: + is-equal-shallow "^0.1.3" + is-primitive "^2.0.0" + +remove-trailing-separator@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.0.2.tgz#69b062d978727ad14dc6b56ba4ab772fd8d70511" + integrity sha1-abBi2XhyetFNxrVrpKt3L9jXBRE= + +repeat-element@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.2.tgz#ef089a178d1483baae4d93eb98b4f9e4e11d990a" + integrity sha1-7wiaF40Ug7quTZPrmLT55OEdmQo= + +repeat-string@^1.5.2: + version "1.6.1" + resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" + integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= + +repeating@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda" + integrity sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo= + dependencies: + is-finite "^1.0.0" + +request@^2.79.0: + version "2.81.0" + resolved "https://registry.yarnpkg.com/request/-/request-2.81.0.tgz#c6928946a0e06c5f8d6f8a9333469ffda46298a0" + integrity sha1-xpKJRqDgbF+Nb4qTM0af/aRimKA= + dependencies: + aws-sign2 "~0.6.0" + aws4 "^1.2.1" + caseless "~0.12.0" + combined-stream "~1.0.5" + extend "~3.0.0" + forever-agent "~0.6.1" + form-data "~2.1.1" + har-validator "~4.2.1" + hawk "~3.1.3" + http-signature "~1.1.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.7" + oauth-sign "~0.8.1" + performance-now "^0.2.0" + qs "~6.4.0" + safe-buffer "^5.0.1" + stringstream "~0.0.4" + tough-cookie "~2.3.0" + tunnel-agent "^0.6.0" + uuid "^3.0.0" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= + +require-main-filename@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" + integrity sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE= + +resolve@1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" + integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs= + +resolve@^1.3.2: + version "1.3.3" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.3.3.tgz#655907c3469a8680dc2de3a275a8fdd69691f0e5" + integrity sha1-ZVkHw0aahoDcLeOidaj91paR8OU= + dependencies: + path-parse "^1.0.5" + +right-align@^0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef" + integrity sha1-YTObci/mo1FWiSENJOFMlhSGE+8= + dependencies: + align-text "^0.1.1" + +rimraf@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.1.tgz#c2338ec643df7a1b7fe5c54fa86f57428a55f33d" + integrity sha1-wjOOxkPfeht/5cVPqG9XQopV8z0= + dependencies: + glob "^7.0.5" + +safe-buffer@^5.0.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" + integrity sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg== + +sane@~1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/sane/-/sane-1.6.0.tgz#9610c452307a135d29c1fdfe2547034180c46775" + integrity sha1-lhDEUjB6E10pwf3+JUcDQYDEZ3U= + dependencies: + anymatch "^1.3.0" + exec-sh "^0.2.0" + fb-watchman "^1.8.0" + minimatch "^3.0.2" + minimist "^1.1.1" + walker "~1.0.5" + watch "~0.10.0" + +sax@^1.2.1: + version "1.2.4" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" + integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== + +"semver@2 || 3 || 4 || 5", semver@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" + integrity sha1-myzl094C0XxgEq0yaqa00M9U+U8= + +set-blocking@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= + +shellwords@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.0.tgz#66afd47b6a12932d9071cbfd98a52e785cd0ba14" + integrity sha1-Zq/Ue2oSky2Qccv9mKUueFzQuhQ= + +slash@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" + integrity sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU= + +sntp@1.x.x: + version "1.0.9" + resolved "https://registry.yarnpkg.com/sntp/-/sntp-1.0.9.tgz#6541184cc90aeea6c6e7b35e2659082443c66198" + integrity sha1-ZUEYTMkK7qbG57NeJlkIJEPGYZg= + dependencies: + hoek "2.x.x" + +source-map-support@^0.4.2: + version "0.4.15" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.15.tgz#03202df65c06d2bd8c7ec2362a193056fef8d3b1" + integrity sha1-AyAt9lwG0r2MfsI2KhkwVv7407E= + dependencies: + source-map "^0.5.6" + +source-map@^0.4.4: + version "0.4.4" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b" + integrity sha1-66T12pwNyZneaAMti092FzZSA2s= + dependencies: + amdefine ">=0.0.4" + +source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.1: + version "0.5.6" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" + integrity sha1-dc449SvwczxafwwRjYEzSiu19BI= + +source-map@~0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.2.0.tgz#dab73fbcfc2ba819b4de03bd6f6eaa48164b3f9d" + integrity sha1-2rc/vPwrqBm03gO9b26qSBZLP50= + dependencies: + amdefine ">=0.0.4" + +spdx-correct@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-1.0.2.tgz#4b3073d933ff51f3912f03ac5519498a4150db40" + integrity sha1-SzBz2TP/UfORLwOsVRlJikFQ20A= + dependencies: + spdx-license-ids "^1.0.2" + +spdx-expression-parse@~1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-1.0.4.tgz#9bdf2f20e1f40ed447fbe273266191fced51626c" + integrity sha1-m98vIOH0DtRH++JzJmGR/O1RYmw= + +spdx-license-ids@^1.0.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-1.2.2.tgz#c9df7a3424594ade6bd11900d596696dc06bac57" + integrity sha1-yd96NCRZSt5r0RkA1ZZpbcBrrFc= + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= + +sshpk@^1.7.0: + version "1.13.1" + resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.13.1.tgz#512df6da6287144316dc4c18fe1cf1d940739be3" + integrity sha1-US322mKHFEMW3EwY/hzx2UBzm+M= + dependencies: + asn1 "~0.2.3" + assert-plus "^1.0.0" + dashdash "^1.12.0" + getpass "^0.1.1" + optionalDependencies: + bcrypt-pbkdf "^1.0.0" + ecc-jsbn "~0.1.1" + jsbn "~0.1.0" + tweetnacl "~0.14.0" + +string-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/string-length/-/string-length-1.0.1.tgz#56970fb1c38558e9e70b728bf3de269ac45adfac" + integrity sha1-VpcPscOFWOnnC3KL894mmsRa36w= + dependencies: + strip-ansi "^3.0.0" + +string-width@^1.0.1, string-width@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" + integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= + dependencies: + code-point-at "^1.0.0" + is-fullwidth-code-point "^1.0.0" + strip-ansi "^3.0.0" + +stringstream@~0.0.4: + version "0.0.5" + resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878" + integrity sha1-TkhM1N5aC7vuGORjB3EKioFiGHg= + +strip-ansi@^3.0.0, strip-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= + dependencies: + ansi-regex "^2.0.0" + +strip-bom@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" + integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM= + +strip-bom@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e" + integrity sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4= + dependencies: + is-utf8 "^0.2.0" + +supports-color@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" + integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= + +supports-color@^3.1.2: + version "3.2.3" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.2.3.tgz#65ac0504b3954171d8a64946b2ae3cbb8a5f54f6" + integrity sha1-ZawFBLOVQXHYpklGsq48u4pfVPY= + dependencies: + has-flag "^1.0.0" + +symbol-tree@^3.2.1: + version "3.2.2" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.2.tgz#ae27db38f660a7ae2e1c3b7d1bc290819b8519e6" + integrity sha1-rifbOPZgp64uHDt9G8KQgZuFGeY= + +test-exclude@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-4.1.1.tgz#4d84964b0966b0087ecc334a2ce002d3d9341e26" + integrity sha512-35+Asrsk3XHJDBgf/VRFexPgh3UyETv8IAn/LRTiZjVy6rjPVqdEk8dJcJYBzl1w0XCJM48lvTy8SfEsCWS4nA== + dependencies: + arrify "^1.0.1" + micromatch "^2.3.11" + object-assign "^4.1.0" + read-pkg-up "^1.0.1" + require-main-filename "^1.0.1" + +throat@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/throat/-/throat-3.2.0.tgz#50cb0670edbc40237b9e347d7e1f88e4620af836" + integrity sha512-/EY8VpvlqJ+sFtLPeOgc8Pl7kQVOWv0woD87KTXVHPIAE842FGT+rokxIhe8xIUP1cfgrkt0as0vDLjDiMtr8w== + +tmpl@1.0.x: + version "1.0.4" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" + integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE= + +to-fast-properties@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47" + integrity sha1-uDVx+k2MJbguIxsG46MFXeTKGkc= + +tough-cookie@^2.3.2, tough-cookie@~2.3.0: + version "2.3.2" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.2.tgz#f081f76e4c85720e6c37a5faced737150d84072a" + integrity sha1-8IH3bkyFcg5sN6X6ztc3FQ2EByo= + dependencies: + punycode "^1.4.1" + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o= + +trim-right@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003" + integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM= + +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= + dependencies: + safe-buffer "^5.0.1" + +tweetnacl@^0.14.3, tweetnacl@~0.14.0: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= + +type-check@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" + integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I= + dependencies: + prelude-ls "~1.1.2" + +type-detect@0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-0.1.1.tgz#0ba5ec2a885640e470ea4e8505971900dac58822" + integrity sha1-C6XsKohWQORw6k6FBZcZANrFiCI= + +type-detect@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-1.0.0.tgz#762217cc06db258ec48908a1298e8b95121e8ea2" + integrity sha1-diIXzAbbJY7EiQihKY6LlRIejqI= + +uglify-js@^2.6: + version "2.8.29" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.29.tgz#29c5733148057bb4e1f75df35b7a9cb72e6a59dd" + integrity sha1-KcVzMUgFe7Th913zW3qcty5qWd0= + dependencies: + source-map "~0.5.1" + yargs "~3.10.0" + optionalDependencies: + uglify-to-browserify "~1.0.0" + +uglify-to-browserify@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7" + integrity sha1-bgkk1r2mta/jSeOabWMoUKD4grc= + +uuid@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04" + integrity sha512-DIWtzUkw04M4k3bf1IcpS2tngXEL26YUD2M0tMDUpnUrz2hgzUBlD55a4FjdLGPvfHxS6uluGWvaVEqgBcVa+g== + +validate-npm-package-license@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.1.tgz#2804babe712ad3379459acfbe24746ab2c303fbc" + integrity sha1-KAS6vnEq0zeUWaz74kdGqywwP7w= + dependencies: + spdx-correct "~1.0.0" + spdx-expression-parse "~1.0.0" + +verror@1.3.6: + version "1.3.6" + resolved "https://registry.yarnpkg.com/verror/-/verror-1.3.6.tgz#cff5df12946d297d2baaefaa2689e25be01c005c" + integrity sha1-z/XfEpRtKX0rqu+qJoniW+AcAFw= + dependencies: + extsprintf "1.0.2" + +walker@~1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb" + integrity sha1-L3+bj9ENZ3JisYqITijRlhjgKPs= + dependencies: + makeerror "1.0.x" + +watch@~0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/watch/-/watch-0.10.0.tgz#77798b2da0f9910d595f1ace5b0c2258521f21dc" + integrity sha1-d3mLLaD5kQ1ZXxrOWwwiWFIfIdw= + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE= + +webidl-conversions@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.1.tgz#8015a17ab83e7e1b311638486ace81da6ce206a0" + integrity sha1-gBWherg+fhsxFjhIas6B2mziBqA= + +whatwg-encoding@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.1.tgz#3c6c451a198ee7aec55b1ec61d0920c67801a5f4" + integrity sha1-PGxFGhmO567FWx7GHQkgxngBpfQ= + dependencies: + iconv-lite "0.4.13" + +whatwg-url@^4.3.0: + version "4.8.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-4.8.0.tgz#d2981aa9148c1e00a41c5a6131166ab4683bbcc0" + integrity sha1-0pgaqRSMHgCkHFphMRZqtGg7vMA= + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +which-module@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f" + integrity sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8= + +which@^1.2.12: + version "1.2.14" + resolved "https://registry.yarnpkg.com/which/-/which-1.2.14.tgz#9a87c4378f03e827cecaf1acdf56c736c01c14e5" + integrity sha1-mofEN48D6CfOyvGs31bHNsAcFOU= + dependencies: + isexe "^2.0.0" + +window-size@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d" + integrity sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0= + +wordwrap@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f" + integrity sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8= + +wordwrap@~0.0.2: + version "0.0.3" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107" + integrity sha1-o9XabNXAvAAI03I0u68b7WMFkQc= + +wordwrap@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= + +worker-farm@^1.3.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.4.1.tgz#a438bc993a7a7d133bcb6547c95eca7cff4897d8" + integrity sha512-tgFAtgOYLPutkAyzgpS6VJFL5HY+0ui1Tvua+fITgz8ByaJTMFGtazR6xxQfwfiAcbwE+2fLG/K49wc2TfwCNw== + dependencies: + errno "^0.1.4" + xtend "^4.0.1" + +wrap-ansi@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" + integrity sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU= + dependencies: + string-width "^1.0.1" + strip-ansi "^3.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + +xml-name-validator@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-2.0.1.tgz#4d8b8f1eccd3419aa362061becef515e1e559635" + integrity sha1-TYuPHszTQZqjYgYb7O9RXh5VljU= + +xtend@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" + integrity sha1-pcbVMr5lbiPbgg77lDofBJmNY68= + +y18n@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" + integrity sha1-bRX7qITAhnnA136I53WegR4H+kE= + +yargs-parser@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-5.0.0.tgz#275ecf0d7ffe05c77e64e7c86e4cd94bf0e1228a" + integrity sha1-J17PDX/+Bcd+ZOfIbkzZS/DhIoo= + dependencies: + camelcase "^3.0.0" + +yargs@^7.0.2: + version "7.1.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-7.1.0.tgz#6ba318eb16961727f5d284f8ea003e8d6154d0c8" + integrity sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg= + dependencies: + camelcase "^3.0.0" + cliui "^3.2.0" + decamelize "^1.1.1" + get-caller-file "^1.0.1" + os-locale "^1.4.0" + read-pkg-up "^1.0.1" + require-directory "^2.1.1" + require-main-filename "^1.0.1" + set-blocking "^2.0.0" + string-width "^1.0.2" + which-module "^1.0.0" + y18n "^3.2.1" + yargs-parser "^5.0.0" + +yargs@~3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1" + integrity sha1-9+572FfdfB0tOMDnTvvWgdFDH9E= + dependencies: + camelcase "^1.0.2" + cliui "^2.1.0" + decamelize "^1.0.0" + window-size "0.1.0" + +zen-observable@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.6.0.tgz#8a6157ed15348d185d948cfc4a59d90a2c0f70ee" + integrity sha512-G5v4eFZ1+qJC9U3iyldcc7OLsYfFf6/+/vywU3acbq0qq1PdFXsyfqtOUaQSTwhdbejO7AQWil3GzHxYlMchIg== diff --git a/lib/graphql.rb b/lib/graphql.rb index 95008755b8..9c5de5b518 100644 --- a/lib/graphql.rb +++ b/lib/graphql.rb @@ -69,6 +69,7 @@ def self.scan_with_ragel(graphql_string) require "graphql/execution" require "graphql/dig" require "graphql/schema" +require "graphql/execution" require "graphql/types" require "graphql/relay" require "graphql/boolean_type" diff --git a/lib/graphql/analysis.rb b/lib/graphql/analysis.rb index 7e47d9d14d..17f426862f 100644 --- a/lib/graphql/analysis.rb +++ b/lib/graphql/analysis.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require "graphql/analysis/ast" require "graphql/analysis/max_query_complexity" require "graphql/analysis/max_query_depth" require "graphql/analysis/query_complexity" diff --git a/lib/graphql/analysis/ast.rb b/lib/graphql/analysis/ast.rb new file mode 100644 index 0000000000..7b049d2403 --- /dev/null +++ b/lib/graphql/analysis/ast.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true +require "graphql/analysis/ast/visitor" +require "graphql/analysis/ast/analyzer" +require "graphql/analysis/ast/field_usage" +require "graphql/analysis/ast/query_complexity" +require "graphql/analysis/ast/max_query_complexity" +require "graphql/analysis/ast/query_depth" +require "graphql/analysis/ast/max_query_depth" + +# frozen_string_literal: true +module GraphQL + module Analysis + module AST + module_function + + def use(schema_defn) + schema = schema_defn.target + schema.analysis_engine = GraphQL::Analysis::AST + end + + # Analyze a multiplex, and all queries within. + # Multiplex analyzers are ran for all queries, keeping state. + # Query analyzers are ran per query, without carrying state between queries. + # + # @param multiplex [GraphQL::Execution::Multiplex] + # @param analyzers [Array] + # @return [void] + def analyze_multiplex(multiplex, analyzers) + multiplex_analyzers = analyzers.map { |analyzer| analyzer.new(multiplex) } + + multiplex.trace("analyze_multiplex", { multiplex: multiplex }) do + query_results = multiplex.queries.map do |query| + if query.valid? + analyze_query( + query, + query.analyzers, + multiplex_analyzers: multiplex_analyzers + ) + else + [] + end + end + + multiplex_results = multiplex_analyzers.map(&:result) + multiplex_errors = analysis_errors(multiplex_results) + + multiplex.queries.each_with_index do |query, idx| + query.analysis_errors = multiplex_errors + analysis_errors(query_results[idx]) + end + end + nil + end + + # @param query [GraphQL::Query] + # @param analyzers [Array] + # @return [Array] Results from those analyzers + def analyze_query(query, analyzers, multiplex_analyzers: []) + query.trace("analyze_query", { query: query }) do + query_analyzers = analyzers + .map { |analyzer| analyzer.new(query) } + .select { |analyzer| analyzer.analyze? } + + analyzers_to_run = query_analyzers + multiplex_analyzers + return [] unless analyzers_to_run.any? + + visitor = GraphQL::Analysis::AST::Visitor.new( + query: query, + analyzers: analyzers_to_run + ) + + visitor.visit + + query_analyzers.map(&:result) + end + end + + def analysis_errors(results) + results.flatten.select { |r| r.is_a?(GraphQL::AnalysisError) } + end + end + end +end diff --git a/lib/graphql/analysis/ast/analyzer.rb b/lib/graphql/analysis/ast/analyzer.rb new file mode 100644 index 0000000000..3b22c28f79 --- /dev/null +++ b/lib/graphql/analysis/ast/analyzer.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true +module GraphQL + module Analysis + module AST + # Query analyzer for query ASTs. Query analyzers respond to visitor style methods + # but are prefixed by `enter` and `leave`. + # + # @param [GraphQL::Query] The query to analyze + class Analyzer + def initialize(query) + @query = query + end + + # Analyzer hook to decide at analysis time whether a query should + # be analyzed or not. + # @return [Boolean] If the query should be analyzed or not + def analyze? + true + end + + # The result for this analyzer. Returning {GraphQL::AnalysisError} results + # in a query error. + # @return [Any] The analyzer result + def result + raise NotImplementedError + end + + # Don't use make_visit_method becuase it breaks `super` + def self.build_visitor_hooks(member_name) + class_eval(<<-EOS, __FILE__, __LINE__ + 1) + def on_enter_#{member_name}(node, parent, visitor) + end + + def on_leave_#{member_name}(node, parent, visitor) + end + EOS + end + + build_visitor_hooks :argument + build_visitor_hooks :directive + build_visitor_hooks :document + build_visitor_hooks :enum + build_visitor_hooks :field + build_visitor_hooks :fragment_spread + build_visitor_hooks :inline_fragment + build_visitor_hooks :input_object + build_visitor_hooks :list_type + build_visitor_hooks :non_null_type + build_visitor_hooks :null_value + build_visitor_hooks :operation_definition + build_visitor_hooks :type_name + build_visitor_hooks :variable_definition + build_visitor_hooks :variable_identifier + build_visitor_hooks :abstract_node + + protected + + attr_reader :query + end + end + end +end diff --git a/lib/graphql/analysis/ast/field_usage.rb b/lib/graphql/analysis/ast/field_usage.rb new file mode 100644 index 0000000000..ba6ff02c4c --- /dev/null +++ b/lib/graphql/analysis/ast/field_usage.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +module GraphQL + module Analysis + module AST + class FieldUsage < Analyzer + def initialize(query) + super + @used_fields = Set.new + @used_deprecated_fields = Set.new + end + + def on_leave_field(parent, node, visitor) + field_defn = visitor.field_definition + field = "#{visitor.parent_type_definition.name}.#{field_defn.name}" + @used_fields << field + @used_deprecated_fields << field if field_defn.deprecation_reason + end + + def result + { + used_fields: @used_fields.to_a, + used_deprecated_fields: @used_deprecated_fields.to_a + } + end + end + end + end +end diff --git a/lib/graphql/analysis/ast/max_query_complexity.rb b/lib/graphql/analysis/ast/max_query_complexity.rb new file mode 100644 index 0000000000..842dd1caa5 --- /dev/null +++ b/lib/graphql/analysis/ast/max_query_complexity.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +require_relative "./query_complexity" +module GraphQL + module Analysis + module AST + # Used under the hood to implement complexity validation, + # see {Schema#max_complexity} and {Query#max_complexity} + class MaxQueryComplexity < QueryComplexity + def result + return if query.max_complexity.nil? + + total_complexity = super + + if total_complexity > query.max_complexity + GraphQL::AnalysisError.new("Query has complexity of #{total_complexity}, which exceeds max complexity of #{query.max_complexity}") + else + nil + end + end + end + end + end +end diff --git a/lib/graphql/analysis/ast/max_query_depth.rb b/lib/graphql/analysis/ast/max_query_depth.rb new file mode 100644 index 0000000000..b997b8141a --- /dev/null +++ b/lib/graphql/analysis/ast/max_query_depth.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +module GraphQL + module Analysis + module AST + class MaxQueryDepth < QueryDepth + def result + return unless query.max_depth + + if @max_depth > query.max_depth + GraphQL::AnalysisError.new("Query has depth of #{@max_depth}, which exceeds max depth of #{query.max_depth}") + else + nil + end + end + end + end + end +end diff --git a/lib/graphql/analysis/ast/query_complexity.rb b/lib/graphql/analysis/ast/query_complexity.rb new file mode 100644 index 0000000000..4370a84593 --- /dev/null +++ b/lib/graphql/analysis/ast/query_complexity.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true +module GraphQL + module Analysis + # Calculate the complexity of a query, using {Field#complexity} values. + module AST + class QueryComplexity < Analyzer + # State for the query complexity calcuation: + # - `complexities_on_type` holds complexity scores for each type in an IRep node + def initialize(query) + super + @complexities_on_type = [TypeComplexity.new] + end + + # Overide this method to use the complexity result + def result + raise NotImplementedError + end + + def on_enter_field(node, parent, visitor) + # We don't want to visit fragment definitions, + # we'll visit them when we hit the spreads instead + return if visitor.visiting_fragment_definition? + return if visitor.skipping? + + @complexities_on_type.push(TypeComplexity.new) + end + + def on_leave_field(node, parent, visitor) + # We don't want to visit fragment definitions, + # we'll visit them when we hit the spreads instead + return if visitor.visiting_fragment_definition? + return if visitor.skipping? + + type_complexities = @complexities_on_type.pop + child_complexity = type_complexities.max_possible_complexity + own_complexity = get_complexity(node, visitor.field_definition, child_complexity, visitor) + + parent_type = visitor.parent_type_definition + possible_types = if parent_type.kind.abstract? + query.possible_types(parent_type) + else + [parent_type] + end + + key = selection_key(visitor.response_path, visitor.query) + + possible_types.each do |type| + @complexities_on_type.last.merge(type, key, own_complexity) + end + end + + def on_enter_fragment_spread(node, _, visitor) + visitor.enter_fragment_spread_inline(node) + end + + def on_leave_fragment_spread(node, _, visitor) + visitor.leave_fragment_spread_inline(node) + end + + def result + @complexities_on_type.last.max_possible_complexity + end + + private + + def selection_key(response_path, query) + # We add the query object id to support multiplex queries + # even if they have the same response path, they should + # always be added. + response_path.join(".") + "-#{query.object_id}" + end + + # Get a complexity value for a field, + # by getting the number or calling its proc + def get_complexity(ast_node, field_defn, child_complexity, visitor) + # Return if we've visited this response path before (not counting duplicates) + defined_complexity = field_defn.complexity + + arguments = visitor.arguments_for(ast_node, field_defn) + + case defined_complexity + when Proc + defined_complexity.call(query.context, arguments, child_complexity) + when Numeric + defined_complexity + (child_complexity || 0) + else + raise("Invalid complexity: #{defined_complexity.inspect} on #{field_defn.name}") + end + end + + # Selections on an object may apply differently depending on what is _actually_ returned by the resolve function. + # Find the maximum possible complexity among those combinations. + class TypeComplexity + def initialize + @types = Hash.new { |h, k| h[k] = {} } + end + + # Return the max possible complexity for types in this selection + def max_possible_complexity + @types.map do |type, fields| + fields.values.inject(:+) + end.max + end + + # Store the complexity for the branch on `type_defn`. + # Later we will see if this is the max complexity among branches. + def merge(type_defn, key, complexity) + @types[type_defn][key] = complexity + end + end + end + end + end +end diff --git a/lib/graphql/analysis/ast/query_depth.rb b/lib/graphql/analysis/ast/query_depth.rb new file mode 100644 index 0000000000..19fefaaa77 --- /dev/null +++ b/lib/graphql/analysis/ast/query_depth.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true +module GraphQL + module Analysis + # A query reducer for measuring the depth of a given query. + # + # @example Logging the depth of a query + # class LogQueryDepth < GraphQL::Analysis::QueryDepth + # def on_analysis_end + # log("GraphQL query depth: #{@max_depth}") + # end + # end + # + # Schema.execute(query_str) + # # GraphQL query depth: 8 + # + module AST + class QueryDepth < Analyzer + def initialize(query) + @max_depth = 0 + @current_depth = 0 + @skip_depth = 0 + super + end + + def on_enter_field(node, parent, visitor) + return if visitor.skipping? || visitor.visiting_fragment_definition? + + # Don't validate introspection fields or skipped nodes + if GraphQL::Schema::DYNAMIC_FIELDS.include?(visitor.field_definition.name) + @skip_depth += 1 + elsif @skip_depth > 0 + # we're inside an introspection query or skipped node + else + @current_depth += 1 + end + end + + def on_leave_field(node, parent, visitor) + return if visitor.skipping? || visitor.visiting_fragment_definition? + + # Don't validate introspection fields or skipped nodes + if GraphQL::Schema::DYNAMIC_FIELDS.include?(visitor.field_definition.name) + @skip_depth -= 1 + else + if @max_depth < @current_depth + @max_depth = @current_depth + end + @current_depth -= 1 + end + end + + def on_enter_fragment_spread(node, _, visitor) + visitor.enter_fragment_spread_inline(node) + end + + def on_leave_fragment_spread(node, _, visitor) + visitor.leave_fragment_spread_inline(node) + end + + def result + @max_depth + end + end + end + end +end diff --git a/lib/graphql/analysis/ast/visitor.rb b/lib/graphql/analysis/ast/visitor.rb new file mode 100644 index 0000000000..d2a809cf7f --- /dev/null +++ b/lib/graphql/analysis/ast/visitor.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true +module GraphQL + module Analysis + module AST + # Depth first traversal through a query AST, calling AST analyzers + # along the way. + # + # The visitor is a special case of GraphQL::Language::Visitor, visiting + # only the selected operation, providing helpers for common use cases such + # as skipped fields and visiting fragment spreads. + # + # @see {GraphQL::Analysis::AST::Analyzer} AST Analyzers for queries + class Visitor < GraphQL::Language::Visitor + def initialize(query:, analyzers:) + @analyzers = analyzers + @path = [] + @object_types = [] + @directives = [] + @field_definitions = [] + @argument_definitions = [] + @directive_definitions = [] + @query = query + @schema = query.schema + @response_path = [] + @skip_stack = [false] + super(query.selected_operation) + end + + # @return [GraphQL::Query] the query being visited + attr_reader :query + + # @return [Array] Types whose scope we've entered + attr_reader :object_types + + # Visit Helpers + + # @return [GraphQL::Query::Arguments] Arguments for this node, merging default values, literal values and query variables + # @see {GraphQL::Query#arguments_for} + def arguments_for(ast_node, field_definition) + @query.arguments_for(ast_node, field_definition) + end + + # @return [Boolean] If the visitor is currently inside a fragment definition + def visiting_fragment_definition? + @in_fragment_def + end + + # @return [Boolean] If the current node should be skipped because of a skip or include directive + def skipping? + @skipping + end + + # @return [Array] The path to the response key for the current field + def response_path + @response_path.dup + end + + # Visitor Hooks + + def on_operation_definition(node, parent) + object_type = @schema.root_type_for_operation(node.operation_type) + @object_types.push(object_type) + @path.push("#{node.operation_type}#{node.name ? " #{node.name}" : ""}") + call_analyzers(:on_enter_operation_definition, node, parent) + super + call_analyzers(:on_leave_operation_definition, node, parent) + @object_types.pop + @path.pop + end + + def on_fragment_definition(node, parent) + on_fragment_with_type(node) do + @path.push("fragment #{node.name}") + @in_fragment_def = false + call_analyzers(:on_enter_fragment_definition, node, parent) + super + @in_fragment_def = false + call_analyzers(:on_leave_fragment_definition, node, parent) + end + end + + def on_inline_fragment(node, parent) + on_fragment_with_type(node) do + @path.push("...#{node.type ? " on #{node.type.name}" : ""}") + call_analyzers(:on_enter_inline_fragment, node, parent) + super + call_analyzers(:on_leave_inline_fragment, node, parent) + end + end + + def on_field(node, parent) + @response_path.push(node.alias || node.name) + parent_type = @object_types.last + field_definition = @schema.get_field(parent_type, node.name) + @field_definitions.push(field_definition) + if !field_definition.nil? + next_object_type = field_definition.type.unwrap + @object_types.push(next_object_type) + else + @object_types.push(nil) + end + @path.push(node.alias || node.name) + + @skipping = @skip_stack.last || skip?(node) + @skip_stack << @skipping + + call_analyzers(:on_enter_field, node, parent) + super + + @skipping = @skip_stack.pop + + call_analyzers(:on_leave_field, node, parent) + @response_path.pop + @field_definitions.pop + @object_types.pop + @path.pop + end + + def on_directive(node, parent) + directive_defn = @schema.directives[node.name] + @directive_definitions.push(directive_defn) + call_analyzers(:on_enter_directive, node, parent) + super + call_analyzers(:on_leave_directive, node, parent) + @directive_definitions.pop + end + + def on_argument(node, parent) + argument_defn = if (arg = @argument_definitions.last) + arg_type = arg.type.unwrap + if arg_type.kind.input_object? + arg_type.input_fields[node.name] + else + nil + end + elsif (directive_defn = @directive_definitions.last) + directive_defn.arguments[node.name] + elsif (field_defn = @field_definitions.last) + field_defn.arguments[node.name] + else + nil + end + + @argument_definitions.push(argument_defn) + @path.push(node.name) + call_analyzers(:on_enter_argument, node, parent) + super + call_analyzers(:on_leave_argument, node, parent) + @argument_definitions.pop + @path.pop + end + + def on_fragment_spread(node, parent) + @path.push("... #{node.name}") + call_analyzers(:on_enter_fragment_spread, node, parent) + super + call_analyzers(:on_leave_fragment_spread, node, parent) + @path.pop + end + + def on_abstract_node(node, parent) + call_analyzers(:on_enter_abstract_node, node, parent) + super + call_analyzers(:on_leave_abstract_node, node, parent) + end + + # Visit a fragment spread inline instead of visiting the definition + # by itself. + def enter_fragment_spread_inline(fragment_spread) + fragment_def = query.fragments[fragment_spread.name] + + object_type = if fragment_def.type + query.schema.types.fetch(fragment_def.type.name, nil) + else + object_types.last + end + + object_types << object_type + + fragment_def.selections.each do |selection| + visit_node(selection, fragment_def) + end + end + + # Visit a fragment spread inline instead of visiting the definition + # by itself. + def leave_fragment_spread_inline(_fragment_spread) + object_types.pop + end + + # @return [GraphQL::BaseType] The current object type + def type_definition + @object_types.last + end + + # @return [GraphQL::BaseType] The type which the current type came from + def parent_type_definition + @object_types[-2] + end + + # @return [GraphQL::Field, nil] The most-recently-entered GraphQL::Field, if currently inside one + def field_definition + @field_definitions.last + end + + # @return [GraphQL::Field, nil] The GraphQL field which returned the object that the current field belongs to + def previous_field_definition + @field_definitions[-2] + end + + # @return [GraphQL::Directive, nil] The most-recently-entered GraphQL::Directive, if currently inside one + def directive_definition + @directive_definitions.last + end + + # @return [GraphQL::Argument, nil] The most-recently-entered GraphQL::Argument, if currently inside one + def argument_definition + # Don't get the _last_ one because that's the current one. + # Get the second-to-last one, which is the parent of the current one. + @argument_definitions[-2] + end + + private + + def skip?(ast_node) + dir = ast_node.directives + dir.any? && !GraphQL::Execution::DirectiveChecks.include?(dir, query) + end + + def call_analyzers(method, node, parent) + @analyzers.each do |analyzer| + analyzer.public_send(method, node, parent, self) + end + end + + def on_fragment_with_type(node) + object_type = if node.type + @schema.types.fetch(node.type.name, nil) + else + @object_types.last + end + @object_types.push(object_type) + yield(node) + @object_types.pop + @path.pop + end + end + end + end +end diff --git a/lib/graphql/argument.rb b/lib/graphql/argument.rb index 670a4770ab..aaf193c9ee 100644 --- a/lib/graphql/argument.rb +++ b/lib/graphql/argument.rb @@ -89,6 +89,11 @@ def expose_as @expose_as ||= (@as || @name).to_s end + # Backport this to support legacy-style directives + def keyword + @keyword ||= GraphQL::Schema::Member::BuildType.underscore(expose_as).to_sym + end + # @param value [Object] The incoming value from variables or query string literal # @param ctx [GraphQL::Query::Context] # @return [Object] The prepared `value` for this argument or `value` itself if no `prepare` function exists. diff --git a/lib/graphql/authorization.rb b/lib/graphql/authorization.rb index 7d10a97959..3a408a1e24 100644 --- a/lib/graphql/authorization.rb +++ b/lib/graphql/authorization.rb @@ -20,6 +20,7 @@ def initialize(fields:, irep_nodes:, context:) end end + # @deprecated authorization at query runtime is generally a better idea. module Analyzer module_function def initial_value(query) diff --git a/lib/graphql/compatibility/schema_parser_specification.rb b/lib/graphql/compatibility/schema_parser_specification.rb index bfe86fd8af..5d1c754e67 100644 --- a/lib/graphql/compatibility/schema_parser_specification.rb +++ b/lib/graphql/compatibility/schema_parser_specification.rb @@ -595,31 +595,27 @@ def test_it_parses_whole_definition_with_descriptions assert_equal 6, document.definitions.size - schema_definition = document.definitions.shift + schema_definition, directive_definition, enum_type_definition, object_type_definition, input_object_type_definition, interface_type_definition = document.definitions + assert_equal GraphQL::Language::Nodes::SchemaDefinition, schema_definition.class - directive_definition = document.definitions.shift assert_equal GraphQL::Language::Nodes::DirectiveDefinition, directive_definition.class assert_equal 'This is a directive', directive_definition.description - enum_type_definition = document.definitions.shift assert_equal GraphQL::Language::Nodes::EnumTypeDefinition, enum_type_definition.class assert_equal "Multiline comment\n\nWith an enum", enum_type_definition.description assert_nil enum_type_definition.values[0].description assert_equal 'Not a creative color', enum_type_definition.values[1].description - object_type_definition = document.definitions.shift assert_equal GraphQL::Language::Nodes::ObjectTypeDefinition, object_type_definition.class assert_equal 'Comment without preceding space', object_type_definition.description assert_equal 'And a field to boot', object_type_definition.fields[0].description - input_object_type_definition = document.definitions.shift assert_equal GraphQL::Language::Nodes::InputObjectTypeDefinition, input_object_type_definition.class assert_equal 'Comment for input object types', input_object_type_definition.description assert_equal 'Color of the car', input_object_type_definition.fields[0].description - interface_type_definition = document.definitions.shift assert_equal GraphQL::Language::Nodes::InterfaceTypeDefinition, interface_type_definition.class assert_equal 'Comment for interface definitions', interface_type_definition.description assert_equal 'Amount of wheels', interface_type_definition.fields[0].description diff --git a/lib/graphql/execution.rb b/lib/graphql/execution.rb index 9f25d6f71b..711f18e8a6 100644 --- a/lib/graphql/execution.rb +++ b/lib/graphql/execution.rb @@ -3,6 +3,7 @@ require "graphql/execution/execute" require "graphql/execution/flatten" require "graphql/execution/instrumentation" +require "graphql/execution/interpreter" require "graphql/execution/lazy" require "graphql/execution/lookahead" require "graphql/execution/multiplex" diff --git a/lib/graphql/execution/execute.rb b/lib/graphql/execution/execute.rb index 47881b2bb8..46c9de83e6 100644 --- a/lib/graphql/execution/execute.rb +++ b/lib/graphql/execution/execute.rb @@ -24,6 +24,23 @@ def execute(ast_operation, root_type, query) GraphQL::Execution::Flatten.call(query.context) end + def self.begin_multiplex(_multiplex) + end + + def self.begin_query(query, _multiplex) + ExecutionFunctions.resolve_root_selection(query) + end + + def self.finish_multiplex(results, multiplex) + ExecutionFunctions.lazy_resolve_root_selection(results, multiplex: multiplex) + end + + def self.finish_query(query, _multiplex) + { + "data" => Execution::Flatten.call(query.context) + } + end + # @api private module ExecutionFunctions module_function @@ -179,7 +196,7 @@ def continue_resolve_field(raw_value, field_type, field_ctx) if list_errors.any? list_errors.each do |error, index| error.ast_node = field_ctx.ast_node - error.path = field_ctx.path + [index] + error.path = field_ctx.path + (field_ctx.type.list? ? [index] : []) query.context.errors.push(error) end end diff --git a/lib/graphql/execution/interpreter.rb b/lib/graphql/execution/interpreter.rb new file mode 100644 index 0000000000..e612cdb006 --- /dev/null +++ b/lib/graphql/execution/interpreter.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true +require "graphql/execution/interpreter/execution_errors" +require "graphql/execution/interpreter/hash_response" +require "graphql/execution/interpreter/runtime" + +module GraphQL + module Execution + class Interpreter + def initialize + end + + # Support `Executor` :S + def execute(_operation, _root_type, query) + runtime = evaluate(query) + sync_lazies(query: query) + runtime.final_value + end + + def self.use(schema_defn) + schema_defn.target.interpreter = true + # Reach through the legacy objects for the actual class defn + schema_class = schema_defn.target.class + # This is not good, since both of these are holding state now, + # we have to update both :( + [schema_class, schema_defn].each do |schema_config| + schema_config.query_execution_strategy(GraphQL::Execution::Interpreter) + schema_config.mutation_execution_strategy(GraphQL::Execution::Interpreter) + schema_config.subscription_execution_strategy(GraphQL::Execution::Interpreter) + end + end + + def self.begin_multiplex(multiplex) + # Since this is basically the batching context, + # share it for a whole multiplex + multiplex.context[:interpreter_instance] ||= self.new + end + + def self.begin_query(query, multiplex) + # The batching context is shared by the multiplex, + # so fetch it out and use that instance. + interpreter = + query.context.namespace(:interpreter)[:interpreter_instance] = + multiplex.context[:interpreter_instance] + interpreter.evaluate(query) + query + end + + def self.finish_multiplex(_results, multiplex) + interpreter = multiplex.context[:interpreter_instance] + interpreter.sync_lazies(multiplex: multiplex) + end + + def self.finish_query(query, _multiplex) + { + "data" => query.context.namespace(:interpreter)[:runtime].final_value + } + end + + # Run the eager part of `query` + # @return {Interpreter::Runtime} + def evaluate(query) + # Although queries in a multiplex _share_ an Interpreter instance, + # they also have another item of state, which is private to that query + # in particular, assign it here: + runtime = Runtime.new( + query: query, + response: HashResponse.new, + ) + query.context.namespace(:interpreter)[:runtime] = runtime + + query.trace("execute_query", {query: query}) do + runtime.run_eager + end + + runtime + end + + # Run the lazy part of `query` or `multiplex`. + # @return [void] + def sync_lazies(query: nil, multiplex: nil) + tracer = query || multiplex + if query.nil? && multiplex.queries.length == 1 + query = multiplex.queries[0] + end + queries = multiplex ? multiplex.queries : [query] + final_values = queries.map do |query| + runtime = query.context.namespace(:interpreter)[:runtime] + # it might not be present if the query has an error + runtime ? runtime.final_value : nil + end + final_values.compact! + tracer.trace("execute_query_lazy", {multiplex: multiplex, query: query}) do + while final_values.any? + final_values = resolve_interpreter_result(final_values) + end + end + end + + private + + # `results_level` is one level of _depth_ of a query or multiplex. + # + # Resolve all lazy values in that depth before moving on + # to the next level. + # + # It's assumed that the lazies will perform side-effects + # and return {Lazy} instances if there's more work to be done, + # or return {Hash}/{Array} if the query should be continued. + # + # @param result [Array, Hash, Object] + # @return void + def resolve_interpreter_result(results_level) + next_level = [] + + # Work through the queue until it's empty + while results_level.size > 0 + result_value = results_level.shift + + if result_value.is_a?(Lazy) + result_value = result_value.value + end + + if result_value.is_a?(Lazy) + # Since this field returned another lazy, + # add it to the same queue + results_level << result_value + elsif result_value.is_a?(Hash) + # This is part of the next level, add it + next_level.concat(result_value.values) + elsif result_value.is_a?(Array) + # This is part of the next level, add it + next_level.concat(result_value) + end + end + + next_level + end + end + end +end diff --git a/lib/graphql/execution/interpreter/execution_errors.rb b/lib/graphql/execution/interpreter/execution_errors.rb new file mode 100644 index 0000000000..e85041f8bc --- /dev/null +++ b/lib/graphql/execution/interpreter/execution_errors.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module GraphQL + module Execution + class Interpreter + class ExecutionErrors + def initialize(ctx, ast_node, path) + @context = ctx + @ast_node = ast_node + @path = path + end + + def add(err_or_msg) + err = case err_or_msg + when String + GraphQL::ExecutionError.new(err_or_msg) + when GraphQL::ExecutionError + err_or_msg + else + raise ArgumentError, "expected String or GraphQL::ExecutionError, not #{err_or_msg.class} (#{err_or_msg.inspect})" + end + err.ast_node ||= @ast_node + err.path ||= @path + @context.add_error(err) + end + end + end + end +end diff --git a/lib/graphql/execution/interpreter/hash_response.rb b/lib/graphql/execution/interpreter/hash_response.rb new file mode 100644 index 0000000000..487338c308 --- /dev/null +++ b/lib/graphql/execution/interpreter/hash_response.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module GraphQL + module Execution + class Interpreter + # This response class handles `#write` by accumulating + # values into a Hash. + class HashResponse + def initialize + @result = {} + end + + def final_value + @result + end + + def inspect + "#<#{self.class.name} result=#{@result.inspect}>" + end + + # Add `value` at `path`. + # @return [void] + def write(path, value) + if path.size == 0 # faster than #none? + @result = value + elsif (write_target = @result) + i = 0 + prefinal_steps = path.size - 1 + # Use `while` to avoid a closure + while i < prefinal_steps + path_part = path[i] + i += 1 + write_target = write_target[path_part] + end + path_part = path[i] + write_target[path_part] = value + else + # The response is completely nulled out + end + + nil + end + end + end + end +end diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb new file mode 100644 index 0000000000..bf47605a46 --- /dev/null +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -0,0 +1,565 @@ +# frozen_string_literal: true + +module GraphQL + module Execution + class Interpreter + # I think it would be even better if we could somehow make + # `continue_field` not recursive. "Trampolining" it somehow. + class Runtime + # @return [GraphQL::Query] + attr_reader :query + + # @return [Class] + attr_reader :schema + + # @return [GraphQL::Query::Context] + attr_reader :context + + def initialize(query:, response:) + @query = query + @schema = query.schema + @context = query.context + @interpreter_context = @context.namespace(:interpreter) + @response = response + @dead_paths = {} + @types_at_paths = {} + # A cache of { Class => { String => Schema::Field } } + # Which assumes that MyObject.get_field("myField") will return the same field + # during the lifetime of a query + @fields_cache = Hash.new { |h, k| h[k] = {} } + end + + def final_value + @response.final_value + end + + def inspect + "#<#{self.class.name} response=#{@response.inspect}>" + end + + # This _begins_ the execution. Some deferred work + # might be stored up in lazies. + # @return [void] + def run_eager + root_operation = query.selected_operation + root_op_type = root_operation.operation_type || "query" + legacy_root_type = schema.root_type_for_operation(root_op_type) + root_type = legacy_root_type.metadata[:type_class] || raise("Invariant: type must be class-based: #{legacy_root_type}") + object_proxy = root_type.authorized_new(query.root_value, context) + object_proxy = schema.sync_lazy(object_proxy) + path = [] + evaluate_selections(path, object_proxy, root_type, root_operation.selections, root_operation_type: root_op_type) + nil + end + + private + + def gather_selections(owner_type, selections, selections_by_name) + selections.each do |node| + case node + when GraphQL::Language::Nodes::Field + if passes_skip_and_include?(node) + response_key = node.alias || node.name + selections = selections_by_name[response_key] + # if there was already a selection of this field, + # use an array to hold all selections, + # otherise, use the single node to represent the selection + if selections + # This field was already selected at least once, + # add this node to the list of selections + s = Array(selections) + s << node + selections_by_name[response_key] = s + else + # No selection was found for this field yet + selections_by_name[response_key] = node + end + end + when GraphQL::Language::Nodes::InlineFragment + if passes_skip_and_include?(node) + if node.type + type_defn = schema.types[node.type.name] + type_defn = type_defn.metadata[:type_class] + # Faster than .map{}.include?() + query.warden.possible_types(type_defn).each do |t| + if t.metadata[:type_class] == owner_type + gather_selections(owner_type, node.selections, selections_by_name) + break + end + end + else + # it's an untyped fragment, definitely continue + gather_selections(owner_type, node.selections, selections_by_name) + end + end + when GraphQL::Language::Nodes::FragmentSpread + if passes_skip_and_include?(node) + fragment_def = query.fragments[node.name] + type_defn = schema.types[fragment_def.type.name] + type_defn = type_defn.metadata[:type_class] + schema.possible_types(type_defn).each do |t| + if t.metadata[:type_class] == owner_type + gather_selections(owner_type, fragment_def.selections, selections_by_name) + break + end + end + end + else + raise "Invariant: unexpected selection class: #{node.class}" + end + end + end + + def evaluate_selections(path, owner_object, owner_type, selections, root_operation_type: nil) + selections_by_name = {} + gather_selections(owner_type, selections, selections_by_name) + selections_by_name.each do |result_name, field_ast_nodes_or_ast_node| + # As a performance optimization, the hash key will be a `Node` if + # there's only one selection of the field. But if there are multiple + # selections of the field, it will be an Array of nodes + if field_ast_nodes_or_ast_node.is_a?(Array) + field_ast_nodes = field_ast_nodes_or_ast_node + ast_node = field_ast_nodes.first + else + field_ast_nodes = nil + ast_node = field_ast_nodes_or_ast_node + end + field_name = ast_node.name + field_defn = @fields_cache[owner_type][field_name] ||= owner_type.get_field(field_name) + is_introspection = false + if field_defn.nil? + field_defn = if owner_type == schema.query.metadata[:type_class] && (entry_point_field = schema.introspection_system.entry_point(name: field_name)) + is_introspection = true + entry_point_field.metadata[:type_class] + elsif (dynamic_field = schema.introspection_system.dynamic_field(name: field_name)) + is_introspection = true + dynamic_field.metadata[:type_class] + else + raise "Invariant: no field for #{owner_type}.#{field_name}" + end + end + + return_type = resolve_if_late_bound_type(field_defn.type) + + next_path = path.dup + next_path << result_name + next_path.freeze + + # This seems janky, but we need to know + # the field's return type at this path in order + # to propagate `null` + set_type_at_path(next_path, return_type) + + object = owner_object + + if is_introspection + object = field_defn.owner.authorized_new(object, context) + end + + kwarg_arguments = arguments(object, field_defn, ast_node) + # It might turn out that making arguments for every field is slow. + # If we have to cache them, we'll need a more subtle approach here. + field_defn.extras.each do |extra| + case extra + when :ast_node + kwarg_arguments[:ast_node] = ast_node + when :execution_errors + kwarg_arguments[:execution_errors] = ExecutionErrors.new(context, ast_node, next_path) + when :path + kwarg_arguments[:path] = next_path + when :lookahead + if !field_ast_nodes + field_ast_nodes = [ast_node] + end + kwarg_arguments[:lookahead] = Execution::Lookahead.new( + query: query, + ast_nodes: field_ast_nodes, + field: field_defn, + ) + else + kwarg_arguments[extra] = field_defn.fetch_extra(extra, context) + end + end + + # Optimize for the case that field is selected only once + if field_ast_nodes.nil? || field_ast_nodes.size == 1 + next_selections = ast_node.selections + else + next_selections = [] + field_ast_nodes.each { |f| next_selections.concat(f.selections) } + end + + app_result = query.trace("execute_field", {field: field_defn, path: next_path}) do + @interpreter_context[:current_path] = next_path + @interpreter_context[:current_field] = field_defn + field_defn.resolve(object, kwarg_arguments, context) + end + + after_lazy(app_result, field: field_defn, path: next_path, eager: root_operation_type == "mutation") do |inner_result| + continue_value = continue_value(next_path, inner_result, field_defn, return_type.non_null?, ast_node) + if HALT != continue_value + continue_field(next_path, continue_value, field_defn, return_type, ast_node, next_selections, false) + end + end + end + end + + HALT = Object.new + def continue_value(path, value, field, is_non_null, ast_node) + if value.nil? + if is_non_null + err = GraphQL::InvalidNullError.new(field.owner, field, value) + write_invalid_null_in_response(path, err) + else + write_in_response(path, nil) + end + HALT + elsif value.is_a?(GraphQL::ExecutionError) + value.path ||= path + value.ast_node ||= ast_node + write_execution_errors_in_response(path, [value]) + HALT + elsif value.is_a?(Array) && value.any? && value.all? { |v| v.is_a?(GraphQL::ExecutionError) } + value.each do |v| + v.path ||= path + v.ast_node ||= ast_node + end + write_execution_errors_in_response(path, value) + HALT + elsif value.is_a?(GraphQL::UnauthorizedError) + # this hook might raise & crash, or it might return + # a replacement value + next_value = begin + schema.unauthorized_object(value) + rescue GraphQL::ExecutionError => err + err + end + + continue_value(path, next_value, field, is_non_null, ast_node) + elsif GraphQL::Execution::Execute::SKIP == value + HALT + else + value + end + end + + # The resolver for `field` returned `value`. Continue to execute the query, + # treating `value` as `type` (probably the return type of the field). + # + # Use `next_selections` to resolve object fields, if there are any. + # + # Location information from `path` and `ast_node`. + # + # @return [Lazy, Array, Hash, Object] Lazy, Array, and Hash are all traversed to resolve lazy values later + def continue_field(path, value, field, type, ast_node, next_selections, is_non_null) + case type.kind.name + when "SCALAR", "ENUM" + r = type.coerce_result(value, context) + write_in_response(path, r) + r + when "UNION", "INTERFACE" + resolved_type = query.resolve_type(type, value) + possible_types = query.possible_types(type) + + if !possible_types.include?(resolved_type) + parent_type = field.owner + type_error = GraphQL::UnresolvedTypeError.new(value, field, parent_type, resolved_type, possible_types) + schema.type_error(type_error, context) + write_in_response(path, nil) + nil + else + resolved_type = resolved_type.metadata[:type_class] + continue_field(path, value, field, resolved_type, ast_node, next_selections, is_non_null) + end + when "OBJECT" + object_proxy = begin + type.authorized_new(value, context) + rescue GraphQL::ExecutionError => err + err + end + after_lazy(object_proxy, path: path, field: field) do |inner_object| + continue_value = continue_value(path, inner_object, field, is_non_null, ast_node) + if HALT != continue_value + response_hash = {} + write_in_response(path, response_hash) + evaluate_selections(path, continue_value, type, next_selections) + response_hash + end + end + when "LIST" + response_list = [] + write_in_response(path, response_list) + inner_type = type.of_type + idx = 0 + value.each do |inner_value| + next_path = path.dup + next_path << idx + next_path.freeze + idx += 1 + set_type_at_path(next_path, inner_type) + # This will update `response_list` with the lazy + after_lazy(inner_value, path: next_path, field: field) do |inner_inner_value| + # reset `is_non_null` here and below, because the inner type will have its own nullability constraint + continue_value = continue_value(next_path, inner_inner_value, field, false, ast_node) + if HALT != continue_value + continue_field(next_path, continue_value, field, inner_type, ast_node, next_selections, false) + end + end + end + response_list + when "NON_NULL" + inner_type = type.of_type + # For fields like `__schema: __Schema!` + inner_type = resolve_if_late_bound_type(inner_type) + # Don't `set_type_at_path` because we want the static type, + # we're going to use that to determine whether a `nil` should be propagated or not. + continue_field(path, value, field, inner_type, ast_node, next_selections, true) + else + raise "Invariant: Unhandled type kind #{type.kind} (#{type})" + end + end + + def passes_skip_and_include?(node) + # Eventually this should actually call out to the directives + # instead of having magical hard-coded behavior. + node.directives.each do |dir| + dir_defn = schema.directives.fetch(dir.name) + if dir.name == "skip" && arguments(nil, dir_defn, dir)[:if] == true + return false + elsif dir.name == "include" && arguments(nil, dir_defn, dir)[:if] == false + return false + end + end + true + end + + def resolve_if_late_bound_type(type) + if type.is_a?(GraphQL::Schema::LateBoundType) + query.warden.get_type(type.name).metadata[:type_class] + else + type + end + end + + # @param obj [Object] Some user-returned value that may want to be batched + # @param path [Array] + # @param field [GraphQL::Schema::Field] + # @param eager [Boolean] Set to `true` for mutation root fields only + # @return [GraphQL::Execution::Lazy, Object] If loading `object` will be deferred, it's a wrapper over it. + def after_lazy(obj, field:, path:, eager: false) + @interpreter_context[:current_path] = path + @interpreter_context[:current_field] = field + if schema.lazy?(obj) + lazy = GraphQL::Execution::Lazy.new(path: path, field: field) do + @interpreter_context[:current_path] = path + @interpreter_context[:current_field] = field + # Wrap the execution of _this_ method with tracing, + # but don't wrap the continuation below + inner_obj = query.trace("execute_field_lazy", {field: field, path: path}) do + begin + schema.sync_lazy(obj) + rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => err + yield(err) + end + end + after_lazy(inner_obj, field: field, path: path, eager: eager) do |really_inner_obj| + yield(really_inner_obj) + end + end + + if eager + lazy.value + else + write_in_response(path, lazy) + lazy + end + else + yield(obj) + end + end + + def each_argument_pair(ast_args_or_hash) + case ast_args_or_hash + when GraphQL::Language::Nodes::Field, GraphQL::Language::Nodes::InputObject, GraphQL::Language::Nodes::Directive + ast_args_or_hash.arguments.each do |arg| + yield(arg.name, arg.value) + end + when Hash + ast_args_or_hash.each do |key, value| + normalized_name = GraphQL::Schema::Member::BuildType.camelize(key.to_s) + yield(normalized_name, value) + end + else + raise "Invariant, unexpected #{ast_args_or_hash.inspect}" + end + end + + def arguments(graphql_object, arg_owner, ast_node_or_hash) + kwarg_arguments = {} + arg_defns = arg_owner.arguments + each_argument_pair(ast_node_or_hash) do |arg_name, arg_value| + arg_defn = arg_defns[arg_name] + # Need to distinguish between client-provided `nil` + # and nothing-at-all + is_present, value = arg_to_value(graphql_object, arg_defn.type, arg_value) + if is_present + # This doesn't apply to directives, which are legacy + # Can remove this when Skip and Include use classes or something. + if graphql_object + value = arg_defn.prepare_value(graphql_object, value) + end + kwarg_arguments[arg_defn.keyword] = value + end + end + arg_defns.each do |name, arg_defn| + if arg_defn.default_value? && !kwarg_arguments.key?(arg_defn.keyword) + _is_present, value = arg_to_value(graphql_object, arg_defn.type, arg_defn.default_value) + kwarg_arguments[arg_defn.keyword] = value + end + end + kwarg_arguments + end + + # Get a Ruby-ready value from a client query. + # @param graphql_object [Object] The owner of the field whose argument this is + # @param arg_type [Class, GraphQL::Schema::NonNull, GraphQL::Schema::List] + # @param ast_value [GraphQL::Language::Nodes::VariableIdentifier, String, Integer, Float, Boolean] + # @return [Array(is_present, value)] + def arg_to_value(graphql_object, arg_type, ast_value) + if ast_value.is_a?(GraphQL::Language::Nodes::VariableIdentifier) + # If it's not here, it will get added later + if query.variables.key?(ast_value.name) + return true, query.variables[ast_value.name] + else + return false, nil + end + elsif ast_value.is_a?(GraphQL::Language::Nodes::NullValue) + return true, nil + elsif arg_type.is_a?(GraphQL::Schema::NonNull) + arg_to_value(graphql_object, arg_type.of_type, ast_value) + elsif arg_type.is_a?(GraphQL::Schema::List) + # Treat a single value like a list + arg_value = Array(ast_value) + list = [] + arg_value.map do |inner_v| + _present, value = arg_to_value(graphql_object, arg_type.of_type, inner_v) + list << value + end + return true, list + elsif arg_type.is_a?(Class) && arg_type < GraphQL::Schema::InputObject + # For these, `prepare` is applied during `#initialize`. + # Pass `nil` so it will be skipped in `#arguments`. + # What a mess. + args = arguments(nil, arg_type, ast_value) + # We're not tracking defaults_used, but for our purposes + # we compare the value to the default value. + return true, arg_type.new(ruby_kwargs: args, context: context, defaults_used: nil) + else + flat_value = flatten_ast_value(ast_value) + return true, arg_type.coerce_input(flat_value, context) + end + end + + def flatten_ast_value(v) + case v + when GraphQL::Language::Nodes::Enum + v.name + when GraphQL::Language::Nodes::InputObject + h = {} + v.arguments.each do |arg| + h[arg.name] = flatten_ast_value(arg.value) + end + h + when Array + v.map { |v2| flatten_ast_value(v2) } + when GraphQL::Language::Nodes::VariableIdentifier + flatten_ast_value(query.variables[v.name]) + else + v + end + end + + def write_invalid_null_in_response(path, invalid_null_error) + if !dead_path?(path) + schema.type_error(invalid_null_error, context) + write_in_response(path, nil) + add_dead_path(path) + end + end + + def write_execution_errors_in_response(path, errors) + if !dead_path?(path) + errors.each do |v| + context.errors << v + end + write_in_response(path, nil) + add_dead_path(path) + end + end + + def write_in_response(path, value) + if dead_path?(path) + return + else + if value.nil? && path.any? && type_at(path).non_null? + # This nil is invalid, try writing it at the previous spot + propagate_path = path[0..-2] + write_in_response(propagate_path, value) + add_dead_path(propagate_path) + else + @response.write(path, value) + end + end + end + + # To propagate nulls, we have to know what the field type was + # at previous parts of the response. + # This hash matches the response + def type_at(path) + t = @types_at_paths + path.each do |part| + t = t[part] || (raise("Invariant: #{part.inspect} not found in #{t}")) + end + t = t[:__type] + t + end + + def set_type_at_path(path, type) + types = @types_at_paths + path.each do |part| + types = types[part] ||= {} + end + # Use this magic key so that the hash contains: + # - string keys for nested fields + # - :__type for the object type of a selection + types[:__type] ||= type + nil + end + + # Mark `path` as having been permanently nulled out. + # No values will be added beyond that path. + def add_dead_path(path) + dead = @dead_paths + path.each do |part| + dead = dead[part] ||= {} + end + dead[:__dead] = true + end + + def dead_path?(path) + res = @dead_paths + path.each do |part| + if res + if res[:__dead] + break + else + res = res[part] + end + end + end + res && res[:__dead] + end + end + end + end +end diff --git a/lib/graphql/execution/lazy.rb b/lib/graphql/execution/lazy.rb index 4bb3cd08c7..2de4d10a4b 100644 --- a/lib/graphql/execution/lazy.rb +++ b/lib/graphql/execution/lazy.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "graphql/execution/lazy/lazy_method_map" require "graphql/execution/lazy/resolve" + module GraphQL module Execution # This wraps a value which is available, but not yet calculated, like a promise or future. @@ -19,11 +20,17 @@ def self.resolve(val) Resolve.resolve(val) end + attr_reader :path, :field + # Create a {Lazy} which will get its inner value by calling the block + # @param path [Array] + # @param field [GraphQL::Schema::Field] # @param get_value_func [Proc] a block to get the inner value (later) - def initialize(&get_value_func) + def initialize(path: nil, field: nil, &get_value_func) @get_value_func = get_value_func @resolved = false + @path = path + @field = field end # @return [Object] The wrapped value, calling the lazy block if necessary diff --git a/lib/graphql/execution/lookahead.rb b/lib/graphql/execution/lookahead.rb index 91f477e3d8..a2314fdcb3 100644 --- a/lib/graphql/execution/lookahead.rb +++ b/lib/graphql/execution/lookahead.rb @@ -36,12 +36,18 @@ class Lookahead # @param field [GraphQL::Schema::Field] if `ast_nodes` are fields, this is the field definition matching those nodes # @param root_type [Class] if `ast_nodes` are operation definition, this is the root type for that operation def initialize(query:, ast_nodes:, field: nil, root_type: nil) - @ast_nodes = ast_nodes + @ast_nodes = ast_nodes.freeze @field = field @root_type = root_type @query = query end + # @return [Array] + attr_reader :ast_nodes + + # @return [GraphQL::Schema::Field] + attr_reader :field + # True if this node has a selection on `field_name`. # If `field_name` is a String, it is treated as a GraphQL-style (camelized) # field name and used verbatim. If `field_name` is a Symbol, it is @@ -112,9 +118,7 @@ def selection(field_name, arguments: nil) def selections(arguments: nil) subselections_by_name = {} @ast_nodes.each do |node| - node.selections.each do |subselection| - subselections_by_name[subselection.name] ||= selection(subselection.name, arguments: arguments) - end + find_selections(subselections_by_name, node.selections, arguments) end # Items may be filtered out if `arguments` doesn't match @@ -132,9 +136,7 @@ def selections(arguments: nil) # # @return [Symbol] def name - return unless @field.respond_to?(:original_name) - - @field.original_name + @field && @field.original_name end # This is returned for {Lookahead#selection} when a non-existent field is passed @@ -182,6 +184,22 @@ def normalize_keyword(keyword) end end + def find_selections(subselections_by_name, ast_selections, arguments) + ast_selections.each do |ast_selection| + case ast_selection + when GraphQL::Language::Nodes::Field + subselections_by_name[ast_selection.name] ||= selection(ast_selection.name, arguments: arguments) + when GraphQL::Language::Nodes::InlineFragment + find_selections(subselections_by_name, ast_selection.selections, arguments) + when GraphQL::Language::Nodes::FragmentSpread + frag_defn = @query.fragments[ast_selection.name] || raise("Invariant: Can't look ahead to nonexistent fragment #{ast_selection.name} (found: #{@query.fragments.keys})") + find_selections(subselections_by_name, frag_defn.selections, arguments) + else + raise "Invariant: Unexpected selection type: #{ast_selection.class}" + end + end + end + # If a selection on `node` matches `field_name` (which is backed by `field_defn`) # and matches the `arguments:` constraints, then add that node to `matches` def find_selected_nodes(node, field_name, field_defn, arguments:, matches:) @@ -204,10 +222,10 @@ def find_selected_nodes(node, field_name, field_defn, arguments:, matches:) end end when GraphQL::Language::Nodes::InlineFragment - node.selections.find { |s| find_selected_nodes(s, field_name, field_defn, arguments: arguments, matches: matches) } + node.selections.each { |s| find_selected_nodes(s, field_name, field_defn, arguments: arguments, matches: matches) } when GraphQL::Language::Nodes::FragmentSpread - frag_defn = @query.fragments[node.name] - frag_defn.selections.find { |s| find_selected_nodes(s, field_name, field_defn, arguments: arguments, matches: matches) } + frag_defn = @query.fragments[node.name] || raise("Invariant: Can't look ahead to nonexistent fragment #{node.name} (found: #{@query.fragments.keys})") + frag_defn.selections.each { |s| find_selected_nodes(s, field_name, field_defn, arguments: arguments, matches: matches) } else raise "Unexpected selection comparison on #{node.class.name} (#{node})" end @@ -306,14 +324,14 @@ def flatten_ast_value(query, v) module FieldHelpers module_function - def get_field(schema, owner_type, field_name ) + def get_field(schema, owner_type, field_name) field_defn = owner_type.get_field(field_name) field_defn ||= if owner_type == schema.query.metadata[:type_class] && (entry_point_field = schema.introspection_system.entry_point(name: field_name)) entry_point_field.metadata[:type_class] elsif (dynamic_field = schema.introspection_system.dynamic_field(name: field_name)) dynamic_field.metadata[:type_class] else - raise "Invariant: no field for #{owner_type}.#{field_name}" + nil end field_defn diff --git a/lib/graphql/execution/multiplex.rb b/lib/graphql/execution/multiplex.rb index 568c0a04db..3dc3123b24 100644 --- a/lib/graphql/execution/multiplex.rb +++ b/lib/graphql/execution/multiplex.rb @@ -29,8 +29,8 @@ class Multiplex include Tracing::Traceable - attr_reader :context, :queries, :schema - def initialize(schema:, queries:, context:) + attr_reader :context, :queries, :schema, :max_complexity + def initialize(schema:, queries:, context:, max_complexity:) @schema = schema @queries = queries @context = context @@ -40,6 +40,7 @@ def initialize(schema:, queries:, context:) if context[:backtrace] && !@tracers.include?(GraphQL::Backtrace::Tracer) @tracers << GraphQL::Backtrace::Tracer end + @max_complexity = max_complexity end class << self @@ -54,20 +55,20 @@ def run_all(schema, query_options, *args) # @param max_complexity [Integer, nil] # @return [Array] One result per query def run_queries(schema, queries, context: {}, max_complexity: schema.max_complexity) - multiplex = self.new(schema: schema, queries: queries, context: context) + multiplex = self.new(schema: schema, queries: queries, context: context, max_complexity: max_complexity) multiplex.trace("execute_multiplex", { multiplex: multiplex }) do - if has_custom_strategy?(schema) + if supports_multiplexing?(schema) + instrument_and_analyze(multiplex) do + run_as_multiplex(multiplex) + end + else if queries.length != 1 raise ArgumentError, "Multiplexing doesn't support custom execution strategies, run one query at a time instead" else - instrument_and_analyze(multiplex, max_complexity: max_complexity) do + instrument_and_analyze(multiplex) do [run_one_legacy(schema, queries.first)] end end - else - instrument_and_analyze(multiplex, max_complexity: max_complexity) do - run_as_multiplex(multiplex) - end end end end @@ -75,19 +76,21 @@ def run_queries(schema, queries, context: {}, max_complexity: schema.max_complex private def run_as_multiplex(multiplex) + + multiplex.schema.query_execution_strategy.begin_multiplex(multiplex) queries = multiplex.queries # Do as much eager evaluation of the query as possible results = queries.map do |query| - begin_query(query) + begin_query(query, multiplex) end # Then, work through lazy results in a breadth-first way - GraphQL::Execution::Execute::ExecutionFunctions.lazy_resolve_root_selection(results, { multiplex: multiplex }) + multiplex.schema.query_execution_strategy.finish_multiplex(results, multiplex) # Then, find all errors and assign the result to the query object results.each_with_index.map do |data_result, idx| query = queries[idx] - finish_query(data_result, query) + finish_query(data_result, query, multiplex) # Get the Query::Result, not the Hash query.result end @@ -99,13 +102,14 @@ def run_as_multiplex(multiplex) # @param query [GraphQL::Query] # @return [Hash] The initial result (may not be finished if there are lazy values) - def begin_query(query) + def begin_query(query, multiplex) operation = query.selected_operation if operation.nil? || !query.valid? || query.context.errors.any? NO_OPERATION else begin - GraphQL::Execution::Execute::ExecutionFunctions.resolve_root_selection(query) + # These were checked to be the same in `#supports_multiplexing?` + query.schema.query_execution_strategy.begin_query(query, multiplex) rescue GraphQL::ExecutionError => err query.context.errors << err NO_OPERATION @@ -116,7 +120,7 @@ def begin_query(query) # @param data_result [Hash] The result for the "data" key, if any # @param query [GraphQL::Query] The query which was run # @return [Hash] final result of this query, including all values and errors - def finish_query(data_result, query) + def finish_query(data_result, query, multiplex) # Assign the result so that it can be accessed in instrumentation query.result_values = if data_result.equal?(NO_OPERATION) if !query.valid? || query.context.errors.any? @@ -127,9 +131,7 @@ def finish_query(data_result, query) end else # Use `context.value` which was assigned during execution - result = { - "data" => Execution::Flatten.call(query.context) - } + result = query.schema.query_execution_strategy.finish_query(query, multiplex) if query.context.errors.any? error_result = query.context.errors.map(&:to_h) @@ -154,24 +156,29 @@ def run_one_legacy(schema, query) end end - def has_custom_strategy?(schema) - schema.query_execution_strategy != GraphQL::Execution::Execute || - schema.mutation_execution_strategy != GraphQL::Execution::Execute || - schema.subscription_execution_strategy != GraphQL::Execution::Execute + DEFAULT_STRATEGIES = [ + GraphQL::Execution::Execute, + GraphQL::Execution::Interpreter + ] + # @return [Boolean] True if the schema is only using one strategy, and it's one that supports multiplexing. + def supports_multiplexing?(schema) + schema_strategies = [schema.query_execution_strategy, schema.mutation_execution_strategy, schema.subscription_execution_strategy] + schema_strategies.uniq! + schema_strategies.size == 1 && DEFAULT_STRATEGIES.include?(schema_strategies.first) end # Apply multiplex & query instrumentation to `queries`. # # It yields when the queries should be executed, then runs teardown. - def instrument_and_analyze(multiplex, max_complexity:) + def instrument_and_analyze(multiplex) GraphQL::Execution::Instrumentation.apply_instrumenters(multiplex) do schema = multiplex.schema multiplex_analyzers = schema.multiplex_analyzers - if max_complexity - multiplex_analyzers += [GraphQL::Analysis::MaxQueryComplexity.new(max_complexity)] + if multiplex.max_complexity + multiplex_analyzers += [GraphQL::Analysis::MaxQueryComplexity.new(multiplex.max_complexity)] end - GraphQL::Analysis.analyze_multiplex(multiplex, multiplex_analyzers) + schema.analysis_engine.analyze_multiplex(multiplex, multiplex_analyzers) yield end end diff --git a/lib/graphql/internal_representation/rewrite.rb b/lib/graphql/internal_representation/rewrite.rb index 033ffc3a25..5f5c7a30a7 100644 --- a/lib/graphql/internal_representation/rewrite.rb +++ b/lib/graphql/internal_representation/rewrite.rb @@ -13,127 +13,29 @@ module InternalRepresentation # # The rewritten query tree serves as the basis for the `FieldsWillMerge` validation. # - class Rewrite + module Rewrite include GraphQL::Language NO_DIRECTIVES = [].freeze # @return InternalRepresentation::Document - attr_reader :document + attr_reader :rewrite_document - def initialize - @document = InternalRepresentation::Document.new - end - - # @return [Hash] Roots of this query - def operations - warn "#{self.class}#operations is deprecated; use `document.operation_definitions` instead" - document.operation_definitions - end - - def validate(context) - visitor = context.visitor - query = context.query + def initialize(*) + super + @query = context.query + @rewrite_document = InternalRepresentation::Document.new # Hash Set> # A record of fragment spreads and the irep nodes that used them - spread_parents = Hash.new { |h, k| h[k] = Set.new } + @rewrite_spread_parents = Hash.new { |h, k| h[k] = Set.new } # Hash Scope> - spread_scopes = {} + @rewrite_spread_scopes = {} # Array> # The current point of the irep_tree during visitation - nodes_stack = [] + @rewrite_nodes_stack = [] # Array - scopes_stack = [] - - skip_nodes = Set.new - - visit_op = VisitDefinition.new(context, @document.operation_definitions, nodes_stack, scopes_stack) - visitor[Nodes::OperationDefinition].enter << visit_op.method(:enter) - visitor[Nodes::OperationDefinition].leave << visit_op.method(:leave) - - visit_frag = VisitDefinition.new(context, @document.fragment_definitions, nodes_stack, scopes_stack) - visitor[Nodes::FragmentDefinition].enter << visit_frag.method(:enter) - visitor[Nodes::FragmentDefinition].leave << visit_frag.method(:leave) - - visitor[Nodes::InlineFragment].enter << ->(ast_node, ast_parent) { - # Inline fragments provide two things to the rewritten tree: - # - They _may_ narrow the scope by their type condition - # - They _may_ apply their directives to their children - if skip?(ast_node, query) - skip_nodes.add(ast_node) - end - - if skip_nodes.none? - scopes_stack.push(scopes_stack.last.enter(context.type_definition)) - end - } - - visitor[Nodes::InlineFragment].leave << ->(ast_node, ast_parent) { - if skip_nodes.none? - scopes_stack.pop - end - - if skip_nodes.include?(ast_node) - skip_nodes.delete(ast_node) - end - } - - visitor[Nodes::Field].enter << ->(ast_node, ast_parent) { - if skip?(ast_node, query) - skip_nodes.add(ast_node) - end - - if skip_nodes.none? - node_name = ast_node.alias || ast_node.name - parent_nodes = nodes_stack.last - next_nodes = [] - - field_defn = context.field_definition - if field_defn.nil? - # It's a non-existent field - new_scope = nil - else - field_return_type = field_defn.type - scopes_stack.last.each do |scope_type| - parent_nodes.each do |parent_node| - node = parent_node.scoped_children[scope_type][node_name] ||= Node.new( - parent: parent_node, - name: node_name, - owner_type: scope_type, - query: query, - return_type: field_return_type, - ) - node.ast_nodes << ast_node - node.definitions << field_defn - next_nodes << node - end - end - new_scope = Scope.new(query, field_return_type.unwrap) - end - - nodes_stack.push(next_nodes) - scopes_stack.push(new_scope) - end - } - - visitor[Nodes::Field].leave << ->(ast_node, ast_parent) { - if skip_nodes.none? - nodes_stack.pop - scopes_stack.pop - end - - if skip_nodes.include?(ast_node) - skip_nodes.delete(ast_node) - end - } - - visitor[Nodes::FragmentSpread].enter << ->(ast_node, ast_parent) { - if skip_nodes.none? && !skip?(ast_node, query) - # Register the irep nodes that depend on this AST node: - spread_parents[ast_node].merge(nodes_stack.last) - spread_scopes[ast_node] = scopes_stack.last - end - } + @rewrite_scopes_stack = [] + @rewrite_skip_nodes = Set.new # Resolve fragment spreads. # Fragment definitions got their own irep trees during visitation. @@ -142,12 +44,12 @@ def validate(context) # can be shared between its usages. context.on_dependency_resolve do |defn_ast_node, spread_ast_nodes, frag_ast_node| frag_name = frag_ast_node.name - fragment_node = @document.fragment_definitions[frag_name] + fragment_node = @rewrite_document.fragment_definitions[frag_name] if fragment_node spread_ast_nodes.each do |spread_ast_node| - parent_nodes = spread_parents[spread_ast_node] - parent_scope = spread_scopes[spread_ast_node] + parent_nodes = @rewrite_spread_parents[spread_ast_node] + parent_scope = @rewrite_spread_scopes[spread_ast_node] parent_nodes.each do |parent_node| parent_node.deep_merge_node(fragment_node, scope: parent_scope, merge_self: false) end @@ -156,43 +58,126 @@ def validate(context) end end - def skip?(ast_node, query) - dir = ast_node.directives - dir.any? && !GraphQL::Execution::DirectiveChecks.include?(dir, query) + # @return [Hash] Roots of this query + def operations + warn "#{self.class}#operations is deprecated; use `document.operation_definitions` instead" + @document.operation_definitions + end + + def on_operation_definition(ast_node, parent) + push_root_node(ast_node, @rewrite_document.operation_definitions) { super } + end + + def on_fragment_definition(ast_node, parent) + push_root_node(ast_node, @rewrite_document.fragment_definitions) { super } + end + + def push_root_node(ast_node, definitions) + # Either QueryType or the fragment type condition + owner_type = context.type_definition + defn_name = ast_node.name + + node = Node.new( + parent: nil, + name: defn_name, + owner_type: owner_type, + query: @query, + ast_nodes: [ast_node], + return_type: owner_type, + ) + + definitions[defn_name] = node + @rewrite_scopes_stack.push(Scope.new(@query, owner_type)) + @rewrite_nodes_stack.push([node]) + yield + @rewrite_nodes_stack.pop + @rewrite_scopes_stack.pop + end + + def on_inline_fragment(node, parent) + # Inline fragments provide two things to the rewritten tree: + # - They _may_ narrow the scope by their type condition + # - They _may_ apply their directives to their children + if skip?(node) + @rewrite_skip_nodes.add(node) + end + + if @rewrite_skip_nodes.none? + @rewrite_scopes_stack.push(@rewrite_scopes_stack.last.enter(context.type_definition)) + end + + super + + if @rewrite_skip_nodes.none? + @rewrite_scopes_stack.pop + end + + if @rewrite_skip_nodes.include?(node) + @rewrite_skip_nodes.delete(node) + end end - class VisitDefinition - def initialize(context, definitions, nodes_stack, scopes_stack) - @context = context - @query = context.query - @definitions = definitions - @nodes_stack = nodes_stack - @scopes_stack = scopes_stack + def on_field(ast_node, ast_parent) + if skip?(ast_node) + @rewrite_skip_nodes.add(ast_node) + end + + if @rewrite_skip_nodes.none? + node_name = ast_node.alias || ast_node.name + parent_nodes = @rewrite_nodes_stack.last + next_nodes = [] + + field_defn = context.field_definition + if field_defn.nil? + # It's a non-existent field + new_scope = nil + else + field_return_type = field_defn.type + @rewrite_scopes_stack.last.each do |scope_type| + parent_nodes.each do |parent_node| + node = parent_node.scoped_children[scope_type][node_name] ||= Node.new( + parent: parent_node, + name: node_name, + owner_type: scope_type, + query: @query, + return_type: field_return_type, + ) + node.ast_nodes << ast_node + node.definitions << field_defn + next_nodes << node + end + end + new_scope = Scope.new(@query, field_return_type.unwrap) + end + + @rewrite_nodes_stack.push(next_nodes) + @rewrite_scopes_stack.push(new_scope) + end + + super + + if @rewrite_skip_nodes.none? + @rewrite_nodes_stack.pop + @rewrite_scopes_stack.pop end - def enter(ast_node, ast_parent) - # Either QueryType or the fragment type condition - owner_type = @context.type_definition && @context.type_definition.unwrap - defn_name = ast_node.name - - node = Node.new( - parent: nil, - name: defn_name, - owner_type: owner_type, - query: @query, - ast_nodes: [ast_node], - return_type: @context.type_definition, - ) - - @definitions[defn_name] = node - @scopes_stack.push(Scope.new(@query, owner_type)) - @nodes_stack.push([node]) + if @rewrite_skip_nodes.include?(ast_node) + @rewrite_skip_nodes.delete(ast_node) end + end - def leave(ast_node, ast_parent) - @nodes_stack.pop - @scopes_stack.pop + def on_fragment_spread(ast_node, ast_parent) + if @rewrite_skip_nodes.none? && !skip?(ast_node) + # Register the irep nodes that depend on this AST node: + @rewrite_spread_parents[ast_node].merge(@rewrite_nodes_stack.last) + @rewrite_spread_scopes[ast_node] = @rewrite_scopes_stack.last end + super + end + + def skip?(ast_node) + dir = ast_node.directives + dir.any? && !GraphQL::Execution::DirectiveChecks.include?(dir, @query) end end end diff --git a/lib/graphql/introspection/dynamic_fields.rb b/lib/graphql/introspection/dynamic_fields.rb index 8040cb6fda..288018a89b 100644 --- a/lib/graphql/introspection/dynamic_fields.rb +++ b/lib/graphql/introspection/dynamic_fields.rb @@ -3,8 +3,14 @@ module GraphQL module Introspection class DynamicFields < Introspection::BaseObject field :__typename, String, "The name of this type", null: false, extras: [:irep_node] - def __typename(irep_node:) - irep_node.owner_type.name + + # `irep_node:` will be nil for the interpreter, since there is no such thing + def __typename(irep_node: nil) + if context.interpreter? + object.class.graphql_name + else + irep_node.owner_type.name + end end end end diff --git a/lib/graphql/introspection/entry_points.rb b/lib/graphql/introspection/entry_points.rb index 687ee0bbca..82ca149001 100644 --- a/lib/graphql/introspection/entry_points.rb +++ b/lib/graphql/introspection/entry_points.rb @@ -15,14 +15,19 @@ def __schema end def __type(name:) - type = @context.warden.get_type(name) - if type + type = context.warden.get_type(name) + + if type && context.interpreter? + type = type.metadata[:type_class] || raise("Invariant: interpreter requires class-based type for #{name}") + end + + # The interpreter provides this wrapping, other execution doesnt, so support both. + if type && !context.interpreter? # Apply wrapping manually since this field isn't wrapped by instrumentation - type_type = @context.schema.introspection_system.type_type - type_type.metadata[:type_class].authorized_new(type, @context) - else - nil + type_type = context.schema.introspection_system.type_type + type = type_type.metadata[:type_class].authorized_new(type, context) end + type end end end diff --git a/lib/graphql/introspection/enum_value_type.rb b/lib/graphql/introspection/enum_value_type.rb index 2f9a581d40..da19d3bc22 100644 --- a/lib/graphql/introspection/enum_value_type.rb +++ b/lib/graphql/introspection/enum_value_type.rb @@ -11,6 +11,10 @@ class EnumValueType < Introspection::BaseObject field :is_deprecated, Boolean, null: false field :deprecation_reason, String, null: true + def name + object.graphql_name + end + def is_deprecated !!@object.deprecation_reason end diff --git a/lib/graphql/introspection/schema_type.rb b/lib/graphql/introspection/schema_type.rb index dddd81bb05..ea55bfa3a1 100644 --- a/lib/graphql/introspection/schema_type.rb +++ b/lib/graphql/introspection/schema_type.rb @@ -14,7 +14,12 @@ class SchemaType < Introspection::BaseObject field :directives, [GraphQL::Schema::LateBoundType.new("__Directive")], "A list of all directives supported by this server.", null: false def types - @context.warden.types + types = @context.warden.types + if context.interpreter? + types.map { |t| t.metadata[:type_class] || raise("Invariant: can't introspect non-class-based type: #{t}") } + else + types + end end def query_type @@ -30,7 +35,7 @@ def subscription_type end def directives - @object.directives.values + context.schema.directives.values end private diff --git a/lib/graphql/introspection/type_type.rb b/lib/graphql/introspection/type_type.rb index 7450d47e8d..493d2b8d0c 100644 --- a/lib/graphql/introspection/type_type.rb +++ b/lib/graphql/introspection/type_type.rb @@ -25,6 +25,10 @@ class TypeType < Introspection::BaseObject field :input_fields, [GraphQL::Schema::LateBoundType.new("__InputValue")], null: true field :of_type, GraphQL::Schema::LateBoundType.new("__Type"), null: true + def name + object.graphql_name + end + def kind @object.kind.name end @@ -33,7 +37,7 @@ def enum_values(include_deprecated:) if !@object.kind.enum? nil else - enum_values = @context.warden.enum_values(@object) + enum_values = @context.warden.enum_values(@object.graphql_definition) if !include_deprecated enum_values = enum_values.select {|f| !f.deprecation_reason } @@ -45,7 +49,7 @@ def enum_values(include_deprecated:) def interfaces if @object.kind == GraphQL::TypeKinds::OBJECT - @context.warden.interfaces(@object) + @context.warden.interfaces(@object.graphql_definition) else nil end @@ -53,7 +57,7 @@ def interfaces def input_fields if @object.kind.input_object? - @context.warden.arguments(@object) + @context.warden.arguments(@object.graphql_definition) else nil end @@ -61,7 +65,7 @@ def input_fields def possible_types if @object.kind.abstract? - @context.warden.possible_types(@object) + @context.warden.possible_types(@object.graphql_definition) else nil end @@ -71,7 +75,7 @@ def fields(include_deprecated:) if !@object.kind.fields? nil else - fields = @context.warden.fields(@object) + fields = @context.warden.fields(@object.graphql_definition) if !include_deprecated fields = fields.select {|f| !f.deprecation_reason } end diff --git a/lib/graphql/invalid_null_error.rb b/lib/graphql/invalid_null_error.rb index 77aa14b26d..f949e3351c 100644 --- a/lib/graphql/invalid_null_error.rb +++ b/lib/graphql/invalid_null_error.rb @@ -16,7 +16,7 @@ def initialize(parent_type, field, value) @parent_type = parent_type @field = field @value = value - super("Cannot return null for non-nullable field #{@parent_type.name}.#{@field.name}") + super("Cannot return null for non-nullable field #{@parent_type.graphql_name}.#{@field.graphql_name}") end # @return [Hash] An entry for the response's "errors" key diff --git a/lib/graphql/language/document_from_schema_definition.rb b/lib/graphql/language/document_from_schema_definition.rb index 00bf9f0099..6d43166955 100644 --- a/lib/graphql/language/document_from_schema_definition.rb +++ b/lib/graphql/language/document_from_schema_definition.rb @@ -65,7 +65,7 @@ def build_field_node(field) ) if field.deprecation_reason - field_node.directives << GraphQL::Language::Nodes::Directive.new( + field_node = field_node.merge_directive( name: GraphQL::Directive::DeprecatedDirective.name, arguments: [GraphQL::Language::Nodes::Argument.new(name: "reason", value: field.deprecation_reason)] ) @@ -107,7 +107,7 @@ def build_enum_value_node(enum_value) ) if enum_value.deprecation_reason - enum_value_node.directives << GraphQL::Language::Nodes::Directive.new( + enum_value_node = enum_value_node.merge_directive( name: GraphQL::Directive::DeprecatedDirective.name, arguments: [GraphQL::Language::Nodes::Argument.new(name: "reason", value: enum_value.deprecation_reason)] ) @@ -124,16 +124,19 @@ def build_scalar_type_node(scalar_type) end def build_argument_node(argument) + if argument.default_value? + default_value = build_default_value(argument.default_value, argument.type) + else + default_value = nil + end + argument_node = GraphQL::Language::Nodes::InputValueDefinition.new( name: argument.name, description: argument.description, type: build_type_name_node(argument.type), + default_value: default_value, ) - if argument.default_value? - argument_node.default_value = build_default_value(argument.default_value, argument.type) - end - argument_node end diff --git a/lib/graphql/language/nodes.rb b/lib/graphql/language/nodes.rb index a907744a5d..8273a67e5b 100644 --- a/lib/graphql/language/nodes.rb +++ b/lib/graphql/language/nodes.rb @@ -10,15 +10,7 @@ module Nodes # - `to_query_string` turns an AST node into a GraphQL string class AbstractNode - module Scalars # :nodoc: - module Name - def scalars - super + [name] - end - end - end - - attr_accessor :line, :col, :filename + attr_reader :line, :col, :filename # Initialize a node by extracting its position, # then calling the class's `initialize_node` method. @@ -34,11 +26,6 @@ def initialize(options={}) initialize_node(options) end - # This is called with node-specific options - def initialize_node(options={}) - raise NotImplementedError - end - # Value equality # @return [Boolean] True if `self` is equivalent to `other` def eql?(other) @@ -48,14 +35,27 @@ def eql?(other) other.children.eql?(self.children) end + NO_CHILDREN = [].freeze + # @return [Array] all nodes in the tree below this one def children - [] + NO_CHILDREN end # @return [Array] Scalar values attached to this node def scalars - [] + NO_CHILDREN + end + + # This might be unnecessary, but its easiest to add it here. + def initialize_copy(other) + @children = nil + @scalars = nil + end + + # @return [Symbol] the method to call on {Language::Visitor} for this node + def visit_method + raise NotImplementedError, "#{self.class.name}#visit_method shold return a symbol" end def position @@ -65,35 +65,202 @@ def position def to_query_string(printer: GraphQL::Language::Printer.new) printer.print(self) end - end - # Base class for non-null type names and list type names - class WrapperType < AbstractNode - attr_accessor :of_type + # This creates a copy of `self`, with `new_options` applied. + # @param new_options [Hash] + # @return [AbstractNode] a shallow copy of `self` + def merge(new_options) + copied_self = dup + new_options.each do |key, value| + copied_self.instance_variable_set(:"@#{key}", value) + end + copied_self + end + + # Copy `self`, but modify the copy so that `previous_child` is replaced by `new_child` + def replace_child(previous_child, new_child) + # Figure out which list `previous_child` may be found in + method_name = previous_child.children_method_name + # Get the value from this (original) node + prev_children = public_send(method_name) + if prev_children.is_a?(Array) + # Copy that list, and replace `previous_child` with `new_child` + # in the list. + new_children = prev_children.dup + prev_idx = new_children.index(previous_child) + new_children[prev_idx] = new_child + else + # Use the new value for the given attribute + new_children = new_child + end + # Copy this node, but with the new child value + copy_of_self = merge(method_name => new_children) + # Return the copy: + copy_of_self + end + + # TODO DRY with `replace_child` + def delete_child(previous_child) + # Figure out which list `previous_child` may be found in + method_name = previous_child.children_method_name + # Copy that list, and delete previous_child + new_children = public_send(method_name).dup + new_children.delete(previous_child) + # Copy this node, but with the new list of children: + copy_of_self = merge(method_name => new_children) + # Return the copy: + copy_of_self + end + + class << self + # Add a default `#visit_method` and `#children_method_name` using the class name + def inherited(child_class) + super + name_underscored = child_class.name + .split("::").last + .gsub(/([a-z])([A-Z])/,'\1_\2') # insert underscores + .downcase # remove caps + + child_class.module_eval <<-RUBY + def visit_method + :on_#{name_underscored} + end + + def children_method_name + :#{name_underscored}s + end + RUBY + end - def initialize_node(of_type: nil) - @of_type = of_type - end + private + + # Name accessors which return lists of nodes, + # along with the kind of node they return, if possible. + # - Add a reader for these children + # - Add a persistent update method to add a child + # - Generate a `#children` method + def children_methods(children_of_type) + if @children_methods + raise "Can't re-call .children_methods for #{self} (already have: #{@children_methods})" + else + @children_methods = children_of_type + end - def scalars - [of_type] + if children_of_type == false + @children_methods = {} + # skip + else + + children_of_type.each do |method_name, node_type| + module_eval <<-RUBY, __FILE__, __LINE__ + # A reader for these children + attr_reader :#{method_name} + RUBY + + if node_type + # Only generate a method if we know what kind of node to make + module_eval <<-RUBY, __FILE__, __LINE__ + # Singular method: create a node with these options + # and return a new `self` which includes that node in this list. + def merge_#{method_name.to_s.sub(/s$/, "")}(node_opts) + merge(#{method_name}: #{method_name} + [#{node_type.name}.new(node_opts)]) + end + RUBY + end + end + + if children_of_type.size == 1 + module_eval <<-RUBY, __FILE__, __LINE__ + alias :children #{children_of_type.keys.first} + RUBY + else + module_eval <<-RUBY, __FILE__, __LINE__ + def children + @children ||= (#{children_of_type.keys.map { |k| "@#{k}" }.join(" + ")}).freeze + end + RUBY + end + end + + if defined?(@scalar_methods) + generate_initialize_node + else + raise "Can't generate_initialize_node because scalar_methods wasn't called; call it before children_methods" + end + end + + # These methods return a plain Ruby value, not another node + # - Add reader methods + # - Add a `#scalars` method + def scalar_methods(*method_names) + if @scalar_methods + raise "Can't re-call .scalar_methods for #{self} (already have: #{@scalar_methods})" + else + @scalar_methods = method_names + end + + if method_names == [false] + @scalar_methods = [] + # skip it + else + module_eval <<-RUBY, __FILE__, __LINE__ + # add readers for each scalar + attr_reader #{method_names.map { |m| ":#{m}"}.join(", ")} + + def scalars + @scalars ||= [#{method_names.map { |k| "@#{k}" }.join(", ")}].freeze + end + RUBY + end + end + + def generate_initialize_node + scalar_method_names = @scalar_methods + # TODO: These probably should be scalar methods, but `types` returns an array + [:types, :description].each do |extra_method| + if method_defined?(extra_method) + scalar_method_names += [extra_method] + end + end + + all_method_names = scalar_method_names + @children_methods.keys + if all_method_names.include?(:alias) + # Rather than complicating this special case, + # let it be overridden (in field) + return + else + arguments = scalar_method_names.map { |m| "#{m}: nil"} + + @children_methods.keys.map { |m| "#{m}: []" } + + assignments = scalar_method_names.map { |m| "@#{m} = #{m}"} + + @children_methods.keys.map { |m| "@#{m} = #{m}.freeze" } + + module_eval <<-RUBY, __FILE__, __LINE__ + def initialize_node #{arguments.join(", ")} + #{assignments.join("\n")} + end + RUBY + end + end end end + # Base class for non-null type names and list type names + class WrapperType < AbstractNode + scalar_methods :of_type + children_methods(false) + end + # Base class for nodes whose only value is a name (no child nodes or other scalars) class NameOnlyNode < AbstractNode - include Scalars::Name - - attr_accessor :name - - def initialize_node(name: nil) - @name = name - end + scalar_methods :name + children_methods(false) end # A key-value pair for a field's inputs class Argument < AbstractNode - attr_accessor :name, :value + scalar_methods :name, :value + children_methods(false) # @!attribute name # @return [String] the key for this argument @@ -101,51 +268,28 @@ class Argument < AbstractNode # @!attribute value # @return [String, Float, Integer, Boolean, Array, InputObject] The value passed for this key - def initialize_node(name: nil, value: nil) - @name = name - @value = value - end - - def scalars - [name, value] - end - def children - [value].flatten.select { |v| v.is_a?(AbstractNode) } + @children ||= Array(value).flatten.select { |v| v.is_a?(AbstractNode) } end end class Directive < AbstractNode - include Scalars::Name - - attr_accessor :name, :arguments - alias :children :arguments + scalar_methods :name + children_methods(arguments: GraphQL::Language::Nodes::Argument) + end - def initialize_node(name: nil, arguments: []) - @name = name - @arguments = arguments - end + class DirectiveLocation < NameOnlyNode end class DirectiveDefinition < AbstractNode - include Scalars::Name - - attr_accessor :name, :arguments, :locations, :description - - def initialize_node(name: nil, arguments: [], locations: [], description: nil) - @name = name - @arguments = arguments - @locations = locations - @description = description - end - - def children - arguments + locations - end + attr_reader :description + scalar_methods :name + children_methods( + locations: Nodes::DirectiveLocation, + arguments: Nodes::Argument, + ) end - class DirectiveLocation < NameOnlyNode; end - # This is the AST root for normal queries # # @example Deriving a document by parsing a string @@ -165,14 +309,10 @@ class DirectiveLocation < NameOnlyNode; end # document.to_query_string(printer: VariableSrubber.new) # class Document < AbstractNode - attr_accessor :definitions - alias :children :definitions - + scalar_methods false + children_methods(definitions: nil) # @!attribute definitions # @return [Array] top-level GraphQL units: operations or fragments - def initialize_node(definitions: []) - @definitions = definitions - end def slice_definition(name) GraphQL::Language::DefinitionSlice.slice(self, name) @@ -180,39 +320,47 @@ def slice_definition(name) end # An enum value. The string is available as {#name}. - class Enum < NameOnlyNode; end + class Enum < NameOnlyNode + end # A null value literal. - class NullValue < NameOnlyNode; end + class NullValue < NameOnlyNode + end # A single selection in a GraphQL query. class Field < AbstractNode - attr_accessor :name, :alias, :arguments, :directives, :selections + scalar_methods :name, :alias + children_methods({ + arguments: GraphQL::Language::Nodes::Argument, + selections: GraphQL::Language::Nodes::Field, + directives: GraphQL::Language::Nodes::Directive, + }) # @!attribute selections # @return [Array] Selections on this object (or empty array if this is a scalar field) def initialize_node(name: nil, arguments: [], directives: [], selections: [], **kwargs) @name = name - # oops, alias is a keyword: - @alias = kwargs.fetch(:alias, nil) @arguments = arguments @directives = directives @selections = selections + # oops, alias is a keyword: + @alias = kwargs.fetch(:alias, nil) end - def scalars - [name, self.alias] - end - - def children - arguments + directives + selections + # Override this because default is `:fields` + def children_method_name + :selections end end # A reusable fragment, defined at document-level. class FragmentDefinition < AbstractNode - attr_accessor :name, :type, :directives, :selections + scalar_methods :name, :type + children_methods({ + selections: GraphQL::Language::Nodes::Field, + directives: GraphQL::Language::Nodes::Directive, + }) # @!attribute name # @return [String] the identifier for this fragment, which may be applied with `...#{name}` @@ -226,65 +374,48 @@ def initialize_node(name: nil, type: nil, directives: [], selections: []) @selections = selections end - def children - directives + selections - end - - def scalars - [name, type] + def children_method_name + :definitions end end # Application of a named fragment in a selection class FragmentSpread < AbstractNode - include Scalars::Name + scalar_methods :name + children_methods(directives: GraphQL::Language::Nodes::Directive) - attr_accessor :name, :directives - alias :children :directives + def children_method_name + :selections + end # @!attribute name # @return [String] The identifier of the fragment to apply, corresponds with {FragmentDefinition#name} - - def initialize_node(name: nil, directives: []) - @name = name - @directives = directives - end end # An unnamed fragment, defined directly in the query with `... { }` class InlineFragment < AbstractNode - attr_accessor :type, :directives, :selections - - # @!attribute type - # @return [String, nil] Name of the type this fragment applies to, or `nil` if this fragment applies to any type - - def initialize_node(type: nil, directives: [], selections: []) - @type = type - @directives = directives - @selections = selections - end + scalar_methods :type + children_methods({ + selections: GraphQL::Language::Nodes::Field, + directives: GraphQL::Language::Nodes::Directive, + }) - def children - directives + selections + def children_method_name + :selections end - def scalars - [type] - end + # @!attribute type + # @return [String, nil] Name of the type this fragment applies to, or `nil` if this fragment applies to any type end # A collection of key-value inputs which may be a field argument class InputObject < AbstractNode - attr_accessor :arguments - alias :children :arguments + scalar_methods(false) + children_methods(arguments: GraphQL::Language::Nodes::Argument) # @!attribute arguments # @return [Array] A list of key-value pairs inside this input object - def initialize_node(arguments: []) - @arguments = arguments - end - # @return [Hash] Recursively turn this input object into a Ruby Hash def to_h(options={}) arguments.inject({}) do |memo, pair| @@ -294,6 +425,10 @@ def to_h(options={}) end end + def children_method_name + :value + end + private def serialize_value_for_hash(value) @@ -316,16 +451,37 @@ def serialize_value_for_hash(value) # A list type definition, denoted with `[...]` (used for variable type definitions) - class ListType < WrapperType; end + class ListType < WrapperType + end # A non-null type definition, denoted with `...!` (used for variable type definitions) - class NonNullType < WrapperType; end + class NonNullType < WrapperType + end + + # An operation-level query variable + class VariableDefinition < AbstractNode + scalar_methods :name, :type, :default_value + children_methods false + # @!attribute default_value + # @return [String, Integer, Float, Boolean, Array, NullValue] A Ruby value to use if no other value is provided + + # @!attribute type + # @return [TypeName, NonNullType, ListType] The expected type of this value + + # @!attribute name + # @return [String] The identifier for this variable, _without_ `$` + end # A query, mutation or subscription. # May be anonymous or named. # May be explicitly typed (eg `mutation { ... }`) or implicitly a query (eg `{ ... }`). class OperationDefinition < AbstractNode - attr_accessor :operation_type, :name, :variables, :directives, :selections + scalar_methods :operation_type, :name + children_methods({ + variables: GraphQL::Language::Nodes::VariableDefinition, + selections: GraphQL::Language::Nodes::Field, + directives: GraphQL::Language::Nodes::Directive, + }) # @!attribute variables # @return [Array] Variable definitions for this operation @@ -339,314 +495,215 @@ class OperationDefinition < AbstractNode # @!attribute name # @return [String, nil] The name for this operation, or `nil` if unnamed - def initialize_node(operation_type: nil, name: nil, variables: [], directives: [], selections: []) - @operation_type = operation_type - @name = name - @variables = variables - @directives = directives - @selections = selections - end - - def children - variables + directives + selections - end - - def scalars - [operation_type, name] + def children_method_name + :definitions end end # A type name, used for variable definitions - class TypeName < NameOnlyNode; end - - # An operation-level query variable - class VariableDefinition < AbstractNode - attr_accessor :name, :type, :default_value - - # @!attribute default_value - # @return [String, Integer, Float, Boolean, Array, NullValue] A Ruby value to use if no other value is provided - - # @!attribute type - # @return [TypeName, NonNullType, ListType] The expected type of this value - - # @!attribute name - # @return [String] The identifier for this variable, _without_ `$` - - def initialize_node(name: nil, type: nil, default_value: nil) - @name = name - @type = type - @default_value = default_value - end - - def scalars - [name, type, default_value] - end + class TypeName < NameOnlyNode end # Usage of a variable in a query. Name does _not_ include `$`. - class VariableIdentifier < NameOnlyNode; end + class VariableIdentifier < NameOnlyNode + end class SchemaDefinition < AbstractNode - attr_accessor :query, :mutation, :subscription, :directives - - def initialize_node(query: nil, mutation: nil, subscription: nil, directives: []) - @query = query - @mutation = mutation - @subscription = subscription - @directives = directives + scalar_methods :query, :mutation, :subscription + children_methods({ + directives: GraphQL::Language::Nodes::Directive, + }) + def children_method_name + :definitions end - - def scalars - [query, mutation, subscription] - end - - alias :children :directives end class SchemaExtension < AbstractNode - attr_accessor :query, :mutation, :subscription, :directives - - def initialize_node(query: nil, mutation: nil, subscription: nil, directives: []) - @query = query - @mutation = mutation - @subscription = subscription - @directives = directives + scalar_methods :query, :mutation, :subscription + children_methods({ + directives: GraphQL::Language::Nodes::Directive, + }) + def children_method_name + :definitions end - - def scalars - [query, mutation, subscription] - end - - alias :children :directives end class ScalarTypeDefinition < AbstractNode - include Scalars::Name - - attr_accessor :name, :directives, :description - alias :children :directives - - def initialize_node(name:, directives: [], description: nil) - @name = name - @directives = directives - @description = description + attr_reader :description + scalar_methods :name + children_methods({ + directives: GraphQL::Language::Nodes::Directive, + }) + def children_method_name + :definitions end end class ScalarTypeExtension < AbstractNode - attr_accessor :name, :directives - alias :children :directives - - def initialize_node(name:, directives: []) - @name = name - @directives = directives - end - end - - class ObjectTypeDefinition < AbstractNode - include Scalars::Name - - attr_accessor :name, :interfaces, :fields, :directives, :description - - def initialize_node(name:, interfaces:, fields:, directives: [], description: nil) - @name = name - @interfaces = interfaces || [] - @directives = directives - @fields = fields - @description = description - end - - def children - interfaces + fields + directives - end - end - - class ObjectTypeExtension < AbstractNode - attr_accessor :name, :interfaces, :fields, :directives - - def initialize_node(name:, interfaces:, fields:, directives: []) - @name = name - @interfaces = interfaces || [] - @directives = directives - @fields = fields - end - - def children - interfaces + fields + directives + scalar_methods :name + children_methods({ + directives: GraphQL::Language::Nodes::Directive, + }) + def children_method_name + :definitions end end class InputValueDefinition < AbstractNode - attr_accessor :name, :type, :default_value, :directives,:description - alias :children :directives - - def initialize_node(name:, type:, default_value: nil, directives: [], description: nil) - @name = name - @type = type - @default_value = default_value - @directives = directives - @description = description - end - - def scalars - [name, type, default_value] + attr_reader :description + scalar_methods :name, :type, :default_value + children_methods({ + directives: GraphQL::Language::Nodes::Directive, + }) + def children_method_name + :fields end end class FieldDefinition < AbstractNode - attr_accessor :name, :arguments, :type, :directives, :description - - def initialize_node(name:, arguments:, type:, directives: [], description: nil) - @name = name - @arguments = arguments - @type = type - @directives = directives - @description = description + attr_reader :description + scalar_methods :name, :type + children_methods({ + directives: GraphQL::Language::Nodes::Directive, + arguments: GraphQL::Language::Nodes::InputValueDefinition, + }) + def children_method_name + :fields + end + + # this is so that `children_method_name` of `InputValueDefinition` works properly + # with `#replace_child` + alias :fields :arguments + def merge(new_options) + if (f = new_options.delete(:fields)) + new_options[:arguments] = f + end + super end + end - def children - arguments + directives + class ObjectTypeDefinition < AbstractNode + attr_reader :description + scalar_methods :name, :interfaces + children_methods({ + directives: GraphQL::Language::Nodes::Directive, + fields: GraphQL::Language::Nodes::FieldDefinition, + }) + def children_method_name + :definitions end + end - def scalars - [name, type] + class ObjectTypeExtension < AbstractNode + scalar_methods :name, :interfaces + children_methods({ + directives: GraphQL::Language::Nodes::Directive, + fields: GraphQL::Language::Nodes::FieldDefinition, + }) + def children_method_name + :definitions end end class InterfaceTypeDefinition < AbstractNode - include Scalars::Name - - attr_accessor :name, :fields, :directives, :description - - def initialize_node(name:, fields:, directives: [], description: nil) - @name = name - @fields = fields - @directives = directives - @description = description - end - - def children - fields + directives + attr_reader :description + scalar_methods :name + children_methods({ + directives: GraphQL::Language::Nodes::Directive, + fields: GraphQL::Language::Nodes::FieldDefinition, + }) + def children_method_name + :definitions end end class InterfaceTypeExtension < AbstractNode - attr_accessor :name, :fields, :directives - - def initialize_node(name:, fields:, directives: []) - @name = name - @fields = fields - @directives = directives - end - - def children - fields + directives + scalar_methods :name + children_methods({ + directives: GraphQL::Language::Nodes::Directive, + fields: GraphQL::Language::Nodes::FieldDefinition, + }) + def children_method_name + :definitions end end class UnionTypeDefinition < AbstractNode - include Scalars::Name - - attr_accessor :name, :types, :directives, :description - - def initialize_node(name:, types:, directives: [], description: nil) - @name = name - @types = types - @directives = directives - @description = description - end - - def children - types + directives + attr_reader :description, :types + scalar_methods :name + children_methods({ + directives: GraphQL::Language::Nodes::Directive, + }) + def children_method_name + :definitions end end class UnionTypeExtension < AbstractNode - attr_accessor :name, :types, :directives - - def initialize_node(name:, types:, directives: []) - @name = name - @types = types - @directives = directives + attr_reader :types + scalar_methods :name + children_methods({ + directives: GraphQL::Language::Nodes::Directive, + }) + def children_method_name + :definitions end + end - def children - types + directives + class EnumValueDefinition < AbstractNode + attr_reader :description + scalar_methods :name + children_methods({ + directives: GraphQL::Language::Nodes::Directive, + }) + def children_method_name + :values end end class EnumTypeDefinition < AbstractNode - include Scalars::Name - - attr_accessor :name, :values, :directives, :description - - def initialize_node(name:, values:, directives: [], description: nil) - @name = name - @values = values - @directives = directives - @description = description - end - - def children - values + directives + attr_reader :description + scalar_methods :name + children_methods({ + directives: GraphQL::Language::Nodes::Directive, + values: GraphQL::Language::Nodes::EnumValueDefinition, + }) + def children_method_name + :definitions end end class EnumTypeExtension < AbstractNode - attr_accessor :name, :values, :directives - - def initialize_node(name:, values:, directives: []) - @name = name - @values = values - @directives = directives - end - - def children - values + directives - end - end - - class EnumValueDefinition < AbstractNode - include Scalars::Name - - attr_accessor :name, :directives, :description - alias :children :directives - - def initialize_node(name:, directives: [], description: nil) - @name = name - @directives = directives - @description = description + scalar_methods :name + children_methods({ + directives: GraphQL::Language::Nodes::Directive, + values: GraphQL::Language::Nodes::EnumValueDefinition, + }) + def children_method_name + :definitions end end class InputObjectTypeDefinition < AbstractNode - include Scalars::Name - - attr_accessor :name, :fields, :directives, :description - - def initialize_node(name:, fields:, directives: [], description: nil) - @name = name - @fields = fields - @directives = directives - @description = description - end - - def children - fields + directives + attr_reader :description + scalar_methods :name + children_methods({ + directives: GraphQL::Language::Nodes::Directive, + fields: GraphQL::Language::Nodes::InputValueDefinition, + }) + def children_method_name + :definitions end end class InputObjectTypeExtension < AbstractNode - attr_accessor :name, :fields, :directives - - def initialize_node(name:, fields:, directives: []) - @name = name - @fields = fields - @directives = directives - end - - def children - fields + directives + scalar_methods :name + children_methods({ + directives: GraphQL::Language::Nodes::Directive, + fields: GraphQL::Language::Nodes::InputValueDefinition, + }) + def children_method_name + :definitions end end end diff --git a/lib/graphql/language/visitor.rb b/lib/graphql/language/visitor.rb index 509af2dae8..a7e8e6087d 100644 --- a/lib/graphql/language/visitor.rb +++ b/lib/graphql/language/visitor.rb @@ -3,32 +3,61 @@ module GraphQL module Language # Depth-first traversal through the tree, calling hooks at each stop. # - # @example Create a visitor, add hooks, then search a document - # total_field_count = 0 - # visitor = GraphQL::Language::Visitor.new(document) - # # Whenever you find a field, increment the field count: - # visitor[GraphQL::Language::Nodes::Field] << ->(node) { total_field_count += 1 } - # # When we finish, print the field count: - # visitor[GraphQL::Language::Nodes::Document].leave << ->(node) { p total_field_count } - # visitor.visit - # # => 6 + # @example Create a visitor counting certain field names + # class NameCounter < GraphQL::Language::Visitor + # def initialize(document, field_name) + # super(document) + # @field_name + # @count = 0 + # end + # + # attr_reader :count + # + # def on_field(node, parent) + # # if this field matches our search, increment the counter + # if node.name == @field_name + # @count = 0 + # end + # # Continue visiting subfields: + # super + # end + # end # + # # Initialize a visitor + # visitor = GraphQL::Language::Visitor.new(document, "name") + # # Run it + # visitor.visit + # # Check the result + # visitor.count + # # => 3 class Visitor # If any hook returns this value, the {Visitor} stops visiting this # node right away + # @deprecated Use `super` to continue the visit; or don't call it to halt. SKIP = :_skip + class DeleteNode; end + + # When this is returned from a visitor method, + # Then the `node` passed into the method is removed from `parent`'s children. + DELETE_NODE = DeleteNode.new + def initialize(document) @document = document @visitors = {} + @result = nil end + # @return [GraphQL::Language::Nodes::Document] The document with any modifications applied + attr_reader :result + # Get a {NodeVisitor} for `node_class` # @param node_class [Class] The node class that you want to listen to # @return [NodeVisitor] # # @example Run a hook whenever you enter a new Field # visitor[GraphQL::Language::Nodes::Field] << ->(node, parent) { p "Here's a field" } + # @deprecated see `on_` methods, like {#on_field} def [](node_class) @visitors[node_class] ||= NodeVisitor.new end @@ -36,17 +65,120 @@ def [](node_class) # Visit `document` and all children, applying hooks as you go # @return [void] def visit - visit_node(@document, nil) + @result, _nil_parent = on_node_with_modifications(@document, nil) end - private + def visit_node(node, parent) + public_send(node.visit_method, node, parent) + end + + # The default implementation for visiting an AST node. + # It doesn't _do_ anything, but it continues to visiting the node's children. + # To customize this hook, override one of its make_visit_methodes (or the base method?) + # in your subclasses. + # + # For compatibility, it calls hook procs, too. + # @param node [GraphQL::Language::Nodes::AbstractNode] the node being visited + # @param parent [GraphQL::Language::Nodes::AbstractNode, nil] the previously-visited node, or `nil` if this is the root node. + # @return [void] + def on_abstract_node(node, parent) + if node == DELETE_NODE + # This might be passed to `super(DELETE_NODE, ...)` + # by a user hook, don't want to keep visiting in that case. + [node, parent] + else + # Run hooks if there are any + begin_hooks_ok = @visitors.none? || begin_visit(node, parent) + if begin_hooks_ok + node.children.each do |child_node| + new_child_and_node = on_node_with_modifications(child_node, node) + # Reassign `node` in case the child hook makes a modification + if new_child_and_node.is_a?(Array) + node = new_child_and_node[1] + end + end + end + @visitors.any? && end_visit(node, parent) + [node, parent] + end + end + + # We don't use `alias` here because it breaks `super` + def self.make_visit_method(node_method, super_method) + class_eval(<<-EOS, __FILE__, __LINE__ + 1) + def #{node_method}(node, parent) + #{super_method}(node, parent) + end + EOS + end + + make_visit_method :on_argument, :on_abstract_node + make_visit_method :on_directive, :on_abstract_node + make_visit_method :on_directive_definition, :on_abstract_node + make_visit_method :on_directive_location, :on_abstract_node + make_visit_method :on_document, :on_abstract_node + make_visit_method :on_enum, :on_abstract_node + make_visit_method :on_enum_type_definition, :on_abstract_node + make_visit_method :on_enum_type_extension, :on_abstract_node + make_visit_method :on_enum_value_definition, :on_abstract_node + make_visit_method :on_field, :on_abstract_node + make_visit_method :on_field_definition, :on_abstract_node + make_visit_method :on_fragment_definition, :on_abstract_node + make_visit_method :on_fragment_spread, :on_abstract_node + make_visit_method :on_inline_fragment, :on_abstract_node + make_visit_method :on_input_object, :on_abstract_node + make_visit_method :on_input_object_type_definition, :on_abstract_node + make_visit_method :on_input_object_type_extension, :on_abstract_node + make_visit_method :on_input_value_definition, :on_abstract_node + make_visit_method :on_interface_type_definition, :on_abstract_node + make_visit_method :on_interface_type_extension, :on_abstract_node + make_visit_method :on_list_type, :on_abstract_node + make_visit_method :on_non_null_type, :on_abstract_node + make_visit_method :on_null_value, :on_abstract_node + make_visit_method :on_object_type_definition, :on_abstract_node + make_visit_method :on_object_type_extension, :on_abstract_node + make_visit_method :on_operation_definition, :on_abstract_node + make_visit_method :on_scalar_type_definition, :on_abstract_node + make_visit_method :on_scalar_type_extension, :on_abstract_node + make_visit_method :on_schema_definition, :on_abstract_node + make_visit_method :on_schema_extension, :on_abstract_node + make_visit_method :on_type_name, :on_abstract_node + make_visit_method :on_union_type_definition, :on_abstract_node + make_visit_method :on_union_type_extension, :on_abstract_node + make_visit_method :on_variable_definition, :on_abstract_node + make_visit_method :on_variable_identifier, :on_abstract_node def visit_node(node, parent) - begin_hooks_ok = begin_visit(node, parent) - if begin_hooks_ok - node.children.each { |child| visit_node(child, node) } + public_send(node.visit_method, node, parent) + end + + private + + # Run the hooks for `node`, and if the hooks return a copy of `node`, + # copy `parent` so that it contains the copy of that node as a child, + # then return the copies + def on_node_with_modifications(node, parent) + new_node_and_new_parent = visit_node(node, parent) + if new_node_and_new_parent.is_a?(Array) + new_node = new_node_and_new_parent[0] + new_parent = new_node_and_new_parent[1] + if new_node.is_a?(Nodes::AbstractNode) && !node.equal?(new_node) + # The user-provided hook returned a new node. + new_parent = new_parent && new_parent.replace_child(node, new_node) + return new_node, new_parent + elsif new_node == DELETE_NODE + # The user-provided hook requested to remove this node + new_parent = new_parent && new_parent.delete_child(node) + return nil, new_parent + else + new_node_and_new_parent + end + else + # The user-provided hook didn't make any modifications. + # In fact, the hook might have returned who-knows-what, so + # ignore the return value and use the original values. + [node, parent] end - end_visit(node, parent) end def begin_visit(node, parent) @@ -75,6 +207,7 @@ class NodeVisitor attr_reader :leave def initialize + warn("Proc-based visitor hooks are deprecated and will be removed in 1.10.0. Migrate to a method-based visitor instead: http://graphql-ruby.org/language_tools/visitor") @enter = [] @leave = [] end diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index 9303470bd8..d8b339b3aa 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -135,10 +135,23 @@ def initialize(schema, query_string = nil, query: nil, document: nil, context: n end end + def_delegators :@schema, :interpreter? + def subscription_update? @subscription_topic && subscription? end + # A lookahead for the root selections of this query + # @return [GraphQL::Execution::Lookahead] + def lookahead + @lookahead ||= begin + ast_node = selected_operation + root_type = warden.root_type_for_operation(ast_node.operation_type || "query") + root_type = root_type.metadata[:type_class] || raise("Invariant: `lookahead` only works with class-based types") + GraphQL::Execution::Lookahead.new(query: self, root_type: root_type, ast_nodes: [ast_node]) + end + end + # @api private def result_values=(result_hash) if @executed @@ -221,7 +234,8 @@ def validation_pipeline with_prepared_ast { @validation_pipeline } end - def_delegators :validation_pipeline, :validation_errors, :internal_representation, :analyzers + def_delegators :validation_pipeline, :validation_errors, :internal_representation, + :analyzers, :ast_analyzers, :max_depth, :max_complexity attr_accessor :analysis_errors def valid? diff --git a/lib/graphql/query/arguments.rb b/lib/graphql/query/arguments.rb index fefc0994be..f345a6e958 100644 --- a/lib/graphql/query/arguments.rb +++ b/lib/graphql/query/arguments.rb @@ -41,7 +41,7 @@ def self.construct_arguments_class(argument_owner) def initialize(values, context:, defaults_used:) @argument_values = values.inject({}) do |memo, (inner_key, inner_value)| arg_name = inner_key.to_s - arg_defn = self.class.argument_definitions[arg_name] + arg_defn = self.class.argument_definitions[arg_name] || raise("Not foudn #{arg_name} among #{self.class.argument_definitions.keys}") arg_default_used = defaults_used.include?(arg_name) arg_value = wrap_value(inner_value, arg_defn.type, context) string_key = arg_defn.expose_as diff --git a/lib/graphql/query/context.rb b/lib/graphql/query/context.rb index 368a0ecc5b..efd58d98a9 100644 --- a/lib/graphql/query/context.rb +++ b/lib/graphql/query/context.rb @@ -157,11 +157,14 @@ def initialize(query:, values: , object:) @context = self # for SharedMethods end + # @api private + attr_writer :interpreter + # @api private attr_writer :value def_delegators :@provided_values, :[], :[]=, :to_h, :to_hash, :key?, :fetch, :dig - def_delegators :@query, :trace + def_delegators :@query, :trace, :interpreter? # @!method [](key) # Lookup `key` from the hash passed to {Schema#execute} as `context:` @@ -228,7 +231,7 @@ def path def_delegators :@context, :[], :[]=, :key?, :fetch, :to_h, :namespace, :dig, :spawn, :warden, :errors, - :execution_strategy, :strategy + :execution_strategy, :strategy, :interpreter? # @return [GraphQL::Language::Nodes::Field] The AST node for the currently-executing field def ast_node diff --git a/lib/graphql/query/validation_pipeline.rb b/lib/graphql/query/validation_pipeline.rb index c478033e7f..8e93f45d57 100644 --- a/lib/graphql/query/validation_pipeline.rb +++ b/lib/graphql/query/validation_pipeline.rb @@ -14,6 +14,8 @@ class Query # # @api private class ValidationPipeline + attr_reader :max_depth, :max_complexity + def initialize(query:, validate:, parse_error:, operation_name_error:, max_depth:, max_complexity:) @validation_errors = [] @internal_representation = nil @@ -34,7 +36,7 @@ def valid? @valid end - # @return [Array] Static validation errors for the query string + # @return [Array] Static validation errors for the query string def validation_errors ensure_has_validated @validation_errors @@ -93,18 +95,40 @@ def ensure_has_validated # If there are max_* values, add them, # otherwise reuse the schema's list of analyzers. def build_analyzers(schema, max_depth, max_complexity) - if max_depth || max_complexity - qa = schema.query_analyzers.dup + qa = schema.query_analyzers.dup - if max_depth - qa << GraphQL::Analysis::MaxQueryDepth.new(max_depth) + # Filter out the built in authorization analyzer. + # It is deprecated and does not have an AST analyzer alternative. + qa = qa.select do |analyzer| + if analyzer == GraphQL::Authorization::Analyzer && schema.using_ast_analysis? + raise "The Authorization analyzer is not supported with AST Analyzers" + else + true end - if max_complexity - qa << GraphQL::Analysis::MaxQueryComplexity.new(max_complexity) + end + + if max_depth || max_complexity + # Depending on the analysis engine, we must use different analyzers + # remove this once everything has switched over to AST analyzers + if schema.using_ast_analysis? + if max_depth + qa << GraphQL::Analysis::AST::MaxQueryDepth + end + if max_complexity + qa << GraphQL::Analysis::AST::MaxQueryComplexity + end + else + if max_depth + qa << GraphQL::Analysis::MaxQueryDepth.new(max_depth) + end + if max_complexity + qa << GraphQL::Analysis::MaxQueryComplexity.new(max_complexity) + end end + qa else - schema.query_analyzers + qa end end end diff --git a/lib/graphql/relay/connection_instrumentation.rb b/lib/graphql/relay/connection_instrumentation.rb index e193a7e293..1ddec45a9d 100644 --- a/lib/graphql/relay/connection_instrumentation.rb +++ b/lib/graphql/relay/connection_instrumentation.rb @@ -32,7 +32,9 @@ def self.default_arguments # - Merging in the default arguments # - Transforming its resolve function to return a connection object def self.instrument(type, field) - if field.connection? + # Don't apply the wrapper to class-based fields, since they + # use Schema::Field::ConnectionFilter + if field.connection? && !field.metadata[:type_class] connection_arguments = default_arguments.merge(field.arguments) original_resolve = field.resolve_proc original_lazy_resolve = field.lazy_resolve_proc diff --git a/lib/graphql/relay/node.rb b/lib/graphql/relay/node.rb index da81c12c53..8b7f222ae9 100644 --- a/lib/graphql/relay/node.rb +++ b/lib/graphql/relay/node.rb @@ -8,13 +8,7 @@ def self.field(**kwargs, &block) # We have to define it fresh each time because # its name will be modified and its description # _may_ be modified. - field = GraphQL::Field.define do - type(GraphQL::Relay::Node.interface) - description("Fetches an object given its ID.") - argument(:id, types.ID.to_non_null_type, "ID of the object.") - resolve(GraphQL::Relay::Node::FindNode) - relay_node_field(true) - end + field = GraphQL::Types::Relay::NodeField.graphql_definition if kwargs.any? || block field = field.redefine(kwargs, &block) @@ -24,13 +18,7 @@ def self.field(**kwargs, &block) end def self.plural_field(**kwargs, &block) - field = GraphQL::Field.define do - type(!types[GraphQL::Relay::Node.interface]) - description("Fetches a list of objects given a list of IDs.") - argument(:ids, types.ID.to_non_null_type.to_list_type.to_non_null_type, "IDs of the objects.") - resolve(GraphQL::Relay::Node::FindNodes) - relay_nodes_field(true) - end + field = GraphQL::Types::Relay::NodesField.graphql_definition if kwargs.any? || block field = field.redefine(kwargs, &block) @@ -43,20 +31,6 @@ def self.plural_field(**kwargs, &block) def self.interface @interface ||= GraphQL::Types::Relay::Node.graphql_definition end - - # A field resolve for finding objects by IDs - module FindNodes - def self.call(obj, args, ctx) - args[:ids].map { |id| ctx.query.schema.object_from_id(id, ctx) } - end - end - - # A field resolve for finding an object by ID - module FindNode - def self.call(obj, args, ctx) - ctx.query.schema.object_from_id(args[:id], ctx ) - end - end end end end diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index 97f36b290d..86e5cd5faf 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -19,7 +19,6 @@ require "graphql/schema/warden" require "graphql/schema/build_from_definition" - require "graphql/schema/member" require "graphql/schema/wrapper" require "graphql/schema/list" @@ -27,15 +26,17 @@ require "graphql/schema/argument" require "graphql/schema/enum_value" require "graphql/schema/enum" +require "graphql/schema/field_extension" require "graphql/schema/field" require "graphql/schema/input_object" require "graphql/schema/interface" +require "graphql/schema/scalar" +require "graphql/schema/object" +require "graphql/schema/union" + require "graphql/schema/resolver" require "graphql/schema/mutation" require "graphql/schema/relay_classic_mutation" -require "graphql/schema/object" -require "graphql/schema/scalar" -require "graphql/schema/union" module GraphQL # A GraphQL schema which may be queried with {GraphQL::Query}. @@ -83,18 +84,23 @@ class Schema :object_from_id, :id_from_object, :default_mask, :cursor_encoder, - directives: ->(schema, directives) { schema.directives = directives.reduce({}) { |m, d| m[d.name] = d; m }}, + directives: ->(schema, directives) { schema.directives = directives.reduce({}) { |m, d| m[d.name] = d; m } }, instrument: ->(schema, type, instrumenter, after_built_ins: false) { if type == :field && after_built_ins type = :field_after_built_ins end schema.instrumenters[type] << instrumenter }, - query_analyzer: ->(schema, analyzer) { schema.query_analyzers << analyzer }, + query_analyzer: ->(schema, analyzer) { + if analyzer == GraphQL::Authorization::Analyzer + warn("The Authorization query analyzer is deprecated. Authorizing at query runtime is generally a better idea.") + end + schema.query_analyzers << analyzer + }, multiplex_analyzer: ->(schema, analyzer) { schema.multiplex_analyzers << analyzer }, middleware: ->(schema, middleware) { schema.middleware << middleware }, lazy_resolve: ->(schema, lazy_class, lazy_value_method) { schema.lazy_methods.set(lazy_class, lazy_value_method) }, - rescue_from: ->(schema, err_class, &block) { schema.rescue_from(err_class, &block)}, + rescue_from: ->(schema, err_class, &block) { schema.rescue_from(err_class, &block) }, tracer: ->(schema, tracer) { schema.tracers.push(tracer) } attr_accessor \ @@ -106,7 +112,8 @@ class Schema :cursor_encoder, :ast_node, :raise_definition_error, - :introspection_namespace + :introspection_namespace, + :analysis_engine # [Boolean] True if this object bubbles validation errors up from a field into its parent InputObject, if there is one. attr_accessor :error_bubbling @@ -138,8 +145,6 @@ def default_filter # @see {Query#tracers} for query-specific tracers attr_reader :tracers - self.default_execution_strategy = GraphQL::Execution::Execute - DIRECTIVES = [GraphQL::Directive::IncludeDirective, GraphQL::Directive::SkipDirective, GraphQL::Directive::DeprecatedDirective] DYNAMIC_FIELDS = ["__type", "__typename", "__schema"] @@ -164,6 +169,7 @@ def initialize @lazy_methods.set(GraphQL::Execution::Lazy, :value) @cursor_encoder = Base64Encoder # Default to the built-in execution strategy: + @analysis_engine = GraphQL::Analysis @query_execution_strategy = self.class.default_execution_strategy @mutation_execution_strategy = self.class.default_execution_strategy @subscription_execution_strategy = self.class.default_execution_strategy @@ -172,9 +178,22 @@ def initialize @context_class = GraphQL::Query::Context @introspection_namespace = nil @introspection_system = nil + @interpeter = false @error_bubbling = true end + # @return [Boolean] True if using the new {GraphQL::Execution::Interpreter} + def interpreter? + @interpreter + end + + # @api private + attr_writer :interpreter + + def inspect + "#<#{self.class.name} ...>" + end + def initialize_copy(other) super @orphan_types = other.orphan_types.dup @@ -211,12 +230,16 @@ def remove_handler(*args, &block) rescue_middleware.remove_handler(*args, &block) end + def using_ast_analysis? + @analysis_engine == GraphQL::Analysis::AST + end + # For forwards-compatibility with Schema classes alias :graphql_definition :itself # Validate a query string according to this schema. # @param string_or_document [String, GraphQL::Language::Nodes::Document] - # @return [Array] + # @return [Array] def validate(string_or_document, rules: nil) doc = if string_or_document.is_a?(String) GraphQL.parse(string_or_document) @@ -394,7 +417,7 @@ def get_field(parent_type, field_name) # Fields for this type, after instrumentation is applied # @return [Hash] def get_fields(type) - @instrumented_field_map[type.name] + @instrumented_field_map[type.graphql_name] end def type_from_ast(ast_node) @@ -661,8 +684,10 @@ class << self :execute, :multiplex, :static_validator, :introspection_system, :query_analyzers, :tracers, :instrumenters, + :execution_strategy_for_operation, :validate, :multiplex_analyzers, :lazy?, :lazy_method_name, :after_lazy, :sync_lazy, # Configuration + :analysis_engine, :analysis_engine=, :using_ast_analysis?, :interpreter?, :max_complexity=, :max_depth=, :error_bubbling=, :metadata, @@ -712,7 +737,7 @@ def to_graphql schema_defn.cursor_encoder = cursor_encoder schema_defn.tracers.concat(defined_tracers) schema_defn.query_analyzers.concat(defined_query_analyzers) - schema_defn.query_analyzers << GraphQL::Authorization::Analyzer + schema_defn.middleware.concat(defined_middleware) schema_defn.multiplex_analyzers.concat(defined_multiplex_analyzers) schema_defn.query_execution_strategy = query_execution_strategy @@ -723,7 +748,6 @@ def to_graphql schema_defn.instrumenters[step] << inst end end - schema_defn.instrumenters[:query] << GraphQL::Schema::Member::Instrumentation lazy_classes.each do |lazy_class, value_method| schema_defn.lazy_methods.set(lazy_class, value_method) end @@ -746,6 +770,10 @@ def to_graphql end end end + # Do this after `plugins` since Interpreter is a plugin + if schema_defn.query_execution_strategy != GraphQL::Execution::Interpreter + schema_defn.instrumenters[:query] << GraphQL::Schema::Member::Instrumentation + end schema_defn.send(:rebuild_artifacts) schema_defn @@ -858,7 +886,7 @@ def default_execution_strategy if superclass <= GraphQL::Schema superclass.default_execution_strategy else - @default_execution_strategy + @default_execution_strategy ||= GraphQL::Execution::Execute end end @@ -958,6 +986,9 @@ def tracer(new_tracer) end def query_analyzer(new_analyzer) + if new_analyzer == GraphQL::Authorization::Analyzer + warn("The Authorization query analyzer is deprecated. Authorizing at query runtime is generally a better idea.") + end defined_query_analyzers << new_analyzer end @@ -1005,7 +1036,7 @@ def defined_multiplex_analyzers # @see {.accessible?} # @see {.authorized?} def call_on_type_class(member, method_name, *args, default:) - member = if member.respond_to?(:metadata) + member = if member.respond_to?(:metadata) && member.metadata member.metadata[:type_class] || member else member @@ -1062,7 +1093,14 @@ def after_lazy(value) # @param ctx [GraphQL::Query::Context] the context for this query # @return [Object] A GraphQL-ready (non-lazy) object def self.sync_lazy(value) - yield(value) + if block_given? + # This was already hit by the instance, just give it back + yield(value) + else + # This was called directly on the class, hit the instance + # which has the lazy method map + self.graphql_definition.sync_lazy(value) + end end # @see Schema.sync_lazy for a hook to override diff --git a/lib/graphql/schema/base_64_encoder.rb b/lib/graphql/schema/base_64_encoder.rb index 7e1fccb4cf..aae8333dae 100644 --- a/lib/graphql/schema/base_64_encoder.rb +++ b/lib/graphql/schema/base_64_encoder.rb @@ -7,7 +7,7 @@ class Schema # @api private module Base64Encoder def self.encode(unencoded_text, nonce: false) - Base64.strict_encode64(unencoded_text) + Base64Bp.urlsafe_encode64(unencoded_text, padding: false) end def self.decode(encoded_text, nonce: false) diff --git a/lib/graphql/schema/field.rb b/lib/graphql/schema/field.rb index 82dd241b0b..fa2916d023 100644 --- a/lib/graphql/schema/field.rb +++ b/lib/graphql/schema/field.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true # test_via: ../object.rb +require "graphql/schema/field/connection_extension" +require "graphql/schema/field/scope_extension" + module GraphQL class Schema class Field @@ -17,19 +20,21 @@ class Field # @return [String, nil] If present, the field is marked as deprecated with this documentation attr_accessor :deprecation_reason - # @return [Symbol] Method or hash key to look up + # @return [Symbol] Method or hash key on the underlying object to look up attr_reader :method_sym - # @return [String] Method or hash key to look up + # @return [String] Method or hash key on the underlying object to look up attr_reader :method_str + # @return [Symbol] The method on the type to look up + attr_reader :resolver_method + # @return [Class] The type that this field belongs to attr_reader :owner - # @return [Symobol] the orignal name of the field, passed in by the user + # @return [Symobol] the original name of the field, passed in by the user attr_reader :original_name - # @return [Class, nil] The {Schema::Resolver} this field was derived from, if there is one def resolver @resolver_class @@ -37,6 +42,15 @@ def resolver alias :mutation :resolver + # @return [Array] + attr_reader :extras + + # @return [Boolean] Apply tracing to this field? (Default: skip scalars, this is the override value) + attr_reader :trace + + # @return [String, nil] + attr_reader :subscription_scope + # Create a field instance from a list of arguments, keyword arguments, and a block. # # This method implements prioritization between the `resolver` or `mutation` defaults @@ -90,7 +104,8 @@ def connection? elsif @return_type_expr Member::BuildType.to_type_name(@return_type_expr) else - raise "No connection info possible" + # As a last ditch, try to force loading the return type: + type.unwrap.name end @connection = return_type_name.end_with?("Connection") else @@ -104,19 +119,19 @@ def scoped? # The default was overridden @scope else - @return_type_expr && type.unwrap.respond_to?(:scope_items) && (connection? || type.list?) + @return_type_expr.is_a?(Array) || (@return_type_expr.is_a?(String) && @return_type_expr.include?("[")) || connection? end end # @param name [Symbol] The underscore-cased version of this field name (will be camelized for the GraphQL API) - # @param return_type_expr [Class, GraphQL::BaseType, Array] The return type of this field - # @param desc [String] Field description + # @param type [Class, GraphQL::BaseType, Array] The return type of this field # @param owner [Class] The type that this field belongs to # @param null [Boolean] `true` if this field may return `null`, `false` if it is never `null` # @param description [String] Field description # @param deprecation_reason [String] If present, the field is marked "deprecated" with this message - # @param method [Symbol] The method to call to resolve this field (defaults to `name`) - # @param hash_key [Object] The hash key to lookup to resolve this field (defaults to `name` or `name.to_s`) + # @param method [Symbol] The method to call on the underlying object to resolve this field (defaults to `name`) + # @param hash_key [String, Symbol] The hash key to lookup on the underlying object (if its a Hash) to resolve this field (defaults to `name` or `name.to_s`) + # @param resolver_method [Symbol] The method on the type to call to resolve this field (defaults to `name`) # @param connection [Boolean] `true` if this field should get automagic connection behavior; default is to infer by `*Connection` in the return type name # @param max_page_size [Integer] For connections, the maximum number of items to return from this field # @param introspection [Boolean] If true, this field will be marked as `#introspection?` and the name may begin with `__` @@ -129,8 +144,9 @@ def scoped? # @param complexity [Numeric] When provided, set the complexity for this field # @param scope [Boolean] If true, the return type's `.scope_items` method will be called on the return value # @param subscription_scope [Symbol, String] A key in `context` which will be used to scope subscription payloads - def initialize(type: nil, name: nil, owner: nil, null: nil, field: nil, function: nil, description: nil, deprecation_reason: nil, method: nil, connection: nil, max_page_size: nil, scope: nil, resolve: nil, introspection: false, hash_key: nil, camelize: true, trace: nil, complexity: 1, extras: [], resolver_class: nil, subscription_scope: nil, arguments: {}, &definition_block) - + # @param extensions [Array] Named extensions to apply to this field (see also {#extension}) + # @param trace [Boolean] If true, a {GraphQL::Tracing} tracer will measure this scalar field + def initialize(type: nil, name: nil, owner: nil, null: nil, field: nil, function: nil, description: nil, deprecation_reason: nil, method: nil, hash_key: nil, resolver_method: nil, resolve: nil, connection: nil, max_page_size: nil, scope: nil, introspection: false, camelize: true, trace: nil, complexity: 1, extras: [], extensions: [], resolver_class: nil, subscription_scope: nil, relay_node_field: false, relay_nodes_field: false, arguments: {}, &definition_block) if name.nil? raise ArgumentError, "missing first `name` argument or keyword `name:`" end @@ -146,25 +162,39 @@ def initialize(type: nil, name: nil, owner: nil, null: nil, field: nil, function raise ArgumentError, "keyword `extras:` may only be used with method-based resolve and class-based field such as mutation class, please remove `field:`, `function:` or `resolve:`" end @original_name = name + @underscored_name = Member::BuildType.underscore(name.to_s) @name = camelize ? Member::BuildType.camelize(name.to_s) : name.to_s @description = description if field.is_a?(GraphQL::Schema::Field) - @field_instance = field + raise ArgumentError, "Instead of passing a field as `field:`, use `add_field(field)` to add an already-defined field." else @field = field end @function = function @resolve = resolve @deprecation_reason = deprecation_reason + if method && hash_key raise ArgumentError, "Provide `method:` _or_ `hash_key:`, not both. (called with: `method: #{method.inspect}, hash_key: #{hash_key.inspect}`)" end + if resolver_method + if method + raise ArgumentError, "Provide `method:` _or_ `resolver_method:`, not both. (called with: `method: #{method.inspect}, resolver_method: #{resolver_method.inspect}`)" + end + + if hash_key + raise ArgumentError, "Provide `hash_key:` _or_ `resolver_method:`, not both. (called with: `hash_key: #{hash_key.inspect}, resolver_method: #{resolver_method.inspect}`)" + end + end + # TODO: I think non-string/symbol hash keys are wrongly normalized (eg `1` will not work) - method_name = method || hash_key || Member::BuildType.underscore(name.to_s) + method_name = method || hash_key || @underscored_name + resolver_method ||= @underscored_name @method_str = method_name.to_s @method_sym = method_name.to_sym + @resolver_method = resolver_method @complexity = complexity @return_type_expr = type @return_type_null = null @@ -175,6 +205,8 @@ def initialize(type: nil, name: nil, owner: nil, null: nil, field: nil, function @resolver_class = resolver_class @scope = scope @trace = trace + @relay_node_field = relay_node_field + @relay_nodes_field = relay_nodes_field # Override the default from HasArguments @own_arguments = {} @@ -189,6 +221,22 @@ def initialize(type: nil, name: nil, owner: nil, null: nil, field: nil, function @owner = owner @subscription_scope = subscription_scope + # Do this last so we have as much context as possible when initializing them: + @extensions = [] + if extensions.any? + self.extensions(extensions) + end + # This should run before connection extension, + # but should it run after the definition block? + if scoped? + self.extension(ScopeExtension) + end + # The problem with putting this after the definition_block + # is that it would override arguments + if connection? + self.extension(ConnectionExtension) + end + if definition_block if definition_block.arity == 1 yield self @@ -208,6 +256,49 @@ def description(text = nil) end end + # Read extension instances from this field, + # or add new classes/options to be initialized on this field. + # + # @param extensions [Array, Hash Object>] Add extensions to this field + # @return [Array] extensions to apply to this field + def extensions(new_extensions = nil) + if new_extensions.nil? + # Read the value + @extensions + else + if @resolve || @function + raise ArgumentError, <<-MSG +Extensions are not supported with resolve procs or functions, +but #{owner.name}.#{name} has: #{@resolve || @function} +So, it can't have extensions: #{extensions}. +Use a method or a Schema::Resolver instead. +MSG + end + + # Normalize to a Hash of {name => options} + extensions_with_options = if new_extensions.last.is_a?(Hash) + new_extensions.pop + else + {} + end + new_extensions.each do |f| + extensions_with_options[f] = nil + end + + # Initialize each class and stash the instance + extensions_with_options.each do |extension_class, options| + @extensions << extension_class.new(field: self, options: options) + end + end + end + + # Add `extension` to this field, initialized with `options` if provided. + # @param extension [Class] subclass of {Schema::Fieldextension} + # @param options [Object] if provided, given as `options:` when initializing `extension`. + def extension(extension, options = nil) + extensions([{extension => options}]) + end + def complexity(new_complexity) case new_complexity when Proc @@ -224,17 +315,13 @@ def complexity(new_complexity) else raise("Invalid complexity: #{new_complexity.inspect} on #{@name}") end - end + # @return [Integer, nil] Applied to connections if present + attr_reader :max_page_size + # @return [GraphQL::Field] def to_graphql - # this field was previously defined and passed here, so delegate to it - if @field_instance - return @field_instance.to_graphql - end - - field_defn = if @field @field.dup elsif @function @@ -267,24 +354,21 @@ def to_graphql field_defn.trace = @trace end + if @relay_node_field + field_defn.relay_node_field = @relay_node_field + end + + if @relay_nodes_field + field_defn.relay_nodes_field = @relay_nodes_field + end + field_defn.resolve = self.method(:resolve_field) field_defn.connection = connection? - field_defn.connection_max_page_size = @max_page_size + field_defn.connection_max_page_size = max_page_size field_defn.introspection = @introspection field_defn.complexity = @complexity field_defn.subscription_scope = @subscription_scope - # apply this first, so it can be overriden below - if connection? - # TODO: this could be a bit weird, because these fields won't be present - # after initialization, only in the `to_graphql` response. - # This calculation _could_ be moved up if need be. - argument :after, "String", "Returns the elements in the list that come after the specified cursor.", required: false - argument :before, "String", "Returns the elements in the list that come before the specified cursor.", required: false - argument :first, "Int", "Returns the first _n_ elements from the list.", required: false - argument :last, "Int", "Returns the last _n_ elements from the list.", required: false - end - arguments.each do |name, defn| arg_graphql = defn.to_graphql field_defn.arguments[arg_graphql.name] = arg_graphql @@ -328,16 +412,29 @@ def accessible?(context) end def authorized?(object, context) - if @resolver_class + self_auth = if @resolver_class @resolver_class.authorized?(object, context) else true end + + if self_auth + # Faster than `.any?` + arguments.each_value do |arg| + if !arg.authorized?(object, context) + return false + end + end + true + else + false + end end # Implement {GraphQL::Field}'s resolve API. # # Eventually, we might hook up field instances to execution in another way. TBD. + # @see #resolve for how the interpreter hooks up to it def resolve_field(obj, args, ctx) ctx.schema.after_lazy(obj) do |after_obj| # First, apply auth ... @@ -345,24 +442,65 @@ def resolve_field(obj, args, ctx) # Some legacy fields can have `nil` here, not exactly sure why. # @see https://github.com/rmosolgo/graphql-ruby/issues/1990 before removing inner_obj = after_obj && after_obj.object - if authorized?(inner_obj, query_ctx) && arguments.each_value.all? { |a| a.authorized?(inner_obj, query_ctx) } + if authorized?(inner_obj, query_ctx) # Then if it passed, resolve the field - v = if @resolve_proc + if @resolve_proc # Might be nil, still want to call the func in that case @resolve_proc.call(inner_obj, args, ctx) - elsif @resolver_class - singleton_inst = @resolver_class.new(object: inner_obj, context: query_ctx) - public_send_field(singleton_inst, args, ctx) else public_send_field(after_obj, args, ctx) end - apply_scope(v, ctx) else nil end end end + # This method is called by the interpreter for each field. + # You can extend it in your base field classes. + # @param object [GraphQL::Schema::Object] An instance of some type class, wrapping an application object + # @param args [Hash] A symbol-keyed hash of Ruby keyword arguments. (Empty if no args) + # @param ctx [GraphQL::Query::Context] + def resolve(object, args, ctx) + if @resolve_proc + raise "Can't run resolve proc for #{path} when using GraphQL::Execution::Interpreter" + end + begin + # Unwrap the GraphQL object to get the application object. + application_object = object.object + if self.authorized?(application_object, ctx) + # Apply field extensions + with_extensions(object, args, ctx) do |extended_obj, extended_args| + field_receiver = if @resolver_class + resolver_obj = if extended_obj.is_a?(GraphQL::Schema::Object) + extended_obj.object + else + extended_obj + end + @resolver_class.new(object: resolver_obj, context: ctx) + else + extended_obj + end + + if field_receiver.respond_to?(@resolver_method) + # Call the method with kwargs, if there are any + if extended_args.any? + field_receiver.public_send(@resolver_method, **extended_args) + else + field_receiver.public_send(@resolver_method) + end + else + resolve_field_method(field_receiver, extended_args, ctx) + end + end + end + rescue GraphQL::UnauthorizedError => err + ctx.schema.unauthorized_object(err) + end + rescue GraphQL::ExecutionError => err + err + end + # Find a way to resolve this field, checking: # # - Hash keys, if the wrapped object is a hash; @@ -391,7 +529,7 @@ def resolve_field_method(obj, ruby_kwargs, ctx) raise <<-ERR Failed to implement #{@owner.graphql_name}.#{@name}, tried: - - `#{obj.class}##{@method_sym}`, which did not exist + - `#{obj.class}##{@resolver_method}`, which did not exist - `#{obj.object.class}##{@method_sym}`, which did not exist - Looking up hash key `#{@method_sym.inspect}` or `#{@method_str.inspect}` on `#{obj.object}`, but it wasn't a Hash @@ -400,23 +538,9 @@ def resolve_field_method(obj, ruby_kwargs, ctx) end end - private - - def apply_scope(value, ctx) - if scoped? - ctx.schema.after_lazy(value) do |inner_value| - @type.unwrap.scope_items(inner_value, ctx) - end - else - value - end - end - - CONTEXT_EXTRAS = [:path] - # @param ctx [GraphQL::Query::Context::FieldResolutionContext] def fetch_extra(extra_name, ctx) - if !CONTEXT_EXTRAS.include?(extra_name) && respond_to?(extra_name) + if extra_name != :path && respond_to?(extra_name) self.public_send(extra_name) elsif ctx.respond_to?(extra_name) ctx.public_send(extra_name) @@ -425,6 +549,8 @@ def fetch_extra(extra_name, ctx) end end + private + NO_ARGS = {}.freeze def public_send_field(obj, graphql_args, field_ctx) @@ -439,14 +565,6 @@ def public_send_field(obj, graphql_args, field_ctx) end end - if connection? - # Remove pagination args before passing it to a user method - ruby_kwargs.delete(:first) - ruby_kwargs.delete(:last) - ruby_kwargs.delete(:before) - ruby_kwargs.delete(:after) - end - @extras.each do |extra_arg| ruby_kwargs[extra_arg] = fetch_extra(extra_arg, field_ctx) end @@ -454,11 +572,59 @@ def public_send_field(obj, graphql_args, field_ctx) ruby_kwargs = NO_ARGS end + query_ctx = field_ctx.query.context + with_extensions(obj, ruby_kwargs, query_ctx) do |extended_obj, extended_args| + if @resolver_class + if extended_obj.is_a?(GraphQL::Schema::Object) + extended_obj = extended_obj.object + end + extended_obj = @resolver_class.new(object: extended_obj, context: query_ctx) + end - if ruby_kwargs.any? - obj.public_send(@method_sym, **ruby_kwargs) + if extended_obj.respond_to?(@resolver_method) + if extended_args.any? + extended_obj.public_send(@resolver_method, **extended_args) + else + extended_obj.public_send(@resolver_method) + end + else + resolve_field_method(extended_obj, extended_args, query_ctx) + end + end + end + + # Wrap execution with hooks. + # Written iteratively to avoid big stack traces. + # @return [Object] Whatever the + def with_extensions(obj, args, ctx) + if @extensions.none? + yield(obj, args) else - obj.public_send(@method_sym) + # Save these so that the originals can be re-given to `after_resolve` handlers. + original_args = args + original_obj = obj + + memos = [] + @extensions.each do |ext| + ext.before_resolve(object: obj, arguments: args, context: ctx) do |extended_obj, extended_args, memo| + # update this scope with the yielded value + obj = extended_obj + args = extended_args + # record the memo (or nil if none was yielded) + memos << memo + end + end + # Call the block which actually calls resolve + value = yield(obj, args) + + ctx.schema.after_lazy(value) do |resolved_value| + @extensions.each_with_index do |ext, idx| + memo = memos[idx] + # TODO after_lazy? + resolved_value = ext.after_resolve(object: original_obj, arguments: original_args, context: ctx, value: resolved_value, memo: memo) + end + resolved_value + end end end end diff --git a/lib/graphql/schema/field/connection_extension.rb b/lib/graphql/schema/field/connection_extension.rb new file mode 100644 index 0000000000..3862c844c1 --- /dev/null +++ b/lib/graphql/schema/field/connection_extension.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module GraphQL + class Schema + class Field + class ConnectionExtension < GraphQL::Schema::FieldExtension + def apply + field.argument :after, "String", "Returns the elements in the list that come after the specified cursor.", required: false + field.argument :before, "String", "Returns the elements in the list that come before the specified cursor.", required: false + field.argument :first, "Int", "Returns the first _n_ elements from the list.", required: false + field.argument :last, "Int", "Returns the last _n_ elements from the list.", required: false + end + + # Remove pagination args before passing it to a user method + def before_resolve(object:, arguments:, context:) + next_args = arguments.dup + next_args.delete(:first) + next_args.delete(:last) + next_args.delete(:before) + next_args.delete(:after) + yield(object, next_args) + end + + def after_resolve(value:, object:, arguments:, context:, memo:) + if value.is_a? GraphQL::ExecutionError + # This isn't even going to work because context doesn't have ast_node anymore + context.add_error(value) + nil + elsif value.nil? + nil + else + if object.is_a?(GraphQL::Schema::Object) + object = object.object + end + connection_class = GraphQL::Relay::BaseConnection.connection_for_nodes(value) + connection_class.new( + value, + arguments, + field: field, + max_page_size: field.max_page_size, + parent: object, + context: context, + ) + end + end + + end + end + end +end diff --git a/lib/graphql/schema/field/scope_extension.rb b/lib/graphql/schema/field/scope_extension.rb new file mode 100644 index 0000000000..1c95f48979 --- /dev/null +++ b/lib/graphql/schema/field/scope_extension.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module GraphQL + class Schema + class Field + class ScopeExtension < GraphQL::Schema::FieldExtension + def after_resolve(value:, context:, **rest) + ret_type = @field.type.unwrap + if ret_type.respond_to?(:scope_items) + ret_type.scope_items(value, context) + else + value + end + end + end + end + end +end diff --git a/lib/graphql/schema/field_extension.rb b/lib/graphql/schema/field_extension.rb new file mode 100644 index 0000000000..fa9d923ad8 --- /dev/null +++ b/lib/graphql/schema/field_extension.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true +module GraphQL + class Schema + # Extend this class to make field-level customizations to resolve behavior. + # + # When a extension is added to a field with `extension(MyExtension)`, a `MyExtension` instance + # is created, and its hooks are applied whenever that field is called. + # + # The instance is frozen so that instance variables aren't modified during query execution, + # which could cause all kinds of issues due to race conditions. + class FieldExtension + # @return [GraphQL::Schema::Field] + attr_reader :field + + # @return [Object] + attr_reader :options + + # Called when the extension is mounted with `extension(name, options)`. + # The instance is frozen to avoid improper use of state during execution. + # @param field [GraphQL::Schema::Field] The field where this extension was mounted + # @param options [Object] The second argument to `extension`, or `nil` if nothing was passed. + def initialize(field:, options:) + @field = field + @options = options + apply + freeze + end + + # Called when this extension is attached to a field. + # The field definition may be extended during this method. + # @return [void] + def apply + end + + # Called before resolving {#field}. It should either: + # - `yield` values to continue execution; OR + # - return something else to shortcut field execution. + # @param object [Object] The object the field is being resolved on + # @param arguments [Hash] Ruby keyword arguments for resolving this field + # @param context [Query::Context] the context for this query + # @yieldparam object [Object] The object to continue resolving the field on + # @yieldparam arguments [Hash] The keyword arguments to continue resolving with + # @yieldparam memo [Object] Any extension-specific value which will be passed to {#after_resolve} later + def before_resolve(object:, arguments:, context:) + yield(object, arguments, nil) + end + + # Called after {#field} was resolved, but before the value was added to the GraphQL response. + # Whatever this hook returns will be used as the return value. + # @param object [Object] The object the field is being resolved on + # @param arguments [Hash] Ruby keyword arguments for resolving this field + # @param context [Query::Context] the context for this query + # @param value [Object] Whatever the field previously returned + # @param memo [Object] The third value yielded by {#before_resolve}, or `nil` if there wasn't one + # @return [Object] The return value for this field. + def after_resolve(object:, arguments:, context:, value:, memo:) + value + end + end + end +end diff --git a/lib/graphql/schema/input_object.rb b/lib/graphql/schema/input_object.rb index f1710a1d6e..911a1dd2c2 100644 --- a/lib/graphql/schema/input_object.rb +++ b/lib/graphql/schema/input_object.rb @@ -7,11 +7,15 @@ class InputObject < GraphQL::Schema::Member extend GraphQL::Schema::Member::HasArguments include GraphQL::Dig - def initialize(values, context:, defaults_used:) + def initialize(values = nil, ruby_kwargs: nil, context:, defaults_used:) @context = context - @arguments = self.class.arguments_class.new(values, context: context, defaults_used: defaults_used) - # Symbolized, underscored hash: - @ruby_style_hash = @arguments.to_kwargs + if ruby_kwargs + @ruby_style_hash = ruby_kwargs + else + @arguments = self.class.arguments_class.new(values, context: context, defaults_used: defaults_used) + # Symbolized, underscored hash: + @ruby_style_hash = @arguments.to_kwargs + end # Apply prepares, not great to have it duplicated here. self.class.arguments.each do |name, arg_defn| ruby_kwargs_key = arg_defn.keyword @@ -57,13 +61,15 @@ def unwrap_value(value) def [](key) if @ruby_style_hash.key?(key) @ruby_style_hash[key] - else + elsif @arguments @arguments[key] + else + nil end end def key?(key) - @ruby_style_hash.key?(key) || @arguments.key?(key) + @ruby_style_hash.key?(key) || (@arguments && @arguments.key?(key)) end # A copy of the Ruby-style hash @@ -79,8 +85,9 @@ def argument(*args) argument_defn = super # Add a method access arg_name = argument_defn.graphql_definition.name - define_method(Member::BuildType.underscore(arg_name)) do - @arguments.public_send(arg_name) + method_name = Member::BuildType.underscore(arg_name).to_sym + define_method(method_name) do + @ruby_style_hash[method_name] end end diff --git a/lib/graphql/schema/member/base_dsl_methods.rb b/lib/graphql/schema/member/base_dsl_methods.rb index 267535a38e..498b56a089 100644 --- a/lib/graphql/schema/member/base_dsl_methods.rb +++ b/lib/graphql/schema/member/base_dsl_methods.rb @@ -55,6 +55,10 @@ def introspection(new_introspection = nil) end end + def introspection? + introspection + end + # The mutation this type was derived from, if it was derived from a mutation # @return [Class] def mutation(mutation_class = nil) @@ -79,9 +83,11 @@ def overridden_graphql_name # The default name is the Ruby constant name, # without any namespaces and with any `-Type` suffix removed def default_graphql_name - raise NotImplementedError, 'Anonymous class should declare a `graphql_name`' if name.nil? + @default_graphql_name ||= begin + raise NotImplementedError, 'Anonymous class should declare a `graphql_name`' if name.nil? - name.split("::").last.sub(/Type\Z/, "") + name.split("::").last.sub(/Type\Z/, "") + end end def visible?(context) diff --git a/lib/graphql/schema/member/has_arguments.rb b/lib/graphql/schema/member/has_arguments.rb index a3722ac9d3..66728d30a8 100644 --- a/lib/graphql/schema/member/has_arguments.rb +++ b/lib/graphql/schema/member/has_arguments.rb @@ -29,9 +29,13 @@ def add_argument(arg_defn) # @return [Hash GraphQL::Schema::Argument] Arguments defined on this thing, keyed by name. Includes inherited definitions def arguments - inherited_arguments = ((self.is_a?(Class) && superclass.respond_to?(:arguments)) ? superclass.arguments : {}) + inherited_arguments = ((self.is_a?(Class) && superclass.respond_to?(:arguments)) ? superclass.arguments : nil) # Local definitions override inherited ones - inherited_arguments.merge(own_arguments) + if inherited_arguments + inherited_arguments.merge(own_arguments) + else + own_arguments + end end # @param new_arg_class [Class] A class to use for building argument definitions diff --git a/lib/graphql/schema/member/has_fields.rb b/lib/graphql/schema/member/has_fields.rb index 0f38f089cc..c7b6e7b086 100644 --- a/lib/graphql/schema/member/has_fields.rb +++ b/lib/graphql/schema/member/has_fields.rb @@ -4,47 +4,6 @@ class Schema class Member # Shared code for Object and Interface module HasFields - class << self - # When this module is added to a class, - # add a place for that class's default behaviors - def self.extended(child_class) - add_default_resolve_module(child_class) - super - end - - # Create a module which will have instance methods for implementing fields. - # These will be `super` methods for fields in interfaces, objects and mutations. - # Use an instance variable on the class instead of a constant - # so that module namespaces won't be an issue. (If we used constants, - # `child_class::DefaultResolve` might find a constant from an included module.) - def add_default_resolve_module(child_class) - if child_class.instance_variable_get(:@_default_resolve) - # This can happen when an object implements an interface, - # since that interface has the `included` hook above. - return - end - - default_resolve_module = Module.new - child_class.instance_variable_set(:@_default_resolve, default_resolve_module) - child_class.include(default_resolve_module) - end - end - - # When this is included into interfaces, - # add a place for default field behaviors - def included(child_class) - HasFields.add_default_resolve_module(child_class) - # Also, prepare a place for default field implementations - super - end - - # When a subclass of objects are created, - # add a place for that subclass's default field behaviors - def inherited(child_class) - HasFields.add_default_resolve_module(child_class) - super - end - # Add a field to this object or interface with the given definition # @see {GraphQL::Schema::Field#initialize} for method signature # @return [void] @@ -67,24 +26,23 @@ def fields end def get_field(field_name) - for ancestor in ancestors - if ancestor.respond_to?(:own_fields) && f = ancestor.own_fields[field_name] - return f + if (f = own_fields[field_name]) + f + else + for ancestor in ancestors + if ancestor.respond_to?(:own_fields) && f = ancestor.own_fields[field_name] + return f + end end + nil end - nil end # Register this field with the class, overriding a previous one if needed. - # Also, add a parent method for resolving this field. # @param field_defn [GraphQL::Schema::Field] # @return [void] def add_field(field_defn) own_fields[field_defn.name] = field_defn - if !method_defined?(field_defn.method_sym) - # Only add the super method if there isn't one already. - add_super_method(field_defn.name.inspect, field_defn.method_sym) - end nil end @@ -103,36 +61,17 @@ def field_class(new_field_class = nil) end def global_id_field(field_name) - field field_name, "ID", null: false, resolve: GraphQL::Relay::GlobalIdResolve.new(type: self) + id_resolver = GraphQL::Relay::GlobalIdResolve.new(type: self) + field field_name, "ID", null: false + define_method(field_name) do + id_resolver.call(object, {}, context) + end end # @return [Array] Fields defined on this class _specifically_, not parent classes def own_fields @own_fields ||= {} end - - private - # Find the magic module for holding super methods, - # and add a field named `method_name` for implementing the field - # called `field_name`. - # It will be the `super` method if the method is overwritten in the class definition. - def add_super_method(field_key, method_name) - default_resolve_module = @_default_resolve - if default_resolve_module.nil? - # This should have been set up in one of the inherited or included hooks above, - # if it wasn't, it's because those hooks weren't called because `super` wasn't present. - raise <<-ERR -Uh oh! #{self} doesn't have a default resolve module. This probably means that an `inherited` hook didn't call super. -Check `inherited` on #{self}'s superclasses. -ERR - end - default_resolve_module.module_eval <<-RUBY, __FILE__, __LINE__ + 1 - def #{method_name}(**args) - field_inst = self.class.fields[#{field_key}] || raise(%|Failed to find field #{field_key} for \#{self.class} among \#{self.class.fields.keys}|) - field_inst.resolve_field_method(self, args, context) - end - RUBY - end end end end diff --git a/lib/graphql/schema/mutation.rb b/lib/graphql/schema/mutation.rb index fffe1d3f79..c737e18fe3 100644 --- a/lib/graphql/schema/mutation.rb +++ b/lib/graphql/schema/mutation.rb @@ -122,7 +122,9 @@ def generate_payload_type description("Autogenerated return type of #{mutation_name}") mutation(mutation_class) mutation_fields.each do |name, f| - field(name, field: f) + # Reattach the already-defined field here + # (The field's `.owner` will still point to the mutation, not the object type, I think) + add_field(f) end end end diff --git a/lib/graphql/schema/non_null.rb b/lib/graphql/schema/non_null.rb index d7a6d64a29..84c4318743 100644 --- a/lib/graphql/schema/non_null.rb +++ b/lib/graphql/schema/non_null.rb @@ -24,10 +24,14 @@ def non_null? def list? @of_type.list? end - + def to_type_signature "#{@of_type.to_type_signature}!" end + + def inspect + "#<#{self.class.name} @of_type=#{@of_type.inspect}>" + end end end end diff --git a/lib/graphql/schema/object.rb b/lib/graphql/schema/object.rb index 081448f5e4..6dcafd5bc1 100644 --- a/lib/graphql/schema/object.rb +++ b/lib/graphql/schema/object.rb @@ -35,19 +35,33 @@ class << self # @return [GraphQL::Schema::Object, GraphQL::Execution::Lazy] # @raise [GraphQL::UnauthorizedError] if the user-provided hook returns `false` def authorized_new(object, context) - context.schema.after_lazy(authorized?(object, context)) do |is_authorized| + auth_val = begin + authorized?(object, context) + # rescue GraphQL::UnauthorizedError => err + # context.schema.unauthorized_object(err) + end + + context.schema.after_lazy(auth_val) do |is_authorized| if is_authorized self.new(object, context) else # It failed the authorization check, so go to the schema's authorized object hook err = GraphQL::UnauthorizedError.new(object: object, type: self, context: context) # If a new value was returned, wrap that instead of the original value - new_obj = context.schema.unauthorized_object(err) - if new_obj - self.new(new_obj, context) + begin + new_obj = context.schema.unauthorized_object(err) + if new_obj + self.new(new_obj, context) + else + nil + end + # rescue GraphQL::ExecutionError => err + # err end end end + # rescue GraphQL::ExecutionError => err + # err end end diff --git a/lib/graphql/schema/relay_classic_mutation.rb b/lib/graphql/schema/relay_classic_mutation.rb index 7a5a06b93d..ec540bf7b4 100644 --- a/lib/graphql/schema/relay_classic_mutation.rb +++ b/lib/graphql/schema/relay_classic_mutation.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require "graphql/types/string" module GraphQL class Schema @@ -27,10 +28,48 @@ class RelayClassicMutation < GraphQL::Schema::Mutation # Override {GraphQL::Schema::Resolver#resolve_with_support} to # delete `client_mutation_id` from the kwargs. - def resolve_with_support(**kwargs) - # This is handled by Relay::Mutation::Resolve, a bit hacky, but here we are. - kwargs.delete(:client_mutation_id) - super + def resolve_with_support(**inputs) + # Without the interpreter, the inputs are unwrapped by an instrumenter. + # But when using the interpreter, no instrumenters are applied. + if context.interpreter? + input = inputs[:input].to_h + # Transfer these from the top-level hash to the + # shortcutted `input:` object + self.class.extras.each do |ext| + input[ext] = inputs[ext] + end + else + input = inputs + end + + if input + # This is handled by Relay::Mutation::Resolve, a bit hacky, but here we are. + input_kwargs = input.to_h + client_mutation_id = input_kwargs.delete(:client_mutation_id) + else + # Relay Classic Mutations with no `argument`s + # don't require `input:` + input_kwargs = {} + end + + return_value = if input_kwargs.any? + super(input_kwargs) + else + super() + end + + # Again, this is done by an instrumenter when using non-interpreter execution. + if context.interpreter? + context.schema.after_lazy(return_value) do |return_hash| + # It might be an error + if return_hash.is_a?(Hash) + return_hash[:client_mutation_id] = client_mutation_id + end + return_hash + end + else + return_value + end end class << self diff --git a/lib/graphql/schema/resolver.rb b/lib/graphql/schema/resolver.rb index fc49e6c9e8..b560ecc121 100644 --- a/lib/graphql/schema/resolver.rb +++ b/lib/graphql/schema/resolver.rb @@ -310,7 +310,7 @@ def field_options type: type_expr, description: description, extras: extras, - method: :resolve_with_support, + resolver_method: :resolve_with_support, resolver_class: self, arguments: arguments, null: null, diff --git a/lib/graphql/schema/traversal.rb b/lib/graphql/schema/traversal.rb index 4beedaca4d..86167d9b77 100644 --- a/lib/graphql/schema/traversal.rb +++ b/lib/graphql/schema/traversal.rb @@ -21,15 +21,19 @@ class Traversal def initialize(schema, introspection: true) @schema = schema @introspection = introspection + built_in_insts = [ + GraphQL::Relay::ConnectionInstrumentation, + GraphQL::Relay::EdgesInstrumentation, + GraphQL::Relay::Mutation::Instrumentation, + ] + + if schema.query_execution_strategy != GraphQL::Execution::Interpreter + built_in_insts << GraphQL::Schema::Member::Instrumentation + end + @field_instrumenters = schema.instrumenters[:field] + - # Wrap Relay-related objects in wrappers - [ - GraphQL::Relay::ConnectionInstrumentation, - GraphQL::Relay::EdgesInstrumentation, - GraphQL::Relay::Mutation::Instrumentation, - GraphQL::Schema::Member::Instrumentation, - ] + + built_in_insts + schema.instrumenters[:field_after_built_ins] # These fields have types specified by _name_, diff --git a/lib/graphql/static_validation.rb b/lib/graphql/static_validation.rb index caac1cfd23..84330cd10a 100644 --- a/lib/graphql/static_validation.rb +++ b/lib/graphql/static_validation.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true -require "graphql/static_validation/message" -require "graphql/static_validation/arguments_validator" +require "graphql/static_validation/error" require "graphql/static_validation/definition_dependencies" require "graphql/static_validation/type_stack" require "graphql/static_validation/validator" require "graphql/static_validation/validation_context" require "graphql/static_validation/literal_validator" - +require "graphql/static_validation/base_visitor" +require "graphql/static_validation/no_validate_visitor" rules_glob = File.expand_path("../static_validation/rules/*.rb", __FILE__) Dir.glob(rules_glob).each do |file| @@ -14,3 +14,5 @@ end require "graphql/static_validation/all_rules" +require "graphql/static_validation/default_visitor" +require "graphql/static_validation/interpreter_visitor" diff --git a/lib/graphql/static_validation/all_rules.rb b/lib/graphql/static_validation/all_rules.rb index 7a196b87c9..503f634abf 100644 --- a/lib/graphql/static_validation/all_rules.rb +++ b/lib/graphql/static_validation/all_rules.rb @@ -11,9 +11,10 @@ module StaticValidation GraphQL::StaticValidation::DirectivesAreDefined, GraphQL::StaticValidation::DirectivesAreInValidLocations, GraphQL::StaticValidation::UniqueDirectivesPerLocation, + GraphQL::StaticValidation::OperationNamesAreValid, + GraphQL::StaticValidation::FragmentNamesAreUnique, GraphQL::StaticValidation::FragmentsAreFinite, GraphQL::StaticValidation::FragmentsAreNamed, - GraphQL::StaticValidation::FragmentNamesAreUnique, GraphQL::StaticValidation::FragmentsAreUsed, GraphQL::StaticValidation::FragmentTypesExist, GraphQL::StaticValidation::FragmentsAreOnCompositeTypes, @@ -33,7 +34,6 @@ module StaticValidation GraphQL::StaticValidation::VariableUsagesAreAllowed, GraphQL::StaticValidation::MutationRootExists, GraphQL::StaticValidation::SubscriptionRootExists, - GraphQL::StaticValidation::OperationNamesAreValid, ] end end diff --git a/lib/graphql/static_validation/arguments_validator.rb b/lib/graphql/static_validation/arguments_validator.rb deleted file mode 100644 index 960d22abf1..0000000000 --- a/lib/graphql/static_validation/arguments_validator.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module StaticValidation - # Implement validate_node - class ArgumentsValidator - module ArgumentsValidatorHelpers - private - - def parent_name(parent, type_defn) - if parent.is_a?(GraphQL::Language::Nodes::Field) - parent.alias || parent.name - elsif parent.is_a?(GraphQL::Language::Nodes::InputObject) - type_defn.name - else - parent.name - end - end - - def node_type(parent) - parent.class.name.split("::").last - end - end - - include ArgumentsValidatorHelpers - include GraphQL::StaticValidation::Message::MessageHelper - - def validate(context) - visitor = context.visitor - visitor[GraphQL::Language::Nodes::Argument] << ->(node, parent) { - case parent - when GraphQL::Language::Nodes::InputObject - arg_defn = context.argument_definition - if arg_defn.nil? - return - else - parent_defn = arg_defn.type.unwrap - if !parent_defn.is_a?(GraphQL::InputObjectType) - return - end - end - when GraphQL::Language::Nodes::Directive - parent_defn = context.schema.directives[parent.name] - when GraphQL::Language::Nodes::Field - parent_defn = context.field_definition - else - raise "Unexpected argument parent: #{parent.class} (##{parent})" - end - validate_node(parent, node, parent_defn, context) - } - end - end - - end -end diff --git a/lib/graphql/static_validation/base_visitor.rb b/lib/graphql/static_validation/base_visitor.rb new file mode 100644 index 0000000000..e5690a0737 --- /dev/null +++ b/lib/graphql/static_validation/base_visitor.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class BaseVisitor < GraphQL::Language::Visitor + def initialize(document, context) + @path = [] + @object_types = [] + @directives = [] + @field_definitions = [] + @argument_definitions = [] + @directive_definitions = [] + @context = context + @schema = context.schema + super(document) + end + + # This will be overwritten by {InternalRepresentation::Rewrite} if it's included + def rewrite_document + nil + end + + attr_reader :context + + # @return [Array] Types whose scope we've entered + attr_reader :object_types + + # @return [Array] The nesting of the current position in the AST + def path + @path.dup + end + + # Build a class to visit the AST and perform validation, + # or use a pre-built class if rules is `ALL_RULES` or empty. + # @param rules [Array] + # @param rewrite [Boolean] if `false`, don't include rewrite + # @return [Class] A class for validating `rules` during visitation + def self.including_rules(rules, rewrite: true) + if rules.none? + if rewrite + NoValidateVisitor + else + # It's not doing _anything?!?_ + BaseVisitor + end + elsif rules == ALL_RULES + if rewrite + DefaultVisitor + else + InterpreterVisitor + end + else + visitor_class = Class.new(self) do + include(GraphQL::StaticValidation::DefinitionDependencies) + end + + rules.reverse_each do |r| + # If it's a class, it gets attached later. + if !r.is_a?(Class) + visitor_class.include(r) + end + end + + if rewrite + visitor_class.include(GraphQL::InternalRepresentation::Rewrite) + end + visitor_class.include(ContextMethods) + visitor_class + end + end + + module ContextMethods + def on_operation_definition(node, parent) + object_type = @schema.root_type_for_operation(node.operation_type) + @object_types.push(object_type) + @path.push("#{node.operation_type}#{node.name ? " #{node.name}" : ""}") + super + @object_types.pop + @path.pop + end + + def on_fragment_definition(node, parent) + on_fragment_with_type(node) do + @path.push("fragment #{node.name}") + super + end + end + + def on_inline_fragment(node, parent) + on_fragment_with_type(node) do + @path.push("...#{node.type ? " on #{node.type.to_query_string}" : ""}") + super + end + end + + def on_field(node, parent) + parent_type = @object_types.last + field_definition = @schema.get_field(parent_type, node.name) + @field_definitions.push(field_definition) + if !field_definition.nil? + next_object_type = field_definition.type.unwrap + @object_types.push(next_object_type) + else + @object_types.push(nil) + end + @path.push(node.alias || node.name) + super + @field_definitions.pop + @object_types.pop + @path.pop + end + + def on_directive(node, parent) + directive_defn = @schema.directives[node.name] + @directive_definitions.push(directive_defn) + super + @directive_definitions.pop + end + + def on_argument(node, parent) + argument_defn = if (arg = @argument_definitions.last) + arg_type = arg.type.unwrap + if arg_type.kind.input_object? + arg_type.input_fields[node.name] + else + nil + end + elsif (directive_defn = @directive_definitions.last) + directive_defn.arguments[node.name] + elsif (field_defn = @field_definitions.last) + field_defn.arguments[node.name] + else + nil + end + + @argument_definitions.push(argument_defn) + @path.push(node.name) + super + @argument_definitions.pop + @path.pop + end + + def on_fragment_spread(node, parent) + @path.push("... #{node.name}") + super + @path.pop + end + + # @return [GraphQL::BaseType] The current object type + def type_definition + @object_types.last + end + + # @return [GraphQL::BaseType] The type which the current type came from + def parent_type_definition + @object_types[-2] + end + + # @return [GraphQL::Field, nil] The most-recently-entered GraphQL::Field, if currently inside one + def field_definition + @field_definitions.last + end + + # @return [GraphQL::Directive, nil] The most-recently-entered GraphQL::Directive, if currently inside one + def directive_definition + @directive_definitions.last + end + + # @return [GraphQL::Argument, nil] The most-recently-entered GraphQL::Argument, if currently inside one + def argument_definition + # Don't get the _last_ one because that's the current one. + # Get the second-to-last one, which is the parent of the current one. + @argument_definitions[-2] + end + + private + + def on_fragment_with_type(node) + object_type = if node.type + @schema.types.fetch(node.type.name, nil) + else + @object_types.last + end + @object_types.push(object_type) + yield(node) + @object_types.pop + @path.pop + end + end + + private + + def add_error(error, path: nil) + error.path ||= (path || @path.dup) + context.errors << error + end + + end + end +end diff --git a/lib/graphql/static_validation/default_visitor.rb b/lib/graphql/static_validation/default_visitor.rb new file mode 100644 index 0000000000..1202f5b3f2 --- /dev/null +++ b/lib/graphql/static_validation/default_visitor.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class DefaultVisitor < BaseVisitor + include(GraphQL::StaticValidation::DefinitionDependencies) + + StaticValidation::ALL_RULES.reverse_each do |r| + include(r) + end + + include(GraphQL::InternalRepresentation::Rewrite) + include(ContextMethods) + end + end +end diff --git a/lib/graphql/static_validation/definition_dependencies.rb b/lib/graphql/static_validation/definition_dependencies.rb index 8deba284e6..c4526a181a 100644 --- a/lib/graphql/static_validation/definition_dependencies.rb +++ b/lib/graphql/static_validation/definition_dependencies.rb @@ -4,79 +4,73 @@ module StaticValidation # Track fragment dependencies for operations # and expose the fragment definitions which # are used by a given operation - class DefinitionDependencies - def self.mount(visitor) - deps = self.new - deps.mount(visitor) - deps - end + module DefinitionDependencies + attr_reader :dependencies - def initialize - @node_paths = {} + def initialize(*) + super + @defdep_node_paths = {} # { name => node } pairs for fragments - @fragment_definitions = {} + @defdep_fragment_definitions = {} # This tracks dependencies from fragment to Node where it was used # { fragment_definition_node => [dependent_node, dependent_node]} - @dependent_definitions = Hash.new { |h, k| h[k] = Set.new } + @defdep_dependent_definitions = Hash.new { |h, k| h[k] = Set.new } # First-level usages of spreads within definitions # (When a key has an empty list as its value, # we can resolve that key's depenedents) # { definition_node => [node, node ...] } - @immediate_dependencies = Hash.new { |h, k| h[k] = Set.new } - end - - # A map of operation definitions to an array of that operation's dependencies - # @return [DependencyMap] - def dependency_map(&block) - @dependency_map ||= resolve_dependencies(&block) - end + @defdep_immediate_dependencies = Hash.new { |h, k| h[k] = Set.new } - def mount(context) - visitor = context.visitor # When we encounter a spread, # this node is the one who depends on it - current_parent = nil - - visitor[GraphQL::Language::Nodes::Document] << ->(node, prev_node) { - node.definitions.each do |definition| - case definition - when GraphQL::Language::Nodes::OperationDefinition - when GraphQL::Language::Nodes::FragmentDefinition - @fragment_definitions[definition.name] = definition - end - end - } + @defdep_current_parent = nil + end - visitor[GraphQL::Language::Nodes::OperationDefinition] << ->(node, prev_node) { - @node_paths[node] = NodeWithPath.new(node, context.path) - current_parent = node + def on_document(node, parent) + node.definitions.each do |definition| + if definition.is_a? GraphQL::Language::Nodes::FragmentDefinition + @defdep_fragment_definitions[definition.name] = definition + end + end + super + @dependencies = dependency_map { |defn, spreads, frag| + context.on_dependency_resolve_handlers.each { |h| h.call(defn, spreads, frag) } } + end - visitor[GraphQL::Language::Nodes::OperationDefinition].leave << ->(node, prev_node) { - current_parent = nil - } + def on_operation_definition(node, prev_node) + @defdep_node_paths[node] = NodeWithPath.new(node, context.path) + @defdep_current_parent = node + super + @defdep_current_parent = nil + end - visitor[GraphQL::Language::Nodes::FragmentDefinition] << ->(node, prev_node) { - @node_paths[node] = NodeWithPath.new(node, context.path) - current_parent = node - } + def on_fragment_definition(node, parent) + @defdep_node_paths[node] = NodeWithPath.new(node, context.path) + @defdep_current_parent = node + super + @defdep_current_parent = nil + end - visitor[GraphQL::Language::Nodes::FragmentDefinition].leave << ->(node, prev_node) { - current_parent = nil - } + def on_fragment_spread(node, parent) + @defdep_node_paths[node] = NodeWithPath.new(node, context.path) - visitor[GraphQL::Language::Nodes::FragmentSpread] << ->(node, prev_node) { - @node_paths[node] = NodeWithPath.new(node, context.path) + # Track both sides of the dependency + @defdep_dependent_definitions[@defdep_fragment_definitions[node.name]] << @defdep_current_parent + @defdep_immediate_dependencies[@defdep_current_parent] << node + super + end - # Track both sides of the dependency - @dependent_definitions[@fragment_definitions[node.name]] << current_parent - @immediate_dependencies[current_parent] << node - } + # A map of operation definitions to an array of that operation's dependencies + # @return [DependencyMap] + def dependency_map(&block) + @dependency_map ||= resolve_dependencies(&block) end + # Map definition AST nodes to the definition AST nodes they depend on. # Expose circular depednencies. class DependencyMap @@ -122,14 +116,14 @@ def resolve_dependencies dependency_map = DependencyMap.new # Don't allow the loop to run more times # than the number of fragments in the document - max_loops = @fragment_definitions.size + max_loops = @defdep_fragment_definitions.size loops = 0 # Instead of tracking independent fragments _as you visit_, # determine them at the end. This way, we can treat fragments with the # same name as if they were the same name. If _any_ of the fragments # with that name has a dependency, we record it. - independent_fragment_nodes = @fragment_definitions.values - @immediate_dependencies.keys + independent_fragment_nodes = @defdep_fragment_definitions.values - @defdep_immediate_dependencies.keys while fragment_node = independent_fragment_nodes.pop loops += 1 @@ -138,22 +132,22 @@ def resolve_dependencies end # Since it's independent, let's remove it from here. # That way, we can use the remainder to identify cycles - @immediate_dependencies.delete(fragment_node) - fragment_usages = @dependent_definitions[fragment_node] + @defdep_immediate_dependencies.delete(fragment_node) + fragment_usages = @defdep_dependent_definitions[fragment_node] if fragment_usages.none? # If we didn't record any usages during the visit, # then this fragment is unused. - dependency_map.unused_dependencies << @node_paths[fragment_node] + dependency_map.unused_dependencies << @defdep_node_paths[fragment_node] else fragment_usages.each do |definition_node| # Register the dependency AND second-order dependencies dependency_map[definition_node] << fragment_node dependency_map[definition_node].concat(dependency_map[fragment_node]) # Since we've regestered it, remove it from our to-do list - deps = @immediate_dependencies[definition_node] + deps = @defdep_immediate_dependencies[definition_node] # Can't find a way to _just_ delete from `deps` and return the deleted entries removed, remaining = deps.partition { |spread| spread.name == fragment_node.name } - @immediate_dependencies[definition_node] = remaining + @defdep_immediate_dependencies[definition_node] = remaining if block_given? yield(definition_node, removed, fragment_node) end @@ -170,20 +164,20 @@ def resolve_dependencies # If any dependencies were _unmet_ # (eg, spreads with no corresponding definition) # then they're still in there - @immediate_dependencies.each do |defn_node, deps| + @defdep_immediate_dependencies.each do |defn_node, deps| deps.each do |spread| - if @fragment_definitions[spread.name].nil? - dependency_map.unmet_dependencies[@node_paths[defn_node]] << @node_paths[spread] + if @defdep_fragment_definitions[spread.name].nil? + dependency_map.unmet_dependencies[@defdep_node_paths[defn_node]] << @defdep_node_paths[spread] deps.delete(spread) end end if deps.none? - @immediate_dependencies.delete(defn_node) + @defdep_immediate_dependencies.delete(defn_node) end end # Anything left in @immediate_dependencies is cyclical - cyclical_nodes = @immediate_dependencies.keys.map { |n| @node_paths[n] } + cyclical_nodes = @defdep_immediate_dependencies.keys.map { |n| @defdep_node_paths[n] } # @immediate_dependencies also includes operation names, but we don't care about # those. They became nil when we looked them up on `@fragment_definitions`, so remove them. cyclical_nodes.compact! diff --git a/lib/graphql/static_validation/message.rb b/lib/graphql/static_validation/error.rb similarity index 57% rename from lib/graphql/static_validation/message.rb rename to lib/graphql/static_validation/error.rb index 073a5f398b..7920f69994 100644 --- a/lib/graphql/static_validation/message.rb +++ b/lib/graphql/static_validation/error.rb @@ -2,22 +2,23 @@ module GraphQL module StaticValidation # Generates GraphQL-compliant validation message. - class Message + class Error # Convenience for validators - module MessageHelper - # Error `message` is located at `node` - def message(message, nodes, context: nil, path: nil) + module ErrorHelper + # Error `error_message` is located at `node` + def error(error_message, nodes, context: nil, path: nil, extensions: {}) path ||= context.path nodes = Array(nodes) - GraphQL::StaticValidation::Message.new(message, nodes: nodes, path: path) + GraphQL::StaticValidation::Error.new(error_message, nodes: nodes, path: path) end end - attr_reader :message, :path + attr_reader :message + attr_accessor :path - def initialize(message, path: [], nodes: []) + def initialize(message, path: nil, nodes: []) @message = message - @nodes = nodes + @nodes = Array(nodes) @path = path end @@ -25,9 +26,8 @@ def initialize(message, path: [], nodes: []) def to_h { "message" => message, - "locations" => locations, - "fields" => path, - } + "locations" => locations + }.tap { |h| h["path"] = path unless path.nil? } end private diff --git a/lib/graphql/static_validation/interpreter_visitor.rb b/lib/graphql/static_validation/interpreter_visitor.rb new file mode 100644 index 0000000000..d652222b9d --- /dev/null +++ b/lib/graphql/static_validation/interpreter_visitor.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class InterpreterVisitor < BaseVisitor + include(GraphQL::StaticValidation::DefinitionDependencies) + + StaticValidation::ALL_RULES.reverse_each do |r| + include(r) + end + + include(ContextMethods) + end + end +end diff --git a/lib/graphql/static_validation/no_validate_visitor.rb b/lib/graphql/static_validation/no_validate_visitor.rb new file mode 100644 index 0000000000..4fc3303433 --- /dev/null +++ b/lib/graphql/static_validation/no_validate_visitor.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class NoValidateVisitor < StaticValidation::BaseVisitor + include(GraphQL::InternalRepresentation::Rewrite) + include(GraphQL::StaticValidation::DefinitionDependencies) + include(ContextMethods) + end + end +end diff --git a/lib/graphql/static_validation/rules/argument_literals_are_compatible.rb b/lib/graphql/static_validation/rules/argument_literals_are_compatible.rb index a68e872bd7..bdf25a0e39 100644 --- a/lib/graphql/static_validation/rules/argument_literals_are_compatible.rb +++ b/lib/graphql/static_validation/rules/argument_literals_are_compatible.rb @@ -1,46 +1,98 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class ArgumentLiteralsAreCompatible < GraphQL::StaticValidation::ArgumentsValidator - def validate_node(parent, node, defn, context) - return if node.value.is_a?(GraphQL::Language::Nodes::VariableIdentifier) - arg_defn = defn.arguments[node.name] - return unless arg_defn - - begin - valid = context.valid_literal?(node.value, arg_defn.type) - rescue GraphQL::CoercionError => err - error_message = err.message - context.schema.error_bubbling - if !context.schema.error_bubbling && !arg_defn.type.unwrap.kind.scalar? - # if error bubbling is disabled and the arg that caused this error isn't a scalar then - # short-circuit here so we avoid bubbling this up to whatever input_object / array contains us - return false - end - rescue GraphQL::LiteralValidationError => err - # check to see if the ast node that caused the error to be raised is - # the same as the node we were checking here. - matched = if arg_defn.type.kind.list? - # for a list we claim an error if the node is contained in our list - node.value.include?(err.ast_value) - elsif arg_defn.type.kind.input_object? && node.value.is_a?(GraphQL::Language::Nodes::InputObject) - # for an input object we check the arguments - node.value.arguments.include?(err.ast_value) + module ArgumentLiteralsAreCompatible + # TODO dedup with ArgumentsAreDefined + def on_argument(node, parent) + parent_defn = case parent + when GraphQL::Language::Nodes::InputObject + arg_defn = context.argument_definition + if arg_defn.nil? + nil else - # otherwise we just check equality - node.value == (err.ast_value) + arg_ret_type = arg_defn.type.unwrap + if !arg_ret_type.is_a?(GraphQL::InputObjectType) + nil + else + arg_ret_type + end end - return false unless matched + when GraphQL::Language::Nodes::Directive + context.schema.directives[parent.name] + when GraphQL::Language::Nodes::Field + context.field_definition + else + raise "Unexpected argument parent: #{parent.class} (##{parent})" end - return if valid - error_message ||= begin - kind_of_node = node_type(parent) - error_arg_name = parent_name(parent, defn) - "Argument '#{node.name}' on #{kind_of_node} '#{error_arg_name}' has an invalid value. Expected type '#{arg_defn.type}'." + if parent_defn && !node.value.is_a?(GraphQL::Language::Nodes::VariableIdentifier) + arg_defn = parent_defn.arguments[node.name] + if arg_defn + begin + valid = context.valid_literal?(node.value, arg_defn.type) + rescue GraphQL::CoercionError => err + context.schema.error_bubbling + if !context.schema.error_bubbling && !arg_defn.type.unwrap.kind.scalar? + # if error bubbling is disabled and the arg that caused this error isn't a scalar then + # short-circuit here so we avoid bubbling this up to whatever input_object / array contains us + return super + end + error = GraphQL::StaticValidation::ArgumentLiteralsAreCompatibleError.new(err.message, nodes: parent, type: "CoercionError") + rescue GraphQL::LiteralValidationError => err + # check to see if the ast node that caused the error to be raised is + # the same as the node we were checking here. + matched = if arg_defn.type.kind.list? + # for a list we claim an error if the node is contained in our list + node.value.include?(err.ast_value) + elsif arg_defn.type.kind.input_object? && node.value.is_a?(GraphQL::Language::Nodes::InputObject) + # for an input object we check the arguments + node.value.arguments.include?(err.ast_value) + else + # otherwise we just check equality + node.value == (err.ast_value) + end + if !matched + # This node isn't the node that caused the error, + # So halt this visit but continue visiting the rest of the tree + return super + end + end + + if !valid + error ||= begin + kind_of_node = node_type(parent) + error_arg_name = parent_name(parent, parent_defn) + + GraphQL::StaticValidation::ArgumentLiteralsAreCompatibleError.new( + "Argument '#{node.name}' on #{kind_of_node} '#{error_arg_name}' has an invalid value. Expected type '#{arg_defn.type}'.", + nodes: parent, + type: kind_of_node, + argument: node.name + ) + end + add_error(error) + end + end end - context.errors << message(error_message, parent, context: context) + super + end + + + private + + def parent_name(parent, type_defn) + if parent.is_a?(GraphQL::Language::Nodes::Field) + parent.alias || parent.name + elsif parent.is_a?(GraphQL::Language::Nodes::InputObject) + type_defn.name + else + parent.name + end + end + + def node_type(parent) + parent.class.name.split("::").last end end end diff --git a/lib/graphql/static_validation/rules/argument_literals_are_compatible_error.rb b/lib/graphql/static_validation/rules/argument_literals_are_compatible_error.rb new file mode 100644 index 0000000000..b6593c65fb --- /dev/null +++ b/lib/graphql/static_validation/rules/argument_literals_are_compatible_error.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class ArgumentLiteralsAreCompatibleError < StaticValidation::Error + attr_reader :type_name + attr_reader :argument_name + + def initialize(message, path: nil, nodes: [], type:, argument: nil) + super(message, path: path, nodes: nodes) + @type_name = type + @argument_name = argument + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "typeName" => type_name + }.tap { |h| h["argumentName"] = argument_name unless argument_name.nil? } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "argumentLiteralsIncompatible" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/argument_names_are_unique.rb b/lib/graphql/static_validation/rules/argument_names_are_unique.rb index 43c7f68f9a..bc8fcea305 100644 --- a/lib/graphql/static_validation/rules/argument_names_are_unique.rb +++ b/lib/graphql/static_validation/rules/argument_names_are_unique.rb @@ -1,27 +1,27 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class ArgumentNamesAreUnique - include GraphQL::StaticValidation::Message::MessageHelper + module ArgumentNamesAreUnique + include GraphQL::StaticValidation::Error::ErrorHelper - def validate(context) - context.visitor[GraphQL::Language::Nodes::Field] << ->(node, parent) { - validate_arguments(node, context) - } + def on_field(node, parent) + validate_arguments(node) + super + end - context.visitor[GraphQL::Language::Nodes::Directive] << ->(node, parent) { - validate_arguments(node, context) - } + def on_directive(node, parent) + validate_arguments(node) + super end - def validate_arguments(node, context) + def validate_arguments(node) argument_defns = node.arguments if argument_defns.any? args_by_name = Hash.new { |h, k| h[k] = [] } argument_defns.each { |a| args_by_name[a.name] << a } args_by_name.each do |name, defns| if defns.size > 1 - context.errors << message("There can be only one argument named \"#{name}\"", defns, context: context) + add_error(GraphQL::StaticValidation::ArgumentNamesAreUniqueError.new("There can be only one argument named \"#{name}\"", nodes: defns, name: name)) end end end diff --git a/lib/graphql/static_validation/rules/argument_names_are_unique_error.rb b/lib/graphql/static_validation/rules/argument_names_are_unique_error.rb new file mode 100644 index 0000000000..13b53d94e9 --- /dev/null +++ b/lib/graphql/static_validation/rules/argument_names_are_unique_error.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class ArgumentNamesAreUniqueError < StaticValidation::Error + attr_reader :name + + def initialize(message, path: nil, nodes: [], name:) + super(message, path: path, nodes: nodes) + @name = name + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "name" => name + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "argumentNotUnique" + end + end + end +end + diff --git a/lib/graphql/static_validation/rules/arguments_are_defined.rb b/lib/graphql/static_validation/rules/arguments_are_defined.rb index 2bee1b344f..205e419a94 100644 --- a/lib/graphql/static_validation/rules/arguments_are_defined.rb +++ b/lib/graphql/static_validation/rules/arguments_are_defined.rb @@ -1,18 +1,62 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class ArgumentsAreDefined < GraphQL::StaticValidation::ArgumentsValidator - def validate_node(parent, node, defn, context) - argument_defn = context.warden.arguments(defn).find { |arg| arg.name == node.name } - if argument_defn.nil? + module ArgumentsAreDefined + def on_argument(node, parent) + parent_defn = case parent + when GraphQL::Language::Nodes::InputObject + arg_defn = context.argument_definition + if arg_defn.nil? + nil + else + arg_ret_type = arg_defn.type.unwrap + if !arg_ret_type.is_a?(GraphQL::InputObjectType) + nil + else + arg_ret_type + end + end + when GraphQL::Language::Nodes::Directive + context.schema.directives[parent.name] + when GraphQL::Language::Nodes::Field + context.field_definition + else + raise "Unexpected argument parent: #{parent.class} (##{parent})" + end + + if parent_defn && context.warden.arguments(parent_defn).any? { |arg| arg.name == node.name } + super + elsif parent_defn kind_of_node = node_type(parent) - error_arg_name = parent_name(parent, defn) - context.errors << message("#{kind_of_node} '#{error_arg_name}' doesn't accept argument '#{node.name}'", node, context: context) - GraphQL::Language::Visitor::SKIP + error_arg_name = parent_name(parent, parent_defn) + add_error(GraphQL::StaticValidation::ArgumentsAreDefinedError.new( + "#{kind_of_node} '#{error_arg_name}' doesn't accept argument '#{node.name}'", + nodes: node, + name: error_arg_name, + type: kind_of_node, + argument: node.name + )) + else + # Some other weird error + super + end + end + + private + + def parent_name(parent, type_defn) + if parent.is_a?(GraphQL::Language::Nodes::Field) + parent.alias || parent.name + elsif parent.is_a?(GraphQL::Language::Nodes::InputObject) + type_defn.name else - nil + parent.name end end + + def node_type(parent) + parent.class.name.split("::").last + end end end end diff --git a/lib/graphql/static_validation/rules/arguments_are_defined_error.rb b/lib/graphql/static_validation/rules/arguments_are_defined_error.rb new file mode 100644 index 0000000000..7c55877c15 --- /dev/null +++ b/lib/graphql/static_validation/rules/arguments_are_defined_error.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class ArgumentsAreDefinedError < StaticValidation::Error + attr_reader :name + attr_reader :type_name + attr_reader :argument_name + + def initialize(message, path: nil, nodes: [], name:, type:, argument:) + super(message, path: path, nodes: nodes) + @name = name + @type_name = type + @argument_name = argument + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "name" => name, + "typeName" => type_name, + "argumentName" => argument_name + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "argumentNotAccepted" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/directives_are_defined.rb b/lib/graphql/static_validation/rules/directives_are_defined.rb index 5811005f9b..d93bf70d37 100644 --- a/lib/graphql/static_validation/rules/directives_are_defined.rb +++ b/lib/graphql/static_validation/rules/directives_are_defined.rb @@ -1,24 +1,21 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class DirectivesAreDefined - include GraphQL::StaticValidation::Message::MessageHelper - - def validate(context) - directive_names = context.schema.directives.keys - context.visitor[GraphQL::Language::Nodes::Directive] << ->(node, parent) { - validate_directive(node, directive_names, context) - } + module DirectivesAreDefined + def initialize(*) + super + @directive_names = context.schema.directives.keys end - private - - def validate_directive(ast_directive, directive_names, context) - if !directive_names.include?(ast_directive.name) - context.errors << message("Directive @#{ast_directive.name} is not defined", ast_directive, context: context) - GraphQL::Language::Visitor::SKIP + def on_directive(node, parent) + if !@directive_names.include?(node.name) + add_error(GraphQL::StaticValidation::DirectivesAreDefinedError.new( + "Directive @#{node.name} is not defined", + nodes: node, + directive: node.name + )) else - nil + super end end end diff --git a/lib/graphql/static_validation/rules/directives_are_defined_error.rb b/lib/graphql/static_validation/rules/directives_are_defined_error.rb new file mode 100644 index 0000000000..2f09522f96 --- /dev/null +++ b/lib/graphql/static_validation/rules/directives_are_defined_error.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class DirectivesAreDefinedError < StaticValidation::Error + attr_reader :directive_name + + def initialize(message, path: nil, nodes: [], directive:) + super(message, path: path, nodes: nodes) + @directive_name = directive + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "directiveName" => directive_name + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "undefinedDirective" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/directives_are_in_valid_locations.rb b/lib/graphql/static_validation/rules/directives_are_in_valid_locations.rb index e14d5bb4ac..0d03dc1d2d 100644 --- a/lib/graphql/static_validation/rules/directives_are_in_valid_locations.rb +++ b/lib/graphql/static_validation/rules/directives_are_in_valid_locations.rb @@ -1,16 +1,12 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class DirectivesAreInValidLocations - include GraphQL::StaticValidation::Message::MessageHelper + module DirectivesAreInValidLocations include GraphQL::Language - def validate(context) - directives = context.schema.directives - - context.visitor[Nodes::Directive] << ->(node, parent) { - validate_location(node, parent, directives, context) - } + def on_directive(node, parent) + validate_location(node, parent, context.schema.directives) + super end private @@ -34,25 +30,34 @@ def validate(context) SIMPLE_LOCATION_NODES = SIMPLE_LOCATIONS.keys - def validate_location(ast_directive, ast_parent, directives, context) + def validate_location(ast_directive, ast_parent, directives) directive_defn = directives[ast_directive.name] case ast_parent when Nodes::OperationDefinition required_location = GraphQL::Directive.const_get(ast_parent.operation_type.upcase) - assert_includes_location(directive_defn, ast_directive, required_location, context) + assert_includes_location(directive_defn, ast_directive, required_location) when *SIMPLE_LOCATION_NODES required_location = SIMPLE_LOCATIONS[ast_parent.class] - assert_includes_location(directive_defn, ast_directive, required_location, context) + assert_includes_location(directive_defn, ast_directive, required_location) else - context.errors << message("Directives can't be applied to #{ast_parent.class.name}s", ast_directive, context: context) + add_error(GraphQL::StaticValidation::DirectivesAreInValidLocationsError.new( + "Directives can't be applied to #{ast_parent.class.name}s", + nodes: ast_directive, + target: ast_parent.class.name + )) end end - def assert_includes_location(directive_defn, directive_ast, required_location, context) + def assert_includes_location(directive_defn, directive_ast, required_location) if !directive_defn.locations.include?(required_location) location_name = LOCATION_MESSAGE_NAMES[required_location] allowed_location_names = directive_defn.locations.map { |loc| LOCATION_MESSAGE_NAMES[loc] } - context.errors << message("'@#{directive_defn.name}' can't be applied to #{location_name} (allowed: #{allowed_location_names.join(", ")})", directive_ast, context: context) + add_error(GraphQL::StaticValidation::DirectivesAreInValidLocationsError.new( + "'@#{directive_defn.name}' can't be applied to #{location_name} (allowed: #{allowed_location_names.join(", ")})", + nodes: directive_ast, + target: location_name, + name: directive_defn.name + )) end end end diff --git a/lib/graphql/static_validation/rules/directives_are_in_valid_locations_error.rb b/lib/graphql/static_validation/rules/directives_are_in_valid_locations_error.rb new file mode 100644 index 0000000000..eb6e3230d6 --- /dev/null +++ b/lib/graphql/static_validation/rules/directives_are_in_valid_locations_error.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class DirectivesAreInValidLocationsError < StaticValidation::Error + attr_reader :target_name + attr_reader :name + + def initialize(message, path: nil, nodes: [], target:, name: nil) + super(message, path: path, nodes: nodes) + @target_name = target + @name = name + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "targetName" => target_name + }.tap { |h| h["name"] = name unless name.nil? } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "directiveCannotBeApplied" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/fields_are_defined_on_type.rb b/lib/graphql/static_validation/rules/fields_are_defined_on_type.rb index 7870148729..d1cac120fc 100644 --- a/lib/graphql/static_validation/rules/fields_are_defined_on_type.rb +++ b/lib/graphql/static_validation/rules/fields_are_defined_on_type.rb @@ -1,30 +1,28 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class FieldsAreDefinedOnType - include GraphQL::StaticValidation::Message::MessageHelper - - def validate(context) - visitor = context.visitor - visitor[GraphQL::Language::Nodes::Field] << ->(node, parent) { - parent_type = context.object_types[-2] - parent_type = parent_type.unwrap - validate_field(context, node, parent_type, parent) - } - end - - private - - def validate_field(context, ast_field, parent_type, parent) - field = context.warden.get_field(parent_type, ast_field.name) + module FieldsAreDefinedOnType + def on_field(node, parent) + parent_type = @object_types[-2] + field = context.warden.get_field(parent_type, node.name) if field.nil? if parent_type.kind.union? - context.errors << message("Selections can't be made directly on unions (see selections on #{parent_type.name})", parent, context: context) + add_error(GraphQL::StaticValidation::FieldsHaveAppropriateSelectionsError.new( + "Selections can't be made directly on unions (see selections on #{parent_type.name})", + nodes: parent, + node_name: parent_type.name + )) else - context.errors << message("Field '#{ast_field.name}' doesn't exist on type '#{parent_type.name}'", ast_field, context: context) + add_error(GraphQL::StaticValidation::FieldsAreDefinedOnTypeError.new( + "Field '#{node.name}' doesn't exist on type '#{parent_type.name}'", + nodes: node, + field: node.name, + type: parent_type.name + )) end - return GraphQL::Language::Visitor::SKIP + else + super end end end diff --git a/lib/graphql/static_validation/rules/fields_are_defined_on_type_error.rb b/lib/graphql/static_validation/rules/fields_are_defined_on_type_error.rb new file mode 100644 index 0000000000..c405eac7e6 --- /dev/null +++ b/lib/graphql/static_validation/rules/fields_are_defined_on_type_error.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class FieldsAreDefinedOnTypeError < StaticValidation::Error + attr_reader :type_name + attr_reader :field_name + + def initialize(message, path: nil, nodes: [], type:, field:) + super(message, path: path, nodes: nodes) + @type_name = type + @field_name = field + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "typeName" => type_name, + "fieldName" => field_name + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "undefinedField" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/fields_have_appropriate_selections.rb b/lib/graphql/static_validation/rules/fields_have_appropriate_selections.rb index 71f3cf3023..1736fdbf07 100644 --- a/lib/graphql/static_validation/rules/fields_have_appropriate_selections.rb +++ b/lib/graphql/static_validation/rules/fields_have_appropriate_selections.rb @@ -3,24 +3,26 @@ module GraphQL module StaticValidation # Scalars _can't_ have selections # Objects _must_ have selections - class FieldsHaveAppropriateSelections - include GraphQL::StaticValidation::Message::MessageHelper + module FieldsHaveAppropriateSelections + include GraphQL::StaticValidation::Error::ErrorHelper - def validate(context) - context.visitor[GraphQL::Language::Nodes::Field] << ->(node, parent) { - field_defn = context.field_definition - validate_field_selections(node, field_defn.type.unwrap, context) - } + def on_field(node, parent) + field_defn = field_definition + if validate_field_selections(node, field_defn.type.unwrap) + super + end + end - context.visitor[GraphQL::Language::Nodes::OperationDefinition] << ->(node, parent) { - validate_field_selections(node, context.type_definition, context) - } + def on_operation_definition(node, _parent) + if validate_field_selections(node, type_definition) + super + end end private - def validate_field_selections(ast_node, resolved_type, context) + def validate_field_selections(ast_node, resolved_type) msg = if resolved_type.nil? nil elsif resolved_type.kind.scalar? && ast_node.selections.any? @@ -48,8 +50,22 @@ def validate_field_selections(ast_node, resolved_type, context) else raise("Unexpected node #{ast_node}") end - context.errors << message(msg % { node_name: node_name }, ast_node, context: context) - GraphQL::Language::Visitor::SKIP + extensions = { + "rule": "StaticValidation::FieldsHaveAppropriateSelections", + "name": node_name.to_s + } + unless resolved_type.nil? + extensions["type"] = resolved_type.to_s + end + add_error(GraphQL::StaticValidation::FieldsHaveAppropriateSelectionsError.new( + msg % { node_name: node_name }, + nodes: ast_node, + node_name: node_name.to_s, + type: resolved_type.nil? ? nil : resolved_type.to_s + )) + false + else + true end end end diff --git a/lib/graphql/static_validation/rules/fields_have_appropriate_selections_error.rb b/lib/graphql/static_validation/rules/fields_have_appropriate_selections_error.rb new file mode 100644 index 0000000000..34b06a9716 --- /dev/null +++ b/lib/graphql/static_validation/rules/fields_have_appropriate_selections_error.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class FieldsHaveAppropriateSelectionsError < StaticValidation::Error + attr_reader :type_name + attr_reader :node_name + + def initialize(message, path: nil, nodes: [], node_name:, type: nil) + super(message, path: path, nodes: nodes) + @node_name = node_name + @type_name = type + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "nodeName" => node_name + }.tap { |h| h["typeName"] = type_name unless type_name.nil? } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "selectionMismatch" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/fields_will_merge.rb b/lib/graphql/static_validation/rules/fields_will_merge.rb index 5897e936d7..b99c52cc76 100644 --- a/lib/graphql/static_validation/rules/fields_will_merge.rb +++ b/lib/graphql/static_validation/rules/fields_will_merge.rb @@ -1,47 +1,374 @@ + # frozen_string_literal: true + # frozen_string_literal: true module GraphQL module StaticValidation - class FieldsWillMerge - # Special handling for fields without arguments + module FieldsWillMerge + # Validates that a selection set is valid if all fields (including spreading any + # fragments) either correspond to distinct response names or can be merged + # without ambiguity. + # + # Original Algorithm: https://github.com/graphql/graphql-js/blob/master/src/validation/rules/OverlappingFieldsCanBeMerged.js NO_ARGS = {}.freeze + Field = Struct.new(:node, :definition, :owner_type, :parents) + FragmentSpread = Struct.new(:name, :parents) + + def initialize(*) + super + @visited_fragments = {} + @compared_fragments = {} + end + + def on_operation_definition(node, _parent) + conflicts_within_selection_set(node, type_definition) + super + end + + def on_field(node, _parent) + conflicts_within_selection_set(node, type_definition) + super + end + + private + + def conflicts_within_selection_set(node, parent_type) + return if parent_type.nil? + + fields, fragment_spreads = fields_and_fragments_from_selection(node, owner_type: parent_type, parents: []) + + # (A) Find find all conflicts "within" the fields of this selection set. + find_conflicts_within(fields) + + fragment_spreads.each_with_index do |fragment_spread, i| + are_mutually_exclusive = mutually_exclusive?( + fragment_spread.parents, + [parent_type] + ) + + # (B) Then find conflicts between these fields and those represented by + # each spread fragment name found. + find_conflicts_between_fields_and_fragment( + fragment_spread, + fields, + mutually_exclusive: are_mutually_exclusive, + ) + + # (C) Then compare this fragment with all other fragments found in this + # selection set to collect conflicts between fragments spread together. + # This compares each item in the list of fragment names to every other + # item in that same list (except for itself). + fragment_spreads[i + 1..-1].each do |fragment_spread2| + are_mutually_exclusive = mutually_exclusive?( + fragment_spread.parents, + fragment_spread2.parents + ) + + find_conflicts_between_fragments( + fragment_spread, + fragment_spread2, + mutually_exclusive: are_mutually_exclusive, + ) + end + end + end + + def find_conflicts_between_fragments(fragment_spread1, fragment_spread2, mutually_exclusive:) + fragment_name1 = fragment_spread1.name + fragment_name2 = fragment_spread2.name + return if fragment_name1 == fragment_name2 + + cache_key = compared_fragments_key( + fragment_name1, + fragment_name2, + mutually_exclusive, + ) + if @compared_fragments.key?(cache_key) + return + else + @compared_fragments[cache_key] = true + end + + fragment1 = context.fragments[fragment_name1] + fragment2 = context.fragments[fragment_name2] + + return if fragment1.nil? || fragment2.nil? + + fragment_type1 = context.schema.types[fragment1.type.name] + fragment_type2 = context.schema.types[fragment2.type.name] + + return if fragment_type1.nil? || fragment_type2.nil? + + fragment_fields1, fragment_spreads1 = fields_and_fragments_from_selection( + fragment1, + owner_type: fragment_type1, + parents: [*fragment_spread1.parents, fragment_type1] + ) + fragment_fields2, fragment_spreads2 = fields_and_fragments_from_selection( + fragment2, + owner_type: fragment_type1, + parents: [*fragment_spread2.parents, fragment_type2] + ) + + # (F) First, find all conflicts between these two collections of fields + # (not including any nested fragments). + find_conflicts_between( + fragment_fields1, + fragment_fields2, + mutually_exclusive: mutually_exclusive, + ) + + # (G) Then collect conflicts between the first fragment and any nested + # fragments spread in the second fragment. + fragment_spreads2.each do |fragment_spread| + find_conflicts_between_fragments( + fragment_spread1, + fragment_spread, + mutually_exclusive: mutually_exclusive, + ) + end + + # (G) Then collect conflicts between the first fragment and any nested + # fragments spread in the second fragment. + fragment_spreads1.each do |fragment_spread| + find_conflicts_between_fragments( + fragment_spread2, + fragment_spread, + mutually_exclusive: mutually_exclusive, + ) + end + end + + def find_conflicts_between_fields_and_fragment(fragment_spread, fields, mutually_exclusive:) + fragment_name = fragment_spread.name + return if @visited_fragments.key?(fragment_name) + @visited_fragments[fragment_name] = true + + fragment = context.fragments[fragment_name] + return if fragment.nil? + + fragment_type = context.schema.types[fragment.type.name] + return if fragment_type.nil? + + fragment_fields, fragment_spreads = fields_and_fragments_from_selection(fragment, owner_type: fragment_type, parents: [*fragment_spread.parents, fragment_type]) + + # (D) First find any conflicts between the provided collection of fields + # and the collection of fields represented by the given fragment. + find_conflicts_between( + fields, + fragment_fields, + mutually_exclusive: mutually_exclusive, + ) + + # (E) Then collect any conflicts between the provided collection of fields + # and any fragment names found in the given fragment. + fragment_spreads.each do |fragment_spread| + find_conflicts_between_fields_and_fragment( + fragment_spread, + fields, + mutually_exclusive: mutually_exclusive, + ) + end + end + + def find_conflicts_within(response_keys) + response_keys.each do |key, fields| + next if fields.size < 2 + # find conflicts within nodes + for i in 0..fields.size - 1 + for j in i + 1..fields.size - 1 + find_conflict(key, fields[i], fields[j]) + end + end + end + end + + def find_conflict(response_key, field1, field2, mutually_exclusive: false) + node1 = field1.node + node2 = field2.node + + are_mutually_exclusive = mutually_exclusive || + mutually_exclusive?(field1.parents, field2.parents) + + if !are_mutually_exclusive + if node1.name != node2.name + errored_nodes = [node1.name, node2.name].sort.join(" or ") + msg = "Field '#{response_key}' has a field conflict: #{errored_nodes}?" + context.errors << GraphQL::StaticValidation::FieldsWillMergeError.new( + msg, + nodes: [node1, node2], + path: [], + field_name: response_key, + conflicts: errored_nodes + ) + end + + args = possible_arguments(node1, node2) + if args.size > 1 + msg = "Field '#{response_key}' has an argument conflict: #{args.map { |arg| GraphQL::Language.serialize(arg) }.join(" or ")}?" + context.errors << GraphQL::StaticValidation::FieldsWillMergeError.new( + msg, + nodes: [node1, node2], + path: [], + field_name: response_key, + conflicts: args.map { |arg| GraphQL::Language.serialize(arg) }.join(" or ") + ) + end + end + + find_conflicts_between_sub_selection_sets( + field1, + field2, + mutually_exclusive: are_mutually_exclusive, + ) + end + + def find_conflicts_between_sub_selection_sets(field1, field2, mutually_exclusive:) + return if field1.definition.nil? || field2.definition.nil? + + return_type1 = field1.definition.type.unwrap + return_type2 = field2.definition.type.unwrap + parents1 = [return_type1] + parents2 = [return_type2] + + fields, fragment_spreads = fields_and_fragments_from_selection( + field1.node, + owner_type: return_type1, + parents: parents1 + ) + + fields2, fragment_spreads2 = fields_and_fragments_from_selection( + field2.node, + owner_type: return_type2, + parents: parents2 + ) + + # (H) First, collect all conflicts between these two collections of field. + find_conflicts_between(fields, fields2, mutually_exclusive: mutually_exclusive) + + # (I) Then collect conflicts between the first collection of fields and + # those referenced by each fragment name associated with the second. + fragment_spreads2.each do |fragment_spread| + find_conflicts_between_fields_and_fragment( + fragment_spread, + fields, + mutually_exclusive: mutually_exclusive, + ) + end + + # (I) Then collect conflicts between the second collection of fields and + # those referenced by each fragment name associated with the first. + fragment_spreads.each do |fragment_spread| + find_conflicts_between_fields_and_fragment( + fragment_spread, + fields2, + mutually_exclusive: mutually_exclusive, + ) + end - def validate(context) - context.each_irep_node do |node| - if node.ast_nodes.size > 1 - defn_names = Set.new(node.ast_nodes.map(&:name)) + # (J) Also collect conflicts between any fragment names by the first and + # fragment names by the second. This compares each item in the first set of + # names to each item in the second set of names. + fragment_spreads.each do |frag1| + fragment_spreads2.each do |frag2| + find_conflicts_between_fragments( + frag1, + frag2, + mutually_exclusive: mutually_exclusive + ) + end + end + end - # Check for more than one GraphQL::Field backing this node: - if defn_names.size > 1 - defn_names = defn_names.sort.join(" or ") - msg = "Field '#{node.name}' has a field conflict: #{defn_names}?" - context.errors << GraphQL::StaticValidation::Message.new(msg, nodes: node.ast_nodes.to_a) + def find_conflicts_between(response_keys, response_keys2, mutually_exclusive:) + response_keys.each do |key, fields| + fields2 = response_keys2[key] + if fields2 + fields.each do |field| + fields2.each do |field2| + find_conflict( + key, + field, + field2, + mutually_exclusive: mutually_exclusive, + ) + end end + end + end + end + + NO_SELECTIONS = [{}.freeze, [].freeze].freeze + + def fields_and_fragments_from_selection(node, owner_type:, parents:) + if node.selections.none? + NO_SELECTIONS + else + fields, fragment_spreads = find_fields_and_fragments(node.selections, owner_type: owner_type, parents: parents, fields: [], fragment_spreads: []) + response_keys = fields.group_by { |f| f.node.alias || f.node.name } + [response_keys, fragment_spreads] + end + end - # Check for incompatible / non-identical arguments on this node: - args = node.ast_nodes.map do |n| - if n.arguments.any? - n.arguments.reduce({}) do |memo, a| - arg_value = a.value - memo[a.name] = case arg_value - when GraphQL::Language::Nodes::AbstractNode - arg_value.to_query_string - else - GraphQL::Language.serialize(arg_value) - end - memo - end + def find_fields_and_fragments(selections, owner_type:, parents:, fields:, fragment_spreads:) + selections.each do |node| + case node + when GraphQL::Language::Nodes::Field + definition = context.schema.get_field(owner_type, node.name) + fields << Field.new(node, definition, owner_type, parents) + when GraphQL::Language::Nodes::InlineFragment + fragment_type = node.type ? context.schema.types[node.type.name] : owner_type + find_fields_and_fragments(node.selections, parents: [*parents, fragment_type], owner_type: owner_type, fields: fields, fragment_spreads: fragment_spreads) if fragment_type + when GraphQL::Language::Nodes::FragmentSpread + fragment_spreads << FragmentSpread.new(node.name, parents) + end + end + + [fields, fragment_spreads] + end + + def possible_arguments(field1, field2) + # Check for incompatible / non-identical arguments on this node: + [field1, field2].map do |n| + if n.arguments.any? + n.arguments.reduce({}) do |memo, a| + arg_value = a.value + memo[a.name] = case arg_value + when GraphQL::Language::Nodes::AbstractNode + arg_value.to_query_string else - NO_ARGS + GraphQL::Language.serialize(arg_value) end + memo end - args.uniq! + else + NO_ARGS + end + end.uniq + end - if args.length > 1 - msg = "Field '#{node.name}' has an argument conflict: #{args.map{ |arg| GraphQL::Language.serialize(arg) }.join(" or ")}?" - context.errors << GraphQL::StaticValidation::Message.new(msg, nodes: node.ast_nodes.to_a) + def compared_fragments_key(frag1, frag2, exclusive) + # Cache key to not compare two fragments more than once. + # The key includes both fragment names sorted (this way we + # avoid computing "A vs B" and "B vs A"). It also includes + # "exclusive" since the result may change depending on the parent_type + "#{[frag1, frag2].sort.join('-')}-#{exclusive}" + end + + # Given two list of parents, find out if they are mutually exclusive + # In this context, `parents` represends the "self scope" of the field, + # what types may be found at this point in the query. + def mutually_exclusive?(parents1, parents2) + parents1.each do |type1| + parents2.each do |type2| + # If the types we're comparing are both different object types, + # they have to be mutually exclusive. + if type1 != type2 && type1.kind.object? && type2.kind.object? + return true end end end + + false end end end diff --git a/lib/graphql/static_validation/rules/fields_will_merge_error.rb b/lib/graphql/static_validation/rules/fields_will_merge_error.rb new file mode 100644 index 0000000000..c17e9a947a --- /dev/null +++ b/lib/graphql/static_validation/rules/fields_will_merge_error.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class FieldsWillMergeError < StaticValidation::Error + attr_reader :field_name + attr_reader :conflicts + + def initialize(message, path: nil, nodes: [], field_name:, conflicts:) + super(message, path: path, nodes: nodes) + @field_name = field_name + @conflicts = conflicts + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "fieldName" => field_name, + "conflicts" => conflicts + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "fieldConflict" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/fragment_names_are_unique.rb b/lib/graphql/static_validation/rules/fragment_names_are_unique.rb index f4e9dff7de..7f31d3cbdd 100644 --- a/lib/graphql/static_validation/rules/fragment_names_are_unique.rb +++ b/lib/graphql/static_validation/rules/fragment_names_are_unique.rb @@ -1,22 +1,29 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class FragmentNamesAreUnique - include GraphQL::StaticValidation::Message::MessageHelper + module FragmentNamesAreUnique - def validate(context) - fragments_by_name = Hash.new { |h, k| h[k] = [] } - context.visitor[GraphQL::Language::Nodes::FragmentDefinition] << ->(node, parent) { - fragments_by_name[node.name] << node - } + def initialize(*) + super + @fragments_by_name = Hash.new { |h, k| h[k] = [] } + end + + def on_fragment_definition(node, parent) + @fragments_by_name[node.name] << node + super + end - context.visitor[GraphQL::Language::Nodes::Document].leave << ->(node, parent) { - fragments_by_name.each do |name, fragments| - if fragments.length > 1 - context.errors << message(%|Fragment name "#{name}" must be unique|, fragments, context: context) - end + def on_document(_n, _p) + super + @fragments_by_name.each do |name, fragments| + if fragments.length > 1 + add_error(GraphQL::StaticValidation::FragmentNamesAreUniqueError.new( + %|Fragment name "#{name}" must be unique|, + nodes: fragments, + name: name + )) end - } + end end end end diff --git a/lib/graphql/static_validation/rules/fragment_names_are_unique_error.rb b/lib/graphql/static_validation/rules/fragment_names_are_unique_error.rb new file mode 100644 index 0000000000..692035c948 --- /dev/null +++ b/lib/graphql/static_validation/rules/fragment_names_are_unique_error.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class FragmentNamesAreUniqueError < StaticValidation::Error + attr_reader :fragment_name + + def initialize(message, path: nil, nodes: [], name:) + super(message, path: path, nodes: nodes) + @fragment_name = name + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "fragmentName" => fragment_name + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "fragmentNotUnique" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/fragment_spreads_are_possible.rb b/lib/graphql/static_validation/rules/fragment_spreads_are_possible.rb index 5351c28bbb..972850919d 100644 --- a/lib/graphql/static_validation/rules/fragment_spreads_are_possible.rb +++ b/lib/graphql/static_validation/rules/fragment_spreads_are_possible.rb @@ -1,39 +1,40 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class FragmentSpreadsArePossible - include GraphQL::StaticValidation::Message::MessageHelper - - def validate(context) - - context.visitor[GraphQL::Language::Nodes::InlineFragment] << ->(node, parent) { - fragment_parent = context.object_types[-2] - fragment_child = context.object_types.last - if fragment_child - validate_fragment_in_scope(fragment_parent, fragment_child, node, context, context.path) - end - } + module FragmentSpreadsArePossible + def initialize(*) + super + @spreads_to_validate = [] + end - spreads_to_validate = [] + def on_inline_fragment(node, parent) + fragment_parent = context.object_types[-2] + fragment_child = context.object_types.last + if fragment_child + validate_fragment_in_scope(fragment_parent, fragment_child, node, context, context.path) + end + super + end - context.visitor[GraphQL::Language::Nodes::FragmentSpread] << ->(node, parent) { - fragment_parent = context.object_types.last - spreads_to_validate << FragmentSpread.new(node: node, parent_type: fragment_parent, path: context.path) - } + def on_fragment_spread(node, parent) + fragment_parent = context.object_types.last + @spreads_to_validate << FragmentSpread.new(node: node, parent_type: fragment_parent, path: context.path) + super + end - context.visitor[GraphQL::Language::Nodes::Document].leave << ->(doc_node, parent) { - spreads_to_validate.each do |frag_spread| - frag_node = context.fragments[frag_spread.node.name] - if frag_node - fragment_child_name = frag_node.type.name - fragment_child = context.warden.get_type(fragment_child_name) - # Might be non-existent type name - if fragment_child - validate_fragment_in_scope(frag_spread.parent_type, fragment_child, frag_spread.node, context, frag_spread.path) - end + def on_document(node, parent) + super + @spreads_to_validate.each do |frag_spread| + frag_node = context.fragments[frag_spread.node.name] + if frag_node + fragment_child_name = frag_node.type.name + fragment_child = context.warden.get_type(fragment_child_name) + # Might be non-existent type name + if fragment_child + validate_fragment_in_scope(frag_spread.parent_type, fragment_child, frag_spread.node, context, frag_spread.path) end end - } + end end private @@ -48,7 +49,14 @@ def validate_fragment_in_scope(parent_type, child_type, node, context, path) if child_types.none? { |c| parent_types.include?(c) } name = node.respond_to?(:name) ? " #{node.name}" : "" - context.errors << message("Fragment#{name} on #{child_type.name} can't be spread inside #{parent_type.name}", node, path: path) + add_error(GraphQL::StaticValidation::FragmentSpreadsArePossibleError.new( + "Fragment#{name} on #{child_type.name} can't be spread inside #{parent_type.name}", + nodes: node, + path: path, + fragment_name: name.empty? ? "unknown" : name, + type: child_type.name, + parent: parent_type.name + )) end end diff --git a/lib/graphql/static_validation/rules/fragment_spreads_are_possible_error.rb b/lib/graphql/static_validation/rules/fragment_spreads_are_possible_error.rb new file mode 100644 index 0000000000..cf543d918f --- /dev/null +++ b/lib/graphql/static_validation/rules/fragment_spreads_are_possible_error.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class FragmentSpreadsArePossibleError < StaticValidation::Error + attr_reader :type_name + attr_reader :fragment_name + attr_reader :parent_name + + def initialize(message, path: nil, nodes: [], type:, fragment_name:, parent:) + super(message, path: path, nodes: nodes) + @type_name = type + @fragment_name = fragment_name + @parent_name = parent + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "typeName" => type_name, + "fragmentName" => fragment_name, + "parentName" => parent_name + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "cannotSpreadFragment" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/fragment_types_exist.rb b/lib/graphql/static_validation/rules/fragment_types_exist.rb index 06fac1e4d8..9863eaa968 100644 --- a/lib/graphql/static_validation/rules/fragment_types_exist.rb +++ b/lib/graphql/static_validation/rules/fragment_types_exist.rb @@ -1,29 +1,37 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class FragmentTypesExist - include GraphQL::StaticValidation::Message::MessageHelper - - FRAGMENTS_ON_TYPES = [ - GraphQL::Language::Nodes::FragmentDefinition, - GraphQL::Language::Nodes::InlineFragment, - ] + module FragmentTypesExist + def on_fragment_definition(node, _parent) + if validate_type_exists(node) + super + end + end - def validate(context) - FRAGMENTS_ON_TYPES.each do |node_class| - context.visitor[node_class] << ->(node, parent) { validate_type_exists(node, context) } + def on_inline_fragment(node, _parent) + if validate_type_exists(node) + super end end private - def validate_type_exists(node, context) - return unless node.type - type_name = node.type.name - type = context.warden.get_type(type_name) - if type.nil? - context.errors << message("No such type #{type_name}, so it can't be a fragment condition", node, context: context) - GraphQL::Language::Visitor::SKIP + def validate_type_exists(fragment_node) + if !fragment_node.type + true + else + type_name = fragment_node.type.name + type = context.warden.get_type(type_name) + if type.nil? + add_error(GraphQL::StaticValidation::FragmentTypesExistError.new( + "No such type #{type_name}, so it can't be a fragment condition", + nodes: fragment_node, + type: type_name + )) + false + else + true + end end end end diff --git a/lib/graphql/static_validation/rules/fragment_types_exist_error.rb b/lib/graphql/static_validation/rules/fragment_types_exist_error.rb new file mode 100644 index 0000000000..3d8a9b0532 --- /dev/null +++ b/lib/graphql/static_validation/rules/fragment_types_exist_error.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class FragmentTypesExistError < StaticValidation::Error + attr_reader :type_name + + def initialize(message, path: nil, nodes: [], type:) + super(message, path: path, nodes: nodes) + @type_name = type + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "typeName" => type_name + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "undefinedType" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/fragments_are_finite.rb b/lib/graphql/static_validation/rules/fragments_are_finite.rb index da0d198f54..e0e147c0cd 100644 --- a/lib/graphql/static_validation/rules/fragments_are_finite.rb +++ b/lib/graphql/static_validation/rules/fragments_are_finite.rb @@ -1,16 +1,18 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class FragmentsAreFinite - include GraphQL::StaticValidation::Message::MessageHelper - - def validate(context) - context.visitor[GraphQL::Language::Nodes::Document].leave << ->(_n, _p) do - dependency_map = context.dependencies - dependency_map.cyclical_definitions.each do |defn| - if defn.node.is_a?(GraphQL::Language::Nodes::FragmentDefinition) - context.errors << message("Fragment #{defn.name} contains an infinite loop", defn.node, path: defn.path) - end + module FragmentsAreFinite + def on_document(_n, _p) + super + dependency_map = context.dependencies + dependency_map.cyclical_definitions.each do |defn| + if defn.node.is_a?(GraphQL::Language::Nodes::FragmentDefinition) + context.errors << GraphQL::StaticValidation::FragmentsAreFiniteError.new( + "Fragment #{defn.name} contains an infinite loop", + nodes: defn.node, + path: defn.path, + name: defn.name + ) end end end diff --git a/lib/graphql/static_validation/rules/fragments_are_finite_error.rb b/lib/graphql/static_validation/rules/fragments_are_finite_error.rb new file mode 100644 index 0000000000..1c5e9881a5 --- /dev/null +++ b/lib/graphql/static_validation/rules/fragments_are_finite_error.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class FragmentsAreFiniteError < StaticValidation::Error + attr_reader :fragment_name + + def initialize(message, path: nil, nodes: [], name:) + super(message, path: path, nodes: nodes) + @fragment_name = name + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "fragmentName" => fragment_name + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "infiniteLoop" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/fragments_are_named.rb b/lib/graphql/static_validation/rules/fragments_are_named.rb index d48dc1f6d2..42f58bec8c 100644 --- a/lib/graphql/static_validation/rules/fragments_are_named.rb +++ b/lib/graphql/static_validation/rules/fragments_are_named.rb @@ -1,19 +1,15 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class FragmentsAreNamed - include GraphQL::StaticValidation::Message::MessageHelper - - def validate(context) - context.visitor[GraphQL::Language::Nodes::FragmentDefinition] << ->(node, parent) { validate_name_exists(node, context) } - end - - private - - def validate_name_exists(node, context) + module FragmentsAreNamed + def on_fragment_definition(node, _parent) if node.name.nil? - context.errors << message("Fragment definition has no name", node, context: context) + add_error(GraphQL::StaticValidation::FragmentsAreNamedError.new( + "Fragment definition has no name", + nodes: node + )) end + super end end end diff --git a/lib/graphql/static_validation/rules/fragments_are_named_error.rb b/lib/graphql/static_validation/rules/fragments_are_named_error.rb new file mode 100644 index 0000000000..dd5826c188 --- /dev/null +++ b/lib/graphql/static_validation/rules/fragments_are_named_error.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class FragmentsAreNamedError < StaticValidation::Error + + def initialize(message, path: nil, nodes: []) + super(message, path: path, nodes: nodes) + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "anonymousFragment" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/fragments_are_on_composite_types.rb b/lib/graphql/static_validation/rules/fragments_are_on_composite_types.rb index 5d92cfa535..c97b6f8284 100644 --- a/lib/graphql/static_validation/rules/fragments_are_on_composite_types.rb +++ b/lib/graphql/static_validation/rules/fragments_are_on_composite_types.rb @@ -1,34 +1,34 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class FragmentsAreOnCompositeTypes - include GraphQL::StaticValidation::Message::MessageHelper - - HAS_TYPE_CONDITION = [ - GraphQL::Language::Nodes::FragmentDefinition, - GraphQL::Language::Nodes::InlineFragment, - ] + module FragmentsAreOnCompositeTypes + def on_fragment_definition(node, parent) + validate_type_is_composite(node) && super + end - def validate(context) - HAS_TYPE_CONDITION.each do |node_class| - context.visitor[node_class] << ->(node, parent) { - validate_type_is_composite(node, context) - } - end + def on_inline_fragment(node, parent) + validate_type_is_composite(node) && super end private - def validate_type_is_composite(node, context) + def validate_type_is_composite(node) node_type = node.type if node_type.nil? # Inline fragment on the same type + true else type_name = node_type.to_query_string type_def = context.warden.get_type(type_name) if type_def.nil? || !type_def.kind.composite? - context.errors << message("Invalid fragment on type #{type_name} (must be Union, Interface or Object)", node, context: context) - GraphQL::Language::Visitor::SKIP + add_error(GraphQL::StaticValidation::FragmentsAreOnCompositeTypesError.new( + "Invalid fragment on type #{type_name} (must be Union, Interface or Object)", + nodes: node, + type: type_name + )) + false + else + true end end end diff --git a/lib/graphql/static_validation/rules/fragments_are_on_composite_types_error.rb b/lib/graphql/static_validation/rules/fragments_are_on_composite_types_error.rb new file mode 100644 index 0000000000..2b546c295c --- /dev/null +++ b/lib/graphql/static_validation/rules/fragments_are_on_composite_types_error.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class FragmentsAreOnCompositeTypesError < StaticValidation::Error + attr_reader :type_name + attr_reader :argument_name + + def initialize(message, path: nil, nodes: [], type:) + super(message, path: path, nodes: nodes) + @type_name = type + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "typeName" => type_name + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "fragmentOnNonCompositeType" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/fragments_are_used.rb b/lib/graphql/static_validation/rules/fragments_are_used.rb index 5489818087..3be22b0359 100644 --- a/lib/graphql/static_validation/rules/fragments_are_used.rb +++ b/lib/graphql/static_validation/rules/fragments_are_used.rb @@ -1,22 +1,29 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class FragmentsAreUsed - include GraphQL::StaticValidation::Message::MessageHelper - - def validate(context) - context.visitor[GraphQL::Language::Nodes::Document].leave << ->(_n, _p) do - dependency_map = context.dependencies - dependency_map.unmet_dependencies.each do |op_defn, spreads| - spreads.each do |fragment_spread| - context.errors << message("Fragment #{fragment_spread.name} was used, but not defined", fragment_spread.node, path: fragment_spread.path) - end + module FragmentsAreUsed + def on_document(node, parent) + super + dependency_map = context.dependencies + dependency_map.unmet_dependencies.each do |op_defn, spreads| + spreads.each do |fragment_spread| + add_error(GraphQL::StaticValidation::FragmentsAreUsedError.new( + "Fragment #{fragment_spread.name} was used, but not defined", + nodes: fragment_spread.node, + path: fragment_spread.path, + fragment: fragment_spread.name + )) end + end - dependency_map.unused_dependencies.each do |fragment| - if !fragment.name.nil? - context.errors << message("Fragment #{fragment.name} was defined, but not used", fragment.node, path: fragment.path) - end + dependency_map.unused_dependencies.each do |fragment| + if fragment && !fragment.name.nil? + add_error(GraphQL::StaticValidation::FragmentsAreUsedError.new( + "Fragment #{fragment.name} was defined, but not used", + nodes: fragment.node, + path: fragment.path, + fragment: fragment.name + )) end end end diff --git a/lib/graphql/static_validation/rules/fragments_are_used_error.rb b/lib/graphql/static_validation/rules/fragments_are_used_error.rb new file mode 100644 index 0000000000..1e16794b19 --- /dev/null +++ b/lib/graphql/static_validation/rules/fragments_are_used_error.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class FragmentsAreUsedError < StaticValidation::Error + attr_reader :fragment_name + + def initialize(message, path: nil, nodes: [], fragment:) + super(message, path: path, nodes: nodes) + @fragment_name = fragment + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "fragmentName" => fragment_name + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "useAndDefineFragment" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/mutation_root_exists.rb b/lib/graphql/static_validation/rules/mutation_root_exists.rb index 0f56859e68..8a0929b687 100644 --- a/lib/graphql/static_validation/rules/mutation_root_exists.rb +++ b/lib/graphql/static_validation/rules/mutation_root_exists.rb @@ -1,20 +1,16 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class MutationRootExists - include GraphQL::StaticValidation::Message::MessageHelper - - def validate(context) - return if context.warden.root_type_for_operation("mutation") - - visitor = context.visitor - - visitor[GraphQL::Language::Nodes::OperationDefinition].enter << ->(ast_node, prev_ast_node) { - if ast_node.operation_type == 'mutation' - context.errors << message('Schema is not configured for mutations', ast_node, context: context) - return GraphQL::Language::Visitor::SKIP - end - } + module MutationRootExists + def on_operation_definition(node, _parent) + if node.operation_type == 'mutation' && context.warden.root_type_for_operation("mutation").nil? + add_error(GraphQL::StaticValidation::MutationRootExistsError.new( + 'Schema is not configured for mutations', + nodes: node + )) + else + super + end end end end diff --git a/lib/graphql/static_validation/rules/mutation_root_exists_error.rb b/lib/graphql/static_validation/rules/mutation_root_exists_error.rb new file mode 100644 index 0000000000..9fa0fc94af --- /dev/null +++ b/lib/graphql/static_validation/rules/mutation_root_exists_error.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class MutationRootExistsError < StaticValidation::Error + + def initialize(message, path: nil, nodes: []) + super(message, path: path, nodes: nodes) + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "missingMutationConfiguration" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/no_definitions_are_present.rb b/lib/graphql/static_validation/rules/no_definitions_are_present.rb index c2e661fb2e..7debd19b55 100644 --- a/lib/graphql/static_validation/rules/no_definitions_are_present.rb +++ b/lib/graphql/static_validation/rules/no_definitions_are_present.rb @@ -1,40 +1,40 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class NoDefinitionsArePresent - include GraphQL::StaticValidation::Message::MessageHelper + module NoDefinitionsArePresent + include GraphQL::StaticValidation::Error::ErrorHelper - def validate(context) - schema_definition_nodes = [] - register_node = ->(node, _p) { - schema_definition_nodes << node - GraphQL::Language::Visitor::SKIP - } - - visitor = context.visitor + def initialize(*) + super + @schema_definition_nodes = [] + end - visitor[GraphQL::Language::Nodes::DirectiveDefinition] << register_node - visitor[GraphQL::Language::Nodes::SchemaDefinition] << register_node - visitor[GraphQL::Language::Nodes::ScalarTypeDefinition] << register_node - visitor[GraphQL::Language::Nodes::ObjectTypeDefinition] << register_node - visitor[GraphQL::Language::Nodes::InputObjectTypeDefinition] << register_node - visitor[GraphQL::Language::Nodes::InterfaceTypeDefinition] << register_node - visitor[GraphQL::Language::Nodes::UnionTypeDefinition] << register_node - visitor[GraphQL::Language::Nodes::EnumTypeDefinition] << register_node + def on_invalid_node(node, parent) + @schema_definition_nodes << node + nil + end - visitor[GraphQL::Language::Nodes::SchemaExtension] << register_node - visitor[GraphQL::Language::Nodes::ScalarTypeExtension] << register_node - visitor[GraphQL::Language::Nodes::ObjectTypeExtension] << register_node - visitor[GraphQL::Language::Nodes::InputObjectTypeExtension] << register_node - visitor[GraphQL::Language::Nodes::InterfaceTypeExtension] << register_node - visitor[GraphQL::Language::Nodes::UnionTypeExtension] << register_node - visitor[GraphQL::Language::Nodes::EnumTypeExtension] << register_node + alias :on_directive_definition :on_invalid_node + alias :on_schema_definition :on_invalid_node + alias :on_scalar_type_definition :on_invalid_node + alias :on_object_type_definition :on_invalid_node + alias :on_input_object_type_definition :on_invalid_node + alias :on_interface_type_definition :on_invalid_node + alias :on_union_type_definition :on_invalid_node + alias :on_enum_type_definition :on_invalid_node + alias :on_schema_extension :on_invalid_node + alias :on_scalar_type_extension :on_invalid_node + alias :on_object_type_extension :on_invalid_node + alias :on_input_object_type_extension :on_invalid_node + alias :on_interface_type_extension :on_invalid_node + alias :on_union_type_extension :on_invalid_node + alias :on_enum_type_extension :on_invalid_node - visitor[GraphQL::Language::Nodes::Document].leave << ->(node, _p) { - if schema_definition_nodes.any? - context.errors << message(%|Query cannot contain schema definitions|, schema_definition_nodes, context: context) - end - } + def on_document(node, parent) + super + if @schema_definition_nodes.any? + add_error(GraphQL::StaticValidation::NoDefinitionsArePresentError.new(%|Query cannot contain schema definitions|, nodes: @schema_definition_nodes)) + end end end end diff --git a/lib/graphql/static_validation/rules/no_definitions_are_present_error.rb b/lib/graphql/static_validation/rules/no_definitions_are_present_error.rb new file mode 100644 index 0000000000..362e9bbaf8 --- /dev/null +++ b/lib/graphql/static_validation/rules/no_definitions_are_present_error.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class NoDefinitionsArePresentError < StaticValidation::Error + def initialize(message, path: nil, nodes: []) + super(message, path: path, nodes: nodes) + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "queryContainsSchemaDefinitions" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/operation_names_are_valid.rb b/lib/graphql/static_validation/rules/operation_names_are_valid.rb index e464dc1d2d..d93f0de4b4 100644 --- a/lib/graphql/static_validation/rules/operation_names_are_valid.rb +++ b/lib/graphql/static_validation/rules/operation_names_are_valid.rb @@ -1,27 +1,35 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class OperationNamesAreValid - include GraphQL::StaticValidation::Message::MessageHelper - - def validate(context) - op_names = Hash.new { |h, k| h[k] = [] } + module OperationNamesAreValid + def initialize(*) + super + @operation_names = Hash.new { |h, k| h[k] = [] } + end - context.visitor[GraphQL::Language::Nodes::OperationDefinition].enter << ->(node, _parent) { - op_names[node.name] << node - } + def on_operation_definition(node, parent) + @operation_names[node.name] << node + super + end - context.visitor[GraphQL::Language::Nodes::Document].leave << ->(node, _parent) { - op_count = op_names.values.inject(0) { |m, v| m + v.size } + def on_document(node, parent) + super + op_count = @operation_names.values.inject(0) { |m, v| m + v.size } - op_names.each do |name, nodes| - if name.nil? && op_count > 1 - context.errors << message(%|Operation name is required when multiple operations are present|, nodes, context: context) - elsif nodes.length > 1 - context.errors << message(%|Operation name "#{name}" must be unique|, nodes, context: context) - end + @operation_names.each do |name, nodes| + if name.nil? && op_count > 1 + add_error(GraphQL::StaticValidation::OperationNamesAreValidError.new( + %|Operation name is required when multiple operations are present|, + nodes: nodes + )) + elsif nodes.length > 1 + add_error(GraphQL::StaticValidation::OperationNamesAreValidError.new( + %|Operation name "#{name}" must be unique|, + nodes: nodes, + name: name + )) end - } + end end end end diff --git a/lib/graphql/static_validation/rules/operation_names_are_valid_error.rb b/lib/graphql/static_validation/rules/operation_names_are_valid_error.rb new file mode 100644 index 0000000000..01b131b16d --- /dev/null +++ b/lib/graphql/static_validation/rules/operation_names_are_valid_error.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class OperationNamesAreValidError < StaticValidation::Error + attr_reader :operation_name + + def initialize(message, path: nil, nodes: [], name: nil) + super(message, path: path, nodes: nodes) + @operation_name = name + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code + }.tap { |h| h["operationName"] = operation_name unless operation_name.nil? } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "uniquelyNamedOperations" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/required_arguments_are_present.rb b/lib/graphql/static_validation/rules/required_arguments_are_present.rb index 50e50e97bf..3f628ba182 100644 --- a/lib/graphql/static_validation/rules/required_arguments_are_present.rb +++ b/lib/graphql/static_validation/rules/required_arguments_are_present.rb @@ -1,28 +1,21 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class RequiredArgumentsArePresent - include GraphQL::StaticValidation::Message::MessageHelper - - def validate(context) - v = context.visitor - v[GraphQL::Language::Nodes::Field] << ->(node, parent) { validate_field(node, context) } - v[GraphQL::Language::Nodes::Directive] << ->(node, parent) { validate_directive(node, context) } + module RequiredArgumentsArePresent + def on_field(node, _parent) + assert_required_args(node, field_definition) + super end - private - - def validate_directive(ast_directive, context) - directive_defn = context.schema.directives[ast_directive.name] - assert_required_args(ast_directive, directive_defn, context) + def on_directive(node, _parent) + directive_defn = context.schema.directives[node.name] + assert_required_args(node, directive_defn) + super end - def validate_field(ast_field, context) - defn = context.field_definition - assert_required_args(ast_field, defn, context) - end + private - def assert_required_args(ast_node, defn, context) + def assert_required_args(ast_node, defn) present_argument_names = ast_node.arguments.map(&:name) required_argument_names = defn.arguments.values .select { |a| a.type.kind.non_null? } @@ -30,7 +23,13 @@ def assert_required_args(ast_node, defn, context) missing_names = required_argument_names - present_argument_names if missing_names.any? - context.errors << message("#{ast_node.class.name.split("::").last} '#{ast_node.name}' is missing required arguments: #{missing_names.join(", ")}", ast_node, context: context) + add_error(GraphQL::StaticValidation::RequiredArgumentsArePresentError.new( + "#{ast_node.class.name.split("::").last} '#{ast_node.name}' is missing required arguments: #{missing_names.join(", ")}", + nodes: ast_node, + class_name: ast_node.class.name.split("::").last, + name: ast_node.name, + arguments: "#{missing_names.join(", ")}" + )) end end end diff --git a/lib/graphql/static_validation/rules/required_arguments_are_present_error.rb b/lib/graphql/static_validation/rules/required_arguments_are_present_error.rb new file mode 100644 index 0000000000..828bd3482c --- /dev/null +++ b/lib/graphql/static_validation/rules/required_arguments_are_present_error.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class RequiredArgumentsArePresentError < StaticValidation::Error + attr_reader :class_name + attr_reader :name + attr_reader :arguments + + def initialize(message, path: nil, nodes: [], class_name:, name:, arguments:) + super(message, path: path, nodes: nodes) + @class_name = class_name + @name = name + @arguments = arguments + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "className" => class_name, + "name" => name, + "arguments" => arguments + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "missingRequiredArguments" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/required_input_object_attributes_are_present.rb b/lib/graphql/static_validation/rules/required_input_object_attributes_are_present.rb index cf46f4af40..5f778410b8 100644 --- a/lib/graphql/static_validation/rules/required_input_object_attributes_are_present.rb +++ b/lib/graphql/static_validation/rules/required_input_object_attributes_are_present.rb @@ -1,16 +1,12 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class RequiredInputObjectAttributesArePresent - include GraphQL::StaticValidation::Message::MessageHelper - include GraphQL::StaticValidation::ArgumentsValidator::ArgumentsValidatorHelpers - - def validate(context) - visitor = context.visitor - visitor[GraphQL::Language::Nodes::InputObject] << ->(node, parent) { - next unless parent.is_a? GraphQL::Language::Nodes::Argument + module RequiredInputObjectAttributesArePresent + def on_input_object(node, parent) + if parent.is_a? GraphQL::Language::Nodes::Argument validate_input_object(node, context, parent) - } + end + super end private @@ -34,9 +30,16 @@ def validate_input_object(ast_node, context, parent) missing_fields = required_fields - present_fields missing_fields.each do |missing_field| - path = [ *context.path, missing_field] + path = [*context.path, missing_field] missing_field_type = parent_type.arguments[missing_field].type - context.errors << message("Argument '#{missing_field}' on InputObject '#{parent_type}' is required. Expected type #{missing_field_type}", ast_node, path: path, context: context) + add_error(RequiredInputObjectAttributesArePresentError.new( + "Argument '#{missing_field}' on InputObject '#{parent_type}' is required. Expected type #{missing_field_type}", + argument_name: missing_field, + argument_type: missing_field_type.to_s, + input_object_type: parent_type.to_s, + path: path, + nodes: ast_node, + )) end end end diff --git a/lib/graphql/static_validation/rules/required_input_object_attributes_are_present_error.rb b/lib/graphql/static_validation/rules/required_input_object_attributes_are_present_error.rb new file mode 100644 index 0000000000..581d0a20e1 --- /dev/null +++ b/lib/graphql/static_validation/rules/required_input_object_attributes_are_present_error.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class RequiredInputObjectAttributesArePresentError < StaticValidation::Error + attr_reader :argument_type + attr_reader :argument_name + attr_reader :input_object_type + + def initialize(message, path:, nodes:, argument_type:, argument_name:, input_object_type:) + super(message, path: path, nodes: nodes) + @argument_type = argument_type + @argument_name = argument_name + @input_object_type = input_object_type + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "argumentName" => argument_name, + "argumentType" => argument_type, + "inputObjectType" => input_object_type, + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "missingRequiredInputObjectAttribute" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/subscription_root_exists.rb b/lib/graphql/static_validation/rules/subscription_root_exists.rb index 065b9b0a42..b4ccf10f75 100644 --- a/lib/graphql/static_validation/rules/subscription_root_exists.rb +++ b/lib/graphql/static_validation/rules/subscription_root_exists.rb @@ -1,20 +1,16 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class SubscriptionRootExists - include GraphQL::StaticValidation::Message::MessageHelper - - def validate(context) - return if context.warden.root_type_for_operation("subscription") - - visitor = context.visitor - - visitor[GraphQL::Language::Nodes::OperationDefinition].enter << ->(ast_node, prev_ast_node) { - if ast_node.operation_type == 'subscription' - context.errors << message('Schema is not configured for subscriptions', ast_node, context: context) - return GraphQL::Language::Visitor::SKIP - end - } + module SubscriptionRootExists + def on_operation_definition(node, _parent) + if node.operation_type == "subscription" && context.warden.root_type_for_operation("subscription").nil? + add_error(GraphQL::StaticValidation::SubscriptionRootExistsError.new( + 'Schema is not configured for subscriptions', + nodes: node + )) + else + super + end end end end diff --git a/lib/graphql/static_validation/rules/subscription_root_exists_error.rb b/lib/graphql/static_validation/rules/subscription_root_exists_error.rb new file mode 100644 index 0000000000..ca2f46c0d9 --- /dev/null +++ b/lib/graphql/static_validation/rules/subscription_root_exists_error.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class SubscriptionRootExistsError < StaticValidation::Error + + def initialize(message, path: nil, nodes: []) + super(message, path: path, nodes: nodes) + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "missingSubscriptionConfiguration" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/unique_directives_per_location.rb b/lib/graphql/static_validation/rules/unique_directives_per_location.rb index 43944c4225..a04ee2862c 100644 --- a/lib/graphql/static_validation/rules/unique_directives_per_location.rb +++ b/lib/graphql/static_validation/rules/unique_directives_per_location.rb @@ -1,34 +1,45 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class UniqueDirectivesPerLocation - include GraphQL::StaticValidation::Message::MessageHelper + module UniqueDirectivesPerLocation + DIRECTIVE_NODE_HOOKS = [ + :on_fragment_definition, + :on_fragment_spread, + :on_inline_fragment, + :on_operation_definition, + :on_scalar_type_definition, + :on_object_type_definition, + :on_input_value_definition, + :on_field_definition, + :on_interface_type_definition, + :on_union_type_definition, + :on_enum_type_definition, + :on_enum_value_definition, + :on_input_object_type_definition, + :on_field, + ] - NODES_WITH_DIRECTIVES = GraphQL::Language::Nodes.constants - .map{|c| GraphQL::Language::Nodes.const_get(c)} - .select{|c| c.is_a?(Class) && c.instance_methods.include?(:directives)} - - def validate(context) - NODES_WITH_DIRECTIVES.each do |node_class| - context.visitor[node_class] << ->(node, _) { - validate_directives(node, context) unless node.directives.empty? - } + DIRECTIVE_NODE_HOOKS.each do |method_name| + define_method(method_name) do |node, parent| + if node.directives.any? + validate_directive_location(node) + end + super(node, parent) end end private - def validate_directives(node, context) + def validate_directive_location(node) used_directives = {} - node.directives.each do |ast_directive| directive_name = ast_directive.name if used_directives[directive_name] - context.errors << message( + add_error(GraphQL::StaticValidation::UniqueDirectivesPerLocationError.new( "The directive \"#{directive_name}\" can only be used once at this location.", - [used_directives[directive_name], ast_directive], - context: context - ) + nodes: [used_directives[directive_name], ast_directive], + directive: directive_name, + )) else used_directives[directive_name] = ast_directive end diff --git a/lib/graphql/static_validation/rules/unique_directives_per_location_error.rb b/lib/graphql/static_validation/rules/unique_directives_per_location_error.rb new file mode 100644 index 0000000000..68d9944aab --- /dev/null +++ b/lib/graphql/static_validation/rules/unique_directives_per_location_error.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class UniqueDirectivesPerLocationError < StaticValidation::Error + attr_reader :directive_name + + def initialize(message, path: nil, nodes: [], directive:) + super(message, path: path, nodes: nodes) + @directive_name = directive + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "directiveName" => directive_name + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "directiveNotUniqueForLocation" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/variable_default_values_are_correctly_typed.rb b/lib/graphql/static_validation/rules/variable_default_values_are_correctly_typed.rb index 46520ac507..d609dce70e 100644 --- a/lib/graphql/static_validation/rules/variable_default_values_are_correctly_typed.rb +++ b/lib/graphql/static_validation/rules/variable_default_values_are_correctly_typed.rb @@ -1,40 +1,46 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class VariableDefaultValuesAreCorrectlyTyped - include GraphQL::StaticValidation::Message::MessageHelper - - def validate(context) - context.visitor[GraphQL::Language::Nodes::VariableDefinition] << ->(node, parent) { - if !node.default_value.nil? - validate_default_value(node, context) - end - } - end - - def validate_default_value(node, context) - value = node.default_value - if node.type.is_a?(GraphQL::Language::Nodes::NonNullType) - context.errors << message("Non-null variable $#{node.name} can't have a default value", node, context: context) - else - type = context.schema.type_from_ast(node.type) - if type.nil? - # This is handled by another validator + module VariableDefaultValuesAreCorrectlyTyped + def on_variable_definition(node, parent) + if !node.default_value.nil? + value = node.default_value + if node.type.is_a?(GraphQL::Language::Nodes::NonNullType) + add_error(GraphQL::StaticValidation::VariableDefaultValuesAreCorrectlyTypedError.new( + "Non-null variable $#{node.name} can't have a default value", + nodes: node, + name: node.name, + error_type: VariableDefaultValuesAreCorrectlyTypedError::VIOLATIONS[:INVALID_ON_NON_NULL] + )) else - begin - valid = context.valid_literal?(value, type) - rescue GraphQL::CoercionError => err - error_message = err.message - rescue GraphQL::LiteralValidationError - # noop, we just want to stop any LiteralValidationError from propagating - end + type = context.schema.type_from_ast(node.type) + if type.nil? + # This is handled by another validator + else + begin + valid = context.valid_literal?(value, type) + rescue GraphQL::CoercionError => err + error_message = err.message + rescue GraphQL::LiteralValidationError + # noop, we just want to stop any LiteralValidationError from propagating + end - if !valid - error_message ||= "Default value for $#{node.name} doesn't match type #{type}" - context.errors << message(error_message, node, context: context) + if !valid + error_message ||= "Default value for $#{node.name} doesn't match type #{type}" + VariableDefaultValuesAreCorrectlyTypedError + add_error(GraphQL::StaticValidation::VariableDefaultValuesAreCorrectlyTypedError.new( + error_message, + nodes: node, + name: node.name, + type: type.to_s, + error_type: VariableDefaultValuesAreCorrectlyTypedError::VIOLATIONS[:INVALID_TYPE] + )) + end end end end + + super end end end diff --git a/lib/graphql/static_validation/rules/variable_default_values_are_correctly_typed_error.rb b/lib/graphql/static_validation/rules/variable_default_values_are_correctly_typed_error.rb new file mode 100644 index 0000000000..604fe35895 --- /dev/null +++ b/lib/graphql/static_validation/rules/variable_default_values_are_correctly_typed_error.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class VariableDefaultValuesAreCorrectlyTypedError < StaticValidation::Error + attr_reader :variable_name + attr_reader :type_name + attr_reader :violation + + VIOLATIONS = { + :INVALID_TYPE => "defaultValueInvalidType", + :INVALID_ON_NON_NULL => "defaultValueInvalidOnNonNullVariable", + } + + def initialize(message, path: nil, nodes: [], name:, type: nil, error_type:) + super(message, path: path, nodes: nodes) + @variable_name = name + @type_name = type + raise("Unexpected error type: #{error_type}") if !VIOLATIONS.values.include?(error_type) + @violation = error_type + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "variableName" => variable_name + }.tap { |h| h["typeName"] = type_name unless type_name.nil? } + + super.merge({ + "extensions" => extensions + }) + end + + def code + @violation + end + end + end +end diff --git a/lib/graphql/static_validation/rules/variable_names_are_unique.rb b/lib/graphql/static_validation/rules/variable_names_are_unique.rb index 340aa2eb47..8870846771 100644 --- a/lib/graphql/static_validation/rules/variable_names_are_unique.rb +++ b/lib/graphql/static_validation/rules/variable_names_are_unique.rb @@ -1,22 +1,23 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class VariableNamesAreUnique - include GraphQL::StaticValidation::Message::MessageHelper - - def validate(context) - context.visitor[GraphQL::Language::Nodes::OperationDefinition] << ->(node, parent) { - var_defns = node.variables - if var_defns.any? - vars_by_name = Hash.new { |h, k| h[k] = [] } - var_defns.each { |v| vars_by_name[v.name] << v } - vars_by_name.each do |name, defns| - if defns.size > 1 - context.errors << message("There can only be one variable named \"#{name}\"", defns, context: context) - end + module VariableNamesAreUnique + def on_operation_definition(node, parent) + var_defns = node.variables + if var_defns.any? + vars_by_name = Hash.new { |h, k| h[k] = [] } + var_defns.each { |v| vars_by_name[v.name] << v } + vars_by_name.each do |name, defns| + if defns.size > 1 + add_error(GraphQL::StaticValidation::VariableNamesAreUniqueError.new( + "There can only be one variable named \"#{name}\"", + nodes: defns, + name: name + )) end end - } + end + super end end end diff --git a/lib/graphql/static_validation/rules/variable_names_are_unique_error.rb b/lib/graphql/static_validation/rules/variable_names_are_unique_error.rb new file mode 100644 index 0000000000..f9f9cd2468 --- /dev/null +++ b/lib/graphql/static_validation/rules/variable_names_are_unique_error.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class VariableNamesAreUniqueError < StaticValidation::Error + attr_reader :variable_name + + def initialize(message, path: nil, nodes: [], name:) + super(message, path: path, nodes: nodes) + @variable_name = name + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "variableName" => variable_name + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "variableNotUnique" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/variable_usages_are_allowed.rb b/lib/graphql/static_validation/rules/variable_usages_are_allowed.rb index 50a9d6fe3e..1e76d0cc08 100644 --- a/lib/graphql/static_validation/rules/variable_usages_are_allowed.rb +++ b/lib/graphql/static_validation/rules/variable_usages_are_allowed.rb @@ -1,53 +1,57 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class VariableUsagesAreAllowed - include GraphQL::StaticValidation::Message::MessageHelper - - def validate(context) + module VariableUsagesAreAllowed + def initialize(*) + super # holds { name => ast_node } pairs - declared_variables = {} - context.visitor[GraphQL::Language::Nodes::OperationDefinition] << ->(node, parent) { - declared_variables = node.variables.each_with_object({}) { |var, memo| memo[var.name] = var } - } - - context.visitor[GraphQL::Language::Nodes::Argument] << ->(node, parent) { - node_values = if node.value.is_a?(Array) - node.value - else - [node.value] - end - node_values = node_values.select { |value| value.is_a? GraphQL::Language::Nodes::VariableIdentifier } + @declared_variables = {} + end - return if node_values.none? + def on_operation_definition(node, parent) + @declared_variables = node.variables.each_with_object({}) { |var, memo| memo[var.name] = var } + super + end - arguments = nil - case parent + def on_argument(node, parent) + node_values = if node.value.is_a?(Array) + node.value + else + [node.value] + end + node_values = node_values.select { |value| value.is_a? GraphQL::Language::Nodes::VariableIdentifier } + + if node_values.any? + arguments = case parent when GraphQL::Language::Nodes::Field - arguments = context.field_definition.arguments + context.field_definition.arguments when GraphQL::Language::Nodes::Directive - arguments = context.directive_definition.arguments + context.directive_definition.arguments when GraphQL::Language::Nodes::InputObject arg_type = context.argument_definition.type.unwrap if arg_type.is_a?(GraphQL::InputObjectType) arguments = arg_type.input_fields + else + # This is some kind of error + nil end else raise("Unexpected argument parent: #{parent}") end node_values.each do |node_value| - var_defn_ast = declared_variables[node_value.name] + var_defn_ast = @declared_variables[node_value.name] # Might be undefined :( # VariablesAreUsedAndDefined can't finalize its search until the end of the document. - var_defn_ast && arguments && validate_usage(arguments, node, var_defn_ast, context) + var_defn_ast && arguments && validate_usage(arguments, node, var_defn_ast) end - } + end + super end private - def validate_usage(arguments, arg_node, ast_var, context) + def validate_usage(arguments, arg_node, ast_var) var_type = context.schema.type_from_ast(ast_var.type) if var_type.nil? return @@ -71,16 +75,23 @@ def validate_usage(arguments, arg_node, ast_var, context) var_type = wrap_var_type_with_depth_of_arg(var_type, arg_node) if var_inner_type != arg_inner_type - context.errors << create_error("Type mismatch", var_type, ast_var, arg_defn, arg_node, context) + create_error("Type mismatch", var_type, ast_var, arg_defn, arg_node) elsif list_dimension(var_type) != list_dimension(arg_defn_type) - context.errors << create_error("List dimension mismatch", var_type, ast_var, arg_defn, arg_node, context) + create_error("List dimension mismatch", var_type, ast_var, arg_defn, arg_node) elsif !non_null_levels_match(arg_defn_type, var_type) - context.errors << create_error("Nullability mismatch", var_type, ast_var, arg_defn, arg_node, context) + create_error("Nullability mismatch", var_type, ast_var, arg_defn, arg_node) end end - def create_error(error_message, var_type, ast_var, arg_defn, arg_node, context) - message("#{error_message} on variable $#{ast_var.name} and argument #{arg_node.name} (#{var_type.to_s} / #{arg_defn.type.to_s})", arg_node, context: context) + def create_error(error_message, var_type, ast_var, arg_defn, arg_node) + add_error(GraphQL::StaticValidation::VariableUsagesAreAllowedError.new( + "#{error_message} on variable $#{ast_var.name} and argument #{arg_node.name} (#{var_type.to_s} / #{arg_defn.type.to_s})", + nodes: arg_node, + name: ast_var.name, + type: var_type.to_s, + argument: arg_node.name, + error: error_message + )) end def wrap_var_type_with_depth_of_arg(var_type, arg_node) diff --git a/lib/graphql/static_validation/rules/variable_usages_are_allowed_error.rb b/lib/graphql/static_validation/rules/variable_usages_are_allowed_error.rb new file mode 100644 index 0000000000..b6a8eec2f4 --- /dev/null +++ b/lib/graphql/static_validation/rules/variable_usages_are_allowed_error.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class VariableUsagesAreAllowedError < StaticValidation::Error + attr_reader :type_name + attr_reader :variable_name + attr_reader :argument_name + attr_reader :error_message + + def initialize(message, path: nil, nodes: [], type:, name:, argument:, error:) + super(message, path: path, nodes: nodes) + @type_name = type + @variable_name = name + @argument_name = argument + @error_message = error + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "variableName" => variable_name, + "typeName" => type_name, + "argumentName" => argument_name, + "errorMessage" => error_message + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "variableMismatch" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/variables_are_input_types.rb b/lib/graphql/static_validation/rules/variables_are_input_types.rb index 0f3f6552af..78cebeeca0 100644 --- a/lib/graphql/static_validation/rules/variables_are_input_types.rb +++ b/lib/graphql/static_validation/rules/variables_are_input_types.rb @@ -1,28 +1,32 @@ # frozen_string_literal: true module GraphQL module StaticValidation - class VariablesAreInputTypes - include GraphQL::StaticValidation::Message::MessageHelper - - def validate(context) - context.visitor[GraphQL::Language::Nodes::VariableDefinition] << ->(node, parent) { - validate_is_input_type(node, context) - } - end - - private - - def validate_is_input_type(node, context) + module VariablesAreInputTypes + def on_variable_definition(node, parent) type_name = get_type_name(node.type) type = context.warden.get_type(type_name) if type.nil? - context.errors << message("#{type_name} isn't a defined input type (on $#{node.name})", node, context: context) + add_error(GraphQL::StaticValidation::VariablesAreInputTypesError.new( + "#{type_name} isn't a defined input type (on $#{node.name})", + nodes: node, + name: node.name, + type: type_name + )) elsif !type.kind.input? - context.errors << message("#{type.name} isn't a valid input type (on $#{node.name})", node, context: context) + add_error(GraphQL::StaticValidation::VariablesAreInputTypesError.new( + "#{type.name} isn't a valid input type (on $#{node.name})", + nodes: node, + name: node.name, + type: type_name + )) end + + super end + private + def get_type_name(ast_type) if ast_type.respond_to?(:of_type) get_type_name(ast_type.of_type) diff --git a/lib/graphql/static_validation/rules/variables_are_input_types_error.rb b/lib/graphql/static_validation/rules/variables_are_input_types_error.rb new file mode 100644 index 0000000000..d3d6ce08b1 --- /dev/null +++ b/lib/graphql/static_validation/rules/variables_are_input_types_error.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class VariablesAreInputTypesError < StaticValidation::Error + attr_reader :type_name + attr_reader :variable_name + + def initialize(message, path: nil, nodes: [], type:, name:) + super(message, path: path, nodes: nodes) + @type_name = type + @variable_name = name + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "typeName" => type_name, + "variableName" => variable_name + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + "variableRequiresValidType" + end + end + end +end diff --git a/lib/graphql/static_validation/rules/variables_are_used_and_defined.rb b/lib/graphql/static_validation/rules/variables_are_used_and_defined.rb index c6277e627a..334fd40c9d 100644 --- a/lib/graphql/static_validation/rules/variables_are_used_and_defined.rb +++ b/lib/graphql/static_validation/rules/variables_are_used_and_defined.rb @@ -11,9 +11,7 @@ module StaticValidation # - re-visiting the AST for each validator # - allowing validators to say `followSpreads: true` # - class VariablesAreUsedAndDefined - include GraphQL::StaticValidation::Message::MessageHelper - + module VariablesAreUsedAndDefined class VariableUsage attr_accessor :ast_node, :used_by, :declared_by, :path def used? @@ -25,73 +23,67 @@ def declared? end end - def variable_hash - Hash.new {|h, k| h[k] = VariableUsage.new } + def initialize(*) + super + @variable_usages_for_context = Hash.new {|hash, key| hash[key] = Hash.new {|h, k| h[k] = VariableUsage.new } } + @spreads_for_context = Hash.new {|hash, key| hash[key] = [] } + @variable_context_stack = [] end - def validate(context) - variable_usages_for_context = Hash.new {|hash, key| hash[key] = variable_hash } - spreads_for_context = Hash.new {|hash, key| hash[key] = [] } - variable_context_stack = [] - - # OperationDefinitions and FragmentDefinitions - # both push themselves onto the context stack (and pop themselves off) - push_variable_context_stack = ->(node, parent) { - # initialize the hash of vars for this context: - variable_usages_for_context[node] - variable_context_stack.push(node) - } - - pop_variable_context_stack = ->(node, parent) { - variable_context_stack.pop - } - - - context.visitor[GraphQL::Language::Nodes::OperationDefinition] << push_variable_context_stack - context.visitor[GraphQL::Language::Nodes::OperationDefinition] << ->(node, parent) { - # mark variables as defined: - var_hash = variable_usages_for_context[node] - node.variables.each { |var| - var_usage = var_hash[var.name] - var_usage.declared_by = node - var_usage.path = context.path - } + def on_operation_definition(node, parent) + # initialize the hash of vars for this context: + @variable_usages_for_context[node] + @variable_context_stack.push(node) + # mark variables as defined: + var_hash = @variable_usages_for_context[node] + node.variables.each { |var| + var_usage = var_hash[var.name] + var_usage.declared_by = node + var_usage.path = context.path } - context.visitor[GraphQL::Language::Nodes::OperationDefinition].leave << pop_variable_context_stack - - context.visitor[GraphQL::Language::Nodes::FragmentDefinition] << push_variable_context_stack - context.visitor[GraphQL::Language::Nodes::FragmentDefinition].leave << pop_variable_context_stack + super + @variable_context_stack.pop + end - # For FragmentSpreads: - # - find the context on the stack - # - mark the context as containing this spread - context.visitor[GraphQL::Language::Nodes::FragmentSpread] << ->(node, parent) { - variable_context = variable_context_stack.last - spreads_for_context[variable_context] << node.name - } + def on_fragment_definition(node, parent) + # initialize the hash of vars for this context: + @variable_usages_for_context[node] + @variable_context_stack.push(node) + super + @variable_context_stack.pop + end - # For VariableIdentifiers: - # - mark the variable as used - # - assign its AST node - context.visitor[GraphQL::Language::Nodes::VariableIdentifier] << ->(node, parent) { - usage_context = variable_context_stack.last - declared_variables = variable_usages_for_context[usage_context] - usage = declared_variables[node.name] - usage.used_by = usage_context - usage.ast_node = node - usage.path = context.path - } + # For FragmentSpreads: + # - find the context on the stack + # - mark the context as containing this spread + def on_fragment_spread(node, parent) + variable_context = @variable_context_stack.last + @spreads_for_context[variable_context] << node.name + super + end + # For VariableIdentifiers: + # - mark the variable as used + # - assign its AST node + def on_variable_identifier(node, parent) + usage_context = @variable_context_stack.last + declared_variables = @variable_usages_for_context[usage_context] + usage = declared_variables[node.name] + usage.used_by = usage_context + usage.ast_node = node + usage.path = context.path + super + end - context.visitor[GraphQL::Language::Nodes::Document].leave << ->(node, parent) { - fragment_definitions = variable_usages_for_context.select { |key, value| key.is_a?(GraphQL::Language::Nodes::FragmentDefinition) } - operation_definitions = variable_usages_for_context.select { |key, value| key.is_a?(GraphQL::Language::Nodes::OperationDefinition) } + def on_document(node, parent) + super + fragment_definitions = @variable_usages_for_context.select { |key, value| key.is_a?(GraphQL::Language::Nodes::FragmentDefinition) } + operation_definitions = @variable_usages_for_context.select { |key, value| key.is_a?(GraphQL::Language::Nodes::OperationDefinition) } - operation_definitions.each do |node, node_variables| - follow_spreads(node, node_variables, spreads_for_context, fragment_definitions, []) - create_errors(node_variables, context) - end - } + operation_definitions.each do |node, node_variables| + follow_spreads(node, node_variables, @spreads_for_context, fragment_definitions, []) + create_errors(node_variables) + end end private @@ -129,16 +121,32 @@ def follow_spreads(node, parent_variables, spreads_for_context, fragment_definit # Determine all the error messages, # Then push messages into the validation context - def create_errors(node_variables, context) + def create_errors(node_variables) # Declared but not used: node_variables .select { |name, usage| usage.declared? && !usage.used? } - .each { |var_name, usage| context.errors << message("Variable $#{var_name} is declared by #{usage.declared_by.name} but not used", usage.declared_by, path: usage.path) } + .each { |var_name, usage| + add_error(GraphQL::StaticValidation::VariablesAreUsedAndDefinedError.new( + "Variable $#{var_name} is declared by #{usage.declared_by.name} but not used", + nodes: usage.declared_by, + path: usage.path, + name: var_name, + error_type: VariablesAreUsedAndDefinedError::VIOLATIONS[:VARIABLE_NOT_USED] + )) + } # Used but not declared: node_variables .select { |name, usage| usage.used? && !usage.declared? } - .each { |var_name, usage| context.errors << message("Variable $#{var_name} is used by #{usage.used_by.name} but not declared", usage.ast_node, path: usage.path) } + .each { |var_name, usage| + add_error(GraphQL::StaticValidation::VariablesAreUsedAndDefinedError.new( + "Variable $#{var_name} is used by #{usage.used_by.name} but not declared", + nodes: usage.ast_node, + path: usage.path, + name: var_name, + error_type: VariablesAreUsedAndDefinedError::VIOLATIONS[:VARIABLE_NOT_DEFINED] + )) + } end end end diff --git a/lib/graphql/static_validation/rules/variables_are_used_and_defined_error.rb b/lib/graphql/static_validation/rules/variables_are_used_and_defined_error.rb new file mode 100644 index 0000000000..b005b645ed --- /dev/null +++ b/lib/graphql/static_validation/rules/variables_are_used_and_defined_error.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +module GraphQL + module StaticValidation + class VariablesAreUsedAndDefinedError < StaticValidation::Error + attr_reader :variable_name + attr_reader :violation + + VIOLATIONS = { + :VARIABLE_NOT_USED => "variableNotUsed", + :VARIABLE_NOT_DEFINED => "variableNotDefined", + } + + def initialize(message, path: nil, nodes: [], name:, error_type:) + super(message, path: path, nodes: nodes) + @variable_name = name + raise("Unexpected error type: #{error_type}") if !VIOLATIONS.values.include?(error_type) + @violation = error_type + end + + # A hash representation of this Message + def to_h + extensions = { + "code" => code, + "variableName" => variable_name + } + + super.merge({ + "extensions" => extensions + }) + end + + def code + @violation + end + end + end +end diff --git a/lib/graphql/static_validation/validation_context.rb b/lib/graphql/static_validation/validation_context.rb index 7787936cfe..20df3cd89b 100644 --- a/lib/graphql/static_validation/validation_context.rb +++ b/lib/graphql/static_validation/validation_context.rb @@ -14,70 +14,27 @@ module StaticValidation class ValidationContext extend Forwardable - attr_reader :query, :errors, :visitor, :dependencies, :each_irep_node_handlers + attr_reader :query, :errors, :visitor, + :on_dependency_resolve_handlers def_delegators :@query, :schema, :document, :fragments, :operations, :warden - def initialize(query) + def initialize(query, visitor_class) @query = query @literal_validator = LiteralValidator.new(context: query.context) @errors = [] - @visitor = GraphQL::Language::Visitor.new(document) - @type_stack = GraphQL::StaticValidation::TypeStack.new(schema, visitor) - definition_dependencies = DefinitionDependencies.mount(self) @on_dependency_resolve_handlers = [] - @each_irep_node_handlers = [] - visitor[GraphQL::Language::Nodes::Document].leave << ->(_n, _p) { - @dependencies = definition_dependencies.dependency_map { |defn, spreads, frag| - @on_dependency_resolve_handlers.each { |h| h.call(defn, spreads, frag) } - } - } + @visitor = visitor_class.new(document, self) end + def_delegators :@visitor, + :path, :type_definition, :field_definition, :argument_definition, + :parent_type_definition, :directive_definition, :object_types, :dependencies + def on_dependency_resolve(&handler) @on_dependency_resolve_handlers << handler end - def object_types - @type_stack.object_types - end - - def each_irep_node(&handler) - @each_irep_node_handlers << handler - end - - # @return [GraphQL::BaseType] The current object type - def type_definition - object_types.last - end - - # @return [GraphQL::BaseType] The type which the current type came from - def parent_type_definition - object_types[-2] - end - - # @return [GraphQL::Field, nil] The most-recently-entered GraphQL::Field, if currently inside one - def field_definition - @type_stack.field_definitions.last - end - - # @return [Array] Field names to get to the current field - def path - @type_stack.path.dup - end - - # @return [GraphQL::Directive, nil] The most-recently-entered GraphQL::Directive, if currently inside one - def directive_definition - @type_stack.directive_definitions.last - end - - # @return [GraphQL::Argument, nil] The most-recently-entered GraphQL::Argument, if currently inside one - def argument_definition - # Don't get the _last_ one because that's the current one. - # Get the second-to-last one, which is the parent of the current one. - @type_stack.argument_definitions[-2] - end - def valid_literal?(ast_value, type) @literal_validator.validate(ast_value, type) end diff --git a/lib/graphql/static_validation/validator.rb b/lib/graphql/static_validation/validator.rb index 32d349c3de..ea7ad8f8d5 100644 --- a/lib/graphql/static_validation/validator.rb +++ b/lib/graphql/static_validation/validator.rb @@ -23,29 +23,37 @@ def initialize(schema:, rules: GraphQL::StaticValidation::ALL_RULES) # @return [Array] def validate(query, validate: true) query.trace("validate", { validate: validate, query: query }) do - context = GraphQL::StaticValidation::ValidationContext.new(query) - rewrite = GraphQL::InternalRepresentation::Rewrite.new + can_skip_rewrite = query.context.interpreter? && query.schema.using_ast_analysis? + errors = if validate == false && can_skip_rewrite + [] + else + rules_to_use = validate ? @rules : [] + visitor_class = BaseVisitor.including_rules(rules_to_use, rewrite: !can_skip_rewrite) - # Put this first so its enters and exits are always called - rewrite.validate(context) + context = GraphQL::StaticValidation::ValidationContext.new(query, visitor_class) - # If the caller opted out of validation, don't attach these - if validate - @rules.each do |rules| - rules.new.validate(context) + # Attach legacy-style rules + rules_to_use.each do |rule_class_or_module| + if rule_class_or_module.method_defined?(:validate) + rule_class_or_module.new.validate(context) + end end + + context.visitor.visit + context.errors end - context.visitor.visit - rewrite_result = rewrite.document - # Post-validation: allow validators to register handlers on rewritten query nodes - GraphQL::InternalRepresentation::Visit.visit_each_node(rewrite_result.operation_definitions, context.each_irep_node_handlers) + irep = if errors.none? && context + # Only return this if there are no errors and validation was actually run + context.visitor.rewrite_document + else + nil + end { - errors: context.errors, - # If there were errors, the irep is garbage - irep: context.errors.any? ? nil : rewrite_result, + errors: errors, + irep: irep, } end end diff --git a/lib/graphql/subscriptions.rb b/lib/graphql/subscriptions.rb index a482345651..26df123817 100644 --- a/lib/graphql/subscriptions.rb +++ b/lib/graphql/subscriptions.rb @@ -6,6 +6,7 @@ if defined?(ActionCable) require "graphql/subscriptions/action_cable_subscriptions" end +require "graphql/subscriptions/subscription_root" module GraphQL class Subscriptions diff --git a/lib/graphql/subscriptions/event.rb b/lib/graphql/subscriptions/event.rb index 52970007bb..2e7b4c38a1 100644 --- a/lib/graphql/subscriptions/event.rb +++ b/lib/graphql/subscriptions/event.rb @@ -36,11 +36,15 @@ def self.serialize(name, arguments, field, scope:) when GraphQL::Query::Arguments arguments when Hash - GraphQL::Query::LiteralInput.from_arguments( - arguments, - field, - nil, - ) + if field.is_a?(GraphQL::Schema::Field) + stringify_args(arguments) + else + GraphQL::Query::LiteralInput.from_arguments( + arguments, + field, + nil, + ) + end else raise ArgumentError, "Unexpected arguments: #{arguments}, must be Hash or GraphQL::Arguments" end @@ -48,6 +52,25 @@ def self.serialize(name, arguments, field, scope:) sorted_h = normalized_args.to_h.sort.to_h Serialize.dump_recursive([scope, name, sorted_h]) end + + class << self + private + def stringify_args(args) + case args + when Hash + next_args = {} + args.each do |k, v| + str_k = GraphQL::Schema::Member::BuildType.camelize(k.to_s) + next_args[str_k] = stringify_args(v) + end + next_args + when Array + args.map { |a| stringify_args(a) } + else + args + end + end + end end end end diff --git a/lib/graphql/subscriptions/subscription_root.rb b/lib/graphql/subscriptions/subscription_root.rb new file mode 100644 index 0000000000..1c9d5df2d1 --- /dev/null +++ b/lib/graphql/subscriptions/subscription_root.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module GraphQL + class Subscriptions + # Extend this module in your subscription root when using {GraphQL::Execution::Interpreter}. + module SubscriptionRoot + def field(*args, extensions: [], **rest, &block) + extensions += [Extension] + super(*args, extensions: extensions, **rest, &block) + end + + class Extension < GraphQL::Schema::FieldExtension + def after_resolve(value:, context:, object:, arguments:, **rest) + if value.is_a?(GraphQL::ExecutionError) + value + elsif (events = context.namespace(:subscriptions)[:events]) + # This is the first execution, so gather an Event + # for the backend to register: + events << Subscriptions::Event.new( + name: field.name, + arguments: arguments, + context: context, + field: field, + ) + context.skip + elsif context.query.subscription_topic == Subscriptions::Event.serialize( + field.name, + arguments, + field, + scope: (field.subscription_scope ? context[field.subscription_scope] : nil), + ) + # The root object is _already_ the subscription update, + # it was passed to `.trigger` + object.object + else + # This is a subscription update, but this event wasn't triggered. + context.skip + end + end + end + end + end +end diff --git a/lib/graphql/tracing.rb b/lib/graphql/tracing.rb index cfd4c000d6..f39c999ddd 100644 --- a/lib/graphql/tracing.rb +++ b/lib/graphql/tracing.rb @@ -42,8 +42,8 @@ module GraphQL # execute_multiplex | `{ multiplex: GraphQL::Execution::Multiplex }` # execute_query | `{ query: GraphQL::Query }` # execute_query_lazy | `{ query: GraphQL::Query?, multiplex: GraphQL::Execution::Multiplex? }` - # execute_field | `{ context: GraphQL::Query::Context::FieldResolutionContext }` - # execute_field_lazy | `{ context: GraphQL::Query::Context::FieldResolutionContext }` + # execute_field | `{ context: GraphQL::Query::Context::FieldResolutionContext?, field: GraphQL::Schema::Field?, path: Array?}` + # execute_field_lazy | `{ context: GraphQL::Query::Context::FieldResolutionContext?, field: GraphQL::Schema::Field?, path: Array?}` # module Tracing # Objects may include traceable to gain a `.trace(...)` method. diff --git a/lib/graphql/tracing/appsignal_tracing.rb b/lib/graphql/tracing/appsignal_tracing.rb index 45ad5cff52..a2c8faa9ed 100644 --- a/lib/graphql/tracing/appsignal_tracing.rb +++ b/lib/graphql/tracing/appsignal_tracing.rb @@ -21,7 +21,7 @@ def platform_trace(platform_key, key, data) end def platform_field_key(type, field) - "#{type.name}.#{field.name}.graphql" + "#{type.graphql_name}.#{field.graphql_name}.graphql" end end end diff --git a/lib/graphql/tracing/data_dog_tracing.rb b/lib/graphql/tracing/data_dog_tracing.rb index f6319c6b61..5584d80eea 100644 --- a/lib/graphql/tracing/data_dog_tracing.rb +++ b/lib/graphql/tracing/data_dog_tracing.rb @@ -42,7 +42,7 @@ def tracer end def platform_field_key(type, field) - "#{type.name}.#{field.name}" + "#{type.graphql_name}.#{field.graphql_name}" end end end diff --git a/lib/graphql/tracing/new_relic_tracing.rb b/lib/graphql/tracing/new_relic_tracing.rb index f30b1cc06a..56a592cf3b 100644 --- a/lib/graphql/tracing/new_relic_tracing.rb +++ b/lib/graphql/tracing/new_relic_tracing.rb @@ -47,7 +47,7 @@ def platform_trace(platform_key, key, data) end def platform_field_key(type, field) - "GraphQL/#{type.name}/#{field.name}" + "GraphQL/#{type.graphql_name}/#{field.graphql_name}" end end end diff --git a/lib/graphql/tracing/platform_tracing.rb b/lib/graphql/tracing/platform_tracing.rb index be8427b938..33843102c7 100644 --- a/lib/graphql/tracing/platform_tracing.rb +++ b/lib/graphql/tracing/platform_tracing.rb @@ -26,7 +26,23 @@ def trace(key, data) yield end when "execute_field", "execute_field_lazy" - if (platform_key = data[:context].field.metadata[:platform_key]) + if data[:context] + field = data[:context].field + platform_key = field.metadata[:platform_key] + trace_field = true # implemented with instrumenter + else + field = data[:field] + # Lots of duplicated work here, can this be done ahead of time? + platform_key = platform_field_key(field.owner, field) + return_type = field.type.unwrap + trace_field = if return_type.kind.scalar? || return_type.kind.enum? + (field.trace.nil? && @trace_scalars) || field.trace + else + true + end + end + + if platform_key && trace_field platform_trace(platform_key, key, data) do yield end diff --git a/lib/graphql/tracing/prometheus_tracing.rb b/lib/graphql/tracing/prometheus_tracing.rb index c3de0c5f2d..ca066917c5 100644 --- a/lib/graphql/tracing/prometheus_tracing.rb +++ b/lib/graphql/tracing/prometheus_tracing.rb @@ -33,7 +33,7 @@ def platform_trace(platform_key, key, data, &block) end def platform_field_key(type, field) - "#{type.name}.#{field.name}" + "#{type.graphql_name}.#{field.graphql_name}" end private diff --git a/lib/graphql/tracing/scout_tracing.rb b/lib/graphql/tracing/scout_tracing.rb index fbad688505..93e5372359 100644 --- a/lib/graphql/tracing/scout_tracing.rb +++ b/lib/graphql/tracing/scout_tracing.rb @@ -28,7 +28,7 @@ def platform_trace(platform_key, key, data) end def platform_field_key(type, field) - "#{type.name}.#{field.name}" + "#{type.graphql_name}.#{field.graphql_name}" end end end diff --git a/lib/graphql/tracing/skylight_tracing.rb b/lib/graphql/tracing/skylight_tracing.rb index 9926032725..8b7120704e 100644 --- a/lib/graphql/tracing/skylight_tracing.rb +++ b/lib/graphql/tracing/skylight_tracing.rb @@ -54,7 +54,7 @@ def platform_trace(platform_key, key, data) end def platform_field_key(type, field) - "graphql.#{type.name}.#{field.name}" + "graphql.#{type.graphql_name}.#{field.graphql_name}" end end end diff --git a/lib/graphql/types/relay.rb b/lib/graphql/types/relay.rb index bdca586e42..2f962d780c 100644 --- a/lib/graphql/types/relay.rb +++ b/lib/graphql/types/relay.rb @@ -6,6 +6,8 @@ require "graphql/types/relay/base_connection" require "graphql/types/relay/base_edge" require "graphql/types/relay/node" +require "graphql/types/relay/node_field" +require "graphql/types/relay/nodes_field" module GraphQL module Types diff --git a/lib/graphql/types/relay/base_connection.rb b/lib/graphql/types/relay/base_connection.rb index 3f259dd68f..3a264af7f3 100644 --- a/lib/graphql/types/relay/base_connection.rb +++ b/lib/graphql/types/relay/base_connection.rb @@ -35,6 +35,9 @@ class << self # @return [Class] attr_reader :node_type + # @return [Class] + attr_reader :edge_class + # Configure this connection to return `edges` and `nodes` based on `edge_type_class`. # # This method will use the inputs to create: @@ -51,11 +54,11 @@ def edge_type(edge_type_class, edge_class: GraphQL::Relay::Edge, node_type: edge @node_type = node_type @edge_type = edge_type_class + @edge_class = edge_class field :edges, [edge_type_class, null: true], null: true, description: "A list of edges.", - method: :edge_nodes, edge_class: edge_class define_nodes_field if nodes_field @@ -101,6 +104,17 @@ def define_nodes_field def nodes @object.edge_nodes end + + def edges + if context.interpreter? + context.schema.after_lazy(object.edge_nodes) do |nodes| + nodes.map { |n| self.class.edge_class.new(n, object) } + end + else + # This is done by edges_instrumentation + @object.edge_nodes + end + end end end end diff --git a/lib/graphql/types/relay/node.rb b/lib/graphql/types/relay/node.rb index ad7b0e2b86..b0a638e976 100644 --- a/lib/graphql/types/relay/node.rb +++ b/lib/graphql/types/relay/node.rb @@ -11,7 +11,6 @@ module Node default_relay(true) description "An object with an ID." field(:id, ID, null: false, description: "ID of the object.") - # TODO Should I implement `id` here to call the schema's hook? end end end diff --git a/lib/graphql/types/relay/node_field.rb b/lib/graphql/types/relay/node_field.rb new file mode 100644 index 0000000000..a0a109a00c --- /dev/null +++ b/lib/graphql/types/relay/node_field.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +module GraphQL + module Types + module Relay + # This can be used for implementing `Query.node(id: ...)`, + # or use it for inspiration for your own field definition. + NodeField = GraphQL::Schema::Field.new( + name: "node", + owner: nil, + type: GraphQL::Types::Relay::Node, + null: true, + description: "Fetches an object given its ID.", + relay_node_field: true, + ) do + argument :id, "ID!", required: true, + description: "ID of the object." + + def resolve(obj, args, ctx) + ctx.schema.object_from_id(args[:id], ctx) + end + + def resolve_field(obj, args, ctx) + resolve(obj, args, ctx) + end + end + end + end +end diff --git a/lib/graphql/types/relay/nodes_field.rb b/lib/graphql/types/relay/nodes_field.rb new file mode 100644 index 0000000000..899adadbee --- /dev/null +++ b/lib/graphql/types/relay/nodes_field.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +module GraphQL + module Types + module Relay + # This can be used for implementing `Query.nodes(ids: ...)`, + # or use it for inspiration for your own field definition. + # @see GraphQL::Types::Relay::NodeField + NodesField = GraphQL::Schema::Field.new( + name: "nodes", + owner: nil, + type: [GraphQL::Types::Relay::Node, null: true], + null: false, + description: "Fetches a list of objects given a list of IDs.", + relay_nodes_field: true, + ) do + argument :ids, "[ID!]!", required: true, + description: "IDs of the objects." + + def resolve(obj, args, ctx) + args[:ids].map { |id| ctx.schema.object_from_id(id, ctx) } + end + + def resolve_field(obj, args, ctx) + resolve(obj, args, ctx) + end + end + end + end +end diff --git a/lib/graphql/unauthorized_error.rb b/lib/graphql/unauthorized_error.rb index 5d158c33a1..e48bbe34ef 100644 --- a/lib/graphql/unauthorized_error.rb +++ b/lib/graphql/unauthorized_error.rb @@ -1,5 +1,9 @@ # frozen_string_literal: true module GraphQL + # When an `authorized?` hook returns false, this error is used to communicate the failure. + # It's passed to {Schema.unauthorized_object}. + # + # Alternatively, custom code in `authorized?` may raise this error. It will be routed the same way. class UnauthorizedError < GraphQL::Error # @return [Object] the application object that failed the authorization check attr_reader :object diff --git a/lib/graphql/version.rb b/lib/graphql/version.rb index dadda4fd2c..f239ccbe1a 100644 --- a/lib/graphql/version.rb +++ b/lib/graphql/version.rb @@ -1,4 +1,4 @@ # frozen_string_literal: true module GraphQL - VERSION = "1.8.13" + VERSION = "1.9.0.pre1" end diff --git a/spec/dummy/app/channels/graphql_channel.rb b/spec/dummy/app/channels/graphql_channel.rb index 17f0572412..0463d2def0 100644 --- a/spec/dummy/app/channels/graphql_channel.rb +++ b/spec/dummy/app/channels/graphql_channel.rb @@ -1,20 +1,28 @@ # frozen_string_literal: true class GraphqlChannel < ActionCable::Channel::Base - QueryType = GraphQL::ObjectType.define do - name "Query" - field :value, types.Int, resolve: Proc.new { 3 } + class QueryType < GraphQL::Schema::Object + field :value, Integer, null: false + def value + 3 + end end - SubscriptionType = GraphQL::ObjectType.define do - name "Subscription" - field :payload, PayloadType do - argument :id, !types.ID - end + class PayloadType < GraphQL::Schema::Object + field :value, Integer, null: false end - PayloadType = GraphQL::ObjectType.define do - name "Payload" - field :value, types.Int + class SubscriptionType < GraphQL::Schema::Object + if TESTING_INTERPRETER + extend GraphQL::Subscriptions::SubscriptionRoot + end + + field :payload, PayloadType, null: false do + argument :id, ID, required: true + end + + def payload(id:) + id + end end # Wacky behavior around the number 4 @@ -42,6 +50,9 @@ class GraphQLSchema < GraphQL::Schema subscription(SubscriptionType) use GraphQL::Subscriptions::ActionCableSubscriptions, serializer: CustomSerializer + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end def subscribed diff --git a/spec/dummy/test/test_helper.rb b/spec/dummy/test/test_helper.rb index 4ccb83c0f4..29193f7185 100644 --- a/spec/dummy/test/test_helper.rb +++ b/spec/dummy/test/test_helper.rb @@ -1,3 +1,4 @@ # frozen_string_literal: true +TESTING_INTERPRETER = !!ENV["TESTING_INTERPRETER"] require File.expand_path('../../config/environment', __FILE__) require 'rails/test_help' diff --git a/spec/graphql/analysis/ast/field_usage_spec.rb b/spec/graphql/analysis/ast/field_usage_spec.rb new file mode 100644 index 0000000000..ea974e53d5 --- /dev/null +++ b/spec/graphql/analysis/ast/field_usage_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Analysis::AST::FieldUsage do + let(:result) { GraphQL::Analysis::AST.analyze_query(query, [GraphQL::Analysis::AST::FieldUsage]).first } + let(:query) { GraphQL::Query.new(Dummy::Schema, query_string, variables: variables) } + let(:variables) { {} } + + describe "query with deprecated fields" do + let(:query_string) {%| + query { + cheese(id: 1) { + id + fatContent + } + } + |} + + it "keeps track of used fields" do + assert_equal ['Cheese.id', 'Cheese.fatContent', 'Query.cheese'], result[:used_fields] + end + + it "keeps track of deprecated fields" do + assert_equal ['Cheese.fatContent'], result[:used_deprecated_fields] + end + end + + describe "query with deprecated fields used more than once" do + let(:query_string) {%| + query { + cheese1: cheese(id: 1) { + id + fatContent + } + + cheese2: cheese(id: 2) { + id + fatContent + } + } + |} + + it "omits duplicate usage of a field" do + assert_equal ['Cheese.id', 'Cheese.fatContent', 'Query.cheese'], result[:used_fields] + end + + it "omits duplicate usage of a deprecated field" do + assert_equal ['Cheese.fatContent'], result[:used_deprecated_fields] + end + end +end diff --git a/spec/graphql/analysis/ast/max_query_complexity_spec.rb b/spec/graphql/analysis/ast/max_query_complexity_spec.rb new file mode 100644 index 0000000000..f9db1bae18 --- /dev/null +++ b/spec/graphql/analysis/ast/max_query_complexity_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Analysis::AST::MaxQueryComplexity do + before do + @prev_max_complexity = Dummy::Schema.max_complexity + end + + after do + Dummy::Schema.max_complexity = @prev_max_complexity + end + + let(:query_string) {%| + { + a: cheese(id: 1) { id } + b: cheese(id: 1) { id } + c: cheese(id: 1) { id } + d: cheese(id: 1) { id } + e: cheese(id: 1) { id } + } + |} + let(:query) { GraphQL::Query.new(Dummy::Schema, query_string, variables: {}, max_complexity: max_complexity) } + let(:result) { + GraphQL::Analysis::AST.analyze_query(query, [GraphQL::Analysis::AST::MaxQueryComplexity]).first + } + + + describe "when a query goes over max complexity" do + let(:max_complexity) { 9 } + + it "returns an error" do + assert_equal GraphQL::AnalysisError, result.class + assert_equal "Query has complexity of 10, which exceeds max complexity of 9", result.message + end + end + + describe "when there is no max complexity" do + let(:max_complexity) { nil } + + it "doesn't error" do + assert_nil result + end + end + + describe "when the query is less than the max complexity" do + let(:max_complexity) { 99 } + + it "doesn't error" do + assert_nil result + end + end + + describe "when max_complexity is decreased at query-level" do + before do + Dummy::Schema.max_complexity = 100 + end + + let(:max_complexity) { 7 } + + it "is applied" do + assert_equal GraphQL::AnalysisError, result.class + assert_equal "Query has complexity of 10, which exceeds max complexity of 7", result.message + end + end + + describe "when max_complexity is increased at query-level" do + before do + Dummy::Schema.max_complexity = 1 + end + + let(:max_complexity) { 10 } + + it "doesn't error" do + assert_nil result + end + end + + describe "across a multiplex" do + before do + @old_analysis_engine = Dummy::Schema.analysis_engine + Dummy::Schema.analysis_engine = GraphQL::Analysis::AST + end + + after do + Dummy::Schema.analysis_engine = @old_analysis_engine + end + + let(:queries) { + 5.times.map { |n| + GraphQL::Query.new(Dummy::Schema, "{ cheese(id: #{n}) { id } }", variables: {}) + } + } + + let(:max_complexity) { 9 } + let(:multiplex) { GraphQL::Execution::Multiplex.new(schema: Dummy::Schema, queries: queries, context: {}, max_complexity: max_complexity) } + let(:analyze_multiplex) { + GraphQL::Analysis::AST.analyze_multiplex(multiplex, [GraphQL::Analysis::AST::MaxQueryComplexity]) + } + + it "returns errors for all queries" do + analyze_multiplex + err_msg = "Query has complexity of 10, which exceeds max complexity of 9" + queries.each do |query| + assert_equal err_msg, query.analysis_errors[0].message + end + end + + describe "with a local override" do + let(:max_complexity) { 10 } + + it "uses the override" do + analyze_multiplex + + queries.each do |query| + assert query.analysis_errors.empty? + end + end + end + end +end diff --git a/spec/graphql/analysis/ast/max_query_depth_spec.rb b/spec/graphql/analysis/ast/max_query_depth_spec.rb new file mode 100644 index 0000000000..b6f0cc64d9 --- /dev/null +++ b/spec/graphql/analysis/ast/max_query_depth_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Analysis::AST::MaxQueryDepth do + before do + @prev_max_depth = Dummy::Schema.max_depth + end + + after do + Dummy::Schema.max_depth = @prev_max_depth + end + + let(:query_string) { " + { + cheese(id: 1) { + similarCheese(source: SHEEP) { + similarCheese(source: SHEEP) { + similarCheese(source: SHEEP) { + similarCheese(source: SHEEP) { + similarCheese(source: SHEEP) { + id + } + } + } + } + } + } + } + "} + let(:max_depth) { nil } + let(:query) { + GraphQL::Query.new( + Dummy::Schema.graphql_definition, + query_string, + variables: {}, + max_depth: max_depth + ) + } + let(:result) { + GraphQL::Analysis::AST.analyze_query(query, [GraphQL::Analysis::AST::MaxQueryDepth]).first + } + + describe "when the query is deeper than max depth" do + let(:max_depth) { 5 } + + it "adds an error message for a too-deep query" do + assert_equal "Query has depth of 7, which exceeds max depth of 5", result.message + end + end + + describe "when the query specifies a different max_depth" do + let(:max_depth) { 100 } + + it "obeys that max_depth" do + assert_nil result + end + end + + describe "When the query is not deeper than max_depth" do + before do + Dummy::Schema.max_depth = 100 + end + + it "doesn't add an error" do + assert_nil result + end + end + + describe "when the max depth isn't set" do + before do + Dummy::Schema.max_depth = nil + end + + it "doesn't add an error message" do + assert_nil result + end + end + + describe "when a fragment exceeds max depth" do + before do + Dummy::Schema.max_depth = 4 + end + + let(:query_string) { " + { + cheese(id: 1) { + ...moreFields + } + } + + fragment moreFields on Cheese { + similarCheese(source: SHEEP) { + similarCheese(source: SHEEP) { + similarCheese(source: SHEEP) { + ...evenMoreFields + } + } + } + } + + fragment evenMoreFields on Cheese { + similarCheese(source: SHEEP) { + similarCheese(source: SHEEP) { + id + } + } + } + "} + + it "adds an error message for a too-deep query" do + assert_equal "Query has depth of 7, which exceeds max depth of 4", result.message + end + end +end diff --git a/spec/graphql/analysis/ast/query_complexity_spec.rb b/spec/graphql/analysis/ast/query_complexity_spec.rb new file mode 100644 index 0000000000..b8b57f7185 --- /dev/null +++ b/spec/graphql/analysis/ast/query_complexity_spec.rb @@ -0,0 +1,299 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Analysis::AST::QueryComplexity do + let(:reduce_result) { GraphQL::Analysis::AST.analyze_query(query, [GraphQL::Analysis::AST::QueryComplexity]) } + let(:variables) { {} } + let(:query) { GraphQL::Query.new(Dummy::Schema, query_string, variables: variables) } + + describe "simple queries" do + let(:query_string) {%| + query cheeses($isSkipped: Boolean = false){ + # complexity of 3 + cheese1: cheese(id: 1) { + id + flavor + } + + # complexity of 4 + cheese2: cheese(id: 2) @skip(if: $isSkipped) { + similarCheese(source: SHEEP) { + ... on Cheese { + similarCheese(source: SHEEP) { + id + } + } + } + } + } + |} + + it "sums the complexity" do + complexities = reduce_result.first + assert_equal 7, complexities + end + + describe "when skipped by directives" do + let(:variables) { { "isSkipped" => true } } + it "doesn't include skipped fields" do + complexity = reduce_result.first + assert_equal 3, complexity + end + end + end + + describe "query with fragments" do + let(:query_string) {%| + { + # complexity of 3 + cheese1: cheese(id: 1) { + id + flavor + } + + # complexity of 7 + cheese2: cheese(id: 2) { + ... cheeseFields1 + ... cheeseFields2 + } + } + + fragment cheeseFields1 on Cheese { + similarCow: similarCheese(source: COW) { + id + ... cheeseFields2 + } + } + + fragment cheeseFields2 on Cheese { + similarSheep: similarCheese(source: SHEEP) { + id + } + } + |} + + it "counts all fragment usages, not the definitions" do + complexity = reduce_result.first + assert_equal 10, complexity + end + + describe "mutually exclusive types" do + let(:query_string) {%| + { + favoriteEdible { + # 1 for everybody + fatContent + + # 1 for everybody + ... on Edible { + origin + } + + # 1 for honey + ... on Sweetener { + sweetness + } + + # 2 for milk + ... milkFields + # 1 for cheese + ... cheeseFields + # 1 for honey + ... honeyFields + # 1 for milk + cheese + ... dairyProductFields + } + } + + fragment milkFields on Milk { + id + source + } + + fragment cheeseFields on Cheese { + source + } + + fragment honeyFields on Honey { + flowerType + } + + fragment dairyProductFields on DairyProduct { + ... on Cheese { + flavor + } + + ... on Milk { + flavors + } + } + |} + + it "gets the max among options" do + complexity = reduce_result.first + assert_equal 6, complexity + end + end + + + describe "when there are no selections on any object types" do + let(:query_string) {%| + { + # 1 for everybody + favoriteEdible { + # 1 for everybody + fatContent + + # 1 for everybody + ... on Edible { origin } + + # 1 for honey + ... on Sweetener { sweetness } + } + } + |} + + it "gets the max among interface types" do + complexity = reduce_result.first + assert_equal 4, complexity + end + end + + describe "redundant fields" do + let(:query_string) {%| + { + favoriteEdible { + fatContent + # this is executed separately and counts separately: + aliasedFatContent: fatContent + + ... on Edible { + fatContent + } + + ... edibleFields + } + } + + fragment edibleFields on Edible { + fatContent + } + |} + + it "only counts them once" do + complexity = reduce_result.first + assert_equal 3, complexity + end + end + end + + describe "relay types" do + let(:query) { GraphQL::Query.new(StarWars::Schema, query_string) } + let(:query_string) {%| + { + rebels { + ships { + edges { + node { + id + } + } + pageInfo { + hasNextPage + } + } + } + } + |} + + it "gets the complexity" do + complexity = reduce_result.first + assert_equal 7, complexity + end + end + + describe "custom complexities" do + let(:query) { GraphQL::Query.new(complexity_schema, query_string) } + let(:complexity_schema) { + complexity_interface = GraphQL::InterfaceType.define do + name "ComplexityInterface" + field :value, types.Int + end + + single_complexity_type = GraphQL::ObjectType.define do + name "SingleComplexity" + field :value, types.Int, complexity: 0.1 do + resolve ->(obj, args, ctx) { obj } + end + field :complexity, single_complexity_type do + argument :value, types.Int + complexity ->(ctx, args, child_complexity) { args[:value] + child_complexity } + resolve ->(obj, args, ctx) { args[:value] } + end + interfaces [complexity_interface] + end + + double_complexity_type = GraphQL::ObjectType.define do + name "DoubleComplexity" + field :value, types.Int, complexity: 4 do + resolve ->(obj, args, ctx) { obj } + end + interfaces [complexity_interface] + end + + query_type = GraphQL::ObjectType.define do + name "Query" + field :complexity, single_complexity_type do + argument :value, types.Int + complexity ->(ctx, args, child_complexity) { args[:value] + child_complexity } + resolve ->(obj, args, ctx) { args[:value] } + end + + field :innerComplexity, complexity_interface do + argument :value, types.Int + resolve ->(obj, args, ctx) { args[:value] } + end + end + + GraphQL::Schema.define( + query: query_type, + orphan_types: [double_complexity_type], + resolve_type: ->(a,b,c) { :pass } + ) + } + let(:query_string) {%| + { + a: complexity(value: 3) { value } + b: complexity(value: 6) { + value + complexity(value: 1) { + value + } + } + } + |} + + it "sums the complexity" do + complexity = reduce_result.first + # 10 from `complexity`, `0.3` from `value` + assert_equal 10.3, complexity + end + + describe "same field on multiple types" do + let(:query_string) {%| + { + innerComplexity(value: 2) { + ... on SingleComplexity { value } + ... on DoubleComplexity { value } + } + } + |} + + it "picks them max for those fields" do + complexity = reduce_result.first + # 1 for innerComplexity + 4 for DoubleComplexity.value + assert_equal 5, complexity + end + end + end +end diff --git a/spec/graphql/analysis/ast/query_depth_spec.rb b/spec/graphql/analysis/ast/query_depth_spec.rb new file mode 100644 index 0000000000..9018b9a728 --- /dev/null +++ b/spec/graphql/analysis/ast/query_depth_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Analysis::AST::QueryDepth do + let(:result) { GraphQL::Analysis::AST.analyze_query(query, [GraphQL::Analysis::AST::QueryDepth]) } + let(:query) { GraphQL::Query.new(Dummy::Schema, query_string, variables: variables) } + let(:variables) { {} } + + describe "multiple operations" do + let(:query_string) {%| + query Cheese1 { + cheese1: cheese(id: 1) { + id + flavor + } + } + + query Cheese2 { + cheese(id: 2) { + similarCheese(source: SHEEP) { + ... on Cheese { + similarCheese(source: SHEEP) { + id + } + } + } + } + } + |} + + let(:query) { GraphQL::Query.new(Dummy::Schema, query_string, variables: variables, operation_name: "Cheese1") } + + it "analyzes the selected operation only" do + depth = result.first + assert_equal 2, depth + end + end + + describe "simple queries" do + let(:query_string) {%| + query cheeses($isIncluded: Boolean = true){ + # depth of 2 + cheese1: cheese(id: 1) { + id + flavor + } + + # depth of 4 + cheese2: cheese(id: 2) @include(if: $isIncluded) { + similarCheese(source: SHEEP) { + ... on Cheese { + similarCheese(source: SHEEP) { + id + } + } + } + } + } + |} + + it "finds the max depth" do + depth = result.first + assert_equal 4, depth + end + + describe "with directives" do + let(:variables) { { "isIncluded" => false } } + + it "doesn't count skipped fields" do + assert_equal 2, result.first + end + end + end + + describe "query with fragments" do + let(:query_string) {%| + { + # depth of 2 + cheese1: cheese(id: 1) { + id + flavor + } + + # depth of 4 + cheese2: cheese(id: 2) { + ... cheeseFields1 + } + } + + fragment cheeseFields1 on Cheese { + similarCheese(source: COW) { + id + ... cheeseFields2 + } + } + + fragment cheeseFields2 on Cheese { + similarCheese(source: SHEEP) { + id + } + } + |} + + it "finds the max depth" do + assert_equal 4, result.first + end + end +end diff --git a/spec/graphql/analysis/ast_spec.rb b/spec/graphql/analysis/ast_spec.rb new file mode 100644 index 0000000000..45b23a787f --- /dev/null +++ b/spec/graphql/analysis/ast_spec.rb @@ -0,0 +1,257 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Analysis::AST do + class AstTypeCollector < GraphQL::Analysis::AST::Analyzer + def initialize(query) + super + @types = [] + end + + def on_enter_operation_definition(node, parent, visitor) + @types << visitor.type_definition + end + + def on_enter_field(memo, node, visitor) + @types << visitor.field_definition.type.unwrap + end + + def result + @types + end + end + + class AstNodeCounter < GraphQL::Analysis::AST::Analyzer + def initialize(query) + super + @nodes = Hash.new { |h,k| h[k] = 0 } + end + + def on_enter_abstract_node(node, parent, _visitor) + @nodes[node.class] += 1 + end + + def result + @nodes + end + end + + class AstConditionalAnalyzer < GraphQL::Analysis::AST::Analyzer + def initialize(query) + super + @i_have_been_called = false + end + + def analyze? + !!query.context[:analyze] + end + + def on_operation_definition(node, parent, visitor) + @i_have_been_called = true + end + + def result + @i_have_been_called + end + end + + class AstErrorAnalyzer < GraphQL::Analysis::AST::Analyzer + def result + GraphQL::AnalysisError.new("An Error!") + end + end + + class AstPreviousField < GraphQL::Analysis::AST::Analyzer + def on_enter_field(node, parent, visitor) + @previous_field = visitor.previous_field_definition + end + + def result + @previous_field + end + end + + describe "using the AST analysis engine" do + let(:schema) do + query_type = Class.new(GraphQL::Schema::Object) do + graphql_name 'Query' + + field :foobar, Integer, null: false + + def foobar + 1337 + end + end + + Class.new(GraphQL::Schema) do + query query_type + use GraphQL::Analysis::AST + query_analyzer AstErrorAnalyzer + use GraphQL::Execution::Interpreter + end + end + + let(:query_string) {%| + query { + foobar + } + |} + + let(:query) { GraphQL::Query.new(schema, query_string, variables: {}) } + + it "runs the AST analyzers correctly" do + res = query.result + refute res.key?("data") + assert_equal ["An Error!"], res["errors"].map { |e| e["message"] } + end + + it "skips rewrite" do + # Try running the query: + query.result + # But the validation step doesn't build an irep_node tree + assert_nil query.irep_selection + end + + describe "when validate: false" do + let(:query) { GraphQL::Query.new(schema, query_string, validate: false) } + it "Skips rewrite" do + # Try running the query: + query.result + # But the validation step doesn't build an irep_node tree + assert_nil query.irep_selection + end + end + end + + describe ".analyze_query" do + let(:analyzers) { [AstTypeCollector, AstNodeCounter] } + let(:reduce_result) { GraphQL::Analysis::AST.analyze_query(query, analyzers) } + let(:variables) { {} } + let(:query) { GraphQL::Query.new(Dummy::Schema, query_string, variables: variables) } + let(:query_string) {%| + { + cheese(id: 1) { + id + flavor + } + } + |} + + describe "conditional analysis" do + let(:analyzers) { [AstTypeCollector, AstConditionalAnalyzer] } + + describe "when analyze? returns false" do + let(:query) { GraphQL::Query.new(Dummy::Schema, query_string, variables: variables, context: { analyze: false }) } + + it "does not run the analyzer" do + # Only type_collector ran + assert_equal 1, reduce_result.size + end + end + + describe "when analyze? returns true" do + let(:query) { GraphQL::Query.new(Dummy::Schema, query_string, variables: variables, context: { analyze: true }) } + + it "it runs the analyzer" do + # Both analyzers ran + assert_equal 2, reduce_result.size + end + end + + describe "Visitor#previous_field_definition" do + let(:analyzers) { [AstPreviousField] } + let(:query) { GraphQL::Query.new(Dummy::Schema, "{ __schema { types { name } } }") } + + it "it runs the analyzer" do + prev_field = reduce_result.first + assert_equal "__Schema.types", prev_field.metadata[:type_class].path + end + end + end + + it "calls the defined analyzers" do + collected_types, node_counts = reduce_result + expected_visited_types = [ + Dummy::DairyAppQuery.graphql_definition, + Dummy::Cheese.graphql_definition, + GraphQL::INT_TYPE, + GraphQL::STRING_TYPE + ] + assert_equal expected_visited_types, collected_types + + expected_node_counts = { + GraphQL::Language::Nodes::OperationDefinition => 1, + GraphQL::Language::Nodes::Field => 3, + GraphQL::Language::Nodes::Argument => 1 + } + + assert_equal expected_node_counts, node_counts + end + + describe "tracing" do + let(:query_string) { "{ t: __typename }"} + + it "emits traces" do + traces = TestTracing.with_trace do + ctx = { tracers: [TestTracing] } + Dummy::Schema.execute(query_string, context: ctx) + end + + # The query_trace is on the list _first_ because it finished first + _lex, _parse, _validate, query_trace, multiplex_trace, *_rest = traces + + assert_equal "analyze_multiplex", multiplex_trace[:key] + assert_instance_of GraphQL::Execution::Multiplex, multiplex_trace[:multiplex] + + assert_equal "analyze_query", query_trace[:key] + assert_instance_of GraphQL::Query, query_trace[:query] + end + end + + class AstConnectionCounter < GraphQL::Analysis::AST::Analyzer + def initialize(query) + super + @fields = 0 + @connections = 0 + end + + def on_enter_field(node, parent, visitor) + if visitor.field_definition.connection? + @connections += 1 + else + @fields += 1 + end + end + + def result + { + fields: @fields, + connections: @connections + } + end + end + + describe "when processing fields" do + let(:analyzers) { [AstConnectionCounter] } + let(:reduce_result) { GraphQL::Analysis::AST.analyze_query(query, analyzers) } + let(:query) { GraphQL::Query.new(StarWars::Schema, query_string, variables: variables) } + let(:query_string) {%| + query getBases { + empire { + basesByName(first: 30) { edges { cursor } } + bases(first: 30) { edges { cursor } } + } + } + |} + + it "knows which fields are connections" do + connection_counts = reduce_result.first + expected_connection_counts = { + :fields => 5, + :connections => 2 + } + assert_equal expected_connection_counts, connection_counts + end + end + end +end diff --git a/spec/graphql/authorization_spec.rb b/spec/graphql/authorization_spec.rb index dfc983eed8..7f131692bf 100644 --- a/spec/graphql/authorization_spec.rb +++ b/spec/graphql/authorization_spec.rb @@ -159,7 +159,7 @@ def self.authorized?(value, context) super && value != "a" end - field :value, String, null: false, method: :object + field :value, String, null: false, method: :itself end module UnauthorizedInterface @@ -186,7 +186,7 @@ def self.authorized?(value, context) Box.new(value: Box.new(value: Box.new(value: Box.new(value: is_authed)))) end - field :value, String, null: false, method: :object + field :value, String, null: false, method: :itself end class IntegerObject < BaseObject @@ -197,7 +197,7 @@ def self.authorized?(obj, ctx) is_allowed = !(ctx[:unauthorized_relay] || obj == ctx[:exclude_integer]) Box.new(value: Box.new(value: is_allowed)) end - field :value, Integer, null: false, method: :object + field :value, Integer, null: false, method: :itself end class IntegerObjectEdge < GraphQL::Types::Relay::BaseEdge @@ -237,7 +237,7 @@ class LandscapeFeature < BaseEnum class Query < BaseObject field :hidden, Integer, null: false - field :unauthorized, Integer, null: true, method: :object + field :unauthorized, Integer, null: true, method: :itself field :int2, Integer, null: true do argument :int, Integer, required: false argument :hidden, Integer, required: false @@ -268,22 +268,22 @@ def landscape_features(strings: [], enums: []) end def empty_array; []; end - field :hidden_object, HiddenObject, null: false, method: :itself - field :hidden_interface, HiddenInterface, null: false, method: :itself - field :hidden_default_interface, HiddenDefaultInterface, null: false, method: :itself - field :hidden_connection, RelayObject.connection_type, null: :false, method: :empty_array - field :hidden_edge, RelayObject.edge_type, null: :false, method: :edge_object + field :hidden_object, HiddenObject, null: false, resolver_method: :itself + field :hidden_interface, HiddenInterface, null: false, resolver_method: :itself + field :hidden_default_interface, HiddenDefaultInterface, null: false, resolver_method: :itself + field :hidden_connection, RelayObject.connection_type, null: :false, resolver_method: :empty_array + field :hidden_edge, RelayObject.edge_type, null: :false, resolver_method: :edge_object field :inaccessible, Integer, null: false, method: :object_id - field :inaccessible_object, InaccessibleObject, null: false, method: :itself - field :inaccessible_interface, InaccessibleInterface, null: false, method: :itself - field :inaccessible_default_interface, InaccessibleDefaultInterface, null: false, method: :itself - field :inaccessible_connection, RelayObject.connection_type, null: :false, method: :empty_array - field :inaccessible_edge, RelayObject.edge_type, null: :false, method: :edge_object + field :inaccessible_object, InaccessibleObject, null: false, resolver_method: :itself + field :inaccessible_interface, InaccessibleInterface, null: false, resolver_method: :itself + field :inaccessible_default_interface, InaccessibleDefaultInterface, null: false, resolver_method: :itself + field :inaccessible_connection, RelayObject.connection_type, null: :false, resolver_method: :empty_array + field :inaccessible_edge, RelayObject.edge_type, null: :false, resolver_method: :edge_object - field :unauthorized_object, UnauthorizedObject, null: true, method: :itself - field :unauthorized_connection, RelayObject.connection_type, null: false, method: :array_with_item - field :unauthorized_edge, RelayObject.edge_type, null: false, method: :edge_object + field :unauthorized_object, UnauthorizedObject, null: true, resolver_method: :itself + field :unauthorized_connection, RelayObject.connection_type, null: false, resolver_method: :array_with_item + field :unauthorized_edge, RelayObject.edge_type, null: false, resolver_method: :edge_object def edge_object OpenStruct.new(node: 100) @@ -305,11 +305,11 @@ def unauthorized_list_items [self, self] end - field :unauthorized_lazy_check_box, UnauthorizedCheckBox, null: true, method: :unauthorized_lazy_box do + field :unauthorized_lazy_check_box, UnauthorizedCheckBox, null: true, resolver_method: :unauthorized_lazy_box do argument :value, String, required: true end - field :unauthorized_interface, UnauthorizedInterface, null: true, method: :unauthorized_lazy_box do + field :unauthorized_interface, UnauthorizedInterface, null: true, resolver_method: :unauthorized_lazy_box do argument :value, String, required: true end @@ -369,9 +369,15 @@ class Mutation < BaseObject end class Schema < GraphQL::Schema + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end query(Query) mutation(Mutation) + # Opt in to accessible? checks + query_analyzer GraphQL::Authorization::Analyzer + lazy_resolve(Box, :value) def self.unauthorized_object(err) @@ -598,7 +604,7 @@ def auth_execute(*args) end describe "applying the authorized? method" do - it "halts on unauthorized objects" do + it "halts on unauthorized objects, replacing the object with nil" do query = "{ unauthorizedObject { __typename } }" hidden_response = auth_execute(query, context: { hide: true }) assert_nil hidden_response["data"].fetch("unauthorizedObject") @@ -658,7 +664,25 @@ def auth_execute(*args) unauthorized_res = auth_execute(query, context: { unauthorized_relay: true }) conn = unauthorized_res["data"].fetch("unauthorizedConnection") assert_equal "RelayObjectConnection", conn.fetch("__typename") - assert_equal nil, conn.fetch("nodes") + # This is tricky: the previous behavior was to replace the _whole_ + # list with `nil`. This was due to an implementation detail: + # The list field's return value (an array of integers) was wrapped + # _before_ returning, and during this wrapping, a cascading error + # caused the entire field to be nilled out. + # + # In the interpreter, each list item is contained and the error doesn't propagate + # up to the whole list. + # + # Originally, I thought that this was a _feature_ that obscured list entries. + # But really, look at the test below: you don't get this "feature" if + # you use `edges { node }`, so it can't be relied on in any way. + # + # All that to say, in the interpreter, `nodes` and `edges { node }` behave + # the same. + # + # TODO revisit the docs for this. + failed_nodes_value = TESTING_INTERPRETER ? [nil] : nil + assert_equal failed_nodes_value, conn.fetch("nodes") assert_equal [{"node" => nil, "__typename" => "RelayObjectEdge"}], conn.fetch("edges") edge = unauthorized_res["data"].fetch("unauthorizedEdge") @@ -667,7 +691,7 @@ def auth_execute(*args) unauthorized_object_paths = [ ["unauthorizedConnection", "edges", 0, "node"], - ["unauthorizedConnection", "nodes"], + TESTING_INTERPRETER ? ["unauthorizedConnection", "nodes", 0] : ["unauthorizedConnection", "nodes"], ["unauthorizedEdge", "node"] ] diff --git a/spec/graphql/execution/execute_spec.rb b/spec/graphql/execution/execute_spec.rb index 558a3dabdb..ae504697d2 100644 --- a/spec/graphql/execution/execute_spec.rb +++ b/spec/graphql/execution/execute_spec.rb @@ -280,20 +280,20 @@ def ints field_3_lazy, field_4_lazy, query_lazy, multiplex = exec_traces - assert_equal ["b1"], field_1_eager[:context].path - assert_equal ["b2"], field_2_eager[:context].path + assert_equal ["b1"], field_1_eager[:path] + assert_equal ["b2"], field_2_eager[:path] assert_instance_of GraphQL::Query, query_eager[:query] assert_equal [first_id.to_s, last_id.to_s], lazy_loader[:ids] assert_equal StarWars::Base, lazy_loader[:model] - assert_equal ["b1", "name"], field_3_eager[:context].path - assert_equal ["b1"], field_1_lazy[:context].path - assert_equal ["b2", "name"], field_4_eager[:context].path - assert_equal ["b2"], field_2_lazy[:context].path + assert_equal ["b1", "name"], field_3_eager[:path] + assert_equal ["b1"], field_1_lazy[:path] + assert_equal ["b2", "name"], field_4_eager[:path] + assert_equal ["b2"], field_2_lazy[:path] - assert_equal ["b1", "name"], field_3_lazy[:context].path - assert_equal ["b2", "name"], field_4_lazy[:context].path + assert_equal ["b1", "name"], field_3_lazy[:path] + assert_equal ["b2", "name"], field_4_lazy[:path] assert_instance_of GraphQL::Query, query_lazy[:query] assert_instance_of GraphQL::Execution::Multiplex, multiplex[:multiplex] diff --git a/spec/graphql/execution/interpreter_spec.rb b/spec/graphql/execution/interpreter_spec.rb new file mode 100644 index 0000000000..e40e5ed0af --- /dev/null +++ b/spec/graphql/execution/interpreter_spec.rb @@ -0,0 +1,402 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Execution::Interpreter do + module InterpreterTest + class Box + def initialize(value: nil, &block) + @value = value + @block = block + end + + def value + if @block + @value = @block.call + @block = nil + end + @value + end + end + + class Expansion < GraphQL::Schema::Object + field :sym, String, null: false + field :lazy_sym, String, null: false + field :name, String, null: false + field :cards, ["InterpreterTest::Card"], null: false + + def self.authorized?(expansion, ctx) + if expansion.sym == "NOPE" + false + else + true + end + end + + def cards + Query::CARDS.select { |c| c.expansion_sym == @object.sym } + end + + def lazy_sym + Box.new(value: object.sym) + end + + field :null_union_field_test, Integer, null: false + def null_union_field_test + 1 + end + end + + class Card < GraphQL::Schema::Object + field :name, String, null: false + field :colors, "[InterpreterTest::Color]", null: false + field :expansion, Expansion, null: false + + def expansion + Query::EXPANSIONS.find { |e| e.sym == @object.expansion_sym } + end + + field :null_union_field_test, Integer, null: true + def null_union_field_test + nil + end + end + + class Color < GraphQL::Schema::Enum + value "WHITE" + value "BLUE" + value "BLACK" + value "RED" + value "GREEN" + end + + class Entity < GraphQL::Schema::Union + possible_types Card, Expansion + + def self.resolve_type(obj, ctx) + obj.sym ? Expansion : Card + end + end + + class FieldCounter < GraphQL::Schema::Object + field :field_counter, FieldCounter, null: false + def field_counter; :field_counter; end + + field :calls, Integer, null: false do + argument :expected, Integer, required: true + end + + def calls(expected:) + c = context[:calls] += 1 + if c != expected + raise "Expected #{expected} calls but had #{c} so far" + else + c + end + end + + field :runtime_info, String, null: false + def runtime_info + "#{context.namespace(:interpreter)[:current_path]} -> #{context.namespace(:interpreter)[:current_field].path}" + end + + field :lazy_runtime_info, String, null: false + def lazy_runtime_info + Box.new { + "#{context.namespace(:interpreter)[:current_path]} -> #{context.namespace(:interpreter)[:current_field].path}" + } + end + end + + class Query < GraphQL::Schema::Object + # Try a root-level authorized hook that returns a lazy value + def self.authorized?(obj, ctx) + Box.new(value: true) + end + + field :card, Card, null: true do + argument :name, String, required: true + end + + def card(name:) + Box.new(value: CARDS.find { |c| c.name == name }) + end + + field :expansion, Expansion, null: true do + argument :sym, String, required: true + end + + def expansion(sym:) + EXPANSIONS.find { |e| e.sym == sym } + end + + field :expansions, [Expansion], null: false + def expansions + EXPANSIONS + end + + CARDS = [ + OpenStruct.new(name: "Dark Confidant", colors: ["BLACK"], expansion_sym: "RAV"), + ] + + EXPANSIONS = [ + OpenStruct.new(name: "Ravnica, City of Guilds", sym: "RAV"), + # This data has an error, for testing null propagation + OpenStruct.new(name: nil, sym: "XYZ"), + # This is not allowed by .authorized?, + OpenStruct.new(name: nil, sym: "NOPE"), + ] + + field :find, [Entity], null: false do + argument :id, [ID], required: true + end + + def find(id:) + id.map do |ent_id| + Query::EXPANSIONS.find { |e| e.sym == ent_id } || + Query::CARDS.find { |c| c.name == ent_id } + end + end + + field :findMany, [Entity, null: true], null: false do + argument :ids, [ID], required: true + end + + def find_many(ids:) + find(id: ids).map { |e| Box.new(value: e) } + end + + field :field_counter, FieldCounter, null: false + def field_counter; :field_counter; end + end + + class Schema < GraphQL::Schema + use GraphQL::Execution::Interpreter + use GraphQL::Analysis::AST + query(Query) + lazy_resolve(Box, :value) + end + end + + it "runs a query" do + query_string = <<-GRAPHQL + query($expansion: String!, $id1: ID!, $id2: ID!){ + card(name: "Dark Confidant") { + colors + expansion { + ... { + name + } + cards { + name + } + } + } + expansion(sym: $expansion) { + ... ExpansionFields + } + find(id: [$id1, $id2]) { + __typename + ... on Card { + name + } + ... on Expansion { + sym + } + } + } + + fragment ExpansionFields on Expansion { + cards { + name + } + } + GRAPHQL + + vars = {expansion: "RAV", id1: "Dark Confidant", id2: "RAV"} + result = InterpreterTest::Schema.execute(query_string, variables: vars) + assert_equal ["BLACK"], result["data"]["card"]["colors"] + assert_equal "Ravnica, City of Guilds", result["data"]["card"]["expansion"]["name"] + assert_equal [{"name" => "Dark Confidant"}], result["data"]["card"]["expansion"]["cards"] + assert_equal [{"name" => "Dark Confidant"}], result["data"]["expansion"]["cards"] + expected_abstract_list = [ + {"__typename" => "Card", "name" => "Dark Confidant"}, + {"__typename" => "Expansion", "sym" => "RAV"}, + ] + assert_equal expected_abstract_list, result["data"]["find"] + end + + it "runs skip and include" do + query_str = <<-GRAPHQL + query($truthy: Boolean!, $falsey: Boolean!){ + exp1: expansion(sym: "RAV") @skip(if: true) { name } + exp2: expansion(sym: "RAV") @skip(if: false) { name } + exp3: expansion(sym: "RAV") @include(if: true) { name } + exp4: expansion(sym: "RAV") @include(if: false) { name } + exp5: expansion(sym: "RAV") @include(if: $truthy) { name } + exp6: expansion(sym: "RAV") @include(if: $falsey) { name } + } + GRAPHQL + + vars = {truthy: true, falsey: false} + result = InterpreterTest::Schema.execute(query_str, variables: vars) + expected_data = { + "exp2" => {"name" => "Ravnica, City of Guilds"}, + "exp3" => {"name" => "Ravnica, City of Guilds"}, + "exp5" => {"name" => "Ravnica, City of Guilds"}, + } + assert_equal expected_data, result["data"] + end + + describe "temporary interpreter flag" do + it "is set" do + # This can be removed later, just a sanity check during migration + res = InterpreterTest::Schema.execute("{ __typename }") + assert_equal true, res.context.interpreter? + end + end + + describe "runtime info in context" do + it "is available" do + res = InterpreterTest::Schema.execute <<-GRAPHQL + { + fieldCounter { + runtimeInfo + fieldCounter { + runtimeInfo + lazyRuntimeInfo + } + } + } + GRAPHQL + + assert_equal '["fieldCounter", "runtimeInfo"] -> FieldCounter.runtimeInfo', res["data"]["fieldCounter"]["runtimeInfo"] + assert_equal '["fieldCounter", "fieldCounter", "runtimeInfo"] -> FieldCounter.runtimeInfo', res["data"]["fieldCounter"]["fieldCounter"]["runtimeInfo"] + assert_equal '["fieldCounter", "fieldCounter", "lazyRuntimeInfo"] -> FieldCounter.lazyRuntimeInfo', res["data"]["fieldCounter"]["fieldCounter"]["lazyRuntimeInfo"] + end + end + + describe "CI setup" do + it "sets interpreter based on a constant" do + if TESTING_INTERPRETER + assert_equal GraphQL::Execution::Interpreter, Jazz::Schema.query_execution_strategy + assert_equal GraphQL::Execution::Interpreter, Dummy::Schema.query_execution_strategy + else + refute_equal GraphQL::Execution::Interpreter, Jazz::Schema.query_execution_strategy + refute_equal GraphQL::Execution::Interpreter, Dummy::Schema.query_execution_strategy + end + end + end + describe "null propagation" do + it "propagates nulls" do + query_str = <<-GRAPHQL + { + expansion(sym: "XYZ") { + name + sym + lazySym + } + } + GRAPHQL + + res = InterpreterTest::Schema.execute(query_str) + # Although the expansion was found, its name of `nil` + # propagated to here + assert_nil res["data"].fetch("expansion") + assert_equal ["Cannot return null for non-nullable field Expansion.name"], res["errors"].map { |e| e["message"] } + end + + it "propagates nulls in lists" do + query_str = <<-GRAPHQL + { + expansions { + name + sym + lazySym + } + } + GRAPHQL + + res = InterpreterTest::Schema.execute(query_str) + # A null in one of the list items removed the whole list + assert_nil(res["data"]) + end + + it "works with unions that fail .authorized?" do + res = InterpreterTest::Schema.execute <<-GRAPHQL + { + find(id: "NOPE") { + ... on Expansion { + sym + } + } + } + GRAPHQL + assert_equal ["Cannot return null for non-nullable field Query.find"], res["errors"].map { |e| e["message"] } + end + + it "works with lists of unions" do + res = InterpreterTest::Schema.execute <<-GRAPHQL + { + findMany(ids: ["RAV", "NOPE", "BOGUS"]) { + ... on Expansion { + sym + } + } + } + GRAPHQL + + assert_equal 3, res["data"]["findMany"].size + assert_equal "RAV", res["data"]["findMany"][0]["sym"] + assert_equal nil, res["data"]["findMany"][1] + assert_equal nil, res["data"]["findMany"][2] + assert_equal false, res.key?("errors") + end + + it "works with union lists that have members of different kinds, with different nullabilities" do + res = InterpreterTest::Schema.execute <<-GRAPHQL + { + findMany(ids: ["RAV", "Dark Confidant"]) { + ... on Expansion { + nullUnionFieldTest + } + ... on Card { + nullUnionFieldTest + } + } + } + GRAPHQL + + assert_equal [1, nil], res["data"]["findMany"].map { |f| f["nullUnionFieldTest"] } + end + end + + describe "duplicated fields" do + it "doesn't run them multiple times" do + query_str = <<-GRAPHQL + { + fieldCounter { + calls(expected: 1) + # This should not be called since it matches the above + calls(expected: 1) + fieldCounter { + calls(expected: 2) + } + ...ExtraFields + } + } + fragment ExtraFields on FieldCounter { + fieldCounter { + # This should not be called since it matches the inline field: + calls(expected: 2) + # This _should_ be called + c3: calls(expected: 3) + } + } + GRAPHQL + + # It will raise an error if it doesn't match the expectation + res = InterpreterTest::Schema.execute(query_str, context: { calls: 0 }) + assert_equal 3, res["data"]["fieldCounter"]["fieldCounter"]["c3"] + end + end +end diff --git a/spec/graphql/execution/lazy_spec.rb b/spec/graphql/execution/lazy_spec.rb index 687795b4cc..1aef6eae29 100644 --- a/spec/graphql/execution/lazy_spec.rb +++ b/spec/graphql/execution/lazy_spec.rb @@ -70,6 +70,55 @@ assert_equal expected_data, res["data"] end + # This only works with the interpreter + if TESTING_INTERPRETER + [ + [1, 2, LazyHelpers::MAGIC_NUMBER_WITH_LAZY_AUTHORIZED_HOOK], + [2, LazyHelpers::MAGIC_NUMBER_WITH_LAZY_AUTHORIZED_HOOK, 1], + [LazyHelpers::MAGIC_NUMBER_WITH_LAZY_AUTHORIZED_HOOK, 1, 2], + ].each do |ordered_values| + it "resolves each field at one depth before proceeding to the next depth (using #{ordered_values})" do + res = run_query <<-GRAPHQL, variables: { values: ordered_values } + query($values: [Int!]!) { + listSum(values: $values) { + nestedSum(value: 3) { + value + } + } + } + GRAPHQL + + # Even though magic number `44`'s `.authorized?` hook returns a lazy value, + # these fields should be resolved together and return the same value. + assert_equal 56, res["data"]["listSum"][0]["nestedSum"]["value"] + assert_equal 56, res["data"]["listSum"][1]["nestedSum"]["value"] + assert_equal 56, res["data"]["listSum"][2]["nestedSum"]["value"] + end + end + + it "Handles fields that return nil" do + values = [ + LazyHelpers::MAGIC_NUMBER_THAT_RETURNS_NIL, + LazyHelpers::MAGIC_NUMBER_WITH_LAZY_AUTHORIZED_HOOK, + 1, + 2, + ] + + res = run_query <<-GRAPHQL, variables: { values: values } + query($values: [Int!]!) { + listSum(values: $values) { + nullableNestedSum(value: 3) { + value + } + } + } + GRAPHQL + + values = res["data"]["listSum"].map { |s| s && s["nullableNestedSum"]["value"] } + assert_equal [nil, 56, 56, 56], values + end + end + it "propagates nulls to the root" do res = run_query %| { diff --git a/spec/graphql/execution/lookahead_spec.rb b/spec/graphql/execution/lookahead_spec.rb index 98d9fad01b..d718e24079 100644 --- a/spec/graphql/execution/lookahead_spec.rb +++ b/spec/graphql/execution/lookahead_spec.rb @@ -48,12 +48,22 @@ def find_bird_species(by_name:) end end + class LookaheadInstrumenter + def self.before_query(query) + query.context[:root_lookahead_names] = query.lookahead.selections.map(&:name) + end + + def self.after_query(q) + end + end + class Schema < GraphQL::Schema query(Query) + instrument :query, LookaheadInstrumenter + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end - # Cause everything to be loaded - # TODO remove this - Schema.graphql_definition end describe "looking ahead" do @@ -80,9 +90,7 @@ class Schema < GraphQL::Schema end it "can detect fields on objects with symbol or string" do - ast_node = document.definitions.first.selections.first - field = LookaheadTest::Query.fields["findBirdSpecies"] - lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], field: field) + lookahead = query.lookahead.selection("findBirdSpecies") assert_equal true, lookahead.selects?("similarSpecies") assert_equal true, lookahead.selects?(:similar_species) assert_equal false, lookahead.selects?("isWaterfowl") @@ -90,15 +98,12 @@ class Schema < GraphQL::Schema end it "detects by name, not by alias" do - ast_node = document.definitions.first - lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], root_type: LookaheadTest::Query) - assert_equal true, lookahead.selects?("__typename") + assert_equal true, query.lookahead.selects?("__typename") end describe "constraints by arguments" do let(:lookahead) do - ast_node = document.definitions.first - GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], root_type: LookaheadTest::Query) + query.lookahead end it "is true without constraints" do @@ -138,9 +143,7 @@ class Schema < GraphQL::Schema end it "can do a chained lookahead" do - ast_node = document.definitions.first - lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], root_type: LookaheadTest::Query) - next_lookahead = lookahead.selection(:find_bird_species, arguments: { by_name: "Cardinal" }) + next_lookahead = query.lookahead.selection(:find_bird_species, arguments: { by_name: "Cardinal" }) assert_equal true, next_lookahead.selected? nested_selection = next_lookahead.selection(:similar_species).selection(:is_waterfowl, arguments: {}) assert_equal true, nested_selection.selected? @@ -148,10 +151,8 @@ class Schema < GraphQL::Schema end it "can detect fields on lists with symbol or string" do - ast_node = document.definitions.first - lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], root_type: LookaheadTest::Query) - assert_equal true, lookahead.selection(:find_bird_species).selection(:similar_species).selection(:is_waterfowl).selected? - assert_equal true, lookahead.selection("findBirdSpecies").selection("similarSpecies").selection("isWaterfowl").selected? + assert_equal true, query.lookahead.selection(:find_bird_species).selection(:similar_species).selection(:is_waterfowl).selected? + assert_equal true, query.lookahead.selection("findBirdSpecies").selection("similarSpecies").selection("isWaterfowl").selected? end describe "merging branches and fragments" do @@ -184,9 +185,7 @@ class Schema < GraphQL::Schema } it "finds selections using merging" do - ast_node = document.definitions.first - lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], root_type: LookaheadTest::Query) - merged_lookahead = lookahead.selection(:find_bird_species).selection(:similar_species) + merged_lookahead = query.lookahead.selection(:find_bird_species).selection(:similar_species) assert merged_lookahead.selects?(:__typename) assert merged_lookahead.selects?(:is_waterfowl) assert merged_lookahead.selects?(:name) @@ -213,6 +212,14 @@ class Schema < GraphQL::Schema res = LookaheadTest::Schema.execute(query_str, context: context) refute res.key?("errors") assert_equal 2, context[:lookahead_latin_name] + assert_equal [:find_bird_species], context[:root_lookahead_names] + end + + it "works for invalid queries" do + context = {lookahead_latin_name: 0} + res = LookaheadTest::Schema.execute("{ doesNotExist }", context: context) + assert res.key?("errors") + assert_equal 0, context[:lookahead_latin_name] end end @@ -231,7 +238,7 @@ class Schema < GraphQL::Schema } def query(doc = document) - GraphQL::Query.new(LookaheadTest::Schema, document: document) + GraphQL::Query.new(LookaheadTest::Schema, document: doc) end it "provides a list of all selections" do @@ -257,26 +264,37 @@ def query(doc = document) assert_equal lookahead.selections(arguments: arguments).map(&:name), [:find_bird_species] end - it 'handles duplicate selections' do + it 'handles duplicate selections across fragments' do doc = GraphQL.parse <<-GRAPHQL query { + ... on Query { + ...MoreFields + } + } + + fragment MoreFields on Query { findBirdSpecies(byName: "Laughing Gull") { name } - findBirdSpecies(byName: "Laughing Gull") { - similarSpecies { - likesWater: isWaterfowl - } + ...EvenMoreFields + } + } + + fragment EvenMoreFields on BirdSpecies { + similarSpecies { + likesWater: isWaterfowl } } GRAPHQL - ast_node = doc.definitions.first - lookahead = GraphQL::Execution::Lookahead.new(query: query(doc), ast_nodes: [ast_node], root_type: LookaheadTest::Query) + lookahead = query(doc).lookahead + + root_selections = lookahead.selections + assert_equal [:find_bird_species], root_selections.map(&:name), "Selections are merged" + assert_equal 2, root_selections.first.ast_nodes.size, "It represents both nodes" - assert_equal [:find_bird_species], lookahead.selections.map(&:name), "Selections are merged" - assert_equal [:name, :similar_species], lookahead.selections.first.selections.map(&:name), "Subselections are merged" + assert_equal [:name, :similar_species], root_selections.first.selections.map(&:name), "Subselections are merged" end it "works for missing selections" do diff --git a/spec/graphql/execution/multiplex_spec.rb b/spec/graphql/execution/multiplex_spec.rb index dd42f7e57b..596fd3d507 100644 --- a/spec/graphql/execution/multiplex_spec.rb +++ b/spec/graphql/execution/multiplex_spec.rb @@ -94,7 +94,8 @@ def multiplex(*a) "errors" => [{ "message"=>"Field must have selections (field 'nullableNestedSum' returns LazySum but has no selections. Did you mean 'nullableNestedSum { ... }'?)", "locations"=>[{"line"=>1, "column"=>4}], - "fields"=>["query", "validationError"] + "path"=>["query", "validationError"], + "extensions"=>{"code"=>"selectionMismatch", "nodeName"=>"field 'nullableNestedSum'", "typeName"=>"LazySum"} }] }, ] @@ -105,7 +106,7 @@ def multiplex(*a) {query: q3}, {query: q4}, ]) - assert_equal expected_res, res + assert_equal expected_res, res.map(&:to_h) end end diff --git a/spec/graphql/execution_error_spec.rb b/spec/graphql/execution_error_spec.rb index afd7ff6469..7c7e5135ef 100644 --- a/spec/graphql/execution_error_spec.rb +++ b/spec/graphql/execution_error_spec.rb @@ -3,8 +3,9 @@ describe GraphQL::ExecutionError do let(:result) { Dummy::Schema.execute(query_string) } - describe "when returned from a field" do - let(:query_string) {%| + if TESTING_RESCUE_FROM + describe "when returned from a field" do + let(:query_string) {%| { cheese(id: 1) { id @@ -51,93 +52,94 @@ fragment similarCheeseFields on Cheese { id, flavor } - |} - it "the error is inserted into the errors key and the rest of the query is fulfilled" do - expected_result = { - "data"=>{ - "cheese"=>{ - "id" => 1, - "error1"=> nil, - "error2"=> nil, - "nonError"=> { - "id" => 3, - "flavor" => "Manchego", - }, - "flavor" => "Brie", + |} + it "the error is inserted into the errors key and the rest of the query is fulfilled" do + expected_result = { + "data"=>{ + "cheese"=>{ + "id" => 1, + "error1"=> nil, + "error2"=> nil, + "nonError"=> { + "id" => 3, + "flavor" => "Manchego", + }, + "flavor" => "Brie", + }, + "allDairy" => [ + { "flavor" => "Brie" }, + { "flavor" => "Gouda" }, + { "flavor" => "Manchego" }, + { "source" => "COW", "executionError" => nil } + ], + "dairyErrors" => [ + { "__typename" => "Cheese" }, + nil, + { "__typename" => "Cheese" }, + { "__typename" => "Milk" } + ], + "dairy" => { + "milks" => [ + { + "source" => "COW", + "executionError" => nil, + "allDairy" => [ + { "__typename" => "Cheese" }, + { "__typename" => "Cheese" }, + { "__typename" => "Cheese" }, + { "__typename" => "Milk", "origin" => "Antiquity", "executionError" => nil } + ] + } + ] + }, + "executionError" => nil, + "valueWithExecutionError" => 0 }, - "allDairy" => [ - { "flavor" => "Brie" }, - { "flavor" => "Gouda" }, - { "flavor" => "Manchego" }, - { "source" => "COW", "executionError" => nil } - ], - "dairyErrors" => [ - { "__typename" => "Cheese" }, - nil, - { "__typename" => "Cheese" }, - { "__typename" => "Milk" } - ], - "dairy" => { - "milks" => [ - { - "source" => "COW", - "executionError" => nil, - "allDairy" => [ - { "__typename" => "Cheese" }, - { "__typename" => "Cheese" }, - { "__typename" => "Cheese" }, - { "__typename" => "Milk", "origin" => "Antiquity", "executionError" => nil } - ] - } - ] - }, - "executionError" => nil, - "valueWithExecutionError" => 0 - }, - "errors"=>[ - { - "message"=>"No cheeses are made from Yak milk!", - "locations"=>[{"line"=>5, "column"=>9}], - "path"=>["cheese", "error1"] - }, - { - "message"=>"No cheeses are made from Yak milk!", - "locations"=>[{"line"=>8, "column"=>9}], - "path"=>["cheese", "error2"] - }, - { - "message"=>"There was an execution error", - "locations"=>[{"line"=>22, "column"=>11}], - "path"=>["allDairy", 3, "executionError"] - }, - { - "message"=>"missing dairy", - "locations"=>[{"line"=>25, "column"=>7}], - "path"=>["dairyErrors", 1] - }, - { - "message"=>"There was an execution error", - "locations"=>[{"line"=>31, "column"=>11}], - "path"=>["dairy", "milks", 0, "executionError"] - }, - { - "message"=>"There was an execution error", - "locations"=>[{"line"=>36, "column"=>15}], - "path"=>["dairy", "milks", 0, "allDairy", 3, "executionError"] - }, - { - "message"=>"There was an execution error", - "locations"=>[{"line"=>41, "column"=>7}], - "path"=>["executionError"] - }, - { - "message"=>"Could not fetch latest value", - "locations"=>[{"line"=>42, "column"=>7}], - "path"=>["valueWithExecutionError"] - }, - ] - } - assert_equal(expected_result, result.to_h) + "errors"=>[ + { + "message"=>"No cheeses are made from Yak milk!", + "locations"=>[{"line"=>5, "column"=>9}], + "path"=>["cheese", "error1"] + }, + { + "message"=>"No cheeses are made from Yak milk!", + "locations"=>[{"line"=>8, "column"=>9}], + "path"=>["cheese", "error2"] + }, + { + "message"=>"There was an execution error", + "locations"=>[{"line"=>22, "column"=>11}], + "path"=>["allDairy", 3, "executionError"] + }, + { + "message"=>"missing dairy", + "locations"=>[{"line"=>25, "column"=>7}], + "path"=>["dairyErrors", 1] + }, + { + "message"=>"There was an execution error", + "locations"=>[{"line"=>31, "column"=>11}], + "path"=>["dairy", "milks", 0, "executionError"] + }, + { + "message"=>"There was an execution error", + "locations"=>[{"line"=>36, "column"=>15}], + "path"=>["dairy", "milks", 0, "allDairy", 3, "executionError"] + }, + { + "message"=>"There was an execution error", + "locations"=>[{"line"=>41, "column"=>7}], + "path"=>["executionError"] + }, + { + "message"=>"Could not fetch latest value", + "locations"=>[{"line"=>42, "column"=>7}], + "path"=>["valueWithExecutionError"] + }, + ] + } + assert_equal(expected_result, result.to_h) + end end end @@ -294,15 +296,27 @@ describe "more than one ExecutionError" do let(:query_string) { %|{ multipleErrorsOnNonNullableField} |} it "the errors are inserted into the errors key and the data is nil even for a NonNullable field " do + + # I Think the path here is _wrong_, since this is not an array field: + # expected_result = { + # "data"=>nil, + # "errors"=> + # [{"message"=>"This is an error message for some error.", + # "locations"=>[{"line"=>1, "column"=>3}], + # "path"=>["multipleErrorsOnNonNullableField", 0]}, + # {"message"=>"This is another error message for a different error.", + # "locations"=>[{"line"=>1, "column"=>3}], + # "path"=>["multipleErrorsOnNonNullableField", 1]}] + # } expected_result = { - "data"=>nil, - "errors"=> - [{"message"=>"This is an error message for some error.", - "locations"=>[{"line"=>1, "column"=>3}], - "path"=>["multipleErrorsOnNonNullableField", 0]}, - {"message"=>"This is another error message for a different error.", - "locations"=>[{"line"=>1, "column"=>3}], - "path"=>["multipleErrorsOnNonNullableField", 1]}] + "data"=>nil, + "errors"=> + [{"message"=>"This is an error message for some error.", + "locations"=>[{"line"=>1, "column"=>3}], + "path"=>["multipleErrorsOnNonNullableField"]}, + {"message"=>"This is another error message for a different error.", + "locations"=>[{"line"=>1, "column"=>3}], + "path"=>["multipleErrorsOnNonNullableField"]}], } assert_equal(expected_result, result) end diff --git a/spec/graphql/introspection/type_type_spec.rb b/spec/graphql/introspection/type_type_spec.rb index 98fdf94df1..08cc043869 100644 --- a/spec/graphql/introspection/type_type_spec.rb +++ b/spec/graphql/introspection/type_type_spec.rb @@ -144,10 +144,9 @@ } } GRAPHQL - type_result = res["data"]["__schema"]["types"].find { |t| t["name"] == "Faction" } field_result = type_result["fields"].find { |f| f["name"] == "bases" } - all_arg_names = ["first", "after", "last", "before", "nameIncludes", "complexOrder"] + all_arg_names = ["after", "before", "first", "last", "nameIncludes", "complexOrder"] returned_arg_names = field_result["args"].map { |a| a["name"] } assert_equal all_arg_names, returned_arg_names end diff --git a/spec/graphql/language/nodes_spec.rb b/spec/graphql/language/nodes_spec.rb index 7c46a2024f..127800ec2e 100644 --- a/spec/graphql/language/nodes_spec.rb +++ b/spec/graphql/language/nodes_spec.rb @@ -44,4 +44,24 @@ def print_field_definition(print_field_definition) assert_equal expected.chomp, document.to_query_string(printer: custom_printer_class.new) end end + + describe "#dup" do + it "works with adding selections" do + f = GraphQL::Language::Nodes::Field.new(name: "f") + # Calling `.children` may populate an internal cache + assert_equal "f", f.to_query_string, "the original is unchanged" + assert_equal 0, f.children.size + assert_equal 0, f.selections.size + + f2 = f.merge(selections: [GraphQL::Language::Nodes::Field.new(name: "__typename")]) + + assert_equal "f", f.to_query_string, "the original is unchanged" + assert_equal 0, f.children.size + assert_equal 0, f.selections.size + + assert_equal "f {\n __typename\n}", f2.to_query_string, "the duplicate is updated" + assert_equal 1, f2.children.size + assert_equal 1, f2.selections.size + end + end end diff --git a/spec/graphql/language/visitor_spec.rb b/spec/graphql/language/visitor_spec.rb index 10f5fda046..df8e78d485 100644 --- a/spec/graphql/language/visitor_spec.rb +++ b/spec/graphql/language/visitor_spec.rb @@ -16,10 +16,11 @@ fragment cheeseFields on Cheese { flavor } ")} - let(:counts) { {fields_entered: 0, arguments_entered: 0, arguments_left: 0, argument_names: []} } + let(:hooks_counts) { {fields_entered: 0, arguments_entered: 0, arguments_left: 0, argument_names: []} } - let(:visitor) do + let(:hooks_visitor) do v = GraphQL::Language::Visitor.new(document) + counts = hooks_counts v[GraphQL::Language::Nodes::Field] << ->(node, parent) { counts[:fields_entered] += 1 } # two ways to set up enter hooks: v[GraphQL::Language::Nodes::Argument] << ->(node, parent) { counts[:argument_names] << node.name } @@ -30,14 +31,43 @@ v end - it "calls hooks during a depth-first tree traversal" do - assert_equal(2, visitor[GraphQL::Language::Nodes::Argument].enter.length) - visitor.visit - assert_equal(6, counts[:fields_entered]) - assert_equal(2, counts[:arguments_entered]) - assert_equal(2, counts[:arguments_left]) - assert_equal(["id", "first"], counts[:argument_names]) - assert(counts[:finished]) + class VisitorSpecVisitor < GraphQL::Language::Visitor + attr_reader :counts + def initialize(document) + @counts = {fields_entered: 0, arguments_entered: 0, arguments_left: 0, argument_names: []} + super + end + + def on_field(node, parent) + counts[:fields_entered] += 1 + super(node, parent) + end + + def on_argument(node, parent) + counts[:argument_names] << node.name + counts[:arguments_entered] += 1 + super + ensure + counts[:arguments_left] += 1 + end + + def on_document(node, parent) + counts[:finished] = true + super + end + end + + class SkippingVisitor < VisitorSpecVisitor + def on_document(_n, _p) + SKIP + end + end + + let(:class_based_visitor) { VisitorSpecVisitor.new(document) } + let(:class_based_counts) { class_based_visitor.counts } + + it "has an array of hooks" do + assert_equal(2, hooks_visitor[GraphQL::Language::Nodes::Argument].enter.length) end it "can visit a document with directive definitions" do @@ -65,11 +95,30 @@ assert_equal 10, directive_locations.length end - describe "Visitor::SKIP" do - it "skips the rest of the node" do - visitor[GraphQL::Language::Nodes::Document] << ->(node, parent) { GraphQL::Language::Visitor::SKIP } + [:hooks, :class_based].each do |visitor_type| + it "#{visitor_type} visitor calls hooks during a depth-first tree traversal" do + visitor = public_send("#{visitor_type}_visitor") visitor.visit - assert_equal(0, counts[:fields_entered]) + counts = public_send("#{visitor_type}_counts") + assert_equal(6, counts[:fields_entered]) + assert_equal(2, counts[:arguments_entered]) + assert_equal(2, counts[:arguments_left]) + assert_equal(["id", "first"], counts[:argument_names]) + assert(counts[:finished]) + end + + describe "Visitor::SKIP" do + let(:class_based_visitor) { SkippingVisitor.new(document) } + + it "#{visitor_type} visitor skips the rest of the node" do + visitor = public_send("#{visitor_type}_visitor") + if visitor_type == :hooks + visitor[GraphQL::Language::Nodes::Document] << ->(node, parent) { GraphQL::Language::Visitor::SKIP } + end + visitor.visit + counts = public_send("#{visitor_type}_counts") + assert_equal(0, counts[:fields_entered]) + end end end @@ -91,4 +140,261 @@ assert visited_directive end + + describe "AST modification" do + class ModificationTestVisitor < GraphQL::Language::Visitor + def on_field(node, parent) + if node.name == "c" + new_node = node.merge(name: "renamedC") + super(new_node, parent) + elsif node.name == "addFields" + new_node = node.merge_selection(name: "addedChild") + super(new_node, parent) + elsif node.name == "anotherAddition" + new_node = node + .merge_argument(name: "addedArgument", value: 1) + .merge_directive(name: "doStuff") + super(new_node, parent) + else + super + end + end + + def on_argument(node, parent) + if node.name == "deleteMe" + super(DELETE_NODE, parent) + else + super + end + end + + def on_input_object(node, parent) + if node.arguments.map(&:name).sort == ["delete", "me"] + super(DELETE_NODE, parent) + else + super + end + end + + def on_directive(node, parent) + if node.name == "doStuff" + new_node = node.merge_argument(name: "addedArgument2", value: 2) + super(new_node, parent) + else + super + end + end + + def on_inline_fragment(node, parent) + if node.selections.map(&:name) == ["renameFragmentField", "spread"] + _field, spread = node.selections + new_node = node.merge(selections: [GraphQL::Language::Nodes::Field.new(name: "renamed"), spread]) + super(new_node, parent) + else + super(node, parent) + end + end + + def on_fragment_spread(node, parent) + if node.name == "spread" + new_node = node.merge(name: "renamedSpread") + super(new_node, parent) + else + super(node, parent) + end + end + + def on_object_type_definition(node, parent) + if node.name == "Rename" + new_node = node.merge(name: "WasRenamed") + super(new_node, parent) + else + super(node, parent) + end + end + + def on_field_definition(node, parent) + if node.name == "renameThis" + new_node = node.merge(name: "wasRenamed") + super(new_node, parent) + else + super + end + end + + def on_input_value_definition(node, parent) + if node.name == "renameThisArg" + new_node = node.merge(name: "argWasRenamed") + super(new_node, parent) + else + super + end + end + end + + def get_result(query_str) + document = GraphQL.parse(query_str) + visitor = ModificationTestVisitor.new(document) + visitor.visit + return document, visitor.result + end + + it "returns a new AST with modifications applied" do + query = <<-GRAPHQL.chop +query { + a(a1: 1) { + b(b2: 2) { + c(c3: 3) + } + } + d(d4: 4) +} + GRAPHQL + document, new_document = get_result(query) + refute_equal document, new_document + expected_result = <<-GRAPHQL.chop +query { + a(a1: 1) { + b(b2: 2) { + renamedC(c3: 3) + } + } + d(d4: 4) +} +GRAPHQL + assert_equal expected_result, new_document.to_query_string, "the result has changes" + assert_equal query, document.to_query_string, "the original is unchanged" + + # This is testing the implementation: nodes which aren't affected by modification + # should be shared between the two trees + orig_c3_argument = document.definitions.first.selections.first.selections.first.selections.first.arguments.first + copy_c3_argument = new_document.definitions.first.selections.first.selections.first.selections.first.arguments.first + assert_equal "c3", orig_c3_argument.name + assert orig_c3_argument.equal?(copy_c3_argument), "Child nodes are persisted" + + orig_d_field = document.definitions.first.selections[1] + copy_d_field = new_document.definitions.first.selections[1] + assert_equal "d", orig_d_field.name + assert orig_d_field.equal?(copy_d_field), "Sibling nodes are persisted" + + orig_b_field = document.definitions.first.selections.first.selections.first + copy_b_field = new_document.definitions.first.selections.first.selections.first + assert_equal "b", orig_b_field.name + refute orig_b_field.equal?(copy_b_field), "Parents with modified children are copied" + end + + it "deletes nodes with DELETE_NODE" do + before_query = <<-GRAPHQL.chop +query { + f1 { + f2(deleteMe: 1) { + f3(c1: {deleteMe: {c2: 2}}) + f4(c2: [{keepMe: 1}, {deleteMe: 2}, {keepMe: 3}]) + } + } +} +GRAPHQL + + after_query = <<-GRAPHQL.chop +query { + f1 { + f2 { + f3(c1: {}) + f4(c2: [{keepMe: 1}, {}, {keepMe: 3}]) + } + } +} +GRAPHQL + + document, new_document = get_result(before_query) + assert_equal before_query, document.to_query_string + assert_equal after_query, new_document.to_query_string + end + + it "Deletes from lists" do + before_query = <<-GRAPHQL.chop +query { + f1(arg1: [{a: 1}, {delete: 1, me: 2}, {b: 2}]) +} +GRAPHQL + + after_query = <<-GRAPHQL.chop +query { + f1(arg1: [{a: 1}, {b: 2}]) +} +GRAPHQL + + document, new_document = get_result(before_query) + assert_equal before_query, document.to_query_string + assert_equal after_query, new_document.to_query_string + end + + it "can add children" do + before_query = <<-GRAPHQL.chop +query { + addFields + anotherAddition +} +GRAPHQL + + after_query = <<-GRAPHQL.chop +query { + addFields { + addedChild + } + anotherAddition(addedArgument: 1) @doStuff(addedArgument2: 2) +} +GRAPHQL + + document, new_document = get_result(before_query) + assert_equal before_query, document.to_query_string + assert_equal after_query, new_document.to_query_string + end + + it "can modify inline fragments" do + before_query = <<-GRAPHQL.chop +query { + ... on Query { + renameFragmentField + ...spread + } +} +GRAPHQL + + after_query = <<-GRAPHQL.chop +query { + ... on Query { + renamed + ...renamedSpread + } +} +GRAPHQL + + document, new_document = get_result(before_query) + assert_equal before_query, document.to_query_string + assert_equal after_query, new_document.to_query_string + end + + it "works with SDL" do + before_query = <<-GRAPHQL.chop +type Rename @doStuff { + f: Int + renameThis: String + f2(renameThisArg: Boolean): Boolean +} +GRAPHQL + + after_query = <<-GRAPHQL.chop +type WasRenamed @doStuff(addedArgument2: 2) { + f: Int + wasRenamed: String + f2(argWasRenamed: Boolean): Boolean +} +GRAPHQL + + document, new_document = get_result(before_query) + assert_equal before_query, document.to_query_string + assert_equal after_query, new_document.to_query_string + end + end end diff --git a/spec/graphql/non_null_type_spec.rb b/spec/graphql/non_null_type_spec.rb index e80ec6b5d1..ba823cad49 100644 --- a/spec/graphql/non_null_type_spec.rb +++ b/spec/graphql/non_null_type_spec.rb @@ -39,7 +39,7 @@ query_string = %|{ cow { name cantBeNullButIs } }| err = assert_raises(GraphQL::InvalidNullError) { raise_schema.execute(query_string) } assert_equal("Cannot return null for non-nullable field Cow.cantBeNullButIs", err.message) - assert_equal("Cow", err.parent_type.name) + assert_equal("Cow", err.parent_type.graphql_name) assert_equal("cantBeNullButIs", err.field.name) assert_equal(nil, err.value) end diff --git a/spec/graphql/query/executor_spec.rb b/spec/graphql/query/executor_spec.rb index 7a0202e5f0..5d73b4cc60 100644 --- a/spec/graphql/query/executor_spec.rb +++ b/spec/graphql/query/executor_spec.rb @@ -186,29 +186,31 @@ end end - describe "if the schema has a rescue handler" do - before do - # HACK: reach to the underlying instance to perform a side-effect - schema.graphql_definition.rescue_from(RuntimeError) { "Error was handled!" } - end + if TESTING_RESCUE_FROM + describe "if the schema has a rescue handler" do + before do + # HACK: reach to the underlying instance to perform a side-effect + schema.graphql_definition.rescue_from(RuntimeError) { "Error was handled!" } + end - after do - # remove the handler from the middleware: - schema.remove_handler(RuntimeError) - end + after do + # remove the handler from the middleware: + schema.remove_handler(RuntimeError) + end - it "adds to the errors key" do - expected = { - "data" => {"error" => nil}, - "errors"=>[ - { - "message"=>"Error was handled!", - "locations" => [{"line"=>1, "column"=>17}], - "path"=>["error"] - } - ] - } - assert_equal(expected, result) + it "adds to the errors key" do + expected = { + "data" => {"error" => nil}, + "errors"=>[ + { + "message"=>"Error was handled!", + "locations" => [{"line"=>1, "column"=>17}], + "path"=>["error"] + } + ] + } + assert_equal(expected, result) + end end end end diff --git a/spec/graphql/schema/argument_spec.rb b/spec/graphql/schema/argument_spec.rb index 7c0947cb38..43a7305cc9 100644 --- a/spec/graphql/schema/argument_spec.rb +++ b/spec/graphql/schema/argument_spec.rb @@ -35,6 +35,9 @@ def multiply(val) class Schema < GraphQL::Schema query(Query) + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end end diff --git a/spec/graphql/schema/catchall_middleware_spec.rb b/spec/graphql/schema/catchall_middleware_spec.rb index 9b5c508967..8449cc62ed 100644 --- a/spec/graphql/schema/catchall_middleware_spec.rb +++ b/spec/graphql/schema/catchall_middleware_spec.rb @@ -13,22 +13,23 @@ Dummy::Schema.middleware.delete(GraphQL::Schema::CatchallMiddleware) end - describe "rescuing errors" do - let(:errors) { query.context.errors } + if TESTING_RESCUE_FROM + describe "rescuing errors" do + let(:errors) { query.context.errors } - it "turns into error messages" do - expected = { - "data" => { "error" => nil }, - "errors"=> [ - { - "message"=>"Internal error", - "locations"=>[{"line"=>1, "column"=>17}], - "path"=>["error"] - }, - ] - } - assert_equal(expected, result) + it "turns into error messages" do + expected = { + "data" => { "error" => nil }, + "errors"=> [ + { + "message"=>"Internal error", + "locations"=>[{"line"=>1, "column"=>17}], + "path"=>["error"] + }, + ] + } + assert_equal(expected, result) + end end end - end diff --git a/spec/graphql/schema/field_extension_spec.rb b/spec/graphql/schema/field_extension_spec.rb new file mode 100644 index 0000000000..f84469f51f --- /dev/null +++ b/spec/graphql/schema/field_extension_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Schema::FieldExtension do + module FilterTestSchema + class DoubleFilter < GraphQL::Schema::FieldExtension + def after_resolve(object:, value:, arguments:, context:, memo:) + value * 2 + end + end + + class MultiplyByOption < GraphQL::Schema::FieldExtension + def after_resolve(object:, value:, arguments:, context:, memo:) + value * options[:factor] + end + end + + class MultiplyByArgument < GraphQL::Schema::FieldExtension + def apply + field.argument(:factor, Integer, required: true) + end + + def before_resolve(object:, arguments:, context:) + factor = arguments.delete(:factor) + yield(object, arguments, factor) + end + + def after_resolve(object:, value:, arguments:, context:, memo:) + value * memo + end + end + + class BaseObject < GraphQL::Schema::Object + end + + class Query < BaseObject + field :doubled, Integer, null: false, resolver_method: :pass_thru do + extension(DoubleFilter) + argument :input, Integer, required: true + end + + def pass_thru(input:) + input # return it as-is, it will be modified by extensions + end + + field :trippled_by_option, Integer, null: false, resolver_method: :pass_thru do + extension(MultiplyByOption, factor: 3) + argument :input, Integer, required: true + end + + field :multiply_input, Integer, null: false, resolver_method: :pass_thru, extensions: [MultiplyByArgument] do + argument :input, Integer, required: true + end + end + + class Schema < GraphQL::Schema + query(Query) + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end + end + end + + def exec_query(query_str, **kwargs) + FilterTestSchema::Schema.execute(query_str, **kwargs) + end + + describe "reading" do + it "has a reader method" do + field = FilterTestSchema::Query.fields["multiplyInput"] + assert_equal 1, field.extensions.size + assert_instance_of FilterTestSchema::MultiplyByArgument, field.extensions.first + end + end + + describe "modifying return values" do + it "returns the modified value" do + res = exec_query("{ doubled(input: 5) }") + assert_equal 10, res["data"]["doubled"] + end + + it "has access to config options" do + # The factor of three came from an option + res = exec_query("{ trippledByOption(input: 4) }") + assert_equal 12, res["data"]["trippledByOption"] + end + + it "can hide arguments from resolve methods" do + res = exec_query("{ multiplyInput(input: 3, factor: 5) }") + assert_equal 15, res["data"]["multiplyInput"] + end + end +end diff --git a/spec/graphql/schema/field_spec.rb b/spec/graphql/schema/field_spec.rb index 6c22e4c062..9e47ca3bd2 100644 --- a/spec/graphql/schema/field_spec.rb +++ b/spec/graphql/schema/field_spec.rb @@ -45,6 +45,12 @@ assert_equal 'underscored_arg', arg_defn.name end + it "works with arbitrary hash keys" do + result = Jazz::Schema.execute "{ complexHashKey }", root_value: { :'foo bar/fizz-buzz' => "OK!"} + hash_val = result["data"]["complexHashKey"] + assert_equal "OK!", hash_val, "It looked up the hash key" + end + it "exposes the method override" do object = Class.new(Jazz::BaseObject) do field :t, String, method: :tt, null: true @@ -228,14 +234,12 @@ end it "makes a suggestion when the type is false" do - thing = Class.new(GraphQL::Schema::Object) do - graphql_name "Thing" - # False might come from an invalid `!` - field :stuff, false, null: false - end - err = assert_raises ArgumentError do - thing.fields["stuff"].type + Class.new(GraphQL::Schema::Object) do + graphql_name "Thing" + # False might come from an invalid `!` + field :stuff, false, null: false + end end assert_includes err.message, "Thing.stuff" diff --git a/spec/graphql/schema/input_object_spec.rb b/spec/graphql/schema/input_object_spec.rb index 35ae0efe0f..a5aea99fbc 100644 --- a/spec/graphql/schema/input_object_spec.rb +++ b/spec/graphql/schema/input_object_spec.rb @@ -81,6 +81,9 @@ def inputs(input:) class Schema < GraphQL::Schema query(Query) + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end end @@ -120,6 +123,13 @@ class Schema < GraphQL::Schema end end + describe "when used with default_value" do + it "comes as an instance" do + res = Jazz::Schema.execute("{ defaultValueTest }") + assert_equal "Jazz::InspectableInput -> {:string_value=>\"S\"}", res["data"]["defaultValueTest"] + end + end + describe "#to_h" do module InputObjectToHTest class TestInput1 < GraphQL::Schema::InputObject @@ -200,6 +210,29 @@ class TestInput2 < GraphQL::Schema::InputObject # assert_equal 3, input_object.dig('input_object', 'd') assert_equal 3, input_object.dig(:input_object, :d) end + end + describe "introspection" do + it "returns input fields" do + res = Jazz::Schema.execute(' + { + __type(name: "InspectableInput") { + name + inputFields { name } + } + __schema { + types { + name + inputFields { name } + } + } + }') + # Test __type + assert_equal ["stringValue", "nestedInput", "legacyInput"], res["data"]["__type"]["inputFields"].map { |f| f["name"] } + # Test __schema { types } + # It's upcased to test custom introspection + input_type = res["data"]["__schema"]["types"].find { |t| t["name"] == "INSPECTABLEINPUT" } + assert_equal ["stringValue", "nestedInput", "legacyInput"], input_type["inputFields"].map { |f| f["name"] } + end end end diff --git a/spec/graphql/schema/instrumentation_spec.rb b/spec/graphql/schema/instrumentation_spec.rb index 9c435fdb77..0e719cb859 100644 --- a/spec/graphql/schema/instrumentation_spec.rb +++ b/spec/graphql/schema/instrumentation_spec.rb @@ -26,6 +26,9 @@ def some_field class Schema < GraphQL::Schema query Query orphan_types [SomeType] + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end end diff --git a/spec/graphql/schema/introspection_system_spec.rb b/spec/graphql/schema/introspection_system_spec.rb index d2c3fbb9c6..d5f7e03b36 100644 --- a/spec/graphql/schema/introspection_system_spec.rb +++ b/spec/graphql/schema/introspection_system_spec.rb @@ -13,7 +13,7 @@ assert_equal "ENSEMBLE", res["data"]["__type"]["name"] end - it "serves custom entry points" do + it "serves custom entry points" do res = Jazz::Schema.execute("{ __classname }", root_value: Set.new) assert_equal "Set", res["data"]["__classname"] end @@ -35,5 +35,13 @@ res = Dummy::Schema.execute("{ ensembles { __typenameLength } }") assert_equal 1, res["errors"].length end + + it "runs the introspection query" do + res = Jazz::Schema.execute(GraphQL::Introspection::INTROSPECTION_QUERY) + assert res + query_type = res["data"]["__schema"]["types"].find { |t| t["name"] == "QUERY" } + ensembles_field = query_type["fields"].find { |f| f["name"] == "ensembles" } + assert_equal [], ensembles_field["args"] + end end end diff --git a/spec/graphql/schema/member/accepts_definition_spec.rb b/spec/graphql/schema/member/accepts_definition_spec.rb index 917e43bf18..a80ddc4c57 100644 --- a/spec/graphql/schema/member/accepts_definition_spec.rb +++ b/spec/graphql/schema/member/accepts_definition_spec.rb @@ -6,6 +6,10 @@ class AcceptsDefinitionSchema < GraphQL::Schema accepts_definition :set_metadata set_metadata :a, 999 + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end + class BaseField < GraphQL::Schema::Field class BaseField < GraphQL::Schema::Argument accepts_definition :metadata diff --git a/spec/graphql/schema/member/has_fields_spec.rb b/spec/graphql/schema/member/has_fields_spec.rb deleted file mode 100644 index a67906efc7..0000000000 --- a/spec/graphql/schema/member/has_fields_spec.rb +++ /dev/null @@ -1,129 +0,0 @@ -# frozen_string_literal: true -require "spec_helper" - -describe GraphQL::Schema::Member::HasFields do - module SuperTest - class BaseObject < GraphQL::Schema::Object - end - - module BaseInterface - include GraphQL::Schema::Interface - end - - module InterfaceWithFloatField - include BaseInterface - field :float, Float, null: false - def float - # This should call the default implementation - super * 0.5 - end - end - - module SubInterfaceWithFloatField - include InterfaceWithFloatField - def float - # This should call `InterfaceWithFloatField#float` - super * 0.1 - end - end - - class ObjectWithFloatField < BaseObject - implements InterfaceWithFloatField - end - - class ObjectWithSubFloatField < BaseObject - implements SubInterfaceWithFloatField - end - - module InterfaceWithStringField - include BaseInterface - field :string, String, null: false - def string - # Return a literal value to ensure this method was called - "here's a string" - end - end - - class ObjectWithStringField < BaseObject - implements InterfaceWithStringField - def string - # This should call to `InterfaceWithStringField#string` - super.upcase - end - end - - class SubObjectWithStringField < ObjectWithStringField - def string - # This should call to `ObjectWithStringField#string` - super.reverse - end - end - - class SubSubObjectWithStringField < SubObjectWithStringField - field :string, String, null: false - end - - class Query < BaseObject - field :int, Integer, null: false - def int - # This should call default resolution - super * 2 - end - - field :string1, ObjectWithStringField, null: false, method: :object - field :string2, SubObjectWithStringField, null: false, method: :object - field :string3, SubSubObjectWithStringField, null: false, method: :object - field :float1, ObjectWithFloatField, null: false, method: :object - field :float2, ObjectWithSubFloatField, null: false, method: :object - end - - class Schema < GraphQL::Schema - query(Query) - end - end - - describe "Calling super in field methods" do - # Test that calling `super` in field methods "works", which means: - # - If there is a super method in the user-created hierarchy (either a class or module), it is called - # This is tested by putting random transformations in method bodies, - # then asserting that they are called. - # - If there's no user-defined super method, it calls the built-in default behavior - # This is tested by putting values in the `root_value` hash. - # The default behavior is to fetch hash values by key, so we assert that - # those values are subject to the specified transformations. - - describe "Object methods" do - it "may call super to default implementation" do - res = SuperTest::Schema.execute("{ int }", root_value: { int: 4 }) - assert_equal 8, res["data"]["int"] - end - - it "may call super to interface method" do - res = SuperTest::Schema.execute(" { string1 { string } }", root_value: {}) - assert_equal "HERE'S A STRING", res["data"]["string1"]["string"] - end - - it "may call super to superclass method" do - res = SuperTest::Schema.execute(" { string2 { string } }", root_value: {}) - assert_equal "GNIRTS A S'EREH", res["data"]["string2"]["string"] - end - - it "can get a super method from a newly-added field" do - res = SuperTest::Schema.execute(" { string3 { string } }", root_value: {}) - assert_equal "GNIRTS A S'EREH", res["data"]["string3"]["string"] - end - end - - describe "Interface methods" do - it "may call super to interface method" do - res = SuperTest::Schema.execute(" { float1 { float } }", root_value: { float: 6.0 }) - assert_equal 3.0, res["data"]["float1"]["float"] - end - - it "may call super to superclass method" do - res = SuperTest::Schema.execute(" { float2 { float } }", root_value: { float: 6.0 }) - assert_in_delta 0.001, 0.3, res["data"]["float2"]["float"] - end - end - end -end diff --git a/spec/graphql/schema/member/scoped_spec.rb b/spec/graphql/schema/member/scoped_spec.rb index cd339934e1..c7ed9318d3 100644 --- a/spec/graphql/schema/member/scoped_spec.rb +++ b/spec/graphql/schema/member/scoped_spec.rb @@ -54,11 +54,11 @@ class Query < BaseObject field :items, [Item], null: false field :unscoped_items, [Item], null: false, scope: false, - method: :items + resolver_method: :items field :french_items, [FrenchItem], null: false, - method: :items + resolver_method: :items field :items_connection, Item.connection_type, null: false, - method: :items + resolver_method: :items def items [ @@ -74,6 +74,9 @@ def things end query(Query) + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end describe ".scope_items(items, ctx)" do @@ -118,6 +121,19 @@ def get_item_names_with_context(ctx, field_name: "items") res = ScopeSchema.execute(query_str, context: {english: true}) names = res["data"]["itemsConnection"]["edges"].map { |e| e["node"]["name"] } assert_equal ["Paperclip"], names + + query_str = " + { + itemsConnection { + nodes { + name + } + } + } + " + res = ScopeSchema.execute(query_str, context: {english: true}) + names = res["data"]["itemsConnection"]["nodes"].map { |e| e["name"] } + assert_equal ["Paperclip"], names end it "is called for abstract types" do diff --git a/spec/graphql/schema/mutation_spec.rb b/spec/graphql/schema/mutation_spec.rb index 49d928338a..7684c170fb 100644 --- a/spec/graphql/schema/mutation_spec.rb +++ b/spec/graphql/schema/mutation_spec.rb @@ -23,7 +23,7 @@ describe "argument prepare" do it "calls methods on the mutation, uses `as:`" do - query_str = 'mutation { prepareInput(input: 4) }' + query_str = "mutation { prepareInput(input: 4) }" res = Jazz::Schema.execute(query_str) assert_equal 16, res["data"]["prepareInput"], "It's squared by the prepare method" end @@ -63,7 +63,8 @@ assert_equal(GraphQL::Schema::Object, GraphQL::Schema::Mutation.object_class) assert_equal(obj_class, mutation_class.object_class) - assert_equal(obj_class, mutation_subclass.object_class) end + assert_equal(obj_class, mutation_subclass.object_class) + end end describe ".argument_class" do @@ -101,7 +102,8 @@ response = Jazz::Schema.execute(query_str) assert_equal "Trombone", response["data"]["addInstrument"]["instrument"]["name"] assert_equal "BRASS", response["data"]["addInstrument"]["instrument"]["family"] - assert_equal "GraphQL::Query::Context::ExecutionErrors", response["data"]["addInstrument"]["ee"] + errors_class = TESTING_INTERPRETER ? "GraphQL::Execution::Interpreter::ExecutionErrors" : "GraphQL::Query::Context::ExecutionErrors" + assert_equal errors_class, response["data"]["addInstrument"]["ee"] assert_equal 7, response["data"]["addInstrument"]["entries"].size end end diff --git a/spec/graphql/schema/object_spec.rb b/spec/graphql/schema/object_spec.rb index aaac46cd1e..b7fd28546f 100644 --- a/spec/graphql/schema/object_spec.rb +++ b/spec/graphql/schema/object_spec.rb @@ -236,7 +236,9 @@ module InterfaceType it "skips fields properly" do query_str = "{ find(id: \"MagicalSkipId\") { __typename } }" res = Jazz::Schema.execute(query_str) - assert_equal({"data" => nil }, res.to_h) + # TBH I think `{}` is probably righter than `nil`, I guess we'll see. + skip_value = TESTING_INTERPRETER ? {} : nil + assert_equal({"data" => skip_value }, res.to_h) end end end diff --git a/spec/graphql/schema/relay_classic_mutation_spec.rb b/spec/graphql/schema/relay_classic_mutation_spec.rb index f08808c8a5..b9735d5581 100644 --- a/spec/graphql/schema/relay_classic_mutation_spec.rb +++ b/spec/graphql/schema/relay_classic_mutation_spec.rb @@ -50,6 +50,32 @@ assert_equal "Sitar", res["data"]["addSitar"]["instrument"]["name"] end + + it "supports extras" do + res = Jazz::Schema.execute <<-GRAPHQL + mutation { + hasExtras(input: {}) { + nodeClass + int + } + } + GRAPHQL + + assert_equal "GraphQL::Language::Nodes::Field", res["data"]["hasExtras"]["nodeClass"] + assert_nil res["data"]["hasExtras"]["int"] + + # Also test with given args + res = Jazz::Schema.execute <<-GRAPHQL + mutation { + hasExtras(input: {int: 5}) { + nodeClass + int + } + } + GRAPHQL + assert_equal "GraphQL::Language::Nodes::Field", res["data"]["hasExtras"]["nodeClass"] + assert_equal 5, res["data"]["hasExtras"]["int"] + end end describe "loading multiple application objects" do diff --git a/spec/graphql/schema/resolver_spec.rb b/spec/graphql/schema/resolver_spec.rb index a9ce1b4265..d55d52427a 100644 --- a/spec/graphql/schema/resolver_spec.rb +++ b/spec/graphql/schema/resolver_spec.rb @@ -181,7 +181,7 @@ def self.resolve_type(obj, ctx) class IntegerWrapper < GraphQL::Schema::Object implements HasValue - field :value, Integer, null: false, method: :object + field :value, Integer, null: false, method: :itself end class PrepResolver9 < BaseResolver @@ -319,6 +319,14 @@ def resolve_field(*args) end value end + + def resolve(*) + value = super + if @name == "resolver3" + value << -1 + end + value + end end field_class(CustomField) @@ -355,6 +363,9 @@ class Schema < GraphQL::Schema mutation(Mutation) lazy_resolve LazyBlock, :value orphan_types IntegerWrapper + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end def object_from_id(id, ctx) if id == "invalid" diff --git a/spec/graphql/schema/warden_spec.rb b/spec/graphql/schema/warden_spec.rb index 1f01e9648d..6c92b36b35 100644 --- a/spec/graphql/schema/warden_spec.rb +++ b/spec/graphql/schema/warden_spec.rb @@ -3,20 +3,45 @@ include ErrorBubblingHelpers module MaskHelpers - PhonemeType = GraphQL::ObjectType.define do - name "Phoneme" - description "A building block of sound in a given language" - metadata :hidden_type, true - interfaces [LanguageMemberInterface] + class BaseArgument < GraphQL::Schema::Argument + accepts_definition :metadata + end + + class BaseField < GraphQL::Schema::Field + accepts_definition :metadata + argument_class BaseArgument + end + + class BaseObject < GraphQL::Schema::Object + accepts_definition :metadata + field_class BaseField + end + + class BaseEnumValue < GraphQL::Schema::EnumValue + accepts_definition :metadata + end + + class BaseEnum < GraphQL::Schema::Enum + accepts_definition :metadata + enum_value_class BaseEnumValue + end + + class BaseInputObject < GraphQL::Schema::InputObject + accepts_definition :metadata + argument_class BaseArgument + end + + class BaseUnion < GraphQL::Schema::Union + accepts_definition :metadata + end - field :name, types.String.to_non_null_type - field :symbol, types.String.to_non_null_type - field :languages, LanguageType.to_list_type - field :manner, MannerEnum + module BaseInterface + include GraphQL::Schema::Interface + accepts_definition :metadata + field_class BaseField end - MannerEnum = GraphQL::EnumType.define do - name "Manner" + class MannerType < BaseEnum description "Manner of articulation for this sound" metadata :hidden_input_type, true value "STOP" @@ -29,105 +54,108 @@ module MaskHelpers end end - LanguageType = GraphQL::ObjectType.define do - name "Language" - field :name, types.String.to_non_null_type - field :families, types.String.to_list_type - field :phonemes, PhonemeType.to_list_type - field :graphemes, GraphemeType.to_list_type + class LanguageType < BaseObject + field :name, String, null: false + field :families, [String], null: false + field :phonemes, "[MaskHelpers::PhonemeType]", null: false + field :graphemes, "[MaskHelpers::GraphemeType]", null: false end - GraphemeType = GraphQL::ObjectType.define do - name "Grapheme" + module LanguageMemberType + include BaseInterface + metadata :hidden_abstract_type, true + description "Something that belongs to one or more languages" + field :languages, [LanguageType], null: false + end + + class GraphemeType < BaseObject description "A building block of spelling in a given language" - interfaces [LanguageMemberInterface] + implements LanguageMemberType - field :name, types.String.to_non_null_type - field :glyph, types.String.to_non_null_type - field :languages, LanguageType.to_list_type + field :name, String, null: false + field :glyph, String, null: false + field :languages, [LanguageType], null: false end - LanguageMemberInterface = GraphQL::InterfaceType.define do - name "LanguageMember" - metadata :hidden_abstract_type, true - description "Something that belongs to one or more languages" - field :languages, LanguageType.to_list_type + class PhonemeType < BaseObject + description "A building block of sound in a given language" + metadata :hidden_type, true + implements LanguageMemberType + + field :name, String, null: false + field :symbol, String, null: false + field :languages, [LanguageType], null: false + field :manner, MannerType, null: false end - EmicUnitUnion = GraphQL::UnionType.define do - name "EmicUnit" + class EmicUnitType < BaseUnion description "A building block of a word in a given language" - possible_types [GraphemeType, PhonemeType] + possible_types GraphemeType, PhonemeType end - WithinInputType = GraphQL::InputObjectType.define do - name "WithinInput" + class WithinInputType < BaseInputObject metadata :hidden_input_object_type, true - argument :latitude, !types.Float - argument :longitude, !types.Float - argument :miles, !types.Float do + argument :latitude, Float, required: true + argument :longitude, Float, required: true + argument :miles, Float, required: true do metadata :hidden_input_field, true end end - CheremeInput = GraphQL::InputObjectType.define do - name "CheremeInput" - input_field :name, types.String + class CheremeInput < BaseInputObject + argument :name, String, required: false end - Chereme = GraphQL::ObjectType.define do - name "Chereme" + class Chereme < BaseObject description "A basic unit of signed communication" - field :name, types.String.to_non_null_type + field :name, String, null: false end - Character = GraphQL::ObjectType.define do - name "Character" - interfaces [LanguageMemberInterface] - field :code, types.Int + class Character < BaseObject + implements LanguageMemberType + field :code, Int, null: false end - QueryType = GraphQL::ObjectType.define do - name "Query" - field :languages, LanguageType.to_list_type do - argument :within, WithinInputType, "Find languages nearby a point" do + class QueryType < BaseObject + field :languages, [LanguageType], null: false do + argument :within, WithinInputType, required: false, description: "Find languages nearby a point" do metadata :hidden_argument_with_input_object, true end end - field :language, LanguageType do + + field :language, LanguageType, null: true do metadata :hidden_field, true - argument :name, !types.String do + argument :name, String, required: true do metadata :hidden_argument, true end end - field :chereme, Chereme do + field :chereme, Chereme, null: false do metadata :hidden_field, true end - field :phonemes, PhonemeType.to_list_type do - argument :manners, MannerEnum.to_list_type, "Filter phonemes by manner of articulation" + field :phonemes, [PhonemeType], null: false do + argument :manners, [MannerType], required: false, description: "Filter phonemes by manner of articulation" end - field :phoneme, PhonemeType do + field :phoneme, PhonemeType, null: true do description "Lookup a phoneme by symbol" - argument :symbol, !types.String + argument :symbol, String, required: true end - field :unit, EmicUnitUnion do + field :unit, EmicUnitType, null: true do description "Find an emic unit by its name" - argument :name, types.String.to_non_null_type + argument :name, String, required: true end end - MutationType = GraphQL::ObjectType.define do - name "Mutation" - field :add_phoneme, PhonemeType do - argument :symbol, types.String + class MutationType < BaseObject + field :add_phoneme, PhonemeType, null: true do + argument :symbol, String, required: false end - field :add_chereme, types.String do - argument :chereme, CheremeInput do + field :add_chereme, String, null: true do + argument :chereme, CheremeInput, required: false do metadata :hidden_argument, true end end @@ -146,18 +174,25 @@ def self.before_query(query) def self.after_query(q); end end - Schema = GraphQL::Schema.define do + class Schema < GraphQL::Schema query QueryType mutation MutationType subscription MutationType orphan_types [Character] - resolve_type ->(type, obj, ctx) { PhonemeType } + def self.resolve_type(type, obj, ctx) + PhonemeType + end + instrument :query, FilterInstrumentation + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + use GraphQL::Analysis::AST + end end module Data UVULAR_TRILL = OpenStruct.new({name: "Uvular Trill", symbol: "ʀ", manner: "TRILL"}) - def self.unit + def self.unit(name:) UVULAR_TRILL end end @@ -206,16 +241,16 @@ def error_messages(query_result) end describe "hiding root types" do - let(:mask) { ->(m, ctx) { m == MaskHelpers::MutationType } } + let(:mask) { ->(m, ctx) { m == MaskHelpers::MutationType.graphql_definition } } it "acts as if the root doesn't exist" do - query_string = %|mutation { add_phoneme(symbol: "ϕ") { name } }| + query_string = %|mutation { addPhoneme(symbol: "ϕ") { name } }| res = MaskHelpers.query_with_mask(query_string, mask) assert MaskHelpers::Schema.mutation # it _does_ exist assert_equal 1, res["errors"].length assert_equal "Schema is not configured for mutations", res["errors"][0]["message"] - query_string = %|subscription { add_phoneme(symbol: "ϕ") { name } }| + query_string = %|subscription { addPhoneme(symbol: "ϕ") { name } }| res = MaskHelpers.query_with_mask(query_string, mask) assert MaskHelpers::Schema.subscription # it _does_ exist assert_equal 1, res["errors"].length @@ -558,7 +593,6 @@ def error_messages(query_result) end end - describe "hiding arguments" do let(:mask) { ->(member, ctx) { member.metadata[:hidden_argument] || member.metadata[:hidden_input_type] } @@ -760,30 +794,30 @@ def error_messages(query_result) res = MaskHelpers.query_with_mask(query_string, mask) # It's not a good error message ... but it's something! expected_errors = [ - "Argument 'manners' on Field 'phonemes' has an invalid value. Expected type '[Manner]'.", + "Argument 'manners' on Field 'phonemes' has an invalid value. Expected type '[Manner!]'.", ] assert_equal expected_errors, error_messages(res) end it "isn't a valid default value" do query_string = %| - query getPhonemes($manners: [Manner] = [STOP, TRILL]){ phonemes(manners: $manners) { symbol } } + query getPhonemes($manners: [Manner!] = [STOP, TRILL]){ phonemes(manners: $manners) { symbol } } | res = MaskHelpers.query_with_mask(query_string, mask) - expected_errors = ["Default value for $manners doesn't match type [Manner]"] + expected_errors = ["Default value for $manners doesn't match type [Manner!]"] assert_equal expected_errors, error_messages(res) end it "isn't a valid variable input" do query_string = %| - query getPhonemes($manners: [Manner]!) { + query getPhonemes($manners: [Manner!]!) { phonemes(manners: $manners) { symbol } } | res = MaskHelpers.query_with_mask(query_string, mask, variables: { "manners" => ["STOP", "TRILL"] }) # It's not a good error message ... but it's something! expected_errors = [ - "Variable manners of type [Manner]! was provided invalid value", + "Variable manners of type [Manner!]! was provided invalid value", ] assert_equal expected_errors, error_messages(res) end diff --git a/spec/graphql/static_validation/rules/argument_literals_are_compatible_spec.rb b/spec/graphql/static_validation/rules/argument_literals_are_compatible_spec.rb index b06203d63f..7675ee2566 100644 --- a/spec/graphql/static_validation/rules/argument_literals_are_compatible_spec.rb +++ b/spec/graphql/static_validation/rules/argument_literals_are_compatible_spec.rb @@ -20,6 +20,7 @@ similarCheese(source: 4.5) { __typename } } |} + describe "with error bubbling disabled" do it "finds undefined or missing-required arguments to fields and directives" do without_error_bubbling(schema) do @@ -30,28 +31,32 @@ query_root_error = { "message"=>"Argument 'id' on Field 'stringCheese' has an invalid value. Expected type 'Int!'.", "locations"=>[{"line"=>3, "column"=>7}], - "fields"=>["query getCheese", "stringCheese", "id"], + "path"=>["query getCheese", "stringCheese", "id"], + "extensions"=>{"code"=>"argumentLiteralsIncompatible", "typeName"=>"Field", "argumentName"=>"id"}, } assert_includes(errors, query_root_error) directive_error = { "message"=>"Argument 'if' on Directive 'skip' has an invalid value. Expected type 'Boolean!'.", "locations"=>[{"line"=>4, "column"=>30}], - "fields"=>["query getCheese", "cheese", "source", "if"], + "path"=>["query getCheese", "cheese", "source", "if"], + "extensions"=>{"code"=>"argumentLiteralsIncompatible", "typeName"=>"Directive", "argumentName"=>"if"}, } assert_includes(errors, directive_error) input_object_field_error = { "message"=>"Argument 'source' on InputObject 'DairyProductInput' has an invalid value. Expected type 'DairyAnimal!'.", "locations"=>[{"line"=>6, "column"=>40}], - "fields"=>["query getCheese", "badSource", "product", "source"], + "path"=>["query getCheese", "badSource", "product", "source"], + "extensions"=>{"code"=>"argumentLiteralsIncompatible", "typeName"=>"InputObject", "argumentName"=>"source"}, } assert_includes(errors, input_object_field_error) fragment_error = { "message"=>"Argument 'source' on Field 'similarCheese' has an invalid value. Expected type '[DairyAnimal!]!'.", "locations"=>[{"line"=>13, "column"=>7}], - "fields"=>["fragment cheeseFields", "similarCheese", "source"], + "path"=>["fragment cheeseFields", "similarCheese", "source"], + "extensions"=> {"code"=>"argumentLiteralsIncompatible", "typeName"=>"Field", "argumentName"=>"source"} } assert_includes(errors, fragment_error) end @@ -63,42 +68,48 @@ query_root_error = { "message"=>"Argument 'id' on Field 'stringCheese' has an invalid value. Expected type 'Int!'.", "locations"=>[{"line"=>3, "column"=>7}], - "fields"=>["query getCheese", "stringCheese", "id"], + "path"=>["query getCheese", "stringCheese", "id"], + "extensions"=>{"code"=>"argumentLiteralsIncompatible", "typeName"=>"Field", "argumentName"=>"id"}, } assert_includes(errors, query_root_error) directive_error = { "message"=>"Argument 'if' on Directive 'skip' has an invalid value. Expected type 'Boolean!'.", "locations"=>[{"line"=>4, "column"=>30}], - "fields"=>["query getCheese", "cheese", "source", "if"], + "path"=>["query getCheese", "cheese", "source", "if"], + "extensions"=>{"code"=>"argumentLiteralsIncompatible", "typeName"=>"Directive", "argumentName"=>"if"}, } assert_includes(errors, directive_error) input_object_error = { "message"=>"Argument 'product' on Field 'badSource' has an invalid value. Expected type '[DairyProductInput]'.", "locations"=>[{"line"=>6, "column"=>7}], - "fields"=>["query getCheese", "badSource", "product"], + "path"=>["query getCheese", "badSource", "product"], + "extensions"=>{"code"=>"argumentLiteralsIncompatible", "typeName"=>"Field", "argumentName"=>"product"}, } assert_includes(errors, input_object_error) input_object_field_error = { "message"=>"Argument 'source' on InputObject 'DairyProductInput' has an invalid value. Expected type 'DairyAnimal!'.", "locations"=>[{"line"=>6, "column"=>40}], - "fields"=>["query getCheese", "badSource", "product", "source"], + "path"=>["query getCheese", "badSource", "product", "source"], + "extensions"=>{"code"=>"argumentLiteralsIncompatible", "typeName"=>"InputObject", "argumentName"=>"source"}, } assert_includes(errors, input_object_field_error) missing_required_field_error = { "message"=>"Argument 'product' on Field 'missingSource' has an invalid value. Expected type '[DairyProductInput]'.", "locations"=>[{"line"=>7, "column"=>7}], - "fields"=>["query getCheese", "missingSource", "product"], + "path"=>["query getCheese", "missingSource", "product"], + "extensions"=>{"code"=>"argumentLiteralsIncompatible", "typeName"=>"Field", "argumentName"=>"product"} } assert_includes(errors, missing_required_field_error) fragment_error = { "message"=>"Argument 'source' on Field 'similarCheese' has an invalid value. Expected type '[DairyAnimal!]!'.", "locations"=>[{"line"=>13, "column"=>7}], - "fields"=>["fragment cheeseFields", "similarCheese", "source"], + "path"=>["fragment cheeseFields", "similarCheese", "source"], + "extensions"=> {"code"=>"argumentLiteralsIncompatible", "typeName"=>"Field", "argumentName"=>"source"} } assert_includes(errors, fragment_error) end @@ -164,7 +175,8 @@ assert_equal [{ "message"=>"Argument 'arg' on Field 'field' has an invalid value. Expected type 'Int!'.", "locations"=>[{"line"=>3, "column"=>11}], - "fields"=>["query", "field", "arg"], + "path"=>["query", "field", "arg"], + "extensions"=>{"code"=>"argumentLiteralsIncompatible", "typeName"=>"Field", "argumentName"=>"arg"} }], errors end end @@ -187,7 +199,8 @@ assert_equal [{ "message"=>"Argument 'arg' on Field 'field' has an invalid value. Expected type '[Int!]'.", "locations"=>[{"line"=>3, "column"=>11}], - "fields"=>["query", "field", "arg"], + "path"=>["query", "field", "arg"], + "extensions"=>{"code"=>"argumentLiteralsIncompatible", "typeName"=>"Field", "argumentName"=>"arg"} }], errors end end @@ -234,32 +247,36 @@ it "works with error bubbling disabled" do without_error_bubbling(schema) do assert_equal 1, errors.length - refute_includes errors, {"message"=> - "Argument 'arg' on Field 'field' has an invalid value. Expected type 'Input'.", - "locations"=>[{"line"=>3, "column"=>11}], - "fields"=>["query", "field", "arg"]} + refute_includes errors, { + "message"=>"Argument 'arg' on Field 'field' has an invalid value. Expected type 'Input'.", + "locations"=>[{"line"=>3, "column"=>11}], + "path"=>["query", "field", "arg"], + "extensions"=>{"code"=>"argumentLiteralsIncompatible", "typeName"=>"Field", "argumentName"=>"arg"} + } assert_includes errors, { "message"=>"Argument 'b' on InputObject 'Input' has an invalid value. Expected type 'Int!'.", "locations"=>[{"line"=>3, "column"=>22}], - "fields"=>["query", "field", "arg", "b"] + "path"=>["query", "field", "arg", "b"], + "extensions"=>{"code"=>"argumentLiteralsIncompatible", "typeName"=>"InputObject", "argumentName"=>"b"} } - end end it "works with error bubbling enabled" do with_error_bubbling(schema) do assert_equal 2, errors.length - assert_includes errors, {"message"=> - "Argument 'arg' on Field 'field' has an invalid value. Expected type 'Input'.", - "locations"=>[{"line"=>3, "column"=>11}], - "fields"=>["query", "field", "arg"]} + assert_includes errors, { + "message"=>"Argument 'arg' on Field 'field' has an invalid value. Expected type 'Input'.", + "locations"=>[{"line"=>3, "column"=>11}], + "path"=>["query", "field", "arg"], + "extensions"=>{"code"=>"argumentLiteralsIncompatible", "typeName"=>"Field", "argumentName"=>"arg"} + } assert_includes errors, { "message"=>"Argument 'b' on InputObject 'Input' has an invalid value. Expected type 'Int!'.", "locations"=>[{"line"=>3, "column"=>22}], - "fields"=>["query", "field", "arg", "b"] + "path"=>["query", "field", "arg", "b"], + "extensions"=>{"code"=>"argumentLiteralsIncompatible", "typeName"=>"InputObject", "argumentName"=>"b"} } - end end end @@ -277,7 +294,8 @@ assert_includes(errors, { "message"=>"Argument 'name' on Field '__type' has an invalid value. Expected type 'String!'.", "locations"=>[{"line"=>3, "column"=>9}], - "fields"=>["query", "__type", "name"], + "path"=>["query", "__type", "name"], + "extensions"=>{"code"=>"argumentLiteralsIncompatible", "typeName"=>"Field", "argumentName"=>"name"} }) end end @@ -337,7 +355,8 @@ assert_includes errors, { "message"=> "cannot coerce to Float", "locations"=>[{"line"=>3, "column"=>9}], - "fields"=>["query", "time", "value"] + "path"=>["query", "time", "value"], + "extensions"=>{"code"=>"argumentLiteralsIncompatible", "typeName"=>"CoercionError"} } end end @@ -352,19 +371,22 @@ from_error = { "message"=>"cannot coerce to Float", "locations"=>[{"line"=>3, "column"=>23}], - "fields"=>["query", "time", "range", "from"] + "path"=>["query", "time", "range", "from"], + "extensions"=>{"code"=>"argumentLiteralsIncompatible", "typeName"=>"CoercionError"}, } to_error = { "message"=>"cannot coerce to Float", "locations"=>[{"line"=>3, "column"=>23}], - "fields"=>["query", "time", "range", "to"] + "path"=>["query", "time", "range", "to"], + "extensions"=>{"code"=>"argumentLiteralsIncompatible", "typeName"=>"CoercionError"}, } bubbling_error = { "message"=>"cannot coerce to Float", "locations"=>[{"line"=>3, "column"=>11}], - "fields"=>["query", "time", "range"] + "path"=>["query", "time", "range"], + "extensions"=>{"code"=>"argumentLiteralsIncompatible", "typeName"=>"CoercionError"}, } describe "sets deep error message from a CoercionError if raised" do diff --git a/spec/graphql/static_validation/rules/argument_names_are_unique_spec.rb b/spec/graphql/static_validation/rules/argument_names_are_unique_spec.rb index efa442c0d9..0b79882f73 100644 --- a/spec/graphql/static_validation/rules/argument_names_are_unique_spec.rb +++ b/spec/graphql/static_validation/rules/argument_names_are_unique_spec.rb @@ -19,7 +19,7 @@ error = errors.first assert_equal 'There can be only one argument named "id"', error["message"] assert_equal [{ "line" => 2, "column" => 18}, { "line" => 2, "column" => 25 }], error["locations"] - assert_equal ["query GetStuff", "c1"], error["fields"] + assert_equal ["query GetStuff", "c1"], error["path"] end end @@ -38,7 +38,7 @@ error = errors.first assert_equal 'There can be only one argument named "if"', error["message"] assert_equal [{ "line" => 2, "column" => 34}, { "line" => 2, "column" => 44 }], error["locations"] - assert_equal ["query GetStuff", "c1"], error["fields"] + assert_equal ["query GetStuff", "c1"], error["path"] end end end diff --git a/spec/graphql/static_validation/rules/arguments_are_defined_spec.rb b/spec/graphql/static_validation/rules/arguments_are_defined_spec.rb index 9f4fe9f323..4b7a03f4ca 100644 --- a/spec/graphql/static_validation/rules/arguments_are_defined_spec.rb +++ b/spec/graphql/static_validation/rules/arguments_are_defined_spec.rb @@ -18,7 +18,6 @@ } "} - describe "finds undefined arguments to fields and directives" do it "works with error bubbling" do with_error_bubbling(Dummy::Schema) do @@ -29,28 +28,52 @@ query_root_error = { "message"=>"Field 'cheese' doesn't accept argument 'silly'", "locations"=>[{"line"=>4, "column"=>14}], - "fields"=>["query getCheese", "cheese", "silly"], + "path"=>["query getCheese", "cheese", "silly"], + "extensions"=>{ + "code"=>"argumentNotAccepted", + "name"=>"cheese", + "typeName"=>"Field", + "argumentName"=>"silly" + }, } assert_includes(errors, query_root_error) input_obj_record = { "message"=>"InputObject 'DairyProductInput' doesn't accept argument 'wacky'", "locations"=>[{"line"=>5, "column"=>30}], - "fields"=>["query getCheese", "searchDairy", "product", "wacky"], + "path"=>["query getCheese", "searchDairy", "product", "wacky"], + "extensions"=>{ + "code"=>"argumentNotAccepted", + "name"=>"DairyProductInput", + "typeName"=>"InputObject", + "argumentName"=>"wacky" + }, } assert_includes(errors, input_obj_record) fragment_error = { "message"=>"Field 'similarCheese' doesn't accept argument 'nonsense'", "locations"=>[{"line"=>9, "column"=>36}], - "fields"=>["fragment cheeseFields", "similarCheese", "nonsense"], + "path"=>["fragment cheeseFields", "similarCheese", "nonsense"], + "extensions"=>{ + "code"=>"argumentNotAccepted", + "name"=>"similarCheese", + "typeName"=>"Field", + "argumentName"=>"nonsense", + }, } assert_includes(errors, fragment_error) directive_error = { "message"=>"Directive 'skip' doesn't accept argument 'something'", "locations"=>[{"line"=>10, "column"=>16}], - "fields"=>["fragment cheeseFields", "id", "something"], + "path"=>["fragment cheeseFields", "id", "something"], + "extensions"=>{ + "code"=>"argumentNotAccepted", + "name"=>"skip", + "typeName"=>"Directive", + "argumentName"=>"something", + }, } assert_includes(errors, directive_error) end @@ -60,14 +83,14 @@ without_error_bubbling(Dummy::Schema) do assert_equal(5, errors.length) - extra_error = {"message"=> - "Argument 'product' on Field 'searchDairy' has an invalid value. Expected type '[DairyProductInput]'.", - "locations"=>[{"line"=>5, "column"=>7}], - "fields"=>["query getCheese", "searchDairy", "product"]} - refute_includes(errors, extra_error) + extra_error = { + "message"=>"Argument 'product' on Field 'searchDairy' has an invalid value. Expected type '[DairyProductInput]'.", + "locations"=>[{"line"=>5, "column"=>7}], + "path"=>["query getCheese", "searchDairy", "product"] + } + refute_includes(errors, extra_error) end end - end describe "dynamic fields" do @@ -81,7 +104,8 @@ assert_includes(errors, { "message"=>"Field '__type' doesn't accept argument 'somethingInvalid'", "locations"=>[{"line"=>3, "column"=>16}], - "fields"=>["query", "__type", "somethingInvalid"], + "path"=>["query", "__type", "somethingInvalid"], + "extensions"=>{"code"=>"argumentNotAccepted", "name"=>"__type", "typeName"=>"Field", "argumentName"=>"somethingInvalid"} }) end end diff --git a/spec/graphql/static_validation/rules/directives_are_defined_spec.rb b/spec/graphql/static_validation/rules/directives_are_defined_spec.rb index 79f6178a43..fa8899be1e 100644 --- a/spec/graphql/static_validation/rules/directives_are_defined_spec.rb +++ b/spec/graphql/static_validation/rules/directives_are_defined_spec.rb @@ -20,11 +20,13 @@ { "message"=>"Directive @nonsense is not defined", "locations"=>[{"line"=>5, "column"=>16}], - "fields"=>["query getCheese", "okCheese", "source"], + "path"=>["query getCheese", "okCheese", "source"], + "extensions"=>{"code"=>"undefinedDirective", "directiveName"=>"nonsense"} }, { "message"=>"Directive @moreNonsense is not defined", "locations"=>[{"line"=>7, "column"=>18}], - "fields"=>["query getCheese", "okCheese", "... on Cheese", "flavor"], + "path"=>["query getCheese", "okCheese", "... on Cheese", "flavor"], + "extensions"=>{"code"=>"undefinedDirective", "directiveName"=>"moreNonsense"} } ] assert_equal(expected, errors) diff --git a/spec/graphql/static_validation/rules/directives_are_in_valid_locations_spec.rb b/spec/graphql/static_validation/rules/directives_are_in_valid_locations_spec.rb index 076627a347..50a01970f9 100644 --- a/spec/graphql/static_validation/rules/directives_are_in_valid_locations_spec.rb +++ b/spec/graphql/static_validation/rules/directives_are_in_valid_locations_spec.rb @@ -26,12 +26,14 @@ { "message"=> "'@skip' can't be applied to queries (allowed: fields, fragment spreads, inline fragments)", "locations"=>[{"line"=>2, "column"=>21}], - "fields"=>["query getCheese"], + "path"=>["query getCheese"], + "extensions"=>{"code"=>"directiveCannotBeApplied", "targetName"=>"queries", "name"=>"skip"} }, { "message"=>"'@skip' can't be applied to fragment definitions (allowed: fields, fragment spreads, inline fragments)", "locations"=>[{"line"=>13, "column"=>33}], - "fields"=>["fragment whatever"], + "path"=>["fragment whatever"], + "extensions"=>{"code"=>"directiveCannotBeApplied", "targetName"=>"fragment definitions", "name"=>"skip"} }, ] assert_equal(expected, errors) diff --git a/spec/graphql/static_validation/rules/fields_are_defined_on_type_spec.rb b/spec/graphql/static_validation/rules/fields_are_defined_on_type_spec.rb index 4fb4360ed8..4f02b93067 100644 --- a/spec/graphql/static_validation/rules/fields_are_defined_on_type_spec.rb +++ b/spec/graphql/static_validation/rules/fields_are_defined_on_type_spec.rb @@ -31,7 +31,8 @@ { "message"=>"Field 'notDefinedField' doesn't exist on type 'Query'", "locations"=>[{"line"=>1, "column"=>18}], - "fields"=>["query getStuff", "notDefinedField"], + "path"=>["query getStuff", "notDefinedField"], + "extensions"=>{"code"=>"undefinedField", "typeName"=>"Query", "fieldName"=>"notDefinedField"} } ] assert_equal(expected_errors, errors) @@ -46,7 +47,8 @@ { "message"=>"Field 'amountThatILikeIt' doesn't exist on type 'Edible'", "locations"=>[{"line"=>1, "column"=>35}], - "fields"=>["query getStuff", "favoriteEdible", "amountThatILikeIt"], + "path"=>["query getStuff", "favoriteEdible", "amountThatILikeIt"], + "extensions"=>{"code"=>"undefinedField", "typeName"=>"Edible", "fieldName"=>"amountThatILikeIt"} } ] assert_equal(expected_errors, errors) @@ -67,7 +69,8 @@ "locations"=>[ {"line"=>3, "column"=>7} ], - "fields"=>["fragment dpFields", "source"], + "path"=>["fragment dpFields", "source"], + "extensions"=>{"code"=>"selectionMismatch", "nodeName"=>"DairyProduct"} } ] assert_equal(expected_errors, errors) @@ -120,7 +123,8 @@ "locations"=>[ {"line"=>2, "column"=>33} ], - "fields"=>["query", "cheese", "__schema"], + "path"=>["query", "cheese", "__schema"], + "extensions"=>{"code"=>"undefinedField", "typeName"=>"Cheese", "fieldName"=>"__schema"} } ] assert_equal(expected_errors, errors) @@ -151,7 +155,8 @@ "locations"=>[ {"line"=>2, "column"=>33} ], - "fields"=>["query", "cheese", "__type"], + "path"=>["query", "cheese", "__type"], + "extensions"=>{"code"=>"undefinedField", "typeName"=>"Cheese", "fieldName"=>"__type"} } ] assert_equal(expected_errors, errors) diff --git a/spec/graphql/static_validation/rules/fields_have_appropriate_selections_spec.rb b/spec/graphql/static_validation/rules/fields_have_appropriate_selections_spec.rb index b718a233c5..a9881ec357 100644 --- a/spec/graphql/static_validation/rules/fields_have_appropriate_selections_spec.rb +++ b/spec/graphql/static_validation/rules/fields_have_appropriate_selections_spec.rb @@ -19,28 +19,32 @@ illegal_selection_error = { "message"=>"Selections can't be made on scalars (field 'id' returns Int but has selections [something, someFields])", "locations"=>[{"line"=>6, "column"=>47}], - "fields"=>["query getCheese", "illegalSelectionCheese", "id"], + "path"=>["query getCheese", "illegalSelectionCheese", "id"], + "extensions"=>{"code"=>"selectionMismatch", "nodeName"=>"field 'id'", "typeName"=>"Int"} } assert_includes(errors, illegal_selection_error, "finds illegal selections on scalars") objects_selection_required_error = { "message"=>"Field must have selections (field 'cheese' returns Cheese but has no selections. Did you mean 'cheese { ... }'?)", "locations"=>[{"line"=>4, "column"=>7}], - "fields"=>["query getCheese", "missingFieldsObject"], + "path"=>["query getCheese", "missingFieldsObject"], + "extensions"=>{"code"=>"selectionMismatch", "nodeName"=>"field 'cheese'", "typeName"=>"Cheese"} } assert_includes(errors, objects_selection_required_error, "finds objects without selections") interfaces_selection_required_error = { "message"=>"Field must have selections (field 'selfAsEdible' returns Edible but has no selections. Did you mean 'selfAsEdible { ... }'?)", "locations"=>[{"line"=>5, "column"=>47}], - "fields"=>["query getCheese", "missingFieldsInterface", "selfAsEdible"], + "path"=>["query getCheese", "missingFieldsInterface", "selfAsEdible"], + "extensions"=>{"code"=>"selectionMismatch", "nodeName"=>"field 'selfAsEdible'", "typeName"=>"Edible"} } assert_includes(errors, interfaces_selection_required_error, "finds interfaces without selections") incorrect_fragment_error = { "message"=>"Selections can't be made on scalars (field 'flavor' returns String but has inline fragments [String])", "locations"=>[{"line"=>7, "column"=>48}], - "fields"=>["query getCheese", "incorrectFragmentSpread", "flavor"], + "path"=>["query getCheese", "incorrectFragmentSpread", "flavor"], + "extensions"=>{"code"=>"selectionMismatch", "nodeName"=>"field 'flavor'", "typeName"=>"String"} } assert_includes(errors, incorrect_fragment_error, "finds scalar fields with selections") end @@ -53,7 +57,8 @@ selections_required_error = { "message"=> "Field must have selections (anonymous query returns Query but has no selections. Did you mean ' { ... }'?)", "locations"=>[{"line"=>1, "column"=>1}], - "fields"=>["query"] + "path"=>["query"], + "extensions"=>{"code"=>"selectionMismatch", "nodeName"=>"anonymous query", "typeName"=>"Query"} } assert_includes(errors, selections_required_error) end diff --git a/spec/graphql/static_validation/rules/fields_will_merge_spec.rb b/spec/graphql/static_validation/rules/fields_will_merge_spec.rb index c625c6ba90..327ab63a92 100644 --- a/spec/graphql/static_validation/rules/fields_will_merge_spec.rb +++ b/spec/graphql/static_validation/rules/fields_will_merge_spec.rb @@ -39,13 +39,18 @@ species: PetSpecies! } + interface Mammal { + name(surname: Boolean = false): String! + nickname: String + } + interface Pet { name(surname: Boolean = false): String! nickname: String toys: [Toy!]! } - type Dog implements Pet { + type Dog implements Pet & Mammal { name(surname: Boolean = false): String! nickname: String doesKnowCommand(dogCommand: PetCommand): Boolean! @@ -53,7 +58,7 @@ toys: [Toy!]! } - type Cat implements Pet { + type Cat implements Pet & Mammal { name(surname: Boolean = false): String! nickname: String doesKnowCommand(catCommand: PetCommand): Boolean! @@ -348,12 +353,13 @@ it "fails rule" do assert_equal [ - "Field 'name' has a field conflict: name or nickname?", "Field 'x' has a field conflict: name or nickname?", + "Field 'name' has a field conflict: name or nickname?" ], error_messages end end + describe "deep conflict" do let(:query_string) {%| { @@ -375,7 +381,8 @@ {"line"=>4, "column"=>11}, {"line"=>8, "column"=>11} ], - "fields"=>[] + "path"=>[], + "extensions"=>{"code"=>"fieldConflict", "fieldName"=>"x", "conflicts"=>"name or nickname"} } ] assert_equal expected_errors, errors @@ -429,6 +436,7 @@ end end + describe "same aliases allowed on non-overlapping fields" do let(:query_string) {%| { @@ -448,6 +456,53 @@ end end + describe "same aliases not allowed on different interfaces" do + let(:query_string) {%| + { + pet { + ... on Pet { + name + } + ... on Mammal { + name: nickname + } + } + } + |} + + it "fails rule" do + assert_equal [ + "Field 'name' has a field conflict: name or nickname?", + ], error_messages + end + end + + describe "same aliases allowed on different parent interfaces and different concrete types" do + let(:query_string) {%| + { + pet { + ... on Pet { + ...X + } + ... on Mammal { + ...Y + } + } + } + + fragment X on Dog { + name + } + fragment Y on Cat { + name: nickname + } + |} + + it "passes rule" do + assert_equal [], errors + end + end + describe "allows different args where no conflict is possible" do let(:query_string) {%| { @@ -473,6 +528,77 @@ it "passes rule" do assert_equal [], errors end + + describe "allows different args where no conflict is possible" do + let(:query_string) {%| + { + pet { + ... on Dog { + ... on Pet { + name + } + } + ... on Cat { + name(surname: true) + } + } + } + |} + + it "passes rule" do + assert_equal [], errors + end + end + + describe "allows different args where no conflict is possible with uneven abstract scoping" do + let(:query_string) {%| + { + pet { + ... on Pet { + ... on Dog { + name + } + } + ... on Cat { + name(surname: true) + } + } + } + |} + + it "passes rule" do + assert_equal [], errors + end + end + end + + describe "allows different args where no conflict is possible deep" do + let(:query_string) {%| + { + pet { + ... on Dog { + ...X + } + } + pet { + ... on Cat { + ...Y + } + } + } + + fragment X on Pet { + name(surname: true) + } + + fragment Y on Pet { + name + } + |} + + it "passes rule" do + assert_equal [], errors + end end describe "return types must be unambiguous" do @@ -599,7 +725,7 @@ } } fragment X on SomeBox { - scalar: deepBox { unreleatedField } + scalar: deepBox { unrelatedField } } fragment Y on SomeBox { scalar: unrelatedField diff --git a/spec/graphql/static_validation/rules/fragment_names_are_unique_spec.rb b/spec/graphql/static_validation/rules/fragment_names_are_unique_spec.rb index 331d0b13a1..9ec4f659ba 100644 --- a/spec/graphql/static_validation/rules/fragment_names_are_unique_spec.rb +++ b/spec/graphql/static_validation/rules/fragment_names_are_unique_spec.rb @@ -20,7 +20,8 @@ fragment_def_error = { "message"=>"Fragment name \"frag1\" must be unique", "locations"=>[{"line"=>8, "column"=>5}, {"line"=>9, "column"=>5}], - "fields"=>[], + "path"=>[], + "extensions"=>{"code"=>"fragmentNotUnique", "fragmentName"=>"frag1"} } assert_includes(errors, fragment_def_error) end diff --git a/spec/graphql/static_validation/rules/fragment_spreads_are_possible_spec.rb b/spec/graphql/static_validation/rules/fragment_spreads_are_possible_spec.rb index 1b964b35a7..7e5bc2867f 100644 --- a/spec/graphql/static_validation/rules/fragment_spreads_are_possible_spec.rb +++ b/spec/graphql/static_validation/rules/fragment_spreads_are_possible_spec.rb @@ -31,17 +31,20 @@ { "message"=>"Fragment on Milk can't be spread inside Cheese", "locations"=>[{"line"=>6, "column"=>9}], - "fields"=>["query getCheese", "cheese", "... on Milk"], + "path"=>["query getCheese", "cheese", "... on Milk"], + "extensions"=>{"code"=>"cannotSpreadFragment", "typeName"=>"Milk", "fragmentName"=>"unknown", "parentName"=>"Cheese"} }, { "message"=>"Fragment milkFields on Milk can't be spread inside Cheese", "locations"=>[{"line"=>4, "column"=>9}], - "fields"=>["query getCheese", "cheese", "... milkFields"], + "path"=>["query getCheese", "cheese", "... milkFields"], + "extensions"=>{"code"=>"cannotSpreadFragment", "typeName"=>"Milk", "fragmentName"=>" milkFields", "parentName"=>"Cheese"} }, { "message"=>"Fragment milkFields on Milk can't be spread inside Cheese", "locations"=>[{"line"=>18, "column"=>7}], - "fields"=>["fragment cheeseFields", "... milkFields"], + "path"=>["fragment cheeseFields", "... milkFields"], + "extensions"=>{"code"=>"cannotSpreadFragment", "typeName"=>"Milk", "fragmentName"=>" milkFields", "parentName"=>"Cheese"} } ] assert_equal(expected, errors) diff --git a/spec/graphql/static_validation/rules/fragment_types_exist_spec.rb b/spec/graphql/static_validation/rules/fragment_types_exist_spec.rb index 029dbc8214..d895b27544 100644 --- a/spec/graphql/static_validation/rules/fragment_types_exist_spec.rb +++ b/spec/graphql/static_validation/rules/fragment_types_exist_spec.rb @@ -27,13 +27,15 @@ inline_fragment_error = { "message"=>"No such type Something, so it can't be a fragment condition", "locations"=>[{"line"=>11, "column"=>5}], - "fields"=>["fragment somethingFields"], + "path"=>["fragment somethingFields"], + "extensions"=>{"code"=>"undefinedType", "typeName"=>"Something"} } assert_includes(errors, inline_fragment_error, "on inline fragments") fragment_def_error = { "message"=>"No such type Nothing, so it can't be a fragment condition", "locations"=>[{"line"=>5, "column"=>9}], - "fields"=>["query getCheese", "cheese", "... on Nothing"], + "path"=>["query getCheese", "cheese", "... on Nothing"], + "extensions"=>{"code"=>"undefinedType", "typeName"=>"Nothing"} } assert_includes(errors, fragment_def_error, "on fragment definitions") end diff --git a/spec/graphql/static_validation/rules/fragments_are_finite_spec.rb b/spec/graphql/static_validation/rules/fragments_are_finite_spec.rb index 4a72dc9650..538742f17c 100644 --- a/spec/graphql/static_validation/rules/fragments_are_finite_spec.rb +++ b/spec/graphql/static_validation/rules/fragments_are_finite_spec.rb @@ -38,12 +38,14 @@ { "message"=>"Fragment sourceField contains an infinite loop", "locations"=>[{"line"=>12, "column"=>5}], - "fields"=>["fragment sourceField"], + "path"=>["fragment sourceField"], + "extensions"=>{"code"=>"infiniteLoop", "fragmentName"=>"sourceField"} }, { "message"=>"Fragment flavorField contains an infinite loop", "locations"=>[{"line"=>17, "column"=>5}], - "fields"=>["fragment flavorField"], + "path"=>["fragment flavorField"], + "extensions"=>{"code"=>"infiniteLoop", "fragmentName"=>"flavorField"} } ] assert_equal(expected, errors) diff --git a/spec/graphql/static_validation/rules/fragments_are_named_spec.rb b/spec/graphql/static_validation/rules/fragments_are_named_spec.rb index e86b09f156..23fdb5d682 100644 --- a/spec/graphql/static_validation/rules/fragments_are_named_spec.rb +++ b/spec/graphql/static_validation/rules/fragments_are_named_spec.rb @@ -16,7 +16,8 @@ fragment_def_error = { "message"=>"Fragment definition has no name", "locations"=>[{"line"=>2, "column"=>5}], - "fields"=>["fragment "], + "path"=>["fragment "], + "extensions"=>{"code"=>"anonymousFragment"} } assert_includes(errors, fragment_def_error, "on fragment definitions") end diff --git a/spec/graphql/static_validation/rules/fragments_are_on_composite_types_spec.rb b/spec/graphql/static_validation/rules/fragments_are_on_composite_types_spec.rb index 33f428f3c2..3fa06a0205 100644 --- a/spec/graphql/static_validation/rules/fragments_are_on_composite_types_spec.rb +++ b/spec/graphql/static_validation/rules/fragments_are_on_composite_types_spec.rb @@ -35,17 +35,20 @@ { "message"=>"Invalid fragment on type Boolean (must be Union, Interface or Object)", "locations"=>[{"line"=>6, "column"=>11}], - "fields"=>["query getCheese", "cheese", "... on Cheese", "... on Boolean"], + "path"=>["query getCheese", "cheese", "... on Cheese", "... on Boolean"], + "extensions"=>{"code"=>"fragmentOnNonCompositeType", "typeName"=>"Boolean"} }, { "message"=>"Invalid fragment on type DairyProductInput (must be Union, Interface or Object)", "locations"=>[{"line"=>16, "column"=>9}], - "fields"=>["query getCheese", "cheese", "... on DairyProductInput"], + "path"=>["query getCheese", "cheese", "... on DairyProductInput"], + "extensions"=>{"code"=>"fragmentOnNonCompositeType", "typeName"=>"DairyProductInput"} }, { "message"=>"Invalid fragment on type Int (must be Union, Interface or Object)", "locations"=>[{"line"=>22, "column"=>5}], - "fields"=>["fragment intFields"], + "path"=>["fragment intFields"], + "extensions"=>{"code"=>"fragmentOnNonCompositeType", "typeName"=>"Int"} }, ] assert_equal(expected, errors) diff --git a/spec/graphql/static_validation/rules/fragments_are_used_spec.rb b/spec/graphql/static_validation/rules/fragments_are_used_spec.rb index de212e48b6..e6c8ee5fb4 100644 --- a/spec/graphql/static_validation/rules/fragments_are_used_spec.rb +++ b/spec/graphql/static_validation/rules/fragments_are_used_spec.rb @@ -17,7 +17,8 @@ assert_includes(errors, { "message"=>"Fragment unusedFields was defined, but not used", "locations"=>[{"line"=>8, "column"=>5}], - "fields"=>["fragment unusedFields"], + "path"=>["fragment unusedFields"], + "extensions"=>{"code"=>"useAndDefineFragment", "fragmentName"=>"unusedFields"} }) end @@ -25,7 +26,8 @@ assert_includes(errors, { "message"=>"Fragment undefinedFields was used, but not defined", "locations"=>[{"line"=>5, "column"=>7}], - "fields"=>["query getCheese", "... undefinedFields"] + "path"=>["query getCheese", "... undefinedFields"], + "extensions"=>{"code"=>"useAndDefineFragment", "fragmentName"=>"undefinedFields"} }) end @@ -38,4 +40,22 @@ assert_equal({}, result) end end + + describe "invalid unused fragments" do + let(:query_string) {" + query getCheese { + name + } + fragment Invalid on DoesNotExist { fatContent } + "} + + it "handles them gracefully" do + assert_includes(errors, { + "message"=>"No such type DoesNotExist, so it can't be a fragment condition", + "locations"=>[{"line"=>5, "column"=>7}], + "path"=>["fragment Invalid"], + "extensions"=>{"code"=>"undefinedType", "typeName"=>"DoesNotExist"} + }) + end + end end diff --git a/spec/graphql/static_validation/rules/mutation_root_exists_spec.rb b/spec/graphql/static_validation/rules/mutation_root_exists_spec.rb index 367518ceb2..20eea54953 100644 --- a/spec/graphql/static_validation/rules/mutation_root_exists_spec.rb +++ b/spec/graphql/static_validation/rules/mutation_root_exists_spec.rb @@ -31,7 +31,8 @@ missing_mutation_root_error = { "message"=>"Schema is not configured for mutations", "locations"=>[{"line"=>2, "column"=>5}], - "fields"=>["mutation addBagel"], + "path"=>["mutation addBagel"], + "extensions"=>{"code"=>"missingMutationConfiguration"} } assert_includes(errors, missing_mutation_root_error) end diff --git a/spec/graphql/static_validation/rules/operation_names_are_valid_spec.rb b/spec/graphql/static_validation/rules/operation_names_are_valid_spec.rb index 62c0ab7790..0db7751d01 100644 --- a/spec/graphql/static_validation/rules/operation_names_are_valid_spec.rb +++ b/spec/graphql/static_validation/rules/operation_names_are_valid_spec.rb @@ -25,7 +25,8 @@ requires_name_error = { "message"=>"Operation name is required when multiple operations are present", "locations"=>[{"line"=>5, "column"=>5}, {"line"=>9, "column"=>5}], - "fields"=>[], + "path"=>[], + "extensions"=>{"code"=>"uniquelyNamedOperations"} } assert_includes(errors, requires_name_error) end @@ -48,7 +49,8 @@ requires_name_error = { "message"=>"Operation name is required when multiple operations are present", "locations"=>[{"line"=>1, "column"=>5}, {"line"=>5, "column"=>5}], - "fields"=>[], + "path"=>[], + "extensions"=>{"code"=>"uniquelyNamedOperations"} } assert_includes(errors, requires_name_error) end @@ -71,7 +73,8 @@ name_uniqueness_error = { "message"=>'Operation name "getCheese" must be unique', "locations"=>[{"line"=>1, "column"=>5}, {"line"=>5, "column"=>5}], - "fields"=>[], + "path"=>[], + "extensions"=>{"code"=>"uniquelyNamedOperations", "operationName"=>"getCheese"} } assert_includes(errors, name_uniqueness_error) end diff --git a/spec/graphql/static_validation/rules/required_arguments_are_present_spec.rb b/spec/graphql/static_validation/rules/required_arguments_are_present_spec.rb index 9efadd51ca..4d34cc0685 100644 --- a/spec/graphql/static_validation/rules/required_arguments_are_present_spec.rb +++ b/spec/graphql/static_validation/rules/required_arguments_are_present_spec.rb @@ -22,21 +22,24 @@ query_root_error = { "message"=>"Field 'cheese' is missing required arguments: id", "locations"=>[{"line"=>4, "column"=>7}], - "fields"=>["query getCheese", "cheese"], + "path"=>["query getCheese", "cheese"], + "extensions"=>{"code"=>"missingRequiredArguments", "className"=>"Field", "name"=>"cheese", "arguments"=>"id"} } assert_includes(errors, query_root_error) fragment_error = { "message"=>"Field 'similarCheese' is missing required arguments: source", "locations"=>[{"line"=>8, "column"=>7}], - "fields"=>["fragment cheeseFields", "similarCheese"], + "path"=>["fragment cheeseFields", "similarCheese"], + "extensions"=>{"code"=>"missingRequiredArguments", "className"=>"Field", "name"=>"similarCheese", "arguments"=>"source"} } assert_includes(errors, fragment_error) directive_error = { "message"=>"Directive 'skip' is missing required arguments: if", "locations"=>[{"line"=>10, "column"=>10}], - "fields"=>["fragment cheeseFields", "id"], + "path"=>["fragment cheeseFields", "id"], + "extensions"=>{"code"=>"missingRequiredArguments", "className"=>"Directive", "name"=>"skip", "arguments"=>"if"} } assert_includes(errors, directive_error) end @@ -55,7 +58,13 @@ "locations"=>[ {"line"=>3, "column"=>9} ], - "fields"=>["query", "__type"], + "path"=>["query", "__type"], + "extensions"=>{ + "code"=>"missingRequiredArguments", + "className"=>"Field", + "name"=>"__type", + "arguments"=>"name" + } } ] assert_equal(expected_errors, errors) diff --git a/spec/graphql/static_validation/rules/required_input_object_attributes_are_present_spec.rb b/spec/graphql/static_validation/rules/required_input_object_attributes_are_present_spec.rb index 3e10cc6b56..17666b0962 100644 --- a/spec/graphql/static_validation/rules/required_input_object_attributes_are_present_spec.rb +++ b/spec/graphql/static_validation/rules/required_input_object_attributes_are_present_spec.rb @@ -24,12 +24,24 @@ missing_required_field_error = { "message"=>"Argument 'product' on Field 'missingSource' has an invalid value. Expected type '[DairyProductInput]'.", "locations"=>[{"line"=>7, "column"=>7}], - "fields"=>["query getCheese", "missingSource", "product"], + "path"=>["query getCheese", "missingSource", "product"], + "extensions"=>{ + "code"=>"argumentLiteralsIncompatible", + "typeName"=>"Field", + "argumentName"=>"product", + }, + } + missing_source_error = { + "message"=>"Argument 'source' on InputObject 'DairyProductInput' is required. Expected type DairyAnimal!", + "locations"=>[{"line"=>7, "column"=>44}], + "path"=>["query getCheese", "missingSource", "product", "source"], + "extensions"=>{ + "code"=>"missingRequiredInputObjectAttribute", + "argumentName"=>"source", + "argumentType"=>"DairyAnimal!", + "inputObjectType"=>"DairyProductInput" + } } - missing_source_error = {"message"=> - "Argument 'source' on InputObject 'DairyProductInput' is required. Expected type DairyAnimal!", - "locations"=>[{"line"=>7, "column"=>44}], - "fields"=>["query getCheese", "missingSource", "product", "source"]} it "finds undefined or missing-required arguments to fields and directives" do without_error_bubbling(schema) do assert_includes(errors, missing_source_error) diff --git a/spec/graphql/static_validation/rules/subscription_root_exists_spec.rb b/spec/graphql/static_validation/rules/subscription_root_exists_spec.rb index 5f160c40a4..cd008544f5 100644 --- a/spec/graphql/static_validation/rules/subscription_root_exists_spec.rb +++ b/spec/graphql/static_validation/rules/subscription_root_exists_spec.rb @@ -26,7 +26,8 @@ missing_subscription_root_error = { "message"=>"Schema is not configured for subscriptions", "locations"=>[{"line"=>2, "column"=>5}], - "fields"=>["subscription"], + "path"=>["subscription"], + "extensions"=>{"code"=>"missingSubscriptionConfiguration"} } assert_includes(errors, missing_subscription_root_error) end diff --git a/spec/graphql/static_validation/rules/unique_directives_per_location_spec.rb b/spec/graphql/static_validation/rules/unique_directives_per_location_spec.rb index e4baddece7..c4504538ca 100644 --- a/spec/graphql/static_validation/rules/unique_directives_per_location_spec.rb +++ b/spec/graphql/static_validation/rules/unique_directives_per_location_spec.rb @@ -101,7 +101,8 @@ assert_includes errors, { "message" => 'The directive "A" can only be used once at this location.', "locations" => [{ "line" => 4, "column" => 17 }, { "line" => 4, "column" => 20 }], - "fields" => ["query", "type", "field"], + "path" => ["query", "type", "field"], + "extensions" => {"code"=>"directiveNotUniqueForLocation", "directiveName"=>"A"} } end end @@ -120,13 +121,15 @@ assert_includes errors, { "message" => 'The directive "A" can only be used once at this location.', "locations" => [{ "line" => 4, "column" => 17 }, { "line" => 4, "column" => 20 }], - "fields" => ["query", "type", "field"], + "path" => ["query", "type", "field"], + "extensions" => {"code"=>"directiveNotUniqueForLocation", "directiveName"=>"A"} } assert_includes errors, { "message" => 'The directive "A" can only be used once at this location.', "locations" => [{ "line" => 4, "column" => 17 }, { "line" => 4, "column" => 23 }], - "fields" => ["query", "type", "field"], + "path" => ["query", "type", "field"], + "extensions" => {"code"=>"directiveNotUniqueForLocation", "directiveName"=>"A"} } end end @@ -144,13 +147,15 @@ assert_includes errors, { "message" => 'The directive "A" can only be used once at this location.', "locations" => [{ "line" => 4, "column" => 17 }, { "line" => 4, "column" => 23 }], - "fields" => ["query", "type", "field"], + "path" => ["query", "type", "field"], + "extensions" => {"code"=>"directiveNotUniqueForLocation", "directiveName"=>"A"} } assert_includes errors, { "message" => 'The directive "B" can only be used once at this location.', "locations" => [{ "line" => 4, "column" => 20 }, { "line" => 4, "column" => 26 }], - "fields" => ["query", "type", "field"], + "path" => ["query", "type", "field"], + "extensions" => {"code"=>"directiveNotUniqueForLocation", "directiveName"=>"B"} } end end @@ -168,13 +173,15 @@ assert_includes errors, { "message" => 'The directive "A" can only be used once at this location.', "locations" => [{ "line" => 3, "column" => 14 }, { "line" => 3, "column" => 17 }], - "fields" => ["query", "type"], + "path" => ["query", "type"], + "extensions" => {"code"=>"directiveNotUniqueForLocation", "directiveName"=>"A"} } assert_includes errors, { "message" => 'The directive "A" can only be used once at this location.', "locations" => [{ "line" => 4, "column" => 17 }, { "line" => 4, "column" => 20 }], - "fields" => ["query", "type", "field"], + "path" => ["query", "type", "field"], + "extensions" => {"code"=>"directiveNotUniqueForLocation", "directiveName"=>"A"} } end end diff --git a/spec/graphql/static_validation/rules/variable_default_values_are_correctly_typed_spec.rb b/spec/graphql/static_validation/rules/variable_default_values_are_correctly_typed_spec.rb index c70b72a85a..99f08cc19f 100644 --- a/spec/graphql/static_validation/rules/variable_default_values_are_correctly_typed_spec.rb +++ b/spec/graphql/static_validation/rules/variable_default_values_are_correctly_typed_spec.rb @@ -29,17 +29,20 @@ { "message"=>"Default value for $badInt doesn't match type Int", "locations"=>[{"line"=>6, "column"=>7}], - "fields"=>["query getCheese"], + "path"=>["query getCheese"], + "extensions"=>{"code"=>"defaultValueInvalidType", "variableName"=>"badInt", "typeName"=>"Int"} }, { "message"=>"Default value for $badInput doesn't match type DairyProductInput", "locations"=>[{"line"=>8, "column"=>7}], - "fields"=>["query getCheese"], + "path"=>["query getCheese"], + "extensions"=>{"code"=>"defaultValueInvalidType", "variableName"=>"badInput", "typeName"=>"DairyProductInput"} }, { "message"=>"Non-null variable $nonNull can't have a default value", "locations"=>[{"line"=>9, "column"=>7}], - "fields"=>["query getCheese"], + "path"=>["query getCheese"], + "extensions"=>{"code"=>"defaultValueInvalidOnNonNullVariable", "variableName"=>"nonNull"} } ] assert_equal(expected, errors) @@ -116,17 +119,20 @@ { "message"=>"Non-null variable $a can't have a default value", "locations"=>[{"line"=>3, "column"=>11}], - "fields"=>["query getCheese"] + "path"=>["query getCheese"], + "extensions"=>{"code"=>"defaultValueInvalidOnNonNullVariable", "variableName"=>"a"} }, { "message"=>"Non-null variable $b can't have a default value", "locations"=>[{"line"=>4, "column"=>11}], - "fields"=>["query getCheese"] + "path"=>["query getCheese"], + "extensions"=>{"code"=>"defaultValueInvalidOnNonNullVariable", "variableName"=>"b"} }, { "message"=>"Default value for $c doesn't match type ComplexInput", "locations"=>[{"line"=>5, "column"=>11}], - "fields"=>["query getCheese"] + "path"=>["query getCheese"], + "extensions"=>{"code"=>"defaultValueInvalidType", "variableName"=>"c", "typeName"=>"ComplexInput"} } ] @@ -182,7 +188,8 @@ assert_includes errors, { "message"=> "cannot coerce to Float", "locations"=>[{"line"=>3, "column"=>9}], - "fields"=>["query"] + "path"=>["query"], + "extensions"=>{"code"=>"defaultValueInvalidType", "variableName"=>"value", "typeName"=>"Time"} } end end diff --git a/spec/graphql/static_validation/rules/variable_usages_are_allowed_spec.rb b/spec/graphql/static_validation/rules/variable_usages_are_allowed_spec.rb index e357275dee..aeb9db7384 100644 --- a/spec/graphql/static_validation/rules/variable_usages_are_allowed_spec.rb +++ b/spec/graphql/static_validation/rules/variable_usages_are_allowed_spec.rb @@ -43,22 +43,26 @@ { "message"=>"Nullability mismatch on variable $badInt and argument id (Int / Int!)", "locations"=>[{"line"=>14, "column"=>28}], - "fields"=>["query getCheese", "badCheese", "id"], + "path"=>["query getCheese", "badCheese", "id"], + "extensions"=>{"code"=>"variableMismatch", "variableName"=>"badInt", "typeName"=>"Int", "argumentName"=>"id", "errorMessage"=>"Nullability mismatch"} }, { "message"=>"Type mismatch on variable $badStr and argument id (String! / Int!)", "locations"=>[{"line"=>15, "column"=>28}], - "fields"=>["query getCheese", "badStrCheese", "id"], + "path"=>["query getCheese", "badStrCheese", "id"], + "extensions"=>{"code"=>"variableMismatch", "variableName"=>"badStr", "typeName"=>"String!", "argumentName"=>"id", "errorMessage"=>"Type mismatch"} }, { "message"=>"Nullability mismatch on variable $badAnimals and argument source ([DairyAnimal]! / [DairyAnimal!]!)", "locations"=>[{"line"=>18, "column"=>30}], - "fields"=>["query getCheese", "cheese", "other", "source"], + "path"=>["query getCheese", "cheese", "other", "source"], + "extensions"=>{"code"=>"variableMismatch", "variableName"=>"badAnimals", "typeName"=>"[DairyAnimal]!", "argumentName"=>"source", "errorMessage"=>"Nullability mismatch"} }, { "message"=>"List dimension mismatch on variable $deepAnimals and argument source ([[DairyAnimal!]!]! / [DairyAnimal!]!)", "locations"=>[{"line"=>19, "column"=>32}], - "fields"=>["query getCheese", "cheese", "tooDeep", "source"], + "path"=>["query getCheese", "cheese", "tooDeep", "source"], + "extensions"=>{"code"=>"variableMismatch", "variableName"=>"deepAnimals", "typeName"=>"[[DairyAnimal!]!]!", "argumentName"=>"source", "errorMessage"=>"List dimension mismatch"} } ] assert_equal(expected, errors) diff --git a/spec/graphql/static_validation/rules/variables_are_input_types_spec.rb b/spec/graphql/static_validation/rules/variables_are_input_types_spec.rb index a084910435..7c90e80bb7 100644 --- a/spec/graphql/static_validation/rules/variables_are_input_types_spec.rb +++ b/spec/graphql/static_validation/rules/variables_are_input_types_spec.rb @@ -22,25 +22,29 @@ assert_includes(errors, { "message"=>"AnimalProduct isn't a valid input type (on $interface)", "locations"=>[{"line"=>5, "column"=>7}], - "fields"=>["query getCheese"], + "path"=>["query getCheese"], + "extensions"=> {"code"=>"variableRequiresValidType", "typeName"=>"AnimalProduct", "variableName"=>"interface"} }) assert_includes(errors, { "message"=>"Milk isn't a valid input type (on $object)", "locations"=>[{"line"=>6, "column"=>7}], - "fields"=>["query getCheese"], + "path"=>["query getCheese"], + "extensions"=>{"code"=>"variableRequiresValidType", "typeName"=>"Milk", "variableName"=>"object"} }) assert_includes(errors, { "message"=>"Cheese isn't a valid input type (on $objects)", "locations"=>[{"line"=>7, "column"=>7}], - "fields"=>["query getCheese"], + "path"=>["query getCheese"], + "extensions"=>{"code"=>"variableRequiresValidType", "typeName"=>"Cheese", "variableName"=>"objects"} }) assert_includes(errors, { "message"=>"Nonsense isn't a defined input type (on $unknownType)", "locations"=>[{"line"=>8, "column"=>7}], - "fields"=>["query getCheese"], + "path"=>["query getCheese"], + "extensions"=>{"code"=>"variableRequiresValidType", "typeName"=>"Nonsense", "variableName"=>"unknownType"} }) end diff --git a/spec/graphql/static_validation/rules/variables_are_used_and_defined_spec.rb b/spec/graphql/static_validation/rules/variables_are_used_and_defined_spec.rb index e628c09c05..7d332aade1 100644 --- a/spec/graphql/static_validation/rules/variables_are_used_and_defined_spec.rb +++ b/spec/graphql/static_validation/rules/variables_are_used_and_defined_spec.rb @@ -42,20 +42,40 @@ { "message"=>"Variable $notUsedVar is declared by getCheese but not used", "locations"=>[{"line"=>2, "column"=>5}], - "fields"=>["query getCheese"], + "path"=>["query getCheese"], + "extensions"=>{"code"=>"variableNotUsed", "variableName"=>"notUsedVar"} }, { "message"=>"Variable $undefinedVar is used by getCheese but not declared", "locations"=>[{"line"=>19, "column"=>22}], - "fields"=>["query getCheese", "c3", "id"], + "path"=>["query getCheese", "c3", "id"], + "extensions"=>{"code"=>"variableNotDefined", "variableName"=>"undefinedVar"} }, { "message"=>"Variable $undefinedFragmentVar is used by innerCheeseFields but not declared", "locations"=>[{"line"=>29, "column"=>22}], - "fields"=>["fragment innerCheeseFields", "c4", "id"], + "path"=>["fragment innerCheeseFields", "c4", "id"], + "extensions"=>{"code"=>"variableNotDefined", "variableName"=>"undefinedFragmentVar"} }, ] assert_equal(expected, errors) end + + describe "usages in directives on fragment spreads" do + let(:query_string) { + <<-GRAPHQL + query($f: Boolean!){ + ...F @include(if: $f) + } + fragment F on Query { + __typename + } + GRAPHQL + } + + it "finds usages" do + assert_equal([], errors) + end + end end diff --git a/spec/graphql/static_validation/type_stack_spec.rb b/spec/graphql/static_validation/type_stack_spec.rb index 6ce5a3478a..a749fe01ca 100644 --- a/spec/graphql/static_validation/type_stack_spec.rb +++ b/spec/graphql/static_validation/type_stack_spec.rb @@ -1,19 +1,6 @@ # frozen_string_literal: true require "spec_helper" -class TypeCheckValidator - def self.checks - @checks ||= [] - end - - def validate(context) - self.class.checks.clear - context.visitor[GraphQL::Language::Nodes::Field] << ->(node, parent) { - self.class.checks << context.object_types.map {|t| t.name || t.kind.name } - } - end -end - describe GraphQL::StaticValidation::TypeStack do let(:query_string) {%| query getCheese { @@ -22,17 +9,21 @@ def validate(context) fragment edibleFields on Edible { fatContent @skip(if: false)} |} - let(:validator) { GraphQL::StaticValidation::Validator.new(schema: Dummy::Schema, rules: [TypeCheckValidator]) } - let(:query) { GraphQL::Query.new(Dummy::Schema, query_string) } - - it "stores up types" do - validator.validate(query) + document = GraphQL.parse(query_string) + visitor = GraphQL::Language::Visitor.new(document) + type_stack = GraphQL::StaticValidation::TypeStack.new(Dummy::Schema, visitor) + checks = [] + visitor[GraphQL::Language::Nodes::Field].enter << ->(node, parent) { + checks << type_stack.object_types.map {|t| t.name || t.kind.name } + } + visitor.visit + expected = [ ["Query", "Cheese"], ["Query", "Cheese", "NON_NULL"], ["Edible", "NON_NULL"] ] - assert_equal(expected, TypeCheckValidator.checks) + assert_equal(expected, checks) end end diff --git a/spec/graphql/static_validation/validator_spec.rb b/spec/graphql/static_validation/validator_spec.rb index 3982d824f1..b74834bb37 100644 --- a/spec/graphql/static_validation/validator_spec.rb +++ b/spec/graphql/static_validation/validator_spec.rb @@ -34,7 +34,8 @@ expected_errors = [{ "message" => "Variable $undefinedVar is used by but not declared", "locations" => [{"line" => 1, "column" => 14, "filename" => "not_a_real.graphql"}], - "fields" => ["query", "cheese", "id"] + "path" => ["query", "cheese", "id"], + "extensions"=>{"code"=>"variableNotDefined", "variableName"=>"undefinedVar"} }] assert_equal expected_errors, errors end @@ -115,7 +116,8 @@ { "message"=>"Fragment cheeseFields contains an infinite loop", "locations"=>[{"line"=>10, "column"=>9}], - "fields"=>["fragment cheeseFields"] + "path"=>["fragment cheeseFields"], + "extensions"=>{"code"=>"infiniteLoop", "fragmentName"=>"cheeseFields"} } ] assert_equal(expected, errors) @@ -153,4 +155,50 @@ end end end + + describe "Custom ruleset" do + let(:query_string) { " + fragment Thing on Cheese { + __typename + similarCheese(source: COW) + } + " + } + + let(:rules) { + # This is from graphql-client, eg + # https://github.com/github/graphql-client/blob/c86fc05d7eba2370452592bb93572caced4123af/lib/graphql/client.rb#L168 + GraphQL::StaticValidation::ALL_RULES - [ + GraphQL::StaticValidation::FragmentsAreUsed, + GraphQL::StaticValidation::FieldsHaveAppropriateSelections + ] + } + let(:validator) { GraphQL::StaticValidation::Validator.new(schema: Dummy::Schema, rules: rules) } + + it "runs the specified rules" do + assert_equal 0, errors.size + end + + describe "With a legacy-style rule" do + # GraphQL-Pro's operation store uses this + class ValidatorSpecLegacyRule + include GraphQL::StaticValidation::Error::ErrorHelper + def validate(ctx) + ctx.visitor[GraphQL::Language::Nodes::OperationDefinition] << ->(n, _p) { + ctx.errors << error("Busted!", n, context: ctx) + } + end + end + + let(:rules) { + GraphQL::StaticValidation::ALL_RULES + [ValidatorSpecLegacyRule] + } + + let(:query_string) { "{ __typename }"} + + it "runs the rule" do + assert_equal ["Busted!"], errors.map { |e| e["message"] } + end + end + end end diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index 50525ca75b..92a2f40bff 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -109,6 +109,9 @@ class StreamInput < GraphQL::Schema::InputObject end class Subscription < GraphQL::Schema::Object + if TESTING_INTERPRETER + extend GraphQL::Subscriptions::SubscriptionRoot + end field :payload, Payload, null: false do argument :id, ID, required: true end @@ -133,9 +136,13 @@ def my_event(type: nil) object end - field :failed_event, Payload, null: false, resolve: ->(o, a, c) { raise GraphQL::ExecutionError.new("unauthorized") } do + field :failed_event, Payload, null: false do argument :id, ID, required: true end + + def failed_event(id:) + raise GraphQL::ExecutionError.new("unauthorized") + end end class Query < GraphQL::Schema::Object @@ -146,6 +153,9 @@ class Schema < GraphQL::Schema query(Query) subscription(Subscription) use InMemoryBackend::Subscriptions, extra: 123 + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end end @@ -236,9 +246,17 @@ def to_param res_1 = schema.execute(query_str, context: { socket: "1" }, variables: { "id" => "100" }, root_value: root_object) res_2 = schema.execute(query_str, context: { socket: "2" }, variables: { "id" => "200" }, root_value: root_object) + # This difference is because of how `SKIP` is handled. + # Honestly the new way is probably better, since it puts a value there. + empty_response = if TESTING_INTERPRETER && schema == ClassBasedInMemoryBackend::Schema + {} + else + nil + end + # Initial response is nil, no broadcasts yet - assert_equal(nil, res_1["data"]) - assert_equal(nil, res_2["data"]) + assert_equal(empty_response, res_1["data"]) + assert_equal(empty_response, res_2["data"]) assert_equal [], deliveries["1"] assert_equal [], deliveries["2"] @@ -410,12 +428,12 @@ def str failedEvent(id: $id) { str, int } } GRAPHQL - assert_equal nil, res["data"] assert_equal "unauthorized", res["errors"][0]["message"] # this is to make sure nothing actually got subscribed.. but I don't have any idea better than checking its instance variable - assert_equal 0, schema.subscriptions.instance_variable_get(:@subscriptions).size + subscriptions = schema.subscriptions.instance_variable_get(:@subscriptions) + assert_equal 0, subscriptions.size end it "lets unhandled errors crash" do diff --git a/spec/graphql/tracing/new_relic_tracing_spec.rb b/spec/graphql/tracing/new_relic_tracing_spec.rb index 57bda51164..8230e72490 100644 --- a/spec/graphql/tracing/new_relic_tracing_spec.rb +++ b/spec/graphql/tracing/new_relic_tracing_spec.rb @@ -14,11 +14,17 @@ def int class SchemaWithoutTransactionName < GraphQL::Schema query(Query) use(GraphQL::Tracing::NewRelicTracing) + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end class SchemaWithTransactionName < GraphQL::Schema query(Query) use(GraphQL::Tracing::NewRelicTracing, set_transaction_name: true) + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end class SchemaWithScalarTrace < GraphQL::Schema diff --git a/spec/graphql/tracing/platform_tracing_spec.rb b/spec/graphql/tracing/platform_tracing_spec.rb index b28992cab6..8a4b628f2c 100644 --- a/spec/graphql/tracing/platform_tracing_spec.rb +++ b/spec/graphql/tracing/platform_tracing_spec.rb @@ -17,7 +17,7 @@ class CustomPlatformTracer < GraphQL::Tracing::PlatformTracing } def platform_field_key(type, field) - "#{type.name[0]}.#{field.name[0]}" + "#{type.graphql_name[0]}.#{field.graphql_name[0]}" end def platform_trace(platform_key, key, data) @@ -39,17 +39,25 @@ def platform_trace(platform_key, key, data) it "calls the platform's own method with its own keys" do schema.execute(" { cheese(id: 1) { flavor } }") - expected_trace = [ - "em", - "l", - "p", - "v", - "am", - "aq", - "eq", - "Q.c", # notice that the flavor is skipped - "eql", - ] + # This is different because schema/member/instrumentation + # calls `irep_selection` which causes the query to be parsed. + # But interpreter doesn't require parsing until later. + expected_trace = if TESTING_INTERPRETER + [ + "em", + "am", + "l", + "p", + "v", + "aq", + "eq", + "Q.c", # notice that the flavor is skipped + "eql", + ] + else + ["em", "l", "p", "v", "am", "aq", "eq", "Q.c", "eql"] + end + assert_equal expected_trace, CustomPlatformTracer::TRACE end end @@ -67,18 +75,25 @@ def platform_trace(platform_key, key, data) it "only traces traceTrue, not traceFalse or traceNil" do schema.execute(" { tracingScalar { traceNil traceFalse traceTrue } }") - expected_trace = [ - "em", - "l", - "p", - "v", - "am", - "aq", - "eq", - "Q.t", - "T.t", - "eql", - ] + # This is different because schema/member/instrumentation + # calls `irep_selection` which causes the query to be parsed. + # But interpreter doesn't require parsing until later. + expected_trace = if TESTING_INTERPRETER + [ + "em", + "am", + "l", + "p", + "v", + "aq", + "eq", + "Q.t", + "T.t", + "eql", + ] + else + ["em", "l", "p", "v", "am", "aq", "eq", "Q.t", "T.t", "eql"] + end assert_equal expected_trace, CustomPlatformTracer::TRACE end end @@ -96,19 +111,26 @@ def platform_trace(platform_key, key, data) it "traces traceTrue and traceNil but not traceFalse" do schema.execute(" { tracingScalar { traceNil traceFalse traceTrue } }") - expected_trace = [ - "em", - "l", - "p", - "v", - "am", - "aq", - "eq", - "Q.t", - "T.t", - "T.t", - "eql", - ] + # This is different because schema/member/instrumentation + # calls `irep_selection` which causes the query to be parsed. + # But interpreter doesn't require parsing until later. + expected_trace = if TESTING_INTERPRETER + [ + "em", + "am", + "l", + "p", + "v", + "aq", + "eq", + "Q.t", + "T.t", + "T.t", + "eql", + ] + else + ["em", "l", "p", "v", "am", "aq", "eq", "Q.t", "T.t", "T.t", "eql"] + end assert_equal expected_trace, CustomPlatformTracer::TRACE end end diff --git a/spec/graphql/tracing/prometheus_tracing_spec.rb b/spec/graphql/tracing/prometheus_tracing_spec.rb index 180500efc5..ad4ec9ff1e 100644 --- a/spec/graphql/tracing/prometheus_tracing_spec.rb +++ b/spec/graphql/tracing/prometheus_tracing_spec.rb @@ -14,6 +14,9 @@ def int class Schema < GraphQL::Schema query Query + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end end diff --git a/spec/graphql/tracing/skylight_tracing_spec.rb b/spec/graphql/tracing/skylight_tracing_spec.rb index d399e43823..33c8524a71 100644 --- a/spec/graphql/tracing/skylight_tracing_spec.rb +++ b/spec/graphql/tracing/skylight_tracing_spec.rb @@ -14,11 +14,17 @@ def int class SchemaWithoutTransactionName < GraphQL::Schema query(Query) use(GraphQL::Tracing::SkylightTracing) + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end class SchemaWithTransactionName < GraphQL::Schema query(Query) use(GraphQL::Tracing::SkylightTracing, set_endpoint_name: true) + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end class SchemaWithScalarTrace < GraphQL::Schema diff --git a/spec/graphql/types/iso_8601_date_time_spec.rb b/spec/graphql/types/iso_8601_date_time_spec.rb index 40d8157fec..e8d5a77b93 100644 --- a/spec/graphql/types/iso_8601_date_time_spec.rb +++ b/spec/graphql/types/iso_8601_date_time_spec.rb @@ -11,8 +11,7 @@ class DateTimeObject < GraphQL::Schema::Object field :minute, Integer, null: false field :second, Integer, null: false field :zone, String, null: false - # Use method: :object so that the DateTime instance is passed to the scalar - field :iso8601, GraphQL::Types::ISO8601DateTime, null: false, method: :object + field :iso8601, GraphQL::Types::ISO8601DateTime, null: false, method: :itself end class Query < GraphQL::Schema::Object @@ -29,6 +28,9 @@ def parse_date(date:) class Schema < GraphQL::Schema query(Query) + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end end diff --git a/spec/integration/mongoid/graphql/relay/mongo_relation_connection_spec.rb b/spec/integration/mongoid/graphql/relay/mongo_relation_connection_spec.rb index b4c049d649..c70c6f6cf2 100644 --- a/spec/integration/mongoid/graphql/relay/mongo_relation_connection_spec.rb +++ b/spec/integration/mongoid/graphql/relay/mongo_relation_connection_spec.rb @@ -60,19 +60,19 @@ def get_last_cursor(result) assert_equal(2, get_names(result).length) assert_equal(true, get_page_info(result)["hasNextPage"]) assert_equal(false, get_page_info(result)["hasPreviousPage"]) - assert_equal("MQ==", get_page_info(result)["startCursor"]) - assert_equal("Mg==", get_page_info(result)["endCursor"]) - assert_equal("MQ==", get_first_cursor(result)) - assert_equal("Mg==", get_last_cursor(result)) + assert_equal("MQ", get_page_info(result)["startCursor"]) + assert_equal("Mg", get_page_info(result)["endCursor"]) + assert_equal("MQ", get_first_cursor(result)) + assert_equal("Mg", get_last_cursor(result)) result = star_trek_query(query_string, "first" => 3) assert_equal(3, get_names(result).length) assert_equal(false, get_page_info(result)["hasNextPage"]) assert_equal(false, get_page_info(result)["hasPreviousPage"]) - assert_equal("MQ==", get_page_info(result)["startCursor"]) - assert_equal("Mw==", get_page_info(result)["endCursor"]) - assert_equal("MQ==", get_first_cursor(result)) - assert_equal("Mw==", get_last_cursor(result)) + assert_equal("MQ", get_page_info(result)["startCursor"]) + assert_equal("Mw", get_page_info(result)["endCursor"]) + assert_equal("MQ", get_first_cursor(result)) + assert_equal("Mw", get_last_cursor(result)) end it 'provides custom fields on the connection type' do @@ -268,7 +268,7 @@ def get_last_cursor(result) assert_equal(first_and_second_names, get_names(result)) assert_equal(false, result["data"]["federation"]["bases"]["pageInfo"]["hasPreviousPage"], "hasPreviousPage is false when last is not specified") - third_cursor = "Mw==" + third_cursor = "Mw" result = star_trek_query(query_string, "last" => 100, "before" => third_cursor) assert_equal(first_and_second_names, get_names(result)) @@ -462,8 +462,8 @@ def get_names(result, field_name) let(:connection) { GraphQL::Relay::MongoRelationConnection.new(StarTrek::Base.where(faction_id: 1), {}) } it "returns the cursor for a node in the connection" do - assert_equal "MQ==", connection.cursor_from_node(StarTrek::Base.all[0]) - assert_equal "Mg==", connection.cursor_from_node(StarTrek::Base.all[1]) + assert_equal "MQ", connection.cursor_from_node(StarTrek::Base.all[0]) + assert_equal "Mg", connection.cursor_from_node(StarTrek::Base.all[1]) end it "raises when the node isn't found" do diff --git a/spec/integration/mongoid/star_trek/schema.rb b/spec/integration/mongoid/star_trek/schema.rb index 48ae8e918d..2fa2faa176 100644 --- a/spec/integration/mongoid/star_trek/schema.rb +++ b/spec/integration/mongoid/star_trek/schema.rb @@ -79,18 +79,18 @@ def field_name end end - # Example of GraphQL::Function used with the connection helper: - class ShipsWithMaxPageSize < GraphQL::Function - argument :nameIncludes, GraphQL::STRING_TYPE - def call(obj, args, ctx) - all_ships = obj.ships.map { |ship_id| StarTrek::DATA["Ship"][ship_id] } - if args[:nameIncludes] - all_ships = all_ships.select { |ship| ship.name.include?(args[:nameIncludes])} + + class ShipsWithMaxPageSize < GraphQL::Schema::Resolver + argument :name_includes, String, required: false + type Ship.connection_type, null: true + + def resolve(name_includes: nil) + all_ships = object.ships.map { |ship_id| StarTrek::DATA["Ship"][ship_id] } + if name_includes + all_ships = all_ships.select { |ship| ship.name.include?(name_includes)} end all_ships end - - type Ship.connection_type end class ShipConnectionWithParentType < GraphQL::Types::Relay::BaseConnection @@ -107,10 +107,14 @@ class Faction < GraphQL::Schema::Object field :id, ID, null: false, resolve: GraphQL::Relay::GlobalIdResolve.new(type: Faction) field :name, String, null: true - field :ships, ShipConnectionWithParentType, connection: true, max_page_size: 1000, null: true, resolve: ->(obj, args, ctx) { - all_ships = obj.ships.map {|ship_id| StarTrek::DATA["Ship"][ship_id] } - if args[:nameIncludes] - case args[:nameIncludes] + field :ships, ShipConnectionWithParentType, connection: true, max_page_size: 1000, null: true do + argument :name_includes, String, required: false + end + + def ships(name_includes: nil) + all_ships = object.ships.map {|ship_id| StarTrek::DATA["Ship"][ship_id] } + if name_includes + case name_includes when "error" all_ships = GraphQL::ExecutionError.new("error from within connection") when "raisedError" @@ -125,25 +129,24 @@ class Faction < GraphQL::Schema::Object prev_all_ships = all_ships all_ships = LazyWrapper.new { prev_all_ships } else - all_ships = all_ships.select { |ship| ship.name.include?(args[:nameIncludes])} + all_ships = all_ships.select { |ship| ship.name.include?(name_includes)} end end all_ships - } do - # You can define arguments here and use them in the connection - argument :nameIncludes, String, required: false end - field :shipsWithMaxPageSize, "Ships with max page size", max_page_size: 2, function: ShipsWithMaxPageSize.new + field :shipsWithMaxPageSize, "Ships with max page size", max_page_size: 2, resolver: ShipsWithMaxPageSize - field :bases, BaseConnectionWithTotalCountType, null: true, connection: true, resolve: ->(obj, args, ctx) { - all_bases = obj.bases - if args[:nameIncludes] - all_bases = all_bases.where(name: Regexp.new(args[:nameIncludes])) + field :bases, BaseConnectionWithTotalCountType, null: true, connection: true do + argument :name_includes, String, required: false + end + + def bases(name_includes: nil) + all_bases = object.bases + if name_includes + all_bases = all_bases.where(name: Regexp.new(name_includes)) end all_bases - } do - argument :nameIncludes, String, required: false end field :basesClone, BaseType.connection_type, null: true @@ -158,13 +161,24 @@ def bases_by_name(order: nil) end end - field :basesWithMaxLimitRelation, BaseType.connection_type, null: true, max_page_size: 2, resolve: Proc.new { Base.all} - field :basesWithMaxLimitArray, BaseType.connection_type, null: true, max_page_size: 2, resolve: Proc.new { Base.all.to_a } - field :basesWithDefaultMaxLimitRelation, BaseType.connection_type, null: true, resolve: Proc.new { Base.all } - field :basesWithDefaultMaxLimitArray, BaseType.connection_type, null: true, resolve: Proc.new { Base.all.to_a } - field :basesWithLargeMaxLimitRelation, BaseType.connection_type, null: true, max_page_size: 1000, resolve: Proc.new { Base.all } + def all_bases + Base.all + end + + def all_bases_array + all_bases.to_a + end - field :basesWithCustomEdge, CustomEdgeBaseConnectionType, null: true, connection: true, resolve: ->(o, a, c) { LazyNodesWrapper.new(o.bases) } + field :basesWithMaxLimitRelation, BaseType.connection_type, null: true, max_page_size: 2, resolver_method: :all_bases + field :basesWithMaxLimitArray, BaseType.connection_type, null: true, max_page_size: 2, resolver_method: :all_bases_array + field :basesWithDefaultMaxLimitRelation, BaseType.connection_type, null: true, resolver_method: :all_bases + field :basesWithDefaultMaxLimitArray, BaseType.connection_type, null: true, resolver_method: :all_bases_array + field :basesWithLargeMaxLimitRelation, BaseType.connection_type, null: true, max_page_size: 1000, resolver_method: :all_bases + + field :basesWithCustomEdge, CustomEdgeBaseConnectionType, null: true, connection: true + def bases_with_custom_edge + LazyNodesWrapper.new(object.bases) + end end class IntroduceShipMutation < GraphQL::Schema::RelayClassicMutation @@ -302,7 +316,9 @@ class QueryType < GraphQL::Schema::Object field :largestBase, BaseType, null: true, resolve: ->(obj, args, ctx) { Base.find(3) } - field :newestBasesGroupedByFaction, BaseType.connection_type, null: true, resolve: ->(obj, args, ctx) { + field :newestBasesGroupedByFaction, BaseType.connection_type, null: true + + def newest_bases_grouped_by_faction agg = Base.collection.aggregate([{ "$group" => { "_id" => "$faction_id", @@ -312,11 +328,13 @@ class QueryType < GraphQL::Schema::Object Base. in(id: agg.map { |doc| doc['baseId'] }). order_by(faction_id: -1) - } + end - field :basesWithNullName, BaseType.connection_type, null: false, resolve: ->(obj, args, ctx) { + field :basesWithNullName, BaseType.connection_type, null: false + + def bases_with_null_name [OpenStruct.new(id: nil)] - } + end field :node, field: GraphQL::Relay::Node.field @@ -371,6 +389,10 @@ class Schema < GraphQL::Schema mutation(MutationType) default_max_page_size 3 + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end + def self.resolve_type(type, object, ctx) if object == :test_error :not_a_type diff --git a/spec/integration/rails/graphql/relay/array_connection_spec.rb b/spec/integration/rails/graphql/relay/array_connection_spec.rb index e13f1613a7..0ab85dc343 100644 --- a/spec/integration/rails/graphql/relay/array_connection_spec.rb +++ b/spec/integration/rails/graphql/relay/array_connection_spec.rb @@ -43,8 +43,8 @@ def get_page_info(result, key = "bases") assert_equal(2, number_of_ships) assert_equal(true, result["data"]["rebels"]["ships"]["pageInfo"]["hasNextPage"]) assert_equal(false, result["data"]["rebels"]["ships"]["pageInfo"]["hasPreviousPage"]) - assert_equal("MQ==", result["data"]["rebels"]["ships"]["pageInfo"]["startCursor"]) - assert_equal("Mg==", result["data"]["rebels"]["ships"]["pageInfo"]["endCursor"]) + assert_equal("MQ", result["data"]["rebels"]["ships"]["pageInfo"]["startCursor"]) + assert_equal("Mg", result["data"]["rebels"]["ships"]["pageInfo"]["endCursor"]) result = star_wars_query(query_string, "first" => 3) number_of_ships = get_names(result).length @@ -55,14 +55,14 @@ def get_page_info(result, key = "bases") result = star_wars_query(query_string, "first" => 2) assert_equal(true, result["data"]["rebels"]["ships"]["pageInfo"]["hasNextPage"]) assert_equal(false, result["data"]["rebels"]["ships"]["pageInfo"]["hasPreviousPage"]) - assert_equal("MQ==", result["data"]["rebels"]["ships"]["pageInfo"]["startCursor"]) - assert_equal("Mg==", result["data"]["rebels"]["ships"]["pageInfo"]["endCursor"]) + assert_equal("MQ", result["data"]["rebels"]["ships"]["pageInfo"]["startCursor"]) + assert_equal("Mg", result["data"]["rebels"]["ships"]["pageInfo"]["endCursor"]) result = star_wars_query(query_string, "first" => 100) assert_equal(false, result["data"]["rebels"]["ships"]["pageInfo"]["hasNextPage"]) assert_equal(false, result["data"]["rebels"]["ships"]["pageInfo"]["hasPreviousPage"]) - assert_equal("MQ==", result["data"]["rebels"]["ships"]["pageInfo"]["startCursor"]) - assert_equal("NQ==", result["data"]["rebels"]["ships"]["pageInfo"]["endCursor"]) + assert_equal("MQ", result["data"]["rebels"]["ships"]["pageInfo"]["startCursor"]) + assert_equal("NQ", result["data"]["rebels"]["ships"]["pageInfo"]["endCursor"]) end it "provides bidirectional_pagination" do @@ -166,8 +166,8 @@ def get_page_info(result, key = "bases") assert_equal(false, result["data"]["rebels"]["ships"]["pageInfo"]["hasNextPage"]) assert_equal(false, result["data"]["rebels"]["ships"]["pageInfo"]["hasPreviousPage"]) - assert_equal("MQ==", result["data"]["rebels"]["ships"]["pageInfo"]["startCursor"]) - assert_equal("NQ==", result["data"]["rebels"]["ships"]["pageInfo"]["endCursor"]) + assert_equal("MQ", result["data"]["rebels"]["ships"]["pageInfo"]["startCursor"]) + assert_equal("NQ", result["data"]["rebels"]["ships"]["pageInfo"]["endCursor"]) assert_equal(5, result["data"]["rebels"]["ships"]["edges"].length) end @@ -217,7 +217,7 @@ def get_names(result) assert_equal(["Yavin", "Echo Base"], get_names(result)) assert_equal(false, get_page_info(result)["hasPreviousPage"], "hasPreviousPage is false when last is not specified") - third_cursor = "Mw==" + third_cursor = "Mw" first_and_second_names = ["Yavin", "Echo Base"] result = star_wars_query(query_string, "last" => 100, "before" => third_cursor) assert_equal(first_and_second_names, get_names(result)) diff --git a/spec/integration/rails/graphql/relay/base_connection_spec.rb b/spec/integration/rails/graphql/relay/base_connection_spec.rb index 4dedcbf500..ca5c5faa50 100644 --- a/spec/integration/rails/graphql/relay/base_connection_spec.rb +++ b/spec/integration/rails/graphql/relay/base_connection_spec.rb @@ -77,11 +77,11 @@ def decode(str, nonce: false); str.reverse; end assert_equal "Person/1", conn.decode("1/nosreP") end - it "defaults to base64" do + it "defaults to base64 (urlsafe_base64 without padding)" do conn = GraphQL::Relay::BaseConnection.new([], {}, context: nil) - assert_equal "UGVyc29uLzE=", conn.encode("Person/1") - assert_equal "Person/1", conn.decode("UGVyc29uLzE=") + assert_equal "UGVyc29uLzE", conn.encode("Person/1") + assert_equal "Person/1", conn.decode("UGVyc29uLzE=") # can decode with padding end it "handles trimmed base64" do diff --git a/spec/integration/rails/graphql/relay/connection_instrumentation_spec.rb b/spec/integration/rails/graphql/relay/connection_instrumentation_spec.rb index aaf2ec3a75..c96871c725 100644 --- a/spec/integration/rails/graphql/relay/connection_instrumentation_spec.rb +++ b/spec/integration/rails/graphql/relay/connection_instrumentation_spec.rb @@ -11,11 +11,6 @@ assert_equal ["tests"], test_type.fields.keys end - it "keeps a reference to the function" do - conn_field = StarWars::Faction.graphql_definition.fields["shipsWithMaxPageSize"] - assert_instance_of StarWars::ShipsWithMaxPageSize, conn_field.function - end - let(:build_schema) { test_type = nil @@ -58,26 +53,28 @@ assert_instance_of GraphQL::Relay::ConnectionResolve, redefined_connection_field.resolve_proc end - describe "after_built_ins instrumentation" do - it "has access to connection objects" do - query_str = <<-GRAPHQL - { - rebels { - ships { - pageInfo { - __typename + # field instrumentation doesn't exist here + if !TESTING_INTERPRETER + describe "after_built_ins instrumentation" do + it "has access to connection objects" do + query_str = <<-GRAPHQL + { + rebels { + ships { + pageInfo { + __typename + } } } } - } - GRAPHQL - ctx = { before_built_ins: [], after_built_ins: [] } - star_wars_query(query_str, {}, context: ctx) - # The second item is different here: - # Before the object is wrapped in a connection, the instrumentation sees `Array` - assert_equal ["StarWars::FactionRecord", "Array", "GraphQL::Relay::ArrayConnection"], ctx[:before_built_ins] - # After the object is wrapped in a connection, it sees the connection object - assert_equal ["StarWars::Faction", "StarWars::ShipConnectionWithParentType", "GraphQL::Types::Relay::PageInfo"], ctx[:after_built_ins] + GRAPHQL + ctx = { before_built_ins: [], after_built_ins: [] } + star_wars_query(query_str, {}, context: ctx) + # These are data classes, later they're wrapped with type proxies + assert_equal ["StarWars::FactionRecord", "GraphQL::Relay::ArrayConnection", "GraphQL::Relay::ArrayConnection"], ctx[:before_built_ins] + # After the object is wrapped in a connection, it sees the connection object + assert_equal ["StarWars::Faction", "StarWars::ShipConnectionWithParentType", "GraphQL::Types::Relay::PageInfo"], ctx[:after_built_ins] + end end end end diff --git a/spec/integration/rails/graphql/relay/connection_resolve_spec.rb b/spec/integration/rails/graphql/relay/connection_resolve_spec.rb index 17efd01fe1..2b8c1c0bc8 100644 --- a/spec/integration/rails/graphql/relay/connection_resolve_spec.rb +++ b/spec/integration/rails/graphql/relay/connection_resolve_spec.rb @@ -53,6 +53,22 @@ end end + describe "when a resolver is used" do + it "returns the items with the correct parent" do + resolver_query_str = <<-GRAPHQL + { + rebels { + shipsByResolver { + parentClassName + } + } + } + GRAPHQL + result = star_wars_query(resolver_query_str) + assert_equal "StarWars::FactionRecord", result["data"]["rebels"]["shipsByResolver"]["parentClassName"] + end + end + describe "when nil is returned" do it "becomes null" do result = star_wars_query(query_string, { "name" => "null" }) diff --git a/spec/integration/rails/graphql/relay/page_info_spec.rb b/spec/integration/rails/graphql/relay/page_info_spec.rb index 1dd6ebc288..fc6dc41d4a 100644 --- a/spec/integration/rails/graphql/relay/page_info_spec.rb +++ b/spec/integration/rails/graphql/relay/page_info_spec.rb @@ -46,63 +46,63 @@ def get_last_cursor(result) result = star_wars_query(query_string, "first" => 2) assert_equal(true, get_page_info(result)["hasNextPage"]) assert_equal(false, get_page_info(result)["hasPreviousPage"], "hasPreviousPage is false if 'last' is missing") - assert_equal("MQ==", get_page_info(result)["startCursor"]) - assert_equal("Mg==", get_page_info(result)["endCursor"]) + assert_equal("MQ", get_page_info(result)["startCursor"]) + assert_equal("Mg", get_page_info(result)["endCursor"]) last_cursor = get_last_cursor(result) result = star_wars_query(query_string, "first" => 100, "after" => last_cursor) assert_equal(false, get_page_info(result)["hasNextPage"]) assert_equal(false, get_page_info(result)["hasPreviousPage"]) - assert_equal("Mw==", get_page_info(result)["startCursor"]) - assert_equal("Mw==", get_page_info(result)["endCursor"]) + assert_equal("Mw", get_page_info(result)["startCursor"]) + assert_equal("Mw", get_page_info(result)["endCursor"]) end it "hasPreviousPage if there are more items" do result = star_wars_query(query_string, "last" => 100, "before" => cursor_of_last_base) assert_equal(false, get_page_info(result)["hasNextPage"]) assert_equal(false, get_page_info(result)["hasPreviousPage"]) - assert_equal("MQ==", get_page_info(result)["startCursor"]) - assert_equal("Mg==", get_page_info(result)["endCursor"]) + assert_equal("MQ", get_page_info(result)["startCursor"]) + assert_equal("Mg", get_page_info(result)["endCursor"]) result = star_wars_query(query_string, "last" => 1, "before" => cursor_of_last_base) assert_equal(false, get_page_info(result)["hasNextPage"]) assert_equal(true, get_page_info(result)["hasPreviousPage"]) - assert_equal("Mg==", get_page_info(result)["startCursor"]) - assert_equal("Mg==", get_page_info(result)["endCursor"]) + assert_equal("Mg", get_page_info(result)["startCursor"]) + assert_equal("Mg", get_page_info(result)["endCursor"]) end it "has both if first and last are present" do result = star_wars_query(query_string, "last" => 1, "first" => 1, "before" => cursor_of_last_base) assert_equal(true, get_page_info(result)["hasNextPage"]) assert_equal(true, get_page_info(result)["hasPreviousPage"]) - assert_equal("MQ==", get_page_info(result)["startCursor"]) - assert_equal("MQ==", get_page_info(result)["endCursor"]) + assert_equal("MQ", get_page_info(result)["startCursor"]) + assert_equal("MQ", get_page_info(result)["endCursor"]) end it "startCursor and endCursor are the cursors of the first and last edge" do result = star_wars_query(query_string, "first" => 2) assert_equal(true, get_page_info(result)["hasNextPage"]) assert_equal(false, get_page_info(result)["hasPreviousPage"]) - assert_equal("MQ==", get_page_info(result)["startCursor"]) - assert_equal("Mg==", get_page_info(result)["endCursor"]) - assert_equal("MQ==", get_first_cursor(result)) - assert_equal("Mg==", get_last_cursor(result)) + assert_equal("MQ", get_page_info(result)["startCursor"]) + assert_equal("Mg", get_page_info(result)["endCursor"]) + assert_equal("MQ", get_first_cursor(result)) + assert_equal("Mg", get_last_cursor(result)) result = star_wars_query(query_string, "first" => 1, "after" => get_page_info(result)["endCursor"]) assert_equal(false, get_page_info(result)["hasNextPage"]) assert_equal(false, get_page_info(result)["hasPreviousPage"]) - assert_equal("Mw==", get_page_info(result)["startCursor"]) - assert_equal("Mw==", get_page_info(result)["endCursor"]) - assert_equal("Mw==", get_first_cursor(result)) - assert_equal("Mw==", get_last_cursor(result)) + assert_equal("Mw", get_page_info(result)["startCursor"]) + assert_equal("Mw", get_page_info(result)["endCursor"]) + assert_equal("Mw", get_first_cursor(result)) + assert_equal("Mw", get_last_cursor(result)) result = star_wars_query(query_string, "last" => 1, "before" => get_page_info(result)["endCursor"]) assert_equal(false, get_page_info(result)["hasNextPage"]) assert_equal(true, get_page_info(result)["hasPreviousPage"]) - assert_equal("Mg==", get_page_info(result)["startCursor"]) - assert_equal("Mg==", get_page_info(result)["endCursor"]) - assert_equal("Mg==", get_first_cursor(result)) - assert_equal("Mg==", get_last_cursor(result)) + assert_equal("Mg", get_page_info(result)["startCursor"]) + assert_equal("Mg", get_page_info(result)["endCursor"]) + assert_equal("Mg", get_first_cursor(result)) + assert_equal("Mg", get_last_cursor(result)) end end diff --git a/spec/integration/rails/graphql/relay/relation_connection_spec.rb b/spec/integration/rails/graphql/relay/relation_connection_spec.rb index 00b035365a..dd1b97ea2a 100644 --- a/spec/integration/rails/graphql/relay/relation_connection_spec.rb +++ b/spec/integration/rails/graphql/relay/relation_connection_spec.rb @@ -51,19 +51,19 @@ def get_last_cursor(result) assert_equal(2, get_names(result).length) assert_equal(true, get_page_info(result)["hasNextPage"]) assert_equal(false, get_page_info(result)["hasPreviousPage"]) - assert_equal("MQ==", get_page_info(result)["startCursor"]) - assert_equal("Mg==", get_page_info(result)["endCursor"]) - assert_equal("MQ==", get_first_cursor(result)) - assert_equal("Mg==", get_last_cursor(result)) + assert_equal("MQ", get_page_info(result)["startCursor"]) + assert_equal("Mg", get_page_info(result)["endCursor"]) + assert_equal("MQ", get_first_cursor(result)) + assert_equal("Mg", get_last_cursor(result)) result = star_wars_query(query_string, "first" => 3) assert_equal(3, get_names(result).length) assert_equal(false, get_page_info(result)["hasNextPage"]) assert_equal(false, get_page_info(result)["hasPreviousPage"]) - assert_equal("MQ==", get_page_info(result)["startCursor"]) - assert_equal("Mw==", get_page_info(result)["endCursor"]) - assert_equal("MQ==", get_first_cursor(result)) - assert_equal("Mw==", get_last_cursor(result)) + assert_equal("MQ", get_page_info(result)["startCursor"]) + assert_equal("Mw", get_page_info(result)["endCursor"]) + assert_equal("MQ", get_first_cursor(result)) + assert_equal("Mw", get_last_cursor(result)) end it "uses unscope(:order) count(*) when the relation has some complicated SQL" do @@ -317,7 +317,7 @@ def get_last_cursor(result) assert_equal(first_and_second_names, get_names(result)) assert_equal(false, result["data"]["empire"]["bases"]["pageInfo"]["hasPreviousPage"], "hasPreviousPage is false when last is not specified") - third_cursor = "Mw==" + third_cursor = "Mw" result = star_wars_query(query_string, "last" => 100, "before" => third_cursor) assert_equal(first_and_second_names, get_names(result)) @@ -557,19 +557,19 @@ def get_last_cursor(result) assert_equal(2, get_names(result).length) assert_equal(true, get_page_info(result)["hasNextPage"]) assert_equal(false, get_page_info(result)["hasPreviousPage"]) - assert_equal("MQ==", get_page_info(result)["startCursor"]) - assert_equal("Mg==", get_page_info(result)["endCursor"]) - assert_equal("MQ==", get_first_cursor(result)) - assert_equal("Mg==", get_last_cursor(result)) + assert_equal("MQ", get_page_info(result)["startCursor"]) + assert_equal("Mg", get_page_info(result)["endCursor"]) + assert_equal("MQ", get_first_cursor(result)) + assert_equal("Mg", get_last_cursor(result)) result = star_wars_query(query_string, "first" => 3) assert_equal(3, get_names(result).length) assert_equal(false, get_page_info(result)["hasNextPage"]) assert_equal(false, get_page_info(result)["hasPreviousPage"]) - assert_equal("MQ==", get_page_info(result)["startCursor"]) - assert_equal("Mw==", get_page_info(result)["endCursor"]) - assert_equal("MQ==", get_first_cursor(result)) - assert_equal("Mw==", get_last_cursor(result)) + assert_equal("MQ", get_page_info(result)["startCursor"]) + assert_equal("Mw", get_page_info(result)["endCursor"]) + assert_equal("MQ", get_first_cursor(result)) + assert_equal("Mw", get_last_cursor(result)) end it 'provides custom fields on the connection type' do @@ -664,8 +664,8 @@ def get_last_cursor(result) let(:connection) { GraphQL::Relay::RelationConnection.new(StarWars::Base.where(faction_id: 1), {}) } it "returns the cursor for a node in the connection" do - assert_equal "MQ==", connection.cursor_from_node(StarWars::Base.all[0]) - assert_equal "Mg==", connection.cursor_from_node(StarWars::Base.all[1]) + assert_equal "MQ", connection.cursor_from_node(StarWars::Base.all[0]) + assert_equal "Mg", connection.cursor_from_node(StarWars::Base.all[1]) end it "raises when the node isn't found" do diff --git a/spec/integration/rails/graphql/schema_spec.rb b/spec/integration/rails/graphql/schema_spec.rb index 1c043faaa8..2026168e73 100644 --- a/spec/integration/rails/graphql/schema_spec.rb +++ b/spec/integration/rails/graphql/schema_spec.rb @@ -73,10 +73,13 @@ end end - describe "#subscription" do - it "calls fields on the subscription type" do - res = schema.execute("subscription { test }") - assert_equal("Test", res["data"]["test"]) + # Interpreter has subscription support hardcoded, it doesn't just call through. + if !TESTING_INTERPRETER + describe "#subscription" do + it "calls fields on the subscription type" do + res = schema.execute("subscription { test }") + assert_equal("Test", res["data"]["test"]) + end end end diff --git a/spec/integration/rails/graphql/tracing/active_support_notifications_tracing_spec.rb b/spec/integration/rails/graphql/tracing/active_support_notifications_tracing_spec.rb index 451ba135cf..2e036635d6 100644 --- a/spec/integration/rails/graphql/tracing/active_support_notifications_tracing_spec.rb +++ b/spec/integration/rails/graphql/tracing/active_support_notifications_tracing_spec.rb @@ -12,7 +12,14 @@ traces = [] callback = ->(name, started, finished, id, data) { - traces << name + path_str = if data.key?(:field) + " (#{data[:field].path})" + elsif data.key?(:context) + " (#{data[:context].irep_node.owner_type}.#{data[:context].field.name})" + else + "" + end + traces << "#{name}#{path_str}" } query_string = <<-GRAPHQL @@ -37,16 +44,16 @@ "graphql.validate", "graphql.analyze_query", "graphql.analyze_multiplex", - "graphql.execute_field", - "graphql.execute_field", + "graphql.execute_field (Query.batchedBase)", + "graphql.execute_field (Query.batchedBase)", "graphql.execute_query", "graphql.lazy_loader", - "graphql.execute_field_lazy", - "graphql.execute_field", - "graphql.execute_field_lazy", - "graphql.execute_field", - "graphql.execute_field_lazy", - "graphql.execute_field_lazy", + "graphql.execute_field_lazy (Query.batchedBase)", + "graphql.execute_field (Base.name)", + "graphql.execute_field_lazy (Query.batchedBase)", + "graphql.execute_field (Base.name)", + "graphql.execute_field_lazy (Base.name)", + "graphql.execute_field_lazy (Base.name)", "graphql.execute_query_lazy", "graphql.execute_multiplex", ] diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 69c7e47689..91598df498 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -6,6 +6,10 @@ # Print full backtrace for failiures: ENV["BACKTRACE"] = "1" +# Set this env var to use Interpreter for fixture schemas. +# Eventually, interpreter will be the default. +TESTING_INTERPRETER = ENV["TESTING_INTERPRETER"] +TESTING_RESCUE_FROM = !TESTING_INTERPRETER require "codeclimate-test-reporter" CodeClimate::TestReporter.start @@ -102,6 +106,7 @@ def traces def trace(key, data) data[:key] = key + data[:path] ||= data.key?(:context) ? data[:context].path : nil result = yield data[:result] = result traces << data diff --git a/spec/support/dummy/schema.rb b/spec/support/dummy/schema.rb index 6dbed7ccba..56c4904515 100644 --- a/spec/support/dummy/schema.rb +++ b/spec/support/dummy/schema.rb @@ -270,42 +270,43 @@ def self.coerce_result(value, ctx) end end - class FetchItem < GraphQL::Function - attr_reader :type, :description, :arguments + class FetchItem < GraphQL::Schema::Resolver + class << self + attr_accessor :data + end - def initialize(type:, data:, id_type: !GraphQL::INT_TYPE) - @type = type - @data = data - @description = "Find a #{type.name} by id" - @arguments = self.class.arguments.merge({"id" => GraphQL::Argument.define(name: "id", type: id_type)}) + def self.build(type:, data:, id_type: "Int") + Class.new(self) do + self.data = data + type(type, null: true) + description("Find a #{type.name} by id") + argument :id, id_type, required: true + end end - def call(obj, args, ctx) - id_string = args["id"].to_s # Cheese has Int type, Milk has ID type :( - _id, item = @data.find { |id, _item| id.to_s == id_string } + def resolve(id:) + id_string = id.to_s # Cheese has Int type, Milk has ID type :( + _id, item = self.class.data.find { |item_id, _item| item_id.to_s == id_string } item end end - class GetSingleton < GraphQL::Function - attr_reader :description, :type - - def initialize(type:, data:) - @description = "Find the only #{type.name}" - @type = type - @data = data + class GetSingleton < GraphQL::Schema::Resolver + class << self + attr_accessor :data end - def call(obj, args, ctx) - @data + def self.build(type:, data:) + Class.new(self) do + description("Find the only #{type.name}") + type(type, null: true) + self.data = data + end end - end - FavoriteFieldDefn = GraphQL::Field.define do - name "favoriteEdible" - description "My favorite food" - type Edible - resolve ->(t, a, c) { MILKS[1] } + def resolve + self.class.data + end end class DairyAppQuery < BaseObject @@ -316,9 +317,9 @@ class DairyAppQuery < BaseObject def root object end - field :cheese, function: FetchItem.new(type: Cheese, data: CHEESES) - field :milk, function: FetchItem.new(type: Milk, data: MILKS, id_type: GraphQL::Types::ID.to_non_null_type) - field :dairy, function: GetSingleton.new(type: Dairy, data: DAIRY) + field :cheese, resolver: FetchItem.build(type: Cheese, data: CHEESES) + field :milk, resolver: FetchItem.build(type: Milk, data: MILKS, id_type: "ID") + field :dairy, resolver: GetSingleton.build(type: Dairy, data: DAIRY) field :from_source, [Cheese, null: true], null: true, description: "Cheese from source" do argument :source, DairyAnimal, required: false, default_value: 1 end @@ -326,12 +327,16 @@ def from_source(source:) CHEESES.values.select { |c| c.source == source } end - field :favorite_edible, field: FavoriteFieldDefn - field :cow, function: GetSingleton.new(type: Cow, data: COWS[1]) + field :favorite_edible, Edible, null: true, description: "My favorite food" + def favorite_edible + MILKS[1] + end + + field :cow, resolver: GetSingleton.build(type: Cow, data: COWS[1]) field :search_dairy, DairyProduct, null: false do description "Find dairy products matching a description" # This is a list just for testing 😬 - argument :product, [DairyProductInput, null: true], required: false, default_value: [{"source" => "SHEEP"}] + argument :product, [DairyProductInput, null: true], required: false, default_value: [{source: "SHEEP"}] argument :expires_after, Time, required: false end @@ -349,7 +354,7 @@ def all_animal COWS.values + GOATS.values end - field :all_animal_as_cow, [AnimalAsCow, null: true], null: false, method: :all_animal + field :all_animal_as_cow, [AnimalAsCow, null: true], null: false, resolver_method: :all_animal field :all_dairy, [DairyProduct, null: true], null: true do argument :execution_error_at_index, Integer, required: false @@ -367,7 +372,7 @@ def all_edible CHEESES.values + MILKS.values end - field :all_edible_as_milk, [EdibleAsMilk, null: true], null: true, method: :all_edible + field :all_edible_as_milk, [EdibleAsMilk, null: true], null: true, resolver_method: :all_edible field :error, String, null: true, description: "Raise an error" def error @@ -445,7 +450,7 @@ def push_value(val:) def replace_values(input:) GLOBAL_VALUES.clear - GLOBAL_VALUES.concat(input["values"]) + GLOBAL_VALUES.concat(input[:values]) GLOBAL_VALUES end end @@ -468,5 +473,8 @@ class Schema < GraphQL::Schema def self.resolve_type(type, obj, ctx) Schema.types[obj.class.name.split("::").last] end + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end end diff --git a/spec/support/jazz.rb b/spec/support/jazz.rb index 9b1f85a83b..5646bd4225 100644 --- a/spec/support/jazz.rb +++ b/spec/support/jazz.rb @@ -9,7 +9,7 @@ module Models Key = Struct.new(:root, :sharp, :flat) do def self.from_notation(key_str) key, sharp_or_flat = key_str.split("") - sharp = sharp_or_flat == "♯" + sharp = sharp_or_flat == "♯" flat = sharp_or_flat == "♭" Models::Key.new(key, sharp, flat) end @@ -36,7 +36,7 @@ def self.reset ], "Musician" => [ Models::Musician.new("Herbie Hancock", Models::Key.from_notation("B♭")), - ] + ], } end @@ -62,6 +62,7 @@ def to_graphql class BaseField < GraphQL::Schema::Field argument_class BaseArgument attr_reader :upcase + def initialize(*args, **options, &block) @upcase = options.delete(:upcase) super(*args, **options, &block) @@ -75,6 +76,15 @@ def resolve_field(*) result end end + + def resolve(*) + result = super + if @upcase && result + result.upcase + else + result + end + end end class BaseObject < GraphQL::Schema::Object @@ -92,7 +102,7 @@ def configs def to_graphql type_defn = super - configs.each do |k,v| + configs.each do |k, v| type_defn.metadata[k] = v end type_defn @@ -141,7 +151,7 @@ module GloballyIdentifiableType null: false, description: "A unique identifier for this object", ) - upcased_field :upcased_id, ID, null: false, method: :id # upcase: true added by helper + upcased_field :upcased_id, ID, null: false, resolver_method: :id # upcase: true added by helper def id GloballyIdentifiableType.to_id(@object) @@ -157,10 +167,9 @@ def self.find(id) end end - # A legacy-style interface used by new-style types - NamedEntity = GraphQL::InterfaceType.define do - name "NamedEntity" - field :name, !types.String + module NamedEntity + include BaseInterface + field :name, String, null: false end # test field inheritance @@ -178,12 +187,11 @@ module HasMusicians field :musicians, "[Jazz::Musician]", null: false end - # Here's a new-style GraphQL type definition class Ensemble < ObjectWithUpcasedName # Test string type names # This method should override inherited one - field :name, "String", null: false, method: :overridden_name + field :name, "String", null: false, resolver_method: :overridden_name implements GloballyIdentifiableType, NamedEntity, HasMusicians description "A group of musicians playing together" config :config, :configged @@ -215,19 +223,18 @@ class Family < BaseEnum # Lives side-by-side with an old-style definition using GraphQL::DeprecatedDSL # for ! and types[] - InstrumentType = GraphQL::ObjectType.define do - name "Instrument" - interfaces [NamedEntity] + + class InstrumentType < BaseObject + implements NamedEntity implements GloballyIdentifiableType - field :id, !types.ID, "A unique identifier for this object", resolve: ->(obj, args, ctx) { GloballyIdentifiableType.to_id(obj) } - field :upcasedId, !types.ID, resolve: ->(obj, args, ctx) { GloballyIdentifiableType.to_id(obj).upcase } - if RUBY_ENGINE == "jruby" - # JRuby doesn't support refinements, so the `using` above won't work - field :family, Family.to_non_null_type - else - field :family, !Family + field :upcased_id, ID, null: false + + def upcased_id + GloballyIdentifiableType.to_id(object).upcase end + + field :family, Family, null: false end class Key < GraphQL::Schema::Scalar @@ -262,6 +269,7 @@ class Musician < BaseObject # Test lists with nullable members: field :inspect_context, [String, null: true], null: false field :add_error, String, null: false, extras: [:execution_errors] + def inspect_context [ @context.custom_method, @@ -277,21 +285,22 @@ def add_error(execution_errors:) end end - LegacyInputType = GraphQL::InputObjectType.define do - name "LegacyInput" - argument :intValue, !types.Int + # Since this is not a legacy input type, this test can be removed + class LegacyInputType < GraphQL::Schema::InputObject + argument :int_value, Int, required: true end class InspectableInput < GraphQL::Schema::InputObject argument :string_value, String, required: true, description: "Test description kwarg" argument :nested_input, InspectableInput, required: false argument :legacy_input, LegacyInputType, required: false + def helper_method [ # Context is available in the InputObject context[:message], - # A GraphQL::Query::Arguments instance is available - arguments[:stringValue], + # ~~A GraphQL::Query::Arguments instance is available~~ not anymore + self[:string_value], # Legacy inputs have underscored method access too legacy_input ? legacy_input.int_value : "-", # Access by method call is available @@ -337,7 +346,10 @@ class Query < BaseObject field :inspect_key, InspectableKey, null: false do argument :key, Key, required: true end - field :nowPlaying, PerformingAct, null: false, resolve: ->(o, a, c) { Models.data["Ensemble"].first } + field :now_playing, PerformingAct, null: false + + def now_playing; Models.data["Ensemble"].first; end + # For asserting that the object is initialized once: field :object_id, Integer, null: false field :inspect_context, [String], null: false @@ -351,10 +363,10 @@ class Query < BaseObject argument :input, [RawJson], required: true end - field :upcase_check_1, String, null: true, method: :upcase_check, extras: [:upcase] - field :upcase_check_2, String, null: false, upcase: false, method: :upcase_check, extras: [:upcase] - field :upcase_check_3, String, null: false, upcase: true, method: :upcase_check, extras: [:upcase] - field :upcase_check_4, String, null: false, upcase: "why not?", method: :upcase_check, extras: [:upcase] + field :upcase_check_1, String, null: true, resolver_method: :upcase_check, extras: [:upcase] + field :upcase_check_2, String, null: false, upcase: false, resolver_method: :upcase_check, extras: [:upcase] + field :upcase_check_3, String, null: false, upcase: true, resolver_method: :upcase_check, extras: [:upcase] + field :upcase_check_4, String, null: false, upcase: "why not?", resolver_method: :upcase_check, extras: [:upcase] def upcase_check(upcase:) upcase.inspect end @@ -390,8 +402,8 @@ def inspect_input(input:) # Access by key: input[:string_value], input.key?(:string_value).to_s, - # Access by legacy key - input[:stringValue], + # ~~Access by legacy key~~ # not anymore + input[:string_value], ] end @@ -403,7 +415,7 @@ def inspect_context [ context.custom_method, context[:magic_key], - context[:normal_key] + context[:normal_key], ] end @@ -411,11 +423,11 @@ def hashy_ensemble # Both string and symbol keys are supported: { - name: "The Grateful Dead", - "musicians" => [ - OpenStruct.new(name: "Jerry Garcia"), - ], - "formedAtDate" => "May 5, 1965", + name: "The Grateful Dead", + "musicians" => [ + OpenStruct.new(name: "Jerry Garcia"), + ], + "formedAtDate" => "May 5, 1965", } end @@ -429,12 +441,13 @@ def echo_first_json(input:) field :hash_by_string, HashKeyTest, null: false field :hash_by_sym, HashKeyTest, null: false + def hash_by_string - { "falsey" => false } + {"falsey" => false} end def hash_by_sym - { falsey: false } + {falsey: false} end field :named_entities, [NamedEntity, null: true], null: false @@ -442,6 +455,20 @@ def hash_by_sym def named_entities [Models.data["Ensemble"].first, nil] end + + field :default_value_test, String, null: false do + if TESTING_INTERPRETER + argument :arg_with_default, InspectableInput, required: false, default_value: { string_value: "S" } + else + argument :arg_with_default, InspectableInput, required: false, default_value: { "stringValue" => "S" } + end + end + + def default_value_test(arg_with_default:) + "#{arg_with_default.class.name} -> #{arg_with_default.to_h}" + end + + field :complex_hash_key, String, null: false, hash_key: :'foo bar/fizz-buzz' end class EnsembleInput < GraphQL::Schema::InputObject @@ -462,10 +489,11 @@ class AddInstrument < GraphQL::Schema::Mutation field :ee, String, null: false extras [:execution_errors] + def resolve(name:, family:, execution_errors:) instrument = Jazz::Models::Instrument.new(name, family) Jazz::Models.data["Instrument"] << instrument - { instrument: instrument, entries: Jazz::Models.data["Instrument"], ee: execution_errors.class.name} + {instrument: instrument, entries: Jazz::Models.data["Instrument"], ee: execution_errors.class.name} end end @@ -477,7 +505,25 @@ class AddSitar < GraphQL::Schema::RelayClassicMutation def resolve instrument = Models::Instrument.new("Sitar", :str) - { instrument: instrument } + {instrument: instrument} + end + end + + class HasExtras < GraphQL::Schema::RelayClassicMutation + null true + description "Test extras in RelayClassicMutation" + + argument :int, Integer, required: false + extras [:ast_node] + + field :node_class, String, null: false + field :int, Integer, null: true + + def resolve(int: nil, ast_node:) + { + int: int, + node_class: ast_node.class.name, + } end end @@ -526,7 +572,7 @@ def resolve(ensemble:, new_name:) dup_ensemble = ensemble.dup dup_ensemble.name = new_name { - ensemble: dup_ensemble + ensemble: dup_ensemble, } end end @@ -589,6 +635,7 @@ class Mutation < BaseObject field :upvote_ensembles_as_bands, mutation: UpvoteEnsemblesAsBands field :upvote_ensembles_ids, mutation: UpvoteEnsemblesIds field :rename_ensemble_as_band, mutation: RenameEnsembleAsBand + field :has_extras, mutation: HasExtras def add_ensemble(input:) ens = Models::Ensemble.new(input.name) @@ -633,7 +680,8 @@ def custom_method module Introspection class TypeType < GraphQL::Introspection::TypeType def name - object.name.upcase + n = object.graphql_name + n && n.upcase end end @@ -653,6 +701,7 @@ class SchemaType < GraphQL::Introspection::SchemaType graphql_name "__Schema" field :is_jazzy, Boolean, null: false + def is_jazzy true end @@ -661,7 +710,8 @@ def is_jazzy class DynamicFields < GraphQL::Introspection::DynamicFields field :__typename_length, Int, null: false, extras: [:irep_node] field :__ast_node_class, String, null: false, extras: [:ast_node] - def __typename_length(irep_node:) + + def __typename_length(irep_node: nil) __typename(irep_node: irep_node).length end @@ -672,8 +722,13 @@ def __ast_node_class(ast_node:) class EntryPoints < GraphQL::Introspection::EntryPoints field :__classname, String, "The Ruby class name of the root object", null: false + def __classname - object.class.name + if context.interpreter? + object.object.class.name + else + object.class.name + end end end end @@ -693,5 +748,9 @@ def self.resolve_type(type, obj, ctx) def self.object_from_id(id, ctx) GloballyIdentifiableType.find(id) end + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + use GraphQL::Analysis::AST + end end end diff --git a/spec/support/lazy_helpers.rb b/spec/support/lazy_helpers.rb index fbc00ae0a0..b792bdcc92 100644 --- a/spec/support/lazy_helpers.rb +++ b/spec/support/lazy_helpers.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true module LazyHelpers + MAGIC_NUMBER_WITH_LAZY_AUTHORIZED_HOOK = 44 + MAGIC_NUMBER_THAT_RETURNS_NIL = 0 + MAGIC_NUMBER_THAT_RAISES_ERROR = 13 class Wrapper def initialize(item = nil, &block) if block @@ -47,13 +50,29 @@ def self.all end class LazySum < GraphQL::Schema::Object - field :value, Integer, null: true, resolve: ->(o, a, c) { o == 13 ? nil : o } + field :value, Integer, null: true + def value + if object == MAGIC_NUMBER_THAT_RAISES_ERROR + nil + else + object + end + end + + def self.authorized?(obj, ctx) + if obj == MAGIC_NUMBER_WITH_LAZY_AUTHORIZED_HOOK + Wrapper.new { true } + else + true + end + end + field :nestedSum, LazySum, null: false do argument :value, Integer, required: true end def nested_sum(value:) - if value == 13 + if value == MAGIC_NUMBER_THAT_RAISES_ERROR Wrapper.new(nil) else SumAll.new(@object + value) @@ -72,33 +91,42 @@ def nested_sum(value:) GraphQL::DeprecatedDSL.activate end - LazyQuery = GraphQL::ObjectType.define do - name "Query" - field :int, !types.Int do - argument :value, !types.Int - argument :plus, types.Int, default_value: 0 - resolve ->(o, a, c) { Wrapper.new(a[:value] + a[:plus])} + class LazyQuery < GraphQL::Schema::Object + field :int, Integer, null: false do + argument :value, Integer, required: true + argument :plus, Integer, required: false, default_value: 0 + end + def int(value:, plus:) + Wrapper.new(value + plus) end - field :nestedSum, !LazySum do - argument :value, !types.Int - resolve ->(o, args, c) { SumAll.new(args[:value]) } + field :nested_sum, LazySum, null: false do + argument :value, Integer, required: true end - field :nullableNestedSum, LazySum do - argument :value, types.Int - resolve ->(o, args, c) { - if args[:value] == 13 - Wrapper.new { raise GraphQL::ExecutionError.new("13 is unlucky") } - else - SumAll.new(args[:value]) - end - } + def nested_sum(value:) + SumAll.new(value) + end + + field :nullable_nested_sum, LazySum, null: true do + argument :value, Integer, required: true end - field :listSum, types[LazySum] do - argument :values, types[types.Int] - resolve ->(o, args, c) { args[:values] } + def nullable_nested_sum(value:) + if value == MAGIC_NUMBER_THAT_RAISES_ERROR + Wrapper.new { raise GraphQL::ExecutionError.new("#{MAGIC_NUMBER_THAT_RAISES_ERROR} is unlucky") } + elsif value == MAGIC_NUMBER_THAT_RETURNS_NIL + nil + else + SumAll.new(value) + end + end + + field :list_sum, [LazySum, null: true], null: true do + argument :values, [Integer], required: true + end + def list_sum(values:) + values.map { |v| v == MAGIC_NUMBER_THAT_RETURNS_NIL ? nil : v } end end @@ -143,6 +171,11 @@ class LazySchema < GraphQL::Schema instrument(:multiplex, SumAllInstrumentation.new(counter: 1)) instrument(:multiplex, SumAllInstrumentation.new(counter: 2)) + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + use GraphQL::Analysis::AST + end + def self.sync_lazy(lazy) if lazy.is_a?(SumAll) && lazy.own_value > 1000 lazy.value # clear the previous set @@ -153,7 +186,7 @@ def self.sync_lazy(lazy) end end - def run_query(query_str) - LazySchema.execute(query_str) + def run_query(query_str, **rest) + LazySchema.execute(query_str, **rest) end end diff --git a/spec/support/star_wars/schema.rb b/spec/support/star_wars/schema.rb index 34a9412de0..766b69730f 100644 --- a/spec/support/star_wars/schema.rb +++ b/spec/support/star_wars/schema.rb @@ -15,15 +15,16 @@ class BaseType < GraphQL::Schema::Object graphql_name "Base" implements GraphQL::Relay::Node.interface global_id_field :id - field :name, String, null: false, resolve: ->(obj, args, ctx) { + field :name, String, null: false + def name LazyWrapper.new { - if obj.id.nil? + if object.id.nil? raise GraphQL::ExecutionError, "Boom!" else - obj.name + object.name end } - } + end field :planet, String, null: true end @@ -86,18 +87,17 @@ def field_name end end - # Example of GraphQL::Function used with the connection helper: - class ShipsWithMaxPageSize < GraphQL::Function - argument :nameIncludes, GraphQL::STRING_TYPE - def call(obj, args, ctx) - all_ships = obj.ships.map { |ship_id| StarWars::DATA["Ship"][ship_id] } - if args[:nameIncludes] - all_ships = all_ships.select { |ship| ship.name.include?(args[:nameIncludes])} + class ShipsWithMaxPageSize < GraphQL::Schema::Resolver + argument :name_includes, String, required: false + type Ship.connection_type, null: true + + def resolve(name_includes: nil) + all_ships = object.ships.map { |ship_id| StarWars::DATA["Ship"][ship_id] } + if name_includes + all_ships = all_ships.select { |ship| ship.name.include?(name_includes)} end all_ships end - - type Ship.connection_type end class ShipConnectionWithParentType < GraphQL::Types::Relay::BaseConnection @@ -109,15 +109,33 @@ def parent_class_name end end + class ShipsByResolver < GraphQL::Schema::Resolver + type ShipConnectionWithParentType, null: false + + def resolve + object.ships.map { |ship_id| StarWars::DATA["Ship"][ship_id] } + end + end + class Faction < GraphQL::Schema::Object implements GraphQL::Relay::Node.interface - field :id, ID, null: false, resolve: GraphQL::Relay::GlobalIdResolve.new(type: Faction) + field :id, ID, null: false + def id + GraphQL::Relay::GlobalIdResolve.new(type: Faction).call(object, {}, context) + end + field :name, String, null: true - field :ships, ShipConnectionWithParentType, connection: true, max_page_size: 1000, null: true, resolve: ->(obj, args, ctx) { - all_ships = obj.ships.map {|ship_id| StarWars::DATA["Ship"][ship_id] } - if args[:nameIncludes] - case args[:nameIncludes] + field :ships, ShipConnectionWithParentType, connection: true, max_page_size: 1000, null: true do + argument :name_includes, String, required: false + end + + field :shipsByResolver, resolver: ShipsByResolver, connection: true + + def ships(name_includes: nil) + all_ships = object.ships.map {|ship_id| StarWars::DATA["Ship"][ship_id] } + if name_includes + case name_includes when "error" all_ships = GraphQL::ExecutionError.new("error from within connection") when "raisedError" @@ -132,29 +150,28 @@ class Faction < GraphQL::Schema::Object prev_all_ships = all_ships all_ships = LazyWrapper.new { prev_all_ships } else - all_ships = all_ships.select { |ship| ship.name.include?(args[:nameIncludes])} + all_ships = all_ships.select { |ship| ship.name.include?(name_includes)} end end all_ships - } do - # You can define arguments here and use them in the connection - argument :nameIncludes, String, required: false end - field :shipsWithMaxPageSize, "Ships with max page size", max_page_size: 2, function: ShipsWithMaxPageSize.new + field :shipsWithMaxPageSize, "Ships with max page size", max_page_size: 2, resolver: ShipsWithMaxPageSize - field :bases, BasesConnectionWithTotalCountType, null: true, connection: true, resolve: ->(obj, args, ctx) { - all_bases = Base.where(id: obj.bases) - if args[:nameIncludes] - all_bases = all_bases.where("name LIKE ?", "%#{args[:nameIncludes]}%") + field :bases, BasesConnectionWithTotalCountType, null: true, connection: true do + argument :nameIncludes, String, required: false + argument :complexOrder, Boolean, required: false + end + + def bases(name_includes: nil, complex_order: nil) + all_bases = Base.where(id: object.bases) + if name_includes + all_bases = all_bases.where("name LIKE ?", "%#{name_includes}%") end - if args[:complexOrder] + if complex_order all_bases = all_bases.order("bases.name DESC") end all_bases - } do - argument :nameIncludes, String, required: false - argument :complexOrder, Boolean, required: false end field :basesClone, BaseConnection, null: true @@ -169,12 +186,20 @@ def bases_by_name(order: nil) end end - field :basesWithMaxLimitRelation, BaseConnection, null: true, max_page_size: 2, resolve: Proc.new { Base.all} - field :basesWithMaxLimitArray, BaseConnection, null: true, max_page_size: 2, resolve: Proc.new { Base.all.to_a } - field :basesWithDefaultMaxLimitRelation, BaseConnection, null: true, resolve: Proc.new { Base.all } - field :basesWithDefaultMaxLimitArray, BaseConnection, null: true, resolve: Proc.new { Base.all.to_a } - field :basesWithLargeMaxLimitRelation, BaseConnection, null: true, max_page_size: 1000, resolve: Proc.new { Base.all } - field :basesWithoutNodes, BaseConnectionWithoutNodes, null: true, resolve: Proc.new { Base.all.to_a } + def all_bases + Base.all + end + + def all_bases_array + all_bases.to_a + end + + field :basesWithMaxLimitRelation, BaseConnection, null: true, max_page_size: 2, resolver_method: :all_bases + field :basesWithMaxLimitArray, BaseConnection, null: true, max_page_size: 2, resolver_method: :all_bases_array + field :basesWithDefaultMaxLimitRelation, BaseConnection, null: true, resolver_method: :all_bases + field :basesWithDefaultMaxLimitArray, BaseConnection, null: true, resolver_method: :all_bases_array + field :basesWithLargeMaxLimitRelation, BaseConnection, null: true, max_page_size: 1000, resolver_method: :all_bases + field :basesWithoutNodes, BaseConnectionWithoutNodes, null: true, resolver_method: :all_bases_array field :basesAsSequelDataset, BasesConnectionWithTotalCountType, null: true, connection: true, max_page_size: 1000 do argument :nameIncludes, String, required: false @@ -188,7 +213,11 @@ def bases_as_sequel_dataset(name_includes: nil) all_bases end - field :basesWithCustomEdge, CustomEdgeBaseConnectionType, null: true, connection: true, resolve: ->(o, a, c) { LazyNodesWrapper.new(o.bases) } + field :basesWithCustomEdge, CustomEdgeBaseConnectionType, null: true, connection: true, resolver_method: :lazy_bases + + def lazy_bases + LazyNodesWrapper.new(object.bases) + end end class IntroduceShipMutation < GraphQL::Schema::RelayClassicMutation @@ -204,26 +233,6 @@ class IntroduceShipMutation < GraphQL::Schema::RelayClassicMutation field :aliased_faction, Faction, hash_key: :aliased_faction, null: true def resolve(ship_name: nil, faction_id:) - IntroduceShipFunction.new.call(object, {ship_name: ship_name, faction_id: faction_id}, context) - end - end - - class IntroduceShipFunction < GraphQL::Function - description "Add a ship to this faction" - - argument :shipName, GraphQL::STRING_TYPE - argument :factionId, !GraphQL::ID_TYPE - - type(GraphQL::ObjectType.define do - name "IntroduceShipFunctionPayload" - field :shipEdge, Ship.edge_type, hash_key: :shipEdge - field :faction, Faction, hash_key: :shipEdge - end) - - def call(obj, args, ctx) - # support old and new args - ship_name = args["shipName"] || args[:ship_name] - faction_id = args["factionId"] || args[:faction_id] if ship_name == 'Millennium Falcon' GraphQL::ExecutionError.new("Sorry, Millennium Falcon ship is reserved") elsif ship_name == 'Leviathan' @@ -234,15 +243,14 @@ def call(obj, args, ctx) ship = DATA.create_ship(ship_name, faction_id) faction = DATA["Faction"][faction_id] connection_class = GraphQL::Relay::BaseConnection.connection_for_nodes(faction.ships) - ships_connection = connection_class.new(faction.ships, args) + ships_connection = connection_class.new(faction.ships, {ship_name: ship_name, faction: faction}) ship_edge = GraphQL::Relay::Edge.new(ship, ships_connection) result = { - shipEdge: ship_edge, ship_edge: ship_edge, # support new-style, too faction: faction, aliased_faction: faction, } - if args["shipName"] == "Slave II" + if ship_name == "Slave II" LazyWrapper.new(result) else result @@ -251,12 +259,6 @@ def call(obj, args, ctx) end end - IntroduceShipFunctionMutation = GraphQL::Relay::Mutation.define do - # Used as the root for derived types: - name "IntroduceShipFunction" - function IntroduceShipFunction.new - end - # GraphQL-Batch knockoff class LazyLoader def self.defer(ctx, model, id) @@ -318,34 +320,75 @@ def edge_nodes class QueryType < GraphQL::Schema::Object graphql_name "Query" - field :rebels, Faction, null: true, resolve: ->(obj, args, ctx) { StarWars::DATA["Faction"]["1"]} + field :rebels, Faction, null: true + def rebels + StarWars::DATA["Faction"]["1"] + end - field :empire, Faction, null: true, resolve: ->(obj, args, ctx) { StarWars::DATA["Faction"]["2"]} + field :empire, Faction, null: true + def empire + StarWars::DATA["Faction"]["2"] + end + + field :largestBase, BaseType, null: true + + def largest_base + Base.find(3) + end - field :largestBase, BaseType, null: true, resolve: ->(obj, args, ctx) { Base.find(3) } + field :newestBasesGroupedByFaction, BaseConnection, null: true - field :newestBasesGroupedByFaction, BaseConnection, null: true, resolve: ->(obj, args, ctx) { + def newest_bases_grouped_by_faction Base .having('id in (select max(id) from bases group by faction_id)') .group(:id) .order('faction_id desc') - } + end + + field :basesWithNullName, BaseConnection, null: false - field :basesWithNullName, BaseConnection, null: false, resolve: ->(obj, args, ctx) { + def bases_with_null_name [OpenStruct.new(id: nil)] - } + end - field :node, field: GraphQL::Relay::Node.field + if TESTING_INTERPRETER + add_field(GraphQL::Types::Relay::NodeField) + else + field :node, field: GraphQL::Relay::Node.field + end + + if TESTING_INTERPRETER + field :node_with_custom_resolver, GraphQL::Types::Relay::Node, null: true do + argument :id, ID, required: true + end + def node_with_custom_resolver(id:) + StarWars::DATA["Faction"]["1"] + end + else + custom_node_field = GraphQL::Relay::Node.field do + resolve ->(_, _, _) { StarWars::DATA["Faction"]["1"] } + end + field :nodeWithCustomResolver, field: custom_node_field + end - custom_node_field = GraphQL::Relay::Node.field do - resolve ->(_, _, _) { StarWars::DATA["Faction"]["1"] } + if TESTING_INTERPRETER + add_field(GraphQL::Types::Relay::NodesField) + else + field :nodes, field: GraphQL::Relay::Node.plural_field end - field :nodeWithCustomResolver, field: custom_node_field - field :nodes, field: GraphQL::Relay::Node.plural_field - field :nodesWithCustomResolver, field: GraphQL::Relay::Node.plural_field( - resolve: ->(_, _, _) { [StarWars::DATA["Faction"]["1"], StarWars::DATA["Faction"]["2"]] } - ) + if TESTING_INTERPRETER + field :nodes_with_custom_resolver, [GraphQL::Types::Relay::Node, null: true], null: true do + argument :ids, [ID], required: true + end + def nodes_with_custom_resolver(ids:) + [StarWars::DATA["Faction"]["1"], StarWars::DATA["Faction"]["2"]] + end + else + field :nodesWithCustomResolver, field: GraphQL::Relay::Node.plural_field( + resolve: ->(_, _, _) { [StarWars::DATA["Faction"]["1"], StarWars::DATA["Faction"]["2"]] } + ) + end field :batchedBase, BaseType, null: true do argument :id, ID, required: true @@ -359,7 +402,6 @@ def batched_base(id:) class MutationType < GraphQL::Schema::Object graphql_name "Mutation" field :introduceShip, mutation: IntroduceShipMutation - field :introduceShipFunction, field: IntroduceShipFunctionMutation.field end class ClassNameRecorder @@ -387,6 +429,10 @@ class Schema < GraphQL::Schema mutation(MutationType) default_max_page_size 3 + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end + def self.resolve_type(type, object, ctx) if object == :test_error :not_a_type diff --git a/spec/support/static_validation_helpers.rb b/spec/support/static_validation_helpers.rb index 5f284088e8..6a182c0ef5 100644 --- a/spec/support/static_validation_helpers.rb +++ b/spec/support/static_validation_helpers.rb @@ -12,10 +12,12 @@ # end module StaticValidationHelpers def errors - target_schema = schema - validator = GraphQL::StaticValidation::Validator.new(schema: target_schema) - query = GraphQL::Query.new(target_schema, query_string) - validator.validate(query)[:errors].map(&:to_h) + @errors ||= begin + target_schema = schema + validator = GraphQL::StaticValidation::Validator.new(schema: target_schema) + query = GraphQL::Query.new(target_schema, query_string) + validator.validate(query)[:errors].map(&:to_h) + end end def error_messages