Skip to content

Commit

Permalink
feat(query): aggregation functions support
Browse files Browse the repository at this point in the history
close #28
  • Loading branch information
KutsenkoA committed Aug 8, 2018
1 parent 1405b83 commit fb633c2
Show file tree
Hide file tree
Showing 7 changed files with 141 additions and 18 deletions.
31 changes: 31 additions & 0 deletions extra_docs/quickstart.md
Expand Up @@ -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: <AGG_FUNCTION>, col: <DATASET_FIELD> }
```

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.
Expand Down
2 changes: 1 addition & 1 deletion spec/testUtils.ts
Expand Up @@ -56,7 +56,7 @@ export type SpyObj<T> = T & {
[k in keyof T]: jest.Mock<T>;
};

export function createMockFor<T, K extends keyof T>
export function createMockFor<T, K extends keyof T = Extract<keyof T, string>>
(obj: IConstructor<T> | K[] | any[], spyOptions?: ISpyOptions, defaultProps?: object): SpyObj<T> {
const methodNames: K[] = Array.isArray(obj) ? obj : getMethodNamesOf(obj);
if (!methodNames.length && spyOptions) {
Expand Down
30 changes: 30 additions & 0 deletions src/api/dataops/queries/baseQuery.spec.ts
@@ -1,10 +1,12 @@
import { IAggField } from "jexia-sdk-js/internal/query";
import { createMockFor } from "../../../../spec/testUtils";
import { RequestExecuter } from "../../../internal/executer";
import { BaseQuery, QueryAction } from "./baseQuery";

interface IUser {
id: number;
name: string;
age: number;
}

let createSubject = ({
Expand Down Expand Up @@ -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<IUser> = { 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<IUser> = { 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<IUser> = { 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<IUser> = { fn: "AVG", col: "age"};
subject.fields(["id", "name", aggField]);
expect((subject as any).query.fields).toEqual(["id", "name", aggField]);
});
});

describe("compiledRequest method", () => {
Expand Down
18 changes: 10 additions & 8 deletions 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<T> {
action: QueryAction;
Expand Down Expand Up @@ -48,14 +48,16 @@ export abstract class BaseQuery<T> {

/**
* 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<K extends Extract<keyof T, string>>(fields: K[]): this;
public fields<K extends Extract<keyof T, string>>(...fields: K[]): this;
public fields<K extends Extract<keyof T, string>>(field: K, ...fields: K[]): this {
this.query.fields = Array.isArray(field) ? field : [field, ...fields];
return this;
}
public fields(fields: Array<Extract<keyof T, string> | IAggField<T>>): this;
public fields(...fields: Array<Extract<keyof T, string> | IAggField<T>>): 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
Expand Down
2 changes: 1 addition & 1 deletion src/api/dataops/queries/filterableQuery.spec.ts
Expand Up @@ -25,7 +25,7 @@ let createSubject = ({
}

const subject = new FilterableQueryChild<IUser>(requestExecuterMock, action, datasetName);
let queryMock = createMockForQuery ? createMockFor(Query) : new Query(datasetName);
let queryMock = createMockForQuery ? createMockFor<Query>(Query) : new Query(datasetName);

// tslint:disable-next-line:no-string-literal
subject["query"] = queryMock;
Expand Down
40 changes: 35 additions & 5 deletions 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;
Expand Down Expand Up @@ -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<any> = { fn: "MAX", col: "field1"};
query.fields = [aggField];
expect(query.compile()).toEqual({
fields: ["MAX(field1)"],
});
});
it("aggregation method with asterisk to id", () => {
const aggField: IAggField<any> = { fn: "COUNT", col: "*"};
query.fields = [aggField];
expect(query.compile()).toEqual({
fields: ["COUNT(id)"],
});
});
it("mixed fields", () => {
const aggField: IAggField<any> = { 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<any> = { 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);
Expand Down
36 changes: 33 additions & 3 deletions src/internal/query.ts
Expand Up @@ -6,6 +6,20 @@ import { MESSAGE } from "../config/message";
*/
export type Direction = "asc" | "desc";

/**
* @internal
*/
type KeyOfObject<T> = Extract<keyof T, string>;

/**
* Object to be passed as aggregation field
* <T> - generic dataset object
*/
export interface IAggField<T = any> {
fn: "COUNT" | "MIN" | "MAX" | "AVG" | "SUM" | "EVERY";
col: KeyOfObject<T> | "*";
}

/* Data sorting
* list of fields + direction
*/
Expand All @@ -17,7 +31,7 @@ interface ISort<K> {
/* Array of data sorting
fields should be in inherited generic dataset model (if it's been set)
*/
type SortedFields<T> = Array<ISort<Extract<keyof T, string>>>;
type SortedFields<T> = Array<ISort<KeyOfObject<T>>>;

export interface ICompiledQuery<T> {
data: T;
Expand All @@ -29,7 +43,7 @@ export interface ICompiledQuery<T> {
}

export class Query<T = any> {
public fields: string[];
public fields: Array<KeyOfObject<T> | IAggField<T>>;
public limit: number;
public offset: number;
public data: T;
Expand Down Expand Up @@ -85,7 +99,8 @@ export class Query<T = any> {
/* 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
Expand All @@ -110,4 +125,19 @@ export class Query<T = any> {

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})`;
}
}

0 comments on commit fb633c2

Please sign in to comment.