CardDB Ruby Client

A Ruby client library for the CardDB GraphQL API. Search and fetch card game data with an expressive filter DSL.

Installation

Add this line to your application's Gemfile:

gem 'carddb'

Or install from source:

gem 'carddb', git: 'https://github.com/xtda/carddb-ruby'

Quick Start

require 'carddb'

# Configure globally (optional - increases rate limits)
CardDB.configure do |config|
  config.api_key = "carddb_your_api_key"
end

# Search for games
games = CardDB.games.search(publisher_slug: "pokemon-company")
games.each { |game| puts game.name }

# Search for records with filter DSL
records = CardDB.records.search(
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg",
  dataset_key: "cards"
) do
  where(types: contains("Pokemon"))
  where(hp: gte(100))
end

records.each { |record| puts record.name }

Configuration

Global Configuration

CardDB.configure do |config|
  # Authentication (optional but recommended)
  config.api_key = "carddb_your_api_key"

  # Endpoint (defaults to production)
  config.endpoint = "https://carddb.xtda.org/query"

  # Timeouts
  config.timeout = 30        # Request timeout in seconds
  config.open_timeout = 10   # Connection timeout

  # Defaults (reduce repetition in queries)
  config.default_publisher = "pokemon-company"
  config.default_game = "pokemon-tcg"

  # Restrictions (optional - raises error if outside scope)
  config.allowed_publishers = ["pokemon-company"]
  config.allowed_games = {
    "pokemon-company" => ["pokemon-tcg"]
  }

  # Logging (optional)
  config.logger = Logger.new(STDOUT)
  config.log_level = :info  # :debug, :info, :warn, :error

  # Auto-retry on rate limit (optional, disabled by default)
  config.retry_on_rate_limit = true
  config.max_retries = 3

  # Caching (optional, disabled by default)
  config.cache = CardDB::MemoryCache.new
  config.cache_ttl = 300  # 5 minutes
end

Per-Client Configuration

client = CardDB::Client.new(
  api_key: "carddb_different_key",
  default_publisher: "wizards",
  default_game: "magic-the-gathering"
)

records = client.records.search(dataset_key: "cards")

Usage

Publishers

# Search publishers
publishers = CardDB.publishers.search
publishers = CardDB.publishers.search(search: "pokemon")

# Fetch by ID or slug
publisher = CardDB.publishers.fetch(id: "uuid-here")
publisher = CardDB.publishers.fetch(slug: "pokemon-company")

# Fetch multiple by slugs
publishers = CardDB.publishers.fetch_many(["pokemon-company", "wizards"])

# Publisher status is exposed as `ACTIVE` or `DEACTIVATED`
puts publisher.status

# Navigate to games
publisher.games.each { |game| puts game.name }

Games

# Search games
games = CardDB.games.search
games = CardDB.games.search(publisher_slug: "pokemon-company")
games = CardDB.games.search(search: "pokemon")

# Fetch by ID
game = CardDB.games.fetch("uuid-here")

# Get by keys
game = CardDB.games.get(
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg"
)

# Navigate to datasets
game.datasets.each { |dataset| puts dataset.name }

# Navigate to rule-related datasets for this game
game.datasets(purpose: "RULES").each { |dataset| puts dataset.name }

Datasets

# Search datasets
datasets = CardDB.datasets.search
datasets = CardDB.datasets.search(game_key: "pokemon-tcg")
datasets = CardDB.datasets.search(purpose: "RULES")

# Fetch by ID (includes schema)
dataset = CardDB.datasets.fetch("uuid-here")

# Get by keys (includes schema)
dataset = CardDB.datasets.get(
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg",
  dataset_key: "cards"
)

# Get schema only
schema = CardDB.datasets.schema(
  dataset_key: "cards",
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg"
)

schema.fields.each do |field|
  puts "#{field.key}: #{field.type} (filterable: #{field.filterable?})"
end

# Schema-aware helpers
dataset.field_keys          # All field keys
dataset.filterable_fields   # Filterable field keys
dataset.searchable_fields   # Searchable field keys
dataset.identifier_field    # The identifier field key (e.g., "card_id")

dataset.filterable?(:hp)    # Check if field is filterable
dataset.searchable?(:name)  # Check if field is searchable
dataset.field(:hp)          # Get field info by key

Rules and Formats

# List rule-related datasets for a game
rule_datasets = CardDB.rules.datasets(
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg"
)

# List deck/game formats from the standard formats dataset
formats = CardDB.rules.formats(
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg",
  first: 100
)

formats.each { |format| puts format.name }

# List records from the standard rules dataset
rules = CardDB.rules.list(
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg"
)

Records

# Search with filter DSL
records = CardDB.records.search(
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg",
  dataset_key: "cards"
) do
  where(name: ilike("%pikachu%"))
  where(hp: gte(60))
end

# Search with hash filter
records = CardDB.records.search(
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg",
  dataset_key: "cards",
  filter: { "name" => { "ilike" => "%pikachu%" } }
)

# Fetch by ID
record = CardDB.records.fetch("uuid-here")

# Fetch multiple
records = CardDB.records.fetch_many(["uuid-1", "uuid-2"])

# Get by identifier value (uses dataset's identifier field)
record = CardDB.records.get(
  identifier: "xy1-1",
  dataset_key: "cards",
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg"
)

# Get multiple by identifier value
records = CardDB.records.get_many(
  identifiers: ["xy1-1", "xy1-2"],
  dataset_key: "cards",
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg"
)

# Access record data
record["name"]        # Bracket notation
record.name           # Method notation (shorthand)
record.hp
record.record_data    # Full data hash

Filter DSL

The filter DSL provides an expressive way to build queries:

Basic Filters

records = CardDB.records.search(dataset_key: "cards") do
  # Simple equality
  where(name: "Pikachu")

  # With operators
  where(hp: gte(100))
  where(cmc: lte(3))

  # Pattern matching (case-insensitive)
  where(name: ilike("%bolt%"))

  # Array contains
  where(types: contains("Pokemon"))

  # In list
  where(rarity: within(["rare", "ultra-rare"]))

  # Null checks
  where(flavor_text: is_not_null)
end

Available Operators

Operator Description Example
eq(value) Equals where(name: eq("Pikachu"))
neq(value) Not equals where(type: neq("trainer"))
gt(value) Greater than where(hp: gt(100))
gte(value) Greater than or equal where(hp: gte(100))
lt(value) Less than where(cmc: lt(5))
lte(value) Less than or equal where(cmc: lte(3))
within(array) Value in array where(rarity: within(["rare", "mythic"]))
not_within(array) Value not in array where(color: not_within(["black"]))
contains(value) Array contains where(types: contains("Creature"))
like(pattern) Case-sensitive pattern where(name: like("Lightning%"))
ilike(pattern) Case-insensitive pattern where(name: ilike("%bolt%"))
is_null Is null where(deleted_at: is_null)
is_not_null Is not null where(flavor_text: is_not_null)

Boolean Logic

# OR conditions
records = CardDB.records.search(dataset_key: "cards") do
  where(type: "creature")
  any do
    where(color: "red")
    where(color: "blue")
  end
end

# Multiple conditions (implicit AND)
records = CardDB.records.search(dataset_key: "cards") do
  where(type: "creature")
  where(hp: gte(100))
  where(rarity: "rare")
end

Nested Fields

# HASH fields (dot notation)
records = CardDB.records.search(dataset_key: "cards") do
  where(stats: { power: gte(3) })
  # or
  where("stats.power" => gte(3))
end

# Array of objects (any element matches)
records = CardDB.records.search(dataset_key: "cards") do
  where_any(:attacks, damage: gte(50))
  where_any(:attacks, name: ilike("%thunder%"))
end
# Filter on linked records
records = CardDB.records.search(dataset_key: "cards") do
  where_link(:set_id, code: "DMU")
  where_link(:set_id, name: ilike("%dominaria%"))
end

Include data from linked records in your search results using resolve_links.

Path Syntax

Path Description Example
field Single link field set_id
array_field Array of links reprints
hash.field Link inside a hash metadata.artist_id
array.field Link inside each hash in an array abilities.type_id
link.nested Nested resolution set_id.publisher_id

Response Structure

Each resolved link (ResolvedLink) contains:

  • field - The path that was resolved
  • link_field_key - The field in target dataset matched against
  • values - Array of all link values found
  • records - Parallel array of resolved records (nil for unresolved)

For convenience, single-value links also support:

  • value - First value (shorthand for values.first)
  • record - First record (shorthand for records.first)
records = CardDB.records.search(
  dataset_key: "cards",
  resolve_links: ["set_id"]
) do
  where_link(:set_id, code: "DMU")
end

records.each do |record|
  puts record.name
  if (set = record.resolved_links["set_id"])
    # Use .record shorthand for single links
    puts "From set: #{set.record.name}"
    # Or use .records array
    puts "From set: #{set.records.first.name}"
  end
end

Resolve multiple linked records from an array field:

records = CardDB.records.search(
  dataset_key: "cards",
  resolve_links: ["reprints"]
)

records.each do |record|
  if (reprints_link = record.resolved_links["reprints"])
    puts "#{record.name} has #{reprints_link.values.length} reprints:"

    reprints_link.values.each_with_index do |value, i|
      reprint = reprints_link.records[i]
      if reprint
        puts "  - #{reprint.name}"
      else
        puts "  - #{value} (not found)"
      end
    end
  end
end

Resolve a link nested inside a hash/object field:

records = CardDB.records.search(
  dataset_key: "cards",
  resolve_links: ["metadata.artist_id"]
)

records.each do |record|
  if (artist_link = record.resolved_links["metadata.artist_id"])
    puts "Art by: #{artist_link.record&.name || 'Unknown'}"
  end
end

Resolve links from each object in an array field:

records = CardDB.records.search(
  dataset_key: "cards",
  resolve_links: ["abilities.type_id"]
)

records.each do |record|
  if (types_link = record.resolved_links["abilities.type_id"])
    type_names = types_link.records.compact.map(&:name)
    puts "Ability types: #{type_names.join(', ')}"
  end
end

Nested Resolution

Chain through multiple links:

records = CardDB.records.search(
  dataset_key: "cards",
  resolve_links: ["set_id", "set_id.publisher_id"]
)

records.each do |record|
  set_link = record.resolved_links["set_id"]
  publisher_link = record.resolved_links["set_id.publisher_id"]

  set_name = set_link&.record&.name || "Unknown"
  publisher_name = publisher_link&.record&.name || "Unknown"

  puts "#{record.name} from #{set_name} (#{publisher_name})"
end

Combining with Filters

Link resolution works alongside link filtering:

records = CardDB.records.search(
  dataset_key: "cards",
  resolve_links: ["set_id", "set_id.publisher_id", "reprints"]
) do
  where_link(:set_id, code: "DMU")
  where(rarity: "mythic")
end

Pagination

Manual Pagination

records = CardDB.records.search(dataset_key: "cards", first: 100)

while records.any?
  records.each { |r| process(r) }
  break unless records.next_page?
  records = records.next_page
end

Auto-Pagination (Lazy Enumerable)

CardDB.records.search(dataset_key: "cards")
  .auto_paginate
  .take(1000)
  .each { |record| process(record) }

Batch Iteration (Rails-Style)

Process large datasets efficiently with find_each and find_in_batches:

# Iterate one record at a time (fetches in batches behind the scenes)
collection = CardDB.records.search(dataset_key: "cards")
collection.find_each do |record|
  puts record.name
end

# Iterate in batches
collection.find_in_batches(batch_size: 50) do |batch|
  batch.each { |record| process(record) }
end

# Returns an Enumerator when no block given
collection.find_each.with_index do |record, index|
  puts "#{index}: #{record.name}"
end

Collection Info

records.total_count  # Total matching records
records.size         # Records in current page
records.next_page?   # More pages available?

Batch Queries

Combine multiple queries into a single API request for better performance:

results = CardDB.batch do |b|
  b.games.fetch("game-uuid-1")
  b.games.fetch("game-uuid-2")
  b.publishers.fetch(slug: "pokemon-company")
  b.records.get(
    identifier: "xy1-1",
    dataset_key: "cards",
    publisher_slug: "pokemon-company",
    game_key: "pokemon-tcg"
  )
end

results[0]  # => Game
results[1]  # => Game
results[2]  # => Publisher
results[3]  # => Record

Supported batch operations:

  • publishers.fetch(id:) or publishers.fetch(slug:)
  • games.fetch(id) or games.get(publisher_slug:, game_key:)
  • datasets.fetch(id) or datasets.get(publisher_slug:, game_key:, dataset_key:)
  • records.fetch(id), records.fetch_many(ids), records.get(identifier:, dataset_key:, ...), or records.get_many(identifiers:, dataset_key:, ...)

Caching

Enable caching to reduce API calls for repeated fetches:

Built-in Memory Cache

CardDB.configure do |config|
  config.cache = CardDB::MemoryCache.new
  config.cache_ttl = 300  # 5 minutes (default)
end

With Rails.cache

CardDB.configure do |config|
  config.cache = Rails.cache
  config.cache_ttl = 300
end

Per-Resource Cache TTL

Configure different TTLs for different resource types:

CardDB.configure do |config|
  config.cache = CardDB::MemoryCache.new
  config.cache_ttl = 300  # Default: 5 minutes

  # Override TTL per resource type
  config.cache_ttls = {
    publishers: 3600,   # 1 hour (rarely change)
    games: 3600,        # 1 hour
    datasets: 1800,     # 30 minutes
    records: 300        # 5 minutes (default)
  }
end

Per-Request Cache Control

# Enable caching for a specific call (when cache is configured)
game = CardDB.games.fetch("uuid", cache: true)

# Disable caching for a specific call
game = CardDB.games.fetch("uuid", cache: false)

# Caching is supported on fetch, get methods
# Search results are NOT cached (since filters vary)

Logging

Enable logging to debug API interactions:

CardDB.configure do |config|
  config.logger = Logger.new(STDOUT)
  config.log_level = :debug  # :debug, :info, :warn, :error
end

Log output includes:

  • debug: Query names and variables
  • info: Query completion times
  • warn: Rate limit retries
  • error: Connection and request failures

Example output:

[CardDB] Executing SearchRecords with variables: {"publisherSlug"=>"pokemon-company", ...}
[CardDB] SearchRecords completed in 142ms

Auto-Retry on Rate Limit

Automatically retry requests when rate limited:

CardDB.configure do |config|
  config.retry_on_rate_limit = true  # Disabled by default
  config.max_retries = 3             # Default: 3
end

When enabled, the client will:

  1. Catch rate limit errors (HTTP 429)
  2. Sleep for the Retry-After duration (or 60 seconds)
  3. Retry the request up to max_retries times
  4. Raise RateLimitError if all retries fail

Error Handling

begin
  records = CardDB.records.search(dataset_key: "cards")
rescue CardDB::AuthenticationError => e
  # Invalid API key
rescue CardDB::RateLimitError => e
  # Rate limit exceeded
  sleep(e.retry_after)
  retry
rescue CardDB::RestrictedError => e
  # Publisher/game not in allowed list
rescue CardDB::NotFoundError => e
  # Resource not found
rescue CardDB::ValidationError => e
  # Invalid filter or parameters
rescue CardDB::GraphQLError => e
  # GraphQL-level errors
  puts e.errors
rescue CardDB::ConnectionError => e
  # Network issues
rescue CardDB::TimeoutError => e
  # Request timed out
rescue CardDB::Error => e
  # Base error class
end

Rate Limits

Authentication Limit
Anonymous 10 requests/minute
With API Key 100 requests/minute

Access rate limit info after requests:

records = CardDB.records.search(dataset_key: "cards")
info = CardDB.rate_limit_info
puts "Remaining: #{info[:remaining]}/#{info[:limit]}"

Development

# Install dependencies
bundle install

# Run tests
bundle exec rspec

# Run linter
bundle exec rubocop

License

MIT License. See LICENSE.txt.