RailsでActiveRecordを使ってDBからレコードを取得するときに、以下を考慮した昇順・降順ソートで迷ったことがあったため、メモを残します。
- 取得するタイミング
- ソートキーの型
- string
- integer
- enum
- datetime
- boolean
- 外部キー先の属性
目次
- 環境
- データについて
- DBから取得するときにソート
- データ取得後、Rubyのarrayになってからソート
- ソースコード
環境
- Rails 7.0.4.2
なお、記事中のソースコードは必要な部分だけを抜粋しているため、必要に応じて後述のソースコード全体を確認してください。
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 | enumに yellow 、 red 、 green をモデルに定義 |
出荷開始時期 | 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句でソートする場合です。
この場合、ActiveRecordの order
メソッドを使ってソートします。
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])
複数テーブル・単一キーでソート
関連先のテーブル列でソート
今回は、 apples
と areas
を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でソートをする場合は sort
と sort_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型の値でソートしたい」という前提で書いていきます。
また、enumな color
の利用を明確にするため、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_imported
が true
の場合に 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
でソートするのは難しそうです。
- ruby - Sorting multiple values by ascending and descending - Stack Overflow
- The Art of Software : Rubyで複数キーを使ったソートとパフォーマンス
前者で挙げられている実装例は
# 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
前者で実装する場合、降順としたい属性の a
と b
を入れ替えれば良さそうでした。
そのため、今回は enumの昇順 + nameの降順であるため、 name
属性の a
と b
を入れ替えます。
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
属性について a
と b
を入れ替えます。
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