簡易ORMを作りながらActive Recordの基礎を学ぶ
はじめに
Active Recordは、Ruby on RailsにおけるORM(Object-Relational Mapping)ライブラリで、データベースとのやりとりを簡単に行うための便利な機能を提供します。
最近、Active Recordモデルにattr_accessorで仮想的な属性(データベースには保存されない一時的な属性)を定義したコードを改修する中で、エラーの解消やデバッグに時間がかかることがありました。
class Record < ApplicationRecord
attr_accessor :temporary_objects
...
end
例えば:
-
record.assign_attributes(temporary_objects: [{id: 1, title: 'hoge'}])で設定後、pluck("id")では値を取得できるが、pluck(:id)はnilになる
調査メモ
- attr_accessorで定義された属性は、通常のインスタンス変数として扱われるため、文字列キーでしかアクセスできない
- has_manyで関連付けられているデータは、assign_attributesを使用して値を割り当てた場合、ActiveRecord::Associations::CollectionProxyとして返される
- ActiveRecord::Associations::CollectionProxyはActiveRecord::Relationを継承しており、Active Recordモデルのattributesにアクセス可能
- Active Recordモデルのattributesは、データベースのカラムに対応する属性をハッシュ形式で提供し、シンボル・文字列のどちらでもアクセス可能
- assign_attributesは、渡されたキーがアソシエーションであるかどうかを判定し、適切に関連オブジェクトを設定する
https://api.rubyonrails.org/v8.0.1/classes/ActiveRecord/Associations/CollectionProxy.html
https://github.com/rails/rails/blob/main/activerecord/lib/active_record/attribute_assignment.rb -
size, count, lengthで返ってくる件数が違った
調査メモ
メソッド 特徴 適した場面 クエリ発行 length メモリ上のデータから要素数を取得 データがロード済みの場合 発行されない size ロード済みデータでは length
と同じ、未ロードの場合はSQLクエリを発行柔軟に動作を切り替えたい場合 場合による count 常に SELECT COUNT(*)
を発行未ロードデータや大規模データを扱う際 必ず発行される https://api.rubyonrails.org/v8.0.1/classes/ActiveRecord/Associations/CollectionProxy.html
そこで、Active Recordの基本を改めて理解するために、簡単なORMを自作してみることにしました。
本記事では、その過程と学びについて紹介します。
Active Recordの仕組みをより深く理解したい方や、Railsで一歩踏み込んだ実装や工夫をしたいと考えている方にとって、参考になれば幸いです。
やってみたこと
主に、リレーショナルマッピング(モデルクラスを利用してデータベースを操作する仕組み)を構築し、データ操作の基本機能(CRUD)を実装しました。
以下が具体的なタスクです。
タスク名 | 内容 |
---|---|
動的属性生成 | データベースのカラム情報を元に属性を動的に生成 |
インスタンス生成 | 初期値を指定して新しいインスタンスを生成 |
データ保存 | 作成したインスタンスをデータベースに保存 |
データ更新 | 保存済みデータ(レコード)を更新 |
基本的なデータ操作 |
User.find や User.all 、user#delete などの基本的な操作を実装 |
まず、データベース接続とUserモデルの雛形を準備します。
- データベース接続
require 'sequel'
module MySQLClient
def self.with_database
Sequel.connect(connection_options) do |client|
yield(client)
end
end
def self.with_table(table_name)
with_database do |client|
yield(client[table_name.to_sym])
end
end
private
def self.connection_options
{
adapter: 'mysql2',
host: ENV['DATABASE_HOST'],
user: ENV['DATABASE_USER'],
password: ENV['DATABASE_PASSWORD'],
database: ENV['DATABASE_NAME']
}
end
end
- Userモデル
class User
def initialize(attributes={})
@attributes = attributes
end
def name
@attributes[:name]
end
def email
@attributes[:email]
end
def name=(value)
@attributes[:name] = value
end
def email=(value)
@attributes[:email] = value
end
end
動作を確認
$ irb
> user = User.new({name: 'hoge', email: 'hoge@example.com'})
> user.name
# => "hoge"
> user.email
# => "hoge@example.com"
データベースカラムから属性を動的に生成
データベーススキーマのカラム情報を基に、define_method でゲッターとセッターを動的に定義します。(id や created_at などの読み取り専用カラムはゲッターのみ生成)
class User
READONLY_COLUMNS = %i[id created_at updated_at].freeze
def initialize
@attributes = {}
define_columns
end
private
def define_columns
MySQLClient.with_database do |client|
client.schema(self.class.table_name).each do |col|
column = col[0]
define_getter(column)
define_setter(column)
end
end
end
def define_getter(column)
self.class.send(:define_method, column) { @attributes[column] }
end
def define_setter(column)
return if READONLY_COLUMNS.include?(column)
self.class.send(:define_method, "#{column}=") { |value| @attributes[column] = value }
end
end
テストコード例
RSpec.describe User do
it 'responds to attributes' do
user = User.new
%i[name email id created_at updated_at].each do |attribute|
expect(user).to respond_to(attribute)
end
%i[name= email=].each do |attribute|
expect(user).to respond_to(attribute)
end
%i[id= created_at= updated_at=].each do |attribute|
expect(user).not_to respond_to(attribute)
end
end
end
補足: 動的メソッド生成のいろいろ
Rubyでは動的にメソッドを定義する手法がいくつかあります。代表的な3つの方法を紹介します。
-
define_method
メソッド名をシンボルで指定し、ブロックで処理内容を記述。外部の変数を利用可能。 -
define_singleton_method
特定のオブジェクトに特異メソッドを追加。そのオブジェクトだけが新しいメソッドを持つ。 -
method_missing
存在しないメソッドが呼び出された際に実行されるメソッド。未定義のメソッド呼び出しに対して動的な処理を行う。
今回はRailsでも使われてるdefine_methodsを使ってます。
初期値を指定してインスタンスを生成(User.new)
User.new時に渡されたハッシュをもとに、対応する属性に初期値を設定します。
class User
def initialize(params = {})
...
assign_attributes(params)
end
private
...
def assign_attributes(params)
params.each do |key, value|
send("#{key}=", value) if respond_to?("#{key}=")
end
end
end
テストコード例
RSpec.describe User do
it 'assigns attributes correctly' do
user = User.new(name: 'hoge', email: 'hoge@example.com')
expect(user.name).to eq('hoge')
expect(user.email).to eq('hoge@example.com')
end
end
初期値を指定してインスタンスを生成し保存(User.create)
User.create時に渡されたハッシュをもとに属性を設定し、データベースへ保存、生成されたインスタンスに保存内容を設定します。
class User
...
def self.create(params)
instance = User.new(params)
MySQLClient.with_table(self.class.table_name) do |table|
instance.attributes[:id] = table.insert(instance.attributes)
record = table.where(id: instance.id).first
instance.instance_variable_set(:@attributes, record)
end
instance
end
end
テストコード例
RSpec.describe User do
it 'creates and assigns attributes' do
user = create(:user, {name: 'hoge', email: 'hoge@example.com'})
expect(user.id).not_to be_nil
expect(user.name).to eq('hoge')
expect(user.email).to eq('hoge@example.com')
expect(user.created_at).not_to be_nil
expect(user.updated_at).not_to be_nil
end
end
既存のインスタンスとレコードを更新(user#update)
user#update時に渡されたハッシュをもとに属性を設定し、データベースへ保存、生成されたインスタンスに保存内容を設定します。
class User
...
def update(params)
assign_attributes(params)
MySQLClient.with_table(self.class.table_name) do |table|
table.where(id: id).update(@attributes)
record = table.where(id: id).first
instance_variable_set(:@attributes, record)
end
self
end
end
テストコード例
RSpec.describe User do
it 'responds to attributes' do
user = create(:user, {name: 'hoge', email: 'hoge@example.com'})
user.update({name: 'new_name'})
expect(user.id).not_to be_nil
expect(user.name).to eq('new_name')
expect(user.email).to eq('hoge@example.com')
expect(user.created_at).not_to be_nil
expect(user.updated_at).not_to be_nil
end
end
既存のレコードを取得(User.find)
指定したIDのレコードを取得し、モデルのインスタンスを返す、見つからない場合はエラー
class User
...
def self.find(id)
MySQLClient.with_table(table_name) do |table|
record = table.where(id: id).first
raise Sinatra::NotFound, "#{name} not found" unless record
instantiate_from_record(record)
end
end
end
テストコード例
RSpec.describe User do
it 'finds user by ID' do
user = create(:user)
expect(User.find(user.id).id).to eq(user.id)
end
end
全てのレコードを取得(User.all)
全てのレコードを取得し、モデルのインスタンスの配列を返す、ただし、呼び出し時にはクエリが発行されず、データの操作時に初めて実行される
class User
...
def self.all
Enumerator.new do |yielder|
MySQLClient.with_table(table_name) do |table|
table.each do |record|
yielder << instantiate_from_record(record)
end
end
end
end
end
テストコード例
RSpec.describe User do
...
```ruby
describe 'returns all users' do
before do
create_list(:user, 3)
end
it 'does not access the database when initializing the enumerator' do
expect(MySQLClient).not_to receive(:with_table)
enumerator = User.all
expect(enumerator).to be_an(Enumerator)
end
it 'accesses the database only when the enumerator is executed' do
allow(MySQLClient).to receive(:with_table).and_call_original
enumerator = User.all
expect(MySQLClient).not_to have_received(:with_table)
enumerator.each { |_| }
expect(MySQLClient).to have_received(:with_table).once
end
end
レコードを削除(user#delete)
レコードをデータベースから削除、オブジェクトはメモリ上に残る
class User
...
def delete
MySQLClient.with_table(self.class.table_name) do |table|
table.where(id: id).delete.positive?
end
end
end
テストコード例
RSpec.describe User do
it 'raises error when finding deleted user' do
user = create(:user)
user.delete
expect { User.find(user.id) }.to raise_error(Sinatra::NotFound)
expect(user.nil?).to be_falsey
end
end
完成版コード
-
モデル
https://github.com/yuuu-takahashi/template-sinatra/blob/main/app/models/base_model.rb -
テストコード
https://github.com/yuuu-takahashi/template-sinatra/blob/main/spec/models/user_spec.rb
今回やってみた振り返り
-
クエリのタイミングを意識するようになった
ActiveRecordの各メソッドがクエリを発行するタイミングや、データ読み込み方法の違いを意識するようになりました。
例えば、User.find(1)は即時にクエリを発行する一方、User.allは遅延実行であるなど、挙動の違いを知ることで、無駄なメモリ消費を減らし、効率的なクエリ設計ができるようになったと思います。 -
Rubyの挙動が理解しやすくなった
Railsを使っている中で、背後にあるRubyの仕組みを意識するようになりました。
例えば、Railsがカラム名に基づいてメソッドを動的に生成する仕組みを調べた際に、define_methodを活用していることを知り、「どうやって動いているのか」が具体的にイメージできるようになりました。こうした理解を通じて、Railsの便利さを支えるRubyの力や仕組みを身近に感じられるようになり、Rubyの特徴を活かしたコードを書けるようになったと思います。
次やってみたいこと
-
リレーションの実装
親子関係や多対多のリレーションを扱う中で、関連データをうまく取り出すSQLの考え方や、パフォーマンスを意識したクエリ作成のコツが掴めそう。 -
トランザクションの実装
ロールバックやロックを活用したデータ操作を通じて、不整合を防ぐ仕組みや安全なデータ管理の設計のコツが掴めそう。
参考資料
Discussion