diff --git a/README.md b/README.md index da48d5d..1207d82 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Inspired by [dplyr](https://dplyr.tidyverse.org/) and [Arquero](https://github.c ### Run locally 1. Clone this [repo](https://github.com/Justin-ZS/bow) 1. Run `npm i` -1. Run `npm run server` +1. Run `npm start` 1. Do whatever you like ### Develop RoadMap diff --git a/index.html b/index.html index 1e9b4ed..438c2c3 100644 --- a/index.html +++ b/index.html @@ -80,7 +80,7 @@

Example

age: [12, 12, 12, 14, 14], }) .groupBy('name', 'age') - .summarize({ sumOfValue: 'sum(value)' }) + .summarize({ sumOfValue: bow.Op.sum('value') }) .orderBy((a, b) => b.age - a.age) .addColumns({ added: ({ age, sumOfValue }) => age + sumOfValue diff --git a/src/expression/parse.ts b/src/expression/parse.ts index dd4ddcd..cd2f556 100644 --- a/src/expression/parse.ts +++ b/src/expression/parse.ts @@ -1,17 +1,35 @@ import { parse } from 'acorn'; import * as ESTree from 'estree'; +import transformAST from './transform'; const PARSE_OPTS: acorn.Options = { ecmaVersion: 'latest', sourceType: 'script', }; +export const FUNC_PREFIX = '_is_Fn_\u001d'; + +const replacer = (key, value) => { + if (typeof value === 'function') return `${FUNC_PREFIX}${value}`; + return value; +}; + +const transformers = { + 'Literal': (node) => { + if (node.value.startsWith(FUNC_PREFIX)) { + const funcAST = parse(JSON.parse(node.raw).slice(FUNC_PREFIX.length), PARSE_OPTS); + return funcAST; + } + return node; + } +}; // Expression -> ES AST export const parseES = (expr: string | Function): ESTree.Node => { if (!expr) throw Error("ParseES: Empty Expression"); try { - const ast: any = parse(`expr=${expr}`, PARSE_OPTS); - return ast.body[0].expression.right; + const ast: any = parse(`expr=${JSON.stringify(expr, replacer)}`, PARSE_OPTS); + const content = ast.body[0].expression.right; + return transformAST(content, null, transformers); } catch(err) { throw Error(`ParseES: ${err}`); } diff --git a/src/expression/transform/index.ts b/src/expression/transform/index.ts index 712fbc0..5705123 100644 --- a/src/expression/transform/index.ts +++ b/src/expression/transform/index.ts @@ -2,19 +2,20 @@ import * as ESTree from 'estree'; import baseTransformers, { RecursiveTransformer } from './base'; import customTransformers from './custom'; -const getTransformer = (node: ESTree.Node) => { +const getTransformer = (node: ESTree.Node, custom) => { const type = node.type; - return customTransformers[type] ?? baseTransformers[type] ?? (x => x); + return custom[type] ?? baseTransformers[type] ?? (x => x); }; const transformAST = ( root: ESTree.Node, state: T = null, + customTs = customTransformers, ) => (function t( node: ESTree.Node, st: T, ) { - const transformer = getTransformer(node) as RecursiveTransformer; + const transformer = getTransformer(node, customTs) as RecursiveTransformer; return transformer(node, st, t); })(root, state); diff --git a/src/index.ts b/src/index.ts index 27b3b2a..8a6e4dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,9 @@ -import { Table } from './modules'; +import { Table, Op } from './modules'; import { TableEx } from './extensions'; export { Table, TableEx, + Op, }; diff --git a/src/modules/Aggregator.ts b/src/modules/Aggregator.ts index 9362950..b018fc9 100644 --- a/src/modules/Aggregator.ts +++ b/src/modules/Aggregator.ts @@ -1,40 +1,31 @@ -import { AggregateType, FieldDescription, DataType, AggregateDescription } from 'Typings'; +import { AggregateType, DataType } from 'Typings'; export abstract class Aggregator { - readonly field: FieldDescription; - readonly name: string; - type: AggregateType; dataType = DataType.Null; - constructor(targetField: FieldDescription, name?: string) { - this.field = targetField; - this.name = name; - } - - abstract addUp(value: unknown): Aggregator; - abstract get value(): unknown; + abstract addUp(getter: () => unknown): void; + abstract value: number; - isAnonymous() { - return name === undefined; - } clone(): Aggregator { const Ctor = this.constructor as any; - return new Ctor(this.field, this, name); + return new Ctor(); } } class SumAggregator extends Aggregator { type = AggregateType.Sum; dataType = DataType.Number; + private sum: number = null; - addUp(value: number) { + addUp(getter) { + const value = getter(); if (value == null) return this; this.sum += +value; // @TODO: handle type coerce - return this; } + get value() { return this.sum; } @@ -42,9 +33,11 @@ class SumAggregator extends Aggregator { class MinAggregator extends Aggregator { type = AggregateType.Min; dataType = DataType.Number; + private min: number = null; - addUp(value: number) { + addUp(getter) { + const value = getter(); if (value == null) return this; if (this.min == null) { @@ -52,8 +45,6 @@ class MinAggregator extends Aggregator { } else { this.min = Math.min(this.min, +value); // @TODO: handle type coerce } - - return this; } get value() { return this.min; @@ -62,9 +53,11 @@ class MinAggregator extends Aggregator { class MaxAggregator extends Aggregator { type = AggregateType.Max; dataType = DataType.Number; + private max: number = null; - addUp(value: number) { + addUp(getter) { + const value = getter(); if (value == null) return this; if (this.max == null) { @@ -72,8 +65,6 @@ class MaxAggregator extends Aggregator { } else { this.max = Math.max(this.max, +value); // @TODO: handle type coerce } - - return this; } get value() { return this.max; @@ -82,6 +73,7 @@ class MaxAggregator extends Aggregator { class CountAggregator extends Aggregator { type = AggregateType.Count; dataType = DataType.Number; + private count = 0; addUp() { @@ -95,14 +87,14 @@ class CountAggregator extends Aggregator { class AvgAggregator extends Aggregator { type = AggregateType.Avg; dataType = DataType.Number; - private sumAgg = new SumAggregator(this.field, this.name); - private cntAgg = new CountAggregator(this.field, this.name); - addUp(value: number) { + private sumAgg = new SumAggregator(); + private cntAgg = new CountAggregator(); + + addUp(getter) { + const value = getter(); this.sumAgg.addUp(value); this.cntAgg.addUp(); - - return this; } get value() { const sum = this.sumAgg.value; @@ -115,19 +107,10 @@ class AvgAggregator extends Aggregator { } } -export const getAggregatorByDescription = (aggDesc: AggregateDescription): Aggregator => { - switch (aggDesc.type) { - case AggregateType.Sum: - return new SumAggregator(aggDesc.field, aggDesc.name); - case AggregateType.Min: - return new MinAggregator(aggDesc.field, aggDesc.name); - case AggregateType.Max: - return new MaxAggregator(aggDesc.field, aggDesc.name); - case AggregateType.Count: - return new CountAggregator(aggDesc.field, aggDesc.name); - case AggregateType.Avg: - return new AvgAggregator(aggDesc.field, aggDesc.name); - default: - throw Error(`Not Supported Aggregate Type: ${aggDesc.type}`); - } +export const aggMapping = { + [AggregateType.Sum]: SumAggregator, + [AggregateType.Avg]: AvgAggregator, + [AggregateType.Count]: CountAggregator, + [AggregateType.Max]: MaxAggregator, + [AggregateType.Min]: MinAggregator, }; \ No newline at end of file diff --git a/src/modules/aggregate.ts b/src/modules/aggregate.ts index f0d9285..cf01d83 100644 --- a/src/modules/aggregate.ts +++ b/src/modules/aggregate.ts @@ -1,23 +1,34 @@ -import { ITable, AggregateDescription } from 'Typings'; -import { range } from 'PureUtils'; +import { ITable, FieldDescription } from 'Typings'; +import { range, mapRecord } from 'PureUtils'; import { TableEx } from 'Extensions'; -import { extractGroupedColumns } from './/group'; -import { Aggregator, getAggregatorByDescription } from './Aggregator'; +import { extractGroupedColumns } from './group'; +import { Aggregator, aggMapping } from './Aggregator'; + +export type AggregatorDescription = { + name?: string, + agg: Aggregator, + getterFn: (rowIdx, table) => () => unknown, +} const aggregateGroupedTable = ( - aggs: Aggregator[], + descs: AggregatorDescription[], table: ITable, ) => { const { size, keys, map, names } = table.groups; const groups = range(0, size); - const aggss = aggs.map(agg => groups.map(() => agg.clone())); + const descss: AggregatorDescription[][] = descs + .map(desc => groups + .map(() => ({ + ...desc, + agg: desc.agg.clone(), + }))); table.traverse((rowIdx) => { - aggss.forEach(aggs => { - const agg = aggs[keys[rowIdx]]; - agg.addUp(table.getCell(agg.field.name, rowIdx)); + descss.forEach(descs => { + const desc = descs[keys[rowIdx]]; + desc.agg.addUp(desc.getterFn(rowIdx, table)); }); }); @@ -28,26 +39,26 @@ const aggregateGroupedTable = ( data[names[idx]] = column; }); // aggregated values data - aggs.forEach((agg, idx) => { - data[agg.name] = aggss[idx].map(agg => agg.value); + descs.forEach((desc, idx) => { + data[desc.name] = descss[idx].map(({ agg }) => agg.value); }); return TableEx.fromColumns(data); }; const aggregateFlatTable = ( - aggs: Aggregator[], + descs: AggregatorDescription[], table: ITable, ) => { table.traverse((rowIdx) => { - aggs.forEach(agg => { - agg.addUp(table.getCell(agg.field.name, rowIdx)); + descs.forEach(desc => { + desc.agg.addUp(desc.getterFn(rowIdx, table)); }); }); - const data = aggs - .reduce((acc, agg) => { - acc[agg.name] = [agg.value]; + const data = descs + .reduce((acc, { name, agg }) => { + acc[name] = [agg.value]; return acc; }, {}); @@ -55,13 +66,23 @@ const aggregateFlatTable = ( }; export const getAggregatedTable = ( - aggDescs: AggregateDescription[], + aggDescs: AggregatorDescription[], table: ITable, ) => { - const aggs = aggDescs.map(getAggregatorByDescription); - if (table.isGrouped) { - return aggregateGroupedTable(aggs, table); + return aggregateGroupedTable(aggDescs, table); } - return aggregateFlatTable(aggs, table); -}; \ No newline at end of file + return aggregateFlatTable(aggDescs, table); +}; + +const fields2GetterFn = (fields: FieldDescription[]) => + (rowIdx: number, table: ITable) => + () => (fields.length === 1 + ? table.getCell(fields[0].name, rowIdx) + : fields.map(({ name }) => table.getCell(name, rowIdx))); + +export const aggOps = mapRecord((Agg) => + (...fields: FieldDescription[]): AggregatorDescription => ({ + agg: new Agg(), + getterFn: fields2GetterFn(fields), + }), aggMapping); \ No newline at end of file diff --git a/src/modules/index.ts b/src/modules/index.ts index f7804c8..256ff39 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,6 +1,9 @@ import Table from './table'; + export * from './column'; +import * as Op from './operators'; export { Table, + Op }; \ No newline at end of file diff --git a/src/modules/operators/Operator.ts b/src/modules/operators/Operator.ts deleted file mode 100644 index 9ec22ca..0000000 --- a/src/modules/operators/Operator.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Operator as IOperator } from 'Typings'; - -export class Operator implements IOperator { - type: IOperator['type']; - fields: IOperator['fields']; - - static create(...args: ConstructorParameters) { - return new Operator(...args); - } - constructor(type: IOperator['type'], ...fields: IOperator['fields']) { - this.type = type; - this.fields = fields; - } - toString() { - return JSON.stringify(this); - } -} - -export default Operator; \ No newline at end of file diff --git a/src/modules/operators/aggregate.ts b/src/modules/operators/aggregate.ts index cb2a947..9c025f8 100644 --- a/src/modules/operators/aggregate.ts +++ b/src/modules/operators/aggregate.ts @@ -1,15 +1,15 @@ import { AggregateType } from 'Typings'; -import Op from './Operator'; +import { makeOperator } from 'CommonUtils'; -export const sum = (field: string) => Op.create(AggregateType.Sum, field); +export const sum = (field: string) => makeOperator(AggregateType.Sum, field); -export const average = (field: string) => Op.create(AggregateType.Avg, field); +export const average = (field: string) => makeOperator(AggregateType.Avg, field); export const avg = average; export const mean = average; -export const max = (field: string) => Op.create(AggregateType.Max, field); +export const max = (field: string) => makeOperator(AggregateType.Max, field); -export const min = (field: string) => Op.create(AggregateType.Min, field); +export const min = (field: string) => makeOperator(AggregateType.Min, field); -export const rowCount = () => Op.create(AggregateType.Count); -export const n = () => rowCount; \ No newline at end of file +export const rowCount = () => makeOperator(AggregateType.Count); +export const n = rowCount; \ No newline at end of file diff --git a/src/modules/operators/index.ts b/src/modules/operators/index.ts index 4bc1b0b..cccb0fc 100644 --- a/src/modules/operators/index.ts +++ b/src/modules/operators/index.ts @@ -1,2 +1 @@ -export * from './aggregate'; -export * from './Operator'; \ No newline at end of file +export * from './aggregate'; \ No newline at end of file diff --git a/src/modules/table.ts b/src/modules/table.ts index 0d1784b..47fb861 100644 --- a/src/modules/table.ts +++ b/src/modules/table.ts @@ -1,14 +1,15 @@ import { - ITable, TableData, GroupDescription, FieldDescription, - TableDescription, AggregateDescription, AggregateType, + ITable, TableData, GroupDescription, + FieldDescription, TableDescription, } from 'Typings'; -import { pick, omit } from 'PureUtils'; +import { pick, omit, list2Record } from 'PureUtils'; import { makeFieldDesc } from 'CommonUtils'; +import exprResolver from '../expression'; import { getGroupDesc } from './group'; import { getIndexSet } from './filter'; import { getOrderedIndexes } from './order'; -import { getAggregatedTable } from './aggregate'; +import { getAggregatedTable, aggOps } from './aggregate'; import { getCalculatedTable } from './calculate'; export default class Table implements ITable { @@ -211,23 +212,11 @@ export default class Table implements ITable { } // TODO: use expression in parameters public summarize(aggOpts: any) { - const isSumAgg = str => { - const reg = /sum\((.*)\)/i; - const result = reg.exec(str); - - if (!result) return false; - if (result[1] === '') return false; - return true; - }; - const getFieldName = str => /sum\((.*)\)/i.exec(str)[1]; - - const aggDescs: AggregateDescription[] = Object.entries(aggOpts) - .filter(([_, str]) => isSumAgg(str)) - .map(([name, str]) => ({ - name, - type: AggregateType.Sum, - field: this.getFieldDescriptionByName(getFieldName(str)), - })); + const expr = exprResolver(aggOpts); + const t = list2Record(this.fields, 'name'); + + const aggDescs = Object.entries(expr) + .map(([name, fn]) => ({ name, ...(fn as any)(t, aggOps) })); return getAggregatedTable(aggDescs, this); } // #region alias diff --git a/src/utils/pureUtils.ts b/src/utils/pureUtils.ts index a5618e8..b758850 100644 --- a/src/utils/pureUtils.ts +++ b/src/utils/pureUtils.ts @@ -1,6 +1,7 @@ // Never import any lib here! // All utils should be pure function +const isRecord = (x: unknown) => x && typeof x === 'object' && !Array.isArray(x); const isPropertyInRecord = (prop: string, obj: Record) => Object.prototype.hasOwnProperty.call(obj, prop); // (['a', 'd'], {a: 1, b: 2, c: 3, d: 4}) -> {a: 1, d: 4} @@ -87,4 +88,18 @@ export const isFn = >( } return false; }; -}; \ No newline at end of file +}; +// [1, 2, 3] -> { 1: 1, 2: 2, 3: 3 } +// [{ key: 'A' }, { key: 'B' }] -> { A: { key: 'A' }, B: { key: 'B' } } +export const list2Record = (xs: any[], keyName = 'key') => xs + .reduce((record, x, idx) => { + if (!isRecord(x)) record[idx] = x; + else record[x[keyName] ?? idx] = x; + return record; + }, {}); + +export const lens = (getter = i => i, setter) => ({ + view: getter, + set: setter, + over: (fn, obj) => setter(fn(getter(obj)), obj), +}); \ No newline at end of file diff --git a/src/utils/typeUtils.ts b/src/utils/typeUtils.ts index af10aea..e2a1f6a 100644 --- a/src/utils/typeUtils.ts +++ b/src/utils/typeUtils.ts @@ -1,4 +1,4 @@ -import { GroupDescription, FieldDescription, DataType } from 'Typings'; +import { GroupDescription, FieldDescription, DataType, Operator } from 'Typings'; export const makeGroupDesc = ( names: string[], @@ -12,3 +12,8 @@ export const makeFieldDesc = ( idx: number, type: DataType, ): FieldDescription => ({ name, idx, type }); + +export const makeOperator = (type: Operator['type'], ...fields: Operator['fields']): Operator => ({ + type, + fields, +});