graphql 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
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