ODBCのSQLExecuteが2回目以降SQL_NO_DATAになる原因と対策:SQLBindParameterループをNOCOUNT/SQLMoreResultsで安定化

ODBCでSQLBindParameter→SQLExecuteをループすると、1回目は成功するのに2回目以降がSQL_NO_DATAになる——。多くのケースは「前回実行で返った結果(行数メッセージ等)を消化し切れていない」ことが原因です。本記事では切り分け方法と、SET NOCOUNT ON/SQLMoreResultsで確実に解消する手順をまとめます。

目次

症状を整理:SQLTablesのフェッチ中に別ステートメントでINSERTを繰り返すと止まる

問題が起きやすいのは、同一接続上でステートメントを2本持ち、片方でメタデータを読みながら、もう片方でDML(INSERT/UPDATE/DELETE)を回す構成です。典型例を整理すると次の流れになります。

  • stmt1:SQLTables→SQLFetchでテーブル情報を順次取得
  • stmt2:INSERT ... SELECT ?, ? ... WHERE NOT EXISTS(...) を SQLPrepare 済み
  • for (SQLFetch()) { パラメータ設定(SQLBindParameter); SQLExecute(stmt2); }

ところが、1回目のSQLExecuteは成功するのに、2回目以降がSQL_NO_DATAになる/期待通りに動かないという現象が発生します。SQLFreeStmt(stmt2, SQL_RESET_PARAMS) を入れても変わらない、という相談がよくあります。

まず「失敗」だと決めつけない:SQL_NO_DATAの意味は関数によって違う

SQL_NO_DATA は「データが無い」ことを示す返り値ですが、SQLFetch と SQLExecute ではニュアンスが異なります。特に INSERT ... WHERE NOT EXISTS のように意図的に0行を許容するSQLだと、SQLExecute が SQL_NO_DATA を返すこと自体は仕様上あり得ます。

返り値代表的な意味実装上の扱い
SQL_SUCCESS成功通常処理を継続
SQL_SUCCESS_WITH_INFO成功(警告あり)SQLGetDiagRecで警告内容をログに残しつつ継続
SQL_ERROR失敗SQLSTATE/メッセージを取得して原因調査
SQL_NO_DATA(SQLFetch)結果セットの終端(もう行がない)ループ終了(正常系)
SQL_NO_DATA(SQLExecute)更新系SQLで「影響行が0」の可能性SQLRowCountで0行か確認し、要件的にOKなら「スキップ」として扱う

つまり、まずは次の切り分けが有効です。

  • 本当に「実行できていない」のか? それとも0行で終わっただけなのか?
  • 直後に SQLRowCount を呼び、影響行数(挿入行数)が0かどうか確認する
  • SQL_SUCCESS_WITH_INFO や SQL_ERROR が出ているなら SQLGetDiagRec の内容を必ず見る

とはいえ、今回のように「毎回INSERTされるはず」「2回目以降だけ挙動が崩れる」というとき、実際の原因として多いのが次のパターンです。

原因の本命:「前回実行の結果(結果セット/メッセージ)を最後まで消化していない」

ODBCドライバーやDB製品によっては、INSERT/UPDATE/DELETEのように結果セットが返らないはずのSQLでも、内部的に次のような“追加の結果”が返ることがあります。

  • 行数メッセージ(例:(10 rows affected) 相当)
  • トリガーや複合SQLによる追加の結果セット(複数結果セット扱い)
  • ストアドプロシージャ経由での戻り結果やPRINT/RAISERROR(情報メッセージ)

この状態で未処理の結果がステートメント側に残ったまま次のSQLExecuteを投げると、2回目以降の実行が期待通りに進まなくなり、環境によっては SQL_NO_DATA などの形で「うまく動いていないように見える」状態になります。

なぜ「1回目は成功して、2回目から壊れる」のか

1回目の実行時点では、アプリ側が気付かない形で「追加の結果」が溜まります。ところが、2回目に入った瞬間にドライバーは前回の結果処理が終わっていないことを検知し、同じステートメントでの次実行を素直に受け付けられなくなります。結果として、次のいずれかの症状になります。

症状現象として見えるもの背景
2回目のSQLExecuteがSQL_NO_DATA「何も起きていない」ように見える未処理結果が残り、実行状態がクリアされない/影響行0扱いになる
2回目以降がエラーになるSQLSTATEが返る(ドライバー依存)「カーソルが開いたまま」「結果処理中」などの制約に引っかかる
メモリ/ハンドルが増える長時間運用で劣化結果を消化しないまま積み上がる

このタイプの問題は「結果を返さないはず」という思い込みが一番の落とし穴です。ODBCでは、ドライバーやSQLの書き方次第で、更新系SQLでも“結果が返る可能性がある”前提で実装しておくと堅牢になります。

手っ取り早い回避策:接続後にSET NOCOUNT ONを実行する

SQL Serverを使っている場合に特に効くのが、接続直後(または処理開始前)にSET NOCOUNT ONを実行する方法です。これにより、SQL Server側が「(n rows affected)」相当の行数メッセージを抑制し、余計な結果セットが発生しにくくなるため、ループ実行が安定することがあります。

ポイントは次の通りです。

  • SET NOCOUNT ON はセッション(接続)単位で有効。サーバー全体設定を変えるものではない
  • アプリで確実に制御できる(必要なら SET NOCOUNT OFF で戻せる)
  • トリガーやストアドプロシージャ経由でも行数メッセージが増えることがあるため、根本的に「余計な結果」を減らせる

ODBC側で一度だけ実行する例(概念)は次のようなイメージです。

/* 接続直後に一度だけ */
SQLHSTMT hInitStmt = SQL_NULL_HSTMT;
SQLAllocHandle(SQL_HANDLE_STMT, hDbc, &hInitStmt);
SQLExecDirect(hInitStmt, (SQLCHAR*)"SET NOCOUNT ON", SQL_NTS);
SQLFreeHandle(SQL_HANDLE_STMT, hInitStmt);

これで解決するケースは多い一方、SQL Server以外のDBや、トリガー等で別の結果セットが出る場合には、次の「正攻法」も合わせて入れておくと安心です。

正攻法:SQLExecuteの後にSQLMoreResultsで「最後まで回す」

ODBCとして一番堅牢なのは、SQLExecuteの後に返り得る結果(複数結果セット)を必ず消化する実装にすることです。更新系SQLでも「結果が返るかもしれない」前提で、SQLMoreResults を使って最後まで進めます。

結果を消化する基本パターン

更新系SQLでも、以下の流れをテンプレ化しておくと事故が減ります。

  1. SQLExecute を呼ぶ
  2. SQLNumResultCols で列数を確認(列がある=行が返る結果セットの可能性)
  3. 列がある場合は SQLFetch で最後まで読み捨て(内容が不要でも終端まで進める)
  4. SQLMoreResults で次の結果へ。SQL_NO_DATA が返るまで繰り返す
  5. 最後に SQLCloseCursor(または SQLFreeStmt(stmt, SQL_CLOSE))でカーソルを明示的に閉じる

読み捨て用のヘルパー関数としてまとめると、ループ処理がきれいになります。

/* 返り得る結果セットを最後まで消化する(内容は使わない前提) */
static SQLRETURN DrainAllResults(SQLHSTMT hStmt)
{
    SQLRETURN rc;
    SQLSMALLINT cols = 0;

    for (;;)
    {
        rc = SQLNumResultCols(hStmt, &cols);
        if (SQL_SUCCEEDED(rc) && cols > 0)
        {
            /* 行が返る結果セットなら最後まで読み捨てる */
            while ((rc = SQLFetch(hStmt)) == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO)
            {
                /* 何もしない(読み捨て) */
            }
        }

        /* 次の結果セットへ */
        rc = SQLMoreResults(hStmt);
        if (rc == SQL_NO_DATA)
        {
            break; /* これが正常終了 */
        }
        if (!(rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO))
        {
            break; /* エラー等 */
        }
    }

    /* 念のためカーソルを閉じる */
    SQLCloseCursor(hStmt);
    return rc;
}

この関数は「結果の中身を使わない」前提ですが、トリガーやストアドが意図せずSELECTを返してしまうような環境でも、ステートメントの状態をきれいにリセットできるため、次のSQLExecuteが安定します。

SQLFreeStmt(SQL_RESET_PARAMS)が効かない理由

SQLFreeStmt(stmt, SQL_RESET_PARAMS) は名前の通り、パラメータの関連付けを解除/リセットするための操作です。一方で、今回詰まっているのは「前回実行で返った結果が残っている」問題なので、リセットすべきはパラメータではなく結果処理(カーソル/複数結果)です。

目的別に使い分けると分かりやすくなります。

やりたいこと代表的なAPI補足
パラメータのバインドを解除したいSQLFreeStmt(SQL_RESET_PARAMS)バインド戦略を変えるときだけでOK。毎回は不要なことが多い
結果セット(カーソル)を閉じたいSQLCloseCursor / SQLFreeStmt(SQL_CLOSE)結果が残る/カーソルが開く系のトラブルに効く
複数結果セットを最後まで進めたいSQLMoreResults更新系でも「結果があるかも」で必ず回すと堅牢
実行中の処理を中断したいSQLCancelタイムアウトや強制中断が必要な場合に検討

ループでのSQLBindParameterは「毎回」より「一度だけ」が安定しやすい

質問概要では「ループの中でパラメータをSQLBindParameterで設定」とありますが、実装としてはパラメータのバインドは基本的に一度だけ行い、ループ内ではバッファの中身(値)を更新する方が安定しやすく、速度的にも有利です。

理由は単純で、毎回バインドすると次のリスクが増えるからです。

  • バインド対象ポインタの寿命(スタック変数を指してしまう等)を誤りやすい
  • 型指定(SQL_C_* / SQL_*)がループ途中でブレると予期せぬ変換が起きる
  • ドライバーが内部で再準備・再最適化に近い処理をしてしまい、性能が落ちることがある

おすすめの形は「Prepare → Bind(1回) → ループで値更新 → Execute → 結果消化」です。

/* 例:2つの入力パラメータを1回だけバインドして使い回す */
SQLINTEGER p1 = 0;
SQLCHAR    p2[256] = {0};
SQLLEN     p1_ind = 0;
SQLLEN     p2_ind = SQL_NTS;

/* Prepare済みのstmt2に対して一度だけバインド */
SQLBindParameter(stmt2, 1, SQL_PARAM_INPUT, SQL_C_LONG, SQL_INTEGER, 0, 0, &p1, 0, &p1_ind);
SQLBindParameter(stmt2, 2, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_VARCHAR, sizeof(p2), 0, p2, sizeof(p2), &p2_ind);

while (SQLFetch(stmt1) == SQL_SUCCESS)
{
    /* stmt1から値を取り出してp1/p2に詰める(例) */
    /* p1 = ...;  strcpy(p2, ...); */

    SQLRETURN rc = SQLExecute(stmt2);

    /* rcがSQL_NO_DATAでも「0行」の意味の可能性があるため、要件次第で許容 */
    if (!(rc == SQL_SUCCESS || rc == SQL_SUCCESS_WITH_INFO || rc == SQL_NO_DATA))
    {
        /* SQLGetDiagRecでログ→中断 */
        break;
    }

    /* 重要:毎回、返り得る結果を消化してステートメントをクリーンに戻す */
    DrainAllResults(stmt2);
}

この形にしておくと、「パラメータをリセットしていないからおかしいのでは?」という誤解も減り、問題の焦点を結果処理の抜けに絞り込めます。

同一接続で2本のステートメントを同時に使うときの注意点

今回の構成は「stmt1の結果をフェッチしながら、stmt2でINSERTを実行する」ため、DB/ドライバーによっては同一接続での同時実行に制約が出ることがあります。症状はSQL_NO_DATAだけでなく、別のエラーや待ち状態になる場合もあります。

もし結果消化(SQLMoreResults)を入れても安定しない場合は、構成自体を次のいずれかに寄せると解決が早いです。

方針やり方メリット注意点
フェッチを先に終わらせるstmt1の結果を一旦メモリ/一時テーブルに貯めてからstmt2を回す最も確実。接続/ドライバー制約の影響を受けにくい件数が多いとメモリや一時領域が必要
接続を分ける読み取り用と書き込み用で別コネクションを使うドライバーの「接続が忙しい」系制約を回避しやすいトランザクション整合性(分離レベル/タイミング)に注意
DBの多重結果機能を使う製品がサポートする場合のみ(例:SQL ServerならMARS設定など)構成はそのままで並行処理できる環境依存が強い。性能・ロック挙動も要確認

ただし、今回の「2回目以降がSQL_NO_DATA」問題に限れば、まずはstmt2側の結果消化とSET NOCOUNT ONで解消することが多いです。

SQL Serverで特に起きやすいケース:トリガー/ストアド/複合SQLが「余計な結果」を増やす

SQL Serverでは、テーブルにトリガーが付いていたり、アプリが呼び出しているSQLが複合的になっていたりすると、行数メッセージや追加の結果が増えやすくなります。次のような条件がある場合は、NOCOUNT+結果消化のセット導入が強くおすすめです。

  • INSERT対象テーブルにAFTER/INSTEAD OFトリガーがある
  • INSERTの前後で別テーブルを更新している(同一バッチに複数ステートメントがある)
  • ストアドプロシージャを実行している(内部で複数DML/SELECTが走る)
  • 監査ログや履歴テーブルへの書き込みが自動化されている

「アプリ側はINSERTだけのつもり」でも、DB側の仕組みで結果が増えるのはよくある話です。アプリ側で結果処理を堅牢にしておくと、DB側の変更(トリガー追加など)にも耐えやすくなります。

INSERT文の改善:列リストを明示して将来の変更に強くする

今回の問題とは別軸ですが、運用で効いてくる改善として、INSERTに列リストを書くことを強くおすすめします。

列リストなしの INSERT INTO tbl VALUES(...) は、将来の列追加・列順変更・NOT NULL列追加などで壊れやすく、障害が起きたときの影響も大きくなります。特にメタデータ取得(SQLTables)と組み合わせて自動投入する仕組みだと、テーブル定義変更は避けられません。

例えば次のように列を明示します。

INSERT INTO dbo.TargetTable (schema_name, table_name, created_at)
SELECT ?, ?, SYSDATETIME()
WHERE NOT EXISTS (
  SELECT 1
  FROM dbo.TargetTable
  WHERE schema_name = ? AND table_name = ?
);

この形なら、将来列が増えても「このINSERTが入れる列」が固定され、変更点が明確になります。なお、同じ値をWHERE側でも使う場合はパラメータ数が増えるので、バインドするパラメータの個数と順番をミスしないよう、SQLとバインドをセットで管理すると安全です。

実践的な切り分けチェックリスト(再発防止にも有効)

「2回目以降がSQL_NO_DATA」という現象は、複数要因が重なると分かりにくくなります。現場で役立つ確認ポイントを、上から順に潰せる形でまとめます。

確認ポイント見るべきもの対処
SQLExecuteのSQL_NO_DATAは0行の意味では?SQLRowCount の値、SQLの条件(NOT EXISTS等)0行が仕様なら「スキップ扱い」で正常系にする
前回実行の結果が残っていないか実行後にSQLMoreResultsを回しているか更新系でも必ず結果を消化(読み捨て)する
行数メッセージが原因では?SQL Server/トリガー/ストアド利用の有無SET NOCOUNT ONを接続直後に実行
同一接続の同時実行制約では?stmt1のフェッチ中にstmt2を実行しているか結果を先に貯める/接続を分ける/機能設定を見直す
本当にエラーが出ていないかSQLGetDiagRecでSQLSTATEとメッセージSQL_SUCCESS_WITH_INFOでもログを取り、警告を放置しない

診断情報を必ずログに出す(SQLGetDiagRecの最小テンプレ)

ODBCのトラブルは「返り値だけ見て判断」すると遠回りになりがちです。SQL_SUCCESS_WITH_INFO の警告や、ドライバーが返すSQLSTATEは、原因究明の近道になります。最低限、次のような関数でログを出せるようにしておくと、再発時の調査が楽になります。

static void LogOdbcDiag(SQLSMALLINT handleType, SQLHANDLE handle)
{
    SQLCHAR sqlState[6] = {0};
    SQLINTEGER nativeError = 0;
    SQLCHAR messageText[1024] = {0};
    SQLSMALLINT textLength = 0;

    for (SQLSMALLINT i = 1; ; i++)
    {
        SQLRETURN rc = SQLGetDiagRec(handleType, handle, i,
                                     sqlState, &nativeError,
                                     messageText, sizeof(messageText),
                                     &textLength);
        if (rc == SQL_NO_DATA)
        {
            break;
        }
        /* ここでログ出力(printfやファイル出力など) */
        /* sqlState / nativeError / messageText */
    }
}

「2回目以降がSQL_NO_DATA」=必ずしも失敗ではない一方で、周辺に警告が出ているケースもあります。警告を放置すると、環境差(ドライバー更新、DB設定、テーブル定義変更)で突然顕在化するので、ログで早めに潰せる体制にしておくのがおすすめです。

よくある質問

SET NOCOUNT ONを入れると副作用はある?

一般的には「行数メッセージが返らなくなる」だけなので、アプリがそのメッセージに依存していなければ問題になりにくいです。逆に、行数メッセージを前提にしたツールやSQLスクリプトがある場合は、接続単位でON/OFFを管理してください。ODBCアプリでは、影響行数はSQLRowCountで取得できるため、NOCOUNTに依存しない設計にできます。

SQLMoreResultsはSELECTのときにも必要?

単一SELECTだけなら不要なことが多いですが、複数SQLを1回で投げる、ストアドを呼ぶ、トリガーが絡む、というケースでは有効です。「結果が複数返る可能性がある呼び出し」には、SQLMoreResultsで最後まで進める癖を付けておくと安全です。

SQLCloseCursorだけではだめ?

単一の結果セットならSQLCloseCursorで十分なこともありますが、複数結果セットが返るケースでは、SQLMoreResultsで最後まで進めた上で閉じる方が確実です。特に「更新系で行数メッセージや追加結果が返る」環境では、SQLMoreResultsでの消化が本筋になります。

INSERT … WHERE NOT EXISTS の代わりにMERGEを使うべき?

要件次第ですが、MERGEは便利な一方でロックや同時実行、想定外の結果セット(ストアド化等)など、別の論点が増えます。まずは現行SQLを維持しつつ、NOCOUNT+結果消化でODBC側を堅牢化するのが現実的です。その上で、性能や競合が課題なら、DB側のUPSERT戦略(MERGE、ユニーク制約+例外処理等)を検討するとよいでしょう。

まとめ:2回目以降のSQL_NO_DATAは「結果処理の抜け」を疑い、NOCOUNTとSQLMoreResultsで固める

  • SQLExecuteのSQL_NO_DATAは、まず「0行(スキップ)」の可能性をSQLRowCountで確認する
  • 更新系SQLでも、ドライバー/DBによっては行数メッセージ等の追加結果が返ることがある
  • 確実な対策は、SET NOCOUNT ON(SQL Server)と、SQLMoreResultsで結果を最後まで消化する実装
  • ついでに、INSERTは列リストを明示して将来の変更に強くする

「1回目だけ動いて2回目から崩れる」系のODBCトラブルは、ハンドルやパラメータよりも結果セット処理の取りこぼしが原因になっていることが少なくありません。今回のテンプレ(NOCOUNT+SQLMoreResults)を入れておけば、DB側の変更にも強いループ実行になります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次