はじめに
プリザンターの編集画面では「変更履歴の一覧」タブから任意のバージョンを選んで1件ずつ復元できます。しかし、インポートミスや一括更新の失敗で複数レコードを戻したいときに、手作業で1件ずつ復元するのは現実的ではありません。
今回は、拡張スクリプト・拡張サーバスクリプト・拡張SQLを組み合わせて、一覧画面で選択した複数レコードを一括復元する機能を作ってみます。
仕様は次のとおりです。
- 復元先バージョンの入力は不要
- 各レコードごとに「1つ前の履歴バージョン」を自動で特定して復元
- 復元対象バージョン(
MAX(Ver) - 1)の取得は$p.selectedIds()で選択したIDだけを対象に実行
バージョン 1.4.0.0 以降を対象にしています。
通常の復元ロジックを確認する
実装の前に、プリザンター本体の「通常の復元」が内部でどのように動作しているかを確認しておきます。
編集画面の変更履歴タブで「復元」ボタンをクリックすると、RestoreFromHistory アクションが呼び出されます。
処理の流れは次のとおりです。
重要なのは次の2点です。
- リクエストボディに
GridCheckedItems={ver}というフォームデータでバージョン番号を渡す -
VerUp = trueで更新するため、復元前の現在値も新たな履歴バージョンとして自動保存される
つまり RestoreFromHistory は「現在状態を履歴に残しつつ、過去バージョンで上書き」する操作です。
処理の全体像
今回の実装では、まず $p.selectedIds() で選択IDだけを抽出し、そのID群をパラメータにして拡張SQL(Api: true)を API 経由で実行します。そこで取得した RestoreVer(MAX(Ver) - 1)を使って RestoreFromHistory を順に呼び出します。
前提条件
History.json
RestoreFromHistory を有効にするために、App_Data/Parameters/History.json の Restore を true に設定します。
{
"Restore": true,
"PhysicalDelete": true
}
Restore が false の場合、RestoreFromHistory が InvalidRequest エラーを返します。
パラメータファイルの変更後はプリザンターの再起動が必要です。パラメータ再読み込み機能を使う場合は、特権ユーザでログインして実行してください。
テーブルの履歴復元設定
テーブルのプロパティ(管理 → テーブルの管理)で「履歴の復元」が無効になっている場合は、AllowRestoreHistories が false になり RestoreFromHistory が動作しません。デフォルトは有効(true)です。
実装してみよう
5ファイルで構成します。
| ファイル | 種別 | 役割 |
|---|---|---|
BulkRestoreHistory.js |
拡張スクリプト | 選択ID抽出・API呼び出し・復元処理 |
BulkRestoreHistoryButton.json |
拡張サーバスクリプト | ボタン追加(設定) |
BulkRestoreHistoryButton.json.js |
拡張サーバスクリプト | ボタン追加(本体) |
BulkRestoreHistoryLatestByIds.json |
拡張SQL | API公開設定 |
BulkRestoreHistoryLatestByIds.json.sql |
拡張SQL | 選択IDに限定した復元対象版取得SQL(MAX(Ver) - 1) |
拡張SQL(Api: true)
App_Data/Parameters/ExtendedSqls/ に配置します。
{
"Name": "BulkRestoreHistoryLatestByIds",
"Api": true
}
CommandText は同名の .json.sql に分離します。.json.sql が存在する場合、起動時に読み込まれて CommandText にセットされます。
SELECT IssueId AS ReferenceId, MAX(Ver) - 1 AS RestoreVer
FROM Issues_history
WHERE SiteId = @SiteId
AND IssueId IN (
SELECT TRY_CAST([value] AS bigint)
FROM STRING_SPLIT(@SelectedIds, ',')
WHERE TRY_CAST([value] AS bigint) IS NOT NULL
)
GROUP BY IssueId
HAVING MAX(Ver) - 1 > 0
UNION ALL
SELECT ResultId AS ReferenceId, MAX(Ver) - 1 AS RestoreVer
FROM Results_history
WHERE SiteId = @SiteId
AND ResultId IN (
SELECT TRY_CAST([value] AS bigint)
FROM STRING_SPLIT(@SelectedIds, ',')
WHERE TRY_CAST([value] AS bigint) IS NOT NULL
)
GROUP BY ResultId
HAVING MAX(Ver) - 1 > 0
ポイントは次のとおりです。
-
SiteId = @SiteIdで対象サイトを固定 -
@SelectedIds(CSV文字列)をSTRING_SPLITして選択IDだけに限定 -
ReferenceIdに統一してIssues/Resultsを同じマップで扱う
拡張SQLは動的な IN 句組み立てが苦手なため、STRING_SPLIT(@SelectedIds, ',') で回避しています。
拡張スクリプト
App_Data/Parameters/ExtendedScripts/ に配置します。
// 一括復元スクリプト
// プレフィックス brh_ (BulkRestoreHistory) で名前空間を分離
function brh_getLatestMapBySelectedIds(ids) {
var deferred = $.Deferred();
var siteId = Number($p.SiteId || $('#SiteId').val() || 0);
var selectedIdsCsv = ids.join(',');
$p.apiExec('/api/extended/sql', {
data: {
Name: 'BulkRestoreHistoryLatestByIds',
Params: {
SelectedIds: selectedIdsCsv,
SiteId: siteId
}
}
}).done(function(res) {
var map = {};
var rows = ((((res || {}).Response || {}).Data || {}).Table) || [];
for (var i = 0; i < rows.length; i++) {
var id = String(rows[i].ReferenceId || '').trim();
var ver = parseInt(rows[i].RestoreVer, 10);
if (id !== '' && ver > 0) map[id] = ver;
}
deferred.resolve(map);
}).fail(function(xhr) {
deferred.reject('復元対象版の取得に失敗しました(HTTP ' + xhr.status + ')');
});
return deferred.promise();
}
function brh_showDialog(ids, latestMap) {
var available = ids.filter(function(id) {
var key = String(id || '').trim();
return Object.prototype.hasOwnProperty.call(latestMap, key);
}).length;
var missing = ids.length - available;
var $dialog = $('<div title="一括復元"></div>');
$dialog.append(
'<p style="margin:0 0 12px">'
+ '選択した <strong>' + ids.length + ' 件</strong> のレコードを1つ前の履歴で一括復元します。'
+ '</p>'
+ '<p style="margin:0 0 8px;font-size:0.9em;color:#666">'
+ '復元前の現在値は新しいバージョンとして履歴に自動保存されます。'
+ '</p>'
+ '<p style="margin:0;font-size:0.9em;color:#666">'
+ '復元可能: ' + available + ' 件 / 履歴なし: ' + missing + ' 件'
+ '</p>'
);
$dialog.dialog({
modal: true,
width: 440,
open: function() {
$(this).css('margin-bottom', 0);
$(this).next('.ui-dialog-buttonpane').css('margin-top', 0);
},
buttons: {
'復元を実行': function() {
$(this).dialog('close');
$(this).remove();
brh_restoreAll(ids, latestMap);
},
'キャンセル': function() {
$(this).dialog('close');
$(this).remove();
}
}
});
}
function brh_isRestoreSuccess(res) {
var arr = Array.isArray(res) ? res : (res ? [res] : []);
return arr.some(function(r) { return r.Method === 'Href'; });
}
function brh_getErrorText(res) {
var arr = Array.isArray(res) ? res : (res ? [res] : []);
for (var i = 0; i < arr.length; i++) {
if (arr[i].Method === 'Message') {
try {
var msg = typeof arr[i].Value === 'string'
? JSON.parse(arr[i].Value) : arr[i].Value;
if (msg && msg.Css === 'alert-error') return msg.Text;
} catch (e) { /* ignore */ }
}
}
return null;
}
function brh_restoreAll(ids, latestMap) {
var total = ids.length;
var successes = 0;
var failedIds = [];
var index = 0;
var csrfToken = $('meta[name="csrf-token"]').attr('content') || '';
var requestToken = $('#Token').val() || '';
var headers = {};
if (csrfToken) {
headers['X-CSRF-TOKEN'] = csrfToken;
}
$p.clearMessage();
$p.setMessage('#Message', JSON.stringify({
Css: 'alert-information',
Text: '復元処理中... 0 / ' + total + ' 件'
}));
function restoreNext() {
if (index >= total) {
brh_showResult(successes, failedIds);
return;
}
var id = ids[index++];
var key = String(id || '').trim();
var hasHistory = Object.prototype.hasOwnProperty.call(latestMap, key);
var ver = latestMap[key];
if (!hasHistory) {
failedIds.push({ id: id, msg: '履歴が存在しません' });
$p.clearMessage();
$p.setMessage('#Message', JSON.stringify({
Css: 'alert-information',
Text: '復元処理中... ' + index + ' / ' + total + ' 件'
}));
restoreNext();
return;
}
var postData = { GridCheckedItems: ver.toString() };
if (requestToken) {
postData.Token = requestToken;
}
$.ajax({
url: '/items/' + id + '/restoreFromHistory',
method: 'POST',
data: postData,
headers: headers
}).done(function(res) {
var json = typeof res === 'string' ? JSON.parse(res) : res;
if (brh_isRestoreSuccess(json)) {
successes++;
} else {
var errText = brh_getErrorText(json) || '不明なエラー';
failedIds.push({ id: id, msg: errText });
}
$p.clearMessage();
$p.setMessage('#Message', JSON.stringify({
Css: 'alert-information',
Text: '復元処理中... ' + index + ' / ' + total + ' 件'
}));
restoreNext();
}).fail(function(xhr) {
failedIds.push({ id: id, msg: 'HTTP ' + xhr.status });
restoreNext();
});
}
restoreNext();
}
function brh_showResult(successes, failedIds) {
var msg;
if (failedIds.length === 0) {
msg = { Css: 'alert-success', Text: successes + ' 件のレコードを復元しました' };
} else {
var failSummary = failedIds.map(function(f) { return 'ID ' + f.id + '(' + f.msg + ')'; }).join(', ');
msg = {
Css: 'alert-warning',
Text: successes + ' 件を復元しました(失敗 ' + failedIds.length + ' 件: ' + failSummary + ')'
};
}
$p.clearMessage();
$p.setMessage('#Message', JSON.stringify(msg));
$p.send($('#ViewFilters\\,Search'));
}
function brh_bulkRestoreHistory() {
var ids = $p.selectedIds();
if (!ids || ids.length === 0) {
$p.clearMessage();
$p.setMessage('#Message', JSON.stringify({
Css: 'alert-warning',
Text: 'レコードが選択されていません'
}));
return;
}
$p.clearMessage();
$p.setMessage('#Message', JSON.stringify({
Css: 'alert-information',
Text: '復元対象版を取得中...'
}));
brh_getLatestMapBySelectedIds(ids)
.done(function(latestMap) {
brh_showDialog(ids, latestMap);
})
.fail(function(errMsg) {
$p.setMessage('#Message', JSON.stringify({
Css: 'alert-error',
Text: errMsg
}));
});
}
拡張サーバスクリプト(ボタン追加)
App_Data/Parameters/ExtendedServerScripts/ に以下の2ファイルを配置します。
{
"BeforeOpeningPage": true,
"Actions": ["index"],
"TryCatch": true,
"Body": "-- loaded from .json.js"
}
Actions を ["index"] に限定することで一覧画面にのみボタンが表示されます。SiteIdList を省略しているためすべてのサイトが対象ですが、特定テーブルに限定したい場合は "SiteIdList": [12345] を追加してください。
context.AddResponse(
'Append',
'#MainCommands:has(#BulkDeleteCommand)',
'<button id="BulkRestoreHistoryCommand" class="button button-icon" type="button"'
+ ' onclick="brh_bulkRestoreHistory()">'
+ '<span class="ui-icon ui-icon-arrowreturnthick-1-n"></span>'
+ '<span>一括復元</span>'
+ '</button>'
);
一括削除ボタン(#BulkDeleteCommand)の存在を :has() で確認し、削除権限がないユーザには「一括復元」ボタンも非表示にします。更新権限で制御したい場合は #BulkDeleteCommand を #OpenBulkUpdateSelectorDialogCommand(一括更新ボタン)に変えてください。
各処理のポイント
未選択時の警告表示
何もチェックせずにボタンをクリックした場合、$p.setMessage で警告メッセージを表示して処理を終了します。
if (!ids || ids.length === 0) {
$p.setMessage('#Message', JSON.stringify({
Css: 'alert-warning',
Text: 'レコードが選択されていません'
}));
return;
}
復元 API は呼び出されません。
選択IDだけを対象に復元対象版を取得する
$p.selectedIds() で選択IDを取得し、CSV化して拡張SQL APIへ渡します。$p.apiExec を使うことで、#Token がある場合は自動でリクエストに付与されます。
$p.apiExec('/api/extended/sql', {
data: {
Name: 'BulkRestoreHistoryLatestByIds',
Params: {
SelectedIds: ids.join(','),
SiteId: Number($p.SiteId || $('#SiteId').val() || 0)
}
}
})
この API は Controllers/Api/ExtendedController.cs から ExtensionUtilities.Sql を経由して拡張SQL(Api: true)を実行します。
SiteId と TenantId で絞り込む
SQL側で次の2条件を必ず付けます。
TenantId = @_TSiteId = @SiteId
これにより、同じID値が他テナントや他サイトに存在していても誤って混ざることを防げます。
RestoreFromHistory エンドポイントを直接呼び出す
各レコードに対して POST /items/{id}/restoreFromHistory を呼び出しています。
$.ajax({
url: '/items/' + id + '/restoreFromHistory',
method: 'POST',
data: {
GridCheckedItems: restoreVer.toString(),
Token: $('#Token').val() || ''
},
headers: $('meta[name="csrf-token"]').length
? { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') }
: {}
})
GridCheckedItems に渡す値はレコードIDではなくバージョン番号です。本実装では、選択IDだけを対象に取得した RestoreVer(MAX(Ver) - 1)から自動設定しています。
CSRF トークンの取得
<meta name="csrf-token"> はフォーム機能の画面でのみ出力されるため、一覧画面では取得できない場合があります。/api/extended/sql 側は $p.apiExec を使うと Token(#Token)が自動付与されます。restoreFromHistory は /api ではないため、サンプルでは Token を明示的に送信し、csrf-token がある画面では X-CSRF-TOKEN ヘッダーも追加しています。
data: {
GridCheckedItems: restoreVer.toString(),
Token: $('#Token').val() || ''
},
headers: $('meta[name="csrf-token"]').length
? { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') }
: {}
レスポンスの成功・エラー判定
RestoreFromHistory は成功・失敗いずれの場合も HTTP 200 を返します。成功時は ResponseCollection に Method: "Href" が含まれ、失敗時は Method: "Message" に Css: "alert-error" が含まれます。brh_isRestoreSuccess 関数で判定しています。
function brh_isRestoreSuccess(res) {
var arr = Array.isArray(res) ? res : (res ? [res] : []);
return arr.some(function(r) { return r.Method === 'Href'; });
}
連続API呼び出しの直列処理
restoreNext 関数を再帰的に呼び出すことで、1件ずつ順番に処理しています。並列実行も可能ですが、プリザンターサーバへの同時接続数を抑えるために直列処理を選択しています。これは添付ファイル一括削除の記事でも使用したパターンです。
ボタンの表示制御
'#MainCommands:has(#BulkDeleteCommand)'
CSSの :has() 擬似クラスを使い、#BulkDeleteCommand(一括削除ボタン)が存在する場合のみ「一括復元」ボタンを追加します。一括削除ボタンはユーザーに削除権限がある場合のみ描画されるため、間接的な権限チェックとして機能します。
まとめ
拡張スクリプト・拡張サーバスクリプト・拡張SQL(Api: true)を組み合わせることで、一覧画面に「一括復元」ボタンを追加しました。
-
$p.selectedIds()で選択レコードIDだけを取得する - 選択ID群をパラメータに
/api/extended/sqlを呼び、ReferenceId・RestoreVer(MAX(Ver) - 1)を取得する - SQL側で
SiteId = @SiteIdを必須条件にする - 復元先バージョンの入力をなくし、各レコードを直近履歴で自動復元する
- 各レコードに対して
POST /items/{id}/restoreFromHistoryをGridCheckedItems={restoreVer}とX-CSRF-TOKENヘッダー付きで呼び出す - 通常の復元ロジック(VerUp・添付ファイル復元・通知・AfterUpdateトリガー)をそのまま踏襲できる
- レスポンスの
Method: "Href"の有無で成功・失敗を判定し、失敗レコードのIDとエラー内容を報告する - 拡張サーバスクリプトで
#BulkDeleteCommandの有無を:has()で確認し、削除権限のあるユーザーにのみボタンを表示する -
CommandTextは.json.sqlに分離することで、フォーマットしやすく可読性が上がる
インポートや一括更新の失敗時に、複数レコードをまとめて直近履歴へ戻したいシーンで活用してみてください。