graphql 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/lib/graphql.rb +78 -9
  3. data/lib/graphql/call.rb +7 -0
  4. data/lib/graphql/connection.rb +44 -0
  5. data/lib/graphql/field.rb +117 -0
  6. data/lib/graphql/field_definer.rb +29 -0
  7. data/lib/graphql/introspection/call_node.rb +13 -0
  8. data/lib/graphql/introspection/connection.rb +9 -0
  9. data/lib/graphql/introspection/field_node.rb +19 -0
  10. data/lib/graphql/introspection/root_call_argument_node.rb +5 -0
  11. data/lib/graphql/introspection/root_call_node.rb +16 -0
  12. data/lib/graphql/introspection/schema_call.rb +8 -0
  13. data/lib/graphql/introspection/schema_node.rb +17 -0
  14. data/lib/graphql/introspection/type_call.rb +8 -0
  15. data/lib/graphql/introspection/type_node.rb +16 -0
  16. data/lib/graphql/node.rb +141 -34
  17. data/lib/graphql/parser.rb +19 -8
  18. data/lib/graphql/query.rb +64 -21
  19. data/lib/graphql/root_call.rb +176 -0
  20. data/lib/graphql/root_call_argument.rb +8 -0
  21. data/lib/graphql/root_call_argument_definer.rb +20 -0
  22. data/lib/graphql/schema.rb +99 -0
  23. data/lib/graphql/syntax/call.rb +3 -12
  24. data/lib/graphql/syntax/field.rb +4 -2
  25. data/lib/graphql/syntax/node.rb +3 -10
  26. data/lib/graphql/syntax/query.rb +7 -0
  27. data/lib/graphql/syntax/variable.rb +7 -0
  28. data/lib/graphql/transform.rb +14 -5
  29. data/lib/graphql/types/boolean_field.rb +3 -0
  30. data/lib/graphql/types/connection_field.rb +30 -0
  31. data/lib/graphql/types/cursor_field.rb +9 -0
  32. data/lib/graphql/types/number_field.rb +3 -0
  33. data/lib/graphql/types/object_field.rb +8 -0
  34. data/lib/graphql/types/string_field.rb +3 -0
  35. data/lib/graphql/types/type_field.rb +6 -0
  36. data/lib/graphql/version.rb +3 -0
  37. data/readme.md +142 -10
  38. data/spec/graphql/field_spec.rb +66 -0
  39. data/spec/graphql/node_spec.rb +68 -0
  40. data/spec/graphql/parser_spec.rb +75 -25
  41. data/spec/graphql/query_spec.rb +185 -83
  42. data/spec/graphql/root_call_spec.rb +55 -0
  43. data/spec/graphql/schema_spec.rb +128 -0
  44. data/spec/graphql/transform_spec.rb +124 -39
  45. data/spec/spec_helper.rb +2 -1
  46. data/spec/support/dummy_app.rb +43 -23
  47. data/spec/support/nodes.rb +145 -32
  48. metadata +78 -16
  49. data/lib/graphql/collection_edge.rb +0 -62
  50. data/lib/graphql/syntax/edge.rb +0 -12
data/lib/graphql/node.rb CHANGED
@@ -1,61 +1,168 @@
1
+ # Node is the base class for your GraphQL nodes.
2
+ # It's essentially a delegator that only delegates methods you whitelist with {.field}.
3
+ # To use it:
4
+ #
5
+ # - Extend `GraphQL::Node`
6
+ # - Declare what this node will wrap with {.exposes}
7
+ # - Declare fields with {.field}
8
+ #
9
+ # @example
10
+ # class PostNode < GraphQL::Node
11
+ # exposes('Post')
12
+ #
13
+ # cursor(:id)
14
+ #
15
+ # field.number(:id)
16
+ # field.string(:title)
17
+ # field.string(:content)
18
+ # field.connection(:comments)
19
+ # end
20
+ #
21
+
1
22
  class GraphQL::Node
2
- attr_accessor :fields
23
+ # The object wrapped by this `Node`
24
+ attr_reader :target
25
+ # Fields parsed from the query string
26
+ attr_reader :syntax_fields
27
+ # The query to which this `Node` belongs. Used for accessing its {Query#context}.
28
+ attr_reader :query
3
29
 
4
- def initialize(target=nil)
5
- # DONT EXPOSE Node#target! otherwise you might be able to access it
30
+ def initialize(target=nil, fields:, query:)
6
31
  @target = target
32
+ @query = query
33
+ @syntax_fields = fields
7
34
  end
8
35
 
9
- def safe_send(identifier)
10
- if respond_to?(identifier)
11
- public_send(identifier)
36
+ # If the target responds to `method_name`, send it to target.
37
+ def method_missing(method_name, *args, &block)
38
+ if target.respond_to?(method_name)
39
+ target.public_send(method_name, *args, &block)
12
40
  else
13
- raise GraphQL::FieldNotDefinedError, "#{self.class.name}##{identifier} was requested, but it isn't defined."
41
+ super
14
42
  end
15
43
  end
16
44
 
17
- def to_json
45
+ # Looks up {#syntax_fields} against this node and returns the results
46
+ def as_result
18
47
  json = {}
19
- fields.each do |field|
20
- name = field.identifier
21
- if field.is_a?(GraphQL::Syntax::Field)
22
- json[name] = safe_send(name)
23
- elsif field.is_a?(GraphQL::Syntax::Edge)
24
- edge = safe_send(field.identifier)
25
- edge.calls = field.call_hash
26
- edge.fields = field.fields
27
- json[name] = edge.to_json
48
+ syntax_fields.each do |syntax_field|
49
+ key_name = syntax_field.alias_name || syntax_field.identifier
50
+ if key_name == 'node'
51
+ clone_node = self.class.new(target, fields: syntax_field.fields, query: query)
52
+ json[key_name] = clone_node.as_result
53
+ elsif key_name == 'cursor'
54
+ json[key_name] = cursor
55
+ else
56
+ field = get_field(syntax_field)
57
+ json[key_name] = field.as_result
28
58
  end
29
59
  end
30
60
  json
31
61
  end
32
62
 
33
-
34
- def self.call(argument)
35
- raise NotImplementedError, "Implement #{name}#call(argument) to use this node as a call"
63
+ # The object passed to {Query#initialize} as `context`.
64
+ def context
65
+ query.context
36
66
  end
37
67
 
38
- def self.field_reader(*field_names)
39
- field_names.each do |field_name|
40
- define_method(field_name) do
41
- @target.public_send(field_name)
68
+
69
+ class << self
70
+ # Registers this node in {GraphQL::SCHEMA}
71
+ def inherited(child_class)
72
+ # use name to prevent autoloading Connection
73
+ if child_class.ancestors.map(&:name).include?("GraphQL::Connection")
74
+ GraphQL::SCHEMA.add_connection(child_class)
42
75
  end
43
76
  end
44
- end
45
77
 
46
- def self.edges(field_name, collection_class_name:, edge_class_name:)
47
- define_method(field_name) do
48
- collection_items = @target.send(field_name)
49
- collection_class = Object.const_get(collection_class_name)
50
- edge_class = Object.const_get(edge_class_name)
51
- collection = collection_class.new(items: collection_items, edge_class: edge_class)
78
+ # @param [String] class_name name of the class this node will wrap.
79
+ def exposes(ruby_class_name)
80
+ @ruby_class_name = ruby_class_name
81
+ GraphQL::SCHEMA.add_type(self)
82
+ end
83
+
84
+ # The name of the class wrapped by this node
85
+ def ruby_class_name
86
+ @ruby_class_name
87
+ end
88
+
89
+ # @param [String] describe
90
+ # Provide a description for this node which will be accessible from {SCHEMA}
91
+ def desc(describe)
92
+ @description = describe
93
+ end
94
+
95
+ # The description of this node
96
+ def description
97
+ @description || raise("#{name}.description isn't defined")
98
+ end
99
+
100
+ # @param [String] type_name
101
+ # Declares an alternative name to use in {GraphQL::SCHEMA}
102
+ def type(type_name)
103
+ @type_name = type_name.to_s
104
+ GraphQL::SCHEMA.add_type(self)
105
+ end
106
+
107
+ # Returns the name of this node used by {GraphQL::SCHEMA}
108
+ def schema_name
109
+ @type_name || default_schema_name
110
+ end
111
+
112
+ def default_schema_name
113
+ name.split("::").last.sub(/Node$/, '').underscore
114
+ end
115
+
116
+ # @param [String] field_name name of the field to be used as the cursor
117
+ # Declares what field will be used as the cursor for this node.
118
+ def cursor(field_name)
119
+ define_method "cursor" do
120
+ field_class = self.class.all_fields[field_name.to_s]
121
+ field = field_class.new(query: query, owner: self, calls: [])
122
+ cursor = GraphQL::Types::CursorField.new(field.as_result)
123
+ cursor.as_result
124
+ end
125
+ end
126
+
127
+ # All accessible fields on this node (including those defined in parent classes)
128
+ def all_fields
129
+ superclass.all_fields.merge(own_fields)
130
+ rescue NoMethodError
131
+ own_fields
132
+ end
133
+
134
+ # Fields defined by this class, but not its parents
135
+ def own_fields
136
+ @own_fields ||= {}
137
+ end
138
+
139
+ # @return [GraphQL::FieldDefiner] definer
140
+ def field
141
+ @field_definer ||= GraphQL::FieldDefiner.new(self)
142
+ end
143
+
144
+ # @param [String] field_name
145
+ # Un-define field with name `field_name`
146
+ def remove_field(field_name)
147
+ own_fields.delete(field_name.to_s)
52
148
  end
53
149
  end
54
150
 
151
+ private
55
152
 
56
- def self.cursor(field_name)
57
- define_method "cursor" do
58
- safe_send(field_name).to_s
153
+ def get_field(syntax_field)
154
+ field_class = self.class.all_fields[syntax_field.identifier]
155
+ if syntax_field.identifier == "cursor"
156
+ cursor
157
+ elsif field_class.nil?
158
+ raise GraphQL::FieldNotDefinedError.new(self.class.name, syntax_field.identifier)
159
+ else
160
+ field_class.new(
161
+ query: query,
162
+ owner: self,
163
+ calls: syntax_field.calls,
164
+ fields: syntax_field.fields,
165
+ )
59
166
  end
60
167
  end
61
168
  end
@@ -1,18 +1,29 @@
1
1
  class GraphQL::Parser < Parslet::Parser
2
- root(:node)
3
-
2
+ root(:query)
3
+ rule(:query) { node.repeat.as(:nodes) >> variable.repeat.as(:variables) }
4
+ # node
4
5
  rule(:node) { space? >> call >> space? >> fields.as(:fields) }
5
6
 
6
- rule(:fields) { str("{") >> space? >> ((edge | field) >> str(",").maybe >> space?).repeat(1) >> space? >> str("}") >> space?}
7
-
8
- rule(:edge) { call_chain >> space? >> fields.as(:fields) }
9
- rule(:call_chain) { identifier >> (dot >> call).repeat(0).as(:calls) }
7
+ # field set
8
+ rule(:fields) { str("{") >> space? >> (field >> separator?).repeat(1) >> space? >> str("}") >> space?}
10
9
 
11
- rule(:call) { identifier >> str("(") >> name.maybe.as(:argument) >> str(")") }
10
+ #call
11
+ rule(:call) { identifier >> str("(") >> (argument.as(:argument) >> separator?).repeat(0).as(:arguments) >> str(")") }
12
12
  rule(:dot) { str(".") }
13
+ rule(:argument) { (identifier | variable_identifier)}
14
+
15
+ # field
16
+ rule(:field) { identifier >> call_chain.maybe >> alias_name.maybe >> space? >> fields.as(:fields).maybe }
17
+ rule(:call_chain) { (dot >> call).repeat(0).as(:calls) }
18
+ rule(:alias_name) { space >> str("as") >> space >> name.as(:alias_name) }
13
19
 
14
- rule(:field) { identifier }
20
+ # variable
21
+ rule(:variable) { space? >> variable_identifier >> str(":") >> space? >> (name | json_string ).as(:json_string) >> space?}
22
+ rule(:json_string) { str("{") >> (match('[^{}]') | json_string).repeat >> str("}")}
23
+ rule(:variable_identifier) { (str("<") >> name >> str(">")).as(:identifier) }
15
24
 
25
+ # general purpose
26
+ rule(:separator?) { str(",").maybe >> space? }
16
27
  rule(:identifier) { name.as(:identifier) }
17
28
  rule(:name) { match('\w').repeat(1) }
18
29
  rule(:space) { match('[\s\n]+').repeat(1) }
data/lib/graphql/query.rb CHANGED
@@ -1,41 +1,84 @@
1
+ # Executes queries from strings against {GraphQL::SCHEMA}.
2
+ #
3
+ # @example
4
+ # query_str = "post(1) { title, comments { count } }"
5
+ # query_ctx = {user: current_user}
6
+ # query = GraphQL::Query.new(query_str, context: query_ctx)
7
+ # result = query.as_result
8
+
1
9
  class GraphQL::Query
2
- attr_reader :query_string, :root, :namespace
3
- def initialize(query_string, namespace: Object)
10
+ attr_reader :query_string, :root, :context
11
+
12
+ # @param [String] query_string the string to be parsed
13
+ # @param [Object] context an object which will be available to all nodes and fields in the schema
14
+ def initialize(query_string, context: nil)
4
15
  if !query_string.is_a?(String) || query_string.length == 0
5
16
  raise "You must send a query string, not a #{query_string.class.name}"
6
17
  end
7
18
  @query_string = query_string
8
19
  @root = parse(query_string)
9
- @namespace = namespace
10
- end
11
-
12
- def to_json
13
- root_node = make_call(nil, root.identifier, root.argument)
14
- raise "Couldn't find root for #{root.identifier}(#{root.argument})" if root.nil?
15
-
16
- root_node.fields = root.fields
17
- {
18
- root_node.cursor => root_node.to_json
19
- }
20
+ @context = context
20
21
  end
21
22
 
22
- def get_node(identifier)
23
- name = "#{identifier}_node"
24
- namespace.const_get(name.camelize)
23
+ # @return [Hash] result the JSON response to this query
24
+ # Calling {#as_result} more than once won't cause the query to be re-run
25
+ def as_result
26
+ @as_result ||= execute!
25
27
  end
26
28
 
27
- def make_call(context, name, *arguments)
28
- if context.nil?
29
- context = get_node(name)
30
- name = "call"
29
+ # @param [String] identifier
30
+ # returns a query variable named `identifier`, otherwise raises.
31
+ def get_variable(identifier)
32
+ syntax_var = @root.variables.find { |v| v.identifier == identifier }
33
+ if syntax_var.blank?
34
+ raise "No variable found for #{identifier}, defined variables are #{@root.variables.map(&:identifier)}"
31
35
  end
32
- context.send(name, *arguments)
36
+ syntax_var
33
37
  end
34
38
 
35
39
  private
36
40
 
41
+ def execute!
42
+ root_syntax_node = root.nodes[0]
43
+ root_call_identifier = root_syntax_node.identifier
44
+ root_call_class = GraphQL::SCHEMA.get_call(root_call_identifier)
45
+ root_call = root_call_class.new(query: self, syntax_arguments: root_syntax_node.arguments)
46
+ result_object = root_call.as_result
47
+ return_declarations = root_call_class.return_declarations
48
+ result = {}
49
+
50
+ if result_object.is_a?(Hash)
51
+ result_object.each do |name, value|
52
+ node_class = GraphQL::SCHEMA.type_for_object(value)
53
+ field_for_node = root_syntax_node.fields.find {|f| f.identifier == name.to_s }
54
+ if field_for_node.present?
55
+ fields_for_node = field_for_node.fields
56
+ node_value = node_class.new(value,query: self, fields: fields_for_node)
57
+ result[name.to_s] = node_value.as_result
58
+ end
59
+ end
60
+ elsif result_object.is_a?(Array)
61
+ fields_for_node = root_syntax_node.fields
62
+ result_object.each do |item|
63
+ node_class = GraphQL::SCHEMA.type_for_object(item)
64
+ node_value = node_class.new(item, query: self, fields: fields_for_node)
65
+ result[node_value.cursor] = node_value.as_result
66
+ end
67
+ else
68
+ node_class = GraphQL::SCHEMA.type_for_object(result_object)
69
+ fields_for_node = root_syntax_node.fields
70
+ node_value = node_class.new(result_object, query: self, fields: fields_for_node)
71
+ result[node_value.cursor] = node_value.as_result
72
+ end
73
+
74
+ result
75
+ end
76
+
37
77
  def parse(query_string)
38
78
  parsed_hash = GraphQL::PARSER.parse(query_string)
39
79
  root_node = GraphQL::TRANSFORM.apply(parsed_hash)
80
+ rescue Parslet::ParseFailed => error
81
+ line, col = error.cause.source.line_and_column
82
+ raise GraphQL::SyntaxError.new(line, col, query_string)
40
83
  end
41
84
  end
@@ -0,0 +1,176 @@
1
+ # Every query begins with a root call. It might find data or mutate data and return some results.
2
+ #
3
+ # A root call should:
4
+ #
5
+ # - declare any arguments with {.argument}, or declare `argument.none`
6
+ # - declare returns with {.return}
7
+ # - implement {#execute!} to take those arguments and return values
8
+ #
9
+ # @example
10
+ # FindPostCall < GraphQL::RootCall
11
+ # argument.number(:ids, any_number: true)
12
+ # returns :post
13
+ #
14
+ # def execute!(*ids)
15
+ # ids.map { |id| Post.find(id) }
16
+ # end
17
+ # end
18
+ #
19
+ # @example
20
+ # CreateCommentCall < GraphQL::RootCall
21
+ # argument.number(:post_id)
22
+ # argument.object(:comment)
23
+ # returns :post, :comment
24
+ #
25
+ # def execute!(post_id, comment)
26
+ # post = Post.find(post_id)
27
+ # new_comment = post.comments.create!(comment)
28
+ # {
29
+ # comment: new_comment,
30
+ # post: post,
31
+ # }
32
+ # end
33
+ # end
34
+ #
35
+ class GraphQL::RootCall
36
+ attr_reader :query, :arguments
37
+ def initialize(query:, syntax_arguments:)
38
+ @query = query
39
+
40
+ raise "#{self.class.name} must declare arguments" unless self.class.arguments
41
+ @arguments = syntax_arguments.each_with_index.map do |syntax_arg, idx|
42
+
43
+ value = if syntax_arg[0] == "<"
44
+ query.get_variable(syntax_arg).json_string
45
+ else
46
+ syntax_arg
47
+ end
48
+
49
+ self.class.typecast(idx, value)
50
+ end
51
+ end
52
+
53
+ def execute!(*args)
54
+ raise NotImplementedError, "Do work in this method"
55
+ end
56
+
57
+ def context
58
+ query.context
59
+ end
60
+
61
+ def as_result
62
+ return_declarations = self.class.return_declarations
63
+ raise "#{self.class.name} must declare returns" unless return_declarations.present?
64
+ return_values = execute!(*arguments)
65
+
66
+ if return_values.is_a?(Hash)
67
+ unexpected_returns = return_values.keys - return_declarations.keys
68
+ missing_returns = return_declarations.keys - return_values.keys
69
+ if unexpected_returns.any?
70
+ raise "#{self.class.name} returned #{unexpected_returns}, but didn't declare them."
71
+ elsif missing_returns.any?
72
+ raise "#{self.class.name} declared #{missing_returns}, but didn't return them."
73
+ end
74
+ end
75
+ return_values
76
+ end
77
+
78
+ class << self
79
+ # @param [String] ident_name
80
+ # Declare an alternative name used in a query string
81
+ def indentifier(ident_name)
82
+ @identifier = ident_name
83
+ GraphQL::SCHEMA.add_call(self)
84
+ end
85
+
86
+ # The name used by {GraphQL::SCHEMA}. Uses {.identifier} or derives a name from the class name.
87
+ def schema_name
88
+ @identifier || name.split("::").last.sub(/Call$/, '').underscore
89
+ end
90
+
91
+ def inherited(child_class)
92
+ GraphQL::SCHEMA.add_call(child_class)
93
+ end
94
+
95
+ # This call won't be visible in `schema()`
96
+ def abstract!
97
+ GraphQL::SCHEMA.remove_call(self)
98
+ end
99
+
100
+ # @param [Symbol] return_declarations
101
+ # Name of returned values from this call
102
+ def returns(*return_declaration_names)
103
+ if return_declaration_names.last.is_a?(Hash)
104
+ return_declarations_hash = return_declaration_names.pop
105
+ else
106
+ return_declarations_hash = {}
107
+ end
108
+
109
+ raise "Return keys must be symbols" if (return_declarations.keys + return_declaration_names).any? { |k| !k.is_a?(Symbol) }
110
+
111
+ return_declaration_names.each do |return_sym|
112
+ return_type = return_sym.to_s
113
+ return_declarations[return_sym] = return_type
114
+ end
115
+
116
+ return_declarations_hash.each do |return_sym, return_type|
117
+ return_declarations[return_sym] = return_type
118
+ end
119
+ end
120
+
121
+ def return_declarations
122
+ @return_declarations ||= {}
123
+ end
124
+
125
+ # @return [GraphQL::RootCallArgumentDefiner] definer
126
+ # Use this object to declare arguments.
127
+ def argument
128
+ @argument ||= GraphQL::RootCallArgumentDefiner.new(self)
129
+ end
130
+
131
+ def own_arguments
132
+ @argument && @argument.arguments
133
+ end
134
+
135
+ def arguments
136
+ own = own_arguments || []
137
+ own + superclass.arguments
138
+ rescue NoMethodError
139
+ own
140
+ end
141
+
142
+ def argument_for_index(idx)
143
+ if arguments.first.any_number
144
+ arguments.first
145
+ else
146
+ arguments[idx]
147
+ end
148
+ end
149
+
150
+ TYPE_CHECKS = {
151
+ "object" => Hash,
152
+ "number" => Numeric,
153
+ "string" => String,
154
+ }
155
+
156
+ def typecast(idx, value)
157
+ arg_dec = argument_for_index(idx)
158
+ expected_type = arg_dec.type
159
+ expected_type_class = TYPE_CHECKS[expected_type]
160
+
161
+ if expected_type == "string"
162
+ parsed_value = value
163
+ else
164
+ parsed_value = JSON.parse('{ "value" : ' + value + '}')["value"]
165
+ end
166
+
167
+ if !parsed_value.is_a?(expected_type_class)
168
+ raise GraphQL::RootCallArgumentError.new(arg_dec, value)
169
+ end
170
+
171
+ parsed_value
172
+ rescue JSON::ParserError
173
+ raise GraphQL::RootCallArgumentError.new(arg_dec, value)
174
+ end
175
+ end
176
+ end