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