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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rspec_status +96 -0
- data/.rubocop.yml +72 -0
- data/AGENTS.md +27 -0
- data/CHANGELOG.md +127 -0
- data/LICENSE.txt +21 -0
- data/README.md +732 -0
- data/Rakefile +10 -0
- data/examples/basic_usage.rb +60 -0
- data/examples/filtering.rb +93 -0
- data/examples/pagination.rb +58 -0
- data/lib/carddb/batch.rb +287 -0
- data/lib/carddb/cache.rb +120 -0
- data/lib/carddb/client.rb +139 -0
- data/lib/carddb/collection.rb +919 -0
- data/lib/carddb/configuration.rb +185 -0
- data/lib/carddb/connection.rb +224 -0
- data/lib/carddb/errors.rb +85 -0
- data/lib/carddb/filter_builder.rb +214 -0
- data/lib/carddb/query_builder.rb +658 -0
- data/lib/carddb/resources/base.rb +86 -0
- data/lib/carddb/resources/datasets.rb +132 -0
- data/lib/carddb/resources/decks.rb +125 -0
- data/lib/carddb/resources/games.rb +111 -0
- data/lib/carddb/resources/publishers.rb +86 -0
- data/lib/carddb/resources/records.rb +239 -0
- data/lib/carddb/resources/rules.rb +49 -0
- data/lib/carddb/version.rb +5 -0
- data/lib/carddb.rb +160 -0
- metadata +102 -0
|
@@ -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
|