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
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
module Query
|
|
5
|
+
# Builds complete GraphQL queries from a LoaderContext.
|
|
6
|
+
# Handles both fragment construction and query wrapping.
|
|
7
|
+
# Delegates rendering to polymorphic Node subclasses.
|
|
8
|
+
class QueryBuilder
|
|
9
|
+
attr_reader :context
|
|
10
|
+
|
|
11
|
+
def initialize(context)
|
|
12
|
+
@context = context
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# === Class-level factory methods for building complete queries ===
|
|
16
|
+
|
|
17
|
+
def self.build_single_record_query(context)
|
|
18
|
+
builder = new(context)
|
|
19
|
+
fragment = builder.build_fragment
|
|
20
|
+
|
|
21
|
+
Node::SingleRecord.new(
|
|
22
|
+
model_type: context.graphql_type,
|
|
23
|
+
query_name: context.query_name,
|
|
24
|
+
fragment_name: context.fragment_name,
|
|
25
|
+
fragments: [fragment]
|
|
26
|
+
).to_s
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Build a query that doesn't require an ID parameter (e.g., Customer Account API's current customer)
|
|
30
|
+
def self.build_current_customer_query(context, query_name: nil)
|
|
31
|
+
builder = new(context)
|
|
32
|
+
fragment = builder.build_fragment
|
|
33
|
+
|
|
34
|
+
Node::CurrentCustomer.new(
|
|
35
|
+
model_type: context.graphql_type,
|
|
36
|
+
query_name: query_name || context.query_name,
|
|
37
|
+
fragment_name: context.fragment_name,
|
|
38
|
+
fragments: [fragment]
|
|
39
|
+
).to_s
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.build_collection_query(context, query_name:, variables:, include_page_info: false)
|
|
43
|
+
builder = new(context)
|
|
44
|
+
fragment = builder.build_fragment
|
|
45
|
+
|
|
46
|
+
Node::Collection.new(
|
|
47
|
+
model_type: context.graphql_type,
|
|
48
|
+
query_name: query_name,
|
|
49
|
+
fragment_name: context.fragment_name,
|
|
50
|
+
variables: variables,
|
|
51
|
+
fragments: [fragment],
|
|
52
|
+
include_page_info: include_page_info
|
|
53
|
+
).to_s
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Build a paginated collection query that includes pageInfo for cursor-based pagination
|
|
57
|
+
def self.build_paginated_collection_query(context, query_name:, variables:)
|
|
58
|
+
build_collection_query(context, query_name: query_name, variables: variables, include_page_info: true)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.build_connection_query(context, query_name:, variables:, parent_query: nil, singular: false)
|
|
62
|
+
builder = new(context)
|
|
63
|
+
fragment = builder.build_fragment
|
|
64
|
+
|
|
65
|
+
if parent_query
|
|
66
|
+
Node::NestedConnection.new(
|
|
67
|
+
query_name: query_name,
|
|
68
|
+
fragment_name: context.fragment_name,
|
|
69
|
+
variables: variables,
|
|
70
|
+
parent_query: parent_query,
|
|
71
|
+
fragments: [fragment],
|
|
72
|
+
singular: singular
|
|
73
|
+
).to_s
|
|
74
|
+
else
|
|
75
|
+
Node::RootConnection.new(
|
|
76
|
+
query_name: query_name,
|
|
77
|
+
fragment_name: context.fragment_name,
|
|
78
|
+
variables: variables,
|
|
79
|
+
fragments: [fragment],
|
|
80
|
+
singular: singular
|
|
81
|
+
).to_s
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.normalize_includes(includes)
|
|
86
|
+
includes = Array(includes)
|
|
87
|
+
includes.each_with_object({}) do |inc, normalized|
|
|
88
|
+
case inc
|
|
89
|
+
when Hash
|
|
90
|
+
inc.each do |key, value|
|
|
91
|
+
key = key.to_sym
|
|
92
|
+
normalized[key] ||= []
|
|
93
|
+
case value
|
|
94
|
+
when Hash then normalized[key] << value
|
|
95
|
+
when Array then normalized[key].concat(value)
|
|
96
|
+
else normalized[key] << value
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
when Symbol, String
|
|
100
|
+
normalized[inc.to_sym] ||= []
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.fragment_name(graphql_type)
|
|
106
|
+
"#{graphql_type}Fragment"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# === Instance methods for building fragments ===
|
|
110
|
+
|
|
111
|
+
# Build a complete fragment node with all fields and connections
|
|
112
|
+
def build_fragment
|
|
113
|
+
raise NotImplementedError, "#{@context.loader_class} must define attributes" if @context.defined_attributes.empty?
|
|
114
|
+
|
|
115
|
+
fragment_node = Node::Fragment.new(
|
|
116
|
+
name: @context.fragment_name,
|
|
117
|
+
arguments: { on: @context.graphql_type }
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Add field nodes from attributes
|
|
121
|
+
build_field_nodes.each { |node| fragment_node.add_child(node) }
|
|
122
|
+
|
|
123
|
+
# Add connection nodes
|
|
124
|
+
build_connection_nodes.each { |node| fragment_node.add_child(node) }
|
|
125
|
+
|
|
126
|
+
fragment_node
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Build field nodes from attribute definitions
|
|
130
|
+
def build_field_nodes
|
|
131
|
+
path_tree = {}
|
|
132
|
+
metafield_aliases = {}
|
|
133
|
+
raw_graphql_nodes = []
|
|
134
|
+
aliased_field_nodes = []
|
|
135
|
+
|
|
136
|
+
# Build a tree structure for nested paths
|
|
137
|
+
@context.defined_attributes.each do |attr_name, config|
|
|
138
|
+
if config[:raw_graphql]
|
|
139
|
+
raw_graphql_nodes << build_raw_graphql_node(attr_name, config[:raw_graphql])
|
|
140
|
+
elsif config[:is_metafield]
|
|
141
|
+
store_metafield_config(metafield_aliases, config)
|
|
142
|
+
else
|
|
143
|
+
path = config[:path]
|
|
144
|
+
if path.include?('.')
|
|
145
|
+
# Nested path - use tree structure (shared prefixes)
|
|
146
|
+
build_path_tree(path_tree, path)
|
|
147
|
+
else
|
|
148
|
+
# Simple path - add aliased field node
|
|
149
|
+
aliased_field_nodes << build_aliased_field_node(attr_name, path)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Convert tree to Node objects
|
|
155
|
+
nodes_from_tree(path_tree) + aliased_field_nodes + metafield_nodes(metafield_aliases) + raw_graphql_nodes
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Build Node objects for all connections
|
|
159
|
+
def build_connection_nodes
|
|
160
|
+
return [] if @context.included_connections.empty?
|
|
161
|
+
|
|
162
|
+
connections = @context.connections
|
|
163
|
+
return [] if connections.empty?
|
|
164
|
+
|
|
165
|
+
normalized_includes = self.class.normalize_includes(@context.included_connections)
|
|
166
|
+
|
|
167
|
+
normalized_includes.filter_map do |connection_name, nested_includes|
|
|
168
|
+
connection_config = connections[connection_name]
|
|
169
|
+
next unless connection_config
|
|
170
|
+
|
|
171
|
+
build_connection_node(connection_config, nested_includes)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def store_metafield_config(metafield_aliases, config)
|
|
178
|
+
alias_name = config[:metafield_alias]
|
|
179
|
+
value_field = config[:type] == :json ? 'jsonValue' : 'value'
|
|
180
|
+
|
|
181
|
+
metafield_aliases[alias_name] = {
|
|
182
|
+
namespace: config[:metafield_namespace],
|
|
183
|
+
key: config[:metafield_key],
|
|
184
|
+
value_field: value_field
|
|
185
|
+
}
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def build_raw_graphql_node(attr_name, raw_graphql)
|
|
189
|
+
# Prepend alias to raw GraphQL for predictable response mapping
|
|
190
|
+
aliased_raw_graphql = "#{attr_name}: #{raw_graphql}"
|
|
191
|
+
Node::Raw.new(
|
|
192
|
+
name: "raw",
|
|
193
|
+
arguments: { raw_graphql: aliased_raw_graphql }
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def build_aliased_field_node(attr_name, path)
|
|
198
|
+
alias_name = attr_name.to_s
|
|
199
|
+
# Only add alias if the attr_name differs from the GraphQL field name
|
|
200
|
+
alias_name = nil if alias_name == path
|
|
201
|
+
Node::Field.new(name: path, alias_name: alias_name)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def build_path_tree(path_tree, path)
|
|
205
|
+
path_parts = path.split('.')
|
|
206
|
+
current_level = path_tree
|
|
207
|
+
|
|
208
|
+
path_parts.each_with_index do |part, index|
|
|
209
|
+
if index == path_parts.length - 1
|
|
210
|
+
current_level[part] = true
|
|
211
|
+
else
|
|
212
|
+
current_level[part] ||= {}
|
|
213
|
+
current_level = current_level[part]
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def nodes_from_tree(tree)
|
|
219
|
+
tree.map do |key, value|
|
|
220
|
+
if value == true
|
|
221
|
+
Node::Field.new(name: key)
|
|
222
|
+
else
|
|
223
|
+
children = nodes_from_tree(value)
|
|
224
|
+
Node::Field.new(name: key, children: children)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def metafield_nodes(metafield_aliases)
|
|
230
|
+
metafield_aliases.map do |alias_name, config|
|
|
231
|
+
value_node = Node::Field.new(name: config[:value_field])
|
|
232
|
+
Node::Field.new(
|
|
233
|
+
name: "metafield",
|
|
234
|
+
alias_name: alias_name,
|
|
235
|
+
arguments: { namespace: config[:namespace], key: config[:key] },
|
|
236
|
+
children: [value_node]
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def build_connection_node(connection_config, nested_includes)
|
|
242
|
+
target_class = connection_config[:class_name].constantize
|
|
243
|
+
target_context = @context.for_model(target_class, new_connections: nested_includes)
|
|
244
|
+
|
|
245
|
+
# Build child nodes for the target model
|
|
246
|
+
child_nodes = build_target_field_nodes(target_context, nested_includes)
|
|
247
|
+
|
|
248
|
+
query_name = connection_config[:query_name]
|
|
249
|
+
original_name = connection_config[:original_name]
|
|
250
|
+
connection_type = connection_config[:type] || :connection
|
|
251
|
+
formatted_args = (connection_config[:default_arguments] || {}).transform_keys(&:to_sym)
|
|
252
|
+
|
|
253
|
+
# Add alias if the connection name differs from the query name
|
|
254
|
+
alias_name = original_name.to_s == query_name ? nil : original_name.to_s
|
|
255
|
+
|
|
256
|
+
if connection_type == :singular
|
|
257
|
+
Node::Singular.new(
|
|
258
|
+
name: query_name,
|
|
259
|
+
alias_name: alias_name,
|
|
260
|
+
arguments: formatted_args,
|
|
261
|
+
children: child_nodes
|
|
262
|
+
)
|
|
263
|
+
else
|
|
264
|
+
Node::Connection.new(
|
|
265
|
+
name: query_name,
|
|
266
|
+
alias_name: alias_name,
|
|
267
|
+
arguments: formatted_args,
|
|
268
|
+
children: child_nodes
|
|
269
|
+
)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def build_target_field_nodes(target_context, nested_includes)
|
|
274
|
+
# Build attribute nodes
|
|
275
|
+
attribute_nodes = if target_context.defined_attributes.any?
|
|
276
|
+
QueryBuilder.new(target_context.for_model(target_context.model_class, new_connections: [])).build_field_nodes
|
|
277
|
+
else
|
|
278
|
+
[Node::Field.new(name: "id")]
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Build nested connection nodes
|
|
282
|
+
return attribute_nodes if nested_includes.empty?
|
|
283
|
+
|
|
284
|
+
nested_builder = QueryBuilder.new(target_context)
|
|
285
|
+
nested_connection_nodes = nested_builder.build_connection_nodes
|
|
286
|
+
attribute_nodes + nested_connection_nodes
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveShopifyGraphQL
|
|
4
|
+
module Query
|
|
5
|
+
# A unified query builder that encapsulates all query configuration.
|
|
6
|
+
# This class provides a consistent interface for chaining operations like
|
|
7
|
+
# `where`, `find_by`, `includes`, `select`, `limit`, and pagination.
|
|
8
|
+
#
|
|
9
|
+
# Inspired by ActiveRecord::Relation, this class accumulates query state
|
|
10
|
+
# and executes the query when records are accessed.
|
|
11
|
+
#
|
|
12
|
+
# @example Basic usage
|
|
13
|
+
# Customer.where(email: "john@example.com").first
|
|
14
|
+
# Customer.includes(:orders).find_by(id: 123)
|
|
15
|
+
# Customer.select(:id, :email).where(first_name: "John").limit(10).to_a
|
|
16
|
+
#
|
|
17
|
+
# @example Pagination
|
|
18
|
+
# Customer.where(country: "Canada").in_pages(of: 50) do |page|
|
|
19
|
+
# page.each { |customer| process(customer) }
|
|
20
|
+
# end
|
|
21
|
+
class Relation
|
|
22
|
+
include Enumerable
|
|
23
|
+
|
|
24
|
+
DEFAULT_PER_PAGE = 250
|
|
25
|
+
|
|
26
|
+
attr_reader :model_class, :included_connections, :conditions, :total_limit, :per_page
|
|
27
|
+
|
|
28
|
+
def initialize(
|
|
29
|
+
model_class,
|
|
30
|
+
conditions: {},
|
|
31
|
+
included_connections: [],
|
|
32
|
+
selected_attributes: nil,
|
|
33
|
+
total_limit: nil,
|
|
34
|
+
per_page: DEFAULT_PER_PAGE,
|
|
35
|
+
loader_class: nil,
|
|
36
|
+
loader_extra_args: []
|
|
37
|
+
)
|
|
38
|
+
@model_class = model_class
|
|
39
|
+
@conditions = conditions
|
|
40
|
+
@included_connections = included_connections
|
|
41
|
+
@selected_attributes = selected_attributes
|
|
42
|
+
@total_limit = total_limit
|
|
43
|
+
@per_page = [per_page, ActiveShopifyGraphQL.configuration.max_objects_per_paginated_query].min
|
|
44
|
+
@loader_class = loader_class
|
|
45
|
+
@loader_extra_args = loader_extra_args
|
|
46
|
+
@loaded = false
|
|
47
|
+
@records = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# --------------------------------------------------------------------------
|
|
51
|
+
# Chainable Query Methods
|
|
52
|
+
# --------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
# Add conditions to the query
|
|
55
|
+
# @param conditions_or_first_condition [Hash, String] Conditions to filter by
|
|
56
|
+
# @return [Relation] A new relation with conditions applied
|
|
57
|
+
# @raise [ArgumentError] If where is called on a relation that already has conditions
|
|
58
|
+
def where(conditions_or_first_condition = {}, *args, **options)
|
|
59
|
+
new_conditions = build_conditions(conditions_or_first_condition, args, options)
|
|
60
|
+
|
|
61
|
+
if has_conditions? && !new_conditions.empty?
|
|
62
|
+
raise ArgumentError, "Chaining multiple where clauses is not supported. " \
|
|
63
|
+
"Combine conditions in a single where call instead."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
spawn(conditions: new_conditions)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Find a single record by conditions
|
|
70
|
+
# @param conditions [Hash] The conditions to match
|
|
71
|
+
# @return [Object, nil] The first matching record or nil
|
|
72
|
+
def find_by(conditions = {}, **options)
|
|
73
|
+
merged = conditions.empty? ? options : conditions
|
|
74
|
+
where(merged).first
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Find a single record by ID
|
|
78
|
+
# @param id [String, Integer] The record ID
|
|
79
|
+
# @return [Object] The model instance
|
|
80
|
+
# @raise [ObjectNotFoundError] If the record is not found
|
|
81
|
+
def find(id)
|
|
82
|
+
gid = GidHelper.normalize_gid(id, @model_class.model_name.name.demodulize)
|
|
83
|
+
attributes = loader.load_attributes(gid)
|
|
84
|
+
|
|
85
|
+
raise ObjectNotFoundError, "Couldn't find #{@model_class.name} with id=#{id}" if attributes.nil?
|
|
86
|
+
|
|
87
|
+
ModelBuilder.build(@model_class, attributes)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Include connections for eager loading
|
|
91
|
+
# @param connection_names [Array<Symbol>] Connection names to include
|
|
92
|
+
# @return [Relation] A new relation with connections included
|
|
93
|
+
def includes(*connection_names)
|
|
94
|
+
validate_includes_connections!(connection_names)
|
|
95
|
+
|
|
96
|
+
# Merge with existing and auto-eager-loaded connections
|
|
97
|
+
auto_included = @model_class.connections
|
|
98
|
+
.select { |_name, config| config[:eager_load] }
|
|
99
|
+
.keys
|
|
100
|
+
|
|
101
|
+
all_connections = (@included_connections + connection_names + auto_included).uniq
|
|
102
|
+
|
|
103
|
+
spawn(included_connections: all_connections)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Select specific attributes to optimize the query
|
|
107
|
+
# @param attributes [Array<Symbol>] Attributes to select
|
|
108
|
+
# @return [Relation] A new relation with selected attributes
|
|
109
|
+
def select(*attributes)
|
|
110
|
+
attrs = Array(attributes).flatten.map(&:to_sym)
|
|
111
|
+
validate_select_attributes!(attrs)
|
|
112
|
+
|
|
113
|
+
spawn(selected_attributes: attrs)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Limit the total number of records returned
|
|
117
|
+
# @param count [Integer] Maximum records to return
|
|
118
|
+
# @return [Relation] A new relation with limit applied
|
|
119
|
+
def limit(count)
|
|
120
|
+
spawn(total_limit: count)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# --------------------------------------------------------------------------
|
|
124
|
+
# Pagination Methods
|
|
125
|
+
# --------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
# Configure pagination and optionally iterate through pages
|
|
128
|
+
# @param of [Integer] Records per page (default: 250, max: configurable)
|
|
129
|
+
# @yield [PaginatedResult] Each page of results
|
|
130
|
+
# @return [PaginatedResult, self] PaginatedResult if no block given
|
|
131
|
+
def in_pages(of: DEFAULT_PER_PAGE, &block)
|
|
132
|
+
page_size = [of, ActiveShopifyGraphQL.configuration.max_objects_per_paginated_query].min
|
|
133
|
+
scoped = spawn(per_page: page_size)
|
|
134
|
+
|
|
135
|
+
if block_given?
|
|
136
|
+
scoped.each_page(&block)
|
|
137
|
+
self
|
|
138
|
+
else
|
|
139
|
+
scoped.fetch_first_page
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Iterate through all pages, yielding each page
|
|
144
|
+
# @yield [PaginatedResult] Each page of results
|
|
145
|
+
def each_page
|
|
146
|
+
current_page = fetch_first_page
|
|
147
|
+
records_yielded = 0
|
|
148
|
+
|
|
149
|
+
loop do
|
|
150
|
+
break if current_page.empty?
|
|
151
|
+
|
|
152
|
+
# Apply total limit if set
|
|
153
|
+
if @total_limit
|
|
154
|
+
remaining = @total_limit - records_yielded
|
|
155
|
+
break if remaining <= 0
|
|
156
|
+
|
|
157
|
+
if current_page.size > remaining
|
|
158
|
+
trimmed_records = current_page.records.first(remaining)
|
|
159
|
+
current_page = PaginatedResult.new(
|
|
160
|
+
records: trimmed_records,
|
|
161
|
+
page_info: PageInfo.new,
|
|
162
|
+
query_scope: build_query_scope_for_pagination
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
yield current_page
|
|
168
|
+
records_yielded += current_page.size
|
|
169
|
+
|
|
170
|
+
break unless current_page.has_next_page?
|
|
171
|
+
break if @total_limit && records_yielded >= @total_limit
|
|
172
|
+
|
|
173
|
+
current_page = current_page.next_page
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# --------------------------------------------------------------------------
|
|
178
|
+
# Enumerable / Loading Methods
|
|
179
|
+
# --------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
# Iterate through all records across all pages
|
|
182
|
+
# @yield [Object] Each record
|
|
183
|
+
def each(&block)
|
|
184
|
+
return to_enum(:each) unless block_given?
|
|
185
|
+
|
|
186
|
+
each_page do |page|
|
|
187
|
+
page.each(&block)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Load all records respecting total_limit
|
|
192
|
+
# @return [Array] All records
|
|
193
|
+
def to_a
|
|
194
|
+
return @records if @loaded
|
|
195
|
+
|
|
196
|
+
all_records = []
|
|
197
|
+
each_page do |page|
|
|
198
|
+
all_records.concat(page.to_a)
|
|
199
|
+
end
|
|
200
|
+
@records = all_records
|
|
201
|
+
@loaded = true
|
|
202
|
+
@records
|
|
203
|
+
end
|
|
204
|
+
alias load to_a
|
|
205
|
+
|
|
206
|
+
# Get first record(s)
|
|
207
|
+
# @param count [Integer, nil] Number of records to return
|
|
208
|
+
# @return [Object, Array, nil] First record(s) or nil
|
|
209
|
+
def first(count = nil)
|
|
210
|
+
if count
|
|
211
|
+
spawn(total_limit: count, per_page: [count, ActiveShopifyGraphQL.configuration.max_objects_per_paginated_query].min).to_a
|
|
212
|
+
else
|
|
213
|
+
spawn(total_limit: 1, per_page: 1).to_a.first
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Check if any records exist
|
|
218
|
+
# @return [Boolean]
|
|
219
|
+
def exists?
|
|
220
|
+
first(1).any?
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Check if no records exist
|
|
224
|
+
# @return [Boolean]
|
|
225
|
+
def empty?
|
|
226
|
+
first(1).empty?
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Size/length of records (loads all pages)
|
|
230
|
+
# @return [Integer]
|
|
231
|
+
def size
|
|
232
|
+
to_a.size
|
|
233
|
+
end
|
|
234
|
+
alias length size
|
|
235
|
+
|
|
236
|
+
# Count records (loads all pages)
|
|
237
|
+
# @return [Integer]
|
|
238
|
+
def count
|
|
239
|
+
to_a.count
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Array-like access
|
|
243
|
+
def [](index)
|
|
244
|
+
to_a[index]
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Map over records
|
|
248
|
+
def map(&block)
|
|
249
|
+
to_a.map(&block)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Select/filter records (Array compatibility - differs from query select)
|
|
253
|
+
def select_records(&block)
|
|
254
|
+
to_a.select(&block)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# --------------------------------------------------------------------------
|
|
258
|
+
# Inspection
|
|
259
|
+
# --------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def inspect
|
|
262
|
+
parts = [@model_class.name]
|
|
263
|
+
parts << "includes(#{@included_connections.join(', ')})" if @included_connections.any?
|
|
264
|
+
parts << "select(#{@selected_attributes.join(', ')})" if @selected_attributes
|
|
265
|
+
parts << "where(#{@conditions.inspect})" unless @conditions.empty?
|
|
266
|
+
parts << "limit(#{@total_limit})" if @total_limit
|
|
267
|
+
"#<#{self.class.name} #{parts.join('.')}>"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# --------------------------------------------------------------------------
|
|
271
|
+
# Internal State Accessors (for compatibility)
|
|
272
|
+
# --------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
def has_included_connections?
|
|
275
|
+
@included_connections.any?
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# --------------------------------------------------------------------------
|
|
279
|
+
# Pagination Support
|
|
280
|
+
# --------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
# Fetch a specific page by cursor
|
|
283
|
+
# @param after [String, nil] Cursor to fetch records after
|
|
284
|
+
# @param before [String, nil] Cursor to fetch records before
|
|
285
|
+
# @return [PaginatedResult]
|
|
286
|
+
def fetch_page(after: nil, before: nil)
|
|
287
|
+
loader.load_paginated_collection(
|
|
288
|
+
conditions: @conditions,
|
|
289
|
+
per_page: effective_per_page,
|
|
290
|
+
after: after,
|
|
291
|
+
before: before,
|
|
292
|
+
query_scope: build_query_scope_for_pagination
|
|
293
|
+
)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Fetch the first page of results
|
|
297
|
+
# @return [PaginatedResult]
|
|
298
|
+
def fetch_first_page
|
|
299
|
+
fetch_page
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
private
|
|
303
|
+
|
|
304
|
+
# Create a new Relation with modified options
|
|
305
|
+
def spawn(**changes)
|
|
306
|
+
Query::Relation.new(
|
|
307
|
+
@model_class,
|
|
308
|
+
conditions: changes.fetch(:conditions, @conditions),
|
|
309
|
+
included_connections: changes.fetch(:included_connections, @included_connections),
|
|
310
|
+
selected_attributes: changes.fetch(:selected_attributes, @selected_attributes),
|
|
311
|
+
total_limit: changes.fetch(:total_limit, @total_limit),
|
|
312
|
+
per_page: changes.fetch(:per_page, @per_page),
|
|
313
|
+
loader_class: changes.fetch(:loader_class, @loader_class),
|
|
314
|
+
loader_extra_args: changes.fetch(:loader_extra_args, @loader_extra_args)
|
|
315
|
+
)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def loader
|
|
319
|
+
@loader ||= build_loader
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def has_conditions?
|
|
323
|
+
case @conditions
|
|
324
|
+
when Hash then @conditions.any?
|
|
325
|
+
when String then !@conditions.empty?
|
|
326
|
+
when Array then @conditions.any?
|
|
327
|
+
else false
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def build_loader
|
|
332
|
+
klass = @loader_class || @model_class.send(:default_loader_class)
|
|
333
|
+
|
|
334
|
+
klass.new(
|
|
335
|
+
@model_class,
|
|
336
|
+
*@loader_extra_args,
|
|
337
|
+
selected_attributes: @selected_attributes,
|
|
338
|
+
included_connections: @included_connections
|
|
339
|
+
)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def effective_per_page
|
|
343
|
+
if @total_limit && @total_limit < @per_page
|
|
344
|
+
@total_limit
|
|
345
|
+
else
|
|
346
|
+
@per_page
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Build conditions from various input formats:
|
|
351
|
+
# - Hash: where(email: "test") => {email: "test"}
|
|
352
|
+
# - String with positional params: where("sku:?", "ABC") => ["sku:?", "ABC"]
|
|
353
|
+
# - String with named params: where("sku::sku", sku: "ABC") => ["sku::sku", {sku: "ABC"}]
|
|
354
|
+
def build_conditions(conditions_or_first_condition, args, options)
|
|
355
|
+
case conditions_or_first_condition
|
|
356
|
+
when String
|
|
357
|
+
if args.any?
|
|
358
|
+
[conditions_or_first_condition, *args]
|
|
359
|
+
elsif options.any?
|
|
360
|
+
[conditions_or_first_condition, options]
|
|
361
|
+
else
|
|
362
|
+
conditions_or_first_condition
|
|
363
|
+
end
|
|
364
|
+
when Hash
|
|
365
|
+
conditions_or_first_condition.empty? ? options : conditions_or_first_condition
|
|
366
|
+
else
|
|
367
|
+
options
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Build a Query::Scope for backward compatibility with PaginatedResult
|
|
372
|
+
def build_query_scope_for_pagination
|
|
373
|
+
Query::Scope.new(
|
|
374
|
+
@model_class,
|
|
375
|
+
conditions: @conditions,
|
|
376
|
+
loader: loader,
|
|
377
|
+
total_limit: @total_limit,
|
|
378
|
+
per_page: @per_page
|
|
379
|
+
)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def validate_includes_connections!(connection_names)
|
|
383
|
+
return unless @model_class.respond_to?(:connections)
|
|
384
|
+
|
|
385
|
+
available = @model_class.connections.keys
|
|
386
|
+
connection_names.each do |name|
|
|
387
|
+
if name.is_a?(Hash)
|
|
388
|
+
# Nested includes: { line_items: :variant }
|
|
389
|
+
name.each_key do |key|
|
|
390
|
+
next if available.include?(key.to_sym)
|
|
391
|
+
|
|
392
|
+
raise ArgumentError, "Invalid connection for #{@model_class.name}: #{key}. " \
|
|
393
|
+
"Available connections: #{available.join(', ')}"
|
|
394
|
+
end
|
|
395
|
+
else
|
|
396
|
+
next if available.include?(name.to_sym)
|
|
397
|
+
|
|
398
|
+
raise ArgumentError, "Invalid connection for #{@model_class.name}: #{name}. " \
|
|
399
|
+
"Available connections: #{available.join(', ')}"
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def validate_select_attributes!(attributes)
|
|
405
|
+
return if attributes.empty?
|
|
406
|
+
|
|
407
|
+
available = available_select_attributes
|
|
408
|
+
invalid = attributes - available
|
|
409
|
+
return if invalid.empty?
|
|
410
|
+
|
|
411
|
+
raise ArgumentError, "Invalid attributes for #{@model_class.name}: #{invalid.join(', ')}. " \
|
|
412
|
+
"Available attributes are: #{available.join(', ')}"
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def available_select_attributes
|
|
416
|
+
loader_klass = @loader_class || @model_class.send(:default_loader_class)
|
|
417
|
+
model_attrs = @model_class.attributes_for_loader(loader_klass)
|
|
418
|
+
return [] unless model_attrs
|
|
419
|
+
|
|
420
|
+
model_attrs.keys.map(&:to_sym).uniq.sort
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|