dry-schema

Rubyにおける高度なデータ検証と型強制を提供する宣言的スキーマライブラリ

概要

dry-schemaは、Rubyにおけるデータ検証と型強制を提供する宣言的スキーマライブラリです。dry-rbエコシステムの一部として、関数型プログラミングの原則に基づいて設計され、型安全性と表現力豊かなバリデーションルールを提供します。

主な特徴

  • 宣言的スキーマ定義: DSLを使用した直感的なスキーマ定義
  • 自動型強制: 文字列から数値、日付などへの自動変換
  • ネストした構造サポート: ハッシュや配列のネスト構造の検証
  • カスタム述語: 独自の検証ルールの追加
  • 国際化対応: エラーメッセージの多言語サポート
  • 拡張性: プラグインシステムによる機能拡張

インストール

Gemfileに追加

gem 'dry-schema'

bundlerを使用してインストール

bundle install

単体でインストール

gem install dry-schema

基本的な使用方法

基本的なスキーマ定義

require 'dry-schema'

schema = Dry::Schema.Params do
  required(:email).filled(:string)
  required(:age).filled(:integer, gt?: 18)
end

# 検証の実行
result = schema.call(email: '[email protected]', age: 21)

puts result.success? # => true
puts result.to_h     # => {:email=>"jane@doe.org", :age=>21}

エラーハンドリング

schema = Dry::Schema.Params do
  required(:email).filled(:string)
  required(:age).filled(:integer, gt?: 18)
end

result = schema.call(email: '', age: 17)

puts result.success? # => false
puts result.errors.to_h
# => {:email=>["must be filled"], :age=>["must be greater than 18"]}

# フルエラーメッセージ
puts result.errors(full: true).to_h
# => {:email=>["email must be filled"], :age=>["age must be greater than 18"]}

基本的なマクロ

value マクロ

Dry::Schema.Params do
  # 年齢は整数で18より大きい必要がある
  required(:age).value(:integer, gt?: 18)
  
  # IDは文字列または整数
  required(:id).value([:string, :integer])
end

filled マクロ

Dry::Schema.Params do
  # 文字列で空でない必要がある
  required(:name).filled(:string)
  
  # 配列で空でない必要がある
  required(:tags).filled(:array)
end

maybe マクロ

Dry::Schema.Params do
  # nilまたは整数
  required(:age).maybe(:integer)
  
  # nilまたは文字列の配列
  required(:tags).maybe(array[:string])
end

ネストした構造の検証

ハッシュの検証

schema = Dry::Schema.Params do
  required(:address).hash do
    required(:street).filled(:string)
    required(:city).filled(:string)
    required(:country).hash do
      required(:name).filled(:string)
      required(:code).filled(:string)
    end
  end
end

result = schema.call(
  address: {
    street: '123 Main St',
    city: 'NYC',
    country: { name: 'USA', code: 'US' }
  }
)

puts result.success? # => true

配列の検証

# プリミティブ値の配列
schema = Dry::Schema.Params do
  required(:tags).array(:string)
end

# ハッシュの配列
schema = Dry::Schema.Params do
  required(:users).array(:hash) do
    required(:name).filled(:string)
    required(:age).filled(:integer, gteq?: 18)
  end
end

# サイズ制約付きの配列
schema = Dry::Schema.Params do
  required(:numbers).value(array[:integer], size?: 3)
end

オプションのネスト構造

schema = Dry::Schema.Params do
  required(:address).maybe(:hash) do
    required(:street).filled(:string)
    required(:city).filled(:string)
  end
end

# nilも有効
puts schema.call(address: nil).success? # => true

型強制

Paramsスキーマ(文字列から変換)

schema = Dry::Schema.Params do
  required(:age).filled(:integer)
  required(:active).filled(:bool)
  required(:tags).maybe(:array)
end

result = schema.call(
  'age' => '25',
  'active' => 'true',
  'tags' => ''
)

puts result.to_h
# => {:age=>25, :active=>true, :tags=>nil}

JSONスキーマ

schema = Dry::Schema.JSON do
  required(:email).filled(:string)
  required(:age).value(:integer, gt?: 18)
end

result = schema.call(
  'email' => '[email protected]',
  'age' => 21
)

カスタム述語

カスタム述語モジュールの定義

module MyPredicates
  include Dry::Logic::Predicates

  def self.future_date?(date)
    date > Date.today
  end

  def self.email_format?(value)
    value.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
  end
end

カスタム述語の使用

schema = Dry::Schema.Params do
  config.predicates = MyPredicates

  required(:email).value(:string, :email_format?)
  required(:release_date).value(:date, :future_date?)
end

高度な機能

スキーマの再利用

AddressSchema = Dry::Schema.Params do
  required(:street).filled(:string)
  required(:city).filled(:string)
  required(:zipcode).filled(:string)
end

UserSchema = Dry::Schema.Params do
  required(:name).filled(:string)
  required(:email).filled(:string)
  required(:address).hash(AddressSchema)
end

スキーマの合成

# AND演算子 - 両方のスキーマが成功する必要がある
Role = Dry::Schema.JSON do
  required(:id).filled(:string)
end

Expirable = Dry::Schema.JSON do
  required(:expires_on).value(:date)
end

UserSchema = Dry::Schema.JSON do
  required(:name).filled(:string)
  required(:role).hash(Role & Expirable)
end

# OR演算子 - いずれかのスキーマが成功すればよい
RoleID = Dry::Schema.JSON do
  required(:id).filled(:string)
end

RoleTitle = Dry::Schema.JSON do
  required(:title).filled(:string)
end

UserSchema = Dry::Schema.JSON do
  required(:name).filled(:string)
  required(:role).hash(RoleID | RoleTitle)
end

フィルタリング

schema = Dry::Schema.Params do
  required(:email).filled
  required(:birthday).filter(format?: /\d{4}-\d{2}-\d{2}/).value(:date)
end

# 無効なフォーマット
result = schema.call(
  'email' => '[email protected]',
  'birthday' => '1981-1-1'
)
puts result.errors.to_h
# => {:birthday=>["is in invalid format"]}

# 有効なフォーマット
result = schema.call(
  'email' => '[email protected]',
  'birthday' => '1981-01-01'
)
puts result.to_h
# => {:email=>"jane@doe.org", :birthday=>#<Date: 1981-01-01>}

エラーメッセージのカスタマイズ

カスタムエラーメッセージの読み込み

schema = Dry::Schema.Params do
  config.messages.load_paths << '/path/to/my/errors.yml'
  
  required(:email).filled(:string)
  required(:age).filled(:integer, gt?: 18)
end

YAML設定例

ja:
  dry_schema:
    errors:
      filled?: "入力が必要です"
      
      rules:
        email:
          filled?: "メールアドレスを入力してください"
        age:
          gt?: "%{num}より大きな値を入力してください"

名前空間の設定

schema = Dry::Schema.Params do
  config.messages.namespace = :user
  config.messages.top_namespace = :validation_schema
end

拡張機能

Monad拡張

require 'dry/schema'
require 'dry/monads'

Dry::Schema.load_extensions(:monads)

schema = Dry::Schema.Params do
  required(:name).filled(:string, size?: 2..4)
end

# Success/Failureモナドとして結果を取得
monad_result = schema.call(name: 'Jane').to_monad
# => Dry::Monads::Success(#<Dry::Schema::Result{:name=>"Jane"} errors={}>)

# パターンマッチングとの組み合わせ
case schema.call(input).to_monad
in Success(name:)
  "Hello #{name}"
in Failure(name:)
  "#{name} is not a valid name"
end

Hints拡張

Dry::Schema.load_extensions(:hints)

schema = Dry::Schema.Params do
  required(:email).filled(:string)
  required(:age).filled(:integer, gt?: 18)
end

result = schema.call(email: '[email protected]', age: '')

# エラー
puts result.errors.to_h
# => {:age=>["must be filled"]}

# ヒント(評価されなかった述語)
puts result.hints.to_h
# => {:age=>["must be greater than 18"]}

# エラーとヒントの組み合わせ
puts result.messages.to_h
# => {:age=>["must be filled", "must be greater than 18"]}

JSON Schema変換

Dry::Schema.load_extensions(:json_schema)

schema = Dry::Schema.JSON do
  required(:email).filled(:str?, min_size?: 8)
  optional(:favorite_color).filled(:str?, included_in?: %w[red green blue])
  optional(:age).filled(:int?)
end

json_schema = schema.json_schema
puts json_schema
# => {
#   "type": "object",
#   "properties": {
#     "email": {
#       "type": "string",
#       "minLength": 8
#     },
#     "favorite_color": {
#       "type": "string",
#       "enum": ["red", "green", "blue"]
#     },
#     "age": {
#       "type": "integer"
#     }
#   },
#   "required": ["email"]
# }

dry-validationとの統合

require 'dry/validation'

# dry-schemaで基本的な型検証とデータ変換
UserContract = Dry::Validation.Contract do
  # スキーマの定義
  params do
    required(:email).filled(:string)
    required(:age).filled(:integer)
  end

  # カスタムルール
  rule(:email) do
    unless value.include?('@')
      key.failure('must be a valid email')
    end
  end

  rule(:age) do
    key.failure('must be at least 18') if value < 18
  end
end

result = UserContract.new.call(
  email: '[email protected]',
  age: 21
)

puts result.success? # => true
puts result.to_h     # => {:email=>"jane@doe.org", :age=>21}

実践的な例

APIパラメータの検証

class UserRegistrationSchema
  Schema = Dry::Schema.Params do
    required(:user).hash do
      required(:email).filled(:string)
      required(:password).filled(:string, min_size?: 8)
      required(:password_confirmation).filled(:string)
      required(:profile).hash do
        required(:first_name).filled(:string)
        required(:last_name).filled(:string)
        optional(:bio).filled(:string, max_size?: 500)
        optional(:website).filled(:string)
      end
      optional(:preferences).hash do
        optional(:newsletter).filled(:bool)
        optional(:theme).filled(:string, included_in?: %w[light dark])
      end
    end
  end

  def self.call(params)
    Schema.call(params)
  end
end

# 使用例
params = {
  user: {
    email: '[email protected]',
    password: 'securepassword',
    password_confirmation: 'securepassword',
    profile: {
      first_name: 'Jane',
      last_name: 'Doe',
      bio: 'Software developer'
    },
    preferences: {
      newsletter: true,
      theme: 'dark'
    }
  }
}

result = UserRegistrationSchema.call(params)
puts result.success? # => true

設定ファイルの検証

ConfigSchema = Dry::Schema.JSON do
  required(:database).hash do
    required(:host).filled(:string)
    required(:port).filled(:integer, gteq?: 1, lteq?: 65535)
    required(:name).filled(:string)
    optional(:pool_size).filled(:integer, gteq?: 1)
  end

  required(:redis).hash do
    required(:url).filled(:string)
    optional(:timeout).filled(:integer, gteq?: 1)
  end

  optional(:logging).hash do
    optional(:level).filled(:string, included_in?: %w[debug info warn error])
    optional(:output).filled(:string)
  end
end

# JSON設定ファイルの検証
config = JSON.parse(File.read('config.json'))
result = ConfigSchema.call(config)

if result.success?
  puts "設定は有効です"
  app_config = result.to_h
else
  puts "設定エラー:"
  result.errors.to_h.each do |key, errors|
    puts "  #{key}: #{errors.join(', ')}"
  end
end

ベストプラクティス

1. 適切なスキーマタイプの選択

# HTTPパラメータ(文字列)の場合
Dry::Schema.Params do
  # 自動的に文字列から変換される
end

# JSONデータの場合
Dry::Schema.JSON do
  # JSONの型がそのまま使用される
end

# 純粋なRubyオブジェクトの場合
Dry::Schema.define do
  # 型強制なし
end

2. スキーマの分割と再利用

# 共通スキーマを分離
module CommonSchemas
  Address = Dry::Schema.Params do
    required(:street).filled(:string)
    required(:city).filled(:string)
    required(:postal_code).filled(:string)
  end

  Contact = Dry::Schema.Params do
    required(:email).filled(:string)
    optional(:phone).filled(:string)
  end
end

# 再利用
UserSchema = Dry::Schema.Params do
  required(:name).filled(:string)
  required(:address).hash(CommonSchemas::Address)
  required(:contact).hash(CommonSchemas::Contact)
end

3. エラーハンドリングの改善

def validate_user_input(params)
  result = UserSchema.call(params)
  
  if result.success?
    # 成功時の処理
    process_user_data(result.to_h)
  else
    # エラー時の処理
    handle_validation_errors(result.errors)
  end
end

def handle_validation_errors(errors)
  formatted_errors = errors.to_h.map do |field, messages|
    "#{field}: #{messages.join(', ')}"
  end
  
  raise ValidationError, formatted_errors.join('; ')
end

まとめ

dry-schemaは、Rubyアプリケーションにおける堅牢なデータ検証システムを構築するための強力なツールです。宣言的なDSL、型強制、拡張性により、複雑なデータ構造の検証を簡潔に記述できます。dry-validationとの組み合わせにより、さらに高度な検証ロジックも実装可能です。

関連リンク