@travetto/model-query 8.0.3 → 8.0.4

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
@@ -35,7 +35,13 @@ export interface ModelQuerySupport {
35
35
  * @param query The query to search for
36
36
  * @param failOnMany Should the query fail on more than one result found, defaults to true
37
37
  */
38
- queryOne<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>, failOnMany?: boolean): Promise<T>;
38
+ getByQuery<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>, failOnMany?: boolean): Promise<T>;
39
+ /**
40
+ * Find the count of matching documents by query.
41
+ * @param cls The model class
42
+ * @param query The query to count for
43
+ */
44
+ countByQuery<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>): Promise<number>;
39
45
  }
40
46
  ```
41
47
 
@@ -122,35 +128,24 @@ export interface ModelQuerySuggestSupport extends ModelQuerySupport {
122
128
  ```
123
129
 
124
130
  ### Aggregate
125
- 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.
131
+ This contract provides the ability to run strongly-typed field-level statistics (such as min, max, count, avg, and sum) on models with optional query filtering.
126
132
 
127
133
  **Code: Query Aggregate**
128
134
  ```typescript
129
135
  export interface ModelQueryAggregateSupport extends ModelQuerySupport {
130
136
  /**
131
- * Run an aggregation on a field
137
+ * Run an aggregation on a comparable field.
138
+ * For numeric fields (number/bigint), returns count, min, max, avg, and sum.
139
+ * For date fields, returns count, min, and max.
132
140
  * @param modelClass The model class to aggregate
133
- * @param operation The operation to perform ('sum', 'avg', 'min', 'max')
134
141
  * @param field The field to aggregate on
135
142
  * @param query Additional query filtering
136
143
  */
137
- aggregateFieldByQuery<
138
- T extends ModelType,
139
- Op extends AggregateOperation,
140
- F extends (Op extends AggregateNumericOperation ? ValidNumericFields<T> : ValidComparableFields<T>)
141
- >(
144
+ aggregateFieldByQuery<T extends ModelType, F extends ValidComparableFields<T>>(
142
145
  modelClass: Class<T>,
143
- operation: Op,
144
146
  field: F,
145
147
  query?: ModelQuery<T>
146
- ): Promise<AggregateResultType<T, F>>;
147
-
148
- /**
149
- * Find the count of matching documents by query.
150
- * @param cls The model class
151
- * @param query The query to count for
152
- */
153
- countByQuery<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>): Promise<number>;
148
+ ): Promise<FieldAggregateResult<T, F>>;
154
149
  }
155
150
  ```
156
151
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/model-query",
3
- "version": "8.0.3",
3
+ "version": "8.0.4",
4
4
  "description": "Datastore abstraction for advanced query support.",
5
5
  "keywords": [
6
6
  "datastore",
@@ -2,13 +2,9 @@ import type { ModelType } from '@travetto/model';
2
2
  import type { Class } from '@travetto/runtime';
3
3
 
4
4
  import type { ModelQuery } from '../model/query.ts';
5
- import type { ValidComparableFields, ValidNumericFields } from '../model/where-clause.ts';
5
+ import type { ValidComparableFields } from '../model/where-clause.ts';
6
6
  import type { ModelQuerySupport } from './query.ts';
7
7
 
8
- export type AggregateNumericOperation = 'sum' | 'avg';
9
- export type AggregateComparableOperation = 'min' | 'max';
10
- export type AggregateOperation = AggregateNumericOperation | AggregateComparableOperation;
11
-
12
8
  export type PathType<T, Path extends string> = Path extends `${infer Head}.${infer Tail}`
13
9
  ? Head extends keyof T
14
10
  ? PathType<NonNullable<T[Head]> extends (infer Element)[] ? Element : NonNullable<T[Head]>, Tail>
@@ -23,33 +19,39 @@ export type AggregateResultType<T, F extends string> = [PathType<T, F>] extends
23
19
  ? number | Date | bigint | undefined
24
20
  : PathType<T, F> | undefined;
25
21
 
22
+ export type DateFieldAggregateResult = {
23
+ count: number;
24
+ min?: Date;
25
+ max?: Date;
26
+ };
27
+
28
+ export type NumericFieldAggregateResult<T, F extends string> = {
29
+ count: number;
30
+ min?: AggregateResultType<T, F>;
31
+ max?: AggregateResultType<T, F>;
32
+ avg?: AggregateResultType<T, F>;
33
+ sum?: AggregateResultType<T, F>;
34
+ };
35
+
36
+ export type FieldAggregateResult<T, F extends string> =
37
+ NonNullable<PathType<T, F>> extends Date ? DateFieldAggregateResult : NumericFieldAggregateResult<T, F>;
38
+
26
39
  /**
27
40
  * The contract for a model service with aggregate support
28
41
  * @concrete
29
42
  */
30
43
  export interface ModelQueryAggregateSupport extends ModelQuerySupport {
31
44
  /**
32
- * Run an aggregation on a field
45
+ * Run an aggregation on a comparable field.
46
+ * For numeric fields (number/bigint), returns count, min, max, avg, and sum.
47
+ * For date fields, returns count, min, and max.
33
48
  * @param modelClass The model class to aggregate
34
- * @param operation The operation to perform ('sum', 'avg', 'min', 'max')
35
49
  * @param field The field to aggregate on
36
50
  * @param query Additional query filtering
37
51
  */
38
- aggregateFieldByQuery<
39
- T extends ModelType,
40
- Op extends AggregateOperation,
41
- F extends (Op extends AggregateNumericOperation ? ValidNumericFields<T> : ValidComparableFields<T>)
42
- >(
52
+ aggregateFieldByQuery<T extends ModelType, F extends ValidComparableFields<T>>(
43
53
  modelClass: Class<T>,
44
- operation: Op,
45
54
  field: F,
46
55
  query?: ModelQuery<T>
47
- ): Promise<AggregateResultType<T, F>>;
48
-
49
- /**
50
- * Find the count of matching documents by query.
51
- * @param cls The model class
52
- * @param query The query to count for
53
- */
54
- countByQuery<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>): Promise<number>;
56
+ ): Promise<FieldAggregateResult<T, F>>;
55
57
  }
@@ -20,5 +20,11 @@ export interface ModelQuerySupport {
20
20
  * @param query The query to search for
21
21
  * @param failOnMany Should the query fail on more than one result found, defaults to true
22
22
  */
23
- queryOne<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>, failOnMany?: boolean): Promise<T>;
23
+ getByQuery<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>, failOnMany?: boolean): Promise<T>;
24
+ /**
25
+ * Find the count of matching documents by query.
26
+ * @param cls The model class
27
+ * @param query The query to count for
28
+ */
29
+ countByQuery<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>): Promise<number>;
24
30
  }
@@ -2,7 +2,8 @@ import type { ModelType } from '@travetto/model';
2
2
  import { castTo, type Class, hasFunction } from '@travetto/runtime';
3
3
  import { DataUtil, SchemaRegistryIndex } from '@travetto/schema';
4
4
 
5
- import type { AggregateOperation, AggregateResultType, ModelQueryAggregateSupport } from '../types/aggregate.ts';
5
+ import type { ValidComparableFields } from '../model/where-clause.ts';
6
+ import type { AggregateResultType, FieldAggregateResult, ModelQueryAggregateSupport } from '../types/aggregate.ts';
6
7
 
7
8
  export class ModelQueryAggregateUtil {
8
9
  /**
@@ -11,19 +12,37 @@ export class ModelQueryAggregateUtil {
11
12
  static isSupported = hasFunction<ModelQueryAggregateSupport>('aggregateFieldByQuery');
12
13
 
13
14
  /**
14
- * Resolve and coerce the aggregate query result using DataUtil.coerceType
15
+ * Resolve and coerce aggregate query results
15
16
  * @param modelClass The model class being queried
16
- * @param operation The aggregate operation
17
17
  * @param field The field being aggregated
18
- * @param rawValue The raw result value from the datastore
18
+ * @param rawValues The raw result values from the datastore
19
19
  */
20
- static resolveResult<T extends ModelType, Op extends AggregateOperation, F extends string>(
20
+ static resolveAggregate<T extends ModelType, F extends ValidComparableFields<T>>(
21
21
  modelClass: Class<T>,
22
- operation: Op,
23
22
  field: F,
24
- rawValue: unknown
25
- ): AggregateResultType<T, F> {
23
+ rawValues: { count?: unknown; min?: unknown; max?: unknown; avg?: unknown; sum?: unknown }
24
+ ): FieldAggregateResult<T, F> {
25
+ const count = Number(rawValues.count ?? 0);
26
+ if (count === 0) {
27
+ return castTo({ count: 0 });
28
+ }
26
29
  const leafFieldType = SchemaRegistryIndex.getNestedFieldConfig(modelClass, field)!.type;
27
- return castTo(DataUtil.coerceType(rawValue, leafFieldType, false) ?? undefined);
30
+ const coerce = (value: unknown): AggregateResultType<T, F> => castTo(DataUtil.coerceType(value, leafFieldType, false) ?? undefined);
31
+
32
+ if (leafFieldType === Date) {
33
+ return castTo({
34
+ count,
35
+ min: coerce(rawValues.min),
36
+ max: coerce(rawValues.max)
37
+ });
38
+ }
39
+
40
+ return castTo({
41
+ count,
42
+ min: coerce(rawValues.min),
43
+ max: coerce(rawValues.max),
44
+ avg: coerce(rawValues.avg),
45
+ sum: coerce(rawValues.sum)
46
+ });
28
47
  }
29
48
  }
@@ -10,8 +10,30 @@ import { Aged, BigIntModel, Person } from './model.ts';
10
10
 
11
11
  @Suite()
12
12
  export abstract class ModelQueryAggregateSuite extends BaseModelSuite<ModelQueryAggregateSupport & ModelCrudSupport> {
13
- @Test('verify basic aggregations')
14
- async testAggregate() {
13
+ @Test('verify date field aggregate')
14
+ async testDateFieldAggregate() {
15
+ const service = await this.service;
16
+
17
+ const firstDate = new Date(2020, 0, 1);
18
+ const secondDate = new Date(2021, 0, 1);
19
+ const thirdDate = new Date(2022, 0, 1);
20
+
21
+ await this.saveAll(Aged, [
22
+ Aged.from({ createdAt: firstDate }),
23
+ Aged.from({ createdAt: secondDate }),
24
+ Aged.from({ createdAt: thirdDate })
25
+ ]);
26
+
27
+ const aggregate = await service.aggregateFieldByQuery(Aged, 'createdAt');
28
+ assert(aggregate.count === 3);
29
+ assert(aggregate.min instanceof Date);
30
+ assert(aggregate.min.getTime() === firstDate.getTime());
31
+ assert(aggregate.max instanceof Date);
32
+ assert(aggregate.max.getTime() === thirdDate.getTime());
33
+ }
34
+
35
+ @Test('verify field aggregate')
36
+ async testFieldAggregate() {
15
37
  const people = [
16
38
  Person.from({ name: 'Bob', age: 20, gender: 'm', address: { street1: '1st St' } }),
17
39
  Person.from({ name: 'Alice', age: 30, gender: 'f', address: { street1: '2nd St' } }),
@@ -23,67 +45,38 @@ export abstract class ModelQueryAggregateSuite extends BaseModelSuite<ModelQuery
23
45
  const saved = await this.saveAll(Person, people);
24
46
  assert(saved === 4);
25
47
 
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);
48
+ const aggregate = await service.aggregateFieldByQuery(Person, 'age');
49
+ assert(aggregate.count === 4);
50
+ assert(aggregate.min === 20);
51
+ assert(aggregate.max === 50);
52
+ assert(aggregate.avg === 35);
53
+ assert(aggregate.sum === 140);
31
54
 
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', {
55
+ const filteredAggregate = await service.aggregateFieldByQuery(Person, 'age', {
40
56
  where: {
41
57
  age: { $gte: 30 }
42
58
  }
43
59
  });
44
- assert(filteredSumResult === 120);
60
+ assert(filteredAggregate.count === 3);
61
+ assert(filteredAggregate.min === 30);
62
+ assert(filteredAggregate.max === 50);
63
+ assert(filteredAggregate.avg === 40);
64
+ assert(filteredAggregate.sum === 120);
45
65
 
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', {
66
+ const emptyAggregate = await service.aggregateFieldByQuery(Person, 'age', {
55
67
  where: {
56
68
  age: { $gt: 1000 }
57
69
  }
58
70
  });
59
- assert(noMatchResult === undefined);
71
+ assert(emptyAggregate.count === 0);
72
+ assert(emptyAggregate.min === undefined);
73
+ assert(emptyAggregate.max === undefined);
74
+ assert(emptyAggregate.avg === undefined);
75
+ assert(emptyAggregate.sum === undefined);
60
76
  }
61
77
 
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() {
78
+ @Test('verify bigint field aggregate')
79
+ async testBigIntFieldAggregate() {
87
80
  const service = await this.service;
88
81
 
89
82
  await this.saveAll(BigIntModel, [
@@ -92,16 +85,11 @@ export abstract class ModelQueryAggregateSuite extends BaseModelSuite<ModelQuery
92
85
  BigIntModel.from({ largeNumber: 300n })
93
86
  ]);
94
87
 
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);
88
+ const aggregate = await service.aggregateFieldByQuery(BigIntModel, 'largeNumber');
89
+ assert(aggregate.count === 3);
90
+ assert(aggregate.min === 100n);
91
+ assert(aggregate.max === 300n);
92
+ assert(aggregate.avg === 200n);
93
+ assert(aggregate.sum === 600n);
106
94
  }
107
95
  }
@@ -18,9 +18,7 @@ import { ModelQueryFacetUtil } from '../../src/util/facet.ts';
18
18
  import { ModelQuerySuggestUtil } from '../../src/util/suggest.ts';
19
19
 
20
20
  @Suite()
21
- export abstract class ModelQueryPolymorphismSuite extends BaseModelSuite<
22
- ModelQuerySupport & ModelQueryAggregateSupport & ModelCrudSupport
23
- > {
21
+ export abstract class ModelQueryPolymorphismSuite extends BaseModelSuite<ModelQuerySupport & ModelCrudSupport> {
24
22
  @Test()
25
23
  async testQuery() {
26
24
  const svc = await this.service;
@@ -42,13 +40,13 @@ export abstract class ModelQueryPolymorphismSuite extends BaseModelSuite<
42
40
  assert((await svc.countByQuery(Doctor, { where: { name: 'bob' } })) === 1);
43
41
  assert((await svc.countByQuery(Engineer, { where: { name: 'bob' } })) === 0);
44
42
 
45
- assert((await svc.queryOne(Worker, { where: { name: 'bob' } })) instanceof Doctor);
46
- await assert.rejects(() => svc.queryOne(Firefighter, { where: { name: 'bob' } }), NotFoundError);
43
+ assert((await svc.getByQuery(Worker, { where: { name: 'bob' } })) instanceof Doctor);
44
+ await assert.rejects(() => svc.getByQuery(Firefighter, { where: { name: 'bob' } }), NotFoundError);
47
45
  }
48
46
 
49
47
  @Test({ skip: ModelQueryPolymorphismSuite.ifNot(ModelQueryCrudUtil.isSupported) })
50
48
  async testCrudQuery() {
51
- const svc: ModelQueryCrudSupport & ModelQueryAggregateSupport & ModelQuerySupport = castTo(await this.service);
49
+ const svc: ModelQueryCrudSupport & ModelQuerySupport = castTo(await this.service);
52
50
  const [doc, doc2, fire, eng] = [
53
51
  Doctor.from({ name: 'bob', specialty: 'feet' }),
54
52
  Doctor.from({ name: 'nob', specialty: 'eyes' }),
@@ -127,10 +125,11 @@ export abstract class ModelQueryPolymorphismSuite extends BaseModelSuite<
127
125
  await this.saveAll(Worker, [doctorOne, doctorTwo, firefighterOne, firefighterTwo]);
128
126
  assert((await this.getSize(Worker)) === 4);
129
127
 
130
- const sumFirehouse = await service.aggregateFieldByQuery(Firefighter, 'sum', 'firehouse');
131
- assert(sumFirehouse === 50);
132
-
133
- const averageFirehouse = await service.aggregateFieldByQuery(Firefighter, 'avg', 'firehouse');
134
- assert(averageFirehouse === 25);
128
+ const aggregate = await service.aggregateFieldByQuery(Firefighter, 'firehouse');
129
+ assert(aggregate.count === 2);
130
+ assert(aggregate.min === 20);
131
+ assert(aggregate.max === 30);
132
+ assert(aggregate.avg === 25);
133
+ assert(aggregate.sum === 50);
135
134
  }
136
135
  }
@@ -6,12 +6,11 @@ import { Suite, Test } from '@travetto/test';
6
6
 
7
7
  import { BaseModelSuite } from '@travetto/model/support/test/base.ts';
8
8
 
9
- import type { ModelQueryAggregateSupport } from '../../__index__.ts';
10
9
  import type { ModelQuerySupport } from '../../src/types/query.ts';
11
10
  import { Aged, Location, Names, Note, Person, PersonFamily, Recipe, SimpleList, WithNestedLists, WithNestedNestedLists } from './model.ts';
12
11
 
13
12
  @Suite()
14
- export abstract class ModelQuerySuite extends BaseModelSuite<ModelQuerySupport & ModelQueryAggregateSupport & ModelCrudSupport> {
13
+ export abstract class ModelQuerySuite extends BaseModelSuite<ModelQuerySupport & ModelCrudSupport> {
15
14
  supportsGeo = true;
16
15
 
17
16
  @Test()
@@ -33,10 +32,10 @@ export abstract class ModelQuerySuite extends BaseModelSuite<ModelQuerySupport &
33
32
 
34
33
  await this.saveAll(Person, people);
35
34
 
36
- const one = await svc.queryOne(Person, { where: { id: people[0].id } });
35
+ const one = await svc.getByQuery(Person, { where: { id: people[0].id } });
37
36
  assert(one.id === people[0].id);
38
37
 
39
- const one2 = await svc.queryOne(Person, { where: { id: { $eq: people[0].id } } });
38
+ const one2 = await svc.getByQuery(Person, { where: { id: { $eq: people[0].id } } });
40
39
  assert(one2.id === people[0].id);
41
40
 
42
41
  const none = await svc.countByQuery(Person, { where: { id: { $ne: people[0].id } } });
@@ -92,11 +91,11 @@ export abstract class ModelQuerySuite extends BaseModelSuite<ModelQuerySupport &
92
91
  )
93
92
  );
94
93
 
95
- await assert.rejects(() => service.queryOne(Person, { where: { gender: 'm' } }), /Invalid number of results/);
96
- await assert.rejects(() => service.queryOne(Person, { where: { gender: 'z' } }), NotFoundError);
97
- await assert.rejects(() => service.queryOne(Person, { where: { gender: 'z' } }), /No results found for query/);
98
- await assert.rejects(() => service.queryOne(Person, { where: { id: 'orange' } }), NotFoundError);
99
- await assert.rejects(() => service.queryOne(Person, { where: { id: 'orange' } }), /orange/);
94
+ await assert.rejects(() => service.getByQuery(Person, { where: { gender: 'm' } }), /Invalid number of results/);
95
+ await assert.rejects(() => service.getByQuery(Person, { where: { gender: 'z' } }), NotFoundError);
96
+ await assert.rejects(() => service.getByQuery(Person, { where: { gender: 'z' } }), /No results found for query/);
97
+ await assert.rejects(() => service.getByQuery(Person, { where: { id: 'orange' } }), NotFoundError);
98
+ await assert.rejects(() => service.getByQuery(Person, { where: { id: 'orange' } }), /orange/);
100
99
  }
101
100
 
102
101
  @Test('Verify array $in queries work properly')