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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6fe91cae8b27f0c6154d8d43a42477e900dba59925b27421f5ea43278c8a786
4
- data.tar.gz: 6480859442d7a8ff30dc17fa3c0a96c5ae383f0ce0465fbbb8a67ff26c46f6da
3
+ metadata.gz: 2f853d9c152e7118a0916f0daf12f6b855779cf7006e15849738f4ddad75bbc3
4
+ data.tar.gz: '0195855f859d751a6ca53b2bbe08c804892a96dfb640ceb7a73267fb7e7c4748'
5
5
  SHA512:
6
- metadata.gz: e8572b7217ec7bb43172611d1d4059114b70de34342bb7c702265a90f3295004fd7de85b5449077054633ea199dd3f6606638f042cff3798dfc76d76dde24f4e
7
- data.tar.gz: 975eca8ec8d7ab7e3f166884baa6e885d76596d130f4525281b85075b3acef960f5562a45ed8a7ae73ac14e1a2e179cc129dbcff6a5b0d5588ba21ccb6838893
6
+ metadata.gz: 4f7871a5b0aee7d7b7cbe489d0ebec973b7f123adf5f28f821c781fc806e3daae2c5a6048554bdfc29756d4e2d5d97e394ad806c1cbd85c69b9f16aff6fff33f
7
+ data.tar.gz: 0e830f19306b3b0270f326514b775186ba769d76bc6a91a6f8a3e539552bd223a97c3071e42eb29a37e3f146fbe33bad8ee12c035dcf37c4eb45e75677719713
@@ -17,12 +17,11 @@ permissions:
17
17
  contents: read
18
18
 
19
19
  jobs:
20
- test:
21
-
20
+ lint:
22
21
  runs-on: ubuntu-latest
23
22
  strategy:
24
23
  matrix:
25
- ruby-version: ['3.4']
24
+ ruby-version: ['4.0']
26
25
 
27
26
  steps:
28
27
  - uses: actions/checkout@v4
@@ -22,7 +22,7 @@ jobs:
22
22
  runs-on: ubuntu-latest
23
23
  strategy:
24
24
  matrix:
25
- ruby-version: ['3.2', '3.3', '3.4']
25
+ ruby-version: ['3.2', '3.3', '3.4', '4.0']
26
26
 
27
27
  steps:
28
28
  - uses: actions/checkout@v4
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.admin_api_client = ShopifyGraphQL::Client
29
- config.customer_account_client_class = Shopify::Account::Client
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.admin_api_client = ShopifyGraphQL::Client
36
- config.customer_account_client_class = Shopify::Account::Client
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.to_a # Lazily loaded as a single query
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 (must respond to #execute(query, **variables))
142
- config.admin_api_client = ShopifyGraphQL::Client
143
-
144
- # Customer Account API (must have .from_config(token) and #execute)
145
- config.customer_account_client_class = Shopify::Account::Client
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
- # Auto-inferred path: displayName
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 (string query)
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: "*@example.com")
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
- Mock data for tests:
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
- # Mock associations
535
- customer = Customer.new(id: 'gid://shopify/Customer/123')
536
- customer.orders = [Order.new(id: 'gid://shopify/Order/1')]
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
- # Mock connections
539
- customer.orders = mock_orders
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 :admin_api_client, :customer_account_client_class, :logger, :log_queries, :max_objects_per_paginated_query
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
- @admin_api_client = nil
10
- @customer_account_client_class = nil
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.perform_graphql_query(query, id: parent_id)
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.perform_graphql_query(query)
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 = create_response_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 = execute_query(query, id: id)
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
- log_query("Admin API", query, variables)
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
- client = ActiveShopifyGraphQL.configuration.admin_api_client
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
- log_query("Customer Account API", query, variables)
35
- client.query(query, variables)
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 || raise(NotImplementedError, "#{self} must define 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.model_name.name.demodulize)
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
+ )
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveShopifyGraphQL
4
- VERSION = "0.5.3"
4
+ VERSION = "1.1.0"
5
5
  end
@@ -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: 0.5.3
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.11
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