💎

Rails でバージョン情報 (Gem::Version) をソート可能な状態で DB に保存する

に公開

10.1.0 のようなバージョン情報は単純な数値としては扱えないし、文字列として扱っても比較やソートがうまくできない (10.0 < 9.0 になってしまう)。
Gem::Version だと比較やソートが可能になるが DB での比較・ソートを可能にするためには、serialize の方法を工夫する必要がある。

serialize の方法

ここでは下記の形式のみを対象とする。

  • \A\d{1,4}+(\.\d{1,4})*[^ ]*\z つまり、1-4桁の数字で始まり . で区切られた1-4桁の数字が続き、最後に (スペースをのぞく) 任意の文字列があってもよい
  • 数字列は 0 以外は 0 で始まらないものとする
  • 具体的には 1.23.456-alpha のようなもの

serialize 時は . で区切られた数字列は 4 桁になるようにスペース で padding する。

1.23.456-alpha

   1.  23. 456-alpha

のようになる。

これで実質 . で区切られた数字列ごとの比較になるため、意図した比較・ソートが可能になる。

DB から読むときにはスペース を削除するだけでよい。

ActiveRecord::Type

Rails で扱いやすくするため Type を定義する。
config/initializers/version_type.rb を作成する。
(対応していない形式のための例外処理は省略)

class VersionType < ActiveRecord::Type::String
  def self.serialize(value)
    return nil if value.nil?

    value.to_s.match(/\A([\d.]+)(.*)\z/)
    parts, suffix = $~.captures
    parts.to_s.split(".").map {|s| s.rjust(4, " ") }.join(".") + suffix
  end

  # attribute_name= で assign された値の変換
  def cast(value)
    case value
    when Gem::Version
      value
    when NilClass
      nil
    else
      begin
        # `v1.0` などを `1.0` として扱うため数字の前の文字列を削除
        parsed = value.to_s.gsub(/\A[^\d]*/, "").gsub(" ", "")
        Gem::Version.new(parsed)
      rescue ArgumentError
        nil
      end
    end
  end

  # DB から読んだ値の変換
  def deserialize(value)
    return nil if value.nil?

    parsed = value.to_s.gsub(" ", "")
    Gem::Version.new(parsed)
  end

  # DB に書き込む値
  def serialize(value)
    self.class.serialize(value)
  end
end

ActiveRecord::Type.register(:version, VersionType)

これで Model のなかで attribute :foo_version, :version のように書くと、assign や DB から読んだときに Gem::Version オブジェクトになり、DB に保存するときは前述の方法で serialize される。

Discussion