Abstract
During these times of the pandemic, more and more activities have been shifting to remote work, here in Germany known as “home office”. This additional data traffic has been increasingly burdening the infrastructure and there is a requirement to keep certain functions of an application available, even in areas free of Wi-fi and mobile communications. In this blog post, the local IndexedDB is used to store app data. The SAP oData v2 model is extended so that a failed communication with the SAP backend is automatically recognized. Endpoint, HTTP verb and payload are cached in the IndexedDB and sent again as soon as the application is back online.
Also Read: SAP Fiori Application Developer Certification Preparation Guide
1. Basics
IndexedDB
The IndexedDB is a Javascript based database. It allows data and files to be saved locally. The data is structured and stored according to the same origin policy per host / port. No fixed columns are used here but objects with keys are stored instead.
Scope
The goal is a UI5 application that displays a simple list. The list allows you to add and delete entries when the SAP backend server is available and when there is no connection. The list also offers a synchronization function that transfers all data from the local IndexedDB to the SAP system. The entries from the SAP backend are displayed in the table. Likewise are the entries in the IndexedDB that could not to be save on SAP backend yet. Furthermore, the Model bound to the list, stores delete tokens for those entries, the user wanted to be deleted while there was no server connection. The application always works the same for the user regardless of the network status.
OData Service
The associated OData service has only the four fields that are shown in the table. It allows the “Query”, “Create” and “Delete” functions.
JS-Classes oData and IndexedDb
In the UI5 application, new classes for the oData Model and the IndexedDB are created.
The sap/ui /model/odata/v2/ODataModel is extended for the oData.js class.
The sap/ui/model/ json/JSONModel is used to access functions of the IndexedDb.js.
Both classes are later instantiated from Component.js.
Failed calls to the SAP backend should be intercepted directly in the OData model. To do this, the standard SAP Odata model must be extended. The required standard CRUD methods “Create”, “Read” and “Remove” are extended for this. Own methods are called in the success and error callbacks, which forward the information to the IndexedDB.
oData.js
/**
*@class oData
*@classdesc Extends OData Model and offers API to IndexedDB.
*@extends sap/ui/model/odata/v2/ODataModel
*/
sap.ui.define([
"sap/ui/model/odata/v2/ODataModel"
], function(ODataModel)
{
"use strict";
return ODataModel.extend("ui5.offlineFunct.model.oData", {
_oComponent: null,
constructor: function(sServiceURL, mParameters, oComponent)
{
this._oComponent = oComponent;
ODataModel.prototype.constructor.call(this, sServiceURL, mParameters);
},
…
2. Creation of the Model Classes
A global JSON model is created to access the functions of the IndexedDB. The database is opened in the constructor. If an open instance of the IndexedDB already exists, the current version is stored globally.
The metadata is loaded from the SAP backend using the extended oData model. Similarly, a JSON “table model” is created. This controls the loading and saving of data.
IndexedDb.js
sap.ui.define([
"sap/ui/model/json/JSONModel"
], function(Model)
{
"use strict";
var _instance = void 0;
var version = 0;
var IndexedDb = Model.extend("ui5.offlineFunct.model.IndexedDb", {
/**
*@description Constructor
*@memberOf IndexedDb
*@param {object} oComponent - Owner Component
*/
constructor: function(oComponent)
{
if (window.indexedDB === null)
{
console.error("Offline store not supported!");
return null;
}
this._oComponent = oComponent;
Model.prototype.constructor.call(this, {
"_meta": []
});
var request = indexedDB.open("localStorage");
request.onsuccess = function(oEvent)
{
IndexedDb._db = oEvent.target.result;
version = IndexedDb._db.version;
IndexedDb._db.close();
};
IndexedDb._instance = this;
},
Initialization
In order to be able to access the extended ODatamodel class, the model instantiation is removed from manifest.json …
"models":{
"i18n":{
"type":"sap.ui.model.resource.ResourceModel",
"settings":{
"bundleName":"ui5.offlineFunct.i18n.i18n"
}
}
},
…and transfered in the Component.js / models.js:
models.createServiceModel(sServiceUrl, null, this)
.then(function(oServiceModel)
{
oServiceModel.setUseBatch(false);
console.log("Backend connection established");
var networkModel = that.getModel("network");
oServiceModel.attachRequestSent(function()
{
networkModel.setBusy(true);
});
oServiceModel.attachRequestCompleted(function()
{
networkModel.setBusy(false);
networkModel.setProperty("/connected", true)
});
oServiceModel.attachRequestFailed(function()
{
networkModel.setProperty("/connected", false)
})
})
models.js:
createServiceModel: function(sServiceUrl, mParameters, oComponent)
{
return new Promise(function(resolve, reject)
{
try
{
var oServiceModel = new oData(sServiceUrl, null, oComponent);
oComponent.setModel(oServiceModel);
oServiceModel.setUseBatch(true);
oServiceModel.metadataLoaded()
.then(function()
{
resolve(oServiceModel);
});
} catch (err)
{
reject(err);
}
});
},
3. Read Data: Online / Offline
The data is read using the table and odata models. In addition, the “load” method of the TableModel checks whether a table exists in the IndexedDB under the same end point. If this is the case, it is also read out in order to be able to display the data from both sources.
The process that is initiated by reading the “Table1Set” entity is described below. This reading process is called and updated after each create and delete call. This also includes the data from the SAP backend and the IndexedDB. Event S1 symbolizes the start of the reading process.
View1.controller.js
Start the loading process via the table model:
/**
*@description Load Data
*@memberOf View1
*/
onLoadTable: function()
{
this.getView()
.getModel("TableModel")
.load("/Table1Set")
.then(function(data)
{
console.log("BE and INDEXEDDB loaded")
})
.catch(function(err)
{
console.error("Either BE or INDEXEDDB could not be loaded");
})
},
Table.js
Forwarding the request to the oData model:
/**
*@description Loads data from SPATH
* through extended odata Model from both backend and Indexed DB
*@param {string} sPath - Path to Data to be loaded
*/
load: function(sPath)
{
var oData = this.oComponent.getModel(),
that = this;
return new Promise(function(resolve, reject)
{
oData.read(sPath, {
success: function(response)
{
that.setProperty("/results", response.results);
resolve(response.results);
},
error: function(err)
{
reject(err);
}
});
});
},
oData.js
The read function is executed. In the “Success” case, the read result is forwarded to the “_readSuccessCallback” method to check whether data is also stored under the same path locally.
/**
*@description extended READ
* on read (error and success) the local INDEXEDDB is also read with the supplied sPath from the original
* OData Call. Merges data from indexed to to "Results"
*@memberOf oData
*@param {String} sPath - A string containing the path to the data which should be retrieved.
* The path is concatenated to the service URL which was specified in the model constructor. Eg. '/CustomerSet'
*@param {Object} mParameters - Optional parameter map, see API
*/
read: function(sPath, mParameters)
{
var that = this;
mParameters.success = (function(success)
{
function fnExtend(data, oData)
{
that._readSuccessCallback(data, oData, this)
.then(function(localData)
{
data.results ? data.results = data.results.concat(localData.data) : localData.data ? data.results = localData.data : '';
success(data, oData);
});
}
return fnExtend.bind(sPath);
}.bind(sPath))(mParameters.success);
mParameters.error = (function(error)
{
function fnExtend(data)
{
that._readErrorCallback(data, this.path, this.params)
.then(function(localData)
{
if (localData.data) localData.params.success(data);
error(data);
});
}
return fnExtend.bind({
path: sPath,
params: mParameters
});
}.bind(sPath))(mParameters.error);
ODataModel.prototype.read.call(this, sPath, mParameters)
},
…
/**
*@description Extended READ Success function. Tries to read path from BE AND IndexedDB
*@memberOf oData
*@param {Object} data - Data resulting from call
*@param {Object} oData - Overhead Data
*@param {String} sPath - called Path
*/
_readSuccessCallback: function(data, oData, sPath)
{
console.log("Server READ SUCCESS: %s ", sPath);
return this._loadFromIndexedDB(sPath, {})
},
…
/**
*@description loads a Table from the indexed DB
*@param {String} sPath - called Path
*@param {Object} oParams - success / error
*@return {promise}
*/
_loadFromIndexedDB: function(sPath, oParams)
{
var that = this;
return new Promise(function(resolve)
{
that._oComponent.getIndexDb()
.getTable(sPath)
.then(function(data)
{
resolve({
data: data,
params: oParams
})
});
})
},
IndexedDb.js
getTable first checks whether there is a table under the read sPath locally. If this is the case, each entry is read and returned. Each entry receives an additional attribute for determining the origin.
/**
*@description loads data from indexeddb.
* Adds attribute "_origin": "indexeddb" to indexeddb
*@memberOf IndexedDb
*@param {String} sTable - Name of table that is read
*@param {boolean} bIncludeRemove - in cludes entries with property _http === "remove"
*@returns {array} - read table with extra attribute "_origin": "indexeddb"
*/
getTable: function(sTable, bIncludeRemove)
{
var that = this;
return new Promise(function(resolve, reject)
{
var request = indexedDB.open("localStorage", version);
request.onsuccess = function(oEvent)
{
IndexedDb._db = oEvent.target.result;
if (!that._tableAvailable(sTable))
{
IndexedDb._db.close();
return resolve([]);
}
var objectStore = IndexedDb._db.transaction([sTable], "readwrite").objectStore(sTable);
var request = objectStore.openCursor();
var aTable = [];
request.onsuccess = function(event)
{
var cursor = event.target.result;
if (cursor)
{
cursor.value._origin = "indexeddb";
if (bIncludeRemove || cursor.value._http === 'create') aTable.push($.extend(cursor.value, cursor.value));
cursor.continue();
}
else
{
IndexedDb._db.close();
resolve(aTable);
}
};
};
});
},
After the reading process, the table shows the data read from the backend and from the IndexedDB.
4. Create Data: Online
The data is saved via the interface of the table model in the online state. The new data is created via the GUI and transferred to the backend.
The table shows the data read from the backend.
5. Create Data: Offline
For the test case, the network is deactivated via the Chrome browser options. For the user, the creation process on the surface is the same. As in online mode, the data is created via the GUI.
View1.controller.js
After saving, it is read manually (S1)
oTableModel.save(data, "/Table1Set")
.then(function()
{
that.onLoadTable();
})
.catch(function()
{
that.onLoadTable();
})
;
Table.js
/**
*@description Creates new table entry
*@param {Object} data - data to be saved
*@param {String} sPath - Path to be saved to
*@returns {promise}
*/
save: function(data, sPath)
{
var oData = this.oComponent.getModel();
return new Promise(function(resolve, reject)
{
oData.create(sPath,
data,
{
success: function(resp)
{
resolve(resp)
},
error: function(err)
{
reject(err);
}
})
})
},
oData.js
After the Createcall has failed as planned the data to be saved, including the path, is forwarded to the IndexedDB. The data is given an additional attribute “_http” with the value “create” for later synchronization.
/**
*@description extended CREATE
*@memberOf oData
*@param {String} sPath - A string containing the path to the collection
* where an entry should be created.
* The path is concatenated to the service URL which was specified in the
* model constructor.
*@param {Object} oData - Data of the entry that should be created.
*@param {Object} mParameters - Optional parameter map, refer to API
*/
create: function(sPath, oData, mParameters)
{
oData = this._trimPayload(oData);
var that = this;
mParameters.success = (function(success)
{
function fnExtend(data, oData)
{
success(data, oData);
that._createSuccessCallback(data, oData, this)
}
return fnExtend.bind({
path: sPath,
data: oData
});
}.bind({
path: sPath,
data: oData
}))(mParameters.success);
mParameters.error = (function(error)
{
function fnExtend(data)
{
that._createErrorCallback(data, this)
.then(function()
{
error();
});
}
return fnExtend.bind({
path: sPath,
data: oData
});
}.bind({
path: sPath,
data: oData
}))(mParameters.error);
ODataModel.prototype.create.call(this, sPath, oData, mParameters)
},
…
/**
*@description Extended CREATE ERROR function
* Stores data in local Index DB
*@memberOf oData
*@param {Object} data - Data resulting from call
*@param {Object} mParameters - 1: called sPath, 2:saved object
*/
_createErrorCallback: function(data, mParameters)
{
console.error("CREATE ERROR: %s \n Property %s not Set", data.message, sPath);
var sPath = mParameters.path;
var oUnsavedData = mParameters.data;
oUnsavedData._http = 'create';
return this._oComponent.getIndexDb()
.create(oUnsavedData, sPath);
},
IndexedDb.js
If necessary, a new table (sPath) is first created in the IndexedDB. The data is then stored there.
/**
*@description stores data in indexeddb and creates Table if necessary
* adds property _http to save the intended http action (CRUD)
*@memberOf IndexedDb
*@param {object} data - data to be stored in indexed db
*@param {String} sTable - Name of table that data is added to
*/
create: function(data, sTable)
{
var that = this;
sTable = that._trim(sTable)[1];
return Promise.resolve()
.then(that._createTable.bind(that, sTable))
.then(that._createData.bind(that, data, sTable))
.catch(function(err)
{
console.error("Data could not be written to INDEXEDDB");
})
},
…
/**
*@description creates a new table
*@memberOf IndexedDb
*@param {String} sTable - Name of table that data is added to
*/
_createTable: function(sTable)
{
if (!this._tableAvailable(sTable)) version++;
var request = indexedDB.open("localStorage", version);
var indexedDbKey = 'Id';
return new Promise(function(resolve, reject)
{
request.onupgradeneeded = function(oEvent)
{
IndexedDb._db = oEvent.target.result;
var objectStore = IndexedDb._db.createObjectStore(sTable, {
autoIncrement: false,
keyPath: indexedDbKey
});
objectStore.createIndex('_http', '_http', {unique: false});
request.onsuccess = function(evt)
{
resolve();
};
request.onerror = function(oError)
{
IndexedDb._db.close();
reject();
};
};
request.onsuccess = function(oEvent)
{
IndexedDb._db = oEvent.target.result;
resolve();
};
})
},
…
/**
*@description create indexdb entry
*@memberOf IndexedDb
*@param {object} data - data to be stored in indexed db
*@param {String} sTable - Name of table that data is added to
*/
_createData: function(data, sTable)
{
return new Promise(function(resolve, reject)
{
var oTransaction = IndexedDb._db.transaction(sTable, "readwrite");
var oDataStore = oTransaction.objectStore(sTable);
oDataStore.add(data);
IndexedDb._db.close();
resolve();
})
},
The entry can be found in the IndexedDB accordingly. An additional attribute (_http), which stores the http verb, was added for later processing.
Subsequently, the data is read new and the table shows local data as well as data from the backend.
6. Delete Data: Offline
Deletion of data should work the same way as the creation of data, should the application not have access to the SAP systems. The delete function, described below, removes local entries and also creates delete tokens for entries on the backend in the IndexedDB in the offline state.
The application is shifted to offline again in order to delete the first entry in the list (backend data).
The program sequence is as follows:
View1.controller.js
Process-Start via ViewController:
/**
*@description Deletes entry
*@memberOf View1
*@param {object} oEvent
*/
deleteEntry: function(oEvent)
{
var that = this;
var sPath = oEvent.getSource()
.getBindingContext("TableModel").sPath;
var oObj = this.getView()
.getModel("TableModel")
.getProperty(sPath);
this.getView()
.getModel("TableModel")
.remove(oObj)
.then(function(data)
{
that.onLoadTable();
})
.catch(function(error)
{
that.onLoadTable();
})
},
Table.js
Forwarding the request:
/**
*@description removes table entry
*@returns {promise}
*/
remove: function(data)
{
var oData = this.oComponent.getModel();
var sPath = oData.createKey("/Table1Set", {
"Id": data.Id
}
);
return new Promise(function(resolve, reject)
{
oData.remove(sPath,
{
success: function(resp)
{
resolve(resp)
},
error: function(err)
{
reject(err);
}
},
)
})
oData.js
After the removal call has failed, the sPath is forwarded to the “_removeErrorCallback” method:
/**
*@description extended REMOVE
*@memberOf oData
*@param {String} sPath - A string containing the path to the collection where an entry should be created.
* The path is concatenated to the service URL which was specified in the model constructor.
*@param {Object} mParameters - Optional parameter map, refer to API
*/
remove: function(sPath, mParameters)
{
var that = this;
mParameters.success = (function(success)
{
function fnExtend(data, oData)
{
success(data, oData);
that._removeSuccessCallback(data, oData, this);
}
return fnExtend.bind({path: sPath});
}.bind({path: sPath}))(mParameters.success);
mParameters.error = (function(error)
{
function fnExtend(data)
{
error(data);
that._removeErrorCallback(data, this.path);
}
return fnExtend.bind({path: sPath});
}.bind({path: sPath}))(mParameters.error);
ODataModel.prototype.remove.call(this, sPath, mParameters)
},
…
/**
*@description Extended REMOVE ERROR function.
* Stores items that are to be deleted in indexedDB
*@param {Object} data - Data resulting from call
*@param {String} sPath - called Path, including Key
*/
_removeErrorCallback: function(data, sPath)
{
return this._oComponent.getIndexDb()
.remove(sPath)
},
IndexedDb.js
As in the case of storing the data in the “Create” process, it is first checked as to whether a “Spath” table already exists. If this is not the case it will then be created. If the _removeData method does not find an entry under the path and key passed, it is stored locally as an entry to be deleted.
/**
*@description Checks if affected tableentry exists. triggers creation of table if needed.
* if data cannot be found in indexed db, the system asumes that the entry needs to be saved
* to be deleted later
*@memberOf IndexedDb
*@param {String} sPath - Name of Spath that is read (most of it is also the iDB table name)
*/
remove: function(sPath)
{
var that = this;
var sKey = this._getKeys(sPath);
var sShortPath = this._trim(sPath)[0];
return Promise.resolve()
.then(that._createTable.bind(that, sShortPath))
.then(that._removeData.bind(that, sShortPath, sKey, false))
.catch(function(err)
{
that._removeData(sShortPath, sPath, true)
.then(function()
{
console.log("deleteToken successfully written to IndexedDB")
})
.catch(function(err)
{
})
})
},
…
/**
*@description Removes entry from indexed DB
* if entry is not found, affected key and table are stored to be dealt with later
*@memberOf IndexedDb
*@param {String} sTable - Name of Spath that is read (most of it is also the iDB table name)
*@param {String} sKey - spath Key
*@param {Boolean} bDeleteToken - remove a delete token from INDEXED DB (_http: remove).
* if false, its assumed that a create entry is to be delete after it was successfully send to the backend
*/
_removeData: function(sTable, sKey, bDeleteToken)
{
var that = this;
var bIndexedEntryFound = false;
return new Promise(function(resolve, reject)
{
var request = indexedDB.open("localStorage", version);
request.onsuccess = function(oEvent)
{
IndexedDb._db = oEvent.target.result;
var objectStore = IndexedDb._db.transaction([sTable], "readwrite").objectStore(sTable);
var request = objectStore.openKeyCursor(sKey);
request.onsuccess = function(oEvent)
{
var cursor = oEvent.target.result;
if (cursor)
{
bIndexedEntryFound = true;
objectStore.delete(cursor.primaryKey);
cursor.continue();
}
else
{
if (!bIndexedEntryFound)
{
if (bDeleteToken)
{
that.create({ //...create indexeddb table with delete token, if it doesnt exist
'Id': sKey,
_http: 'remove'
}, sTable)
.then(function(data)
{
IndexedDb._db.close();
resolve();
})
}
else
{
reject();
}
}
else
{
resolve(); //entry found + deleted
}
}
};
}
})
},
7. Synchronization
The local data must be synchronized with the SAP system After the network is available again. There is now an entry for deleting and an entry for creating data.
On the IndexedDB a distinction which depends on the stored http verb is made as to whether data is to be created or deleted.
View1.controller.js
Process starts here
/**
*@description Synchronize
*@memberOf View1
*/
processLocalData: function(eEvent)
{
var sPath = "/Table1Set";
var that = this;
this.getOwnerComponent()
.getIndexDb()
.processLocalData(sPath)
.then(function(data)
{
that.onLoadTable();
})
IndexedDb.js
Processing of locally stored data:
/**
*@description After the system is back online, this will synchronize local storage with Backend data
*@memberOf IndexedDb
*@param {String} sTable - Name of table that data is add
*@return {promise}
*/
processLocalData: function(sTable)
{
var that = this;
return new Promise(function(resolve, reject)
{
that.getTable(sTable, true)
.then(function(data)
{
data.forEach(function(line)
{
var sKey = line.Id;
if (line._http === 'create')
{
that._oComponent.getModel()[line._http](sTable, line, {
success: function()
{
that._removeData(sTable, sKey, false)
.then(function()
{
resolve()
});
},
error: reject
})
}
else if (line._http === 'remove')
{
that._oComponent.getModel()[line._http](line.Id, {
success: function()
{
that._removeData(sTable, sKey)
.then(function()
{
resolve()
});
},
error: reject
})
}
});
});
})
},
8. Outlook
Depending on application area, it is conceivable to build an app that after initial loading works independently of the network. All functions are therefore available to the user at any time. Field service technicians or mechanics in large production halls can load the required master data for the application upon opening the app. Full functionality is still guaranteed even though you might be e.g. in storage rooms without reception or at the customer without WIFI. New data is simply created in the IndexedDB and synchronized when the network is available.