active_shopify_graphql 0.5.3 → 1.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 +4 -4
- data/.github/workflows/lint.yml +2 -3
- data/.github/workflows/test.yml +1 -1
- data/CLAUDE.md +1 -0
- data/README.md +150 -22
- data/lib/active_shopify_graphql/configuration.rb +3 -3
- data/lib/active_shopify_graphql/connections/connection_loader.rb +2 -2
- data/lib/active_shopify_graphql/loader.rb +17 -13
- data/lib/active_shopify_graphql/loaders/admin_api_loader.rb +3 -5
- data/lib/active_shopify_graphql/loaders/customer_account_api_loader.rb +4 -9
- data/lib/active_shopify_graphql/logging/graphql_controller_runtime.rb +41 -0
- data/lib/active_shopify_graphql/logging/graphql_logger.rb +41 -0
- data/lib/active_shopify_graphql/logging/graphql_runtime.rb +44 -0
- data/lib/active_shopify_graphql/model/connections.rb +1 -1
- data/lib/active_shopify_graphql/model/finder_methods.rb +12 -0
- data/lib/active_shopify_graphql/model/graphql_type_resolver.rb +2 -3
- data/lib/active_shopify_graphql/query/relation.rb +32 -4
- data/lib/active_shopify_graphql/testing/hook.rb +66 -0
- data/lib/active_shopify_graphql/testing/store.rb +138 -0
- data/lib/active_shopify_graphql/testing/test_loader.rb +233 -0
- data/lib/active_shopify_graphql/testing.rb +66 -0
- data/lib/active_shopify_graphql/version.rb +1 -1
- data/lib/active_shopify_graphql.rb +2 -0
- metadata +11 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2f853d9c152e7118a0916f0daf12f6b855779cf7006e15849738f4ddad75bbc3
|
|
4
|
+
data.tar.gz: '0195855f859d751a6ca53b2bbe08c804892a96dfb640ceb7a73267fb7e7c4748'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4f7871a5b0aee7d7b7cbe489d0ebec973b7f123adf5f28f821c781fc806e3daae2c5a6048554bdfc29756d4e2d5d97e394ad806c1cbd85c69b9f16aff6fff33f
|
|
7
|
+
data.tar.gz: 0e830f19306b3b0270f326514b775186ba769d76bc6a91a6f8a3e539552bd223a97c3071e42eb29a37e3f146fbe33bad8ee12c035dcf37c4eb45e75677719713
|
data/.github/workflows/lint.yml
CHANGED
data/.github/workflows/test.yml
CHANGED
data/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
AGENTS.md
|
data/README.md
CHANGED
|
@@ -25,15 +25,21 @@ gem install active_shopify_graphql
|
|
|
25
25
|
```ruby
|
|
26
26
|
# Configure in pure Ruby
|
|
27
27
|
ActiveShopifyGraphQL.configure do |config|
|
|
28
|
-
config.
|
|
29
|
-
|
|
28
|
+
config.admin_api_executor = lambda do |query, **variables|
|
|
29
|
+
client = ShopifyAPI::Clients::Graphql::Admin.new(session: ShopifyAPI::Context.active_session)
|
|
30
|
+
response = client.query(query:, variables:)
|
|
31
|
+
response.body if response
|
|
32
|
+
end
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
# Or define a Rails initializer
|
|
33
36
|
Rails.configuration.to_prepare do
|
|
34
37
|
ActiveShopifyGraphQL.configure do |config|
|
|
35
|
-
config.
|
|
36
|
-
|
|
38
|
+
config.admin_api_executor = lambda do |query, **variables|
|
|
39
|
+
client = ShopifyAPI::Clients::Graphql::Admin.new(session: ShopifyAPI::Context.active_session)
|
|
40
|
+
response = client.query(query:, variables:)
|
|
41
|
+
response.body if response
|
|
42
|
+
end
|
|
37
43
|
end
|
|
38
44
|
end
|
|
39
45
|
|
|
@@ -90,7 +96,7 @@ orders = customer["orders"]["nodes"].map { |o| parse_order(o) }
|
|
|
90
96
|
customer = Customer.includes(:orders).find(123456789)
|
|
91
97
|
customer.email # => "john@example.com"
|
|
92
98
|
customer.created_at # => #<DateTime>
|
|
93
|
-
customer.orders
|
|
99
|
+
customer.orders # Eagerly loaded as a single query
|
|
94
100
|
```
|
|
95
101
|
|
|
96
102
|
**Benefits:**
|
|
@@ -138,11 +144,19 @@ Configure your Shopify GraphQL clients:
|
|
|
138
144
|
# config/initializers/active_shopify_graphql.rb
|
|
139
145
|
Rails.configuration.to_prepare do
|
|
140
146
|
ActiveShopifyGraphQL.configure do |config|
|
|
141
|
-
# Admin API (
|
|
142
|
-
config.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
147
|
+
# Admin API (receives query, **variables as arguments)
|
|
148
|
+
config.admin_api_executor = lambda do |query, **variables|
|
|
149
|
+
client = ShopifyAPI::Clients::Graphql::Admin.new(session: ShopifyAPI::Context.active_session)
|
|
150
|
+
response = client.query(query:, variables:)
|
|
151
|
+
response.body if response
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Optional Customer Account API (receives query, customer_token, **variables as arguments)
|
|
155
|
+
config.customer_account_api_executor = lambda do |query, customer_token, **variables|
|
|
156
|
+
# This is a custom client example
|
|
157
|
+
client = Shopify::Account::Client.from_config(customer_token)
|
|
158
|
+
response = client.query(query, **variables)
|
|
159
|
+
end
|
|
146
160
|
end
|
|
147
161
|
end
|
|
148
162
|
```
|
|
@@ -169,8 +183,8 @@ Attributes auto-generate GraphQL fragments and handle response mapping:
|
|
|
169
183
|
class Customer < ActiveShopifyGraphQL::Model
|
|
170
184
|
graphql_type "Customer"
|
|
171
185
|
|
|
172
|
-
#
|
|
173
|
-
attribute :name, type: :string
|
|
186
|
+
# Renaming an attribute
|
|
187
|
+
attribute :name, path: "displayName", type: :string
|
|
174
188
|
|
|
175
189
|
# Custom path with dot notation
|
|
176
190
|
attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
|
|
@@ -187,7 +201,7 @@ Connections to related Shopify data with lazy/eager loading:
|
|
|
187
201
|
```ruby
|
|
188
202
|
class Customer < ActiveShopifyGraphQL::Model
|
|
189
203
|
# Lazy by default — loaded on first access
|
|
190
|
-
has_many_connected :orders
|
|
204
|
+
has_many_connected :orders, default_arguments: { first: 10 }
|
|
191
205
|
|
|
192
206
|
# Always eager load — no N+1 queries
|
|
193
207
|
has_many_connected :addresses, eager_load: true, default_arguments: { first: 5 }
|
|
@@ -309,7 +323,8 @@ Customer.where(email: "john@example.com")
|
|
|
309
323
|
Customer.where(created_at: { gte: "2024-01-01", lt: "2024-02-01" })
|
|
310
324
|
Customer.where(orders_count: { gte: 5 })
|
|
311
325
|
|
|
312
|
-
# Wildcards
|
|
326
|
+
# Wildcards are supported in string queries only
|
|
327
|
+
# Use with caution as interpolation can lead to security issues
|
|
313
328
|
Customer.where("email:*@example.com")
|
|
314
329
|
|
|
315
330
|
# Parameter binding (safe)
|
|
@@ -319,6 +334,21 @@ Customer.where("email::email", email: "john@example.com")
|
|
|
319
334
|
Customer.where(email: "@gmail.com").limit(100)
|
|
320
335
|
```
|
|
321
336
|
|
|
337
|
+
#### Sorting
|
|
338
|
+
|
|
339
|
+
```ruby
|
|
340
|
+
# Sort by created_at ascending (default)
|
|
341
|
+
Customer.where(email: "@example.com").order(sort_key: "CREATED_AT")
|
|
342
|
+
|
|
343
|
+
# Sort by updated_at descending
|
|
344
|
+
Customer.order(sort_key: "UPDATED_AT", reverse: true)
|
|
345
|
+
|
|
346
|
+
# Combine with other chainable methods
|
|
347
|
+
Customer.where(country: "Canada").order(sort_key: "CREATED_AT").limit(25)
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
**Note:** Sort keys are Shopify GraphQL enum values (e.g., `"CREATED_AT"`, `"UPDATED_AT"`, `"RELEVANCE"`). Available values depend on the specific Shopify API endpoint.
|
|
351
|
+
|
|
322
352
|
#### Query Optimization
|
|
323
353
|
|
|
324
354
|
```ruby
|
|
@@ -349,7 +379,7 @@ ProductVariant.where("sku:FRZ*").in_pages(of: 10) do |page|
|
|
|
349
379
|
end
|
|
350
380
|
|
|
351
381
|
# Lazy enumeration
|
|
352
|
-
scope = Customer.where(email: "
|
|
382
|
+
scope = Customer.where(email: "@example.com")
|
|
353
383
|
scope.each { |c| puts c.name } # Executes query
|
|
354
384
|
scope.first # Fetches just first
|
|
355
385
|
```
|
|
@@ -442,6 +472,55 @@ reward.customer # Loads Customer from shopify_customer_id
|
|
|
442
472
|
reward.variants # Queries ProductVariant.where({})
|
|
443
473
|
```
|
|
444
474
|
|
|
475
|
+
### Logging
|
|
476
|
+
|
|
477
|
+
Track GraphQL query performance and costs in your Rails logs:
|
|
478
|
+
|
|
479
|
+
```ruby
|
|
480
|
+
# config/initializers/active_shopify_graphql.rb
|
|
481
|
+
require "benchmark"
|
|
482
|
+
|
|
483
|
+
Rails.configuration.to_prepare do
|
|
484
|
+
ActiveShopifyGraphQL.configure do |config|
|
|
485
|
+
config.admin_api_executor = lambda do |query, **variables|
|
|
486
|
+
client = ShopifyAPI::Clients::Graphql::Admin.new(session: ShopifyAPI::Context.active_session)
|
|
487
|
+
response = nil
|
|
488
|
+
duration_ms = Benchmark.realtime { response = client.query(query:, variables:) } * 1000
|
|
489
|
+
cost = response.body.dig("extensions", "cost")
|
|
490
|
+
|
|
491
|
+
# Log query with timing and cost
|
|
492
|
+
ActiveShopifyGraphQL::Logging::GraphqlLogger.log(
|
|
493
|
+
query: query,
|
|
494
|
+
duration_ms: duration_ms,
|
|
495
|
+
cost: cost,
|
|
496
|
+
variables: variables
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
errors = response.body["errors"]
|
|
500
|
+
raise errors.inspect if errors.present?
|
|
501
|
+
response.body if response
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
#### Controller Runtime (Optional)
|
|
508
|
+
|
|
509
|
+
Add GraphQL timing to your Rails request logs (`Completed 200 OK ... GraphQL: 45.2ms, 38 cost`):
|
|
510
|
+
|
|
511
|
+
```ruby
|
|
512
|
+
# config/initializers/graphql_controller_runtime.rb
|
|
513
|
+
ActiveSupport.on_load(:action_controller) do
|
|
514
|
+
include ActiveShopifyGraphQL::Logging::GraphqlControllerRuntime
|
|
515
|
+
end
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
This will append GraphQL timing and cost summaries to your controller logs:
|
|
519
|
+
|
|
520
|
+
```
|
|
521
|
+
Completed 200 OK in 250ms (Views: 12.3ms | ActiveRecord: 5.2ms | GraphQL: 45.2ms, 38 cost)
|
|
522
|
+
```
|
|
523
|
+
|
|
445
524
|
---
|
|
446
525
|
|
|
447
526
|
## API Reference
|
|
@@ -528,15 +607,64 @@ Customer.with_loader(MyCustomLoader).find(123)
|
|
|
528
607
|
|
|
529
608
|
### Testing
|
|
530
609
|
|
|
531
|
-
|
|
610
|
+
The gem ships with a built-in testing module that lets you register test data using Ruby-level attributes. `find`, `where`, `find_by`, `includes`, and connections all work transparently without real API calls.
|
|
611
|
+
|
|
612
|
+
#### Setup
|
|
613
|
+
|
|
614
|
+
```ruby
|
|
615
|
+
# spec/spec_helper.rb (or test_helper.rb)
|
|
616
|
+
require "active_shopify_graphql/testing"
|
|
617
|
+
|
|
618
|
+
RSpec.configure do |config|
|
|
619
|
+
config.before(:each) { ActiveShopifyGraphQL::Testing.enable! }
|
|
620
|
+
config.after(:each) { ActiveShopifyGraphQL::Testing.reset! }
|
|
621
|
+
end
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
#### Registering Test Data
|
|
625
|
+
|
|
626
|
+
```ruby
|
|
627
|
+
# Register records using Ruby attribute names — IDs are auto-normalized to GIDs
|
|
628
|
+
ActiveShopifyGraphQL::Testing.register(Customer, [
|
|
629
|
+
{ id: 1, email: "john@example.com", display_name: "John" },
|
|
630
|
+
{ id: 2, email: "jane@example.com", display_name: "Jane" }
|
|
631
|
+
])
|
|
632
|
+
|
|
633
|
+
# With inline connections
|
|
634
|
+
ActiveShopifyGraphQL::Testing.register(Customer, [
|
|
635
|
+
{
|
|
636
|
+
id: 1,
|
|
637
|
+
email: "john@example.com",
|
|
638
|
+
orders: [{ id: "gid://shopify/Order/100", name: "#1001" }]
|
|
639
|
+
}
|
|
640
|
+
])
|
|
641
|
+
|
|
642
|
+
# With extra search fields (not model attributes, used only for where matching)
|
|
643
|
+
ActiveShopifyGraphQL::Testing.register(ProductVariant, [
|
|
644
|
+
{ id: 1, sku: "ABC", product_id: 10 }
|
|
645
|
+
])
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
#### Using in Tests
|
|
649
|
+
|
|
650
|
+
All query patterns work the same as in production:
|
|
532
651
|
|
|
533
652
|
```ruby
|
|
534
|
-
#
|
|
535
|
-
|
|
536
|
-
|
|
653
|
+
Customer.find(1) # => Customer instance
|
|
654
|
+
Customer.where(email: "john@example.com").to_a # => [Customer]
|
|
655
|
+
Customer.find_by(email: "john@example.com") # => Customer or nil
|
|
656
|
+
Customer.includes(:orders).find(1) # => Customer with orders eager-loaded
|
|
657
|
+
customer.orders # => lazy-loads from store
|
|
658
|
+
ProductVariant.where(product_id: 10).to_a # => matches on search field
|
|
659
|
+
```
|
|
537
660
|
|
|
538
|
-
|
|
539
|
-
|
|
661
|
+
#### Manual Mocking
|
|
662
|
+
|
|
663
|
+
For simpler cases, you can also mock data directly:
|
|
664
|
+
|
|
665
|
+
```ruby
|
|
666
|
+
customer = Customer.new(id: "gid://shopify/Customer/123")
|
|
667
|
+
customer.orders = [Order.new(id: "gid://shopify/Order/1")]
|
|
540
668
|
expect(customer.orders.size).to eq(1)
|
|
541
669
|
```
|
|
542
670
|
|
|
@@ -567,8 +695,8 @@ bundle exec rubocop
|
|
|
567
695
|
- [x] Query optimization with `select`
|
|
568
696
|
- [x] GraphQL connections with lazy/eager loading
|
|
569
697
|
- [x] Cursor-based pagination
|
|
698
|
+
- [x] Builtin instrumentation to track query costs
|
|
570
699
|
- [ ] Metaobjects as models
|
|
571
|
-
- [ ] Builtin instrumentation to track query costs
|
|
572
700
|
- [ ] Advanced error handling and retry mechanisms
|
|
573
701
|
- [ ] Caching layer
|
|
574
702
|
- [ ] Chained `.where` with `.not` support
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
module ActiveShopifyGraphQL
|
|
4
4
|
# Configuration class for setting up external dependencies
|
|
5
5
|
class Configuration
|
|
6
|
-
attr_accessor :
|
|
6
|
+
attr_accessor :admin_api_executor, :customer_account_api_executor, :logger, :log_queries, :max_objects_per_paginated_query
|
|
7
7
|
|
|
8
8
|
def initialize
|
|
9
|
-
@
|
|
10
|
-
@
|
|
9
|
+
@admin_api_executor = nil
|
|
10
|
+
@customer_account_api_executor = nil
|
|
11
11
|
@logger = nil
|
|
12
12
|
@log_queries = false
|
|
13
13
|
@max_objects_per_paginated_query = 250
|
|
@@ -44,7 +44,7 @@ module ActiveShopifyGraphQL
|
|
|
44
44
|
)
|
|
45
45
|
|
|
46
46
|
parent_id = extract_gid(parent)
|
|
47
|
-
response_data = @loader_instance.
|
|
47
|
+
response_data = @loader_instance.execute_query(query, id: parent_id)
|
|
48
48
|
|
|
49
49
|
return [] if response_data.nil?
|
|
50
50
|
|
|
@@ -76,7 +76,7 @@ module ActiveShopifyGraphQL
|
|
|
76
76
|
singular: singular
|
|
77
77
|
)
|
|
78
78
|
|
|
79
|
-
response_data = @loader_instance.
|
|
79
|
+
response_data = @loader_instance.execute_query(query)
|
|
80
80
|
|
|
81
81
|
return [] if response_data.nil?
|
|
82
82
|
|
|
@@ -58,7 +58,7 @@ module ActiveShopifyGraphQL
|
|
|
58
58
|
|
|
59
59
|
# Map the GraphQL response to model attributes
|
|
60
60
|
def map_response_to_attributes(response_data, parent_instance: nil)
|
|
61
|
-
mapper =
|
|
61
|
+
mapper = Response::ResponseMapper.new(context)
|
|
62
62
|
attributes = mapper.map_response(response_data)
|
|
63
63
|
cache_connections(mapper, response_data, target: attributes, parent_instance: parent_instance)
|
|
64
64
|
attributes
|
|
@@ -74,7 +74,7 @@ module ActiveShopifyGraphQL
|
|
|
74
74
|
# @return [Hash, nil] Attribute hash with connection cache, or nil if not found
|
|
75
75
|
def load_attributes(id)
|
|
76
76
|
query = Query::QueryBuilder.build_single_record_query(context)
|
|
77
|
-
response_data =
|
|
77
|
+
response_data = perform_graphql_query(query, id: id)
|
|
78
78
|
return nil if response_data.nil?
|
|
79
79
|
|
|
80
80
|
map_response_to_attributes(response_data)
|
|
@@ -86,15 +86,19 @@ module ActiveShopifyGraphQL
|
|
|
86
86
|
# @param per_page [Integer] Number of records per page
|
|
87
87
|
# @param after [String, nil] Cursor to fetch records after
|
|
88
88
|
# @param before [String, nil] Cursor to fetch records before
|
|
89
|
+
# @param sort_key [String, nil] The Shopify sort key (e.g., "CREATED_AT")
|
|
90
|
+
# @param reverse [Boolean, nil] Whether to reverse the sort order
|
|
89
91
|
# @param query_scope [Query::Scope] The query scope for navigation
|
|
90
92
|
# @return [PaginatedResult] A paginated result with attribute hashes and page info
|
|
91
|
-
def load_paginated_collection(conditions:, per_page:, query_scope:, after: nil, before: nil)
|
|
93
|
+
def load_paginated_collection(conditions:, per_page:, query_scope:, after: nil, before: nil, sort_key: nil, reverse: nil)
|
|
92
94
|
collection_query_name = context.query_name.pluralize
|
|
93
95
|
variables = build_collection_variables(
|
|
94
96
|
conditions,
|
|
95
97
|
per_page: per_page,
|
|
96
98
|
after: after,
|
|
97
|
-
before: before
|
|
99
|
+
before: before,
|
|
100
|
+
sort_key: sort_key,
|
|
101
|
+
reverse: reverse
|
|
98
102
|
)
|
|
99
103
|
|
|
100
104
|
query = Query::QueryBuilder.build_paginated_collection_query(
|
|
@@ -113,6 +117,11 @@ module ActiveShopifyGraphQL
|
|
|
113
117
|
connection_loader.load_records(query_name, variables, parent, connection_config)
|
|
114
118
|
end
|
|
115
119
|
|
|
120
|
+
def execute_query(query, **variables)
|
|
121
|
+
log_query(self.class.name, query, variables)
|
|
122
|
+
perform_graphql_query(query, **variables)
|
|
123
|
+
end
|
|
124
|
+
|
|
116
125
|
# Abstract method for executing GraphQL queries
|
|
117
126
|
def perform_graphql_query(query, **variables)
|
|
118
127
|
raise NotImplementedError, "#{self.class} must implement perform_graphql_query"
|
|
@@ -120,10 +129,6 @@ module ActiveShopifyGraphQL
|
|
|
120
129
|
|
|
121
130
|
private
|
|
122
131
|
|
|
123
|
-
def create_response_mapper
|
|
124
|
-
Response::ResponseMapper.new(context)
|
|
125
|
-
end
|
|
126
|
-
|
|
127
132
|
def should_log?
|
|
128
133
|
ActiveShopifyGraphQL.configuration.log_queries && ActiveShopifyGraphQL.configuration.logger
|
|
129
134
|
end
|
|
@@ -175,17 +180,13 @@ module ActiveShopifyGraphQL
|
|
|
175
180
|
raise ArgumentError, "Shopify query validation failed: #{messages.join(', ')}"
|
|
176
181
|
end
|
|
177
182
|
|
|
178
|
-
def execute_query(query, **variables)
|
|
179
|
-
perform_graphql_query(query, **variables)
|
|
180
|
-
end
|
|
181
|
-
|
|
182
183
|
def execute_query_and_validate_search_response(query, **variables)
|
|
183
184
|
response = execute_query(query, **variables)
|
|
184
185
|
validate_search_query_response(response)
|
|
185
186
|
response
|
|
186
187
|
end
|
|
187
188
|
|
|
188
|
-
def build_collection_variables(conditions, per_page:, after: nil, before: nil)
|
|
189
|
+
def build_collection_variables(conditions, per_page:, after: nil, before: nil, sort_key: nil, reverse: nil)
|
|
189
190
|
search_query = SearchQuery.new(conditions)
|
|
190
191
|
variables = { query: search_query.to_s }
|
|
191
192
|
|
|
@@ -197,6 +198,9 @@ module ActiveShopifyGraphQL
|
|
|
197
198
|
variables[:after] = after if after
|
|
198
199
|
end
|
|
199
200
|
|
|
201
|
+
variables[:sort_key] = sort_key if sort_key
|
|
202
|
+
variables[:reverse] = reverse unless reverse.nil?
|
|
203
|
+
|
|
200
204
|
variables.compact
|
|
201
205
|
end
|
|
202
206
|
|
|
@@ -4,12 +4,10 @@ module ActiveShopifyGraphQL
|
|
|
4
4
|
module Loaders
|
|
5
5
|
class AdminApiLoader < Loader
|
|
6
6
|
def perform_graphql_query(query, **variables)
|
|
7
|
-
|
|
7
|
+
executor = ActiveShopifyGraphQL.configuration.admin_api_executor
|
|
8
|
+
raise Error, "Admin API executor not configured. Please configure it using ActiveShopifyGraphQL.configure" unless executor
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
raise Error, "Admin API client not configured. Please configure it using ActiveShopifyGraphQL.configure" unless client
|
|
11
|
-
|
|
12
|
-
client.execute(query, **variables)
|
|
10
|
+
executor.call(query, **variables)
|
|
13
11
|
end
|
|
14
12
|
end
|
|
15
13
|
end
|
|
@@ -23,16 +23,11 @@ module ActiveShopifyGraphQL
|
|
|
23
23
|
map_response_to_attributes(response_data)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def client
|
|
27
|
-
client_class = ActiveShopifyGraphQL.configuration.customer_account_client_class
|
|
28
|
-
raise Error, "Customer Account API client class not configured" unless client_class
|
|
29
|
-
|
|
30
|
-
@client ||= client_class.from_config(@token)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
26
|
def perform_graphql_query(query, **variables)
|
|
34
|
-
|
|
35
|
-
|
|
27
|
+
executor = ActiveShopifyGraphQL.configuration.customer_account_api_executor
|
|
28
|
+
raise Error, "Customer Account API executor not configured. Please configure it using ActiveShopifyGraphQL.configure" unless executor
|
|
29
|
+
|
|
30
|
+
executor.call(query, @token, **variables)
|
|
36
31
|
end
|
|
37
32
|
|
|
38
33
|
def initialization_args
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
module Logging
|
|
5
|
+
# Adds GraphQL runtime and cost to the Rails request log
|
|
6
|
+
# Pattern borrowed from ActiveRecord::Railties::ControllerRuntime
|
|
7
|
+
#
|
|
8
|
+
# Include this module in your ApplicationController or via an initializer:
|
|
9
|
+
#
|
|
10
|
+
# ActiveSupport.on_load(:action_controller) do
|
|
11
|
+
# include ActiveShopifyGraphQL::Logging::GraphqlControllerRuntime
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
module GraphqlControllerRuntime
|
|
15
|
+
extend ActiveSupport::Concern
|
|
16
|
+
|
|
17
|
+
module ClassMethods
|
|
18
|
+
def log_process_action(payload)
|
|
19
|
+
messages = super
|
|
20
|
+
runtime = payload[:graphql_runtime]
|
|
21
|
+
cost = payload[:graphql_cost]
|
|
22
|
+
|
|
23
|
+
if runtime&.positive?
|
|
24
|
+
cost_info = cost&.positive? ? ", #{cost.round} cost" : ""
|
|
25
|
+
messages << "GraphQL: #{runtime.round(1)}ms#{cost_info}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
messages
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def append_info_to_payload(payload)
|
|
35
|
+
super
|
|
36
|
+
payload[:graphql_runtime] = GraphqlRuntime.reset_runtime
|
|
37
|
+
payload[:graphql_cost] = GraphqlRuntime.reset_cost
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
module Logging
|
|
5
|
+
# Logs GraphQL queries with timing and cost information
|
|
6
|
+
# Uses ActiveSupport::LogSubscriber for consistent Rails-style log formatting
|
|
7
|
+
class GraphqlLogger < ActiveSupport::LogSubscriber
|
|
8
|
+
YELLOW = 33
|
|
9
|
+
GREEN = 32
|
|
10
|
+
BLUE = 34
|
|
11
|
+
CYAN = 36
|
|
12
|
+
MAGENTA = 35
|
|
13
|
+
|
|
14
|
+
def self.log(query:, duration_ms:, cost:, variables:)
|
|
15
|
+
new.log(query:, duration_ms:, cost:, variables:)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def log(query:, duration_ms:, cost:, variables:)
|
|
19
|
+
GraphqlRuntime.add(duration_ms:, cost: cost&.dig("requestedQueryCost"))
|
|
20
|
+
|
|
21
|
+
name = color(" GraphQL (#{duration_ms.round(1)}ms)", YELLOW, bold: true)
|
|
22
|
+
colored_query = color(query.gsub(/\s+/, " ").strip, graphql_color(query), bold: true)
|
|
23
|
+
binds = variables.present? ? " #{variables.inspect}" : ""
|
|
24
|
+
|
|
25
|
+
cost_info = "\n ↳ cost: #{cost}" if cost
|
|
26
|
+
debug "#{name} #{colored_query}#{binds}#{cost_info}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def graphql_color(query)
|
|
32
|
+
case query
|
|
33
|
+
when /\A\s*mutation/i then GREEN
|
|
34
|
+
when /\A\s*query/i then BLUE
|
|
35
|
+
when /\A\s*subscription/i then CYAN
|
|
36
|
+
else MAGENTA
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
module Logging
|
|
5
|
+
# Thread-safe runtime and cost tracking for request summaries
|
|
6
|
+
# Pattern borrowed from ActiveRecord's RuntimeRegistry
|
|
7
|
+
module GraphqlRuntime
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def runtime=(value)
|
|
11
|
+
Thread.current[:active_shopify_graphql_runtime] = value
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def runtime
|
|
15
|
+
Thread.current[:active_shopify_graphql_runtime] ||= 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def reset_runtime
|
|
19
|
+
rt = runtime
|
|
20
|
+
self.runtime = 0
|
|
21
|
+
rt
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def cost=(value)
|
|
25
|
+
Thread.current[:active_shopify_graphql_cost] = value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def cost
|
|
29
|
+
Thread.current[:active_shopify_graphql_cost] ||= 0
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def reset_cost
|
|
33
|
+
c = cost
|
|
34
|
+
self.cost = 0
|
|
35
|
+
c
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def add(duration_ms:, cost:)
|
|
39
|
+
self.runtime += duration_ms
|
|
40
|
+
self.cost += cost if cost
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -102,7 +102,7 @@ module ActiveShopifyGraphQL::Model::Connections
|
|
|
102
102
|
)
|
|
103
103
|
else
|
|
104
104
|
# Create a new proxy for custom options (don't cache these)
|
|
105
|
-
Connections::ConnectionProxy.new(
|
|
105
|
+
ActiveShopifyGraphQL::Connections::ConnectionProxy.new(
|
|
106
106
|
parent: self,
|
|
107
107
|
connection_name: name,
|
|
108
108
|
connection_config: self.class.connections[name],
|
|
@@ -120,6 +120,18 @@ module ActiveShopifyGraphQL::Model::FinderMethods
|
|
|
120
120
|
all.includes(*connection_names)
|
|
121
121
|
end
|
|
122
122
|
|
|
123
|
+
# Set the sort order for the query
|
|
124
|
+
# @param sort_key [String] The Shopify sort key (e.g., "CREATED_AT", "UPDATED_AT")
|
|
125
|
+
# @param reverse [Boolean] Whether to reverse the sort order (optional)
|
|
126
|
+
# @return [Relation] A relation with order applied
|
|
127
|
+
#
|
|
128
|
+
# @example
|
|
129
|
+
# Customer.order(sort_key: "CREATED_AT").where(country: "Canada")
|
|
130
|
+
# Customer.where(status: "active").order(sort_key: "UPDATED_AT", reverse: true)
|
|
131
|
+
def order(sort_key:, reverse: nil)
|
|
132
|
+
all.order(sort_key: sort_key, reverse: reverse)
|
|
133
|
+
end
|
|
134
|
+
|
|
123
135
|
private
|
|
124
136
|
|
|
125
137
|
# Validates that selected attributes exist in the model
|
|
@@ -8,8 +8,7 @@ module ActiveShopifyGraphQL::Model::GraphqlTypeResolver
|
|
|
8
8
|
# Set or get the base GraphQL type for this model.
|
|
9
9
|
#
|
|
10
10
|
# @param type [String, nil] The GraphQL type name to set, or nil to get
|
|
11
|
-
# @return [String] The GraphQL type name
|
|
12
|
-
# @raise [NotImplementedError] If no type is defined
|
|
11
|
+
# @return [String] The GraphQL type name, inferred from the class name if not explicitly set
|
|
13
12
|
def graphql_type(type = nil)
|
|
14
13
|
if type
|
|
15
14
|
if @current_loader_context
|
|
@@ -20,7 +19,7 @@ module ActiveShopifyGraphQL::Model::GraphqlTypeResolver
|
|
|
20
19
|
end
|
|
21
20
|
end
|
|
22
21
|
|
|
23
|
-
@base_graphql_type ||
|
|
22
|
+
@base_graphql_type || name.demodulize
|
|
24
23
|
end
|
|
25
24
|
|
|
26
25
|
# Get the GraphQL type for a specific loader class.
|
|
@@ -23,7 +23,7 @@ module ActiveShopifyGraphQL
|
|
|
23
23
|
|
|
24
24
|
DEFAULT_PER_PAGE = 250
|
|
25
25
|
|
|
26
|
-
attr_reader :model_class, :included_connections, :conditions, :total_limit, :per_page
|
|
26
|
+
attr_reader :model_class, :included_connections, :conditions, :total_limit, :per_page, :sort_key, :reverse
|
|
27
27
|
|
|
28
28
|
def initialize(
|
|
29
29
|
model_class,
|
|
@@ -33,7 +33,9 @@ module ActiveShopifyGraphQL
|
|
|
33
33
|
total_limit: nil,
|
|
34
34
|
per_page: DEFAULT_PER_PAGE,
|
|
35
35
|
loader_class: nil,
|
|
36
|
-
loader_extra_args: []
|
|
36
|
+
loader_extra_args: [],
|
|
37
|
+
sort_key: nil,
|
|
38
|
+
reverse: nil
|
|
37
39
|
)
|
|
38
40
|
@model_class = model_class
|
|
39
41
|
@conditions = conditions
|
|
@@ -43,6 +45,8 @@ module ActiveShopifyGraphQL
|
|
|
43
45
|
@per_page = [per_page, ActiveShopifyGraphQL.configuration.max_objects_per_paginated_query].min
|
|
44
46
|
@loader_class = loader_class
|
|
45
47
|
@loader_extra_args = loader_extra_args
|
|
48
|
+
@sort_key = sort_key
|
|
49
|
+
@reverse = reverse
|
|
46
50
|
@loaded = false
|
|
47
51
|
@records = nil
|
|
48
52
|
end
|
|
@@ -66,6 +70,22 @@ module ActiveShopifyGraphQL
|
|
|
66
70
|
spawn(conditions: new_conditions)
|
|
67
71
|
end
|
|
68
72
|
|
|
73
|
+
# Set the sort order for the query
|
|
74
|
+
# @param sort_key [String] The Shopify sort key (e.g., "CREATED_AT", "UPDATED_AT")
|
|
75
|
+
# @param reverse [Boolean] Whether to reverse the sort order (optional)
|
|
76
|
+
# @return [Relation] A new relation with order applied
|
|
77
|
+
# @raise [ArgumentError] If order is called on a relation that already has ordering
|
|
78
|
+
# @example
|
|
79
|
+
# Customer.where(email: "*@example.com").order(sort_key: "CREATED_AT", reverse: true)
|
|
80
|
+
def order(sort_key:, reverse: nil)
|
|
81
|
+
if has_ordering?
|
|
82
|
+
raise ArgumentError, "Chaining multiple order clauses is not supported. " \
|
|
83
|
+
"Combine ordering in a single order call instead."
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
spawn(sort_key: sort_key, reverse: reverse)
|
|
87
|
+
end
|
|
88
|
+
|
|
69
89
|
# Find a single record by conditions
|
|
70
90
|
# @param conditions [Hash] The conditions to match
|
|
71
91
|
# @return [Object, nil] The first matching record or nil
|
|
@@ -92,7 +112,7 @@ module ActiveShopifyGraphQL
|
|
|
92
112
|
end
|
|
93
113
|
|
|
94
114
|
# Standard case: find by ID
|
|
95
|
-
gid = GidHelper.normalize_gid(id, @model_class.
|
|
115
|
+
gid = GidHelper.normalize_gid(id, @model_class.graphql_type)
|
|
96
116
|
attributes = loader.load_attributes(gid)
|
|
97
117
|
|
|
98
118
|
raise ObjectNotFoundError, "Couldn't find #{@model_class.name} with id=#{id}" if attributes.nil?
|
|
@@ -302,6 +322,8 @@ module ActiveShopifyGraphQL
|
|
|
302
322
|
per_page: effective_per_page,
|
|
303
323
|
after: after,
|
|
304
324
|
before: before,
|
|
325
|
+
sort_key: @sort_key,
|
|
326
|
+
reverse: @reverse,
|
|
305
327
|
query_scope: build_query_scope_for_pagination
|
|
306
328
|
)
|
|
307
329
|
end
|
|
@@ -324,7 +346,9 @@ module ActiveShopifyGraphQL
|
|
|
324
346
|
total_limit: changes.fetch(:total_limit, @total_limit),
|
|
325
347
|
per_page: changes.fetch(:per_page, @per_page),
|
|
326
348
|
loader_class: changes.fetch(:loader_class, @loader_class),
|
|
327
|
-
loader_extra_args: changes.fetch(:loader_extra_args, @loader_extra_args)
|
|
349
|
+
loader_extra_args: changes.fetch(:loader_extra_args, @loader_extra_args),
|
|
350
|
+
sort_key: changes.fetch(:sort_key, @sort_key),
|
|
351
|
+
reverse: changes.fetch(:reverse, @reverse)
|
|
328
352
|
)
|
|
329
353
|
end
|
|
330
354
|
|
|
@@ -341,6 +365,10 @@ module ActiveShopifyGraphQL
|
|
|
341
365
|
end
|
|
342
366
|
end
|
|
343
367
|
|
|
368
|
+
def has_ordering?
|
|
369
|
+
!@sort_key.nil?
|
|
370
|
+
end
|
|
371
|
+
|
|
344
372
|
def build_loader
|
|
345
373
|
klass = @loader_class || @model_class.send(:default_loader_class)
|
|
346
374
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
module Testing
|
|
5
|
+
# Prepended onto LoaderSwitchable::ClassMethods to redirect
|
|
6
|
+
# default_loader_class to TestLoader when testing is enabled.
|
|
7
|
+
module LoaderSwitchableHook
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def default_loader_class
|
|
11
|
+
return super unless ActiveShopifyGraphQL::Testing.enabled?
|
|
12
|
+
|
|
13
|
+
ActiveShopifyGraphQL::Testing::TestLoader
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Prepended onto FinderMethods::ClassMethods to return fresh
|
|
18
|
+
# TestLoader instances (preventing memoization leaks between tests).
|
|
19
|
+
module FinderMethodsHook
|
|
20
|
+
def default_loader
|
|
21
|
+
return super unless ActiveShopifyGraphQL::Testing.enabled?
|
|
22
|
+
|
|
23
|
+
# Return a fresh instance each time to prevent state leakage between tests.
|
|
24
|
+
eagerly_loaded_connections = connections.select { |_name, config| config[:eager_load] }.keys
|
|
25
|
+
ActiveShopifyGraphQL::Testing::TestLoader.new(
|
|
26
|
+
self,
|
|
27
|
+
included_connections: eagerly_loaded_connections
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Prepended onto Attributes::ClassMethods so that attributes_for_loader
|
|
33
|
+
# returns attributes from ALL loader contexts when TestLoader is the
|
|
34
|
+
# requested loader. Without this, attributes defined only inside
|
|
35
|
+
# for_loader blocks (e.g. email on AdminApiLoader) would be invisible
|
|
36
|
+
# to the testing harness.
|
|
37
|
+
module AttributesHook
|
|
38
|
+
def attributes_for_loader(loader_class)
|
|
39
|
+
if ActiveShopifyGraphQL::Testing.enabled? && loader_class == ActiveShopifyGraphQL::Testing::TestLoader
|
|
40
|
+
base = instance_variable_get(:@base_attributes) || {}
|
|
41
|
+
loader_contexts = instance_variable_get(:@loader_contexts) || {}
|
|
42
|
+
|
|
43
|
+
merged = base.dup
|
|
44
|
+
loader_contexts.each_value { |ctx_attrs| merged.merge!(ctx_attrs) { |_key, base_val, _override_val| base_val } }
|
|
45
|
+
return merged
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
super
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Prepended onto GraphqlTypeResolver::ClassMethods so that graphql_type
|
|
53
|
+
# (with no argument) falls back to graphql_type_for_loader(TestLoader)
|
|
54
|
+
# when testing is enabled. Models that only define graphql_type inside
|
|
55
|
+
# for_loader blocks have no @base_graphql_type and would otherwise raise.
|
|
56
|
+
module GraphqlTypeResolverHook
|
|
57
|
+
def graphql_type(type = nil)
|
|
58
|
+
super
|
|
59
|
+
rescue NotImplementedError
|
|
60
|
+
raise unless ActiveShopifyGraphQL::Testing.enabled?
|
|
61
|
+
|
|
62
|
+
graphql_type_for_loader(ActiveShopifyGraphQL::Testing::TestLoader)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
module Testing
|
|
5
|
+
# In-memory data store for test records.
|
|
6
|
+
# Stores records keyed by model class, with IDs normalized to GID format.
|
|
7
|
+
# Supports lookup by ID, filtering by hash/string conditions, and
|
|
8
|
+
# extracting inline connection data from parent records.
|
|
9
|
+
class Store
|
|
10
|
+
def initialize
|
|
11
|
+
@data = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Register test records for a model class.
|
|
15
|
+
# IDs are normalized to GID format using the model's graphql_type.
|
|
16
|
+
#
|
|
17
|
+
# @param model_class [Class] The model class (e.g., Customer)
|
|
18
|
+
# @param records [Array<Hash>] Array of attribute hashes
|
|
19
|
+
def register(model_class, records)
|
|
20
|
+
graphql_type = model_class.graphql_type_for_loader(model_class.send(:default_loader_class))
|
|
21
|
+
|
|
22
|
+
@data[model_class] = records.map do |record|
|
|
23
|
+
record = record.dup
|
|
24
|
+
record[:id] = GidHelper.normalize_gid(record[:id], graphql_type) if record.key?(:id)
|
|
25
|
+
record
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Find a single record by normalized GID.
|
|
30
|
+
#
|
|
31
|
+
# @param model_class [Class] The model class
|
|
32
|
+
# @param id [String] The GID to look up
|
|
33
|
+
# @return [Hash, nil] The matching record or nil
|
|
34
|
+
def find(model_class, id)
|
|
35
|
+
records = @data[model_class] || []
|
|
36
|
+
records.find { |r| r[:id] == id }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Filter records by conditions.
|
|
40
|
+
# Hash conditions: match against ALL keys in stored hash (attributes + search fields).
|
|
41
|
+
# String conditions: parse simple key:value patterns, fallback to return all.
|
|
42
|
+
# Comparison uses .to_s on both sides so plain IDs match naturally.
|
|
43
|
+
#
|
|
44
|
+
# @param model_class [Class] The model class
|
|
45
|
+
# @param conditions [Hash, String] The filter conditions
|
|
46
|
+
# @return [Array<Hash>] Matching records
|
|
47
|
+
def where(model_class, conditions)
|
|
48
|
+
records = @data[model_class] || []
|
|
49
|
+
|
|
50
|
+
case conditions
|
|
51
|
+
when Hash
|
|
52
|
+
filter_by_hash(records, normalize_conditions(model_class, conditions))
|
|
53
|
+
when String
|
|
54
|
+
filter_by_string(records, conditions)
|
|
55
|
+
else
|
|
56
|
+
records
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Extract inline connection data from a parent's stored record.
|
|
61
|
+
#
|
|
62
|
+
# @param model_class [Class] The parent model class
|
|
63
|
+
# @param id [String] The parent's GID
|
|
64
|
+
# @param connection_name [Symbol] The connection key name
|
|
65
|
+
# @return [Array<Hash>, Hash, nil] The connection data
|
|
66
|
+
def connections_for(model_class, id, connection_name)
|
|
67
|
+
record = find(model_class, id)
|
|
68
|
+
return nil unless record
|
|
69
|
+
|
|
70
|
+
record[connection_name]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Clear all stored data.
|
|
74
|
+
def clear
|
|
75
|
+
@data.clear
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def filter_by_hash(records, conditions)
|
|
81
|
+
records.select do |record|
|
|
82
|
+
conditions.all? do |key, value|
|
|
83
|
+
next false unless record.key?(key)
|
|
84
|
+
|
|
85
|
+
case value
|
|
86
|
+
when Array
|
|
87
|
+
value.any? { |v| record[key].to_s == v.to_s }
|
|
88
|
+
else
|
|
89
|
+
record[key].to_s == value.to_s
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Normalize :id values in conditions to GIDs so they match stored records.
|
|
96
|
+
def normalize_conditions(model_class, conditions)
|
|
97
|
+
return conditions unless conditions.key?(:id)
|
|
98
|
+
|
|
99
|
+
graphql_type = model_class.graphql_type_for_loader(model_class.send(:default_loader_class))
|
|
100
|
+
normalized = conditions.dup
|
|
101
|
+
|
|
102
|
+
normalized[:id] =
|
|
103
|
+
case conditions[:id]
|
|
104
|
+
when Array
|
|
105
|
+
conditions[:id].map { |v| GidHelper.normalize_gid(v, graphql_type) }
|
|
106
|
+
else
|
|
107
|
+
GidHelper.normalize_gid(conditions[:id], graphql_type)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
normalized
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def filter_by_string(records, query)
|
|
114
|
+
return records if query.nil? || query.strip.empty?
|
|
115
|
+
|
|
116
|
+
# Parse simple key:'value' or key:value patterns
|
|
117
|
+
# Bail on wildcards or complex boolean queries
|
|
118
|
+
return records if query.include?("*") || query.include?(" AND ") || query.include?(" OR ")
|
|
119
|
+
|
|
120
|
+
pairs = parse_search_query(query)
|
|
121
|
+
return records if pairs.empty?
|
|
122
|
+
|
|
123
|
+
filter_by_hash(records, pairs)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Parse a simple Shopify search query string into key-value pairs.
|
|
127
|
+
# Handles: key:'value', key:"value", key:value
|
|
128
|
+
def parse_search_query(query)
|
|
129
|
+
pairs = {}
|
|
130
|
+
# Match key:'value', key:"value", or key:unquoted_value
|
|
131
|
+
query.scan(/(\w+):(?:'([^']*)'|"([^"]*)"|(\S+))/) do |key, sq, dq, uq|
|
|
132
|
+
pairs[key.to_sym] = sq || dq || uq
|
|
133
|
+
end
|
|
134
|
+
pairs
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
module Testing
|
|
5
|
+
# Loader subclass that reads from the in-memory Store instead of
|
|
6
|
+
# executing GraphQL queries. Overrides the three main loading methods
|
|
7
|
+
# and raises on any attempt to perform a real GraphQL query.
|
|
8
|
+
class TestLoader < Loader
|
|
9
|
+
# Load attributes for a single record by ID.
|
|
10
|
+
#
|
|
11
|
+
# @param id [String] The GID of the record
|
|
12
|
+
# @return [Hash, nil] Filtered attribute hash with optional connection cache
|
|
13
|
+
def load_attributes(id)
|
|
14
|
+
record = Testing.store.find(@model_class, id)
|
|
15
|
+
return nil unless record
|
|
16
|
+
|
|
17
|
+
attrs = filter_to_model_attributes(record)
|
|
18
|
+
|
|
19
|
+
populate_connection_cache(record, attrs) if @included_connections.any?
|
|
20
|
+
|
|
21
|
+
attrs
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Load a paginated collection matching the given conditions.
|
|
25
|
+
#
|
|
26
|
+
# @return [Response::PaginatedResult]
|
|
27
|
+
def load_paginated_collection(conditions:, per_page:, query_scope:, after: nil, before: nil, sort_key: nil, reverse: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
28
|
+
all_records = Testing.store.where(@model_class, conditions)
|
|
29
|
+
|
|
30
|
+
# Simple cursor-based pagination using array indices
|
|
31
|
+
start_index = after ? after.to_i + 1 : 0
|
|
32
|
+
start_index = [all_records.size - per_page, 0].max if before
|
|
33
|
+
|
|
34
|
+
page_records = all_records[start_index, per_page] || []
|
|
35
|
+
end_index = start_index + page_records.size - 1
|
|
36
|
+
|
|
37
|
+
page_info = Response::PageInfo.new(
|
|
38
|
+
"hasNextPage" => end_index < all_records.size - 1,
|
|
39
|
+
"hasPreviousPage" => start_index.positive?,
|
|
40
|
+
"startCursor" => start_index.to_s,
|
|
41
|
+
"endCursor" => end_index.to_s
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
attributes_array = page_records.map do |record|
|
|
45
|
+
attrs = filter_to_model_attributes(record)
|
|
46
|
+
populate_connection_cache(record, attrs) if @included_connections.any?
|
|
47
|
+
attrs
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Response::PaginatedResult.new(
|
|
51
|
+
attributes: attributes_array,
|
|
52
|
+
model_class: @model_class,
|
|
53
|
+
page_info: page_info,
|
|
54
|
+
query_scope: query_scope
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Load records for a connection (lazy-loaded or explicit).
|
|
59
|
+
#
|
|
60
|
+
# @return [Object, Array<Object>, nil] Built model instance(s)
|
|
61
|
+
def load_connection_records(_query_name, _variables, parent = nil, connection_config = nil)
|
|
62
|
+
return [] unless parent && connection_config
|
|
63
|
+
|
|
64
|
+
connection_name = connection_config[:original_name]
|
|
65
|
+
connection_data = Testing.store.connections_for(parent.class, parent.id, connection_name)
|
|
66
|
+
|
|
67
|
+
return (connection_config[:type] == :singular ? nil : []) unless connection_data
|
|
68
|
+
|
|
69
|
+
target_class = connection_config[:class_name].constantize
|
|
70
|
+
singular = connection_config[:type] == :singular
|
|
71
|
+
|
|
72
|
+
if singular
|
|
73
|
+
data = connection_data.is_a?(Array) ? connection_data.first : connection_data
|
|
74
|
+
return nil unless data
|
|
75
|
+
|
|
76
|
+
attrs = filter_to_model_attributes(data, target_class)
|
|
77
|
+
wire_inverse_of(parent, attrs, connection_config)
|
|
78
|
+
ModelBuilder.build(target_class, attrs)
|
|
79
|
+
else
|
|
80
|
+
items = connection_data.is_a?(Array) ? connection_data : [connection_data]
|
|
81
|
+
items.filter_map do |item|
|
|
82
|
+
attrs = filter_to_model_attributes(item, target_class)
|
|
83
|
+
wire_inverse_of(parent, attrs, connection_config)
|
|
84
|
+
ModelBuilder.build(target_class, attrs)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Should never be called — all data comes from the Store.
|
|
90
|
+
def perform_graphql_query(_query, **_variables)
|
|
91
|
+
raise "TestLoader should not execute GraphQL queries. " \
|
|
92
|
+
"Ensure all test data is registered via ActiveShopifyGraphQL::Testing.register"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Filter a stored record hash down to only the declared model attributes,
|
|
98
|
+
# then apply transforms and type coercion to match what the ResponseMapper
|
|
99
|
+
# would produce from a real GraphQL response.
|
|
100
|
+
def filter_to_model_attributes(record, model_class = @model_class)
|
|
101
|
+
defined_attributes = model_class.attributes_for_loader(self.class)
|
|
102
|
+
# Always include :id
|
|
103
|
+
allowed_keys = (defined_attributes.keys + [:id]).uniq
|
|
104
|
+
|
|
105
|
+
record.each_with_object({}) do |(key, value), attrs|
|
|
106
|
+
next unless allowed_keys.include?(key)
|
|
107
|
+
|
|
108
|
+
config = defined_attributes[key]
|
|
109
|
+
if config
|
|
110
|
+
value = apply_defaults_and_transforms(value, config)
|
|
111
|
+
value = coerce_value(value, config[:type])
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
attrs[key] = value
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def apply_defaults_and_transforms(value, config)
|
|
119
|
+
if value.nil?
|
|
120
|
+
return config[:default] unless config[:default].nil?
|
|
121
|
+
|
|
122
|
+
return config[:transform]&.call(value)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
config[:transform] ? config[:transform].call(value) : value
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def coerce_value(value, type)
|
|
129
|
+
return nil if value.nil?
|
|
130
|
+
return value if value.is_a?(Array)
|
|
131
|
+
|
|
132
|
+
case type
|
|
133
|
+
when :string then ActiveModel::Type::String.new
|
|
134
|
+
when :integer then ActiveModel::Type::Integer.new
|
|
135
|
+
when :float then ActiveModel::Type::Float.new
|
|
136
|
+
when :boolean then ActiveModel::Type::Boolean.new
|
|
137
|
+
when :datetime then ActiveModel::Type::DateTime.new
|
|
138
|
+
else ActiveModel::Type::Value.new
|
|
139
|
+
end.cast(value)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Build connection cache from inline connection data in the stored record.
|
|
143
|
+
def populate_connection_cache(record, attrs)
|
|
144
|
+
normalized = Query::QueryBuilder.normalize_includes(@included_connections)
|
|
145
|
+
|
|
146
|
+
cache = {}
|
|
147
|
+
normalized.each do |connection_name, nested_includes|
|
|
148
|
+
connection_config = @model_class.connections[connection_name]
|
|
149
|
+
next unless connection_config
|
|
150
|
+
|
|
151
|
+
connection_data = record[connection_name]
|
|
152
|
+
next unless connection_data
|
|
153
|
+
|
|
154
|
+
target_class = connection_config[:class_name].constantize
|
|
155
|
+
singular = connection_config[:type] == :singular
|
|
156
|
+
|
|
157
|
+
if singular
|
|
158
|
+
data = connection_data.is_a?(Array) ? connection_data.first : connection_data
|
|
159
|
+
if data
|
|
160
|
+
child_attrs = filter_to_model_attributes(data, target_class)
|
|
161
|
+
populate_nested_connections(data, child_attrs, target_class, nested_includes)
|
|
162
|
+
cache[connection_name] = ModelBuilder.build(target_class, child_attrs)
|
|
163
|
+
end
|
|
164
|
+
else
|
|
165
|
+
items = connection_data.is_a?(Array) ? connection_data : [connection_data]
|
|
166
|
+
cache[connection_name] = items.filter_map do |item|
|
|
167
|
+
child_attrs = filter_to_model_attributes(item, target_class)
|
|
168
|
+
populate_nested_connections(item, child_attrs, target_class, nested_includes)
|
|
169
|
+
ModelBuilder.build(target_class, child_attrs)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
attrs[:_connection_cache] = cache unless cache.empty?
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Recursively populate nested connection caches for deeply included connections.
|
|
178
|
+
def populate_nested_connections(record, attrs, model_class, nested_includes)
|
|
179
|
+
return if nested_includes.nil? || nested_includes.empty?
|
|
180
|
+
|
|
181
|
+
normalized = Query::QueryBuilder.normalize_includes(nested_includes)
|
|
182
|
+
cache = {}
|
|
183
|
+
|
|
184
|
+
normalized.each do |connection_name, deeper_includes|
|
|
185
|
+
connection_config = model_class.connections[connection_name]
|
|
186
|
+
next unless connection_config
|
|
187
|
+
|
|
188
|
+
connection_data = record[connection_name]
|
|
189
|
+
next unless connection_data
|
|
190
|
+
|
|
191
|
+
target_class = connection_config[:class_name].constantize
|
|
192
|
+
singular = connection_config[:type] == :singular
|
|
193
|
+
|
|
194
|
+
if singular
|
|
195
|
+
data = connection_data.is_a?(Array) ? connection_data.first : connection_data
|
|
196
|
+
if data
|
|
197
|
+
child_attrs = filter_to_model_attributes(data, target_class)
|
|
198
|
+
populate_nested_connections(data, child_attrs, target_class, deeper_includes)
|
|
199
|
+
cache[connection_name] = ModelBuilder.build(target_class, child_attrs)
|
|
200
|
+
end
|
|
201
|
+
else
|
|
202
|
+
items = connection_data.is_a?(Array) ? connection_data : [connection_data]
|
|
203
|
+
cache[connection_name] = items.filter_map do |item|
|
|
204
|
+
child_attrs = filter_to_model_attributes(item, target_class)
|
|
205
|
+
populate_nested_connections(item, child_attrs, target_class, deeper_includes)
|
|
206
|
+
ModelBuilder.build(target_class, child_attrs)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
attrs[:_connection_cache] = cache unless cache.empty?
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Wire inverse_of associations into the connection cache.
|
|
215
|
+
def wire_inverse_of(parent, attributes, connection_config)
|
|
216
|
+
return unless attributes.is_a?(Hash) && connection_config&.dig(:inverse_of)
|
|
217
|
+
|
|
218
|
+
inverse_name = connection_config[:inverse_of]
|
|
219
|
+
target_class = connection_config[:class_name].constantize
|
|
220
|
+
return unless target_class.respond_to?(:connections) && target_class.connections[inverse_name]
|
|
221
|
+
|
|
222
|
+
attributes[:_connection_cache] ||= {}
|
|
223
|
+
inverse_type = target_class.connections[inverse_name][:type]
|
|
224
|
+
attributes[:_connection_cache][inverse_name] =
|
|
225
|
+
if inverse_type == :singular
|
|
226
|
+
parent
|
|
227
|
+
else
|
|
228
|
+
[parent]
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "testing/store"
|
|
4
|
+
require_relative "testing/test_loader"
|
|
5
|
+
require_relative "testing/hook"
|
|
6
|
+
|
|
7
|
+
module ActiveShopifyGraphQL
|
|
8
|
+
# Testing support module that provides an in-memory store for test data,
|
|
9
|
+
# allowing developers to register records using Ruby-level attributes and
|
|
10
|
+
# have find, where, includes, and connections work transparently without
|
|
11
|
+
# real API calls.
|
|
12
|
+
#
|
|
13
|
+
# @example In spec_helper.rb
|
|
14
|
+
# require 'active_shopify_graphql/testing'
|
|
15
|
+
#
|
|
16
|
+
# RSpec.configure do |config|
|
|
17
|
+
# config.before(:each) { ActiveShopifyGraphQL::Testing.enable! }
|
|
18
|
+
# config.after(:each) { ActiveShopifyGraphQL::Testing.reset! }
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# @example In tests
|
|
22
|
+
# ActiveShopifyGraphQL::Testing.register(Customer, [
|
|
23
|
+
# { id: 1, email: "john@example.com", display_name: "John" }
|
|
24
|
+
# ])
|
|
25
|
+
# Customer.find(1) # => Customer instance from the store
|
|
26
|
+
#
|
|
27
|
+
module Testing
|
|
28
|
+
class << self
|
|
29
|
+
def enable!
|
|
30
|
+
@enabled = true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def reset!
|
|
34
|
+
@enabled = false
|
|
35
|
+
store.clear
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def enabled?
|
|
39
|
+
@enabled == true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def store
|
|
43
|
+
@store ||= Store.new
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Convenience delegate to store.register
|
|
47
|
+
def register(model_class, records)
|
|
48
|
+
store.register(model_class, records)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Apply hooks once at load time
|
|
55
|
+
ActiveShopifyGraphQL::Model::LoaderSwitchable::ClassMethods.prepend(
|
|
56
|
+
ActiveShopifyGraphQL::Testing::LoaderSwitchableHook
|
|
57
|
+
)
|
|
58
|
+
ActiveShopifyGraphQL::Model::FinderMethods::ClassMethods.prepend(
|
|
59
|
+
ActiveShopifyGraphQL::Testing::FinderMethodsHook
|
|
60
|
+
)
|
|
61
|
+
ActiveShopifyGraphQL::Model::Attributes::ClassMethods.prepend(
|
|
62
|
+
ActiveShopifyGraphQL::Testing::AttributesHook
|
|
63
|
+
)
|
|
64
|
+
ActiveShopifyGraphQL::Model::GraphqlTypeResolver::ClassMethods.prepend(
|
|
65
|
+
ActiveShopifyGraphQL::Testing::GraphqlTypeResolverHook
|
|
66
|
+
)
|
|
@@ -14,6 +14,8 @@ loader.inflector.inflect(
|
|
|
14
14
|
"active_shopify_graphql" => "ActiveShopifyGraphQL",
|
|
15
15
|
"graphql_associations" => "GraphQLAssociations"
|
|
16
16
|
)
|
|
17
|
+
loader.ignore("#{__dir__}/active_shopify_graphql/testing")
|
|
18
|
+
loader.ignore("#{__dir__}/active_shopify_graphql/testing.rb")
|
|
17
19
|
loader.setup
|
|
18
20
|
|
|
19
21
|
module ActiveShopifyGraphQL
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_shopify_graphql
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nicolò Rebughini
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-03-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activemodel
|
|
@@ -79,6 +79,7 @@ files:
|
|
|
79
79
|
- ".github/workflows/test.yml"
|
|
80
80
|
- ".rubocop.yml"
|
|
81
81
|
- AGENTS.md
|
|
82
|
+
- CLAUDE.md
|
|
82
83
|
- LICENSE.txt
|
|
83
84
|
- README.md
|
|
84
85
|
- Rakefile
|
|
@@ -93,6 +94,9 @@ files:
|
|
|
93
94
|
- lib/active_shopify_graphql/loader_proxy.rb
|
|
94
95
|
- lib/active_shopify_graphql/loaders/admin_api_loader.rb
|
|
95
96
|
- lib/active_shopify_graphql/loaders/customer_account_api_loader.rb
|
|
97
|
+
- lib/active_shopify_graphql/logging/graphql_controller_runtime.rb
|
|
98
|
+
- lib/active_shopify_graphql/logging/graphql_logger.rb
|
|
99
|
+
- lib/active_shopify_graphql/logging/graphql_runtime.rb
|
|
96
100
|
- lib/active_shopify_graphql/model.rb
|
|
97
101
|
- lib/active_shopify_graphql/model/associations.rb
|
|
98
102
|
- lib/active_shopify_graphql/model/attributes.rb
|
|
@@ -123,6 +127,10 @@ files:
|
|
|
123
127
|
- lib/active_shopify_graphql/search_query/hash_condition_formatter.rb
|
|
124
128
|
- lib/active_shopify_graphql/search_query/parameter_binder.rb
|
|
125
129
|
- lib/active_shopify_graphql/search_query/value_sanitizer.rb
|
|
130
|
+
- lib/active_shopify_graphql/testing.rb
|
|
131
|
+
- lib/active_shopify_graphql/testing/hook.rb
|
|
132
|
+
- lib/active_shopify_graphql/testing/store.rb
|
|
133
|
+
- lib/active_shopify_graphql/testing/test_loader.rb
|
|
126
134
|
- lib/active_shopify_graphql/version.rb
|
|
127
135
|
homepage: https://github.com/nebulab/active_shopify_graphql
|
|
128
136
|
licenses:
|
|
@@ -146,7 +154,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
146
154
|
- !ruby/object:Gem::Version
|
|
147
155
|
version: '0'
|
|
148
156
|
requirements: []
|
|
149
|
-
rubygems_version: 3.5.
|
|
157
|
+
rubygems_version: 3.5.22
|
|
150
158
|
signing_key:
|
|
151
159
|
specification_version: 4
|
|
152
160
|
summary: An ActiveRecord-like interface for Shopify GraphQL APIs
|