From 64c9320e87d1431d917a1a80f9d47596491ef532 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 --- package-lock.json | 21 +- package.json | 4 +- 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 | 92 ++++++ 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 ++++++++++++++++++ 15 files changed, 452 insertions(+), 5 deletions(-) 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/package-lock.json b/package-lock.json index 1c5f268c..0b168278 100644 --- a/package-lock.json +++ b/package-lock.json @@ -748,6 +748,12 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "@types/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==", + "dev": true + }, "@types/yargs": { "version": "16.0.4", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", @@ -2428,6 +2434,14 @@ "p-map": "^3.0.0", "rimraf": "^3.0.0", "uuid": "^3.3.3" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true + } } }, "istanbul-lib-report": { @@ -4242,10 +4256,9 @@ "dev": true }, "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "dev": true + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==" }, "v8-compile-cache": { "version": "2.3.0", diff --git a/package.json b/package.json index 15ae5eff..56571d23 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,8 @@ "dependencies": { "augmentative-iterable": "^1.5.8", "extension-methods": "^1.0.1", - "typed-emitter": "^1.4.0" + "typed-emitter": "^1.4.0", + "uuid": "^9.0.0" }, "devDependencies": { "@codibre/confs": "^1.1.0", @@ -104,6 +105,7 @@ "@types/node": "^16.11.6", "@types/sinon": "^10.0.6", "@types/sinon-chai": "^3.2.5", + "@types/uuid": "^9.0.1", "@typescript-eslint/eslint-plugin": "^5.2.0", "@typescript-eslint/eslint-plugin-tslint": "^5.2.0", "@typescript-eslint/parser": "^5.2.0", 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 8bd9272e..eaea168f 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 2341e31f..d8a0ac9c 100644 --- a/src/mounters/fluent-async-functions.ts +++ b/src/mounters/fluent-async-functions.ts @@ -1,4 +1,5 @@ import { + aggregateAsync, anyAsync, appendAsync, avgAsync, @@ -115,6 +116,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 9ea2cc54..7e9eb3cd 100644 --- a/src/mounters/fluent-functions.ts +++ b/src/mounters/fluent-functions.ts @@ -1,4 +1,5 @@ import { + aggregate, any, contains, count, @@ -58,6 +59,7 @@ import { toObjectChainReduce, } from '../sync'; import { + aggregateAsync, allAsync, avgAsync, anyAsync, @@ -155,6 +157,8 @@ export const special = { }; 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..30b94efd --- /dev/null +++ b/src/recipes/aggregate-recipe.ts @@ -0,0 +1,92 @@ +import { AnyIterable } from 'augmentative-iterable'; +import { AverageStepper } from '../types'; +import { getAverageStepper } from '../utils'; +import { BasicIngredients } from './ingredients'; +import { v4 as uuidV4 } from 'uuid'; + +const baseDefaultId = uuidV4(); +const contextSymbol = Symbol('context'); + +class Aggregations { + [contextSymbol]: any = { + id: 0, + defaultId(): string | number { + return `${baseDefaultId}${this.id++}`; + }, + }; + + 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: string | number = this[contextSymbol].defaultId(), + ) { + const context = (this[contextSymbol].modSum ??= {}); + return (context[id] = this.sum(value, `modSum${baseDefaultId}${id}`) % mod); + } + modMultiply( + value: number, + mod: number, + id: string | number = this[contextSymbol].defaultId(), + ) { + const context = (this[contextSymbol].modMultiply ??= {}); + return (context[id] = + this.multiply(value, `modMultiply${baseDefaultId}${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].id = 0; + result = callback(item, agg, result); + }), + () => result, + ); + }; +} diff --git a/src/recipes/index.ts b/src/recipes/index.ts index b2acec80..bd02c31a 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 64d4d966..db16a7f9 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 e2cdb22d..db9814a6 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 56d5d5fe..6b53c4e8 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 cfec6f47..53223de8 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, + }); + }); + }); +});