carddb 0.2.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.
@@ -0,0 +1,919 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CardDB
4
+ # Represents a paginated collection of results from the API.
5
+ # Implements Enumerable for easy iteration.
6
+ class Collection
7
+ include Enumerable
8
+
9
+ # @return [Integer] Total count of matching records
10
+ attr_reader :total_count
11
+
12
+ # @return [Hash] Page info with cursor data
13
+ attr_reader :page_info
14
+
15
+ # @return [Array] The items in this page
16
+ attr_reader :items
17
+
18
+ # @return [Proc, nil] Proc to fetch the next page
19
+ attr_reader :next_page_loader
20
+
21
+ # @param data [Hash] The connection data from GraphQL response
22
+ # @param item_class [Class, nil] Optional class to wrap items
23
+ # @param next_page_loader [Proc, nil] Proc to load next page
24
+ # @param client [Client, nil] Client instance for making related queries
25
+ def initialize(data, item_class: nil, next_page_loader: nil, client: nil)
26
+ @total_count = data['totalCount']
27
+ @page_info = PageInfo.new(data['pageInfo']) if data['pageInfo']
28
+ @items = parse_items(data['edges'] || [], item_class, client)
29
+ @next_page_loader = next_page_loader
30
+ end
31
+
32
+ # Iterate over items
33
+ def each(&block)
34
+ items.each(&block)
35
+ end
36
+
37
+ # Check if there are more pages
38
+ #
39
+ # @return [Boolean]
40
+ def next_page?
41
+ page_info&.has_next_page || false
42
+ end
43
+
44
+ # Check if there are previous pages
45
+ #
46
+ # @return [Boolean]
47
+ def previous_page?
48
+ page_info&.has_previous_page || false
49
+ end
50
+
51
+ # Get the cursor for the next page
52
+ #
53
+ # @return [String, nil]
54
+ def end_cursor
55
+ page_info&.end_cursor
56
+ end
57
+
58
+ # Get the cursor for the previous page
59
+ #
60
+ # @return [String, nil]
61
+ def start_cursor
62
+ page_info&.start_cursor
63
+ end
64
+
65
+ # Fetch the next page of results
66
+ #
67
+ # @return [Collection, nil] The next page, or nil if no more pages
68
+ # @raise [CardDB::Error] If no next page loader is configured
69
+ def next_page
70
+ return nil unless next_page?
71
+ raise Error, 'No next page loader configured' unless next_page_loader
72
+
73
+ next_page_loader.call(end_cursor)
74
+ end
75
+
76
+ # Returns a lazy enumerator that auto-paginates through all results.
77
+ #
78
+ # @return [Enumerator::Lazy]
79
+ def auto_paginate
80
+ Enumerator.new do |yielder|
81
+ current = self
82
+ loop do
83
+ current.each { |item| yielder << item }
84
+ break unless current.next_page?
85
+
86
+ current = current.next_page
87
+ break if current.nil?
88
+ end
89
+ end.lazy
90
+ end
91
+
92
+ # Iterates through all results, automatically paginating as needed.
93
+ # Similar to ActiveRecord's find_each.
94
+ #
95
+ # @param batch_size [Integer] Number of records per API request (default: 100, max: 100)
96
+ # @yield [item] Each item in the collection
97
+ # @return [void]
98
+ #
99
+ # @example
100
+ # collection.find_each do |record|
101
+ # puts record.name
102
+ # end
103
+ #
104
+ # @example With custom batch size
105
+ # collection.find_each(batch_size: 50) do |record|
106
+ # process(record)
107
+ # end
108
+ def find_each(batch_size: 100, &block)
109
+ return enum_for(:find_each, batch_size: batch_size) unless block_given?
110
+
111
+ find_in_batches(batch_size: batch_size) do |batch|
112
+ batch.each(&block)
113
+ end
114
+ end
115
+
116
+ # Yields batches of records, automatically paginating as needed.
117
+ # Similar to ActiveRecord's find_in_batches.
118
+ #
119
+ # @param batch_size [Integer] Number of records per batch/API request (default: 100, max: 100)
120
+ # @yield [batch] Array of items for each batch
121
+ # @return [void]
122
+ #
123
+ # @example
124
+ # collection.find_in_batches(batch_size: 50) do |batch|
125
+ # batch.each { |record| process(record) }
126
+ # end
127
+ def find_in_batches(batch_size: 100)
128
+ return enum_for(:find_in_batches, batch_size: batch_size) unless block_given?
129
+
130
+ # Clamp batch_size to valid range
131
+ [[batch_size, 1].max, 100].min
132
+
133
+ # For the first batch, we use the items already loaded
134
+ # (they may have been fetched with a different page size, but that's ok)
135
+ current = self
136
+
137
+ loop do
138
+ # Yield the current page's items
139
+ yield current.items unless current.items.empty?
140
+
141
+ # Stop if no more pages
142
+ break unless current.next_page?
143
+
144
+ # Fetch next page with the specified batch_size
145
+ # Note: The next_page_loader uses the original 'first' parameter,
146
+ # but subsequent pages will use the original size.
147
+ # To truly control batch_size, we'd need to modify the loader.
148
+ current = current.next_page
149
+ break if current.nil?
150
+ end
151
+ end
152
+
153
+ # Get number of items in current page
154
+ #
155
+ # @return [Integer]
156
+ def size
157
+ items.size
158
+ end
159
+ alias length size
160
+
161
+ # Check if collection is empty
162
+ #
163
+ # @return [Boolean]
164
+ def empty?
165
+ items.empty?
166
+ end
167
+
168
+ # Get first item
169
+ #
170
+ # @return [Object, nil]
171
+ def first
172
+ items.first
173
+ end
174
+
175
+ # Get last item
176
+ #
177
+ # @return [Object, nil]
178
+ def last
179
+ items.last
180
+ end
181
+
182
+ private
183
+
184
+ def parse_items(edges, item_class, client)
185
+ edges.map do |edge|
186
+ node = edge['node']
187
+ if item_class
188
+ item_class.new(node, client: client)
189
+ else
190
+ node
191
+ end
192
+ end
193
+ end
194
+ end
195
+
196
+ # Represents pagination info
197
+ class PageInfo
198
+ # @return [Boolean]
199
+ attr_reader :has_next_page
200
+
201
+ # @return [Boolean]
202
+ attr_reader :has_previous_page
203
+
204
+ # @return [String, nil]
205
+ attr_reader :start_cursor
206
+
207
+ # @return [String, nil]
208
+ attr_reader :end_cursor
209
+
210
+ def initialize(data)
211
+ @has_next_page = data['hasNextPage'] || false
212
+ @has_previous_page = data['hasPreviousPage'] || false
213
+ @start_cursor = data['startCursor']
214
+ @end_cursor = data['endCursor']
215
+ end
216
+ end
217
+
218
+ # Base class for resource wrappers
219
+ class Resource
220
+ # @return [Hash] The raw data from the API
221
+ attr_reader :data
222
+
223
+ # @return [Client, nil] The client instance for making related queries
224
+ attr_reader :client
225
+
226
+ def initialize(data, client: nil)
227
+ @data = data || {}
228
+ @client = client
229
+ end
230
+
231
+ # Access raw data fields
232
+ def [](key)
233
+ data[key.to_s]
234
+ end
235
+
236
+ # Check if a field exists
237
+ def key?(key)
238
+ data.key?(key.to_s)
239
+ end
240
+
241
+ def to_h
242
+ data
243
+ end
244
+
245
+ def to_json(*args)
246
+ data.to_json(*args)
247
+ end
248
+ end
249
+
250
+ # Wrapper for Publisher objects
251
+ class Publisher < Resource
252
+ def id
253
+ data['id']
254
+ end
255
+
256
+ def name
257
+ data['name']
258
+ end
259
+
260
+ def slug
261
+ data['slug']
262
+ end
263
+
264
+ def description
265
+ data['description']
266
+ end
267
+
268
+ def website
269
+ data['website']
270
+ end
271
+
272
+ # @return [String] Publisher status (`ACTIVE` or `DEACTIVATED`)
273
+ def status
274
+ data['status']
275
+ end
276
+
277
+ def logo_url
278
+ data.dig('logoFile', 'url')
279
+ end
280
+
281
+ def banner_url
282
+ data.dig('bannerFile', 'url')
283
+ end
284
+
285
+ def created_at
286
+ parse_time(data['createdAt'])
287
+ end
288
+
289
+ def updated_at
290
+ parse_time(data['updatedAt'])
291
+ end
292
+
293
+ # Fetch games belonging to this publisher.
294
+ # Results are cached after the first call.
295
+ #
296
+ # @return [Collection<Game>] Collection of games
297
+ # @raise [CardDB::Error] If no client is available
298
+ def games
299
+ @games ||= fetch_games
300
+ end
301
+
302
+ private
303
+
304
+ def fetch_games
305
+ raise Error, 'No client available to fetch games' unless client
306
+
307
+ client.games.search(publisher_slug: slug)
308
+ end
309
+
310
+ def parse_time(value)
311
+ return nil unless value
312
+
313
+ Time.parse(value)
314
+ rescue ArgumentError
315
+ value
316
+ end
317
+ end
318
+
319
+ # Wrapper for Game objects
320
+ class Game < Resource
321
+ def id
322
+ data['id']
323
+ end
324
+
325
+ def key
326
+ data['key']
327
+ end
328
+
329
+ def name
330
+ data['name']
331
+ end
332
+
333
+ def description
334
+ data['description']
335
+ end
336
+
337
+ def website
338
+ data['website']
339
+ end
340
+
341
+ def visibility
342
+ data['visibility']
343
+ end
344
+
345
+ def archived?
346
+ data['isArchived']
347
+ end
348
+
349
+ def publisher_id
350
+ data['publisherId']
351
+ end
352
+
353
+ def publisher
354
+ data['publisher']
355
+ end
356
+
357
+ def logo_url
358
+ data.dig('logoFile', 'url')
359
+ end
360
+
361
+ def cover_url
362
+ data.dig('coverFile', 'url')
363
+ end
364
+
365
+ def created_at
366
+ parse_time(data['createdAt'])
367
+ end
368
+
369
+ def updated_at
370
+ parse_time(data['updatedAt'])
371
+ end
372
+
373
+ # Fetch datasets belonging to this game.
374
+ # Unfiltered results are cached after the first call.
375
+ #
376
+ # @param purpose [String, nil] Filter by dataset purpose (DATA or RULES)
377
+ # @param search [String, nil] Search by dataset name
378
+ # @param first [Integer, nil] Maximum number of results
379
+ # @param after [String, nil] Cursor for pagination
380
+ # @return [Collection<Dataset>] Collection of datasets
381
+ # @raise [CardDB::Error] If no client is available
382
+ def datasets(purpose: nil, search: nil, first: nil, after: nil)
383
+ if purpose.nil? && search.nil? && first.nil? && after.nil?
384
+ @datasets ||= fetch_datasets
385
+ else
386
+ fetch_datasets(purpose: purpose, search: search, first: first, after: after)
387
+ end
388
+ end
389
+
390
+ private
391
+
392
+ def fetch_datasets(purpose: nil, search: nil, first: nil, after: nil)
393
+ raise Error, 'No client available to fetch datasets' unless client
394
+
395
+ publisher_slug = publisher&.[]('slug')
396
+ raise Error, 'Publisher slug not available on this game' unless publisher_slug
397
+
398
+ client.datasets.search(
399
+ publisher_slug: publisher_slug,
400
+ game_key: key,
401
+ search: search,
402
+ purpose: purpose,
403
+ first: first,
404
+ after: after
405
+ )
406
+ end
407
+
408
+ def parse_time(value)
409
+ return nil unless value
410
+
411
+ Time.parse(value)
412
+ rescue ArgumentError
413
+ value
414
+ end
415
+ end
416
+
417
+ # Wrapper for Dataset objects
418
+ class Dataset < Resource
419
+ def id
420
+ data['id']
421
+ end
422
+
423
+ def key
424
+ data['key']
425
+ end
426
+
427
+ def name
428
+ data['name']
429
+ end
430
+
431
+ def description
432
+ data['description']
433
+ end
434
+
435
+ def purpose
436
+ data['purpose']
437
+ end
438
+
439
+ def visibility
440
+ data['visibility']
441
+ end
442
+
443
+ def archived?
444
+ data['isArchived']
445
+ end
446
+
447
+ def publisher_id
448
+ data['publisherId']
449
+ end
450
+
451
+ def game_id
452
+ data['gameId']
453
+ end
454
+
455
+ def publisher
456
+ data['publisher']
457
+ end
458
+
459
+ def game
460
+ data['game']
461
+ end
462
+
463
+ def schema
464
+ @schema ||= DatasetSchema.new(data['schema']) if data['schema']
465
+ end
466
+
467
+ def created_at
468
+ parse_time(data['createdAt'])
469
+ end
470
+
471
+ def updated_at
472
+ parse_time(data['updatedAt'])
473
+ end
474
+
475
+ # Schema-aware helpers
476
+
477
+ # Get all field keys from the schema.
478
+ #
479
+ # @return [Array<String>] List of field keys
480
+ def field_keys
481
+ schema&.fields&.map(&:key) || []
482
+ end
483
+
484
+ # Get all filterable field keys.
485
+ #
486
+ # @return [Array<String>] List of filterable field keys
487
+ def filterable_fields
488
+ schema&.filterable_fields || []
489
+ end
490
+
491
+ # Get all searchable field keys.
492
+ #
493
+ # @return [Array<String>] List of searchable field keys
494
+ def searchable_fields
495
+ schema&.searchable_fields || []
496
+ end
497
+
498
+ # Get the identifier field key.
499
+ #
500
+ # @return [String, nil] The identifier field key or nil if none
501
+ def identifier_field
502
+ schema&.fields&.find(&:identifier?)&.key
503
+ end
504
+
505
+ # Check if a field is filterable.
506
+ #
507
+ # @param field_key [String, Symbol] The field key
508
+ # @return [Boolean]
509
+ def filterable?(field_key)
510
+ filterable_fields.include?(field_key.to_s)
511
+ end
512
+
513
+ # Check if a field is searchable.
514
+ #
515
+ # @param field_key [String, Symbol] The field key
516
+ # @return [Boolean]
517
+ def searchable?(field_key)
518
+ searchable_fields.include?(field_key.to_s)
519
+ end
520
+
521
+ # Get a field's info by key.
522
+ #
523
+ # @param field_key [String, Symbol] The field key
524
+ # @return [FieldInfo, nil] The field info or nil if not found
525
+ def field(field_key)
526
+ schema&.field(field_key)
527
+ end
528
+
529
+ # Search records in this dataset.
530
+ # Unlike datasets on Game, this is NOT cached since filters can vary.
531
+ #
532
+ # @param first [Integer, nil] Maximum number of results
533
+ # @param filter [Hash, nil] Filter conditions (alternative to block)
534
+ # @yield [FilterBuilder] Optional block for filter DSL
535
+ # @return [Collection<Record>] Collection of records
536
+ # @raise [CardDB::Error] If no client is available
537
+ def records(first: nil, filter: nil, &block)
538
+ raise Error, 'No client available to fetch records' unless client
539
+
540
+ publisher_slug = publisher&.[]('slug')
541
+ game_key = game&.[]('key')
542
+
543
+ raise Error, 'Publisher slug not available on this dataset' unless publisher_slug
544
+ raise Error, 'Game key not available on this dataset' unless game_key
545
+
546
+ client.records.search(
547
+ publisher_slug: publisher_slug,
548
+ game_key: game_key,
549
+ dataset_key: key,
550
+ first: first,
551
+ filter: filter,
552
+ &block
553
+ )
554
+ end
555
+
556
+ private
557
+
558
+ def parse_time(value)
559
+ return nil unless value
560
+
561
+ Time.parse(value)
562
+ rescue ArgumentError
563
+ value
564
+ end
565
+ end
566
+
567
+ # Wrapper for DatasetSchema
568
+ class DatasetSchema
569
+ attr_reader :data
570
+
571
+ def initialize(data)
572
+ @data = data || {}
573
+ end
574
+
575
+ def fields
576
+ @fields ||= (data['fields'] || []).map { |f| FieldInfo.new(f) }
577
+ end
578
+
579
+ def filterable_fields
580
+ data['filterableFields'] || []
581
+ end
582
+
583
+ def searchable_fields
584
+ data['searchableFields'] || []
585
+ end
586
+
587
+ def link_fields
588
+ @link_fields ||= (data['linkFields'] || []).map { |f| LinkFieldInfo.new(f) }
589
+ end
590
+
591
+ # Find a field by key
592
+ def field(key)
593
+ fields.find { |f| f.key == key.to_s }
594
+ end
595
+ end
596
+
597
+ # Wrapper for FieldInfo
598
+ class FieldInfo
599
+ attr_reader :data
600
+
601
+ def initialize(data)
602
+ @data = data || {}
603
+ end
604
+
605
+ def key
606
+ data['key']
607
+ end
608
+
609
+ def label
610
+ data['label']
611
+ end
612
+
613
+ def description
614
+ data['description']
615
+ end
616
+
617
+ def type
618
+ data['type']
619
+ end
620
+
621
+ def required?
622
+ data['isRequired']
623
+ end
624
+
625
+ def filterable?
626
+ data['filterable']
627
+ end
628
+
629
+ def searchable?
630
+ data['searchable']
631
+ end
632
+
633
+ def identifier?
634
+ data['isIdentifier']
635
+ end
636
+
637
+ def item_type
638
+ data['itemType']
639
+ end
640
+
641
+ def display_format
642
+ data['displayFormat']
643
+ end
644
+
645
+ def semantic_type
646
+ data['semanticType']
647
+ end
648
+
649
+ def allowed_values
650
+ data['allowedValues']
651
+ end
652
+
653
+ def nested_fields
654
+ @nested_fields ||= (data['nestedFields'] || []).map { |f| FieldInfo.new(f) }
655
+ end
656
+ end
657
+
658
+ # Wrapper for LinkFieldInfo
659
+ class LinkFieldInfo
660
+ attr_reader :data
661
+
662
+ def initialize(data)
663
+ @data = data || {}
664
+ end
665
+
666
+ def key
667
+ data['key']
668
+ end
669
+
670
+ def label
671
+ data['label']
672
+ end
673
+
674
+ def target_dataset_key
675
+ data['targetDatasetKey']
676
+ end
677
+
678
+ def target_dataset_name
679
+ data['targetDatasetName']
680
+ end
681
+
682
+ def target_dataset_id
683
+ data['targetDatasetId']
684
+ end
685
+ end
686
+
687
+ # Wrapper for DatasetRecord objects
688
+ class Record < Resource
689
+ def id
690
+ data['id']
691
+ end
692
+
693
+ def dataset_id
694
+ data['datasetId']
695
+ end
696
+
697
+ def record_data
698
+ data['data']
699
+ end
700
+ alias fields record_data
701
+
702
+ def dataset
703
+ data['dataset']
704
+ end
705
+
706
+ def resolved_links
707
+ @resolved_links ||= parse_resolved_links
708
+ end
709
+
710
+ def created_at
711
+ parse_time(data['createdAt'])
712
+ end
713
+
714
+ def updated_at
715
+ parse_time(data['updatedAt'])
716
+ end
717
+
718
+ # Access record data fields directly via bracket notation
719
+ #
720
+ # @param key [String, Symbol] The field name
721
+ # @return [Object, nil] The field value
722
+ def [](key)
723
+ record_data&.[](key.to_s)
724
+ end
725
+
726
+ # Access record data fields directly via method calls
727
+ #
728
+ # @example
729
+ # record.name # equivalent to record['name']
730
+ # record.hp # equivalent to record['hp']
731
+ def method_missing(method_name, *args, &block)
732
+ key = method_name.to_s
733
+
734
+ # Only handle reader methods (no args, no block, no assignment)
735
+ return record_data&.[](key) if args.empty? && block.nil? && !key.end_with?('=')
736
+
737
+ super
738
+ end
739
+
740
+ # Support respond_to? for dynamic field access
741
+ def respond_to_missing?(method_name, include_private = false)
742
+ key = method_name.to_s
743
+ return true if record_data&.key?(key)
744
+
745
+ super
746
+ end
747
+
748
+ private
749
+
750
+ def parse_resolved_links
751
+ return {} unless data['resolvedLinks']
752
+
753
+ data['resolvedLinks'].each_with_object({}) do |link, hash|
754
+ hash[link['field']] = ResolvedLink.new(link, client: client)
755
+ end
756
+ end
757
+
758
+ def parse_time(value)
759
+ return nil unless value
760
+
761
+ Time.parse(value)
762
+ rescue ArgumentError
763
+ value
764
+ end
765
+ end
766
+
767
+ # Wrapper for hosted Deck objects
768
+ class Deck < Resource
769
+ def id
770
+ data['id']
771
+ end
772
+
773
+ def account_id
774
+ data['accountId']
775
+ end
776
+
777
+ def api_application_id
778
+ data['apiApplicationId']
779
+ end
780
+
781
+ def game_id
782
+ data['gameId']
783
+ end
784
+
785
+ def game
786
+ @game ||= data['game'] ? Game.new(data['game'], client: client) : nil
787
+ end
788
+
789
+ def title
790
+ data['title']
791
+ end
792
+
793
+ def description
794
+ data['description']
795
+ end
796
+
797
+ def format_key
798
+ data['formatKey']
799
+ end
800
+
801
+ def visibility
802
+ data['visibility']
803
+ end
804
+
805
+ def external_ref
806
+ data['externalRef']
807
+ end
808
+
809
+ def source_url
810
+ data['sourceUrl']
811
+ end
812
+
813
+ def metadata
814
+ data['metadata'] || {}
815
+ end
816
+
817
+ def entries
818
+ @entries ||= (data['entries'] || []).map { |entry| DeckEntry.new(entry, client: client) }
819
+ end
820
+
821
+ def created_at
822
+ parse_time(data['createdAt'])
823
+ end
824
+
825
+ def updated_at
826
+ parse_time(data['updatedAt'])
827
+ end
828
+
829
+ private
830
+
831
+ def parse_time(value)
832
+ return nil unless value
833
+
834
+ Time.parse(value)
835
+ rescue ArgumentError
836
+ value
837
+ end
838
+ end
839
+
840
+ # Wrapper for hosted DeckEntry objects
841
+ class DeckEntry < Resource
842
+ def id
843
+ data['id']
844
+ end
845
+
846
+ def dataset_id
847
+ data['datasetId']
848
+ end
849
+
850
+ def record_id
851
+ data['recordId']
852
+ end
853
+
854
+ def identifier
855
+ data['identifier']
856
+ end
857
+
858
+ def quantity
859
+ data['quantity']
860
+ end
861
+
862
+ def section
863
+ data['section']
864
+ end
865
+
866
+ def sort_order
867
+ data['sortOrder']
868
+ end
869
+
870
+ def annotations
871
+ data['annotations'] || {}
872
+ end
873
+
874
+ def record
875
+ @record ||= data['record'] ? Record.new(data['record'], client: client) : nil
876
+ end
877
+ end
878
+
879
+ # Wrapper for ResolvedLink
880
+ # Supports both single links and arrays of links
881
+ class ResolvedLink
882
+ attr_reader :data, :client
883
+
884
+ def initialize(data, client: nil)
885
+ @data = data || {}
886
+ @client = client
887
+ end
888
+
889
+ def field
890
+ data['field']
891
+ end
892
+
893
+ def link_field_key
894
+ data['linkFieldKey']
895
+ end
896
+
897
+ # Returns all values (always an array)
898
+ def values
899
+ data['values'] || []
900
+ end
901
+
902
+ # Returns all resolved records (parallel array to values, nil for unresolved)
903
+ def records
904
+ @records ||= (data['records'] || []).map do |rec|
905
+ rec ? Record.new(rec, client: client) : nil
906
+ end
907
+ end
908
+
909
+ # Convenience: first value (for single-link fields)
910
+ def value
911
+ values.first
912
+ end
913
+
914
+ # Convenience: first record (for single-link fields)
915
+ def record
916
+ records.first
917
+ end
918
+ end
919
+ end