В стандартных хранимых классах Caché при модификации записи прежние значения свойств исчезают безвозвратно. Но бывают случаи, когда это нежелательно, когда «все ходы должны быть записаны». В первую очередь, конечно, такое требование возникает при разработке приложений для материально ответственных лиц, для которых критична возможность, например, отменить ошибочное действие и восстановить состояние документа на заданное время, или, что ещё важнее, провести расследование инцидента с попыткой злоумышленника «замести следы» в базе.
В этой статье демонстрируется, как реализовать хранение и восстановление версий для объектов Caché.
Данный функционал можно добавить к Persistent-классам с помощью метода %OnBeforeSave() и SQL-триггера на событие Update. Поскольку в подавляющем большинстве случаев использования Persistent-классов вся запись, в том числе значения свойств — массивов и списков, хранится в узле глобала хранения вида ^ClassData(id), перед перезаписью объекта есть возможность сохранить предыдущую версию записи в каком-нибудь укромном месте — наподобие «корзины» в Windows.
К данной статье прилагается пример использования вышеописанной возможности, состоящий из абстрактного класса PersHist — наследника %Persistent, к которому добавлены необходимые методы, и демонстрационного класса TestPersHist, в котором нет ровно ничего необычного, кроме того, что он — наследник PersHist, а не напрямую %Persistent. Ровно так же в нём можно создавать и модифицировать записи, как объектным, так и SQL-доступом, но перезаписанные данные там не исчезают бесследно и могут быть восстановлены.
/// contains triggers to enable updates history recording.
Class PersHist Extends %Persistent [ Abstract, ClassType = "", ProcedureBlock ]
{ /// This callback method is invoked by the <METHOD>%Save</METHOD> method to
/// provide notification that the object is being saved. It is called before
/// any data is written to disk.
///
/// <P><VAR>insert</VAR> will be set to 1 if this object is being saved for the first time.
///
/// <P>If this method returns an error then the call to <METHOD>%Save</METHOD> will fail.
Method %OnBeforeSave(insert As %Boolean) As %Status [ Final, Private ]
{
q:insert $$$OK
q:'..%ObjectModified() $$$OK
q ..SaveLastRevision(..%Id())
} /// This callback method is invoked by the <METHOD>%Delete</METHOD> method to
/// provide notification that the object specified by <VAR>oid</VAR> is being deleted.
///
/// <P>If this method returns an error then the object will not be deleted.
ClassMethod %OnDelete(oid As %ObjectIdentity) As %Status [ Final, Private ]
{
s id=$lg(oid) q:'id 0
d ..SaveLastRevision(id)
q $$$OK
} ClassMethod GetDataLocation() As %String
{
s ix=##Class(%ClassDefinition).%OpenId(..%ClassName())
q:ix.Storages.Count()'=1 "" ; sofisticated storages not supported
q ix.Storages.GetAt(1).DataLocation
} /// Returns next or previuos (similar to $o()) id of saved revision
Method OrderIdSave(ids As %String = "", Direction As %String = 1) As %String
{
s dl=..GetDataLocation() q:dl="" ""
s id=..%Id() q:'id ""
q $o(@dl@("History",id,ids),Direction)
} /// Returns timestamp of saved revision
Method GetTimeStampByIdSave(ids As %String) As %String
{
s dl=..GetDataLocation() q:dl="" ""
s id=..%Id() q:'id ""
q:$d(@dl@("History",id,ids))<10 ""
q $o(@dl@("History",id,ids,""))
} /// Makes revision saved at or after specified time actual again
/// and load it. All unsaved changes will be lost.
Method MakeActual(timestamp As %StringTimeStamp) As %Status
{
s dl=..GetDataLocation() q:dl="" 0 ; unsupported storage
s id=..%Id() q:'id 0 ; never saved
s zts=timestamp
; searching saved revision with zts>=timestamp
i $d(@dl@("History","ZI",id,zts)) s ids=$o(@dl@("History","ZI",id,zts,""),-1)
e s zts=$o(@dl@("History","ZI",id,"")) q:zts="" 0 s ids=$o(@dl@("History","ZI",id,zts,""))
q $s(ids:..MakeActualByIdSave(ids),1:0)
} /// Makes saved revision actual again and load it by idsave.
/// All unsaved changes will be lost.
Method MakeActualByIdSave(ids As %String) As %Status
{
s dl=..GetDataLocation() q:dl="" 0 ; unsupported storage
s id=..%Id() q:'id 0 ; never saved
q:$d(@dl@("History",id,ids))<10 0
s zts=$o(@dl@("History",id,ids,"")) q:zts="" 0
d ..SaveLastRevision(id)
k @dl@(id) m @dl@(id)=@dl@("History",id,ids,zts)
d ..%Reload() d ..%SetModified(1)
q ..%Save() ; save again to make indices correct
} /// returns next or previous (similar to $o()) id of deleted record
ClassMethod OrderDeletedId(id As %String = "", Direction As %String = 1) As %String [ Final ]
{
s dl=..GetDataLocation() q:dl="" 0
f s id=$o(@dl@("History",id),Direction) q:id="" q:'$d(@dl@(id))
q id
} ClassMethod SaveLastRevision(id) As %Status [ Final ]
{
s id=$g(id) q:'id $$$OK ; no previous revision to save
s dl=..GetDataLocation() q:dl="" $$$OK
s ids=$i(@dl@("History",id)),zts=$zu(188) ; $ZTIMESTAMP in local timezone
m @dl@("History",id,ids,zts)=@dl@(id)
s @dl@("History","ZI",id,zts,ids)=""
q $$$OK
} ClassMethod UnDeleteId(id As %String) As %Status [ Final ]
{
s dl=..GetDataLocation() q:dl="" 0
q:'$g(id) 0
q:$d(@dl@(id)) 0 ; not deleted
q:$d(@dl@("History",id))'>1 0 ; no saved revisions
; searching latest revision
s ids=$o(@dl@("History",id,""),-1) q:'ids 0
s zts=$o(@dl@("History",id,ids,""),-1) q:zts="" 0
m @dl@(id)=@dl@("History",id,ids,zts)
s ix=..%OpenId(id) d ix.%SetModified(1) s res=ix.%Save() k ix
q res
} Trigger OnBeforeDeleteForSQL [ Event = DELETE ]
{
n id,ix s id={ID} q:'id
d ..SaveLastRevision(id)
q
} Trigger OnBeforeSaveForSQL [ Event = UPDATE ]
{
n id,ix s id={ID} q:'id
d ..SaveLastRevision(id)
q
}
}
///
/// // Create and initialize an instance:
///
/// s ix=##class(TestPersHist).%New()
/// s ix.TestVal=$H
/// w ix.TestArray.SetAt("Abra",1)
/// w ix.TestArray.SetAt("Shvabra",2)
/// w ix.TestList.InsertAt("Cadabra",1)
/// s id=ix.%Id()
/// w ix.%Save() k ix
///
/// // Try to update
///
/// s ix=##class(TestPersHist).%OpenId(id)
/// s ix.TestVal=$J
/// w ix.TestArray.SetAt("Swim",3)
///
/// w ix.%Save() k ix
///
/// // Look at the global ^User.TestPersHistD. All changes recorded!
Class TestPersHist Extends PersHist [ ClassType = persistent, ProcedureBlock ]
{ Property TestArray As array of %String(MAXLEN = 255); // [ Collection = array ]; Property TestList As list of %String; // [ Collection = list ]; Property TestVal As %String(MAXLEN = 255) [ Required ];
}
Вкратце прокомментирую методы, добавленные в класс PersHist.
Классовый метод GetDataLocation() — читает описание класса-наследника и возвращает имя глобала хранения.
Классовый метод SaveLastRevision(id) — центральная фигура нашего класса. По-хорошему, его следовало бы сделать приватным, но в таком случае его нельзя будет вызвать из SQL-триггера. Данный метод копирует запись из узла глобала ^ClassData(id) в ^ClassData(«History»,id,id_сохранённой_версии,$ZTIMESTAMP). Естественно, вместо ^ClassData используется реальное имя глобала хранения, возвращённое методом GetDataLocation().
Приватный метод %OnBeforeSave() — вызывает SaveLastRevision() только когда метод %Save() будет выполнять реальную модификацию уже хранящейся в базе записи.
То же самое делает и SQL-триггер OnBeforeSaveForSQL().
И, наконец, для восстановления данных к состоянию, актуальному на заданный момент времени в формате $ZTIMESTAMP (точнее, $ZU(188) — пересчитанного на местное время $ZTIMESTAMP) предназначен метод MakeActual(timestamp). Данный метод смотрит, обновлялась ли запись точно в указанное время либо после него, и, если перезаписывалась, сохраняет текущую версию записи и восстанавливает ранее сохранённую. Например,
do oref.MakeActual(($h-7)_","_(12.5*3600))
вернёт запись к состоянию, в котором она была неделю назад в 12:30, а копию состояния этой записи на момент обращения, разумеется, сохранит.
Если же требуется полный список сохранений данного объекта, то предлагается следующая последовательность вызовов.
s ids="" f s ids=oref.OrderIdSave(ids) q:ids="" w ids,!
выдаст все идентификаторы сохранённых версий. А метод
write oref.GetTimeStampByIdSave(ids)
вернёт точное время сохранения заинтересовавшей версии. Впрочем, восстановить её можно и непосредственно по идентификатору:
do oref.MakeActualByIdSave(ids)
Но было бы нелогично сохранять все изменения при модификации записи, и при этом позволить безвозвратно лишиться этой записи в случае её удаления. От этой неприятности страхуют приватный метод %OnDelete() и SQL-триггер OnBeforeDeleteForSQL, которые позволят при необходимости восстановить удалённую запись с помощью классового метода UndeleteId(id).
Как вычислить идентификатор записи, подлежащей восстановлению — вопрос нетривиальный, и решаться должен, видимо, уже по усмотрению разработчика конечного приложения. Но в любом случае необходима возможность навигации по удалённым записям. И эту возможность предоставляет метод OrderDeletedId(id,dir), который, аналогично всем известной функции $ORDER(), возвращает ближайший следующий или предыдущий (при dir=-1) идентификатор записи, удалённой, но сохранившейся в истории.
Демонстрационный класс TestPersHist, унаследованный от PersHist, не содержит ничего, кроме свойств в формате обычного поля, списка и массива. Предлагается произвольно создавать и модифицировать записи этого класса (пример — в комментариях к классу), после чего прямым просмотром глобала хранения ^User.TestPersHistD штатными средствами Caché проследить за изменениями содержимого глобала хранения и воочию убедиться, что все редакции всех свойств сохраняются в ветке ^User.TestPersHistD(«History»).
Код классов PersHist и TestPersHist в виде, готовом для импорта в Caché, доступен для скачивания по данной ссылке: в формате CDL для Caché версии 5.0 и более ранних, в формате XML для современных.
Автор: dmart4