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.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+ require 'rubocop/rake_task'
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+ RuboCop::RakeTask.new
9
+
10
+ task default: %i[spec rubocop]
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'carddb'
5
+
6
+ # Configure the client
7
+ CardDB.configure do |config|
8
+ # Optional: Set API key for higher rate limits
9
+ config.api_key = ENV.fetch('CARDDB_API_KEY', nil)
10
+
11
+ # Set defaults for convenience
12
+ config.default_publisher = 'pokemon-company'
13
+ config.default_game = 'pokemon-tcg'
14
+ end
15
+
16
+ # Search for games
17
+ puts '=== Searching for Games ==='
18
+ games = CardDB.games.search
19
+ puts "Found #{games.total_count} games"
20
+ games.each do |game|
21
+ puts " - #{game.name} (#{game.key})"
22
+ end
23
+
24
+ # Search for datasets
25
+ puts "\n=== Searching for Datasets ==="
26
+ datasets = CardDB.datasets.search
27
+ puts "Found #{datasets.total_count} datasets"
28
+ datasets.each do |dataset|
29
+ puts " - #{dataset.name} (#{dataset.key})"
30
+ end
31
+
32
+ # Get dataset schema
33
+ puts "\n=== Dataset Schema ==="
34
+ schema = CardDB.datasets.schema(dataset_key: 'cards')
35
+ if schema
36
+ puts 'Fields:'
37
+ schema.fields.each do |field|
38
+ filterable = field.filterable? ? '[filterable]' : ''
39
+ searchable = field.searchable? ? '[searchable]' : ''
40
+ puts " - #{field.key}: #{field.type} #{filterable} #{searchable}"
41
+ end
42
+ end
43
+
44
+ # Search for records
45
+ puts "\n=== Searching for Records ==="
46
+ records = CardDB.records.search(dataset_key: 'cards', first: 5)
47
+ puts "Found #{records.total_count} records (showing first #{records.size})"
48
+ records.each do |record|
49
+ puts " - #{record['name']}"
50
+ end
51
+
52
+ # Search with filter DSL
53
+ puts "\n=== Filtered Search (HP >= 100) ==="
54
+ filtered = CardDB.records.search(dataset_key: 'cards', first: 5) do
55
+ where(hp: gte(100))
56
+ end
57
+ puts "Found #{filtered.total_count} records with HP >= 100"
58
+ filtered.each do |record|
59
+ puts " - #{record['name']} (HP: #{record['hp']})"
60
+ end
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'carddb'
5
+
6
+ # Configure the client
7
+ CardDB.configure do |config|
8
+ config.api_key = ENV.fetch('CARDDB_API_KEY', nil)
9
+ config.default_publisher = 'pokemon-company'
10
+ config.default_game = 'pokemon-tcg'
11
+ end
12
+
13
+ # Example 1: Simple equality
14
+ puts '=== Simple Equality ==='
15
+ records = CardDB.records.search(dataset_key: 'cards', first: 3) do
16
+ where(rarity: 'rare')
17
+ end
18
+ puts "Rare cards: #{records.total_count}"
19
+
20
+ # Example 2: Comparison operators
21
+ puts "\n=== Comparison Operators ==="
22
+ records = CardDB.records.search(dataset_key: 'cards', first: 3) do
23
+ where(hp: gte(100))
24
+ where(hp: lte(150))
25
+ end
26
+ puts "Cards with HP 100-150: #{records.total_count}"
27
+
28
+ # Example 3: Pattern matching
29
+ puts "\n=== Pattern Matching ==="
30
+ records = CardDB.records.search(dataset_key: 'cards', first: 3) do
31
+ where(name: ilike('%pikachu%'))
32
+ end
33
+ puts "Cards matching 'pikachu': #{records.total_count}"
34
+ records.each { |r| puts " - #{r['name']}" }
35
+
36
+ # Example 4: Array contains
37
+ puts "\n=== Array Contains ==="
38
+ records = CardDB.records.search(dataset_key: 'cards', first: 3) do
39
+ where(types: contains('Pokemon'))
40
+ end
41
+ puts "Pokemon cards: #{records.total_count}"
42
+
43
+ # Example 5: Value in list
44
+ puts "\n=== Value in List ==="
45
+ records = CardDB.records.search(dataset_key: 'cards', first: 3) do
46
+ where(rarity: within(%w[rare ultra-rare secret-rare]))
47
+ end
48
+ puts "Rare+ cards: #{records.total_count}"
49
+
50
+ # Example 6: OR conditions
51
+ puts "\n=== OR Conditions ==="
52
+ records = CardDB.records.search(dataset_key: 'cards', first: 3) do
53
+ any do
54
+ where(hp: gte(200))
55
+ where(rarity: 'ultra-rare')
56
+ end
57
+ end
58
+ puts "High HP or Ultra Rare: #{records.total_count}"
59
+
60
+ # Example 7: Complex filter
61
+ puts "\n=== Complex Filter ==="
62
+ records = CardDB.records.search(dataset_key: 'cards', first: 3) do
63
+ where(types: contains('Pokemon'))
64
+ where(hp: gte(100))
65
+ any do
66
+ where(rarity: 'rare')
67
+ where(rarity: 'ultra-rare')
68
+ end
69
+ end
70
+ puts "High HP rare Pokemon: #{records.total_count}"
71
+ records.each { |r| puts " - #{r['name']} (#{r['rarity']}, HP: #{r['hp']})" }
72
+
73
+ # Example 8: Nested field filtering
74
+ puts "\n=== Nested Fields ==="
75
+ records = CardDB.records.search(dataset_key: 'cards', first: 3) do
76
+ where_any(:attacks, damage: gte(50))
77
+ end
78
+ puts "Cards with 50+ damage attacks: #{records.total_count}"
79
+
80
+ # Example 9: Link filtering
81
+ puts "\n=== Link Filtering ==="
82
+ records = CardDB.records.search(
83
+ dataset_key: 'cards',
84
+ first: 3,
85
+ resolve_links: ['set_id']
86
+ ) do
87
+ where_link(:set_id, name: ilike('%scarlet%'))
88
+ end
89
+ puts "Cards from Scarlet sets: #{records.total_count}"
90
+ records.each do |r|
91
+ set_name = r.resolved_links['set_id']&.record&.[]('name')
92
+ puts " - #{r['name']} (Set: #{set_name})"
93
+ end
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'carddb'
5
+
6
+ # Configure the client
7
+ CardDB.configure do |config|
8
+ config.api_key = ENV.fetch('CARDDB_API_KEY', nil)
9
+ config.default_publisher = 'pokemon-company'
10
+ config.default_game = 'pokemon-tcg'
11
+ end
12
+
13
+ # Example 1: Manual pagination
14
+ puts '=== Manual Pagination ==='
15
+ records = CardDB.records.search(dataset_key: 'cards', first: 10)
16
+ puts "Total records: #{records.total_count}"
17
+ puts "Page 1: #{records.size} records"
18
+
19
+ page = 2
20
+ while records.next_page?
21
+ records = records.next_page
22
+ puts "Page #{page}: #{records.size} records"
23
+ page += 1
24
+ break if page > 5 # Limit for demo
25
+ end
26
+
27
+ # Example 2: Auto-pagination with limit
28
+ puts "\n=== Auto-Pagination (first 50) ==="
29
+ count = 0
30
+ CardDB.records.search(dataset_key: 'cards', first: 10)
31
+ .auto_paginate
32
+ .take(50)
33
+ .each do |record|
34
+ count += 1
35
+ puts "#{count}. #{record['name']}"
36
+ end
37
+
38
+ # Example 3: Collection metadata
39
+ puts "\n=== Collection Metadata ==="
40
+ records = CardDB.records.search(dataset_key: 'cards', first: 5)
41
+ puts "Total count: #{records.total_count}"
42
+ puts "Page size: #{records.size}"
43
+ puts "Has next page: #{records.next_page?}"
44
+ puts "Has previous page: #{records.previous_page?}"
45
+ puts "End cursor: #{records.end_cursor}"
46
+
47
+ # Example 4: Processing all records efficiently
48
+ puts "\n=== Processing All Records ==="
49
+ processed = 0
50
+ CardDB.records.search(dataset_key: 'cards', first: 100)
51
+ .auto_paginate
52
+ .each do |_record|
53
+ # Process record
54
+ processed += 1
55
+ print "\rProcessed: #{processed} records" if (processed % 100).zero?
56
+ break if processed >= 500 # Limit for demo
57
+ end
58
+ puts "\nDone! Processed #{processed} records"
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CardDB
4
+ # Batch query builder for combining multiple queries into a single request.
5
+ #
6
+ # @example
7
+ # results = CardDB.batch do |b|
8
+ # b.games.fetch("uuid-1")
9
+ # b.games.fetch("uuid-2")
10
+ # b.publishers.fetch(slug: "pokemon-company")
11
+ # end
12
+ #
13
+ # results[0] # => Game
14
+ # results[1] # => Game
15
+ # results[2] # => Publisher
16
+ class Batch
17
+ # @return [Array<Hash>] The queued operations
18
+ attr_reader :operations
19
+
20
+ # @param client [Client] The client instance
21
+ def initialize(client)
22
+ @client = client
23
+ @operations = []
24
+ @counter = 0
25
+ end
26
+
27
+ # Access the Publishers resource for batching
28
+ #
29
+ # @return [BatchProxy]
30
+ def publishers
31
+ BatchProxy.new(self, :publishers)
32
+ end
33
+
34
+ # Access the Games resource for batching
35
+ #
36
+ # @return [BatchProxy]
37
+ def games
38
+ BatchProxy.new(self, :games)
39
+ end
40
+
41
+ # Access the Datasets resource for batching
42
+ #
43
+ # @return [BatchProxy]
44
+ def datasets
45
+ BatchProxy.new(self, :datasets)
46
+ end
47
+
48
+ # Access the Records resource for batching
49
+ #
50
+ # @return [BatchProxy]
51
+ def records
52
+ BatchProxy.new(self, :records)
53
+ end
54
+
55
+ # Add an operation to the batch
56
+ #
57
+ # @param resource [Symbol] The resource type
58
+ # @param method [Symbol] The method to call
59
+ # @param args [Array] Method arguments
60
+ # @param kwargs [Hash] Method keyword arguments
61
+ # @return [Integer] The index of this operation in the results
62
+ def add_operation(resource:, method:, args: [], kwargs: {})
63
+ @counter += 1
64
+ alias_name = "op#{@counter}"
65
+
66
+ @operations << {
67
+ alias: alias_name,
68
+ resource: resource,
69
+ method: method,
70
+ args: args,
71
+ kwargs: kwargs
72
+ }
73
+
74
+ @counter - 1
75
+ end
76
+
77
+ # Execute all batched operations
78
+ #
79
+ # @return [Array] Results in the same order as operations were added
80
+ def execute
81
+ return [] if @operations.empty?
82
+
83
+ # Build combined GraphQL query
84
+ query_parts = []
85
+ variables = {}
86
+
87
+ @operations.each_with_index do |op, index|
88
+ query_part, op_vars = build_operation_query(op, index)
89
+ query_parts << query_part
90
+ variables.merge!(op_vars)
91
+ end
92
+
93
+ # Combine into single query
94
+ var_definitions = build_variable_definitions(variables)
95
+ combined_query = <<~GRAPHQL
96
+ query BatchQuery#{var_definitions} {
97
+ #{query_parts.join("\n ")}
98
+ }
99
+ GRAPHQL
100
+
101
+ # Execute the combined query
102
+ data = @client.connection.execute(combined_query, variables)
103
+
104
+ # Parse results
105
+ @operations.map do |op|
106
+ result = data[op[:alias]]
107
+ wrap_result(result, op[:resource], op[:method])
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def build_operation_query(op, index)
114
+ case op[:resource]
115
+ when :publishers
116
+ build_publisher_query(op, index)
117
+ when :games
118
+ build_game_query(op, index)
119
+ when :datasets
120
+ build_dataset_query(op, index)
121
+ when :records
122
+ build_record_query(op, index)
123
+ else
124
+ raise ArgumentError, "Unknown resource: #{op[:resource]}"
125
+ end
126
+ end
127
+
128
+ def build_publisher_query(op, index)
129
+ case op[:method]
130
+ when :fetch
131
+ if op[:kwargs][:id]
132
+ var_name = "publisherId#{index}"
133
+ query = "#{op[:alias]}: fetchPublisher(id: $#{var_name}) { #{publisher_fields} }"
134
+ [query, { var_name => op[:kwargs][:id] }, { var_name => 'UUID' }]
135
+ else
136
+ var_name = "publisherSlug#{index}"
137
+ query = "#{op[:alias]}: fetchPublisher(slug: $#{var_name}) { #{publisher_fields} }"
138
+ [query, { var_name => op[:kwargs][:slug] }, { var_name => 'String' }]
139
+ end
140
+ else
141
+ raise ArgumentError, "Unsupported batch method for publishers: #{op[:method]}"
142
+ end
143
+ end
144
+
145
+ def build_game_query(op, index)
146
+ case op[:method]
147
+ when :fetch
148
+ var_name = "gameId#{index}"
149
+ query = "#{op[:alias]}: fetchGame(id: $#{var_name}) { #{game_fields} }"
150
+ [query, { var_name => op[:args][0] }, { var_name => 'UUID' }]
151
+ when :get
152
+ slug_var = "gamePublisherSlug#{index}"
153
+ key_var = "gameKey#{index}"
154
+ query = "#{op[:alias]}: fetchGame(publisherSlug: $#{slug_var}, gameKey: $#{key_var}) { #{game_fields} }"
155
+ resolved_publisher = @client.config.resolve_publisher(op[:kwargs][:publisher_slug])
156
+ [query, { slug_var => resolved_publisher, key_var => op[:kwargs][:game_key] },
157
+ { slug_var => 'String', key_var => 'String' }]
158
+ else
159
+ raise ArgumentError, "Unsupported batch method for games: #{op[:method]}"
160
+ end
161
+ end
162
+
163
+ def build_dataset_query(op, index)
164
+ case op[:method]
165
+ when :fetch
166
+ var_name = "datasetId#{index}"
167
+ query = "#{op[:alias]}: fetchDataset(id: $#{var_name}) { #{dataset_fields} }"
168
+ [query, { var_name => op[:args][0] }, { var_name => 'UUID' }]
169
+ when :get
170
+ slug_var = "datasetPublisherSlug#{index}"
171
+ game_var = "datasetGameKey#{index}"
172
+ key_var = "datasetKey#{index}"
173
+ query = "#{op[:alias]}: fetchDataset(publisherSlug: $#{slug_var}, gameKey: $#{game_var}, datasetKey: $#{key_var}) { #{dataset_fields} #{schema_fields} }"
174
+ resolved_publisher = @client.config.resolve_publisher(op[:kwargs][:publisher_slug])
175
+ resolved_game = @client.config.resolve_game(op[:kwargs][:game_key])
176
+ [query, { slug_var => resolved_publisher, game_var => resolved_game, key_var => op[:kwargs][:dataset_key] },
177
+ { slug_var => 'String', game_var => 'String', key_var => 'String' }]
178
+ else
179
+ raise ArgumentError, "Unsupported batch method for datasets: #{op[:method]}"
180
+ end
181
+ end
182
+
183
+ def build_record_query(op, index)
184
+ case op[:method]
185
+ when :fetch
186
+ var_name = "recordId#{index}"
187
+ query = "#{op[:alias]}: fetchRecord(id: $#{var_name}) { #{record_fields} }"
188
+ [query, { var_name => op[:args][0] }, { var_name => 'UUID' }]
189
+ when :get
190
+ slug_var = "recordPublisherSlug#{index}"
191
+ game_var = "recordGameKey#{index}"
192
+ dataset_var = "recordDatasetKey#{index}"
193
+ id_var = "recordIdentifier#{index}"
194
+ query = "#{op[:alias]}: fetchRecordByIdentifier(publisherSlug: $#{slug_var}, gameKey: $#{game_var}, datasetKey: $#{dataset_var}, identifier: $#{id_var}) { #{record_fields} }"
195
+ resolved_publisher = @client.config.resolve_publisher(op[:kwargs][:publisher_slug])
196
+ resolved_game = @client.config.resolve_game(op[:kwargs][:game_key])
197
+ [query,
198
+ { slug_var => resolved_publisher, game_var => resolved_game, dataset_var => op[:kwargs][:dataset_key], id_var => op[:kwargs][:identifier] }, { slug_var => 'String', game_var => 'String', dataset_var => 'String', id_var => 'String' }]
199
+ else
200
+ raise ArgumentError, "Unsupported batch method for records: #{op[:method]}"
201
+ end
202
+ end
203
+
204
+ def build_variable_definitions(variables)
205
+ return '' if variables.empty?
206
+
207
+ # Build type map from the operations
208
+ type_map = {}
209
+ @operations.each_with_index do |op, index|
210
+ _, _, types = build_operation_query(op, index)
211
+ type_map.merge!(types) if types
212
+ end
213
+
214
+ defs = variables.keys.map do |key|
215
+ type = type_map[key] || 'String'
216
+ "$#{key}: #{type}!"
217
+ end
218
+
219
+ "(#{defs.join(', ')})"
220
+ end
221
+
222
+ def wrap_result(result, resource, _method)
223
+ return nil if result.nil?
224
+
225
+ case resource
226
+ when :publishers
227
+ Publisher.new(result, client: @client)
228
+ when :games
229
+ Game.new(result, client: @client)
230
+ when :datasets
231
+ Dataset.new(result, client: @client)
232
+ when :records
233
+ Record.new(result, client: @client)
234
+ else
235
+ result
236
+ end
237
+ end
238
+
239
+ # Field definitions (simplified versions)
240
+ def publisher_fields
241
+ 'id name slug status description website createdAt updatedAt logoFile { id url } bannerFile { id url }'
242
+ end
243
+
244
+ def game_fields
245
+ 'id publisherId key name description website visibility isArchived createdAt updatedAt publisher { id name slug } logoFile { id url } coverFile { id url }'
246
+ end
247
+
248
+ def dataset_fields
249
+ 'id publisherId gameId key name description visibility isArchived createdAt updatedAt publisher { id name slug } game { id key name }'
250
+ end
251
+
252
+ def schema_fields
253
+ [
254
+ 'schema { fields {',
255
+ 'key label description type isRequired filterable searchable isIdentifier',
256
+ 'itemType displayFormat semanticType allowedValues',
257
+ '} filterableFields searchableFields linkFields {',
258
+ 'key label targetDatasetKey targetDatasetName targetDatasetId',
259
+ '} }'
260
+ ].join(' ')
261
+ end
262
+
263
+ def record_fields
264
+ 'id datasetId data createdAt updatedAt dataset { id key name gameId publisherId }'
265
+ end
266
+ end
267
+
268
+ # Proxy class for batching operations on a specific resource
269
+ class BatchProxy
270
+ def initialize(batch, resource)
271
+ @batch = batch
272
+ @resource = resource
273
+ end
274
+
275
+ # Proxy fetch calls to the batch
276
+ def fetch(*args, **kwargs)
277
+ @batch.add_operation(resource: @resource, method: :fetch, args: args, kwargs: kwargs)
278
+ nil # Return nil since we're batching
279
+ end
280
+
281
+ # Proxy get calls to the batch
282
+ def get(**kwargs)
283
+ @batch.add_operation(resource: @resource, method: :get, kwargs: kwargs)
284
+ nil # Return nil since we're batching
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CardDB
4
+ # Simple in-memory cache with TTL support.
5
+ # Compatible with Rails.cache interface (read/write/delete).
6
+ #
7
+ # @example
8
+ # cache = CardDB::MemoryCache.new
9
+ # cache.write("key", "value", expires_in: 60)
10
+ # cache.read("key") # => "value"
11
+ # # After 60 seconds...
12
+ # cache.read("key") # => nil
13
+ class MemoryCache
14
+ def initialize
15
+ @store = {}
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ # Read a value from the cache.
20
+ #
21
+ # @param key [String] The cache key
22
+ # @return [Object, nil] The cached value or nil if not found/expired
23
+ def read(key)
24
+ @mutex.synchronize do
25
+ entry = @store[key]
26
+ return nil unless entry
27
+
28
+ if entry[:expires_at] && Time.now > entry[:expires_at]
29
+ @store.delete(key)
30
+ return nil
31
+ end
32
+
33
+ entry[:value]
34
+ end
35
+ end
36
+
37
+ # Write a value to the cache.
38
+ #
39
+ # @param key [String] The cache key
40
+ # @param value [Object] The value to cache
41
+ # @param expires_in [Integer, nil] TTL in seconds (nil = no expiry)
42
+ # @return [Object] The cached value
43
+ def write(key, value, expires_in: nil)
44
+ @mutex.synchronize do
45
+ expires_at = expires_in ? Time.now + expires_in : nil
46
+ @store[key] = { value: value, expires_at: expires_at }
47
+ value
48
+ end
49
+ end
50
+
51
+ # Delete a value from the cache.
52
+ #
53
+ # @param key [String] The cache key
54
+ # @return [Boolean] true if the key was deleted
55
+ def delete(key)
56
+ @mutex.synchronize do
57
+ !!@store.delete(key)
58
+ end
59
+ end
60
+
61
+ # Check if a key exists and is not expired.
62
+ #
63
+ # @param key [String] The cache key
64
+ # @return [Boolean]
65
+ def exist?(key)
66
+ !read(key).nil?
67
+ end
68
+
69
+ # Clear all cached values.
70
+ #
71
+ # @return [void]
72
+ def clear
73
+ @mutex.synchronize do
74
+ @store.clear
75
+ end
76
+ end
77
+
78
+ # Get the number of cached entries (for debugging).
79
+ #
80
+ # @return [Integer]
81
+ def size
82
+ @mutex.synchronize do
83
+ # Clean up expired entries first
84
+ now = Time.now
85
+ @store.delete_if { |_, entry| entry[:expires_at] && now > entry[:expires_at] }
86
+ @store.size
87
+ end
88
+ end
89
+ end
90
+
91
+ # Cache wrapper that provides a unified interface for caching.
92
+ # Supports any cache that responds to read/write (like Rails.cache).
93
+ #
94
+ # @example With built-in memory cache
95
+ # CardDB.configure do |config|
96
+ # config.cache = CardDB::MemoryCache.new
97
+ # config.cache_ttl = 300 # 5 minutes
98
+ # end
99
+ #
100
+ # @example With Rails.cache
101
+ # CardDB.configure do |config|
102
+ # config.cache = Rails.cache
103
+ # config.cache_ttl = 300
104
+ # end
105
+ #
106
+ # @example Disabling cache for a specific call
107
+ # CardDB.games.fetch(id, cache: false)
108
+ module CacheSupport
109
+ # Generate a cache key for a query.
110
+ #
111
+ # @param resource [String] Resource type (publishers, games, etc.)
112
+ # @param method [String] Method name (fetch, get, search, etc.)
113
+ # @param params [Hash] Query parameters
114
+ # @return [String] The cache key
115
+ def self.cache_key(resource, method, params)
116
+ sorted_params = params.sort.map { |k, v| "#{k}=#{v}" }.join('&')
117
+ "carddb:#{resource}:#{method}:#{sorted_params}"
118
+ end
119
+ end
120
+ end