[Technical Resources/Paradox/Cascading Deletes in Paradox for Windows]
 

Multi-Level Cascading Deletes in Paradox for Windows

A "Real World" Adventure into Multi-User Programming in ObjectPAL

Download the Associated ObjectPAL Files


Table of Contents

Table of Figures

 

Introduction

Deleting a record in Paradox for Windows is one of the easiest things a user can do. In fact it is almost too easy. Press Ctr-Del and the record is gone forever. Deleting is this easy until a user, working on a table defined as a master through referential integrity (RI), attempts to delete a record that has detail records that depend on it. This results in that wonderful message: "Master has detail records. Cannot delete or modify."

As tempting as it is, this article will not pick apart the accuracy of this error message, but it does deserve some scrutiny. One of the better known "Far Side" cartoons shows a dog being yelled at by its owner. The dog's interpretation reads something to the effect of "Blah, Spot, blah blah blah, Spot. Blah, Spot, blah." The error message "Master has detail records. Cannot delete or modify." probably has similar meaning to those users unfamiliar with database terminology. This is not the user's fault. Their jobs do not require understanding the expressions "master" and "detail" as they relate to databases. More importantly, they do not understand how these terms relate to their own tables. Even if the user understands the message, and the course of action required to delete the record, manually deleting all of the detail records can be very tedious. This is particularly true if there are several levels of tables.


What more can be done in addition to changing the cryptic terminology of the messaging in Paradox for Windows? As ObjectPAL developers there are three basic courses of action that may be taken to handle the useruser's inability to delete a master record with existing details. The first is to do nothing (except for maybe a little user training) and let them delete the detail records manually and then delete the master. In this case the user must have access to all subordinate tables. The second alternative is to disallow the deletion of any records should detail records exist. This is easily done by removing delete access to the subordinates. The final option is to create a routine that deletes all detail records and then deletes the master.


This article is about a generic utility that handles multi-level cascading deletes on Paradox tables. This is a very powerful utility and one where you should be asking, "Do I really want to give my users the ability to selectively destroy an entire family of data?" If this prospect does not terrify you down to the depths of your fully normalized soul, read on. There were many things learned about Paradox for Windows in the writing of this utility. Some of the issues covered include error handling in ObjectPAL as well as many multi-user issues that are common to any system used by more than one user. Some of these issues may surprise you.


The first thing to look at is, can this be done easily within ObjectPAL? The answer is yes, with three limitations: 1) The system is single user. 2) The delete is single level, meaning that the detail table is not itself a master to any other tables. 3) The routine is not generic, but has to be modified as detail tables are added or taken out of the referential integrity rules for the master table in question.


Figure 1: Simple delete confirmation using the RTL procedure
         msgQuestion.
Figure 1: Simple delete confirmation using the RTL procedure msgQuestion.


Imagine a scenario that meets the conditions cited above. This simple database has been built by Massey Ferguson, a member of the Future Farmers of America.


Massey's Farm Animal Tracking (FAT) Application


Figure 2: A custom question prompting a user 
         for a cascading delete.
Figure 2: A custom question prompting a user for a cascading delete.


Masseyuser's system tracks farms and the individual animals on each farm. To be able to delete a farm and all of its animals, Massey places some code on the active method of the tableFrame or multiRecordObject that is bound to the Farm table on a form. The user is first prompted with the dialog box shown in Figure. This takes care of the problem concerning the ease with which useruser's can irreparably delete a record in a Paradox table.


The meat of the matter is encountered when there are detail records to the master. The user is then prompted with the dialog box shown in Figure 2.


The code Massey wrote for this delete routine is shown in Figure 3.


         var
            tcAnimal tCursor
         endVar
         method action(var eventInfo ActionEvent)
            if isEdit() and eventInfo.id() = dataDeleteRecord then
               if msgQuestion("Delete?","Are you really, absolutely sure you " +
                "want to delete this farm forever?") = "Yes" then
                  doDefault

                  if eventInfo.errorCode() = peDetailRecordsExist then
                     if msgQuestion("This Farm has Animals!", "This " +
                      "farm has animals that belong to it. Deleting it " +
                      "means destroying all of its cute, cuddly, adorable," +
                      "little animals as well. Wipe out the farm and " +
                      "animals?\n\n\tYes - Delete farm and animals" +
                      "\n\n\tNo - Cancel") = "Yes" then

                        tcAnimal.attach(Animal)

                        if tcAnimal.empty() then
                            self.deleteRecord()
                        else
                            errorShow()
                        endif
                     endif
                  endif
               else
                  eventInfo.setErrorcode(userError)
               endif
            endif
         endmethod
         

Figure 3: Cascading delete routine for a single level and a single user environment.


Potential Problems

In analyzing Masseyuser's code we see that he used the dmAttach method to take advantage of the filter that is placed on the Animal data model object . Using dmAttach, the tCursor inherits the filter imposed by the data model of the form on the detail table, Animals. This limits the scope of the tCursor to only those animal records subordinate to the displayed Farm. This filter will also limit the RTL (Run-Time Library) method called empty. Empty will effect only the detail records. An alternative to this technique, but more code intensive, would have been to write a loop that deletes all subordinate records one at a time.


Empty limitations

Aside from the fact that Masseyuser's example is not generic, it has two major flaws that will only become apparent in a multi-user environment. The most glaring is that the empty method requires at least a write lock on the table but it will perform far better if it can obtain an exclusive lock. With an exclusive lock Paradox does not have be concerned with other workstations being updated as records are removed. An exclusive lock guarantees complete sole access to the table while the lock is in place. With an exclusive lock in place, empty deletes everything at once. This lock is impossible to obtain if anyone else on the network is doing anything with the table, even a simple read with a tCursor. A write lock guarantees the ability to have write access to any and all records in the table. With a write lock, it may be obtained even with other users accessing it as long as they are only reading it. If only a write lock can be acquired, Paradox must delete the records individually while running the empty method. This is because the table will have to be updated on other workstations while the records are being removed. In rudimentary tests using the empty method against a filtered tCursor, an exclusive lock is up to 55% faster than a write lock. With an unfiltered tCursor, empty runs up to 99% faster. In either case, an exclusive lock must be explicitly obtained either interactively or with the lock method in order to gain these kinds of performance advantages. If even one record lock by another user exists in the table, the write lock will fail. For this reason relying on empty is a terrible technique for multi-user environments. Both types of locks will frequently fail causing empty to fail also.


Using a loop and deleting each record one at a time is a poor methodology because one of the detail records could be locked. If the code deletes three detail records and then encounters a lock on the fourth, there is no way to undo the three deleted records. The purge has not completed and yet some of the records are gone. This is far worse than not deleting anything. What is needed is a transaction processing idea where either everything is deleted, or nothing at all. In Paradox, transaction processing is only indirectly supported by connecting to a database server using SQL Link. We want to perform cascading deletes against Paradox tables.


Action fires action

The final glaring problem is the statement self.deleteRecord() in the action method. doDefault causes the default behavior of deleting the record to take place. Default behaviors only take place once so the second delete of the master must be explicit. Deleting a record is an action. Deleting a record with self.deleteRecord() in the action method will recursively fire action again. This causes the user to get prompted again by the dialog box shown in Figure 1. The user has already made it clear the record should be deleted so this is both confusing and irritating.


Adding more tables

Another flaw with Masseyuser's code is that it will only work for single level cascade. If another table such as Feed were added subordinate to Animal, then any Animal record will not be able to be deleted if it has Feed records that depend on it. To cover another level of referential integrity, a loop would have to be added that would run through each Animal record deleting all of the corresponding records in the Feed table. In this loop Feed records would have to be deleted followed by the Animal record on an animal by animal basis. Only after this is completed and all Animal records and their Feeding records are deleted, can the Farm record be deleted. All this code and only three levels of deletions!


As tables are added, the number of records that must be deleted increases exponentially. The possibility of deleting only some of the records before coming to a locked record is far greater than in the previous example. Adding more levels of tables means more and more code. Almost immediately the code becomes very hard to maintain.


Massey expands the database

Massey has expanded his database. In addition to tracking animals per farm. He has added a feeding schedule table called Feed that is subordinate to Animal. Subordinate to feed are two other tables. One is called Portions and tracks what to feed and the quantity. The other is called FeedTime and tracks each individual feeding date and time. A data model of Masseyuser's expanded database is shown in Figure 4.


Figure 4: The data model for Massey's expanded database.
Figure 4: The data model for Massey's expanded database.


The goals of a complete delete routine

There are three main goals when writing a delete routine. 1) A true delete module must delete either everything or nothing. 2) It must also be very easily maintained so that adding subordinate tables, even with several levels of referential integrity, will require either little additional programming or, ideally, none at all. 3) Finally, it must also meet the first two objectives, but in a multi-user setting. PurgeAll, the utility in this article, meets all of these criteria. It is completely generic, requiring no maintenance at all.


Calling PurgeAll

Figure 5: The PurgeAll Demo form. All of the code to call 
         PurgeAll is listed in Figure 8

Figure 5: The PurgeAll Demo form. All of the code to call PurgeAll is listed in Figure 8


Massey built the form in Figure 5 and, because of his multi-level database and the need to run it on the FFAuser's network, he has the form calling PurgeAll. His new code is shown in Figure 6, Figure 7 and Figure 8.


Both methods are stored in an ObjectPAL library. Calling these methods entails the standard programming when calling any method in an ObjectPAL library:


1) Declare the syntax for the methods in a uses statement. The uses statement must be on an object whose scope encompasses the method making the call as in Figure 6.


         ; Declared on the form in the Uses window
         Uses ObjectPAL 
            purgeAllUI(var eventInfo actionEvent, const lMsg logical, const lUI logical)
            purgeAllTC(var tcAction tCursor, const lMsg logical, const lUI logical) logical
         endUses

         ; Declared on the form in the Var window
         Var
            strPath  string
            lbPurge  library
         endVar
         

Figure 6: Uses statement for calling purgeAllUI and purgeAllTC


2) Declare a library variable as in Figure 6. Both the uses and the var window must be within the scope of the objects calling the PurgeAll library. Make sure the object they are declared on contains all objects coded to call the utility.


3) Open the library variable (see Figure 7).


         ; Page. Could be the open method
         ; of any object.
         method open(var eventInfo Event)
         var
            strDrive, strFile, strExt  string
         endVar
            ; The code to get the path is to
            ; ensure the opening of the library
            ; regardless of where the form is
            ; opened from. As long as the
            ; library is in the same directory
            ; as the form, this will work fine.
            splitFullFileName(getFileName(),
             strDrive, strPath, strFile, strExt)
             strPath = strDrive + strPath

            ; Open the library variable
            if not lbPurge.open(strPath +
             "PurgeAll") then
               errorShow()
               close()
            endif
         endmethod

Figure 7 : Open the library variable.


4) Using the library variable to reference the library, call the method using dot notation. Examples of doing this with purgeAllTC and purgeAllUI are show in Figure 8.


            var
               tcDelete tCursor
            endVar
            method pushButton(var eventInfo Event)
                  if not tcDelete.attach(active) then
                     errorShow()
                     return
                  endif
                  lbPurge.purgeAllTC(tcDelete, true, true)
            endmethod

            ; On each tableFrame shown in Figure 5
            method action(var eventInfo ActionEvent)
               if eventInfo.id() = dataDeleteRecord then
                  lbPurge.purgeAllUI(eventInfo, true, true)
               endif
            endmethod
         

Figure 8: Example of calling purgeAllTC and purgeAllUI.


Why bother analyzing PurgeAll?

The main point of this article is not just to explore the PurgeAll coding scheme. It is also intended to point out various coding techniques. Some of these coding techniques should be done in almost any ObjectPAL application. This is the case with wrapping the open of a library or tCursor variable in an "if" statement. Many, if not most RTL methods return a logical value according to whether or not they have succeeded. It is good defensive programming to test for the success of the method in an "if" and take the appropriate action should the method fail. This is clearly shown in the library open method in Figure 8. By encasing the open of the library in an "if" statement, the demo form will close if the library cannot open. Since the entire point of the form is to demonstrate the functionality of the utility, this is an appropriate course of action.


Many of the coding techniques in PurgeAll are very useful and not very well known. Such is the case with placing getFileName in the splitFullFileName procedure to retrieve the path of the form and use it to find the library. In a standard business application it is typically better to reference aliases, but this code is in a demo form. There is no way of knowing what environment in which this form will be used. Since the library and tables the form relies on are bundled with the form, it is coded with the assumption that the library and tables it needs will be in the same directory as the form. For that reason this technique has some real utility here. PurgeAll is a utility that encounters a vast number of coding issues in a relatively small amount of code. None of the issues are covered in great depth, but exploring PurgeAll means covering coding issues that many ObjectPAL developers do not see over the course of a year, or maybe even several years. The rest of this article does not cover PurgeAll line-by-line, instead it explores the more critical and more interesting aspects of the utility and the Paradox issues that led to its design.


Examples of ObjectPAL covered in this article include: heavy use of tCursors (arrays of tCursors!) and assigning them to tables using both open and attach; using setBatchOn and setBatchOff; declaring variables inside and outside the method statement; explicitly placing table and record locks; error trapping using "if" statements and try..onFail..endTry; managing the error stack; scans within scans; resizing arrays; changing an action event with setID; use of setGenFilter with an index; recursion; selectively deleting or emptying records; and the use of built-in object variables and enum methods to keep PurgeAll generic. Typically you would find this variety of code only in large business applications. Believe it or not, there is not much code to PurgeAll. That is why it is such a good way to learn many of the deeper facets of ObjectPAL coding.


PurgeAll

Individually deleting a record is performed either in a form, or using a tCursor. PurgeAll comes in two flavors. One accepts a tCursor as a parameter and the cascading deletes are performed using the tCursor as the master record. The other takes the eventInfo packet of type actionEvent.


Deleting with tCursors - purgeAllTC

The calling method when using a tCursor is purgeAllTC whose syntax is shown in Figure 9. The first parameter is self explanatory. It is the tCursor pointing to the master record to be deleted. The second parameter, lMsg is a logical value. When lMsg is true, the method displays messages such as number of deletions to perform and a percentage bar showing the percentage deleted. The last parameter is lUI (lowercase "L" for logical, followed "U" then uppercase "I" for user interface). When lUI is true, PurgeAll displays dialog boxes that the user must respond to in order for the purge to continue. These dialog boxes include confirmation of the delete, confirmation of the purge if there are detail records, and various dialogs to display appropriate messages should the purge be aborted because a detail record could not be deleted. The method returns a logical value if the master record and all of its subordinate records are deleted. Notice that an actionEvent variable is declared and passed to the method called purgeAll. While the eventInfo packet will be modified in the purgeAll method, it does not interfere or hinder anything. This allows for using one method for both tCursors and UIObjects for the bulk of the code.


         var
            aeBogus  actionEvent
         endVar
         method purgeAllTC(var tcSelf tCursor, const lMsg logical, const lUI logical) logical
            return purgeAll(aeBogus, tcSelf, lMsg, lUI)
         endmethod
         

Figure 9: purgeAllTC


Deleting with UIObjects - purgeAllUI

The syntax for the UI version, purgeAllUI, can be seen in Figure 10. purgeAllUI takes an actionEvent variable and uses the built-in object variable self to access the record. Its first parameter is an actionEvent variable. This means this must be called from the built-in method action. There was some thought given as to whether or not a UI deletion would be performed from any other built-in method other than action. Coding for a response to a useruser's attempt to delete a record in any built-in method other than action would be very unusual. The ability to modify the eventInfo packet gives some nice additional functionality to PurgeAll as you will see later in this article. If the purge must be called from a different built-in method, it can be done by declaring an actionEvent variable (see purgeAllTC in Figure 9 for an example of how to do this) or attaching a tCursor to the object on which to perform the deletion and calling purgeAllTC. The logical parameters are the same as those in purgeAllUI.


            var
               tcSelf   tCursor
               lPA  logical
            endVar
            method purgeAllUI(var eventInfo actionEvent,
             const lMsg logical, const lUI logical) logical

               ; Edit mode to get a true result from unlockRecord()
               ; and to pass the state to the tCursor through attach()
               if self.recordStatus("New") and
                not self.recordStatus("Modified") then
                  return true
               endif

               if not isEdit() then
                  edit()
               endif

               if not tcSelf.attach(self) then
                  errorShow()
                  eventInfo.setErrorcode(userError)
                  return false
               endif

               tcSelf.setBatchOn()
               if not (self.unlockRecord() and
                lockRec(tcSelf, lUI) then
                  eventInfo.setErrorCode(userError)
                  return false
               endif
         
               purgeAll(eventInfo, tcSelf, lMsg, lUI)
               if tcSelf.isAssigned() then
                  tcSelf.endEdit()
                  tcSelf.close()
               endif
               return lPA
         endmethod
         

Figure 10:purgeAllUI


Generally, this method will not need to be called in an "if" statement. The reason is that when the purge is unsuccessful, the errorCode component of eventInfo is set to a non-zero value to stop the default behavior of the action method which should be deleting the record. If the purge is successful, it changes the id component of the actionEvent variable from dataDeleteRecord to dataRefresh. This forces the form to be updated which it needs since the delete is not performed by the form but by PurgeAll.


The first thing purgeAllUI does is put the form into edit mode (Figure 10). Later in the code self.unlockRecord() will be called. Since in this routine the record will be accessed with a tCursor and no two objects may have a record locked at the same time, the success of unlocking the object pointed to by self is critical to being able to lock the tCursor. The entire method depends on this. The RTL unlockRecord method will fail if the form is not in edit mode.


Attach revisited - A tCursor instead of self

Another benefit to placing the form in edit mode is that it saves the need to explicitly place the tCursor in edit mode. The tCursor needs to be bound to the same table and point to the same record as the object referenced by self. The tCursor method attach does this in a single statement. In addition to being bound to the same table as self, attach causes the tCursor to inherit the edit state, the current index, current filter and it will point to the same record as the UIObject. Another benefit of using attach is that purgeAllUI depends on being called by an object bound to a table such as a field, record, tableframe, or multi-record object (MRO). In a method in a library, self always points to the object that called the method. If purgeAllUI is called from the action method of a different type of object, it will fail with a critical error. The attach method will also fail if it is not one of these object types, but it does not generate an error, it just return a false value. Testing attach in an "if" statement allows for a graceful exit.


Why is a tCursor attached instead of simply referencing self? To determine the reason a record could not be deleted in the built-in action method, it would be easy to test for the action constant dataDeleteRecord, issue a doDefault and then test the value returned by errorCode in the eventInfo packet. This is exactly what was done in Masseyuser's single user routine shown in Figure 3. This is not being done here for several reasons. First, doDefault does not do anything when issued in custom methods or custom procedures. In order to be able to use doDefault, either all of the code would have to reside in the action method, or two custom methods would need to be called from action. One of the custom methods would be called prior to doDefault to prompt the user about deleting the record. The second method would have to be called after doDefault to respond to the event after the default behavior has been triggered.


In order to save the need to place two calls in the action method, doDefault is not used here. Another problem is created by what occurs if the delete fails. Should this happen because of dependent detail records, the thing to do is purge them all. After all of the detail records are deleted, the master record must be deleted. Remember that purgeAllUI has been called by the action method. To call self.deleteRecord() from a method called by the built-in action method would result in the action method being fired again with another delete event. This is exactly what happens with Masseyuser's form. The user is asked a second time if they want to delete the record. For these two reasons, a tCursor is used throughout PurgeAll instead of referencing self.


Relocating the lock with setBatchOn & setBatchOff

At this point the tCursor and the object referenced by self are pointing at the same record. A record may be locked by only one object, even within the same session. The tCursor needs to acquire a lock on the record that self may or may not have locked. Should the object referenced by self have a record lock, the lock needs to be transferred from self to the tCursor without allowing a window in which anyone else on the network can lock the record. To remove this window there is a very powerful and potentially hazardous method of tCursor type called setBatchOn. SetBatchOn gives exclusive access to a table. It stops all locking activity for all users on the network trying to access that table. If setBatchOn is used, another user performing a task that locks or unlocks the same table or a record within that table will see their computer "hang" until the counter part to setBatchOn, setBatchOff, is called by the same tCursor. There is no messaging during this "hang" time. For this reason setBatchOff, must be used very, very soon after setBatchOn. A commonly recommended amount of time is two seconds or less. If setBatchOff is not called for more than two minutes after setBatchOn, the block is removed. Do not rely on this fact! Of interest to note is that if a tCursor has a record lock, setBatchOff releases it.


Notice that even when using setBatchOn the unlock of self and the lock of the tCursor are still placed in an "if" statement. With both the unlocking and the locking in the one statement, the record lock is transferred. The "if" statement also serves another purpose. SetBatchOn may guarantee access to a table, but it does not ensure access to all records. Any records locked by other users prior to setBatchOn are still in place and cannot be edited or deleted. Furthermore, setBatchOn returns a logical value because it can fail. On some DOS-based network operating systems, it will fail if any other session has a record lock on the same table. SetBatchOff is not turned off here because its execution causes a record lock to be lost. SetBatchOff is used after the attempt to delete the record. Note: The lockRec method in the "if" statement is a custom method that attempts to place record locks on tCursors and handles the errors that might occur. If it displays an error the code will pause until the user responds to the dialog. SetBatchOff is used before the dialog box is opened, not after. The point is that setBatchOn is only kept on for, at most, a few tenths of a second.


Error trapping with lockRec

Parts of lockRec are shown in Figure 11. It was created to handle errors that might arise when trying to lock a record with a tCursor. Such errors are harder to trap with tCursors than they are in the built-in action method. In the action method it is easy to check eventInfo.errorCode() to determine the reason an operation failed. The problem with tCursors is that a failed record lock does not typically generate an error into the error stack. This is because the lockRecord method will return a true or false value according to its success so that it may be tested in an "if" or case statement. While it is nice to know that a record lock failed, it is even better to know why and, depending on the error, who caused it. That is why lockRec was written. It handles these errors in different ways and displays useful context sensitive information to the user. Errors give such information. In order to generate an error, the RTL procedure, errorTrapOnWarnings, must be turned on. This will cause Paradox to create an error stack when the attempt to lock a record fails.


What is this mystical error stack? When an error is generated, several other errors can occur right behind it. Paradox tracks all of the errors in a series. There are RTL procedures that can be used to retrieve these errors and also to remove them from the stack either one at a time or in total.


Creating an error stack normally results in the error dialog box being displayed. In order avoid this, we must trap for the error by placing the lockRecord method in a try..OnFail..endTry construct. Should the lock fail, code under the onFail part of the construct will be executed. The error dialog will not be displayed.


If an error occurs with lockRecord against a tCursor, the first error in the error stack is always peTCursorErr. The message that goes with this error is "An error was triggered in the lockRecord method of tCursor type." Since that is not particularly useful for displaying a context sensitive message to the user, we need to step through the error stack. The procedure for removing the current error from the stack and advancing to the next one is errorPop. To continue stepping through the errors, errorPop is placed at the bottom of a loop that starts with while errorCode() <> peOK. PeOK is the Paradox constant whose existence in the error stack signifies that there are no errors and everything is smooth sailing. The loop is exited as soon as all of the errors havebeen removed from the stack and everything is "peOK" (Figure 11).


         errorTrapOnWarnings(yes)
         try
            tcToLock.lockRecord()
         onFail
            while errorCode() <> peOK
               ; Store the error and its pneumonic equivalent
               ; because parsing the error message replaces the
               ; error stack
               liError = errorCode()
               if lUI then
                  constantValuetoName("Errors", errorCode(), strError)
                  strError = strError + " - " + errorMessage()
               endif

               if liError <> peTCursorErr then
                  ; NEVER leave setBatchOn while prompting a user, 
                  ; but use setBatchOff here, after getting the error
                  ; info, or it will change the error stack.
                  if tcToLock.tableName() = strFullMaster then
                     tcToLock.setBatchOff()
                  endif

                  if lUI then
                     ; Find the user name
                     strError.breakApart(aryError," :\n\r.")
                     *
                     *
                     *
                  endif
                  switch
                     case liError = peTCursorErr :
                        if not lUI then ; If not messaging
                           quitLoop
                        endif

                     ; If displaying messages, do nothing. Let it loop
                     case lUI and liError = peRecLockLimit :
                        msgStop("Record Delete Limit", "Cannot " +
                         "delete your " + strMaster + " record. " +
                         "There are too many records to delete in " +
                         "the " + dyTable["Name"] + " table.\n\n " +
                         " Try again with all other users " +
                         " out of the system.")
                        *
                        *
                        *
                  endSwitch
               else
                  if lUI then
                     msgStop("Error",strError)
                  endif
                  quitLoop
               endIf
               errorPop()
            endWhile

            errorTrapOnWarnings(no)
            return false
         endTry
         

Figure 11: lockRec. Error trapping is used to capture the ID of the user causing the error and display context sensitive message.


The errors we are interested in processing are peTCursorErr (to throw it out), peRecLockLimit, peLocked, peRecAlreadyLocked, peRecordGroupConflict, and peFileLocked. Descriptions of these errors are shown in Figure 12.


Error Paradox Description Additional Information
peTCursorErr An error was triggered in the lockRecord method of tCursor type. No kidding.
peFileLocked File is locked. At table lock caused the error.
peLocked Record locked by another user. Another user on the LAN has the same record locked.
peRecAlreadyLocked This record is locked by another module in this session. A tableframe, form, or another tCursor has the record locked in the same session.
peRecordGroupConflict Conflicting record lock in this session. A reverential integrity group lock has the record locked in the same session.
peRecLockLimit Too many record locks on table. There is a maximum of 64 locks allowable per session to the BDE (Borland Database Engine).

Figure 12: Error constants related to locking.


All right, who locked me out?

One big advantage of trapping these errors specifically has to do with peLocked and peFileLocked. The user that causes the error is reported in the error message. This is useful since a glaring omission in ObjectPAL is the lack of a method or procedure to report on who caused the lock attempt to fail (errorUser() in Paradox for DOS). By parsing out the error message into an array using the breakApart method, lockRec displays the network user name to the user in a dialog box. This gives them the ability to contact the offender and have them release the lock. One of the dialog boxes produced by lockRec is shown in Figure 13.


<strong>Figure 13: </strong>An error dialog box 
         created in the <i>lockRec</i> method. Notice that the user name is displayed. In ObjectPAL there is no
         explicit way to get the network id of the user who has the lock that causes such errors. The name was
         obtained by parsing the error message.

Figure 13: An error dialog box created in the lockRec method. Notice that the user name is displayed. In ObjectPAL there is no explicit way to get the network id of the user who has the lock that causes such errors. The name was obtained by parsing the error message.


Store the errorCode

In Figure 11 you can see that prior to parsing out the error message, the error code is stored in a variable (liError). liError is referenced in the case statements instead of errorCode. This is because the error stack is changed with the code that performs the parsing. The methods used there have the potential to generate an error so running them changes the error stack. If errorCode were used in the case statements, none of them would be hit because after the parsing errorCode will return peOK.


Deleting - Trap & relock

The first step before deleting is to prompt the user about the delete. In order to make the routine generic, familiar and informative, the name of the table is retrieved from the using the tableName method.


At the top of Figure 14 splitFullFileName breaks out the drive, path, name and extension. If an alias is used to reference the file, the alias is assigned to the drive element and the path gets a blank value. In this version of splitFullFileName, the procedure uses a dynamic array. The format procedure (an extremely flexible and powerful procedure in its own right) set the table name to proper case. The result is a prompt with a familiar table name (farm) displayed the dialog box shown in Figure 15.


         strFullMaster = tcSelf.tableName()
         ; Get the name of the table without a path or alias
         splitFullFileName(tcSelf.tableName(), dyTable)
         strMaster = format("cc",lower(dyTable["Name"]))

         if lUI then
            if msgQuestion("Delete?", "Are you really, absolutely " +
             "sure you want to delete this " + lower(strMaster) +
             "record?\n\n\tYes - Delete\n\n\tNo - Cancel") = "No" then
               eventInfo.setErrorcode(userError)
               return false
            endif
         endif 

         siOldRetry = retryPeriod()
         setRetryPeriod(0)

         errorTrapOnWarnings(yes)
         try
            tcSelf.deleteRecord()
         onFail
            while errorCode() <> peOK
               if errorCode() = peTCursorErr or
              errorCode() = peDetailRecordsExist then
                 if errorCode() = peDetailRecordsExist then
                     tcSelf.setBatchOff()

                     ; Master record lock was lost when 
                     ; attempt to delete it failed.
                     ; Must get it back before continuing.
                     if not tcSelf.recordStatus("Locked") then
                        if not lockRec(tcSelf, lUI) then
                           return purgeFails(eventInfo, tcSelf, 
                            aryLockedDetails, siOldRetry, lMsg)
                        endif
                     endif

                     if lUI then
                        if msgQuestion("Details...details...", "This " + 
                         lower(strMaster) + " record has detail data." +
                         "\n\nTo delete this record, all of its details " +
                         "must be deleted also. Depending on the " +
                         "number of records, this process can be "
                         "time consuming.\n\n\t Continue?\n\n\t" +
                         "Yes - Delete everything\n\n\tNo - Cancel") = "No" then
                           return
                           purgeFails(eventInfo, tcSelf,
                            aryLockedDetails, siOldRetry, lMsg)
                     endif
                  endif

                  errorTrapOnWarnings(no)
                     *
                     *
                     *
                     if not getDetailLocks(tcSelf, dyLockedTables,
                      aryLockedDetails, liDetailRecs, lMsg, lUI) then
                        delUIFails(eventInfo, tcSelf, siOldRetry)
                        return
                     endif
         

Figure 14: The deletion attempt on the master record, prompting the user for the permission to purge and the dive into getDetailLocks.


Figure 15: The delete confirmation in PurgeAll.

Figure 15: The delete confirmation in PurgeAll.


Deleting a family of records can be a time consuming process depending on the number of records involved. Paradox has a built in setting that determines how long it attempts to retry a locking or unlocking action on a table or record. It is the retry period. By default the retry period is three seconds. That is a long time to wait for a response. To get immediate response, the retry period is set to zero. Some developers may have good reason for having this set to a certain value. Before changing the setting, the value of the current retry period is saved to a variable (See Figure 14). On exiting purgeAll, the old retry period is restored.


The next step is to delete the record. Should the delete fail, it will be necessary to find out why through the error code. As with lockRecord, the errorTrapOnWarnings must be turned on and deleteRecord placed in a try..OnFail..endTry construct. Figure 14 illustrates that the first thing that happens in the onFail after the existence of detail records is established is that it immediately relocks the record. Paradox releases a record lock whenever a record delete is attempted even if the delete fails. This is true not only for tCursor, but UIObjects as well. The master record must be kept locked so that the record will definitely be able to be deleted after all of its details are purged. Holding off on calling setBatchOff will do no good since setBatchOff also releases the record lock held by the tCursor.


Now that the existence of subordinate records has been confirmed, the dialog box in Figure 16 appears to be asking the user for permission to purge. If confirmed, the next step is to call the getDetailLocks procedure.


<strong>Figure 16:</strong> "Do 
          you really want to do this?" The dialog that prompts the user to purge all detail records.

Figure 16: "Do you really want to do this?" The dialog that prompts the user to purge all detail records.


getDetailLocks

The reason that getDetailLocks is a procedure and not a method is performance. Although they run at the same speed, the process of calling a routine is faster with procedures than with methods. Since getDetailLocks is called many times during a purge, it is possible the performance benefits might make a difference. (1/30 of a millisecond according to Cary Jensen's article "Methods and Procedures" in PI 8/95). GetDetailLocks is declared in the procedure window and not above the method statement because there is enough code to make perusing both it and purgeAlla bit unwieldy in a single window.


The main purpose of getDetailLocks is to locate each detail record and place locks that will guarantee the ability to delete them. This is done through a series of looping mechanisms and can take some time. To help cover this time PurgeAll provides some messaging for the user. The dialog in Figure 17 provides some visual relief but more importantly, it is augmented by a real time running total of the number of tables and the number of records found. This is displayed on the status bar at the bottom of the Paradox desktop.


<strong>Figure 17: </strong>Taking advantage 
         of processing the purge in a loop, this dialog displays while the detail records are being locked. A 
         running total is constantly updated on the status bar.

Figure 17: Taking advantage of processing the purge in a loop, this dialog displays while the detail records are being locked. A running total is constantly updated on the status bar.


Enum what?

The key to making getDetailLocks a generic routine is a method called enumRefIntStruct. It is available for both table and tCursor variable types. EnumRefIntStruct creates a table listing all of the referential integrity rules for the table that is being pointed to by the tCursor. This method has two different syntaxuser's. It outputs to either a file on disk or a tCursor. When a method outputs to a tCursor, the table is kept in memory and never written to a disk file. This is a great performance boost over writing out a table to a file. In order to avoid disk I/O and increase performance, getDetailLocks outputs the enumRefIntStruct table to a tCursor as seen at the top of Figure 18. The structure of the table is shown in Figure 19.


         tcMaster.enumRefIntStruct(tcRefInt)
         ; Scan for detail tables
         scan tcRefInt for tcRefInt."infoHeader" = "Y"
          and tcRefInt."Slave" = "" :
            if not
               tcDet.open(tcRefInt."OtherTable") then
               errorShow()
               return GDLFails()
            endif

            ; If no dynamic array element, the table lock has not 
            ; been attempted
            if not dyTableLocks.contains(tcDet.tableName()) then
               ; If a read or exclusive table lock can be obtained, 
               ; empty may be used instead of record locks
               for siLoop from 1 to 2
                  if tcDet.lock(iif(siLoop = 1, "Exclusive", "Write")) then
                     quitLoop
                  endif
               endFor

               ; Create a dynamic array element with the table name as the
               ; tag if siLoop = 3 the table lock was not obtained so it should
               ; be false
               dyTableLocks[tcDet.tableName()] = iif(siLoop = 3, false, true)

               ; msgCounter displays a message on the status bar
               msgCounter(dyTableLocks.size(),CstrMsgTbl, CstrMsgLeft,lMsg)
            endif
         

Figure 18: getDetailRecs. A tCursor is used to scan for detail tables. In this loop table locks are being attempted and their success is being tracked in a dynamic array.


Field Name Type Paradox Description Additional Information
infoHeader A1 If Y, this record is a header for (and the data it contains is shared by) subsequent consecutive records that have a value of N in this field. If N, this record is not a header. If it contains a "Y", then the record is the start of a new RI rule. Up until the next record that contains a "Y" in this field, all records that follow this one represent linking fields for the same RI rule..
RefName A31 A name to identify this referential integrity constraint. Contains the name of the RI rule as defined by the user while defining RI during the create or restructure of the table..
OtherTable A81 Name (including path) of the other table in the referential integrity

relationship.

The name and DOS path of the other table in the RI relationship.
Slave A1 Y if the able is slave, not master (i.e., table is dependent); otherwise blank. Contains a "Y" if the table is subordinate to the table with whom this table has an RI relationship. It is blank if it is the master table
Modify A1 Specifies update rule:

Y = Cascade

blank = Prohibit.

Delete A1 Specifies delete rule:

Y = Cascade

blank = Prohibit.

The existence of this field is extremely interesting. It is appears as if Borland intended to implement cascading deletes in Paradox but later decided not to implement the feature.
FieldNo N Ordinal position of the field in this table involved in a referential integrity relationship The sequential number of the one of the linking fields in this table.
aiThisTabField A31 Name of the field in this table involved in a referential integrity

relationship.

The name of the linking field in this table.
OtherFieldNo N Ordinal position of the field in the other table involved in a referential integrity relationship. The number of the corresponding field in the other table (referenced in the 'OtherTableuser' field).
aiOthTabField A31 Name of the field in the other table involved in a referential integrity relationship. The name of the corresponding field in the other table (referenced in the 'OtherTableuser' field).

Figure 19: The fields of the table created with enumRefIntStruct.


Figure 20: A normalized view of the data described by 
          the table created by enumRefIntStruct.

Figure 20: A normalized view of the data described by the table created by enumRefIntStruct.


From a normalization standpoint, the enumRefIntStruct table should be two tables. It describes a one-to-many relationship that is heavily denormalized into a single table. Were it two tables, the master table would be a list of all referential integrity rules for the table that the method is referencing. The detail table would be the linking fields for both the master and the detail table similar to what is shown in Figure 20. The table created by enumRefIntStruct is only a single table. The key to understanding this table is that each record really represents one of the linking field(s) in the referential integrity relationship. The first field in the table is infoHeader. If it contains a "Y", then that record is the start of a new RI rule. Up until the next record that contains a "Y" in this field, all records that follow this one represent linking fields for the same RI rule. Notice that there is a field called Delete. This field is used to define the delete update rule. If this field has a "Y" value, Paradox cascades deletes to subordinate records.


Wait a minute! Paradox does not support cascading deletes! If it did, you would not be reading this. The existence of this field is extremely interesting. It appears as if Borland intended to implement cascading deletes in Paradox but later decided not to implement the feature. An enumRefIntStruct table for the Animal table is shown in Figure 21. You can see that it has two RI rules. The first one is called Farm. It is a link to the table "H:\DELETE\FARM.DB". The "Y" in the Slave field makes it known that Animal is subordinate to Farm. Animaluser's first field, Farm, is linked to Farmuser's first field called Name. The second rule is called Animal and is with "H:\DELETE\FEED.DB". Animal is a master table to Feed. They are linked by two fields. There is a link between their first fields both of which are called Farm and their second fields that are both called Name. It is apparent that the Name field is part of the same RI rule because the value of the infoHeader field in that record is "N".


<strong>Figure 21: </strong>The referential integrity data generated 
          from the animal table with enumRefIntStruct. Notice the field called "Delete".

Figure 21: The referential integrity data generated from the animal table with enumRefIntStruct. Notice the field called "Delete".


Make it faster. Lock the tables.

The tCursor pointing to the enumRefIntStruct table is placed into a scan loop that only stops on the records representing the header record (infoHeader = "Y") for RI rules for subordinate tables (Slave = ""). A tCursor is opened on the subordinate table and then an attempt is made to place an exclusive lock on the table. If the exclusive lock fails, a write lock is tried. If either of these locks succeeds, the empty method may be used on the table. This means individual record locks will not be necessary.


The success of the table lock attempt is tracked in a dynamic array of type logical. The tag of the element is the name of the table and its value is true if a table lock succeeds, if not it is false (See Figure 18). In the first version of PurgeAll the system just placed individual record locks on each detail record and then deleted them. This is a slow process. With 300 records subordinate to a master record, 300 record locks were placed to purge the details. In order to speed things up, the system now uses the empty method whenever possible. If all tables can be locked, only one empty is required per table. This makes a tremendous difference in performance.

Creating a filter on the fly

The table lock attempt is followed by a loop that goes through the rest of the records for the RI rule. By using the field names listed in the enumRefIntStruct table, the code in the loop is able to reference the linking fields in the tCursor to get their values as shown in Figure 22. Using the linking values, it creates a dynamic array to be used in a setGenFilter statement. This filter will limit the scope of the tCursor to only those records subordinate to the master record.


         while not tcRefInt.eot()
; Quotes required so symbols are not interpreted as operators if not isBlank(tcMaster.(tcRefInt."aiThisTabField")) then dyFilt[tcRefInt."aiOthTabField"] = +tcMaster.(tcRefInt."aiThisTabField")+"\"" endif tcRefInt.nextRecord() if tcRefInt."infoHeader" = "Y" and not tcRefInt.eot() then tcRefInt.priorRecord() quitLoop endif endWhile

Figure 22: Creating a dynamic array to use as a filter for detail records.


Bug Alert: On the left of the equal symbol you will see a tCursor. The triple quotes ("\"") surrounding both sides of the tCursor reference are necessary due to a bug in Paradox. When referencing the value of an object by using parenthesis to complete the reference with a string, Paradox comes up with an anomaly during run time. The workaround for this is to place triple quotes around the entire reference so that Paradox sees it as a string. This is true even if the value assigned to the left side of the equal symbol is not a string.


Finding the right index name

Immediately following the loop is the switch statement in Figure 23. Each case statement in the switch attempts to place the filter on the table by referencing an index name. There is a syntax version of setGenFilter that allows for referencing an index. Since setGenFilter without an index is quite slow on large tables, it is critical to the performance of PurgeAll that an index be used whenever possible. Paradox for Windows requires a case insensitive index on the linking field(s) in an RI relationship. When a referential integrity rule is defined, and an index on the linking fields does not exist, Paradox creates the index. Assume for the following discussion that the case insensitive index does not already exist for the linking fields. If the link is on only one field, the name of the index Paradox creates is the same as the field name. This is a standard enforced by Paradox for any case insensitive index based on only one field. If the link consists of more than one field, the index created is given the same name as the RI rule. If a case insensitive index was already in existence for the linking fields, Paradox will simply use it. The problem with this index is that there is no way of knowing its name. The first case statement tries setGenFilter and uses the referential integrity name as the index name. If that fails, the next case statement uses the field name. This is followed by the primary index. While the primary index may not be the linking index, all of the other knowable possibilities have been tried. Since any table that is part of a referential integrity rule will always have an index, this will always work. Most likely the linking index is one of the three possibilities but if it is not, setGenFilter will still work on the primary index, it will just perform more slowly.


   
         switch
            ; Secondary index by ref. int. name
            case tcDet.setGenFilter(tcRefInt."RefName",dyFilt) :

            ; Secondary index by field name (one field and case 
            ; insensitive)
            case tcDet.setGenFilter(tcRefInt."aiOthTabField",dyFilt) 

            ; Primary
            case tcDet.setGenFilter("",dyFilt) :

            otherwise :
               ; Can't find index name, none used
               if not tcDet.setGenFilter(dyFilt) then
                 errorShow()
                 return GDLFails()
               endif
         endSwitch
         

Figure 23: Placing a filter on the detail table based on linking value. Note the comments before each case statement.


One thing to beware of when using setGenFilter is that all values placed in a filter must be valid filter expressions or they will cause a Paradox error.  Test them interactively first.


Recursion : Have you ever heard of dejá vu?

There is now a filter on the detail table tCursor that limits its scope to those records subordinate to the master record. An enumRefIntStruct is run against the tCursor bound to the detail table, tcDet in Figure 24. The resulting tCursor is filtered for subordinate tables (infoHeader = "Y" and Slave = ""). If there are any tables subordinate to this detail table, getDetailLocks is recursively called. The current tCursor that is scanning the detail table becomes the master record for this recursive call to getDetailLocks (Figure 24). This is how the PurgeAll is able to perform cascading deletes even down multiple levels.


         tcDet.enumRefIntStruct(tcDetRefInt)
            if dyRIFilt.size() = 0 then
               dyRIFilt["infoHeader"] = "Y"
               dyRIFilt["Slave"] = "Blank"
            endif
            if not tcDetRefInt.setGenFilter(dyRIFilt) then
               errorShow()
               return GDLFails()
            endif
            scan tcDet :
               if tcDetRefInt.cCount(1) > 0 then
                  if not getDetailLocks(tcDet,dyTableLocks,aryLockDetails, liDetailRecs, lMsg, lUI) then
                    return GDLFails()
                  endif
               endif
         

Figure 24: Have you ever felt like you've been here before? Calling getDetailLocks within the getDetailLocks method.


Inside, outside, does it really matter?

The place variables are declared is extremely important when using recursion. Generally it is safe, and even advisable, to declare variables outside the method statement on methods that are repeatedly called. The variables remain in memory and are simply reused the next time the method is called. This is preferable over declaring them inside the method statement where they are released as soon as the method ends. To do so on a frequently called method is to release and rebuild the variables every time the method is called.


Every time one method or procedure calls another, it adds another level of suspended routines to memory. The various levels of suspended routines are collectively referred to as the call stack. When working with recursion, there are far more imposing issues involving variable declaration than whether or not the variables will be repeatedly built. If a variable is declared outside the method statement, it is then global to each instance of the method in the call stack. Modification of a variable effects its value all the way through the call stack. This can have catastrophic results. For example, a method in the first iteration sets a variable to "Value1". It has code that follows the recursive call (the call to itself) that depends on the variable retaining its value. If any recursive iteration of the method changes the variable value to "Value3", that is the value the variable will have when control returns to the first iteration. When using recursion it is necessary to ensure that a variable will not be modified by another instance of the method higher in the call stack. For this reason, always declare variables in the method statement when using recursion. This makes the variables private to each instance of the method.


Sometimes declarations are made both inside and outside the method statement as with purgeAll in Figure 25. The purgeAll method is not recursively called. In this case the array of tCursors and the dynamic array, have a varying number of elements every time the purgeAll method is called. Since reusing the elements cannot be relied on, it is better to release the arrays every time the method ends. The other variables may as well be reused, and so, remain in memory. They will be released when the library is closed.


         var
            siOldRetry  smallInt
            siError, liDetailRecs, liDel  longInt
            tcRefInt, tcDet   tCursor
            strError string
            anyLinkVal  anyType
            fmMsg,fmPC  form
         endVar
         method purgeAll(var eventInfo actionEvent, var tcSelf tCursor, 
          const lMsg logical, const lUI logical) logical
         var
            dyLockedTables   dyLogical
            aryLockedDetails   aryTC ; aryTC = array[] tCursor
         endVar
         

Figure 25: In this declaration of purgeAll, variables are declared both inside and outside the method statement. The arrays, particularly the tCursors, must be released so they are declared inside the method. There is no need to release the others until the library is closed.


Skip, empty or lock the record?

The first "if" in Figure 26 comes after the recursive call to getDetailLocks (Figure 24). As previously mentioned, the recursive call deals with all detail records to the record currently being scanned. This "if" in Figure 26 states that if the table lock has been obtained and it is not on the last record of the set of records for the linking value, loop. This skips creating a tCursor for this record. That is because the last record of the set is the one that will be used to empty the table.

  
         ; A table lock was obtained, therefore empty can 
         ; be used instead of individual record locks. Only
         ; perform the lock on the last record for this set 
         ; of linked records.
         if dyTableLocks[tcDet.tableName()] and not tcDet.atLast() then
            ; Looping here will skip both creating a tCursor for 
            ; this record and attempting to lock it. Empty on the
            ; last record of the set will take care of them all.
            loop
         endif

         aryLockDetails.grow(1)
         liDetailRecs = aryLockDetails.size()
         if not aryLockDetails[liDetailRecs].attach(tcDet) then
            errorShow()
            return GDLFails()
         endif
         *
         *
         *
         if not lockRec(aryLockDetails[liDetailRecs], lUI) then
            return GDLFails()
         endif
         

Figure 26: The last "if" adds an another array element to an array of tCursors and attaches it to the tCursor scanning the detail table. The embedded if at the top skips the whole process (see comments). The last if passes the new array element/tCursor to lockRec attempt locking.


Creating new tCursors on the fly

If the conditions in the previous paragraph were not met, a tCursor is added to memory by growing a resizeable array of type tCursor (declared in purgeAll in Figure 25). The ability to declare an array of tCursors is critical to PurgeAll. As the tCursor scanning the detail table (tcDet) moves to another detail record, a new tCursor is spawned by resizing the array as illustrated in Figure 26. This tCursor/array element is attached to the tCursor that is scanning the detail table.


Remember that tcDet is filtered on the master recorduser's linking values. The new tCursor will point to the same record and inherit the same filter. If the table locks are in place for this table, empty can be used to delete all records with the same linking value. In this case the system loops to the next detail record.


If the table locks are not in place, an attempt is made to lock the new tCursor. As shown in Figure 26, if it fails the purge is abandoned and all record locks are released. If the record lock succeeds, the system continues on to the next detail record.


An additional reason a lock can fail

There are two basic reasons why the lock could fail. 1) Another user has a restrictive table lock. 2) Another object either on another workstation or even on the useruser's own Paradox desktop has the record locked. These are fairly obvious possibilities. There is a less well known reason that the record could not be locked: too many record locks on the table.


What does this mean in terms of PurgeAll? It can delete an unlimited number of records if an exclusive or write lock can be placed on the table by using the empty method. If any other user on the LAN has a record lock on detail table needing to be purged, neither an exclusive nor a write lock will be possible to obtain. If this is the case, the 64 record limit will be in place. Figure 27 shows the dialog box displayed by PurgeAll when it encounters more than records than it may lock. Efforts were made to overcome this problem (see Trying to overcome the 64 lock constraint), but for the time being 64 record locks per table remains a limitation.


<strong>Figure 27:</strong> Too many record 
         locks. No more than 64 are possible per table, per session against Paradox tables

Figure 27: Too many record locks. No more than 64 are possible per table, per session against Paradox tables


Finishing Touches

getDetailLocks completes the series of recursive scans. If no errors are found, all of the records are either individually locked or covered by a table lock so they may be deleted with empty on a filtered tCursor. Control returns back to purgeAll.


The purgeAll method now simply uses a "for" loop to step through every tCursor in the array of tCursors. At each pass the system checks to see if a table lock has been acquired for table being referenced by the tCursor. If so, empty is used and all of the records for the linking value are removed. If not, the tCursor is holding a record lock and deleteRecord is executed against it.


Since the deleting is in a finite loop, a decrementing total is possible. Such a total is displayed on the status bar while a running percentage displays in the dialog box shown in Figure 28.


<strong>Figure 28: </strong>The locks are in and the deletions are taking place.

Figure 28: The locks are in and the deletions are taking place.


After running through all of the tCursors in the array, the master record is deleted and the purge is complete. One of the last things that purgeAll does is set the id component of the eventInfo packet to dataRefresh. This does two things. First, because purgeAll deleted the record, changing the value of id stops the delete action in the built-in method action. This is particularly important since the record has already been deleted by purgeAll and a deletion would delete not the record the user had been on, but the following record. It also changes the action from deleting to updating the screen so that the useruser's screen will immediately reflect the change.


Conclusion

To say there are a vast number of ObjectPAL issues in PurgeAll is almost an understatement. Major topics are covered here that include scoping issues, table and record locking, using arrays, passing arguments and many more. Hopefully there are some techniques here that will prove useful in your systems. Remember to program as defensively as possible and above all be creative. PurgeAll is another of the many examples of the power and flexibility of ObjectPAL.


It obvious that Borland set out to employ cascading deletes in Paradox for Windows. Had it been deployed, it is probable that it would have been limited to a single level cascade as is the case with updating key values. Even if you are loathe to allow your users to perform cascading deletes, records will have to be deleted eventually, even if only by a database administrator. In any complex database design that employs normalization, you are bound to have multi-level relationships. PurgeAll will turn the previously arduous task of deleting a family of records into a simple keypress.


Download the Assocated ObjectPAL Files

Building systems that last...