active_shopify_graphql 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e4003c2f9180531bc4c3a08e9c921d16402642c60c45c777ac49726b5a6a3f2
4
- data.tar.gz: df5619e4f4845d688cdd1beb5228047d52022eca04e3b1c3479265ed1b59d9c1
3
+ metadata.gz: f0b5f3a4ce7ed92feb8483e2c064379788138d7a7336daa849a81715fd0dd3f2
4
+ data.tar.gz: b48985b14a5e07a5e5273431d82c06756dfc7feab40ddd27f65fa313c080d261
5
5
  SHA512:
6
- metadata.gz: d95bac2e81df933e3f3bbfbef55c486e2ba719271a93283b7e8fca72684a43208f0479be44286bd6ee99e9de427cdbdf44378aa20feb2c5895e3908ad112ab8d
7
- data.tar.gz: f87d6688ac0374fb4821da10928b13127f8c272d090978646026dbf84adfc210039e8690e348cd570b48c28608a43252e998e5dea7dc527a193bc67e97d96440
6
+ metadata.gz: 5f7cd6debcc40234ea46da5b8e88332a6c715db5d78ef82040ec1827a963557b60589f70df2261d09f6b08e699233b2aad7eaf00eb20eb6a2f80eb35608d6258
7
+ data.tar.gz: e3d0c95ba71e3be17f0a57ba26cd6f25d7c6ea783dc4b0608609f6344bf783ef2aec0e5b532b83d1a90084c3e3389bc8018396911a02c2da06d9f2c523b33c66
data/.rubocop.yml CHANGED
@@ -1,5 +1,6 @@
1
1
  AllCops:
2
2
  NewCops: enable
3
+ SuggestExtensions: false
3
4
 
4
5
  Style/StringLiterals:
5
6
  Enabled: false
data/README.md CHANGED
@@ -323,6 +323,35 @@ end
323
323
 
324
324
  The associations automatically handle Shopify GID format conversion, extracting numeric IDs when needed for querying related records.
325
325
 
326
+ ## Bridging ActiveRecord with GraphQL
327
+
328
+ The `GraphQLAssociations` module allows ActiveRecord models (or duck-typed objects) to define associations to Shopify GraphQL models:
329
+
330
+ ```ruby
331
+ class Reward < ApplicationRecord
332
+ include ActiveShopifyGraphQL::GraphQLAssociations
333
+
334
+ belongs_to_graphql :customer # Expects shopify_customer_id column
335
+ has_one_graphql :primary_address,
336
+ class_name: "Address",
337
+ foreign_key: :customer_id
338
+ has_many_graphql :variants,
339
+ class_name: "ProductVariant"
340
+ end
341
+
342
+ reward = Reward.find(1)
343
+ reward.customer # Loads Customer from shopify_customer_id
344
+ reward.primary_address # Queries Address.where(customer_id: reward.shopify_customer_id).first
345
+ reward.variants # Queries ProductVariant.where({})
346
+ ```
347
+
348
+ **Available associations:**
349
+ - `belongs_to_graphql` - Loads single GraphQL object via stored GID/ID
350
+ - `has_one_graphql` - Queries first GraphQL object matching foreign key
351
+ - `has_many_graphql` - Queries multiple GraphQL objects with optional filtering
352
+
353
+ All associations support `class_name`, `foreign_key`, `primary_key`, and `loader_class` options. Results are automatically cached and setter methods are provided for testing.
354
+
326
355
  ## GraphQL Connections
327
356
 
328
357
  ActiveShopifyGraphQL supports GraphQL connections for loading related data from Shopify APIs. Connections provide both lazy and eager loading patterns.
@@ -36,9 +36,6 @@ module ActiveShopifyGraphQL
36
36
  primary_key_value = send(association_primary_key)
37
37
  return @_association_cache[name] = [] if primary_key_value.blank?
38
38
 
39
- # Extract numeric ID from Shopify GID if needed
40
- primary_key_value = primary_key_value.to_plain_id if primary_key_value.gid?
41
-
42
39
  association_class = association_class_name.constantize
43
40
  @_association_cache[name] = association_class.where(association_foreign_key => primary_key_value)
44
41
  end
@@ -73,7 +70,14 @@ module ActiveShopifyGraphQL
73
70
  return @_association_cache[name] = nil if primary_key_value.blank?
74
71
 
75
72
  # Extract numeric ID from Shopify GID if needed
76
- primary_key_value = primary_key_value.to_plain_id if primary_key_value.gid?
73
+ if primary_key_value.is_a?(String)
74
+ begin
75
+ parsed_gid = URI::GID.parse(primary_key_value)
76
+ primary_key_value = parsed_gid.model_id
77
+ rescue URI::InvalidURIError, URI::BadURIError, ArgumentError
78
+ # Not a GID, use value as-is
79
+ end
80
+ end
77
81
 
78
82
  association_class = association_class_name.constantize
79
83
  @_association_cache[name] = association_class.find_by(association_foreign_key => primary_key_value)
@@ -8,19 +8,23 @@ module ActiveShopifyGraphQL
8
8
  # Find a single record by ID using the provided loader
9
9
  # @param id [String, Integer] The record ID (will be converted to GID automatically)
10
10
  # @param loader [ActiveShopifyGraphQL::Loader] The loader to use for fetching data
11
- # @return [Object, nil] The model instance or nil if not found
11
+ # @return [Object] The model instance
12
+ # @raise [ActiveShopifyGraphQL::ObjectNotFoundError] If the record is not found
12
13
  def find(id, loader: default_loader)
13
14
  gid = GidHelper.normalize_gid(id, model_name.name.demodulize)
14
15
 
15
16
  # If we have included connections, we need to handle inverse_of properly
16
- if loader.respond_to?(:load_with_instance) && loader.has_included_connections?
17
- loader.load_with_instance(gid, self)
18
- else
19
- attributes = loader.load_attributes(gid)
20
- return nil if attributes.nil?
17
+ result =
18
+ if loader.has_included_connections?
19
+ loader.load_with_instance(gid, self)
20
+ else
21
+ attributes = loader.load_attributes(gid)
22
+ attributes.nil? ? nil : new(attributes)
23
+ end
21
24
 
22
- new(attributes)
23
- end
25
+ raise ObjectNotFoundError, "Couldn't find #{name} with id=#{id}" if result.nil?
26
+
27
+ result
24
28
  end
25
29
 
26
30
  # Returns the default loader for this model's queries
@@ -77,6 +81,25 @@ module ActiveShopifyGraphQL
77
81
  selected_class
78
82
  end
79
83
 
84
+ # Find a single record by attribute conditions
85
+ # @param conditions [Hash] The conditions to query (e.g., { email: "example@test.com", first_name: "John" })
86
+ # @param options [Hash] Options hash containing loader
87
+ # @option options [ActiveShopifyGraphQL::Loader] :loader The loader to use for fetching data
88
+ # @return [Object, nil] The first matching model instance or nil if not found
89
+ # @raise [ArgumentError] If any attribute is not valid for querying
90
+ #
91
+ # @example
92
+ # # Keyword argument style (recommended)
93
+ # Customer.find_by(email: "john@example.com")
94
+ # Customer.find_by(first_name: "John", country: "Canada")
95
+ # Customer.find_by(orders_count: { gte: 5 })
96
+ #
97
+ # # Hash style with options
98
+ # Customer.find_by({ email: "john@example.com" }, loader: custom_loader)
99
+ def find_by(conditions_or_first_condition = {}, *args, **options)
100
+ where(conditions_or_first_condition, *args, **options.merge(limit: 1)).first
101
+ end
102
+
80
103
  # Query for multiple records using attribute conditions
81
104
  # @param conditions [Hash] The conditions to query (e.g., { email: "example@test.com", first_name: "John" })
82
105
  # @param options [Hash] Options hash containing loader and limit (when first arg is a Hash)
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveShopifyGraphQL
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -23,6 +23,7 @@ require_relative "active_shopify_graphql/loaders/customer_account_api_loader"
23
23
  require_relative "active_shopify_graphql/loader_switchable"
24
24
  require_relative "active_shopify_graphql/finder_methods"
25
25
  require_relative "active_shopify_graphql/associations"
26
+ require_relative "active_shopify_graphql/graphql_associations"
26
27
  require_relative "active_shopify_graphql/includes_scope"
27
28
  require_relative "active_shopify_graphql/connections"
28
29
  require_relative "active_shopify_graphql/attributes"
@@ -32,4 +33,5 @@ require_relative "active_shopify_graphql/base"
32
33
 
33
34
  module ActiveShopifyGraphQL
34
35
  class Error < StandardError; end
36
+ class ObjectNotFoundError < Error; end
35
37
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_shopify_graphql
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicolò Rebughini
@@ -79,6 +79,7 @@ files:
79
79
  - lib/active_shopify_graphql/finder_methods.rb
80
80
  - lib/active_shopify_graphql/fragment_builder.rb
81
81
  - lib/active_shopify_graphql/gid_helper.rb
82
+ - lib/active_shopify_graphql/graphql_associations.rb
82
83
  - lib/active_shopify_graphql/graphql_type_resolver.rb
83
84
  - lib/active_shopify_graphql/includes_scope.rb
84
85
  - lib/active_shopify_graphql/loader.rb