Question
· Sep 12, 2023

Scoping OID / OREF map

It's a feature of ObjectScript (perhaps widely known, perhaps not) that if you open the same object ID multiple times, you end up with the same OREF. For example:

USER>set obj1 = ##class(Sample.Person).%OpenId(1)
 
USER>set obj2 = ##class(Sample.Person).%OpenId(1)

USER>w obj1,!,obj2
1@Sample.Person
1@Sample.Person

Generally speaking, this is an important feature - you won't end up accidentally modifying the same record via multiple paths and losing some of the changes.

I have a use case, though, where in %OnBeforeSave I want to actually get the old version of the object (and, theoretically, all related objects). Is there a valid/supported way to effectively "new" the map from OID to OREF so I can (briefly) open the old version of the object and do stuff with it without persisting changes and before returning to the %Save process?

(I know using a code-generated row/object trigger could be an option, but it would involve a lot of refactoring.)

Product version: IRIS 2023.1
Discussion (10)6
Log in or sign up to continue

Use %ConstructClone (from %RegisteredObject)

Method %ConstructClone(deep As %Integer = 0, ByRef cloned As %String, location As %String) as %RegisteredObject

Clone the current object to a new object. If deep is 1 then this does a deep copy which will also copy any subobjects and if deep is 0 then it will create another reference to any subobjects and increment the reference count appropriately. It returns the new cloned object.

Note that even if deep=0 when you clone a parent object in a parent child relationship or a one object of a one to many relationship then it will construct clones of all the child/many objects. This is because a child/many object can only point at a single parent and so if we did not create a clone of these then you would have a relationship with zero items in it. If you really just want to clone the object without these child/many objects then pass deep=-1 to this method.

After the clone is constructed it will call %OnConstructClone(object,deep,.cloned) on the clone if it is defined so that you can perform any additional steps e.g. taking out a lock. This works just the same way as %OnNew() does.

The object is the oref of the original object that was cloned. The cloned array is just used internally when doing a deep clone to prevent recursive loops, do not pass anything in at all for this parameter on the initial call. If you write a %OnConstructClone and from here you wish to call %ConstructClone on another object pass in the cloned array, e.g. 'Do oref.%ConstructClone(1,.cloned)' so that it can prevent recursive loops.

The location is used internally to pass the new location for stream objects.

Would making a deep clone immediately after first opening the object resolve this?

Class dc.Per Extends %Persistent
{
Property Color As %String;
}

Test:

>s obj=##class(dc.Per).%New()
 
>s obj.Color="Red"
 
>w obj.%Save()
1
>kill obj
 
>s obj=##class(dc.Per).%OpenId(1)
 
>s objcopy=obj.%ConstructClone(1)
 
>w objcopy
2@dc.Per
>w obj
1@dc.Per

Interesting question. There is no good option to manipulate the ObjectRegistry but you do have options to access stored data. My recommended option you've already taken off the table.

The FOREACH=ROW/OBJECT trigger is by far the best and easiest solution if you require access to the version of an object that is currently stored. There are several reasons why this is the best option, perhaps the most important being that ROW/OBJECT triggers are consistently applied between Objects and SQL. Of course, the same restrictions exist for triggers that exist for %OnBeforeSave - we don't recommend modifying objects/rows.

Another option is to use <property>GetStored. This may not work for every situation but it does allow code to retrieve the value of a property directly from storage. I believe this is restricted to what we call "default storage". That isn't an entirely true statement but it is for common storage types (SQL Mapped storage being the other common type).

USER>zw ^demo.person.1(1)
^demo.person.1(1)=$lb("Doe, John","123 Main Street","Cambridge","MA","02142","[{""type"":""mobile"",""country_code"":""1"",""number"":""999-999-9999""},{""type"":""work"",""country_code"":""1"",""number"":""888-888-8888""}]")

USER>set person = ##class(demo.intersystems.Person).%OpenId(1)

USER>write person.name
Doe, John
USER>set person.name = "Richard, Maurice"

USER>write person.nameGetStored(person.%Id())
Doe, John
USER>write person.name
Richard, Maurice

Of course, you can always use SQL to retrieve stored values.

For the intrepid explorer, there is another option. This code isn't supported as the %Load* api's aren't supposed to be public. I'm sure most people have examined generated code and have discovered these methods so I'm divulging any big secrets here. First, the results and then the code.

USER>set person = ##class(demo.intersystems.Person).%OpenId(1)

USER>set person.name = "Enono, William"

USER>set sperson = ##class(demo.intersystems.Person).GetStored(person.%Id())

USER>write sperson.name
Leavitt, Timothy
USER>write person.name
Enono, William

Fair warning - this code isn't supported, it is considered to be user-implemented code. I also didn't rigorously test this code. I added this class method to my demo.intersystems.Person class:

ClassMethod GetStored(id As %Integer) As demo.intersystems.Person
{
    try {
        set obj = ..%New()
        set cur = obj.%Concurrency
        set obj.%Concurrency = 0
        $$$THROWONERROR(sc,obj.%LoadInit(,,1))
        $$$THROWONERROR(sc, obj.%LoadData(id))
        set obj.%Concurrency = cur
        do obj.%SetModified(0)
    } catch e {
        set obj = $$$NULLOREF
    }
    return obj
}

The object referenced by the oref returned by this code is not assigned an id. If you were to save this object it would create a new stored object and it would be assigned its own ID. Exercise caution.

There are two aspects of the question,
a) how to get the old property value of a modified property and
b) what happens, if one still opens the same object in the SAME PROCESS once again.

For a) the answer is already given by either using the GetStored() method or cloning the object. In cases, where an old propvalue is needed, I prefer the object cloning, especially if the base object contains embedded objects.
Of course, if the application uses the %Reload() method, then the %OnReload() callback method should do the same as %OnOpen() does.

Class DC.Data Extends %Persistent
{
Property Name As %String;
Property Addr As DataAddr;
Property oldObj As Data [ ReadOnly, Transient ];
Method %OnOpen() As %Status [ Private, ServerOnly = 1 ]
{
    set r%oldObj=..%ConstructClone(1)  // do a deep clone
    do ..%SetModified(0)               // clear modified state
    Quit $$$OK
}
}

Class DC.DataAddr Extends %SerialObject
{
Property Street As %String;
Property City As %String;
}

kill
set obj=##class(DC.Data).%New(),obj.Name="Joe"
set obj.Addr.Street="Abbey Rd. 123", obj.Addr.City="London"
write obj.%Save() --> 1

kill
set obj=##class(DC.Data).%OpenId(1)

set obj.Name="Paul"
write obj.Name --> Paul
write obj.oldObj.Name --> Joe

set obj.Addr.City="London East"
write obj.Addr.City --> London East
write obj.oldObj.Addr.City --> London

For b), it was said, that a second open (of the same object) returns the same OID. This is true. But there is one more thing. That second open doesn't even trys to get that object from the database but just keeps a lookout in the memory. This means two things (assuming no locks are used)
1) if the object was modified by another process, you "open the old version" already in the memory of your process, and
2) nevertheless that an other process deletes that object, your proces will open a nonexisting object...

Seq  Process-A                                  Process-B
-------------------------------------------------------------------------
 1   set obj1=##class(DC.Data).%OpenId(1)
 2                                              do ##class(DC.Data).%DeleteId(1)
 3   write ##class(DC.Data).%ExistsId(1) --> 0
 4   set obj2=##class(DC.Data).%OpenId(1)
 5   write obj1=obj2 --> 1
 

As a consequence of the above, I have learned not to use a simple open:

set obj=##class(Some.Class).%OpenId(oid)

but a more secure open, by invalidating a possible old instance:

set obj="", obj=##class(Some.Class).%OpenId(oid)