ActiveRecordっぽいものを一度実装してみて本物と実装方法の違いを眺めるのが勉強になりそうだと思ったのでとりあえず作ってみました。
ついでにSQLite3のC言語APIをRubyで実行できるようにする拡張ライブラリ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_to
はTableClass.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文が発行されるが、どう考えても謎なのでソースを漁ろうと思う。