active_shopify_graphql 0.2.0 → 0.4.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 +16 -0
- data/AGENTS.md +1 -0
- data/README.md +149 -10
- data/lib/active_shopify_graphql/associations.rb +8 -4
- 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 +33 -5
- data/lib/active_shopify_graphql/fragment_builder.rb +37 -4
- data/lib/active_shopify_graphql/graphql_associations.rb +245 -0
- 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 +3 -0
- metadata +4 -29
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f0b5f3a4ce7ed92feb8483e2c064379788138d7a7336daa849a81715fd0dd3f2
|
|
4
|
+
data.tar.gz: b48985b14a5e07a5e5273431d82c06756dfc7feab40ddd27f65fa313c080d261
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5f7cd6debcc40234ea46da5b8e88332a6c715db5d78ef82040ec1827a963557b60589f70df2261d09f6b08e699233b2aad7eaf00eb20eb6a2f80eb35608d6258
|
|
7
|
+
data.tar.gz: e3d0c95ba71e3be17f0a57ba26cd6f25d7c6ea783dc4b0608609f6344bf783ef2aec0e5b532b83d1a90084c3e3389bc8018396911a02c2da06d9f2c523b33c66
|
data/.rubocop.yml
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
NewCops: enable
|
|
3
|
+
SuggestExtensions: false
|
|
4
|
+
|
|
1
5
|
Style/StringLiterals:
|
|
2
6
|
Enabled: false
|
|
3
7
|
|
|
@@ -48,3 +52,15 @@ Naming/MethodParameterName:
|
|
|
48
52
|
|
|
49
53
|
Naming/AccessorMethodName:
|
|
50
54
|
Enabled: false
|
|
55
|
+
|
|
56
|
+
Style/ArgumentsForwarding:
|
|
57
|
+
Enabled: false
|
|
58
|
+
|
|
59
|
+
Style/KeywordArgumentsMerging:
|
|
60
|
+
Enabled: false
|
|
61
|
+
|
|
62
|
+
Naming/BlockForwarding:
|
|
63
|
+
Enabled: false
|
|
64
|
+
|
|
65
|
+
Lint/DuplicateBranch:
|
|
66
|
+
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
|
|
@@ -296,9 +323,38 @@ end
|
|
|
296
323
|
|
|
297
324
|
The associations automatically handle Shopify GID format conversion, extracting numeric IDs when needed for querying related records.
|
|
298
325
|
|
|
326
|
+
## Bridging ActiveRecord with GraphQL
|
|
327
|
+
|
|
328
|
+
The `GraphQLAssociations` module allows ActiveRecord models (or duck-typed objects) to define associations to Shopify GraphQL models:
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
class Reward < ApplicationRecord
|
|
332
|
+
include ActiveShopifyGraphQL::GraphQLAssociations
|
|
333
|
+
|
|
334
|
+
belongs_to_graphql :customer # Expects shopify_customer_id column
|
|
335
|
+
has_one_graphql :primary_address,
|
|
336
|
+
class_name: "Address",
|
|
337
|
+
foreign_key: :customer_id
|
|
338
|
+
has_many_graphql :variants,
|
|
339
|
+
class_name: "ProductVariant"
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
reward = Reward.find(1)
|
|
343
|
+
reward.customer # Loads Customer from shopify_customer_id
|
|
344
|
+
reward.primary_address # Queries Address.where(customer_id: reward.shopify_customer_id).first
|
|
345
|
+
reward.variants # Queries ProductVariant.where({})
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
**Available associations:**
|
|
349
|
+
- `belongs_to_graphql` - Loads single GraphQL object via stored GID/ID
|
|
350
|
+
- `has_one_graphql` - Queries first GraphQL object matching foreign key
|
|
351
|
+
- `has_many_graphql` - Queries multiple GraphQL objects with optional filtering
|
|
352
|
+
|
|
353
|
+
All associations support `class_name`, `foreign_key`, `primary_key`, and `loader_class` options. Results are automatically cached and setter methods are provided for testing.
|
|
354
|
+
|
|
299
355
|
## GraphQL Connections
|
|
300
356
|
|
|
301
|
-
ActiveShopifyGraphQL supports GraphQL connections for loading related data from Shopify APIs. Connections provide both lazy and eager loading patterns
|
|
357
|
+
ActiveShopifyGraphQL supports GraphQL connections for loading related data from Shopify APIs. Connections provide both lazy and eager loading patterns.
|
|
302
358
|
|
|
303
359
|
### Defining Connections
|
|
304
360
|
|
|
@@ -329,12 +385,14 @@ class Customer
|
|
|
329
385
|
}
|
|
330
386
|
|
|
331
387
|
# Example of a "scoped" connection
|
|
388
|
+
# Multiple connections can use the same query_name with different arguments
|
|
332
389
|
has_many_connected :recent_orders,
|
|
333
|
-
query_name: "orders", #
|
|
334
|
-
class_name: "
|
|
335
|
-
default_arguments: { #
|
|
336
|
-
first:
|
|
337
|
-
reverse: true
|
|
390
|
+
query_name: "orders", # Uses the same GraphQL field as :orders
|
|
391
|
+
class_name: "Order", # The class would be inferred to RecentOrder without this
|
|
392
|
+
default_arguments: { # Different arguments for filtering
|
|
393
|
+
first: 5,
|
|
394
|
+
reverse: true,
|
|
395
|
+
sort_key: 'CREATED_AT'
|
|
338
396
|
}
|
|
339
397
|
end
|
|
340
398
|
|
|
@@ -349,6 +407,23 @@ class Order
|
|
|
349
407
|
end
|
|
350
408
|
```
|
|
351
409
|
|
|
410
|
+
**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:
|
|
411
|
+
|
|
412
|
+
```graphql
|
|
413
|
+
fragment CustomerFragment on Customer {
|
|
414
|
+
id
|
|
415
|
+
displayName
|
|
416
|
+
orders(first: 2) {
|
|
417
|
+
edges { node { id name } }
|
|
418
|
+
}
|
|
419
|
+
recent_orders: orders(first: 5, reverse: true, sortKey: CREATED_AT) {
|
|
420
|
+
edges { node { id name } }
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
This allows you to have multiple "views" of the same connection with different filtering or sorting parameters, all in a single query.
|
|
426
|
+
|
|
352
427
|
### Lazy Loading (Default Behavior)
|
|
353
428
|
|
|
354
429
|
Connections are loaded lazily when accessed. A separate GraphQL query is fired when the connection is first accessed:
|
|
@@ -505,6 +580,69 @@ expect(customer.orders.size).to eq(2)
|
|
|
505
580
|
expect(customer.orders.first.name).to eq('#1001')
|
|
506
581
|
```
|
|
507
582
|
|
|
583
|
+
### Inverse Relationships with `inverse_of`
|
|
584
|
+
|
|
585
|
+
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.
|
|
586
|
+
|
|
587
|
+
#### Basic Usage
|
|
588
|
+
|
|
589
|
+
```ruby
|
|
590
|
+
class Product
|
|
591
|
+
include ActiveShopifyGraphQL::Base
|
|
592
|
+
|
|
593
|
+
graphql_type 'Product'
|
|
594
|
+
|
|
595
|
+
attribute :id
|
|
596
|
+
attribute :title
|
|
597
|
+
|
|
598
|
+
# Define inverse relationship to avoid redundant queries
|
|
599
|
+
has_many_connected :variants,
|
|
600
|
+
class_name: "ProductVariant",
|
|
601
|
+
inverse_of: :product, # Points to the inverse connection name
|
|
602
|
+
default_arguments: { first: 10 }
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
class ProductVariant
|
|
606
|
+
include ActiveShopifyGraphQL::Base
|
|
607
|
+
|
|
608
|
+
graphql_type 'ProductVariant'
|
|
609
|
+
|
|
610
|
+
attribute :id
|
|
611
|
+
attribute :title
|
|
612
|
+
|
|
613
|
+
# Define inverse relationship back to Product
|
|
614
|
+
has_one_connected :product,
|
|
615
|
+
inverse_of: :variants # Points back to the parent's connection
|
|
616
|
+
end
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
#### With Eager Loading
|
|
620
|
+
|
|
621
|
+
```ruby
|
|
622
|
+
# Load product with variants in a single GraphQL query
|
|
623
|
+
product = Product.includes(:variants).find(123)
|
|
624
|
+
|
|
625
|
+
# Access variants - already loaded, no additional query
|
|
626
|
+
product.variants.each do |variant|
|
|
627
|
+
# Access product from variant - uses cached parent, NO QUERY!
|
|
628
|
+
puts variant.product.title
|
|
629
|
+
end
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
#### With Lazy Loading
|
|
633
|
+
|
|
634
|
+
```ruby
|
|
635
|
+
# Load product without preloading variants
|
|
636
|
+
product = Product.find(123)
|
|
637
|
+
|
|
638
|
+
# First access triggers a query to load variants
|
|
639
|
+
variants = product.variants.to_a
|
|
640
|
+
|
|
641
|
+
# Access product from variant - uses cached parent, NO QUERY!
|
|
642
|
+
variant = variants.first
|
|
643
|
+
puts variant.product.title # Returns the same product instance
|
|
644
|
+
```
|
|
645
|
+
|
|
508
646
|
### Connection Configuration
|
|
509
647
|
|
|
510
648
|
Connections automatically infer sensible defaults but can be customized:
|
|
@@ -514,6 +652,7 @@ Connections automatically infer sensible defaults but can be customized:
|
|
|
514
652
|
- **foreign_key**: Field used to filter connection records (defaults to `{model_name}_id`)
|
|
515
653
|
- **loader_class**: Custom loader class (defaults to model's default loader)
|
|
516
654
|
- **eager_load**: Whether to automatically eager load this connection on find/where queries (default: false)
|
|
655
|
+
- **inverse_of**: The name of the inverse connection on the target model (optional, enables automatic inverse caching)
|
|
517
656
|
- **default_arguments**: Hash of default arguments to pass to the GraphQL query (e.g., `{ first: 10, sort_key: 'CREATED_AT' }`)
|
|
518
657
|
|
|
519
658
|
### Error Handling
|
|
@@ -527,7 +666,7 @@ Connection queries use the same error handling as regular model queries. If a co
|
|
|
527
666
|
- [x] Support `Model.where(param: value)` proxying params to the GraphQL query attribute
|
|
528
667
|
- [x] Query optimization with `select` method
|
|
529
668
|
- [x] GraphQL connections with lazy and eager loading via `Customer.includes(:orders).find(id)`
|
|
530
|
-
- [ ] Support for paginating query results
|
|
669
|
+
- [ ] Support for paginating query results with cursors
|
|
531
670
|
- [ ] Better error handling and retry mechanisms for GraphQL API calls
|
|
532
671
|
- [ ] Caching layer for frequently accessed data
|
|
533
672
|
|
|
@@ -36,9 +36,6 @@ module ActiveShopifyGraphQL
|
|
|
36
36
|
primary_key_value = send(association_primary_key)
|
|
37
37
|
return @_association_cache[name] = [] if primary_key_value.blank?
|
|
38
38
|
|
|
39
|
-
# Extract numeric ID from Shopify GID if needed
|
|
40
|
-
primary_key_value = primary_key_value.to_plain_id if primary_key_value.gid?
|
|
41
|
-
|
|
42
39
|
association_class = association_class_name.constantize
|
|
43
40
|
@_association_cache[name] = association_class.where(association_foreign_key => primary_key_value)
|
|
44
41
|
end
|
|
@@ -73,7 +70,14 @@ module ActiveShopifyGraphQL
|
|
|
73
70
|
return @_association_cache[name] = nil if primary_key_value.blank?
|
|
74
71
|
|
|
75
72
|
# Extract numeric ID from Shopify GID if needed
|
|
76
|
-
|
|
73
|
+
if primary_key_value.is_a?(String)
|
|
74
|
+
begin
|
|
75
|
+
parsed_gid = URI::GID.parse(primary_key_value)
|
|
76
|
+
primary_key_value = parsed_gid.model_id
|
|
77
|
+
rescue URI::InvalidURIError, URI::BadURIError, ArgumentError
|
|
78
|
+
# Not a GID, use value as-is
|
|
79
|
+
end
|
|
80
|
+
end
|
|
77
81
|
|
|
78
82
|
association_class = association_class_name.constantize
|
|
79
83
|
@_association_cache[name] = association_class.find_by(association_foreign_key => primary_key_value)
|
|
@@ -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
|
|
@@ -8,14 +8,23 @@ module ActiveShopifyGraphQL
|
|
|
8
8
|
# Find a single record by ID using the provided loader
|
|
9
9
|
# @param id [String, Integer] The record ID (will be converted to GID automatically)
|
|
10
10
|
# @param loader [ActiveShopifyGraphQL::Loader] The loader to use for fetching data
|
|
11
|
-
# @return [Object
|
|
11
|
+
# @return [Object] The model instance
|
|
12
|
+
# @raise [ActiveShopifyGraphQL::ObjectNotFoundError] If the record is not found
|
|
12
13
|
def find(id, loader: default_loader)
|
|
13
14
|
gid = GidHelper.normalize_gid(id, model_name.name.demodulize)
|
|
14
|
-
attributes = loader.load_attributes(gid)
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
# If we have included connections, we need to handle inverse_of properly
|
|
17
|
+
result =
|
|
18
|
+
if loader.has_included_connections?
|
|
19
|
+
loader.load_with_instance(gid, self)
|
|
20
|
+
else
|
|
21
|
+
attributes = loader.load_attributes(gid)
|
|
22
|
+
attributes.nil? ? nil : new(attributes)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
raise ObjectNotFoundError, "Couldn't find #{name} with id=#{id}" if result.nil?
|
|
17
26
|
|
|
18
|
-
|
|
27
|
+
result
|
|
19
28
|
end
|
|
20
29
|
|
|
21
30
|
# Returns the default loader for this model's queries
|
|
@@ -72,6 +81,25 @@ module ActiveShopifyGraphQL
|
|
|
72
81
|
selected_class
|
|
73
82
|
end
|
|
74
83
|
|
|
84
|
+
# Find a single record by attribute conditions
|
|
85
|
+
# @param conditions [Hash] The conditions to query (e.g., { email: "example@test.com", first_name: "John" })
|
|
86
|
+
# @param options [Hash] Options hash containing loader
|
|
87
|
+
# @option options [ActiveShopifyGraphQL::Loader] :loader The loader to use for fetching data
|
|
88
|
+
# @return [Object, nil] The first matching model instance or nil if not found
|
|
89
|
+
# @raise [ArgumentError] If any attribute is not valid for querying
|
|
90
|
+
#
|
|
91
|
+
# @example
|
|
92
|
+
# # Keyword argument style (recommended)
|
|
93
|
+
# Customer.find_by(email: "john@example.com")
|
|
94
|
+
# Customer.find_by(first_name: "John", country: "Canada")
|
|
95
|
+
# Customer.find_by(orders_count: { gte: 5 })
|
|
96
|
+
#
|
|
97
|
+
# # Hash style with options
|
|
98
|
+
# Customer.find_by({ email: "john@example.com" }, loader: custom_loader)
|
|
99
|
+
def find_by(conditions_or_first_condition = {}, *args, **options)
|
|
100
|
+
where(conditions_or_first_condition, *args, **options.merge(limit: 1)).first
|
|
101
|
+
end
|
|
102
|
+
|
|
75
103
|
# Query for multiple records using attribute conditions
|
|
76
104
|
# @param conditions [Hash] The conditions to query (e.g., { email: "example@test.com", first_name: "John" })
|
|
77
105
|
# @param options [Hash] Options hash containing loader and limit (when first arg is a Hash)
|
|
@@ -130,7 +158,7 @@ module ActiveShopifyGraphQL
|
|
|
130
158
|
return unless invalid_attrs.any?
|
|
131
159
|
|
|
132
160
|
raise ArgumentError, "Invalid attributes for #{name}: #{invalid_attrs.join(', ')}. " \
|
|
133
|
-
|
|
161
|
+
"Available attributes are: #{available_attrs.join(', ')}"
|
|
134
162
|
end
|
|
135
163
|
|
|
136
164
|
# Gets all available attributes for selection
|