ROM (Ruby Object Mapper)
ROM(Ruby Object Mapper)は「関数型プログラミングの原則に基づいたRubyのデータマッピングツールキット」として設計された、従来のActive Recordパターンに代わる新しいアプローチのデータ永続化ライブラリです。「コマンドとクエリの責任分離(CQRS)」と「関数型プログラミング」の概念を採用し、イミュータブルなオブジェクト、純粋関数、明示的な依存関係により、高い保守性と予測可能性を実現します。複数のデータベース(PostgreSQL、MySQL、SQLite、Redis、Elasticsearch等)と統一的なAPIでやり取りでき、複雑なエンタープライズアプリケーションにおけるデータ層の設計を大幅に改善します。
ライブラリ
ROM (Ruby Object Mapper)
概要
ROM(Ruby Object Mapper)は「関数型プログラミングの原則に基づいたRubyのデータマッピングツールキット」として設計された、従来のActive Recordパターンに代わる新しいアプローチのデータ永続化ライブラリです。「コマンドとクエリの責任分離(CQRS)」と「関数型プログラミング」の概念を採用し、イミュータブルなオブジェクト、純粋関数、明示的な依存関係により、高い保守性と予測可能性を実現します。複数のデータベース(PostgreSQL、MySQL、SQLite、Redis、Elasticsearch等)と統一的なAPIでやり取りでき、複雑なエンタープライズアプリケーションにおけるデータ層の設計を大幅に改善します。
詳細
ROM 2025年版は、Ruby 3.3の最新機能を完全活用し、より表現力豊かで安全なデータアクセス層を提供します。Repository Pattern、Data Mapper Pattern、Unit of Workパターンの組み合わせにより、ドメイン駆動設計(DDD)との親和性が非常に高く、クリーンアーキテクチャの実装を強力にサポートします。マルチテナント対応、読み書き分離、カスタムデータ型支援、包括的バリデーションシステムなど、現代的なWebアプリケーション開発に必要な機能を包括的に提供。また、関数型プログラミングの利点を活かし、サイドエフェクトを最小限に抑えた予測可能なデータ操作を実現しています。
主な特徴
- 関数型アプローチ: イミュータブルオブジェクトと純粋関数による予測可能な処理
- CQRS対応: コマンドとクエリの明確な分離
- Repository Pattern: データアクセスの抽象化と依存性逆転
- マルチアダプター: 複数データベースの統一的操作
- Schema Definition: 型安全なスキーマ定義とバリデーション
- DDD統合: ドメイン駆動設計との自然な統合
メリット・デメリット
メリット
- Active Recordの「神クラス」問題を解決する関数型設計
- イミュータブルオブジェクトによる予測可能性と安全性の向上
- 複数データベースを同一APIで操作できる高い柔軟性
- テスタビリティの大幅向上と副作用の明示的管理
- ドメイン駆動設計やクリーンアーキテクチャとの優れた親和性
- 関数型プログラミングの利点を活かした保守性の高いコード
デメリット
- 学習コストが高く、関数型プログラミングの理解が必要
- Active Recordに慣れた開発者には概念的な転換が困難
- 設定とセットアップが複雑で、小規模プロジェクトには過剰
- Railsエコシステムとの統合がActive Recordほど自然ではない
- 日本語ドキュメントやコミュニティリソースが限定的
- 豊富なActive Record用プラグインを活用できない
参考ページ
書き方の例
セットアップ
# Gemfile
gem 'rom', '~> 5.3'
gem 'rom-sql', '~> 3.6'
gem 'rom-postgres', '~> 3.0' # PostgreSQL用
gem 'rom-repository', '~> 5.3'
# 開発・テスト用
gem 'rom-factory', '~> 0.11', group: [:development, :test]
# インストール
bundle install
# config/database.yml (Rails使用時)
default: &default
adapter: postgresql
encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
development:
<<: *default
database: myapp_development
username: postgres
password: password
host: localhost
test:
<<: *default
database: myapp_test
username: postgres
password: password
host: localhost
production:
<<: *default
database: myapp_production
username: <%= ENV['DATABASE_USER'] %>
password: <%= ENV['DATABASE_PASSWORD'] %>
host: <%= ENV['DATABASE_HOST'] %>
# config/rom.rb
require 'rom'
require 'rom-sql'
require 'rom-repository'
# ROM設定
ROM_CONFIG = ROM::Configuration.new(:sql, ENV['DATABASE_URL'])
# スキーマ定義の読み込み
ROM_CONFIG.auto_registration('./app/persistence')
# コンテナの作成
ROM_CONTAINER = ROM.container(ROM_CONFIG)
スキーマとリレーション定義
# app/persistence/relations/users.rb
module Relations
class Users < ROM::Relation[:sql]
schema(:users, infer: true) do
attribute :id, ROM::Types::Integer.meta(primary_key: true)
attribute :name, ROM::Types::String
attribute :email, ROM::Types::String
attribute :age, ROM::Types::Integer
attribute :is_active, ROM::Types::Bool
attribute :created_at, ROM::Types::DateTime
attribute :updated_at, ROM::Types::DateTime
primary_key :id
associations do
has_many :posts, foreign_key: :user_id
has_one :profile, foreign_key: :user_id
end
end
# クエリメソッド
def active
where(is_active: true)
end
def by_age_range(min_age, max_age)
where { age >= min_age }.where { age <= max_age }
end
def search_by_name(pattern)
where { name.ilike("%#{pattern}%") }
end
def recent(limit = 20)
order(:created_at).reverse.limit(limit)
end
def with_post_count
select_append { integer::count(posts[:id]).as(:post_count) }
.left_join(:posts)
.group(:users[:id])
end
def adults
where { age >= 18 }
end
def by_email(email)
where(email: email)
end
end
end
# app/persistence/relations/posts.rb
module Relations
class Posts < ROM::Relation[:sql]
schema(:posts, infer: true) do
attribute :id, ROM::Types::Integer.meta(primary_key: true)
attribute :title, ROM::Types::String
attribute :content, ROM::Types::String
attribute :excerpt, ROM::Types::String.optional
attribute :is_published, ROM::Types::Bool
attribute :published_at, ROM::Types::DateTime.optional
attribute :user_id, ROM::Types::Integer.meta(foreign_key: true)
attribute :category_id, ROM::Types::Integer.meta(foreign_key: true).optional
attribute :view_count, ROM::Types::Integer
attribute :created_at, ROM::Types::DateTime
attribute :updated_at, ROM::Types::DateTime
primary_key :id
associations do
belongs_to :user, foreign_key: :user_id
belongs_to :category, foreign_key: :category_id
has_many :comments, foreign_key: :post_id
end
end
def published
where(is_published: true)
end
def by_user(user_id)
where(user_id: user_id)
end
def by_category(category_id)
where(category_id: category_id)
end
def popular(limit = 10)
where(is_published: true)
.order(:view_count)
.reverse
.limit(limit)
end
def recent_published(limit = 20)
published
.order(:published_at)
.reverse
.limit(limit)
end
def search_content(pattern)
where { title.ilike("%#{pattern}%") | content.ilike("%#{pattern}%") }
end
def with_author
combine(:user)
end
def with_category
combine(:category)
end
def with_comments_count
select_append { integer::count(comments[:id]).as(:comments_count) }
.left_join(:comments)
.group(:posts[:id])
end
end
end
# app/persistence/relations/categories.rb
module Relations
class Categories < ROM::Relation[:sql]
schema(:categories, infer: true) do
attribute :id, ROM::Types::Integer.meta(primary_key: true)
attribute :name, ROM::Types::String
attribute :description, ROM::Types::String.optional
attribute :color, ROM::Types::String
attribute :is_active, ROM::Types::Bool
attribute :sort_order, ROM::Types::Integer
attribute :created_at, ROM::Types::DateTime
primary_key :id
associations do
has_many :posts, foreign_key: :category_id
end
end
def active
where(is_active: true)
end
def ordered
order(:sort_order, :name)
end
def with_post_count
select_append { integer::count(posts[:id]).as(:post_count) }
.left_join(:posts)
.group(:categories[:id])
end
end
end
リポジトリパターンの実装
# app/repositories/user_repository.rb
class UserRepository < ROM::Repository[:users]
include ROM::Repository::Plugins::CommandAPIDefinition
commands :create, :update, :delete
# リレーション読み込み設定
def all
users.to_a
end
def find(id)
users.by_pk(id).one!
end
def find_by_email(email)
users.by_email(email).one
end
def active_users
users.active.to_a
end
def users_by_age_range(min_age, max_age)
users.by_age_range(min_age, max_age).to_a
end
def search_users(name_pattern)
users.search_by_name(name_pattern).to_a
end
def recent_users(limit = 20)
users.recent(limit).to_a
end
def users_with_post_count
users.with_post_count.to_a
end
def adult_users
users.adults.to_a
end
# ユーザー作成(バリデーション付き)
def create_user(attributes)
changeset = users.changeset(:create, attributes)
command = users.command(:create)
if changeset.valid?
command.call(changeset)
else
raise ROM::ValidationError, changeset.errors
end
end
# ユーザー更新
def update_user(id, attributes)
user = find(id)
changeset = users.changeset(:update, attributes)
command = users.command(:update)
if changeset.valid?
command.by_pk(id).call(changeset)
else
raise ROM::ValidationError, changeset.errors
end
end
# ユーザー削除(関連データも削除)
def delete_user(id)
# 関連投稿を先に削除
posts.command(:delete).where(user_id: id).call
# ユーザー削除
users.command(:delete).by_pk(id).call
end
# ユーザー統計
def user_statistics
total_users = users.count
active_users_count = users.active.count
average_age = users.dataset.avg(:age)
{
total: total_users,
active: active_users_count,
average_age: average_age&.to_f&.round(2) || 0.0
}
end
# 複雑なクエリ例
def users_with_recent_posts(days = 7)
cutoff_date = Date.current - days.days
users
.join(:posts)
.where { posts[:created_at] >= cutoff_date }
.select(:users[:id], :users[:name], :users[:email])
.group(:users[:id])
.to_a
end
# トランザクション処理
def transfer_posts(from_user_id, to_user_id)
ROM_CONTAINER.gateways[:default].transaction do
from_user = find(from_user_id)
to_user = find(to_user_id)
raise ArgumentError, "One or both users not found" unless from_user && to_user
posts.command(:update)
.where(user_id: from_user_id)
.call(user_id: to_user_id, updated_at: Time.current)
end
end
private
def posts
ROM_CONTAINER.relations[:posts]
end
end
# app/repositories/post_repository.rb
class PostRepository < ROM::Repository[:posts]
include ROM::Repository::Plugins::CommandAPIDefinition
commands :create, :update, :delete
def all
posts.to_a
end
def find(id)
posts.by_pk(id).one!
end
def published_posts
posts.published.to_a
end
def posts_by_user(user_id)
posts.by_user(user_id).to_a
end
def posts_by_category(category_id)
posts.by_category(category_id).to_a
end
def popular_posts(limit = 10)
posts.popular(limit).to_a
end
def recent_published_posts(limit = 20)
posts.recent_published(limit).to_a
end
def search_posts(search_term)
posts.search_content(search_term).to_a
end
def posts_with_author
posts.combine(:user).to_a
end
def posts_with_category
posts.combine(:category).to_a
end
def posts_with_comments_count
posts.with_comments_count.to_a
end
# 投稿作成
def create_post(attributes)
# タイムスタンプの自動設定
attributes_with_timestamps = attributes.merge(
created_at: Time.current,
updated_at: Time.current,
view_count: 0
)
# 抜粋の自動生成
if attributes_with_timestamps[:excerpt].blank? && attributes_with_timestamps[:content].present?
attributes_with_timestamps[:excerpt] = attributes_with_timestamps[:content][0..200]
end
changeset = posts.changeset(:create, attributes_with_timestamps)
if changeset.valid?
posts.command(:create).call(changeset)
else
raise ROM::ValidationError, changeset.errors
end
end
# 投稿更新
def update_post(id, attributes)
attributes_with_timestamp = attributes.merge(updated_at: Time.current)
changeset = posts.changeset(:update, attributes_with_timestamp)
if changeset.valid?
posts.command(:update).by_pk(id).call(changeset)
else
raise ROM::ValidationError, changeset.errors
end
end
# 投稿公開
def publish_post(id)
update_post(id, {
is_published: true,
published_at: Time.current
})
end
# 投稿非公開
def unpublish_post(id)
update_post(id, {
is_published: false,
published_at: nil
})
end
# 閲覧数増加
def increment_view_count(id)
post = find(id)
update_post(id, view_count: post.view_count + 1)
end
# 投稿削除
def delete_post(id)
posts.command(:delete).by_pk(id).call
end
# 投稿統計
def post_statistics
total_posts = posts.count
published_posts_count = posts.published.count
draft_posts_count = total_posts - published_posts_count
average_view_count = posts.dataset.avg(:view_count)
{
total: total_posts,
published: published_posts_count,
drafts: draft_posts_count,
average_views: average_view_count&.to_f&.round(2) || 0.0
}
end
# 最近の投稿統計
def recent_post_statistics(days = 7)
cutoff_date = Date.current - days.days
recent_posts = posts.where { created_at >= cutoff_date }
recent_published = recent_posts.where(is_published: true)
{
recent_total: recent_posts.count,
recent_published: recent_published.count,
recent_drafts: recent_posts.count - recent_published.count
}
end
# ユーザー別投稿数
def posts_count_by_user
posts
.join(:users)
.select(:users[:id], :users[:name])
.select_append { integer::count(posts[:id]).as(:post_count) }
.group(:users[:id])
.to_a
end
end
コマンドとバリデーション
# app/persistence/commands/create_user.rb
module Commands
class CreateUser < ROM::Commands::Create[:sql]
relation :users
register_as :create_user
# バリデーションルール
schema do
required(:name).filled(:string)
required(:email).filled(:string, format?: /@/)
required(:age).filled(:integer, gt?: 0, lt?: 150)
optional(:is_active).filled(:bool)
end
# フック処理
def call(attributes)
# デフォルト値設定
attributes_with_defaults = attributes.merge(
is_active: attributes.fetch(:is_active, true),
created_at: Time.current,
updated_at: Time.current
)
# メール重複チェック
if users.by_email(attributes_with_defaults[:email]).exist?
raise ROM::ValidationError, { email: ['already exists'] }
end
super(attributes_with_defaults)
end
end
end
# app/persistence/commands/update_user.rb
module Commands
class UpdateUser < ROM::Commands::Update[:sql]
relation :users
register_as :update_user
schema do
optional(:name).filled(:string)
optional(:email).filled(:string, format?: /@/)
optional(:age).filled(:integer, gt?: 0, lt?: 150)
optional(:is_active).filled(:bool)
end
def call(attributes)
attributes_with_timestamp = attributes.merge(updated_at: Time.current)
# メール重複チェック(自分以外)
if attributes[:email] && relation.dataset.exclude(id: restriction[:id]).where(email: attributes[:email]).any?
raise ROM::ValidationError, { email: ['already exists'] }
end
super(attributes_with_timestamp)
end
end
end
# app/persistence/commands/create_post.rb
module Commands
class CreatePost < ROM::Commands::Create[:sql]
relation :posts
register_as :create_post
schema do
required(:title).filled(:string, min_size?: 1)
required(:content).filled(:string, min_size?: 10)
required(:user_id).filled(:integer)
optional(:category_id).filled(:integer)
optional(:is_published).filled(:bool)
optional(:excerpt).filled(:string)
end
def call(attributes)
# バリデーション
user_exists = ROM_CONTAINER.relations[:users].by_pk(attributes[:user_id]).exist?
raise ROM::ValidationError, { user_id: ['does not exist'] } unless user_exists
if attributes[:category_id]
category_exists = ROM_CONTAINER.relations[:categories].by_pk(attributes[:category_id]).exist?
raise ROM::ValidationError, { category_id: ['does not exist'] } unless category_exists
end
# デフォルト値とタイムスタンプ設定
attributes_with_defaults = attributes.merge(
is_published: attributes.fetch(:is_published, false),
view_count: 0,
created_at: Time.current,
updated_at: Time.current
)
# 抜粋自動生成
if attributes_with_defaults[:excerpt].blank?
attributes_with_defaults[:excerpt] = attributes_with_defaults[:content][0..200]
end
# 公開日時設定
if attributes_with_defaults[:is_published]
attributes_with_defaults[:published_at] = Time.current
end
super(attributes_with_defaults)
end
end
end
サービスレイヤーの実装
# app/services/user_service.rb
class UserService
include ROM::Repository::Plugins::Helpers
def initialize(container = ROM_CONTAINER)
@container = container
@user_repo = UserRepository.new(container)
@post_repo = PostRepository.new(container)
end
# ユーザー作成サービス
def create_user(params)
# パラメータ検証
clean_params = sanitize_user_params(params)
@container.gateways[:default].transaction do
user = @user_repo.create_user(clean_params)
# ウェルカム処理(例)
# send_welcome_email(user)
# create_default_profile(user)
user
end
rescue ROM::ValidationError => e
raise ServiceError, "User creation failed: #{e.message}"
end
# ユーザー更新サービス
def update_user(id, params)
clean_params = sanitize_user_params(params)
@container.gateways[:default].transaction do
user = @user_repo.update_user(id, clean_params)
# 更新後処理
# update_search_index(user) if user
user
end
rescue ROM::ValidationError => e
raise ServiceError, "User update failed: #{e.message}"
end
# ユーザー削除サービス(安全削除)
def delete_user(id)
@container.gateways[:default].transaction do
# 削除前チェック
user = @user_repo.find(id)
posts_count = @post_repo.posts_by_user(id).length
if posts_count > 0
# 投稿がある場合の処理選択
# Option 1: 投稿も削除
@user_repo.delete_user(id)
# Option 2: エラーを投げる
# raise ServiceError, "Cannot delete user with existing posts"
# Option 3: 投稿を別ユーザーに移管
# transfer_posts_to_system_user(id)
# @user_repo.delete_user(id)
else
@user_repo.delete_user(id)
end
end
rescue ROM::TupleCountMismatchError
raise ServiceError, "User not found"
end
# 複雑なビジネスロジック例
def activate_user_with_verification(id, verification_code)
@container.gateways[:default].transaction do
user = @user_repo.find(id)
# 検証コードチェック(仮想的な実装)
unless verify_activation_code(user, verification_code)
raise ServiceError, "Invalid verification code"
end
# ユーザーアクティベート
@user_repo.update_user(id, {
is_active: true,
verified_at: Time.current
})
# アクティベート後処理
# send_activation_confirmation(user)
# grant_default_permissions(user)
@user_repo.find(id)
end
end
# ユーザー検索サービス
def search_users(query_params)
results = @user_repo.all
if query_params[:name_pattern]
results = @user_repo.search_users(query_params[:name_pattern])
end
if query_params[:min_age] || query_params[:max_age]
min_age = query_params[:min_age] || 0
max_age = query_params[:max_age] || 150
results = @user_repo.users_by_age_range(min_age, max_age)
end
if query_params[:active_only]
results = results.select { |user| user.is_active }
end
results
end
# データエクスポートサービス
def export_user_data(format = :json)
users_with_stats = @user_repo.users_with_post_count
case format
when :json
users_with_stats.map do |user|
{
id: user.id,
name: user.name,
email: user.email,
age: user.age,
is_active: user.is_active,
post_count: user.post_count || 0,
created_at: user.created_at.iso8601
}
end
when :csv
# CSV形式での出力実装
CSV.generate do |csv|
csv << ['ID', 'Name', 'Email', 'Age', 'Active', 'Post Count', 'Created At']
users_with_stats.each do |user|
csv << [user.id, user.name, user.email, user.age, user.is_active, user.post_count || 0, user.created_at.iso8601]
end
end
else
raise ServiceError, "Unsupported export format: #{format}"
end
end
private
def sanitize_user_params(params)
{
name: params[:name]&.strip,
email: params[:email]&.strip&.downcase,
age: params[:age]&.to_i,
is_active: params.key?(:is_active) ? params[:is_active] : true
}.compact
end
def verify_activation_code(user, code)
# 実際の実装では、データベースから検証コードを取得して比較
true # 簡略化
end
# カスタム例外
class ServiceError < StandardError; end
end
# app/services/post_service.rb
class PostService
def initialize(container = ROM_CONTAINER)
@container = container
@post_repo = PostRepository.new(container)
@user_repo = UserRepository.new(container)
end
# 投稿作成サービス
def create_post(params)
clean_params = sanitize_post_params(params)
@container.gateways[:default].transaction do
# 作成者の存在確認
author = @user_repo.find(clean_params[:user_id])
raise ServiceError, "Author not found" unless author
post = @post_repo.create_post(clean_params)
# 作成後処理
# notify_followers(author, post) if post.is_published
# update_search_index(post)
post
end
rescue ROM::ValidationError => e
raise ServiceError, "Post creation failed: #{e.message}"
end
# 投稿公開サービス
def publish_post(id, publish_at = nil)
@container.gateways[:default].transaction do
post = @post_repo.find(id)
# 公開前バリデーション
validate_post_for_publishing(post)
@post_repo.publish_post(id)
# 公開後処理
# notify_subscribers(post)
# update_sitemap
# social_media_post(post)
@post_repo.find(id)
end
rescue ROM::TupleCountMismatchError
raise ServiceError, "Post not found"
end
# バルク操作サービス
def bulk_update_posts(post_ids, attributes)
@container.gateways[:default].transaction do
updated_posts = []
post_ids.each do |id|
updated_post = @post_repo.update_post(id, attributes)
updated_posts << updated_post if updated_post
end
updated_posts
end
rescue => e
raise ServiceError, "Bulk update failed: #{e.message}"
end
# コンテンツ分析サービス
def analyze_post_performance(days = 30)
cutoff_date = Date.current - days.days
# 期間内の投稿を取得
recent_posts = @post_repo.posts.where { created_at >= cutoff_date }.to_a
total_views = recent_posts.sum(&:view_count)
avg_views = recent_posts.empty? ? 0 : total_views.to_f / recent_posts.length
top_posts = recent_posts
.select { |post| post.is_published }
.sort_by(&:view_count)
.reverse
.first(10)
{
period_days: days,
total_posts: recent_posts.length,
published_posts: recent_posts.count(&:is_published),
total_views: total_views,
average_views: avg_views.round(2),
top_posts: top_posts.map { |post|
{
id: post.id,
title: post.title,
views: post.view_count
}
}
}
end
private
def sanitize_post_params(params)
{
title: params[:title]&.strip,
content: params[:content]&.strip,
excerpt: params[:excerpt]&.strip,
user_id: params[:user_id]&.to_i,
category_id: params[:category_id]&.to_i,
is_published: params.key?(:is_published) ? params[:is_published] : false
}.compact
end
def validate_post_for_publishing(post)
errors = []
errors << "Title cannot be empty" if post.title.blank?
errors << "Content too short" if post.content.length < 100
errors << "Author must be active" unless post.user&.is_active
raise ServiceError, errors.join(", ") if errors.any?
end
class ServiceError < StandardError; end
end
テストの書き方
# spec/repositories/user_repository_spec.rb
require 'spec_helper'
RSpec.describe UserRepository, type: :repository do
let(:repo) { described_class.new(ROM_CONTAINER) }
describe '#create_user' do
it 'creates a user with valid attributes' do
attributes = {
name: 'John Doe',
email: '[email protected]',
age: 30
}
user = repo.create_user(attributes)
expect(user.name).to eq('John Doe')
expect(user.email).to eq('[email protected]')
expect(user.age).to eq(30)
expect(user.is_active).to be(true)
end
it 'raises error for invalid email' do
attributes = {
name: 'John Doe',
email: 'invalid-email',
age: 30
}
expect { repo.create_user(attributes) }.to raise_error(ROM::ValidationError)
end
end
describe '#search_users' do
before do
repo.create_user(name: 'Alice Johnson', email: '[email protected]', age: 25)
repo.create_user(name: 'Bob Smith', email: '[email protected]', age: 35)
end
it 'finds users by name pattern' do
results = repo.search_users('Alice')
expect(results.length).to eq(1)
expect(results.first.name).to eq('Alice Johnson')
end
end
describe '#user_statistics' do
before do
repo.create_user(name: 'User 1', email: '[email protected]', age: 25)
repo.create_user(name: 'User 2', email: '[email protected]', age: 35)
end
it 'returns correct statistics' do
stats = repo.user_statistics
expect(stats[:total]).to eq(2)
expect(stats[:active]).to eq(2)
expect(stats[:average_age]).to eq(30.0)
end
end
end
# spec/services/user_service_spec.rb
require 'spec_helper'
RSpec.describe UserService do
let(:service) { described_class.new }
describe '#create_user' do
it 'creates user successfully' do
params = {
name: 'Jane Doe',
email: '[email protected]',
age: 28
}
user = service.create_user(params)
expect(user.name).to eq('Jane Doe')
expect(user.email).to eq('[email protected]')
end
it 'handles validation errors gracefully' do
params = {
name: '',
email: 'invalid',
age: -5
}
expect { service.create_user(params) }.to raise_error(UserService::ServiceError)
end
end
describe '#search_users' do
before do
service.create_user(name: 'Alice', email: '[email protected]', age: 25)
service.create_user(name: 'Bob', email: '[email protected]', age: 35)
end
it 'searches by name pattern' do
results = service.search_users(name_pattern: 'Ali')
expect(results.length).to eq(1)
expect(results.first.name).to eq('Alice')
end
it 'filters by age range' do
results = service.search_users(min_age: 30, max_age: 40)
expect(results.length).to eq(1)
expect(results.first.name).to eq('Bob')
end
end
end
# spec/factories/user_factory.rb (ROM::Factory使用)
require 'rom-factory'
ROM::Factory.configure do |config|
config.rom = ROM_CONTAINER
end
module Factories
extend ROM::Factory::DSL
define(:user) do |f|
f.sequence(:name) { |n| "User #{n}" }
f.sequence(:email) { |n| "user#{n}@example.com" }
f.age { rand(18..80) }
f.is_active { true }
f.created_at { Time.current }
f.updated_at { Time.current }
end
define(:post) do |f|
f.sequence(:title) { |n| "Post #{n}" }
f.content { "This is the content of the post." * 10 }
f.association(:user)
f.is_published { false }
f.view_count { rand(0..1000) }
f.created_at { Time.current }
f.updated_at { Time.current }
end
end
# テストでのファクトリー使用例
RSpec.describe 'Integration test' do
it 'creates users and posts' do
user = Factories[:user]
post = Factories[:post, user: user, title: 'Custom Title']
expect(user.name).to start_with('User')
expect(post.title).to eq('Custom Title')
expect(post.user_id).to eq(user.id)
end
end
エラーハンドリングと例外処理
# app/errors/rom_errors.rb
module ROMErrors
class BaseError < StandardError
attr_reader :details
def initialize(message, details = {})
super(message)
@details = details
end
end
class ValidationError < BaseError; end
class NotFoundError < BaseError; end
class DuplicateError < BaseError; end
class TransactionError < BaseError; end
class ConfigurationError < BaseError; end
end
# app/services/safe_user_service.rb
class SafeUserService
include ROMErrors
def initialize(container = ROM_CONTAINER)
@container = container
@user_repo = UserRepository.new(container)
end
# 安全なユーザー作成
def create_user_safely(params)
# 入力検証
validate_user_input(params)
@container.gateways[:default].transaction do
@user_repo.create_user(params)
end
rescue ROM::ValidationError => e
raise ValidationError.new("User validation failed", e.errors)
rescue ROM::ConstraintError => e
if e.message.include?('email')
raise DuplicateError.new("Email already exists", { email: params[:email] })
else
raise ValidationError.new("Data constraint violation", { error: e.message })
end
rescue => e
raise TransactionError.new("User creation failed", { original_error: e.message })
end
# 安全なユーザー取得
def get_user_safely(id)
@user_repo.find(id)
rescue ROM::TupleCountMismatchError
raise NotFoundError.new("User not found", { user_id: id })
rescue => e
raise BaseError.new("Failed to retrieve user", { user_id: id, error: e.message })
end
# 安全なユーザー更新
def update_user_safely(id, params)
validate_user_input(params, updating: true)
@container.gateways[:default].transaction do
# ユーザー存在確認
user = get_user_safely(id)
# メール重複チェック
if params[:email] && params[:email] != user.email
existing_user = @user_repo.find_by_email(params[:email])
if existing_user && existing_user.id != id
raise DuplicateError.new("Email already exists", { email: params[:email] })
end
end
@user_repo.update_user(id, params)
end
rescue NotFoundError, DuplicateError
raise
rescue ROM::ValidationError => e
raise ValidationError.new("User validation failed", e.errors)
rescue => e
raise TransactionError.new("User update failed", { user_id: id, error: e.message })
end
# 安全なユーザー削除
def delete_user_safely(id)
@container.gateways[:default].transaction do
user = get_user_safely(id)
# 削除前チェック
posts_count = @container.relations[:posts].where(user_id: id).count
if posts_count > 0
raise ValidationError.new(
"Cannot delete user with existing posts",
{ user_id: id, posts_count: posts_count }
)
end
@user_repo.delete_user(id)
true
end
rescue NotFoundError, ValidationError
raise
rescue => e
raise TransactionError.new("User deletion failed", { user_id: id, error: e.message })
end
# バルク操作の安全実行
def bulk_create_users_safely(users_data)
results = { success: [], failed: [] }
@container.gateways[:default].transaction do
users_data.each_with_index do |user_params, index|
begin
user = create_user_safely(user_params)
results[:success] << { index: index, user: user }
rescue BaseError => e
results[:failed] << {
index: index,
params: user_params,
error: e.message,
details: e.details
}
end
end
# 失敗が多い場合はロールバック
if results[:failed].length > users_data.length * 0.5
raise TransactionError.new(
"Bulk operation failed: too many errors",
{ success_count: results[:success].length, failed_count: results[:failed].length }
)
end
end
results
rescue TransactionError
raise
rescue => e
raise TransactionError.new("Bulk operation failed", { error: e.message })
end
# データベース整合性チェック
def perform_data_integrity_check
issues = []
begin
# 孤立したデータのチェック
orphaned_posts = @container.relations[:posts]
.left_join(:users, id: :user_id)
.where(users[:id] => nil)
if orphaned_posts.count > 0
issues << {
type: :orphaned_data,
table: :posts,
count: orphaned_posts.count,
message: "Posts without valid user references found"
}
end
# 重複メールのチェック
duplicate_emails = @container.relations[:users]
.dataset
.group(:email)
.having { count.function.* > 1 }
.select(:email)
.count
if duplicate_emails > 0
issues << {
type: :duplicate_data,
table: :users,
count: duplicate_emails,
message: "Duplicate email addresses found"
}
end
# 無効データのチェック
invalid_users = @container.relations[:users]
.where { (age < 0) | (age > 150) }
.count
if invalid_users > 0
issues << {
type: :invalid_data,
table: :users,
count: invalid_users,
message: "Users with invalid age values found"
}
end
rescue => e
raise BaseError.new("Integrity check failed", { error: e.message })
end
{
healthy: issues.empty?,
issues: issues,
checked_at: Time.current
}
end
# データクリーンアップ
def cleanup_invalid_data
cleanup_results = {}
@container.gateways[:default].transaction do
# 孤立投稿の削除
orphaned_posts_count = @container.relations[:posts]
.left_join(:users, id: :user_id)
.where(users[:id] => nil)
.delete
cleanup_results[:orphaned_posts_removed] = orphaned_posts_count
# 無効ユーザーデータの修正
invalid_users_fixed = @container.relations[:users]
.where { (age < 0) | (age > 150) }
.update(age: 0) # 無効な年齢を0にリセット
cleanup_results[:invalid_users_fixed] = invalid_users_fixed
cleanup_results[:cleaned_at] = Time.current
end
cleanup_results
rescue => e
raise TransactionError.new("Data cleanup failed", { error: e.message })
end
private
def validate_user_input(params, updating: false)
errors = []
if !updating || params.key?(:name)
name = params[:name]
errors << "Name cannot be empty" if name.nil? || name.strip.empty?
end
if !updating || params.key?(:email)
email = params[:email]
if email.nil? || email.strip.empty?
errors << "Email cannot be empty"
elsif !email.match?(/\A[^@\s]+@[^@\s]+\z/)
errors << "Invalid email format"
end
end
if !updating || params.key?(:age)
age = params[:age]
if age.nil?
errors << "Age cannot be empty"
elsif age < 0 || age > 150
errors << "Age must be between 0 and 150"
end
end
raise ValidationError.new("Input validation failed", { errors: errors }) if errors.any?
end
end
# 使用例とエラーハンドリング
class UserController
def initialize
@user_service = SafeUserService.new
end
def create
user = @user_service.create_user_safely(params)
render json: { success: true, user: user }
rescue ROMErrors::ValidationError => e
render json: {
success: false,
error: "Validation failed",
details: e.details
}, status: 422
rescue ROMErrors::DuplicateError => e
render json: {
success: false,
error: "Duplicate data",
details: e.details
}, status: 409
rescue ROMErrors::TransactionError => e
render json: {
success: false,
error: "Transaction failed",
details: e.details
}, status: 500
rescue => e
render json: {
success: false,
error: "Unexpected error",
message: e.message
}, status: 500
end
def update
user = @user_service.update_user_safely(params[:id], params)
render json: { success: true, user: user }
rescue ROMErrors::NotFoundError => e
render json: {
success: false,
error: "User not found",
details: e.details
}, status: 404
rescue ROMErrors::ValidationError, ROMErrors::DuplicateError => e
render json: {
success: false,
error: e.message,
details: e.details
}, status: 422
rescue => e
render json: {
success: false,
error: "Update failed",
message: e.message
}, status: 500
end
def health_check
integrity_result = @user_service.perform_data_integrity_check
if integrity_result[:healthy]
render json: {
status: "healthy",
checked_at: integrity_result[:checked_at]
}
else
render json: {
status: "issues_found",
issues: integrity_result[:issues],
checked_at: integrity_result[:checked_at]
}, status: 200
end
rescue => e
render json: {
status: "check_failed",
error: e.message
}, status: 500
end
end