graphql_includable 0.2.8 → 0.2.10

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 3fb8b1ec2e2a339a4fe78952b2426ef79dc588a8
4
- data.tar.gz: 0a528542daf9f1723fdc1c64619b68591f2e7c78
3
+ metadata.gz: 58421ff04b72f73e873564c8ac6cfe3d731ed59a
4
+ data.tar.gz: edd842cae9ab0006a27caabc27cb68792d52d718
5
5
  SHA512:
6
- metadata.gz: 3e9b99fc48e697dcdda9a1fc821c6f310bb38cc555f91c4fd068ea40cfb9683b09c0e01115d9f4b005879c6942474027745cc56e984ef3c2c87e8b9cabf17bea
7
- data.tar.gz: 8a896215e28522ef35f9862e7e2f89db6c228a7d20390dc52343e0f43b489d0f19a9afd00b8e6b9e9a5345ceba1bb095a82522a12ac96bf20f37a05f6a11919d
6
+ metadata.gz: 8f6a55a190f097a48cb8aefd9673c6d3d9b996071cd5c1d6a74103dc6592ffc6b26ce47e1f816050c4952b10f93ae108e255c0e6ec9a8234e6493451a408aa27
7
+ data.tar.gz: 897f124f919a46f4885e50f9ff2fd54f2c64ca6d7eaced6cd9bad2c6c8223e5ab785fab95a462168e5e699436e7de715201eacd093cf5d0b2d1ff2db68c11fc5
@@ -1,16 +1,24 @@
1
1
  require 'active_support/concern'
2
+ require 'logger'
2
3
 
3
4
  module GraphQLIncludable
5
+ # ActiveSupport::Concern to include onto GraphQL-mapped models
4
6
  module Concern
5
7
  extend ActiveSupport::Concern
6
8
 
7
9
  module ClassMethods
10
+ # Main entry point of the concern, to be called from top-level fields
11
+ # Accepts a graphql-ruby query context, preloads, and returns itself
12
+ # @param ctx GraphQL::Query::Context
8
13
  def includes_from_graphql(ctx)
9
- node = Resolver.find_node_by_return_type(ctx.irep_node, name)
10
- generated_includes = Resolver.includes_for_node(node)
14
+ node = GraphQLIncludable::Resolver.find_node_by_return_type(ctx.irep_node, name)
15
+ generated_includes = GraphQLIncludable::Resolver.includes_for_node(node)
11
16
  includes(generated_includes)
12
17
  rescue => e
13
- Rails.logger.error(e)
18
+ # As this feature is just for a performance gain, it should never
19
+ # fail destructively, so catch and log all exceptions, but continue
20
+ raise e if Rails && Rails.env.development?
21
+ Rails.logger.debug("#{e.message}\n#{e.backtrace.join('\n')}")
14
22
  self
15
23
  end
16
24
 
@@ -18,6 +26,8 @@ module GraphQLIncludable
18
26
  @delegate_cache ||= {}
19
27
  end
20
28
 
29
+ # Hook and cache all calls to ActiveRecord's `delegate` method,
30
+ # so that preloading can take place through delegated models
21
31
  def delegate(*methods, args)
22
32
  methods.each do |method|
23
33
  delegate_cache[method] = args[:to]
@@ -1,78 +1,115 @@
1
1
  module GraphQLIncludable
2
2
  class Edge < GraphQL::Relay::Edge
3
- def edge
4
- return @edge if @edge
5
- join_chain = joins_along_edge
6
- edge_class_name = join_chain.shift
7
- edge_class = str_to_class(edge_class_name)
8
-
9
- root_association_key = class_to_str(parent.class)
10
- unless edge_class.reflections.keys.include?(root_association_key)
11
- is_polymorphic = true
12
- root_association_key = edge_class.reflections.select { |k, r| r.polymorphic? }.keys.first
3
+ # edge_record represents the data in between `node` and `parent`,
4
+ # basically the `through` record in a has-many-through association
5
+ # @returns record ActiveRecord::Base
6
+ def edge_record
7
+ @edge_record ||= edge_record_from_memory || edge_record_from_database
8
+ end
9
+
10
+ # attempt to query the edge record freshly from the database
11
+ # @returns record ActiveRecord::Base
12
+ def edge_record_from_database
13
+ first_association, *nested_associations = associations_between_node_and_parent
14
+ edge_class = self.class.str_to_class(first_association)
15
+ root_association_key = root_association_key(edge_class)
16
+
17
+ selector = edge_class
18
+
19
+ if nested_associations.present?
20
+ nested_association_names = nested_associations.map { |s| s.to_s.singularize }
21
+ selector = selector.merge(edge_class.includes(*nested_association_names))
13
22
  end
14
- root_node = { root_association_key.to_sym => [parent] }
15
- terminal_node = { class_to_str(node.class) => node }
16
- join_chain.reverse.each do |rel_name|
17
- terminal_node = { rel_name.to_s.pluralize => terminal_node }
23
+
24
+ if class_is_polymorphic?(edge_class)
25
+ selector = selector.merge(edge_class.joins(root_association_key))
18
26
  end
19
27
 
20
- search_hash = root_node.merge(terminal_node)
21
- edge_includes = join_chain.map { |s| s.to_s.singularize }
22
- edge_class = edge_class.includes(*edge_includes) unless edge_includes.empty?
23
- edge_class = edge_class.joins(root_association_key.to_sym) unless is_polymorphic
24
- @edge ||= edge_class.find_by(search_hash)
28
+ selector.find_by(
29
+ where_hash_for_edge(root_association_key, nested_associations)
30
+ )
25
31
  end
26
32
 
27
- def method_missing(method_name, *args, &block)
28
- if edge.respond_to?(method_name)
29
- edge.send(method_name, *args, &block)
30
- else
31
- super
33
+ # attempt to pull the preloaded edge record out of the associated object in memory
34
+ # @returns record ActiveRecord::Base
35
+ def edge_record_from_memory
36
+ associations = associations_between_node_and_parent
37
+ records = associations.reverse.reduce(parent) { |acc, cur| acc.send(cur) }
38
+ return unless records.loaded?
39
+ records.first do |rec|
40
+ child_association_name = node.class.name.downcase.to_sym
41
+ rec.send(root_association_key) == parent && rec.send(child_association_name) == node
32
42
  end
33
43
  end
34
44
 
35
- def respond_to_missing(method_name, include_private = false)
36
- edge.respond_to?(method_name) || super
45
+ def class_is_polymorphic?(klass)
46
+ klass.reflections.any? { |_k, r| r.polymorphic? }
47
+ end
48
+
49
+ # Delegate method calls on this Edge instance to the ActiveRecord instance
50
+ def method_missing(method_name, *args, &block)
51
+ return super unless edge_record.respond_to?(method_name)
52
+ edge_record.send(method_name, *args, &block)
53
+ end
54
+
55
+ def respond_to_missing?(method_name, include_private = false)
56
+ edge_record.respond_to?(method_name) || super
37
57
  end
38
58
 
39
59
  private
40
60
 
41
- def str_to_class(str)
42
- str.to_s.singularize.camelize.constantize
43
- rescue
61
+ # List all HasManyThrough associations between node and parent models
62
+ # @returns associations Array<Symbol>
63
+ def associations_between_node_and_parent
64
+ return @associations if @associations.present?
65
+
66
+ associations = []
67
+ association_name = node.class.name.pluralize.downcase.to_sym
68
+ association = parent.class.reflect_on_association(association_name)
69
+ while association.is_a?(ActiveRecord::Reflection::ThroughReflection)
70
+ associations.unshift(association.options[:through])
71
+ association = parent.class.reflect_on_association(association.options[:through])
72
+ end
73
+
74
+ @associations = associations
75
+ end
76
+
77
+ # List the key:value criteria for finding the edge record in the database
78
+ # @param root_key Symbol
79
+ # @param nested_associations Array<Symbol>
80
+ # @returns where_hash Hash<?>
81
+ def where_hash_for_edge(root_key, nested_associations)
82
+ root_include = { root_key => [parent] }
83
+ terminal_include = { self.class.class_to_str(node.class) => node }
84
+ inner_includes = nested_associations.reverse.reduce(terminal_include) do |acc, rel_name|
85
+ { rel_name.to_s.pluralize => acc }
86
+ end
87
+ root_include.merge(inner_includes)
44
88
  end
45
89
 
46
- def class_to_str(klass)
47
- klass.name.downcase
90
+ # @param edge_class ActiveRecord::Base
91
+ # @returns key Symbol
92
+ def root_association_key(edge_class)
93
+ key = self.class.class_to_str(parent.class)
94
+ unless edge_class.reflections.keys.include?(key)
95
+ key = edge_class.reflections.select { |_k, r| r.polymorphic? }.keys.first
96
+ end
97
+ key.to_sym
48
98
  end
49
99
 
50
- def joins_along_edge
51
- # node.edges
52
- # edge.node
53
- # edge.parent
54
- # parent.edges
55
- # node.parents
56
- # parent.nodes
57
- edge_association_name = node.class.name.pluralize.downcase.to_sym
58
- edge_association = parent.class.reflect_on_association(edge_association_name)
59
- edge_joins = []
60
- while edge_association.is_a? ActiveRecord::Reflection::ThroughReflection
61
- edge_joins.unshift edge_association.options[:through]
62
- edge_association = parent.class.reflect_on_association(edge_association.options[:through])
100
+ class << self
101
+ # @param str String
102
+ # @returns klass Class
103
+ def str_to_class(str)
104
+ str.to_s.singularize.camelize.constantize
105
+ rescue # rubocop:disable Lint/HandleExceptions
106
+ end
107
+
108
+ # @param klass Class
109
+ # @returns str String
110
+ def class_to_str(klass)
111
+ klass.name.downcase
63
112
  end
64
- edge_joins
65
- # join_chain = []
66
- # starting_class = parent.class
67
- # node_relationship_name = class_to_str(node.class)
68
- # while starting_class
69
- # reflection = starting_class.reflect_on_association(node_relationship_name)
70
- # association_name = reflection&.options&.try(:[], :through)
71
- # join_chain << association_name if association_name
72
- # starting_class = str_to_class(association_name)
73
- # node_relationship_name = node_relationship_name.singularize
74
- # end
75
- # join_chain
76
113
  end
77
114
  end
78
115
  end
@@ -1,10 +1,15 @@
1
+ # Includes = Symbol | Array<Symbol> | Hash<Symbol, Includes>
1
2
  module GraphQLIncludable
2
3
  class Resolver
3
- # Returns the first node in the tree which returns a specific type
4
- def self.find_node_by_return_type(node, desired_return_type)
5
- return_type = node.return_type.unwrap.to_s
6
- return node if return_type == desired_return_type
7
- if node.respond_to?(:scoped_children)
4
+ class << self
5
+ # Returns the first node in the tree which returns a specific type
6
+ # @param node GraphQL::InternalRepresentation::Node
7
+ # @param desired_return_type String
8
+ # @returns matching_node GraphQL::InternalRepresentation::Node
9
+ def find_node_by_return_type(node, desired_return_type)
10
+ return_type = node.return_type.unwrap.to_s
11
+ return node if return_type == desired_return_type
12
+ return unless node.respond_to?(:scoped_children)
8
13
  matching_node = nil
9
14
  node.scoped_children.values.each do |selections|
10
15
  matching_node = selections.values.find do |child_node|
@@ -14,100 +19,185 @@ module GraphQLIncludable
14
19
  end
15
20
  matching_node
16
21
  end
17
- end
18
22
 
19
- # Translate a node's selections into `includes` values
20
- # Combine and format children values
21
- # Noop on nodes that don't return AR (so no associations to include)
22
- def self.includes_for_node(node)
23
- return_model = node_return_model(node)
24
- return [] if return_model.blank?
25
-
26
- includes = []
27
- children = node.scoped_children[node.return_type.unwrap]
28
- nested_includes = {}
29
-
30
- children.each_value do |child_node|
31
- child_includes = includes_for_child(child_node, return_model)
32
-
33
- if child_includes.is_a?(Hash)
34
- nested_includes.merge!(child_includes)
35
- elsif child_includes.is_a?(Array)
36
- includes += child_includes
37
- else
38
- includes << child_includes
23
+ # Collect preloadable relationships for the resolution of `node`
24
+ #
25
+ # @param node GraphQL::InternalRepresentation::Node
26
+ # @returns includes Includes
27
+ def includes_for_node(node)
28
+ return_model = node_return_model(node)
29
+ return [] if return_model.blank?
30
+ children = node.scoped_children[node.return_type.unwrap]
31
+ child_includes = children.map { |_key, child| includes_for_node_child(child, return_model) }
32
+ combine_child_includes(child_includes)
33
+ end
34
+
35
+ # Collect preloadable relationships for all selections on `node`,
36
+ # as accessed from `parent_model`.
37
+ #
38
+ # @param node GraphQL::InternalRepresentation::Node
39
+ # @param parent_model ActiveRecord::Base
40
+ # @returns child_includes Includes
41
+ def includes_for_node_child(node, parent_model)
42
+ included_attributes = node_includes_source_from_metadata(node)
43
+ return included_attributes if included_attributes.is_a?(Hash)
44
+ included_attributes = [included_attributes] unless included_attributes.is_a?(Array)
45
+ includes = included_attributes.map do |attribute_name|
46
+ includes_for_node_child_attribute(node, parent_model, attribute_name)
39
47
  end
48
+ includes.size == 1 ? includes.first : includes
40
49
  end
41
50
 
42
- includes << nested_includes if nested_includes.present?
43
- includes.uniq
44
- end
51
+ # Collect preloadable relationships for a single selection on `node`,
52
+ # as accessed from `parent_model`
53
+ #
54
+ # @param node GraphQL::InternalRepresentation::Node
55
+ # @param parent_model ActiveRecord::Base
56
+ # @param attribute_name Symbol
57
+ # @returns includes Includes
58
+ def includes_for_node_child_attribute(node, parent_model, attribute_name)
59
+ includes_array = includes_delegated_through(parent_model, attribute_name)
60
+ target_model = model_name_to_class(includes_array.last) || parent_model
61
+ association = target_model.reflect_on_association(attribute_name)
45
62
 
46
- def self.includes_for_child(node, parent_model)
47
- attribute_name = node_predicted_association_name(node)
48
- delegated_through = includes_delegated_through(parent_model, attribute_name)
49
- delegated_model = model_name_to_class(delegated_through.last) if delegated_through.present?
50
- association = (delegated_model || parent_model).reflect_on_association(attribute_name)
51
- interceding_includes = []
52
- # association = get_model_association(parent_model, attribute_name, interceding_includes)
53
-
54
- if association
55
- child_includes = includes_for_node(node)
56
- array_to_nested_hash(interceding_includes + [attribute_name, child_includes].reject(&:blank?))
57
- # if node_is_relay_connection?(node)
58
- # join_name = association.options[:through]
59
- # edge_includes_chain = [association.name]
60
- # edge_includes_chain << child_includes.pop[association.name.to_s.singularize.to_sym] if child_includes.last&.is_a?(Hash)
61
- # edge_includes = array_to_nested_hash(edge_includes_chain)
62
- # end
63
- else
64
- # TODO: specified includes?
65
- [array_to_nested_hash(interceding_includes)].reject(&:blank?)
63
+ if association && node_is_relay_connection?(node)
64
+ includes_array << includes_for_node_relay_child_attribute(
65
+ node, attribute_name, association
66
+ )
67
+ elsif association
68
+ includes_array += [attribute_name, includes_for_node(node)]
69
+ end
70
+ array_to_nested_hash(includes_array)
66
71
  end
67
- end
68
72
 
69
- def self.model_name_to_class(model_name)
70
- begin
71
- model_name.to_s.camelize.constantize
72
- rescue NameError
73
- model_name.to_s.singularize.camelize.constantize
73
+ # Collect preloadable relationships for a single selection on `node`,
74
+ # as accessed from `parent_model`, through the Relay specification's
75
+ # named `edges` and `nodes` selections.
76
+ #
77
+ # @param node GraphQL::InternalRepresentation::Node
78
+ # @param attribute_name Symbol
79
+ # @param association ActiveRecord::HasManyThroughAssociation
80
+ def includes_for_node_relay_child_attribute(node, attribute_name, association)
81
+ children = node.scoped_children[node.return_type.unwrap]
82
+
83
+ edge_data = includes_for_node_relay_child_edge_attribute(
84
+ children['edges'], attribute_name, association
85
+ )
86
+ leaf_data = [attribute_name, includes_for_node(node)] if children['nodes']
87
+
88
+ [array_to_nested_hash(edge_data), array_to_nested_hash(leaf_data)].reject(&:blank?)
74
89
  end
75
- rescue
76
- end
77
90
 
78
- # Translate a node's return type to an ActiveRecord model
79
- def self.node_return_model(node)
80
- model = Object.const_get(node.return_type.unwrap.name.gsub(/(^SquareFoot|Edge$|Connection$)/, ''))
81
- model if model < ActiveRecord::Base
82
- rescue NameError
83
- end
91
+ # Collect preloadable relationships for a single selection on `node`,
92
+ # a Relay Edge node whose parent is a Connection being accessed from `parent_model`
93
+ #
94
+ # @param node GraphQL::InternalRepresentation::Node
95
+ # @param attribute_name Symbol
96
+ # @param association ActiveRecord::HasManyThroughAssociation
97
+ def includes_for_node_relay_child_edge_attribute(node, attribute_name, association)
98
+ return unless node
99
+ leaf_includes = [attribute_name.to_s.singularize.to_sym]
100
+ edge_children = node.scoped_children[node.return_type.unwrap]
101
+ if edge_children['node']
102
+ leaf_includes << includes_for_node(edge_children['node'])
103
+ end
104
+ leaf_includes = array_to_nested_hash(leaf_includes)
84
105
 
85
- def self.node_predicted_association_name(node)
86
- definition = node.definitions.first
87
- definition.metadata[:includes] || (definition.property || definition.name).to_sym
88
- end
106
+ [association.options[:through], [*includes_for_node(node), leaf_includes]]
107
+ end
108
+
109
+ # Format child includes into a data structure that can be preloaded
110
+ # Singular terminal nodes become symbols,
111
+ # multiple terminal nodes become arrays of symbols,
112
+ # and branching nodes become hashes.
113
+ # The result can be passed directly into ActiveModel::Base.includes
114
+ #
115
+ # @param child_includes Includes
116
+ # @returns child_includes Includes
117
+ def combine_child_includes(child_includes)
118
+ includes = []
119
+ nested_includes = {}
120
+ child_includes.each do |child|
121
+ if child.is_a?(Hash)
122
+ nested_includes.merge!(child)
123
+ elsif child.is_a?(Array)
124
+ includes += child
125
+ else
126
+ includes << child
127
+ end
128
+ end
129
+ includes << nested_includes if nested_includes.present?
130
+ includes.uniq.reject(&:blank?)
131
+ end
89
132
 
90
- # If method_name is delegated from base_model, return an array of
91
- # associations through which those methods can be delegated
92
- def self.includes_delegated_through(base_model, method_name)
93
- chain = []
94
- method = method_name.to_sym
95
- model_name = base_model.instance_variable_get(:@delegate_cache).try(:[], method)
96
- while model_name
97
- chain << model_name
98
- model = model_name_to_class(model_name)
99
- model_name = model.instance_variable_get(:@delegate_cache).try(:[], method)
133
+ # Retrieve the Ruby class for a model by name
134
+ # Attempts to singularize the name if not found
135
+ #
136
+ # @param model_name Symbol | String
137
+ # @returns klass Class
138
+ def model_name_to_class(model_name)
139
+ begin
140
+ model_name.to_s.camelize.constantize
141
+ rescue NameError
142
+ model_name.to_s.singularize.camelize.constantize
143
+ end
144
+ rescue # rubocop:disable Lint/HandleExceptions
100
145
  end
101
- chain
102
- end
103
146
 
104
- # Right-reduce an array into a nested hash
105
- def self.array_to_nested_hash(arr)
106
- arr.reverse.inject { |acc, item| { item => acc } } || {}
107
- end
147
+ # Translate a node's return type to an ActiveRecord model
148
+ #
149
+ # @param node GraphQL::InternalRepresentation::Node
150
+ # @returns model ActiveRecord::Base | nil
151
+ REGEX_CLEAN_GQL_TYPE_NAME = /(^SquareFoot|Edge$|Connection$)/
152
+ def node_return_model(node)
153
+ model = Object.const_get(node.return_type.unwrap.name.gsub(REGEX_CLEAN_GQL_TYPE_NAME, ''))
154
+ model if model < ActiveRecord::Base
155
+ rescue NameError # rubocop:disable Lint/HandleExceptions
156
+ end
157
+
158
+ # @param node GraphQL::InternalRepresentation::Node
159
+ # @returns is_relay_connection Boolean
160
+ REGEX_RELAY_CONNECTION = /Connection$/
161
+ def node_is_relay_connection?(node)
162
+ node.return_type.unwrap.name =~ REGEX_RELAY_CONNECTION
163
+ end
108
164
 
109
- # def self.node_is_relay_connection?(node)
110
- # node.return_type.unwrap.name =~ /Connection$/
111
- # end
165
+ # Predict the association name to include from a field's metadata
166
+ #
167
+ # @param node GraphQL::InternalRepresentation::Node
168
+ # @returns possible_includes Includes
169
+ def node_includes_source_from_metadata(node)
170
+ definition = node.definitions.first
171
+ definition.metadata[:includes] || (definition.property || definition.name).to_sym
172
+ end
173
+
174
+ # If method_name is delegated from base_model, return an array of
175
+ # associations through which those methods can be delegated
176
+ #
177
+ # @param base_model ActiveRecord::Base
178
+ # @param method_name String | Symbol
179
+ # @returns chain Array<Symbol>
180
+ def includes_delegated_through(base_model, method_name)
181
+ chain = []
182
+ method = method_name.to_sym
183
+ model_name = base_model.instance_variable_get(:@delegate_cache).try(:[], method)
184
+ while model_name
185
+ chain << model_name
186
+ model = model_name_to_class(model_name)
187
+ model_name = model.instance_variable_get(:@delegate_cache).try(:[], method)
188
+ end
189
+ chain
190
+ end
191
+
192
+ # Right-reduce an array into a nested hash
193
+ #
194
+ # @param arr Array<Includes>
195
+ # @returns nested_hash Includes
196
+ def array_to_nested_hash(arr)
197
+ (arr || []).reject(&:blank?)
198
+ .reverse
199
+ .inject { |acc, item| { item => acc } } || {}
200
+ end
201
+ end
112
202
  end
113
203
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphql_includable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.8
4
+ version: 0.2.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dan Rouse
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2018-06-19 00:00:00.000000000 Z
12
+ date: 2018-08-29 00:00:00.000000000 Z
13
13
  dependencies: []
14
14
  description:
15
15
  email: