active_shopify_graphql 0.4.0 → 0.5.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +3 -0
  3. data/README.md +158 -56
  4. data/lib/active_shopify_graphql/configuration.rb +2 -15
  5. data/lib/active_shopify_graphql/connections/connection_loader.rb +141 -0
  6. data/lib/active_shopify_graphql/gid_helper.rb +2 -0
  7. data/lib/active_shopify_graphql/loader.rb +147 -126
  8. data/lib/active_shopify_graphql/loader_context.rb +2 -47
  9. data/lib/active_shopify_graphql/loader_proxy.rb +67 -0
  10. data/lib/active_shopify_graphql/loaders/admin_api_loader.rb +1 -17
  11. data/lib/active_shopify_graphql/loaders/customer_account_api_loader.rb +10 -26
  12. data/lib/active_shopify_graphql/model/associations.rb +94 -0
  13. data/lib/active_shopify_graphql/model/attributes.rb +48 -0
  14. data/lib/active_shopify_graphql/model/connections.rb +174 -0
  15. data/lib/active_shopify_graphql/model/finder_methods.rb +155 -0
  16. data/lib/active_shopify_graphql/model/graphql_type_resolver.rb +89 -0
  17. data/lib/active_shopify_graphql/model/loader_switchable.rb +79 -0
  18. data/lib/active_shopify_graphql/model/metafield_attributes.rb +59 -0
  19. data/lib/active_shopify_graphql/{base.rb → model.rb} +30 -16
  20. data/lib/active_shopify_graphql/model_builder.rb +53 -0
  21. data/lib/active_shopify_graphql/query/node/collection.rb +78 -0
  22. data/lib/active_shopify_graphql/query/node/connection.rb +16 -0
  23. data/lib/active_shopify_graphql/query/node/current_customer.rb +37 -0
  24. data/lib/active_shopify_graphql/query/node/field.rb +23 -0
  25. data/lib/active_shopify_graphql/query/node/fragment.rb +17 -0
  26. data/lib/active_shopify_graphql/query/node/nested_connection.rb +79 -0
  27. data/lib/active_shopify_graphql/query/node/raw.rb +15 -0
  28. data/lib/active_shopify_graphql/query/node/root_connection.rb +76 -0
  29. data/lib/active_shopify_graphql/query/node/single_record.rb +36 -0
  30. data/lib/active_shopify_graphql/query/node/singular.rb +16 -0
  31. data/lib/active_shopify_graphql/query/node.rb +95 -0
  32. data/lib/active_shopify_graphql/query/query_builder.rb +290 -0
  33. data/lib/active_shopify_graphql/query/relation.rb +424 -0
  34. data/lib/active_shopify_graphql/query/scope.rb +219 -0
  35. data/lib/active_shopify_graphql/response/page_info.rb +40 -0
  36. data/lib/active_shopify_graphql/response/paginated_result.rb +114 -0
  37. data/lib/active_shopify_graphql/response/response_mapper.rb +242 -0
  38. data/lib/active_shopify_graphql/search_query/hash_condition_formatter.rb +107 -0
  39. data/lib/active_shopify_graphql/search_query/parameter_binder.rb +68 -0
  40. data/lib/active_shopify_graphql/search_query/value_sanitizer.rb +24 -0
  41. data/lib/active_shopify_graphql/search_query.rb +34 -84
  42. data/lib/active_shopify_graphql/version.rb +1 -1
  43. data/lib/active_shopify_graphql.rb +29 -29
  44. metadata +46 -15
  45. data/lib/active_shopify_graphql/associations.rb +0 -94
  46. data/lib/active_shopify_graphql/attributes.rb +0 -50
  47. data/lib/active_shopify_graphql/connection_loader.rb +0 -96
  48. data/lib/active_shopify_graphql/connections.rb +0 -198
  49. data/lib/active_shopify_graphql/finder_methods.rb +0 -182
  50. data/lib/active_shopify_graphql/fragment_builder.rb +0 -228
  51. data/lib/active_shopify_graphql/graphql_type_resolver.rb +0 -91
  52. data/lib/active_shopify_graphql/includes_scope.rb +0 -48
  53. data/lib/active_shopify_graphql/loader_switchable.rb +0 -180
  54. data/lib/active_shopify_graphql/metafield_attributes.rb +0 -61
  55. data/lib/active_shopify_graphql/query_node.rb +0 -173
  56. data/lib/active_shopify_graphql/query_tree.rb +0 -225
  57. data/lib/active_shopify_graphql/response_mapper.rb +0 -249
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ # A chainable query builder that accumulates query configuration
6
+ # and executes the query when records are accessed.
7
+ #
8
+ # @example Basic usage
9
+ # ProductVariant.where(sku: "*").limit(100).to_a
10
+ #
11
+ # @example Pagination with block
12
+ # ProductVariant.where(sku: "*").in_pages(of: 50) do |page|
13
+ # process_batch(page)
14
+ # end
15
+ #
16
+ # @example Manual pagination
17
+ # page = ProductVariant.where(sku: "*").in_pages(of: 50)
18
+ # page.each { |record| process(record) }
19
+ # page = page.next_page while page.has_next_page?
20
+ class Scope
21
+ include Enumerable
22
+
23
+ DEFAULT_PER_PAGE = 250
24
+
25
+ attr_reader :model_class, :conditions, :total_limit, :per_page
26
+
27
+ def initialize(model_class, conditions: {}, loader: nil, total_limit: nil, per_page: DEFAULT_PER_PAGE)
28
+ @model_class = model_class
29
+ @conditions = conditions
30
+ @loader = loader
31
+ @total_limit = total_limit
32
+ @per_page = [per_page, ActiveShopifyGraphQL.configuration.max_objects_per_paginated_query].min
33
+ @loaded = false
34
+ @records = nil
35
+ end
36
+
37
+ # Set a limit on total records to return
38
+ # @param count [Integer] Maximum number of records to fetch across all pages
39
+ # @return [Scope] A new scope with the limit applied
40
+ def limit(count)
41
+ dup_with(total_limit: count)
42
+ end
43
+
44
+ # Configure pagination and optionally iterate through pages
45
+ # @param of [Integer] Number of records per page (default: 250, max: 250)
46
+ # @yield [PaginatedResult] Each page of results
47
+ # @return [PaginatedResult, self] Returns PaginatedResult if no block given
48
+ def in_pages(of: DEFAULT_PER_PAGE, &block)
49
+ page_size = [of, ActiveShopifyGraphQL.configuration.max_objects_per_paginated_query].min
50
+ scoped = dup_with(per_page: page_size)
51
+
52
+ if block_given?
53
+ scoped.each_page(&block)
54
+ self
55
+ else
56
+ scoped.fetch_first_page
57
+ end
58
+ end
59
+
60
+ # Iterate through all pages, yielding each page
61
+ # @yield [PaginatedResult] Each page of results
62
+ def each_page
63
+ current_page = fetch_first_page
64
+ records_yielded = 0
65
+
66
+ loop do
67
+ break if current_page.empty?
68
+
69
+ # Apply total limit if set
70
+ if @total_limit
71
+ remaining = @total_limit - records_yielded
72
+ break if remaining <= 0
73
+
74
+ if current_page.size > remaining
75
+ # Trim the page to fit the limit
76
+ trimmed_records = current_page.records.first(remaining)
77
+ current_page = Response::PaginatedResult.new(
78
+ records: trimmed_records,
79
+ page_info: Response::PageInfo.new, # Empty page info to stop pagination
80
+ query_scope: self
81
+ )
82
+ end
83
+ end
84
+
85
+ yield current_page
86
+ records_yielded += current_page.size
87
+
88
+ break unless current_page.has_next_page?
89
+ break if @total_limit && records_yielded >= @total_limit
90
+
91
+ current_page = current_page.next_page
92
+ end
93
+ end
94
+
95
+ # Iterate through all records across all pages
96
+ # @yield [Object] Each record
97
+ def each(&block)
98
+ return to_enum(:each) unless block_given?
99
+
100
+ each_page do |page|
101
+ page.each(&block)
102
+ end
103
+ end
104
+
105
+ # Load all records respecting total_limit
106
+ # @return [Array] All records
107
+ def to_a
108
+ return @records if @loaded
109
+
110
+ all_records = []
111
+ each_page do |page|
112
+ all_records.concat(page.to_a)
113
+ end
114
+ @records = all_records
115
+ @loaded = true
116
+ @records
117
+ end
118
+ alias load to_a
119
+
120
+ # Get first record
121
+ # @param count [Integer, nil] Number of records to return
122
+ # @return [Object, Array, nil] First record(s) or nil
123
+ def first(count = nil)
124
+ if count
125
+ scoped = dup_with(total_limit: count, per_page: [count, max_objects_per_paginated_query].min)
126
+ scoped.to_a
127
+ else
128
+ scoped = dup_with(total_limit: 1, per_page: 1)
129
+ scoped.to_a.first
130
+ end
131
+ end
132
+
133
+ # Check if any records exist
134
+ # @return [Boolean]
135
+ def exists?
136
+ first(1).any?
137
+ end
138
+
139
+ # Check if no records exist (Array compatibility)
140
+ # @return [Boolean]
141
+ def empty?
142
+ first(1).empty?
143
+ end
144
+
145
+ # Size/length of records (loads all pages, use with caution)
146
+ # @return [Integer]
147
+ def size
148
+ to_a.size
149
+ end
150
+ alias length size
151
+
152
+ # Count records (loads all pages, use with caution)
153
+ # @return [Integer]
154
+ def count
155
+ to_a.count
156
+ end
157
+
158
+ # Array-like access
159
+ def [](index)
160
+ to_a[index]
161
+ end
162
+
163
+ # Map over records (Array compatibility)
164
+ def map(&block)
165
+ to_a.map(&block)
166
+ end
167
+
168
+ # Select/filter records (Array compatibility)
169
+ def select_records(&block)
170
+ to_a.select(&block)
171
+ end
172
+
173
+ # Fetch a specific page by cursor
174
+ # @param after [String, nil] Cursor to fetch records after
175
+ # @param before [String, nil] Cursor to fetch records before
176
+ # @return [PaginatedResult]
177
+ def fetch_page(after: nil, before: nil)
178
+ loader.load_paginated_collection(
179
+ conditions: @conditions,
180
+ per_page: effective_per_page,
181
+ after: after,
182
+ before: before,
183
+ query_scope: self
184
+ )
185
+ end
186
+
187
+ # Fetch the first page of results
188
+ # @return [PaginatedResult]
189
+ def fetch_first_page
190
+ fetch_page
191
+ end
192
+
193
+ private
194
+
195
+ # Calculate effective per_page considering total_limit
196
+ def effective_per_page
197
+ if @total_limit && @total_limit < @per_page
198
+ @total_limit
199
+ else
200
+ @per_page
201
+ end
202
+ end
203
+
204
+ def loader
205
+ @loader ||= @model_class.default_loader
206
+ end
207
+
208
+ def dup_with(**changes)
209
+ Scope.new(
210
+ @model_class,
211
+ conditions: changes.fetch(:conditions, @conditions),
212
+ loader: changes.fetch(:loader, @loader),
213
+ total_limit: changes.fetch(:total_limit, @total_limit),
214
+ per_page: changes.fetch(:per_page, @per_page)
215
+ )
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Response
5
+ # Holds pagination metadata returned from a Shopify GraphQL connection query.
6
+ # Provides methods for navigating between pages.
7
+ class PageInfo
8
+ attr_reader :start_cursor, :end_cursor
9
+
10
+ def initialize(data = {})
11
+ @has_next_page = data["hasNextPage"] || false
12
+ @has_previous_page = data["hasPreviousPage"] || false
13
+ @start_cursor = data["startCursor"]
14
+ @end_cursor = data["endCursor"]
15
+ end
16
+
17
+ def has_next_page?
18
+ @has_next_page
19
+ end
20
+
21
+ def has_previous_page?
22
+ @has_previous_page
23
+ end
24
+
25
+ # Check if this is an empty/null page info
26
+ def empty?
27
+ @start_cursor.nil? && @end_cursor.nil?
28
+ end
29
+
30
+ def to_h
31
+ {
32
+ has_next_page: @has_next_page,
33
+ has_previous_page: @has_previous_page,
34
+ start_cursor: @start_cursor,
35
+ end_cursor: @end_cursor
36
+ }
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Response
5
+ # Represents a page of results from a paginated GraphQL query.
6
+ # Lazily builds model instances from attribute hashes on access.
7
+ # Provides methods to navigate between pages and access pagination metadata.
8
+ #
9
+ # @example Manual pagination
10
+ # page = ProductVariant.where(sku: "*").in_pages(of: 10)
11
+ # page.has_next_page? # => true
12
+ # next_page = page.next_page
13
+ #
14
+ # @example Iteration with block
15
+ # ProductVariant.where(sku: "*").in_pages(of: 10) do |page|
16
+ # page.each { |variant| process(variant) }
17
+ # end
18
+ class PaginatedResult
19
+ include Enumerable
20
+
21
+ attr_reader :page_info, :query_scope
22
+
23
+ def initialize(attributes:, model_class:, page_info:, query_scope:)
24
+ @attributes = attributes
25
+ @model_class = model_class
26
+ @page_info = page_info
27
+ @query_scope = query_scope
28
+ @records = nil # Lazily built
29
+ end
30
+
31
+ # Get the records for this page (builds instances on first access)
32
+ def records
33
+ @records ||= ModelBuilder.build_many(@model_class, @attributes)
34
+ end
35
+
36
+ # Iterate over records in this page
37
+ def each(&block)
38
+ records.each(&block)
39
+ end
40
+
41
+ # Access records by index
42
+ def [](index)
43
+ records[index]
44
+ end
45
+
46
+ # Number of records in this page
47
+ def size
48
+ @attributes.size
49
+ end
50
+ alias length size
51
+
52
+ # Check if this page has records
53
+ def empty?
54
+ @attributes.empty?
55
+ end
56
+
57
+ # Check if there is a next page
58
+ def has_next_page?
59
+ @page_info.has_next_page?
60
+ end
61
+
62
+ # Check if there is a previous page
63
+ def has_previous_page?
64
+ @page_info.has_previous_page?
65
+ end
66
+
67
+ # Cursor pointing to the start of this page
68
+ def start_cursor
69
+ @page_info.start_cursor
70
+ end
71
+
72
+ # Cursor pointing to the end of this page
73
+ def end_cursor
74
+ @page_info.end_cursor
75
+ end
76
+
77
+ # Fetch the next page of results
78
+ # @return [PaginatedResult, nil] The next page or nil if no more pages
79
+ def next_page
80
+ return nil unless has_next_page?
81
+
82
+ @query_scope.fetch_page(after: end_cursor)
83
+ end
84
+
85
+ # Fetch the previous page of results
86
+ # @return [PaginatedResult, nil] The previous page or nil if no previous pages
87
+ def previous_page
88
+ return nil unless has_previous_page?
89
+
90
+ @query_scope.fetch_page(before: start_cursor)
91
+ end
92
+
93
+ # Convert to array (useful for compatibility)
94
+ def to_a
95
+ records.dup
96
+ end
97
+
98
+ # Return all records across all pages
99
+ # Warning: This will make multiple API calls if there are many pages
100
+ # @return [Array] All records from all pages
101
+ def all_records
102
+ all = records.dup
103
+ current = self
104
+
105
+ while current.has_next_page?
106
+ current = current.next_page
107
+ all.concat(current.records)
108
+ end
109
+
110
+ all
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Response
5
+ # Handles mapping GraphQL responses to model attributes.
6
+ # Refactored to use LoaderContext and unified mapping methods.
7
+ class ResponseMapper
8
+ attr_reader :context
9
+
10
+ def initialize(context)
11
+ @context = context
12
+ end
13
+
14
+ # Map GraphQL response to attributes using declared attribute metadata
15
+ # @param response_data [Hash] The full GraphQL response
16
+ # @param root_path [Array<String>] Path to the data root (e.g., ["data", "customer"])
17
+ # @return [Hash] Mapped attributes
18
+ def map_response(response_data, root_path: nil)
19
+ root_path ||= ["data", @context.query_name]
20
+ root_data = response_data.dig(*root_path)
21
+ return {} unless root_data
22
+
23
+ map_node_to_attributes(root_data)
24
+ end
25
+
26
+ # Map a single node's data to attributes (used for both root and nested)
27
+ def map_node_to_attributes(node_data)
28
+ return {} unless node_data
29
+
30
+ result = {}
31
+ @context.defined_attributes.each do |attr_name, config|
32
+ value = extract_and_transform_value(node_data, config, attr_name)
33
+ result[attr_name] = value
34
+ end
35
+ result
36
+ end
37
+
38
+ # Extract connection data from GraphQL response for eager loading
39
+ def extract_connection_data(response_data, root_path: nil, parent_instance: nil)
40
+ return {} if @context.included_connections.empty?
41
+
42
+ root_path ||= ["data", @context.query_name]
43
+ root_data = response_data.dig(*root_path)
44
+ return {} unless root_data
45
+
46
+ extract_connections_from_node(root_data, parent_instance)
47
+ end
48
+
49
+ # Extract connections from a node (reusable for nested connections)
50
+ def extract_connections_from_node(node_data, parent_instance = nil)
51
+ return {} if @context.included_connections.empty?
52
+
53
+ connections = @context.connections
54
+ return {} if connections.empty?
55
+
56
+ normalized_includes = Query::QueryBuilder.normalize_includes(@context.included_connections)
57
+ connection_cache = {}
58
+
59
+ normalized_includes.each do |connection_name, nested_includes|
60
+ connection_config = connections[connection_name]
61
+ next unless connection_config
62
+
63
+ records = extract_connection_records(node_data, connection_config, nested_includes, parent_instance: parent_instance)
64
+ connection_cache[connection_name] = records if records
65
+ end
66
+
67
+ connection_cache
68
+ end
69
+
70
+ # Map nested connection response (when loading via parent query)
71
+ # Returns attributes instead of instances
72
+ def map_nested_connection_response(response_data, connection_field_name, parent, connection_config = nil)
73
+ parent_type = parent.class.graphql_type_for_loader(@context.loader_class)
74
+ parent_query_name = parent_type.camelize(:lower)
75
+ connection_type = connection_config&.dig(:type) || :connection
76
+
77
+ if connection_type == :singular
78
+ node_data = response_data.dig("data", parent_query_name, connection_field_name)
79
+ return nil unless node_data
80
+
81
+ map_node_to_attributes(node_data)
82
+ else
83
+ nodes = response_data.dig("data", parent_query_name, connection_field_name, "nodes")
84
+ return [] unless nodes
85
+
86
+ nodes.filter_map do |node_data|
87
+ map_node_to_attributes(node_data) if node_data
88
+ end
89
+ end
90
+ end
91
+
92
+ # Map root connection response
93
+ # Returns attributes instead of instances
94
+ def map_connection_response(response_data, query_name, connection_config = nil)
95
+ connection_type = connection_config&.dig(:type) || :connection
96
+
97
+ if connection_type == :singular
98
+ node_data = response_data.dig("data", query_name)
99
+ return nil unless node_data
100
+
101
+ map_node_to_attributes(node_data)
102
+ else
103
+ nodes = response_data.dig("data", query_name, "nodes")
104
+ return [] unless nodes
105
+
106
+ nodes.filter_map do |node_data|
107
+ map_node_to_attributes(node_data) if node_data
108
+ end
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ def extract_and_transform_value(node_data, config, attr_name)
115
+ path = config[:path]
116
+
117
+ value = if config[:raw_graphql]
118
+ # For raw_graphql, the alias is the attr_name, then dig using path if nested
119
+ raw_data = node_data[attr_name.to_s]
120
+ if path.include?('.')
121
+ # Path is relative to the aliased root
122
+ path_parts = path.split('.')[1..] # Skip the first part (attr_name itself)
123
+ path_parts.any? ? raw_data&.dig(*path_parts) : raw_data
124
+ else
125
+ raw_data
126
+ end
127
+ elsif path.include?('.')
128
+ # Nested path - dig using the full path
129
+ path_parts = path.split('.')
130
+ node_data.dig(*path_parts)
131
+ else
132
+ # Simple path - use attr_name as key (matches the alias in the query)
133
+ node_data[attr_name.to_s]
134
+ end
135
+
136
+ value = apply_defaults_and_transforms(value, config)
137
+ validate_null_constraint!(value, config, attr_name)
138
+ coerce_value(value, config[:type])
139
+ end
140
+
141
+ def apply_defaults_and_transforms(value, config)
142
+ if value.nil?
143
+ return config[:default] unless config[:default].nil?
144
+
145
+ return config[:transform]&.call(value)
146
+ end
147
+
148
+ config[:transform] ? config[:transform].call(value) : value
149
+ end
150
+
151
+ def validate_null_constraint!(value, config, attr_name)
152
+ return unless !config[:null] && value.nil?
153
+
154
+ raise ArgumentError, "Attribute '#{attr_name}' (GraphQL path: '#{config[:path]}') cannot be null but received nil"
155
+ end
156
+
157
+ def coerce_value(value, type)
158
+ return nil if value.nil?
159
+ return value if value.is_a?(Array) # Preserve arrays
160
+
161
+ type_caster(type).cast(value)
162
+ end
163
+
164
+ def type_caster(type)
165
+ case type
166
+ when :string then ActiveModel::Type::String.new
167
+ when :integer then ActiveModel::Type::Integer.new
168
+ when :float then ActiveModel::Type::Float.new
169
+ when :boolean then ActiveModel::Type::Boolean.new
170
+ when :datetime then ActiveModel::Type::DateTime.new
171
+ else ActiveModel::Type::Value.new
172
+ end
173
+ end
174
+
175
+ def extract_connection_records(node_data, connection_config, nested_includes, parent_instance: nil)
176
+ # Use original_name (Ruby attr name) as the response key since we alias connections
177
+ response_key = connection_config[:original_name].to_s
178
+ connection_type = connection_config[:type] || :connection
179
+ target_class = connection_config[:class_name].constantize
180
+
181
+ if connection_type == :singular
182
+ item_data = node_data[response_key]
183
+ return nil unless item_data
184
+
185
+ build_nested_model_instance(item_data, target_class, nested_includes,
186
+ parent_instance: parent_instance,
187
+ connection_config: connection_config)
188
+ else
189
+ nodes = node_data.dig(response_key, "nodes")
190
+ return nil unless nodes
191
+
192
+ nodes.filter_map do |item_data|
193
+ if item_data
194
+ build_nested_model_instance(item_data, target_class, nested_includes,
195
+ parent_instance: parent_instance,
196
+ connection_config: connection_config)
197
+ end
198
+ end
199
+ end
200
+ end
201
+
202
+ # Build a nested model instance with inverse_of wiring
203
+ # Used for eager-loaded connections that go into the connection cache
204
+ def build_nested_model_instance(node_data, target_class, nested_includes, parent_instance: nil, connection_config: nil)
205
+ nested_context = @context.for_model(target_class, new_connections: nested_includes)
206
+ nested_mapper = ResponseMapper.new(nested_context)
207
+
208
+ attributes = nested_mapper.map_node_to_attributes(node_data)
209
+ instance = target_class.new(attributes)
210
+
211
+ # Populate inverse cache if inverse_of is specified
212
+ if parent_instance && connection_config && connection_config[:inverse_of]
213
+ inverse_name = connection_config[:inverse_of]
214
+ instance.instance_variable_set(:@_connection_cache, {}) unless instance.instance_variable_get(:@_connection_cache)
215
+ cache = instance.instance_variable_get(:@_connection_cache)
216
+
217
+ # Check the type of the inverse connection to determine how to cache
218
+ if target_class.respond_to?(:connections) && target_class.connections[inverse_name]
219
+ inverse_type = target_class.connections[inverse_name][:type]
220
+ cache[inverse_name] =
221
+ if inverse_type == :singular
222
+ parent_instance
223
+ else
224
+ # For collection inverses, wrap parent in an array
225
+ [parent_instance]
226
+ end
227
+ end
228
+ end
229
+
230
+ # Handle nested connections recursively (instance becomes parent for its children)
231
+ if nested_includes.any?
232
+ nested_data = nested_mapper.extract_connections_from_node(node_data, instance)
233
+ nested_data.each do |nested_name, nested_records|
234
+ instance.send("#{nested_name}=", nested_records)
235
+ end
236
+ end
237
+
238
+ instance
239
+ end
240
+ end
241
+ end
242
+ end