active_shopify_graphql 0.2.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: 2f8f9c57e3dd7f5371ace9fb8d3db79d796a0aa34890065d4727ae7a45a0723b
4
- data.tar.gz: 16d8e5361a00e4aaa2b166ba8108496a8564e669fc16c76e68e4ed058df12a23
3
+ metadata.gz: f0b5f3a4ce7ed92feb8483e2c064379788138d7a7336daa849a81715fd0dd3f2
4
+ data.tar.gz: b48985b14a5e07a5e5273431d82c06756dfc7feab40ddd27f65fa313c080d261
5
5
  SHA512:
6
- metadata.gz: c30453eafcf98bc563f309e2a5762373615ad9a23c0b8d0c6e3c0919e5e8490088974d0d187582e6a55b0d012d4cc2e9c3abcc77cb2c638bd4acd8098be1b032
7
- data.tar.gz: 1b190403f8fcdee4d69d35b534c168b63200a87aca06a6cbf465ea09dc873b3d723597e3f99201acbd8e0d4ed9618897af028dbcf73653be891391cc6d383d23
6
+ metadata.gz: 5f7cd6debcc40234ea46da5b8e88332a6c715db5d78ef82040ec1827a963557b60589f70df2261d09f6b08e699233b2aad7eaf00eb20eb6a2f80eb35608d6258
7
+ data.tar.gz: e3d0c95ba71e3be17f0a57ba26cd6f25d7c6ea783dc4b0608609f6344bf783ef2aec0e5b532b83d1a90084c3e3389bc8018396911a02c2da06d9f2c523b33c66
data/.rubocop.yml CHANGED
@@ -1,3 +1,7 @@
1
+ AllCops:
2
+ NewCops: enable
3
+ SuggestExtensions: false
4
+
1
5
  Style/StringLiterals:
2
6
  Enabled: false
3
7
 
@@ -48,3 +52,15 @@ Naming/MethodParameterName:
48
52
 
49
53
  Naming/AccessorMethodName:
50
54
  Enabled: false
55
+
56
+ Style/ArgumentsForwarding:
57
+ Enabled: false
58
+
59
+ Style/KeywordArgumentsMerging:
60
+ Enabled: false
61
+
62
+ Naming/BlockForwarding:
63
+ Enabled: false
64
+
65
+ Lint/DuplicateBranch:
66
+ Enabled: false
data/AGENTS.md CHANGED
@@ -31,6 +31,7 @@
31
31
 
32
32
  ## Testing Guidelines
33
33
  - Framework: RSpec with documentation formatter (`.rspec`).
34
+ - Before stubbing classes look in `model_factories.rb` for existing ones.
34
35
  - Place specs under `spec/` and name files `*_spec.rb` matching the class/module under test.
35
36
  - Do not use `let` or `before` blocks in specs; each test case should tell a complete story.
36
37
  - Use verifying doubles instead of normal doubles. Prefer `{instance|class}_{double|spy}` to `double` or `spy`
data/README.md CHANGED
@@ -42,7 +42,7 @@ Rails.configuration.to_prepare do
42
42
  config.admin_api_client = ShopifyGraphQL::Client
43
43
 
44
44
  # Configure the Customer Account API client class (must have .from_config(token) class method)
45
- # and reponsd to #execute(query, **variables)
45
+ # and respond to #execute(query, **variables)
46
46
  config.customer_account_client_class = Shopify::Account::Client
47
47
  end
48
48
  end
@@ -143,6 +143,33 @@ end
143
143
 
144
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
145
 
146
+ #### Raw GraphQL Attributes
147
+
148
+ 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
+
150
+ ```ruby
151
+ class Product
152
+ include ActiveShopifyGraphQL::Base
153
+
154
+ graphql_type "Product"
155
+
156
+ attribute :id, type: :string
157
+ attribute :title, type: :string
158
+
159
+ # Raw GraphQL for accessing metaobject references with union types
160
+ attribute :provider_id,
161
+ path: "roaster.reference.id", # Path to extract from the response
162
+ type: :string,
163
+ raw_graphql: 'metafield(namespace: "custom", key: "provider") { reference { ... on Metaobject { id } } }'
164
+
165
+ # Another example with complex nested queries not warranting full blown models
166
+ attribute :product_bundle,
167
+ path: "bundle", # The alias will be used as the response key
168
+ type: :json,
169
+ raw_graphql: 'metafield(namespace: "bundles", key: "items") { references(first: 10) { edges { node { ... on Product { id title } } } } }'
170
+ end
171
+ ```
172
+
146
173
  #### API-Specific Attributes
147
174
 
148
175
  For models that need different attributes depending on the API being used, you can define loader-specific overrides:
@@ -198,7 +225,7 @@ customer = Customer.with_customer_account_api(token).find
198
225
  # Use Admin API explicitly
199
226
  customer = Customer.with_admin_api.find(id)
200
227
 
201
- # Use you own custom Loader
228
+ # Use your own custom Loader
202
229
  customer = Customer.with_loader(MyCustomLoader).find(id)
203
230
  ```
204
231
 
@@ -264,7 +291,7 @@ class Customer
264
291
  # Define an association to one of your own ActiveRecord models
265
292
  # foreign_key maps the id of the GraphQL powered model to the rewards.shopify_customer_id table
266
293
  has_many :rewards, foreign_key: :shopify_customer_id
267
- # primary_key specifies which attribute use as the value for matching the ActiveRecord ID
294
+ # primary_key specifies which attribute to use as the value for matching the ActiveRecord ID
268
295
  has_many :referrals, primary_key: :plain_id, foreign_key: :shopify_id
269
296
 
270
297
  validates :id, presence: true
@@ -296,9 +323,38 @@ end
296
323
 
297
324
  The associations automatically handle Shopify GID format conversion, extracting numeric IDs when needed for querying related records.
298
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
+
299
355
  ## GraphQL Connections
300
356
 
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.
357
+ ActiveShopifyGraphQL supports GraphQL connections for loading related data from Shopify APIs. Connections provide both lazy and eager loading patterns.
302
358
 
303
359
  ### Defining Connections
304
360
 
@@ -329,12 +385,14 @@ class Customer
329
385
  }
330
386
 
331
387
  # Example of a "scoped" connection
388
+ # Multiple connections can use the same query_name with different arguments
332
389
  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
390
+ query_name: "orders", # Uses the same GraphQL field as :orders
391
+ class_name: "Order", # The class would be inferred to RecentOrder without this
392
+ default_arguments: { # Different arguments for filtering
393
+ first: 5,
394
+ reverse: true,
395
+ sort_key: 'CREATED_AT'
338
396
  }
339
397
  end
340
398
 
@@ -349,6 +407,23 @@ class Order
349
407
  end
350
408
  ```
351
409
 
410
+ **Connection Aliasing:** When multiple connections use the same `query_name` (like `orders` and `recent_orders` both using the "orders" field), the gem automatically generates GraphQL aliases to prevent conflicts:
411
+
412
+ ```graphql
413
+ fragment CustomerFragment on Customer {
414
+ id
415
+ displayName
416
+ orders(first: 2) {
417
+ edges { node { id name } }
418
+ }
419
+ recent_orders: orders(first: 5, reverse: true, sortKey: CREATED_AT) {
420
+ edges { node { id name } }
421
+ }
422
+ }
423
+ ```
424
+
425
+ This allows you to have multiple "views" of the same connection with different filtering or sorting parameters, all in a single query.
426
+
352
427
  ### Lazy Loading (Default Behavior)
353
428
 
354
429
  Connections are loaded lazily when accessed. A separate GraphQL query is fired when the connection is first accessed:
@@ -505,6 +580,69 @@ expect(customer.orders.size).to eq(2)
505
580
  expect(customer.orders.first.name).to eq('#1001')
506
581
  ```
507
582
 
583
+ ### Inverse Relationships with `inverse_of`
584
+
585
+ When you have bidirectional relationships between models, you can use the `inverse_of` parameter to avoid redundant GraphQL queries. This is similar to ActiveRecord's `inverse_of` option and automatically caches the parent object when loading children.
586
+
587
+ #### Basic Usage
588
+
589
+ ```ruby
590
+ class Product
591
+ include ActiveShopifyGraphQL::Base
592
+
593
+ graphql_type 'Product'
594
+
595
+ attribute :id
596
+ attribute :title
597
+
598
+ # Define inverse relationship to avoid redundant queries
599
+ has_many_connected :variants,
600
+ class_name: "ProductVariant",
601
+ inverse_of: :product, # Points to the inverse connection name
602
+ default_arguments: { first: 10 }
603
+ end
604
+
605
+ class ProductVariant
606
+ include ActiveShopifyGraphQL::Base
607
+
608
+ graphql_type 'ProductVariant'
609
+
610
+ attribute :id
611
+ attribute :title
612
+
613
+ # Define inverse relationship back to Product
614
+ has_one_connected :product,
615
+ inverse_of: :variants # Points back to the parent's connection
616
+ end
617
+ ```
618
+
619
+ #### With Eager Loading
620
+
621
+ ```ruby
622
+ # Load product with variants in a single GraphQL query
623
+ product = Product.includes(:variants).find(123)
624
+
625
+ # Access variants - already loaded, no additional query
626
+ product.variants.each do |variant|
627
+ # Access product from variant - uses cached parent, NO QUERY!
628
+ puts variant.product.title
629
+ end
630
+ ```
631
+
632
+ #### With Lazy Loading
633
+
634
+ ```ruby
635
+ # Load product without preloading variants
636
+ product = Product.find(123)
637
+
638
+ # First access triggers a query to load variants
639
+ variants = product.variants.to_a
640
+
641
+ # Access product from variant - uses cached parent, NO QUERY!
642
+ variant = variants.first
643
+ puts variant.product.title # Returns the same product instance
644
+ ```
645
+
508
646
  ### Connection Configuration
509
647
 
510
648
  Connections automatically infer sensible defaults but can be customized:
@@ -514,6 +652,7 @@ Connections automatically infer sensible defaults but can be customized:
514
652
  - **foreign_key**: Field used to filter connection records (defaults to `{model_name}_id`)
515
653
  - **loader_class**: Custom loader class (defaults to model's default loader)
516
654
  - **eager_load**: Whether to automatically eager load this connection on find/where queries (default: false)
655
+ - **inverse_of**: The name of the inverse connection on the target model (optional, enables automatic inverse caching)
517
656
  - **default_arguments**: Hash of default arguments to pass to the GraphQL query (e.g., `{ first: 10, sort_key: 'CREATED_AT' }`)
518
657
 
519
658
  ### Error Handling
@@ -527,7 +666,7 @@ Connection queries use the same error handling as regular model queries. If a co
527
666
  - [x] Support `Model.where(param: value)` proxying params to the GraphQL query attribute
528
667
  - [x] Query optimization with `select` method
529
668
  - [x] GraphQL connections with lazy and eager loading via `Customer.includes(:orders).find(id)`
530
- - [ ] Support for paginating query results
669
+ - [ ] Support for paginating query results with cursors
531
670
  - [ ] Better error handling and retry mechanisms for GraphQL API calls
532
671
  - [ ] Caching layer for frequently accessed data
533
672
 
@@ -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)
@@ -13,9 +13,10 @@ module ActiveShopifyGraphQL
13
13
  # @param null [Boolean] Whether the attribute can be null (default: true)
14
14
  # @param default [Object] Default value when GraphQL response is nil
15
15
  # @param transform [Proc] Custom transform block for the value
16
- def attribute(name, path: nil, type: :string, null: true, default: nil, transform: nil)
16
+ # @param raw_graphql [String] Raw GraphQL string to inject directly (escape hatch for unsupported features)
17
+ def attribute(name, path: nil, type: :string, null: true, default: nil, transform: nil, raw_graphql: nil)
17
18
  path ||= infer_path(name)
18
- config = { path: path, type: type, null: null, default: default, transform: transform }
19
+ config = { path: path, type: type, null: null, default: default, transform: transform, raw_graphql: raw_graphql }
19
20
 
20
21
  if @current_loader_context
21
22
  # Store in loader-specific context
@@ -20,10 +20,46 @@ module ActiveShopifyGraphQL
20
20
  def initialize(attributes = {})
21
21
  super()
22
22
 
23
- # Extract connection cache if present
24
- @_connection_cache = attributes.delete(:_connection_cache) if attributes.key?(:_connection_cache)
23
+ # Extract connection cache if present and populate inverse caches
24
+ if attributes.key?(:_connection_cache)
25
+ @_connection_cache = attributes.delete(:_connection_cache)
26
+ populate_inverse_caches_on_initialization
27
+ end
25
28
 
26
29
  assign_attributes(attributes)
27
30
  end
31
+
32
+ private
33
+
34
+ def populate_inverse_caches_on_initialization
35
+ return unless @_connection_cache
36
+
37
+ @_connection_cache.each do |connection_name, records|
38
+ connection_config = self.class.connections[connection_name]
39
+ next unless connection_config && connection_config[:inverse_of]
40
+
41
+ inverse_name = connection_config[:inverse_of]
42
+ records_array = Array(records).compact
43
+
44
+ records_array.each do |record|
45
+ next unless record.respond_to?(:instance_variable_set)
46
+
47
+ record.instance_variable_set(:@_connection_cache, {}) unless record.instance_variable_get(:@_connection_cache)
48
+ cache = record.instance_variable_get(:@_connection_cache)
49
+
50
+ # Determine the type of the inverse connection
51
+ next unless record.class.respond_to?(:connections) && record.class.connections[inverse_name]
52
+
53
+ inverse_type = record.class.connections[inverse_name][:type]
54
+ cache[inverse_name] =
55
+ if inverse_type == :singular
56
+ self
57
+ else
58
+ # For collection inverses, wrap parent in an array
59
+ [self]
60
+ end
61
+ end
62
+ end
63
+ end
28
64
  end
29
65
  end
@@ -33,7 +33,7 @@ module ActiveShopifyGraphQL
33
33
 
34
34
  def load_nested_connection(query_name, variables, parent, connection_config)
35
35
  parent_type = parent.class.graphql_type_for_loader(@context.loader_class)
36
- parent_query_name = parent_type.downcase
36
+ parent_query_name = parent_type.camelize(:lower)
37
37
  connection_type = connection_config&.dig(:type) || :connection
38
38
 
39
39
  query = QueryTree.build_connection_query(
@@ -100,6 +100,9 @@ module ActiveShopifyGraphQL
100
100
  @connection_config
101
101
  ) || []
102
102
 
103
+ # Populate inverse cache if inverse_of is specified
104
+ populate_inverse_cache(@records, @connection_config, @parent)
105
+
103
106
  @loaded = true
104
107
  end
105
108
 
@@ -107,6 +110,35 @@ module ActiveShopifyGraphQL
107
110
  default_args = @connection_config[:default_arguments] || {}
108
111
  default_args.merge(@options).compact
109
112
  end
113
+
114
+ def populate_inverse_cache(records, connection_config, parent)
115
+ return unless connection_config[:inverse_of]
116
+ return if records.nil? || (records.is_a?(Array) && records.empty?)
117
+
118
+ inverse_name = connection_config[:inverse_of]
119
+ target_class = connection_config[:class_name].constantize
120
+
121
+ # Ensure target class has the inverse connection defined
122
+ return unless target_class.respond_to?(:connections) && target_class.connections[inverse_name]
123
+
124
+ inverse_type = target_class.connections[inverse_name][:type]
125
+ records_array = records.is_a?(Array) ? records : [records]
126
+
127
+ records_array.each do |record|
128
+ next unless record
129
+
130
+ record.instance_variable_set(:@_connection_cache, {}) unless record.instance_variable_get(:@_connection_cache)
131
+ cache = record.instance_variable_get(:@_connection_cache)
132
+
133
+ cache[inverse_name] =
134
+ if inverse_type == :singular
135
+ parent
136
+ else
137
+ # For collection inverses, wrap parent in an array
138
+ [parent]
139
+ end
140
+ end
141
+ end
110
142
  end
111
143
  end
112
144
  end
@@ -17,14 +17,14 @@ module ActiveShopifyGraphQL
17
17
  class_methods do
18
18
  # Define a singular connection (returns a single object)
19
19
  # @see #connection
20
- def has_one_connected(name, **options)
21
- connection(name, type: :singular, **options)
20
+ def has_one_connected(name, inverse_of: nil, **options)
21
+ connection(name, type: :singular, inverse_of: inverse_of, **options)
22
22
  end
23
23
 
24
24
  # Define a plural connection (returns a collection via edges)
25
25
  # @see #connection
26
- def has_many_connected(name, **options)
27
- connection(name, type: :connection, **options)
26
+ def has_many_connected(name, inverse_of: nil, **options)
27
+ connection(name, type: :connection, inverse_of: inverse_of, **options)
28
28
  end
29
29
 
30
30
  # Define a GraphQL connection to another ActiveShopifyGraphQL model
@@ -36,7 +36,8 @@ module ActiveShopifyGraphQL
36
36
  # @param eager_load [Boolean] Whether to automatically eager load this connection (default: false)
37
37
  # @param type [Symbol] The type of connection (:connection, :singular). Default is :connection.
38
38
  # @param default_arguments [Hash] Default arguments to pass to the GraphQL query (e.g. first: 10)
39
- def connection(name, class_name: nil, query_name: nil, foreign_key: nil, loader_class: nil, eager_load: false, type: :connection, default_arguments: {})
39
+ # @param inverse_of [Symbol] The name of the inverse connection on the target model (optional)
40
+ def connection(name, class_name: nil, query_name: nil, foreign_key: nil, loader_class: nil, eager_load: false, type: :connection, default_arguments: {}, inverse_of: nil)
40
41
  # Infer defaults
41
42
  connection_class_name = class_name || name.to_s.classify
42
43
 
@@ -56,9 +57,13 @@ module ActiveShopifyGraphQL
56
57
  nested: true, # Always treated as nested (accessed via parent field)
57
58
  target_class_name: connection_class_name,
58
59
  original_name: name,
59
- default_arguments: default_arguments
60
+ default_arguments: default_arguments,
61
+ inverse_of: inverse_of
60
62
  }
61
63
 
64
+ # Validate inverse relationship if specified (validation is deferred to runtime)
65
+ validate_inverse_of!(name, connection_class_name, inverse_of) if inverse_of
66
+
62
67
  # Define the connection method that returns a proxy
63
68
  define_method name do |**options|
64
69
  # Check if this connection was eager loaded
@@ -74,6 +79,9 @@ module ActiveShopifyGraphQL
74
79
  # Load the record
75
80
  records = loader.load_connection_records(config[:query_name], options, self, config)
76
81
 
82
+ # Populate inverse cache if inverse_of is specified
83
+ populate_inverse_cache_for_connection(records, config, self)
84
+
77
85
  # Cache it
78
86
  @_connection_cache ||= {}
79
87
  @_connection_cache[name] = records
@@ -123,30 +131,19 @@ module ActiveShopifyGraphQL
123
131
  # Merge manual and automatic connections
124
132
  all_included_connections = (connection_names + auto_included_connections).uniq
125
133
 
126
- # Create a new class that inherits from self with eager loading enabled
127
- included_class = Class.new(self)
128
-
129
- # Store the connections to include
130
- included_class.instance_variable_set(:@included_connections, all_included_connections)
131
-
132
- # Override methods to use eager loading
133
- included_class.define_singleton_method(:default_loader) do
134
- @default_loader ||= superclass.default_loader.class.new(
135
- superclass,
136
- included_connections: @included_connections
137
- )
138
- end
139
-
140
- # Preserve the original class name and model name for GraphQL operations
141
- included_class.define_singleton_method(:name) { superclass.name }
142
- included_class.define_singleton_method(:model_name) { superclass.model_name }
143
- included_class.define_singleton_method(:connections) { superclass.connections }
144
-
145
- included_class
134
+ # Create a scope object that holds the included connections
135
+ IncludesScope.new(self, all_included_connections)
146
136
  end
147
137
 
148
138
  private
149
139
 
140
+ def validate_inverse_of!(_name, _target_class_name, _inverse_name)
141
+ # Validation is deferred until runtime when connections are actually used
142
+ # This allows class definitions to be in any order
143
+ # The validation logic will be checked when inverse cache is populated
144
+ nil
145
+ end
146
+
150
147
  def validate_includes_connections!(connection_names)
151
148
  connection_names.each do |name|
152
149
  if name.is_a?(Hash)
@@ -166,5 +163,36 @@ module ActiveShopifyGraphQL
166
163
  end
167
164
  end
168
165
  end
166
+
167
+ # Instance method to populate inverse cache for lazy-loaded connections
168
+
169
+ def populate_inverse_cache_for_connection(records, connection_config, parent)
170
+ return unless connection_config[:inverse_of]
171
+ return if records.nil? || (records.is_a?(Array) && records.empty?)
172
+
173
+ inverse_name = connection_config[:inverse_of]
174
+ target_class = connection_config[:class_name].constantize
175
+
176
+ # Ensure target class has the inverse connection defined
177
+ return unless target_class.respond_to?(:connections) && target_class.connections[inverse_name]
178
+
179
+ inverse_type = target_class.connections[inverse_name][:type]
180
+ records_array = records.is_a?(Array) ? records : [records]
181
+
182
+ records_array.each do |record|
183
+ next unless record
184
+
185
+ record.instance_variable_set(:@_connection_cache, {}) unless record.instance_variable_get(:@_connection_cache)
186
+ cache = record.instance_variable_get(:@_connection_cache)
187
+
188
+ cache[inverse_name] =
189
+ if inverse_type == :singular
190
+ parent
191
+ else
192
+ # For collection inverses, wrap parent in an array
193
+ [parent]
194
+ end
195
+ end
196
+ end
169
197
  end
170
198
  end
@@ -8,14 +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
- attributes = loader.load_attributes(gid)
15
15
 
16
- return nil if attributes.nil?
16
+ # If we have included connections, we need to handle inverse_of properly
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
24
+
25
+ raise ObjectNotFoundError, "Couldn't find #{name} with id=#{id}" if result.nil?
17
26
 
18
- new(attributes)
27
+ result
19
28
  end
20
29
 
21
30
  # Returns the default loader for this model's queries
@@ -72,6 +81,25 @@ module ActiveShopifyGraphQL
72
81
  selected_class
73
82
  end
74
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
+
75
103
  # Query for multiple records using attribute conditions
76
104
  # @param conditions [Hash] The conditions to query (e.g., { email: "example@test.com", first_name: "John" })
77
105
  # @param options [Hash] Options hash containing loader and limit (when first arg is a Hash)
@@ -130,7 +158,7 @@ module ActiveShopifyGraphQL
130
158
  return unless invalid_attrs.any?
131
159
 
132
160
  raise ArgumentError, "Invalid attributes for #{name}: #{invalid_attrs.join(', ')}. " \
133
- "Available attributes are: #{available_attrs.join(', ')}"
161
+ "Available attributes are: #{available_attrs.join(', ')}"
134
162
  end
135
163
 
136
164
  # Gets all available attributes for selection