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