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 +4 -4
- data/.rubocop.yml +15 -0
- data/AGENTS.md +1 -0
- data/README.md +120 -10
- data/lib/active_shopify_graphql/attributes.rb +3 -2
- data/lib/active_shopify_graphql/base.rb +38 -2
- data/lib/active_shopify_graphql/connection_loader.rb +1 -1
- data/lib/active_shopify_graphql/connections/connection_proxy.rb +32 -0
- data/lib/active_shopify_graphql/connections.rb +54 -26
- data/lib/active_shopify_graphql/finder_methods.rb +9 -4
- data/lib/active_shopify_graphql/fragment_builder.rb +37 -4
- data/lib/active_shopify_graphql/includes_scope.rb +48 -0
- data/lib/active_shopify_graphql/loader.rb +34 -3
- data/lib/active_shopify_graphql/loader_context.rb +1 -1
- data/lib/active_shopify_graphql/loaders/admin_api_loader.rb +1 -1
- data/lib/active_shopify_graphql/query_node.rb +17 -4
- data/lib/active_shopify_graphql/query_tree.rb +0 -5
- data/lib/active_shopify_graphql/response_mapper.rb +63 -16
- data/lib/active_shopify_graphql/search_query.rb +32 -0
- data/lib/active_shopify_graphql/version.rb +1 -1
- data/lib/active_shopify_graphql.rb +1 -0
- metadata +3 -29
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6e4003c2f9180531bc4c3a08e9c921d16402642c60c45c777ac49726b5a6a3f2
|
|
4
|
+
data.tar.gz: df5619e4f4845d688cdd1beb5228047d52022eca04e3b1c3479265ed1b59d9c1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
|
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", #
|
|
334
|
-
class_name: "
|
|
335
|
-
default_arguments: { #
|
|
336
|
-
first:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
36
|
-
if config[:
|
|
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
|
-
|
|
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).
|
|
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
|
|
@@ -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
|
|
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
|
-
"#{
|
|
92
|
+
"#{field_name}#{args_string} { edges { node { #{fields_string} } } }"
|
|
88
93
|
else
|
|
89
94
|
<<~GRAPHQL.strip
|
|
90
|
-
#{
|
|
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
|
-
"#{
|
|
120
|
+
"#{field_name}#{args_string} { #{fields_string} }"
|
|
113
121
|
else
|
|
114
|
-
"#{
|
|
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.
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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[
|
|
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(
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
@@ -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.
|
|
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:
|