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 +4 -4
- data/lib/graphql_includable/concern.rb +13 -3
- data/lib/graphql_includable/edge.rb +93 -56
- data/lib/graphql_includable/resolver.rb +176 -86
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 58421ff04b72f73e873564c8ac6cfe3d731ed59a
|
4
|
+
data.tar.gz: edd842cae9ab0006a27caabc27cb68792d52d718
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
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
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
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
|
-
|
15
|
-
|
16
|
-
|
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
|
-
|
21
|
-
|
22
|
-
|
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
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
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
|
36
|
-
|
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
|
-
|
42
|
-
|
43
|
-
|
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
|
-
|
47
|
-
|
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
|
-
|
51
|
-
#
|
52
|
-
#
|
53
|
-
|
54
|
-
|
55
|
-
#
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
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
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
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
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
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
|
-
|
43
|
-
|
44
|
-
|
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
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
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
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
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
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
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
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
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
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
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
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
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
|
-
|
110
|
-
|
111
|
-
|
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.
|
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-
|
12
|
+
date: 2018-08-29 00:00:00.000000000 Z
|
13
13
|
dependencies: []
|
14
14
|
description:
|
15
15
|
email:
|