active_shopify_graphql 0.3.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -0
  3. data/README.md +187 -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/graphql_associations.rb +245 -0
  8. data/lib/active_shopify_graphql/loader.rb +147 -126
  9. data/lib/active_shopify_graphql/loader_context.rb +2 -47
  10. data/lib/active_shopify_graphql/loader_proxy.rb +67 -0
  11. data/lib/active_shopify_graphql/loaders/admin_api_loader.rb +1 -17
  12. data/lib/active_shopify_graphql/loaders/customer_account_api_loader.rb +10 -26
  13. data/lib/active_shopify_graphql/model/associations.rb +94 -0
  14. data/lib/active_shopify_graphql/model/attributes.rb +48 -0
  15. data/lib/active_shopify_graphql/model/connections.rb +174 -0
  16. data/lib/active_shopify_graphql/model/finder_methods.rb +155 -0
  17. data/lib/active_shopify_graphql/model/graphql_type_resolver.rb +89 -0
  18. data/lib/active_shopify_graphql/model/loader_switchable.rb +79 -0
  19. data/lib/active_shopify_graphql/model/metafield_attributes.rb +59 -0
  20. data/lib/active_shopify_graphql/{base.rb → model.rb} +30 -16
  21. data/lib/active_shopify_graphql/model_builder.rb +53 -0
  22. data/lib/active_shopify_graphql/query/node/collection.rb +78 -0
  23. data/lib/active_shopify_graphql/query/node/connection.rb +16 -0
  24. data/lib/active_shopify_graphql/query/node/current_customer.rb +37 -0
  25. data/lib/active_shopify_graphql/query/node/field.rb +23 -0
  26. data/lib/active_shopify_graphql/query/node/fragment.rb +17 -0
  27. data/lib/active_shopify_graphql/query/node/nested_connection.rb +79 -0
  28. data/lib/active_shopify_graphql/query/node/raw.rb +15 -0
  29. data/lib/active_shopify_graphql/query/node/root_connection.rb +76 -0
  30. data/lib/active_shopify_graphql/query/node/single_record.rb +36 -0
  31. data/lib/active_shopify_graphql/query/node/singular.rb +16 -0
  32. data/lib/active_shopify_graphql/query/node.rb +95 -0
  33. data/lib/active_shopify_graphql/query/query_builder.rb +290 -0
  34. data/lib/active_shopify_graphql/query/relation.rb +424 -0
  35. data/lib/active_shopify_graphql/query/scope.rb +219 -0
  36. data/lib/active_shopify_graphql/response/page_info.rb +40 -0
  37. data/lib/active_shopify_graphql/response/paginated_result.rb +114 -0
  38. data/lib/active_shopify_graphql/response/response_mapper.rb +242 -0
  39. data/lib/active_shopify_graphql/search_query/hash_condition_formatter.rb +107 -0
  40. data/lib/active_shopify_graphql/search_query/parameter_binder.rb +68 -0
  41. data/lib/active_shopify_graphql/search_query/value_sanitizer.rb +24 -0
  42. data/lib/active_shopify_graphql/search_query.rb +34 -84
  43. data/lib/active_shopify_graphql/version.rb +1 -1
  44. data/lib/active_shopify_graphql.rb +30 -28
  45. metadata +47 -15
  46. data/lib/active_shopify_graphql/associations.rb +0 -90
  47. data/lib/active_shopify_graphql/attributes.rb +0 -50
  48. data/lib/active_shopify_graphql/connection_loader.rb +0 -96
  49. data/lib/active_shopify_graphql/connections.rb +0 -198
  50. data/lib/active_shopify_graphql/finder_methods.rb +0 -159
  51. data/lib/active_shopify_graphql/fragment_builder.rb +0 -228
  52. data/lib/active_shopify_graphql/graphql_type_resolver.rb +0 -91
  53. data/lib/active_shopify_graphql/includes_scope.rb +0 -48
  54. data/lib/active_shopify_graphql/loader_switchable.rb +0 -180
  55. data/lib/active_shopify_graphql/metafield_attributes.rb +0 -61
  56. data/lib/active_shopify_graphql/query_node.rb +0 -173
  57. data/lib/active_shopify_graphql/query_tree.rb +0 -225
  58. data/lib/active_shopify_graphql/response_mapper.rb +0 -249
@@ -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
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Query
5
+ # A chainable query builder that accumulates query configuration
6
+ # and executes the query when records are accessed.
7
+ #
8
+ # @example Basic usage
9
+ # ProductVariant.where(sku: "*").limit(100).to_a
10
+ #
11
+ # @example Pagination with block
12
+ # ProductVariant.where(sku: "*").in_pages(of: 50) do |page|
13
+ # process_batch(page)
14
+ # end
15
+ #
16
+ # @example Manual pagination
17
+ # page = ProductVariant.where(sku: "*").in_pages(of: 50)
18
+ # page.each { |record| process(record) }
19
+ # page = page.next_page while page.has_next_page?
20
+ class Scope
21
+ include Enumerable
22
+
23
+ DEFAULT_PER_PAGE = 250
24
+
25
+ attr_reader :model_class, :conditions, :total_limit, :per_page
26
+
27
+ def initialize(model_class, conditions: {}, loader: nil, total_limit: nil, per_page: DEFAULT_PER_PAGE)
28
+ @model_class = model_class
29
+ @conditions = conditions
30
+ @loader = loader
31
+ @total_limit = total_limit
32
+ @per_page = [per_page, ActiveShopifyGraphQL.configuration.max_objects_per_paginated_query].min
33
+ @loaded = false
34
+ @records = nil
35
+ end
36
+
37
+ # Set a limit on total records to return
38
+ # @param count [Integer] Maximum number of records to fetch across all pages
39
+ # @return [Scope] A new scope with the limit applied
40
+ def limit(count)
41
+ dup_with(total_limit: count)
42
+ end
43
+
44
+ # Configure pagination and optionally iterate through pages
45
+ # @param of [Integer] Number of records per page (default: 250, max: 250)
46
+ # @yield [PaginatedResult] Each page of results
47
+ # @return [PaginatedResult, self] Returns PaginatedResult if no block given
48
+ def in_pages(of: DEFAULT_PER_PAGE, &block)
49
+ page_size = [of, ActiveShopifyGraphQL.configuration.max_objects_per_paginated_query].min
50
+ scoped = dup_with(per_page: page_size)
51
+
52
+ if block_given?
53
+ scoped.each_page(&block)
54
+ self
55
+ else
56
+ scoped.fetch_first_page
57
+ end
58
+ end
59
+
60
+ # Iterate through all pages, yielding each page
61
+ # @yield [PaginatedResult] Each page of results
62
+ def each_page
63
+ current_page = fetch_first_page
64
+ records_yielded = 0
65
+
66
+ loop do
67
+ break if current_page.empty?
68
+
69
+ # Apply total limit if set
70
+ if @total_limit
71
+ remaining = @total_limit - records_yielded
72
+ break if remaining <= 0
73
+
74
+ if current_page.size > remaining
75
+ # Trim the page to fit the limit
76
+ trimmed_records = current_page.records.first(remaining)
77
+ current_page = Response::PaginatedResult.new(
78
+ records: trimmed_records,
79
+ page_info: Response::PageInfo.new, # Empty page info to stop pagination
80
+ query_scope: self
81
+ )
82
+ end
83
+ end
84
+
85
+ yield current_page
86
+ records_yielded += current_page.size
87
+
88
+ break unless current_page.has_next_page?
89
+ break if @total_limit && records_yielded >= @total_limit
90
+
91
+ current_page = current_page.next_page
92
+ end
93
+ end
94
+
95
+ # Iterate through all records across all pages
96
+ # @yield [Object] Each record
97
+ def each(&block)
98
+ return to_enum(:each) unless block_given?
99
+
100
+ each_page do |page|
101
+ page.each(&block)
102
+ end
103
+ end
104
+
105
+ # Load all records respecting total_limit
106
+ # @return [Array] All records
107
+ def to_a
108
+ return @records if @loaded
109
+
110
+ all_records = []
111
+ each_page do |page|
112
+ all_records.concat(page.to_a)
113
+ end
114
+ @records = all_records
115
+ @loaded = true
116
+ @records
117
+ end
118
+ alias load to_a
119
+
120
+ # Get first record
121
+ # @param count [Integer, nil] Number of records to return
122
+ # @return [Object, Array, nil] First record(s) or nil
123
+ def first(count = nil)
124
+ if count
125
+ scoped = dup_with(total_limit: count, per_page: [count, max_objects_per_paginated_query].min)
126
+ scoped.to_a
127
+ else
128
+ scoped = dup_with(total_limit: 1, per_page: 1)
129
+ scoped.to_a.first
130
+ end
131
+ end
132
+
133
+ # Check if any records exist
134
+ # @return [Boolean]
135
+ def exists?
136
+ first(1).any?
137
+ end
138
+
139
+ # Check if no records exist (Array compatibility)
140
+ # @return [Boolean]
141
+ def empty?
142
+ first(1).empty?
143
+ end
144
+
145
+ # Size/length of records (loads all pages, use with caution)
146
+ # @return [Integer]
147
+ def size
148
+ to_a.size
149
+ end
150
+ alias length size
151
+
152
+ # Count records (loads all pages, use with caution)
153
+ # @return [Integer]
154
+ def count
155
+ to_a.count
156
+ end
157
+
158
+ # Array-like access
159
+ def [](index)
160
+ to_a[index]
161
+ end
162
+
163
+ # Map over records (Array compatibility)
164
+ def map(&block)
165
+ to_a.map(&block)
166
+ end
167
+
168
+ # Select/filter records (Array compatibility)
169
+ def select_records(&block)
170
+ to_a.select(&block)
171
+ end
172
+
173
+ # Fetch a specific page by cursor
174
+ # @param after [String, nil] Cursor to fetch records after
175
+ # @param before [String, nil] Cursor to fetch records before
176
+ # @return [PaginatedResult]
177
+ def fetch_page(after: nil, before: nil)
178
+ loader.load_paginated_collection(
179
+ conditions: @conditions,
180
+ per_page: effective_per_page,
181
+ after: after,
182
+ before: before,
183
+ query_scope: self
184
+ )
185
+ end
186
+
187
+ # Fetch the first page of results
188
+ # @return [PaginatedResult]
189
+ def fetch_first_page
190
+ fetch_page
191
+ end
192
+
193
+ private
194
+
195
+ # Calculate effective per_page considering total_limit
196
+ def effective_per_page
197
+ if @total_limit && @total_limit < @per_page
198
+ @total_limit
199
+ else
200
+ @per_page
201
+ end
202
+ end
203
+
204
+ def loader
205
+ @loader ||= @model_class.default_loader
206
+ end
207
+
208
+ def dup_with(**changes)
209
+ Scope.new(
210
+ @model_class,
211
+ conditions: changes.fetch(:conditions, @conditions),
212
+ loader: changes.fetch(:loader, @loader),
213
+ total_limit: changes.fetch(:total_limit, @total_limit),
214
+ per_page: changes.fetch(:per_page, @per_page)
215
+ )
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveShopifyGraphQL
4
+ module Response
5
+ # Holds pagination metadata returned from a Shopify GraphQL connection query.
6
+ # Provides methods for navigating between pages.
7
+ class PageInfo
8
+ attr_reader :start_cursor, :end_cursor
9
+
10
+ def initialize(data = {})
11
+ @has_next_page = data["hasNextPage"] || false
12
+ @has_previous_page = data["hasPreviousPage"] || false
13
+ @start_cursor = data["startCursor"]
14
+ @end_cursor = data["endCursor"]
15
+ end
16
+
17
+ def has_next_page?
18
+ @has_next_page
19
+ end
20
+
21
+ def has_previous_page?
22
+ @has_previous_page
23
+ end
24
+
25
+ # Check if this is an empty/null page info
26
+ def empty?
27
+ @start_cursor.nil? && @end_cursor.nil?
28
+ end
29
+
30
+ def to_h
31
+ {
32
+ has_next_page: @has_next_page,
33
+ has_previous_page: @has_previous_page,
34
+ start_cursor: @start_cursor,
35
+ end_cursor: @end_cursor
36
+ }
37
+ end
38
+ end
39
+ end
40
+ end