graphql-ruby
graphql-ruby copied to clipboard
Support Lazy Enumerators with Stream
Currently @stream will defer child selections on list items, but all items are enumerated before any child selections are run.
A better workflow might be that, when a @streamed list is resolved, there's no call to .each right away. Instead, and empty list is put in the response, and then each deferral gets the item it needs and resolves its subfields.
Here's a script to demonstrate the behavior in question:
`@stream` with lazy enumeration
require "bundler/inline"
gemfile do
gem "graphql", path: "./" # "~>2.2.0"
gem "graphql-pro", "~>1.0"
end
class MySchema < GraphQL::Schema
class LazyItems
DATA = [
OpenStruct.new(name: "Stopwatch"),
OpenStruct.new(name: "Necktie"),
OpenStruct.new(name: "Suitcase"),
OpenStruct.new(name: "Chopstick"),
OpenStruct.new(name: "Peanut"),
OpenStruct.new(name: "Screwdriver"),
]
def each
idx = 0
while idx < DATA.length
item = DATA[idx]
puts "Yielding #{idx}: #{item.name}"
yield(item)
idx += 1
end
self
end
end
class Item < GraphQL::Schema::Object
field :name, String
def name
puts "Resolving Item#name: #{object.name}"
object.name
end
end
class Query < GraphQL::Schema::Object
field :items, [Item]
def items
puts "Resolving items"
LazyItems.new
end
end
query(Query)
use GraphQL::Pro::Stream
end
query_str = "{ items @stream { name } }"
res = MySchema.execute(query_str)
res.context[:defer].each do |deferral|
deferral.to_h
end
pp res.to_h
It should alternate between yield and resolve messages, but instead, it does all the yields then all the resolves.
If the implementation supported lazy enumeration, then you could use @stream to break up database requests into smaller chunks using a cursor.
We're currently looking into using streaming more for our APIs for the https://shop.app. Because the current lack of support for lazy enumerators, per this issue, we have all such APIs separate from our main APIs, and don't use GraphQL. Big ❤️ for this.
Hey, thanks for chiming in, @tgwizard. I started looking into the implementation for this and determined it was going to be ... tricky. It's still definitely my goal ... but no low-hanging fruit here 😅
@rmosolgo yeah, I got that from the trampolining comment in the PR 😄 However I can help I'd be happy to do it.
In GraphQL-Pro v1.29.7, GraphQL::Pro::FutureStream supports lazy enumerators with GraphQL v2.5.6+. To use it, add to your schema:
# Add the @stream directive
use GraphQL::Pro::FutureStream
Then, for any field where you want to support @stream, add streamable: true:
field :users, [User], streamable: true
You could also make all list fields streamable:
class Types::BaseField < GraphQL::Schema::Field
def initialize(*args, streamable: nil, **kwargs, &block)
super(*args, **kwargs, &block)
if streamable == true ||
(streamable.nil? && (type.list? || (type.non_null? && type.of_type.list?)))
extension(GraphQL::Pro::FutureStream::StreamableExtension)
end
end
end
Probably the next release will have a straightforward API for that.
After that, streamable fields that return lazy enumerators will properly stream items without resolving the whole list!
I updated the script above and confirmed it now outputs the expected thing:
Resolving items
Yielding 0: Stopwatch
Yielding 1: Necktie
Resolving Item#name: Stopwatch
Yielding 2: Suitcase
Resolving Item#name: Necktie
Yielding 3: Chopstick
Resolving Item#name: Suitcase
Yielding 4: Peanut
Resolving Item#name: Chopstick
Yielding 5: Screwdriver
Resolving Item#name: Peanut
Resolving Item#name: Screwdriver
{"data" =>
{"items" =>
[{"name" => "Stopwatch"},
{"name" => "Necktie"},
{"name" => "Suitcase"},
{"name" => "Chopstick"},
{"name" => "Peanut"},
{"name" => "Screwdriver"}]}}
It has Yielding 0 and Yielding 1 right after one another because FutureStream uses enumerator.peek to determine if hasNext should be true or false. If the Enumerator was created with a size, it uses that instead, but the script above doesn't provide a size.