active_shopify_graphql 0.1.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 (32) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/lint.yml +35 -0
  3. data/.github/workflows/test.yml +35 -0
  4. data/.rubocop.yml +50 -0
  5. data/AGENTS.md +53 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +544 -0
  8. data/Rakefile +8 -0
  9. data/lib/active_shopify_graphql/associations.rb +90 -0
  10. data/lib/active_shopify_graphql/attributes.rb +49 -0
  11. data/lib/active_shopify_graphql/base.rb +29 -0
  12. data/lib/active_shopify_graphql/configuration.rb +29 -0
  13. data/lib/active_shopify_graphql/connection_loader.rb +96 -0
  14. data/lib/active_shopify_graphql/connections/connection_proxy.rb +112 -0
  15. data/lib/active_shopify_graphql/connections.rb +170 -0
  16. data/lib/active_shopify_graphql/finder_methods.rb +154 -0
  17. data/lib/active_shopify_graphql/fragment_builder.rb +195 -0
  18. data/lib/active_shopify_graphql/gid_helper.rb +54 -0
  19. data/lib/active_shopify_graphql/graphql_type_resolver.rb +91 -0
  20. data/lib/active_shopify_graphql/loader.rb +183 -0
  21. data/lib/active_shopify_graphql/loader_context.rb +88 -0
  22. data/lib/active_shopify_graphql/loader_switchable.rb +121 -0
  23. data/lib/active_shopify_graphql/loaders/admin_api_loader.rb +32 -0
  24. data/lib/active_shopify_graphql/loaders/customer_account_api_loader.rb +71 -0
  25. data/lib/active_shopify_graphql/metafield_attributes.rb +61 -0
  26. data/lib/active_shopify_graphql/query_node.rb +160 -0
  27. data/lib/active_shopify_graphql/query_tree.rb +204 -0
  28. data/lib/active_shopify_graphql/response_mapper.rb +202 -0
  29. data/lib/active_shopify_graphql/search_query.rb +71 -0
  30. data/lib/active_shopify_graphql/version.rb +5 -0
  31. data/lib/active_shopify_graphql.rb +34 -0
  32. metadata +147 -0
data/README.md ADDED
@@ -0,0 +1,544 @@
1
+ # ActiveShopifyGraphQL
2
+
3
+ Bringing domain object peace of mind to the world of Shopify GraphQL APIs.
4
+
5
+ An ActiveRecord-like interface for Shopify's GraphQL APIs, supporting both Admin API and Customer Account API with automatic query building and response mapping.
6
+
7
+ ## The problem it solves
8
+
9
+ GraphQL is excellent to provide the exact data for each specific place it's used. However this can be difficult to reason with where you have to deal with very similar payloads across your application. Using hashes or OpenStructs resulting from raw query responses can be cumbersome, as they may have different shapes if they are coming from a query or another.
10
+
11
+ This becomes even more complex when Shopify itself has different GraphQL schemas for the same types: good luck matching two `Customer` objects where one is coming from the [Admin API](https://shopify.dev/docs/api/admin-graphql/latest) and the other from the [Customer Account API](https://shopify.dev/docs/api/customer/latest).
12
+
13
+ This library moves the focus away from the raw query responses, bringing it back to the application domain with actual models. In this way, models present a stable interface, independent of the underlying schema they're coming from.
14
+
15
+ This library brings a Convention over Configuration approach to organize your custom data loaders, along with many ActiveRecord inspired features.
16
+
17
+ ## Installation
18
+
19
+ Add this line to your application's Gemfile:
20
+
21
+ ```ruby
22
+ gem 'active_shopify_graphql'
23
+ ```
24
+
25
+ And then execute:
26
+
27
+ $ bundle install
28
+
29
+ Or install it yourself as:
30
+
31
+ $ gem install active_shopify_graphql
32
+
33
+ ## Configuration
34
+
35
+ Before using ActiveShopifyGraphQL, you need to configure the API clients:
36
+
37
+ ```ruby
38
+ # config/initializers/active_shopify_graphql.rb
39
+ Rails.configuration.to_prepare do
40
+ ActiveShopifyGraphQL.configure do |config|
41
+ # Configure the Admin API client (must respond to #execute(query, **variables))
42
+ config.admin_api_client = ShopifyGraphQL::Client
43
+
44
+ # Configure the Customer Account API client class (must have .from_config(token) class method)
45
+ # and reponsd to #execute(query, **variables)
46
+ config.customer_account_client_class = Shopify::Account::Client
47
+ end
48
+ end
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ ### Basic Model Setup
54
+
55
+ Create a model that includes `ActiveShopifyGraphQL::Base` and define attributes directly:
56
+
57
+ ```ruby
58
+ class Customer
59
+ include ActiveShopifyGraphQL::Base
60
+
61
+ # Define the GraphQL type
62
+ graphql_type "Customer"
63
+
64
+ # Define attributes with automatic GraphQL path inference and type coercion
65
+ attribute :id, type: :string
66
+ attribute :name, path: "displayName", type: :string
67
+ attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
68
+ attribute :created_at, type: :datetime
69
+
70
+ validates :id, presence: true
71
+
72
+ def first_name
73
+ name.split(" ").first
74
+ end
75
+ end
76
+ ```
77
+
78
+ ### Defining Attributes
79
+
80
+ Attributes are now defined directly in the model class using the `attribute` method. The GraphQL fragments and response mapping are automatically generated!
81
+
82
+ #### Basic Attribute Definition
83
+
84
+ ```ruby
85
+ class Customer
86
+ include ActiveShopifyGraphQL::Base
87
+
88
+ graphql_type "Customer"
89
+
90
+ # Define attributes with automatic GraphQL path inference and type coercion
91
+ attribute :id, type: :string
92
+ attribute :name, path: "displayName", type: :string
93
+ attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
94
+ attribute :created_at, type: :datetime
95
+
96
+ # Custom transform example
97
+ attribute :tags, type: :string, transform: ->(tags_array) { tags_array.join(", ") }
98
+ end
99
+ ```
100
+
101
+ #### Attribute Definition Options
102
+
103
+ The `attribute` method supports several options for flexibility:
104
+
105
+ ```ruby
106
+ attribute :name,
107
+ path: "displayName", # Custom GraphQL path (auto-inferred if omitted)
108
+ type: :string, # Type coercion (:string, :integer, :float, :boolean, :datetime)
109
+ null: false, # Whether the attribute can be null (default: true)
110
+ default: "a default value", # The value to assign in case it's nil (default: nil)
111
+ transform: ->(value) { value.upcase } # Custom transformation block
112
+ ```
113
+
114
+ **Auto-inference:** When `path` is omitted, it's automatically inferred by converting snake_case to camelCase (e.g., `display_name` → `displayName`).
115
+
116
+ **Nested paths:** Use dot notation for nested GraphQL fields (e.g., `"defaultEmailAddress.emailAddress"`).
117
+
118
+ **Type coercion:** Automatic conversion using ActiveModel types ensures type safety.
119
+
120
+ **Array handling:** Arrays are automatically preserved regardless of the specified type.
121
+
122
+ #### Metafield Attributes
123
+
124
+ Shopify metafields can be easily accessed using the `metafield_attribute` method:
125
+
126
+ ```ruby
127
+ class Product
128
+ include ActiveShopifyGraphQL::Base
129
+
130
+ graphql_type "Product"
131
+
132
+ # Regular attributes
133
+ attribute :id, type: :string
134
+ attribute :title, type: :string
135
+
136
+ # Metafield attributes
137
+ metafield_attribute :boxes_available, namespace: 'custom', key: 'available_boxes', type: :integer
138
+ metafield_attribute :seo_description, namespace: 'seo', key: 'meta_description', type: :string
139
+ metafield_attribute :product_data, namespace: 'custom', key: 'data', type: :json
140
+ metafield_attribute :is_featured, namespace: 'custom', key: 'featured', type: :boolean, null: false
141
+ end
142
+ ```
143
+
144
+ The metafield attributes automatically generate the correct GraphQL syntax and handle value extraction from either `value` or `jsonValue` fields based on the type.
145
+
146
+ #### API-Specific Attributes
147
+
148
+ For models that need different attributes depending on the API being used, you can define loader-specific overrides:
149
+
150
+ ```ruby
151
+ class Customer
152
+ include ActiveShopifyGraphQL::Base
153
+
154
+ graphql_type "Customer"
155
+
156
+ # Default attributes (used by all loaders)
157
+ attribute :id, type: :string
158
+ attribute :name, path: "displayName", type: :string
159
+
160
+ for_loader ActiveShopifyGraphQL::Loaders::AdminApiLoader do
161
+ attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
162
+ attribute :created_at, type: :datetime
163
+ end
164
+
165
+ # Customer Account API uses different field names
166
+ for_loader ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader do
167
+ attribute :email, path: "emailAddress.emailAddress", type: :string
168
+ attribute :created_at, path: "creationDate", type: :datetime
169
+ end
170
+ end
171
+ ```
172
+
173
+ ### Finding Records
174
+
175
+ Use the `find` method to retrieve records by ID:
176
+
177
+ ```ruby
178
+ # Using Admin API (default)
179
+ customer = Customer.find("gid://shopify/Customer/123456789")
180
+ # You can also use just the ID number
181
+ customer = Customer.find(123456789)
182
+
183
+ # Using Customer Account API
184
+ customer = Customer.with_customer_account_api(token).find
185
+ ```
186
+
187
+ ### API Switching
188
+
189
+ Switch between Admin API and Customer Account API:
190
+
191
+ ```ruby
192
+ # Use Admin API (default)
193
+ customer = Customer.find(id)
194
+
195
+ # Use Customer Account API with token
196
+ customer = Customer.with_customer_account_api(token).find
197
+
198
+ # Use Admin API explicitly
199
+ customer = Customer.with_admin_api.find(id)
200
+
201
+ # Use you own custom Loader
202
+ customer = Customer.with_loader(MyCustomLoader).find(id)
203
+ ```
204
+
205
+ ### Querying Records
206
+
207
+ Use the `where` method to query multiple records using Shopify's search syntax:
208
+
209
+ ```ruby
210
+ # Simple conditions
211
+ customers = Customer.where(email: "john@example.com")
212
+
213
+ # Range queries
214
+ customers = Customer.where(created_at: { gte: "2024-01-01", lt: "2024-02-01" })
215
+ customers = Customer.where(orders_count: { gte: 5 })
216
+
217
+ # Multi-word values are automatically quoted
218
+ customers = Customer.where(first_name: "John Doe")
219
+
220
+ # With limits
221
+ customers = Customer.where({ email: "john@example.com" }, limit: 100)
222
+ ```
223
+
224
+ The `where` method automatically converts Ruby conditions into Shopify's GraphQL query syntax and validates that the query fields are supported by Shopify.
225
+
226
+ ### Optimizing Queries with Select
227
+
228
+ Use the `select` method to only fetch specific attributes, reducing GraphQL query size and improving performance:
229
+
230
+ ```ruby
231
+ # Only fetch id, name, and email
232
+ customer = Customer.select(:id, :name, :email).find(123)
233
+
234
+ # Works with where queries too
235
+ customers = Customer.select(:id, :name).where(country: "Canada")
236
+
237
+ # Always includes id even if not specified
238
+ customer = Customer.select(:name).find(123)
239
+ # This will still include :id in the GraphQL query
240
+ ```
241
+
242
+ The `select` method validates that the specified attributes exist and automatically includes the `id` field for proper object identification.
243
+
244
+ ## Associations
245
+
246
+ ActiveShopifyGraphQL provides ActiveRecord-like associations to define relationships between the your own Shopify GraphQL backed ones and ActiveRecord objects.
247
+
248
+ ### Has Many Associations
249
+
250
+ Use `has_many` to define one-to-many relationships:
251
+
252
+ ```ruby
253
+ class Customer
254
+ include ActiveShopifyGraphQL::Base
255
+
256
+ graphql_type "Customer"
257
+
258
+ attribute :id, type: :string
259
+ attribute :plain_id, path: "id", type: :string, transform: ->(id) { id.split("/").last }
260
+ attribute :display_name, type: :string
261
+ attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
262
+ attribute :created_at, type: :datetime
263
+
264
+ # Define an association to one of your own ActiveRecord models
265
+ # foreign_key maps the id of the GraphQL powered model to the rewards.shopify_customer_id table
266
+ has_many :rewards, foreign_key: :shopify_customer_id
267
+ # primary_key specifies which attribute use as the value for matching the ActiveRecord ID
268
+ has_many :referrals, primary_key: :plain_id, foreign_key: :shopify_id
269
+
270
+ validates :id, presence: true
271
+ end
272
+ ```
273
+
274
+ #### Using the Association
275
+
276
+ ```ruby
277
+ customer = Customer.find("gid://shopify/Customer/123456789") # or Customer.find(123456789)
278
+
279
+ # Access associated orders (lazy loaded)
280
+ customer.rewards
281
+ # => [#<Reward:0x... ]
282
+
283
+ ```
284
+
285
+ ### Has One Associations
286
+
287
+ Use `has_one` to define one-to-one relationships:
288
+
289
+ ```ruby
290
+ class Order
291
+ include ActiveShopifyGraphQL::Base
292
+
293
+ has_one :billing_address, class_name: 'Address'
294
+ end
295
+ ```
296
+
297
+ The associations automatically handle Shopify GID format conversion, extracting numeric IDs when needed for querying related records.
298
+
299
+ ## GraphQL Connections
300
+
301
+ ActiveShopifyGraphQL supports GraphQL connections for loading related data from Shopify APIs. Connections provide both lazy and eager loading patterns with cursor-based pagination support.
302
+
303
+ ### Defining Connections
304
+
305
+ Use the `connection` class method to define connections to other ActiveShopifyGraphQL models:
306
+
307
+ ```ruby
308
+ class Customer
309
+ include ActiveShopifyGraphQL::Base
310
+
311
+ graphql_type 'Customer'
312
+
313
+ attribute :id
314
+ attribute :display_name, path: "displayName"
315
+ attribute :email
316
+
317
+ # Basic connection
318
+ has_many_connected :orders, default_arguments: { first: 10 }
319
+
320
+ # Connection with custom parameters
321
+ has_many_connected :addresses,
322
+ class_name: 'MailingAddress', # Target model class (defaults to connection name)
323
+ query_name: 'customerAddresses', # GraphQL query field (defaults to pluralized name)
324
+ eager_load: true, # Automatically eager load this connection (default: false)
325
+ default_arguments: { # Default arguments for the GraphQL query
326
+ first: 5, # Number of records to fetch (default: 10)
327
+ sort_key: 'CREATED_AT', # Sort key (default: 'CREATED_AT')
328
+ reverse: false # Sort direction (default: false for ascending)
329
+ }
330
+
331
+ # Example of a "scoped" connection
332
+ has_many_connected :recent_orders,
333
+ query_name: "orders", # The query would be inferred to recentOrders() without this
334
+ class_name: "Shopify::Order", # The class would be inferred to RecentOrder without this
335
+ default_arguments: { # The arguments passed in the connection query
336
+ first: 2,
337
+ reverse: true
338
+ }
339
+ end
340
+
341
+ class Order
342
+ include ActiveShopifyGraphQL::Base
343
+
344
+ graphql_type 'Order'
345
+
346
+ attribute :id
347
+ attribute :name
348
+ attribute :total_price, path: "totalPriceSet.shopMoney.amount"
349
+ end
350
+ ```
351
+
352
+ ### Lazy Loading (Default Behavior)
353
+
354
+ Connections are loaded lazily when accessed. A separate GraphQL query is fired when the connection is first accessed:
355
+
356
+ ```ruby
357
+ customer = Customer.find(123456789)
358
+
359
+ # This creates a connection proxy but doesn't load data yet
360
+ orders_proxy = customer.orders
361
+ puts orders_proxy.loaded? # => false
362
+
363
+ # This triggers the GraphQL query and loads the data
364
+ orders = customer.orders.to_a
365
+ puts customer.orders.loaded? # => true (for this specific proxy instance)
366
+
367
+ # Connection proxies implement Enumerable
368
+ customer.orders.each do |order|
369
+ puts order.name
370
+ end
371
+
372
+ # Array-like access methods
373
+ customer.orders.size # Number of records
374
+ customer.orders.first # First record
375
+ customer.orders.last # Last record
376
+ customer.orders[0] # Access by index
377
+ customer.orders.empty? # Check if empty
378
+ ```
379
+
380
+ ### Runtime Parameter Overrides
381
+
382
+ You can override connection parameters at runtime:
383
+
384
+ ```ruby
385
+ customer = Customer.find(123456789)
386
+
387
+ # Override default parameters for this call
388
+ recent_orders = customer.orders(
389
+ first: 25, # Fetch 25 records instead of default 10
390
+ sort_key: 'UPDATED_AT', # Sort by update date instead of creation date
391
+ reverse: true # Most recent first
392
+ ).to_a
393
+ ```
394
+
395
+ ### Eager Loading with `includes`
396
+
397
+ Use `includes` to load connections in the same GraphQL query as the parent record, eliminating the N+1 query problem:
398
+
399
+ ```ruby
400
+ # Load customer with orders and addresses in a single GraphQL query
401
+ customer = Customer.includes(:orders, :addresses).find(123456789)
402
+
403
+ # These connections are already loaded - no additional queries fired
404
+ orders = customer.orders # Uses cached data
405
+ addresses = customer.addresses # Uses cached data
406
+
407
+ puts customer.orders.loaded? # => This won't be a proxy since data was eager loaded
408
+ ```
409
+
410
+ ### Automatic Eager Loading
411
+
412
+ For connections that should always be loaded, you can use the `eager_load: true` parameter when defining the connection. This will automatically include the connection in all find and where queries without needing to explicitly use `includes`:
413
+
414
+ ```ruby
415
+ class Customer
416
+ include ActiveShopifyGraphQL::Base
417
+
418
+ graphql_type 'Customer'
419
+
420
+ attribute :id
421
+ attribute :display_name, path: "displayName"
422
+
423
+ # This connection will always be eager loaded
424
+ connection :orders, eager_load: true
425
+
426
+ # This connection will only be loaded lazily (default behavior)
427
+ connection :addresses
428
+ end
429
+
430
+ # The orders connection is automatically loaded
431
+ customer = Customer.find(123456789)
432
+ orders = customer.orders # Uses cached data - no additional query fired
433
+
434
+ # The addresses connection is lazy loaded
435
+ addresses = customer.addresses # This will fire a GraphQL query when first accessed
436
+ ```
437
+
438
+ This feature is perfect for connections that are frequently accessed and should be included in most queries to avoid N+1 problems.
439
+
440
+ The `includes` method modifies the GraphQL fragment to include connection fields:
441
+
442
+ ```graphql
443
+ query customer($id: ID!) {
444
+ customer(id: $id) {
445
+ # Regular customer fields
446
+ id
447
+ displayName
448
+
449
+ # Eager-loaded connections
450
+ orders(first: 10, sortKey: CREATED_AT, reverse: false) {
451
+ edges {
452
+ node {
453
+ id
454
+ name
455
+ totalPriceSet {
456
+ shopMoney {
457
+ amount
458
+ }
459
+ }
460
+ }
461
+ }
462
+ }
463
+ addresses(first: 5, sortKey: CREATED_AT, reverse: false) {
464
+ edges {
465
+ node {
466
+ id
467
+ address1
468
+ city
469
+ }
470
+ }
471
+ }
472
+ }
473
+ }
474
+ ```
475
+
476
+ ### Method Chaining
477
+
478
+ Connection methods support chaining with other query methods:
479
+
480
+ ```ruby
481
+ # Chain includes with select for optimized queries
482
+ Customer.includes(:orders).select(:id, :display_name).find(123456789)
483
+
484
+ # Chain includes with where for filtered queries
485
+ Customer.includes(:orders).where(email: "john@example.com").first
486
+ ```
487
+
488
+ ### Testing Support
489
+
490
+ For testing, you can manually set connection data to avoid making real API calls:
491
+
492
+ ```ruby
493
+ # In your tests
494
+ customer = Customer.new(id: 'gid://shopify/Customer/123')
495
+ mock_orders = [
496
+ Order.new(id: 'gid://shopify/Order/1', name: '#1001'),
497
+ Order.new(id: 'gid://shopify/Order/2', name: '#1002')
498
+ ]
499
+
500
+ # Set mock data
501
+ customer.orders = mock_orders
502
+
503
+ # Now customer.orders returns the mock data
504
+ expect(customer.orders.size).to eq(2)
505
+ expect(customer.orders.first.name).to eq('#1001')
506
+ ```
507
+
508
+ ### Connection Configuration
509
+
510
+ Connections automatically infer sensible defaults but can be customized:
511
+
512
+ - **class_name**: Target model class name (defaults to connection name singularized and classified)
513
+ - **query_name**: GraphQL query field name (defaults to connection name pluralized)
514
+ - **foreign_key**: Field used to filter connection records (defaults to `{model_name}_id`)
515
+ - **loader_class**: Custom loader class (defaults to model's default loader)
516
+ - **eager_load**: Whether to automatically eager load this connection on find/where queries (default: false)
517
+ - **default_arguments**: Hash of default arguments to pass to the GraphQL query (e.g., `{ first: 10, sort_key: 'CREATED_AT' }`)
518
+
519
+ ### Error Handling
520
+
521
+ Connection queries use the same error handling as regular model queries. If a connection query fails, an appropriate exception will be raised with details about the GraphQL error.
522
+
523
+ ## Next steps
524
+
525
+ - [x] Attribute-based model definition with automatic GraphQL fragment generation
526
+ - [x] Metafield attributes for easy access to Shopify metafields
527
+ - [x] Support `Model.where(param: value)` proxying params to the GraphQL query attribute
528
+ - [x] Query optimization with `select` method
529
+ - [x] GraphQL connections with lazy and eager loading via `Customer.includes(:orders).find(id)`
530
+ - [ ] Support for paginating query results
531
+ - [ ] Better error handling and retry mechanisms for GraphQL API calls
532
+ - [ ] Caching layer for frequently accessed data
533
+
534
+ ## Development
535
+
536
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
537
+
538
+ ## Contributing
539
+
540
+ Bug reports and pull requests are welcome on GitHub at https://github.com/nebulab/active_shopify_graphql.
541
+
542
+ ## License
543
+
544
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ # Handles associations between ActiveShopifyGraphQL objects and ActiveRecord objects
5
+ module Associations
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ class << self
10
+ attr_accessor :associations
11
+ end
12
+
13
+ self.associations = {}
14
+ end
15
+
16
+ class_methods do
17
+ def has_many(name, class_name: nil, foreign_key: nil, primary_key: nil)
18
+ association_class_name = class_name || name.to_s.classify
19
+ association_foreign_key = foreign_key || "shopify_#{model_name.element.downcase}_id"
20
+ association_primary_key = primary_key || :id
21
+
22
+ # Store association metadata
23
+ associations[name] = {
24
+ type: :has_many,
25
+ class_name: association_class_name,
26
+ foreign_key: association_foreign_key,
27
+ primary_key: association_primary_key
28
+ }
29
+
30
+ # Define the association method
31
+ define_method name do
32
+ return @_association_cache[name] if @_association_cache&.key?(name)
33
+
34
+ @_association_cache ||= {}
35
+
36
+ primary_key_value = send(association_primary_key)
37
+ return @_association_cache[name] = [] if primary_key_value.blank?
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
+ association_class = association_class_name.constantize
43
+ @_association_cache[name] = association_class.where(association_foreign_key => primary_key_value)
44
+ end
45
+
46
+ # Define the association setter method for testing/mocking
47
+ define_method "#{name}=" do |value|
48
+ @_association_cache ||= {}
49
+ @_association_cache[name] = value
50
+ end
51
+ end
52
+
53
+ def has_one(name, class_name: nil, foreign_key: nil, primary_key: nil)
54
+ association_class_name = class_name || name.to_s.classify
55
+ association_foreign_key = foreign_key || "shopify_#{model_name.element.downcase}_id"
56
+ association_primary_key = primary_key || :id
57
+
58
+ # Store association metadata
59
+ associations[name] = {
60
+ type: :has_one,
61
+ class_name: association_class_name,
62
+ foreign_key: association_foreign_key,
63
+ primary_key: association_primary_key
64
+ }
65
+
66
+ # Define the association method
67
+ define_method name do
68
+ return @_association_cache[name] if @_association_cache&.key?(name)
69
+
70
+ @_association_cache ||= {}
71
+
72
+ primary_key_value = send(association_primary_key)
73
+ return @_association_cache[name] = nil if primary_key_value.blank?
74
+
75
+ # Extract numeric ID from Shopify GID if needed
76
+ primary_key_value = primary_key_value.to_plain_id if primary_key_value.gid?
77
+
78
+ association_class = association_class_name.constantize
79
+ @_association_cache[name] = association_class.find_by(association_foreign_key => primary_key_value)
80
+ end
81
+
82
+ # Define the association setter method for testing/mocking
83
+ define_method "#{name}=" do |value|
84
+ @_association_cache ||= {}
85
+ @_association_cache[name] = value
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Attributes
5
+ extend ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ # Define an attribute with automatic GraphQL path inference and type coercion.
9
+ #
10
+ # @param name [Symbol] The Ruby attribute name
11
+ # @param path [String] The GraphQL field path (auto-inferred if not provided)
12
+ # @param type [Symbol] The type for coercion (:string, :integer, :float, :boolean, :datetime)
13
+ # @param null [Boolean] Whether the attribute can be null (default: true)
14
+ # @param default [Object] Default value when GraphQL response is nil
15
+ # @param transform [Proc] Custom transform block for the value
16
+ def attribute(name, path: nil, type: :string, null: true, default: nil, transform: nil)
17
+ path ||= infer_path(name)
18
+ config = { path: path, type: type, null: null, default: default, transform: transform }
19
+
20
+ if @current_loader_context
21
+ # Store in loader-specific context
22
+ @loader_contexts[@current_loader_context][name] = config
23
+ else
24
+ # Store in base attributes
25
+ @base_attributes ||= {}
26
+ @base_attributes[name] = config
27
+ end
28
+
29
+ # Always create attr_accessor
30
+ attr_accessor name unless method_defined?(name) || method_defined?("#{name}=")
31
+ end
32
+
33
+ # Get attributes for a specific loader class, merging base with loader-specific overrides.
34
+ def attributes_for_loader(loader_class)
35
+ base = @base_attributes || {}
36
+ overrides = @loader_contexts&.dig(loader_class) || {}
37
+
38
+ base.merge(overrides) { |_key, base_val, override_val| base_val.merge(override_val) }
39
+ end
40
+
41
+ private
42
+
43
+ # Infer GraphQL path from Ruby attribute name (snake_case -> camelCase)
44
+ def infer_path(name)
45
+ name.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }
46
+ end
47
+ end
48
+ end
49
+ end