Skip to main content

Working with Persistent Objects

The %PersistentOpens in a new tab class is the API for objects that can be saved (written to disk). This chapter describes how to use this API. Information in this chapter applies to all subclasses of %PersistentOpens in a new tab. It discusses the following topics:

Also see the chapters “Introduction to Persistent Objects”, “Defining Persistent Classes,” and “Other Options for Persistent Classes.”

When viewing this book online, use the preface of this book to quickly find other topics.

Saving Objects

To save an object to the database, invoke its %Save() method. For example:

 Set obj = ##class(MyApp.MyClass).%New()
 Set obj.MyValue = 10

 Set sc = obj.%Save()

The %Save() method returns a %StatusOpens in a new tab value that indicates whether the save operation succeeded or failed. Failure could occur, for example, if an object has invalid property values or violates a uniqueness constraint; see “Validating Objects” in the previous chapter.

Calling %Save() on an object automatically saves all modified objects that can be “reached” from the object being saved: that is, all embedded objects, collections, streams, referenced objects, and relationships involving the object are automatically saved if needed. The entire save operation is carried out as one transaction: if any object fails to save, the entire transaction fails and rolls back (no changes are made to disk; all in-memory object values are what they were before calling %Save()).

When an object is saved for the first time, the default behavior is for the %Save() method to automatically assign it an object ID value that is used to later find the object within the database. In the default case, the ID is generated using the $Increment function; alternately, the class can use a user-provided object ID based on property values that have an idkey index (and, in this case, the property values cannot include the string “||”) . Once assigned, you cannot alter the object ID value for a specific object instance (even if it is a user-provided ID).

You can find the object ID value for an object that has been saved using the %Id() method:

 // Open person "22"
 Set person = ##class(Sample.Person).%OpenId(22)
 Write "Object ID: ",person.%Id(),!

In more detail, the %Save() method does the following:

  1. First it constructs a temporary structure known as a “SaveSet.” The SaveSet is simply a graph containing references to every object that is reachable from the object being saved. (Generally, when an object class A has a property whose value is another object class B, an instance of A can “reach” an instance of B.) The purpose of the SaveSet is to make sure that save operations involving complex sets of related objects are handled as efficiently as possible. The SaveSet also resolves any save order dependencies among objects.

    As each object is added to the SaveSet, its %OnAddToSaveSet() callback method is called, if present.

  2. It then visits each object in the SaveSet in order and checks if they are modified (that is, if any of their property values have been modified since the object was opened or last saved). If an object has been modified, it will then be saved.

  3. Before being saved, each modified object is validated (its property values are tested; its %OnValidateObject() method, if present, is called; and uniqueness constraints are tested); if the object is valid, the save proceeds. If any object is invalid, then the call to %Save() fails and the current transaction is rolled back.

  4. Before and after saving each object, the %OnBeforeSave() and %OnAfterSave() callback methods are called, if present.

    These callbacks are passed an Insert argument which indicates whether an object is being inserted (saved for the first time) or updated.

    If either of these callback methods fails (returns an error code) then the call to %Save() fails and the current transaction is rolled back.

If the current object is not modified, then %Save() does not write it to disk; it returns success because the object did not need to be saved and, therefore, there is no way that there could have been a failure to save it. In fact, the return value of %Save() indicates that the save operation either did all that it was asked or it was unable to do as it was asked (and not specifically whether or not anything was written to disk).

Important:

In a multi-process environment, be sure to use proper concurrency controls; see “Object Concurrency Options.”

Rollback

The %Save() method automatically saves all the objects in its SaveSet as a single transaction. If any of these objects fail to save, then the entire transaction is rolled back. In this rollback, Caché does the following:

  1. It reverts assigned IDs.

  2. It recovers removed IDs.

  3. It recovers modified bits.

  4. It invokes the %OnRollBack() callback method, if implemented, for any object that had been successfully serialized.

    Caché does not invoke this method for an object that has not been successfully serialized, that is, an object that is not valid.

Saving Objects and Transactions

As noted previously, the %Save() method automatically saves all the objects in its SaveSet as a single transaction. If any of these objects fail to save, then the entire transaction is rolled back.

If you wish to save two or more unrelated objects as a single transaction, however, you must enclose the calls to %Save() within an explicit transaction: that is, you must start the transaction using the TSTART command and end it with the TCOMMIT command.

For example:

 // start a transaction
 TSTART

 // save first object
 Set sc = obj1.%Save()

 // save second object (if first was save)
 If ($$$ISOK(sc)) {
     Set sc = obj2.%Save()
 }

 // if both saves are ok, commit the transaction
 If ($$$ISOK(sc)) {
     TCOMMIT
 }

There are two things to note about this example:

  1. The %Save() method knows if it is being called within an enclosing transaction (because the system variable, $TLEVEL, will be greater than 0).

  2. If any of the %Save() methods within the transaction fails, the entire transaction is rolled back (the TROLLBACK command is invoked). This means that an application must test every call to %Save() within a explicit transaction and if one fails, skip calling %Save() on the other objects and skip invoking the final TCOMMIT command.

Testing the Existence of Saved Objects

There are two basic ways to test if a specific object instance is stored within the database:

In these examples, the ID is an integer, which is how Caché generates IDs by default. The next chapter describes how you can define a class so that the ID is instead based on a unique property of the object.

Testing for Object Existence with ObjectScript

The %ExistsId() class method checks a specified ID; it returns a true value (1) if the specified object is present in the database and false (0) otherwise. It is available to all classes that inherit from %PersistentOpens in a new tab. For example:

 Write ##class(Sample.Person).%ExistsId(1),!   // should be 1 
 Write ##class(Sample.Person).%ExistsId(-1),!  // should be 0

Here, the first line should return 1 because Sample.PersonOpens in a new tab inherits from %PersistentOpens in a new tab and the SAMPLES database provides data for this class.

You can also use the %Exists() method, which requires an OID rather than an ID.

Testing for Object Existence with SQL

To test for the existence of a saved object with SQL, use a SELECT statement that selects a row whose %ID field matches the given ID. (The identity property of a saved object is projected as the %ID field.)

For example, using embedded SQL:

 &sql(SELECT %ID FROM Sample.Person WHERE %ID = '1')
 Write SQLCODE,!  // should be 0: success

 &sql(SELECT %ID FROM Sample.Person WHERE %ID = '-1')
 Write SQLCODE,!  // should be 100: not found

Here, the first case should result in an SQLCODE of 0 (meaning success) because Sample.PersonOpens in a new tab inherits from %PersistentOpens in a new tab and the SAMPLES database provides data for this class.

The second case should result in an SQLCODE of 100, which means that the statement successfully executed but returned no data. This is expected because the system never automatically generates an ID value less than zero.

For more information on embedded SQL, see the chapter “Embedded SQL” in Using Caché SQL. For more information on SQLCODE, see “SQLCODE Values and Error Messages” in the Caché Error Reference.

Opening Saved Objects

To open an object (load an object instance from disk into memory), use the %OpenId() method, which is as follows:

classmethod %OpenId(id As %String, 
                    concurrency As %Integer = -1, 
                    ByRef sc As %Status = $$$OK) as %ObjectHandle

Where:

  • id is the ID of the object to open.

    In these examples, the ID is an integer. The next chapter describes how you can define a class so that the ID is instead based on a unique property of the object.

  • concurrency is the concurrency level (locking) used to open the object.

  • sc, which is passed by reference, is a %StatusOpens in a new tab value that indicates whether the call succeeded or failed.

The method returns an OREF if it can open the given object. It returns a null value ("") if it cannot find or otherwise open the object.

For example:

 // Open person "10"
 Set person = ##class(Sample.Person).%OpenId(10)

 Write "Person: ",person,!    // should be an object reference

 // Open person "-10"
 Set person = ##class(Sample.Person).%OpenId(-10)

 Write "Person: ",person,!    // should be a null string

Note that in Caché Basic, the OpenId command is equivalent to the %OpenId() method:

person = OpenId Sample.Person(1)
PrintLn "Name: " & person.Name

You can also use the %Open() method, which requires an OID rather than an ID.

Multiple Calls to %OpenId()

If %OpenId() is called multiple times within a Caché process for the same ID and the same, only one object instance is created in memory: all subsequent calls to %OpenId() will return a reference to the object already loaded into memory.

This is demonstrated in the following example:

' open and modify Person 1 in memory
personA = OpenId Sample.Person(1)
personA.Name = "Black,Jimmy Carl"

' open Person 1 "again"
personB = OpenId Sample.Person(1)

PrintLn "NameA: " & personA.Name
PrintLn "NameB: " & personB.Name

Concurrency

The %OpenId() method takes an optional concurrency argument as input. This argument specifies the concurrency level (type of locks) that should be used to open the object instance.

For more information on the possible object concurrency levels, see “Object Concurrency Options,” later in this chapter.

If the %OpenId() method is unable to acquire a lock on an object, it will fail.

To raise or lower the current concurrency setting for an object, reopen it with %OpenId() and specify a different concurrency level. For example,

 Set person = ##class(Sample.Person).%OpenId(6,0)

opens person with a concurrency of 0 and the following effectively upgrades the concurrency to 4:

 Set person = ##class(Sample.Person).%OpenId(6,4)

Swizzling

If you open (load into memory) an instance of a persistent object, and use an object that it references, then this referenced object is automatically opened. This process is referred to as swizzling; it is also sometimes known as “lazy loading.”

For example, the following code opens an instance of Sample.EmployeeOpens in a new tab object and automatically swizzles (opens) its related Sample.CompanyOpens in a new tab object by referring to it using dot syntax:

 // Open employee "101"
 Set emp = ##class(Sample.Employee).%OpenId(101)

 // Automatically open Sample.Company by referring to it:
 Write "Company: ",emp.Company.Name,!

When an object is swizzled, it is opened using the default concurrency value of the class it is a member of, not the concurrency value of the object that swizzles it. See “Object Concurrency Options,” later in this chapter.

A swizzled object is removed from memory as soon as no objects or variables refer to it.

Reloading an Object from Disk

To reload an in-memory object with the values stored within the database, call its %Reload() method.

 // Open person "1"
 Set person = ##class(Sample.Person).%OpenId(1)
 Write "Original value: ",person.Name,!

 // modify the object
 Set person.Name = "Black,Jimmy Carl"
 Write "Modified value: ",person.Name,!

 // Now reload the object from disk
 Do person.%Reload()
 Write "Reloaded value: ",person.Name,!

Reading Stored Values

Suppose you have opened an instance of a persistent object, modified its properties, and then wish to view the original value stored in the database before saving the object. The easiest way to do this is to use an SQL statement (SQL is always executed against the database; not against objects in memory).

For example:

 // Open person "1"
 Set person = ##class(Sample.Person).%OpenId(1)
 Write "Original value: ",person.Name,!

 // modify the object
 Set person.Name = "Black,Jimmy Carl"
 Write "Modified value: ",person.Name,!

 // Now see what value is on disk
 Set id = person.%Id()
 &sql(SELECT Name INTO :name
         FROM Sample.Person WHERE %ID = :id)

 Write "Disk value: ",name,!

Deleting Saved Objects

The persistence interface includes methods for deleting objects from the database.

The %DeleteId() Method

The %DeleteId() method deletes an object that is stored within a database, including any stream data associated with the object. This method is as follows:

classmethod %DeleteId(id As %String, concurrency As %Integer = -1) as %Status

Where:

  • id is the of the object to open.

    In these examples, the ID is an integer. The next chapter describes how you can define a class so that the ID is instead based on a unique property of the object.

  • concurrency is the concurrency level (locking) used when deleting the object.

For example:

 Set sc = ##class(MyApp.MyClass).%DeleteId(id)

%DeleteId() returns a %StatusOpens in a new tab value indicating whether the object was deleted or not.

%DeleteId() calls the %OnDelete() callback method (if present) before deleting the object. %OnDelete() returns a %StatusOpens in a new tab value; if %OnDelete() returns an error value, then the object will not be deleted, the current transaction is rolled back, and %DeleteId() returns an error value.

Note that the %DeleteId() method has no effect on any object instances in memory.

You can also use the %Delete() method, which requires an OID rather than an ID.

The %DeleteExtent() Method

The %DeleteExtent() method deletes every object (and subclass of object) within its extent. Specifically it iterates through the entire extent and invokes the %DeleteId() method on each instance.

The %KillExtent() Method

The %KillExtent() method directly deletes the globals that store an extent of objects (not including data associated with streams). It does not invoke the %DeleteId() method and performs no referential integrity actions. This method is simply intended to serve as a help to developers during the development process. (It is similar to the TRUNCATE TABLE command found in older relational database products.) If you need to delete every object in an extent, including associated stream data, use %DeleteExtent() instead.

Caution:

%KillExtent() is intended for use only in a development environment and should not be used in a live application. %KillExtent() bypasses constraints and user-implemented callbacks, potentially causing data integrity problems.

Accessing Object Identifiers

If an object has been saved, it has an ID and an OID, the permanent identifiers that are used on disk. If you have an OREF for the object, you can use that to obtain these identifiers.

To find the ID associated with an OREF, call the %Id() method of the object. For example:

 write oref.%Id()

To find the OID associated with an OREF, you have two options:

  1. You can call the %Oid() method of the object. For example:

     write oref.%Oid()
  2. you can access the %%OID property of the object. Because this property name contains % characters, you must enclose the name in double quotes. For example:

     write oref."%%OID"

Object Concurrency Options

It is important to specify concurrency appropriately when you open or delete objects. You can specify concurrency at several different levels:

  1. You can specify the concurrency argument for the method that you are using.

    Many of the methods of the %PersistentOpens in a new tab class allow you to specify this argument, an integer. This argument determines how locks are used for concurrency control. A later subsection lists the allowed values.

    If you do not specify the concurrency argument, Caché uses the value of the DEFAULTCONCURRENCY class parameter for the class you are working with; see the next item.

  2. You can specify the DEFAULTCONCURRENCY class parameter for the associated class. All persistent classes inherit this parameter from %PersistentOpens in a new tab, which defines the parameter as an expression that obtains the default concurrency for the process; see the next item.

    You could override this parameter in your class and specify a hardcoded value or an expression that determines the concurrency via your own rules. In either case, the value of the parameter must be one of the allowed concurrency values discussed later in this section.

  3. You can set the default concurrency mode for a process. To do so, use the $system.OBJ.SetConcurrencyMode() method (which is the SetConcurrencyMode()Opens in a new tab method of the %SYSTEM.OBJOpens in a new tab class).

    As in the other cases, you must use one of the allowed concurrency values. If you do not set the concurrency mode for a process explicitly, the default value is 1.

    The $system.OBJ.SetConcurrencyMode() method has no effect on any classes that specify an explicit value for the DEFAULTCONCURRENCY class parameter.

Why Specify Concurrency?

The following scenario demonstrates why it is important to control concurrency appropriately when you read or write objects. Consider the following scenario:

  1. Process A opens an object without specifying the concurrency.

    SAMPLES>set person = ##class(Sample.Person).%OpenId(5)
     
    SAMPLES>write person
    1@Sample.Person
    
  2. Process B opens the same object with the concurrency value of 4.

    SAMPLES>set person = ##class(Sample.Person).%OpenId(5, 4)
     
    SAMPLES>write person
    1@Sample.Person
    
  3. Process A modifies a property of the object and attempts to save it using %Save() and receives an error status.

    SAMPLES>do person.FavoriteColors.Insert("Green")
    
    SAMPLES>set status = person.%Save()
     
    SAMPLES>do $system.Status.DisplayError(status)
     
    ERROR #5803: Failed to acquire exclusive lock on instance of 'Sample.Person'
    

This is an example of concurrent operations without adequate concurrency control. For example, if process A could possibly save the object back to the disk, it must open the object with concurrency 4 to ensure it can save the object without conflict with other processes. (These values are discussed later in this chapter.) In this case, Process B would then be denied access (failed with a concurrency violation) or would have to wait until Process A releases the object.

Concurrency Values

This section describes the possible concurrency values. First, note the following details:

  • Atomic writes are guaranteed when concurrency is greater than 0.

  • Caché acquires and releases locks during operations such as saving and deleting objects; the details depend upon the concurrency value, what constraints are present, lock escalation status, and the storage structure.

  • In all cases, when an object is removed from memory, any locks for it are removed.

The possible concurrency values are as follows; each value has a name, also shown in the list.

Concurrency Value 0 (No locking)

No locks are used.

Concurrency Value 1 (Atomic read)

Locks are acquired and released as needed to guarantee that an object read will be executed as an atomic operation.

Caché does not acquire any lock when creating a new object.

While opening an object, Caché acquires a shared lock for the object, if that is necessary to guarantee an atomic read. Caché releases the lock after completing the read operation.

The following table lists the locks that are present in each scenario:

  When object is created While object is being opened After object has been opened After save operation is complete
New object no lock N/A N/A no lock
Existing object N/A shared lock, if that is necessary to guarantee an atomic read no lock no lock
Concurrency Value 2 (Shared locks)

The same as 1 (atomic read) except that opening an object always acquires a shared lock (even if the lock is not needed to guarantee an atomic read). The following table lists the locks that are present in each scenario:

  When object is created While object is being opened After object has been opened After save operation is complete
New object no lock N/A N/A no lock
Existing object N/A shared lock no lock no lock
Concurrency Value 3 (Shared/retained locks)

Caché does not acquire any lock when creating a new object.

While opening an existing object, Caché acquires a shared lock for the object.

After saving a new object, Caché has a shared lock for the object.

The following table lists the scenarios:

  When object is created While object is being opened After object has been opened After save operation is complete
New object no lock N/A N/A shared lock
Existing object N/A shared lock shared lock shared lock
Concurrency Value 4 (Exclusive/retained locks)

When an existing object is opened or when a new object is first saved, Caché acquires an exclusive lock.

The following table lists the scenarios:

  When object is created While object is being opened After object has been opened After save operation is complete
New object no lock N/A N/A exclusive lock
Existing object N/A exclusive lock exclusive lock exclusive lock

Concurrency and Swizzled Objects

An object referenced by a property is swizzled on access using the default concurrency defined by the swizzled object’s class. If the default is not defined for the class, the object is swizzled using the default concurrency mode of the process. The swizzled object does not use the concurrency value of the object that swizzles it.

If the object being swizzled is already in memory, then swizzling does not actually open the object — it simply references the existing in-memory object; in that case, the current state of the object is maintained and the concurrency is unchanged.

There are two ways to override this default behavior:

  • Upgrade the concurrency on the swizzled object with a call to the %Open() method that specifies the new concurrency. For example:

     Do person.Spouse.%OpenId(person.Spouse.%Id(),4,.status) 

    where the first argument to %OpenId()Opens in a new tab specifies the ID, the second specifies the new concurrency, and the third (passed by reference) receives the status of the method.

  • Set the default concurrency mode for the process before swizzling the object. For example:

     Set olddefault = $system.OBJ.SetConcurrencyMode(4) 

    This method takes the new concurrency mode as its argument and returns the previous concurrency mode.

    When you no longer need a different concurrency mode, reset the default concurrency mode as follows:

     Do $system.OBJ.SetConcurrencyMode(olddefault)

Version Checking (Alternative to Concurrency Argument)

Rather than specifying the concurrency argument when you open or delete an object, you can implement version checking. To do so, you specify a class parameter called VERSIONPROPERTY. All persistent classes have this parameter. When defining a persistent class, the procedure for enabling version checking is:

  1. Create a property of type %IntegerOpens in a new tab that holds the updateable version number for each instance of the class.

  2. For that property, set the value of the InitialExpression keyword to 0.

  3. For the class, set the value of the VERSIONPROPERTY class parameter equal to the name of that property. The value of VERSIONPROPERTY cannot be changed to a different property by a subclass.

This incorporates version checking into updates to instances of the class.

When version checking is implemented, the property specified by VERSIONPROPERTY is automatically incremented each time an instance of the class is updated (either by objects or SQL). Prior to incrementing the property, Caché compares its in-memory value to its stored value. If they are different, then a concurrency conflict is indicated and an error is returned; if they are the same, then the property is incremented and saved.

Note:

You can use this set of features to implement optimistic concurrency.

To implement a concurrency check in an SQL update statement for a class where VERSIONPROPERTY refers to a property called InstanceVersion, the code would be something like:

 SELECT InstanceVersion,Name,SpecialRelevantField,%ID
     FROM my_table
     WHERE %ID = :myid

     // Application performs operations on the selected row

 UPDATE my_table
     SET SpecialRelevantField = :newMoreSpecialValue
     WHERE %ID = :myid AND InstanceVersion = :myversion

where myversion is the value of the version property selected with the original data.

FeedbackOpens in a new tab