Snowflake ODBC with Ruby で日本語を扱うTips ~ ruby-odbc と共に ~
Snowflakeには2025/03現在、RubyのSDKが存在しません。
そのため、Rubyアプリをクライアントにする場合REST APIかODBCによる接続を実装する必要があります。
今回、ODBCでの接続においてエラー調査をすることになったので内容をまとめてみました。
環境
- Snowflake ODBC driver 3.1.4 https://docs.snowflake.com/ja/developer-guide/odbc/odbc-linux
- ruby-odbc 0.999992 http://www.ch-werner.de/rubyodbc/
- Sequel 5.89.0 https://github.com/jeremyevans/sequel
発生していたエラー
条件1) 文字列カラム定義の文字数が小さい (VARCHAR(4)
など)
条件2) 日本語文字列が格納されている
というテーブルの場合に、以下の二種類のエラーが発生していました。
Sequel::DatabaseError: ArgumentError: negative string size (or size too big)
→ データの取得処理でエラー
JSON::GeneratorError: source sequence is illegal/malformed utf-8
→ データの取得処理自体ではエラーは発生しないが、取得したバイト列を含むオブジェクトをJSONシリアライズしたタイミングでエラー
調査
エラー発生箇所を追っていきます。前者のRubyスタックトレースで追跡可能だったのがこの行でした。
デバッグしてみると、s
は ODBC::Statement
[1]のオブジェクトであることがわかりました。 each
メソッドは ruby-odbc のC拡張部分で定義されています。
Ruby 3.2対応の最新版である v0.999992 のGemのコードはChristian Werner氏によってメンテされていますがGitHubにはコードがコミットされていないので、説明の便宜上Lars Kanis氏による一つ前バージョンのコードで確認してみます。[2]
stmt_each
のコードを追っていくと do_fetch
という関数にたどり着きます。この関数は500行くらいあるのでとても読むのが大変です...
結果の取得には SQLGetData
関数を呼び出します。
ChatGPT曰く、以下のような仕様のようです。
SQLGetData関数の場合、データ自体はあらかじめ確保したバッファのアドレス(ポインタ)を渡しておき、関数内部でそのバッファに取得したデータが書き込まれます。また、データの長さやNULL判定などの情報も、あらかじめ渡したポインタ先(StrLen_or_IndPtr)に格納されます。
あらかじめ確保するバッファサイズには SQLColAttribute
関数によって取得したカラムメタデータを使用します。
文字列カラムについてまとめると以下のような条件になるようです。
ODBCドライバの返却型 | ruby-odbcのUNICODEフラグ | 型 | サイズ |
---|---|---|---|
SQL_LONGVARCHAR | ON (UNICODE=true) | SQL_C_WCHAR | SQL_NO_TOTAL |
SQL_LONGVARCHAR | OFF (UNICODE=false) | SQL_C_CHAR | SQL_NO_TOTAL |
非 SQL_LONGVARCHAR | ON (UNICODE=true) | SQL_C_WCHAR | 文字数 × sizeof(SQLWCHAR) + sizeof(SQLWCHAR)(NULL終端分) |
非 SQL_LONGVARCHAR | OFF (UNICODE=false) | SQL_C_CHAR | 文字数 + 1(NULL終端分) |
UNICODE
をtrueにするにはどうやらUTF8向けのビルドを行い、 require 'odbc-utf8'
で呼び出す必要があるようです。[3]
このことから、カラムの文字数に対してデータのバイトサイズが大きくなる場合、4つ目のパターンでは、確保されるバッファサイズが実際に必要なサイズよりも小さくなり、エラーが発生する可能性が示唆されてきました。
以上を踏まえて、もう一度 do_fetch
内の実装を見ていきます。
先ほど取得したsizeで curlen
という変数を初期化しています。
type == SQL_C_CHAR
の場合、先ほどの文字サイズで rb_tainted_str_new
(Ruby 3.2 では rb_str_new
へ移行)を呼び出し、文字列を生成します。
Rubyのstring.cの rb_tainted_str_new
関数の定義を見ると、内部では str_new0
関数が呼び出され、その中で len < 0
の場合(curlen
が負の値の場合)、以下のエラーメッセージが発生することがわかります。
curlen
は SQLGetData
の引数にも指定しているため、何らかの条件下で SQLGetData
の呼び出しにより curlen
が負の値となり、エラーが発生しているのではないかと考えられます。(これ以上の調査を続ける前に力尽きました...)
解決方法
- 正攻法: ruby-odbc の UTF-8 対応版(
odbc-utf8
)を使用する未検証ですが、Sequelと共に使用する場合工夫が必要かもしれません- Gemfileにて、sequelの前にrequireを追加したら動作することを確認しました
- ワークアラウンド: Snowflake の場合、カラムの型が
SQL_LONGVARCHAR
にマップされるよう、Snowflake ODBC ドライバのMapToLongVarchar
パラメータ[4]を構成/接続パラメータに指定する
おわりに
今回の調査は大部分が ruby-odbc のコードリーディングでした。日本語情報が少ない中で、Ruby における ODBC バインディングの内部動作について理解を深めるよい機会となりました。
同様の課題に直面された方の参考になれば幸いです。
Discussion