用 Ruby 寫個簡單的 Database

Juanito FatasThinking about what to do.

今天又重讀了 HuangZ 翻譯的 Practical Common Lisp 個人翻譯版

看完以後試著拿 Ruby 實現一遍(除了宏以外)。

題目是簡單的資料庫,記錄唱片(CD)的資料,唱片有標題、作者、評分以及是否已經轉換成 MP3 的紀錄。

首先資料庫就是一個簡單的 Ruby 陣列,裡面每一筆便是一張唱片(Hash 形式保存):

$db = []

新增一張唱片:

def make_cd(title, artist, rating, ripped)
 { title: title, artist: artist, rating: rating, ripped: ripped }
end

把唱片資料保存到資料庫裡:

def add_record(record)
  $db.push record
end

這麼保存不太人性化,加入一個互動式新增唱片的介面:

def prompt_read(prompt)
  print "#{prompt}: "
  return STDIN.readline.chomp
end

def prompt_for_cd
  make_cd(
    prompt_read('Title'),
    prompt_read('Artist'),
    prompt_read('Rating').to_i,
    yes_or_no?('Ripped [y/n]')
  )
end

prompt_read('Rating').to_i 我們希望評分是數字,而不是文字內容。

prompt_read 的功用是印出一個提示符,接著讀取輸入(去掉換行符)。

上面還用到一個方法是:yes_or_no?,這是從 Common Lisp 學來的一個函數:y-or-n-p

輸入不是 yYnN 開始的字元,便會重新要求輸入:

def yes_or_no?(input)
  first_time = true
  print "#{input}: "
  while (input =~ /^(y|yes|n|no)/i).nil?
    print 'Please answer y or n: ' if first_time != true
    input = gets.chomp
    first_time = false
  end
  input =~ /y/i ? true : false
end

有了 add_recordprompt_for_cd 之後,便可以一次新增多筆資料了:

def add_cds
  loop do
    add_record(prompt_for_cd)
    break unless yes_or_no?("Another? [y/n]")
  end
end
> add_cds
Title: 再見青春
Artist: 汪鋒
Rating: 9
Ripped [y/n]: n
Another? [y/n]: y
Title: 致青春
Artist: 王菲
Rating: 9999
Ripped [y/n]: n
Another? [y/n]: n
=> nil

現在我們已經可以成功的往資料庫裡面添加唱片資料了。

現在看看怎麼樣把資料庫裡的唱片資料印出來:

def dump_db
  $db.each do |cds|
    cds.each { |key, value|
      printf(STDOUT, "%s \t\t\t %s\n", key, value)
    }
  end; nil # supress self return
end
> dump_db
title        再見青春
artist       汪鋒
rating       9
ripped       false

title        致青春
artist       王菲
rating       9999
ripped       false

接著看看怎麼樣把資料庫的資料儲存到電腦裡:

def save_db(filename)
  require 'fileutils'
  FileUtils.touch(filename) unless File.exist? filename
  File.open(filename, 'w') { |f|
    require 'yaml'
    f.write($db.to_yaml)
  }
end

save_db "cd.db" 就會存在當下目錄,存成一個 cd.db 檔案。

下次再載入也是很簡單:

def load_db(filename)
  File.exist?(filename) && $db = YAML.load_file(filename)
end

load_db "cd.db"

這裡儲存的時候採用的是 YAML 格式,方便處理。

大致上的功能都搞定了。

接下來看怎麼從資料庫選出資料,比如按照標題選出我們要的唱片:

def select_by_title(title)
  $db.select(->(cd) { cd[:title] == title })
end

於是便可以實現 select_by_artist, select_by_rating,再把 select 的介面統一一下:

def select selector
  $db.select(&selector)
end

def select_by_title(title)
  $db.select(->(cd) { cd[:title] == title })
end

def select_by_artist(artist)
  $db.select(->(cd) { cd[:artist] == artist })
end

def select_by_rating(rating)
  $db.select(->(cd) { cd[:rating] == rating })
end

def select_by_ripped(ripped)
  $db.select(->(cd) { cd[:ripped] == ripped })
end

可以用 define_method 定義出這些方法,這裡保留講解的易懂性,就不貼出來了。

這樣還是不太爽,有沒有像是 SQL 的 SELECT ... WHERE ...

def where(title: nil, artist: nil, rating: nil, ripped: nil)
  ->(cd) {
    title  ? cd[:title]  == title  : true &&
    artist ? cd[:artist] == artist : true &&
    rating ? cd[:rating] == rating : true &&
    ripped ? cd[:ripped] == ripped : true
  }
end

現在便可以:

select(where(rating: 10)) 這麼選。

更新也是差不多的思路:

def update(selector, title: nil, artist: nil, rating: nil, ripped: nil)
  $db.each { |row|
    if selector.call(row)
      row[:title]  = title  if title
      row[:artist] = artist if artist
      row[:rating] = rating if rating
      row[:ripped] = ripped if ripped
    end
  }
end

從資料庫裡刪除資料也很簡單:

def delete_rows(selector)
  $db.delete_if(&selector)
end

(完)