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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +3 -0
  3. data/README.md +158 -56
  4. data/lib/active_shopify_graphql/configuration.rb +2 -15
  5. data/lib/active_shopify_graphql/connections/connection_loader.rb +141 -0
  6. data/lib/active_shopify_graphql/gid_helper.rb +2 -0
  7. data/lib/active_shopify_graphql/loader.rb +147 -126
  8. data/lib/active_shopify_graphql/loader_context.rb +2 -47
  9. data/lib/active_shopify_graphql/loader_proxy.rb +67 -0
  10. data/lib/active_shopify_graphql/loaders/admin_api_loader.rb +1 -17
  11. data/lib/active_shopify_graphql/loaders/customer_account_api_loader.rb +10 -26
  12. data/lib/active_shopify_graphql/model/associations.rb +94 -0
  13. data/lib/active_shopify_graphql/model/attributes.rb +48 -0
  14. data/lib/active_shopify_graphql/model/connections.rb +174 -0
  15. data/lib/active_shopify_graphql/model/finder_methods.rb +155 -0
  16. data/lib/active_shopify_graphql/model/graphql_type_resolver.rb +89 -0
  17. data/lib/active_shopify_graphql/model/loader_switchable.rb +79 -0
  18. data/lib/active_shopify_graphql/model/metafield_attributes.rb +59 -0
  19. data/lib/active_shopify_graphql/{base.rb → model.rb} +30 -16
  20. data/lib/active_shopify_graphql/model_builder.rb +53 -0
  21. data/lib/active_shopify_graphql/query/node/collection.rb +78 -0
  22. data/lib/active_shopify_graphql/query/node/connection.rb +16 -0
  23. data/lib/active_shopify_graphql/query/node/current_customer.rb +37 -0
  24. data/lib/active_shopify_graphql/query/node/field.rb +23 -0
  25. data/lib/active_shopify_graphql/query/node/fragment.rb +17 -0
  26. data/lib/active_shopify_graphql/query/node/nested_connection.rb +79 -0
  27. data/lib/active_shopify_graphql/query/node/raw.rb +15 -0
  28. data/lib/active_shopify_graphql/query/node/root_connection.rb +76 -0
  29. data/lib/active_shopify_graphql/query/node/single_record.rb +36 -0
  30. data/lib/active_shopify_graphql/query/node/singular.rb +16 -0
  31. data/lib/active_shopify_graphql/query/node.rb +95 -0
  32. data/lib/active_shopify_graphql/query/query_builder.rb +290 -0
  33. data/lib/active_shopify_graphql/query/relation.rb +424 -0
  34. data/lib/active_shopify_graphql/query/scope.rb +219 -0
  35. data/lib/active_shopify_graphql/response/page_info.rb +40 -0
  36. data/lib/active_shopify_graphql/response/paginated_result.rb +114 -0
  37. data/lib/active_shopify_graphql/response/response_mapper.rb +242 -0
  38. data/lib/active_shopify_graphql/search_query/hash_condition_formatter.rb +107 -0
  39. data/lib/active_shopify_graphql/search_query/parameter_binder.rb +68 -0
  40. data/lib/active_shopify_graphql/search_query/value_sanitizer.rb +24 -0
  41. data/lib/active_shopify_graphql/search_query.rb +34 -84
  42. data/lib/active_shopify_graphql/version.rb +1 -1
  43. data/lib/active_shopify_graphql.rb +29 -29
  44. metadata +46 -15
  45. data/lib/active_shopify_graphql/associations.rb +0 -94
  46. data/lib/active_shopify_graphql/attributes.rb +0 -50
  47. data/lib/active_shopify_graphql/connection_loader.rb +0 -96
  48. data/lib/active_shopify_graphql/connections.rb +0 -198
  49. data/lib/active_shopify_graphql/finder_methods.rb +0 -182
  50. data/lib/active_shopify_graphql/fragment_builder.rb +0 -228
  51. data/lib/active_shopify_graphql/graphql_type_resolver.rb +0 -91
  52. data/lib/active_shopify_graphql/includes_scope.rb +0 -48
  53. data/lib/active_shopify_graphql/loader_switchable.rb +0 -180
  54. data/lib/active_shopify_graphql/metafield_attributes.rb +0 -61
  55. data/lib/active_shopify_graphql/query_node.rb +0 -173
  56. data/lib/active_shopify_graphql/query_tree.rb +0 -225
  57. 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