きしださんのかわいいリレーショナルデータベースの最初のバージョンを写経してみました。 この記事、もう8年前なんですね。ついこないだ読んだような気がしていましたが……。
簡単なものだったら自作できないかなと以前から思っていたんですよね。 最近やっと重い腰を上げて着手したのですが、 そこできしださんの記事のことをふと思い出し、 参考のために写経してみました。 そういえば、そもそもリレーショナルデータベース自作について考えるようになったきっかけの1つもきしださんの記事だった気がします。
一度そのまま書いたあと、いろいろいじってみました。 それにより非効率になっている部分もありますが、ひとまず自分にとっての理解のしやすさを優先しました。
以下の kawaii_rdb.rb
と test_kawaii_rdb.rb
を同じディレクトリに置いて
ruby test_kawaii_rdb.rb
で実行できます。
class Database @@tables = {} def self.tables @@tables end end class Relation attr_reader :columns, :tuples def initialize(columns, tuples) @columns = columns @tuples = tuples end def valid_index?(index) 0 <= index && index < @columns.size end # みつからなかったときは属性数(インデックスからあふれる)を返す def column_index(name) idx = @columns.index { |column| column.name == name } idx || @columns.size end def to_s all_rows = [@columns] + @tuples.map(&:values) all_rows.map { |values| inner = values .map { |v| v.nil? ? "null" : v } .join(" | ") "| #{inner} |\n" }.join end end class Query < Relation def self.from(table_name) tbl = Database.tables[table_name] new_columns = tbl.columns.map { |column| Column.new(column.name, table_name) } Query.new(new_columns, tbl.tuples) end def filter_tuple(tuple, column_names) values = column_names.map { |column_name| idx = column_index(column_name) if tuple.valid_index?(idx) tuple.values[idx] else nil end } Tuple.new(values) end def select(*column_names) new_columns = column_names.map { |column_name| Column.new(column_name) } new_tpls = tuples.map { |tuple| filter_tuple(tuple, column_names) } Query.new(new_columns, new_tpls) end def join_tuple(tuple_l, tuple_r, num_columns) new_tpl = tuple_l.dup if tuple_r tuple_r.values.each { |v| new_tpl.values << v } else while new_tpl.size < num_columns new_tpl.values << nil end end new_tpl end def left_join(table_name, matching_field) tbl_r = Database.tables[table_name] # 属性の作成 new_columns = @columns + tbl_r.columns.map { |column| Column.new(column.name, table_name) } # 値の作成 left_column_idx = column_index(matching_field) right_column_idx = tbl_r.column_index(matching_field) if valid_index?(left_column_idx) && tbl_r.valid_index?(right_column_idx) # 両方に該当フィールドあり else # 該当フィールドがない場合は値の結合をしない return Query.new(new_columns, []) end # 結合処理 new_tpls = @tuples.map { |tpl_l| # 結合対象のフィールドを探す(左) left_value = tpl_l.values[left_column_idx] # 結合対象のタプルを探す tpl_r = tbl_r.tuples .find { |iter_tpl_r| # 結合対象のフィールドを探す(右) iter_tpl_r.values[right_column_idx] == left_value } join_tuple(tpl_l, tpl_r, new_columns.size) } Query.new(new_columns, new_tpls) end def less_than(column_name, value) idx = column_index(column_name) unless valid_index?(idx) return Query.new(columns, []) end new_tpls = tuples.select { |tuple| tuple.values[idx] < value } Query.new(columns, new_tpls) end end class Table < Relation def initialize(name, columns) super(columns, []) @name = name end def self.create(name, column_names) columns = column_names.map { |column_name| Column.new(column_name) } tbl = Table.new(name, columns) Database.tables[name] = tbl tbl end def insert(*values) @tuples << Tuple.new(values) self end end class Tuple attr_reader :values def initialize(values) @values = values end def size @values.size end def valid_index?(index) 0 <= index && index < size end end class Column attr_reader :name def initialize(name, parent = nil) @parent = parent @name = name end def to_s if @parent "#{@parent}.#{@name}" else @name end end end
require_relative "./kawaii_rdb" require "minitest/autorun" class Test < Minitest::Test def setup # 商品テーブル shohin = Table.create( "shohin", %w(shohin_id shohin_name kubun_id price) ) shohin .insert(1, "りんご" , 1 , 300) .insert(2, "みかん" , 1 , 130) .insert(3, "キャベツ", 2 , 200) .insert(4, "わかめ" , nil, 250) # 区分が null .insert(5, "しいたけ", 3 , 180) # 該当区分なし # 区分テーブル kubun = Table.create( "kubun", %w(kubun_id kubun_name) ) kubun .insert(1, "くだもの") .insert(2, "野菜" ) end # テーブル内容 def test_table expected = <<~EXP | shohin_id | shohin_name | kubun_id | price | | 1 | りんご | 1 | 300 | | 2 | みかん | 1 | 130 | | 3 | キャベツ | 2 | 200 | | 4 | わかめ | null | 250 | | 5 | しいたけ | 3 | 180 | EXP assert_equal( expected, Database.tables["shohin"].to_s ) end # クエリー経由でテーブル内容 def test_from expected = <<~EXP | shohin.shohin_id | shohin.shohin_name | shohin.kubun_id | shohin.price | | 1 | りんご | 1 | 300 | | 2 | みかん | 1 | 130 | | 3 | キャベツ | 2 | 200 | | 4 | わかめ | null | 250 | | 5 | しいたけ | 3 | 180 | EXP assert_equal( expected, Query.from("shohin").to_s ) end # 射影 def test_select expected = <<~EXP | shohin_name | price | | りんご | 300 | | みかん | 130 | | キャベツ | 200 | | わかめ | 250 | | しいたけ | 180 | EXP assert_equal( expected, Query.from("shohin").select("shohin_name", "price").to_s ) end # フィルター def test_filter expected = <<~EXP | shohin.shohin_id | shohin.shohin_name | shohin.kubun_id | shohin.price | | 2 | みかん | 1 | 130 | | 3 | キャベツ | 2 | 200 | | 5 | しいたけ | 3 | 180 | EXP assert_equal( expected, Query.from("shohin").less_than("price", 250).to_s ) end # 結合 def test_join expected = <<~EXP | shohin.shohin_id | shohin.shohin_name | shohin.kubun_id | shohin.price | kubun.kubun_id | kubun.kubun_name | | 1 | りんご | 1 | 300 | 1 | くだもの | | 2 | みかん | 1 | 130 | 1 | くだもの | | 3 | キャベツ | 2 | 200 | 2 | 野菜 | | 4 | わかめ | null | 250 | null | null | | 5 | しいたけ | 3 | 180 | null | null | EXP assert_equal( expected, Query.from("shohin").left_join("kubun", "kubun_id").to_s ) end # 全部入り def test_all expected = <<~EXP | shohin_name | kubun_name | price | | みかん | くだもの | 130 | | しいたけ | null | 180 | EXP actual = Query .from("shohin") .left_join("kubun", "kubun_id") .less_than("price", 200) .select("shohin_name", "kubun_name", "price") .to_s assert_equal(expected, actual) end end