ユースケース
railsでよく行う実装として、ransackで検索・絞り込み・ソートを行ったオブジェクトをviewのindexにレンダリングするというケースが多いと思います。
例えば商品一覧(products)をテーブル形式で表示した際、テーブルのヘッダーにsort_link @q, :selling_price, 'Price'
という記述を加えて、価格の高い順・安い順で並べ替えるなんてことがよくあるかと思います。
今回はこのようなsort_linkを使用した場合に起きえる問題点をご紹介します。
問題点
上記で説明したsort_link @q, :selling_price, 'Price'
というケースですとあまり起き得ないかもしれませんが、例えば以下のように在庫の数量で同じ実装をしたとします。
sort_link @q, :quantity, 'Quantity'
class ProductsController
before_action :set_products, only: :index
PER = 30
def index; end
private
def set_products
@q = Product.ransack(params[:q])
page_per = params[:page_per].present? ? params[:page_per] : PER
@products = @q.result.page(params[:page]).per(page_per)
end
end
この場合にquantityのソートを実行すると発行されるSQLは以下のようになります。
SELECT
`products`.*
FROM
`products`
ORDER BY
`products`.`quantity` DESC
LIMIT 30 OFFSET 0;
一見問題なさそうに見えますが、このようにページネーションがあるかつ、ソートする値(quantity)に同数のものが多数存在する場合、1ページ目と2ページ目に同じ商品が表示されてしまうというケースが起きえます(1ページ目にあった商品Aが2ページ目にも表示されているというような状態)。
原因
なぜこのようなことが起きるのかというと、ORDER BY句の並び順が一意でないからです。
例えば1ページあたりの表示数を30件(PER = 30
)としている場合にquantityが1のものが100件あるとします。
その状態でページネーションにより2ページ目以降にアクセスした場合、複数のレコードが同じquantityの値を持っており、これらのレコードの並び順はベースエンジンによって任意に決定されます。
また、OFFSETを使うとき、どのレコードが次のページに含まれるかが曖昧になるため、同じレコードが異なるページに現れることがあるようです。
対策
並び順が一意となるセカンダリソートキーを設定しましょう。
例えば以下のようにidで一位性を保つなどの方法があります。
sort_link @q, :quantity, [:quantity, 'id asc'], 'Quantity'
SELECT
`products`.*
FROM
`products`
ORDER BY
`products`.`quantity` ASC,
`products`.`id` ASC
LIMIT 30 OFFSET 0;
もしくは以下のようにransackのソート条件の後に作成時刻(ほぼ一位と思われる)などで並べ替えすればデータの並び順が安定し、ページングを移動しても問題はなくなります。
def set_products
@q = Product.ransack(params[:q])
page_per = params[:page_per].present? ? params[:page_per] : PER
@products = @q.result.order(created_at: :desc).page(params[:page]).per(page_per)
end
さいごに
この問題に直面した際、最初は同じデータが重複して作成されていることを疑ったのですが、そうではなく並び替えの一位性が原因でした。
同じような問題に直面した方の解決の助けになれば幸いです。