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.
@@ -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.each_value do |config|
36
- if config[:is_metafield]
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
- build_path_tree(path_tree, config[:path])
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).downcase
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
@@ -38,7 +38,7 @@ module ActiveShopifyGraphQL
38
38
 
39
39
  # Helper methods delegated from context
40
40
  def query_name
41
- graphql_type.downcase
41
+ graphql_type.camelize(:lower)
42
42
  end
43
43
 
44
44
  def fragment_name
@@ -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(model_class, selected_attributes: selected_attributes, included_connections: included_connections)
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
- "#{@name}#{args_string} { edges { node { #{fields_string} } } }"
92
+ "#{field_name}#{args_string} { edges { node { #{fields_string} } } }"
88
93
  else
89
94
  <<~GRAPHQL.strip
90
- #{@name}#{args_string} {
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
- "#{@name}#{args_string} { #{fields_string} }"
120
+ "#{field_name}#{args_string} { #{fields_string} }"
113
121
  else
114
- "#{@name}#{args_string} {\n#{nested_indent}#{fields_string}\n#{indent}}"
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.downcase
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
- path_parts = config[:path].split('.')
115
- value = node_data.dig(*path_parts)
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
- query_name = connection_config[:query_name]
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[query_name]
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(query_name, "edges")
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
- build_nested_model_instance(item_data, target_class, nested_includes) if item_data
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
- # Handle nested connections recursively
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