Railsで、DBから取得するデータに対し、order・sort_by・sortを使って、昇順・降順ソートする

RailsActiveRecordを使ってDBからレコードを取得するときに、以下を考慮した昇順・降順ソートで迷ったことがあったため、メモを残します。

  • 取得するタイミング
    • データ取得時 (SQL発行時にソート)
    • データ取得後 (Rubyのarrayになってからソート)
  • ソートキーの型
    • string
    • integer
    • enum
    • datetime
    • boolean
    • 外部キー先の属性

 
目次  

 

環境

なお、記事中のソースコードは必要な部分だけを抜粋しているため、必要に応じて後述のソースコード全体を確認してください。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app/pull/5/files

ちなみに、ソートが意図通りにできているかの確認は、RSpecで行っています。

 

データについて

テーブル構造

今回は areas (地域) と apples (りんご) の2つのテーブルを用意します。

areas

項目 列名
id id integer
地域名 name string

 
apples

項目 列名 備考
りんご名 name string
平均重量 weight integer
coloer integer enumyellowredgreen をモデルに定義
出荷開始時期 starts_at datetime 年は2023年固定。上旬(1日)、中旬(10日)、下旬(20日)を指定
海外品種 is_imported boolean true の場合は海外から来た品種
育成地域 area_id foreign_key areas を外部キー指定

 
rails-mermaid_erd によるER図は以下です。

 

各テーブルデー

基本的には以下のデータを持っているとします。

もし必要であれば本文中で追記します。

areas

No name
1 青森県
2 長野県
3 アメリ
4 オーストラリア
5 イギリス

 
apples

No name weight color starts_at is_imported area_id
1 シナノドルチェ 300 2 (red) 2023/09/10 false 2 (長野県)
2 秋映 300 2 (red) 2023/10/10 false 2 (長野県)
3 シナノゴールド 350 1 (yellow) 2023/10/20 false 2 (長野県)
4 つがる 300 2 (red) 2023/09/01 false 1 (青森県)
5 ふじ 300 2 (red) 2023/11/01 false 1 (青森県)

 

DBから取得するときにソート

まずは、SQLのORDER句でソートする場合です。

この場合、ActiveRecordorder メソッドを使ってソートします。
https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-order

 

単一テーブル・単一キーでソート

昇順ソート

order メソッドのドキュメントにある通り、デフォルトは昇順ソートになります。

以下の例では、出荷開始時期の昇順でソートしています。

actual = Apple.order(:starts_at).to_a

expect(actual).to eq([tsugaru, shinano_dolce, akibae, shinano_gold, fuji])

 

降順ソート

order メソッドに desc シンボルを指定することで、降順ソートができます。

actual = Apple.order(starts_at: :desc).to_a

expect(actual).to eq([fuji, shinano_gold, akibae, shinano_dolce, tsugaru])

 

複数テーブル・単一キーでソート

関連先のテーブル列でソート

今回は、 applesareas をJOINし、 areas.name をキーにソートします。

ソート方法として、

  • orderメソッドに文字列を渡す
  • mergeメソッドを使う

の2つがあるため、それぞれ見ていきます。
Railsでjoinしたテーブルでorderする - Qiita

 

orderメソッドに文字列を渡す

actual = Apple.joins(:area).order('areas.name DESC').to_a

expect(actual).to eq([tsugaru, fuji,  shinano_dolce, akibae, shinano_gold])

 

mergeメソッドを使う

actual = Apple.joins(:area).merge(Area.order(name: :desc)).to_a

expect(actual).to eq([tsugaru, fuji,  shinano_dolce, akibae, shinano_gold])

 
mergeメソッドの場合も、SQLではORDER句でのソートになっています。

SELECT
  "apples".*
FROM
  "apples"
  INNER JOIN "areas" ON "areas"."id" = "apples"."area_id"
ORDER BY
  "areas"."name" DESC

 

単一テーブル・複数キーでソート

order メソッドにソートキーを複数指定すれば良いです。

actual = Apple.order(name: :asc, starts_at: :desc).to_a

expect(actual).to eq([tsugaru, fuji, shinano_gold, shinano_dolce, akibae])

 
なお、発行されるSQLはこちらです。

SELECT
  "apples".*
FROM
  "apples"
ORDER BY
  "apples"."name" ASC,
  "apples"."starts_at" DESC

 

複数テーブル・複数キーでソート

結果をわかりやすくするため、秋映と同じ時期の青森県育成りんご 津軽ゴールド を追加します。

No name weight color starts_at is_imported area_id
1 シナノドルチェ 300 2 (red) 2023/09/10 false 2 (長野県)
2 秋映 300 2 (red) 2023/10/10 false 2 (長野県)
3 シナノゴールド 350 1 (yellow) 2023/10/20 false 2 (長野県)
4 つがる 300 2 (red) 2023/09/01 false 1 (青森県)
5 ふじ 300 2 (red) 2023/11/01 false 1 (青森県)
6 津軽ゴールド 300 1 (yellow) 2023/10/10 false 1 (青森県)

 

関連元の列 > 関連先の列でソート

例えば、appleのstarts_atの降順 > areaのnameの降順 でソートしたいとします。

この場合、関連元にて order でソートしてから、関連先のソートをmergeします。

秋映の後に津軽ゴールドが来るように並んでいます。

actual = Apple.joins(:area).order(starts_at: :desc).merge(Area.order(name: :desc)).to_a

expect(actual).to eq([fuji, shinano_gold, akibae, tsugaru_gold, shinano_dolce, tsugaru])

 
発行されるSQLはこちら。

SELECT
  "apples".*
FROM
  "apples"
  INNER JOIN "areas" ON "areas"."id" = "apples"."area_id"
ORDER BY
  "apples"."starts_at" DESC,
  "areas"."name" DESC

 

関連先の列 > 関連元の列でソート

例えば、 areaのnameの降順 > appleのstarts_atの降順でソート とします。

この場合、関連先をmergeでソートしてから、関連元をorderでソートします。

areas.nameの降順なので、青森県育成のものが先頭に来てから、長野県育成のものが並んでいます。

actual = Apple.joins(:area).merge(Area.order(name: :desc)).order(starts_at: :desc).to_a

expect(actual).to eq([fuji, tsugaru_gold, tsugaru, shinano_gold, akibae, shinano_dolce])

 
発行されるSQLはこちら。

SELECT
  "apples".*
FROM
  "apples"
  INNER JOIN "areas" ON "areas"."id" = "apples"."area_id"
ORDER BY
  "areas"."name" DESC,
  "apples"."starts_at" DESC

 

データ取得後、Rubyのarrayになってからソート

DBからデータを取得する時点ではなく、要素がモデルオブジェクトのarrayになってからソートしたいことがあるかもしれません。

その場合のソート方法について見ていきます。

なお、特に断りのない限り、 apples は以下となります。

let(:apples) { Apple.all.to_a }

 

モデルオブジェクトの単一属性をキーにソート

今回使うモデル Apple のオブジェクトには属性として

  • string
  • integer
  • enum
  • datetime
  • boolean
  • 外部キー先の属性

などがあります。

そこで、それぞれの属性ごとに昇順・降順ソートを見ていきます。

なお、Rubyでソートをする場合は sortsort_by メソッドが使えます。

 
パフォーマンス的には sort_by の方が良さそうですので、この記事ではできる限り sort_by を使います。
sorting - How to sort an array in descending order in Ruby - Stack Overflow

 

string型の属性

昇順ソートは sort_by

普通にソートできます。

actual = apples.sort_by(&:name)

expect(actual).to eq([tsugaru, fuji, shinano_gold, shinano_dolce, akibae])

 

降順ソートは sort_by + reverse

sort_by での降順ソートは、先頭に - をつける記事がみあたります。

ただ、stringの場合はstringクラスに - メソッドが無いせいか、正しく並び替えが行われません。
https://docs.ruby-lang.org/ja/latest/class/String.html

そのため、昇順ソートした後に reverse メソッドを使って逆に並べ替えることになりそうです。
Sorting an array of strings in Ruby - Stack Overflow

actual = apples.sort_by(&:name).reverse

expect(actual).to eq([akibae, shinano_dolce, shinano_gold, fuji, tsugaru])

 

integer型の属性

昇順・降順とも sort_by

integer型の場合は sort_by が使えます。

昇順はそのまま。

actual = apples.sort_by(&:weight)

expect(actual).to eq([shinano_dolce, akibae, tsugaru, fuji, shinano_gold])

 
降順は、項目の先頭に - を付ければ可能です。

actual = apples.sort_by { |a| -a.weight }

expect(actual).to eq([shinano_gold, shinano_dolce, akibae, tsugaru, fuji])

 

enum型の属性

enum型はinteger型と似ていますが、

  • DBにはinteger型の値が入る
  • shinano_gold.color とすると yellow という文字列が返る
    • shinano_gold.color_before_type_cast とすると、 1 などのinteger型の値が返る

の違いがあります。

今回は「integer型の値でソートしたい」という前提で書いていきます。

また、enumcolor の利用を明確にするため、green な値を持つりんご ブラムリー を追加します。

データの全体はこんな感じです。

No name weight color starts_at is_imported area_id
1 シナノドルチェ 300 2 (red) 2023/09/10 false 2 (長野県)
2 秋映 300 2 (red) 2023/10/10 false 2 (長野県)
3 シナノゴールド 350 1 (yellow) 2023/10/20 false 2 (長野県)
4 つがる 300 2 (red) 2023/09/01 false 1 (青森県)
5 ふじ 300 2 (red) 2023/11/01 false 1 (青森県)
6 ブラムリー 300 3 (green) 2023/09/10 true 5 (イギリス)

 

attributeの場合、string型と同じく sort_by

attributeの場合、取得できる値はstringなので、string型と同じ方法でのソートとなります。

# 昇順ソート
actual = apples.sort_by(&:color)

# green -> red -> yellow
expect(actual).to eq([bramley, shinano_dolce, akibae, tsugaru, fuji, shinano_gold])


# 降順ソート
actual = apples.sort_by { |a| a.color }.reverse

# yellow -> red -> green
expect(actual).to eq([shinano_gold, fuji, tsugaru, akibae, shinano_dolce, bramley])

 

before_type_castの場合、昇順・降順ソートとも sort_by

enumからinteger型の値で取り出す場合には、integer型と同じになります。

# 昇順ソート
actual = apples.sort_by(&:color_before_type_cast)

# yellow (1) -> red (2) -> green (3)
expect(actual).to eq([shinano_gold, shinano_dolce, akibae, tsugaru, fuji, bramley])


# 降順ソート
actual = apples.sort_by { |a| -(a.color_before_type_cast) }

# green (3) -> red (2) -> yellow (1)
expect(actual).to eq([bramley, shinano_dolce, akibae, tsugaru, fuji, shinano_gold])

なお、今回の例では分かりづらいのですが、降順ソートの結果がattributeとbefore_type_castで異なっています。

 

datetime型の属性

昇順ソートは sort_by

昇順ソートは sort_by でdatetime型の属性をそのまま使えます。

actual = apples.sort_by(&:starts_at)

expect(actual).to eq([tsugaru, shinano_dolce, akibae, shinano_gold, fuji])

 

降順ソートは sort_by + to_i

降順ソートの場合、 - を指定するとエラーになります。

actual = apples.sort_by { |a| -(a.starts_at) }
#=> NoMethodError: undefined method `-@' for Sun, 10 Sep 2023 00:00:00.000000000 JST +09:00:ActiveSupport::TimeWithZone

 
そのため、datetime型をそのまま使うのではなく、 to_i メソッドでUNIXタイムスタンプへと変換してから降順ソートすれば良さそうです。
https://api.rubyonrails.org/classes/DateTime.html#method-i-to_i

actual = apples.sort_by { |a| -(a.starts_at.to_i) }

expect(actual).to eq([fuji, shinano_gold, akibae, shinano_dolce, tsugaru])

 

boolean型の属性

boolean型の属性の場合、そのまま使うとエラーになります。

actual = apples.sort_by { |a| a.is_imported }
#=> comparison of FalseClass with true failed

 
そこで、 boolean を integer に変換して使うのが良さそうです。
ruby - How do I convert boolean values to integers? - Stack Overflow

 
なお、booleanに昇順・降順という意味があるのか分からないため、今回は昇順・降順という表現は使わないようにします。

また、ソートを分かりやすくするよう、ここでは is_imported == true なデータを増やしてみます。

No name weight color starts_at is_imported area_id
1 シナノドルチェ 300 2 (red) 2023/09/10 false 2 (長野県)
2 秋映 300 2 (red) 2023/10/10 false 2 (長野県)
3 シナノゴールド 350 1 (yellow) 2023/10/20 false 2 (長野県)
4 つがる 300 2 (red) 2023/09/01 false 1 (青森県)
5 ふじ 300 2 (red) 2023/11/01 false 1 (青森県)
6 ピンクレディ 250 2 (red) 2023/11/20 true 4 (オーストラリア)

 

true -> false の順に並べるときは、trueで 0 を返す

is_importedtrue の場合に 0 となるよう、 sort_by で実装します。

actual = apples.sort_by { |a| a.is_imported ? 0 : 1 }

expect(actual).to eq([pink_lady, shinano_dolce, akibae, shinano_gold, tsugaru, fuji])

 

false -> true の順に並べるときは、true で 1 を返す

先ほどとは逆で、 true のときに 1 が返るようにします。

actual = apples.sort_by { |a| a.is_imported ? 1 : 0 }

expect(actual).to eq([shinano_dolce, akibae, shinano_gold, tsugaru, fuji, pink_lady])

 

関連先の属性

昇順・降順ソートとも、 eager_load + sort_by

まず、関連先の属性を使えるようにするため、 eager_load を使って取得するようにします。

let(:apples) { Apple.eager_load(:area).all.to_a }

 
あとは、関連先の列をドットで参照しつつ、列の型に対するソート方法に従えばよいです。

今回は関連先の id 列でソートするため、integer型のソート方法に従います。

# 昇順ソート
actual = apples.sort_by { |a| a.area.id }

expect(actual).to eq([tsugaru, fuji, shinano_dolce, akibae, shinano_gold, pink_lady])


# 降順ソート
actual = apples.sort_by { |a| -(a.area.id) }

expect(actual).to eq([pink_lady, shinano_dolce, akibae, shinano_gold, tsugaru, fuji])

 

モデルオブジェクトの複数属性をキーにソート

今回は多少手間のかかる

  • color (enum型)
  • name (string型)

の2属性を使い、各種ソートを試してみます。

なお、enumのソートにて yellow なりんごが複数あると分かりやすくなるため、シナノゴールドと同じ色の津軽ゴールドを追加します。

No name weight color starts_at is_imported area_id
1 シナノドルチェ 300 2 (red) 2023/09/10 false 2 (長野県)
2 秋映 300 2 (red) 2023/10/10 false 2 (長野県)
3 シナノゴールド 350 1 (yellow) 2023/10/20 false 2 (長野県)
4 つがる 300 2 (red) 2023/09/01 false 1 (青森県)
5 ふじ 300 2 (red) 2023/11/01 false 1 (青森県)
6 津軽ゴールド 300 1 (yellow) 2023/10/10 false 1 (青森県)

 

並べ方が単一の場合

enumの昇順 + stringの昇順の場合、 sort_by + ブロックに属性の配列を指定

sort_by で複数のキーを指定する場合、ブロックにキーの配列を渡せば良さそうです。
Sorting: Sort array based on multiple conditions in Ruby - Stack Overflow

actual = apples.sort_by { |a| [a.color_before_type_cast, a.name] }

expect(actual).to eq([shinano_gold, tsugaru_gold, tsugaru, fuji, shinano_dolce, akibae])

 

enumの降順 + stringの降順の場合、sort_by + ブロックに属性の配列を指定 + reverse

並び方としては 昇順 + 昇順 の逆となれば良いので、並び替えた結果を reverse します。

actual = apples.sort_by { |a| [a.color_before_type_cast, a.name] }.reverse

expect(actual).to eq([akibae, shinano_dolce, fuji, tsugaru, tsugaru_gold, shinano_gold])

 

並べ方が混在している場合

並び方が混在(昇順 + 降順)している場合、 sort_by でソートするのは難しそうです。

前者で挙げられている実装例は

# gender desc, name asc
p(dogs.sort do |a, b|
  [b[:gender], a[:name]] <=> [a[:gender], b[:name]]
end)

後者で挙げられている実装例は

books.sort! do |a, b|
  (a.author <=> b.author).nonzero? ||
    (b.title <=> a.title)
end

でした。

今回は前者を見ながら実装してみます。

 

enumの昇順 + nameの降順は sort

前者で実装する場合、降順としたい属性の ab を入れ替えれば良さそうでした。

そのため、今回は enumの昇順 + nameの降順であるため、 name 属性の ab を入れ替えます。

actual = apples.sort do |a, b|
  [a.color_before_type_cast, b.name] <=> [b.color_before_type_cast, a.name]
end

# 色が yellow -> red、同じ色の中では漢字 -> カタカナ -> ひらがな 順
expect(actual).to eq([tsugaru_gold, shinano_gold, akibae, shinano_dolce, fuji, tsugaru])

 

enumの降順 + nameの昇順は sort

先ほどとは逆に、 enum型の color 属性について ab を入れ替えます。

actual = apples.sort do |a, b|
  [b.color_before_type_cast, a.name] <=> [a.color_before_type_cast, b.name]
end

# 色が red -> yellow、同じ色の中ではひらがな -> カタカナ -> 漢字 順
expect(actual).to eq([tsugaru, fuji, shinano_dolce, akibae, shinano_gold, tsugaru_gold])

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app

今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app/pull/5