From b7da7fd61a51e022ca4e17330af13149d6be3937 Mon Sep 17 00:00:00 2001 From: Thiago Santos Date: Wed, 19 Apr 2023 18:03:37 -0300 Subject: [PATCH] feat: adding aggregate operation --- src/async/aggregate-async.ts | 4 + src/async/index.ts | 1 + src/mounters/fluent-async-functions.ts | 2 + src/mounters/fluent-functions.ts | 4 + src/recipes/aggregate-recipe.ts | 109 +++++++ src/recipes/index.ts | 1 + src/sync/aggregate.ts | 4 + src/sync/index.ts | 1 + src/types/fluent-async-iterable.ts | 1 + src/types/fluent-iterable.ts | 2 + .../function-types/aggregate-function.ts | 36 +++ src/types/function-types/index.ts | 1 + test/aggregate.spec.ts | 283 ++++++++++++++++++ 13 files changed, 449 insertions(+) create mode 100644 src/async/aggregate-async.ts create mode 100644 src/recipes/aggregate-recipe.ts create mode 100644 src/sync/aggregate.ts create mode 100644 src/types/function-types/aggregate-function.ts create mode 100644 test/aggregate.spec.ts diff --git a/src/async/aggregate-async.ts b/src/async/aggregate-async.ts new file mode 100644 index 00000000..afe4ed0f --- /dev/null +++ b/src/async/aggregate-async.ts @@ -0,0 +1,4 @@ +import { basicAsync } from './basic-ingredients-async'; +import { aggregateRecipe } from '../recipes'; + +export const aggregateAsync = aggregateRecipe(basicAsync); diff --git a/src/async/index.ts b/src/async/index.ts index 2f839fd4..fb8618a9 100644 --- a/src/async/index.ts +++ b/src/async/index.ts @@ -1,3 +1,4 @@ +export * from './aggregate-async'; export * from './all-async'; export * from './any-async'; export * from './append-async'; diff --git a/src/mounters/fluent-async-functions.ts b/src/mounters/fluent-async-functions.ts index e5efa624..da08e0eb 100644 --- a/src/mounters/fluent-async-functions.ts +++ b/src/mounters/fluent-async-functions.ts @@ -1,5 +1,6 @@ import { toMapChainAsync } from './../async/to-map-chain-async'; import { + aggregateAsync, anyAsync, appendAsync, avgAsync, @@ -119,6 +120,7 @@ export const asyncSpecial = { }; export const asyncResolvingFuncs = { + aggregate: aggregateAsync, count: countAsync, first: firstAsync, last: lastAsync, diff --git a/src/mounters/fluent-functions.ts b/src/mounters/fluent-functions.ts index e9412ab3..a4320294 100644 --- a/src/mounters/fluent-functions.ts +++ b/src/mounters/fluent-functions.ts @@ -1,4 +1,5 @@ import { + aggregate, any, branch, contains, @@ -62,6 +63,7 @@ import { toMapChainReduce, } from '../sync'; import { + aggregateAsync, allAsync, avgAsync, anyAsync, @@ -166,6 +168,8 @@ export const specialAsync = { }; export const resolvingFuncs = { + aggregate, + aggregateAsync, count, countAsync, emit, diff --git a/src/recipes/aggregate-recipe.ts b/src/recipes/aggregate-recipe.ts new file mode 100644 index 00000000..a7818497 --- /dev/null +++ b/src/recipes/aggregate-recipe.ts @@ -0,0 +1,109 @@ +import { AnyIterable } from 'augmentative-iterable'; +import { AverageStepper } from '../types'; +import { getAverageStepper } from '../utils'; +import { BasicIngredients } from './ingredients'; + +const contextSymbol = Symbol('context'); + +class Context { + id = 0; + customIds: any = {}; + modMultiplyId = 0; + modMultiplySymbols: any = {}; + modSumId = 0; + modSumSymbols: any = {}; + [key: string]: any; + + resetIds() { + this.modSumId = this.modMultiplyId = this.id = 0; + } + defaultId() { + return (this.customIds[this.id++] ??= Symbol(this.id)); + } + getModMultiplyId(id: any) { + return typeof id === 'symbol' + ? id + : (this.modMultiplySymbols[id] ??= Symbol(id)); + } + getModSumId(id: any) { + return typeof id === 'symbol' + ? id + : (this.modSumSymbols[id] ??= Symbol(id)); + } +} + +class Aggregations { + [contextSymbol] = new Context(); + + sum(value: number, id: string | number = this[contextSymbol].defaultId()) { + const context = (this[contextSymbol].sum ??= {}); + return (context[id] = (context[id] ?? 0) + value); + } + multiply( + value: number, + id: string | number = this[contextSymbol].defaultId(), + ) { + const context = (this[contextSymbol].multiply ??= {}); + return (context[id] = (context[id] ?? 1) * value); + } + max( + value: string | number, + id: string | number = this[contextSymbol].defaultId(), + ) { + const context = (this[contextSymbol].max ??= {}); + if (context[id] === undefined || context[id] < value) context[id] = value; + return context[id]; + } + avg(value: number, id: string | number = this[contextSymbol].defaultId()) { + const context = (this[contextSymbol].avg ??= {}); + if (context[id] === undefined) context[id] = getAverageStepper(); + const stepper: AverageStepper = context[id]; + stepper.step(value); + return stepper.avg; + } + min(value: T, id: string | number = this[contextSymbol].defaultId()) { + const context = (this[contextSymbol].min ??= {}); + if (context[id] === undefined || context[id] > value) context[id] = value; + return context[id]; + } + first(value: T, id: string | number = this[contextSymbol].defaultId()) { + const context = (this[contextSymbol].first ??= {}); + if (context[id] === undefined) context[id] = { value }; + return context[id].value; + } + last(value: T) { + return value; + } + modSum(value: number, mod: number, id = this[contextSymbol].defaultId()) { + const context = (this[contextSymbol].modSumInternal ??= {}); + return (context[id] = + this.sum(value, this[contextSymbol].getModSumId(id)) % mod); + } + modMultiply( + value: number, + mod: number, + id = this[contextSymbol].defaultId(), + ) { + const context = (this[contextSymbol].modMultiplyInternal ??= {}); + return (context[id] = + this.multiply(value, this[contextSymbol].getModMultiplyId(id)) % mod); + } +} + +export function aggregateRecipe(ingredients: BasicIngredients): any { + const { forEach, resolver } = ingredients; + return function aggregate( + this: AnyIterable, + callback: (a: T, agg: Aggregations, prev: any) => any, + result?: any, + ) { + const agg = new Aggregations(); + return resolver( + forEach.call(this, (item: T) => { + agg[contextSymbol].resetIds(); + result = callback(item, agg, result); + }), + () => result, + ); + }; +} diff --git a/src/recipes/index.ts b/src/recipes/index.ts index 6b2dbd50..a8ece639 100644 --- a/src/recipes/index.ts +++ b/src/recipes/index.ts @@ -1,3 +1,4 @@ +export * from './aggregate-recipe'; export * from './append-recipe'; export * from './augment-iterable-recipe'; export * from './avg-recipe'; diff --git a/src/sync/aggregate.ts b/src/sync/aggregate.ts new file mode 100644 index 00000000..3d820a8f --- /dev/null +++ b/src/sync/aggregate.ts @@ -0,0 +1,4 @@ +import { basic } from './basic-ingredients'; +import { aggregateRecipe } from '../recipes'; + +export const aggregate = aggregateRecipe(basic); diff --git a/src/sync/index.ts b/src/sync/index.ts index 6b949a9c..7cc5da1d 100644 --- a/src/sync/index.ts +++ b/src/sync/index.ts @@ -1,3 +1,4 @@ +export * from './aggregate'; export * from './all'; export * from './any'; export * from './append'; diff --git a/src/types/fluent-async-iterable.ts b/src/types/fluent-async-iterable.ts index 90e1f29f..52669c1a 100644 --- a/src/types/fluent-async-iterable.ts +++ b/src/types/fluent-async-iterable.ts @@ -2,6 +2,7 @@ import * as f from './function-types'; declare module './base' { interface FluentAsyncIterable { + aggregate: f.AsyncAggregateFunction; withIndex: f.AsyncWithIndexFunction; takeWhile: f.AsyncTakeWhileFunction; take: f.AsyncTakeFunction; diff --git a/src/types/fluent-iterable.ts b/src/types/fluent-iterable.ts index a946753e..a259e33e 100644 --- a/src/types/fluent-iterable.ts +++ b/src/types/fluent-iterable.ts @@ -2,6 +2,8 @@ import * as f from './function-types'; declare module './base' { interface FluentIterable { + aggregate: f.AggregateFunction; + aggregateAsync: f.AsyncAggregateFunction; withIndex: f.WithIndexFunction; takeWhile: f.TakeWhileFunction; takeWhileAsync: f.AsyncTakeWhileFunction; diff --git a/src/types/function-types/aggregate-function.ts b/src/types/function-types/aggregate-function.ts new file mode 100644 index 00000000..a432bf6a --- /dev/null +++ b/src/types/function-types/aggregate-function.ts @@ -0,0 +1,36 @@ +export interface Aggregations { + sum(value: number, id?: string | number): number; + multiply(value: number, id?: string | number): number; + max(value: T, id?: string | number): T; + avg(value: number, id?: string | number): number; + min(value: T, id?: string | number): T; + first(value: T, id?: string | number): T; + last(value: T, id?: string | number): T; + modSum(value: number, mod: number, id?: string | number): number; + modMultiply(value: number, mod: number, id?: string | number): number; +} + +export interface AggregateFunction { + /** + * Execute aggregations, returning the last result. This is a resolving operation + * The aggregations available are: sum, multiply, max, avg, min, first, last, modSum, modMultiply + * @param callback The callback to execute the aggregations. It receives a second param: an object containing all the available aggregations + * @returns the last method result + */ + ( + callback: (item: T, agg: Aggregations, acc: I) => R, + initialize?: I, + ): R; +} +export interface AsyncAggregateFunction { + /** + * Execute aggregations, returning the last result. This is a resolving operation + * The aggregations available are: sum, multiply, max, avg, min, first, last, modSum, modMultiply + * @param callback The callback to execute the aggregations. It receives a second param: an object containing all the available aggregations + * @returns the last method result + */ + ( + callback: (item: T, agg: Aggregations, acc: R | undefined) => R, + initialize?: I, + ): Promise; +} diff --git a/src/types/function-types/index.ts b/src/types/function-types/index.ts index e81fa0e8..8d6800c5 100644 --- a/src/types/function-types/index.ts +++ b/src/types/function-types/index.ts @@ -1,3 +1,4 @@ +export * from './aggregate-function'; export * from './all-function'; export * from './any-function'; export * from './append-function'; diff --git a/test/aggregate.spec.ts b/test/aggregate.spec.ts new file mode 100644 index 00000000..3036d058 --- /dev/null +++ b/test/aggregate.spec.ts @@ -0,0 +1,283 @@ +import { expect } from 'chai'; +import { fluent, fluentAsync } from '../src'; +import 'chai-callslike'; + +describe('aggregate', () => { + describe('sync', () => { + it('should work without custom ids', () => { + const result = fluent([10, 20, 30]).aggregate((x, agg) => ({ + avg: agg.avg(x), + avg2: agg.avg(x * 2), + first: agg.first(x), + first2: agg.first(x * 2), + last: agg.last(x), + last2: agg.last(x * 2), + max: agg.max(x), + max2: agg.max(x * 2), + min: agg.min(x), + min2: agg.min(x * 2), + modMultiply: agg.modMultiply(x, 11), + modMultiply2: agg.modMultiply(x * 2, 11), + modSum: agg.modSum(x, 11), + modSum2: agg.modSum(x * 2, 11), + multiply: agg.multiply(x), + multiply2: agg.multiply(x * 2), + sum: agg.sum(x), + sum2: agg.sum(x * 2), + })); + + expect(result).to.be.like({ + avg: 20, + avg2: 40, + first: 10, + first2: 20, + last: 30, + last2: 60, + max: 30, + max2: 60, + min: 10, + min2: 20, + modMultiply: 5, + modMultiply2: 7, + modSum: 5, + modSum2: 10, + multiply: 6000, + multiply2: 48000, + sum: 60, + sum2: 120, + }); + }); + + it('should work with conditional', () => { + const result = fluent([5, 6, 9]).aggregate( + (x, agg, acc) => + acc + (x % 2 === 1 ? agg.modSum(x, 3, 1) : agg.modSum(x, 5, 2)), + 0, + ); + + expect(result).to.be.eq(5); + }); + + it('should work with custom ids', () => { + const result = fluent([10, 20, 30]).aggregate((x, agg) => ({ + avg: agg.avg(x, 1), + avg2: agg.avg(x * 2, 1), + first: agg.first(x, 1), + first2: agg.first(x * 2, 1), + last: agg.last(x, 1), + last2: agg.last(x * 2, 1), + max: agg.max(x, 1), + max2: agg.max(x * 2, 1), + min: agg.min(x, 1), + min2: agg.min(x * 2, 1), + modMultiply: agg.modMultiply(x, 11, 1), + modMultiply2: agg.modMultiply(x * 2, 11, 1), + modSum: agg.modSum(x, 11, 1), + modSum2: agg.modSum(x * 2, 11, 1), + multiply: agg.multiply(x, 1), + multiply2: agg.multiply(x * 2, 1), + sum: agg.sum(x, 1), + sum2: agg.sum(x * 2, 1), + })); + + expect(result).to.be.like({ + avg: 24, + avg2: 30, + first: 10, + first2: 10, + last: 30, + last2: 60, + max: 40, + max2: 60, + min: 10, + min2: 10, + modMultiply: 7, + modMultiply2: 2, + modSum: 10, + modSum2: 4, + multiply: 4800000, + multiply2: 288000000, + sum: 120, + sum2: 180, + }); + }); + + it('should work without custom ids using async variant', async () => { + const result = await fluent([10, 20, 30]).aggregateAsync((x, agg) => ({ + avg: agg.avg(x), + avg2: agg.avg(x * 2), + first: agg.first(x), + first2: agg.first(x * 2), + last: agg.last(x), + last2: agg.last(x * 2), + max: agg.max(x), + max2: agg.max(x * 2), + min: agg.min(x), + min2: agg.min(x * 2), + modMultiply: agg.modMultiply(x, 11), + modMultiply2: agg.modMultiply(x * 2, 11), + modSum: agg.modSum(x, 11), + modSum2: agg.modSum(x * 2, 11), + multiply: agg.multiply(x), + multiply2: agg.multiply(x * 2), + sum: agg.sum(x), + sum2: agg.sum(x * 2), + })); + + expect(result).to.be.like({ + avg: 20, + avg2: 40, + first: 10, + first2: 20, + last: 30, + last2: 60, + max: 30, + max2: 60, + min: 10, + min2: 20, + modMultiply: 5, + modMultiply2: 7, + modSum: 5, + modSum2: 10, + multiply: 6000, + multiply2: 48000, + sum: 60, + sum2: 120, + }); + }); + + it('should work with custom ids using async variant', async () => { + const result = await fluent([10, 20, 30]).aggregate((x, agg) => ({ + avg: agg.avg(x, 1), + avg2: agg.avg(x * 2, 1), + first: agg.first(x, 1), + first2: agg.first(x * 2, 1), + last: agg.last(x, 1), + last2: agg.last(x * 2, 1), + max: agg.max(x, 1), + max2: agg.max(x * 2, 1), + min: agg.min(x, 1), + min2: agg.min(x * 2, 1), + modMultiply: agg.modMultiply(x, 11, 1), + modMultiply2: agg.modMultiply(x * 2, 11, 1), + modSum: agg.modSum(x, 11, 1), + modSum2: agg.modSum(x * 2, 11, 1), + multiply: agg.multiply(x, 1), + multiply2: agg.multiply(x * 2, 1), + sum: agg.sum(x, 1), + sum2: agg.sum(x * 2, 1), + })); + + expect(result).to.be.like({ + avg: 24, + avg2: 30, + first: 10, + first2: 10, + last: 30, + last2: 60, + max: 40, + max2: 60, + min: 10, + min2: 10, + modMultiply: 7, + modMultiply2: 2, + modSum: 10, + modSum2: 4, + multiply: 4800000, + multiply2: 288000000, + sum: 120, + sum2: 180, + }); + }); + }); + + describe('async', () => { + it('should work without custom ids', async () => { + const result = await fluentAsync([10, 20, 30]).aggregate((x, agg) => ({ + avg: agg.avg(x), + avg2: agg.avg(x * 2), + first: agg.first(x), + first2: agg.first(x * 2), + last: agg.last(x), + last2: agg.last(x * 2), + max: agg.max(x), + max2: agg.max(x * 2), + min: agg.min(x), + min2: agg.min(x * 2), + modMultiply: agg.modMultiply(x, 11), + modMultiply2: agg.modMultiply(x * 2, 11), + modSum: agg.modSum(x, 11), + modSum2: agg.modSum(x * 2, 11), + multiply: agg.multiply(x), + multiply2: agg.multiply(x * 2), + sum: agg.sum(x), + sum2: agg.sum(x * 2), + })); + + expect(result).to.be.like({ + avg: 20, + avg2: 40, + first: 10, + first2: 20, + last: 30, + last2: 60, + max: 30, + max2: 60, + min: 10, + min2: 20, + modMultiply: 5, + modMultiply2: 7, + modSum: 5, + modSum2: 10, + multiply: 6000, + multiply2: 48000, + sum: 60, + sum2: 120, + }); + }); + + it('should work with custom ids', async () => { + const result = await fluentAsync([10, 20, 30]).aggregate((x, agg) => ({ + avg: agg.avg(x, 1), + avg2: agg.avg(x * 2, 1), + first: agg.first(x, 1), + first2: agg.first(x * 2, 1), + last: agg.last(x, 1), + last2: agg.last(x * 2, 1), + max: agg.max(x, 1), + max2: agg.max(x * 2, 1), + min: agg.min(x, 1), + min2: agg.min(x * 2, 1), + modMultiply: agg.modMultiply(x, 11, 1), + modMultiply2: agg.modMultiply(x * 2, 11, 1), + modSum: agg.modSum(x, 11, 1), + modSum2: agg.modSum(x * 2, 11, 1), + multiply: agg.multiply(x, 1), + multiply2: agg.multiply(x * 2, 1), + sum: agg.sum(x, 1), + sum2: agg.sum(x * 2, 1), + })); + + expect(result).to.be.like({ + avg: 24, + avg2: 30, + first: 10, + first2: 10, + last: 30, + last2: 60, + max: 40, + max2: 60, + min: 10, + min2: 10, + modMultiply: 7, + modMultiply2: 2, + modSum: 10, + modSum2: 4, + multiply: 4800000, + multiply2: 288000000, + sum: 120, + sum2: 180, + }); + }); + }); +});