💎

簡易ORMを作りながらActive Recordの基礎を学ぶ

2025/01/20に公開

はじめに

Active Recordは、Ruby on RailsにおけるORM(Object-Relational Mapping)ライブラリで、データベースとのやりとりを簡単に行うための便利な機能を提供します。
https://railsguides.jp/active_record_basics.html

最近、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.findUser.alluser#delete などの基本的な操作を実装

まず、データベース接続とUserモデルの雛形を準備します。

  • データベース接続
mysql_client.rb
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モデル
user.rb
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つの方法を紹介します。

  1. define_method
    メソッド名をシンボルで指定し、ブロックで処理内容を記述。外部の変数を利用可能。

  2. define_singleton_method
    特定のオブジェクトに特異メソッドを追加。そのオブジェクトだけが新しいメソッドを持つ。

  3. method_missing
    存在しないメソッドが呼び出された際に実行されるメソッド。未定義のメソッド呼び出しに対して動的な処理を行う。

今回はRailsでも使われてるdefine_methodsを使ってます。

https://github.com/rails/rails/blob/892955b5c9e647b957d49eb00854df56d15f0ab3/activesupport/lib/active_support/class_attribute.rb

初期値を指定してインスタンスを生成(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

完成版コード

今回やってみた振り返り

  • クエリのタイミングを意識するようになった
    ActiveRecordの各メソッドがクエリを発行するタイミングや、データ読み込み方法の違いを意識するようになりました。
    例えば、User.find(1)は即時にクエリを発行する一方、User.allは遅延実行であるなど、挙動の違いを知ることで、無駄なメモリ消費を減らし、効率的なクエリ設計ができるようになったと思います。

  • Rubyの挙動が理解しやすくなった
    Railsを使っている中で、背後にあるRubyの仕組みを意識するようになりました。
    例えば、Railsがカラム名に基づいてメソッドを動的に生成する仕組みを調べた際に、define_methodを活用していることを知り、「どうやって動いているのか」が具体的にイメージできるようになりました。こうした理解を通じて、Railsの便利さを支えるRubyの力や仕組みを身近に感じられるようになり、Rubyの特徴を活かしたコードを書けるようになったと思います。

次やってみたいこと

  • リレーションの実装
    親子関係や多対多のリレーションを扱う中で、関連データをうまく取り出すSQLの考え方や、パフォーマンスを意識したクエリ作成のコツが掴めそう。

  • トランザクションの実装
    ロールバックやロックを活用したデータ操作を通じて、不整合を防ぐ仕組みや安全なデータ管理の設計のコツが掴めそう。

参考資料

https://www.amazon.co.jp/dp/B077Q8BXHC
https://www.oreilly.co.jp/books/9784873117430/
https://tech.smarthr.jp/entry/2021/11/11/151444

Discussion