致命的な「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` オブジェクトでアトミックに実行する。この一見手間のかかる作法こそが、数年後のシステム運用において、君の信頼を守る唯一の盾となるのだ。
