LocalDataStore.js

/*
 @author pavel.mash
 */

// ***********   !!!!WARNING!!!!! **********************
// Module shared between server and client code

/**
 * @typedef TubSelectRequest
 * @property {Array<String>} fieldList Array of entity attribute names
 * @property {Object} whereList Where clauses
 * @property {Object} orderList Order clauses
 * @property {Object} options Options
 * @property {Number} ID ID
 */

/**
 * @typedef TubCachedData
 * @property {Array<Array>} data
 * @property {Array<String>} fields
 * @property {Number} rowCount
 */

/**
 * @class LocalDataStore
 * @singleton
 * Helper class for manipulation with data, stored locally ({@link TubCachedData} format).
 *
 * This module shared between client & server. In case of server we use it together with {@link FileBasedStoreLoader},
 * in case of client - inside {@link UBConnection#select} to handle operations with entity data cached in IndexedDB.
 *
 * For server-side samples see ubm_forms.doSelect method implementation.
 *
 * Client-side sample:
 *
 *         $App.connection.run({
                entity: 'tst_IDMapping',
                method: 'addnew',
                fieldList: ['ID', 'code']
           }).done(function(result){
                // here result in array-of-array format: [{"entity":"tst_IDMapping","method":"addnew","fieldList":["ID","code"],"__fieldListExternal":["ID","code"],"resultData":{"fields":["ID","code"],"rowCount": 1, "data":[[3500000016003,null]]}}]
                var objArray = UB.LocalDataStore.selectResultToArrayOfObjects(result); // transform array-of-array result representation to array-of-object
                console.log(objArray); // now result in more simple array-of-object format: [{ID: 12312312312, code: null}]
           });
 *
 */
var LocalDataStore = {};
/**
 * Perform local filtration and sorting of data array according to ubRequest whereList & order list
 * @param {TubCachedData} cachedData Data, retrieved from cache
 * @param {TubSelectRequest} ubRequest Initial server request
 * @returns {*} new filtered & sorted array
 */
LocalDataStore.doFilterAndSort = function (cachedData, ubRequest){
    var
        filteredData, totalLength, rangeStart;

    filteredData = this.doFiltration(cachedData, ubRequest);
    totalLength = filteredData.length;
    this.doSorting(filteredData, cachedData, ubRequest);
    //apply options start & limit
    if (ubRequest.options){
        rangeStart = ubRequest.options.start || 0;
        if (ubRequest.options.limit){
            filteredData = filteredData.slice(rangeStart, rangeStart + ubRequest.options.limit);
        } else {
            filteredData = filteredData.slice(rangeStart);
        }
    }
    return {
        resultData: {
            data: filteredData,
            fields: cachedData.fields
        },
        total: totalLength
    };
};

/**
 * Just a helper for search cached data by row ID
 * @param {TubCachedData} cachedData Data, retrieved from cache
 * @param {Number} IDValue row ID.
 */
LocalDataStore.byID = function(cachedData, IDValue){
    return this.doFilterAndSort(cachedData, {ID: IDValue});
};

/**
 * Apply ubRequest.whereList to data array and return new array contain filtered data
 * @protected
 * @param {TubCachedData} cachedData Data, retrieved from cache
 * @param {TubSelectRequest} ubRequest
 * @returns {Array.<Array>}
 */
LocalDataStore.doFiltration = function(cachedData, ubRequest){
    var
        result, i, l, f,
        filterFabric, filterCount, isAcceptable,
        rawDataArray = cachedData.data,
        byPrimaryKey = Boolean(ubRequest.ID);

    filterFabric = whereListToFunctions(ubRequest, cachedData.fields);
    filterCount = filterFabric.length;

    if (filterCount === 0) {
        return rawDataArray;
    }

    result = []; l = rawDataArray.length; i = -1;
    while (++i < l){ // for each data
        isAcceptable = true; f = -1;
        while(++f < filterCount && isAcceptable === true){
            isAcceptable = filterFabric[f](rawDataArray[i]);
        }
        if (isAcceptable){
            result.push(rawDataArray[i]);
            if (byPrimaryKey){
                return result;
            }
        }
    }
    return result;
};

/**
 * Apply ubRequest.orderList to inputArray (inputArray is modified)
 * @protected
 * @param {Array.<Array>} filteredArray
 * @param {TubCachedData} cachedData
 * @param {Object} ubRequest
 */
LocalDataStore.doSorting = function(filteredArray, cachedData, ubRequest){
    var preparedOrder = [], orderLen, attrIdx, compareFn;
    if (ubRequest.orderList){
        _.each(ubRequest.orderList, function(orderItem){
            attrIdx = cachedData.fields.indexOf(orderItem.expression);
            if(attrIdx < 0){
                throw new Error('Ordering by '+ orderItem.expression + ' attribute that don\'t present in fieldList not allowed');
            }
            preparedOrder.push({
                idx: attrIdx,
                modifier: (orderItem.order === 'desc') ? -1 : 1
            });
        });
        orderLen = preparedOrder.length;
        if (orderLen) {
            compareFn = function (v1, v2) {
                var res = 0, idx = -1, colNum;
                while (++idx < orderLen && res === 0) {
                    colNum = preparedOrder[idx].idx;
                    if ( v1[colNum] !== v2[colNum]){
                        if (v1[colNum] === null && v2[colNum] !== null ){
                            res = 1;
                        } else if (v2[colNum] === null && v1[colNum] !== null){
                            res = -1;
                        }  else if (v2[colNum] === null && v1[colNum] === null){
                           res = 0;
                        } else if (v1[colNum] > v2[colNum]) {
                            res = 1;
                        } else {
                            res = -1;
                        }
                        res = res * preparedOrder[idx].modifier;
                    }
                }
                return res;
            };
            filteredArray.sort(compareFn);
        }
    }
};

/**
 * Transform whereList to array of function
 * @private
 * @param {TubSelectRequest} request
 * @param {Array.<String>} fieldList
 * @returns {Array}
 */
function whereListToFunctions(request, fieldList){
    var propIdx,
        filters = [], fValue,
        filterFabricFn,
        escapeForRegexp = function(text) {  //TODO - do we need this?
            if (text && typeof text === 'string'){
                return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
            } else {
                return '';
            }
        },
        whereList = request.whereList;

    filterFabricFn = function(propertyIdx, condition, value){
        var regExpFilter;

        switch(condition){
            case 'like':
                regExpFilter = new RegExp( escapeForRegexp(value), 'i');
                return function(record){
                    var val = record[propertyIdx];
                    return val && regExpFilter.test(val);
                };
            case 'equal':
                return function(record){
                    return record[propertyIdx] === value;
                };
            case 'notEqual':
                return function(record){
                    return record[propertyIdx] !== value;
                };
            case 'more':
                return function(record){
                    return record[propertyIdx] > value;
                };
            case 'moreEqual':
                return function(record){
                    return record[propertyIdx] >= value;
                };
            case 'less':
                return function(record){
                    return record[propertyIdx] < value;
                };
            case 'lessEqual':
                return function(record){
                    return record[propertyIdx] <= value;
                };
            case 'isNull':
                return function(record){
                    return record[propertyIdx] === null;
                };
            case 'notIsNull':
                return function(record){
                    return record[propertyIdx] !== null;
                };
            case 'notLike':
                regExpFilter = new RegExp(escapeForRegexp(value), 'i');
                return function(record){
                    var val = record[propertyIdx];
                    return val && !regExpFilter.test(val);
                };
            case 'startWith':
                return function(record){
                    var str = record[propertyIdx];
                    return (str && str.indexOf(value)===0);
                };
            case 'notStartWith':
                return function(record){
                    var str = record[propertyIdx];
                    return str && str.indexOf(value)!==0;
                };
            case 'in':
                return function(record){
                    var str = record[propertyIdx];
                    return str && value.indexOf(str)>=0;
                };
            case 'notIn':
                return function(record){
                    var str = record[propertyIdx];
                    return str && value.indexOf(str) < 0;
                };
            default:
                throw new Error('Unknown whereList condition');
        }
    };

    function transformClause(clause){
        var property = clause.expression || '';

        if(clause.condition === 'custom'){
            throw new Error('Condition "custom" is not supported for cached instances.');
        }
        property = (property.replace(/(\[)|(])/ig,'') || '').trim();
        propIdx = fieldList.indexOf(property);
        if(propIdx === -1){
            throw new Error('Filtering by field ' + property + ' is not allowed, because it is not pressing in fieldList');
        }

        fValue = _.values(clause.values)[0];
        filters.push(filterFabricFn( propIdx, clause.condition, fValue));
    }
    // check for top level ID  - in this case add condition for filter by ID
    if (request.ID){
        transformClause({expression: '[ID]', condition: 'equal', values: {ID: request.ID}});
    }
    _.forEach(whereList, transformClause);
    return filters;
}

/**
 * Transform result of {@link UBConnection#select} response
 * from Array of Array representation to Array of Object.
 *
 *      LocalDataStore.selectResultToArrayOfObjects({resultData: {
 *          data: [['row1_attr1Val', 1], ['row2_attr2Val', 22]],
 *          fields: ['attrID.name', 'attr2']}
 *      });
 *      // result is:
 *      // [{"attrID.name": "row1_attr1Val", attr2: 1},
 *      //  {"attrID.name": "row2_attr2Val", attr2: 22}
 *      // ]
 *
 *      // object keys simplify by passing fieldAliases
 *      LocalDataStore.selectResultToArrayOfObjects({resultData: {
 *          data: [['row1_attr1Val', 1], ['row2_attr2Val', 22]],
 *          fields: ['attrID.name', 'attr2']}
 *      }, {'attrID.name': 'attr1Name'});
 *      // result is:
 *      // [{attr1Name: "row1_attr1Val", attr2: 1},
 *      //  {attr1Name: "row2_attr2Val", attr2: 22}
 *      // ]
 *
 * @param {{resultData: TubCachedData}} selectResult
 * @param {{field: alias}} [fieldAlias] Optional object to change attribute names during transform array to object
 * @returns {Array.<*>}
 */
LocalDataStore.selectResultToArrayOfObjects = function(selectResult, fieldAlias){

    var inData = selectResult.resultData.data,
        inAttributes = selectResult.resultData.fields,
        inDataLength = inData.length,
        result = inDataLength ? new Array(inDataLength) : [],
        i;
    if (fieldAlias){
        _.forEach(fieldAlias, function(alias, field){
            var idx = inAttributes.indexOf(field);
            if (idx >= 0){
                inAttributes[idx] = alias;
            }
        });
    }
    for(i=0; i<inDataLength; i++ ){
        result[i] = _.zipObject(inAttributes, inData[i]);
    }
    return result;
};

/**
 * Flatten cached data (or result of {@link LocalDataStore#doFilterAndSort}.resultData )
 * to Object expected by {@link TubDataStore#initFromJSON} compact format (faster when [{}..] format).
 *
        //consider we have cached data in variable filteredData.resultData
        // to initialize dataStore with cached data:
        mySelectMethod = function(ctxt){
            var fieldList = ctxt.mParams.fieldList;
            resp = LocalDataStore.flatten(fieldList, filteredData.resultData);
            ctxt.dataStore.initFromJSON(resp);
        }
 *
 * cachedData may contain more field or field in order not in requestedFieldList - in this case we use expectedFieldList
 * @param {Array.<string>} requestedFieldList Array of attributes to transform to. Can be ['*'] - in this case we return all cached attributes
 * @param {TubCachedData} cachedData
 * @result {{fieldCount: number, rowCount: number, values: array.<*>}}
 */
LocalDataStore.flatten = function(requestedFieldList, cachedData) {
    var fldIdxArr = [], cachedFields = cachedData.fields, idx,
        rowIdx=-1, col=-1, pos = 0, resultData = [], row,
        fieldCount,
        rowCount = cachedData.data.length;

    if (!requestedFieldList || !requestedFieldList.length) {
        throw new Error('fieldList not exist or empty');
    }

    // client ask for all attributes
    if (requestedFieldList.length === 1 && requestedFieldList[0] === '*'){
        requestedFieldList = cachedData.fields;
    }

    requestedFieldList.forEach(function(field){
        idx = cachedFields.indexOf(field);
        if (idx !== -1){
            fldIdxArr.push(idx)
        } else {
            throw new Error('Invalid field list. Attribute ' + field + ' not found in local data store');
        }
    });
    fieldCount = requestedFieldList.length;
    resultData.length =  rowCount * (fieldCount + 1); // reserve fieldCount for field names
    while(++col<fieldCount) {
        resultData[pos] = requestedFieldList[pos]; pos++;
    }
    while (++rowIdx < rowCount) {
        col = -1; row = cachedData.data[rowIdx];
        while (++col < fieldCount) {
            resultData[pos++] = row[ fldIdxArr[col] ];
        }
    }
    return {fieldCount: fieldCount, rowCount: rowCount, values: resultData}
};


/**
 * Reverse conversion to {@link LocalDataStore#selectResultToArrayOfObjects}
 * Transform array of object to array of array using passed attributes array
 *
 *      LocalDataStore.arrayOfObjectsToSelectResult([{a: 1, b: 'as'}, {b: 'other', a: 12}], ['a', 'b']);
 *      // result is: [[1,"as"],[12,"other"]]
 *
 * @param {Array.<Object>} arrayOfObject
 * @param {Array.<String>} attributeNames
 * @returns {Array.<Array>}
 */
LocalDataStore.arrayOfObjectsToSelectResult = function(arrayOfObject, attributeNames){
    var result = [];
    arrayOfObject.forEach(function(obj){
        var row = [];
        attributeNames.forEach(function(attribute){
            row.push(obj[attribute]);
        });
        result.push(row);
    });
    return result;
};

module.exports = LocalDataStore;