active_shopify_graphql 0.4.0 → 0.5.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 +3 -0
- data/README.md +158 -56
- data/lib/active_shopify_graphql/configuration.rb +2 -15
- data/lib/active_shopify_graphql/connections/connection_loader.rb +141 -0
- data/lib/active_shopify_graphql/gid_helper.rb +2 -0
- data/lib/active_shopify_graphql/loader.rb +147 -126
- data/lib/active_shopify_graphql/loader_context.rb +2 -47
- data/lib/active_shopify_graphql/loader_proxy.rb +67 -0
- data/lib/active_shopify_graphql/loaders/admin_api_loader.rb +1 -17
- data/lib/active_shopify_graphql/loaders/customer_account_api_loader.rb +10 -26
- data/lib/active_shopify_graphql/model/associations.rb +94 -0
- data/lib/active_shopify_graphql/model/attributes.rb +48 -0
- data/lib/active_shopify_graphql/model/connections.rb +174 -0
- data/lib/active_shopify_graphql/model/finder_methods.rb +155 -0
- data/lib/active_shopify_graphql/model/graphql_type_resolver.rb +89 -0
- data/lib/active_shopify_graphql/model/loader_switchable.rb +79 -0
- data/lib/active_shopify_graphql/model/metafield_attributes.rb +59 -0
- data/lib/active_shopify_graphql/{base.rb → model.rb} +30 -16
- data/lib/active_shopify_graphql/model_builder.rb +53 -0
- data/lib/active_shopify_graphql/query/node/collection.rb +78 -0
- data/lib/active_shopify_graphql/query/node/connection.rb +16 -0
- data/lib/active_shopify_graphql/query/node/current_customer.rb +37 -0
- data/lib/active_shopify_graphql/query/node/field.rb +23 -0
- data/lib/active_shopify_graphql/query/node/fragment.rb +17 -0
- data/lib/active_shopify_graphql/query/node/nested_connection.rb +79 -0
- data/lib/active_shopify_graphql/query/node/raw.rb +15 -0
- data/lib/active_shopify_graphql/query/node/root_connection.rb +76 -0
- data/lib/active_shopify_graphql/query/node/single_record.rb +36 -0
- data/lib/active_shopify_graphql/query/node/singular.rb +16 -0
- data/lib/active_shopify_graphql/query/node.rb +95 -0
- data/lib/active_shopify_graphql/query/query_builder.rb +290 -0
- data/lib/active_shopify_graphql/query/relation.rb +424 -0
- data/lib/active_shopify_graphql/query/scope.rb +219 -0
- data/lib/active_shopify_graphql/response/page_info.rb +40 -0
- data/lib/active_shopify_graphql/response/paginated_result.rb +114 -0
- data/lib/active_shopify_graphql/response/response_mapper.rb +242 -0
- data/lib/active_shopify_graphql/search_query/hash_condition_formatter.rb +107 -0
- data/lib/active_shopify_graphql/search_query/parameter_binder.rb +68 -0
- data/lib/active_shopify_graphql/search_query/value_sanitizer.rb +24 -0
- data/lib/active_shopify_graphql/search_query.rb +34 -84
- data/lib/active_shopify_graphql/version.rb +1 -1
- data/lib/active_shopify_graphql.rb +29 -29
- metadata +46 -15
- data/lib/active_shopify_graphql/associations.rb +0 -94
- data/lib/active_shopify_graphql/attributes.rb +0 -50
- data/lib/active_shopify_graphql/connection_loader.rb +0 -96
- data/lib/active_shopify_graphql/connections.rb +0 -198
- data/lib/active_shopify_graphql/finder_methods.rb +0 -182
- data/lib/active_shopify_graphql/fragment_builder.rb +0 -228
- data/lib/active_shopify_graphql/graphql_type_resolver.rb +0 -91
- data/lib/active_shopify_graphql/includes_scope.rb +0 -48
- data/lib/active_shopify_graphql/loader_switchable.rb +0 -180
- data/lib/active_shopify_graphql/metafield_attributes.rb +0 -61
- data/lib/active_shopify_graphql/query_node.rb +0 -173
- data/lib/active_shopify_graphql/query_tree.rb +0 -225
- data/lib/active_shopify_graphql/response_mapper.rb +0 -249
|
@@ -4,48 +4,31 @@ require 'active_model/type'
|
|
|
4
4
|
require 'global_id'
|
|
5
5
|
|
|
6
6
|
module ActiveShopifyGraphQL
|
|
7
|
-
#
|
|
8
|
-
#
|
|
7
|
+
# The Loader acts as a stateless orchestrator that:
|
|
8
|
+
# - Receives a model class and delegates to it for GraphQL type and attribute definitions
|
|
9
|
+
# - Builds a LoaderContext that encapsulates all query-building parameters
|
|
10
|
+
# - Delegates query construction to Query::QueryBuilder
|
|
11
|
+
# - Delegates response mapping to Response::ResponseMapper
|
|
12
|
+
# - Executes GraphQL queries via subclass-specific implementations (perform_graphql_query)
|
|
13
|
+
#
|
|
14
|
+
# == Subclass Requirements
|
|
15
|
+
#
|
|
16
|
+
# Subclasses must implement:
|
|
17
|
+
# - +perform_graphql_query(query, **variables)+ - Execute the query against the appropriate API
|
|
18
|
+
#
|
|
19
|
+
# == Usage
|
|
20
|
+
#
|
|
21
|
+
# loader = AdminApiLoader.new(Customer, selected_attributes: [:id, :email])
|
|
22
|
+
# attributes = loader.load_attributes("gid://shopify/Customer/123")
|
|
23
|
+
# customer = Customer.new(attributes)
|
|
24
|
+
#
|
|
25
|
+
# @see LoaderContext For query-building context management
|
|
26
|
+
# @see Query::QueryBuilder For GraphQL query construction
|
|
27
|
+
# @see Response::ResponseMapper For response-to-attribute mapping
|
|
9
28
|
class Loader
|
|
10
|
-
class
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
29
|
+
# Initialize loader with model class and configuration
|
|
30
|
+
def initialize(model_class, selected_attributes: nil, included_connections: nil, **)
|
|
31
|
+
@model_class = model_class
|
|
49
32
|
@selected_attributes = selected_attributes&.map(&:to_sym)
|
|
50
33
|
@included_connections = included_connections || []
|
|
51
34
|
end
|
|
@@ -53,7 +36,7 @@ module ActiveShopifyGraphQL
|
|
|
53
36
|
# Build the LoaderContext for this loader instance
|
|
54
37
|
def context
|
|
55
38
|
@context ||= LoaderContext.new(
|
|
56
|
-
graphql_type:
|
|
39
|
+
graphql_type: resolve_graphql_type,
|
|
57
40
|
loader_class: self.class,
|
|
58
41
|
defined_attributes: defined_attributes,
|
|
59
42
|
model_class: @model_class,
|
|
@@ -61,51 +44,23 @@ module ActiveShopifyGraphQL
|
|
|
61
44
|
)
|
|
62
45
|
end
|
|
63
46
|
|
|
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
47
|
# Get defined attributes for this loader instance
|
|
70
48
|
def defined_attributes
|
|
71
|
-
|
|
72
|
-
@model_class.attributes_for_loader(self.class)
|
|
73
|
-
else
|
|
74
|
-
self.class.defined_attributes
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
filter_selected_attributes(attrs)
|
|
49
|
+
filter_selected_attributes(@model_class.attributes_for_loader(self.class))
|
|
78
50
|
end
|
|
79
51
|
|
|
80
|
-
# Returns the
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
# Delegate query building methods
|
|
86
|
-
def query_name(model_type = nil)
|
|
87
|
-
(model_type || graphql_type).camelize(:lower)
|
|
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)
|
|
52
|
+
# Returns the arguments needed to initialize a new loader of the same type
|
|
53
|
+
# Subclasses should override this if they require additional initialization arguments
|
|
54
|
+
# @return [Array] Array of arguments to pass to the loader initializer
|
|
55
|
+
def initialization_args
|
|
56
|
+
[]
|
|
96
57
|
end
|
|
97
58
|
|
|
98
59
|
# Map the GraphQL response to model attributes
|
|
99
60
|
def map_response_to_attributes(response_data, parent_instance: nil)
|
|
100
|
-
mapper =
|
|
61
|
+
mapper = create_response_mapper
|
|
101
62
|
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, parent_instance: parent_instance)
|
|
106
|
-
attributes[:_connection_cache] = connection_data unless connection_data.empty?
|
|
107
|
-
end
|
|
108
|
-
|
|
63
|
+
cache_connections(mapper, response_data, target: attributes, parent_instance: parent_instance)
|
|
109
64
|
attributes
|
|
110
65
|
end
|
|
111
66
|
|
|
@@ -114,63 +69,47 @@ module ActiveShopifyGraphQL
|
|
|
114
69
|
@included_connections&.any?
|
|
115
70
|
end
|
|
116
71
|
|
|
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
|
-
|
|
143
72
|
# Executes the GraphQL query and returns the mapped attributes hash
|
|
73
|
+
# @param id [String] The GID of the record to load
|
|
74
|
+
# @return [Hash, nil] Attribute hash with connection cache, or nil if not found
|
|
144
75
|
def load_attributes(id)
|
|
145
|
-
query =
|
|
146
|
-
response_data =
|
|
147
|
-
|
|
76
|
+
query = Query::QueryBuilder.build_single_record_query(context)
|
|
77
|
+
response_data = execute_query(query, id: id)
|
|
148
78
|
return nil if response_data.nil?
|
|
149
79
|
|
|
150
80
|
map_response_to_attributes(response_data)
|
|
151
81
|
end
|
|
152
82
|
|
|
153
|
-
# Executes a collection query
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
83
|
+
# Executes a paginated collection query that returns attributes and page info
|
|
84
|
+
# Executes a paginated collection query that returns attributes and page info
|
|
85
|
+
# @param conditions [Hash] Search conditions
|
|
86
|
+
# @param per_page [Integer] Number of records per page
|
|
87
|
+
# @param after [String, nil] Cursor to fetch records after
|
|
88
|
+
# @param before [String, nil] Cursor to fetch records before
|
|
89
|
+
# @param query_scope [Query::Scope] The query scope for navigation
|
|
90
|
+
# @return [PaginatedResult] A paginated result with attribute hashes and page info
|
|
91
|
+
def load_paginated_collection(conditions:, per_page:, query_scope:, after: nil, before: nil)
|
|
92
|
+
collection_query_name = context.query_name.pluralize
|
|
93
|
+
variables = build_collection_variables(
|
|
94
|
+
conditions,
|
|
95
|
+
per_page: per_page,
|
|
96
|
+
after: after,
|
|
97
|
+
before: before
|
|
98
|
+
)
|
|
158
99
|
|
|
159
|
-
query =
|
|
100
|
+
query = Query::QueryBuilder.build_paginated_collection_query(
|
|
160
101
|
context,
|
|
161
102
|
query_name: collection_query_name,
|
|
162
|
-
variables: variables
|
|
163
|
-
connection_type: :nodes_only
|
|
103
|
+
variables: variables
|
|
164
104
|
)
|
|
165
105
|
|
|
166
|
-
response =
|
|
167
|
-
|
|
168
|
-
map_collection_response(response, collection_query_name)
|
|
106
|
+
response = execute_query_and_validate_search_response(query, **variables)
|
|
107
|
+
map_paginated_response(response, collection_query_name, query_scope)
|
|
169
108
|
end
|
|
170
109
|
|
|
171
110
|
# Load records for a connection query
|
|
172
111
|
def load_connection_records(query_name, variables, parent = nil, connection_config = nil)
|
|
173
|
-
connection_loader = ConnectionLoader.new(context, loader_instance: self)
|
|
112
|
+
connection_loader = Connections::ConnectionLoader.new(context, loader_instance: self)
|
|
174
113
|
connection_loader.load_records(query_name, variables, parent, connection_config)
|
|
175
114
|
end
|
|
176
115
|
|
|
@@ -181,6 +120,41 @@ module ActiveShopifyGraphQL
|
|
|
181
120
|
|
|
182
121
|
private
|
|
183
122
|
|
|
123
|
+
def create_response_mapper
|
|
124
|
+
Response::ResponseMapper.new(context)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def should_log?
|
|
128
|
+
ActiveShopifyGraphQL.configuration.log_queries && ActiveShopifyGraphQL.configuration.logger
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def log_query(api_name, query, variables)
|
|
132
|
+
return unless should_log?
|
|
133
|
+
|
|
134
|
+
ActiveShopifyGraphQL.configuration.logger.info("ActiveShopifyGraphQL Query (#{api_name}):\n#{query}")
|
|
135
|
+
ActiveShopifyGraphQL.configuration.logger.info("ActiveShopifyGraphQL Variables:\n#{variables}")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def cache_connections(mapper, response_data, target:, parent_instance: nil)
|
|
139
|
+
return unless @included_connections.any?
|
|
140
|
+
|
|
141
|
+
connection_data = mapper.extract_connection_data(response_data, parent_instance: parent_instance)
|
|
142
|
+
return if connection_data.empty?
|
|
143
|
+
|
|
144
|
+
case target
|
|
145
|
+
when Hash
|
|
146
|
+
target[:_connection_cache] = connection_data
|
|
147
|
+
else
|
|
148
|
+
target.instance_variable_set(:@_connection_cache, connection_data)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def resolve_graphql_type
|
|
153
|
+
raise ArgumentError, "#{self.class} requires a model_class" unless @model_class
|
|
154
|
+
|
|
155
|
+
@model_class.graphql_type_for_loader(self.class)
|
|
156
|
+
end
|
|
157
|
+
|
|
184
158
|
def filter_selected_attributes(attrs)
|
|
185
159
|
return attrs unless @selected_attributes
|
|
186
160
|
|
|
@@ -191,7 +165,7 @@ module ActiveShopifyGraphQL
|
|
|
191
165
|
selected
|
|
192
166
|
end
|
|
193
167
|
|
|
194
|
-
def
|
|
168
|
+
def validate_search_query_response(response)
|
|
195
169
|
return unless response.dig("extensions", "search")
|
|
196
170
|
|
|
197
171
|
warnings = response["extensions"]["search"].flat_map { |s| s["warnings"] || [] }
|
|
@@ -201,14 +175,61 @@ module ActiveShopifyGraphQL
|
|
|
201
175
|
raise ArgumentError, "Shopify query validation failed: #{messages.join(', ')}"
|
|
202
176
|
end
|
|
203
177
|
|
|
204
|
-
def
|
|
205
|
-
|
|
206
|
-
|
|
178
|
+
def execute_query(query, **variables)
|
|
179
|
+
perform_graphql_query(query, **variables)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def execute_query_and_validate_search_response(query, **variables)
|
|
183
|
+
response = execute_query(query, **variables)
|
|
184
|
+
validate_search_query_response(response)
|
|
185
|
+
response
|
|
186
|
+
end
|
|
207
187
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
188
|
+
def build_collection_variables(conditions, per_page:, after: nil, before: nil)
|
|
189
|
+
search_query = SearchQuery.new(conditions)
|
|
190
|
+
variables = { query: search_query.to_s }
|
|
191
|
+
|
|
192
|
+
if before
|
|
193
|
+
variables[:last] = per_page
|
|
194
|
+
variables[:before] = before
|
|
195
|
+
else
|
|
196
|
+
variables[:first] = per_page
|
|
197
|
+
variables[:after] = after if after
|
|
211
198
|
end
|
|
199
|
+
|
|
200
|
+
variables.compact
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def map_node_to_attributes(node_data)
|
|
204
|
+
single_response = { "data" => { context.query_name => node_data } }
|
|
205
|
+
map_response_to_attributes(single_response)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def map_paginated_response(response_data, collection_query_name, query_scope)
|
|
209
|
+
connection_data = response_data.dig("data", collection_query_name)
|
|
210
|
+
return empty_paginated_result(query_scope) unless connection_data
|
|
211
|
+
|
|
212
|
+
page_info_data = connection_data["pageInfo"] || {}
|
|
213
|
+
page_info = Response::PageInfo.new(page_info_data)
|
|
214
|
+
|
|
215
|
+
nodes = connection_data["nodes"] || []
|
|
216
|
+
attributes_array = nodes.filter_map { |node_data| map_node_to_attributes(node_data) }
|
|
217
|
+
|
|
218
|
+
Response::PaginatedResult.new(
|
|
219
|
+
attributes: attributes_array,
|
|
220
|
+
model_class: @model_class,
|
|
221
|
+
page_info: page_info,
|
|
222
|
+
query_scope: query_scope
|
|
223
|
+
)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def empty_paginated_result(query_scope)
|
|
227
|
+
Response::PaginatedResult.new(
|
|
228
|
+
attributes: [],
|
|
229
|
+
model_class: @model_class,
|
|
230
|
+
page_info: Response::PageInfo.new,
|
|
231
|
+
query_scope: query_scope
|
|
232
|
+
)
|
|
212
233
|
end
|
|
213
234
|
end
|
|
214
235
|
end
|
|
@@ -14,23 +14,12 @@ module ActiveShopifyGraphQL
|
|
|
14
14
|
@included_connections = Array(included_connections)
|
|
15
15
|
end
|
|
16
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
17
|
# Create a new context for a different model (for connection targets)
|
|
29
18
|
def for_model(new_model_class, new_graphql_type: nil, new_attributes: nil, new_connections: [])
|
|
30
19
|
self.class.new(
|
|
31
|
-
graphql_type: new_graphql_type ||
|
|
20
|
+
graphql_type: new_graphql_type || new_model_class.graphql_type_for_loader(loader_class),
|
|
32
21
|
loader_class: loader_class,
|
|
33
|
-
defined_attributes: new_attributes ||
|
|
22
|
+
defined_attributes: new_attributes || new_model_class.attributes_for_loader(loader_class),
|
|
34
23
|
model_class: new_model_class,
|
|
35
24
|
included_connections: new_connections
|
|
36
25
|
)
|
|
@@ -50,39 +39,5 @@ module ActiveShopifyGraphQL
|
|
|
50
39
|
|
|
51
40
|
model_class.connections
|
|
52
41
|
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
42
|
end
|
|
88
43
|
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
# Simple proxy class to handle loader delegation when using a specific API
|
|
5
|
+
# This provides a consistent interface with Relation while using a custom loader
|
|
6
|
+
class LoaderProxy
|
|
7
|
+
def initialize(model_class, loader)
|
|
8
|
+
@model_class = model_class
|
|
9
|
+
@loader = loader
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Create a Relation with this loader's configuration
|
|
13
|
+
# @return [Relation] A relation configured with this loader
|
|
14
|
+
def all
|
|
15
|
+
build_relation
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Delegate chainable methods to Relation
|
|
19
|
+
def includes(*connection_names)
|
|
20
|
+
build_relation.includes(*connection_names)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def select(*attribute_names)
|
|
24
|
+
build_relation.select(*attribute_names)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def where(*args, **options)
|
|
28
|
+
build_relation.where(*args, **options)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def find_by(conditions = {}, **options)
|
|
32
|
+
build_relation.find_by(conditions, **options)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def find(id = nil)
|
|
36
|
+
# For Customer Account API, if no ID is provided, load the current customer
|
|
37
|
+
if id.nil? && @loader.is_a?(ActiveShopifyGraphQL::Loaders::CustomerAccountApiLoader)
|
|
38
|
+
attributes = @loader.load_attributes
|
|
39
|
+
return nil if attributes.nil?
|
|
40
|
+
|
|
41
|
+
return @model_class.new(attributes)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# For other cases, require ID and use standard flow
|
|
45
|
+
return nil if id.nil?
|
|
46
|
+
|
|
47
|
+
build_relation.find(id)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
attr_reader :loader
|
|
51
|
+
|
|
52
|
+
def inspect
|
|
53
|
+
"#{@model_class.name}(with_#{@loader.class.name.demodulize})"
|
|
54
|
+
end
|
|
55
|
+
alias to_s inspect
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def build_relation
|
|
60
|
+
Query::Relation.new(
|
|
61
|
+
@model_class,
|
|
62
|
+
loader_class: @loader.class,
|
|
63
|
+
loader_extra_args: @loader.initialization_args
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -3,30 +3,14 @@
|
|
|
3
3
|
module ActiveShopifyGraphQL
|
|
4
4
|
module Loaders
|
|
5
5
|
class AdminApiLoader < Loader
|
|
6
|
-
def initialize(model_class = nil, selected_attributes: nil, included_connections: nil)
|
|
7
|
-
super
|
|
8
|
-
end
|
|
9
|
-
|
|
10
6
|
def perform_graphql_query(query, **variables)
|
|
11
|
-
log_query(query, variables)
|
|
7
|
+
log_query("Admin API", query, variables)
|
|
12
8
|
|
|
13
9
|
client = ActiveShopifyGraphQL.configuration.admin_api_client
|
|
14
10
|
raise Error, "Admin API client not configured. Please configure it using ActiveShopifyGraphQL.configure" unless client
|
|
15
11
|
|
|
16
12
|
client.execute(query, **variables)
|
|
17
13
|
end
|
|
18
|
-
|
|
19
|
-
private
|
|
20
|
-
|
|
21
|
-
def should_log?
|
|
22
|
-
ActiveShopifyGraphQL.configuration.log_queries && ActiveShopifyGraphQL.configuration.logger
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def log_query(query, variables)
|
|
26
|
-
logger = ActiveShopifyGraphQL.configuration.logger
|
|
27
|
-
logger.info("ActiveShopifyGraphQL Query (Admin API):\n#{query}")
|
|
28
|
-
logger.info("ActiveShopifyGraphQL Variables:\n#{variables}")
|
|
29
|
-
end
|
|
30
14
|
end
|
|
31
15
|
end
|
|
32
16
|
end
|
|
@@ -8,23 +8,15 @@ module ActiveShopifyGraphQL
|
|
|
8
8
|
@token = token
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
# Override to handle Customer queries that don't need an ID
|
|
12
|
-
def graphql_query(model_type = nil)
|
|
13
|
-
type = model_type || graphql_type
|
|
14
|
-
if type == 'Customer'
|
|
15
|
-
QueryTree.build_current_customer_query(context)
|
|
16
|
-
else
|
|
17
|
-
super(type)
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
|
|
21
11
|
# Override load_attributes to handle the Customer case
|
|
22
12
|
def load_attributes(id = nil)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
13
|
+
query = if context.graphql_type == 'Customer'
|
|
14
|
+
Query::QueryBuilder.build_current_customer_query(context)
|
|
15
|
+
else
|
|
16
|
+
Query::QueryBuilder.build_single_record_query(context)
|
|
17
|
+
end
|
|
18
|
+
variables = context.graphql_type == 'Customer' ? {} : { id: id }
|
|
19
|
+
response_data = execute_query(query, **variables)
|
|
28
20
|
|
|
29
21
|
return nil if response_data.nil?
|
|
30
22
|
|
|
@@ -39,20 +31,12 @@ module ActiveShopifyGraphQL
|
|
|
39
31
|
end
|
|
40
32
|
|
|
41
33
|
def perform_graphql_query(query, **variables)
|
|
42
|
-
log_query(query, variables)
|
|
34
|
+
log_query("Customer Account API", query, variables)
|
|
43
35
|
client.query(query, variables)
|
|
44
36
|
end
|
|
45
37
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def should_log?
|
|
49
|
-
ActiveShopifyGraphQL.configuration.log_queries && ActiveShopifyGraphQL.configuration.logger
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def log_query(query, variables)
|
|
53
|
-
logger = ActiveShopifyGraphQL.configuration.logger
|
|
54
|
-
logger.info("ActiveShopifyGraphQL Query (Customer Account API):\n#{query}")
|
|
55
|
-
logger.info("ActiveShopifyGraphQL Variables:\n#{variables}")
|
|
38
|
+
def initialization_args
|
|
39
|
+
[@token]
|
|
56
40
|
end
|
|
57
41
|
end
|
|
58
42
|
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL::Model::Associations
|
|
4
|
+
# Handles associations between ActiveShopifyGraphQL objects and ActiveRecord objects
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
class << self
|
|
9
|
+
def associations
|
|
10
|
+
@associations ||= {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
attr_writer :associations
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class_methods do
|
|
18
|
+
def has_many(name, class_name: nil, foreign_key: nil, primary_key: nil)
|
|
19
|
+
association_class_name = class_name || name.to_s.classify
|
|
20
|
+
association_foreign_key = foreign_key || "shopify_#{model_name.element.downcase}_id"
|
|
21
|
+
association_primary_key = primary_key || :id
|
|
22
|
+
|
|
23
|
+
# Store association metadata
|
|
24
|
+
associations[name] = {
|
|
25
|
+
type: :has_many,
|
|
26
|
+
class_name: association_class_name,
|
|
27
|
+
foreign_key: association_foreign_key,
|
|
28
|
+
primary_key: association_primary_key
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Define the association method
|
|
32
|
+
define_method name do
|
|
33
|
+
return @_association_cache[name] if @_association_cache&.key?(name)
|
|
34
|
+
|
|
35
|
+
@_association_cache ||= {}
|
|
36
|
+
|
|
37
|
+
primary_key_value = send(association_primary_key)
|
|
38
|
+
return @_association_cache[name] = [] if primary_key_value.blank?
|
|
39
|
+
|
|
40
|
+
association_class = association_class_name.constantize
|
|
41
|
+
@_association_cache[name] = association_class.where(association_foreign_key => primary_key_value)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Define the association setter method for testing/mocking
|
|
45
|
+
define_method "#{name}=" do |value|
|
|
46
|
+
@_association_cache ||= {}
|
|
47
|
+
@_association_cache[name] = value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def has_one(name, class_name: nil, foreign_key: nil, primary_key: nil)
|
|
52
|
+
association_class_name = class_name || name.to_s.classify
|
|
53
|
+
association_foreign_key = foreign_key || "shopify_#{model_name.element.downcase}_id"
|
|
54
|
+
association_primary_key = primary_key || :id
|
|
55
|
+
|
|
56
|
+
# Store association metadata
|
|
57
|
+
associations[name] = {
|
|
58
|
+
type: :has_one,
|
|
59
|
+
class_name: association_class_name,
|
|
60
|
+
foreign_key: association_foreign_key,
|
|
61
|
+
primary_key: association_primary_key
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Define the association method
|
|
65
|
+
define_method name do
|
|
66
|
+
return @_association_cache[name] if @_association_cache&.key?(name)
|
|
67
|
+
|
|
68
|
+
@_association_cache ||= {}
|
|
69
|
+
|
|
70
|
+
primary_key_value = send(association_primary_key)
|
|
71
|
+
return @_association_cache[name] = nil if primary_key_value.blank?
|
|
72
|
+
|
|
73
|
+
# Extract numeric ID from Shopify GID if needed
|
|
74
|
+
if primary_key_value.is_a?(String)
|
|
75
|
+
begin
|
|
76
|
+
parsed_gid = URI::GID.parse(primary_key_value)
|
|
77
|
+
primary_key_value = parsed_gid.model_id
|
|
78
|
+
rescue URI::InvalidURIError, URI::BadURIError, ArgumentError
|
|
79
|
+
# Not a GID, use value as-is
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
association_class = association_class_name.constantize
|
|
84
|
+
@_association_cache[name] = association_class.find_by(association_foreign_key => primary_key_value)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Define the association setter method for testing/mocking
|
|
88
|
+
define_method "#{name}=" do |value|
|
|
89
|
+
@_association_cache ||= {}
|
|
90
|
+
@_association_cache[name] = value
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|