flowerbi 3.8.3 → 3.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,18 +16,22 @@ export declare type QueryColumnType<T> = T extends QueryColumn<infer C> ? C : ne
16
16
  /**
17
17
  * Defines the shape of a record returned from a query, based on its `select` object.
18
18
  * Each selected property appears as a property in the record with the same name. For
19
- * plain columns the data type depends on the column definition, but for aggregates
20
- * the data type is always `number`.
19
+ * plain columns the data type depends on the column definition, but for aggregations
20
+ * and calculations the data type is always `number`.
21
21
  */
22
- export declare type ExpandedQueryRecord<S extends QuerySelect> = {
22
+ export declare type ExpandedQueryRecord<S extends QuerySelect, C extends QueryCalculations<S>> = {
23
23
  [P in keyof S]: S[P] extends QueryColumn<any> ? QueryColumnType<S[P]> : number;
24
+ } & {
25
+ [P in keyof C]: number;
24
26
  };
25
27
  /**
26
28
  * Similar to {@link ExpandedQueryRecord} but the plain columns are optional, so they
27
- * may be `undefined`. Aggregations are not optional.
29
+ * may be `undefined`. Aggregations/calculations are not optional.
28
30
  */
29
- export declare type ExpandedQueryRecordWithOptionalColumns<S extends QuerySelect> = {
30
- [P in keyof S]: S[P] extends QueryColumn<any> ? (QueryColumnType<S[P]> | undefined) : number;
31
+ export declare type ExpandedQueryRecordWithOptionalColumns<S extends QuerySelect, C extends QueryCalculations<S>> = {
32
+ [P in keyof S]: S[P] extends QueryColumn<any> ? QueryColumnType<S[P]> | undefined : number;
33
+ } & {
34
+ [P in keyof C]: number;
31
35
  };
32
36
  /**
33
37
  * A helper type used in the definition of {@link AggregatePropsOnly}.
@@ -62,6 +66,9 @@ export declare function getAggregatePropsOnly<T extends QuerySelect>(select: T):
62
66
  export declare type AggregateValuesOnly<T extends QuerySelect> = {
63
67
  [K in AggregatePropsOnly<T>]: number;
64
68
  };
69
+ export declare type CalculationValues<C extends QueryCalculations<any>> = {
70
+ [K in keyof C]: number;
71
+ };
65
72
  /**
66
73
  * A helper type used in the definition of {@link ColumnPropsOnly}.
67
74
  */
@@ -93,17 +100,30 @@ export declare function getColumnPropsOnly<T extends QuerySelect>(select: T): Co
93
100
  * @param select the `select` object from a query
94
101
  */
95
102
  export declare function getColumnsOnly(select: QuerySelect): QueryColumn<any>[];
103
+ export declare type Calculation<S extends QuerySelect> = number | AggregatePropsOnly<S> | [Calculation<S>, "+" | "-" | "*" | "/", Calculation<S>];
104
+ /**
105
+ * The `select` object of a query has named properties of type {@link SelectMember}.
106
+ */
107
+ export declare type QueryCalculations<S extends QuerySelect> = Record<string, Calculation<S>>;
108
+ export declare type Ordering<S extends QuerySelect, C extends QueryCalculations<S>> = {
109
+ descending?: boolean;
110
+ } & ({
111
+ select: keyof S;
112
+ } | {
113
+ calculation: keyof C;
114
+ });
96
115
  /**
97
116
  * Strongly-typed query definition: use {@link jsonifyQuery} to convert to the JSON format
98
117
  * and {@link expandQueryResult} to generate corresponding output records.
99
118
  */
100
- export interface Query<S extends QuerySelect> {
119
+ export interface Query<S extends QuerySelect, C extends QueryCalculations<S>> {
101
120
  select: S;
102
121
  filters?: FilterJson[];
122
+ calculations?: C;
103
123
  /**
104
124
  * Ordering criteria to apply.
105
125
  */
106
- orderBy?: OrderingJson[];
126
+ orderBy?: (OrderingJson | Ordering<S, C>)[];
107
127
  /**
108
128
  * Specifies whether to return a `totals` property containing the
109
129
  * aggregation values across the whole dataset, e.g. if the `select`
@@ -1 +1 @@
1
- {"version":3,"file":"queryModel.js","sourceRoot":"","sources":["../src/queryModel.ts"],"names":[],"mappings":";;;AAAA,+CAAwC;AACxC,6CAA4C;AAkD5C;;;;;;;;;;;;GAYG;AACH,SAAgB,qBAAqB,CAAwB,MAAS;IAClE,IAAM,IAAI,GAAG,IAAA,qBAAM,EAAC,MAAM,CAAC,CAAC,MAAM,CAAC,UAAA,CAAC,IAAI,OAAA,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,YAAY,yBAAW,CAAC,EAAnC,CAAmC,CAAC,CAAA;IAC5E,OAAO,IAA+B,CAAC;AAC3C,CAAC;AAHD,sDAGC;AAwBD;;;;;;;;;;;;GAYG;AACH,SAAgB,kBAAkB,CAAwB,MAAS;IAC/D,IAAM,IAAI,GAAG,IAAA,qBAAM,EAAC,MAAM,CAAC,CAAC,MAAM,CAAC,UAAA,CAAC,IAAI,OAAA,MAAM,CAAC,CAAC,CAAC,YAAY,yBAAW,EAAhC,CAAgC,CAAC,CAAA;IACzE,OAAO,IAA4B,CAAC;AACxC,CAAC;AAHD,gDAGC;AAED;;;;GAIG;AACH,SAAgB,cAAc,CAAC,MAAmB;IAC9C,OAAO,IAAA,qBAAM,EAAC,MAAM,CAAC;SAChB,GAAG,CAAC,UAAA,CAAC,IAAI,OAAA,MAAM,CAAC,CAAC,CAAC,EAAT,CAAS,CAAC;SACnB,MAAM,CAAC,UAAA,CAAC,IAAI,OAAA,CAAC,YAAY,yBAAW,EAAxB,CAAwB,CAAC;SACrC,GAAG,CAAC,UAAA,CAAC,IAAI,OAAC,CAAsB,EAAvB,CAAuB,CAAC,CAAC;AAC3C,CAAC;AALD,wCAKC"}
1
+ {"version":3,"file":"queryModel.js","sourceRoot":"","sources":["../src/queryModel.ts"],"names":[],"mappings":";;;AAAA,+CAAwC;AACxC,6CAA4C;AAsD5C;;;;;;;;;;;;GAYG;AACH,SAAgB,qBAAqB,CAAwB,MAAS;IAClE,IAAM,IAAI,GAAG,IAAA,qBAAM,EAAC,MAAM,CAAC,CAAC,MAAM,CAAC,UAAC,CAAC,IAAK,OAAA,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,YAAY,yBAAW,CAAC,EAAnC,CAAmC,CAAC,CAAC;IAC/E,OAAO,IAA+B,CAAC;AAC3C,CAAC;AAHD,sDAGC;AA4BD;;;;;;;;;;;;GAYG;AACH,SAAgB,kBAAkB,CAAwB,MAAS;IAC/D,IAAM,IAAI,GAAG,IAAA,qBAAM,EAAC,MAAM,CAAC,CAAC,MAAM,CAAC,UAAC,CAAC,IAAK,OAAA,MAAM,CAAC,CAAC,CAAC,YAAY,yBAAW,EAAhC,CAAgC,CAAC,CAAC;IAC5E,OAAO,IAA4B,CAAC;AACxC,CAAC;AAHD,gDAGC;AAED;;;;GAIG;AACH,SAAgB,cAAc,CAAC,MAAmB;IAC9C,OAAO,IAAA,qBAAM,EAAC,MAAM,CAAC;SAChB,GAAG,CAAC,UAAC,CAAC,IAAK,OAAA,MAAM,CAAC,CAAC,CAAC,EAAT,CAAS,CAAC;SACrB,MAAM,CAAC,UAAC,CAAC,IAAK,OAAA,CAAC,YAAY,yBAAW,EAAxB,CAAwB,CAAC;SACvC,GAAG,CAAC,UAAC,CAAC,IAAK,OAAA,CAAqB,EAArB,CAAqB,CAAC,CAAC;AAC3C,CAAC;AALD,wCAKC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowerbi",
3
- "version": "3.8.3",
3
+ "version": "3.9.1",
4
4
  "author": "Daniel Earwicker <dan@earwicker.com>",
5
5
  "license": "MIT",
6
6
  "description": "FlowerBI: ultra-minimal BI analytics query and UI tools",
package/src/QueryJson.ts CHANGED
@@ -37,13 +37,36 @@ export interface AggregationJson {
37
37
 
38
38
  /**
39
39
  * Specifies an ordering criteria: which column to sort by, and optionally
40
- * whether it is descending (the default is ascending). The column is
41
- * specified by a string of the form `table.column`.
40
+ * whether it is descending (the default is ascending). The column can be
41
+ * specified by a string of the form `table.column`, though this can only
42
+ * target one of the columns specified in select. More flexibly, specify
43
+ * a type of column (the type 'Value' refers to aggregations) and its
44
+ * zero-based position.
42
45
  */
43
- export interface OrderingJson {
44
- column: string;
46
+ export type OrderingJson = {
45
47
  descending?: boolean;
46
- }
48
+ } & (
49
+ | {
50
+ column: string;
51
+ }
52
+ | {
53
+ type: "Select" | "Value" | "Calculation";
54
+ index: number;
55
+ }
56
+ );
57
+
58
+ /**
59
+ * Specifies an expression for calculating a derived value based on
60
+ * the values of aggregations, specified by zero-based position.
61
+ */
62
+ export type CalculationJson =
63
+ | { value: number }
64
+ | { aggregation: number }
65
+ | {
66
+ first: CalculationJson;
67
+ operator: "+" | "-" | "*" | "/";
68
+ second: CalculationJson;
69
+ };
47
70
 
48
71
  /**
49
72
  * Specifies an entire query.
@@ -57,6 +80,10 @@ export interface QueryJson {
57
80
  * The aggregated values to fetch.
58
81
  */
59
82
  aggregations: AggregationJson[];
83
+ /**
84
+ * The calculations to perform.
85
+ */
86
+ calculations?: CalculationJson[];
60
87
  /**
61
88
  * Filters to apply. They are always combined with AND.
62
89
  */
@@ -1,29 +1,29 @@
1
- import {
2
- QuerySelect,
3
- ExpandedQueryRecord,
4
- AggregateValuesOnly,
5
- AggregatePropsOnly,
6
- ExpandedQueryRecordWithOptionalColumns
1
+ import {
2
+ QuerySelect,
3
+ ExpandedQueryRecord,
4
+ AggregateValuesOnly,
5
+ AggregatePropsOnly,
6
+ ExpandedQueryRecordWithOptionalColumns,
7
+ QueryCalculations,
8
+ CalculationValues,
7
9
  } from "./queryModel";
8
10
 
9
11
  /**
10
12
  * An abstract interface representing either a row from a dataset or
11
- * the {@link ExpandedQueryResult.totals} row, so that generic code can
13
+ * the {@link ExpandedQueryResult.totals} row, so that generic code can
12
14
  * format either of them in a consistent way.
13
15
  */
14
- export interface QueryValues<S extends QuerySelect> {
16
+ export interface QueryValues<S extends QuerySelect, C extends QueryCalculations<S>> {
15
17
  /**
16
18
  * The plain values of columns, which may be `undefined` if this
17
19
  * refers to the {@link ExpandedQueryResult.totals} record.
18
20
  */
19
- values: ExpandedQueryRecordWithOptionalColumns<S>;
21
+ values: ExpandedQueryRecordWithOptionalColumns<S, C>;
20
22
  percentage<K extends AggregatePropsOnly<S>>(key: K): number;
21
23
  }
22
24
 
23
- export class QueryValuesRow<S extends QuerySelect> implements QueryValues<S> {
24
- constructor(
25
- public readonly values: ExpandedQueryRecord<S>,
26
- public readonly totals: AggregateValuesOnly<S> | undefined) {}
25
+ export class QueryValuesRow<S extends QuerySelect, C extends QueryCalculations<S>> implements QueryValues<S, C> {
26
+ constructor(public readonly values: ExpandedQueryRecord<S, C>, public readonly totals: (AggregateValuesOnly<S> & CalculationValues<C>) | undefined) {}
27
27
 
28
28
  percentage<K extends AggregatePropsOnly<S>>(key: K) {
29
29
  if (!this.totals) return 0;
@@ -35,11 +35,11 @@ export class QueryValuesRow<S extends QuerySelect> implements QueryValues<S> {
35
35
  }
36
36
  }
37
37
 
38
- export class QueryValuesTotal<S extends QuerySelect> implements QueryValues<S> {
39
- public readonly values: ExpandedQueryRecordWithOptionalColumns<S>;
38
+ export class QueryValuesTotal<S extends QuerySelect, C extends QueryCalculations<S>> implements QueryValues<S, C> {
39
+ public readonly values: ExpandedQueryRecordWithOptionalColumns<S, C>;
40
40
 
41
- constructor(totals: AggregateValuesOnly<S>) {
42
- this.values = totals as ExpandedQueryRecordWithOptionalColumns<S>;
41
+ constructor(totals: AggregateValuesOnly<S> & CalculationValues<C>) {
42
+ this.values = totals as ExpandedQueryRecordWithOptionalColumns<S, C>;
43
43
  }
44
44
 
45
45
  percentage() {
@@ -1,5 +1,6 @@
1
- import { jsonifyQuery } from "./executeQuery";
1
+ import { expandQueryResult, jsonifyQuery, QueryResultJson } from "./executeQuery";
2
2
  import { QueryColumn } from "./QueryColumn";
3
+ import { Query, QueryCalculations, QuerySelect } from "./queryModel";
3
4
 
4
5
  export const Customer = {
5
6
  Id: new QueryColumn<number>("Customer.Id"),
@@ -8,22 +9,28 @@ export const Customer = {
8
9
 
9
10
  export const Bug = {
10
11
  Id: new QueryColumn<number>("Bug.Id"),
11
- CustomerId: new QueryColumn<number>("Bug.CustomerId"),
12
+ CustomerId: new QueryColumn<number>("Bug.CustomerId"),
13
+ Fixed: new QueryColumn<boolean>("Bug.Fixed"),
12
14
  };
13
15
 
14
- test("jsonifies mixed columns", () => {
15
- expect(jsonifyQuery({
16
- select: {
17
- customer: Customer.CustomerName,
18
- bugCount: Bug.Id.count()
19
- }
20
- })).toStrictEqual({
16
+ test("jsonifies mixed columns", () => {
17
+ expect(
18
+ jsonifyQuery({
19
+ select: {
20
+ customer: Customer.CustomerName,
21
+ bugCount: Bug.Id.count(),
22
+ },
23
+ })
24
+ ).toStrictEqual({
21
25
  select: ["Customer.CustomerName"],
22
- aggregations: [{
23
- column: "Bug.Id",
24
- function: "Count",
25
- filters: undefined
26
- }],
26
+ aggregations: [
27
+ {
28
+ column: "Bug.Id",
29
+ function: "Count",
30
+ filters: undefined,
31
+ },
32
+ ],
33
+ calculations: undefined,
27
34
  filters: [],
28
35
  orderBy: [],
29
36
  skip: 0,
@@ -34,14 +41,17 @@ test("jsonifies mixed columns", () => {
34
41
  });
35
42
  });
36
43
 
37
- test("jsonifies select", () => {
38
- expect(jsonifyQuery({
39
- select: {
40
- customer: Customer.CustomerName,
41
- }
42
- })).toStrictEqual({
44
+ test("jsonifies select", () => {
45
+ expect(
46
+ jsonifyQuery({
47
+ select: {
48
+ customer: Customer.CustomerName,
49
+ },
50
+ })
51
+ ).toStrictEqual({
43
52
  select: ["Customer.CustomerName"],
44
53
  aggregations: [],
54
+ calculations: undefined,
45
55
  filters: [],
46
56
  orderBy: [],
47
57
  skip: 0,
@@ -53,16 +63,19 @@ test("jsonifies select", () => {
53
63
  });
54
64
 
55
65
  test("jsonifies params", () => {
56
- expect(jsonifyQuery({
57
- select: {},
58
- skip: 3,
59
- take: 42,
60
- totals: true,
61
- allowDuplicates: true,
62
- comment: "a comment",
63
- })).toStrictEqual({
66
+ expect(
67
+ jsonifyQuery({
68
+ select: {},
69
+ skip: 3,
70
+ take: 42,
71
+ totals: true,
72
+ allowDuplicates: true,
73
+ comment: "a comment",
74
+ })
75
+ ).toStrictEqual({
64
76
  select: [],
65
77
  aggregations: [],
78
+ calculations: undefined,
66
79
  filters: [],
67
80
  orderBy: [],
68
81
  skip: 3,
@@ -74,32 +87,36 @@ test("jsonifies params", () => {
74
87
  });
75
88
 
76
89
  test("jsonifies filters", () => {
77
- expect(jsonifyQuery({
78
- select: {
79
- bugCount: Bug.Id.count([
80
- Customer.CustomerName.lessThan("z")
81
- ])
82
- },
83
- filters: [
84
- Customer.CustomerName.greaterThan("a")
85
- ]
86
-
87
- })).toStrictEqual({
90
+ expect(
91
+ jsonifyQuery({
92
+ select: {
93
+ bugCount: Bug.Id.count([Customer.CustomerName.lessThan("z")]),
94
+ },
95
+ filters: [Customer.CustomerName.greaterThan("a")],
96
+ })
97
+ ).toStrictEqual({
88
98
  select: [],
89
- aggregations: [{
90
- column: "Bug.Id",
91
- function: "Count",
92
- filters: [{
99
+ aggregations: [
100
+ {
101
+ column: "Bug.Id",
102
+ function: "Count",
103
+ filters: [
104
+ {
105
+ column: "Customer.CustomerName",
106
+ operator: "<",
107
+ value: "z",
108
+ },
109
+ ],
110
+ },
111
+ ],
112
+ calculations: undefined,
113
+ filters: [
114
+ {
93
115
  column: "Customer.CustomerName",
94
- operator: "<",
95
- value: "z",
96
- }]
97
- }],
98
- filters: [{
99
- column: "Customer.CustomerName",
100
- operator: ">",
101
- value: "a",
102
- }],
116
+ operator: ">",
117
+ value: "a",
118
+ },
119
+ ],
103
120
  orderBy: [],
104
121
  skip: 0,
105
122
  take: 100,
@@ -109,20 +126,126 @@ test("jsonifies filters", () => {
109
126
  });
110
127
  });
111
128
 
112
- test("jsonifies orderBy", () => {
113
- expect(jsonifyQuery({
114
- select: {},
129
+ test("jsonifies orderBy", () => {
130
+ expect(
131
+ jsonifyQuery({
132
+ select: {},
133
+ orderBy: [Customer.CustomerName.descending()],
134
+ })
135
+ ).toStrictEqual({
136
+ select: [],
137
+ aggregations: [],
138
+ calculations: undefined,
139
+ filters: [],
140
+ orderBy: [
141
+ {
142
+ column: "Customer.CustomerName",
143
+ descending: true,
144
+ },
145
+ ],
146
+ skip: 0,
147
+ take: 100,
148
+ totals: false,
149
+ allowDuplicates: undefined,
150
+ comment: undefined,
151
+ });
152
+ });
153
+
154
+ test("jsonifies calculations and ordering by keys", () => {
155
+ expect(
156
+ jsonifyQuery({
157
+ select: {
158
+ customer: Customer.CustomerName,
159
+ bugCount: Bug.Id.count(),
160
+ bugsFixed: Bug.Id.count([Bug.Fixed.equalTo(true)]),
161
+ },
162
+ calculations: {
163
+ successRate: [100, "*", ["bugsFixed", "/", "bugCount"]],
164
+ failureRate: [100, "-", [100, "*", ["bugsFixed", "/", "bugCount"]]],
165
+ },
166
+ orderBy: [
167
+ { select: "customer" },
168
+ { select: "bugsFixed", descending: true },
169
+ { calculation: "successRate" },
170
+ { calculation: "failureRate", descending: true },
171
+ ],
172
+ })
173
+ ).toStrictEqual({
174
+ select: ["Customer.CustomerName"],
175
+ aggregations: [
176
+ {
177
+ column: "Bug.Id",
178
+ function: "Count",
179
+ filters: undefined,
180
+ },
181
+ {
182
+ column: "Bug.Id",
183
+ function: "Count",
184
+ filters: [
185
+ {
186
+ column: "Bug.Fixed",
187
+ operator: "=",
188
+ value: true,
189
+ },
190
+ ],
191
+ },
192
+ ],
193
+ calculations: [
194
+ {
195
+ first: { value: 100 },
196
+ operator: "*",
197
+ second: {
198
+ first: { aggregation: 1 },
199
+ operator: "/",
200
+ second: { aggregation: 0 },
201
+ },
202
+ },
203
+ {
204
+ first: { value: 100 },
205
+ operator: "-",
206
+ second: {
207
+ first: { value: 100 },
208
+ operator: "*",
209
+ second: {
210
+ first: { aggregation: 1 },
211
+ operator: "/",
212
+ second: { aggregation: 0 },
213
+ },
214
+ },
215
+ },
216
+ ],
217
+ filters: [],
115
218
  orderBy: [
116
- Customer.CustomerName.descending()
117
- ]
118
- })).toStrictEqual({
219
+ { type: "Select", index: 0, descending: undefined },
220
+ { type: "Value", index: 1, descending: true },
221
+ { type: "Calculation", index: 0, descending: undefined },
222
+ { type: "Calculation", index: 1, descending: true },
223
+ ],
224
+ skip: 0,
225
+ take: 100,
226
+ totals: false,
227
+ allowDuplicates: undefined,
228
+ comment: undefined,
229
+ });
230
+ });
231
+
232
+ test("passes through JSON orderBy", () => {
233
+ expect(
234
+ jsonifyQuery({
235
+ select: {},
236
+ orderBy: [Customer.CustomerName.descending()],
237
+ })
238
+ ).toStrictEqual({
119
239
  select: [],
120
240
  aggregations: [],
241
+ calculations: undefined,
121
242
  filters: [],
122
- orderBy: [{
123
- column: "Customer.CustomerName",
124
- descending: true,
125
- }],
243
+ orderBy: [
244
+ {
245
+ column: "Customer.CustomerName",
246
+ descending: true,
247
+ },
248
+ ],
126
249
  skip: 0,
127
250
  take: 100,
128
251
  totals: false,
@@ -130,3 +253,81 @@ test("jsonifies orderBy", () => {
130
253
  comment: undefined,
131
254
  });
132
255
  });
256
+
257
+ function formatResultsFromQuery<S extends QuerySelect, C extends QueryCalculations<S>>(query: Query<S, C>, result: QueryResultJson) {
258
+ return expandQueryResult<S, C>(query.select, result, query.calculations);
259
+ }
260
+
261
+ test("generates typed results", () => {
262
+ const result = formatResultsFromQuery(
263
+ {
264
+ select: {
265
+ customer: Customer.CustomerName,
266
+ bugCount: Bug.Id.count(),
267
+ bugsFixed: Bug.Id.count([Bug.Fixed.equalTo(true)]),
268
+ },
269
+ calculations: {
270
+ successRate: [100, "*", ["bugsFixed", "/", "bugCount"]],
271
+ failureRate: [100, "-", [100, "*", ["bugsFixed", "/", "bugCount"]]],
272
+ },
273
+ orderBy: [
274
+ { select: "customer" },
275
+ { select: "bugsFixed", descending: true },
276
+ { calculation: "successRate" },
277
+ { calculation: "failureRate", descending: true },
278
+ ],
279
+ },
280
+ {
281
+ records: [
282
+ {
283
+ selected: ["woolworths"],
284
+ aggregated: [3, 1, 33.33, 66.67],
285
+ },
286
+ ],
287
+ }
288
+ );
289
+
290
+ expect(result).toStrictEqual({
291
+ records: [{ customer: "woolworths", bugCount: 3, bugsFixed: 1, successRate: 33.33, failureRate: 66.67 }],
292
+ totals: undefined,
293
+ });
294
+ });
295
+
296
+ test("generates typed results with totals", () => {
297
+ const result = formatResultsFromQuery(
298
+ {
299
+ select: {
300
+ customer: Customer.CustomerName,
301
+ bugCount: Bug.Id.count(),
302
+ bugsFixed: Bug.Id.count([Bug.Fixed.equalTo(true)]),
303
+ },
304
+ calculations: {
305
+ successRate: [100, "*", ["bugsFixed", "/", "bugCount"]],
306
+ failureRate: [100, "-", [100, "*", ["bugsFixed", "/", "bugCount"]]],
307
+ },
308
+ orderBy: [
309
+ { select: "customer" },
310
+ { select: "bugsFixed", descending: true },
311
+ { calculation: "successRate" },
312
+ { calculation: "failureRate", descending: true },
313
+ ],
314
+ },
315
+ {
316
+ records: [
317
+ {
318
+ selected: ["woolworths"],
319
+ aggregated: [3, 1, 33.33, 66.67],
320
+ },
321
+ ],
322
+ totals: {
323
+ selected: [""],
324
+ aggregated: [4, 2, 50, 50],
325
+ },
326
+ }
327
+ );
328
+
329
+ expect(result).toStrictEqual({
330
+ records: [{ customer: "woolworths", bugCount: 3, bugsFixed: 1, successRate: 33.33, failureRate: 66.67 }],
331
+ totals: { bugCount: 4, bugsFixed: 2, successRate: 50, failureRate: 50 },
332
+ });
333
+ });