Skip to end of metadata
Go to start of metadata

You are viewing an old version of this page. View the current version.

Compare with Current View Page History

« Previous Version 8 Next »

There are various situations when editing data takes time. In case of many people working on the same dataset, is not unusual that one or more of them will end up editing the same data, each of them being unaware of the fact that maybe is overwriting data other people just saved. In order to overcome this, we can use the DataSource's functionalities to detect when this concurrent save appears, in order to notify the user that the record he edited was already changed by someone else, allowing him to overwrite the record with his own changes, continue editing or revert the changes he made. Let's see how this can be done. 
To keep it simple and small, we will start from the builtinDS sample, to which we'll add an additional DataSource which will be checking for concurrent editing and we'll add the required code to allow user to make decisions about what should be done with the data.

Description

There are various situations when editing data takes time. In case of many people working on the same dataset, is not unusual that one or more of them will end up editing the same data, each of them being unaware of the fact that maybe is overwriting data other people just saved. In order to overcome this, we can use the DataSource's functionalities to detect when this concurrent save appears, in order to notify the user that the record he edited was already changed by someone else, allowing him to overwrite the record with his own changes, continue editing or revert the changes he made. Let's see how this can be done. 

To keep it simple and small, we will start from the builtinDS sample, to which we'll add an additional DataSource which will be checking for concurrent save and we'll add the required code to allow user to make decisions about what should be done with the data.

The following part of the document assumes you have already successfully imported the sample in Eclipse and you can run it without errors. Please refer to the README file in the builtinDS sample project for steps to import it.

Creating the new DataSource and the required test data

  • locate the war/ds/ folder in the project explorer and copy supplyItem.ds.xml to a new file called supplyItemLong.ds.xml
  • locate the war/ds/test_data/ folder in the project explorer and copy supplyItemLarge.data.xml to supplyItemLong.data.xml
  • locate the war/ds/test_data/ folder in the project explorer and copy supplyItemLarge.data.xml to supplyItemLargeLong.data.xml
  • open supplyItemLong.ds.xml for editing and change the following attributes to reflect the id of the newly created datasource and the new data to be imported:

Attribute name

Value

ID

supplyItemLong

tableName

supplyItemLong

dbImportFileName

supplyItemLargeLong.data.xml

(Small side note: although the sample has absolute path for dbImportFileName, seems like for me when importing the DataSource, the import tool won't see the test data unless it has specified as in the table)

Creating the database table and importing test data

We are going to create a database table for this newly declared DataSource and import some test data into it. For this we'll have to go to the Admin Console. For this, launch the application and then navigate to http://127.0.0.1:8888/builtinds/tools/adminConsole.jsp link.
In the opened web page, go to 'Import DataSources' tab and there select 'supplyItemLong' DataSource. Verify to have in the 'Test Data' column 'yes'. If it is 'no' then check the file names in the previous steps. Once selected, locate the 'Import' button on bottom of the page and click it. (Leave checked both checkboxes to create test data and generate the table).

If all goes well, you will be notified by a popup window that DataSource was imported. Dismiss the window.

You can double-check if all went well by selecting the 'Database Configuration' tab, from there select your database name and then click onto 'Browse' button. In the newly displayed window select your database and on the left bottom grid you should have SUPPLYITEMLONG table. By clicking onto it, you should see the imported test data in the grid on the right.

Modifying the sample for detecting concurent editing

First, we need to make the code use the newly defined DataSource. For this locate the BuiltinDS.html file in the war folder and open it for editing. Locate inside the file the lines loading the DataSources:

<script src="builtinds/sc/DataSourceLoader?dataSource=supplyItem,animals,employees"></script>

and change it to also load our new DataSource:

<script src="builtinds/sc/DataSourceLoader?dataSource=supplyItem,supplyItemLong,animals,employees"></script>

Next, we'll have to wire in the new DataSource in the user interface, to allow users to interact with. For this, edit the BuiltInDS.java file in the com.smartgwt.sample.client package and locate the following code sequence, which sets the records for the listgrid showing the available DataSources:

grid.setData(new ListGridRecord[]{
                new DSRecord("Animals", "animals"),
                new DSRecord("Office Supplies", "supplyItem"),
                new DSRecord("Employees", "employees")}
        );

and change it to following (a new line was added with Long Running Edit datasource)

grid.setData(new ListGridRecord[]{
                new DSRecord("Animals", "animals"),
                new DSRecord("Long Running Edit", "supplyItemLong"),
                new DSRecord("Office Supplies", "supplyItem"),
                new DSRecord("Employees", "employees")}
        );

Rebuild the application and start it. At this moment you should have a new DataSource named Long Running Edit which can be selected, and when selected, the data will appear in the grid. It will be the same data as the one for Office Supplies.

Time to work on detecting concurrent changes. For this we will use a nice feature of DSRequest: on each update, DSRequest besides to the newly changed data also contain the old data which was changed. In order to detect concurrent changes, we'll fetch the record just about to be updated from the database and we'll compare it against the old data received in DSRequest. If they don't match somebody changed the record we were editing, so we'll abort the update process and return back the latest record found in the database together with an arbitrary error code (-74).

So, first let's subclass  SQLDataSource by creating LongDataSource in the com.smartgwt.sample.server package:

package com.smartgwt.sample.server;

import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;

import com.isomorphic.datasource.DSRequest;
import com.isomorphic.datasource.DSResponse;
import com.isomorphic.sql.SQLDataSource;
import com.isomorphic.util.ISCDate;

public class LongDataSource extends SQLDataSource
{
	private final static int MILLISECONDS_IN_DAY = 1000*60*60*24;

	private static final long serialVersionUID = 1L;

	private static Logger logger = Logger.getLogger(LongDataSource.class.getName ());

	public DSResponse execute(DSRequest request) throws Exception
	{
		if (request.getOperationType().equals("update"))
		{
			// get the entire list of fields in this DataSource
			List fields = request.getDataSource().getFieldNames();

			// create a DSRequest to fetch the record we have to update and execute it
			DSRequest first = new DSRequest();
			first.setDataSource(request.getDataSource());
			first.setCriteria(request.getCriteria());
			first.setOperationType("fetch");
			DSResponse record = first.execute();

			// check if there was any change in database while the user was editing the data
			// for each key we have to check if is defined in both nw and old and also if their
			// value is same.
			Map old = request.getOldValues();
			Map nw = record.getRecord();

			Iterator it = fields.iterator();
			while( it.hasNext())
			{
				String key = (String)it.next();

				if (!compareRecordValues(old.get(key), nw.get(key)))
				{
					logger.info("Difference key="+key+" old="+old.get(key)+" new="+nw.get(key));

					// Difference was found.
					// return the latest record, so the client side can display it to the user
					record.setStatus(-74);
					return record;
				}
			}
		}

		// if no change was found or the operation is not update, execute the standard code
		return super.execute(request);
	}

	/**
	 * Compare two record values to check if they're same or not. Comparing them is not easy since both
	 * values can be different type representing the same thing, for example Integer and String with same value
	 * or Boolean and String, or ISCDate and Date, or Date and String and so on, so conversions are required
	 * in order to be able to compare them
	 *
	 * @param v1 first value to be checked
	 * @param v2 second value to be checked
	 *
	 * @return true if the two values match, false otherwise
	 */
	private boolean compareRecordValues(Object v1, Object v2)
	{
		// if both are null, they're equal
		if (v1 == null && v2 == null)
		{
			return true;
		}

		String c1 = "null";
		String c2 = "nul";
		if (v1 != null)
		{
			c1 = v1.getClass().getName();
		}
		if (v2 != null)
		{
			c2 = v2.getClass().getName();
		}

		logger.info("Comparing "+v1+" type of "+c1+" with "+v2+" type of "+c2);

		// if one of them is null and the other isn't, then they're not equal
		if ((v1 != null && v2==null ) || (v1 == null && v2 != null))
		{
			return false;
		}

		// if both of them have the same class, compare them with equals()
		if (v1.getClass() == v2.getClass())
		{
			logger.info("Both values have same class, comparing with equals()");

			return v1.equals(v2);
		}

		// if one of items is ISCDate, convert both of them to ISCDate
		if (v1 instanceof ISCDate|| v2 instanceof ISCDate)
		{
			ISCDate d1 = null;
			ISCDate d2 = null;

			logger.info("Converting both of them to ISCDate before compare");
			// v1 is ISCDate, convert v2
			if (v1 instanceof ISCDate)
			{
				d1 = (ISCDate)v1;

				if ( v2 instanceof Date)
				{
					d2 = new ISCDate(((Date)v2).getTime());
				}
				else
				{
					// not date, might be string or something else?
					d2 = new ISCDate(new Date(v2.toString()).getTime());
				}
			}
			else
			{
				// v2 is ISCDate, convert v1
				d2 = (ISCDate)v2;

				if ( v1 instanceof Date)
				{
					d1 = new ISCDate(((Date)v1).getTime());
				}
				else
				{
					// not date, might be string or something else?
					d1 = new ISCDate(new Date(v1.toString()).getTime());
				}
			}

			logger.info("d1="+d1.toString()+" d2="+d2.toString());

			if (Math.abs(d1.getTime()-d2.getTime()) < MILLISECONDS_IN_DAY)
			{
				return true;
			}

			return false;
		}

		/* additional comparing removed to keep the code section small. They can be found in the attached archive */

		// if no one of previous conditions were true, probably the fields don't match
		return false;
	}
}

This being done, we'll need a way to make our client side DataSource to use this subclassed DataSource. We can accomplish this by adding

serverConstructor="com.smartgwt.sample.server.LongDataSource"

to the supplyItemLong.ds.xml DataSource attributes, making it look like this:

<DataSource
    ID="supplyItemLong"
    serverType="sql"
    tableName="supplyItemLong"
    titleField="itemName"
    testFileName="supplyItem.data.xml"
    dbImportFileName="supplyItemLarge.data.xml"
    serverConstructor="com.smartgwt.sample.server.LongDataSource"
>
    <fields>
        <field name="itemID"      type="sequence" hidden="true"       primaryKey="true"/>
        ...

At this point, server side is set up and the client side should use the newly created LongDataSource class. If concurrent save occurs, it is detected and an error popup is displayed on the client side (you can check this by opening two browser tabs, start editing a line in the grid, then in the second tab edit the same record, save it and then go back to first tab and try to save this also. You should be notified that error code -74 occurred on server side).

On the client side however, there is some more work to be done. We need to catch the error returned by the server and make use of the latest data returned by the server. How this can be done depends on the component. However once we got called in the error handler and if it is for our arbitrary error code then we handle it by displaying a popup window to user with the database values and his values and allowing him to overwrite database values, continue editing or abort his changes. How the 'overwrite values' part works, also depends on component, but let's do it step by step.

Since the same window will be also used for the dynamic form, we will make it as a separate component, called ConcurrentConflictDialog. It will use the two records passed at construction time (server values and edited values) to inform the user about the values in the database and about the values edited and by a callback which is also passed as parameter, can call a handler to perform an action based on user's choice.It's omitted from this article however you can take a look into it by following these links: ConcurrentConflictDialog.java ConcurrentConflictDialogCallback.java  or by checking them in the attached document.

Let's see how can we use this window for our grid and our form.

Changing ListGrid to detect concurrent save

For detecting the error code sent back by the server, we will attach the edit failed handler to the ListGrid in the BuiltInDS.java:

// add an error handler to handle edit failed error
        boundList.addEditFailedHandler(new EditFailedHandler() {

			@Override
			public void onEditFailed(final EditFailedEvent event) {

				if (event.getDsResponse().getStatus() == -74)
				{
					// get some data we're going to use
					final int rowNum = event.getRowNum();
					final Record serverValues = event.getDsResponse().getData()[0];
					final Record editedValues = boundList.getEditedRecord(rowNum);
					final String[] fields = boundList.getDataSource().getFieldNames();

					final ListGridRecord oldValues = boundList.getRecord(rowNum);

					// create and display a conflict dialog to allow user see database record, his changed
					// record and choose how to resolve the conflict
					final ConcurrentConflictDialog dlg = new ConcurrentConflictDialog(serverValues, editedValues, fields, new ConcurrentConflictDialogCallback() {

						/**
						 * This will be called when user choose a option on the conflict dialog
						 */
						@Override
						public void dialogClosed(UserAction userAction) {

							if (userAction == UserAction.KEEP_EDITING)
							{
								// if user wants to continue editing, start editing on the list record
								boundList.startEditing(rowNum, 0, true);
							}
							else if (userAction == UserAction.DISCARD_CHANGES)
							{
								// if user wants to discard all changes, then we discard changes
								// on the row then cancel editing
								boundList.discardAllEdits(new int[]{rowNum},false);
								boundList.cancelEditing();
							}
							else if (userAction == UserAction.SAVE_ANYWAY)
							{
								// user wants to overwrite the database values
								// first set the old values to the values we're received from the server
								for(int i=0;i<fields.length;i++)
								{
									oldValues.setAttribute(fields[i],
										serverValues.getAttribute(fields[i]));
								}

								// perform save once more, this time the server side should not detect
								// any change as old values will match the database record (unless of
								// course somebody changed them yet again meanwhile)
								boundList.saveAllEdits(null, new int[]{rowNum});
							}
						}
					});

					dlg.show();
				}
			}
		});

Code is straight forward. We get the server values and the edited values, then we'll create the conflict dialog passing him also a callback. In this callback, depending on the user's action we perform the intended operations:

  • If user opts for continuing the edit process, then he will be returned to editing the row.
  • If opts for discarding changes, all changes on the given row will be discarded and the editing process will be cancelled.
  • Lastly, if user opts to overwrite the new data with his data, then we iterate over the edited fields and set them as attributes in the ListGrid record being edited, which will have as effect setting them as old values on next save. After this we will save again the row we're editing. This time - assuming no one changed the record meanwhile - because of the previous attribute setting, the old values will match the ones in the database, meaning no concurrent edit will be detected, so save will be successfully performed. However if meanwhile someone edits once more the record, then the received old values won't match the yet once again changed database record, so the entire cycle is restarted by returning the new database record to the user which again is notified about the concurrent editing issue and the new database values.

Changing DynamicForm to detect concurrent save

Changing  DynamicForm to intercept the response code from the server requires a bit more work, but not that much. First we need to tell the form to not suppress the validation errors. For this we'll add:

boundForm.setSuppressValidationErrorCallback(false);

to BuiltInDS.java, right after boundForm is created. Next, we will create a method which will take care of saving the form itself:

/**
     * Helper method to save the bound form
     *
     * @param oldValues the old values of the fields. If this is specified, before the
     * save request is made, form field old values will be set to these values. This is used
     * when user choose to overwrite the database values with his own, case in which the
     * database values have to become old values in order to perform a successful save
     * (otherwise save will fail as current old values will be different than the ones in the
     * database record).
     */
    private void saveForm(Record oldValues)
    {
    	// this is required in order to be able to handle errors on server side
        com.smartgwt.client.data.DSRequest req = new com.smartgwt.client.data.DSRequest();
    	req.setWillHandleError(true);

    	// if we're saved with old value, then set them up in the request
    	if (oldValues != null)
    	{
    		req.setOldValues(oldValues);
    	}

    	// save form data
    	boundForm.saveData(new DSCallback(){

			@Override
			public void execute(DSResponse response, Object rawData,
					com.smartgwt.client.data.DSRequest request)
			{
				// check if we have a concurrency issue
				if (response.getStatus() == -74)
				{
			    	// set up some data we're going to use later in the dialog
			    	final Record editedValues = boundForm.getValuesAsRecord();
					final String[] fields = boundList.getDataSource().getFieldNames();
					final Record serverValues = response.getData()[0];

					// create a conflict dialog and display it to the user
					final ConcurrentConflictDialog dlg = new ConcurrentConflictDialog(serverValues, editedValues, fields, new ConcurrentConflictDialogCallback() {

						/**
						 * This will be called when user choose a option on the conflict dialog
						 */
						@Override
						public void dialogClosed(UserAction userAction) {

							if (userAction == UserAction.KEEP_EDITING)
							{
								// no action needs to be performed if user wants to keep editing
								return;
							}
							else if (userAction == UserAction.DISCARD_CHANGES)
							{
								// if user wants to discard changes, we cancel editing and
								// clear form values
								boundForm.cancelEditing();
								boundForm.clearValues();
							}
							else if (userAction == UserAction.SAVE_ANYWAY)
							{
								// User wants to overwrite the database values.
								// save the form once more, with the server sent values as old values
								saveForm(serverValues);
							}
						}
					});

					dlg.show();
				}

				// if operation was successful, then clear form values and disable save button
				if (response.getStatus() == DSResponse.STATUS_SUCCESS)
				{
                                   boundForm.clearValues();
                                   saveBtn.disable();
                                }
			}

    	}, req);
    }

The code follows the same route as the error handler attached to the ListGrid, however first we need to make sure our handler will be called in case of error. For this we create a DSRequest and configure it accordingly. As an additional difference, if there are old values to be set (previously the user opted to overwrite the database values) we will have to set them into this newly created  DSRequest in order to make them available on the server side - as opposed to the ListGrid, where we made them available by setting attributes.

Also as a note to clarify the flow, when user opts for overwriting the database values, we just simply call the save function once more, passing the values sent us by the server to be set as old values.

If save is performed successfully, we will clear the form values and disable the save button.

This being said, all remaining to be done is to refactor the save button action handler to call our function, so replace the code with this:

saveBtn.addClickHandler(new ClickHandler() {

            public void onClick(final ClickEvent event) {
				saveForm(null);
            }
        });

We pass null as parameter, since initially we don't have any old values from the server.

Testing the functionality

The functionality can be tested with two open windows. After selecting our Long Running Edit DataSource in both, start editing one row in one of the windows, but don't submit the form. Then go to the second page and change the same row you're editing in the first window and save the changes. Then go back to the first window and save the changes there also. Concurrent change should be detected and you should get something like this:

Some notes:

  • in the LongDataSource execute() method, probably each data type which can be used in DataSource need to handled accordingly. Some cases are handled, but might require handling additional cases. I've left some debugging code which should help identifying these situations.
  • in LongDataSource, you have to override execute() method. Overriding execute_udpdate() does not seem to work.
  • Fields which are or will be set to null won't show up in Record sometimes, so when you have to iterate over the record for setting old values for all fields, is best to retrieve the list of fields from the DataSource.

A eclipse project with all the required files can be downloaded from here.builtinds-concurentChanges.zip. It will require however to create the datasource and import the sample data as described in this document.

  • No labels