未知のenumキーや範囲外整数でNULL行が出現する `in_order_of` の挙動を修正
ActiveRecord::QueryMethods#in_order_of が、列挙型の未知キーや整数型の範囲外値を受け取ったときに、NULL 行を意図せず結果に混入させるバグを修正しました。これにより、Enumerable#in_order_of と同様に、表現できない値は単に無視されるようになります。
背景
in_order_of は渡された値を シリアライズ し、NULL 判定とともに WHERE … OR column IS NULL 句を生成していました。シリアライズに失敗した場合は nil が返り、呼び出し側が意図した nil と区別できなかったため、範囲外整数や未知の enum キーが NULL 行としてマッチしてしまっていました。結果として、Book.in_order_of(:status, [:written, :bogus, :proposed]) が NULL ステータスのレコードを :bogus の位置に混入させていました。
技術的な変更
in_order_of の内部ロジックを cast_values_for_in_order_of ヘルパーメソッドに置き換え、シリアライズできない値を明示的に除外するようにしました。変更前は次のようにマッピングしていました。
values = values.map do |value|
if value.is_a?(Array)
value.map do |current_value|
caster.serialize(current_value) if caster.serializable?(current_value)
end
else
caster.serialize(value) if caster.serializable?(value)
end
end
変更後は以下のように簡略化し、空リストの場合は spawn.none! を即座に返します。
values = cast_values_for_in_order_of(values, arel_column.type_caster)
return spawn.none! if values.empty?
新たに導入された cast_values_for_in_order_of は、シリアライズ成功時はその結果を、失敗時は bad_value と呼ばれる特殊オブジェクトでマークし、最後にそれらを除去します。配列内の空要素や bad_value はすべて reject され、結果的に表現できない値はクエリに現れません。
def cast_values_for_in_order_of(values, type_caster)
bad_value = Symbol # sentinel that cannot be a valid column value
values = values.map do |value|
if value.is_a?(Array)
cast_values_for_in_order_of(value, type_caster).without(bad_value)
else
serialized = type_caster.serialize(value) if type_caster.serializable?(value)
if value.nil?
nil
elsif !serialized.nil?
serialized
else
bad_value
end
end
end
values.reject { |v| v == bad_value || (v.is_a?(Array) && v.empty?) }
end
設計判断
今回の修正は Enumerable#in_order_of の挙動と完全に整合させる ことを設計上の指針としています。表現できない値は単に除外し、nil(呼び出し側が明示的に渡した)だけが NULL 行とマッチするようにすることで、期待通りに「無視」されるというドキュメント通りの動作を実現しました。空リストへの対応として spawn.none! を返す方針は、既存の空配列入力と同等の挙動を保ち、SQL の CASE END 生成エラーを防止しています。
まとめ
in_order_of が未知の enum キーや範囲外整数を NULL 行として誤分類していた問題を、シリアライズ失敗値の除外ロジックに置き換えることで解消しました。これにより、NULL データが意図せず結果に混入することはなくなり、ドキュメントと実装の整合性が保たれます。追加テストにより、enum、整数、空リストすべてで期待通りの動作が確認されています。