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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5fbed890ba698aa5134ff32bdd471ffa8944e56eaf5d6a8a6d28e8060983e519
4
- data.tar.gz: 34d83bf513460e154f33369fe54e953c68a466e37d16effccb7cb92014f16824
3
+ metadata.gz: 2f853d9c152e7118a0916f0daf12f6b855779cf7006e15849738f4ddad75bbc3
4
+ data.tar.gz: '0195855f859d751a6ca53b2bbe08c804892a96dfb640ceb7a73267fb7e7c4748'
5
5
  SHA512:
6
- metadata.gz: 4a1dad259585ae87c1afa68c18eb03f74b48b13068b452a41a7d42bfb576c244df4de5eab321559dc5c9a5f11e1f2964fb0cdef142058ae345a75cb29e9e5700
7
- data.tar.gz: f847bdf0f63e10b67f866abd6fae9c0d24195977d078c6d7125e7eff083f2f6ba99e1b0c30ada1374f5ceb4f36f0e8a1d52506a2d7354c7e01020d7d1ab4bcf7
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
@@ -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
- # Auto-inferred path: displayName
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
- 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
562
613
 
563
614
  ```ruby
564
- # Mock associations
565
- customer = Customer.new(id: 'gid://shopify/Customer/123')
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
- # Mock connections
569
- customer.orders = mock_orders
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 || 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.
@@ -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.model_name.name.demodulize)
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
+ )
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveShopifyGraphQL
4
- VERSION = "1.0.0"
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: 1.0.0
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