Skip to content

Commit

Permalink
Replaced MathJS with Tinymath (elastic#321)
Browse files Browse the repository at this point in the history
* Replaced mathjs with tinymath in math.js. Wrote unit tests for math.js. Upgraded tinymath

* Added string and bool columns to testTable for math.js tests. Added unit tests for math.js

* Added columns to testTable in math.js test file

* Upgraded tinymath to version 0.1.8

* Created function to convert datable to math context for math.js

* Replaced pivotObjectArray with datableToMathContext

* Wrote unit tests for math.js

* Fixed unit test for math.js. Removed references to mathjs package in math.js

* Uncommented line to run server tests

* Refactored math.js test file

* Replaced every instance of Object.value with value from lodash

* Create test file for pointseries. Wrote unit tests for pointseries.

* Added unit tests for pointseries

* Moved 'server/functions/pointseries.js' to 'server/functions/pointseries/index.js'. Updated unit tests. Created lib folder for pointseries helper functions

* Removed map function from lodash

* Added unit test for pointseries.js

* Refactored pointseries and replaced mathJS functions with tinymath functions

* Removed common/lib/math.js. Replaced reference to math.js with tinymath in handlebars.js.

* Replaced mathjs with tinymath in datacolumn.js

* Refactored pointseries.js. Replaced mathjs with tinymath. Removed unused lodash functions

* Removed mathjs package and generated docs

* Fixed unit tests. Fixed getType function in math.js

* Moved getFormObject function to a separate file. Imported getFormObject into datacolumn.js. Rewrote getFormObject to work with parse from tinymath

* Rewrote getFormObject to work with parse from tinymath. Wrote unit tests for getFormObject

* Added unit test to test runtime of pointseries using demodata

* Updated demodata test

* Removed console logs

* Updated runtime test for pointseries

* Removed runtime test for pointseries

* Added error handling for empty datatables in math.js. Updated unit tests

* Upgraded tinymath to 0.1.9

* Changed error message thrown in getFormObject, updated unit tests

* Created test datatables file

* Cleaned up tests for math.js.

* Wrote tests for datatableToMathContext

* Cleaned up unit tests for pointseries

* wrote tests for isColumnReference

* Renamed getType in pointseries to getExpressionType

* Pulled out functions out of pointseries into lib functions and added imports to pointseries

* Wrote unit tests for getFieldType

* Wrote unit tests for getFieldNames

* Wrote unit tests for getExpressionType. Refactored pointseries. Updated unit tests for pointseries helper functions.

* Removed mode from dropdown menu in SimpleMathFunction

* Cleaned up unit tests for pointseries and helper functions
  • Loading branch information
cqliu1 authored and Rashid Khan committed Feb 13, 2018
1 parent 4c09668 commit dbe419a
Show file tree
Hide file tree
Showing 22 changed files with 629 additions and 130 deletions.
98 changes: 98 additions & 0 deletions common/functions/__tests__/fixtures/test_tables.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
const emptyTable = {
type: 'datatable',
columns: [],
rows: [],
};

const testTable = {
type: 'datatable',
columns: [
{
name: 'name',
type: 'string',
},
{
name: 'time',
type: 'date',
},
{
name: 'price',
type: 'number',
},
{
name: 'quantity',
type: 'number',
},
{
name: 'in_stock',
type: 'boolean',
},
],
rows: [
{
name: 'product1',
time: 1517842800950, //05 Feb 2018 15:00:00 GMT
price: 605,
quantity: 100,
in_stock: true,
},
{
name: 'product1',
time: 1517929200950, //06 Feb 2018 15:00:00 GMT
price: 583,
quantity: 200,
in_stock: true,
},
{
name: 'product1',
time: 1518015600950, //07 Feb 2018 15:00:00 GMT
price: 420,
quantity: 300,
in_stock: true,
},
{
name: 'product2',
time: 1517842800950, //05 Feb 2018 15:00:00 GMT
price: 216,
quantity: 350,
in_stock: false,
},
{
name: 'product2',
time: 1517929200950, //06 Feb 2018 15:00:00 GMT
price: 200,
quantity: 256,
in_stock: false,
},
{
name: 'product2',
time: 1518015600950, //07 Feb 2018 15:00:00 GMT
price: 190,
quantity: 231,
in_stock: false,
},
{
name: 'product3',
time: 1517842800950, //05 Feb 2018 15:00:00 GMT
price: 67,
quantity: 240,
in_stock: true,
},
{
name: 'product4',
time: 1517842800950, //05 Feb 2018 15:00:00 GMT
price: 311,
quantity: 447,
in_stock: false,
},
{
name: 'product5',
time: 1517842800950, //05 Feb 2018 15:00:00 GMT
price: 288,
quantity: 384,
in_stock: true,
},
],
};

export { emptyTable, testTable };
78 changes: 73 additions & 5 deletions common/functions/__tests__/math.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,89 @@
import expect from 'expect.js';
import { math } from '../math.js';
import { emptyTable, testTable } from './fixtures/test_tables';

describe('math', () => {
const fn = math().fn;
describe('spec', () => {
it('is a function', () => {
expect(math).to.be.a('function');
});
});

describe('function', () => {
let fn;
beforeEach(() => {
fn = math().fn;
});

it('is a function', () => {
expect(fn).to.be.a('function');
});

it('math expressions, no context', () => {
expect(fn(null, { _: '10' })).to.be.equal(10);
expect(fn(null, { _: '10.5345' })).to.be.equal(10.5345);
expect(fn(null, { _: '123 + 456' })).to.be.equal(579);
expect(fn(null, { _: '100 - 46' })).to.be.equal(54);
expect(fn(1, { _: '100 / 5' })).to.be.equal(20);
expect(fn('foo', { _: '100 / 5' })).to.be.equal(20);
expect(fn(true, { _: '100 / 5' })).to.be.equal(20);
expect(fn(testTable, { _: '100 * 5' })).to.be.equal(500);
expect(fn(emptyTable, { _: '100 * 5' })).to.be.equal(500);
});

it('math expressions, context as number', () => {
expect(fn(23.23, { _: 'floor(value)' })).to.be.equal(23);
expect(fn(-103, { _: 'abs(value)' })).to.be.equal(103);
});

it('math expressions, context as datatable', () => {
expect(fn(testTable, { _: 'count(price)' })).to.be.equal(9);
expect(fn(testTable, { _: 'sum(quantity)' })).to.be.equal(2508);
expect(fn(testTable, { _: 'mean(price)' })).to.be.equal(320);
expect(fn(testTable, { _: 'min(price)' })).to.be.equal(67);
expect(fn(testTable, { _: 'median(quantity)' })).to.be.equal(256);
expect(fn(testTable, { _: 'max(price)' })).to.be.equal(605);
});
});

describe('invalid expression', () => {
it('throws when expression evaluates to an array', () => {
expect(fn)
.withArgs(testTable, { _: 'multiply(price, 2)' })
.to.throwException(e => {
expect(e.message).to.be(
'Expressions must return a single number. Try wrapping your expression in mean() or sum()'
);
});
});
it('throws when using an unknown context variable', () => {
expect(fn)
.withArgs(testTable, { _: 'sum(foo)' })
.to.throwException(e => {
expect(e.message).to.be('Unknown variable: foo');
});
});
it('throws when using non-numeric data', () => {
expect(fn)
.withArgs(testTable, { _: 'mean(name)' })
.to.throwException(e => {
expect(e.message).to.be('Unknown variable: name');
});
expect(fn)
.withArgs(testTable, { _: 'max(in_stock)' })
.to.throwException(e => {
expect(e.message).to.be('Unknown variable: in_stock');
});
});
it('throws when missing expression', () => {
expect(fn)
.withArgs(testTable, { _: '' })
.to.throwException(e => {
expect(e.message).to.be('Empty expression');
});
});
it('throws when passing a context variable from an empty datatable', () => {
expect(fn)
.withArgs(emptyTable, { _: 'mean(foo)' })
.to.throwException(e => {
expect(e.message).to.be('Empty datatable');
});
});
});
});
38 changes: 25 additions & 13 deletions common/functions/math.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,43 @@
import { map } from 'lodash';
import mathjs from 'mathjs';
import { pivotObjectArray } from '../lib/pivot_object_array.js';
import { evaluate } from 'tinymath';
import { datatableToMathContext } from '../lib/datatable_to_math_context.js';

export const math = () => ({
name: 'math',
type: 'number',
help:
'Interpret a mathJS expression, with a number or datatable as context. Datatable columns are available by their column name. ' +
'Interpret a math expression, with a number or datatable as context. Datatable columns are available by their column name. ' +
'If you pass in a number it is available as "value" (without the quotes)',
context: {
types: ['number', 'datatable'],
},
args: {
_: {
types: ['string'],
help:
'An evaluated MathJS expression. (See http://mathjs.org/docs/expressions/parsing.html#eval)',
help: 'An evaluated tinymath expression. (See https://github.com/elastic/tinymath)',
},
},
fn: (context, args) => {
if (args._.trim() === '') {
throw new Error('Empty expression');
}
const isDatatable = context && context.type === 'datatable';
const mathContext = isDatatable
? pivotObjectArray(context.rows, map(context.columns, 'name'))
: { value: context };
const result = mathjs.eval(args._, mathContext);
if (typeof result !== 'number')
throw new Error('Failed to execute math expression. Check your column names');
return result;
const mathContext = isDatatable ? datatableToMathContext(context) : { value: context };
try {
const result = evaluate(args._, mathContext);
if (Array.isArray(result)) {
throw new Error(
'Expressions must return a single number. Try wrapping your expression in mean() or sum()'
);
}
if (typeof result !== 'number')
throw new Error('Failed to execute math expression. Check your column names');
return result;
} catch (e) {
if (context.rows.length === 0) {
throw new Error('Empty datatable');
} else {
throw e;
}
}
},
});
14 changes: 14 additions & 0 deletions common/lib/__tests__/datatable_to_math_context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import expect from 'expect.js';
import { datatableToMathContext } from '../datatable_to_math_context';
import { emptyTable, testTable } from '../../functions/__tests__/fixtures/test_tables';
describe('datatableToMathContext', () => {
it('empty table', () => {
expect(datatableToMathContext(emptyTable)).to.be.eql({});
});
it('filters out non-numeric columns and pivots datatable', () => {
expect(datatableToMathContext(testTable)).to.be.eql({
price: [605, 583, 420, 216, 200, 190, 67, 311, 288],
quantity: [100, 200, 300, 350, 256, 231, 240, 447, 384],
});
});
});
17 changes: 17 additions & 0 deletions common/lib/__tests__/get_field_type.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import expect from 'expect.js';
import { getFieldType } from '../get_field_type';
import { emptyTable, testTable } from '../../functions/__tests__/fixtures/test_tables';

describe('getFieldType', () => {
it('returns type of a field in a datatable', () => {
expect(getFieldType(testTable.columns, 'name')).to.be('string');
expect(getFieldType(testTable.columns, 'time')).to.be('date');
expect(getFieldType(testTable.columns, 'price')).to.be('number');
expect(getFieldType(testTable.columns, 'quantity')).to.be('number');
expect(getFieldType(testTable.columns, 'in_stock')).to.be('boolean');
});
it(`returns 'null' if field does not exist in datatable`, () => {
expect(getFieldType(testTable.columns, 'foo')).to.be('null');
expect(getFieldType(emptyTable.columns, 'foo')).to.be('null');
});
});
7 changes: 7 additions & 0 deletions common/lib/datatable_to_math_context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { pivotObjectArray } from '../lib/pivot_object_array.js';

// filters columns with type number and passes filtered columns to pivotObjectArray
export function datatableToMathContext(datatable) {
const filteredColumns = datatable.columns.filter(col => col.type === 'number');
return pivotObjectArray(datatable.rows, filteredColumns.map(col => col.name));
}
7 changes: 7 additions & 0 deletions common/lib/get_field_type.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// put in common

export function getFieldType(columns, field) {
if (!field) return 'null';
const column = columns.find(column => column.name === field);
return column ? column.type : 'null';
}
4 changes: 2 additions & 2 deletions common/lib/handlebars.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import Hbars from 'handlebars/dist/handlebars';
import { math } from './math.js';
import { evaluate } from 'tinymath';
import { pivotObjectArray } from './pivot_object_array.js';

// example use: {{math rows 'mean(price - cost)' 2}}
Hbars.registerHelper('math', (rows, expression, precision) => {
if (!Array.isArray(rows)) return 'MATH ERROR: first argument must be an array';
const value = math.eval(expression, pivotObjectArray(rows));
const value = evaluate(expression, pivotObjectArray(rows));
try {
return precision ? value.toFixed(precision) : value;
} catch (e) {
Expand Down
8 changes: 0 additions & 8 deletions common/lib/math.js

This file was deleted.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@
"lodash.uniqby": "^4.7.0",
"lz-string": "^1.4.4",
"markdown-it": "^8.3.2",
"mathjs": "^3.12.3",
"metalsmith-snippet": "^2.0.0",
"moment": "^2.18.1",
"object-path-immutable": "^0.5.1",
Expand All @@ -77,6 +76,7 @@
"redux-thunks": "^1.0.0",
"socket.io": "^1.7.3",
"style-it": "^1.6.12",
"tinymath": "0.1.9",
"uuid": "^3.0.1"
},
"devDependencies": {
Expand Down Expand Up @@ -131,4 +131,4 @@
"sinon": "^2.3.2",
"through2": "^2.0.3"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import expect from 'expect.js';
import { getFormObject } from '../get_form_object';

describe('getFormObject', () => {
describe('valid input', () => {
it('string', () => {
expect(getFormObject('field')).to.be.eql({ fn: '', column: 'field' });
});
it('simple expression', () => {
expect(getFormObject('mean(field)')).to.be.eql({ fn: 'mean', column: 'field' });
});
});
describe('invalid input', () => {
it('number', () => {
expect(getFormObject)
.withArgs('2')
.to.throwException(e => {
expect(e.message).to.be('Cannot render scalar values or complex math expressions');
});
});
it('complex expression', () => {
expect(getFormObject)
.withArgs('mean(field * 3)')
.to.throwException(e => {
expect(e.message).to.be('Cannot render scalar values or complex math expressions');
});
});
});
});
Loading

0 comments on commit dbe419a

Please sign in to comment.