Skip to content

Instantly share code, notes, and snippets.

@bennadel
Created March 25, 2014 11:40
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bennadel/9760043 to your computer and use it in GitHub Desktop.
Save bennadel/9760043 to your computer and use it in GitHub Desktop.
Experimenting With Offline Data Synchronization Using jQuery And ColdFusion
<!DOCTYPE html>
<html>
<head>
<title>Offline Data Sync Exploration</title>
<script type="text/javascript" src="./jquery-1.4.2.js"></script>
<script type="text/javascript">
// I am the collection of girls (this is meant to represent
// the local database store - typically we would use a
// SQLite database, but just trying to keep it simple).
var girls = [];
// DOM references (to be set when DOM is ready).
var dom = {
table: null,
sync: null,
form: null
};
// -------------------------------------------------- //
// -------------------------------------------------- //
// Create a girls auto-incrementing id.
girls.autoID = 0;
// Create a key for the last audit sync. This key will be
// used to track what changes we have taken from the live
// server.
girls.lastAuditID = 0;
// Create an array to hold the local audits that need to
// be pushed to the live server. We need this in order to
// be able to track DELETE actions that are no longer
// present in the girls collection.
girls.audit = [];
// Create helper function to lookup girls by local ID.
girls.getByID = function( id ){
// Loop over the girls to find the record with the
// given ID value.
for (var i = 0; i < this.length; i++){
// Return the girl if it has the matching ID.
if (this[ i ].id == id){
return( this[ i ] );
}
}
// If we made it this far, then the girl could not
// be found - just return NULL.
return( null );
};
// Create helper function to lookup girls by GUID.
girls.getByGUID = function( guid ){
// Loop over the girls to find the record with the
// given GUID value.
for (var i = 0; i < this.length; i++){
// Return the girl if it has the matching GUID.
if (this[ i ].guid == guid){
return( this[ i ] );
}
}
// If we made it this far, then the girl could not
// be found - just return NULL.
return( null );
};
// Create a helper function to locate a girl by ID. Unlike
// the findByID() method, this one returns the location,
// not the actual girl object.
girls.findByID = function( id ){
// Loop over the girls to find the record with the
// given ID value.
for (var i = 0; i < this.length; i++){
// Return the current location if the girl has a
// matching ID.
if (this[ i ].id == id){
return( i );
}
}
// If we made it this far, then the girl could not
// be found - just return -1 for location.
return( -1 );
};
// I delete the girl with the given ID.
girls.deleteByID = function( id ){
// Find the girl's location.
var index = this.findByID( id );
// Check for a valid location.
if (index >= 0){
// Remove girl from the collection.
var removedGirls = this.splice( index, 1 );
// Track the girl audit.
this.audit.push({
guid: removedGirls[ 0 ].guid,
action: "delete"
});
// Return true - delete successful.
return( true );
}
// Return false - delete was not successful.
return( false );
};
// I save teh given girl.
girls.save = function( girlData ){
// Check to see if we are dealing with an existing girl
// record or creating a new one.
if (girlData.id != ""){
// Get the existing girl that we need to update.
var girl = this.getByID( girlData.id );
// Update the properties.
girl.name = girlData.name;
girl.age = girlData.age;
} else {
// Create a new girl object. Notice that we are
// including boolean for sync status.
var girl = {
id: ++this.autoID,
name: girlData.name,
age: girlData.age,
guid: (girlData.guid || ("guid" + (new Date().getTime())))
};
// Add the girl to the local (offline) collection.
this.push( girl );
}
// Track the girl audit; but, only do this is there is
// no GUID value. The reasoning here is that the only
// time a GUID will be passed-in is if the update is
// coming from the **server** and not from the local UI.
// Not the best approach, but I am still learning.
if (!girlData.guid) {
this.audit.push({
guid: girl.guid,
name: girl.name,
age: girl.age,
action: "update"
});
}
// Return the girl object.
return( girl );
};
// -------------------------------------------------- //
// -------------------------------------------------- //
// I render the table based on the girls collection.
function renderGirls(){
var tbody = dom.table.find( "tbody" );
// Clear out any current girls.
tbody.empty();
// Loop over the girls to create a row for each record.
for ( var i = 0 ; i < girls.length ; i++ ){
// Get a reference to the current girl object.
var girl = girls[ i ];
// Append the record as HTML.
tbody.append(
"<tr rel='" + girl.id + "'>" +
"<td>" + girl.id + "</td>" +
"<td>" + girl.name + "</td>" +
"<td>" + girl.age + "</td>" +
"<td>" +
"<a href='#' class='edit'>Edit</a> " +
"<a href='#' class='delete'>Delete</a>" +
"</td>" +
"</tr>"
);
}
}
// I edit the girl with the given ID.
function editGirl( girlID ){
// Get references to the inputs.
var id = dom.form.find( "input[ name = 'id' ]" );
var name = dom.form.find( "input[ name = 'name' ]" );
var age = dom.form.find( "input[ name = 'age' ]" );
// Get the girl reference.
var girl = girls.getByID( girlID );
// Move girl data into form.
id.val( girl.id );
name.val( girl.name );
age.val( girl.age );
// Focus the first field.
name.focus();
}
// I delete the girl with the given ID.
function deleteGirl( girlID ){
// Delete the girl.
girls.deleteByID( girlID );
// Re-render the data table.
renderGirls();
}
// I save the girl form.
function saveForm(){
// Get references to the inputs.
var id = dom.form.find( "input[ name = 'id' ]" );
var name = dom.form.find( "input[ name = 'name' ]" );
var age = dom.form.find( "input[ name = 'age' ]" );
// Make sure the form is valid.
if ((name.val() == "") || (age.val() == "")){
// The most basic of error handling for the demo.
alert( "Please complete the form!" )
// Return out since we don't want to process this
// form data until it is valid.
return;
}
// Save the girl object.
var girl = girls.save({
id: id.val(),
name: name.val(),
age: age.val()
});
// Clear the form inputs.
dom.form.find( "input" ).val( "" );
// Refocus the first input.
name.focus();
// Re-render the data table.
renderGirls();
}
// -------------------------------------------------- //
// -------------------------------------------------- //
// I sync the current data. This both PUSHES and PULLS data
// to and from the server to sync in both directions.
function syncGirls(){
// Push updates to the server and get update responses.
//
// NOTE: We are using the JSON object to convert the
// array of audits into a JSON string for posting. This
// JSON object is not available in all browsers.
$.ajax({
type: "post",
url: "./sync.cfm",
data: {
lastAuditID: girls.lastAuditID,
updates: JSON.stringify( girls.audit )
},
dataType: "json",
success: function( response ){
// Clear out the local audits.
girls.audit = [];
// Save the last audit ID.
girls.lastAuditID = response.lastAuditID;
// Commit the sync locally.
mergeUpdates( response.updates );
}
});
}
// I merge the live updates into the local collection.
function mergeUpdates( updates ){
// Loop over the updates to merge them.
for (var i = 0 ; i < updates.length ; i++){
// Get the current update.
var update = updates[ i ];
// Get the girl at the given GUID.
var girl = girls.getByGUID( update.guid );
// Check if this update is an update or a delete.
if (update.action == "update"){
// Update.
girls.save({
id: (girl ? girl.id : ""),
name: update.name,
age: update.age,
guid: update.guid
});
// This is a delete. Check to make sure we have the
// girl locally before we try to delete her.
} else if (girl){
// Delete.
girls.deleteByID( girl.id );
}
}
// Now that we have merged the udpates, re-render the
// the girls table.
renderGirls();
}
// -------------------------------------------------- //
// -------------------------------------------------- //
// When the DOM is ready, initialize the scripts.
$(function(){
// Get the DOM references.
dom.table = $( "#girls");
dom.sync = $( "#sync" );
dom.form = $( "#form" );
// Bind to the form to make sure that submits gets
// handled locally.
dom.form.submit(
function( event ){
// Prevent the default event since we are going
// to process it locally.
event.preventDefault();
// Save the form.
saveForm();
}
);
// Bind to the table to listen form action events.
dom.table.click(
function( event ){
var target = $( event.target );
// In any case, we want to prevent the default
// event since the table contains no valid links.
event.preventDefault();
// Check for edit action.
if (target.is( ".edit" )){
// Edit the girl with the given ID.
editGirl(
target.closest( "tr" ).attr( "rel" )
);
// Check for delete action.
} else if (target.is( ".delete" )){
// Delete the girl with the given ID.
if (confirm( "Delete girl?" )){
deleteGirl(
target.closest("tr").attr("rel")
);
}
}
}
);
// Bind the sync button.
dom.sync.click(
function( event ){
// Prevent the default link event.
event.preventDefault();
// Sync data with the server.
syncGirls();
}
);
});
</script>
</head>
<body>
<h1>
Offline Data Sync Exploration
</h1>
<!-- BEGIN: Data Table. -->
<table id="girls" border="1" cellspacing="2" cellpadding="5">
<thead>
<tr>
<th>
ID
</th>
<th>
Name
</th>
<th>
Age
</th>
<th>
<br />
</th>
</tr>
</thead>
<!-- This will be populated on Sync. -->
<tbody />
</table>
<!-- END: Data Table. -->
<p>
<a id="sync" href="##">Sync Offline Data</a>
</p>
<!-- BEGIN: Add/Edit Form. -->
<form id="form">
<!-- I am the *local* ID of the record. --->
<input type="hidden" name="id" value="" />
<p>
<strong>Name:</strong>
<input type="text" name="name" value="" size="30" />
</p>
<p>
<strong>Age:</strong>
<input type="text" name="age" value="" size="7" />
</p>
<p>
<button type="submit">Save</button>
</p>
</form>
<!-- END: Add/Edit Form. -->
</body>
</html>
<cfcomponent
output="false"
hint="I define the application settings and event handlers.">
<!--- Define the application settings. --->
<cfset this.name = hash( getCurrentTemplatePath() ) />
<cfset this.applicationTimeout = createTimeSpan( 0, 0, 30, 0 ) />
<!--- Define the request settings. --->
<cfsetting
requesttimeout="10"
showdebugoutput="false"
/>
<cffunction
name="onApplicationStart"
access="public"
returntype="boolean"
output="false"
hint="I initialize the application.">
<!--- Define the primary "data table" for this demo. --->
<cfset application.girls = queryNew(
"id, name, age, guid",
"cf_sql_integer, cf_sql_varchar, cf_sql_integer, cf_sql_varchar"
) />
<!---
Define a auto-increment value for girls. This helps us
to create new record IDs without a true datatable key.
--->
<cfset application.girlsAutoID = 0 />
<!---
Define the audit table for our primary data table. This
contains meta information about when the data table was
updated and what records were updated.
--->
<cfset application.girlsRemoteAudit = queryNew(
"id, guid",
"cf_sql_integer, cf_sql_varchar"
) />
<!---
Define a auto-increment value for girls audit. This
helps us to create new record IDs without a true
datatable key.
--->
<cfset application.girlsRemoteAuditAutoID = 0 />
<!--- Return true so the page can load. --->
<cfreturn true />
</cffunction>
<cffunction
name="onRequestStart"
access="public"
returntype="boolean"
output="false"
hint="I initialize the request.">
<!--- Check to see if we need to manually reset the app. --->
<cfif structKeyExists( url, "reset" )>
<!--- Reset the application. --->
<cfset this.onApplicationStart() />
</cfif>
<!--- Return true so the page can load. --->
<cfreturn true />
</cffunction>
</cfcomponent>
<!---
NOTE: We would typically single-thread this entire process since
it is, by definition, meant to sync with multiple clients. There
are obvious race conditions in this code; however, for the sake
of the demo, I am just going to leave out locking and assume that
only one request will be made at a time.
--->
<!--- Param the FORM variables. --->
<cfparam name="form.lastAuditID" type="numeric" />
<cfparam name="form.updates" type="string" />
<!---
The updates data has been posted as JSON data (representing
an array of update objects). Therefore, we need to deserialize
it into a native ColdFusion array.
--->
<cfset form.updates = deserializeJSON( form.updates ) />
<!---
Get the current Auto ID for the audits table so we can echo back
changes before the ones we are about to merge.
--->
<cfset currentMaxAuditID = application.girlsRemoteAuditAutoID />
<!---
The first thing we are going to do is deal with the updates made
by this client. If the updates conflict with a previous update,
they will be overridden - my conflict resolution is a first-come,
first-serve basis.
--->
<cfloop
index="update"
array="#form.updates#">
<!---
Check to see if this is a delete - if so, we can just remove
the record from the target table.
--->
<cfif (update.action eq "delete")>
<!---
Delete the record and store the result back into the
same variable (the easiest way to delete using ColdFusion
query of queries).
--->
<cfquery name="application.girls" dbtype="query">
SELECT
*
FROM
application.girls
WHERE
guid != <cfqueryparam value="#update.guid#" cfsqltype="cf_sql_varchar" />
</cfquery>
<!--- Add the audit. --->
<cfset queryAddRow( application.girlsRemoteAudit ) />
<!--- Set audit values. --->
<cfset application.girlsRemoteAudit[ "id" ][ application.girlsRemoteAudit.recordCount ] = javaCast( "int", ++application.girlsRemoteAuditAutoID ) />
<cfset application.girlsRemoteAudit[ "guid" ][ application.girlsRemoteAudit.recordCount ] = javaCast( "string", update.guid ) />
<!--- This is an update, not a delete. --->
<cfelse>
<!--- Keep track of if a record was found. --->
<cfset recordFound = false />
<!---
Let's loop over the existing girls to see if any of the
GUID values match. If so, we will update the record; if not,
we'll insert a new record. We are using this looping
technique simply cause Query of Queries does not have an easy
UPDATE feature.
NOTE: The GUID values are created on the client.
--->
<cfloop query="application.girls">
<!--- Check to see if this girl matches the update. --->
<cfif (application.girls.guid eq update.guid)>
<!--- Update the current record. --->
<cfset application.girls[ "name" ][ application.girls.currentRow ] = javaCast( "string", update.name ) />
<cfset application.girls[ "age" ][ application.girls.currentRow ] = javaCast( "int", update.age ) />
<!--- Flag that record was found. --->
<cfset recordFound = true />
<!---
Break out of this loop since we found a girl to
update (we will not need to update another record or
add a record for this girl).
--->
<cfbreak />
</cfif>
</cfloop>
<!---
Check to see the matching record was found. If not, then
we need to add a new Girl record.
--->
<cfif !recordFound>
<!--- Add a new row. --->
<cfset queryAddRow( application.girls ) />
<!--- Set values. --->
<cfset application.girls[ "id" ][ application.girls.recordCount ] = javaCast( "int", ++application.girlsAutoID ) />
<cfset application.girls[ "name" ][ application.girls.recordCount ] = javaCast( "string", update.name ) />
<cfset application.girls[ "age" ][ application.girls.recordCount ] = javaCast( "int", update.age ) />
<cfset application.girls[ "guid" ][ application.girls.recordCount ] = javaCast( "string", update.guid ) />
</cfif>
<!---
At this point, we either updated an existing girl record,
or we added a new girl record. In either case, let's add
a record to the audit table. The audit record does not
indicate whether it as an UPDATE/INSERT - it merely flags
that *something* was done to the given girl record.
--->
<cfset queryAddRow( application.girlsRemoteAudit ) />
<!--- Set audit values. --->
<cfset application.girlsRemoteAudit[ "id" ][ application.girlsRemoteAudit.recordCount ] = javaCast( "int", ++application.girlsRemoteAuditAutoID ) />
<cfset application.girlsRemoteAudit[ "guid" ][ application.girlsRemoteAudit.recordCount ] = javaCast( "string", update.guid ) />
</cfif>
</cfloop>
<!---
Now that we have merged in the incoming changes, we have to send
back any changes that have not been synced to the given client.
Let's get all the changes since the last audit ID (not including
the audits we just committed).
--->
<cfquery name="audits" dbtype="query">
SELECT
id,
guid
FROM
application.girlsRemoteAudit
WHERE
id > <cfqueryparam value="#form.lastAuditID#" cfsqltype="cf_sql_integer" />
AND
id <= <cfqueryparam value="#currentMaxAuditID#" cfsqltype="cf_sql_integer" />
ORDER BY
id ASC
</cfquery>
<!--- Create an array to hold the updates. --->
<cfset updates = [] />
<!--- Loop over the audits to build an array of return data. --->
<cfloop query="audits">
<!---
Query to see if the corresponding girl record still exists
for this audit record. Its existence will indicate the type
of audit we need to pass back.
--->
<cfquery name="girl" dbtype="query">
SELECT
*
FROM
application.girls
WHERE
guid = <cfqueryparam value="#audits.guid#" cfsqltype="cf_sql_varchar" />
</cfquery>
<!---
Check to see if the girl was found. If it was, we'll pass
the data back as an update; if not, we'll pass it back as
a delete.
--->
<cfif girl.recordCount>
<!--- Pass back an update action. --->
<cfset audit = {} />
<cfset audit[ "action" ] = "update" />
<cfset audit[ "name" ] = girl.name />
<cfset audit[ "age" ] = girl.age />
<cfset audit[ "guid" ] = girl.guid />
<cfelse>
<!--- Pass back a delete action. --->
<cfset audit = {} />
<cfset audit[ "action" ] = "delete" />
<cfset audit[ "guid" ] = audits.guid />
</cfif>
<!--- Append the audit object. --->
<cfset arrayAppend( updates, audit ) />
</cfloop>
<!--- Build the response object. --->
<cfset response = {} />
<cfset response[ "lastAuditID" ] = application.girlsRemoteAuditAutoID />
<cfset response[ "updates" ] = updates />
<!--- Stream the response back as JSON. --->
<cfcontent
type="application/json"
variable="#toBinary( toBase64( serializeJSON( response ) ) )#"
/>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment