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
@@ -4,48 +4,31 @@ require 'active_model/type'
4
4
  require 'global_id'
5
5
 
6
6
  module ActiveShopifyGraphQL
7
- # Base loader class that orchestrates GraphQL query execution and response mapping.
8
- # Refactored to use LoaderContext for cleaner parameter management.
7
+ # The Loader acts as a stateless orchestrator that:
8
+ # - Receives a model class and delegates to it for GraphQL type and attribute definitions
9
+ # - Builds a LoaderContext that encapsulates all query-building parameters
10
+ # - Delegates query construction to Query::QueryBuilder
11
+ # - Delegates response mapping to Response::ResponseMapper
12
+ # - Executes GraphQL queries via subclass-specific implementations (perform_graphql_query)
13
+ #
14
+ # == Subclass Requirements
15
+ #
16
+ # Subclasses must implement:
17
+ # - +perform_graphql_query(query, **variables)+ - Execute the query against the appropriate API
18
+ #
19
+ # == Usage
20
+ #
21
+ # loader = AdminApiLoader.new(Customer, selected_attributes: [:id, :email])
22
+ # attributes = loader.load_attributes("gid://shopify/Customer/123")
23
+ # customer = Customer.new(attributes)
24
+ #
25
+ # @see LoaderContext For query-building context management
26
+ # @see Query::QueryBuilder For GraphQL query construction
27
+ # @see Response::ResponseMapper For response-to-attribute mapping
9
28
  class Loader
10
- class << self
11
- # Set or get the GraphQL type for this loader
12
- def graphql_type(type = nil)
13
- return @graphql_type = type if type
14
-
15
- # Try to get GraphQL type from associated model class first
16
- return model_class.graphql_type_for_loader(self) if model_class
17
-
18
- @graphql_type || raise(NotImplementedError, "#{self} must define graphql_type")
19
- end
20
-
21
- # Get the model class associated with this loader
22
- def model_class
23
- @model_class ||= infer_model_class
24
- end
25
-
26
- attr_writer :model_class
27
-
28
- # Get attributes from the model class for this loader
29
- def defined_attributes
30
- return {} unless model_class
31
-
32
- model_class.attributes_for_loader(self)
33
- end
34
-
35
- private
36
-
37
- def infer_model_class
38
- return nil unless @graphql_type
39
-
40
- Object.const_get(@graphql_type)
41
- rescue NameError
42
- nil
43
- end
44
- end
45
-
46
- # Initialize loader with optional model class and configuration
47
- def initialize(model_class = nil, selected_attributes: nil, included_connections: nil, **)
48
- @model_class = model_class || self.class.model_class
29
+ # Initialize loader with model class and configuration
30
+ def initialize(model_class, selected_attributes: nil, included_connections: nil, **)
31
+ @model_class = model_class
49
32
  @selected_attributes = selected_attributes&.map(&:to_sym)
50
33
  @included_connections = included_connections || []
51
34
  end
@@ -53,7 +36,7 @@ module ActiveShopifyGraphQL
53
36
  # Build the LoaderContext for this loader instance
54
37
  def context
55
38
  @context ||= LoaderContext.new(
56
- graphql_type: graphql_type,
39
+ graphql_type: resolve_graphql_type,
57
40
  loader_class: self.class,
58
41
  defined_attributes: defined_attributes,
59
42
  model_class: @model_class,
@@ -61,51 +44,23 @@ module ActiveShopifyGraphQL
61
44
  )
62
45
  end
63
46
 
64
- # Get GraphQL type for this loader instance
65
- def graphql_type
66
- GraphqlTypeResolver.resolve(model_class: @model_class, loader_class: self.class)
67
- end
68
-
69
47
  # Get defined attributes for this loader instance
70
48
  def defined_attributes
71
- attrs = if @model_class
72
- @model_class.attributes_for_loader(self.class)
73
- else
74
- self.class.defined_attributes
75
- end
76
-
77
- filter_selected_attributes(attrs)
49
+ filter_selected_attributes(@model_class.attributes_for_loader(self.class))
78
50
  end
79
51
 
80
- # Returns the complete GraphQL fragment
81
- def fragment
82
- FragmentBuilder.new(context).build
83
- end
84
-
85
- # Delegate query building methods
86
- def query_name(model_type = nil)
87
- (model_type || graphql_type).camelize(:lower)
88
- end
89
-
90
- def fragment_name(model_type = nil)
91
- "#{model_type || graphql_type}Fragment"
92
- end
93
-
94
- def graphql_query(_model_type = nil)
95
- QueryTree.build_single_record_query(context)
52
+ # Returns the arguments needed to initialize a new loader of the same type
53
+ # Subclasses should override this if they require additional initialization arguments
54
+ # @return [Array] Array of arguments to pass to the loader initializer
55
+ def initialization_args
56
+ []
96
57
  end
97
58
 
98
59
  # Map the GraphQL response to model attributes
99
60
  def map_response_to_attributes(response_data, parent_instance: nil)
100
- mapper = ResponseMapper.new(context)
61
+ mapper = create_response_mapper
101
62
  attributes = mapper.map_response(response_data)
102
-
103
- # If we have included connections, extract and cache them
104
- if @included_connections.any?
105
- connection_data = mapper.extract_connection_data(response_data, parent_instance: parent_instance)
106
- attributes[:_connection_cache] = connection_data unless connection_data.empty?
107
- end
108
-
63
+ cache_connections(mapper, response_data, target: attributes, parent_instance: parent_instance)
109
64
  attributes
110
65
  end
111
66
 
@@ -114,63 +69,47 @@ module ActiveShopifyGraphQL
114
69
  @included_connections&.any?
115
70
  end
116
71
 
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
-
143
72
  # Executes the GraphQL query and returns the mapped attributes hash
73
+ # @param id [String] The GID of the record to load
74
+ # @return [Hash, nil] Attribute hash with connection cache, or nil if not found
144
75
  def load_attributes(id)
145
- query = graphql_query
146
- response_data = perform_graphql_query(query, id: id)
147
-
76
+ query = Query::QueryBuilder.build_single_record_query(context)
77
+ response_data = execute_query(query, id: id)
148
78
  return nil if response_data.nil?
149
79
 
150
80
  map_response_to_attributes(response_data)
151
81
  end
152
82
 
153
- # Executes a collection query using Shopify's search syntax
154
- def load_collection(conditions = {}, limit: 250)
155
- search_query = SearchQuery.new(conditions)
156
- collection_query_name = query_name.pluralize
157
- variables = { query: search_query.to_s, first: limit }
83
+ # Executes a paginated collection query that returns attributes and page info
84
+ # Executes a paginated collection query that returns attributes and page info
85
+ # @param conditions [Hash] Search conditions
86
+ # @param per_page [Integer] Number of records per page
87
+ # @param after [String, nil] Cursor to fetch records after
88
+ # @param before [String, nil] Cursor to fetch records before
89
+ # @param query_scope [Query::Scope] The query scope for navigation
90
+ # @return [PaginatedResult] A paginated result with attribute hashes and page info
91
+ def load_paginated_collection(conditions:, per_page:, query_scope:, after: nil, before: nil)
92
+ collection_query_name = context.query_name.pluralize
93
+ variables = build_collection_variables(
94
+ conditions,
95
+ per_page: per_page,
96
+ after: after,
97
+ before: before
98
+ )
158
99
 
159
- query = QueryTree.build_collection_query(
100
+ query = Query::QueryBuilder.build_paginated_collection_query(
160
101
  context,
161
102
  query_name: collection_query_name,
162
- variables: variables,
163
- connection_type: :nodes_only
103
+ variables: variables
164
104
  )
165
105
 
166
- response = perform_graphql_query(query, **variables)
167
- validate_search_response(response)
168
- map_collection_response(response, collection_query_name)
106
+ response = execute_query_and_validate_search_response(query, **variables)
107
+ map_paginated_response(response, collection_query_name, query_scope)
169
108
  end
170
109
 
171
110
  # Load records for a connection query
172
111
  def load_connection_records(query_name, variables, parent = nil, connection_config = nil)
173
- connection_loader = ConnectionLoader.new(context, loader_instance: self)
112
+ connection_loader = Connections::ConnectionLoader.new(context, loader_instance: self)
174
113
  connection_loader.load_records(query_name, variables, parent, connection_config)
175
114
  end
176
115
 
@@ -181,6 +120,41 @@ module ActiveShopifyGraphQL
181
120
 
182
121
  private
183
122
 
123
+ def create_response_mapper
124
+ Response::ResponseMapper.new(context)
125
+ end
126
+
127
+ def should_log?
128
+ ActiveShopifyGraphQL.configuration.log_queries && ActiveShopifyGraphQL.configuration.logger
129
+ end
130
+
131
+ def log_query(api_name, query, variables)
132
+ return unless should_log?
133
+
134
+ ActiveShopifyGraphQL.configuration.logger.info("ActiveShopifyGraphQL Query (#{api_name}):\n#{query}")
135
+ ActiveShopifyGraphQL.configuration.logger.info("ActiveShopifyGraphQL Variables:\n#{variables}")
136
+ end
137
+
138
+ def cache_connections(mapper, response_data, target:, parent_instance: nil)
139
+ return unless @included_connections.any?
140
+
141
+ connection_data = mapper.extract_connection_data(response_data, parent_instance: parent_instance)
142
+ return if connection_data.empty?
143
+
144
+ case target
145
+ when Hash
146
+ target[:_connection_cache] = connection_data
147
+ else
148
+ target.instance_variable_set(:@_connection_cache, connection_data)
149
+ end
150
+ end
151
+
152
+ def resolve_graphql_type
153
+ raise ArgumentError, "#{self.class} requires a model_class" unless @model_class
154
+
155
+ @model_class.graphql_type_for_loader(self.class)
156
+ end
157
+
184
158
  def filter_selected_attributes(attrs)
185
159
  return attrs unless @selected_attributes
186
160
 
@@ -191,7 +165,7 @@ module ActiveShopifyGraphQL
191
165
  selected
192
166
  end
193
167
 
194
- def validate_search_response(response)
168
+ def validate_search_query_response(response)
195
169
  return unless response.dig("extensions", "search")
196
170
 
197
171
  warnings = response["extensions"]["search"].flat_map { |s| s["warnings"] || [] }
@@ -201,14 +175,61 @@ module ActiveShopifyGraphQL
201
175
  raise ArgumentError, "Shopify query validation failed: #{messages.join(', ')}"
202
176
  end
203
177
 
204
- def map_collection_response(response_data, collection_query_name)
205
- nodes = response_data.dig("data", collection_query_name, "nodes")
206
- return [] unless nodes&.any?
178
+ def execute_query(query, **variables)
179
+ perform_graphql_query(query, **variables)
180
+ end
181
+
182
+ def execute_query_and_validate_search_response(query, **variables)
183
+ response = execute_query(query, **variables)
184
+ validate_search_query_response(response)
185
+ response
186
+ end
207
187
 
208
- nodes.filter_map do |node_data|
209
- single_response = { "data" => { query_name => node_data } }
210
- map_response_to_attributes(single_response)
188
+ def build_collection_variables(conditions, per_page:, after: nil, before: nil)
189
+ search_query = SearchQuery.new(conditions)
190
+ variables = { query: search_query.to_s }
191
+
192
+ if before
193
+ variables[:last] = per_page
194
+ variables[:before] = before
195
+ else
196
+ variables[:first] = per_page
197
+ variables[:after] = after if after
211
198
  end
199
+
200
+ variables.compact
201
+ end
202
+
203
+ def map_node_to_attributes(node_data)
204
+ single_response = { "data" => { context.query_name => node_data } }
205
+ map_response_to_attributes(single_response)
206
+ end
207
+
208
+ def map_paginated_response(response_data, collection_query_name, query_scope)
209
+ connection_data = response_data.dig("data", collection_query_name)
210
+ return empty_paginated_result(query_scope) unless connection_data
211
+
212
+ page_info_data = connection_data["pageInfo"] || {}
213
+ page_info = Response::PageInfo.new(page_info_data)
214
+
215
+ nodes = connection_data["nodes"] || []
216
+ attributes_array = nodes.filter_map { |node_data| map_node_to_attributes(node_data) }
217
+
218
+ Response::PaginatedResult.new(
219
+ attributes: attributes_array,
220
+ model_class: @model_class,
221
+ page_info: page_info,
222
+ query_scope: query_scope
223
+ )
224
+ end
225
+
226
+ def empty_paginated_result(query_scope)
227
+ Response::PaginatedResult.new(
228
+ attributes: [],
229
+ model_class: @model_class,
230
+ page_info: Response::PageInfo.new,
231
+ query_scope: query_scope
232
+ )
212
233
  end
213
234
  end
214
235
  end
@@ -14,23 +14,12 @@ module ActiveShopifyGraphQL
14
14
  @included_connections = Array(included_connections)
15
15
  end
16
16
 
17
- # Create a new context with different included connections (for nested loading)
18
- def with_connections(new_connections)
19
- self.class.new(
20
- graphql_type: graphql_type,
21
- loader_class: loader_class,
22
- defined_attributes: defined_attributes,
23
- model_class: model_class,
24
- included_connections: new_connections
25
- )
26
- end
27
-
28
17
  # Create a new context for a different model (for connection targets)
29
18
  def for_model(new_model_class, new_graphql_type: nil, new_attributes: nil, new_connections: [])
30
19
  self.class.new(
31
- graphql_type: new_graphql_type || infer_graphql_type(new_model_class),
20
+ graphql_type: new_graphql_type || new_model_class.graphql_type_for_loader(loader_class),
32
21
  loader_class: loader_class,
33
- defined_attributes: new_attributes || infer_attributes(new_model_class),
22
+ defined_attributes: new_attributes || new_model_class.attributes_for_loader(loader_class),
34
23
  model_class: new_model_class,
35
24
  included_connections: new_connections
36
25
  )
@@ -50,39 +39,5 @@ module ActiveShopifyGraphQL
50
39
 
51
40
  model_class.connections
52
41
  end
53
-
54
- def ==(other)
55
- other.is_a?(LoaderContext) &&
56
- graphql_type == other.graphql_type &&
57
- loader_class == other.loader_class &&
58
- defined_attributes == other.defined_attributes &&
59
- model_class == other.model_class &&
60
- included_connections == other.included_connections
61
- end
62
- alias eql? ==
63
-
64
- def hash
65
- [graphql_type, loader_class, defined_attributes, model_class, included_connections].hash
66
- end
67
-
68
- private
69
-
70
- def infer_graphql_type(klass)
71
- if klass.respond_to?(:graphql_type_for_loader)
72
- klass.graphql_type_for_loader(loader_class)
73
- elsif klass.respond_to?(:graphql_type)
74
- klass.graphql_type
75
- elsif klass.respond_to?(:name) && klass.name
76
- klass.name.demodulize
77
- else
78
- raise ArgumentError, "Cannot infer graphql_type for #{klass}"
79
- end
80
- end
81
-
82
- def infer_attributes(klass)
83
- return klass.attributes_for_loader(loader_class) if klass.respond_to?(:attributes_for_loader)
84
-
85
- {}
86
- end
87
42
  end
88
43
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ # Simple proxy class to handle loader delegation when using a specific API
5
+ # This provides a consistent interface with Relation while using a custom loader
6
+ class LoaderProxy
7
+ def initialize(model_class, loader)
8
+ @model_class = model_class
9
+ @loader = loader
10
+ end
11
+
12
+ # Create a Relation with this loader's configuration
13
+ # @return [Relation] A relation configured with this loader
14
+ def all
15
+ build_relation
16
+ end
17
+
18
+ # Delegate chainable methods to Relation
19
+ def includes(*connection_names)
20
+ build_relation.includes(*connection_names)
21
+ end
22
+
23
+ def select(*attribute_names)
24
+ build_relation.select(*attribute_names)
25
+ end
26
+
27
+ def where(*args, **options)
28
+ build_relation.where(*args, **options)
29
+ end
30
+
31
+ def find_by(conditions = {}, **options)
32
+ build_relation.find_by(conditions, **options)
33
+ end
34
+
35
+ def find(id = nil)
36
+ # For Customer Account API, if no ID is provided, load the current customer
37
+ if id.nil? && @loader.is_a?(ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader)
38
+ attributes = @loader.load_attributes
39
+ return nil if attributes.nil?
40
+
41
+ return @model_class.new(attributes)
42
+ end
43
+
44
+ # For other cases, require ID and use standard flow
45
+ return nil if id.nil?
46
+
47
+ build_relation.find(id)
48
+ end
49
+
50
+ attr_reader :loader
51
+
52
+ def inspect
53
+ "#{@model_class.name}(with_#{@loader.class.name.demodulize})"
54
+ end
55
+ alias to_s inspect
56
+
57
+ private
58
+
59
+ def build_relation
60
+ Query::Relation.new(
61
+ @model_class,
62
+ loader_class: @loader.class,
63
+ loader_extra_args: @loader.initialization_args
64
+ )
65
+ end
66
+ end
67
+ end
@@ -3,30 +3,14 @@
3
3
  module ActiveShopifyGraphQL
4
4
  module Loaders
5
5
  class AdminApiLoader < Loader
6
- def initialize(model_class = nil, selected_attributes: nil, included_connections: nil)
7
- super
8
- end
9
-
10
6
  def perform_graphql_query(query, **variables)
11
- log_query(query, variables) if should_log?
7
+ log_query("Admin API", query, variables)
12
8
 
13
9
  client = ActiveShopifyGraphQL.configuration.admin_api_client
14
10
  raise Error, "Admin API client not configured. Please configure it using ActiveShopifyGraphQL.configure" unless client
15
11
 
16
12
  client.execute(query, **variables)
17
13
  end
18
-
19
- private
20
-
21
- def should_log?
22
- ActiveShopifyGraphQL.configuration.log_queries && ActiveShopifyGraphQL.configuration.logger
23
- end
24
-
25
- def log_query(query, variables)
26
- logger = ActiveShopifyGraphQL.configuration.logger
27
- logger.info("ActiveShopifyGraphQL Query (Admin API):\n#{query}")
28
- logger.info("ActiveShopifyGraphQL Variables:\n#{variables}")
29
- end
30
14
  end
31
15
  end
32
16
  end
@@ -8,23 +8,15 @@ module ActiveShopifyGraphQL
8
8
  @token = token
9
9
  end
10
10
 
11
- # Override to handle Customer queries that don't need an ID
12
- def graphql_query(model_type = nil)
13
- type = model_type || graphql_type
14
- if type == 'Customer'
15
- QueryTree.build_current_customer_query(context)
16
- else
17
- super(type)
18
- end
19
- end
20
-
21
11
  # Override load_attributes to handle the Customer case
22
12
  def load_attributes(id = nil)
23
- type = graphql_type
24
- query = graphql_query(type)
25
-
26
- variables = type == 'Customer' ? {} : { id: id }
27
- response_data = perform_graphql_query(query, **variables)
13
+ query = if context.graphql_type == 'Customer'
14
+ Query::QueryBuilder.build_current_customer_query(context)
15
+ else
16
+ Query::QueryBuilder.build_single_record_query(context)
17
+ end
18
+ variables = context.graphql_type == 'Customer' ? {} : { id: id }
19
+ response_data = execute_query(query, **variables)
28
20
 
29
21
  return nil if response_data.nil?
30
22
 
@@ -39,20 +31,12 @@ module ActiveShopifyGraphQL
39
31
  end
40
32
 
41
33
  def perform_graphql_query(query, **variables)
42
- log_query(query, variables) if should_log?
34
+ log_query("Customer Account API", query, variables)
43
35
  client.query(query, variables)
44
36
  end
45
37
 
46
- private
47
-
48
- def should_log?
49
- ActiveShopifyGraphQL.configuration.log_queries && ActiveShopifyGraphQL.configuration.logger
50
- end
51
-
52
- def log_query(query, variables)
53
- logger = ActiveShopifyGraphQL.configuration.logger
54
- logger.info("ActiveShopifyGraphQL Query (Customer Account API):\n#{query}")
55
- logger.info("ActiveShopifyGraphQL Variables:\n#{variables}")
38
+ def initialization_args
39
+ [@token]
56
40
  end
57
41
  end
58
42
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL::Model::Associations
4
+ # Handles associations between ActiveShopifyGraphQL objects and ActiveRecord objects
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class << self
9
+ def associations
10
+ @associations ||= {}
11
+ end
12
+
13
+ attr_writer :associations
14
+ end
15
+ end
16
+
17
+ class_methods do
18
+ def has_many(name, class_name: nil, foreign_key: nil, primary_key: nil)
19
+ association_class_name = class_name || name.to_s.classify
20
+ association_foreign_key = foreign_key || "shopify_#{model_name.element.downcase}_id"
21
+ association_primary_key = primary_key || :id
22
+
23
+ # Store association metadata
24
+ associations[name] = {
25
+ type: :has_many,
26
+ class_name: association_class_name,
27
+ foreign_key: association_foreign_key,
28
+ primary_key: association_primary_key
29
+ }
30
+
31
+ # Define the association method
32
+ define_method name do
33
+ return @_association_cache[name] if @_association_cache&.key?(name)
34
+
35
+ @_association_cache ||= {}
36
+
37
+ primary_key_value = send(association_primary_key)
38
+ return @_association_cache[name] = [] if primary_key_value.blank?
39
+
40
+ association_class = association_class_name.constantize
41
+ @_association_cache[name] = association_class.where(association_foreign_key => primary_key_value)
42
+ end
43
+
44
+ # Define the association setter method for testing/mocking
45
+ define_method "#{name}=" do |value|
46
+ @_association_cache ||= {}
47
+ @_association_cache[name] = value
48
+ end
49
+ end
50
+
51
+ def has_one(name, class_name: nil, foreign_key: nil, primary_key: nil)
52
+ association_class_name = class_name || name.to_s.classify
53
+ association_foreign_key = foreign_key || "shopify_#{model_name.element.downcase}_id"
54
+ association_primary_key = primary_key || :id
55
+
56
+ # Store association metadata
57
+ associations[name] = {
58
+ type: :has_one,
59
+ class_name: association_class_name,
60
+ foreign_key: association_foreign_key,
61
+ primary_key: association_primary_key
62
+ }
63
+
64
+ # Define the association method
65
+ define_method name do
66
+ return @_association_cache[name] if @_association_cache&.key?(name)
67
+
68
+ @_association_cache ||= {}
69
+
70
+ primary_key_value = send(association_primary_key)
71
+ return @_association_cache[name] = nil if primary_key_value.blank?
72
+
73
+ # Extract numeric ID from Shopify GID if needed
74
+ if primary_key_value.is_a?(String)
75
+ begin
76
+ parsed_gid = URI::GID.parse(primary_key_value)
77
+ primary_key_value = parsed_gid.model_id
78
+ rescue URI::InvalidURIError, URI::BadURIError, ArgumentError
79
+ # Not a GID, use value as-is
80
+ end
81
+ end
82
+
83
+ association_class = association_class_name.constantize
84
+ @_association_cache[name] = association_class.find_by(association_foreign_key => primary_key_value)
85
+ end
86
+
87
+ # Define the association setter method for testing/mocking
88
+ define_method "#{name}=" do |value|
89
+ @_association_cache ||= {}
90
+ @_association_cache[name] = value
91
+ end
92
+ end
93
+ end
94
+ end