【実務・中級編】ADOとSQL Serverの「IDENTITY列」取得:新規レコード追加直後のIDを確実に取得する実装パターン – Access VBA解析バイブル

スポンサーリンク
スポンサーリンク

致命的な「MAX(ID)」からの脱却:SQL Serverとの対話における IDENTITY 取得の真髄

業務アプリケーションの開発において、親レコードを挿入した直後にその生成された ID(IDENTITY列)を取得し、続けて子レコードを投入する――これは日常茶飯事のシナリオだ。

しかし、多くの開発者がここで「致命的な設計ミス」を犯している。もし君が、`SELECT MAX(ID) FROM Table` や、あるいは安易に `@@IDENTITY` を使って ID を取得しようとしているなら、今すぐその手を止めてほしい。それは、マルチユーザー環境における「爆弾」を自ら仕込んでいるに等しい。

本稿では、Access VBAからSQL Serverを操作するプロフェッショナルとして、いかにしてアトミック(不可分)かつ安全に新規レコードの ID を奪取するか、その極限の実装パターンを伝授する。

1. なぜ君の ID 取得ロジックは壊れるのか

まず、設計思想の欠如が招く2つの破滅的なシナリオを理解せよ。

シナリオA:`SELECT MAX(ID)` の罠(競合状態)

最も初歩的で、最も危険な方法だ。
1. ユーザーAがレコードを挿入する。
2. (コンマ数秒の隙に) ユーザーBもレコードを挿入する。
3. ユーザーAが `SELECT MAX(ID)` を実行する。
4. ユーザーAは、自分のものではないユーザーBのIDを取得してしまう。

これは「レースコンディション(競合状態)」と呼ばれる。並列実行される実務環境において、この手法はシステムの整合性を根底から破壊する。

シナリオB:`@@IDENTITY` の罠(スコープの汚染)

次に、SQL Serverのグローバル変数 `@@IDENTITY` を使う者がいる。これは「セッション内で最後に生成された IDENTITY」を返す。一見正しく見えるが、もし対象のテーブルに Trigger(トリガー) が設定されていたらどうなるか?

トリガーが内部で別のログ用テーブルにレコードを挿入した瞬間、`@@IDENTITY` は「ログ用テーブルの新しいID」を返してしまう。君が欲しかったのは親テーブルのIDだったはずだ。

2. 解決策:`SCOPE_IDENTITY()` と ADO Command の融合

我々が採用すべき正解は、`SCOPE_IDENTITY()` である。これは「現在のスコープ(現在のバッチ、またはストアドプロシージャ)内で生成された最新の IDENTITY」のみを返す。トリガーによる影響を受けない、極めて堅牢な関数だ。

さらに、これを Access VBA から呼び出す際、`Recordset.AddNew` を使うのはスマートではない。なぜなら、`AddNew` はクライアント側の抽象化レイヤーであり、サーバー側の詳細な制御(アトミックな実行)を疎かにしがちだからだ。

真のアーキテクトは、`ADODB.Command` オブジェクト を使い、`INSERT` 文と `SELECT SCOPE_IDENTITY()` を一つの SQL バッチとしてサーバーへ叩き込む。これにより、ネットワークの往復(ラウンドトリップ)を最小限に抑え、かつ確実に ID を回収できる。

3. プロダクション・コード:堅牢なる ID 取得の実装

以下に、実務でそのまま投入可能な、パラメータ化クエリを用いた実装パターンを示す。SQLインジェクションを防ぎ、型安全性を確保した設計だ。

'---------------------------------------------------------------------------------------
' Module: modDatabaseAccess
' Description: SQL Serverとの通信における、高信頼性ID取得ロジック
' References: Microsoft ActiveX Data Objects 6.1 Library
'---------------------------------------------------------------------------------------
Option Compare Database
Option Explicit

''' <summary>
''' 新規レコードを挿入し、生成されたIDENTITY列の値を返す。
''' </summary>
''' <param name="conn">既存のADODB.Connectionオブジェクト</param>
''' <param name="sqlInsert">INSERT文(パラメータを使用すること)</param>
''' <param name="params">Commandオブジェクトに付与するパラメータのコレクション</param>
''' <returns>生成されたID (Long)</returns>
Public Function InsertAndGetNewID(ByRef conn As ADODB.Connection, _
                                  ByVal sqlInsert As String, _
                                  ByRef params As Collection) As Long
    
    Dim cmd As ADODB.Command
    Dim rs As ADODB.Recordset
    Dim paramItem As Variant
    
    On Error GoTo ErrorHandler

    ' 1. Commandオブジェクトの初期化
    Set cmd = New ADODB.Command
    With cmd
        .ActiveConnection = conn
        .CommandText = sqlInsert & "; SELECT SCOPE_IDENTITY() AS NewID;"
        .CommandType = adCmdText
        ' SET NOCOUNT ON をSQLの先頭に含めるか、ここで制御することで、
        ' 「○行処理されました」というメッセージがRecordsetの返却を阻害するのを防ぐ
    End With

    ' 2. パラメータのバインド(SQLインジェクション対策)
    ' paramsコレクションには、Array(Name, Type, Value) の形式で格納されていることを想定
    For Each paramItem In params
        ' paramItem: Array(Name, Type, Value)
        ' 例: Array("@UserName", adVarChar, "田中太郎")
        cmd.Parameters.Append cmd.CreateParameter( _
            paramItem(0), _
            paramItem(1), _
            adParamInput, _
            IIf(paramItem(1) = adVarChar, 50, 0), _
            paramItem(2))
    Next paramItem

    ' 3. 実行と結果の回収
    ' SCOPE_IDENTITY() の結果を Recordset として受け取る
    Set rs = cmd.Execute

    If Not rs.EOF Then
        InsertAndGetNewID = rs.Fields("NewID").Value
    Else
        Err.Raise vbObjectError + 513, "modDatabaseAccess", "IDの取得に失敗しました。"
    End If

CleanUp:
    If Not rs Is Nothing Then
        If rs.State = adStateOpen Then rs.Close
        Set rs = Nothing
    End If
    Set cmd = Nothing
    Exit Function

ErrorHandler:
    ' 実務ではここにログ出力処理を挟む
    Debug.Print "Error in InsertAndGetNewID: " & Err.Description
    Err.Raise Err.Number, "modDatabaseAccess", Err.Description
    Resume CleanUp
End Function

''' <summary>
''' 実装テスト用メインルーチン
''' </summary>
Public Sub Test_InsertPattern()
    Dim conn As ADODB.Connection
    Dim params As Collection
    Dim newID As Long
    Dim sql As String
    
    ' 接続文字列(環境に合わせて書き換え)
    Const CONN_STR As String = "Provider=SQLOLEDB;Data Source=SERVER_NAME;Initial Catalog=DB_NAME;User ID=sa;Password=password;"

    Set conn = New ADODB.Connection
    conn.Open CONN_STR

    ' 挿入するSQL(パラメータ名を使用)
    sql = "INSERT INTO M_User (UserName, Email) VALUES (@Name, @Email)"

    ' パラメータのセットアップ
    Set params = New Collection
    params.Add Array("@Name", adVarChar, "Legendary Architect")
    params.Add Array("@Email", adVarChar, "architect@example.com")

    ' 実行
    newID = InsertAndGetNewID(conn, sql, params)

    MsgBox "成功!生成されたIDは: " & newID, vbInformation

    conn.Close
    Set conn = Nothing
End Sub

4. アーキテクトの視点:設計における重要事項

このコードを使いこなすために、以下の3点を脳に刻んでおけ。

① `SET NOCOUNT ON` の重要性

SQL Serverは通常、`INSERT` 文の後に「(1 row affected)」というメッセージをクライアントに返す。ADO経由で複数のステートメント(INSERT + SELECT)を実行する場合、このメッセージが「空のレコードセット」として先に届いてしまい、肝心の `SCOPE_IDENTITY()` が取得できないという現象が頻発する。
SQL文の先頭に `SET NOCOUNT ON;` を付与するか、上記のように `Command` オブジェクトで制御することで、この問題を回避できる。

② トランザクションの境界線

もし、親レコードの挿入と、その後の子レコードの挿入を一つの業務単位として扱うなら、必ず `conn.BeginTrans` から `conn.CommitTrans` で囲め。
`InsertAndGetNewID` 自体は単一の SQL バッチとしてアトミックだが、その後の処理でエラーが起きた場合に親レコードだけが残ってしまうのは、データの整合性における敗北だ。

③ パラメータ化クエリは「マナー」ではなく「義務」

`sql = “INSERT INTO … VALUES (‘” & userName & “‘)”` のような文字列結合は、今すぐ捨てろ。
SQLインジェクションは、外部からシステムを破壊するための最も古典的かつ強力な武器だ。`ADODB.Command.Parameters` を使うことは、プロフェッショナルとしての最低限の規律である。

結論

IDの取得は、単なる「値の回収」ではない。それは「データベースの整合性を保証するための儀式」である。

`SCOPE_IDENTITY()` を使い、`Command` オブジェクトでアトミックに実行する。この一見手間のかかる作法こそが、数年後のシステム運用において、君の信頼を守る唯一の盾となるのだ。

タイトルとURLをコピーしました