きしださんのかわいいリレーショナルデータベースをRubyで写経した

nowokay.hatenablog.com

きしださんのかわいいリレーショナルデータベースの最初のバージョンを写経してみました。 この記事、もう8年前なんですね。ついこないだ読んだような気がしていましたが……。

簡単なものだったら自作できないかなと以前から思っていたんですよね。 最近やっと重い腰を上げて着手したのですが、 そこできしださんの記事のことをふと思い出し、 参考のために写経してみました。 そういえば、そもそもリレーショナルデータベース自作について考えるようになったきっかけの1つもきしださんの記事だった気がします。


一度そのまま書いたあと、いろいろいじってみました。 それにより非効率になっている部分もありますが、ひとまず自分にとっての理解のしやすさを優先しました。


以下の kawaii_rdb.rbtest_kawaii_rdb.rb を同じディレクトリに置いて

ruby test_kawaii_rdb.rb

で実行できます。


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

test_kawaii_rdb.rb

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

参考