Project VBAを掌握する極限の知見:SQL Serverとの同期における「真の堅牢性」を求めて
MS Projectを単なる「スケジューリング・ツール」として使っているうちは、まだ初級者だ。
真のプロジェクト管理システムを構築するエンジニアにとって、Projectは「データの一部を可視化するためのフロントエンド」に過ぎない。真のデータ(Single Source of Truth)は、SQL Serverなどのリレーショナルデータベース(RDB)に存在すべきなのだ。
しかし、多くの開発者がここで躓く。SQL Serverからデータを取得し、Projectのカスタムフィールドに流し込む。一見単純なタスクに見えるが、その実装には「パフォーマンスの罠」と「データの不整合」という、実務を破綻させる二大リスクが潜んでいる。
本稿では、単なる「データのコピペ」ではない。大規模プロジェクトでも耐えうる、高効率かつ堅牢な「SQL Server ↔ MS Project 同期エンジン」の設計思想と実装コードを伝授する。
1. 凡庸なエンジニアが陥る「N+1問題」の罠
まず、私がレビューで真っ先に突き返す、典型的な「悪い設計」を提示しよう。
【アンチパターン】タスクごとにSQLを発行する設計
' --- 決してやってはいけない設計 ---
For Each t In ActiveProject.Tasks
If Not t Is Nothing Then
' タスクごとにSQLを実行(これが致命的な遅延を生む)
sql = "SELECT Cost FROM ProjectData WHERE TaskID = " & t.ID
Set rs = conn.Execute(sql)
If Not rs.EOF Then
t.Text1 = rs!Cost ' カスタムフィールドへ書き込み
End If
End If
Next t
なぜこれが「ゴミ」なのか? 理由は明白だ。
「ネットワークI/Oのオーバーヘッド」だ。タスクが1,000個あれば、1,000回のSQL発行と1,000回のネットワーク往復が発生する。ProjectのCOMオブジェクト操作はそれ自体が重い。その上で、ループ内でDB接続を繰り返すなど、設計として極めて低レベルだ。
2. アーキテクチャの黄金律:メモリ内マッピング戦略
プロフェッショナルが採用すべき戦略は、「一括取得(Bulk Fetch)とメモリ内照合(In-Memory Mapping)」である。
設計の3原則
1. I/Oの最小化: SQL発行は、必要なデータを一括ですべて取得する1回(または数回)に限定する。
2. Dictionaryによる高速検索: 取得したデータは、VBAの `Scripting.Dictionary` を用いてメモリ上に展開する。これにより、タスクとの照合コストを $O(1)$ にまで抑え込む。
3. キーの厳格性: `TaskName`(タスク名)を結合キーにしてはならない。名前は重複し、変更される。必ず `UniqueID` をキーとして使用せよ。
3. プロダクション・コードの実装例
以下に、実務でそのまま耐えうる設計のコードを示す。このコードは、SQL Serverからデータを一括取得し、Projectの `UniqueID` をキーとしてカスタムフィールド(`Text1` を想定)へ高速にマッピングする。
Option Explicit
' ---------------------------------------------------------------------------
' Module: ProjectDataConnector
' Description: SQL Serverからデータを取得し、Projectのカスタムフィールドに
' 高速に同期するための高度なコネクタ。
' ---------------------------------------------------------------------------
' 外部参照設定:
' 1. Microsoft ActiveX Data Objects 6.1 Library
' 2. Microsoft Scripting Runtime
Private Const CONN_STR As String = "Provider=SQLNCLI11;Server=YOUR_SERVER;Database=YOUR_DB;Trusted_Connection=yes;"
''' <summary>
''' プロジェクトのカスタムフィールドをSQL Serverの情報で一括更新する
''' </summary>
Public Sub SyncCustomFieldsWithSQL()
Dim dictData As Scripting.Dictionary
Dim task As Task
Dim startTime As Double
On Error GoTo ErrorHandler
startTime = Timer
Debug.Print "Sync Started: " & Now
' 1. SQL Serverからデータをメモリ上にロード
' ここでは Dictionary(UniqueID -> Value) の形式で保持する
Set dictData = FetchDataFromSQL()
If dictData Is Nothing Then
MsgBox "データの取得に失敗しました。", vbCritical
Exit Sub
End If
' 2. Projectの計算モードを一時停止(パフォーマンス向上のため)
' ※ProjectにはExcelのようなCalculationモードの直接制御が限定的なため、
' 画面更新の停止と、必要に応じて再計算を制御する
Application.DisplayAlerts = False
' 3. メモリ内照合による高速書き込み
Dim updateCount As Long: updateCount = 0
' Projectのタスクを走査
For Each task In ActiveProject.Tasks
If Not task Is Nothing Then
' UniqueIDをキーとしてDictionaryを検索 (O(1)の計算量)
If dictData.Exists(task.UniqueID) Then
' カスタムフィールド(Text1)へ値を設定
' ここではSQLから取得した値を格納
task.Text1 = dictData(task.UniqueID)
updateCount = updateCount + 1
End If
End If
Next task
' 4. 完了通知
Application.DisplayAlerts = True
Debug.Print "Sync Completed: " & Now
Debug.Print "Updated Tasks: " & updateCount
Debug.Print "Execution Time: " & Format(Timer - startTime, "0.00") & " sec"
MsgBox "同期が完了しました。" & vbCrLf & _
"更新タスク数: " & updateCount & vbCrLf & _
"実行時間: " & Format(Timer - startTime, "0.00") & " 秒", vbInformation
Exit Sub
ErrorHandler:
Application.DisplayAlerts = True
MsgBox "致命的なエラーが発生しました。" & vbCrLf & _
"Error No: " & Err.Number & vbCrLf & _
"Description: " & Err.Description, vbCritical
End Sub
''' <summary>
''' SQL Serverからデータを一括取得し、Dictionaryに格納する
''' </summary>
''' <returns>Key: UniqueID (Long), Value: SyncValue (String)</returns>
Private Function FetchDataFromSQL() As Scripting.Dictionary
Dim conn As ADODB.Connection
Dim rs As ADODB.Recordset
Dim dict As Scripting.Dictionary
Dim sql As String
Set dict = New Scripting.Dictionary
Set conn = New ADODB.Connection
' SQL文: UniqueIDと同期したい値を一括で取得
' プロジェクトの「UniqueID」はDB側でも保持しておくことが運用の鉄則
sql = "SELECT TaskUniqueID, TargetValue FROM ProjectSyncTable"
On Error GoTo SQL_Error
conn.Open CONN_STR
Set rs = conn.Execute(sql)
' Recordsetを回してDictionaryに詰め込む
' これにより、ネットワークI/Oはここで完了する
Do While Not rs.EOF
' UniqueIDをキー、値を値として格納
' Dictionaryのキーは重複を許さないため、一意性が保証される
dict.Add CLng(rs!TaskUniqueID), CStr(rs!TargetValue)
rs.MoveNext
Loop
Set FetchDataFromSQL = dict
' クリーンアップ
rs.Close
conn.Close
Set rs = Nothing
Set conn = Nothing
Exit Function
SQL_Error:
Debug.Print "SQL Error: " & Err.Description
If Not rs Is Nothing Then If rs.State = adStateOpen Then rs.Close
If Not conn Is Nothing Then If conn.State = adStateOpen Then conn.Close
Set FetchDataFromSQL = Nothing
End Function
4. 設計の肝:なぜこのコードは「堅牢」なのか
このコードを単なる「動くコード」として捉えてはいけない。以下の設計判断が、プロダクション環境での安定性を担保している。
① `UniqueID` による不変性の確保
`Task.ID` は、タスクを削除したり並べ替えたりすると変わる可能性がある。一方、`UniqueID` はプロジェクトのライフサイクルを通じて不変だ。外部DBと連携する際の結合キー(Join Key)には、必ず `UniqueID` を選べ。これは設計における「絶対的な掟」だ。
② 空間計算量と時間計算量のトレードオフ
本実装では、`Scripting.Dictionary` を使用して、DBのデータをメモリ上に展開している。
- 時間計算量: タスク数 $N$ に対して、DB接続は $O(1)$、ループは $O(N)$。合計 $O(N)$。
- 空間計算量: メモリ上に $O(N)$ のデータ保持が必要。
数万件のタスクでない限り、現代のPCにおいてメモリ消費は無視できるほど小さく、ネットワークI/Oを削減するメリットが圧倒的に上回る。
③ エラーハンドリングの階層化
`SyncCustomFieldsWithSQL`(ビジネスロジック層)と `FetchDataFromSQL`(データアクセス層)を分離している。これにより、DB接続エラーが発生した際に、Projectのデータ操作(書き込み)に悪影響を及ぼす前に処理を安全に中断できる。
5. 次のステップへの助言
もし君がさらに上のレベルを目指すなら、以下の概念を検討せよ。
- 差分更新(Delta Update): 全件更新ではなく、`LastModified` カラムを利用して、前回実行時以降に変更されたデータのみを取得する。
- トランザクションの擬似実装: Project VBAにはDBのような強力なロールバック機能はない。更新前に現在の値をバックアップする、あるいは「更新中フラグ」をカスタムフィールドに持たせるなどの工夫が必要だ。
エンジニアリングとは、単に動くものを作ることではない。「変化に強く、予測可能なもの」を作ることだ。この設計思想を、君のプロジェクトに組み込んでほしい。
