先週 Kaigi on Rails 2026 に初参加し、モチベーションが向上している chika です。
はじめに
先日、Rails + SQL Server 環境のプロジェクトで運用しているアプリケーションを Rails v8.0 へアップデートする作業をしていたところ、 activerecord-sqlserver-adapter gem のバグらしきものを発見し、人生初の OSS 活動で gem のバグを報告・修正したので、その内容などを紹介しようと思います
環境
- Rails v8.0.5.1
- activerecord-sqlserver-adapter v8.0.11
- ActiveRecord から SQL Server を使うためのアダプタ
- SQL Server
発見したバグ
アプリケーションを Rails v8.0.5.1 にアップデートしテストを回したところ、以下のエラーが出た
Failure/Error: ActiveRecord::Base.connection.execute(sql)
ActiveRecord::StatementInvalid:
Table 'hoge AS Target' doesn't exist
エラーとなった元の構文は以下
sql = <<-SQL.squish
MERGE INTO #{table_name} AS Target
USING (VALUES #{values}) AS Source #{source_columns}
ON Target.item_id = Source.item_id
AND Target.date = Source.date
WHEN MATCHED THEN
UPDATE SET Target.count = Source.count
WHEN NOT MATCHED THEN
INSERT #{source_columns}
VALUES #{insert_values};
SQL
ActiveRecord::Base.connection.execute(sql)
コードとしては、MERGE INTO… USING の SQL を生クエリで書いている部分
エラーの内容としては Table 'hoge AS Target' doesn't exist で、これは指定したテーブル名が存在しない時に起きるエラーだが、よく見るとテーブル名が hoge AS Target となっていて、 AS を含めた名前までテーブル名として解釈してしまっている
エラー解消
このエラーは AS までテーブル名として解釈されているだけなので、AS を削除し、以下のようなクエリに書き換えることでテスト自体は通るようになる
MERGE INTO #{table_name}
USING (VALUES #{values}) AS Source #{source_columns}
...
実際、バージョンアップにあたりプロジェクト側のクエリは上記のように AS を削除する形で対応済み
元々、AS は見た目上の見やすさでしか使われておらず、無くても特に問題はない。バージョンアップのブロッキングとなるような複雑な問題ではなかった(モンキーパッチも不要)
どの実装が元でエラーになったのか?
アダプタ側の Rails v8.0 対応
このバグは v8.0 から入ったもので、activerecord-sqlserver-adapter にて insert_all / upsert_all の MERGE 文対応が入ったタイミングで生まれた
v7.2 まで SQL Server では upsert_all が使用不可だったが、上記 PR によりアダプタ経由で MERGE 文を生成し upsert_all を使用することが可能になった(これ自体はありがたい)
厳密には、v7.2 までも insert_all!(重複があれば例外)は使えたが、 insert_all(重複をスキップ)と upsert_all(重複を更新)の 2 つは使えず、どちらも呼んだ時点で ArgumentError になっていた
根本原因
元々アダプタには、INSERT で主キーなどの自動採番(identity)列に明示的な値を入れようとしている場合、前後に SET IDENTITY_INSERT [table_name] ON / OFF を自動で挿入してくれる機能がある(SQL Server は identity 列への明示的な INSERT を既定で拒否するため)
その際、アダプタ側は前処理として「実行しようとしているクエリが INSERT かを判定し、INSERT だった場合は正規表現でテーブル名を取得する」というロジックを実行していた
# before (v7.2)
def insert_sql?(sql)
!(sql =~ /\A\s*(INSERT|EXEC sp_executesql N'INSERT)/i).nil?
end
v7.2 までは純粋に、「クエリが INSERT という文字列で始まるか?」のみで判定していた所が、上記の insert_all / upsert_all 対応により、MERGE 文の場合も insert_sql? が true になるように変更になった
# after (v8.0)
def insert_sql?(sql)
!(sql =~ /\A\s*(INSERT|EXEC sp_executesql N'INSERT|MERGE INTO.+THEN INSERT)/im).nil?
end
この処理自体に問題なかったが、後続の「INSERT or MERGE だった場合、テーブル名を抜き出す処理」が MERGE 文にも適用されるようになり、そこで追加された正規表現にバグがあったのが原因だった
実際の処理は以下
def get_raw_table_name(sql)
#...
# MERGE文からテーブル名を抜き出す正規表現(v8.0にて追加)
elsif s.match?(/^\s*MERGE INTO.*/i)
s.match(/^\s*MERGE\s+INTO\s+(\[?[a-z0-9_ -]+\]?\.?\[?[a-z0-9_ -]+\]?)\s+(AS|WITH|USING)/i)[1]
end
end
この正規表現では、テーブル名を受ける文字クラスが [a-z0-9_ -] でスペースを含んでいる
なので MERGE INTO hoge AS Target を食わせると AS の手前で止まらず、hoge AS Target までまるごとテーブル名として拾ってしまう。冒頭のエラーはこれ
MERGE 文が生成される経路として、「生クエリを手書きで書く」ものと「アダプタが自動生成する」ものの2パターンがあり、今回追加された正規表現では「アダプタが自動生成する」パターンしか網羅されておらず、「生クエリを手書きで書く」パターンはケアされていなかった
この、「アダプタが自動生成する」パターンの MERGE 文は全て MERGE INTO [tbl] WITH (UPDLOCK, HOLDLOCK) AS target という形となり、これは通常のテーブル名であれば正規表現で正しく拾えるようになっているが、大元となった MERGE INTO hoge AS Target のような WITH を省略した手書きのパターンを拾えるようになっていなかった
gem 側への提案・PR 作成
事前準備①
ここまで調査した段階で、「初めて gem 側のバグを発見したぜ!早速 OSS へコミットだ!」と思ったのだが、一点疑問が浮かんだ
Q.
- アダプタが生成する MERGE 文は「
MERGE INTO [tbl] WITH (UPDLOCK, HOLDLOCK) AS target」という形になる。ということは、アダプタ経由で生成すればバグらないのだから、嬉々として報告するのは微妙ではないか?
というのを思い返し手が止まったので、Fable 5.1 君に聞いてみたところ、
A.
- そもそも
MERGE INTO tbl AS targetは正当な T-SQL。Microsoft 公式ドキュメントの例文自体がAS targetになっている。 -
adapter が勝手に SQL を覗いている側。
executeに正当な SQL を渡しただけで、それを独自の正規表現でパースして失敗している。責任はパーサ側 - しかも失敗の仕方が悪い。best-effort じゃなくて、失敗=例外 or 誤ったテーブル名で IDENTITY_INSERT を誤判定。優雅に劣化しない
- #1312 で adapter 自身が「MERGE を解析する」責任を引き受けた。 引き受けた以上、正当な MERGE で壊れるのはライブラリのバグ。手書きか自動生成かは関係ない
- ついでに: 2025 年 3 月まで、SQL Server で upsert したければ手書き MERGE しか無かった(
ON CONFLICT相当が無い)。手書き MERGE を持っているコードベースは相当数あるはずなので、 OSS 側が直って助かる人は多いはず
というありがたい回答を貰ったので自信を持って先に進んだ。
※ ただし、今回の事象に関しては明確な回避策が存在していて、致命的なバグではなかったので、そこは弱い可能性があるとも補足があった(なるほど)
事前準備②
事前準備をするにあたり、念の為 claude にテーブル名を抜き出す正規表現の処理を色々なパターンでチェックしてもらったところ、追加で以下の複数の抜け漏れが発見された(流石AI。人力では見つけられない穴を大量に見つけてくれる)
正規表現をよく見ると、テーブル名を受ける部分がこうなっている
(\[?[a-z0-9_ -]+\]?\.?\[?[a-z0-9_ -]+\]?)
^^^^^^^^^^^^^^^ ^^ ^^^^^^^^^^^^^^^
名前(1 文字以上) ドット 名前(1 文字以上)
(任意)
この正規表現は本来 [dbo].[products] のような「[スキーマ名].[テーブル名]」を受けるために、名前のパターン [a-z0-9_ -]+ を 2 回並べていて、どちらも 1 文字以上必須。
間のドットは任意なので、[products] のようにスキーマ名を省略した名前でも 2 回分マッチしないといけない(= 最低 2 文字必要)
スキーマ名を省略した場合、[products] は product + s のように適当な位置で分割されてマッチしていただけで、分割できない [x] はマッチ失敗 → nil[1] で NoMethodError になる
また # $ 日本語など、文字クラス [a-z0-9_ -] に無い文字を含む名前は、文字数に関係なくそもそもマッチしない
| ケース | 例 | 修正前 | 修正後 |
|---|---|---|---|
| テーブル名が 1 文字 | MERGE INTO [x] |
NoMethodError |
OK |
| 一時テーブル | MERGE INTO [#tmp] |
NoMethodError |
OK |
| 非 ASCII(日本語など) | MERGE INTO [商品] |
NoMethodError |
OK |
テーブル名に $ を含む |
MERGE INTO [my$tab] |
NoMethodError |
OK |
| 3 パート名(クロス DB) | MERGE INTO [otherdb].[dbo].[dogs] |
NoMethodError |
OK |
| 手書き MERGE でエイリアスあり (今回もともと発見したバグ) |
MERGE INTO [products] AS target USING ... |
テーブル名を [products] AS target と誤抽出し、後続で Table ... doesn't exist
|
OK |
| ドットの前にスペース (回帰が無いことの確認用) |
MERGE INTO dbo .products ... |
OK | OK(変化なし) |
これを踏まえて Fable 君と相談したところ、「手書き MERGE 文で AS が入っているときにエラーとなる」という訴求より、「テーブル名が一文字だった場合にエラーとなる」という方が致命的(回避策がない)ので、そっちを推して行った方が良い。という指摘を貰ったので、そちらの方向で Issue を上げることに
事前準備③
方向性の確認・検証・修正が終わったので、あとは claude に gem の Issue / PR を作る際のお作法をチェックしてもらって実行
修正後の実際のコード
MERGE_TARGET_IDENTIFIER = /(?:\[[^\]]+\]|[a-z0-9_-]+)/i # :nodoc:
MERGE_TARGET_TABLE_NAME = /\A\s*MERGE\s+INTO\s+(#{MERGE_TARGET_IDENTIFIER}(?:\s*\.#{MERGE_TARGET_IDENTIFIER}){0,2})\s+(?:AS|WITH|USING)/i # :nodoc:
# ...
elsif s.match?(/^\s*MERGE INTO.*/i)
s.match(MERGE_TARGET_TABLE_NAME)[1]
(密かに、2 回並んでいた名前のパターンを定数に切り出して共通化😃)
修正前後で変わったのは以下の 4 点
| 修正前 | 修正後 | |
|---|---|---|
| 全体の構造 | 名前のパターンを 2 回並べる([スキーマ].[テーブル] 想定)。どちらも 1 文字以上必須 |
「[...] で括った名前」か「括らない名前」を 1 つの識別子として定義し、ドット区切りで 1〜3 個 |
| スペース | 括らない名前にも許していた → エイリアスまで名前として飲み込む |
[...] の中だけ許す |
| 使える文字 |
[a-z0-9_ -] のみ → # $ 日本語は不可 |
[...] の中なら ] 以外なんでも。括らない名前は [a-z0-9_-]
|
| ドット区切り | 任意(無くても 2 回分マッチしないといけない) | 必須。最大 2 個(= 3 パート) |
軽く調べたところ、この正規表現が生まれた時点では文字クラスに数字が無く、3 か月後に #1338 で 0-9 が足されている。
SQL の識別子は括弧の有無・スキーマの有無・使える文字の広さと自由度が高く、あらゆるパターンを考慮する必要が出てくるので、万人が使う gem 実装の難しさを実感...
Issue 作成
claude の分析によると、過去の傾向を見るに「 Issue を作成して修正案まで載せて、『PR を作る準備はできてます』的なことを書いておいたらメンテナが確認して『PR 作ってね』って言ってくれるはず」とのことだったのでその通りに作成
Issue を作成して割とすぐに確認してもらい、「PR を作ってください!」と返信をもらった
PR 作成
最初は main ブランチに向けて出したのだが、「現在 main のテストが(別起因の影響で)通っていないので、8-1-stable に出し直してほしい」と言われたので出し直し(テストが落ちた時はちょっと焦ったが、別問題だったので一安心)
PR を作成して数日後、返信が来て無事マージ🎉
その後、main / 8-0-stable にも取り込んでもらえた
activerecord-sqlserver-adapter gem のコントリビュータに名を刻めました
(あまり活発ではないが、ないと大変困る gem なので感謝)
感想
Rails アプデとかをしていると gem 側のコードを読みに行ったりすることは多いのですが、そこで見つけた不具合っぽいものを AI によって業務の片手間で深掘り・調査・場合によっては gem 側のバグ修正までハイスピードで行けるようになったので、その辺りもかなりやりやすくなったなと実感しました
(OSS コミットのお作法的な部分とかも自分はかなり気にしてしまうので、今回初めてだったがその辺り含めて AI がチェックしてくれてかなり助かる)


