active_shopify_graphql 0.5.1 β†’ 0.5.3

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: bfcae985a03d79f8f96eb49bebceb473b30f74beb3345eaee50e4b855bd025a2
4
- data.tar.gz: d10731edbeae0fddb323dbb7a4151a0da7e2ede0c717d64f65e0a721cc49e9e8
3
+ metadata.gz: e6fe91cae8b27f0c6154d8d43a42477e900dba59925b27421f5ea43278c8a786
4
+ data.tar.gz: 6480859442d7a8ff30dc17fa3c0a96c5ae383f0ce0465fbbb8a67ff26c46f6da
5
5
  SHA512:
6
- metadata.gz: 23418843999e4908113a33af0bed8c9777f2d422067e48a2f333763dfc38ea16b04c746f347204193222fed27b0496311fe845e7faebb62fc0e87a66db131af4
7
- data.tar.gz: 810bae03627d5705cd6a00927b3aa1d6b40c75dc1b7da6fe3dc8c763e1c07c9e399b671c4c4fcfa61bd44e150e6f6de48afafeab0b591a9cc441e9f9f7010570
6
+ metadata.gz: e8572b7217ec7bb43172611d1d4059114b70de34342bb7c702265a90f3295004fd7de85b5449077054633ea199dd3f6606638f042cff3798dfc76d76dde24f4e
7
+ data.tar.gz: 975eca8ec8d7ab7e3f166884baa6e885d76596d130f4525281b85075b3acef960f5562a45ed8a7ae73ac14e1a2e179cc129dbcff6a5b0d5588ba21ccb6838893
data/README.md CHANGED
@@ -1,160 +1,241 @@
1
+ <div align="center">
2
+
1
3
  # ActiveShopifyGraphQL
2
4
 
3
- Bringing domain object peace of mind to the world of Shopify GraphQL APIs.
5
+ **Bringing Read Only (for now) ActiveRecord-like domain modeling to Shopify GraphQL APIs**
4
6
 
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.
7
+ [![Gem Version](https://badge.fury.io/rb/active_shopify_graphql.svg)](https://badge.fury.io/rb/active_shopify_graphql)
8
+ [![Spec](https://github.com/nebulab/active_shopify_graphql/actions/workflows/test.yml/badge.svg)](https://github.com/nebulab/active_shopify_graphql/actions/workflows/test.yml)
9
+ [![Lint](https://github.com/nebulab/active_shopify_graphql/actions/workflows/lint.yml/badge.svg)](https://github.com/nebulab/active_shopify_graphql/actions/workflows/lint.yml)
6
10
 
7
- ## The problem it solves
11
+ </div>
8
12
 
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.
13
+ <div align="center">
14
+ Support for both Admin and Customer Account APIs with automatic query building, response mapping, and N+1-free connections.
15
+ </div>
10
16
 
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).
17
+ ---
12
18
 
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.
19
+ ## πŸš€ Quick Start
14
20
 
15
- This library brings a Convention over Configuration approach to organize your custom data loaders, along with many ActiveRecord inspired features.
21
+ ```bash
22
+ gem install active_shopify_graphql
23
+ ```
16
24
 
17
- ## Installation
25
+ ```ruby
26
+ # Configure in pure Ruby
27
+ ActiveShopifyGraphQL.configure do |config|
28
+ config.admin_api_client = ShopifyGraphQL::Client
29
+ config.customer_account_client_class = Shopify::Account::Client
30
+ end
31
+
32
+ # Or define a Rails initializer
33
+ Rails.configuration.to_prepare do
34
+ ActiveShopifyGraphQL.configure do |config|
35
+ config.admin_api_client = ShopifyGraphQL::Client
36
+ config.customer_account_client_class = Shopify::Account::Client
37
+ end
38
+ end
39
+
40
+ # Define your model
41
+ class Customer < ActiveShopifyGraphQL::Model
42
+ graphql_type "Customer" # Optional as it's auto inferred
43
+
44
+ attribute :id, type: :string
45
+ attribute :name, path: "displayName", type: :string
46
+ attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
47
+ attribute :created_at, type: :datetime
48
+
49
+ has_many_connected :orders, default_arguments: { first: 10 }
50
+ end
51
+
52
+ # Use it like ActiveRecord
53
+ customer = Customer.find(123456789)
54
+ customer.name # => "John Doe"
55
+ customer.orders.to_a # => [#<Order:0x...>, ...]
56
+
57
+ Customer.where(email: "@example.com")
58
+ Customer.includes(:orders).find(id)
59
+ ```
60
+
61
+ ---
62
+
63
+ ## ✨ Why?
64
+
65
+ ### The Problem
18
66
 
19
- Add this line to your application's Gemfile:
67
+ GraphQL is powerful, but dealing with raw responses is painful:
20
68
 
21
69
  ```ruby
22
- gem 'active_shopify_graphql'
70
+ # Before: The struggle
71
+ response = shopify_client.execute(query)
72
+ customer = response["data"]["customer"]
73
+ email = customer["defaultEmailAddress"]["emailAddress"]
74
+ created_at = Time.parse(customer["createdAt"])
75
+ orders = customer["orders"]["nodes"].map { |o| parse_order(o) }
76
+ # Different API? Different field names. Good luck!
23
77
  ```
24
78
 
25
- And then execute:
79
+ **Problems:**
80
+ - ❌ Different schemas for Admin API vs any other API
81
+ - ❌ Inconsistent data shapes across queries
82
+ - ❌ Manual type conversions everywhere
83
+ - ❌ N+1 query problems with connections
84
+ - ❌ No validation or business logic layer
26
85
 
27
- $ bundle install
86
+ ### The Solution
28
87
 
29
- Or install it yourself as:
88
+ ```ruby
89
+ # After: Peace of mind
90
+ customer = Customer.includes(:orders).find(123456789)
91
+ customer.email # => "john@example.com"
92
+ customer.created_at # => #<DateTime>
93
+ customer.orders.to_a # Lazily loaded as a single query
94
+ ```
95
+
96
+ **Benefits:**
97
+ - βœ… **Single source of truth** β€” Models, not hashes
98
+ - βœ… **Type-safe attributes** β€” Automatic coercion
99
+ - βœ… **Unified across APIs** β€” Same model, different loaders
100
+ - βœ… **Optional eager loading** β€” Save points by default, eager load when needed
101
+ - βœ… **ActiveRecord-like** β€” Familiar, idiomatic Ruby and Rails
102
+
103
+ ---
30
104
 
31
- $ gem install active_shopify_graphql
105
+ ## πŸ“š Table of Contents
106
+
107
+ - [Installation](#installation)
108
+ - [Configuration](#configuration)
109
+ - [Core Concepts](#core-concepts)
110
+ - [Features](#features)
111
+ - [API Reference](#api-reference)
112
+ - [Advanced Topics](#advanced-topics)
113
+ - [Development](#development)
114
+
115
+ ---
116
+
117
+ ## Installation
118
+
119
+ Add to your Gemfile:
120
+
121
+ ```ruby
122
+ gem "active_shopify_graphql"
123
+ ```
124
+
125
+ Or install globally:
126
+
127
+ ```bash
128
+ gem install active_shopify_graphql
129
+ ```
130
+
131
+ ---
32
132
 
33
133
  ## Configuration
34
134
 
35
- Before using ActiveShopifyGraphQL, you need to configure the API clients:
135
+ Configure your Shopify GraphQL clients:
36
136
 
37
137
  ```ruby
38
138
  # config/initializers/active_shopify_graphql.rb
39
139
  Rails.configuration.to_prepare do
40
140
  ActiveShopifyGraphQL.configure do |config|
41
- # Configure the Admin API client (must respond to #execute(query, **variables))
141
+ # Admin API (must respond to #execute(query, **variables))
42
142
  config.admin_api_client = ShopifyGraphQL::Client
43
143
 
44
- # Configure the Customer Account API client class (must have .from_config(token) class method)
45
- # and respond to #execute(query, **variables)
144
+ # Customer Account API (must have .from_config(token) and #execute)
46
145
  config.customer_account_client_class = Shopify::Account::Client
47
146
  end
48
147
  end
49
148
  ```
50
149
 
51
- ## Usage
150
+ ---
151
+
152
+ ## Core Concepts
153
+
154
+ ### Models
155
+
156
+ Models are the heart of ActiveShopifyGraphQL. They define:
52
157
 
53
- ### Basic Model Setup
158
+ - **GraphQL type** β†’ Which Shopify schema type they map to
159
+ - **Attributes** β†’ Fields to fetch and their types
160
+ - **Associations** β†’ Relationships to other models
161
+ - **Connections** β†’ GraphQL connections for related data
162
+ - **Business logic** β†’ Validations, methods, transformations
54
163
 
55
- Create a model that inherits from `ActiveShopifyGraphQL::Model` and define attributes directly:
164
+ ### Attributes
165
+
166
+ Attributes auto-generate GraphQL fragments and handle response mapping:
56
167
 
57
168
  ```ruby
58
169
  class Customer < ActiveShopifyGraphQL::Model
59
- # Define the GraphQL type
60
170
  graphql_type "Customer"
61
171
 
62
- # Define attributes with automatic GraphQL path inference and type coercion
63
- attribute :id, type: :string
64
- attribute :name, path: "displayName", type: :string
65
- attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
66
- attribute :created_at, type: :datetime
172
+ # Auto-inferred path: displayName
173
+ attribute :name, type: :string
67
174
 
68
- validates :id, presence: true
175
+ # Custom path with dot notation
176
+ attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
69
177
 
70
- def first_name
71
- name.split(" ").first
72
- end
178
+ # Custom transformation
179
+ attribute :plain_id, path: "id", transform: ->(gid) { gid.split("/").last }
73
180
  end
74
181
  ```
75
182
 
76
- ### Application Base Class (Recommended)
183
+ ### Connections
77
184
 
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`:
185
+ Connections to related Shopify data with lazy/eager loading:
79
186
 
80
187
  ```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:
188
+ class Customer < ActiveShopifyGraphQL::Model
189
+ # Lazy by default β€” loaded on first access
190
+ has_many_connected :orders
91
191
 
92
- ```ruby
93
- class Customer < ApplicationShopifyRecord
94
- graphql_type "Customer"
192
+ # Always eager load β€” no N+1 queries
193
+ has_many_connected :addresses, eager_load: true, default_arguments: { first: 5 }
95
194
 
96
- attribute :name, path: "displayName"
97
- attribute :email, path: "defaultEmailAddress.emailAddress"
98
- attribute :created_at, type: :datetime
195
+ # Scoped connection with custom arguments
196
+ has_many_connected :recent_orders,
197
+ query_name: "orders",
198
+ default_arguments: { first: 5, reverse: true, sort_key: "CREATED_AT" }
99
199
  end
100
200
  ```
101
201
 
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
202
+ ---
106
203
 
107
- ### Defining Attributes
204
+ ## Features
108
205
 
109
- Attributes are now defined directly in the model class using the `attribute` method. The GraphQL fragments and response mapping are automatically generated!
206
+ ### πŸ—οΈ Attribute Definition
110
207
 
111
- #### Basic Attribute Definition
208
+ Define attributes with automatic GraphQL generation:
112
209
 
113
210
  ```ruby
114
- class Customer < ActiveShopifyGraphQL::Model
115
- graphql_type "Customer"
211
+ class Product < ActiveShopifyGraphQL::Model
212
+ graphql_type "Product"
116
213
 
117
- # Define attributes with automatic GraphQL path inference and type coercion
118
- attribute :id, type: :string
119
- attribute :name, path: "displayName", type: :string
120
- attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
121
- attribute :created_at, type: :datetime
214
+ # Simple attribute (path auto-inferred as "title")
215
+ attribute :title, type: :string
122
216
 
123
- # Custom transform example
124
- attribute :tags, type: :string, transform: ->(tags_array) { tags_array.join(", ") }
125
- end
126
- ```
217
+ # Custom path
218
+ attribute :price, path: "priceRange.minVariantPrice.amount", type: :float
127
219
 
128
- #### Attribute Definition Options
220
+ # With default
221
+ attribute :description, type: :string, default: "No description"
129
222
 
130
- The `attribute` method supports several options for flexibility:
223
+ # Custom transformation
224
+ attribute :slug, path: "handle", transform: ->(handle) { handle.parameterize }
131
225
 
132
- ```ruby
133
- attribute :name,
134
- path: "displayName", # Custom GraphQL path (auto-inferred if omitted)
135
- type: :string, # Type coercion (:string, :integer, :float, :boolean, :datetime)
136
- null: false, # Whether the attribute can be null (default: true)
137
- default: "a default value", # The value to assign in case it's nil (default: nil)
138
- transform: ->(value) { value.upcase } # Custom transformation block
226
+ # Nullable validation
227
+ attribute :vendor, type: :string, null: false
228
+ end
139
229
  ```
140
230
 
141
- **Auto-inference:** When `path` is omitted, it's automatically inferred by converting snake_case to camelCase (e.g., `display_name` β†’ `displayName`).
142
-
143
- **Nested paths:** Use dot notation for nested GraphQL fields (e.g., `"defaultEmailAddress.emailAddress"`).
144
-
145
- **Type coercion:** Automatic conversion using ActiveModel types ensures type safety.
231
+ #### Metafields
146
232
 
147
- **Array handling:** Arrays are automatically preserved regardless of the specified type.
148
-
149
- #### Metafield Attributes
150
-
151
- Shopify metafields can be easily accessed using the `metafield_attribute` method:
233
+ Easy access to Shopify metafields:
152
234
 
153
235
  ```ruby
154
236
  class Product < ActiveShopifyGraphQL::Model
155
237
  graphql_type "Product"
156
238
 
157
- # Regular attributes
158
239
  attribute :id, type: :string
159
240
  attribute :title, type: :string
160
241
 
@@ -162,624 +243,353 @@ class Product < ActiveShopifyGraphQL::Model
162
243
  metafield_attribute :boxes_available, namespace: 'custom', key: 'available_boxes', type: :integer
163
244
  metafield_attribute :seo_description, namespace: 'seo', key: 'meta_description', type: :string
164
245
  metafield_attribute :product_data, namespace: 'custom', key: 'data', type: :json
165
- metafield_attribute :is_featured, namespace: 'custom', key: 'featured', type: :boolean, null: false
166
246
  end
167
247
  ```
168
248
 
169
- The metafield attributes automatically generate the correct GraphQL syntax and handle value extraction from either `value` or `jsonValue` fields based on the type.
170
-
171
- #### Raw GraphQL Attributes
249
+ #### Raw GraphQL
172
250
 
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:
251
+ For advanced features like union types:
174
252
 
175
253
  ```ruby
176
254
  class Product < ActiveShopifyGraphQL::Model
177
255
  graphql_type "Product"
178
256
 
179
- attribute :id, type: :string
180
- attribute :title, type: :string
181
-
182
- # Raw GraphQL for accessing metaobject references with union types
257
+ # Raw GraphQL injection for union types
183
258
  attribute :provider_id,
184
- path: "roaster.reference.id", # Path to extract from the response
259
+ path: "provider_id.reference.id", # first part must match the attribute name as the field is aliased to that
185
260
  type: :string,
186
261
  raw_graphql: 'metafield(namespace: "custom", key: "provider") { reference { ... on Metaobject { id } } }'
187
-
188
- # Another example with complex nested queries not warranting full blown models
189
- attribute :product_bundle,
190
- path: "bundle", # The alias will be used as the response key
191
- type: :json,
192
- raw_graphql: 'metafield(namespace: "bundles", key: "items") { references(first: 10) { nodes { ... on Product { id title } } } }'
193
262
  end
194
263
  ```
195
264
 
196
265
  #### API-Specific Attributes
197
266
 
198
- For models that need different attributes depending on the API being used, you can define loader-specific overrides:
267
+ Different fields per API:
199
268
 
200
269
  ```ruby
201
270
  class Customer < ActiveShopifyGraphQL::Model
202
271
  graphql_type "Customer"
203
272
 
204
- # Default attributes (used by all loaders)
205
273
  attribute :id, type: :string
206
274
  attribute :name, path: "displayName", type: :string
207
275
 
276
+ # Admin API specific
208
277
  for_loader ActiveShopifyGraphQL::Loaders::AdminApiLoader do
209
278
  attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
210
- attribute :created_at, type: :datetime
211
279
  end
212
280
 
213
- # Customer Account API uses different field names
281
+ # Customer Account API specific
214
282
  for_loader ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader do
215
283
  attribute :email, path: "emailAddress.emailAddress", type: :string
216
- attribute :created_at, path: "creationDate", type: :datetime
217
284
  end
218
285
  end
219
286
  ```
220
287
 
221
- ### Finding Records
288
+ ### Querying
222
289
 
223
- Use the `find` method to retrieve records by ID:
290
+ #### Finding Records
224
291
 
225
292
  ```ruby
226
- # Using Admin API (default)
293
+ # By GID or numeric ID
227
294
  customer = Customer.find("gid://shopify/Customer/123456789")
228
- # You can also use just the ID number
229
295
  customer = Customer.find(123456789)
230
296
 
231
- # Using Customer Account API
232
- customer = Customer.with_customer_account_api(token).find
233
- ```
234
-
235
- ### API Switching
236
-
237
- Switch between Admin API and Customer Account API:
238
-
239
- ```ruby
240
- # Use Admin API (default)
241
- customer = Customer.find(id)
242
-
243
- # Use Customer Account API with token
244
- customer = Customer.with_customer_account_api(token).find
245
-
246
- # Use Admin API explicitly
247
- customer = Customer.with_admin_api.find(id)
248
-
249
- # Use your own custom Loader
250
- customer = Customer.with_loader(MyCustomLoader).find(id)
297
+ # With specific API
298
+ Customer.with_customer_account_api(token).find
299
+ Customer.with_admin_api.find(123456789)
251
300
  ```
252
301
 
253
- ### Querying Records
254
-
255
- Use the `where` method to query multiple records using Shopify's search syntax:
302
+ #### Filtering
256
303
 
257
304
  ```ruby
258
- # Hash-based queries (safe, with automatic escaping)
259
- customers = Customer.where(email: "john@example.com")
305
+ # Hash queries (auto-escaped)
306
+ Customer.where(email: "john@example.com")
260
307
 
261
308
  # Range queries
262
- customers = Customer.where(created_at: { gte: "2024-01-01", lt: "2024-02-01" })
263
- customers = Customer.where(orders_count: { gte: 5 })
309
+ Customer.where(created_at: { gte: "2024-01-01", lt: "2024-02-01" })
310
+ Customer.where(orders_count: { gte: 5 })
311
+
312
+ # Wildcards (string query)
313
+ Customer.where("email:*@example.com")
264
314
 
265
- # Multi-word values are automatically quoted
266
- customers = Customer.where(first_name: "John Doe")
315
+ # Parameter binding (safe)
316
+ Customer.where("email::email", email: "john@example.com")
267
317
 
268
318
  # With limits
269
- customers = Customer.where({ email: "john@example.com" }, limit: 100)
319
+ Customer.where(email: "@gmail.com").limit(100)
270
320
  ```
271
321
 
272
- #### String-based Queries for Advanced Syntax
273
-
274
- For advanced queries like wildcard matching, use string-based queries:
322
+ #### Query Optimization
275
323
 
276
324
  ```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)
325
+ # Select only needed fields
326
+ Customer.select(:id, :name).find(123)
284
327
 
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")
328
+ # Combine with includes (N+1-free)
329
+ Customer.includes(:orders).select(:id, :name).where(first_name: "Andrea")
291
330
  ```
292
331
 
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
-
298
- The `where` method automatically converts Ruby conditions into Shopify's GraphQL query syntax and validates that the query fields are supported by Shopify.
299
-
300
332
  ### Pagination
301
333
 
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:
334
+ Automatic cursor-based pagination:
307
335
 
308
336
  ```ruby
309
- # Fetch up to 100 records (automatically handles pagination behind the scenes)
310
- variants = ProductVariant.where(sku: "*").limit(100).to_a
337
+ # Automatic pagination with limit
338
+ # Query for non-empty SKUs
339
+ ProductVariant.where("-sku:''").limit(100).to_a
311
340
 
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
341
+ # Manual pagination
342
+ page = ProductVariant.where("sku:FRZ*").in_pages(of: 50)
325
343
  page.has_next_page? # => true
326
- page.end_cursor # => "eyJsYXN0X2lk..."
327
-
328
- # Navigate to next page
329
344
  next_page = page.next_page
330
- next_page.size # => 50
331
345
 
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
+ # Batch processing
347
+ ProductVariant.where("sku:FRZ*").in_pages(of: 10) do |page|
348
+ page.each { |variant| process(variant) }
346
349
  end
347
350
 
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
351
+ # Lazy enumeration
352
+ scope = Customer.where(email: "*@example.com")
353
+ scope.each { |c| puts c.name } # Executes query
354
+ scope.first # Fetches just first
353
355
  ```
354
356
 
355
- #### Lazy Enumeration
357
+ ### Connections
356
358
 
357
- The `Query::Scope` returned by `where` is enumerable and lazy-loads records:
359
+ #### Lazy Loading
358
360
 
359
361
  ```ruby
360
- # These don't execute queries immediately
361
- scope = Customer.where(email: "*@example.com")
362
+ customer = Customer.find(123)
362
363
 
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
- ```
364
+ # Not loaded yet
365
+ customer.orders.loaded? # => false
369
366
 
370
- **Note:** Shopify imposes a maximum of 250 records per page. The `in_pages(of: n)` method will cap `n` at 250.
367
+ # Loads on access (separate query)
368
+ orders = customer.orders.to_a
369
+ customer.orders.loaded? # => true
371
370
 
372
- ### Optimizing Queries with Select
371
+ # Enumerable
372
+ customer.orders.each { |order| puts order.name }
373
+ customer.orders.size
374
+ customer.orders.first
375
+ ```
373
376
 
374
- Use the `select` method to only fetch specific attributes, reducing GraphQL query size and improving performance:
377
+ #### Eager Loading
375
378
 
376
379
  ```ruby
377
- # Only fetch id, name, and email
378
- customer = Customer.select(:id, :name, :email).find(123)
380
+ # Load in single query (no N+1!)
381
+ customer = Customer.includes(:orders, :addresses).find(123)
379
382
 
380
- # Works with where queries too
381
- customers = Customer.select(:id, :name).where(country: "Canada")
382
-
383
- # Always includes id even if not specified
384
- customer = Customer.select(:name).find(123)
385
- # This will still include :id in the GraphQL query
383
+ # Already loaded
384
+ orders = customer.orders # No additional query
385
+ addresses = customer.addresses
386
386
  ```
387
387
 
388
- The `select` method validates that the specified attributes exist and automatically includes the `id` field for proper object identification.
389
-
390
- ## Associations
391
-
392
- ActiveShopifyGraphQL provides ActiveRecord-like associations to define relationships between the your own Shopify GraphQL backed ones and ActiveRecord objects.
393
-
394
- ### Has Many Associations
395
-
396
- Use `has_many` to define one-to-many relationships:
388
+ #### Automatic Eager Loading
397
389
 
398
390
  ```ruby
399
391
  class Customer < ActiveShopifyGraphQL::Model
400
- graphql_type "Customer"
401
-
402
- attribute :id, type: :string
403
- attribute :plain_id, path: "id", type: :string, transform: ->(id) { id.split("/").last }
404
- attribute :display_name, type: :string
405
- attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
406
- attribute :created_at, type: :datetime
407
-
408
- # Define an association to one of your own ActiveRecord models
409
- # foreign_key maps the id of the GraphQL powered model to the rewards.shopify_customer_id table
410
- has_many :rewards, foreign_key: :shopify_customer_id
411
- # primary_key specifies which attribute to use as the value for matching the ActiveRecord ID
412
- has_many :referrals, primary_key: :plain_id, foreign_key: :shopify_id
413
-
414
- validates :id, presence: true
392
+ # Always loaded without explicit includes
393
+ has_many_connected :orders, eager_load: true
415
394
  end
395
+
396
+ customer = Customer.find(123)
397
+ orders = customer.orders # Already loaded
416
398
  ```
417
399
 
418
- #### Using the Association
400
+ #### Runtime Parameters
419
401
 
420
402
  ```ruby
421
- customer = Customer.find("gid://shopify/Customer/123456789") # or Customer.find(123456789)
422
-
423
- # Access associated orders (lazy loaded)
424
- customer.rewards
425
- # => [#<Reward:0x... ]
403
+ customer = Customer.find(123)
426
404
 
405
+ # Override defaults
406
+ customer.orders(first: 25, sort_key: 'UPDATED_AT', reverse: true).to_a
427
407
  ```
428
408
 
429
- ### Has One Associations
430
-
431
- Use `has_one` to define one-to-one relationships:
409
+ #### Inverse Relationships
432
410
 
433
411
  ```ruby
434
- class Order < ActiveShopifyGraphQL::Model
435
- has_one :billing_address, class_name: 'Address'
412
+ class Product < ActiveShopifyGraphQL::Model
413
+ has_many_connected :variants, inverse_of: :product
436
414
  end
437
- ```
438
415
 
439
- The associations automatically handle Shopify GID format conversion, extracting numeric IDs when needed for querying related records.
416
+ class ProductVariant < ActiveShopifyGraphQL::Model
417
+ has_one_connected :product, inverse_of: :variants
418
+ end
419
+
420
+ # Bidirectional caching β€” no redundant queries
421
+ product = Product.includes(:variants).find(123)
422
+ product.variants.each do |variant|
423
+ variant.product # Uses cached parent, no query runs
424
+ end
425
+ ```
440
426
 
441
- ## Bridging ActiveRecord with GraphQL
427
+ ### ActiveRecord Associations
442
428
 
443
- The `GraphQLAssociations` module allows ActiveRecord models (or duck-typed objects) to define associations to Shopify GraphQL models:
429
+ Bridge between your ActiveRecord models and Shopify GraphQL:
444
430
 
445
431
  ```ruby
446
432
  class Reward < ApplicationRecord
447
433
  include ActiveShopifyGraphQL::GraphQLAssociations
448
434
 
449
- belongs_to_graphql :customer # Expects shopify_customer_id column
450
- has_one_graphql :primary_address,
451
- class_name: "Address",
452
- foreign_key: :customer_id
453
- has_many_graphql :variants,
454
- class_name: "ProductVariant"
435
+ belongs_to_graphql :customer
436
+ has_one_graphql :primary_address, class_name: "Address"
437
+ has_many_graphql :variants, class_name: "ProductVariant"
455
438
  end
456
439
 
457
440
  reward = Reward.find(1)
458
441
  reward.customer # Loads Customer from shopify_customer_id
459
- reward.primary_address # Queries Address.where(customer_id: reward.shopify_customer_id).first
460
442
  reward.variants # Queries ProductVariant.where({})
461
443
  ```
462
444
 
463
- **Available associations:**
464
- - `belongs_to_graphql` - Loads single GraphQL object via stored GID/ID
465
- - `has_one_graphql` - Queries first GraphQL object matching foreign key
466
- - `has_many_graphql` - Queries multiple GraphQL objects with optional filtering
467
-
468
- All associations support `class_name`, `foreign_key`, `primary_key`, and `loader_class` options. Results are automatically cached and setter methods are provided for testing.
445
+ ---
469
446
 
470
- ## GraphQL Connections
447
+ ## API Reference
471
448
 
472
- ActiveShopifyGraphQL supports GraphQL connections for loading related data from Shopify APIs. Connections provide both lazy and eager loading patterns.
473
-
474
- ### Defining Connections
475
-
476
- Use the `connection` class method to define connections to other ActiveShopifyGraphQL models:
449
+ ### Attribute Options
477
450
 
478
451
  ```ruby
479
- class Customer < ActiveShopifyGraphQL::Model
480
- graphql_type 'Customer'
481
-
482
- attribute :id
483
- attribute :display_name, path: "displayName"
484
- attribute :email
485
-
486
- # Basic connection
487
- has_many_connected :orders, default_arguments: { first: 10 }
488
-
489
- # Connection with custom parameters
490
- has_many_connected :addresses,
491
- class_name: 'MailingAddress', # Target model class (defaults to connection name)
492
- query_name: 'customerAddresses', # GraphQL query field (defaults to pluralized name)
493
- eager_load: true, # Automatically eager load this connection (default: false)
494
- default_arguments: { # Default arguments for the GraphQL query
495
- first: 5, # Number of records to fetch (default: 10)
496
- sort_key: 'CREATED_AT', # Sort key (default: 'CREATED_AT')
497
- reverse: false # Sort direction (default: false for ascending)
498
- }
499
-
500
- # Example of a "scoped" connection
501
- # Multiple connections can use the same query_name with different arguments
502
- has_many_connected :recent_orders,
503
- query_name: "orders", # Uses the same GraphQL field as :orders
504
- class_name: "Order", # The class would be inferred to RecentOrder without this
505
- default_arguments: { # Different arguments for filtering
506
- first: 5,
507
- reverse: true,
508
- sort_key: 'CREATED_AT'
509
- }
510
- end
511
-
512
- class Order < ActiveShopifyGraphQL::Model
513
- graphql_type 'Order'
514
-
515
- attribute :id
516
- attribute :name
517
- attribute :total_price, path: "totalPriceSet.shopMoney.amount"
518
- end
519
- ```
520
-
521
- **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:
522
-
523
- ```graphql
524
- fragment CustomerFragment on Customer {
525
- id
526
- displayName
527
- orders(first: 2) {
528
- nodes { id name }
529
- }
530
- recent_orders: orders(first: 5, reverse: true, sortKey: CREATED_AT) {
531
- nodes { id name }
532
- }
533
- }
452
+ attribute :name,
453
+ path: "displayName", # GraphQL path (auto-inferred if omitted)
454
+ type: :string, # Type coercion
455
+ null: false, # Can be null? (default: true)
456
+ default: "value", # Default value (default: nil)
457
+ transform: ->(v) { v.upcase } # Custom transform
534
458
  ```
535
459
 
536
- This allows you to have multiple "views" of the same connection with different filtering or sorting parameters, all in a single query.
537
-
538
- ### Lazy Loading (Default Behavior)
460
+ **Supported Types:** `:string`, `:integer`, `:float`, `:boolean`, `:datetime`
539
461
 
540
- Connections are loaded lazily when accessed. A separate GraphQL query is fired when the connection is first accessed:
462
+ ### Connection Options
541
463
 
542
464
  ```ruby
543
- customer = Customer.find(123456789)
544
-
545
- # This creates a connection proxy but doesn't load data yet
546
- orders_proxy = customer.orders
547
- puts orders_proxy.loaded? # => false
548
-
549
- # This triggers the GraphQL query and loads the data
550
- orders = customer.orders.to_a
551
- puts customer.orders.loaded? # => true (for this specific proxy instance)
552
-
553
- # Connection proxies implement Enumerable
554
- customer.orders.each do |order|
555
- puts order.name
556
- end
557
-
558
- # Array-like access methods
559
- customer.orders.size # Number of records
560
- customer.orders.first # First record
561
- customer.orders.last # Last record
562
- customer.orders[0] # Access by index
563
- customer.orders.empty? # Check if empty
465
+ has_many_connected :orders,
466
+ class_name: "Order", # Target class (default: connection name)
467
+ query_name: "orders", # GraphQL field (default: pluralized)
468
+ default_arguments: { # Default query args
469
+ first: 10,
470
+ sort_key: 'CREATED_AT',
471
+ reverse: false
472
+ },
473
+ eager_load: true, # Auto eager load? (default: false)
474
+ inverse_of: :customer # Inverse connection (optional)
564
475
  ```
565
476
 
566
- ### Runtime Parameter Overrides
567
-
568
- You can override connection parameters at runtime:
477
+ ### Association Options
569
478
 
570
479
  ```ruby
571
- customer = Customer.find(123456789)
480
+ has_many :rewards,
481
+ foreign_key: :shopify_customer_id # ActiveRecord column
482
+ primary_key: :id # Model attribute (default: :id)
572
483
 
573
- # Override default parameters for this call
574
- recent_orders = customer.orders(
575
- first: 25, # Fetch 25 records instead of default 10
576
- sort_key: 'UPDATED_AT', # Sort by update date instead of creation date
577
- reverse: true # Most recent first
578
- ).to_a
484
+ has_one :billing_address,
485
+ class_name: "Address"
579
486
  ```
580
487
 
581
- ### Eager Loading with `includes`
488
+ ---
582
489
 
583
- Use `includes` to load connections in the same GraphQL query as the parent record, eliminating the N+1 query problem:
490
+ ## Advanced Topics
584
491
 
585
- ```ruby
586
- # Load customer with orders and addresses in a single GraphQL query
587
- customer = Customer.includes(:orders, :addresses).find(123456789)
588
-
589
- # These connections are already loaded - no additional queries fired
590
- orders = customer.orders # Uses cached data
591
- addresses = customer.addresses # Uses cached data
592
-
593
- puts customer.orders.loaded? # => This won't be a proxy since data was eager loaded
594
- ```
492
+ ### Application Base Class
595
493
 
596
- ### Automatic Eager Loading
597
-
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`:
494
+ Create a base class for shared behavior:
599
495
 
600
496
  ```ruby
601
- class Customer < ActiveShopifyGraphQL::Model
602
- graphql_type 'Customer'
603
-
604
- attribute :id
605
- attribute :display_name, path: "displayName"
606
-
607
- # This connection will always be eager loaded
608
- connection :orders, eager_load: true
609
-
610
- # This connection will only be loaded lazily (default behavior)
611
- connection :addresses
497
+ # app/models/application_shopify_gql_record.rb
498
+ class ApplicationShopifyRecord < ActiveShopifyGraphQL::Model
499
+ attribute :id, transform: ->(gid) { gid.split("/").last }
500
+ attribute :gid, path: "id"
612
501
  end
613
502
 
614
- # The orders connection is automatically loaded
615
- customer = Customer.find(123456789)
616
- orders = customer.orders # Uses cached data - no additional query fired
617
-
618
- # The addresses connection is lazy loaded
619
- addresses = customer.addresses # This will fire a GraphQL query when first accessed
620
- ```
621
-
622
- This feature is perfect for connections that are frequently accessed and should be included in most queries to avoid N+1 problems.
623
-
624
- The `includes` method modifies the GraphQL fragment to include connection fields:
625
-
626
- ```graphql
627
- query customer($id: ID!) {
628
- customer(id: $id) {
629
- # Regular customer fields
630
- id
631
- displayName
632
-
633
- # Eager-loaded connections
634
- orders(first: 10, sortKey: CREATED_AT, reverse: false) {
635
- nodes {
636
- id
637
- name
638
- totalPriceSet {
639
- shopMoney {
640
- amount
641
- }
642
- }
643
- }
644
- }
645
- addresses(first: 5, sortKey: CREATED_AT, reverse: false) {
646
- nodes {
647
- id
648
- address1
649
- city
650
- }
651
- }
652
- }
653
- }
503
+ # Then inherit
504
+ class Customer < ApplicationShopifyRecord
505
+ graphql_type "Customer"
506
+ attribute :name, path: "displayName"
507
+ end
654
508
  ```
655
509
 
656
- ### Method Chaining
510
+ ### Custom Loaders
657
511
 
658
- Connection methods support chaining with other query methods:
512
+ Create your own loaders for specialized behavior:
659
513
 
660
514
  ```ruby
661
- # Chain includes with select for optimized queries
662
- Customer.includes(:orders).select(:id, :display_name).find(123456789)
515
+ class MyCustomLoader < ActiveShopifyGraphQL::Loader
516
+ def fragment
517
+ # Return GraphQL fragment string
518
+ end
663
519
 
664
- # Chain includes with where for filtered queries
665
- Customer.includes(:orders).where(email: "john@example.com").first
520
+ def map_response_to_attributes(response)
521
+ # Map response to attribute hash
522
+ end
523
+ end
524
+
525
+ # Use it
526
+ Customer.with_loader(MyCustomLoader).find(123)
666
527
  ```
667
528
 
668
- ### Testing Support
529
+ ### Testing
669
530
 
670
- For testing, you can manually set connection data to avoid making real API calls:
531
+ Mock data for tests:
671
532
 
672
533
  ```ruby
673
- # In your tests
534
+ # Mock associations
674
535
  customer = Customer.new(id: 'gid://shopify/Customer/123')
675
- mock_orders = [
676
- Order.new(id: 'gid://shopify/Order/1', name: '#1001'),
677
- Order.new(id: 'gid://shopify/Order/2', name: '#1002')
678
- ]
536
+ customer.orders = [Order.new(id: 'gid://shopify/Order/1')]
679
537
 
680
- # Set mock data
538
+ # Mock connections
681
539
  customer.orders = mock_orders
682
-
683
- # Now customer.orders returns the mock data
684
- expect(customer.orders.size).to eq(2)
685
- expect(customer.orders.first.name).to eq('#1001')
540
+ expect(customer.orders.size).to eq(1)
686
541
  ```
687
542
 
688
- ### Inverse Relationships with `inverse_of`
543
+ ---
689
544
 
690
- 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.
691
-
692
- #### Basic Usage
693
-
694
- ```ruby
695
- class Product < ActiveShopifyGraphQL::Model
696
- graphql_type 'Product'
697
-
698
- attribute :id
699
- attribute :title
700
-
701
- # Define inverse relationship to avoid redundant queries
702
- has_many_connected :variants,
703
- class_name: "ProductVariant",
704
- inverse_of: :product, # Points to the inverse connection name
705
- default_arguments: { first: 10 }
706
- end
707
-
708
- class ProductVariant < ActiveShopifyGraphQL::Model
709
- graphql_type 'ProductVariant'
710
-
711
- attribute :id
712
- attribute :title
713
-
714
- # Define inverse relationship back to Product
715
- has_one_connected :product,
716
- inverse_of: :variants # Points back to the parent's connection
717
- end
718
- ```
719
-
720
- #### With Eager Loading
721
-
722
- ```ruby
723
- # Load product with variants in a single GraphQL query
724
- product = Product.includes(:variants).find(123)
725
-
726
- # Access variants - already loaded, no additional query
727
- product.variants.each do |variant|
728
- # Access product from variant - uses cached parent, NO QUERY!
729
- puts variant.product.title
730
- end
731
- ```
545
+ ## Development
732
546
 
733
- #### With Lazy Loading
547
+ ```bash
548
+ # Install dependencies
549
+ bin/setup
734
550
 
735
- ```ruby
736
- # Load product without preloading variants
737
- product = Product.find(123)
551
+ # Run tests
552
+ bundle exec rake spec
738
553
 
739
- # First access triggers a query to load variants
740
- variants = product.variants.to_a
554
+ # Run console
555
+ bin/console
741
556
 
742
- # Access product from variant - uses cached parent, NO QUERY!
743
- variant = variants.first
744
- puts variant.product.title # Returns the same product instance
557
+ # Lint
558
+ bundle exec rubocop
745
559
  ```
746
560
 
747
- ### Connection Configuration
561
+ ---
748
562
 
749
- Connections automatically infer sensible defaults but can be customized:
563
+ ## Roadmap
750
564
 
751
- - **class_name**: Target model class name (defaults to connection name singularized and classified)
752
- - **query_name**: GraphQL query field name (defaults to connection name pluralized)
753
- - **foreign_key**: Field used to filter connection records (defaults to `{model_name}_id`)
754
- - **loader_class**: Custom loader class (defaults to model's default loader)
755
- - **eager_load**: Whether to automatically eager load this connection on find/where queries (default: false)
756
- - **inverse_of**: The name of the inverse connection on the target model (optional, enables automatic inverse caching)
757
- - **default_arguments**: Hash of default arguments to pass to the GraphQL query (e.g., `{ first: 10, sort_key: 'CREATED_AT' }`)
565
+ - [x] Attribute-based model definition
566
+ - [x] Metafield attributes
567
+ - [x] Query optimization with `select`
568
+ - [x] GraphQL connections with lazy/eager loading
569
+ - [x] Cursor-based pagination
570
+ - [ ] Metaobjects as models
571
+ - [ ] Builtin instrumentation to track query costs
572
+ - [ ] Advanced error handling and retry mechanisms
573
+ - [ ] Caching layer
574
+ - [ ] Chained `.where` with `.not` support
575
+ - [ ] Basic mutation support
758
576
 
759
- ### Error Handling
577
+ ---
760
578
 
761
- 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.
579
+ ## Contributing
762
580
 
763
- ## Next steps
581
+ Bug reports and pull requests are welcome on GitHub at [nebulab/active_shopify_graphql](https://github.com/nebulab/active_shopify_graphql).
764
582
 
765
- - [x] Attribute-based model definition with automatic GraphQL fragment generation
766
- - [x] Metafield attributes for easy access to Shopify metafields
767
- - [x] Support `Model.where(param: value)` proxying params to the GraphQL query attribute
768
- - [x] Query optimization with `select` method
769
- - [x] GraphQL connections with lazy and eager loading via `Customer.includes(:orders).find(id)`
770
- - [x] Support for paginating query results with cursors
771
- - [ ] Better error handling and retry mechanisms for GraphQL API calls
772
- - [ ] Caching layer for frequently accessed data
773
- - [ ] Multiple `.where` chaining with possibility of using `.not`
583
+ ---
774
584
 
775
- ## Development
585
+ ## License
776
586
 
777
- 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.
587
+ The gem is available as open source under the [MIT License](https://opensource.org/licenses/MIT).
778
588
 
779
- ## Contributing
589
+ ---
780
590
 
781
- Bug reports and pull requests are welcome on GitHub at https://github.com/nebulab/active_shopify_graphql.
591
+ <div align="center">
782
592
 
783
- ## License
593
+ Made by [Nebulab](https://nebulab.com)
784
594
 
785
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
595
+ </div>
@@ -69,15 +69,12 @@ module ActiveShopifyGraphQL
69
69
  # Resolve the target class
70
70
  target_class = association_class_name.constantize
71
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
72
+ # Determine which loader class to use (if custom specified)
73
+ custom_loader_class = self.class.graphql_associations[name][:loader_class]
78
74
 
79
- # Load and cache the GraphQL object
80
- @_graphql_association_cache[name] = target_class.find(gid_or_id, loader: loader)
75
+ # Load and cache the GraphQL object using a Relation with the appropriate loader
76
+ relation = Query::Relation.new(target_class, loader_class: custom_loader_class)
77
+ @_graphql_association_cache[name] = relation.find(gid_or_id)
81
78
  end
82
79
 
83
80
  # Define setter method for testing/mocking
@@ -126,18 +123,15 @@ module ActiveShopifyGraphQL
126
123
  # Resolve the target class
127
124
  target_class = association_class_name.constantize
128
125
 
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
126
+ # Determine which loader class to use (if custom specified)
127
+ custom_loader_class = self.class.graphql_associations[name][:loader_class]
135
128
 
136
129
  # Query with foreign key filter if provided
137
130
  result = if self.class.graphql_associations[name][:foreign_key]
138
131
  foreign_key_sym = self.class.graphql_associations[name][:foreign_key]
139
132
  query_conditions = { foreign_key_sym => primary_key_value }
140
- target_class.where(query_conditions, loader: loader).first
133
+ relation = Query::Relation.new(target_class, conditions: query_conditions, loader_class: custom_loader_class)
134
+ relation.first
141
135
  end
142
136
 
143
137
  # Cache the result
@@ -206,12 +200,8 @@ module ActiveShopifyGraphQL
206
200
  # Resolve the target class
207
201
  target_class = association_class_name.constantize
208
202
 
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
203
+ # Determine which loader class to use (if custom specified)
204
+ custom_loader_class = self.class.graphql_associations[name][:loader_class]
215
205
 
216
206
  # Build query based on query_method
217
207
  result = if association_query_method == :connection
@@ -223,10 +213,10 @@ module ActiveShopifyGraphQL
223
213
  # Query with foreign key filter
224
214
  foreign_key_sym = self.class.graphql_associations[name][:foreign_key]
225
215
  query_conditions = { foreign_key_sym => primary_key_value }.merge(options)
226
- target_class.where(query_conditions, loader: loader)
216
+ Query::Relation.new(target_class, conditions: query_conditions, loader_class: custom_loader_class)
227
217
  else
228
218
  # No foreign key specified, just query with provided options
229
- target_class.where(options, loader: loader)
219
+ Query::Relation.new(target_class, conditions: options, loader_class: custom_loader_class)
230
220
  end
231
221
 
232
222
  # Cache if no runtime options provided
@@ -11,11 +11,13 @@ module ActiveShopifyGraphQL::Model::FinderMethods
11
11
  end
12
12
 
13
13
  # Find a single record by ID
14
- # @param id [String, Integer] The record ID (will be converted to GID automatically)
14
+ # For Customer Account API, if no ID is provided, fetches the current customer
15
+ # @param id [String, Integer, nil] The record ID (will be converted to GID automatically)
15
16
  # @param loader [ActiveShopifyGraphQL::Loader] The loader to use for fetching data (deprecated, use Relation chain)
16
17
  # @return [Object] The model instance
17
18
  # @raise [ActiveShopifyGraphQL::ObjectNotFoundError] If the record is not found
18
- def find(id)
19
+ # @raise [ArgumentError] If id is nil and not using Customer Account API loader
20
+ def find(id = nil)
19
21
  all.find(id)
20
22
  end
21
23
 
@@ -75,10 +75,23 @@ module ActiveShopifyGraphQL
75
75
  end
76
76
 
77
77
  # Find a single record by ID
78
- # @param id [String, Integer] The record ID
78
+ # For Customer Account API, if no ID is provided, fetches the current customer
79
+ # @param id [String, Integer, nil] The record ID (will be converted to GID automatically)
79
80
  # @return [Object] The model instance
80
81
  # @raise [ObjectNotFoundError] If the record is not found
81
- def find(id)
82
+ # @raise [ArgumentError] If id is nil and not using Customer Account API loader
83
+ def find(id = nil)
84
+ # Handle Customer Account API case where no ID means "current customer"
85
+ if id.nil?
86
+ raise ArgumentError, "find requires an ID argument unless using Customer Account API" unless loader.is_a?(ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader)
87
+
88
+ attributes = loader.load_attributes
89
+ raise ObjectNotFoundError, "Couldn't find current customer" if attributes.nil?
90
+
91
+ return ModelBuilder.build(@model_class, attributes)
92
+ end
93
+
94
+ # Standard case: find by ID
82
95
  gid = GidHelper.normalize_gid(id, @model_class.model_name.name.demodulize)
83
96
  attributes = loader.load_attributes(gid)
84
97
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveShopifyGraphQL
4
- VERSION = "0.5.1"
4
+ VERSION = "0.5.3"
5
5
  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.5.1
4
+ version: 0.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - NicolΓ² Rebughini