ubjs/packages/base/LocalDataStore.js

/**
 * Helper class for manipulation with data, stored locally in ({@link TubCachedData} format).
 *
 * This module shared between client & server. In case of server we use it together with {@link dataLoader},
 * 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}]
           });

 * @module @unitybase/base/LocalDataStore
 */
/*
 @author pavel.mash
 */

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

const _ = require('lodash')
/**
 * Format for UBQ select request
 * @typedef {Object} 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
 */

/**
 * Format for data, stored in client-side cache
 * @typedef {Object} TubCachedData
 * @property {Array<Array>} data
 * @property {Array<String>} fields
 * @property {Number} rowCount
 * @property {Number} [version] A data version in case `mi_modifyDate` is in fields
 */

/**
 * 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
 */
module.exports.doFilterAndSort = function (cachedData, ubRequest) {
  let rangeStart

  let filteredData = this.doFiltration(cachedData, ubRequest)
  let 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.
 */
module.exports.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>}
 */
module.exports.doFiltration = function (cachedData, ubRequest) {
  let f, isAcceptable
  let rawDataArray = cachedData.data
  let byPrimaryKey = Boolean(ubRequest.ID)

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

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

  let result = []
  let l = rawDataArray.length
  let 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
 */
module.exports.doSorting = function (filteredArray, cachedData, ubRequest) {
  let preparedOrder = []
  if (ubRequest.orderList) {
    _.each(ubRequest.orderList, function (orderItem) {
      let 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
      })
    })
    let orderLen = preparedOrder.length
    if (orderLen) {
      let compareFn = function (v1, v2) {
        let res = 0
        let idx = -1
        while (++idx < orderLen && res === 0) {
          let 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) {
  Object.keys(request) // FIX BUG WITH TubList TODO - rewrite to native
  let propIdx, fValue, filterFabricFn
  let filters = []
  let escapeForRegexp = function (text) {
    // convert text to string
    return text ? ('' + text).replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') : ''
  }
  let whereList = request.whereList

  filterFabricFn = function (propertyIdx, condition, value) {
    let regExpFilter

    switch (condition) {
      case 'like':
        regExpFilter = new RegExp(escapeForRegexp(value), 'i')
        return function (record) {
          let 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) {
          let val = record[propertyIdx]
          return val && !regExpFilter.test(val)
        }
      case 'startWith':
        return function (record) {
          let str = record[propertyIdx]
          return (str && str.indexOf(value) === 0)
        }
      case 'notStartWith':
        return function (record) {
          let str = record[propertyIdx]
          return str && str.indexOf(value) !== 0
        }
      case 'in':
        return function (record) {
          let str = record[propertyIdx]
          return str && value.indexOf(str) >= 0
        }
      case 'notIn':
        return function (record) {
          let str = record[propertyIdx]
          return str && value.indexOf(str) < 0
        }
      default:
        throw new Error('Unknown whereList condition')
    }
  }

  function transformClause (clause) {
    let 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 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
  const reqID = request.ID
  if (reqID) {
    transformClause({expression: '[ID]', condition: 'equal', values: {ID: reqID}})
  }
  _.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 {Object<string, string>} [fieldAlias] Optional object to change attribute names during transform array to object. Keys are original names, values - new names
 * @returns {Array.<*>}
 */
module.exports.selectResultToArrayOfObjects = function (selectResult, fieldAlias) {
  let inData = selectResult.resultData.data
  let inAttributes = selectResult.resultData.fields
  let inDataLength = inData.length
  let result = inDataLength ? new Array(inDataLength) : []
  if (fieldAlias) {
    _.forEach(fieldAlias, function (alias, field) {
      let idx = inAttributes.indexOf(field)
      if (idx >= 0) {
        inAttributes[idx] = alias
      }
    })
  }
  for (let 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 TubDataStore.initialize Flatten format (faster than [{}..] 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.<*>}}
 */
module.exports.flatten = function (requestedFieldList, cachedData) {
  let fldIdxArr = []
  let cachedFields = cachedData.fields
  let rowIdx = -1
  let col = -1
  let pos = 0
  let resultData = []
  let rowCount = cachedData.data.length
  let idx, row, fieldCount

  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>}
 */
module.exports.arrayOfObjectsToSelectResult = function (arrayOfObject, attributeNames) {
  let result = []
  arrayOfObject.forEach(function (obj) {
    let row = []
    attributeNames.forEach(function (attribute) {
      row.push(obj[attribute])
    })
    result.push(row)
  })
  return result
}