active_shopify_graphql 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/lint.yml +35 -0
- data/.github/workflows/test.yml +35 -0
- data/.rubocop.yml +50 -0
- data/AGENTS.md +53 -0
- data/LICENSE.txt +21 -0
- data/README.md +544 -0
- data/Rakefile +8 -0
- data/lib/active_shopify_graphql/associations.rb +90 -0
- data/lib/active_shopify_graphql/attributes.rb +49 -0
- data/lib/active_shopify_graphql/base.rb +29 -0
- data/lib/active_shopify_graphql/configuration.rb +29 -0
- data/lib/active_shopify_graphql/connection_loader.rb +96 -0
- data/lib/active_shopify_graphql/connections/connection_proxy.rb +112 -0
- data/lib/active_shopify_graphql/connections.rb +170 -0
- data/lib/active_shopify_graphql/finder_methods.rb +154 -0
- data/lib/active_shopify_graphql/fragment_builder.rb +195 -0
- data/lib/active_shopify_graphql/gid_helper.rb +54 -0
- data/lib/active_shopify_graphql/graphql_type_resolver.rb +91 -0
- data/lib/active_shopify_graphql/loader.rb +183 -0
- data/lib/active_shopify_graphql/loader_context.rb +88 -0
- data/lib/active_shopify_graphql/loader_switchable.rb +121 -0
- data/lib/active_shopify_graphql/loaders/admin_api_loader.rb +32 -0
- data/lib/active_shopify_graphql/loaders/customer_account_api_loader.rb +71 -0
- data/lib/active_shopify_graphql/metafield_attributes.rb +61 -0
- data/lib/active_shopify_graphql/query_node.rb +160 -0
- data/lib/active_shopify_graphql/query_tree.rb +204 -0
- data/lib/active_shopify_graphql/response_mapper.rb +202 -0
- data/lib/active_shopify_graphql/search_query.rb +71 -0
- data/lib/active_shopify_graphql/version.rb +5 -0
- data/lib/active_shopify_graphql.rb +34 -0
- metadata +147 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
# Builds GraphQL fragments from model attributes and connections.
|
|
5
|
+
class FragmentBuilder
|
|
6
|
+
def initialize(context)
|
|
7
|
+
@context = context
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Build a complete fragment node with all fields and connections
|
|
11
|
+
def build
|
|
12
|
+
raise NotImplementedError, "#{@context.loader_class} must define attributes" if @context.defined_attributes.empty?
|
|
13
|
+
|
|
14
|
+
fragment_node = QueryNode.new(
|
|
15
|
+
name: @context.fragment_name,
|
|
16
|
+
arguments: { on: @context.graphql_type },
|
|
17
|
+
node_type: :fragment
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Add field nodes from attributes
|
|
21
|
+
build_field_nodes.each { |node| fragment_node.add_child(node) }
|
|
22
|
+
|
|
23
|
+
# Add connection nodes
|
|
24
|
+
build_connection_nodes.each { |node| fragment_node.add_child(node) }
|
|
25
|
+
|
|
26
|
+
fragment_node
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Build field nodes from attribute definitions (protected for recursive calls)
|
|
30
|
+
def build_field_nodes
|
|
31
|
+
path_tree = {}
|
|
32
|
+
metafield_aliases = {}
|
|
33
|
+
|
|
34
|
+
# Build a tree structure for nested paths
|
|
35
|
+
@context.defined_attributes.each_value do |config|
|
|
36
|
+
if config[:is_metafield]
|
|
37
|
+
store_metafield_config(metafield_aliases, config)
|
|
38
|
+
else
|
|
39
|
+
build_path_tree(path_tree, config[:path])
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Convert tree to QueryNode objects
|
|
44
|
+
nodes_from_tree(path_tree) + metafield_nodes(metafield_aliases)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Build QueryNode objects for all connections (protected for recursive calls)
|
|
48
|
+
def build_connection_nodes
|
|
49
|
+
return [] if @context.included_connections.empty?
|
|
50
|
+
|
|
51
|
+
connections = @context.connections
|
|
52
|
+
return [] if connections.empty?
|
|
53
|
+
|
|
54
|
+
normalized_includes = normalize_includes(@context.included_connections)
|
|
55
|
+
|
|
56
|
+
normalized_includes.filter_map do |connection_name, nested_includes|
|
|
57
|
+
connection_config = connections[connection_name]
|
|
58
|
+
next unless connection_config
|
|
59
|
+
|
|
60
|
+
build_connection_node(connection_config, nested_includes)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def store_metafield_config(metafield_aliases, config)
|
|
67
|
+
alias_name = config[:metafield_alias]
|
|
68
|
+
value_field = config[:type] == :json ? 'jsonValue' : 'value'
|
|
69
|
+
|
|
70
|
+
metafield_aliases[alias_name] = {
|
|
71
|
+
namespace: config[:metafield_namespace],
|
|
72
|
+
key: config[:metafield_key],
|
|
73
|
+
value_field: value_field
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def build_path_tree(path_tree, path)
|
|
78
|
+
path_parts = path.split('.')
|
|
79
|
+
current_level = path_tree
|
|
80
|
+
|
|
81
|
+
path_parts.each_with_index do |part, index|
|
|
82
|
+
if index == path_parts.length - 1
|
|
83
|
+
current_level[part] = true
|
|
84
|
+
else
|
|
85
|
+
current_level[part] ||= {}
|
|
86
|
+
current_level = current_level[part]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def nodes_from_tree(tree)
|
|
92
|
+
tree.map do |key, value|
|
|
93
|
+
if value == true
|
|
94
|
+
QueryNode.new(name: key, node_type: :field)
|
|
95
|
+
else
|
|
96
|
+
children = nodes_from_tree(value)
|
|
97
|
+
QueryNode.new(name: key, node_type: :field, children: children)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def metafield_nodes(metafield_aliases)
|
|
103
|
+
metafield_aliases.map do |alias_name, config|
|
|
104
|
+
value_node = QueryNode.new(name: config[:value_field], node_type: :field)
|
|
105
|
+
QueryNode.new(
|
|
106
|
+
name: "metafield",
|
|
107
|
+
alias_name: alias_name,
|
|
108
|
+
arguments: { namespace: config[:namespace], key: config[:key] },
|
|
109
|
+
node_type: :field,
|
|
110
|
+
children: [value_node]
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def build_connection_node(connection_config, nested_includes)
|
|
116
|
+
target_class = connection_config[:class_name].constantize
|
|
117
|
+
target_context = @context.for_model(target_class, new_connections: nested_includes)
|
|
118
|
+
|
|
119
|
+
# Build child nodes for the target model
|
|
120
|
+
child_nodes = build_target_field_nodes(target_context, nested_includes)
|
|
121
|
+
|
|
122
|
+
query_name = connection_config[:query_name]
|
|
123
|
+
connection_type = connection_config[:type] || :connection
|
|
124
|
+
formatted_args = (connection_config[:default_arguments] || {}).transform_keys(&:to_sym)
|
|
125
|
+
|
|
126
|
+
node_type = connection_type == :singular ? :singular : :connection
|
|
127
|
+
QueryNode.new(
|
|
128
|
+
name: query_name,
|
|
129
|
+
arguments: formatted_args,
|
|
130
|
+
node_type: node_type,
|
|
131
|
+
children: child_nodes
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def build_target_field_nodes(target_context, nested_includes)
|
|
136
|
+
# Build attribute nodes
|
|
137
|
+
attribute_nodes = if target_context.defined_attributes.any?
|
|
138
|
+
FragmentBuilder.new(target_context.with_connections([])).build_field_nodes
|
|
139
|
+
else
|
|
140
|
+
[QueryNode.new(name: "id", node_type: :field)]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Build nested connection nodes
|
|
144
|
+
return attribute_nodes if nested_includes.empty?
|
|
145
|
+
|
|
146
|
+
nested_builder = FragmentBuilder.new(target_context)
|
|
147
|
+
nested_connection_nodes = nested_builder.build_connection_nodes
|
|
148
|
+
attribute_nodes + nested_connection_nodes
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Normalize includes from various formats to a consistent hash structure
|
|
152
|
+
def normalize_includes(includes)
|
|
153
|
+
includes = Array(includes)
|
|
154
|
+
includes.each_with_object({}) do |inc, normalized|
|
|
155
|
+
case inc
|
|
156
|
+
when Hash
|
|
157
|
+
inc.each do |key, value|
|
|
158
|
+
key = key.to_sym
|
|
159
|
+
normalized[key] ||= []
|
|
160
|
+
case value
|
|
161
|
+
when Hash then normalized[key] << value
|
|
162
|
+
when Array then normalized[key].concat(value)
|
|
163
|
+
else normalized[key] << value
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
when Symbol, String
|
|
167
|
+
normalized[inc.to_sym] ||= []
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
class << self
|
|
173
|
+
# Expose for external use (QueryTree needs this)
|
|
174
|
+
def normalize_includes(includes)
|
|
175
|
+
includes = Array(includes)
|
|
176
|
+
includes.each_with_object({}) do |inc, normalized|
|
|
177
|
+
case inc
|
|
178
|
+
when Hash
|
|
179
|
+
inc.each do |key, value|
|
|
180
|
+
key = key.to_sym
|
|
181
|
+
normalized[key] ||= []
|
|
182
|
+
case value
|
|
183
|
+
when Hash then normalized[key] << value
|
|
184
|
+
when Array then normalized[key].concat(value)
|
|
185
|
+
else normalized[key] << value
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
when Symbol, String
|
|
189
|
+
normalized[inc.to_sym] ||= []
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
# Helper module for handling Shopify Global IDs (GIDs)
|
|
5
|
+
# Provides utilities for parsing and building GIDs according to the URI::GID standard
|
|
6
|
+
module GidHelper
|
|
7
|
+
# Normalize an ID value to a proper Shopify GID format
|
|
8
|
+
# If the ID is already a valid GID, returns it as-is
|
|
9
|
+
# Otherwise, builds a new GID using the provided model name
|
|
10
|
+
#
|
|
11
|
+
# @param id [String, Integer] The ID value (can be numeric or existing GID)
|
|
12
|
+
# @param model_name [String] The GraphQL type name (e.g., "Customer", "Order")
|
|
13
|
+
# @return [String] The normalized GID in format "gid://shopify/ModelName/id"
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# normalize_gid(123, "Customer")
|
|
17
|
+
# # => "gid://shopify/Customer/123"
|
|
18
|
+
#
|
|
19
|
+
# normalize_gid("gid://shopify/Customer/123", "Customer")
|
|
20
|
+
# # => "gid://shopify/Customer/123"
|
|
21
|
+
#
|
|
22
|
+
def self.normalize_gid(id, model_name)
|
|
23
|
+
# Check if id is already a valid GID
|
|
24
|
+
begin
|
|
25
|
+
parsed_gid = URI::GID.parse(id)
|
|
26
|
+
return id if parsed_gid
|
|
27
|
+
rescue URI::InvalidURIError, URI::BadURIError, ArgumentError
|
|
28
|
+
# Not a valid GID, proceed to build one
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Build GID from the provided ID and model name
|
|
32
|
+
URI::GID.build(app: "shopify", model_name: model_name, model_id: id).to_s
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Check if a value is a valid Shopify GID
|
|
36
|
+
#
|
|
37
|
+
# @param value [String] The value to check
|
|
38
|
+
# @return [Boolean] true if the value is a valid GID, false otherwise
|
|
39
|
+
#
|
|
40
|
+
# @example
|
|
41
|
+
# valid_gid?("gid://shopify/Customer/123")
|
|
42
|
+
# # => true
|
|
43
|
+
#
|
|
44
|
+
# valid_gid?("123")
|
|
45
|
+
# # => false
|
|
46
|
+
#
|
|
47
|
+
def self.valid_gid?(value)
|
|
48
|
+
URI::GID.parse(value)
|
|
49
|
+
true
|
|
50
|
+
rescue URI::InvalidURIError, URI::BadURIError, ArgumentError
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
# Centralizes GraphQL type resolution logic.
|
|
5
|
+
module GraphqlTypeResolver
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
class_methods do
|
|
9
|
+
# Set or get the base GraphQL type for this model.
|
|
10
|
+
#
|
|
11
|
+
# @param type [String, nil] The GraphQL type name to set, or nil to get
|
|
12
|
+
# @return [String] The GraphQL type name
|
|
13
|
+
# @raise [NotImplementedError] If no type is defined
|
|
14
|
+
def graphql_type(type = nil)
|
|
15
|
+
if type
|
|
16
|
+
if @current_loader_context
|
|
17
|
+
@loader_graphql_types ||= {}
|
|
18
|
+
@loader_graphql_types[@current_loader_context] = type
|
|
19
|
+
else
|
|
20
|
+
@base_graphql_type = type
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@base_graphql_type || raise(NotImplementedError, "#{self} must define graphql_type")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Get the GraphQL type for a specific loader class.
|
|
28
|
+
# Resolution order:
|
|
29
|
+
# 1. Loader-specific type defined via `for_loader`
|
|
30
|
+
# 2. Base graphql_type defined on the model
|
|
31
|
+
# 3. Type defined on the loader class itself
|
|
32
|
+
# 4. Inferred from model class name
|
|
33
|
+
#
|
|
34
|
+
# @param loader_class [Class] The loader class to resolve type for
|
|
35
|
+
# @return [String] The resolved GraphQL type
|
|
36
|
+
# @raise [NotImplementedError] If no type can be resolved
|
|
37
|
+
def graphql_type_for_loader(loader_class)
|
|
38
|
+
# 1. Check loader-specific override
|
|
39
|
+
return @loader_graphql_types[loader_class] if @loader_graphql_types&.key?(loader_class)
|
|
40
|
+
|
|
41
|
+
# 2. Check base graphql_type
|
|
42
|
+
return @base_graphql_type if @base_graphql_type
|
|
43
|
+
|
|
44
|
+
# 3. Check loader class itself
|
|
45
|
+
loader_type = loader_class.instance_variable_get(:@graphql_type)
|
|
46
|
+
return loader_type if loader_type
|
|
47
|
+
|
|
48
|
+
# 4. Infer from model name
|
|
49
|
+
return name.demodulize if respond_to?(:name) && name
|
|
50
|
+
|
|
51
|
+
raise NotImplementedError,
|
|
52
|
+
"#{self} must define graphql_type or #{loader_class} must define graphql_type"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Resolve the GraphQL type from any source (model class, loader, or value).
|
|
56
|
+
# Useful for external callers that need to resolve type from various inputs.
|
|
57
|
+
#
|
|
58
|
+
# @param model_class [Class, nil] The model class
|
|
59
|
+
# @param loader_class [Class, nil] The loader class
|
|
60
|
+
# @return [String] The resolved GraphQL type
|
|
61
|
+
def resolve_graphql_type(model_class: nil, loader_class: nil)
|
|
62
|
+
if model_class.respond_to?(:graphql_type_for_loader) && loader_class
|
|
63
|
+
model_class.graphql_type_for_loader(loader_class)
|
|
64
|
+
elsif model_class.respond_to?(:graphql_type)
|
|
65
|
+
model_class.graphql_type
|
|
66
|
+
elsif loader_class.respond_to?(:graphql_type)
|
|
67
|
+
loader_class.graphql_type
|
|
68
|
+
elsif model_class.respond_to?(:name) && model_class.name
|
|
69
|
+
model_class.name.demodulize
|
|
70
|
+
else
|
|
71
|
+
raise ArgumentError, "Cannot resolve graphql_type from provided arguments"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Module-level resolver for convenience
|
|
77
|
+
def self.resolve(model_class: nil, loader_class: nil)
|
|
78
|
+
if model_class.respond_to?(:graphql_type_for_loader) && loader_class
|
|
79
|
+
model_class.graphql_type_for_loader(loader_class)
|
|
80
|
+
elsif model_class.respond_to?(:graphql_type)
|
|
81
|
+
model_class.graphql_type
|
|
82
|
+
elsif loader_class.respond_to?(:graphql_type)
|
|
83
|
+
loader_class.graphql_type
|
|
84
|
+
elsif model_class.respond_to?(:name) && model_class.name
|
|
85
|
+
model_class.name.demodulize
|
|
86
|
+
else
|
|
87
|
+
raise ArgumentError, "Cannot resolve graphql_type"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_model/type'
|
|
4
|
+
require 'global_id'
|
|
5
|
+
|
|
6
|
+
module ActiveShopifyGraphQL
|
|
7
|
+
# Base loader class that orchestrates GraphQL query execution and response mapping.
|
|
8
|
+
# Refactored to use LoaderContext for cleaner parameter management.
|
|
9
|
+
class Loader
|
|
10
|
+
class << self
|
|
11
|
+
# Set or get the GraphQL type for this loader
|
|
12
|
+
def graphql_type(type = nil)
|
|
13
|
+
return @graphql_type = type if type
|
|
14
|
+
|
|
15
|
+
# Try to get GraphQL type from associated model class first
|
|
16
|
+
return model_class.graphql_type_for_loader(self) if model_class
|
|
17
|
+
|
|
18
|
+
@graphql_type || raise(NotImplementedError, "#{self} must define graphql_type")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Get the model class associated with this loader
|
|
22
|
+
def model_class
|
|
23
|
+
@model_class ||= infer_model_class
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_writer :model_class
|
|
27
|
+
|
|
28
|
+
# Get attributes from the model class for this loader
|
|
29
|
+
def defined_attributes
|
|
30
|
+
return {} unless model_class
|
|
31
|
+
|
|
32
|
+
model_class.attributes_for_loader(self)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def infer_model_class
|
|
38
|
+
return nil unless @graphql_type
|
|
39
|
+
|
|
40
|
+
Object.const_get(@graphql_type)
|
|
41
|
+
rescue NameError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Initialize loader with optional model class and configuration
|
|
47
|
+
def initialize(model_class = nil, selected_attributes: nil, included_connections: nil, **)
|
|
48
|
+
@model_class = model_class || self.class.model_class
|
|
49
|
+
@selected_attributes = selected_attributes&.map(&:to_sym)
|
|
50
|
+
@included_connections = included_connections || []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Build the LoaderContext for this loader instance
|
|
54
|
+
def context
|
|
55
|
+
@context ||= LoaderContext.new(
|
|
56
|
+
graphql_type: graphql_type,
|
|
57
|
+
loader_class: self.class,
|
|
58
|
+
defined_attributes: defined_attributes,
|
|
59
|
+
model_class: @model_class,
|
|
60
|
+
included_connections: @included_connections
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Get GraphQL type for this loader instance
|
|
65
|
+
def graphql_type
|
|
66
|
+
GraphqlTypeResolver.resolve(model_class: @model_class, loader_class: self.class)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Get defined attributes for this loader instance
|
|
70
|
+
def defined_attributes
|
|
71
|
+
attrs = if @model_class
|
|
72
|
+
@model_class.attributes_for_loader(self.class)
|
|
73
|
+
else
|
|
74
|
+
self.class.defined_attributes
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
filter_selected_attributes(attrs)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Returns the complete GraphQL fragment
|
|
81
|
+
def fragment
|
|
82
|
+
FragmentBuilder.new(context).build
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Delegate query building methods
|
|
86
|
+
def query_name(model_type = nil)
|
|
87
|
+
(model_type || graphql_type).downcase
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def fragment_name(model_type = nil)
|
|
91
|
+
"#{model_type || graphql_type}Fragment"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def graphql_query(_model_type = nil)
|
|
95
|
+
QueryTree.build_single_record_query(context)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Map the GraphQL response to model attributes
|
|
99
|
+
def map_response_to_attributes(response_data)
|
|
100
|
+
mapper = ResponseMapper.new(context)
|
|
101
|
+
attributes = mapper.map_response(response_data)
|
|
102
|
+
|
|
103
|
+
# If we have included connections, extract and cache them
|
|
104
|
+
if @included_connections.any?
|
|
105
|
+
connection_data = mapper.extract_connection_data(response_data)
|
|
106
|
+
attributes[:_connection_cache] = connection_data unless connection_data.empty?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
attributes
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Executes the GraphQL query and returns the mapped attributes hash
|
|
113
|
+
def load_attributes(id)
|
|
114
|
+
query = graphql_query
|
|
115
|
+
response_data = perform_graphql_query(query, id: id)
|
|
116
|
+
|
|
117
|
+
return nil if response_data.nil?
|
|
118
|
+
|
|
119
|
+
map_response_to_attributes(response_data)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Executes a collection query using Shopify's search syntax
|
|
123
|
+
def load_collection(conditions = {}, limit: 250)
|
|
124
|
+
search_query = SearchQuery.new(conditions)
|
|
125
|
+
collection_query_name = query_name.pluralize
|
|
126
|
+
variables = { query: search_query.to_s, first: limit }
|
|
127
|
+
|
|
128
|
+
query = QueryTree.build_collection_query(
|
|
129
|
+
context,
|
|
130
|
+
query_name: collection_query_name,
|
|
131
|
+
variables: variables,
|
|
132
|
+
connection_type: :nodes_only
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
response = perform_graphql_query(query, **variables)
|
|
136
|
+
validate_search_response(response)
|
|
137
|
+
map_collection_response(response, collection_query_name)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Load records for a connection query
|
|
141
|
+
def load_connection_records(query_name, variables, parent = nil, connection_config = nil)
|
|
142
|
+
connection_loader = ConnectionLoader.new(context, loader_instance: self)
|
|
143
|
+
connection_loader.load_records(query_name, variables, parent, connection_config)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Abstract method for executing GraphQL queries
|
|
147
|
+
def perform_graphql_query(query, **variables)
|
|
148
|
+
raise NotImplementedError, "#{self.class} must implement perform_graphql_query"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def filter_selected_attributes(attrs)
|
|
154
|
+
return attrs unless @selected_attributes
|
|
155
|
+
|
|
156
|
+
selected = {}
|
|
157
|
+
(@selected_attributes + [:id]).uniq.each do |attr|
|
|
158
|
+
selected[attr] = attrs[attr] if attrs.key?(attr)
|
|
159
|
+
end
|
|
160
|
+
selected
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def validate_search_response(response)
|
|
164
|
+
return unless response.dig("extensions", "search")
|
|
165
|
+
|
|
166
|
+
warnings = response["extensions"]["search"].flat_map { |s| s["warnings"] || [] }
|
|
167
|
+
return if warnings.empty?
|
|
168
|
+
|
|
169
|
+
messages = warnings.map { |w| "#{w['field']}: #{w['message']}" }
|
|
170
|
+
raise ArgumentError, "Shopify query validation failed: #{messages.join(', ')}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def map_collection_response(response_data, collection_query_name)
|
|
174
|
+
nodes = response_data.dig("data", collection_query_name, "nodes")
|
|
175
|
+
return [] unless nodes&.any?
|
|
176
|
+
|
|
177
|
+
nodes.filter_map do |node_data|
|
|
178
|
+
single_response = { "data" => { query_name => node_data } }
|
|
179
|
+
map_response_to_attributes(single_response)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
# Value object that encapsulates the shared context needed across query building,
|
|
5
|
+
# response mapping, and connection loading operations.
|
|
6
|
+
class LoaderContext
|
|
7
|
+
attr_reader :graphql_type, :loader_class, :defined_attributes, :model_class, :included_connections
|
|
8
|
+
|
|
9
|
+
def initialize(graphql_type:, loader_class:, defined_attributes:, model_class:, included_connections: [])
|
|
10
|
+
@graphql_type = graphql_type
|
|
11
|
+
@loader_class = loader_class
|
|
12
|
+
@defined_attributes = defined_attributes
|
|
13
|
+
@model_class = model_class
|
|
14
|
+
@included_connections = Array(included_connections)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Create a new context with different included connections (for nested loading)
|
|
18
|
+
def with_connections(new_connections)
|
|
19
|
+
self.class.new(
|
|
20
|
+
graphql_type: graphql_type,
|
|
21
|
+
loader_class: loader_class,
|
|
22
|
+
defined_attributes: defined_attributes,
|
|
23
|
+
model_class: model_class,
|
|
24
|
+
included_connections: new_connections
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Create a new context for a different model (for connection targets)
|
|
29
|
+
def for_model(new_model_class, new_graphql_type: nil, new_attributes: nil, new_connections: [])
|
|
30
|
+
self.class.new(
|
|
31
|
+
graphql_type: new_graphql_type || infer_graphql_type(new_model_class),
|
|
32
|
+
loader_class: loader_class,
|
|
33
|
+
defined_attributes: new_attributes || infer_attributes(new_model_class),
|
|
34
|
+
model_class: new_model_class,
|
|
35
|
+
included_connections: new_connections
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Helper methods delegated from context
|
|
40
|
+
def query_name
|
|
41
|
+
graphql_type.downcase
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fragment_name
|
|
45
|
+
"#{graphql_type}Fragment"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def connections
|
|
49
|
+
return {} unless model_class
|
|
50
|
+
|
|
51
|
+
model_class.connections
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def ==(other)
|
|
55
|
+
other.is_a?(LoaderContext) &&
|
|
56
|
+
graphql_type == other.graphql_type &&
|
|
57
|
+
loader_class == other.loader_class &&
|
|
58
|
+
defined_attributes == other.defined_attributes &&
|
|
59
|
+
model_class == other.model_class &&
|
|
60
|
+
included_connections == other.included_connections
|
|
61
|
+
end
|
|
62
|
+
alias eql? ==
|
|
63
|
+
|
|
64
|
+
def hash
|
|
65
|
+
[graphql_type, loader_class, defined_attributes, model_class, included_connections].hash
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def infer_graphql_type(klass)
|
|
71
|
+
if klass.respond_to?(:graphql_type_for_loader)
|
|
72
|
+
klass.graphql_type_for_loader(loader_class)
|
|
73
|
+
elsif klass.respond_to?(:graphql_type)
|
|
74
|
+
klass.graphql_type
|
|
75
|
+
elsif klass.respond_to?(:name) && klass.name
|
|
76
|
+
klass.name.demodulize
|
|
77
|
+
else
|
|
78
|
+
raise ArgumentError, "Cannot infer graphql_type for #{klass}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def infer_attributes(klass)
|
|
83
|
+
return klass.attributes_for_loader(loader_class) if klass.respond_to?(:attributes_for_loader)
|
|
84
|
+
|
|
85
|
+
{}
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|