SequelのTipsのようなもの

Rubyにおける軽量なデータベースツールキットとして、なかなかお手軽なSequelですが、実際に使うにあたっていくつか悩んだポイントがあったので、備忘録としてその解決法をメモしておきます。

なお、この記事はRuby 1.9.1p378とSequel 3.8.0の組み合わせを対象として書いています。

モデルの定義前にDB接続をしたくない

SequelにもActiveRecordパターンに基づいたモデルの仕組みが用意されていますが、「モデルを定義した時点でデータベースへの接続が存在していなければならない」という制約があります。

つまり、

require 'sequel'

Sequel.connect('sqlite://test.db')

class User < Sequel::Model; end

p User.all

は動きますが、

require 'sequel'

class User < Sequel::Model; end # Sequel::Error

Sequel.connect('sqlite://test.db')

p User.all

は動きません。"No database associated with Sequel::Model"といって怒られます。

この制約がどういうときに不便かというと、例えば

require 'sequel'

class App
  def self.connect_database(option)
    Sequel.connect(option)
  end

  class User < Sequel::Model; end # Sequel::Error
end

App.connect_database('sqlite://test.db')
p App::User.all

のように、データベース関連の定義をクラスやモジュールの中に押し込めようとしたとき、ちょっと綺麗に書けなくなってしまいます。

これはmodule_evalを使うことで解決できます。

require 'sequel'

class App
  class << self
    def connect_database(option)
      Sequel.connect(option)
      define_models
    end

    def define_models
      module_eval %{
        class User < Sequel::Model; end
      }
    end
  end
end

App.connect_database('sqlite://test.db')
p App::User.all

実際にはモデル定義のタイミングを遅らせているだけなのですが、とりあえず「それっぽく書きたい」という目的は達成できるかな、と。

なお、Sequel::Modelを継承したクラスは、放っておくと勝手に「それまでに接続した一番最初のデータベース」と関連付けられてしまいます。例えば複数のクラスでそれぞれ別のデータベースを参照させたい場合は、以下のようにモデルクラスの継承時にデータベースのインスタンスを渡してやればOKです。

require 'sequel'

module App
  def connect_database(option)
    db = Sequel.connect(option)
    define_models(db)
  end

  def define_models(db)
    module_eval %{
      class User < Sequel::Model(db); end
    }
  end
end

class AppA
  extend App
end
class AppB
  extend App
end

AppA.connect_database('sqlite://testa.db')
AppB.connect_database('sqlite://testb.db')

p AppA::User.all
p AppB::User.all

あまりこういうケースがあるかどうかはわかりませんが…。

文字列型のデータをforce_encodingしたい

デフォルトでは、取得した文字列型データのエンコーディングはASCII-8BITになっています。

require 'sequel'

Sequel.connect('sqlite://test.db')

class User < Sequel::Model; end

p User.first.name.encoding # => #<Encoding:ASCII-8BIT>

逐一String#force_encodingをかけてもいいのですが、面倒な場合はForceEncodingプラグインを使うことで、全ての文字列型カラムのエンコーディングを強制的に指定できます。

require 'sequel'

Sequel.connect('sqlite://test.db')

class User < Sequel::Model
  plugin :force_encoding, 'UTF-8'
end

p User.first.name.encoding # => #<Encoding:UTF-8>

ちなみに、モデルではなくデータセット経由で操作する場合は、このような全体的にエンコーディングの指定をする方法はなく、それぞれにforce_encodingをかけるしかないようです。

require 'sequel'

db = Sequel.connect('sqlite://test.db')

p db[:users].first[:name] # => "\xE5\xA4\xAA\xE9\x83\x8E"
p db[:users].first[:name].force_encoding('UTF-8') # => "太郎"

プライマリキーを指定したい

Sequelのモデルは、デフォルトではプライマリキーはRestrictedな値として、明示的な指定が許可されていない状態になっています。

require 'sequel'

db = Sequel.connect('sqlite://test.db')

# データセット経由の場合は関係ない
db[:users].insert(:id => 123, :name => 'John', :age => 18) # ok

class User < Sequel::Model; end

User.create(:id => 456, :name => 'Bob', :age => 17) # Sequel::Error
# => ...method id= doesn't exist or access is restricted to it...

モデル定義時にunrestrict_primary_keyを宣言することで、プライマリキーの明示的な指定が可能となります。

require 'sequel'

Sequel.connect('sqlite://test.db')

class User < Sequel::Model
  unrestrict_primary_key
end

User.create(:id => 456, :name => 'Bob', :age => 17) # ok

同名のカラムが存在するテーブル同士をJOINしたい

例えばusersとpostsというテーブルがあり、双方がcreated_atという名前でレコードの生成日時を記録しているとします。このとき、

require 'sequel'

Sequel.connect('sqlite://test.db')

class User < Sequel::Model
  one_to_many :posts
end

class Post < Sequel::Model
  many_to_one :users
end

p Post.join(User, :id => :user_id).first.created_at # => User's created_at

のように単純にJOINすると、得られたレコードのcreated_atはUserのそれとなります。

こういうケースではgraphメソッドを使うことでカラム名の衝突を防ぐことができます。graphメソッドは、テーブル毎に個別のレコードを格納したハッシュとして結果を返します。

require 'sequel'

Sequel.connect('sqlite://test.db')

class User < Sequel::Model
  one_to_many :posts
end

class Post < Sequel::Model
  many_to_one :users
end

record = Post.graph(User, :id => :user_id).first
p record[:posts].created_at # => Post's created_at
p record[:users].created_at # => User's created_at

3つ以上のテーブルをJOINしたい

SequelはメソッドチェインでさくさくSQLを生成できるのが便利なのですが、たまにそこがハマりポイントになることがあります。

例えば今説明したgraphメソッド。これはJOINの左側のテーブルとして、「直前までに生成されたデータセットのうち、最後にJOINされたテーブル」を使用します。

つまり、以下のようなコードを実行すると、

require 'sequel'

Sequel.connect('sqlite://test.db')

class User < Sequel::Model
  one_to_many :posts
end

class Category < Sequel::Model
  one_to_many :posts
end

class Post < Sequel::Model
  many_to_one :users
  many_to_one :categories
end

p Post.graph(User, :id => :user_id)
      .graph(Category, :id => :category_id)
      .first # Sequel::DatabaseError

categoriesテーブルをJOINする段階で*1"no such column: users.category_id"と怒られます。postsとcategoriesをJOINして欲しいのに、usersとcategoriesをJOINしようとしてしまっているわけです。

このようなケースでは、:implicit_qualifierオプションによってJOINの左辺を指定してやります。

require 'sequel'

Sequel.connect('sqlite://test.db')

class User < Sequel::Model
  one_to_many :posts
end

class Category < Sequel::Model
  one_to_many :posts
end

class Post < Sequel::Model
  many_to_one :users
  many_to_one :categories
end

p Post.graph(User, :id => :user_id)
      .graph(Category, { :id => :category_id }, :implicit_qualifier => :posts)
      .first # ok

{}によって第2引数のJOIN条件と第3引数のオプションを明示的に区別することを忘れずに。

気を付けたいSequelのクセ

その他、場合によっては悩みポイントとなるかもしれない、ちょっと変わったSequelのクセについてもメモを残しておきます。

Sequel::DATABASESの存在

Sequelでは、生成したデータベースを全てSequel::DATABASESという配列に格納して保持しています。

注意したいのは、ブロック内でデータベースを扱う場合は、ブロックを抜けたタイミングでSequel::DATABASESからインスタンスを削除してくれるのに、ブロックを使わずにdisconnectメソッドで接続を切った場合は、Sequel::DATABASESからインスタンスを削除してくれず、残ったままになる…という点です。

require 'sequel'

puts Sequel::DATABASES.length # => 0

Sequel.connect('sqlite://test.db') do |db|
  puts Sequel::DATABASES.length # => 1
end

puts Sequel::DATABASES.length # => 0

db = Sequel.connect('sqlite://test.db')

puts Sequel::DATABASES.length # => 1

db.disconnect

puts Sequel::DATABASES.length # => 1

Sequel::DATABASES.delete(db)

puts Sequel::DATABASES.length # => 0

この場合、当然参照が残ったままになるのでGCの対象になりませんし、また前述のように「デフォルトではモデルは最初に接続したデータベース*2と関連付けられる」ため、思わぬところで変な挙動をする危険性があります。*3

必要であれば、上のコードの最後のように自分でSequel::DATABASESからインスタンスを取り除いてやると良いかと思います。

モデルに定義されるアクセサメソッド

前述のように、Sequelのモデルは定義時にデータベースに接続済みである必要があります。

何故かというと、モデルの定義時にテーブルに存在するカラムを取得し、各カラムに対応するアクセサメソッドを定義しているためです。

次のようなコードを実行してみるとわかりやすいです。

require 'sequel'
require 'logger'

db = Sequel.connect('sqlite://test.db')
db.loggers << Logger.new($stderr)

class User < Sequel::Model; end # この時点でSQLを発行している
# SQLiteの場合は PRAGMA table_info('users')

この場合に何に注意したら良いかというと、「モデルの定義以降に追加・変更されたカラムはアクセサメソッドによるアクセスができない」という点です。

require 'sequel'

db = Sequel.connect('sqlite://test.db')

class User < Sequel::Model; end

puts User.instance_methods.include?(:name) # => true
puts User.first.name # ok

db.alter_table(:users) do
  add_column :country, :string, :default => 'jp'
end

# ハッシュによるアクセスはいつでも可能
puts User.first[:country] # => jp

puts User.instance_methods.include?(:country) # => false
puts User.first.country # NoMethodError

これもあまり問題になるケースは無さそうな気がしますが、万一これが問題になる場合は、alter_table後に再度モデル定義をしてやればOKです。その際はremove_constで一度モデルクラスを削除することを忘れずに。

*1:実際に例外が発生しているのは生成したSQLを実行した時ですが

*2:つまりSequel::DATABASES.first

*3:特に単体テストなどで要注意。