UNPKG

@entestat/formula

Version:

fast excel formula parser

413 lines (376 loc) 14.6 kB
const TextFunctions = require('../formulas/functions/text'); const MathFunctions = require('../formulas/functions/math'); const TrigFunctions = require('../formulas/functions/trigonometry'); const LogicalFunctions = require('../formulas/functions/logical'); const EngFunctions = require('../formulas/functions/engineering'); const ReferenceFunctions = require('../formulas/functions/reference'); const InformationFunctions = require('../formulas/functions/information'); const StatisticalFunctions = require('../formulas/functions/statistical'); const DateFunctions = require('../formulas/functions/date'); const WebFunctions = require('../formulas/functions/web'); const FormulaError = require('../formulas/error'); const {FormulaHelpers, Address} = require('../formulas/helpers'); const {Parser, allTokens} = require('./parsing'); const lexer = require('./lexing'); const Utils = require('./utils'); const {DateHelper} = require("../silk/DateHelper"); /** * A Excel Formula Parser & Evaluator */ class FormulaParser { /** * @param {{functions: {}, functionsNeedContext: {}, onVariable: function, onCell: function, * onRange: function, outputValuesMapper:function, inputValuesMapper:function}} [config] * @param isTest - is in testing environment */ constructor(config, isTest = false) { this.contextData = {}; this.logs = []; this.notFoundVariables = []; this.isTest = isTest; this.utils = new Utils(this); config = Object.assign({ functions: {}, functionsNeedContext: {}, onVariable: (name, sheetName) => { if(this.contextData.hasOwnProperty(name)){ return {val: this.fixType(this.contextData[name])}; } else { this.notFoundVariables.push(name); return null; } }, onCell: ({sheet, row, col}) => { let name = Address.columnNumberToName(col) + (row+1); this.notFoundVariables.push(name); return 0; }, onRange: () => [[0]], }, config); this.onVariable = config.onVariable; this.functions = Object.assign({}, DateFunctions, StatisticalFunctions, InformationFunctions, ReferenceFunctions, EngFunctions, LogicalFunctions, TextFunctions, MathFunctions, TrigFunctions, WebFunctions, config.functions, config.functionsNeedContext); this.onRange = config.onRange; this.onCell = config.onCell; // functions treat null as 0, other functions treats null as "" this.funsNullAs0 = Object.keys(MathFunctions) .concat(Object.keys(TrigFunctions)) .concat(Object.keys(LogicalFunctions)) .concat(Object.keys(EngFunctions)) .concat(Object.keys(ReferenceFunctions)) .concat(Object.keys(StatisticalFunctions)) .concat(Object.keys(DateFunctions)); // functions need context and don't need to retrieve references this.funsNeedContextAndNoDataRetrieve = ['ROW', 'ROWS', 'COLUMN', 'COLUMNS', 'SUMIF', 'INDEX', 'AVERAGEIF', 'IF']; // functions need parser context this.funsNeedContext = [...Object.keys(config.functionsNeedContext), ...this.funsNeedContextAndNoDataRetrieve, 'INDEX', 'OFFSET', 'INDIRECT', 'IF', 'CHOOSE', 'WEBSERVICE']; // functions preserve reference in arguments this.funsPreserveRef = Object.keys(InformationFunctions); this.parser = new Parser(this, this.utils); } inputValuesMapper(val){ if(val && val.constructor && val.constructor.name.toLowerCase() === "bigdecimal"){ return parseFloat(val.getValue()); } return val; } outputValuesMapper(val, contextData){ return val; } fixType(val){ return this.inputValuesMapper(val); } /** * Get all lexing token names. Webpack needs this. * @return {Array.<string>} - All token names that should not be minimized. */ static get allTokens() { return allTokens; } /** * Get value from the cell reference * @param ref * @return {*} */ getCell(ref) { // console.log('get cell', JSON.stringify(ref)); if(ref.hasOwnProperty('val')){ return ref.val; } if (ref.sheet == null) ref.sheet = this.position ? this.position.sheet : undefined; return this.onCell(ref); } /** * Get values from the range reference. * @param ref * @return {*} */ getRange(ref) { // console.log('get range', JSON.stringify(ref)); if (ref.sheet == null) ref.sheet = this.position ? this.position.sheet : undefined; return this.onRange(ref) } /** * TODO: * Get references or values from a user defined variable. * @param name * @return {*} */ getVariable(name) { // console.log('get variable', name); const res = {ref: this.onVariable(name, this.position ? this.position.sheet: null, this.position)}; if (res.ref == null) return FormulaError.NAME; return res; } /** * Retrieve values from the given reference. * @param valueOrRef * @return {*} */ retrieveRef(valueOrRef) { if (FormulaHelpers.isRangeRef(valueOrRef)) { return this.getRange(valueOrRef.ref); } if (FormulaHelpers.isCellRef(valueOrRef)) { return this.getCell(valueOrRef.ref) } return valueOrRef; } /** * Call an excel function. * @param name - Function name. * @param args - Arguments that pass to the function. * @return {*} */ _callFunction(name, args) { if (name.indexOf('_xlfn.') === 0) name = name.slice(6); name = name.toUpperCase(); // if one arg is null, it means 0 or "" depends on the function it calls const nullValue = this.funsNullAs0.includes(name) ? 0 : ''; if (!this.funsNeedContextAndNoDataRetrieve.includes(name)) { // retrieve reference args = args.map(arg => { if (arg === null) return {value: nullValue, isArray: false, omitted: true}; const res = this.utils.extractRefValue(arg); if (this.funsPreserveRef.includes(name)) { return {value: res.val, isArray: res.isArray, ref: arg.ref}; } return { value: res.val, isArray: res.isArray, isRangeRef: !!FormulaHelpers.isRangeRef(arg), isCellRef: !!FormulaHelpers.isCellRef(arg) }; }); } // console.log('callFunction', name, args) if (this.functions[name]) { let res; try { if (!this.funsNeedContextAndNoDataRetrieve.includes(name) && !this.funsNeedContext.includes(name)) res = (this.functions[name](...args)); else res = (this.functions[name](this, ...args)); } catch (e) { // allow functions throw FormulaError, this make functions easier to implement! if (e instanceof FormulaError) { return e; } else { throw e; } } if (res === undefined) { // console.log(`Function ${name} may be not implemented.`); if (this.isTest) { if (!this.logs.includes(name)) this.logs.push(name); return {value: 0, ref: {}}; } throw FormulaError.NOT_IMPLEMENTED(name); } return res; } else { // console.log(`Function ${name} is not implemented`); if (this.isTest) { if (!this.logs.includes(name)) this.logs.push(name); return {value: 0, ref: {}}; } throw FormulaError.NOT_IMPLEMENTED(name); } } async callFunctionAsync(name, args) { const awaitedArgs = []; for (const arg of args) { awaitedArgs.push(await arg); } const res = await this._callFunction(name, awaitedArgs); return FormulaHelpers.checkFunctionResult(res) } callFunction(name, args) { if (this.async) { return this.callFunctionAsync(name, args); } else { const res = this._callFunction(name, args); return FormulaHelpers.checkFunctionResult(res); } } /** * Return currently supported functions. * @return {this} */ supportedFunctions() { const supported = []; const functions = Object.keys(this.functions); functions.forEach(fun => { try { const res = this.functions[fun](0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); if (res === undefined) return; supported.push(fun); } catch (e) { if (e instanceof Error) supported.push(fun); } }); return supported.sort(); } /** * Check and return the appropriate formula result. * @param result * @param {boolean} [allowReturnArray] - If the formula can return an array * @return {*} */ checkFormulaResult(result, allowReturnArray = false) { const type = typeof result; // number if (type === 'number') { if (isNaN(result)) { return FormulaError.VALUE; } else if (!isFinite(result)) { return FormulaError.NUM; } result += 0; // make -0 to 0 } else if (type === 'object') { if(result.hasOwnProperty("ref") && result.ref.hasOwnProperty("val")){ return result.ref.val; } if (result instanceof FormulaError) return result; if(DateHelper.isDate(result)){ return result; } if (allowReturnArray) { if (result.ref) { result = this.retrieveRef(result); } // Disallow union, and other unknown data types. // e.g. `=(A1:C1, A2:E9)` -> #VALUE! if (typeof result === 'object' && !Array.isArray(result) && result != null) { return FormulaError.VALUE; } } else { if (result.ref && result.ref.row && !result.ref.from) { // single cell reference result = this.retrieveRef(result); } else if (result.ref && result.ref.from && result.ref.from.col === result.ref.to.col) { // single Column reference result = this.retrieveRef({ ref: { row: result.ref.from.row, col: result.ref.from.col } }); } else if (Array.isArray(result)) { result = result[0][0] } else { // array, range reference, union collections return FormulaError.VALUE; } } } return result; } hasMissingVariables(){ return 0 !== this.notFoundVariables.length; } getMissingVariables(){ return this.notFoundVariables; } run(inputText, contextData = null){ return this.outputValuesMapper(this.parse(inputText, null, false, contextData), contextData); } /** * Parse an excel formula. * @param {string} inputText * @param {{row: number, col: number}} [position] - The position of the parsed formula * e.g. {row: 1, col: 1} * @param {boolean} [allowReturnArray] - If the formula can return an array. Useful when parsing array formulas, * or data validation formulas. * @param contextData * @returns {*} */ parse(inputText, position, allowReturnArray = false, contextData = null) { this.notFoundVariables = []; if (inputText.length === 0) throw Error('Input must not be empty.'); this.position = position; this.async = false; this.contextData = contextData || {}; const lexResult = lexer.lex(inputText); this.parser.input = lexResult.tokens; let res; try { res = this.parser.formulaWithBinaryOp(); res = this.checkFormulaResult(res, allowReturnArray); if (res instanceof FormulaError) { return res; } } catch (e) { throw FormulaError.ERROR(e.message, e); } if (this.parser.errors.length > 0) { const error = this.parser.errors[0]; throw Utils.formatChevrotainError(error, inputText); } return res; } /** * Parse an excel formula asynchronously. * Use when providing custom async functions. * @param {string} inputText * @param {{row: number, col: number}} [position] - The position of the parsed formula * e.g. {row: 1, col: 1} * @param {boolean} [allowReturnArray] - If the formula can return an array. Useful when parsing array formulas, * or data validation formulas. * @returns {*} */ async parseAsync(inputText, position, allowReturnArray = false) { if (inputText.length === 0) throw Error('Input must not be empty.'); this.position = position; this.async = true; const lexResult = lexer.lex(inputText); this.parser.input = lexResult.tokens; let res; try { res = await this.parser.formulaWithBinaryOp(); res = this.checkFormulaResult(res, allowReturnArray); if (res instanceof FormulaError) { return res; } } catch (e) { throw FormulaError.ERROR(e.message, e); } if (this.parser.errors.length > 0) { const error = this.parser.errors[0]; throw Utils.formatChevrotainError(error, inputText); } return res; } } module.exports = { FormulaParser, FormulaHelpers, };