💎

Snowflake ODBC with Ruby で日本語を扱うTips ~ ruby-odbc と共に ~

に公開

Snowflakeには2025/03現在、RubyのSDKが存在しません。
そのため、Rubyアプリをクライアントにする場合REST APIかODBCによる接続を実装する必要があります。
今回、ODBCでの接続においてエラー調査をすることになったので内容をまとめてみました。

環境

発生していたエラー

条件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スタックトレースで追跡可能だったのがこの行でした。

https://github.com/jeremyevans/sequel/blob/5.89.0/lib/sequel/adapters/odbc.rb#L95

デバッグしてみると、sODBC::Statement [1]のオブジェクトであることがわかりました。 each メソッドは ruby-odbc のC拡張部分で定義されています。

https://github.com/larskanis/ruby-odbc/blob/38d1431b7b8589e54079d93817db03a3846afbf7/ext/odbc.c#L6785

Ruby 3.2対応の最新版である v0.999992 のGemのコードはChristian Werner氏によってメンテされていますがGitHubにはコードがコミットされていないので、説明の便宜上Lars Kanis氏による一つ前バージョンのコードで確認してみます。[2]

stmt_each のコードを追っていくと do_fetch という関数にたどり着きます。この関数は500行くらいあるのでとても読むのが大変です...

https://github.com/larskanis/ruby-odbc/blob/38d1431b7b8589e54079d93817db03a3846afbf7/ext/odbc.c#L5921

結果の取得には SQLGetData 関数を呼び出します。

https://learn.microsoft.com/ja-jp/sql/odbc/reference/syntax/sqlgetdata-function?view=sql-server-ver16

ChatGPT曰く、以下のような仕様のようです。

SQLGetData関数の場合、データ自体はあらかじめ確保したバッファのアドレス(ポインタ)を渡しておき、関数内部でそのバッファに取得したデータが書き込まれます。また、データの長さやNULL判定などの情報も、あらかじめ渡したポインタ先(StrLen_or_IndPtr)に格納されます。

あらかじめ確保するバッファサイズには SQLColAttribute 関数によって取得したカラムメタデータを使用します。

https://github.com/larskanis/ruby-odbc/blob/38d1431b7b8589e54079d93817db03a3846afbf7/ext/odbc.c#L3184

文字列カラムについてまとめると以下のような条件になるようです。

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 内の実装を見ていきます。

https://github.com/larskanis/ruby-odbc/blob/38d1431b7b8589e54079d93817db03a3846afbf7/ext/odbc.c#L6169

先ほど取得したsizeで curlen という変数を初期化しています。

https://github.com/larskanis/ruby-odbc/blob/38d1431b7b8589e54079d93817db03a3846afbf7/ext/odbc.c#L6361

type == SQL_C_CHAR の場合、先ほどの文字サイズで rb_tainted_str_new (Ruby 3.2 では rb_str_new へ移行)を呼び出し、文字列を生成します。

https://github.com/larskanis/ruby-odbc/blob/38d1431b7b8589e54079d93817db03a3846afbf7/ext/odbc.c#L6361

Rubyのstring.cの rb_tainted_str_new 関数の定義を見ると、内部では str_new0 関数が呼び出され、その中で len < 0 の場合(curlen が負の値の場合)、以下のエラーメッセージが発生することがわかります。

https://github.com/ruby/ruby/blob/v3_1_6/string.c#L881-L883

curlenSQLGetData の引数にも指定しているため、何らかの条件下で SQLGetData の呼び出しにより curlen が負の値となり、エラーが発生しているのではないかと考えられます。(これ以上の調査を続ける前に力尽きました...)

解決方法

  • 正攻法: ruby-odbc の UTF-8 対応版(odbc-utf8)を使用する
    • 未検証ですが、Sequelと共に使用する場合工夫が必要かもしれません
    • Gemfileにて、sequelの前にrequireを追加したら動作することを確認しました
  • ワークアラウンド: Snowflake の場合、カラムの型が SQL_LONGVARCHAR にマップされるよう、Snowflake ODBC ドライバの MapToLongVarchar パラメータ[4]を構成/接続パラメータに指定する

おわりに

今回の調査は大部分が ruby-odbc のコードリーディングでした。日本語情報が少ない中で、Ruby における ODBC バインディングの内部動作について理解を深めるよい機会となりました。
同様の課題に直面された方の参考になれば幸いです。

脚注
  1. http://ch-werner.de/rubyodbc/odbc.html ↩︎

  2. https://github.com/larskanis/ruby-odbc/issues/7 ↩︎

  3. http://www.ch-werner.de/rubyodbc/README ↩︎

  4. https://docs.snowflake.com/ja/developer-guide/odbc/odbc-parameters ↩︎

株式会社primeNumber

Discussion