カレーの恩返し

おいしいのでオススメ。

ActiveRecordっぽいO/Rマッパーを作ってみた

github.com

ActiveRecordっぽいものを一度実装してみて本物と実装方法の違いを眺めるのが勉強になりそうだと思ったのでとりあえず作ってみました。

ついでにSQLite3のC言語APIRubyで実行できるようにする拡張ライブラリsqlite3_coreも作りました。

※記事中では読みやすさのために例外処理は省いています。


実装したのは以下のメソッドになります。

  • set_database(db_path, type)
    どの種類(type)のどこに保存されている(db_path)DBを利用するのか設定する。
  • TableClass.new(attribute)
    レコードオブジェクトを生成する。
  • TableClass#{column名}
    レコードオブジェクトのカラムデータを参照する。
  • TableClass#{column名}=
    レコードオブジェクトのカラムデータに書き込む。
  • TableClass.where(condition)
    conditionに合致するTableのレコードオブジェクトを全件取得する。
  • TableClass.all
    Tableのレコードオブジェクトを全件取得する。
  • TableClass.find(id)
    同一のidをもつTableのレコードオブジェクトを1件取得する。
  • TableClass#save
    レコードオブジェクトの情報をDBに保存する。
  • TableClass#update(attribute)
    レコードオブジェクトの更新をDBに反映させる。
  • TableClass#destroy
    レコードオブジェクトをDBから削除する。
  • TableClass.belongs_to(table)
    Tableがtableに所属しているリレーションをTableClassに反映させる。
  • TableClass.has_many(tables)
    Tableは複数のtableを所持しているリレーションをTableClassに反映させる。

 


それではひとつずつ見ていきます。

set_database(db_path, type)

どの種類(type)のどこに保存されている(db_path)DBを利用するのか設定する。

def set_database(db_path, type)
  $db = Object.const_get(type.to_s.capitalize).new(db_path)
end
  • DBの情報をグローバル変数として持たせることにしました。
  • type: :sqlite3のときはSqlite3.new(db_path)を実行するという単純な仕組みにしました。
irb(main):003:0> set_database('./db_test.sqlite3', :sqlite3)
=> #<Sqlite3:0x00007f86858fdc08 @db=#<Sqlite3Core:0x00007f86858fdbe0>>

 

TableClass.new(attribute)

レコードオブジェクトを生成する。

def initialize(args = {})
      @table_schema = $db.table_schema(self.class.table_name)

      define_column_name
      
      store_record_to(args)

      define_accessor_belongs_to
      define_accessor_has_many
  end

# レコードオブジェクトにnewの引数の値を格納
def store_record_to(info)
  @table_schema.keys.each do |column|
    if info.has_key?(column)
      instance_eval "@#{column} = #{info[column].inspect}"
    else
      instance_eval "@#{column} = nil"
    end
  end
end
  • instance_evalでレコードオブジェクトに初期値を与えるようにしました。
  • 引数infoでeachを回すのではなくテーブルのカラム名でeachを回しているので存在しない引数を受け取ると無視してくれます。
  • define_column_name, define_accessor_belongs_to, define_accessor_has_manyは後ほど説明します。
irb(main):007:0> User.new
=> #<User:0x00007f8a2316bdb8 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=nil, @name=nil, @age=nil>

irb(main):008:0> User.new(id:1, name: 'name01', age:20)
=> #<User:0x00007f8a24872ea8 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=1, @name="name01", @age=20>

irb(main):021:0> User.new(id: 5, name: 'name05', height: 174, weight: 56)
=> #<User:0x00007f868582c428 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=5, @name="name05", @age=nil>

 

TableClass#{column名} / TableClass#{column名}=

レコードオブジェクトのカラムデータを参照/書き込みをする。

def define_column_name
  @table_schema.keys.each do |column|
    self.class.class_eval "attr_accessor :#{column}"
  end
end
  • attr_accessorで定義しました。
irb(main):009:0> user = User.new
=> #<User:0x00007f8a23124418 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=nil, @name=nil, @age=nil>

irb(main):010:0> user.id = 1
=> 1

irb(main):011:0> user.id
=> 1

irb(main):012:0> user.name = 'name01'
=> "name01"

irb(main):013:0> user.name
=> "name01"

irb(main):014:0> user.age = 20
=> 20

irb(main):015:0> user.age
=> 20

irb(main):016:0> user
=> #<User:0x00007f8a23124418 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=1, @name="name01", @age=20>

 

TableClass.where(condition)

conditionに合致するTableのレコードオブジェクトを全件取得する。

def where(condition)
    objects = []
    
    if condition == :all
      condition = nil
    end

    records = $db.select(table_name, condition)
    records.each do |record|
      objects << self.new(record)
    end
    objects
end
  • selectで取得した内容を引数にTableClass.newして配列に格納しました。
rb(main):019:0> User.where(age: 20)
=> [#<User:0x00007f8a23160828 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=1, @name="aaa", @age=20>, 
#<User:0x00007f8a24888398 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=2, @name="bbb", @age=20>, 
#<User:0x00007f8a23139390 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=3, @name="ccc", @age=20>]

irb(main):020:0> User.where(age: 20, name: ['aaa', 'bbb'])
=> [#<User:0x00007f8a2310d740 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=1, @name="aaa", @age=20>, 
#<User:0x00007f8a230f50a0 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=2, @name="bbb", @age=20>]

 

TableClass.all

Tableのレコードオブジェクトを全件取得する。

def all
  where(:all)
end
  • TableClass.where(condition)を使いました。
irb(main):017:0> User.all
=> [#<User:0x00007f8a2484a048 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=1, @name="aaa", @age=20>, 
#<User:0x00007f8a24822c00 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=2, @name="bbb", @age=20>, 
#<User:0x00007f8a2301fea0 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=3, @name="ccc", @age=20>, 
#<User:0x00007f8a23818ff8 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=4, @name="ddd", @age=25>]

 

TableClass.find(id)

同一のidをもつTableのレコードオブジェクトを1件取得する。

def find(id)
  hash = $db.select(table_name, { id: id })
  return nil if hash.empty?

  self.new(hash[0])
end
  • そのままです。
irb(main):018:0> User.find(1)
=> #<User:0x00007f8a230446b0 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=1, @name="aaa", @age=20>

 

TableClass#save

レコードオブジェクトの情報をDBに保存する。

def save
  $db.insert(self.class.table_name, self.to_attr)
end
  • これもそのままです。
irb(main):021:0> User.find(5)
=> nil

irb(main):022:0> user = User.new(id: 5, name: 'name05', age: 100)
=> #<User:0x00007f8a230bf180 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=5, @name="name05", @age=100>

irb(main):023:0> user.save
=> true

irb(main):024:0> User.find(5)
=> #<User:0x00007f8a238320e8 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=5, @name="name05", @age=100>

 

TableClass#update(attribute)

レコードオブジェクトの更新をDBに反映させる。

def update(attribute)
    if $db.update(self.class.table_name, attribute, self.id)
      @table_schema.keys.each do |column|
        if attribute.has_key?(column)
          instance_eval "@#{column} = #{attribute[column].inspect}"
        end
      end

      return true
    end

    false
  end
  • $db.updateでDBを更新した後instance_evalでレコードオブジェクト自身も更新しています。
irb(main):028:0> user = User.find(1)
=> #<User:0x00007f8a2483aad0 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=1, @name="aaa", @age=20>

irb(main):029:0> user.update(age: 500000000)
=> true

irb(main):030:0> User.find(1)
=> #<User:0x00007f8a230f4178 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=1, @name="aaa", @age=500000000>

 

TableClass#destroy

レコードオブジェクトをDBから削除する。

def destroy
  $db.delete(self.class.table_name, self.id)
end
  • そのままです
irb(main):009:0> user = User.find(3)
=> #<User:0x00007f868598cc00 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=3, @name="ccc", @age=20>

irb(main):010:0> user.destroy
=> true

irb(main):011:0> User.find(3)
=> nil

 

TableClass.belongs_to(table)

Tableがtableに所属しているリレーションをTableClassに反映させる。

def belongs_to(table)
  if $db.table_schema(table_name).keys.include?("#{table}_id".to_sym)
    @@belongs_to_tables << table
  end
end

def define_accessor_belongs_to
  @@belongs_to_tables.each do |belongs_to_table|
    instance_eval <<~EOS
      def #{belongs_to_table}
        if @#{belongs_to_table}_id.nil?
          nil
        else
          Object.const_get('#{belongs_to_table}'.capitalize).find @#{belongs_to_table}_id
        end
      end
    EOS
  end
end
  • define_accessor_belongs_toTableClass.new内で実行されます。
  • クラス変数@@belongs_to_tablesにbelongs_toしたいテーブルをためておいてnew時にinstance_evalでメソッドを定義してます。
# post.rb
class Post < NonActiveRecord
  belongs_to :user
end

irb(main):019:0> post = Post.find(1)
=> #<Post:0x00007f86859363a0 @table_schema={:id=>:integer, :title=>:text, :content=>:text, :user_id=>:integer}, @id=1, @title="title1", @content="content1", @user_id=1>

irb(main):020:0> post.user
=> #<User:0x00007f86858fc0d8 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=1, @name="aaa", @age=20>

 

TableClass.has_many(tables)

Tableは複数のtableを所持しているリレーションをTableClassに反映させる。

def has_many(tables)
  if $db.table_schema(tables.to_s).keys.include?("#{self.to_s.downcase}_id".to_sym)
    @@has_many_tables << tables
  end
end

def define_accessor_has_many
  @@has_many_tables.each do |has_many_table| 
    instance_eval <<~EOS
      def #{has_many_table}
        Object.const_get(self.class.singularize('#{has_many_table}').capitalize).where(#{self.class.to_s.downcase}_id: self.id)
      end
    EOS
  end
end
  • だいたいbelongs_toと同じで定義されるメソッドの内容が違うだけです。
# user.rb
class User < NonActiveRecord
  has_many :posts
end

irb(main):015:0> user = User.find(1)
=> #<User:0x00007f868580eec8 @table_schema={:id=>:integer, :name=>:text, :age=>:integer}, @id=1, @name="aaa", @age=20>

irb(main):016:0> user.posts
=> [#<Post:0x00007f86859c6ce8 @table_schema={:id=>:integer, :title=>:text, :content=>:text, :user_id=>:integer}, @id=1, @title="title1", @content="content1", @user_id=1>, 
#<Post:0x00007f86859aef58 @table_schema={:id=>:integer, :title=>:text, :content=>:text, :user_id=>:integer}, @id=2, @title="title2", @content="content2", @user_id=1>]

 


 

感想

  • 今回実装した処理と本家の処理を見比べてどこが違うのかを調べていきたいと思った。
  • 本家のwhere句はメソッドチェインすると適切なSQL文が発行されるが、どう考えても謎なのでソースを漁ろうと思う。