From fb633c23b123c1556ed4ee38841eac4cf2556d83 Mon Sep 17 00:00:00 2001 From: Kutsenko Andrey Date: Wed, 8 Aug 2018 11:39:32 +0300 Subject: [PATCH] feat(query): aggregation functions support close #28 --- extra_docs/quickstart.md | 31 ++++++++++++++ spec/testUtils.ts | 2 +- src/api/dataops/queries/baseQuery.spec.ts | 30 ++++++++++++++ src/api/dataops/queries/baseQuery.ts | 18 +++++---- .../dataops/queries/filterableQuery.spec.ts | 2 +- src/internal/query.spec.ts | 40 ++++++++++++++++--- src/internal/query.ts | 36 +++++++++++++++-- 7 files changed, 141 insertions(+), 18 deletions(-) diff --git a/extra_docs/quickstart.md b/extra_docs/quickstart.md index 93fff492..7a652fd7 100644 --- a/extra_docs/quickstart.md +++ b/extra_docs/quickstart.md @@ -179,6 +179,37 @@ posts.select().fields("id", "title").execute().then( (records) => { [..] ``` +### Aggregation functions + +You can also use following aggregation functions with `.fields` method: +- MAX +- MIN +- AVG +- SUM +- EVERY +- COUNT + +There is a special object that you need to use for aggregation: +``` +{ fn: , col: } +``` + +Example: +``` Javascript +[..] +posts.select().fields({ fn: "MAX", col: "likes" }); +[..] +``` + +For `COUNT` function you can provide asterisk (`*`) as a field name. Also you can combine +field names with aggregation functions to get more complicated results: +``` Javascript +[..] +posts.select().fields("date", "author", { fn: "COUNT", col: "*" }); +[..] +``` + + ### Filtering records You can use the filtering feature to select what records a certain query will operate on. diff --git a/spec/testUtils.ts b/spec/testUtils.ts index b4e8209c..282e116d 100644 --- a/spec/testUtils.ts +++ b/spec/testUtils.ts @@ -56,7 +56,7 @@ export type SpyObj = T & { [k in keyof T]: jest.Mock; }; -export function createMockFor +export function createMockFor> (obj: IConstructor | K[] | any[], spyOptions?: ISpyOptions, defaultProps?: object): SpyObj { const methodNames: K[] = Array.isArray(obj) ? obj : getMethodNamesOf(obj); if (!methodNames.length && spyOptions) { diff --git a/src/api/dataops/queries/baseQuery.spec.ts b/src/api/dataops/queries/baseQuery.spec.ts index b7dddb61..762f77e5 100644 --- a/src/api/dataops/queries/baseQuery.spec.ts +++ b/src/api/dataops/queries/baseQuery.spec.ts @@ -1,3 +1,4 @@ +import { IAggField } from "jexia-sdk-js/internal/query"; import { createMockFor } from "../../../../spec/testUtils"; import { RequestExecuter } from "../../../internal/executer"; import { BaseQuery, QueryAction } from "./baseQuery"; @@ -5,6 +6,7 @@ import { BaseQuery, QueryAction } from "./baseQuery"; interface IUser { id: number; name: string; + age: number; } let createSubject = ({ @@ -76,6 +78,34 @@ describe("fields method", () => { subject.fields(["id", "name"]); expect((subject as any).query.fields).toEqual(["id", "name"]); }); + + it("should accept aggregation object", () => { + const { subject } = createSubject(); + const aggField: IAggField = { fn: "AVG", col: "age"}; + subject.fields(aggField); + expect((subject as any).query.fields).toEqual([aggField]); + }); + + it("should accept several fields and aggregation object", () => { + const { subject } = createSubject(); + const aggField: IAggField = { fn: "AVG", col: "age"}; + subject.fields("id", "name", aggField); + expect((subject as any).query.fields).toEqual(["id", "name", aggField]); + }); + + it("should accept aggregation object and several fields", () => { + const { subject } = createSubject(); + const aggField: IAggField = { fn: "AVG", col: "age"}; + subject.fields(aggField, "id", "name"); + expect((subject as any).query.fields).toEqual([aggField, "id", "name"]); + }); + + it("should accept several fields and aggregation object as an array", () => { + const { subject } = createSubject(); + const aggField: IAggField = { fn: "AVG", col: "age"}; + subject.fields(["id", "name", aggField]); + expect((subject as any).query.fields).toEqual(["id", "name", aggField]); + }); }); describe("compiledRequest method", () => { diff --git a/src/api/dataops/queries/baseQuery.ts b/src/api/dataops/queries/baseQuery.ts index e4011f03..ae8a9e0b 100644 --- a/src/api/dataops/queries/baseQuery.ts +++ b/src/api/dataops/queries/baseQuery.ts @@ -1,5 +1,5 @@ import { RequestExecuter } from "../../../internal/executer"; -import { ICompiledQuery, Query } from "../../../internal/query"; +import { IAggField, ICompiledQuery, Query } from "../../../internal/query"; interface ICompiledRequest { action: QueryAction; @@ -48,14 +48,16 @@ export abstract class BaseQuery { /** * Select the fields to be returned at the response that represent the affected data - * @param fields fields names + * Aggregation functions can be provided as an object: + * { fn: aggFn, col: string } + * @param fields fields names or aggregation object */ - public fields>(fields: K[]): this; - public fields>(...fields: K[]): this; - public fields>(field: K, ...fields: K[]): this { - this.query.fields = Array.isArray(field) ? field : [field, ...fields]; - return this; - } + public fields(fields: Array | IAggField>): this; + public fields(...fields: Array | IAggField>): this; + public fields(field: any, ...fields: any[]): this { + this.query.fields = Array.isArray(field) ? field : [field, ...fields]; + return this; + } /** * Prepare compiled request before execute it diff --git a/src/api/dataops/queries/filterableQuery.spec.ts b/src/api/dataops/queries/filterableQuery.spec.ts index 03a3fa9e..ed24dc3b 100644 --- a/src/api/dataops/queries/filterableQuery.spec.ts +++ b/src/api/dataops/queries/filterableQuery.spec.ts @@ -25,7 +25,7 @@ let createSubject = ({ } const subject = new FilterableQueryChild(requestExecuterMock, action, datasetName); - let queryMock = createMockForQuery ? createMockFor(Query) : new Query(datasetName); + let queryMock = createMockForQuery ? createMockFor(Query) : new Query(datasetName); // tslint:disable-next-line:no-string-literal subject["query"] = queryMock; diff --git a/src/internal/query.spec.ts b/src/internal/query.spec.ts index a2aa559a..beff0c01 100644 --- a/src/internal/query.spec.ts +++ b/src/internal/query.spec.ts @@ -1,6 +1,6 @@ import { FilteringCriterion } from "../api/dataops/filteringApi"; import { FilteringCondition } from "../api/dataops/filteringCondition"; -import { Query } from "./query"; +import { IAggField, Query } from "./query"; describe("Query class", () => { let query: Query; @@ -113,12 +113,42 @@ describe("Query class", () => { range: { limit: 10, offset: 20 }, }); }); - it("fields option", () => { - query.fields = ["field1", "field2"]; - expect(query.compile()).toEqual({ - fields: ["field1", "field2"], + + describe("fields option should compile", () => { + it("simple string fields", () => { + query.fields = ["field1", "field2"]; + expect(query.compile()).toEqual({ + fields: ["field1", "field2"], + }); + }); + it("aggregation method", () => { + const aggField: IAggField = { fn: "MAX", col: "field1"}; + query.fields = [aggField]; + expect(query.compile()).toEqual({ + fields: ["MAX(field1)"], + }); + }); + it("aggregation method with asterisk to id", () => { + const aggField: IAggField = { fn: "COUNT", col: "*"}; + query.fields = [aggField]; + expect(query.compile()).toEqual({ + fields: ["COUNT(id)"], + }); + }); + it("mixed fields", () => { + const aggField: IAggField = { fn: "MAX", col: "field3"}; + query.fields = ["field1", "field2", aggField, "field4"]; + expect(query.compile()).toEqual({ + fields: ["field1", "field2", "MAX(field3)", "field4"], + }); + }); + it("wrong * usage to throwing an error", () => { + const aggField: IAggField = { fn: "SUM", col: "*"}; + query.fields = [aggField]; + expect(() => query.compile()).toThrow("Field name should be provided with the SUM function"); }); }); + it("sort option", () => { query.addSortCondition(sort1.direction, ...sort1.fields); query.addSortCondition(sort2.direction, ...sort2.fields); diff --git a/src/internal/query.ts b/src/internal/query.ts index 39246657..f4a3a3f6 100644 --- a/src/internal/query.ts +++ b/src/internal/query.ts @@ -6,6 +6,20 @@ import { MESSAGE } from "../config/message"; */ export type Direction = "asc" | "desc"; +/** + * @internal + */ +type KeyOfObject = Extract; + +/** + * Object to be passed as aggregation field + * - generic dataset object + */ +export interface IAggField { + fn: "COUNT" | "MIN" | "MAX" | "AVG" | "SUM" | "EVERY"; + col: KeyOfObject | "*"; +} + /* Data sorting * list of fields + direction */ @@ -17,7 +31,7 @@ interface ISort { /* Array of data sorting fields should be in inherited generic dataset model (if it's been set) */ -type SortedFields = Array>>; +type SortedFields = Array>>; export interface ICompiledQuery { data: T; @@ -29,7 +43,7 @@ export interface ICompiledQuery { } export class Query { - public fields: string[]; + public fields: Array | IAggField>; public limit: number; public offset: number; public data: T; @@ -85,7 +99,8 @@ export class Query { /* Compile fields */ if (this.fields) { - compiledQueryOptions.fields = this.fields; + compiledQueryOptions.fields = this.fields.map((field) => typeof field === "object" + ? this.compileAggregation(field) : field); } /* Compile sort options @@ -110,4 +125,19 @@ export class Query { return compiledQueryOptions; } + + /** + * Compile aggregation object to the string + * for COUNT function replace asterisk with i field + * @param {IAggField} agg an aggregation object + * @returns {string} compiled aggregation function + */ + private compileAggregation(agg: IAggField): string { + if (agg.fn === "COUNT" && agg.col === "*") { + agg.col = "id"; + } else if (agg.col === "*") { + throw new Error(`Field name should be provided with the ${agg.fn} function`); + } + return `${agg.fn}(${agg.col})`; + } }