active_shopify_graphql 0.3.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -0
  3. data/README.md +187 -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/graphql_associations.rb +245 -0
  8. data/lib/active_shopify_graphql/loader.rb +147 -126
  9. data/lib/active_shopify_graphql/loader_context.rb +2 -47
  10. data/lib/active_shopify_graphql/loader_proxy.rb +67 -0
  11. data/lib/active_shopify_graphql/loaders/admin_api_loader.rb +1 -17
  12. data/lib/active_shopify_graphql/loaders/customer_account_api_loader.rb +10 -26
  13. data/lib/active_shopify_graphql/model/associations.rb +94 -0
  14. data/lib/active_shopify_graphql/model/attributes.rb +48 -0
  15. data/lib/active_shopify_graphql/model/connections.rb +174 -0
  16. data/lib/active_shopify_graphql/model/finder_methods.rb +155 -0
  17. data/lib/active_shopify_graphql/model/graphql_type_resolver.rb +89 -0
  18. data/lib/active_shopify_graphql/model/loader_switchable.rb +79 -0
  19. data/lib/active_shopify_graphql/model/metafield_attributes.rb +59 -0
  20. data/lib/active_shopify_graphql/{base.rb → model.rb} +30 -16
  21. data/lib/active_shopify_graphql/model_builder.rb +53 -0
  22. data/lib/active_shopify_graphql/query/node/collection.rb +78 -0
  23. data/lib/active_shopify_graphql/query/node/connection.rb +16 -0
  24. data/lib/active_shopify_graphql/query/node/current_customer.rb +37 -0
  25. data/lib/active_shopify_graphql/query/node/field.rb +23 -0
  26. data/lib/active_shopify_graphql/query/node/fragment.rb +17 -0
  27. data/lib/active_shopify_graphql/query/node/nested_connection.rb +79 -0
  28. data/lib/active_shopify_graphql/query/node/raw.rb +15 -0
  29. data/lib/active_shopify_graphql/query/node/root_connection.rb +76 -0
  30. data/lib/active_shopify_graphql/query/node/single_record.rb +36 -0
  31. data/lib/active_shopify_graphql/query/node/singular.rb +16 -0
  32. data/lib/active_shopify_graphql/query/node.rb +95 -0
  33. data/lib/active_shopify_graphql/query/query_builder.rb +290 -0
  34. data/lib/active_shopify_graphql/query/relation.rb +424 -0
  35. data/lib/active_shopify_graphql/query/scope.rb +219 -0
  36. data/lib/active_shopify_graphql/response/page_info.rb +40 -0
  37. data/lib/active_shopify_graphql/response/paginated_result.rb +114 -0
  38. data/lib/active_shopify_graphql/response/response_mapper.rb +242 -0
  39. data/lib/active_shopify_graphql/search_query/hash_condition_formatter.rb +107 -0
  40. data/lib/active_shopify_graphql/search_query/parameter_binder.rb +68 -0
  41. data/lib/active_shopify_graphql/search_query/value_sanitizer.rb +24 -0
  42. data/lib/active_shopify_graphql/search_query.rb +34 -84
  43. data/lib/active_shopify_graphql/version.rb +1 -1
  44. data/lib/active_shopify_graphql.rb +30 -28
  45. metadata +47 -15
  46. data/lib/active_shopify_graphql/associations.rb +0 -90
  47. data/lib/active_shopify_graphql/attributes.rb +0 -50
  48. data/lib/active_shopify_graphql/connection_loader.rb +0 -96
  49. data/lib/active_shopify_graphql/connections.rb +0 -198
  50. data/lib/active_shopify_graphql/finder_methods.rb +0 -159
  51. data/lib/active_shopify_graphql/fragment_builder.rb +0 -228
  52. data/lib/active_shopify_graphql/graphql_type_resolver.rb +0 -91
  53. data/lib/active_shopify_graphql/includes_scope.rb +0 -48
  54. data/lib/active_shopify_graphql/loader_switchable.rb +0 -180
  55. data/lib/active_shopify_graphql/metafield_attributes.rb +0 -61
  56. data/lib/active_shopify_graphql/query_node.rb +0 -173
  57. data/lib/active_shopify_graphql/query_tree.rb +0 -225
  58. data/lib/active_shopify_graphql/response_mapper.rb +0 -249
@@ -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