active_shopify_graphql 0.2.0 → 0.3.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: 2f8f9c57e3dd7f5371ace9fb8d3db79d796a0aa34890065d4727ae7a45a0723b
4
- data.tar.gz: 16d8e5361a00e4aaa2b166ba8108496a8564e669fc16c76e68e4ed058df12a23
3
+ metadata.gz: 6e4003c2f9180531bc4c3a08e9c921d16402642c60c45c777ac49726b5a6a3f2
4
+ data.tar.gz: df5619e4f4845d688cdd1beb5228047d52022eca04e3b1c3479265ed1b59d9c1
5
5
  SHA512:
6
- metadata.gz: c30453eafcf98bc563f309e2a5762373615ad9a23c0b8d0c6e3c0919e5e8490088974d0d187582e6a55b0d012d4cc2e9c3abcc77cb2c638bd4acd8098be1b032
7
- data.tar.gz: 1b190403f8fcdee4d69d35b534c168b63200a87aca06a6cbf465ea09dc873b3d723597e3f99201acbd8e0d4ed9618897af028dbcf73653be891391cc6d383d23
6
+ metadata.gz: d95bac2e81df933e3f3bbfbef55c486e2ba719271a93283b7e8fca72684a43208f0479be44286bd6ee99e9de427cdbdf44378aa20feb2c5895e3908ad112ab8d
7
+ data.tar.gz: f87d6688ac0374fb4821da10928b13127f8c272d090978646026dbf84adfc210039e8690e348cd570b48c28608a43252e998e5dea7dc527a193bc67e97d96440
data/.rubocop.yml CHANGED
@@ -1,3 +1,6 @@
1
+ AllCops:
2
+ NewCops: enable
3
+
1
4
  Style/StringLiterals:
2
5
  Enabled: false
3
6
 
@@ -48,3 +51,15 @@ Naming/MethodParameterName:
48
51
 
49
52
  Naming/AccessorMethodName:
50
53
  Enabled: false
54
+
55
+ Style/ArgumentsForwarding:
56
+ Enabled: false
57
+
58
+ Style/KeywordArgumentsMerging:
59
+ Enabled: false
60
+
61
+ Naming/BlockForwarding:
62
+ Enabled: false
63
+
64
+ Lint/DuplicateBranch:
65
+ Enabled: false
data/AGENTS.md CHANGED
@@ -31,6 +31,7 @@
31
31
 
32
32
  ## Testing Guidelines
33
33
  - Framework: RSpec with documentation formatter (`.rspec`).
34
+ - Before stubbing classes look in `model_factories.rb` for existing ones.
34
35
  - Place specs under `spec/` and name files `*_spec.rb` matching the class/module under test.
35
36
  - Do not use `let` or `before` blocks in specs; each test case should tell a complete story.
36
37
  - Use verifying doubles instead of normal doubles. Prefer `{instance|class}_{double|spy}` to `double` or `spy`
data/README.md CHANGED
@@ -42,7 +42,7 @@ Rails.configuration.to_prepare do
42
42
  config.admin_api_client = ShopifyGraphQL::Client
43
43
 
44
44
  # Configure the Customer Account API client class (must have .from_config(token) class method)
45
- # and reponsd to #execute(query, **variables)
45
+ # and respond to #execute(query, **variables)
46
46
  config.customer_account_client_class = Shopify::Account::Client
47
47
  end
48
48
  end
@@ -143,6 +143,33 @@ end
143
143
 
144
144
  The metafield attributes automatically generate the correct GraphQL syntax and handle value extraction from either `value` or `jsonValue` fields based on the type.
145
145
 
146
+ #### Raw GraphQL Attributes
147
+
148
+ For advanced GraphQL features not yet fully supported by the gem (like union types with `... on` syntax), you can inject raw GraphQL directly into the query using the `raw_graphql` option:
149
+
150
+ ```ruby
151
+ class Product
152
+ include ActiveShopifyGraphQL::Base
153
+
154
+ graphql_type "Product"
155
+
156
+ attribute :id, type: :string
157
+ attribute :title, type: :string
158
+
159
+ # Raw GraphQL for accessing metaobject references with union types
160
+ attribute :provider_id,
161
+ path: "roaster.reference.id", # Path to extract from the response
162
+ type: :string,
163
+ raw_graphql: 'metafield(namespace: "custom", key: "provider") { reference { ... on Metaobject { id } } }'
164
+
165
+ # Another example with complex nested queries not warranting full blown models
166
+ attribute :product_bundle,
167
+ path: "bundle", # The alias will be used as the response key
168
+ type: :json,
169
+ raw_graphql: 'metafield(namespace: "bundles", key: "items") { references(first: 10) { edges { node { ... on Product { id title } } } } }'
170
+ end
171
+ ```
172
+
146
173
  #### API-Specific Attributes
147
174
 
148
175
  For models that need different attributes depending on the API being used, you can define loader-specific overrides:
@@ -198,7 +225,7 @@ customer = Customer.with_customer_account_api(token).find
198
225
  # Use Admin API explicitly
199
226
  customer = Customer.with_admin_api.find(id)
200
227
 
201
- # Use you own custom Loader
228
+ # Use your own custom Loader
202
229
  customer = Customer.with_loader(MyCustomLoader).find(id)
203
230
  ```
204
231
 
@@ -264,7 +291,7 @@ class Customer
264
291
  # Define an association to one of your own ActiveRecord models
265
292
  # foreign_key maps the id of the GraphQL powered model to the rewards.shopify_customer_id table
266
293
  has_many :rewards, foreign_key: :shopify_customer_id
267
- # primary_key specifies which attribute use as the value for matching the ActiveRecord ID
294
+ # primary_key specifies which attribute to use as the value for matching the ActiveRecord ID
268
295
  has_many :referrals, primary_key: :plain_id, foreign_key: :shopify_id
269
296
 
270
297
  validates :id, presence: true
@@ -298,7 +325,7 @@ The associations automatically handle Shopify GID format conversion, extracting
298
325
 
299
326
  ## GraphQL Connections
300
327
 
301
- ActiveShopifyGraphQL supports GraphQL connections for loading related data from Shopify APIs. Connections provide both lazy and eager loading patterns with cursor-based pagination support.
328
+ ActiveShopifyGraphQL supports GraphQL connections for loading related data from Shopify APIs. Connections provide both lazy and eager loading patterns.
302
329
 
303
330
  ### Defining Connections
304
331
 
@@ -329,12 +356,14 @@ class Customer
329
356
  }
330
357
 
331
358
  # Example of a "scoped" connection
359
+ # Multiple connections can use the same query_name with different arguments
332
360
  has_many_connected :recent_orders,
333
- query_name: "orders", # The query would be inferred to recentOrders() without this
334
- class_name: "Shopify::Order", # The class would be inferred to RecentOrder without this
335
- default_arguments: { # The arguments passed in the connection query
336
- first: 2,
337
- reverse: true
361
+ query_name: "orders", # Uses the same GraphQL field as :orders
362
+ class_name: "Order", # The class would be inferred to RecentOrder without this
363
+ default_arguments: { # Different arguments for filtering
364
+ first: 5,
365
+ reverse: true,
366
+ sort_key: 'CREATED_AT'
338
367
  }
339
368
  end
340
369
 
@@ -349,6 +378,23 @@ class Order
349
378
  end
350
379
  ```
351
380
 
381
+ **Connection Aliasing:** When multiple connections use the same `query_name` (like `orders` and `recent_orders` both using the "orders" field), the gem automatically generates GraphQL aliases to prevent conflicts:
382
+
383
+ ```graphql
384
+ fragment CustomerFragment on Customer {
385
+ id
386
+ displayName
387
+ orders(first: 2) {
388
+ edges { node { id name } }
389
+ }
390
+ recent_orders: orders(first: 5, reverse: true, sortKey: CREATED_AT) {
391
+ edges { node { id name } }
392
+ }
393
+ }
394
+ ```
395
+
396
+ This allows you to have multiple "views" of the same connection with different filtering or sorting parameters, all in a single query.
397
+
352
398
  ### Lazy Loading (Default Behavior)
353
399
 
354
400
  Connections are loaded lazily when accessed. A separate GraphQL query is fired when the connection is first accessed:
@@ -505,6 +551,69 @@ expect(customer.orders.size).to eq(2)
505
551
  expect(customer.orders.first.name).to eq('#1001')
506
552
  ```
507
553
 
554
+ ### Inverse Relationships with `inverse_of`
555
+
556
+ When you have bidirectional relationships between models, you can use the `inverse_of` parameter to avoid redundant GraphQL queries. This is similar to ActiveRecord's `inverse_of` option and automatically caches the parent object when loading children.
557
+
558
+ #### Basic Usage
559
+
560
+ ```ruby
561
+ class Product
562
+ include ActiveShopifyGraphQL::Base
563
+
564
+ graphql_type 'Product'
565
+
566
+ attribute :id
567
+ attribute :title
568
+
569
+ # Define inverse relationship to avoid redundant queries
570
+ has_many_connected :variants,
571
+ class_name: "ProductVariant",
572
+ inverse_of: :product, # Points to the inverse connection name
573
+ default_arguments: { first: 10 }
574
+ end
575
+
576
+ class ProductVariant
577
+ include ActiveShopifyGraphQL::Base
578
+
579
+ graphql_type 'ProductVariant'
580
+
581
+ attribute :id
582
+ attribute :title
583
+
584
+ # Define inverse relationship back to Product
585
+ has_one_connected :product,
586
+ inverse_of: :variants # Points back to the parent's connection
587
+ end
588
+ ```
589
+
590
+ #### With Eager Loading
591
+
592
+ ```ruby
593
+ # Load product with variants in a single GraphQL query
594
+ product = Product.includes(:variants).find(123)
595
+
596
+ # Access variants - already loaded, no additional query
597
+ product.variants.each do |variant|
598
+ # Access product from variant - uses cached parent, NO QUERY!
599
+ puts variant.product.title
600
+ end
601
+ ```
602
+
603
+ #### With Lazy Loading
604
+
605
+ ```ruby
606
+ # Load product without preloading variants
607
+ product = Product.find(123)
608
+
609
+ # First access triggers a query to load variants
610
+ variants = product.variants.to_a
611
+
612
+ # Access product from variant - uses cached parent, NO QUERY!
613
+ variant = variants.first
614
+ puts variant.product.title # Returns the same product instance
615
+ ```
616
+
508
617
  ### Connection Configuration
509
618
 
510
619
  Connections automatically infer sensible defaults but can be customized:
@@ -514,6 +623,7 @@ Connections automatically infer sensible defaults but can be customized:
514
623
  - **foreign_key**: Field used to filter connection records (defaults to `{model_name}_id`)
515
624
  - **loader_class**: Custom loader class (defaults to model's default loader)
516
625
  - **eager_load**: Whether to automatically eager load this connection on find/where queries (default: false)
626
+ - **inverse_of**: The name of the inverse connection on the target model (optional, enables automatic inverse caching)
517
627
  - **default_arguments**: Hash of default arguments to pass to the GraphQL query (e.g., `{ first: 10, sort_key: 'CREATED_AT' }`)
518
628
 
519
629
  ### Error Handling
@@ -527,7 +637,7 @@ Connection queries use the same error handling as regular model queries. If a co
527
637
  - [x] Support `Model.where(param: value)` proxying params to the GraphQL query attribute
528
638
  - [x] Query optimization with `select` method
529
639
  - [x] GraphQL connections with lazy and eager loading via `Customer.includes(:orders).find(id)`
530
- - [ ] Support for paginating query results
640
+ - [ ] Support for paginating query results with cursors
531
641
  - [ ] Better error handling and retry mechanisms for GraphQL API calls
532
642
  - [ ] Caching layer for frequently accessed data
533
643
 
@@ -13,9 +13,10 @@ module ActiveShopifyGraphQL
13
13
  # @param null [Boolean] Whether the attribute can be null (default: true)
14
14
  # @param default [Object] Default value when GraphQL response is nil
15
15
  # @param transform [Proc] Custom transform block for the value
16
- def attribute(name, path: nil, type: :string, null: true, default: nil, transform: nil)
16
+ # @param raw_graphql [String] Raw GraphQL string to inject directly (escape hatch for unsupported features)
17
+ def attribute(name, path: nil, type: :string, null: true, default: nil, transform: nil, raw_graphql: nil)
17
18
  path ||= infer_path(name)
18
- config = { path: path, type: type, null: null, default: default, transform: transform }
19
+ config = { path: path, type: type, null: null, default: default, transform: transform, raw_graphql: raw_graphql }
19
20
 
20
21
  if @current_loader_context
21
22
  # Store in loader-specific context
@@ -20,10 +20,46 @@ module ActiveShopifyGraphQL
20
20
  def initialize(attributes = {})
21
21
  super()
22
22
 
23
- # Extract connection cache if present
24
- @_connection_cache = attributes.delete(:_connection_cache) if attributes.key?(:_connection_cache)
23
+ # Extract connection cache if present and populate inverse caches
24
+ if attributes.key?(:_connection_cache)
25
+ @_connection_cache = attributes.delete(:_connection_cache)
26
+ populate_inverse_caches_on_initialization
27
+ end
25
28
 
26
29
  assign_attributes(attributes)
27
30
  end
31
+
32
+ private
33
+
34
+ def populate_inverse_caches_on_initialization
35
+ return unless @_connection_cache
36
+
37
+ @_connection_cache.each do |connection_name, records|
38
+ connection_config = self.class.connections[connection_name]
39
+ next unless connection_config && connection_config[:inverse_of]
40
+
41
+ inverse_name = connection_config[:inverse_of]
42
+ records_array = Array(records).compact
43
+
44
+ records_array.each do |record|
45
+ next unless record.respond_to?(:instance_variable_set)
46
+
47
+ record.instance_variable_set(:@_connection_cache, {}) unless record.instance_variable_get(:@_connection_cache)
48
+ cache = record.instance_variable_get(:@_connection_cache)
49
+
50
+ # Determine the type of the inverse connection
51
+ next unless record.class.respond_to?(:connections) && record.class.connections[inverse_name]
52
+
53
+ inverse_type = record.class.connections[inverse_name][:type]
54
+ cache[inverse_name] =
55
+ if inverse_type == :singular
56
+ self
57
+ else
58
+ # For collection inverses, wrap parent in an array
59
+ [self]
60
+ end
61
+ end
62
+ end
63
+ end
28
64
  end
29
65
  end
@@ -33,7 +33,7 @@ module ActiveShopifyGraphQL
33
33
 
34
34
  def load_nested_connection(query_name, variables, parent, connection_config)
35
35
  parent_type = parent.class.graphql_type_for_loader(@context.loader_class)
36
- parent_query_name = parent_type.downcase
36
+ parent_query_name = parent_type.camelize(:lower)
37
37
  connection_type = connection_config&.dig(:type) || :connection
38
38
 
39
39
  query = QueryTree.build_connection_query(
@@ -100,6 +100,9 @@ module ActiveShopifyGraphQL
100
100
  @connection_config
101
101
  ) || []
102
102
 
103
+ # Populate inverse cache if inverse_of is specified
104
+ populate_inverse_cache(@records, @connection_config, @parent)
105
+
103
106
  @loaded = true
104
107
  end
105
108
 
@@ -107,6 +110,35 @@ module ActiveShopifyGraphQL
107
110
  default_args = @connection_config[:default_arguments] || {}
108
111
  default_args.merge(@options).compact
109
112
  end
113
+
114
+ def populate_inverse_cache(records, connection_config, parent)
115
+ return unless connection_config[:inverse_of]
116
+ return if records.nil? || (records.is_a?(Array) && records.empty?)
117
+
118
+ inverse_name = connection_config[:inverse_of]
119
+ target_class = connection_config[:class_name].constantize
120
+
121
+ # Ensure target class has the inverse connection defined
122
+ return unless target_class.respond_to?(:connections) && target_class.connections[inverse_name]
123
+
124
+ inverse_type = target_class.connections[inverse_name][:type]
125
+ records_array = records.is_a?(Array) ? records : [records]
126
+
127
+ records_array.each do |record|
128
+ next unless record
129
+
130
+ record.instance_variable_set(:@_connection_cache, {}) unless record.instance_variable_get(:@_connection_cache)
131
+ cache = record.instance_variable_get(:@_connection_cache)
132
+
133
+ cache[inverse_name] =
134
+ if inverse_type == :singular
135
+ parent
136
+ else
137
+ # For collection inverses, wrap parent in an array
138
+ [parent]
139
+ end
140
+ end
141
+ end
110
142
  end
111
143
  end
112
144
  end
@@ -17,14 +17,14 @@ module ActiveShopifyGraphQL
17
17
  class_methods do
18
18
  # Define a singular connection (returns a single object)
19
19
  # @see #connection
20
- def has_one_connected(name, **options)
21
- connection(name, type: :singular, **options)
20
+ def has_one_connected(name, inverse_of: nil, **options)
21
+ connection(name, type: :singular, inverse_of: inverse_of, **options)
22
22
  end
23
23
 
24
24
  # Define a plural connection (returns a collection via edges)
25
25
  # @see #connection
26
- def has_many_connected(name, **options)
27
- connection(name, type: :connection, **options)
26
+ def has_many_connected(name, inverse_of: nil, **options)
27
+ connection(name, type: :connection, inverse_of: inverse_of, **options)
28
28
  end
29
29
 
30
30
  # Define a GraphQL connection to another ActiveShopifyGraphQL model
@@ -36,7 +36,8 @@ module ActiveShopifyGraphQL
36
36
  # @param eager_load [Boolean] Whether to automatically eager load this connection (default: false)
37
37
  # @param type [Symbol] The type of connection (:connection, :singular). Default is :connection.
38
38
  # @param default_arguments [Hash] Default arguments to pass to the GraphQL query (e.g. first: 10)
39
- def connection(name, class_name: nil, query_name: nil, foreign_key: nil, loader_class: nil, eager_load: false, type: :connection, default_arguments: {})
39
+ # @param inverse_of [Symbol] The name of the inverse connection on the target model (optional)
40
+ def connection(name, class_name: nil, query_name: nil, foreign_key: nil, loader_class: nil, eager_load: false, type: :connection, default_arguments: {}, inverse_of: nil)
40
41
  # Infer defaults
41
42
  connection_class_name = class_name || name.to_s.classify
42
43
 
@@ -56,9 +57,13 @@ module ActiveShopifyGraphQL
56
57
  nested: true, # Always treated as nested (accessed via parent field)
57
58
  target_class_name: connection_class_name,
58
59
  original_name: name,
59
- default_arguments: default_arguments
60
+ default_arguments: default_arguments,
61
+ inverse_of: inverse_of
60
62
  }
61
63
 
64
+ # Validate inverse relationship if specified (validation is deferred to runtime)
65
+ validate_inverse_of!(name, connection_class_name, inverse_of) if inverse_of
66
+
62
67
  # Define the connection method that returns a proxy
63
68
  define_method name do |**options|
64
69
  # Check if this connection was eager loaded
@@ -74,6 +79,9 @@ module ActiveShopifyGraphQL
74
79
  # Load the record
75
80
  records = loader.load_connection_records(config[:query_name], options, self, config)
76
81
 
82
+ # Populate inverse cache if inverse_of is specified
83
+ populate_inverse_cache_for_connection(records, config, self)
84
+
77
85
  # Cache it
78
86
  @_connection_cache ||= {}
79
87
  @_connection_cache[name] = records
@@ -123,30 +131,19 @@ module ActiveShopifyGraphQL
123
131
  # Merge manual and automatic connections
124
132
  all_included_connections = (connection_names + auto_included_connections).uniq
125
133
 
126
- # Create a new class that inherits from self with eager loading enabled
127
- included_class = Class.new(self)
128
-
129
- # Store the connections to include
130
- included_class.instance_variable_set(:@included_connections, all_included_connections)
131
-
132
- # Override methods to use eager loading
133
- included_class.define_singleton_method(:default_loader) do
134
- @default_loader ||= superclass.default_loader.class.new(
135
- superclass,
136
- included_connections: @included_connections
137
- )
138
- end
139
-
140
- # Preserve the original class name and model name for GraphQL operations
141
- included_class.define_singleton_method(:name) { superclass.name }
142
- included_class.define_singleton_method(:model_name) { superclass.model_name }
143
- included_class.define_singleton_method(:connections) { superclass.connections }
144
-
145
- included_class
134
+ # Create a scope object that holds the included connections
135
+ IncludesScope.new(self, all_included_connections)
146
136
  end
147
137
 
148
138
  private
149
139
 
140
+ def validate_inverse_of!(_name, _target_class_name, _inverse_name)
141
+ # Validation is deferred until runtime when connections are actually used
142
+ # This allows class definitions to be in any order
143
+ # The validation logic will be checked when inverse cache is populated
144
+ nil
145
+ end
146
+
150
147
  def validate_includes_connections!(connection_names)
151
148
  connection_names.each do |name|
152
149
  if name.is_a?(Hash)
@@ -166,5 +163,36 @@ module ActiveShopifyGraphQL
166
163
  end
167
164
  end
168
165
  end
166
+
167
+ # Instance method to populate inverse cache for lazy-loaded connections
168
+
169
+ def populate_inverse_cache_for_connection(records, connection_config, parent)
170
+ return unless connection_config[:inverse_of]
171
+ return if records.nil? || (records.is_a?(Array) && records.empty?)
172
+
173
+ inverse_name = connection_config[:inverse_of]
174
+ target_class = connection_config[:class_name].constantize
175
+
176
+ # Ensure target class has the inverse connection defined
177
+ return unless target_class.respond_to?(:connections) && target_class.connections[inverse_name]
178
+
179
+ inverse_type = target_class.connections[inverse_name][:type]
180
+ records_array = records.is_a?(Array) ? records : [records]
181
+
182
+ records_array.each do |record|
183
+ next unless record
184
+
185
+ record.instance_variable_set(:@_connection_cache, {}) unless record.instance_variable_get(:@_connection_cache)
186
+ cache = record.instance_variable_get(:@_connection_cache)
187
+
188
+ cache[inverse_name] =
189
+ if inverse_type == :singular
190
+ parent
191
+ else
192
+ # For collection inverses, wrap parent in an array
193
+ [parent]
194
+ end
195
+ end
196
+ end
169
197
  end
170
198
  end
@@ -11,11 +11,16 @@ module ActiveShopifyGraphQL
11
11
  # @return [Object, nil] The model instance or nil if not found
12
12
  def find(id, loader: default_loader)
13
13
  gid = GidHelper.normalize_gid(id, model_name.name.demodulize)
14
- attributes = loader.load_attributes(gid)
15
14
 
16
- return nil if attributes.nil?
15
+ # If we have included connections, we need to handle inverse_of properly
16
+ if loader.respond_to?(:load_with_instance) && loader.has_included_connections?
17
+ loader.load_with_instance(gid, self)
18
+ else
19
+ attributes = loader.load_attributes(gid)
20
+ return nil if attributes.nil?
17
21
 
18
- new(attributes)
22
+ new(attributes)
23
+ end
19
24
  end
20
25
 
21
26
  # Returns the default loader for this model's queries
@@ -130,7 +135,7 @@ module ActiveShopifyGraphQL
130
135
  return unless invalid_attrs.any?
131
136
 
132
137
  raise ArgumentError, "Invalid attributes for #{name}: #{invalid_attrs.join(', ')}. " \
133
- "Available attributes are: #{available_attrs.join(', ')}"
138
+ "Available attributes are: #{available_attrs.join(', ')}"
134
139
  end
135
140
 
136
141
  # Gets all available attributes for selection
@@ -30,18 +30,29 @@ module ActiveShopifyGraphQL
30
30
  def build_field_nodes
31
31
  path_tree = {}
32
32
  metafield_aliases = {}
33
+ raw_graphql_nodes = []
34
+ aliased_field_nodes = []
33
35
 
34
36
  # Build a tree structure for nested paths
35
- @context.defined_attributes.each_value do |config|
36
- if config[:is_metafield]
37
+ @context.defined_attributes.each do |attr_name, config|
38
+ if config[:raw_graphql]
39
+ raw_graphql_nodes << build_raw_graphql_node(attr_name, config[:raw_graphql])
40
+ elsif config[:is_metafield]
37
41
  store_metafield_config(metafield_aliases, config)
38
42
  else
39
- build_path_tree(path_tree, config[:path])
43
+ path = config[:path]
44
+ if path.include?('.')
45
+ # Nested path - use tree structure (shared prefixes)
46
+ build_path_tree(path_tree, path)
47
+ else
48
+ # Simple path - add aliased field node
49
+ aliased_field_nodes << build_aliased_field_node(attr_name, path)
50
+ end
40
51
  end
41
52
  end
42
53
 
43
54
  # Convert tree to QueryNode objects
44
- nodes_from_tree(path_tree) + metafield_nodes(metafield_aliases)
55
+ nodes_from_tree(path_tree) + aliased_field_nodes + metafield_nodes(metafield_aliases) + raw_graphql_nodes
45
56
  end
46
57
 
47
58
  # Build QueryNode objects for all connections (protected for recursive calls)
@@ -74,6 +85,23 @@ module ActiveShopifyGraphQL
74
85
  }
75
86
  end
76
87
 
88
+ def build_raw_graphql_node(attr_name, raw_graphql)
89
+ # Prepend alias to raw GraphQL for predictable response mapping
90
+ aliased_raw_graphql = "#{attr_name}: #{raw_graphql}"
91
+ QueryNode.new(
92
+ name: "raw",
93
+ arguments: { raw_graphql: aliased_raw_graphql },
94
+ node_type: :raw
95
+ )
96
+ end
97
+
98
+ def build_aliased_field_node(attr_name, path)
99
+ alias_name = attr_name.to_s
100
+ # Only add alias if the attr_name differs from the GraphQL field name
101
+ alias_name = nil if alias_name == path
102
+ QueryNode.new(name: path, alias_name: alias_name, node_type: :field)
103
+ end
104
+
77
105
  def build_path_tree(path_tree, path)
78
106
  path_parts = path.split('.')
79
107
  current_level = path_tree
@@ -120,12 +148,17 @@ module ActiveShopifyGraphQL
120
148
  child_nodes = build_target_field_nodes(target_context, nested_includes)
121
149
 
122
150
  query_name = connection_config[:query_name]
151
+ original_name = connection_config[:original_name]
123
152
  connection_type = connection_config[:type] || :connection
124
153
  formatted_args = (connection_config[:default_arguments] || {}).transform_keys(&:to_sym)
125
154
 
155
+ # Add alias if the connection name differs from the query name
156
+ alias_name = original_name.to_s == query_name ? nil : original_name.to_s
157
+
126
158
  node_type = connection_type == :singular ? :singular : :connection
127
159
  QueryNode.new(
128
160
  name: query_name,
161
+ alias_name: alias_name,
129
162
  arguments: formatted_args,
130
163
  node_type: node_type,
131
164
  children: child_nodes
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ # A scope object that holds included connections for eager loading.
5
+ # This allows chaining methods like find() and where() while maintaining
6
+ # the included connections configuration.
7
+ class IncludesScope
8
+ attr_reader :model_class, :included_connections
9
+
10
+ def initialize(model_class, included_connections)
11
+ @model_class = model_class
12
+ @included_connections = included_connections
13
+ end
14
+
15
+ # Delegate find to the model class with a custom loader
16
+ def find(id, loader: nil)
17
+ loader ||= default_loader
18
+ @model_class.find(id, loader: loader)
19
+ end
20
+
21
+ # Delegate where to the model class with a custom loader
22
+ def where(*args, **options)
23
+ loader = options.delete(:loader) || default_loader
24
+ @model_class.where(*args, **options.merge(loader: loader))
25
+ end
26
+
27
+ # Delegate select to create a new scope with select
28
+ def select(*attributes)
29
+ selected_scope = @model_class.select(*attributes)
30
+ # Chain the includes on top of select
31
+ IncludesScope.new(selected_scope, @included_connections)
32
+ end
33
+
34
+ # Allow chaining includes calls
35
+ def includes(*connection_names)
36
+ @model_class.includes(*(@included_connections + connection_names).uniq)
37
+ end
38
+
39
+ private
40
+
41
+ def default_loader
42
+ @default_loader ||= @model_class.default_loader.class.new(
43
+ @model_class,
44
+ included_connections: @included_connections
45
+ )
46
+ end
47
+ end
48
+ end
@@ -84,7 +84,7 @@ module ActiveShopifyGraphQL
84
84
 
85
85
  # Delegate query building methods
86
86
  def query_name(model_type = nil)
87
- (model_type || graphql_type).downcase
87
+ (model_type || graphql_type).camelize(:lower)
88
88
  end
89
89
 
90
90
  def fragment_name(model_type = nil)
@@ -96,19 +96,50 @@ module ActiveShopifyGraphQL
96
96
  end
97
97
 
98
98
  # Map the GraphQL response to model attributes
99
- def map_response_to_attributes(response_data)
99
+ def map_response_to_attributes(response_data, parent_instance: nil)
100
100
  mapper = ResponseMapper.new(context)
101
101
  attributes = mapper.map_response(response_data)
102
102
 
103
103
  # If we have included connections, extract and cache them
104
104
  if @included_connections.any?
105
- connection_data = mapper.extract_connection_data(response_data)
105
+ connection_data = mapper.extract_connection_data(response_data, parent_instance: parent_instance)
106
106
  attributes[:_connection_cache] = connection_data unless connection_data.empty?
107
107
  end
108
108
 
109
109
  attributes
110
110
  end
111
111
 
112
+ # Check if this loader has included connections
113
+ def has_included_connections?
114
+ @included_connections&.any?
115
+ end
116
+
117
+ # Load and construct an instance with proper inverse_of support for included connections
118
+ def load_with_instance(id, model_class)
119
+ query = graphql_query
120
+ response_data = perform_graphql_query(query, id: id)
121
+
122
+ return nil if response_data.nil?
123
+
124
+ # First, extract just the attributes (without connections)
125
+ mapper = ResponseMapper.new(context)
126
+ attributes = mapper.map_response(response_data)
127
+
128
+ # Create the instance with basic attributes
129
+ instance = model_class.new(attributes)
130
+
131
+ # Now extract connection data with the instance as parent to support inverse_of
132
+ if @included_connections.any?
133
+ connection_data = mapper.extract_connection_data(response_data, parent_instance: instance)
134
+ unless connection_data.empty?
135
+ # Manually set the connection cache on the instance
136
+ instance.instance_variable_set(:@_connection_cache, connection_data)
137
+ end
138
+ end
139
+
140
+ instance
141
+ end
142
+
112
143
  # Executes the GraphQL query and returns the mapped attributes hash
113
144
  def load_attributes(id)
114
145
  query = graphql_query
@@ -38,7 +38,7 @@ module ActiveShopifyGraphQL
38
38
 
39
39
  # Helper methods delegated from context
40
40
  def query_name
41
- graphql_type.downcase
41
+ graphql_type.camelize(:lower)
42
42
  end
43
43
 
44
44
  def fragment_name
@@ -4,7 +4,7 @@ module ActiveShopifyGraphQL
4
4
  module Loaders
5
5
  class AdminApiLoader < Loader
6
6
  def initialize(model_class = nil, selected_attributes: nil, included_connections: nil)
7
- super(model_class, selected_attributes: selected_attributes, included_connections: included_connections)
7
+ super
8
8
  end
9
9
 
10
10
  def perform_graphql_query(query, **variables)
@@ -40,6 +40,8 @@ class QueryNode
40
40
  render_singular(indent_level: indent_level)
41
41
  when :fragment
42
42
  render_fragment
43
+ when :raw
44
+ render_raw
43
45
  else
44
46
  raise ArgumentError, "Unknown node type: #{@node_type}"
45
47
  end
@@ -83,11 +85,14 @@ class QueryNode
83
85
  nested_fields = @children.map { |child| child.to_s(indent_level: indent_level + 2) }
84
86
  fields_string = nested_fields.join(compact? ? " " : "\n#{nested_indent} ")
85
87
 
88
+ # Include alias if present
89
+ field_name = @alias_name ? "#{@alias_name}: #{@name}" : @name
90
+
86
91
  if compact?
87
- "#{@name}#{args_string} { edges { node { #{fields_string} } } }"
92
+ "#{field_name}#{args_string} { edges { node { #{fields_string} } } }"
88
93
  else
89
94
  <<~GRAPHQL.strip
90
- #{@name}#{args_string} {
95
+ #{field_name}#{args_string} {
91
96
  #{nested_indent}edges {
92
97
  #{nested_indent} node {
93
98
  #{nested_indent} #{fields_string}
@@ -108,10 +113,13 @@ class QueryNode
108
113
  nested_fields = @children.map { |child| child.to_s(indent_level: indent_level + 1) }
109
114
  fields_string = nested_fields.join(compact? ? " " : "\n#{nested_indent}")
110
115
 
116
+ # Include alias if present
117
+ field_name = @alias_name ? "#{@alias_name}: #{@name}" : @name
118
+
111
119
  if compact?
112
- "#{@name}#{args_string} { #{fields_string} }"
120
+ "#{field_name}#{args_string} { #{fields_string} }"
113
121
  else
114
- "#{@name}#{args_string} {\n#{nested_indent}#{fields_string}\n#{indent}}"
122
+ "#{field_name}#{args_string} {\n#{nested_indent}#{fields_string}\n#{indent}}"
115
123
  end
116
124
  end
117
125
 
@@ -127,6 +135,11 @@ class QueryNode
127
135
  end
128
136
  end
129
137
 
138
+ def render_raw
139
+ # Raw GraphQL string stored in arguments[:raw_graphql]
140
+ @arguments[:raw_graphql]
141
+ end
142
+
130
143
  def format_arguments
131
144
  return "" if @arguments.empty?
132
145
 
@@ -73,11 +73,6 @@ module ActiveShopifyGraphQL
73
73
  FragmentBuilder.normalize_includes(includes)
74
74
  end
75
75
 
76
- # Helper methods (kept for backward compatibility)
77
- def self.query_name(graphql_type)
78
- graphql_type.downcase
79
- end
80
-
81
76
  def self.fragment_name(graphql_type)
82
77
  "#{graphql_type}Fragment"
83
78
  end
@@ -35,18 +35,18 @@ module ActiveShopifyGraphQL
35
35
  end
36
36
 
37
37
  # Extract connection data from GraphQL response for eager loading
38
- def extract_connection_data(response_data, root_path: nil)
38
+ def extract_connection_data(response_data, root_path: nil, parent_instance: nil)
39
39
  return {} if @context.included_connections.empty?
40
40
 
41
41
  root_path ||= ["data", @context.query_name]
42
42
  root_data = response_data.dig(*root_path)
43
43
  return {} unless root_data
44
44
 
45
- extract_connections_from_node(root_data)
45
+ extract_connections_from_node(root_data, parent_instance)
46
46
  end
47
47
 
48
48
  # Extract connections from a node (reusable for nested connections)
49
- def extract_connections_from_node(node_data)
49
+ def extract_connections_from_node(node_data, parent_instance = nil)
50
50
  return {} if @context.included_connections.empty?
51
51
 
52
52
  connections = @context.connections
@@ -59,7 +59,7 @@ module ActiveShopifyGraphQL
59
59
  connection_config = connections[connection_name]
60
60
  next unless connection_config
61
61
 
62
- records = extract_connection_records(node_data, connection_config, nested_includes)
62
+ records = extract_connection_records(node_data, connection_config, nested_includes, parent_instance: parent_instance)
63
63
  connection_cache[connection_name] = records if records
64
64
  end
65
65
 
@@ -69,7 +69,7 @@ module ActiveShopifyGraphQL
69
69
  # Map nested connection response (when loading via parent query)
70
70
  def map_nested_connection_response(response_data, connection_field_name, parent, connection_config = nil)
71
71
  parent_type = parent.class.graphql_type_for_loader(@context.loader_class)
72
- parent_query_name = parent_type.downcase
72
+ parent_query_name = parent_type.camelize(:lower)
73
73
  connection_type = connection_config&.dig(:type) || :connection
74
74
 
75
75
  if connection_type == :singular
@@ -111,8 +111,26 @@ module ActiveShopifyGraphQL
111
111
  private
112
112
 
113
113
  def extract_and_transform_value(node_data, config, attr_name)
114
- path_parts = config[:path].split('.')
115
- value = node_data.dig(*path_parts)
114
+ path = config[:path]
115
+
116
+ value = if config[:raw_graphql]
117
+ # For raw_graphql, the alias is the attr_name, then dig using path if nested
118
+ raw_data = node_data[attr_name.to_s]
119
+ if path.include?('.')
120
+ # Path is relative to the aliased root
121
+ path_parts = path.split('.')[1..] # Skip the first part (attr_name itself)
122
+ path_parts.any? ? raw_data&.dig(*path_parts) : raw_data
123
+ else
124
+ raw_data
125
+ end
126
+ elsif path.include?('.')
127
+ # Nested path - dig using the full path
128
+ path_parts = path.split('.')
129
+ node_data.dig(*path_parts)
130
+ else
131
+ # Simple path - use attr_name as key (matches the alias in the query)
132
+ node_data[attr_name.to_s]
133
+ end
116
134
 
117
135
  value = apply_defaults_and_transforms(value, config)
118
136
  validate_null_constraint!(value, config, attr_name)
@@ -153,23 +171,33 @@ module ActiveShopifyGraphQL
153
171
  end
154
172
  end
155
173
 
156
- def extract_connection_records(node_data, connection_config, nested_includes)
157
- query_name = connection_config[:query_name]
174
+ def extract_connection_records(node_data, connection_config, nested_includes, parent_instance: nil)
175
+ # Use original_name (Ruby attr name) as the response key since we alias connections
176
+ response_key = connection_config[:original_name].to_s
158
177
  connection_type = connection_config[:type] || :connection
159
178
  target_class = connection_config[:class_name].constantize
179
+ connection_name = connection_config[:original_name]
160
180
 
161
181
  if connection_type == :singular
162
- item_data = node_data[query_name]
182
+ item_data = node_data[response_key]
163
183
  return nil unless item_data
164
184
 
165
- build_nested_model_instance(item_data, target_class, nested_includes)
185
+ build_nested_model_instance(item_data, target_class, nested_includes,
186
+ parent_instance: parent_instance,
187
+ parent_connection_name: connection_name,
188
+ connection_config: connection_config)
166
189
  else
167
- edges = node_data.dig(query_name, "edges")
190
+ edges = node_data.dig(response_key, "edges")
168
191
  return nil unless edges
169
192
 
170
193
  edges.filter_map do |edge|
171
194
  item_data = edge["node"]
172
- build_nested_model_instance(item_data, target_class, nested_includes) if item_data
195
+ if item_data
196
+ build_nested_model_instance(item_data, target_class, nested_includes,
197
+ parent_instance: parent_instance,
198
+ parent_connection_name: connection_name,
199
+ connection_config: connection_config)
200
+ end
173
201
  end
174
202
  end
175
203
  end
@@ -181,16 +209,35 @@ module ActiveShopifyGraphQL
181
209
  @context.model_class.new(attributes)
182
210
  end
183
211
 
184
- def build_nested_model_instance(node_data, target_class, nested_includes)
212
+ def build_nested_model_instance(node_data, target_class, nested_includes, parent_instance: nil, parent_connection_name: nil, connection_config: nil) # rubocop:disable Lint/UnusedMethodArgument
185
213
  nested_context = @context.for_model(target_class, new_connections: nested_includes)
186
214
  nested_mapper = ResponseMapper.new(nested_context)
187
215
 
188
216
  attributes = nested_mapper.map_node_to_attributes(node_data)
189
217
  instance = target_class.new(attributes)
190
218
 
191
- # Handle nested connections recursively
219
+ # Populate inverse cache if inverse_of is specified
220
+ if parent_instance && connection_config && connection_config[:inverse_of]
221
+ inverse_name = connection_config[:inverse_of]
222
+ instance.instance_variable_set(:@_connection_cache, {}) unless instance.instance_variable_get(:@_connection_cache)
223
+ cache = instance.instance_variable_get(:@_connection_cache)
224
+
225
+ # Check the type of the inverse connection to determine how to cache
226
+ if target_class.respond_to?(:connections) && target_class.connections[inverse_name]
227
+ inverse_type = target_class.connections[inverse_name][:type]
228
+ cache[inverse_name] =
229
+ if inverse_type == :singular
230
+ parent_instance
231
+ else
232
+ # For collection inverses, wrap parent in an array
233
+ [parent_instance]
234
+ end
235
+ end
236
+ end
237
+
238
+ # Handle nested connections recursively (instance becomes parent for its children)
192
239
  if nested_includes.any?
193
- nested_data = nested_mapper.extract_connections_from_node(node_data)
240
+ nested_data = nested_mapper.extract_connections_from_node(node_data, instance)
194
241
  nested_data.each do |nested_name, nested_records|
195
242
  instance.send("#{nested_name}=", nested_records)
196
243
  end
@@ -27,6 +27,8 @@ module ActiveShopifyGraphQL
27
27
  # @return [String] The formatted query condition
28
28
  def format_condition(key, value)
29
29
  case value
30
+ when Array
31
+ format_array_condition(key, value)
30
32
  when String
31
33
  format_string_condition(key, value)
32
34
  when Numeric, true, false
@@ -38,6 +40,36 @@ module ActiveShopifyGraphQL
38
40
  end
39
41
  end
40
42
 
43
+ # Formats an array condition with OR clauses
44
+ # @param key [String] The attribute name
45
+ # @param values [Array] The array of values
46
+ # @return [String] The formatted query with OR clauses wrapped in parentheses
47
+ def format_array_condition(key, values)
48
+ return "" if values.empty?
49
+ return format_condition(key, values.first) if values.size == 1
50
+
51
+ or_parts = values.map do |value|
52
+ format_single_value(key, value)
53
+ end
54
+
55
+ "(#{or_parts.join(' OR ')})"
56
+ end
57
+
58
+ # Formats a single value for use in array OR clauses
59
+ # @param key [String] The attribute name
60
+ # @param value [Object] The attribute value
61
+ # @return [String] The formatted key:value pair
62
+ def format_single_value(key, value)
63
+ case value
64
+ when String
65
+ format_string_condition(key, value)
66
+ when Numeric, true, false
67
+ "#{key}:#{value}"
68
+ else
69
+ "#{key}:#{value}"
70
+ end
71
+ end
72
+
41
73
  # Formats a string condition with proper quoting
42
74
  def format_string_condition(key, value)
43
75
  # Handle special string values and escape quotes
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveShopifyGraphQL
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -23,6 +23,7 @@ require_relative "active_shopify_graphql/loaders/customer_account_api_loader"
23
23
  require_relative "active_shopify_graphql/loader_switchable"
24
24
  require_relative "active_shopify_graphql/finder_methods"
25
25
  require_relative "active_shopify_graphql/associations"
26
+ require_relative "active_shopify_graphql/includes_scope"
26
27
  require_relative "active_shopify_graphql/connections"
27
28
  require_relative "active_shopify_graphql/attributes"
28
29
  require_relative "active_shopify_graphql/metafield_attributes"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_shopify_graphql
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicolò Rebughini
@@ -52,34 +52,6 @@ dependencies:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
54
  version: '1.3'
55
- - !ruby/object:Gem::Dependency
56
- name: rspec
57
- requirement: !ruby/object:Gem::Requirement
58
- requirements:
59
- - - "~>"
60
- - !ruby/object:Gem::Version
61
- version: '3.0'
62
- type: :development
63
- prerelease: false
64
- version_requirements: !ruby/object:Gem::Requirement
65
- requirements:
66
- - - "~>"
67
- - !ruby/object:Gem::Version
68
- version: '3.0'
69
- - !ruby/object:Gem::Dependency
70
- name: rubocop
71
- requirement: !ruby/object:Gem::Requirement
72
- requirements:
73
- - - "~>"
74
- - !ruby/object:Gem::Version
75
- version: '1.0'
76
- type: :development
77
- prerelease: false
78
- version_requirements: !ruby/object:Gem::Requirement
79
- requirements:
80
- - - "~>"
81
- - !ruby/object:Gem::Version
82
- version: '1.0'
83
55
  description: ActiveShopifyGraphQL provides an ActiveRecord-like interface for interacting
84
56
  with Shopify's GraphQL APIs, supporting both Admin API and Customer Account API
85
57
  with automatic query building and response mapping.
@@ -108,6 +80,7 @@ files:
108
80
  - lib/active_shopify_graphql/fragment_builder.rb
109
81
  - lib/active_shopify_graphql/gid_helper.rb
110
82
  - lib/active_shopify_graphql/graphql_type_resolver.rb
83
+ - lib/active_shopify_graphql/includes_scope.rb
111
84
  - lib/active_shopify_graphql/loader.rb
112
85
  - lib/active_shopify_graphql/loader_context.rb
113
86
  - lib/active_shopify_graphql/loader_switchable.rb
@@ -125,6 +98,7 @@ licenses:
125
98
  metadata:
126
99
  homepage_uri: https://github.com/nebulab/active_shopify_graphql
127
100
  source_code_uri: https://github.com/nebulab/active_shopify_graphql
101
+ rubygems_mfa_required: 'true'
128
102
  post_install_message:
129
103
  rdoc_options: []
130
104
  require_paths: