From 80c7187622234ceb9480438ab783bfc410758096 Mon Sep 17 00:00:00 2001 From: Benjamin P Toews Date: Tue, 18 Oct 2016 09:27:31 -0600 Subject: [PATCH] first commit --- .gitignore | 1 + CODE_OF_CONDUCT.md | 74 ++++++ CONTRIBUTING.md | 33 +++ Gemfile | 3 + LICENSE.md | 21 ++ README.md | 62 +++++ examples/github_walker/Gemfile | 4 + examples/github_walker/README.md | 43 ++++ examples/github_walker/lib/github_walker.rb | 32 +++ examples/github_walker/script/walk | 3 + examples/github_walker/script/walk.rb | 53 +++++ graphql-relay-walker.gemspec | 13 ++ lib/graphql/relay/walker.rb | 32 +++ lib/graphql/relay/walker/client_ext.rb | 29 +++ lib/graphql/relay/walker/query_builder.rb | 247 ++++++++++++++++++++ lib/graphql/relay/walker/queue.rb | 108 +++++++++ 16 files changed, 758 insertions(+) create mode 100644 .gitignore create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Gemfile create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 examples/github_walker/Gemfile create mode 100644 examples/github_walker/README.md create mode 100644 examples/github_walker/lib/github_walker.rb create mode 100755 examples/github_walker/script/walk create mode 100644 examples/github_walker/script/walk.rb create mode 100644 graphql-relay-walker.gemspec create mode 100644 lib/graphql/relay/walker.rb create mode 100644 lib/graphql/relay/walker/client_ext.rb create mode 100644 lib/graphql/relay/walker/query_builder.rb create mode 100644 lib/graphql/relay/walker/queue.rb diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b844b14 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +Gemfile.lock diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..d05f4bc --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,74 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at [INSERT EMAIL ADDRESS]. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3d2f621 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,33 @@ +## Contributing + +[fork]: https://github.com/github/graphql-relay-walker/fork +[pr]: https://github.com/github/graphql-relay-walker/compare +[style]: https://github.com/styleguide/ruby +[code-of-conduct]: CODE_OF_CONDUCT.md + +Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. + +Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. + +## Submitting a pull request + +0. [Fork][fork] and clone the repository +0. Configure and install the dependencies: `script/bootstrap` +0. Make sure the tests pass on your machine: `rake` +0. Create a new branch: `git checkout -b my-branch-name` +0. Make your change, add tests, and make sure the tests still pass +0. Push to your fork and [submit a pull request][pr] +0. Pat your self on the back and wait for your pull request to be reviewed and merged. + +Here are a few things you can do that will increase the likelihood of your pull request being accepted: + +- Follow the [style guide][style]. +- Write tests. +- Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. +- Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). + +## Resources + +- [Contributing to Open Source on GitHub](https://guides.github.com/activities/contributing-to-open-source/) +- [Using Pull Requests](https://help.github.com/articles/using-pull-requests/) +- [GitHub Help](https://help.github.com) diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..b4e2a20 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gemspec diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..250bddb --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 GitHub, inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0041104 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# GraphQL Relay Walker + +![](https://cloud.githubusercontent.com/assets/1144197/19287829/9ce479b8-8fc0-11e6-975c-8d686e3e0783.jpg) + +`GraphQL::Relay::Walker` is a Ruby library that generates queries for walking a [Relay](https://facebook.github.io/relay/docs/graphql-relay-specification.html#content) [GraphQL](http://graphql.org/) schema. Given a `GraphQL::Schema`, you can walk from a given starting point, exercising any defined connections. This can be useful for various kinds of automated testing. Check out the [GitHub Walker](./examples/github_walker) example to see it in action. + +## Setup + +You can install this library as a Ruby Gem: + +```bash +gem install graphql-relay-walker +``` + +## Usage + +```ruby +require "graphql/relay/walker" + +id = "" +query = GraphQL::Relay::Walker.query_string(client.schema) + + +GraphQL::Relay::Walker.walk(from_id: id) do |frame| + # The global relay id of the object we're looking at. + frame.gid + + # The frame where we discovered this object's GID. + frame.parent + + # Execute the query and store the result in the frame. + # The implementation here is up to you, but you should set + # `frame.result` to the Hash result of executing the query. + frame.result = execute(query, variables: {"id" => frame.gid}) + + # Parse the results, adding any newly discovered IDs to our queue. + frame.enqueue_found_gids +end +``` + +## Usage with `GraphQL::Client` + +Requiring `graphql/relay/walker/client_ext` will add a `GraphQL::Client#walk` method. This simplifies things by allowing the client to build and execute the query for you. + +Here's how you would walk the [SWAPI GraphQL Wrapper](https://github.com/graphql/swapi-graphql), starting from Luke Skywalker, assuming a client configuration like [this](https://github.com/github/graphql-client/blob/2761908e735e6d34bf6056d26e97de54d384aa14/README.md#configuration). + +```ruby +require "graphql/relay/walker/client_ext" + +skywalker_gid = "cGVvcGxlOjE=" + +SWAPI::Client.walk(from_id: skywalker_gid) do |frame| + # The global relay id of the object we're looking at. + frame.gid + + # The frame where we discovered this object's GID. + frame.parent + + # The result of executing the query for this frame's GID. + frame.result +end +``` diff --git a/examples/github_walker/Gemfile b/examples/github_walker/Gemfile new file mode 100644 index 0000000..02d7e96 --- /dev/null +++ b/examples/github_walker/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +gem "graphql-client", "~> 0.2" +gem "graphql-relay-walker", path: "../.." diff --git a/examples/github_walker/README.md b/examples/github_walker/README.md new file mode 100644 index 0000000..5ece0d7 --- /dev/null +++ b/examples/github_walker/README.md @@ -0,0 +1,43 @@ +# GitHub Walker + +GraphQL Relay Walker example: *walk GitHub's GraphQL API* + +## Usage + +You can use this code as a library: + +```ruby +require "github_walker" + +# Walk the graph, printing each object we come across. +GitHubWalker.walk do |frame| + msg = "Found object `#{frame.gid}`" + msg += " via object `#{frame.parent.gid}`" += if frame.parent + puts message +end +``` + +or you can run the provided script: + +```bash +# install dependencies +bundle install + +# Walk the graph. +script/walk +``` + +either way, you need to have a GitHub API token in your environment: + +```bash +export GITHUB_ACCESS_TOKEN= +``` + +You can make one of these tokens [here](https://github.com/settings/tokens). Your token will need these scopes: + +- `read:gpg_key` +- `read:org` +- `read:public_key` +- `read:repo_hook` +- `repo` +- `user` diff --git a/examples/github_walker/lib/github_walker.rb b/examples/github_walker/lib/github_walker.rb new file mode 100644 index 0000000..7fbde4e --- /dev/null +++ b/examples/github_walker/lib/github_walker.rb @@ -0,0 +1,32 @@ +require "graphql/client" +require "graphql/client/http" +require "graphql/relay/walker/client_ext" + +module GitHubWalker + URL = "https://api.github.com/graphql" + + TOKEN = ENV["GITHUB_ACCESS_TOKEN"] + + HTTP = GraphQL::Client::HTTP.new(URL) do + def headers(context) + {"Authorization" => "Bearer #{TOKEN}"} + end + end + + Schema = GraphQL::Client.load_schema(HTTP) + + Client = GraphQL::Client.new(schema: Schema, execute: HTTP) + + ViewerIdQuery = Client.parse <<-'GRAPHQL' + query { + viewer { + id + } + } + GRAPHQL + + def self.walk(&blk) + gid = Client.query(ViewerIdQuery).data.viewer.id + Client.walk(from_id: gid, &blk) + end +end diff --git a/examples/github_walker/script/walk b/examples/github_walker/script/walk new file mode 100755 index 0000000..6bd9d34 --- /dev/null +++ b/examples/github_walker/script/walk @@ -0,0 +1,3 @@ +#!/bin/sh + +bundle exec ruby script/walk.rb diff --git a/examples/github_walker/script/walk.rb b/examples/github_walker/script/walk.rb new file mode 100644 index 0000000..9c66d16 --- /dev/null +++ b/examples/github_walker/script/walk.rb @@ -0,0 +1,53 @@ +# NAME +# walk — Walk GitHub's GraphQL API, starting with your user. +# +# SYNOPSIS +# script/walk +# +# DESCRIPTION +# Starting from your user, walk the GitHub GraphQL API, following all +# accessible connections. A GitHub API token must be provided in the +# `GITHUB_ACCESS_TOKEN` environment variable. +# + +require "json" + +unless ENV["GITHUB_ACCESS_TOKEN"] + lines = File.read(__FILE__).split("\n") + help = [] + help << lines.shift[2..-1] while lines.first.start_with?("#") + puts help.join("\n") + exit(1) +end + +puts "Loading GitHub Schema" +$:.unshift File.expand_path('../../lib', __FILE__) +require "github_walker" + +stats = { + :depth => Hash.new { |h,k| h[k] = 0 }, + :total => 0 +} + +def frame_depth(frame, depth=1) + if frame.parent + frame_depth(frame.parent, depth + 1) + else + depth + end +end + +puts "Starting walking" +GitHubWalker.walk do |frame| + stats[:total] += 1 + stats[:depth][frame_depth(frame)] += 1 + + if stats[:total] % 100 == 0 + puts JSON.pretty_generate(stats) + puts + end + + if frame.context[:response].errors.any? + puts frame.context[:response].errors.values.flatten.uniq.join("\n") + end +end diff --git a/graphql-relay-walker.gemspec b/graphql-relay-walker.gemspec new file mode 100644 index 0000000..087f8e3 --- /dev/null +++ b/graphql-relay-walker.gemspec @@ -0,0 +1,13 @@ +Gem::Specification.new do |s| + s.name = "graphql-relay-walker" + s.version = "0.0.1" + s.licenses = ["MIT"] + s.summary = "Traverse a Relay GraphQL graph" + s.authors = ["Ben Toews"] + s.email = "opensource+graphql-relay-walker@github.com" + s.files = %w(LICENSE.md README.md CONTRIBUTING.md CODE_OF_CONDUCT.md graphql-relay-walker.gemspec) + s.files += Dir.glob("lib/**/*.rb") + s.homepage = "https://github.com/github/graphql-relay-walker" + + s.add_dependency "graphql", "~> 0.19" +end diff --git a/lib/graphql/relay/walker.rb b/lib/graphql/relay/walker.rb new file mode 100644 index 0000000..e3323c5 --- /dev/null +++ b/lib/graphql/relay/walker.rb @@ -0,0 +1,32 @@ +module GraphQL::Relay + module Walker + # Build a query that starts with a relay node and grabs the IDs of all its + # connections and node fields. + # + # schema - The GraphQL::Schema to build a query for. + # + # Returns a String query. + def self.query_string(schema) + QueryBuilder.new(schema).query_string + end + + # Start traversing a graph, starting from the given relay node ID. + # + # from_id: - The `ID!` id to start walking from. + # &blk - A block to call with each Walker::Queue::Frame that is visited. + # This block is responsible for executing a query for the frame's + # GID, storing the results in the frame, and enqueuing further + # node IDs to visit. + # + # Returns nothing. + def self.walk(from_id:, &blk) + queue = Queue.new + queue.add_gid(from_id) + queue.each_frame(&blk) + end + + end +end + +require "graphql/relay/walker/queue" +require "graphql/relay/walker/query_builder" diff --git a/lib/graphql/relay/walker/client_ext.rb b/lib/graphql/relay/walker/client_ext.rb new file mode 100644 index 0000000..7c80cb4 --- /dev/null +++ b/lib/graphql/relay/walker/client_ext.rb @@ -0,0 +1,29 @@ +module GraphQL::Relay::Walker + module ClientExt + # Walk this client's graph from the given GID. + # + # from_id: - The String GID to start walking from. + # &blk - A block to call with each Walker::Queue::Frame that is visited. + # + # Returns nothing. + def walk(from_id:) + query_string = GraphQL::Relay::Walker.query_string(schema) + walker_query = parse(query_string) + + GraphQL::Relay::Walker.walk(from_id: from_id) do |frame| + response = query(walker_query, variables: {"id" => frame.gid}) + frame.context[:response] = response + frame.result = response.data.to_h + frame.enqueue_found_gids + yield(frame) if block_given? + end + end + end +end + +begin + require "graphql/relay/walker" + require "graphql/client" + GraphQL::Client.send(:include, GraphQL::Relay::Walker::ClientExt) +rescue LoadError +end diff --git a/lib/graphql/relay/walker/query_builder.rb b/lib/graphql/relay/walker/query_builder.rb new file mode 100644 index 0000000..147dc39 --- /dev/null +++ b/lib/graphql/relay/walker/query_builder.rb @@ -0,0 +1,247 @@ +module GraphQL::Relay::Walker + class QueryBuilder + DEFAULT_ARGUMENTS = {"first" => 5} + BASE_QUERY = "query($id: ID!) { node(id: $id) {} }" + + attr_reader :schema, :connection_arguments, :ast + + # Initialize a new QueryBuilder. + # + # schema - The GraphQL::Schema to build a query for. + # connection_arguments: - A Hash or arguments to use for connection fields + # (optional). + # + # Returns nothing. + def initialize(schema, connection_arguments: DEFAULT_ARGUMENTS) + @schema = schema + @connection_arguments = connection_arguments + @ast = build_query + end + + # Get the built query. + # + # Returns a String. + def query_string + ast.to_query_string + end + + private + + # Build a query for our relay schema that selects an inline fragment for + # every node type. For every inline fragment, we select the ID of every node + # field and connection. + # + # Returns a GraphQL::Language::Nodes::Document instance. + def build_query + GraphQL.parse(BASE_QUERY).tap do |d_ast| + selections = d_ast.definitions.first.selections.first.selections + + node_types.each do |type| + selections << inline_fragment_ast(type) + end + + selections.compact! + end + end + + # Private: Make a AST of the given type. + # + # klass - The GraphQL::Language::Nodes::AbstractNode subclass + # to create. + # needs_selections: - Boolean. Will this AST be invalid if it doesn't have + # any selections? + # + # Returns a GraphQL::Language::Nodes::AbstractNode subclass instance or nil + # if the created AST was invalid for having no selections. + def make(klass, needs_selections: true) + k_ast = klass.new + yield(k_ast) if block_given? + k_ast.selections.compact! + + if k_ast.selections.empty? && needs_selections + nil + else + k_ast + end + end + + # Make an inline fragment AST. + # + # type - The GraphQL::ObjectType instance to make the fragment + # for. + # with_children: - Boolean. Whether to select all children of this inline + # fragment, or just it's ID. + # + # Returns a GraphQL::Language::Nodes::InlineFragment instance or nil if the + # created AST was invalid for having no selections. + def inline_fragment_ast(type, with_children: true) + make(GraphQL::Language::Nodes::InlineFragment) do |if_ast| + if_ast.type = type.name + + if with_children + type.all_fields.each do |field| + if node_field?(field) + if_ast.selections << node_field_ast(field) + elsif connection_field?(field) + if_ast.selections << connection_field_ast(field) + end + end + elsif id = type.get_field("id") + if_ast.selections << field_ast(id) + end + end + end + + # Make a field AST. + # + # field - The GraphQL::Field instance to make the fragment for. + # arguments - A Hash of arguments to include in the field. + # &blk - A block to call with the AST and field type before returning + # the AST. + # + # Returns a GraphQL::Language::Nodes::Field instance or nil if the created + # AST was invalid for having no selections or missing required arguments. + def field_ast(field, arguments={}, &blk) + type = field.type.unwrap + + # Bail unless we have the required arguments. + return unless field.arguments.reject do |_, arg| + arg.type.valid_input?(nil) + end.all? do |name, _| + arguments.key?(name) + end + + make(GraphQL::Language::Nodes::Field, needs_selections: !type.kind.scalar?) do |f_ast| + f_ast.name = field.name + f_ast.alias = random_alias unless field.name == "id" + f_ast.arguments = arguments.map do |name, value| + GraphQL::Language::Nodes::Argument.new(name: name, value: value) + end + + blk.call(f_ast, type) if blk + end + end + + # Make a field AST for a node field. + # + # field - The GraphQL::Field instance to make the fragment for. + # + # Returns a GraphQL::Language::Nodes::Field instance. + def node_field_ast(field) + field_ast(field) do |f_ast, type| + selections = f_ast.selections + + if type.kind.object? + selections << field_ast(type.get_field("id")) + else + possible_node_types(type).each do |if_type| + selections << inline_fragment_ast(if_type, with_children: false) + end + end + end + end + + # Make a field AST for an edges field. + # + # field - The GraphQL::Field instance to make the fragment for. + # + # Returns a GraphQL::Language::Nodes::Field instance. + def edges_field_ast(field) + field_ast(field) do |f_ast, type| + f_ast.selections << node_field_ast(type.get_field("node")) + end + end + + # Make a field AST for a connection field. + # + # field - The GraphQL::Field instance to make the fragment for. + # + # Returns a GraphQL::Language::Nodes::Field instance or nil if the created + # AST was invalid for missing required arguments. + def connection_field_ast(field) + field_ast(field, connection_arguments) do |f_ast, type| + f_ast.selections << edges_field_ast(type.get_field("edges")) + end + end + + # Is this field for a relay node? + # + # field - A GraphQL::Field instance. + # + # Returns true if the field's type includes the `Node` interface or is a + # union or interface with a possible type that includes the `Node` interface + # Returns false otherwise. + def node_field?(field) + type = field.type.unwrap + kind = type.kind + + if kind.object? + node_types.include?(type) + elsif kind.interface? || kind.union? + possible_node_types(type).any? + end + end + + # Is this field for a relay connection? + # + # field - A GraphQL::Field instance. + # + # Returns true if this field's type has a `edges` field whose type has a + # `node` field that is a relay node. Returns false otherwise. + def connection_field?(field) + type = field.type.unwrap + + if edges_field = type.get_field("edges") + edges = edges_field.type.unwrap + if node_field = edges.get_field("node") + return node_field?(node_field) + end + end + + false + end + + # Get the possible types of a union or interface. + # + # type - A GraphQL::UnionType or GraphQL::InterfaceType instance. + # + # Returns an Array of GraphQL::ObjectType instances. + def possible_types(type) + if type.kind.interface? + schema.possible_types(type) + elsif type.kind.union? + type.possible_types + end + end + + # Get the possible types of a union or interface that are relay nodes. + # + # type - A GraphQL::UnionType or GraphQL::InterfaceType instance. + # + # Returns an Array of GraphQL::ObjectType instances. + def possible_node_types(type) + possible_types(type) & node_types + end + + # Get the types that implement the `Node` interface. + # + # Returns an Array of GraphQL::ObjectType instances. + def node_types + schema.possible_types(node_interface) + end + + # Get the `Node` interface. + # + # Returns a GraphQL::InterfaceType instance. + def node_interface + schema.types["Node"] + end + + # Make a random alias for a field. + # + # Returns a six character random String. + def random_alias + 6.times.map { (SecureRandom.random_number(26) + 97).chr }.join + end + end +end diff --git a/lib/graphql/relay/walker/queue.rb b/lib/graphql/relay/walker/queue.rb new file mode 100644 index 0000000..828f10b --- /dev/null +++ b/lib/graphql/relay/walker/queue.rb @@ -0,0 +1,108 @@ +module GraphQL::Relay::Walker + class Queue + attr_accessor :max_size, :random_idx + + # Initialize a new Queue. + # + # max_size: - The maximum size the queue can grow to. This helps when + # walking a large graph by forcing us to walk deeper. + # random_idx: - Add frames to the queue at random indicies. This helps when + # walking a large graph by forcing us to walk deeper. + # + # Returns nothing. + def initialize(max_size: nil, random_idx: false) + @max_size = max_size + @random_idx = random_idx + + @queue = [] + @seen = Set.new + end + + # Add a frame to the queue if its GID hasn't been seen already and the queue + # hasn't exceeded its max size. + # + # frame - The Queue::Frame to add to the queue. + # + # Returns true if the frame was added, false otherwise. + def add(frame) + return false if max_size && queue.length >= max_size + return false if @seen.include?(frame.gid) + + @seen.add(frame.gid) + idx = random_idx ? rand(@queue.length + 1) : @queue.length + @queue.insert(idx, frame) + + true + end + + # Add a GID to the queue. + # + # gid - The String GID to add to the queue. + # parent - The frame where this GID was discovered (optional). + # + # Returns true if a frame was added, false otherwise. + def add_gid(gid, parent=nil) + frame = Frame.new(self, gid, parent) + add(frame) + end + + # Iterate through the queue, yielding each frame. + # + # Returns nothing. + def each_frame + while frame = @queue.shift + yield(frame) + end + end + end + + class Frame + attr_reader :queue, :gid, :parent, :context + attr_accessor :result + + # Initialize a new Frame. + # + # queue - The Queue that this frame belongs to. + # gid - The String GID. + # parent - The Frame where this GID was discovered. + # + # Returns nothing. + def initialize(queue, gid, parent) + @queue = queue + @gid = gid + @parent = parent + @context = {} + end + + # Add each found GID to the queue. + # + # Returns nothing. + def enqueue_found_gids + found_gids.each { |gid| queue.add(child(gid)) } + end + + # Make a new frame with the given GID and this frame as its parent. + # + # gid - The String GID to create the frame with. + # + # Returns a Queue::Frame instance. + def child(gid) + Frame.new(queue, gid, self) + end + + # The GIDs from this frame's results. + # + # Returns an Array of GID Strings. + def found_gids(data=result) + [].tap do |ids| + case data + when Hash + ids.concat(Array(data["id"])) + ids.concat(found_gids(data.values)) + when Array + data.each { |datum| ids.concat(found_gids(datum)) } + end + end + end + end +end