active_shopify_graphql 0.2.0 → 0.4.0
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/.rubocop.yml +16 -0
- data/AGENTS.md +1 -0
- data/README.md +149 -10
- data/lib/active_shopify_graphql/associations.rb +8 -4
- data/lib/active_shopify_graphql/attributes.rb +3 -2
- data/lib/active_shopify_graphql/base.rb +38 -2
- data/lib/active_shopify_graphql/connection_loader.rb +1 -1
- data/lib/active_shopify_graphql/connections/connection_proxy.rb +32 -0
- data/lib/active_shopify_graphql/connections.rb +54 -26
- data/lib/active_shopify_graphql/finder_methods.rb +33 -5
- data/lib/active_shopify_graphql/fragment_builder.rb +37 -4
- data/lib/active_shopify_graphql/graphql_associations.rb +245 -0
- data/lib/active_shopify_graphql/includes_scope.rb +48 -0
- data/lib/active_shopify_graphql/loader.rb +34 -3
- data/lib/active_shopify_graphql/loader_context.rb +1 -1
- data/lib/active_shopify_graphql/loaders/admin_api_loader.rb +1 -1
- data/lib/active_shopify_graphql/query_node.rb +17 -4
- data/lib/active_shopify_graphql/query_tree.rb +0 -5
- data/lib/active_shopify_graphql/response_mapper.rb +63 -16
- data/lib/active_shopify_graphql/search_query.rb +32 -0
- data/lib/active_shopify_graphql/version.rb +1 -1
- data/lib/active_shopify_graphql.rb +3 -0
- metadata +4 -29
|
@@ -30,18 +30,29 @@ module ActiveShopifyGraphQL
|
|
|
30
30
|
def build_field_nodes
|
|
31
31
|
path_tree = {}
|
|
32
32
|
metafield_aliases = {}
|
|
33
|
+
raw_graphql_nodes = []
|
|
34
|
+
aliased_field_nodes = []
|
|
33
35
|
|
|
34
36
|
# Build a tree structure for nested paths
|
|
35
|
-
@context.defined_attributes.
|
|
36
|
-
if config[:
|
|
37
|
+
@context.defined_attributes.each do |attr_name, config|
|
|
38
|
+
if config[:raw_graphql]
|
|
39
|
+
raw_graphql_nodes << build_raw_graphql_node(attr_name, config[:raw_graphql])
|
|
40
|
+
elsif config[:is_metafield]
|
|
37
41
|
store_metafield_config(metafield_aliases, config)
|
|
38
42
|
else
|
|
39
|
-
|
|
43
|
+
path = config[:path]
|
|
44
|
+
if path.include?('.')
|
|
45
|
+
# Nested path - use tree structure (shared prefixes)
|
|
46
|
+
build_path_tree(path_tree, path)
|
|
47
|
+
else
|
|
48
|
+
# Simple path - add aliased field node
|
|
49
|
+
aliased_field_nodes << build_aliased_field_node(attr_name, path)
|
|
50
|
+
end
|
|
40
51
|
end
|
|
41
52
|
end
|
|
42
53
|
|
|
43
54
|
# Convert tree to QueryNode objects
|
|
44
|
-
nodes_from_tree(path_tree) + metafield_nodes(metafield_aliases)
|
|
55
|
+
nodes_from_tree(path_tree) + aliased_field_nodes + metafield_nodes(metafield_aliases) + raw_graphql_nodes
|
|
45
56
|
end
|
|
46
57
|
|
|
47
58
|
# Build QueryNode objects for all connections (protected for recursive calls)
|
|
@@ -74,6 +85,23 @@ module ActiveShopifyGraphQL
|
|
|
74
85
|
}
|
|
75
86
|
end
|
|
76
87
|
|
|
88
|
+
def build_raw_graphql_node(attr_name, raw_graphql)
|
|
89
|
+
# Prepend alias to raw GraphQL for predictable response mapping
|
|
90
|
+
aliased_raw_graphql = "#{attr_name}: #{raw_graphql}"
|
|
91
|
+
QueryNode.new(
|
|
92
|
+
name: "raw",
|
|
93
|
+
arguments: { raw_graphql: aliased_raw_graphql },
|
|
94
|
+
node_type: :raw
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def build_aliased_field_node(attr_name, path)
|
|
99
|
+
alias_name = attr_name.to_s
|
|
100
|
+
# Only add alias if the attr_name differs from the GraphQL field name
|
|
101
|
+
alias_name = nil if alias_name == path
|
|
102
|
+
QueryNode.new(name: path, alias_name: alias_name, node_type: :field)
|
|
103
|
+
end
|
|
104
|
+
|
|
77
105
|
def build_path_tree(path_tree, path)
|
|
78
106
|
path_parts = path.split('.')
|
|
79
107
|
current_level = path_tree
|
|
@@ -120,12 +148,17 @@ module ActiveShopifyGraphQL
|
|
|
120
148
|
child_nodes = build_target_field_nodes(target_context, nested_includes)
|
|
121
149
|
|
|
122
150
|
query_name = connection_config[:query_name]
|
|
151
|
+
original_name = connection_config[:original_name]
|
|
123
152
|
connection_type = connection_config[:type] || :connection
|
|
124
153
|
formatted_args = (connection_config[:default_arguments] || {}).transform_keys(&:to_sym)
|
|
125
154
|
|
|
155
|
+
# Add alias if the connection name differs from the query name
|
|
156
|
+
alias_name = original_name.to_s == query_name ? nil : original_name.to_s
|
|
157
|
+
|
|
126
158
|
node_type = connection_type == :singular ? :singular : :connection
|
|
127
159
|
QueryNode.new(
|
|
128
160
|
name: query_name,
|
|
161
|
+
alias_name: alias_name,
|
|
129
162
|
arguments: formatted_args,
|
|
130
163
|
node_type: node_type,
|
|
131
164
|
children: child_nodes
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
# Allows ActiveRecord (or duck-typed) objects to define associations to GraphQL objects
|
|
5
|
+
# This module bridges the gap between local database records and remote Shopify GraphQL data
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# class Reward < ApplicationRecord
|
|
9
|
+
# include ActiveShopifyGraphQL::GraphQLAssociations
|
|
10
|
+
#
|
|
11
|
+
# belongs_to_graphql :customer
|
|
12
|
+
# has_many_graphql :variants, class: "ProductVariant", query_name: "productVariants"
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# reward = Reward.first
|
|
16
|
+
# customer = reward.customer # => ActiveShopifyGraphQL::Customer instance
|
|
17
|
+
# variants = reward.variants(first: 10) # => Array of ProductVariant instances
|
|
18
|
+
module GraphQLAssociations
|
|
19
|
+
extend ActiveSupport::Concern
|
|
20
|
+
|
|
21
|
+
included do
|
|
22
|
+
class << self
|
|
23
|
+
attr_accessor :graphql_associations
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
self.graphql_associations = {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class_methods do
|
|
30
|
+
# Define a belongs_to relationship with a GraphQL object
|
|
31
|
+
# Fetches a single GraphQL object using a stored GID or foreign key
|
|
32
|
+
#
|
|
33
|
+
# @param name [Symbol] The association name (e.g., :customer)
|
|
34
|
+
# @param class_name [String] The target GraphQL model class name (defaults to name.to_s.classify)
|
|
35
|
+
# @param foreign_key [Symbol, String] The attribute/column storing the GID or ID (defaults to "shopify_#{name}_id")
|
|
36
|
+
# @param loader_class [Class] Custom loader class (defaults to target class's default_loader)
|
|
37
|
+
#
|
|
38
|
+
# @example Basic usage
|
|
39
|
+
# belongs_to_graphql :customer
|
|
40
|
+
# # Expects: shopify_customer_id column with GID like "gid://shopify/Customer/123"
|
|
41
|
+
#
|
|
42
|
+
# @example With custom foreign key
|
|
43
|
+
# belongs_to_graphql :customer, foreign_key: :customer_gid
|
|
44
|
+
#
|
|
45
|
+
# @example With custom class
|
|
46
|
+
# belongs_to_graphql :owner, class_name: "Customer"
|
|
47
|
+
def belongs_to_graphql(name, class_name: nil, foreign_key: nil, loader_class: nil)
|
|
48
|
+
association_class_name = class_name || name.to_s.classify
|
|
49
|
+
association_foreign_key = foreign_key || "shopify_#{name}_id"
|
|
50
|
+
|
|
51
|
+
# Store association metadata
|
|
52
|
+
graphql_associations[name] = {
|
|
53
|
+
type: :belongs_to,
|
|
54
|
+
class_name: association_class_name,
|
|
55
|
+
foreign_key: association_foreign_key,
|
|
56
|
+
loader_class: loader_class
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Define the association method
|
|
60
|
+
define_method name do
|
|
61
|
+
return @_graphql_association_cache[name] if @_graphql_association_cache&.key?(name)
|
|
62
|
+
|
|
63
|
+
@_graphql_association_cache ||= {}
|
|
64
|
+
|
|
65
|
+
# Get the GID or ID value from the foreign key
|
|
66
|
+
gid_or_id = send(association_foreign_key)
|
|
67
|
+
return @_graphql_association_cache[name] = nil if gid_or_id.blank?
|
|
68
|
+
|
|
69
|
+
# Resolve the target class
|
|
70
|
+
target_class = association_class_name.constantize
|
|
71
|
+
|
|
72
|
+
# Determine which loader to use
|
|
73
|
+
loader = if self.class.graphql_associations[name][:loader_class]
|
|
74
|
+
self.class.graphql_associations[name][:loader_class].new(target_class)
|
|
75
|
+
else
|
|
76
|
+
target_class.default_loader
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Load and cache the GraphQL object
|
|
80
|
+
@_graphql_association_cache[name] = target_class.find(gid_or_id, loader: loader)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Define setter method for testing/mocking
|
|
84
|
+
define_method "#{name}=" do |value|
|
|
85
|
+
@_graphql_association_cache ||= {}
|
|
86
|
+
@_graphql_association_cache[name] = value
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Define a has_one relationship with a GraphQL object
|
|
91
|
+
# Fetches a single GraphQL object using a where clause
|
|
92
|
+
#
|
|
93
|
+
# @param name [Symbol] The association name (e.g., :primary_address)
|
|
94
|
+
# @param class_name [String] The target GraphQL model class name (defaults to name.to_s.classify)
|
|
95
|
+
# @param foreign_key [Symbol, String] The attribute on GraphQL objects to filter by (e.g., :customer_id)
|
|
96
|
+
# @param primary_key [Symbol, String] The local attribute to use as filter value (defaults to :id)
|
|
97
|
+
# @param loader_class [Class] Custom loader class (defaults to target class's default_loader)
|
|
98
|
+
#
|
|
99
|
+
# @example Basic usage
|
|
100
|
+
# has_one_graphql :primary_address, class_name: "Address", foreign_key: :customer_id
|
|
101
|
+
# customer.primary_address # Returns first Address where customer_id matches
|
|
102
|
+
def has_one_graphql(name, class_name: nil, foreign_key: nil, primary_key: nil, loader_class: nil)
|
|
103
|
+
association_class_name = class_name || name.to_s.classify
|
|
104
|
+
association_primary_key = primary_key || :id
|
|
105
|
+
association_loader_class = loader_class
|
|
106
|
+
|
|
107
|
+
# Store association metadata
|
|
108
|
+
graphql_associations[name] = {
|
|
109
|
+
type: :has_one,
|
|
110
|
+
class_name: association_class_name,
|
|
111
|
+
foreign_key: foreign_key,
|
|
112
|
+
primary_key: association_primary_key,
|
|
113
|
+
loader_class: association_loader_class
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# Define the association method
|
|
117
|
+
define_method name do
|
|
118
|
+
return @_graphql_association_cache[name] if @_graphql_association_cache&.key?(name)
|
|
119
|
+
|
|
120
|
+
@_graphql_association_cache ||= {}
|
|
121
|
+
|
|
122
|
+
# Get primary key value
|
|
123
|
+
primary_key_value = send(association_primary_key)
|
|
124
|
+
return @_graphql_association_cache[name] = nil if primary_key_value.blank?
|
|
125
|
+
|
|
126
|
+
# Resolve the target class
|
|
127
|
+
target_class = association_class_name.constantize
|
|
128
|
+
|
|
129
|
+
# Determine which loader to use
|
|
130
|
+
loader = if self.class.graphql_associations[name][:loader_class]
|
|
131
|
+
self.class.graphql_associations[name][:loader_class].new(target_class)
|
|
132
|
+
else
|
|
133
|
+
target_class.default_loader
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Query with foreign key filter if provided
|
|
137
|
+
result = if self.class.graphql_associations[name][:foreign_key]
|
|
138
|
+
foreign_key_sym = self.class.graphql_associations[name][:foreign_key]
|
|
139
|
+
query_conditions = { foreign_key_sym => primary_key_value }
|
|
140
|
+
target_class.where(query_conditions, loader: loader).first
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Cache the result
|
|
144
|
+
@_graphql_association_cache[name] = result
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Define setter method for testing/mocking
|
|
148
|
+
define_method "#{name}=" do |value|
|
|
149
|
+
@_graphql_association_cache ||= {}
|
|
150
|
+
@_graphql_association_cache[name] = value
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Define a has_many relationship with GraphQL objects
|
|
155
|
+
# Queries multiple GraphQL objects using a where clause or connection
|
|
156
|
+
#
|
|
157
|
+
# @param name [Symbol] The association name (e.g., :variants)
|
|
158
|
+
# @param class_name [String] The target GraphQL model class name (defaults to name.to_s.classify.singularize)
|
|
159
|
+
# @param query_name [String] The GraphQL query field name (defaults to class_name.pluralize.camelize(:lower))
|
|
160
|
+
# @param foreign_key [Symbol, String] The attribute on GraphQL objects to filter by (e.g., :customer_id)
|
|
161
|
+
# @param primary_key [Symbol, String] The local attribute to use as filter value (defaults to :id)
|
|
162
|
+
# @param loader_class [Class] Custom loader class (defaults to target class's default_loader)
|
|
163
|
+
# @param query_method [Symbol] Method to use for querying (:where or :connection, defaults to :where)
|
|
164
|
+
#
|
|
165
|
+
# @example Basic usage with where query
|
|
166
|
+
# has_many_graphql :variants, class: "ProductVariant"
|
|
167
|
+
# reward.variants # Uses where to query
|
|
168
|
+
#
|
|
169
|
+
# @example With custom query_name for a connection
|
|
170
|
+
# has_many_graphql :line_items, query_name: "lineItems", query_method: :connection
|
|
171
|
+
# order.line_items(first: 10)
|
|
172
|
+
#
|
|
173
|
+
# @example With filtering by foreign key
|
|
174
|
+
# has_many_graphql :orders, foreign_key: :customer_id
|
|
175
|
+
# # Queries orders where customer_id matches the local record's id
|
|
176
|
+
def has_many_graphql(name, class_name: nil, query_name: nil, foreign_key: nil, primary_key: nil, loader_class: nil, query_method: :where)
|
|
177
|
+
association_class_name = class_name || name.to_s.singularize.classify
|
|
178
|
+
association_primary_key = primary_key || :id
|
|
179
|
+
association_loader_class = loader_class
|
|
180
|
+
association_query_method = query_method
|
|
181
|
+
|
|
182
|
+
# Auto-determine query_name if not provided
|
|
183
|
+
association_query_name = query_name || name.to_s.camelize(:lower)
|
|
184
|
+
|
|
185
|
+
# Store association metadata
|
|
186
|
+
graphql_associations[name] = {
|
|
187
|
+
type: :has_many,
|
|
188
|
+
class_name: association_class_name,
|
|
189
|
+
query_name: association_query_name,
|
|
190
|
+
foreign_key: foreign_key,
|
|
191
|
+
primary_key: association_primary_key,
|
|
192
|
+
loader_class: association_loader_class,
|
|
193
|
+
query_method: association_query_method
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
# Define the association method
|
|
197
|
+
define_method name do |**options|
|
|
198
|
+
return @_graphql_association_cache[name] if @_graphql_association_cache&.key?(name) && options.empty?
|
|
199
|
+
|
|
200
|
+
@_graphql_association_cache ||= {}
|
|
201
|
+
|
|
202
|
+
# Get primary key value
|
|
203
|
+
primary_key_value = send(association_primary_key)
|
|
204
|
+
return @_graphql_association_cache[name] = [] if primary_key_value.blank?
|
|
205
|
+
|
|
206
|
+
# Resolve the target class
|
|
207
|
+
target_class = association_class_name.constantize
|
|
208
|
+
|
|
209
|
+
# Determine which loader to use
|
|
210
|
+
loader = if self.class.graphql_associations[name][:loader_class]
|
|
211
|
+
self.class.graphql_associations[name][:loader_class].new(target_class)
|
|
212
|
+
else
|
|
213
|
+
target_class.default_loader
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Build query based on query_method
|
|
217
|
+
result = if association_query_method == :connection
|
|
218
|
+
# For connections, we need to handle this differently
|
|
219
|
+
# This would typically be a nested connection on a parent object
|
|
220
|
+
# For now, return empty array - real implementation would need parent context
|
|
221
|
+
[]
|
|
222
|
+
elsif self.class.graphql_associations[name][:foreign_key]
|
|
223
|
+
# Query with foreign key filter
|
|
224
|
+
foreign_key_sym = self.class.graphql_associations[name][:foreign_key]
|
|
225
|
+
query_conditions = { foreign_key_sym => primary_key_value }.merge(options)
|
|
226
|
+
target_class.where(query_conditions, loader: loader)
|
|
227
|
+
else
|
|
228
|
+
# No foreign key specified, just query with provided options
|
|
229
|
+
target_class.where(options, loader: loader)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Cache if no runtime options provided
|
|
233
|
+
@_graphql_association_cache[name] = result if options.empty?
|
|
234
|
+
result
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Define setter method for testing/mocking
|
|
238
|
+
define_method "#{name}=" do |value|
|
|
239
|
+
@_graphql_association_cache ||= {}
|
|
240
|
+
@_graphql_association_cache[name] = value
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
# A scope object that holds included connections for eager loading.
|
|
5
|
+
# This allows chaining methods like find() and where() while maintaining
|
|
6
|
+
# the included connections configuration.
|
|
7
|
+
class IncludesScope
|
|
8
|
+
attr_reader :model_class, :included_connections
|
|
9
|
+
|
|
10
|
+
def initialize(model_class, included_connections)
|
|
11
|
+
@model_class = model_class
|
|
12
|
+
@included_connections = included_connections
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Delegate find to the model class with a custom loader
|
|
16
|
+
def find(id, loader: nil)
|
|
17
|
+
loader ||= default_loader
|
|
18
|
+
@model_class.find(id, loader: loader)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Delegate where to the model class with a custom loader
|
|
22
|
+
def where(*args, **options)
|
|
23
|
+
loader = options.delete(:loader) || default_loader
|
|
24
|
+
@model_class.where(*args, **options.merge(loader: loader))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Delegate select to create a new scope with select
|
|
28
|
+
def select(*attributes)
|
|
29
|
+
selected_scope = @model_class.select(*attributes)
|
|
30
|
+
# Chain the includes on top of select
|
|
31
|
+
IncludesScope.new(selected_scope, @included_connections)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Allow chaining includes calls
|
|
35
|
+
def includes(*connection_names)
|
|
36
|
+
@model_class.includes(*(@included_connections + connection_names).uniq)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def default_loader
|
|
42
|
+
@default_loader ||= @model_class.default_loader.class.new(
|
|
43
|
+
@model_class,
|
|
44
|
+
included_connections: @included_connections
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -84,7 +84,7 @@ module ActiveShopifyGraphQL
|
|
|
84
84
|
|
|
85
85
|
# Delegate query building methods
|
|
86
86
|
def query_name(model_type = nil)
|
|
87
|
-
(model_type || graphql_type).
|
|
87
|
+
(model_type || graphql_type).camelize(:lower)
|
|
88
88
|
end
|
|
89
89
|
|
|
90
90
|
def fragment_name(model_type = nil)
|
|
@@ -96,19 +96,50 @@ module ActiveShopifyGraphQL
|
|
|
96
96
|
end
|
|
97
97
|
|
|
98
98
|
# Map the GraphQL response to model attributes
|
|
99
|
-
def map_response_to_attributes(response_data)
|
|
99
|
+
def map_response_to_attributes(response_data, parent_instance: nil)
|
|
100
100
|
mapper = ResponseMapper.new(context)
|
|
101
101
|
attributes = mapper.map_response(response_data)
|
|
102
102
|
|
|
103
103
|
# If we have included connections, extract and cache them
|
|
104
104
|
if @included_connections.any?
|
|
105
|
-
connection_data = mapper.extract_connection_data(response_data)
|
|
105
|
+
connection_data = mapper.extract_connection_data(response_data, parent_instance: parent_instance)
|
|
106
106
|
attributes[:_connection_cache] = connection_data unless connection_data.empty?
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
attributes
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
+
# Check if this loader has included connections
|
|
113
|
+
def has_included_connections?
|
|
114
|
+
@included_connections&.any?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Load and construct an instance with proper inverse_of support for included connections
|
|
118
|
+
def load_with_instance(id, model_class)
|
|
119
|
+
query = graphql_query
|
|
120
|
+
response_data = perform_graphql_query(query, id: id)
|
|
121
|
+
|
|
122
|
+
return nil if response_data.nil?
|
|
123
|
+
|
|
124
|
+
# First, extract just the attributes (without connections)
|
|
125
|
+
mapper = ResponseMapper.new(context)
|
|
126
|
+
attributes = mapper.map_response(response_data)
|
|
127
|
+
|
|
128
|
+
# Create the instance with basic attributes
|
|
129
|
+
instance = model_class.new(attributes)
|
|
130
|
+
|
|
131
|
+
# Now extract connection data with the instance as parent to support inverse_of
|
|
132
|
+
if @included_connections.any?
|
|
133
|
+
connection_data = mapper.extract_connection_data(response_data, parent_instance: instance)
|
|
134
|
+
unless connection_data.empty?
|
|
135
|
+
# Manually set the connection cache on the instance
|
|
136
|
+
instance.instance_variable_set(:@_connection_cache, connection_data)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
instance
|
|
141
|
+
end
|
|
142
|
+
|
|
112
143
|
# Executes the GraphQL query and returns the mapped attributes hash
|
|
113
144
|
def load_attributes(id)
|
|
114
145
|
query = graphql_query
|
|
@@ -4,7 +4,7 @@ module ActiveShopifyGraphQL
|
|
|
4
4
|
module Loaders
|
|
5
5
|
class AdminApiLoader < Loader
|
|
6
6
|
def initialize(model_class = nil, selected_attributes: nil, included_connections: nil)
|
|
7
|
-
super
|
|
7
|
+
super
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def perform_graphql_query(query, **variables)
|
|
@@ -40,6 +40,8 @@ class QueryNode
|
|
|
40
40
|
render_singular(indent_level: indent_level)
|
|
41
41
|
when :fragment
|
|
42
42
|
render_fragment
|
|
43
|
+
when :raw
|
|
44
|
+
render_raw
|
|
43
45
|
else
|
|
44
46
|
raise ArgumentError, "Unknown node type: #{@node_type}"
|
|
45
47
|
end
|
|
@@ -83,11 +85,14 @@ class QueryNode
|
|
|
83
85
|
nested_fields = @children.map { |child| child.to_s(indent_level: indent_level + 2) }
|
|
84
86
|
fields_string = nested_fields.join(compact? ? " " : "\n#{nested_indent} ")
|
|
85
87
|
|
|
88
|
+
# Include alias if present
|
|
89
|
+
field_name = @alias_name ? "#{@alias_name}: #{@name}" : @name
|
|
90
|
+
|
|
86
91
|
if compact?
|
|
87
|
-
"#{
|
|
92
|
+
"#{field_name}#{args_string} { edges { node { #{fields_string} } } }"
|
|
88
93
|
else
|
|
89
94
|
<<~GRAPHQL.strip
|
|
90
|
-
#{
|
|
95
|
+
#{field_name}#{args_string} {
|
|
91
96
|
#{nested_indent}edges {
|
|
92
97
|
#{nested_indent} node {
|
|
93
98
|
#{nested_indent} #{fields_string}
|
|
@@ -108,10 +113,13 @@ class QueryNode
|
|
|
108
113
|
nested_fields = @children.map { |child| child.to_s(indent_level: indent_level + 1) }
|
|
109
114
|
fields_string = nested_fields.join(compact? ? " " : "\n#{nested_indent}")
|
|
110
115
|
|
|
116
|
+
# Include alias if present
|
|
117
|
+
field_name = @alias_name ? "#{@alias_name}: #{@name}" : @name
|
|
118
|
+
|
|
111
119
|
if compact?
|
|
112
|
-
"#{
|
|
120
|
+
"#{field_name}#{args_string} { #{fields_string} }"
|
|
113
121
|
else
|
|
114
|
-
"#{
|
|
122
|
+
"#{field_name}#{args_string} {\n#{nested_indent}#{fields_string}\n#{indent}}"
|
|
115
123
|
end
|
|
116
124
|
end
|
|
117
125
|
|
|
@@ -127,6 +135,11 @@ class QueryNode
|
|
|
127
135
|
end
|
|
128
136
|
end
|
|
129
137
|
|
|
138
|
+
def render_raw
|
|
139
|
+
# Raw GraphQL string stored in arguments[:raw_graphql]
|
|
140
|
+
@arguments[:raw_graphql]
|
|
141
|
+
end
|
|
142
|
+
|
|
130
143
|
def format_arguments
|
|
131
144
|
return "" if @arguments.empty?
|
|
132
145
|
|
|
@@ -73,11 +73,6 @@ module ActiveShopifyGraphQL
|
|
|
73
73
|
FragmentBuilder.normalize_includes(includes)
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
# Helper methods (kept for backward compatibility)
|
|
77
|
-
def self.query_name(graphql_type)
|
|
78
|
-
graphql_type.downcase
|
|
79
|
-
end
|
|
80
|
-
|
|
81
76
|
def self.fragment_name(graphql_type)
|
|
82
77
|
"#{graphql_type}Fragment"
|
|
83
78
|
end
|
|
@@ -35,18 +35,18 @@ module ActiveShopifyGraphQL
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
# Extract connection data from GraphQL response for eager loading
|
|
38
|
-
def extract_connection_data(response_data, root_path: nil)
|
|
38
|
+
def extract_connection_data(response_data, root_path: nil, parent_instance: nil)
|
|
39
39
|
return {} if @context.included_connections.empty?
|
|
40
40
|
|
|
41
41
|
root_path ||= ["data", @context.query_name]
|
|
42
42
|
root_data = response_data.dig(*root_path)
|
|
43
43
|
return {} unless root_data
|
|
44
44
|
|
|
45
|
-
extract_connections_from_node(root_data)
|
|
45
|
+
extract_connections_from_node(root_data, parent_instance)
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
# Extract connections from a node (reusable for nested connections)
|
|
49
|
-
def extract_connections_from_node(node_data)
|
|
49
|
+
def extract_connections_from_node(node_data, parent_instance = nil)
|
|
50
50
|
return {} if @context.included_connections.empty?
|
|
51
51
|
|
|
52
52
|
connections = @context.connections
|
|
@@ -59,7 +59,7 @@ module ActiveShopifyGraphQL
|
|
|
59
59
|
connection_config = connections[connection_name]
|
|
60
60
|
next unless connection_config
|
|
61
61
|
|
|
62
|
-
records = extract_connection_records(node_data, connection_config, nested_includes)
|
|
62
|
+
records = extract_connection_records(node_data, connection_config, nested_includes, parent_instance: parent_instance)
|
|
63
63
|
connection_cache[connection_name] = records if records
|
|
64
64
|
end
|
|
65
65
|
|
|
@@ -69,7 +69,7 @@ module ActiveShopifyGraphQL
|
|
|
69
69
|
# Map nested connection response (when loading via parent query)
|
|
70
70
|
def map_nested_connection_response(response_data, connection_field_name, parent, connection_config = nil)
|
|
71
71
|
parent_type = parent.class.graphql_type_for_loader(@context.loader_class)
|
|
72
|
-
parent_query_name = parent_type.
|
|
72
|
+
parent_query_name = parent_type.camelize(:lower)
|
|
73
73
|
connection_type = connection_config&.dig(:type) || :connection
|
|
74
74
|
|
|
75
75
|
if connection_type == :singular
|
|
@@ -111,8 +111,26 @@ module ActiveShopifyGraphQL
|
|
|
111
111
|
private
|
|
112
112
|
|
|
113
113
|
def extract_and_transform_value(node_data, config, attr_name)
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
path = config[:path]
|
|
115
|
+
|
|
116
|
+
value = if config[:raw_graphql]
|
|
117
|
+
# For raw_graphql, the alias is the attr_name, then dig using path if nested
|
|
118
|
+
raw_data = node_data[attr_name.to_s]
|
|
119
|
+
if path.include?('.')
|
|
120
|
+
# Path is relative to the aliased root
|
|
121
|
+
path_parts = path.split('.')[1..] # Skip the first part (attr_name itself)
|
|
122
|
+
path_parts.any? ? raw_data&.dig(*path_parts) : raw_data
|
|
123
|
+
else
|
|
124
|
+
raw_data
|
|
125
|
+
end
|
|
126
|
+
elsif path.include?('.')
|
|
127
|
+
# Nested path - dig using the full path
|
|
128
|
+
path_parts = path.split('.')
|
|
129
|
+
node_data.dig(*path_parts)
|
|
130
|
+
else
|
|
131
|
+
# Simple path - use attr_name as key (matches the alias in the query)
|
|
132
|
+
node_data[attr_name.to_s]
|
|
133
|
+
end
|
|
116
134
|
|
|
117
135
|
value = apply_defaults_and_transforms(value, config)
|
|
118
136
|
validate_null_constraint!(value, config, attr_name)
|
|
@@ -153,23 +171,33 @@ module ActiveShopifyGraphQL
|
|
|
153
171
|
end
|
|
154
172
|
end
|
|
155
173
|
|
|
156
|
-
def extract_connection_records(node_data, connection_config, nested_includes)
|
|
157
|
-
|
|
174
|
+
def extract_connection_records(node_data, connection_config, nested_includes, parent_instance: nil)
|
|
175
|
+
# Use original_name (Ruby attr name) as the response key since we alias connections
|
|
176
|
+
response_key = connection_config[:original_name].to_s
|
|
158
177
|
connection_type = connection_config[:type] || :connection
|
|
159
178
|
target_class = connection_config[:class_name].constantize
|
|
179
|
+
connection_name = connection_config[:original_name]
|
|
160
180
|
|
|
161
181
|
if connection_type == :singular
|
|
162
|
-
item_data = node_data[
|
|
182
|
+
item_data = node_data[response_key]
|
|
163
183
|
return nil unless item_data
|
|
164
184
|
|
|
165
|
-
build_nested_model_instance(item_data, target_class, nested_includes
|
|
185
|
+
build_nested_model_instance(item_data, target_class, nested_includes,
|
|
186
|
+
parent_instance: parent_instance,
|
|
187
|
+
parent_connection_name: connection_name,
|
|
188
|
+
connection_config: connection_config)
|
|
166
189
|
else
|
|
167
|
-
edges = node_data.dig(
|
|
190
|
+
edges = node_data.dig(response_key, "edges")
|
|
168
191
|
return nil unless edges
|
|
169
192
|
|
|
170
193
|
edges.filter_map do |edge|
|
|
171
194
|
item_data = edge["node"]
|
|
172
|
-
|
|
195
|
+
if item_data
|
|
196
|
+
build_nested_model_instance(item_data, target_class, nested_includes,
|
|
197
|
+
parent_instance: parent_instance,
|
|
198
|
+
parent_connection_name: connection_name,
|
|
199
|
+
connection_config: connection_config)
|
|
200
|
+
end
|
|
173
201
|
end
|
|
174
202
|
end
|
|
175
203
|
end
|
|
@@ -181,16 +209,35 @@ module ActiveShopifyGraphQL
|
|
|
181
209
|
@context.model_class.new(attributes)
|
|
182
210
|
end
|
|
183
211
|
|
|
184
|
-
def build_nested_model_instance(node_data, target_class, nested_includes)
|
|
212
|
+
def build_nested_model_instance(node_data, target_class, nested_includes, parent_instance: nil, parent_connection_name: nil, connection_config: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
185
213
|
nested_context = @context.for_model(target_class, new_connections: nested_includes)
|
|
186
214
|
nested_mapper = ResponseMapper.new(nested_context)
|
|
187
215
|
|
|
188
216
|
attributes = nested_mapper.map_node_to_attributes(node_data)
|
|
189
217
|
instance = target_class.new(attributes)
|
|
190
218
|
|
|
191
|
-
#
|
|
219
|
+
# Populate inverse cache if inverse_of is specified
|
|
220
|
+
if parent_instance && connection_config && connection_config[:inverse_of]
|
|
221
|
+
inverse_name = connection_config[:inverse_of]
|
|
222
|
+
instance.instance_variable_set(:@_connection_cache, {}) unless instance.instance_variable_get(:@_connection_cache)
|
|
223
|
+
cache = instance.instance_variable_get(:@_connection_cache)
|
|
224
|
+
|
|
225
|
+
# Check the type of the inverse connection to determine how to cache
|
|
226
|
+
if target_class.respond_to?(:connections) && target_class.connections[inverse_name]
|
|
227
|
+
inverse_type = target_class.connections[inverse_name][:type]
|
|
228
|
+
cache[inverse_name] =
|
|
229
|
+
if inverse_type == :singular
|
|
230
|
+
parent_instance
|
|
231
|
+
else
|
|
232
|
+
# For collection inverses, wrap parent in an array
|
|
233
|
+
[parent_instance]
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Handle nested connections recursively (instance becomes parent for its children)
|
|
192
239
|
if nested_includes.any?
|
|
193
|
-
nested_data = nested_mapper.extract_connections_from_node(node_data)
|
|
240
|
+
nested_data = nested_mapper.extract_connections_from_node(node_data, instance)
|
|
194
241
|
nested_data.each do |nested_name, nested_records|
|
|
195
242
|
instance.send("#{nested_name}=", nested_records)
|
|
196
243
|
end
|