active_shopify_graphql 0.5.2 β 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 +4 -4
- data/README.md +350 -540
- data/lib/active_shopify_graphql/graphql_associations.rb +13 -23
- data/lib/active_shopify_graphql/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e6fe91cae8b27f0c6154d8d43a42477e900dba59925b27421f5ea43278c8a786
|
|
4
|
+
data.tar.gz: 6480859442d7a8ff30dc17fa3c0a96c5ae383f0ce0465fbbb8a67ff26c46f6da
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
5
|
+
**Bringing Read Only (for now) ActiveRecord-like domain modeling to Shopify GraphQL APIs**
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
[](https://badge.fury.io/rb/active_shopify_graphql)
|
|
8
|
+
[](https://github.com/nebulab/active_shopify_graphql/actions/workflows/test.yml)
|
|
9
|
+
[](https://github.com/nebulab/active_shopify_graphql/actions/workflows/lint.yml)
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
</div>
|
|
8
12
|
|
|
9
|
-
|
|
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
|
-
|
|
17
|
+
---
|
|
12
18
|
|
|
13
|
-
|
|
19
|
+
## π Quick Start
|
|
14
20
|
|
|
15
|
-
|
|
21
|
+
```bash
|
|
22
|
+
gem install active_shopify_graphql
|
|
23
|
+
```
|
|
16
24
|
|
|
17
|
-
|
|
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
|
-
|
|
67
|
+
GraphQL is powerful, but dealing with raw responses is painful:
|
|
20
68
|
|
|
21
69
|
```ruby
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
+
### The Solution
|
|
28
87
|
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
141
|
+
# Admin API (must respond to #execute(query, **variables))
|
|
42
142
|
config.admin_api_client = ShopifyGraphQL::Client
|
|
43
143
|
|
|
44
|
-
#
|
|
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
|
-
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Core Concepts
|
|
153
|
+
|
|
154
|
+
### Models
|
|
155
|
+
|
|
156
|
+
Models are the heart of ActiveShopifyGraphQL. They define:
|
|
52
157
|
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
63
|
-
attribute :
|
|
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
|
-
|
|
175
|
+
# Custom path with dot notation
|
|
176
|
+
attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
|
|
69
177
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
end
|
|
178
|
+
# Custom transformation
|
|
179
|
+
attribute :plain_id, path: "id", transform: ->(gid) { gid.split("/").last }
|
|
73
180
|
end
|
|
74
181
|
```
|
|
75
182
|
|
|
76
|
-
###
|
|
183
|
+
### Connections
|
|
77
184
|
|
|
78
|
-
|
|
185
|
+
Connections to related Shopify data with lazy/eager loading:
|
|
79
186
|
|
|
80
187
|
```ruby
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
+
## Features
|
|
108
205
|
|
|
109
|
-
|
|
206
|
+
### ποΈ Attribute Definition
|
|
110
207
|
|
|
111
|
-
|
|
208
|
+
Define attributes with automatic GraphQL generation:
|
|
112
209
|
|
|
113
210
|
```ruby
|
|
114
|
-
class
|
|
115
|
-
graphql_type "
|
|
211
|
+
class Product < ActiveShopifyGraphQL::Model
|
|
212
|
+
graphql_type "Product"
|
|
116
213
|
|
|
117
|
-
#
|
|
118
|
-
attribute :
|
|
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
|
|
124
|
-
attribute :
|
|
125
|
-
end
|
|
126
|
-
```
|
|
217
|
+
# Custom path
|
|
218
|
+
attribute :price, path: "priceRange.minVariantPrice.amount", type: :float
|
|
127
219
|
|
|
128
|
-
|
|
220
|
+
# With default
|
|
221
|
+
attribute :description, type: :string, default: "No description"
|
|
129
222
|
|
|
130
|
-
|
|
223
|
+
# Custom transformation
|
|
224
|
+
attribute :slug, path: "handle", transform: ->(handle) { handle.parameterize }
|
|
131
225
|
|
|
132
|
-
|
|
133
|
-
attribute :
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
#### Raw GraphQL Attributes
|
|
249
|
+
#### Raw GraphQL
|
|
172
250
|
|
|
173
|
-
For advanced
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
|
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
|
-
###
|
|
288
|
+
### Querying
|
|
222
289
|
|
|
223
|
-
|
|
290
|
+
#### Finding Records
|
|
224
291
|
|
|
225
292
|
```ruby
|
|
226
|
-
#
|
|
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
|
-
#
|
|
232
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
Use the `where` method to query multiple records using Shopify's search syntax:
|
|
302
|
+
#### Filtering
|
|
256
303
|
|
|
257
304
|
```ruby
|
|
258
|
-
# Hash
|
|
259
|
-
|
|
305
|
+
# Hash queries (auto-escaped)
|
|
306
|
+
Customer.where(email: "john@example.com")
|
|
260
307
|
|
|
261
308
|
# Range queries
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
#
|
|
266
|
-
|
|
315
|
+
# Parameter binding (safe)
|
|
316
|
+
Customer.where("email::email", email: "john@example.com")
|
|
267
317
|
|
|
268
318
|
# With limits
|
|
269
|
-
|
|
319
|
+
Customer.where(email: "@gmail.com").limit(100)
|
|
270
320
|
```
|
|
271
321
|
|
|
272
|
-
####
|
|
273
|
-
|
|
274
|
-
For advanced queries like wildcard matching, use string-based queries:
|
|
322
|
+
#### Query Optimization
|
|
275
323
|
|
|
276
324
|
```ruby
|
|
277
|
-
#
|
|
278
|
-
|
|
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
|
-
#
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
310
|
-
|
|
337
|
+
# Automatic pagination with limit
|
|
338
|
+
# Query for non-empty SKUs
|
|
339
|
+
ProductVariant.where("-sku:''").limit(100).to_a
|
|
311
340
|
|
|
312
|
-
#
|
|
313
|
-
|
|
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
|
-
#
|
|
333
|
-
|
|
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
|
-
#
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
357
|
+
### Connections
|
|
356
358
|
|
|
357
|
-
|
|
359
|
+
#### Lazy Loading
|
|
358
360
|
|
|
359
361
|
```ruby
|
|
360
|
-
|
|
361
|
-
scope = Customer.where(email: "*@example.com")
|
|
362
|
+
customer = Customer.find(123)
|
|
362
363
|
|
|
363
|
-
#
|
|
364
|
-
|
|
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
|
-
|
|
367
|
+
# Loads on access (separate query)
|
|
368
|
+
orders = customer.orders.to_a
|
|
369
|
+
customer.orders.loaded? # => true
|
|
371
370
|
|
|
372
|
-
|
|
371
|
+
# Enumerable
|
|
372
|
+
customer.orders.each { |order| puts order.name }
|
|
373
|
+
customer.orders.size
|
|
374
|
+
customer.orders.first
|
|
375
|
+
```
|
|
373
376
|
|
|
374
|
-
|
|
377
|
+
#### Eager Loading
|
|
375
378
|
|
|
376
379
|
```ruby
|
|
377
|
-
#
|
|
378
|
-
customer = Customer.
|
|
380
|
+
# Load in single query (no N+1!)
|
|
381
|
+
customer = Customer.includes(:orders, :addresses).find(123)
|
|
379
382
|
|
|
380
|
-
#
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
####
|
|
400
|
+
#### Runtime Parameters
|
|
419
401
|
|
|
420
402
|
```ruby
|
|
421
|
-
customer = Customer.find(
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
Use `has_one` to define one-to-one relationships:
|
|
409
|
+
#### Inverse Relationships
|
|
432
410
|
|
|
433
411
|
```ruby
|
|
434
|
-
class
|
|
435
|
-
|
|
412
|
+
class Product < ActiveShopifyGraphQL::Model
|
|
413
|
+
has_many_connected :variants, inverse_of: :product
|
|
436
414
|
end
|
|
437
|
-
```
|
|
438
415
|
|
|
439
|
-
|
|
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
|
-
|
|
427
|
+
### ActiveRecord Associations
|
|
442
428
|
|
|
443
|
-
|
|
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
|
|
450
|
-
has_one_graphql :primary_address,
|
|
451
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
447
|
+
## API Reference
|
|
471
448
|
|
|
472
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
### Lazy Loading (Default Behavior)
|
|
460
|
+
**Supported Types:** `:string`, `:integer`, `:float`, `:boolean`, `:datetime`
|
|
539
461
|
|
|
540
|
-
|
|
462
|
+
### Connection Options
|
|
541
463
|
|
|
542
464
|
```ruby
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
#
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
###
|
|
567
|
-
|
|
568
|
-
You can override connection parameters at runtime:
|
|
477
|
+
### Association Options
|
|
569
478
|
|
|
570
479
|
```ruby
|
|
571
|
-
|
|
480
|
+
has_many :rewards,
|
|
481
|
+
foreign_key: :shopify_customer_id # ActiveRecord column
|
|
482
|
+
primary_key: :id # Model attribute (default: :id)
|
|
572
483
|
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
488
|
+
---
|
|
582
489
|
|
|
583
|
-
|
|
490
|
+
## Advanced Topics
|
|
584
491
|
|
|
585
|
-
|
|
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
|
-
|
|
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
|
-
|
|
602
|
-
|
|
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
|
-
#
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
###
|
|
510
|
+
### Custom Loaders
|
|
657
511
|
|
|
658
|
-
|
|
512
|
+
Create your own loaders for specialized behavior:
|
|
659
513
|
|
|
660
514
|
```ruby
|
|
661
|
-
|
|
662
|
-
|
|
515
|
+
class MyCustomLoader < ActiveShopifyGraphQL::Loader
|
|
516
|
+
def fragment
|
|
517
|
+
# Return GraphQL fragment string
|
|
518
|
+
end
|
|
663
519
|
|
|
664
|
-
|
|
665
|
-
|
|
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
|
|
529
|
+
### Testing
|
|
669
530
|
|
|
670
|
-
|
|
531
|
+
Mock data for tests:
|
|
671
532
|
|
|
672
533
|
```ruby
|
|
673
|
-
#
|
|
534
|
+
# Mock associations
|
|
674
535
|
customer = Customer.new(id: 'gid://shopify/Customer/123')
|
|
675
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
543
|
+
---
|
|
689
544
|
|
|
690
|
-
|
|
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
|
-
|
|
547
|
+
```bash
|
|
548
|
+
# Install dependencies
|
|
549
|
+
bin/setup
|
|
734
550
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
product = Product.find(123)
|
|
551
|
+
# Run tests
|
|
552
|
+
bundle exec rake spec
|
|
738
553
|
|
|
739
|
-
#
|
|
740
|
-
|
|
554
|
+
# Run console
|
|
555
|
+
bin/console
|
|
741
556
|
|
|
742
|
-
#
|
|
743
|
-
|
|
744
|
-
puts variant.product.title # Returns the same product instance
|
|
557
|
+
# Lint
|
|
558
|
+
bundle exec rubocop
|
|
745
559
|
```
|
|
746
560
|
|
|
747
|
-
|
|
561
|
+
---
|
|
748
562
|
|
|
749
|
-
|
|
563
|
+
## Roadmap
|
|
750
564
|
|
|
751
|
-
-
|
|
752
|
-
-
|
|
753
|
-
-
|
|
754
|
-
-
|
|
755
|
-
-
|
|
756
|
-
-
|
|
757
|
-
-
|
|
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
|
-
|
|
577
|
+
---
|
|
760
578
|
|
|
761
|
-
|
|
579
|
+
## Contributing
|
|
762
580
|
|
|
763
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
585
|
+
## License
|
|
776
586
|
|
|
777
|
-
|
|
587
|
+
The gem is available as open source under the [MIT License](https://opensource.org/licenses/MIT).
|
|
778
588
|
|
|
779
|
-
|
|
589
|
+
---
|
|
780
590
|
|
|
781
|
-
|
|
591
|
+
<div align="center">
|
|
782
592
|
|
|
783
|
-
|
|
593
|
+
Made by [Nebulab](https://nebulab.com)
|
|
784
594
|
|
|
785
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|