@travetto/model-query 8.0.0 → 8.0.2

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.
package/README.md CHANGED
@@ -127,12 +127,38 @@ export interface ModelQuerySuggestSupport extends ModelQuerySupport {
127
127
  }
128
128
  ```
129
129
 
130
+ ### Aggregate
131
+ This contract provides the ability to run strongly-typed field-level aggregations (such as sum, avg, min, and max) on models with optional query filtering.
132
+
133
+ **Code: Query Aggregate**
134
+ ```typescript
135
+ export interface ModelQueryAggregateSupport extends ModelQuerySupport {
136
+ /**
137
+ * Run an aggregation on a field
138
+ * @param modelClass The model class to aggregate
139
+ * @param operation The operation to perform ('sum', 'avg', 'min', 'max')
140
+ * @param field The field to aggregate on
141
+ * @param query Additional query filtering
142
+ */
143
+ aggregateFieldByQuery<
144
+ T extends ModelType,
145
+ Op extends AggregateOperation,
146
+ F extends (Op extends AggregateNumericOperation ? ValidNumericFields<T> : ValidComparableFields<T>)
147
+ >(
148
+ modelClass: Class<T>,
149
+ operation: Op,
150
+ field: F,
151
+ query?: ModelQuery<T>
152
+ ): Promise<AggregateResultType<T, F>>;
153
+ }
154
+ ```
155
+
130
156
  ## Implementations
131
- |Service|Query|QueryCrud|QueryFacet|
132
- |-------|-----|---------|----------|
133
- |[Elasticsearch Model Source](https://github.com/travetto/travetto/tree/main/module/model-elasticsearch#readme "Elasticsearch backing for the travetto model module, with real-time modeling support for Elasticsearch mappings.")|X|X|X|
134
- |[MongoDB Model Support](https://github.com/travetto/travetto/tree/main/module/model-mongo#readme "Mongo backing for the travetto model module.")|X'|X'|X'|
135
- |[SQL Model Service](https://github.com/travetto/travetto/tree/main/module/model-sql#readme "SQL backing for the travetto model module, with real-time modeling support for SQL schemas.")|X'|X'|X'|
157
+ |Service|Query|QueryCrud|QueryFacet|QueryAggregate|
158
+ |-------|-----|---------|----------|--------------|
159
+ |[Elasticsearch Model Source](https://github.com/travetto/travetto/tree/main/module/model-elasticsearch#readme "Elasticsearch backing for the travetto model module, with real-time modeling support for Elasticsearch mappings.")|X|X|X|X|
160
+ |[MongoDB Model Support](https://github.com/travetto/travetto/tree/main/module/model-mongo#readme "Mongo backing for the travetto model module.")|X'|X'|X'|X'|
161
+ |[SQL Model Service](https://github.com/travetto/travetto/tree/main/module/model-sql#readme "SQL backing for the travetto model module, with real-time modeling support for SQL schemas.")|X'|X'|X'|X'|
136
162
 
137
163
  ## Querying
138
164
  One of the complexities of abstracting multiple storage mechanisms, is providing a consistent query language. The query language the module uses is a derivation of [mongodb](https://mongodb.com)'s query language, with some restrictions, additions, and caveats. Additionally, given the nature of typescript, all queries are statically typed, and will catch type errors at compile time.
@@ -227,9 +253,15 @@ In addition to the provided contracts, the module also provides common utilities
227
253
  ```typescript
228
254
  import { Config } from '@travetto/config';
229
255
  import { Injectable } from '@travetto/di';
230
- import type { ModelQueryCrudSupport, ModelQueryFacetSupport, ModelQuerySuggestSupport } from '@travetto/model-query';
256
+ import type {
257
+ ModelQueryAggregateSupport,
258
+ ModelQueryCrudSupport,
259
+ ModelQueryFacetSupport,
260
+ ModelQuerySuggestSupport
261
+ } from '@travetto/model-query';
231
262
  import { Suite } from '@travetto/test';
232
263
 
264
+ import { ModelQueryAggregateSuite } from '@travetto/model-query/support/test/aggregate.ts';
233
265
  import { ModelQueryCrudSuite } from '@travetto/model-query/support/test/crud.ts';
234
266
  import { ModelQueryFacetSuite } from '@travetto/model-query/support/test/facet.ts';
235
267
  import { ModelQueryPolymorphismSuite } from '@travetto/model-query/support/test/polymorphism.ts';
@@ -242,7 +274,9 @@ import { QueryModelService } from './query-service.ts';
242
274
  class CustomModelConfig {}
243
275
 
244
276
  @Injectable()
245
- class CustomModelService extends QueryModelService implements ModelQueryCrudSupport, ModelQueryFacetSupport, ModelQuerySuggestSupport {}
277
+ class CustomModelService
278
+ extends QueryModelService
279
+ implements ModelQueryAggregateSupport, ModelQueryCrudSupport, ModelQueryFacetSupport, ModelQuerySuggestSupport {}
246
280
 
247
281
  @Suite()
248
282
  class CustomQuerySuite extends ModelQuerySuite {
@@ -250,6 +284,12 @@ class CustomQuerySuite extends ModelQuerySuite {
250
284
  configClass = CustomModelConfig;
251
285
  }
252
286
 
287
+ @Suite()
288
+ class CustomQueryAggregateSuite extends ModelQueryAggregateSuite {
289
+ serviceClass = CustomModelService;
290
+ configClass = CustomModelConfig;
291
+ }
292
+
253
293
  @Suite()
254
294
  class CustomQueryCrudSuite extends ModelQueryCrudSuite {
255
295
  serviceClass = CustomModelService;
package/__index__.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  export * from './src/model/indexes.ts';
2
2
  export * from './src/model/query.ts';
3
3
  export * from './src/model/where-clause.ts';
4
+ export * from './src/types/aggregate.ts';
4
5
  export * from './src/types/crud.ts';
5
6
  export * from './src/types/facet.ts';
6
7
  export * from './src/types/query.ts';
7
8
  export * from './src/types/suggest.ts';
9
+ export * from './src/util/aggregate.ts';
8
10
  export * from './src/util/crud.ts';
9
11
  export * from './src/util/facet.ts';
10
12
  export * from './src/util/query.ts';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/model-query",
3
- "version": "8.0.0",
3
+ "version": "8.0.2",
4
4
  "description": "Datastore abstraction for advanced query support.",
5
5
  "keywords": [
6
6
  "datastore",
@@ -30,12 +30,12 @@
30
30
  "access": "public"
31
31
  },
32
32
  "dependencies": {
33
- "@travetto/di": "^8.0.0",
34
- "@travetto/model": "^8.0.0",
35
- "@travetto/schema": "^8.0.0"
33
+ "@travetto/di": "^8.0.1",
34
+ "@travetto/model": "^8.0.2",
35
+ "@travetto/schema": "^8.0.2"
36
36
  },
37
37
  "peerDependencies": {
38
- "@travetto/test": "^8.0.0"
38
+ "@travetto/test": "^8.0.1"
39
39
  },
40
40
  "peerDependenciesMeta": {
41
41
  "@travetto/test": {
@@ -6,6 +6,8 @@ export type QueryPrimitiveArray = QueryPrimitive[];
6
6
  export type DistanceUnit = 'mi' | 'm' | 'km' | 'ft' | 'rad';
7
7
  export type RetainQueryPrimitiveFields<T> = Pick<T, ValidFields<T, QueryPrimitive>>;
8
8
  export type ValidStringFields<T> = KeyPaths<T, String | string | string[] | String[] | undefined>;
9
+ export type ValidNumericFields<T> = KeyPaths<T, number | bigint | Number | undefined>;
10
+ export type ValidComparableFields<T> = KeyPaths<T, number | bigint | Number | Date | undefined>;
9
11
 
10
12
  type General<T> = {
11
13
  $eq?: T;
@@ -0,0 +1,48 @@
1
+ import type { ModelType } from '@travetto/model';
2
+ import type { Class } from '@travetto/runtime';
3
+
4
+ import type { ModelQuery } from '../model/query.ts';
5
+ import type { ValidComparableFields, ValidNumericFields } from '../model/where-clause.ts';
6
+ import type { ModelQuerySupport } from './query.ts';
7
+
8
+ export type AggregateNumericOperation = 'sum' | 'avg';
9
+ export type AggregateComparableOperation = 'min' | 'max';
10
+ export type AggregateOperation = AggregateNumericOperation | AggregateComparableOperation;
11
+
12
+ export type PathType<T, Path extends string> = Path extends `${infer Head}.${infer Tail}`
13
+ ? Head extends keyof T
14
+ ? PathType<NonNullable<T[Head]> extends (infer Element)[] ? Element : NonNullable<T[Head]>, Tail>
15
+ : never
16
+ : Path extends keyof T
17
+ ? NonNullable<T[Path]> extends (infer Element)[]
18
+ ? Element
19
+ : NonNullable<T[Path]>
20
+ : never;
21
+
22
+ export type AggregateResultType<T, F extends string> = [PathType<T, F>] extends [never]
23
+ ? number | Date | bigint | undefined
24
+ : PathType<T, F> | undefined;
25
+
26
+ /**
27
+ * The contract for a model service with aggregate support
28
+ * @concrete
29
+ */
30
+ export interface ModelQueryAggregateSupport extends ModelQuerySupport {
31
+ /**
32
+ * Run an aggregation on a field
33
+ * @param modelClass The model class to aggregate
34
+ * @param operation The operation to perform ('sum', 'avg', 'min', 'max')
35
+ * @param field The field to aggregate on
36
+ * @param query Additional query filtering
37
+ */
38
+ aggregateFieldByQuery<
39
+ T extends ModelType,
40
+ Op extends AggregateOperation,
41
+ F extends (Op extends AggregateNumericOperation ? ValidNumericFields<T> : ValidComparableFields<T>)
42
+ >(
43
+ modelClass: Class<T>,
44
+ operation: Op,
45
+ field: F,
46
+ query?: ModelQuery<T>
47
+ ): Promise<AggregateResultType<T, F>>;
48
+ }
@@ -0,0 +1,29 @@
1
+ import type { ModelType } from '@travetto/model';
2
+ import { castTo, type Class, hasFunction } from '@travetto/runtime';
3
+ import { DataUtil, SchemaRegistryIndex } from '@travetto/schema';
4
+
5
+ import type { AggregateOperation, AggregateResultType, ModelQueryAggregateSupport } from '../types/aggregate.ts';
6
+
7
+ export class ModelQueryAggregateUtil {
8
+ /**
9
+ * Type guard for determining if service supports query aggregate operations
10
+ */
11
+ static isSupported = hasFunction<ModelQueryAggregateSupport>('aggregateFieldByQuery');
12
+
13
+ /**
14
+ * Resolve and coerce the aggregate query result using DataUtil.coerceType
15
+ * @param modelClass The model class being queried
16
+ * @param operation The aggregate operation
17
+ * @param field The field being aggregated
18
+ * @param rawValue The raw result value from the datastore
19
+ */
20
+ static resolveResult<T extends ModelType, Op extends AggregateOperation, F extends string>(
21
+ modelClass: Class<T>,
22
+ operation: Op,
23
+ field: F,
24
+ rawValue: unknown
25
+ ): AggregateResultType<T, F> {
26
+ const leafFieldType = SchemaRegistryIndex.getNestedFieldConfig(modelClass, field)!.type;
27
+ return castTo(DataUtil.coerceType(rawValue, leafFieldType, false) ?? undefined);
28
+ }
29
+ }
@@ -2,6 +2,7 @@
2
2
  import { DocFileUtil, type DocJSXElement, type DocJSXElementByFn, d } from '@travetto/doc';
3
3
  import { castKey, Runtime, toConcrete } from '@travetto/runtime';
4
4
 
5
+ import type { ModelQueryAggregateSupport } from '../src/types/aggregate.ts';
5
6
  import type { ModelQueryCrudSupport } from '../src/types/crud.ts';
6
7
  import type { ModelQueryFacetSupport } from '../src/types/facet.ts';
7
8
  import type { ModelQuerySupport } from '../src/types/query.ts';
@@ -11,6 +12,7 @@ const toLink = (title: string, target: Function): DocJSXElementByFn<'CodeLink'>
11
12
  d.codeLink(title, Runtime.getSourceFile(target), new RegExp(`\\binterface\\s+${target.name}`));
12
13
 
13
14
  export const Links = {
15
+ QueryAggregate: toLink('Aggregate', toConcrete<ModelQueryAggregateSupport>()),
14
16
  QueryCrud: toLink('Query Crud', toConcrete<ModelQueryCrudSupport>()),
15
17
  QueryFacet: toLink('Facet', toConcrete<ModelQueryFacetSupport>()),
16
18
  QuerySuggest: toLink('Suggest', toConcrete<ModelQuerySuggestSupport>()),
@@ -21,7 +23,7 @@ export const ModelQueryTypes = (fn: Function): DocJSXElement[] => {
21
23
  const { content } = DocFileUtil.readSource(fn);
22
24
  const found: DocJSXElementByFn<'CodeLink'>[] = [];
23
25
  const seen = new Set<string>();
24
- for (const [, key] of content.matchAll(/Model(Query(Suggest|Facet|Crud)?)Support/g)) {
26
+ for (const [, key] of content.matchAll(/Model(Query(Aggregate|Suggest|Facet|Crud)?)Support/g)) {
25
27
  if (!seen.has(key) && key in Links) {
26
28
  seen.add(key);
27
29
  const link = Links[castKey(key)];
@@ -0,0 +1,107 @@
1
+ import assert from 'node:assert';
2
+
3
+ import type { ModelCrudSupport } from '@travetto/model';
4
+ import { Suite, Test } from '@travetto/test';
5
+
6
+ import { BaseModelSuite } from '@travetto/model/support/test/base.ts';
7
+
8
+ import type { ModelQueryAggregateSupport } from '../../src/types/aggregate.ts';
9
+ import { Aged, BigIntModel, Person } from './model.ts';
10
+
11
+ @Suite()
12
+ export abstract class ModelQueryAggregateSuite extends BaseModelSuite<ModelQueryAggregateSupport & ModelCrudSupport> {
13
+ @Test('verify basic aggregations')
14
+ async testAggregate() {
15
+ const people = [
16
+ Person.from({ name: 'Bob', age: 20, gender: 'm', address: { street1: '1st St' } }),
17
+ Person.from({ name: 'Alice', age: 30, gender: 'f', address: { street1: '2nd St' } }),
18
+ Person.from({ name: 'Charlie', age: 40, gender: 'm', address: { street1: '3rd St' } }),
19
+ Person.from({ name: 'Dana', age: 50, gender: 'f', address: { street1: '4th St' } })
20
+ ];
21
+
22
+ const service = await this.service;
23
+ const saved = await this.saveAll(Person, people);
24
+ assert(saved === 4);
25
+
26
+ const sumResult = await service.aggregateFieldByQuery(Person, 'sum', 'age');
27
+ assert(sumResult === 140);
28
+
29
+ const averageResult = await service.aggregateFieldByQuery(Person, 'avg', 'age');
30
+ assert(averageResult === 35);
31
+
32
+ const minimumResult = await service.aggregateFieldByQuery(Person, 'min', 'age');
33
+ assert(minimumResult === 20);
34
+
35
+ const maximumResult = await service.aggregateFieldByQuery(Person, 'max', 'age');
36
+ assert(maximumResult === 50);
37
+
38
+ // Filtered aggregations
39
+ const filteredSumResult = await service.aggregateFieldByQuery(Person, 'sum', 'age', {
40
+ where: {
41
+ age: { $gte: 30 }
42
+ }
43
+ });
44
+ assert(filteredSumResult === 120);
45
+
46
+ const filteredAverageResult = await service.aggregateFieldByQuery(Person, 'avg', 'age', {
47
+ where: {
48
+ gender: 'f'
49
+ }
50
+ });
51
+ assert(filteredAverageResult === 40);
52
+
53
+ // No matches
54
+ const noMatchResult = await service.aggregateFieldByQuery(Person, 'sum', 'age', {
55
+ where: {
56
+ age: { $gt: 1000 }
57
+ }
58
+ });
59
+ assert(noMatchResult === undefined);
60
+ }
61
+
62
+ @Test('verify date aggregations')
63
+ async testDateAggregate() {
64
+ const service = await this.service;
65
+
66
+ const firstDate = new Date(2020, 0, 1);
67
+ const secondDate = new Date(2021, 0, 1);
68
+ const thirdDate = new Date(2022, 0, 1);
69
+
70
+ await this.saveAll(Aged, [
71
+ Aged.from({ createdAt: firstDate }),
72
+ Aged.from({ createdAt: secondDate }),
73
+ Aged.from({ createdAt: thirdDate })
74
+ ]);
75
+
76
+ const minimumDateResult = await service.aggregateFieldByQuery(Aged, 'min', 'createdAt');
77
+ assert(minimumDateResult instanceof Date);
78
+ assert(minimumDateResult.getTime() === firstDate.getTime());
79
+
80
+ const maximumDateResult = await service.aggregateFieldByQuery(Aged, 'max', 'createdAt');
81
+ assert(maximumDateResult instanceof Date);
82
+ assert(maximumDateResult.getTime() === thirdDate.getTime());
83
+ }
84
+
85
+ @Test('verify bigint aggregations')
86
+ async testBigIntAggregate() {
87
+ const service = await this.service;
88
+
89
+ await this.saveAll(BigIntModel, [
90
+ BigIntModel.from({ largeNumber: 100n }),
91
+ BigIntModel.from({ largeNumber: 200n }),
92
+ BigIntModel.from({ largeNumber: 300n })
93
+ ]);
94
+
95
+ const sumResult = await service.aggregateFieldByQuery(BigIntModel, 'sum', 'largeNumber');
96
+ assert(sumResult === 600n);
97
+
98
+ const minimumResult = await service.aggregateFieldByQuery(BigIntModel, 'min', 'largeNumber');
99
+ assert(minimumResult === 100n);
100
+
101
+ const maximumResult = await service.aggregateFieldByQuery(BigIntModel, 'max', 'largeNumber');
102
+ assert(maximumResult === 300n);
103
+
104
+ const averageResult = await service.aggregateFieldByQuery(BigIntModel, 'avg', 'largeNumber');
105
+ assert(averageResult === 200n);
106
+ }
107
+ }
@@ -7,10 +7,12 @@ import { Suite, Test } from '@travetto/test';
7
7
  import { BaseModelSuite } from '@travetto/model/support/test/base.ts';
8
8
  import { Doctor, Engineer, Firefighter, Worker } from '@travetto/model/support/test/polymorphism.ts';
9
9
 
10
+ import type { ModelQueryAggregateSupport } from '../../src/types/aggregate.ts';
10
11
  import type { ModelQueryCrudSupport } from '../../src/types/crud.ts';
11
12
  import type { ModelQueryFacetSupport } from '../../src/types/facet.ts';
12
13
  import type { ModelQuerySupport } from '../../src/types/query.ts';
13
14
  import type { ModelQuerySuggestSupport } from '../../src/types/suggest.ts';
15
+ import { ModelQueryAggregateUtil } from '../../src/util/aggregate.ts';
14
16
  import { ModelQueryCrudUtil } from '../../src/util/crud.ts';
15
17
  import { ModelQueryFacetUtil } from '../../src/util/facet.ts';
16
18
  import { ModelQuerySuggestUtil } from '../../src/util/suggest.ts';
@@ -109,4 +111,24 @@ export abstract class ModelQueryPolymorphismSuite extends BaseModelSuite<ModelQu
109
111
  const docFacet = await svc.facetByQuery(Doctor, 'specialty');
110
112
  assert.deepStrictEqual(docFacet, [{ count: 2, key: 'eyes' }]);
111
113
  }
114
+
115
+ @Test({ skip: ModelQueryPolymorphismSuite.ifNot(ModelQueryAggregateUtil.isSupported) })
116
+ async testAggregateQuery() {
117
+ const service: ModelQueryAggregateSupport & ModelQuerySupport = castTo(await this.service);
118
+ const [doctorOne, doctorTwo, firefighterOne, firefighterTwo] = [
119
+ Doctor.from({ name: 'bob', specialty: 'eyes' }),
120
+ Doctor.from({ name: 'nob', specialty: 'eyes' }),
121
+ Firefighter.from({ name: 'rob', firehouse: 20 }),
122
+ Firefighter.from({ name: 'fob', firehouse: 30 })
123
+ ];
124
+
125
+ await this.saveAll(Worker, [doctorOne, doctorTwo, firefighterOne, firefighterTwo]);
126
+ assert((await this.getSize(Worker)) === 4);
127
+
128
+ const sumFirehouse = await service.aggregateFieldByQuery(Firefighter, 'sum', 'firehouse');
129
+ assert(sumFirehouse === 50);
130
+
131
+ const averageFirehouse = await service.aggregateFieldByQuery(Firefighter, 'avg', 'firehouse');
132
+ assert(averageFirehouse === 25);
133
+ }
112
134
  }