active_shopify_graphql 1.0.0 → 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 +108 -10
- 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/graphql_type_resolver.rb +2 -3
- data/lib/active_shopify_graphql/query/relation.rb +1 -1
- 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
|
@@ -183,8 +183,8 @@ Attributes auto-generate GraphQL fragments and handle response mapping:
|
|
|
183
183
|
class Customer < ActiveShopifyGraphQL::Model
|
|
184
184
|
graphql_type "Customer"
|
|
185
185
|
|
|
186
|
-
#
|
|
187
|
-
attribute :name, type: :string
|
|
186
|
+
# Renaming an attribute
|
|
187
|
+
attribute :name, path: "displayName", type: :string
|
|
188
188
|
|
|
189
189
|
# Custom path with dot notation
|
|
190
190
|
attribute :email, path: "defaultEmailAddress.emailAddress", type: :string
|
|
@@ -201,7 +201,7 @@ Connections to related Shopify data with lazy/eager loading:
|
|
|
201
201
|
```ruby
|
|
202
202
|
class Customer < ActiveShopifyGraphQL::Model
|
|
203
203
|
# Lazy by default — loaded on first access
|
|
204
|
-
has_many_connected :orders
|
|
204
|
+
has_many_connected :orders, default_arguments: { first: 10 }
|
|
205
205
|
|
|
206
206
|
# Always eager load — no N+1 queries
|
|
207
207
|
has_many_connected :addresses, eager_load: true, default_arguments: { first: 5 }
|
|
@@ -472,6 +472,55 @@ reward.customer # Loads Customer from shopify_customer_id
|
|
|
472
472
|
reward.variants # Queries ProductVariant.where({})
|
|
473
473
|
```
|
|
474
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
|
+
|
|
475
524
|
---
|
|
476
525
|
|
|
477
526
|
## API Reference
|
|
@@ -558,15 +607,64 @@ Customer.with_loader(MyCustomLoader).find(123)
|
|
|
558
607
|
|
|
559
608
|
### Testing
|
|
560
609
|
|
|
561
|
-
|
|
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
|
|
562
613
|
|
|
563
614
|
```ruby
|
|
564
|
-
#
|
|
565
|
-
|
|
566
|
-
customer.orders = [Order.new(id: 'gid://shopify/Order/1')]
|
|
615
|
+
# spec/spec_helper.rb (or test_helper.rb)
|
|
616
|
+
require "active_shopify_graphql/testing"
|
|
567
617
|
|
|
568
|
-
|
|
569
|
-
|
|
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:
|
|
651
|
+
|
|
652
|
+
```ruby
|
|
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
|
+
```
|
|
660
|
+
|
|
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")]
|
|
570
668
|
expect(customer.orders.size).to eq(1)
|
|
571
669
|
```
|
|
572
670
|
|
|
@@ -597,8 +695,8 @@ bundle exec rubocop
|
|
|
597
695
|
- [x] Query optimization with `select`
|
|
598
696
|
- [x] GraphQL connections with lazy/eager loading
|
|
599
697
|
- [x] Cursor-based pagination
|
|
698
|
+
- [x] Builtin instrumentation to track query costs
|
|
600
699
|
- [ ] Metaobjects as models
|
|
601
|
-
- [ ] Builtin instrumentation to track query costs
|
|
602
700
|
- [ ] Advanced error handling and retry mechanisms
|
|
603
701
|
- [ ] Caching layer
|
|
604
702
|
- [ ] Chained `.where` with `.not` support
|
|
@@ -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
|
|
@@ -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.
|
|
@@ -112,7 +112,7 @@ module ActiveShopifyGraphQL
|
|
|
112
112
|
end
|
|
113
113
|
|
|
114
114
|
# Standard case: find by ID
|
|
115
|
-
gid = GidHelper.normalize_gid(id, @model_class.
|
|
115
|
+
gid = GidHelper.normalize_gid(id, @model_class.graphql_type)
|
|
116
116
|
attributes = loader.load_attributes(gid)
|
|
117
117
|
|
|
118
118
|
raise ObjectNotFoundError, "Couldn't find #{@model_class.name} with id=#{id}" if attributes.nil?
|
|
@@ -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: 1.
|
|
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
|