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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f0b5f3a4ce7ed92feb8483e2c064379788138d7a7336daa849a81715fd0dd3f2
4
- data.tar.gz: b48985b14a5e07a5e5273431d82c06756dfc7feab40ddd27f65fa313c080d261
3
+ metadata.gz: 3044e5a4ac6255ea55f7d10e48b0fe9a65825f813712f0cdc14e5993437e24bf
4
+ data.tar.gz: f6b9e14fe814887bf0af4b90c174088f7c912ead322cb353fd84a95b2c4799e2
5
5
  SHA512:
6
- metadata.gz: 5f7cd6debcc40234ea46da5b8e88332a6c715db5d78ef82040ec1827a963557b60589f70df2261d09f6b08e699233b2aad7eaf00eb20eb6a2f80eb35608d6258
7
- data.tar.gz: e3d0c95ba71e3be17f0a57ba26cd6f25d7c6ea783dc4b0608609f6344bf783ef2aec0e5b532b83d1a90084c3e3389bc8018396911a02c2da06d9f2c523b33c66
6
+ metadata.gz: d5b38dbe06885fc9783e0f0980f1c690ad0a3edc0355f379bff6c0277eb0d69c76c07824d31295a020272b648ee8cd90c812e7399c75dc629650a9681df04b2f
7
+ data.tar.gz: 6bdfb30c5888e3532cd19325769d460470c044d5db9c47ebf4b52bec368e8baaccfc65660d2696e04f978bf3d5d178e15a914718925a82b08f655fea8a3d25bf
data/.rubocop.yml CHANGED
@@ -64,3 +64,6 @@ Naming/BlockForwarding:
64
64
 
65
65
  Lint/DuplicateBranch:
66
66
  Enabled: false
67
+
68
+ Style/ClassAndModuleChildren:
69
+ Enabled: false
data/README.md CHANGED
@@ -52,12 +52,10 @@ end
52
52
 
53
53
  ### Basic Model Setup
54
54
 
55
- Create a model that includes `ActiveShopifyGraphQL::Base` and define attributes directly:
55
+ Create a model that inherits from `ActiveShopifyGraphQL::Model` and define attributes directly:
56
56
 
57
57
  ```ruby
58
- class Customer
59
- include ActiveShopifyGraphQL::Base
60
-
58
+ class Customer < ActiveShopifyGraphQL::Model
61
59
  # Define the GraphQL type
62
60
  graphql_type "Customer"
63
61
 
@@ -75,6 +73,37 @@ class Customer
75
73
  end
76
74
  ```
77
75
 
76
+ ### Application Base Class (Recommended)
77
+
78
+ For consistency and to share common behavior across all your Shopify GraphQL models, we recommend creating an `ApplicationShopifyGqlRecord` base class, similar to Rails' `ApplicationRecord`:
79
+
80
+ ```ruby
81
+ # app/models/application_shopify_gql_record.rb
82
+ class ApplicationShopifyRecord < ActiveShopifyGraphQL::Model
83
+ # Extract numeric ID from Shopify GID
84
+ attribute :id, transform: ->(id) { id.split("/").last }
85
+ # Keep the original GID available
86
+ attribute :gid, path: "id"
87
+ end
88
+ ```
89
+
90
+ Then inherit from this base class in your models:
91
+
92
+ ```ruby
93
+ class Customer < ApplicationShopifyRecord
94
+ graphql_type "Customer"
95
+
96
+ attribute :name, path: "displayName"
97
+ attribute :email, path: "defaultEmailAddress.emailAddress"
98
+ attribute :created_at, type: :datetime
99
+ end
100
+ ```
101
+
102
+ This pattern provides:
103
+ - **Consistent ID handling**: All models automatically get a numeric `id` and full `gid`
104
+ - **Shared behavior**: Add validations, methods, or transformations once for all models
105
+ - **Clear inheritance**: The class definition line clearly shows the persistence layer
106
+
78
107
  ### Defining Attributes
79
108
 
80
109
  Attributes are now defined directly in the model class using the `attribute` method. The GraphQL fragments and response mapping are automatically generated!
@@ -82,9 +111,7 @@ Attributes are now defined directly in the model class using the `attribute` met
82
111
  #### Basic Attribute Definition
83
112
 
84
113
  ```ruby
85
- class Customer
86
- include ActiveShopifyGraphQL::Base
87
-
114
+ class Customer < ActiveShopifyGraphQL::Model
88
115
  graphql_type "Customer"
89
116
 
90
117
  # Define attributes with automatic GraphQL path inference and type coercion
@@ -124,9 +151,7 @@ attribute :name,
124
151
  Shopify metafields can be easily accessed using the `metafield_attribute` method:
125
152
 
126
153
  ```ruby
127
- class Product
128
- include ActiveShopifyGraphQL::Base
129
-
154
+ class Product < ActiveShopifyGraphQL::Model
130
155
  graphql_type "Product"
131
156
 
132
157
  # Regular attributes
@@ -148,9 +173,7 @@ The metafield attributes automatically generate the correct GraphQL syntax and h
148
173
  For advanced GraphQL features not yet fully supported by the gem (like union types with `... on` syntax), you can inject raw GraphQL directly into the query using the `raw_graphql` option:
149
174
 
150
175
  ```ruby
151
- class Product
152
- include ActiveShopifyGraphQL::Base
153
-
176
+ class Product < ActiveShopifyGraphQL::Model
154
177
  graphql_type "Product"
155
178
 
156
179
  attribute :id, type: :string
@@ -166,7 +189,7 @@ class Product
166
189
  attribute :product_bundle,
167
190
  path: "bundle", # The alias will be used as the response key
168
191
  type: :json,
169
- raw_graphql: 'metafield(namespace: "bundles", key: "items") { references(first: 10) { edges { node { ... on Product { id title } } } } }'
192
+ raw_graphql: 'metafield(namespace: "bundles", key: "items") { references(first: 10) { nodes { ... on Product { id title } } } }'
170
193
  end
171
194
  ```
172
195
 
@@ -175,9 +198,7 @@ end
175
198
  For models that need different attributes depending on the API being used, you can define loader-specific overrides:
176
199
 
177
200
  ```ruby
178
- class Customer
179
- include ActiveShopifyGraphQL::Base
180
-
201
+ class Customer < ActiveShopifyGraphQL::Model
181
202
  graphql_type "Customer"
182
203
 
183
204
  # Default attributes (used by all loaders)
@@ -234,7 +255,7 @@ customer = Customer.with_loader(MyCustomLoader).find(id)
234
255
  Use the `where` method to query multiple records using Shopify's search syntax:
235
256
 
236
257
  ```ruby
237
- # Simple conditions
258
+ # Hash-based queries (safe, with automatic escaping)
238
259
  customers = Customer.where(email: "john@example.com")
239
260
 
240
261
  # Range queries
@@ -248,8 +269,106 @@ customers = Customer.where(first_name: "John Doe")
248
269
  customers = Customer.where({ email: "john@example.com" }, limit: 100)
249
270
  ```
250
271
 
272
+ #### String-based Queries for Advanced Syntax
273
+
274
+ For advanced queries like wildcard matching, use string-based queries:
275
+
276
+ ```ruby
277
+ # Raw string queries (user responsible for proper escaping)
278
+ variants = ProductVariant.where("sku:*") # Wildcard matching
279
+ customers = Customer.where("email:*@example.com AND orders_count:>5")
280
+
281
+ # String queries with parameter binding (safe, with automatic escaping)
282
+ # Positional parameters
283
+ variants = ProductVariant.where("sku:? AND product_id:?", "Test's Product", 123)
284
+
285
+ # Named parameters (as hash)
286
+ customers = Customer.where("email::email AND first_name::name",
287
+ { email: "test@example.com", name: "John" })
288
+
289
+ # Named parameters (as keyword arguments - more convenient!)
290
+ variants = ProductVariant.where("sku::sku", sku: "Test's Product")
291
+ ```
292
+
293
+ **Query safety levels:**
294
+ - **Hash queries**: Fully safe, all values are automatically escaped (wildcards become literals)
295
+ - **String with binding**: Safe, bound parameters are automatically escaped
296
+ - **Raw strings**: User responsible for escaping; allows advanced syntax like wildcards
297
+
251
298
  The `where` method automatically converts Ruby conditions into Shopify's GraphQL query syntax and validates that the query fields are supported by Shopify.
252
299
 
300
+ ### Pagination
301
+
302
+ ActiveShopifyGraphQL supports cursor-based pagination for efficiently working with large result sets. Queries return a chainable `Query::Scope` that provides both automatic and manual pagination.
303
+
304
+ #### Basic Pagination with Limits
305
+
306
+ Use the `limit` method to control the total number of records fetched:
307
+
308
+ ```ruby
309
+ # Fetch up to 100 records (automatically handles pagination behind the scenes)
310
+ variants = ProductVariant.where(sku: "*").limit(100).to_a
311
+
312
+ # Chainable with other query methods
313
+ customers = Customer.where(email: "*@example.com").limit(500).to_a
314
+ ```
315
+
316
+ #### Manual Pagination
317
+
318
+ Use `in_pages` without a block to manually navigate through pages:
319
+
320
+ ```ruby
321
+ # Get first page (50 records per page)
322
+ page = ProductVariant.where(sku: "FRZ*").in_pages(of: 50)
323
+
324
+ page.size # => 50
325
+ page.has_next_page? # => true
326
+ page.end_cursor # => "eyJsYXN0X2lk..."
327
+
328
+ # Navigate to next page
329
+ next_page = page.next_page
330
+ next_page.size # => 50
331
+
332
+ # Navigate backwards
333
+ prev_page = next_page.previous_page
334
+ ```
335
+
336
+ #### Automatic Pagination with Blocks
337
+
338
+ Process records in batches to control memory usage:
339
+
340
+ ```ruby
341
+ # Process 10 records at a time
342
+ ProductVariant.where(sku: "*").in_pages(of: 10) do |page|
343
+ page.each do |variant|
344
+ MemoryExpensiveThing.run(variant)
345
+ end
346
+ end
347
+
348
+ # Combine with limit to stop after a certain number of records
349
+ ProductVariant.where(sku: "*").limit(500).in_pages(of: 50) do |page|
350
+ # Processes 10 pages of 50 records each, then stops
351
+ process_batch(page.to_a)
352
+ end
353
+ ```
354
+
355
+ #### Lazy Enumeration
356
+
357
+ The `Query::Scope` returned by `where` is enumerable and lazy-loads records:
358
+
359
+ ```ruby
360
+ # These don't execute queries immediately
361
+ scope = Customer.where(email: "*@example.com")
362
+
363
+ # Query executes when you iterate or convert to array
364
+ scope.each { |customer| puts customer.email }
365
+ scope.to_a # Returns array of all records
366
+ scope.first # Fetches just the first record
367
+ scope.empty? # Checks if any records exist
368
+ ```
369
+
370
+ **Note:** Shopify imposes a maximum of 250 records per page. The `in_pages(of: n)` method will cap `n` at 250.
371
+
253
372
  ### Optimizing Queries with Select
254
373
 
255
374
  Use the `select` method to only fetch specific attributes, reducing GraphQL query size and improving performance:
@@ -277,9 +396,7 @@ ActiveShopifyGraphQL provides ActiveRecord-like associations to define relations
277
396
  Use `has_many` to define one-to-many relationships:
278
397
 
279
398
  ```ruby
280
- class Customer
281
- include ActiveShopifyGraphQL::Base
282
-
399
+ class Customer < ActiveShopifyGraphQL::Model
283
400
  graphql_type "Customer"
284
401
 
285
402
  attribute :id, type: :string
@@ -314,9 +431,7 @@ customer.rewards
314
431
  Use `has_one` to define one-to-one relationships:
315
432
 
316
433
  ```ruby
317
- class Order
318
- include ActiveShopifyGraphQL::Base
319
-
434
+ class Order < ActiveShopifyGraphQL::Model
320
435
  has_one :billing_address, class_name: 'Address'
321
436
  end
322
437
  ```
@@ -361,9 +476,7 @@ ActiveShopifyGraphQL supports GraphQL connections for loading related data from
361
476
  Use the `connection` class method to define connections to other ActiveShopifyGraphQL models:
362
477
 
363
478
  ```ruby
364
- class Customer
365
- include ActiveShopifyGraphQL::Base
366
-
479
+ class Customer < ActiveShopifyGraphQL::Model
367
480
  graphql_type 'Customer'
368
481
 
369
482
  attribute :id
@@ -396,9 +509,7 @@ class Customer
396
509
  }
397
510
  end
398
511
 
399
- class Order
400
- include ActiveShopifyGraphQL::Base
401
-
512
+ class Order < ActiveShopifyGraphQL::Model
402
513
  graphql_type 'Order'
403
514
 
404
515
  attribute :id
@@ -414,10 +525,10 @@ fragment CustomerFragment on Customer {
414
525
  id
415
526
  displayName
416
527
  orders(first: 2) {
417
- edges { node { id name } }
528
+ nodes { id name }
418
529
  }
419
530
  recent_orders: orders(first: 5, reverse: true, sortKey: CREATED_AT) {
420
- edges { node { id name } }
531
+ nodes { id name }
421
532
  }
422
533
  }
423
534
  ```
@@ -487,9 +598,7 @@ puts customer.orders.loaded? # => This won't be a proxy since data was eager
487
598
  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`:
488
599
 
489
600
  ```ruby
490
- class Customer
491
- include ActiveShopifyGraphQL::Base
492
-
601
+ class Customer < ActiveShopifyGraphQL::Model
493
602
  graphql_type 'Customer'
494
603
 
495
604
  attribute :id
@@ -523,25 +632,21 @@ query customer($id: ID!) {
523
632
 
524
633
  # Eager-loaded connections
525
634
  orders(first: 10, sortKey: CREATED_AT, reverse: false) {
526
- edges {
527
- node {
528
- id
529
- name
530
- totalPriceSet {
531
- shopMoney {
532
- amount
533
- }
635
+ nodes {
636
+ id
637
+ name
638
+ totalPriceSet {
639
+ shopMoney {
640
+ amount
534
641
  }
535
642
  }
536
643
  }
537
644
  }
538
645
  addresses(first: 5, sortKey: CREATED_AT, reverse: false) {
539
- edges {
540
- node {
541
- id
542
- address1
543
- city
544
- }
646
+ nodes {
647
+ id
648
+ address1
649
+ city
545
650
  }
546
651
  }
547
652
  }
@@ -587,9 +692,7 @@ When you have bidirectional relationships between models, you can use the `inver
587
692
  #### Basic Usage
588
693
 
589
694
  ```ruby
590
- class Product
591
- include ActiveShopifyGraphQL::Base
592
-
695
+ class Product < ActiveShopifyGraphQL::Model
593
696
  graphql_type 'Product'
594
697
 
595
698
  attribute :id
@@ -602,9 +705,7 @@ class Product
602
705
  default_arguments: { first: 10 }
603
706
  end
604
707
 
605
- class ProductVariant
606
- include ActiveShopifyGraphQL::Base
607
-
708
+ class ProductVariant < ActiveShopifyGraphQL::Model
608
709
  graphql_type 'ProductVariant'
609
710
 
610
711
  attribute :id
@@ -666,9 +767,10 @@ Connection queries use the same error handling as regular model queries. If a co
666
767
  - [x] Support `Model.where(param: value)` proxying params to the GraphQL query attribute
667
768
  - [x] Query optimization with `select` method
668
769
  - [x] GraphQL connections with lazy and eager loading via `Customer.includes(:orders).find(id)`
669
- - [ ] Support for paginating query results with cursors
770
+ - [x] Support for paginating query results with cursors
670
771
  - [ ] Better error handling and retry mechanisms for GraphQL API calls
671
772
  - [ ] Caching layer for frequently accessed data
773
+ - [ ] Multiple `.where` chaining with possibility of using `.not`
672
774
 
673
775
  ## Development
674
776
 
@@ -3,27 +3,14 @@
3
3
  module ActiveShopifyGraphQL
4
4
  # Configuration class for setting up external dependencies
5
5
  class Configuration
6
- attr_accessor :admin_api_client, :customer_account_client_class, :logger, :log_queries, :compact_queries
6
+ attr_accessor :admin_api_client, :customer_account_client_class, :logger, :log_queries, :max_objects_per_paginated_query
7
7
 
8
8
  def initialize
9
9
  @admin_api_client = nil
10
10
  @customer_account_client_class = nil
11
11
  @logger = nil
12
12
  @log_queries = false
13
- @compact_queries = false
13
+ @max_objects_per_paginated_query = 250
14
14
  end
15
15
  end
16
-
17
- def self.configuration
18
- @configuration ||= Configuration.new
19
- end
20
-
21
- def self.configure
22
- yield(configuration)
23
- end
24
-
25
- # Reset configuration (useful for testing)
26
- def self.reset_configuration!
27
- @configuration = Configuration.new
28
- end
29
16
  end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Connections
5
+ # Handles loading records for GraphQL connections.
6
+ # Refactored to use LoaderContext for cleaner parameter passing.
7
+ class ConnectionLoader
8
+ attr_reader :context
9
+
10
+ def initialize(context, loader_instance:)
11
+ @context = context
12
+ @loader_instance = loader_instance
13
+ end
14
+
15
+ # Load records for a connection query
16
+ # @param query_name [String] The connection field name (e.g., 'orders', 'addresses')
17
+ # @param variables [Hash] The GraphQL variables (first, sort_key, reverse, query)
18
+ # @param parent [Object] The parent object that owns this connection
19
+ # @param connection_config [Hash] The connection configuration
20
+ # @return [Array<Object>] Array of model instances
21
+ def load_records(query_name, variables, parent = nil, connection_config = nil)
22
+ is_nested = connection_config&.dig(:nested) || parent.respond_to?(:id)
23
+
24
+ if is_nested && parent
25
+ load_nested_connection(query_name, variables, parent, connection_config)
26
+ else
27
+ load_root_connection(query_name, variables, connection_config)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def load_nested_connection(query_name, variables, parent, connection_config)
34
+ parent_type = parent.class.graphql_type_for_loader(@context.loader_class)
35
+ parent_query_name = parent_type.camelize(:lower)
36
+ singular = connection_config&.dig(:type) == :singular
37
+
38
+ query = Query::QueryBuilder.build_connection_query(
39
+ @context,
40
+ query_name: query_name,
41
+ variables: variables,
42
+ parent_query: "#{parent_query_name}(id: $id)",
43
+ singular: singular
44
+ )
45
+
46
+ parent_id = extract_gid(parent)
47
+ response_data = @loader_instance.perform_graphql_query(query, id: parent_id)
48
+
49
+ return [] if response_data.nil?
50
+
51
+ mapper = Response::ResponseMapper.new(@context)
52
+ attributes = mapper.map_nested_connection_response(response_data, query_name, parent, connection_config)
53
+
54
+ # ResponseMapper now returns attributes, need to build instances
55
+ if singular
56
+ return nil if attributes.nil?
57
+
58
+ wire_inverse_of(parent, attributes, connection_config)
59
+ ModelBuilder.build(@context.model_class, attributes)
60
+ else
61
+ return [] if attributes.nil? || attributes.empty?
62
+
63
+ attributes.each { |attrs| wire_inverse_of(parent, attrs, connection_config) }
64
+ ModelBuilder.build_many(@context.model_class, attributes)
65
+ end
66
+ end
67
+
68
+ def load_root_connection(query_name, variables, connection_config)
69
+ singular = connection_config&.dig(:type) == :singular
70
+
71
+ query = Query::QueryBuilder.build_connection_query(
72
+ @context,
73
+ query_name: query_name,
74
+ variables: variables,
75
+ parent_query: nil,
76
+ singular: singular
77
+ )
78
+
79
+ response_data = @loader_instance.perform_graphql_query(query)
80
+
81
+ return [] if response_data.nil?
82
+
83
+ mapper = Response::ResponseMapper.new(@context)
84
+ attributes = mapper.map_connection_response(response_data, query_name, connection_config)
85
+
86
+ # ResponseMapper now returns attributes, need to build instances
87
+ if singular
88
+ return nil if attributes.nil?
89
+
90
+ ModelBuilder.build(@context.model_class, attributes)
91
+ else
92
+ return [] if attributes.nil? || attributes.empty?
93
+
94
+ ModelBuilder.build_many(@context.model_class, attributes)
95
+ end
96
+ end
97
+
98
+ # Wire up inverse_of associations by adding parent to attributes cache
99
+ def wire_inverse_of(parent, attributes, connection_config)
100
+ return unless attributes.is_a?(Hash) && connection_config&.dig(:inverse_of)
101
+
102
+ inverse_name = connection_config[:inverse_of]
103
+ attributes[:_connection_cache] ||= {}
104
+
105
+ # Check the type of the inverse connection
106
+ target_class = @context.model_class
107
+ return unless target_class.respond_to?(:connections) && target_class.connections[inverse_name]
108
+
109
+ inverse_type = target_class.connections[inverse_name][:type]
110
+ attributes[:_connection_cache][inverse_name] =
111
+ if inverse_type == :singular
112
+ parent
113
+ else
114
+ # For collection inverses, wrap parent in an array
115
+ [parent]
116
+ end
117
+ end
118
+
119
+ def extract_gid(parent)
120
+ return parent.gid if parent.respond_to?(:gid) && !parent.gid.nil?
121
+
122
+ id_value = parent.id
123
+ parent_type = resolve_parent_type(parent)
124
+
125
+ GidHelper.normalize_gid(id_value, parent_type)
126
+ end
127
+
128
+ def resolve_parent_type(parent)
129
+ klass = parent.class
130
+
131
+ if klass.respond_to?(:graphql_type_for_loader)
132
+ klass.graphql_type_for_loader(@context.loader_class)
133
+ elsif klass.respond_to?(:graphql_type)
134
+ klass.graphql_type
135
+ else
136
+ klass.name
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'global_id'
4
+
3
5
  module ActiveShopifyGraphQL
4
6
  # Helper module for handling Shopify Global IDs (GIDs)
5
7
  # Provides utilities for parsing and building GIDs according to the URI::GID standard