Версионное хранение данных в Persistent-классах Caché

в 3:57, , рубрики: cache, dbms, intersystems cache, nosql, ObjectScript, Блог компании InterSystems, глобалы, объектные субд, разработка, метки: , , , , ,

В стандартных хранимых классах Caché при модификации записи прежние значения свойств исчезают безвозвратно. Но бывают случаи, когда это нежелательно, когда «все ходы должны быть записаны». В первую очередь, конечно, такое требование возникает при разработке приложений для материально ответственных лиц, для которых критична возможность, например, отменить ошибочное действие и восстановить состояние документа на заданное время, или, что ещё важнее, провести расследование инцидента с попыткой злоумышленника «замести следы» в базе.
В этой статье демонстрируется, как реализовать хранение и восстановление версий для объектов Caché.

Данный функционал можно добавить к Persistent-классам с помощью метода %OnBeforeSave() и SQL-триггера на событие Update. Поскольку в подавляющем большинстве случаев использования Persistent-классов вся запись, в том числе значения свойств — массивов и списков, хранится в узле глобала хранения вида ^ClassData(id), перед перезаписью объекта есть возможность сохранить предыдущую версию записи в каком-нибудь укромном месте — наподобие «корзины» в Windows.
К данной статье прилагается пример использования вышеописанной возможности, состоящий из абстрактного класса PersHist — наследника %Persistent, к которому добавлены необходимые методы, и демонстрационного класса TestPersHist, в котором нет ровно ничего необычного, кроме того, что он — наследник PersHist, а не напрямую %Persistent. Ровно так же в нём можно создавать и модифицировать записи, как объектным, так и SQL-доступом, но перезаписанные данные там не исчезают бесследно и могут быть восстановлены.

Исходник класса PersHist

/// This ancestor of the %Persistent class
/// contains triggers to enable updates history recording.
Class PersHist Extends %Persistent 
AbstractClassType = "", 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 %BooleanAs %Status FinalPrivate ]
{
  
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 %ObjectIdentityAs %Status FinalPrivate ]
{
  
id=$lg(oidq:'id 0
  
..SaveLastRevision(id)
  
q $$$OK
}

ClassMethod GetDataLocation() As %String
{
  
ix=##Class(%ClassDefinition).%OpenId(..%ClassName())
  
q:ix.Storages.Count()'=1 "" ; sofisticated storages not supported
  
ix.Storages.GetAt(1).DataLocation
}

/// Returns next or previuos (similar to $o()) id of saved revision
Method 
OrderIdSave(ids As %String ""Direction As %String 1As %String
{
  
dl=..GetDataLocation() q:dl="" ""
  
id=..%Id() q:'id ""
  
q $o(@dl@("History",id,ids),Direction)
}

/// Returns timestamp of saved revision
Method 
GetTimeStampByIdSave(ids As %StringAs %String
{
  
dl=..GetDataLocation() q:dl="" ""
  
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 %StringTimeStampAs %Status
{
  
dl=..GetDataLocation() q:dl="" ; unsupported storage
  
id=..%Id() q:'id ; never saved
  
zts=timestamp
  
; searching saved revision with zts>=timestamp
  
i $d(@dl@("History","ZI",id,zts)) ids=$o(@dl@("History","ZI",id,zts,""),-1)
  
e  s zts=$o(@dl@("History","ZI",id,"")) q:zts="" 0  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 %StringAs %Status
{
  
dl=..GetDataLocation() q:dl="" ; unsupported storage
  
id=..%Id() q:'id ; never saved
  
q:$d(@dl@("History",id,ids))<10 0
  
zts=$o(@dl@("History",id,ids,""))  q:zts="" 0
  
..SaveLastRevision(id)
  
@dl@(id@dl@(id)=@dl@("History",id,ids,zts)
  
..%Reload() ..%SetModified(1)
  
..%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 1As %String Final ]
{
  
dl=..GetDataLocation() q:dl="" 0
  
f  s id=$o(@dl@("History",id),Directionq:id=""  q:'$d(@dl@(id))
  
id
}

ClassMethod SaveLastRevision(idAs %Status Final ]
{
  
id=$g(idq:'id $$$OK ; no previous revision to save
  
dl=..GetDataLocation() q:dl="" $$$OK
  s 
ids=$i(@dl@("History",id)),zts=$zu(188) ; $ZTIMESTAMP in local timezone
  
@dl@("History",id,ids,zts)=@dl@(id)
  
@dl@("History","ZI",id,zts,ids)=""
  
q $$$OK
}

ClassMethod UnDeleteId(id As %StringAs %Status Final ]
{
  
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
  
ids=$o(@dl@("History",id,""),-1) q:'ids 0
  
zts=$o(@dl@("History",id,ids,""),-1) q:zts="" 0
  
@dl@(id)=@dl@("History",id,ids,zts)
  
ix=..%OpenId(idix.%SetModified(1) res=ix.%Save() ix
  
res
}

Trigger OnBeforeDeleteForSQL [ Event = DELETE ]
{
  
id,ix id={ID} q:'id
  
..SaveLastRevision(id)
  
q
}

Trigger OnBeforeSaveForSQL [ Event = UPDATE ]
{
  
id,ix id={ID} q:'id
  
..SaveLastRevision(id)
  
q
}

}

Исходник класса TestPersHist

/// How to Test
/// 
/// // 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, а копию состояния этой записи на момент обращения, разумеется, сохранит.
Если же требуется полный список сохранений данного объекта, то предлагается следующая последовательность вызовов.

ids="" f  s ids=oref.OrderIdSave(idsq:ids=""  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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js