【Rails7.1.2】SQLにバインド変数を使うと数値が文字列として展開されてしまう

## はじめに

Rails7.1.2では、SQLへ変数を埋め込む際に不具合があることを学びました。
<br>

この記事ではその内容をまとめます。
<br>

バージョンは**Rails7.1.2**, **MySQL8.0**です。
<br>

**※この記事は2024年10月7日時点の情報に基づいています。**

## 【Rails7.1.2】SQLにバインド変数を使うと数値が文字列として展開されてしまう

バインド変数の確認のために以下のサンプルコードを用います。
<br>

```ruby:app/models/article.rb
class Article < ApplicationRecord
  def self.fetch_with_limit(limit)
    sql = <<-SQL
      SELECT * FROM articles LIMIT ?
    SQL

    Article.find_by_sql([sql, limit])
  end
end
```
<br>

DBにはArticleのレコードが10件登録済みです。
<br>

```ruby
irb(main):004> Article.count
  Article Count (1.5ms)  SELECT COUNT(*) FROM `articles`
=> 10
```
<br>

この状況で先ほどのサンプルコードの`def self.fetch_with_limit(limit)`を実行すると、次のエラーが出てしまいます。
<br>

```ruby
irb(main):005> Article.fetch_with_limit(5)
  Article Load (5.1ms)        SELECT * FROM articles LIMIT '5'

/usr/local/bundle/gems/mysql2-0.5.5/lib/mysql2/client.rb:151:in `_query': Mysql2::Error: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''5'' at line 1 (ActiveRecord::StatementInvalid)
/usr/local/bundle/gems/mysql2-0.5.5/lib/mysql2/client.rb:151:in `_query': You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''5'' at line 1 (Mysql2::Error)
```
<br>

詳しく見てみると、`LIMIT 5`となってほしいのに、`LIMIT '5'`となっています。
<br>

つまり、埋め込んだ値が文字列として展開されてしまっているのです。
<br>

Rails7.0以降に存在する既知の不具合のようで、Issueがいくつかありました。

https://github.com/rails/rails/issues/44853

https://github.com/rails/rails/issues/44312

<br>

なお、この事象はSQLite3では発生しませんでした（PostgreSQLでは未検証です）

## 対処法

もう一度、先ほどのサンプルコードを確認します。
<br>

```ruby:app/models/article.rb
class Article < ApplicationRecord
  def self.fetch_with_limit(limit)
    sql = <<-SQL
      SELECT * FROM articles LIMIT ?
    SQL

    Article.find_by_sql([sql, limit])
  end
end
```
<br>

対処法として、単純に文字列展開して値を埋め込むことができます。
<br>

```ruby:app/models/article.rb
class Article < ApplicationRecord
  def self.fetch_with_limit(limit)
    sql = <<-SQL
      SELECT * FROM articles LIMIT #{limit}
    SQL

    Article.find_by_sql(sql)
  end
end
```
<br>

ただしこれだけだとSQLインジェクションを引き起こす可能性があります。
<br>

次のようにガード節を追加するなど、Integer以外の値が決して埋め込まれないように配慮しなくてはなりません。
<br>

```ruby:app/models/article.rb
  def self.fetch_with_limit(limit)
    # SQLインジェクションを防ぐ
    unless limit.is_a?(::Integer)
      raise ::ArgumentError, 'limit must be an Integer.'
    end
    
    sql = <<-SQL
      SELECT * FROM articles LIMIT #{limit}
    SQL
    Article.find_by_sql(sql)
  end
```
<br>

これで再度実行してみます。
<br>

```ruby
irb(main):002> Article.fetch_with_limit(5)
  Article Load (8.3ms)        SELECT * FROM articles LIMIT 5

=> 
[#<Article:0x0000ffff9e66c1c0
  id: 1,
  title: "タイトル1",
  description: "記事の詳細1",
  created_at: Thu, 17 Oct 2024 11:13:28.322262000 UTC +00:00,
  updated_at: Thu, 17 Oct 2024 11:13:28.322262000 UTC +00:00>,
 #<Article:0x0000ffff9e66c080
  id: 2,
  title: "タイトル2",
  description: "記事の詳細2",
  created_at: Thu, 17 Oct 2024 11:13:28.330934000 UTC +00:00,
  updated_at: Thu, 17 Oct 2024 11:13:28.330934000 UTC +00:00>,
 #<Article:0x0000ffff9e66bf40
  id: 3,
  title: "タイトル3",
  description: "記事の詳細3",
  created_at: Thu, 17 Oct 2024 11:13:28.337640000 UTC +00:00,
  updated_at: Thu, 17 Oct 2024 11:13:28.337640000 UTC +00:00>,
 #<Article:0x0000ffff9e66be00
  id: 4,
  title: "タイトル4",
  description: "記事の詳細4",
  created_at: Thu, 17 Oct 2024 11:13:28.343972000 UTC +00:00,
  updated_at: Thu, 17 Oct 2024 11:13:28.343972000 UTC +00:00>,
 #<Article:0x0000ffff9e66bcc0
  id: 5,
  title: "タイトル5",
  description: "記事の詳細5",
  created_at: Thu, 17 Oct 2024 11:13:28.366889000 UTC +00:00,
  updated_at: Thu, 17 Oct 2024 11:13:28.366889000 UTC +00:00>]
```
<br>

きちんと`LIMIT 5`で展開されたSQLが実行されています。

## おわりに

Railsのバージョンアップに関わることでさまざまな発見があります。
<br>

引き続きアウトプットを続けます。