From d1454e9c630396f4b7f758e669f93c9cad612b30 Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Tue, 22 Nov 2016 11:03:24 -0500 Subject: [PATCH] Require loaders to be used within a GraphQL::Batch.batch block (#36) This is needed to avoid global state leaking between requests/tests. --- README.md | 12 +- lib/graphql/batch.rb | 11 + lib/graphql/batch/execution_strategy.rb | 9 +- lib/graphql/batch/executor.rb | 6 +- test/batch_test.rb | 351 +----------------------- test/executor_test.rb | 8 + test/graphql_test.rb | 343 +++++++++++++++++++++++ test/loader_test.rb | 5 +- 8 files changed, 398 insertions(+), 347 deletions(-) create mode 100644 test/graphql_test.rb diff --git a/README.md b/README.md index c6164de..e2e40a5 100644 --- a/README.md +++ b/README.md @@ -109,13 +109,19 @@ end ## Unit Testing -Promise#sync can be used to wait for a promise to be resolved and return its result. This can be useful for debugging and unit testing loaders. +Your loaders can be tested outside of a GraphQL query by doing the +batch loads in a block passed to GraphQL::Batch.batch. That method +will set up thread-local state to store the loaders, batch load any +promise returned from the block then clear the thread-local state +to avoid leaking state between tests. ```ruby def test_single_query product = products(:snowboard) - query = RecordLoader.for(Product).load(args["id"]).then(&:title) - assert_equal product.title, query.sync + title = GraphQL::Batch.batch do + RecordLoader.for(Product).load(product.id).then(&:title) + end + assert_equal product.title, title end ``` diff --git a/lib/graphql/batch.rb b/lib/graphql/batch.rb index f1755a2..3cf8486 100644 --- a/lib/graphql/batch.rb +++ b/lib/graphql/batch.rb @@ -4,6 +4,17 @@ module GraphQL module Batch BrokenPromiseError = ::Promise::BrokenError + class NestedError < StandardError; end + + def self.batch + raise NestedError if GraphQL::Batch::Executor.current + begin + GraphQL::Batch::Executor.current = GraphQL::Batch::Executor.new + Promise.sync(yield) + ensure + GraphQL::Batch::Executor.current = nil + end + end end end diff --git a/lib/graphql/batch/execution_strategy.rb b/lib/graphql/batch/execution_strategy.rb index 004897a..221e9ae 100644 --- a/lib/graphql/batch/execution_strategy.rb +++ b/lib/graphql/batch/execution_strategy.rb @@ -1,15 +1,16 @@ module GraphQL::Batch class ExecutionStrategy < GraphQL::Query::SerialExecution def execute(_, _, query) - deep_sync(super) + GraphQL::Batch.batch do + as_promise_unless_resolved(super) + end rescue GraphQL::InvalidNullError => err err.parent_error? || query.context.errors.push(err) nil - ensure - GraphQL::Batch::Executor.current.clear end - def deep_sync(result) + # Needed for MutationExecutionStrategy + def deep_sync(result) #:nodoc: Promise.sync(as_promise_unless_resolved(result)) end diff --git a/lib/graphql/batch/executor.rb b/lib/graphql/batch/executor.rb index 5eae601..e9ff2ff 100644 --- a/lib/graphql/batch/executor.rb +++ b/lib/graphql/batch/executor.rb @@ -4,7 +4,11 @@ class Executor private_constant :THREAD_KEY def self.current - Thread.current[THREAD_KEY] ||= new + Thread.current[THREAD_KEY] + end + + def self.current=(executor) + Thread.current[THREAD_KEY] = executor end attr_reader :loaders diff --git a/test/batch_test.rb b/test/batch_test.rb index 36f5c48..06f0db9 100644 --- a/test/batch_test.rb +++ b/test/batch_test.rb @@ -1,343 +1,18 @@ require_relative 'test_helper' class GraphQL::BatchTest < Minitest::Test - attr_reader :queries - - def setup - @queries = [] - QueryNotifier.subscriber = ->(query) { @queries << query } - end - - def teardown - QueryNotifier.subscriber = nil - end - - def test_no_queries - query_string = '{ constant }' - result = Schema.execute(query_string) - expected = { - "data" => { - "constant" => "constant value" - } - } - assert_equal expected, result - assert_equal [], queries - end - - def test_single_query - query_string = <<-GRAPHQL - { - product(id: "1") { - id - title - } - } - GRAPHQL - result = Schema.execute(query_string) - expected = { - "data" => { - "product" => { - "id" => "1", - "title" => "Shirt", - } - } - } - assert_equal expected, result - assert_equal ["Product/1"], queries - end - - def test_batched_find_by_id - query_string = <<-GRAPHQL - { - product1: product(id: "1") { id, title } - product2: product(id: "2") { id, title } - } - GRAPHQL - result = Schema.execute(query_string) - expected = { - "data" => { - "product1" => { "id" => "1", "title" => "Shirt" }, - "product2" => { "id" => "2", "title" => "Pants" }, - } - } - assert_equal expected, result - assert_equal ["Product/1,2"], queries - end - - def test_record_missing - query_string = <<-GRAPHQL - { - product(id: "123") { - id - title - } - } - GRAPHQL - result = Schema.execute(query_string) - expected = { "data" => { "product" => nil } } - assert_equal expected, result - assert_equal ["Product/123"], queries - end - - def test_non_null_field_that_raises_on_nullable_parent - query_string = <<-GRAPHQL - { - product(id: "1") { - id - nonNullButRaises - } - } - GRAPHQL - result = Schema.execute(query_string) - expected = { 'data' => { 'product' => nil }, 'errors' => [{ 'message' => 'Error', 'locations' => [{ 'line' => 4, 'column' => 11 }], 'path' => ['product', 'nonNullButRaises'] }] } - assert_equal expected, result - end - - def test_non_null_field_that_raises_on_query_root - query_string = <<-GRAPHQL - { - nonNullButRaises { - id - } - } - GRAPHQL - result = Schema.execute(query_string) - expected = { 'data' => nil, 'errors' => [{ 'message' => 'Error', 'locations' => [{ 'line' => 2, 'column' => 9 }], 'path' => ['nonNullButRaises'] }] } - assert_equal expected, result - end - - def test_non_null_field_promise_raises - result = Schema.execute('{ nonNullButPromiseRaises }') - expected = { 'data' => nil, 'errors' => [{ 'message' => 'Error', 'locations' => [{ 'line' => 1, 'column' => 3 }], 'path' => ['nonNullButPromiseRaises'] }] } - assert_equal expected, result - end - - def test_batched_association_preload - query_string = <<-GRAPHQL - { - products(first: 2) { - id - title - variants { - id - title - } - } - } - GRAPHQL - result = Schema.execute(query_string) - expected = { - "data" => { - "products" => [ - { - "id" => "1", - "title" => "Shirt", - "variants" => [ - { "id" => "1", "title" => "Red" }, - { "id" => "2", "title" => "Blue" }, - ], - }, - { - "id" => "2", - "title" => "Pants", - "variants" => [ - { "id" => "4", "title" => "Small" }, - { "id" => "5", "title" => "Medium" }, - { "id" => "6", "title" => "Large" }, - ], - } - ] - } - } - assert_equal expected, result - assert_equal ["Product?limit=2", "Product/1,2/variants"], queries - end - - def test_query_group_with_single_query - query_string = <<-GRAPHQL - { - products(first: 2) { - id - title - variants_count - variants { - id - title - } - } - } - GRAPHQL - result = Schema.execute(query_string) - expected = { - "data" => { - "products" => [ - { - "id" => "1", - "title" => "Shirt", - "variants_count" => 2, - "variants" => [ - { "id" => "1", "title" => "Red" }, - { "id" => "2", "title" => "Blue" }, - ], - }, - { - "id" => "2", - "title" => "Pants", - "variants_count" => 3, - "variants" => [ - { "id" => "4", "title" => "Small" }, - { "id" => "5", "title" => "Medium" }, - { "id" => "6", "title" => "Large" }, - ], - } - ] - } - } - assert_equal expected, result - assert_equal ["Product?limit=2", "Product/1,2/variants"], queries - end - - def test_sub_queries - query_string = <<-GRAPHQL - { - product_variants_count(id: "2") - } - GRAPHQL - result = Schema.execute(query_string) - expected = { - "data" => { - "product_variants_count" => 3 - } - } - assert_equal expected, result - assert_equal ["Product/2", "Product/2/variants"], queries - end - - def test_query_group_with_sub_queries - query_string = <<-GRAPHQL - { - product(id: "1") { - images { id, filename } - } - } - GRAPHQL - result = Schema.execute(query_string) - expected = { - "data" => { - "product" => { - "images" => [ - { "id" => "1", "filename" => "shirt.jpg" }, - { "id" => "4", "filename" => "red-shirt.jpg" }, - { "id" => "5", "filename" => "blue-shirt.jpg" }, - ] - } - } - } - assert_equal expected, result - assert_equal ["Product/1", "Image/1", "Product/1/variants", "ProductVariant/1,2/images"], queries - end - - def test_load_list_of_objects_with_loaded_field - query_string = <<-GRAPHQL - { - products(first: 2) { - id - variants { - id - image_ids - } - } - } - GRAPHQL - result = Schema.execute(query_string) - expected = { - "data" => { - "products" => [ - { - "id" => "1", - "variants" => [ - { "id" => "1", "image_ids" => ["4"] }, - { "id" => "2", "image_ids" => ["5"] }, - ], - }, - { - "id" => "2", - "variants" => [ - { "id" => "4", "image_ids" => [] }, - { "id" => "5", "image_ids" => [] }, - { "id" => "6", "image_ids" => [] }, - ], - } - ] - } - } - assert_equal expected, result - assert_equal ["Product?limit=2", "Product/1,2/variants", "ProductVariant/1,2,4,5,6/images"], queries - end - - def test_load_error - query_string = <<-GRAPHQL - { - constant - load_execution_error - } - GRAPHQL - result = Schema.execute(query_string) - expected = { - "data" => { "constant"=>"constant value", "load_execution_error" => nil }, - "errors" => [{ "message" => "test error message", "locations"=>[{"line"=>3, "column"=>9}], "path" => ["load_execution_error"] }], - } - assert_equal expected, result - end - - def test_mutation_execution - query_string = <<-GRAPHQL - mutation { - count1: counter_loader - incr1: increment_counter { value, load_value } - count2: counter_loader - incr2: increment_counter { value, load_value } - } - GRAPHQL - result = Schema.execute(query_string, context: { counter: [0] }) - expected = { - "data" => { - "count1" => 0, - "incr1" => { "value" => 1, "load_value" => 1 }, - "count2" => 1, - "incr2" => { "value" => 2, "load_value" => 2 }, - } - } - assert_equal expected, result - end - - def test_mutation_batch_subselection_execution - query_string = <<-GRAPHQL - mutation { - mutation1: no_op { - product1: product(id: "1") { id, title } - product2: product(id: "2") { id, title } - } - mutation2: no_op { - product1: product(id: "2") { id, title } - product2: product(id: "3") { id, title } - } - } - GRAPHQL - result = Schema.execute(query_string) - expected = { - "data" => { - "mutation1" => { - "product1" => { "id" => "1", "title" => "Shirt" }, - "product2" => { "id" => "2", "title" => "Pants" }, - }, - "mutation2" => { - "product1" => { "id" => "2", "title" => "Pants" }, - "product2" => { "id" => "3", "title" => "Sweater" }, - } - } - } - assert_equal expected, result - assert_equal ["Product/1,2", "Product/2,3"], queries + def test_batch + product = GraphQL::Batch.batch do + RecordLoader.for(Product).load(1) + end + assert_equal 'Shirt', product.title + end + + def test_nested_batch + GraphQL::Batch.batch do + assert_raises(GraphQL::Batch::NestedError) do + GraphQL::Batch.batch {} + end + end end end diff --git a/test/executor_test.rb b/test/executor_test.rb index 527a1a7..c21e0bd 100644 --- a/test/executor_test.rb +++ b/test/executor_test.rb @@ -1,6 +1,14 @@ require_relative 'test_helper' class GraphQL::Batch::ExecutorTest < Minitest::Test + def setup + GraphQL::Batch::Executor.current = GraphQL::Batch::Executor.new + end + + def teardown + GraphQL::Batch::Executor.current = nil + end + def test_loading_flag_when_not_loading assert_equal false, GraphQL::Batch::Executor.current.loading end diff --git a/test/graphql_test.rb b/test/graphql_test.rb new file mode 100644 index 0000000..4fdc9b3 --- /dev/null +++ b/test/graphql_test.rb @@ -0,0 +1,343 @@ +require_relative 'test_helper' + +class GraphQL::GraphQLTest < Minitest::Test + attr_reader :queries + + def setup + @queries = [] + QueryNotifier.subscriber = ->(query) { @queries << query } + end + + def teardown + QueryNotifier.subscriber = nil + end + + def test_no_queries + query_string = '{ constant }' + result = Schema.execute(query_string) + expected = { + "data" => { + "constant" => "constant value" + } + } + assert_equal expected, result + assert_equal [], queries + end + + def test_single_query + query_string = <<-GRAPHQL + { + product(id: "1") { + id + title + } + } + GRAPHQL + result = Schema.execute(query_string) + expected = { + "data" => { + "product" => { + "id" => "1", + "title" => "Shirt", + } + } + } + assert_equal expected, result + assert_equal ["Product/1"], queries + end + + def test_batched_find_by_id + query_string = <<-GRAPHQL + { + product1: product(id: "1") { id, title } + product2: product(id: "2") { id, title } + } + GRAPHQL + result = Schema.execute(query_string) + expected = { + "data" => { + "product1" => { "id" => "1", "title" => "Shirt" }, + "product2" => { "id" => "2", "title" => "Pants" }, + } + } + assert_equal expected, result + assert_equal ["Product/1,2"], queries + end + + def test_record_missing + query_string = <<-GRAPHQL + { + product(id: "123") { + id + title + } + } + GRAPHQL + result = Schema.execute(query_string) + expected = { "data" => { "product" => nil } } + assert_equal expected, result + assert_equal ["Product/123"], queries + end + + def test_non_null_field_that_raises_on_nullable_parent + query_string = <<-GRAPHQL + { + product(id: "1") { + id + nonNullButRaises + } + } + GRAPHQL + result = Schema.execute(query_string) + expected = { 'data' => { 'product' => nil }, 'errors' => [{ 'message' => 'Error', 'locations' => [{ 'line' => 4, 'column' => 11 }], 'path' => ['product', 'nonNullButRaises'] }] } + assert_equal expected, result + end + + def test_non_null_field_that_raises_on_query_root + query_string = <<-GRAPHQL + { + nonNullButRaises { + id + } + } + GRAPHQL + result = Schema.execute(query_string) + expected = { 'data' => nil, 'errors' => [{ 'message' => 'Error', 'locations' => [{ 'line' => 2, 'column' => 9 }], 'path' => ['nonNullButRaises'] }] } + assert_equal expected, result + end + + def test_non_null_field_promise_raises + result = Schema.execute('{ nonNullButPromiseRaises }') + expected = { 'data' => nil, 'errors' => [{ 'message' => 'Error', 'locations' => [{ 'line' => 1, 'column' => 3 }], 'path' => ['nonNullButPromiseRaises'] }] } + assert_equal expected, result + end + + def test_batched_association_preload + query_string = <<-GRAPHQL + { + products(first: 2) { + id + title + variants { + id + title + } + } + } + GRAPHQL + result = Schema.execute(query_string) + expected = { + "data" => { + "products" => [ + { + "id" => "1", + "title" => "Shirt", + "variants" => [ + { "id" => "1", "title" => "Red" }, + { "id" => "2", "title" => "Blue" }, + ], + }, + { + "id" => "2", + "title" => "Pants", + "variants" => [ + { "id" => "4", "title" => "Small" }, + { "id" => "5", "title" => "Medium" }, + { "id" => "6", "title" => "Large" }, + ], + } + ] + } + } + assert_equal expected, result + assert_equal ["Product?limit=2", "Product/1,2/variants"], queries + end + + def test_query_group_with_single_query + query_string = <<-GRAPHQL + { + products(first: 2) { + id + title + variants_count + variants { + id + title + } + } + } + GRAPHQL + result = Schema.execute(query_string) + expected = { + "data" => { + "products" => [ + { + "id" => "1", + "title" => "Shirt", + "variants_count" => 2, + "variants" => [ + { "id" => "1", "title" => "Red" }, + { "id" => "2", "title" => "Blue" }, + ], + }, + { + "id" => "2", + "title" => "Pants", + "variants_count" => 3, + "variants" => [ + { "id" => "4", "title" => "Small" }, + { "id" => "5", "title" => "Medium" }, + { "id" => "6", "title" => "Large" }, + ], + } + ] + } + } + assert_equal expected, result + assert_equal ["Product?limit=2", "Product/1,2/variants"], queries + end + + def test_sub_queries + query_string = <<-GRAPHQL + { + product_variants_count(id: "2") + } + GRAPHQL + result = Schema.execute(query_string) + expected = { + "data" => { + "product_variants_count" => 3 + } + } + assert_equal expected, result + assert_equal ["Product/2", "Product/2/variants"], queries + end + + def test_query_group_with_sub_queries + query_string = <<-GRAPHQL + { + product(id: "1") { + images { id, filename } + } + } + GRAPHQL + result = Schema.execute(query_string) + expected = { + "data" => { + "product" => { + "images" => [ + { "id" => "1", "filename" => "shirt.jpg" }, + { "id" => "4", "filename" => "red-shirt.jpg" }, + { "id" => "5", "filename" => "blue-shirt.jpg" }, + ] + } + } + } + assert_equal expected, result + assert_equal ["Product/1", "Image/1", "Product/1/variants", "ProductVariant/1,2/images"], queries + end + + def test_load_list_of_objects_with_loaded_field + query_string = <<-GRAPHQL + { + products(first: 2) { + id + variants { + id + image_ids + } + } + } + GRAPHQL + result = Schema.execute(query_string) + expected = { + "data" => { + "products" => [ + { + "id" => "1", + "variants" => [ + { "id" => "1", "image_ids" => ["4"] }, + { "id" => "2", "image_ids" => ["5"] }, + ], + }, + { + "id" => "2", + "variants" => [ + { "id" => "4", "image_ids" => [] }, + { "id" => "5", "image_ids" => [] }, + { "id" => "6", "image_ids" => [] }, + ], + } + ] + } + } + assert_equal expected, result + assert_equal ["Product?limit=2", "Product/1,2/variants", "ProductVariant/1,2,4,5,6/images"], queries + end + + def test_load_error + query_string = <<-GRAPHQL + { + constant + load_execution_error + } + GRAPHQL + result = Schema.execute(query_string) + expected = { + "data" => { "constant"=>"constant value", "load_execution_error" => nil }, + "errors" => [{ "message" => "test error message", "locations"=>[{"line"=>3, "column"=>9}], "path" => ["load_execution_error"] }], + } + assert_equal expected, result + end + + def test_mutation_execution + query_string = <<-GRAPHQL + mutation { + count1: counter_loader + incr1: increment_counter { value, load_value } + count2: counter_loader + incr2: increment_counter { value, load_value } + } + GRAPHQL + result = Schema.execute(query_string, context: { counter: [0] }) + expected = { + "data" => { + "count1" => 0, + "incr1" => { "value" => 1, "load_value" => 1 }, + "count2" => 1, + "incr2" => { "value" => 2, "load_value" => 2 }, + } + } + assert_equal expected, result + end + + def test_mutation_batch_subselection_execution + query_string = <<-GRAPHQL + mutation { + mutation1: no_op { + product1: product(id: "1") { id, title } + product2: product(id: "2") { id, title } + } + mutation2: no_op { + product1: product(id: "2") { id, title } + product2: product(id: "3") { id, title } + } + } + GRAPHQL + result = Schema.execute(query_string) + expected = { + "data" => { + "mutation1" => { + "product1" => { "id" => "1", "title" => "Shirt" }, + "product2" => { "id" => "2", "title" => "Pants" }, + }, + "mutation2" => { + "product1" => { "id" => "2", "title" => "Pants" }, + "product2" => { "id" => "3", "title" => "Sweater" }, + } + } + } + assert_equal expected, result + assert_equal ["Product/1,2", "Product/2,3"], queries + end +end diff --git a/test/loader_test.rb b/test/loader_test.rb index caf7661..c49e30d 100644 --- a/test/loader_test.rb +++ b/test/loader_test.rb @@ -33,9 +33,12 @@ def cache_key(load_key) end end + def setup + GraphQL::Batch::Executor.current = GraphQL::Batch::Executor.new + end def teardown - GraphQL::Batch::Executor.current.clear + GraphQL::Batch::Executor.current = nil end