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
@@ -1,173 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Represents a node in the GraphQL query tree
4
- # This allows building the complete query structure before converting to a string
5
- class QueryNode
6
- attr_reader :name, :arguments, :children, :node_type, :alias_name
7
-
8
- # @param name [String] The field name (e.g., 'id', 'displayName', 'orders')
9
- # @param alias_name [String] Optional field alias (e.g., 'myAlias' for 'myAlias: fieldName')
10
- # @param arguments [Hash] Field arguments (e.g., { first: 10, sortKey: 'CREATED_AT' })
11
- # @param node_type [Symbol] Type of node: :field, :connection, :singular, :fragment
12
- # @param children [Array<QueryNode>] Child nodes for nested structures
13
- def initialize(name:, alias_name: nil, arguments: {}, node_type: :field, children: [])
14
- @name = name
15
- @alias_name = alias_name
16
- @arguments = arguments
17
- @node_type = node_type
18
- @children = children
19
- end
20
-
21
- # Add a child node
22
- def add_child(node)
23
- @children << node
24
- node
25
- end
26
-
27
- # Check if node has children
28
- def has_children?
29
- @children.any?
30
- end
31
-
32
- # Convert node to GraphQL string
33
- def to_s(indent_level: 0)
34
- case @node_type
35
- when :field
36
- render_field(indent_level: indent_level)
37
- when :connection
38
- render_connection(indent_level: indent_level)
39
- when :singular
40
- render_singular(indent_level: indent_level)
41
- when :fragment
42
- render_fragment
43
- when :raw
44
- render_raw
45
- else
46
- raise ArgumentError, "Unknown node type: #{@node_type}"
47
- end
48
- end
49
-
50
- private
51
-
52
- def compact?
53
- ActiveShopifyGraphQL.configuration.compact_queries
54
- end
55
-
56
- def render_field(indent_level:)
57
- # Simple field with no children
58
- field_name = @alias_name ? "#{@alias_name}: #{@name}" : @name
59
- args_string = format_arguments
60
- full_name = "#{field_name}#{args_string}"
61
-
62
- return full_name unless has_children?
63
-
64
- # Field with nested structure (e.g., defaultAddress { city })
65
- indent = compact? ? "" : " " * indent_level
66
- nested_indent = compact? ? "" : " " * (indent_level + 1)
67
-
68
- nested_fields = @children.map { |child| child.to_s(indent_level: indent_level + 1) }
69
-
70
- if compact?
71
- "#{full_name} { #{nested_fields.join(' ')} }"
72
- else
73
- separator = "\n"
74
- "#{full_name} {#{separator}#{nested_indent}#{nested_fields.join("\n#{nested_indent}")}#{separator}#{indent}}"
75
- end
76
- end
77
-
78
- def render_connection(indent_level:)
79
- args_string = format_arguments
80
-
81
- indent = compact? ? "" : " " * indent_level
82
- nested_indent = compact? ? "" : " " * (indent_level + 1)
83
-
84
- # Build nested fields from children
85
- nested_fields = @children.map { |child| child.to_s(indent_level: indent_level + 2) }
86
- fields_string = nested_fields.join(compact? ? " " : "\n#{nested_indent} ")
87
-
88
- # Include alias if present
89
- field_name = @alias_name ? "#{@alias_name}: #{@name}" : @name
90
-
91
- if compact?
92
- "#{field_name}#{args_string} { edges { node { #{fields_string} } } }"
93
- else
94
- <<~GRAPHQL.strip
95
- #{field_name}#{args_string} {
96
- #{nested_indent}edges {
97
- #{nested_indent} node {
98
- #{nested_indent} #{fields_string}
99
- #{nested_indent} }
100
- #{nested_indent}}
101
- #{indent}}
102
- GRAPHQL
103
- end
104
- end
105
-
106
- def render_singular(indent_level:)
107
- args_string = format_arguments
108
-
109
- indent = compact? ? "" : " " * indent_level
110
- nested_indent = compact? ? "" : " " * (indent_level + 1)
111
-
112
- # Build nested fields from children
113
- nested_fields = @children.map { |child| child.to_s(indent_level: indent_level + 1) }
114
- fields_string = nested_fields.join(compact? ? " " : "\n#{nested_indent}")
115
-
116
- # Include alias if present
117
- field_name = @alias_name ? "#{@alias_name}: #{@name}" : @name
118
-
119
- if compact?
120
- "#{field_name}#{args_string} { #{fields_string} }"
121
- else
122
- "#{field_name}#{args_string} {\n#{nested_indent}#{fields_string}\n#{indent}}"
123
- end
124
- end
125
-
126
- def render_fragment
127
- # Fragment fields are the children
128
- fields = @children.map { |child| child.to_s(indent_level: 0) }
129
- all_fields = fields.join(compact? ? " " : "\n")
130
-
131
- if compact?
132
- "fragment #{@name} on #{@arguments[:on]} { #{all_fields} }"
133
- else
134
- "fragment #{@name} on #{@arguments[:on]} {\n#{all_fields}\n}"
135
- end
136
- end
137
-
138
- def render_raw
139
- # Raw GraphQL string stored in arguments[:raw_graphql]
140
- @arguments[:raw_graphql]
141
- end
142
-
143
- def format_arguments
144
- return "" if @arguments.empty?
145
-
146
- args = @arguments.map do |key, value|
147
- # Convert Ruby snake_case to GraphQL camelCase
148
- graphql_key = key.to_s.camelize(:lower)
149
-
150
- # Format value based on type
151
- formatted_value = case value
152
- when String
153
- # Check if it needs quotes (query parameter vs enum values)
154
- # For metafields and most strings, add quotes
155
- if %i[namespace key].include?(key.to_sym)
156
- "\"#{value}\""
157
- elsif key.to_sym == :query
158
- "\"#{value}\""
159
- else
160
- value
161
- end
162
- when Symbol
163
- value.to_s
164
- else
165
- value
166
- end
167
-
168
- "#{graphql_key}: #{formatted_value}"
169
- end
170
-
171
- "(#{args.join(', ')})"
172
- end
173
- end
@@ -1,225 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActiveShopifyGraphQL
4
- # Builds complete GraphQL queries from a LoaderContext.
5
- # Refactored for Single Responsibility - only handles query string generation.
6
- # Fragment building is delegated to FragmentBuilder.
7
- class QueryTree
8
- attr_reader :context
9
-
10
- def initialize(context)
11
- @context = context
12
- @fragments = []
13
- @query_config = {}
14
- end
15
-
16
- # Class-level factory methods for building complete queries
17
-
18
- def self.build_single_record_query(context)
19
- new(context).tap do |tree|
20
- tree.add_fragment(FragmentBuilder.new(context).build)
21
- tree.set_query_config(
22
- type: :single_record,
23
- model_type: context.graphql_type,
24
- query_name: context.query_name,
25
- fragment_name: context.fragment_name
26
- )
27
- end.to_s
28
- end
29
-
30
- # Build a query that doesn't require an ID parameter (e.g., Customer Account API's current customer)
31
- def self.build_current_customer_query(context, query_name: nil)
32
- new(context).tap do |tree|
33
- tree.add_fragment(FragmentBuilder.new(context).build)
34
- tree.set_query_config(
35
- type: :current_customer,
36
- model_type: context.graphql_type,
37
- query_name: query_name || context.query_name,
38
- fragment_name: context.fragment_name
39
- )
40
- end.to_s
41
- end
42
-
43
- def self.build_collection_query(context, query_name:, variables:, connection_type: :nodes_only)
44
- new(context).tap do |tree|
45
- tree.add_fragment(FragmentBuilder.new(context).build)
46
- tree.set_query_config(
47
- type: :collection,
48
- model_type: context.graphql_type,
49
- query_name: query_name,
50
- fragment_name: context.fragment_name,
51
- variables: variables,
52
- connection_type: connection_type
53
- )
54
- end.to_s
55
- end
56
-
57
- def self.build_connection_query(context, query_name:, variables:, parent_query: nil, connection_type: :connection)
58
- new(context).tap do |tree|
59
- tree.add_fragment(FragmentBuilder.new(context).build)
60
- tree.set_query_config(
61
- type: :connection,
62
- query_name: query_name,
63
- fragment_name: context.fragment_name,
64
- variables: variables,
65
- parent_query: parent_query,
66
- connection_type: connection_type
67
- )
68
- end.to_s
69
- end
70
-
71
- # Delegate normalize_includes to FragmentBuilder
72
- def self.normalize_includes(includes)
73
- FragmentBuilder.normalize_includes(includes)
74
- end
75
-
76
- def self.fragment_name(graphql_type)
77
- "#{graphql_type}Fragment"
78
- end
79
-
80
- def add_fragment(fragment_node)
81
- @fragments << fragment_node
82
- end
83
-
84
- def set_query_config(config)
85
- @query_config = config
86
- end
87
-
88
- def to_s
89
- case @query_config[:type]
90
- when :single_record then render_single_record_query
91
- when :current_customer then render_current_customer_query
92
- when :collection then render_collection_query
93
- when :connection then render_connection_query
94
- else ""
95
- end
96
- end
97
-
98
- private
99
-
100
- def compact?
101
- ActiveShopifyGraphQL.configuration.compact_queries
102
- end
103
-
104
- def fragments_string
105
- @fragments.map(&:to_s).join(compact? ? " " : "\n\n")
106
- end
107
-
108
- def field_signature(variables)
109
- params = build_field_parameters(variables.compact)
110
- params.empty? ? "" : "(#{params.join(', ')})"
111
- end
112
-
113
- def render_single_record_query
114
- type = @query_config[:model_type]
115
- query_name = @query_config[:query_name]
116
- fragment_name = @query_config[:fragment_name]
117
-
118
- if compact?
119
- "#{fragments_string} query get#{type}($id: ID!) { #{query_name}(id: $id) { ...#{fragment_name} } }"
120
- else
121
- "#{fragments_string}\n\nquery get#{type}($id: ID!) {\n #{query_name}(id: $id) {\n ...#{fragment_name}\n }\n}\n"
122
- end
123
- end
124
-
125
- def render_current_customer_query
126
- type = @query_config[:model_type]
127
- query_name = @query_config[:query_name]
128
- fragment_name = @query_config[:fragment_name]
129
-
130
- if compact?
131
- "#{fragments_string} query getCurrent#{type} { #{query_name} { ...#{fragment_name} } }"
132
- else
133
- "#{fragments_string}\n\nquery getCurrent#{type} {\n #{query_name} {\n ...#{fragment_name}\n }\n}\n"
134
- end
135
- end
136
-
137
- def render_collection_query
138
- type = @query_config[:model_type]
139
- query_name = @query_config[:query_name]
140
- fragment_name = @query_config[:fragment_name]
141
- variables = @query_config[:variables] || {}
142
- connection_type = @query_config[:connection_type] || :nodes_only
143
-
144
- field_sig = field_signature(variables)
145
-
146
- if compact?
147
- body = wrap_connection_body_compact(fragment_name, connection_type)
148
- "#{fragments_string} query get#{type.pluralize} { #{query_name}#{field_sig} { #{body} } }"
149
- else
150
- body = wrap_connection_body_formatted(fragment_name, connection_type, 2)
151
- "#{fragments_string}\nquery get#{type.pluralize} {\n #{query_name}#{field_sig} {\n#{body}\n }\n}\n"
152
- end
153
- end
154
-
155
- def render_connection_query
156
- query_name = @query_config[:query_name]
157
- fragment_name = @query_config[:fragment_name]
158
- variables = @query_config[:variables] || {}
159
- parent_query = @query_config[:parent_query]
160
- connection_type = @query_config[:connection_type] || :connection
161
-
162
- field_sig = field_signature(variables)
163
-
164
- if parent_query
165
- render_nested_connection_query(query_name, fragment_name, field_sig, parent_query, connection_type)
166
- else
167
- render_root_connection_query(query_name, fragment_name, field_sig, connection_type)
168
- end
169
- end
170
-
171
- def render_nested_connection_query(query_name, fragment_name, field_sig, parent_query, connection_type)
172
- if compact?
173
- body = wrap_connection_body_compact(fragment_name, connection_type)
174
- "#{fragments_string} query($id: ID!) { #{parent_query} { #{query_name}#{field_sig} { #{body} } } }"
175
- else
176
- body = wrap_connection_body_formatted(fragment_name, connection_type, 3)
177
- "#{fragments_string}\nquery($id: ID!) {\n #{parent_query} {\n #{query_name}#{field_sig} {\n#{body}\n }\n }\n}\n"
178
- end
179
- end
180
-
181
- def render_root_connection_query(query_name, fragment_name, field_sig, connection_type)
182
- if compact?
183
- body = wrap_connection_body_compact(fragment_name, connection_type)
184
- "#{fragments_string} query { #{query_name}#{field_sig} { #{body} } }"
185
- else
186
- body = wrap_connection_body_formatted(fragment_name, connection_type, 2)
187
- "#{fragments_string}\nquery {\n #{query_name}#{field_sig} {\n#{body}\n }\n}\n"
188
- end
189
- end
190
-
191
- def wrap_connection_body_compact(fragment_name, connection_type)
192
- case connection_type
193
- when :singular then "...#{fragment_name}"
194
- when :nodes_only then "nodes { ...#{fragment_name} }"
195
- else "edges { node { ...#{fragment_name} } }"
196
- end
197
- end
198
-
199
- def wrap_connection_body_formatted(fragment_name, connection_type, indent_level)
200
- indent = " " * indent_level
201
- case connection_type
202
- when :singular
203
- "#{indent}...#{fragment_name}"
204
- when :nodes_only
205
- "#{indent}nodes {\n#{indent} ...#{fragment_name}\n#{indent}}"
206
- else
207
- "#{indent}edges {\n#{indent} node {\n#{indent} ...#{fragment_name}\n#{indent} }\n#{indent}}"
208
- end
209
- end
210
-
211
- def build_field_parameters(variables)
212
- variables.map do |key, value|
213
- "#{key.to_s.camelize(:lower)}: #{format_inline_value(key, value)}"
214
- end
215
- end
216
-
217
- def format_inline_value(key, value)
218
- case value
219
- when Integer, TrueClass, FalseClass then value.to_s
220
- when String then key.to_sym == :query ? "\"#{value}\"" : value
221
- else value.to_s
222
- end
223
- end
224
- end
225
- end
@@ -1,249 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActiveShopifyGraphQL
4
- # Handles mapping GraphQL responses to model attributes.
5
- # Refactored to use LoaderContext and unified mapping methods.
6
- class ResponseMapper
7
- attr_reader :context
8
-
9
- def initialize(context)
10
- @context = context
11
- end
12
-
13
- # Map GraphQL response to attributes using declared attribute metadata
14
- # @param response_data [Hash] The full GraphQL response
15
- # @param root_path [Array<String>] Path to the data root (e.g., ["data", "customer"])
16
- # @return [Hash] Mapped attributes
17
- def map_response(response_data, root_path: nil)
18
- root_path ||= ["data", @context.query_name]
19
- root_data = response_data.dig(*root_path)
20
- return {} unless root_data
21
-
22
- map_node_to_attributes(root_data)
23
- end
24
-
25
- # Map a single node's data to attributes (used for both root and nested)
26
- def map_node_to_attributes(node_data)
27
- return {} unless node_data
28
-
29
- result = {}
30
- @context.defined_attributes.each do |attr_name, config|
31
- value = extract_and_transform_value(node_data, config, attr_name)
32
- result[attr_name] = value
33
- end
34
- result
35
- end
36
-
37
- # Extract connection data from GraphQL response for eager loading
38
- def extract_connection_data(response_data, root_path: nil, parent_instance: nil)
39
- return {} if @context.included_connections.empty?
40
-
41
- root_path ||= ["data", @context.query_name]
42
- root_data = response_data.dig(*root_path)
43
- return {} unless root_data
44
-
45
- extract_connections_from_node(root_data, parent_instance)
46
- end
47
-
48
- # Extract connections from a node (reusable for nested connections)
49
- def extract_connections_from_node(node_data, parent_instance = nil)
50
- return {} if @context.included_connections.empty?
51
-
52
- connections = @context.connections
53
- return {} if connections.empty?
54
-
55
- normalized_includes = FragmentBuilder.normalize_includes(@context.included_connections)
56
- connection_cache = {}
57
-
58
- normalized_includes.each do |connection_name, nested_includes|
59
- connection_config = connections[connection_name]
60
- next unless connection_config
61
-
62
- records = extract_connection_records(node_data, connection_config, nested_includes, parent_instance: parent_instance)
63
- connection_cache[connection_name] = records if records
64
- end
65
-
66
- connection_cache
67
- end
68
-
69
- # Map nested connection response (when loading via parent query)
70
- def map_nested_connection_response(response_data, connection_field_name, parent, connection_config = nil)
71
- parent_type = parent.class.graphql_type_for_loader(@context.loader_class)
72
- parent_query_name = parent_type.camelize(:lower)
73
- connection_type = connection_config&.dig(:type) || :connection
74
-
75
- if connection_type == :singular
76
- node_data = response_data.dig("data", parent_query_name, connection_field_name)
77
- return nil unless node_data
78
-
79
- build_model_instance(node_data)
80
- else
81
- edges = response_data.dig("data", parent_query_name, connection_field_name, "edges")
82
- return [] unless edges
83
-
84
- edges.filter_map do |edge|
85
- node_data = edge["node"]
86
- build_model_instance(node_data) if node_data
87
- end
88
- end
89
- end
90
-
91
- # Map root connection response
92
- def map_connection_response(response_data, query_name, connection_config = nil)
93
- connection_type = connection_config&.dig(:type) || :connection
94
-
95
- if connection_type == :singular
96
- node_data = response_data.dig("data", query_name)
97
- return nil unless node_data
98
-
99
- build_model_instance(node_data)
100
- else
101
- edges = response_data.dig("data", query_name, "edges")
102
- return [] unless edges
103
-
104
- edges.filter_map do |edge|
105
- node_data = edge["node"]
106
- build_model_instance(node_data) if node_data
107
- end
108
- end
109
- end
110
-
111
- private
112
-
113
- def extract_and_transform_value(node_data, config, attr_name)
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
134
-
135
- value = apply_defaults_and_transforms(value, config)
136
- validate_null_constraint!(value, config, attr_name)
137
- coerce_value(value, config[:type])
138
- end
139
-
140
- def apply_defaults_and_transforms(value, config)
141
- if value.nil?
142
- return config[:default] unless config[:default].nil?
143
-
144
- return config[:transform]&.call(value)
145
- end
146
-
147
- config[:transform] ? config[:transform].call(value) : value
148
- end
149
-
150
- def validate_null_constraint!(value, config, attr_name)
151
- return unless !config[:null] && value.nil?
152
-
153
- raise ArgumentError, "Attribute '#{attr_name}' (GraphQL path: '#{config[:path]}') cannot be null but received nil"
154
- end
155
-
156
- def coerce_value(value, type)
157
- return nil if value.nil?
158
- return value if value.is_a?(Array) # Preserve arrays
159
-
160
- type_caster(type).cast(value)
161
- end
162
-
163
- def type_caster(type)
164
- case type
165
- when :string then ActiveModel::Type::String.new
166
- when :integer then ActiveModel::Type::Integer.new
167
- when :float then ActiveModel::Type::Float.new
168
- when :boolean then ActiveModel::Type::Boolean.new
169
- when :datetime then ActiveModel::Type::DateTime.new
170
- else ActiveModel::Type::Value.new
171
- end
172
- end
173
-
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
177
- connection_type = connection_config[:type] || :connection
178
- target_class = connection_config[:class_name].constantize
179
- connection_name = connection_config[:original_name]
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
- parent_connection_name: connection_name,
188
- connection_config: connection_config)
189
- else
190
- edges = node_data.dig(response_key, "edges")
191
- return nil unless edges
192
-
193
- edges.filter_map do |edge|
194
- item_data = edge["node"]
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
201
- end
202
- end
203
- end
204
-
205
- def build_model_instance(node_data)
206
- return nil unless node_data
207
-
208
- attributes = map_node_to_attributes(node_data)
209
- @context.model_class.new(attributes)
210
- end
211
-
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
213
- nested_context = @context.for_model(target_class, new_connections: nested_includes)
214
- nested_mapper = ResponseMapper.new(nested_context)
215
-
216
- attributes = nested_mapper.map_node_to_attributes(node_data)
217
- instance = target_class.new(attributes)
218
-
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)
239
- if nested_includes.any?
240
- nested_data = nested_mapper.extract_connections_from_node(node_data, instance)
241
- nested_data.each do |nested_name, nested_records|
242
- instance.send("#{nested_name}=", nested_records)
243
- end
244
- end
245
-
246
- instance
247
- end
248
- end
249
- end