diff --git a/__tests__/helpers/function_wrapper.js b/__tests__/helpers/function_wrapper.js new file mode 100644 index 0000000000000..1c21bc592b01a --- /dev/null +++ b/__tests__/helpers/function_wrapper.js @@ -0,0 +1,10 @@ +import { mapValues } from 'lodash'; + +export const functionWrapper = fnSpec => { + const spec = fnSpec(); + const defaultArgs = mapValues(spec.args, argSpec => { + return argSpec.default; + }); + + return (context, args, handlers) => spec.fn(context, { ...defaultArgs, ...args }, handlers); +}; diff --git a/common/functions/__tests__/alterColumn.js b/common/functions/__tests__/alterColumn.js new file mode 100644 index 0000000000000..e35775c0742d2 --- /dev/null +++ b/common/functions/__tests__/alterColumn.js @@ -0,0 +1,210 @@ +import expect from 'expect.js'; +import { alterColumn } from '../alterColumn'; +import { functionWrapper } from '../../../__tests__/helpers/function_wrapper'; +import { emptyTable, testTable } from './fixtures/test_tables'; + +describe('alterColumn', () => { + const fn = functionWrapper(alterColumn); + const nameColumnIndex = testTable.columns.findIndex(({ name }) => name === 'name'); + const timeColumnIndex = testTable.columns.findIndex(({ name }) => name === 'time'); + const priceColumnIndex = testTable.columns.findIndex(({ name }) => name === 'price'); + const inStockColumnIndex = testTable.columns.findIndex(({ name }) => name === 'in_stock'); + + it('returns a datatable', () => { + const alteredTable = fn(testTable, { column: 'price', type: 'string', name: 'priceString' }); + + expect(alteredTable.type).to.be('datatable'); + }); + + describe('args', () => { + it('returns original context if no args are provided', () => { + expect(fn(testTable)).to.eql(testTable); + }); + + describe('column', () => { + // ISO 8601 string -> date + it('specifies which column to alter', () => { + const dateToString = fn(testTable, { column: 'time', type: 'string', name: 'timeISO' }); + const originalColumn = testTable.columns[timeColumnIndex]; + const newColumn = dateToString.columns[timeColumnIndex]; + const arbitraryRowIndex = 6; + + expect(newColumn.name).to.not.be(originalColumn.name); + expect(newColumn.type).to.not.be(originalColumn.type); + expect(dateToString.rows[arbitraryRowIndex].timeISO).to.be.a('string'); + expect(new Date(dateToString.rows[arbitraryRowIndex].timeISO)).to.eql( + new Date(testTable.rows[arbitraryRowIndex].time) + ); + }); + + it('returns original context if column is not specified', () => { + expect(fn(testTable, { type: 'date', name: 'timeISO' })).to.eql(testTable); + }); + + it('throws if column does not exists', () => { + expect(() => fn(emptyTable, { column: 'foo', type: 'number' })).to.throwException(e => { + expect(e.message).to.be("Column not found: 'foo'"); + }); + }); + }); + + describe('type', () => { + it('converts the column to the specified type', () => { + const dateToString = fn(testTable, { column: 'time', type: 'string', name: 'timeISO' }); + + expect(dateToString.columns[timeColumnIndex].type).to.be('string'); + expect(dateToString.rows[timeColumnIndex].timeISO).to.be.a('string'); + expect(new Date(dateToString.rows[timeColumnIndex].timeISO)).to.eql( + new Date(testTable.rows[timeColumnIndex].time) + ); + }); + + it('does not change column if type is not specified', () => { + const unconvertedColumn = fn(testTable, { column: 'price', name: 'foo' }); + const originalType = testTable.columns[priceColumnIndex].type; + const arbitraryRowIndex = 2; + + expect(unconvertedColumn.columns[priceColumnIndex].type).to.be(originalType); + expect(unconvertedColumn.rows[arbitraryRowIndex].foo).to.be.a( + originalType, + testTable.rows[arbitraryRowIndex].price + ); + }); + + it('throws when converting to an invalid type', () => { + expect(() => fn(testTable, { column: 'name', type: 'foo' })).to.throwException(e => { + expect(e.message).to.be('Cannot convert to foo'); + }); + }); + }); + + describe('name', () => { + it('changes column name to specified name', () => { + const dateToString = fn(testTable, { column: 'time', type: 'date', name: 'timeISO' }); + const arbitraryRowIndex = 8; + + expect(dateToString.columns[timeColumnIndex].name).to.be('timeISO'); + expect(dateToString.rows[arbitraryRowIndex]).to.have.property('timeISO'); + }); + + it('overwrites existing column if provided an existing column name', () => { + const overwriteName = fn(testTable, { column: 'time', type: 'string', name: 'name' }); + const originalColumn = testTable.columns[timeColumnIndex]; + const newColumn = overwriteName.columns[nameColumnIndex]; + const arbitraryRowIndex = 5; + + expect(newColumn.name).to.not.be(originalColumn.name); + expect(newColumn.type).to.not.be(originalColumn.type); + expect(overwriteName.rows[arbitraryRowIndex].name).to.be.a('string'); + expect(new Date(overwriteName.rows[arbitraryRowIndex].name)).to.eql( + new Date(testTable.rows[arbitraryRowIndex].time) + ); + }); + + it('retains original column name if name is not provided', () => { + const unchangedName = fn(testTable, { column: 'price', type: 'string' }); + + expect(unchangedName.columns[priceColumnIndex].name).to.be( + testTable.columns[priceColumnIndex].name + ); + }); + }); + }); + + describe('valid type conversions', () => { + it('converts number <-> string', () => { + const arbitraryRowIndex = 4; + const numberToString = fn(testTable, { column: 'price', type: 'string' }); + + expect(numberToString.columns[priceColumnIndex]) + .to.have.property('name', 'price') + .and.to.have.property('type', 'string'); + expect(numberToString.rows[arbitraryRowIndex].price) + .to.be.a('string') + .and.to.eql(testTable.rows[arbitraryRowIndex].price); + + const stringToNumber = fn(numberToString, { column: 'price', type: 'number' }); + + expect(stringToNumber.columns[priceColumnIndex]) + .to.have.property('name', 'price') + .and.to.have.property('type', 'number'); + expect(stringToNumber.rows[arbitraryRowIndex].price) + .to.be.a('number') + .and.to.eql(numberToString.rows[arbitraryRowIndex].price); + }); + + it('converts date <-> string', () => { + const arbitraryRowIndex = 4; + const dateToString = fn(testTable, { column: 'time', type: 'string' }); + + expect(dateToString.columns[timeColumnIndex]) + .to.have.property('name', 'time') + .and.to.have.property('type', 'string'); + expect(dateToString.rows[arbitraryRowIndex].time).to.be.a('string'); + expect(new Date(dateToString.rows[arbitraryRowIndex].time)).to.eql( + new Date(testTable.rows[arbitraryRowIndex].time) + ); + + const stringToDate = fn(dateToString, { column: 'time', type: 'date' }); + + expect(stringToDate.columns[timeColumnIndex]) + .to.have.property('name', 'time') + .and.to.have.property('type', 'date'); + expect(new Date(stringToDate.rows[timeColumnIndex].time)) + .to.be.a(Date) + .and.to.eql(new Date(dateToString.rows[timeColumnIndex].time)); + }); + + it('converts date <-> number', () => { + const dateToNumber = fn(testTable, { column: 'time', type: 'number' }); + const arbitraryRowIndex = 1; + + expect(dateToNumber.columns[timeColumnIndex]) + .to.have.property('name', 'time') + .and.to.have.property('type', 'number'); + expect(dateToNumber.rows[arbitraryRowIndex].time) + .to.be.a('number') + .and.to.eql(testTable.rows[arbitraryRowIndex].time); + + const numberToDate = fn(dateToNumber, { column: 'time', type: 'date' }); + + expect(numberToDate.columns[timeColumnIndex]) + .to.have.property('name', 'time') + .and.to.have.property('type', 'date'); + expect(new Date(numberToDate.rows[arbitraryRowIndex].time)) + .to.be.a(Date) + .and.to.eql(testTable.rows[arbitraryRowIndex].time); + }); + + it('converts bool <-> number', () => { + const booleanToNumber = fn(testTable, { column: 'in_stock', type: 'number' }); + const arbitraryRowIndex = 7; + + expect(booleanToNumber.columns[inStockColumnIndex]) + .to.have.property('name', 'in_stock') + .and.to.have.property('type', 'number'); + expect(booleanToNumber.rows[arbitraryRowIndex].in_stock) + .to.be.a('number') + .and.to.eql(booleanToNumber.rows[arbitraryRowIndex].in_stock); + + const numberToBoolean = fn(booleanToNumber, { column: 'in_stock', type: 'boolean' }); + + expect(numberToBoolean.columns[inStockColumnIndex]) + .to.have.property('name', 'in_stock') + .and.to.have.property('type', 'boolean'); + expect(numberToBoolean.rows[arbitraryRowIndex].in_stock) + .to.be.a('boolean') + .and.to.eql(numberToBoolean.rows[arbitraryRowIndex].in_stock); + }); + + it('converts any type -> null', () => { + const stringToNull = fn(testTable, { column: 'name', type: 'null' }); + const arbitraryRowIndex = 0; + + expect(stringToNull.columns[nameColumnIndex]) + .to.have.property('name', 'name') + .and.to.have.property('type', 'null'); + expect(stringToNull.rows[arbitraryRowIndex].name).to.be(null); + }); + }); +}); diff --git a/common/functions/__tests__/as.js b/common/functions/__tests__/as.js new file mode 100644 index 0000000000000..596a5faa69be9 --- /dev/null +++ b/common/functions/__tests__/as.js @@ -0,0 +1,39 @@ +import expect from 'expect.js'; +import { asFn } from '../as'; +import { functionWrapper } from '../../../__tests__/helpers/function_wrapper'; + +describe('as', () => { + const fn = functionWrapper(asFn); + + it('returns a datatable with a single column and single row', () => { + expect(fn('foo', { _: 'bar' })).to.eql({ + type: 'datatable', + columns: [{ name: 'bar', type: 'string' }], + rows: [{ bar: 'foo' }], + }); + + expect(fn(2, { _: 'num' })).to.eql({ + type: 'datatable', + columns: [{ name: 'num', type: 'number' }], + rows: [{ num: 2 }], + }); + + expect(fn(true, { _: 'bool' })).to.eql({ + type: 'datatable', + columns: [{ name: 'bool', type: 'boolean' }], + rows: [{ bool: true }], + }); + }); + + describe('args', () => { + describe('_', () => { + it('sets the column name of the resulting datatable', () => { + expect(fn(null, { _: 'foo' }).columns[0].name).to.eql('foo'); + }); + + it("returns a datatable with the column name 'value'", () => { + expect(fn(null).columns[0].name).to.eql('value'); + }); + }); + }); +}); diff --git a/common/functions/__tests__/columns.js b/common/functions/__tests__/columns.js new file mode 100644 index 0000000000000..518eecce5474d --- /dev/null +++ b/common/functions/__tests__/columns.js @@ -0,0 +1,114 @@ +import expect from 'expect.js'; +import { columns } from '../columns'; +import { functionWrapper } from '../../../__tests__/helpers/function_wrapper'; +import { emptyTable, testTable } from './fixtures/test_tables'; + +describe('columns', () => { + const fn = functionWrapper(columns); + + it('returns a datatable', () => { + expect(fn(testTable, { include: 'name' }).type).to.be('datatable'); + }); + + describe('args', () => { + it('returns a datatable with included columns and without excluded columns', () => { + const arbitraryRowIndex = 7; + const result = fn(testTable, { + include: 'name, price, quantity, foo, bar', + exclude: 'price, quantity, fizz, buzz', + }); + + expect(result.columns[0]).to.have.property('name', 'name'); + expect(result.rows[arbitraryRowIndex]) + .to.have.property('name', testTable.rows[arbitraryRowIndex].name) + .and.to.not.have.property('price') + .and.to.not.have.property('quantity') + .and.to.not.have.property('foo') + .and.to.not.have.property('bar') + .and.to.not.have.property('fizz') + .and.to.not.have.property('buzz'); + }); + + it('returns original context if args are not provided', () => { + expect(fn(testTable)).to.eql(testTable); + }); + + it('returns an empty datatable if include and exclude both reference the same column(s)', () => { + expect(fn(testTable, { include: 'price', exclude: 'price' })).to.eql(emptyTable); + + expect( + fn(testTable, { + include: 'price, quantity, in_stock', + exclude: 'price, quantity, in_stock', + }) + ).to.eql(emptyTable); + }); + + describe('include', () => { + it('returns a datatable with included columns only', () => { + const arbitraryRowIndex = 3; + const result = fn(testTable, { + include: 'name, time, in_stock', + }); + + expect(result.columns).to.have.length(3); + expect(Object.keys(result.rows[0])).to.have.length(3); + + expect(result.columns[0]).to.have.property('name', 'name'); + expect(result.columns[1]).to.have.property('name', 'time'); + expect(result.columns[2]).to.have.property('name', 'in_stock'); + expect(result.rows[arbitraryRowIndex]) + .to.have.property('name', testTable.rows[arbitraryRowIndex].name) + .and.to.have.property('time', testTable.rows[arbitraryRowIndex].time) + .and.to.have.property('in_stock', testTable.rows[arbitraryRowIndex].in_stock); + }); + + it('ignores invalid columns', () => { + const arbitraryRowIndex = 6; + const result = fn(testTable, { + include: 'name, foo, bar', + }); + + expect(result.columns[0]).to.have.property('name', 'name'); + expect(result.rows[arbitraryRowIndex]) + .to.have.property('name', testTable.rows[arbitraryRowIndex].name) + .and.to.not.have.property('foo') + .and.to.not.have.property('bar'); + }); + + it('returns an empty datable if include only has invalid columns', () => { + expect(fn(testTable, { include: 'foo, bar' })).to.eql(emptyTable); + }); + }); + + describe('exclude', () => { + it('returns a datatable without excluded columns', () => { + const arbitraryRowIndex = 5; + const result = fn(testTable, { exclude: 'price, quantity, foo, bar' }); + + expect(result.columns.length).to.equal(testTable.columns.length - 2); + expect(Object.keys(result.rows[0])).to.have.length(testTable.columns.length - 2); + expect(result.rows[arbitraryRowIndex]) + .to.not.have.property('price') + .and.to.not.have.property('quantity') + .and.to.not.have.property('foo') + .and.to.not.have.property('bar'); + }); + + it('ignores invalid columns', () => { + const arbitraryRowIndex = 1; + const result = fn(testTable, { exclude: 'time, foo, bar' }); + + expect(result.columns.length).to.equal(testTable.columns.length - 1); + expect(result.rows[arbitraryRowIndex]) + .to.not.have.property('time') + .and.to.not.have.property('foo') + .and.to.not.have.property('bar'); + }); + + it('returns original context if exclude only references invalid column name(s)', () => { + expect(fn(testTable, { exclude: 'foo, bar, fizz, buzz' })).to.eql(testTable); + }); + }); + }); +}); diff --git a/common/functions/__tests__/fixtures/test_tables.js b/common/functions/__tests__/fixtures/test_tables.js index 43ca2d27e9bec..cace2ba391128 100644 --- a/common/functions/__tests__/fixtures/test_tables.js +++ b/common/functions/__tests__/fixtures/test_tables.js @@ -95,4 +95,95 @@ const testTable = { ], }; -export { emptyTable, testTable }; +const stringTable = { + type: 'datatable', + columns: [ + { + name: 'name', + type: 'string', + }, + { + name: 'time', + type: 'string', + }, + { + name: 'price', + type: 'string', + }, + { + name: 'quantity', + type: 'string', + }, + { + name: 'in_stock', + type: 'string', + }, + ], + rows: [ + { + name: 'product1', + time: '2018-02-05T15:00:00.950Z', + price: '605', + quantity: '100', + in_stock: 'true', + }, + { + name: 'product1', + time: '2018-02-06T15:00:00.950Z', + price: '583', + quantity: '200', + in_stock: 'true', + }, + { + name: 'product1', + time: '2018-02-07T15:00:00.950Z', + price: '420', + quantity: '300', + in_stock: 'true', + }, + { + name: 'product2', + time: '2018-02-05T15:00:00.950Z', + price: '216', + quantity: '350', + in_stock: 'false', + }, + { + name: 'product2', + time: '2018-02-06T15:00:00.950Z', + price: '200', + quantity: '256', + in_stock: 'false', + }, + { + name: 'product2', + time: '2018-02-07T15:00:00.950Z', + price: '190', + quantity: '231', + in_stock: 'false', + }, + { + name: 'product3', + time: '2018-02-05T15:00:00.950Z', + price: '67', + quantity: '240', + in_stock: 'true', + }, + { + name: 'product4', + time: '2018-02-05T15:00:00.950Z', + price: '311', + quantity: '447', + in_stock: 'false', + }, + { + name: 'product5', + time: '2018-02-05T15:00:00.950Z', + price: '288', + quantity: '384', + in_stock: 'true', + }, + ], +}; + +export { emptyTable, testTable, stringTable }; diff --git a/common/functions/__tests__/getCell.js b/common/functions/__tests__/getCell.js new file mode 100644 index 0000000000000..513c3bfaa88f6 --- /dev/null +++ b/common/functions/__tests__/getCell.js @@ -0,0 +1,76 @@ +import expect from 'expect.js'; +import { getCell } from '../getCell'; +import { functionWrapper } from '../../../__tests__/helpers/function_wrapper'; +import { emptyTable, testTable } from './fixtures/test_tables'; + +describe('getCell', () => { + const fn = functionWrapper(getCell); + + it('returns the value from the specified row and column', () => { + const arbitraryRowIndex = 3; + + expect(fn(testTable, { _: 'quantity', row: arbitraryRowIndex })).to.eql( + testTable.rows[arbitraryRowIndex].quantity + ); + }); + + describe('args', () => { + const firstColumn = testTable.columns[0].name; + + it('defaults to first column in first row if no args are provided', () => { + expect(fn(testTable)).to.be(testTable.rows[0][firstColumn]); + }); + + describe('_', () => { + const arbitraryRowIndex = 1; + + it('sets which column to get the value from', () => { + expect(fn(testTable, { _: 'price', row: arbitraryRowIndex })).to.be( + testTable.rows[arbitraryRowIndex].price + ); + }); + + it('defaults to first column if not provided', () => { + expect(fn(testTable, { row: arbitraryRowIndex })).to.be( + testTable.rows[arbitraryRowIndex][firstColumn] + ); + }); + + it('throws when invalid column is provided', () => { + expect(() => fn(testTable, { _: 'foo' })).to.throwException(e => { + expect(e.message).to.be('Column not found: foo'); + }); + }); + }); + + describe('row', () => { + it('sets which row to get the value from', () => { + const arbitraryRowIndex = 8; + + expect(fn(testTable, { _: 'in_stock', row: arbitraryRowIndex })).to.eql( + testTable.rows[arbitraryRowIndex].in_stock + ); + }); + + it('defaults to first row if not specified', () => { + expect(fn(testTable, { _: 'name' })).to.eql(testTable.rows[0].name); + }); + + it('throws when row does not exist', () => { + const invalidRow = testTable.rows.length; + + expect(() => fn(testTable, { _: 'name', row: invalidRow })).to.throwException(e => { + expect(e.message).to.be(`Row not found: ${invalidRow}`); + }); + + expect(() => fn(emptyTable, { _: 'foo' })).to.throwException(e => { + expect(e.message).to.be('Row not found: 0'); + }); + + expect(() => fn(emptyTable)).to.throwException(e => { + expect(e.message).to.be('Row not found: 0'); + }); + }); + }); + }); +}); diff --git a/common/functions/__tests__/head.js b/common/functions/__tests__/head.js new file mode 100644 index 0000000000000..2f84268e1e82c --- /dev/null +++ b/common/functions/__tests__/head.js @@ -0,0 +1,31 @@ +import expect from 'expect.js'; +import { head } from '../head'; +import { functionWrapper } from '../../../__tests__/helpers/function_wrapper'; +import { emptyTable, testTable } from './fixtures/test_tables'; + +describe('head', () => { + const fn = functionWrapper(head); + + it('returns a datatable with the first N rows of the context', () => { + const result = fn(testTable, { _: 2 }); + + expect(result.type).to.be('datatable'); + expect(result.columns).to.eql(testTable.columns); + expect(result.rows).to.have.length(2); + expect(result.rows[0]).to.eql(testTable.rows[0]); + expect(result.rows[1]).to.eql(testTable.rows[1]); + }); + + it('returns the original context if N >= context.rows.length', () => { + expect(fn(testTable, { _: testTable.rows.length + 5 })).to.eql(testTable); + expect(fn(testTable, { _: testTable.rows.length })).to.eql(testTable); + expect(fn(emptyTable)).to.eql(emptyTable); + }); + + it('returns the first row if N is not specified', () => { + const result = fn(testTable); + + expect(result.rows).to.have.length(1); + expect(result.rows[0]).to.eql(testTable.rows[0]); + }); +}); diff --git a/common/functions/__tests__/mapColumn.js b/common/functions/__tests__/mapColumn.js new file mode 100644 index 0000000000000..b3ac4704c4504 --- /dev/null +++ b/common/functions/__tests__/mapColumn.js @@ -0,0 +1,66 @@ +import expect from 'expect.js'; +import { mapColumn } from '../mapColumn'; +import { functionWrapper } from '../../../__tests__/helpers/function_wrapper'; +import { testTable } from './fixtures/test_tables'; + +const pricePlusTwo = datatable => Promise.resolve(datatable.rows[0].price + 2); + +describe('mapColumn', () => { + const fn = functionWrapper(mapColumn); + + it('returns a datatable with a new column with the values from mapping a function over each row in a datatable', () => { + return fn(testTable, { _: 'pricePlusTwo', expression: pricePlusTwo }).then(result => { + const arbitraryRowIndex = 2; + + expect(result.type).to.be('datatable'); + expect(result.columns).to.eql([ + ...testTable.columns, + { name: 'pricePlusTwo', type: 'number' }, + ]); + expect(result.columns[result.columns.length - 1]).to.have.property('name', 'pricePlusTwo'); + expect(result.rows[arbitraryRowIndex]).to.have.property('pricePlusTwo'); + }); + }); + + it('overwrites existing column with the new column if an existing column name is provided', () => { + return fn(testTable, { _: 'name', expression: pricePlusTwo }).then(result => { + const nameColumnIndex = result.columns.findIndex(({ name }) => name === 'name'); + const arbitraryRowIndex = 4; + + expect(result.type).to.be('datatable'); + expect(result.columns).to.have.length(testTable.columns.length); + expect(result.columns[nameColumnIndex]) + .to.have.property('name', 'name') + .and.to.have.property('type', 'number'); + expect(result.rows[arbitraryRowIndex]).to.have.property('name', 202); + }); + }); + + describe('missing args', () => { + describe('_', () => { + it('throws when no column name is provided', () => { + expect(() => fn(testTable, { expression: pricePlusTwo })).to.throwException(e => { + expect(e.message).to.be('Must provide a column name'); + }); + + expect(() => fn(testTable, { _: '', expression: pricePlusTwo })).to.throwException(e => { + expect(e.message).to.be('Must provide a column name'); + }); + }); + }); + + describe('expression', () => { + it('maps null values to the new column', () => { + return fn(testTable, { _: 'empty' }).then(result => { + const emptyColumnIndex = result.columns.findIndex(({ name }) => name === 'empty'); + const arbitraryRowIndex = 8; + + expect(result.columns[emptyColumnIndex]) + .to.have.property('name', 'empty') + .and.to.have.property('type', 'null'); + expect(result.rows[arbitraryRowIndex]).to.have.property('empty', null); + }); + }); + }); + }); +}); diff --git a/common/functions/__tests__/ply.js b/common/functions/__tests__/ply.js new file mode 100644 index 0000000000000..322836f48cd20 --- /dev/null +++ b/common/functions/__tests__/ply.js @@ -0,0 +1,110 @@ +import expect from 'expect.js'; +import { ply } from '../ply'; +import { functionWrapper } from '../../../__tests__/helpers/function_wrapper'; +import { testTable } from './fixtures/test_tables'; + +const averagePrice = datatable => { + const average = datatable.rows.reduce((sum, row) => sum + row.price, 0) / datatable.rows.length; + + return Promise.resolve({ + type: 'datatable', + columns: [{ name: 'average_price', type: 'number' }], + rows: [{ average_price: average }], + }); +}; + +const doublePrice = datatable => { + const newRows = datatable.rows.map(row => ({ double_price: row.price * 2 })); + + return Promise.resolve({ + type: 'datatable', + columns: [{ name: 'double_price', type: 'number' }], + rows: newRows, + }); +}; + +const rowCount = datatable => { + return Promise.resolve({ + type: 'datatable', + columns: [{ name: 'row_count', type: 'number' }], + rows: [ + { + row_count: datatable.rows.length, + }, + ], + }); +}; + +describe('ply', () => { + const fn = functionWrapper(ply); + + it('maps a function over sub datatables grouped by specified columns and merges results into one datatable', () => { + const arbitaryRowIndex = 0; + + return fn(testTable, { by: ['name', 'in_stock'], expression: [averagePrice, rowCount] }).then( + result => { + expect(result.type).to.be('datatable'); + expect(result.columns).to.eql([ + { name: 'name', type: 'string' }, + { name: 'in_stock', type: 'boolean' }, + { name: 'average_price', type: 'number' }, + { name: 'row_count', type: 'number' }, + ]); + expect(result.rows[arbitaryRowIndex]) + .to.have.property('average_price') + .and.to.have.property('row_count'); + } + ); + }); + + describe('missing args', () => { + it('returns the original datatable if both args are missing', () => { + return fn(testTable).then(result => expect(result).to.eql(testTable)); + }); + + describe('by', () => { + it('passes the entire context into the expression when no columns are provided', () => { + return fn(testTable, { expression: [rowCount] }).then(result => + expect(result).to.eql({ + type: 'datatable', + rows: [{ row_count: testTable.rows.length }], + columns: [{ name: 'row_count', type: 'number' }], + }) + ); + }); + + it('throws when by is an invalid column', () => { + expect(() => fn(testTable, { by: [''], expression: [averagePrice] })).to.throwException( + e => { + expect(e.message).to.be('No such column: '); + } + ); + expect(() => fn(testTable, { by: ['foo'], expression: [averagePrice] })).to.throwException( + e => { + expect(e.message).to.be('No such column: foo'); + } + ); + }); + }); + + describe('expression', () => { + it('returns the original datatable grouped by the specified columns', () => { + const arbitaryRowIndex = 6; + + return fn(testTable, { by: ['price', 'quantity'] }).then(result => { + expect(result.columns[0]).to.have.property('name', 'price'); + expect(result.columns[1]).to.have.property('name', 'quantity'); + expect(result.rows[arbitaryRowIndex]) + .to.have.property('price') + .and.to.have.property('quantity'); + }); + }); + + it('throws when row counts do not match across resulting datatables', () => { + return fn(testTable, { by: ['name'], expression: [doublePrice, rowCount] }).catch(e => + expect(e.message).to.be('All expressions must return the same number of rows') + ); + }); + }); + }); +}); diff --git a/common/functions/__tests__/rowCount.js b/common/functions/__tests__/rowCount.js new file mode 100644 index 0000000000000..0337eba92546a --- /dev/null +++ b/common/functions/__tests__/rowCount.js @@ -0,0 +1,13 @@ +import expect from 'expect.js'; +import { rowCount } from '../rowCount'; +import { functionWrapper } from '../../../__tests__/helpers/function_wrapper'; +import { emptyTable, testTable } from './fixtures/test_tables'; + +describe('rowCount', () => { + const fn = functionWrapper(rowCount); + + it('returns the number of rows in the datatable', () => { + expect(fn(testTable)).to.equal(testTable.rows.length); + expect(fn(emptyTable)).to.equal(0); + }); +}); diff --git a/common/functions/__tests__/sort.js b/common/functions/__tests__/sort.js new file mode 100644 index 0000000000000..6ba7b898a122c --- /dev/null +++ b/common/functions/__tests__/sort.js @@ -0,0 +1,61 @@ +import expect from 'expect.js'; +import { sort } from '../sort'; +import { functionWrapper } from '../../../__tests__/helpers/function_wrapper'; +import { testTable } from './fixtures/test_tables'; + +describe('sort', () => { + const fn = functionWrapper(sort); + + const isSorted = (rows, column, reverse) => { + if (reverse) return !rows.some((row, i) => rows[i + 1] && row[column] < rows[i + 1][column]); + return !rows.some((row, i) => rows[i + 1] && row[column] > rows[i + 1][column]); + }; + + it('returns a datatable sorted by a specified column in asc order', () => { + const result = fn(testTable, { _: 'price', reverse: false }); + + expect(result.type).to.be('datatable'); + expect(result.columns).to.eql(testTable.columns); + expect(isSorted(result.rows, 'price', false)).to.be(true); + }); + + describe('args', () => { + describe('_', () => { + it('sorts on a specified column', () => { + const result = fn(testTable, { _: 'quantity', reverse: true }); + + expect(isSorted(result.rows, 'quantity', true)).to.be(true); + }); + + it('sorts on the first column if not specified', () => { + const result = fn(testTable, { reverse: false }); + + expect(isSorted(result.rows, result.columns[0].name, false)).to.be(true); + }); + + it('returns the original datatable if given an invalid column', () => { + expect(fn(testTable, { _: 'foo' })).to.eql(testTable); + }); + }); + + describe('reverse', () => { + it('sorts in asc order', () => { + const result = fn(testTable, { _: 'in_stock', reverse: false }); + + expect(isSorted(result.rows, 'in_stock', false)).to.be(true); + }); + + it('sorts in desc order', () => { + const result = fn(testTable, { _: 'price', reverse: true }); + + expect(isSorted(result.rows, 'price', true)).to.be(true); + }); + + it('sorts in asc order by default', () => { + const result = fn(testTable, { _: 'time' }); + + expect(isSorted(result.rows, 'time', false)).to.be(true); + }); + }); + }); +}); diff --git a/common/functions/__tests__/staticColumn.js b/common/functions/__tests__/staticColumn.js new file mode 100644 index 0000000000000..16e35bf5f41eb --- /dev/null +++ b/common/functions/__tests__/staticColumn.js @@ -0,0 +1,52 @@ +import expect from 'expect.js'; +import { staticColumn } from '../staticColumn'; +import { functionWrapper } from '../../../__tests__/helpers/function_wrapper'; +import { testTable } from './fixtures/test_tables'; + +describe('staticColumn', () => { + const fn = functionWrapper(staticColumn); + + it('adds a column to a datatable with a static value in every row', () => { + const result = fn(testTable, { _: 'foo', value: 'bar' }); + + expect(result.type).to.be('datatable'); + expect(result.columns).to.eql([...testTable.columns, { name: 'foo', type: 'string' }]); + expect(result.rows.every(row => typeof row.foo === 'string')).to.be(true); + expect(result.rows.every(row => row.foo === 'bar')).to.be(true); + }); + + it('overwrites an existing column if provided an existing column name', () => { + const result = fn(testTable, { _: 'name', value: 'John' }); + + expect(result.type).to.be('datatable'); + expect(result.columns).to.eql(testTable.columns); + expect(result.rows.every(row => typeof row.name === 'string')).to.be(true); + expect(result.rows.every(row => row.name === 'John')).to.be(true); + }); + + describe('missing args:', () => { + describe('_', () => { + it('throws when no column name or invalid column name is provided', () => { + expect(() => fn(testTable)).to.throwException(e => { + expect(e.message).to.be('Must provide a column name'); + }); + expect(() => fn(testTable, { value: 'foo' })).to.throwException(e => { + expect(e.message).to.be('Must provide a column name'); + }); + expect(() => fn(testTable, { _: '', value: 'foo' })).to.throwException(e => { + expect(e.message).to.be('Must provide a column name'); + }); + }); + }); + + describe('value', () => { + it('adds a column with null values', () => { + const result = fn(testTable, { _: 'empty' }); + + expect(result.type).to.be('datatable'); + expect(result.columns).to.eql([...testTable.columns, { name: 'empty', type: 'null' }]); + expect(result.rows.every(row => row.empty === null)).to.be(true); + }); + }); + }); +}); diff --git a/common/functions/__tests__/tail.js b/common/functions/__tests__/tail.js new file mode 100644 index 0000000000000..32777c898f9c6 --- /dev/null +++ b/common/functions/__tests__/tail.js @@ -0,0 +1,32 @@ +import expect from 'expect.js'; +import { tail } from '../tail'; +import { functionWrapper } from '../../../__tests__/helpers/function_wrapper'; +import { emptyTable, testTable } from './fixtures/test_tables'; + +describe('tail', () => { + const fn = functionWrapper(tail); + const lastIndex = testTable.rows.length - 1; + + it('returns a datatable with the last N rows of the context', () => { + const result = fn(testTable, { _: 2 }); + + expect(result.type).to.be('datatable'); + expect(result.columns).to.eql(testTable.columns); + expect(result.rows).to.have.length(2); + expect(result.rows[0]).to.eql(testTable.rows[lastIndex - 1]); + expect(result.rows[1]).to.eql(testTable.rows[lastIndex]); + }); + + it('returns the original context if N >= context.rows.length', () => { + expect(fn(testTable, { _: testTable.rows.length + 5 })).to.eql(testTable); + expect(fn(testTable, { _: testTable.rows.length })).to.eql(testTable); + expect(fn(emptyTable)).to.eql(emptyTable); + }); + + it('returns the last row if N is not specified', () => { + const result = fn(testTable); + + expect(result.rows).to.have.length(1); + expect(result.rows[0]).to.eql(testTable.rows[lastIndex]); + }); +}); diff --git a/common/functions/alterColumn.js b/common/functions/alterColumn.js index c2c1d59372f70..3ba0abbce6939 100644 --- a/common/functions/alterColumn.js +++ b/common/functions/alterColumn.js @@ -25,21 +25,29 @@ export const alterColumn = () => ({ }, }, fn: (context, args) => { - const column = context.columns.find(column => column.name === args.column); - if (!column) throw new Error(`Column not found: '${args.column}'`); + if (!args.column || (!args.type && !args.name)) return context; - let destination = args.column; - if (args.name) { - destination = args.name; - } + const column = context.columns.find(col => col.name === args.column); + if (!column) throw new Error(`Column not found: '${args.column}'`); + const name = args.name || column.name; const type = args.type || column.type; + const columns = context.columns.reduce((all, col) => { + if (col.name !== args.name) { + if (col.name !== column.name) all.push(col); + else all.push({ name, type }); + } + return all; + }, []); + let handler = val => val; + if (args.type) { handler = (function getHandler() { switch (type) { case 'string': + if (column.type === 'date') return v => new Date(v).toISOString(); return String; case 'number': return Number; @@ -48,21 +56,22 @@ export const alterColumn = () => ({ case 'boolean': return Boolean; case 'null': - return null; + return () => null; default: - throw new Error(`can not convert to ${type}`); + throw new Error(`Cannot convert to ${type}`); } })(); } - column.name = destination; - column.type = type; - - context.rows = context.rows.map(row => ({ - ...omit(row, args.column), - [destination]: handler(row[args.column]), + const rows = context.rows.map(row => ({ + ...omit(row, column.name), + [name]: handler(row[column.name]), })); - return context; + return { + type: 'datatable', + columns, + rows, + }; }, }); diff --git a/common/functions/as.js b/common/functions/as.js index 90ba81763db85..5901784575037 100644 --- a/common/functions/as.js +++ b/common/functions/as.js @@ -12,6 +12,7 @@ export const asFn = () => ({ types: ['string'], aliases: ['name'], help: 'A name to give the column', + default: 'value', }, }, fn: (context, args) => { diff --git a/common/functions/columns.js b/common/functions/columns.js index 242edd31e553c..eba52be81177f 100644 --- a/common/functions/columns.js +++ b/common/functions/columns.js @@ -27,14 +27,14 @@ export const columns = () => ({ if (exclude) { const fields = exclude.split(',').map(field => field.trim()); - const rows = result.rows.map(row => omit(row, fields)); const columns = result.columns.filter(col => !fields.includes(col.name)); + const rows = columns.length > 0 ? result.rows.map(row => omit(row, fields)) : []; + result = { ...result, rows, columns }; } if (include) { const fields = include.split(',').map(field => field.trim()); - const rows = result.rows.map(row => pick(row, fields)); //const columns = result.columns.filter(col => fields.includes(col.name)); // Include columns in the order the user specified const columns = []; @@ -42,6 +42,7 @@ export const columns = () => ({ const column = find(result.columns, { name: field }); if (column) columns.push(column); }); + const rows = columns.length > 0 ? result.rows.map(row => pick(row, fields)) : []; result = { ...result, rows, columns }; } diff --git a/common/functions/getCell.js b/common/functions/getCell.js index 7160fb9609ce8..53d13925c3e2b 100644 --- a/common/functions/getCell.js +++ b/common/functions/getCell.js @@ -21,8 +21,10 @@ export const getCell = () => ({ const row = context.rows[args.row]; if (!row) throw new Error(`Row not found: ${args.row}`); + if (!args._) args._ = context.columns[0].name; const value = row[args._]; - if (typeof value === 'undefined') `Column not found: ${args._}`; + + if (typeof value === 'undefined') throw new Error(`Column not found: ${args._}`); return value; }, diff --git a/common/functions/head.js b/common/functions/head.js index 9c504c2d6a255..f72ab6108bfe1 100644 --- a/common/functions/head.js +++ b/common/functions/head.js @@ -12,6 +12,7 @@ export const head = () => ({ _: { types: ['number'], help: 'Return this many rows from the beginning of the datatable', + default: 1, }, }, fn: (context, args) => ({ diff --git a/common/functions/index.js b/common/functions/index.js index 0d7c042e18326..b5ed3d529c438 100644 --- a/common/functions/index.js +++ b/common/functions/index.js @@ -17,7 +17,6 @@ import { grid } from './grid'; import { head } from './head'; import { ifFn } from './if'; import { image } from './image'; -import { jsonquery } from './jsonquery'; import { mapColumn } from './mapColumn'; import { markdown } from './markdown'; import { math } from './math'; @@ -60,7 +59,6 @@ export const commonFunctions = [ head, ifFn, image, - jsonquery, mapColumn, markdown, math, diff --git a/common/functions/jsonquery.js b/common/functions/jsonquery.js deleted file mode 100644 index af0e47e4657b4..0000000000000 --- a/common/functions/jsonquery.js +++ /dev/null @@ -1,36 +0,0 @@ -import jsonQuery from 'json-query'; -import { get } from 'lodash'; - -// TODO: Decide if we actually want to get data from table like this -// We need *some* way of accessing the data in tables, that could be via a collection of our own functions -// Or via something like this. I'm using JSON Query for the moment because it is easy, but we should make -// an actual decision, and replace this if needed. -export const jsonquery = () => ({ - name: 'jsonquery', - aliases: [], - type: 'string', - help: - 'Retrieve a string from a datatable using JSON Query. (See https://github.com/mmckegg/json-query)', - context: { - types: ['datatable'], - }, - args: { - _: { - types: ['string'], - aliases: ['q', 'query'], - multi: false, - help: 'A JSON Query to run on the datatable rows.', - }, - }, - fn: (context, args) => { - const result = get( - jsonQuery(args._, { - data: context.rows, - }), - 'value' - ); - - if (Array.isArray(result)) return result.map(item => String(item)).join(', '); - return String(result); - }, -}); diff --git a/common/functions/mapColumn.js b/common/functions/mapColumn.js index 4e9d63bf21843..f8931c1d26e2e 100644 --- a/common/functions/mapColumn.js +++ b/common/functions/mapColumn.js @@ -17,27 +17,22 @@ export const mapColumn = () => ({ expression: { types: ['function'], aliases: ['exp', 'fn'], - help: - 'A canvas expression which will be passed each row as a single row datatable unless you set context=false', - }, - context: { - types: ['boolean'], - default: 'true', - help: 'Should I pass context into `expression`?', + help: 'A canvas expression which will be passed each row as a single row datatable', }, }, fn: (context, args) => { + if (!args._) throw new Error('Must provide a column name'); + + args.expression = args.expression || (() => Promise.resolve(null)); + + const columns = [...context.columns]; const rowPromises = context.rows.map(row => { return args - .expression( - !args.context - ? null - : { - type: 'datatable', - columns: context.columns, - rows: [row], - } - ) + .expression({ + type: 'datatable', + columns, + rows: [row], + }) .then(val => { if (typeof val === 'object' && val !== null) { throw new Error('Expression must return a literal, eg a string, number, boolean, null'); @@ -51,13 +46,18 @@ export const mapColumn = () => ({ }); return Promise.all(rowPromises).then(rows => { - if (!context.columns.find(column => column.name === args._)) { - context.columns.push({ name: args._, type: getType(rows[0][args._]) }); + const existingColumnIndex = columns.findIndex(({ name }) => name === args._); + const type = getType(rows[0][args._]); + const newColumn = { name: args._, type }; + if (existingColumnIndex === -1) { + columns.push(newColumn); + } else { + columns[existingColumnIndex] = newColumn; } return { type: 'datatable', - columns: context.columns, + columns, rows, }; }); diff --git a/common/functions/ply.js b/common/functions/ply.js index 2e9f808cf6396..4fd1999d32ac8 100644 --- a/common/functions/ply.js +++ b/common/functions/ply.js @@ -12,10 +12,11 @@ function checkDatatableType(datatable) { function combineColumns(arrayOfColumnsArrays) { return arrayOfColumnsArrays.reduce((resultingColumns, columns) => { - columns.forEach(column => { - if (resultingColumns.find(resultingColumn => resultingColumn.name === column.name)) return; - else resultingColumns.push(column); - }); + if (columns) + columns.forEach(column => { + if (resultingColumns.find(resultingColumn => resultingColumn.name === column.name)) return; + else resultingColumns.push(column); + }); return resultingColumns; }, []); @@ -83,22 +84,33 @@ export const ply = () => ({ // The way the function below is written you can add as many arbitrary named args as you want. }, fn: (context, args) => { - const byColumns = args.by.map(by => { - const column = context.columns.find(column => column.name === by); - if (!column) throw new Error(`No such column: ${by}`); - return column; - }); + if (!args) return context; + let byColumns; + let originalDatatables; - const keyedDatatables = groupBy(context.rows, row => JSON.stringify(pick(row, args.by))); - const originalDatatables = Object.values(keyedDatatables).map(rows => ({ - ...context, - rows, - })); + if (args.by) { + byColumns = args.by.map(by => { + const column = context.columns.find(column => column.name === by); + if (!column) throw new Error(`No such column: ${by}`); + return column; + }); + const keyedDatatables = groupBy(context.rows, row => JSON.stringify(pick(row, args.by))); + originalDatatables = Object.values(keyedDatatables).map(rows => ({ + ...context, + rows, + })); + } else { + originalDatatables = [context]; + } const datatablePromises = originalDatatables.map(originalDatatable => { - const expressionResultPromises = args.expression.map(expression => - expression(originalDatatable) - ); + let expressionResultPromises = []; + + if (args.expression) { + expressionResultPromises = args.expression.map(expression => expression(originalDatatable)); + } else { + expressionResultPromises.push(Promise.resolve(originalDatatable)); + } return Promise.all(expressionResultPromises).then(combineAcross); }); diff --git a/common/functions/sort.js b/common/functions/sort.js index 580c7fb10c711..6080c7b90c69f 100644 --- a/common/functions/sort.js +++ b/common/functions/sort.js @@ -2,7 +2,6 @@ import { sortBy } from 'lodash'; export const sort = () => ({ name: 'sort', - aliases: [], type: 'datatable', help: 'Sorts a datatable on a column', context: { @@ -11,17 +10,23 @@ export const sort = () => ({ args: { _: { types: ['string'], - aliases: [], + aliases: ['column'], multi: false, // TODO: No reason you couldn't. - help: 'The column to sort on', + help: + 'The column to sort on. If column is not specified, the datatable will be sorted on the first column.', }, reverse: { types: ['boolean'], - help: 'Reverse the sort order', + help: + 'Reverse the sort order. If reverse is not specified, the datatable will be sorted in ascending order.', }, }, - fn: (context, args) => ({ - ...context, - rows: args.reverse ? sortBy(context.rows, args._).reverse() : sortBy(context.rows, args._), - }), + fn: (context, args) => { + const column = args._ || context.columns[0].name; + + return { + ...context, + rows: args.reverse ? sortBy(context.rows, column).reverse() : sortBy(context.rows, column), + }; + }, }); diff --git a/common/functions/staticColumn.js b/common/functions/staticColumn.js index 8e8d2c2a4cf8a..033b5676eae2a 100644 --- a/common/functions/staticColumn.js +++ b/common/functions/staticColumn.js @@ -17,11 +17,23 @@ export const staticColumn = () => ({ types: ['string', 'number', 'boolean', 'null'], help: 'The value to insert in each column. Tip: use a sub-expression to rollup other columns into a static value', + default: null, }, }, fn: (context, args) => { + if (!args._) throw new Error('Must provide a column name'); + const rows = context.rows.map(row => ({ ...row, [args._]: args.value })); - const columns = context.columns.concat([{ type: getType(rows[0][args._]), name: args._ }]); + const type = getType(rows[0][args._]); + const columns = [...context.columns]; + const existingColumnIndex = columns.findIndex(({ name }) => name === args._); + const newColumn = { name: args._, type }; + + if (existingColumnIndex > -1) { + columns[existingColumnIndex] = newColumn; + } else { + columns.push(newColumn); + } return { type: 'datatable', diff --git a/package.json b/package.json index 17118075cd94a..244ec139b2e1e 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "history": "^4.7.2", "inline-style": "^2.0.0", "jquery": "^3.3.1", - "json-query": "^2.2.2", "lodash": "^3.10.1", "lodash.clone": "^4.5.0", "lodash.keyby": "^4.6.0",