@simtlix/simfinity-js 2.0.2 → 2.1.0
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/AGGREGATION_CHANGES_SUMMARY.md +235 -0
- package/AGGREGATION_EXAMPLE.md +567 -0
- package/README.md +233 -0
- package/package.json +1 -1
- package/src/index.js +342 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -10
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -10
- package/.github/workflows/master.yml +0 -19
- package/.github/workflows/publish.yml +0 -45
- package/.github/workflows/release.yml +0 -65
- package/BACKUP_README.md +0 -26
- package/README_INDEX_EXAMPLE.md +0 -252
- package/simtlix-simfinity-js-1.9.1.tgz +0 -0
- package/tests/objectid-indexes.test.js +0 -215
- package/tests/prevent-collection-creation.test.js +0 -67
- package/tests/scalar-naming.test.js +0 -125
- package/tests/validated-scalar.test.js +0 -172
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@ A powerful Node.js framework that automatically generates GraphQL schemas from y
|
|
|
7
7
|
- **Automatic Schema Generation**: Define your object model, and Simfinity.js generates all queries and mutations
|
|
8
8
|
- **MongoDB Integration**: Seamless translation between GraphQL and MongoDB
|
|
9
9
|
- **Powerful Querying**: Any query that can be executed in MongoDB can be executed in GraphQL
|
|
10
|
+
- **Aggregation Queries**: Built-in support for GROUP BY queries with aggregation operations (SUM, COUNT, AVG, MIN, MAX)
|
|
10
11
|
- **Auto-Generated Resolvers**: Automatically generates resolve methods for relationship fields
|
|
11
12
|
- **Automatic Index Creation**: Automatically creates MongoDB indexes for all ObjectId fields, including nested embedded objects and relationship fields
|
|
12
13
|
- **Business Logic**: Implement business logic and domain validations declaratively
|
|
@@ -1358,6 +1359,238 @@ console.log(UserType.getFields()); // Access GraphQL fields
|
|
|
1358
1359
|
const BookInput = simfinity.getInputType(BookType);
|
|
1359
1360
|
```
|
|
1360
1361
|
|
|
1362
|
+
## 📊 Aggregation Queries
|
|
1363
|
+
|
|
1364
|
+
Simfinity.js now supports powerful GraphQL aggregation queries with GROUP BY functionality, allowing you to perform aggregate operations (SUM, COUNT, AVG, MIN, MAX) on your data.
|
|
1365
|
+
|
|
1366
|
+
### Overview
|
|
1367
|
+
|
|
1368
|
+
For each entity type registered with `connect()`, an additional aggregation endpoint is automatically generated with the format `{entityname}_aggregate`.
|
|
1369
|
+
|
|
1370
|
+
### Features
|
|
1371
|
+
|
|
1372
|
+
- **Group By**: Group results by any field (direct or related entity field path)
|
|
1373
|
+
- **Aggregation Operations**: SUM, COUNT, AVG, MIN, MAX
|
|
1374
|
+
- **Filtering**: Use the same filter parameters as regular queries
|
|
1375
|
+
- **Sorting**: Sort by groupId or any calculated fact (metrics), with support for multiple sort fields
|
|
1376
|
+
- **Pagination**: Use the same pagination parameters as regular queries
|
|
1377
|
+
- **Related Entity Fields**: Group by or aggregate on fields from related entities using dot notation
|
|
1378
|
+
|
|
1379
|
+
### GraphQL Types
|
|
1380
|
+
|
|
1381
|
+
#### QLAggregationOperation (Enum)
|
|
1382
|
+
- `SUM`: Sum of numeric values
|
|
1383
|
+
- `COUNT`: Count of records
|
|
1384
|
+
- `AVG`: Average of numeric values
|
|
1385
|
+
- `MIN`: Minimum value
|
|
1386
|
+
- `MAX`: Maximum value
|
|
1387
|
+
|
|
1388
|
+
#### QLTypeAggregationFact (Input)
|
|
1389
|
+
```graphql
|
|
1390
|
+
input QLTypeAggregationFact {
|
|
1391
|
+
operation: QLAggregationOperation!
|
|
1392
|
+
factName: String!
|
|
1393
|
+
path: String!
|
|
1394
|
+
}
|
|
1395
|
+
```
|
|
1396
|
+
|
|
1397
|
+
#### QLTypeAggregationExpression (Input)
|
|
1398
|
+
```graphql
|
|
1399
|
+
input QLTypeAggregationExpression {
|
|
1400
|
+
groupId: String!
|
|
1401
|
+
facts: [QLTypeAggregationFact!]!
|
|
1402
|
+
}
|
|
1403
|
+
```
|
|
1404
|
+
|
|
1405
|
+
#### QLTypeAggregationResult (Output)
|
|
1406
|
+
```graphql
|
|
1407
|
+
type QLTypeAggregationResult {
|
|
1408
|
+
groupId: JSON
|
|
1409
|
+
facts: JSON
|
|
1410
|
+
}
|
|
1411
|
+
```
|
|
1412
|
+
|
|
1413
|
+
### Quick Examples
|
|
1414
|
+
|
|
1415
|
+
#### Simple Group By
|
|
1416
|
+
```graphql
|
|
1417
|
+
query {
|
|
1418
|
+
series_aggregate(
|
|
1419
|
+
aggregation: {
|
|
1420
|
+
groupId: "category"
|
|
1421
|
+
facts: [
|
|
1422
|
+
{ operation: COUNT, factName: "total", path: "id" }
|
|
1423
|
+
]
|
|
1424
|
+
}
|
|
1425
|
+
) {
|
|
1426
|
+
groupId
|
|
1427
|
+
facts
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
```
|
|
1431
|
+
|
|
1432
|
+
#### Group By Related Entity
|
|
1433
|
+
```graphql
|
|
1434
|
+
query {
|
|
1435
|
+
series_aggregate(
|
|
1436
|
+
aggregation: {
|
|
1437
|
+
groupId: "country.name"
|
|
1438
|
+
facts: [
|
|
1439
|
+
{ operation: COUNT, factName: "count", path: "id" }
|
|
1440
|
+
{ operation: AVG, factName: "avgRating", path: "rating" }
|
|
1441
|
+
]
|
|
1442
|
+
}
|
|
1443
|
+
) {
|
|
1444
|
+
groupId
|
|
1445
|
+
facts
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
```
|
|
1449
|
+
|
|
1450
|
+
#### Multiple Aggregation Facts
|
|
1451
|
+
```graphql
|
|
1452
|
+
query {
|
|
1453
|
+
series_aggregate(
|
|
1454
|
+
aggregation: {
|
|
1455
|
+
groupId: "category"
|
|
1456
|
+
facts: [
|
|
1457
|
+
{ operation: COUNT, factName: "total", path: "id" }
|
|
1458
|
+
{ operation: SUM, factName: "totalEpisodes", path: "episodeCount" }
|
|
1459
|
+
{ operation: AVG, factName: "avgRating", path: "rating" }
|
|
1460
|
+
{ operation: MIN, factName: "minRating", path: "rating" }
|
|
1461
|
+
{ operation: MAX, factName: "maxRating", path: "rating" }
|
|
1462
|
+
]
|
|
1463
|
+
}
|
|
1464
|
+
) {
|
|
1465
|
+
groupId
|
|
1466
|
+
facts
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
```
|
|
1470
|
+
|
|
1471
|
+
#### With Filtering
|
|
1472
|
+
```graphql
|
|
1473
|
+
query {
|
|
1474
|
+
series_aggregate(
|
|
1475
|
+
rating: { operator: GTE, value: 8.0 }
|
|
1476
|
+
aggregation: {
|
|
1477
|
+
groupId: "category"
|
|
1478
|
+
facts: [
|
|
1479
|
+
{ operation: COUNT, factName: "highRated", path: "id" }
|
|
1480
|
+
]
|
|
1481
|
+
}
|
|
1482
|
+
) {
|
|
1483
|
+
groupId
|
|
1484
|
+
facts
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
```
|
|
1488
|
+
|
|
1489
|
+
#### Sorting by Multiple Fields
|
|
1490
|
+
```graphql
|
|
1491
|
+
query {
|
|
1492
|
+
series_aggregate(
|
|
1493
|
+
sort: {
|
|
1494
|
+
terms: [
|
|
1495
|
+
{ field: "total", order: "DESC" }, # Sort by count first
|
|
1496
|
+
{ field: "groupId", order: "ASC" } # Then by name
|
|
1497
|
+
]
|
|
1498
|
+
}
|
|
1499
|
+
aggregation: {
|
|
1500
|
+
groupId: "category"
|
|
1501
|
+
facts: [
|
|
1502
|
+
{ operation: COUNT, factName: "total", path: "id" }
|
|
1503
|
+
{ operation: AVG, factName: "avgRating", path: "rating" }
|
|
1504
|
+
]
|
|
1505
|
+
}
|
|
1506
|
+
) {
|
|
1507
|
+
groupId
|
|
1508
|
+
facts
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
```
|
|
1512
|
+
|
|
1513
|
+
#### With Pagination (Top 5)
|
|
1514
|
+
```graphql
|
|
1515
|
+
query {
|
|
1516
|
+
series_aggregate(
|
|
1517
|
+
sort: {
|
|
1518
|
+
terms: [{ field: "total", order: "DESC" }]
|
|
1519
|
+
}
|
|
1520
|
+
pagination: {
|
|
1521
|
+
page: 1
|
|
1522
|
+
size: 5
|
|
1523
|
+
}
|
|
1524
|
+
aggregation: {
|
|
1525
|
+
groupId: "category"
|
|
1526
|
+
facts: [
|
|
1527
|
+
{ operation: COUNT, factName: "total", path: "id" }
|
|
1528
|
+
]
|
|
1529
|
+
}
|
|
1530
|
+
) {
|
|
1531
|
+
groupId
|
|
1532
|
+
facts
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
```
|
|
1536
|
+
|
|
1537
|
+
### Field Path Resolution
|
|
1538
|
+
|
|
1539
|
+
The `groupId` and `path` parameters support:
|
|
1540
|
+
|
|
1541
|
+
1. **Direct Fields**: Simple field names from the entity
|
|
1542
|
+
- Example: `"category"`, `"rating"`, `"id"`
|
|
1543
|
+
|
|
1544
|
+
2. **Related Entity Fields**: Dot notation for fields in related entities
|
|
1545
|
+
- Example: `"country.name"`, `"studio.foundedYear"`
|
|
1546
|
+
|
|
1547
|
+
3. **Nested Related Entities**: Multiple levels of relationships
|
|
1548
|
+
- Example: `"country.region.name"`
|
|
1549
|
+
|
|
1550
|
+
### Sorting Options
|
|
1551
|
+
|
|
1552
|
+
- Sort by **groupId** or **any fact name**
|
|
1553
|
+
- **Multiple sort fields supported** - results are sorted by the first field, then by the second field for ties, etc.
|
|
1554
|
+
- Set the `field` parameter to:
|
|
1555
|
+
- `"groupId"` to sort by the grouping field
|
|
1556
|
+
- Any fact name (e.g., `"avgRating"`, `"total"`) to sort by that calculated metric
|
|
1557
|
+
- The `order` parameter (ASC/DESC) determines the sort direction for each field
|
|
1558
|
+
- If a field doesn't match groupId or any fact name, it defaults to groupId
|
|
1559
|
+
- If no sort is specified, defaults to sorting by groupId ascending
|
|
1560
|
+
|
|
1561
|
+
### Pagination Notes
|
|
1562
|
+
|
|
1563
|
+
- The `page` and `size` parameters work as expected
|
|
1564
|
+
- The `count` parameter is **ignored** for aggregation queries
|
|
1565
|
+
- Pagination is applied **after** grouping and sorting
|
|
1566
|
+
|
|
1567
|
+
### MongoDB Translation
|
|
1568
|
+
|
|
1569
|
+
Aggregation queries are translated to efficient MongoDB aggregation pipelines:
|
|
1570
|
+
|
|
1571
|
+
1. **$lookup**: Joins with related entity collections
|
|
1572
|
+
2. **$unwind**: Flattens joined arrays
|
|
1573
|
+
3. **$match**: Applies filters (before grouping)
|
|
1574
|
+
4. **$group**: Groups by the specified field with aggregation operations
|
|
1575
|
+
5. **$project**: Formats final output with groupId and facts fields
|
|
1576
|
+
6. **$sort**: Sorts results by groupId or facts (with multiple fields support)
|
|
1577
|
+
7. **$limit** / **$skip**: Applied for pagination (after sorting)
|
|
1578
|
+
|
|
1579
|
+
### Result Structure
|
|
1580
|
+
|
|
1581
|
+
Results are returned in a consistent format:
|
|
1582
|
+
```json
|
|
1583
|
+
{
|
|
1584
|
+
"groupId": <value>,
|
|
1585
|
+
"facts": {
|
|
1586
|
+
"factName1": <calculated_value>,
|
|
1587
|
+
"factName2": <calculated_value>
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
```
|
|
1591
|
+
|
|
1592
|
+
For complete documentation with more examples, see [AGGREGATION_EXAMPLE.md](./AGGREGATION_EXAMPLE.md) and [AGGREGATION_CHANGES_SUMMARY.md](./AGGREGATION_CHANGES_SUMMARY.md).
|
|
1593
|
+
|
|
1361
1594
|
## 📚 Complete Example
|
|
1362
1595
|
|
|
1363
1596
|
Here's a complete bookstore example with relationships, validations, and state machines:
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -13,6 +13,41 @@ import QLSort from './const/QLSort.js';
|
|
|
13
13
|
|
|
14
14
|
mongoose.set('strictQuery', false);
|
|
15
15
|
|
|
16
|
+
// Custom JSON scalar type for aggregation results
|
|
17
|
+
const GraphQLJSON = new GraphQLScalarType({
|
|
18
|
+
name: 'JSON',
|
|
19
|
+
description: 'The `JSON` scalar type represents JSON values as specified by ECMA-404',
|
|
20
|
+
serialize(value) {
|
|
21
|
+
return value;
|
|
22
|
+
},
|
|
23
|
+
parseValue(value) {
|
|
24
|
+
return value;
|
|
25
|
+
},
|
|
26
|
+
parseLiteral(ast) {
|
|
27
|
+
switch (ast.kind) {
|
|
28
|
+
case Kind.STRING:
|
|
29
|
+
case Kind.BOOLEAN:
|
|
30
|
+
return ast.value;
|
|
31
|
+
case Kind.INT:
|
|
32
|
+
case Kind.FLOAT:
|
|
33
|
+
return parseFloat(ast.value);
|
|
34
|
+
case Kind.OBJECT: {
|
|
35
|
+
const value = Object.create(null);
|
|
36
|
+
ast.fields.forEach((field) => {
|
|
37
|
+
value[field.name.value] = GraphQLJSON.parseLiteral(field.value);
|
|
38
|
+
});
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
case Kind.LIST:
|
|
42
|
+
return ast.values.map((n) => GraphQLJSON.parseLiteral(n));
|
|
43
|
+
case Kind.NULL:
|
|
44
|
+
return null;
|
|
45
|
+
default:
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
16
51
|
// Adding 'extensions' field into instronspection query
|
|
17
52
|
const RelationType = new GraphQLObjectType({
|
|
18
53
|
name: 'RelationType',
|
|
@@ -147,6 +182,42 @@ const QLSortExpression = new GraphQLInputObjectType({
|
|
|
147
182
|
}),
|
|
148
183
|
});
|
|
149
184
|
|
|
185
|
+
const QLAggregationOperation = new GraphQLEnumType({
|
|
186
|
+
name: 'QLAggregationOperation',
|
|
187
|
+
values: {
|
|
188
|
+
SUM: { value: 'SUM' },
|
|
189
|
+
COUNT: { value: 'COUNT' },
|
|
190
|
+
AVG: { value: 'AVG' },
|
|
191
|
+
MIN: { value: 'MIN' },
|
|
192
|
+
MAX: { value: 'MAX' },
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const QLTypeAggregationFact = new GraphQLInputObjectType({
|
|
197
|
+
name: 'QLTypeAggregationFact',
|
|
198
|
+
fields: () => ({
|
|
199
|
+
operation: { type: new GraphQLNonNull(QLAggregationOperation) },
|
|
200
|
+
factName: { type: new GraphQLNonNull(GraphQLString) },
|
|
201
|
+
path: { type: new GraphQLNonNull(GraphQLString) },
|
|
202
|
+
}),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const QLTypeAggregationExpression = new GraphQLInputObjectType({
|
|
206
|
+
name: 'QLTypeAggregationExpression',
|
|
207
|
+
fields: () => ({
|
|
208
|
+
groupId: { type: new GraphQLNonNull(GraphQLString) },
|
|
209
|
+
facts: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(QLTypeAggregationFact))) },
|
|
210
|
+
}),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const QLTypeAggregationResult = new GraphQLObjectType({
|
|
214
|
+
name: 'QLTypeAggregationResult',
|
|
215
|
+
fields: () => ({
|
|
216
|
+
groupId: { type: GraphQLJSON },
|
|
217
|
+
facts: { type: GraphQLJSON },
|
|
218
|
+
}),
|
|
219
|
+
});
|
|
220
|
+
|
|
150
221
|
const isNonNullOfType = (fieldEntryType, graphQLType) => {
|
|
151
222
|
let isOfType = false;
|
|
152
223
|
if (fieldEntryType instanceof GraphQLNonNull) {
|
|
@@ -1482,6 +1553,254 @@ const buildQuery = async (input, gqltype, isCount) => {
|
|
|
1482
1553
|
return aggregateClauses;
|
|
1483
1554
|
};
|
|
1484
1555
|
|
|
1556
|
+
const buildFieldPath = (gqltype, fieldPath) => {
|
|
1557
|
+
// This function resolves a field path (e.g., "category" or "country.name")
|
|
1558
|
+
// and returns the MongoDB field path and any necessary lookups
|
|
1559
|
+
const pathParts = fieldPath.split('.');
|
|
1560
|
+
const aggregateClauses = [];
|
|
1561
|
+
let currentPath = '';
|
|
1562
|
+
let currentGQLType = gqltype;
|
|
1563
|
+
|
|
1564
|
+
for (let i = 0; i < pathParts.length; i++) {
|
|
1565
|
+
const part = pathParts[i];
|
|
1566
|
+
const field = currentGQLType.getFields()[part];
|
|
1567
|
+
|
|
1568
|
+
if (!field) {
|
|
1569
|
+
throw new Error(`Field ${part} not found in type ${currentGQLType.name}`);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
let fieldType = field.type;
|
|
1573
|
+
if (fieldType instanceof GraphQLNonNull || fieldType instanceof GraphQLList) {
|
|
1574
|
+
fieldType = fieldType.ofType;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// If it's an object type with non-embedded relation, we need a lookup
|
|
1578
|
+
if ((fieldType instanceof GraphQLObjectType) &&
|
|
1579
|
+
field.extensions && field.extensions.relation &&
|
|
1580
|
+
!field.extensions.relation.embedded) {
|
|
1581
|
+
|
|
1582
|
+
const relatedModel = typesDict.types[fieldType.name].model;
|
|
1583
|
+
const collectionName = relatedModel.collection.collectionName;
|
|
1584
|
+
const connectionField = field.extensions.relation.connectionField || part;
|
|
1585
|
+
|
|
1586
|
+
const lookupAlias = currentPath ? `${currentPath}_${part}` : part;
|
|
1587
|
+
const localField = currentPath ? `${currentPath}.${connectionField}` : connectionField;
|
|
1588
|
+
|
|
1589
|
+
aggregateClauses.push({
|
|
1590
|
+
$lookup: {
|
|
1591
|
+
from: collectionName,
|
|
1592
|
+
foreignField: '_id',
|
|
1593
|
+
localField,
|
|
1594
|
+
as: lookupAlias,
|
|
1595
|
+
},
|
|
1596
|
+
});
|
|
1597
|
+
|
|
1598
|
+
aggregateClauses.push({
|
|
1599
|
+
$unwind: { path: `$${lookupAlias}`, preserveNullAndEmptyArrays: true },
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
currentPath = lookupAlias;
|
|
1603
|
+
currentGQLType = fieldType;
|
|
1604
|
+
} else if (fieldType instanceof GraphQLObjectType &&
|
|
1605
|
+
field.extensions && field.extensions.relation &&
|
|
1606
|
+
field.extensions.relation.embedded) {
|
|
1607
|
+
// Embedded object - just append to path
|
|
1608
|
+
currentPath = currentPath ? `${currentPath}.${part}` : part;
|
|
1609
|
+
currentGQLType = fieldType;
|
|
1610
|
+
} else {
|
|
1611
|
+
// Scalar field - final part of path
|
|
1612
|
+
if (part === 'id') {
|
|
1613
|
+
currentPath = currentPath ? `${currentPath}._id` : '_id';
|
|
1614
|
+
} else {
|
|
1615
|
+
currentPath = currentPath ? `${currentPath}.${part}` : part;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
return { mongoPath: currentPath, lookups: aggregateClauses };
|
|
1621
|
+
};
|
|
1622
|
+
|
|
1623
|
+
const buildAggregationQuery = async (input, gqltype, aggregationExpression) => {
|
|
1624
|
+
const aggregateClauses = [];
|
|
1625
|
+
const matchesClauses = { $match: {} };
|
|
1626
|
+
let addMatch = false;
|
|
1627
|
+
const aggregationsIncluded = {};
|
|
1628
|
+
const sortTerms = []; // Store multiple sort terms
|
|
1629
|
+
let limitClause = null;
|
|
1630
|
+
let skipClause = null;
|
|
1631
|
+
|
|
1632
|
+
// Build filter and lookup clauses (similar to buildQuery)
|
|
1633
|
+
for (const [key, filterField] of Object.entries(input)) {
|
|
1634
|
+
if (Object.prototype.hasOwnProperty.call(input, key) && key !== 'pagination' && key !== 'sort' && key !== 'aggregation') {
|
|
1635
|
+
const qlField = gqltype.getFields()[key];
|
|
1636
|
+
|
|
1637
|
+
const result = await buildQueryTerms(filterField, qlField, key);
|
|
1638
|
+
|
|
1639
|
+
if (result) {
|
|
1640
|
+
for (const [prop, aggregate] of Object.entries(result.aggregateClauses)) {
|
|
1641
|
+
aggregateClauses.push(aggregate.lookup);
|
|
1642
|
+
aggregateClauses.push(aggregate.unwind);
|
|
1643
|
+
aggregationsIncluded[prop] = true;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
for (const [matchClauseKey, matchClause] of Object.entries(result.matchesClauses)) {
|
|
1647
|
+
if (Object.prototype.hasOwnProperty.call(result.matchesClauses, matchClauseKey)) {
|
|
1648
|
+
for (const [matchKey, match] of Object.entries(matchClause)) {
|
|
1649
|
+
if (Object.prototype.hasOwnProperty.call(matchClause, matchKey)) {
|
|
1650
|
+
matchesClauses.$match[matchKey] = match;
|
|
1651
|
+
addMatch = true;
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
} else if (key === 'sort' && filterField && filterField.terms && filterField.terms.length > 0) {
|
|
1658
|
+
// Extract all sort terms
|
|
1659
|
+
filterField.terms.forEach(sortTerm => {
|
|
1660
|
+
sortTerms.push({
|
|
1661
|
+
field: sortTerm.field || 'groupId',
|
|
1662
|
+
direction: sortTerm.order === 'ASC' ? 1 : -1,
|
|
1663
|
+
});
|
|
1664
|
+
});
|
|
1665
|
+
} else if (key === 'pagination' && filterField) {
|
|
1666
|
+
// Handle pagination (ignore count parameter)
|
|
1667
|
+
if (filterField.page && filterField.size) {
|
|
1668
|
+
const skip = filterField.size * (filterField.page - 1);
|
|
1669
|
+
limitClause = { $limit: filterField.size + skip };
|
|
1670
|
+
skipClause = { $skip: skip };
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
if (addMatch) {
|
|
1676
|
+
aggregateClauses.push(matchesClauses);
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
// Now build the aggregation with $group
|
|
1680
|
+
const { groupId, facts } = aggregationExpression;
|
|
1681
|
+
|
|
1682
|
+
// Resolve the groupId field path
|
|
1683
|
+
const groupIdPath = buildFieldPath(gqltype, groupId);
|
|
1684
|
+
|
|
1685
|
+
// Add any lookups needed for the groupId field
|
|
1686
|
+
groupIdPath.lookups.forEach(lookup => {
|
|
1687
|
+
const lookupKey = Object.keys(lookup)[0];
|
|
1688
|
+
const lookupAlias = lookup[lookupKey].as;
|
|
1689
|
+
if (!aggregationsIncluded[lookupAlias]) {
|
|
1690
|
+
aggregateClauses.push(lookup);
|
|
1691
|
+
// Check if next item is an unwind for this lookup
|
|
1692
|
+
const unwindItem = groupIdPath.lookups[groupIdPath.lookups.indexOf(lookup) + 1];
|
|
1693
|
+
if (unwindItem && unwindItem.$unwind) {
|
|
1694
|
+
aggregateClauses.push(unwindItem);
|
|
1695
|
+
}
|
|
1696
|
+
aggregationsIncluded[lookupAlias] = true;
|
|
1697
|
+
}
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
// Build the $group stage
|
|
1701
|
+
const groupStage = {
|
|
1702
|
+
$group: {
|
|
1703
|
+
_id: `$${groupIdPath.mongoPath}`,
|
|
1704
|
+
},
|
|
1705
|
+
};
|
|
1706
|
+
|
|
1707
|
+
// Add aggregation operations for each fact
|
|
1708
|
+
facts.forEach(fact => {
|
|
1709
|
+
const { operation, factName, path } = fact;
|
|
1710
|
+
const factPath = buildFieldPath(gqltype, path);
|
|
1711
|
+
|
|
1712
|
+
// Add any lookups needed for the fact field
|
|
1713
|
+
factPath.lookups.forEach(lookup => {
|
|
1714
|
+
const lookupKey = Object.keys(lookup)[0];
|
|
1715
|
+
const lookupAlias = lookup[lookupKey].as;
|
|
1716
|
+
if (!aggregationsIncluded[lookupAlias]) {
|
|
1717
|
+
aggregateClauses.push(lookup);
|
|
1718
|
+
// Check if next item is an unwind for this lookup
|
|
1719
|
+
const unwindItem = factPath.lookups[factPath.lookups.indexOf(lookup) + 1];
|
|
1720
|
+
if (unwindItem && unwindItem.$unwind) {
|
|
1721
|
+
aggregateClauses.push(unwindItem);
|
|
1722
|
+
}
|
|
1723
|
+
aggregationsIncluded[lookupAlias] = true;
|
|
1724
|
+
}
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
// Map GraphQL operations to MongoDB aggregation operators
|
|
1728
|
+
let mongoOperation;
|
|
1729
|
+
switch (operation) {
|
|
1730
|
+
case 'SUM':
|
|
1731
|
+
mongoOperation = { $sum: `$${factPath.mongoPath}` };
|
|
1732
|
+
break;
|
|
1733
|
+
case 'COUNT':
|
|
1734
|
+
mongoOperation = { $sum: 1 };
|
|
1735
|
+
break;
|
|
1736
|
+
case 'AVG':
|
|
1737
|
+
mongoOperation = { $avg: `$${factPath.mongoPath}` };
|
|
1738
|
+
break;
|
|
1739
|
+
case 'MIN':
|
|
1740
|
+
mongoOperation = { $min: `$${factPath.mongoPath}` };
|
|
1741
|
+
break;
|
|
1742
|
+
case 'MAX':
|
|
1743
|
+
mongoOperation = { $max: `$${factPath.mongoPath}` };
|
|
1744
|
+
break;
|
|
1745
|
+
default:
|
|
1746
|
+
throw new Error(`Unknown aggregation operation: ${operation}`);
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
groupStage.$group[factName] = mongoOperation;
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
aggregateClauses.push(groupStage);
|
|
1753
|
+
|
|
1754
|
+
// Add a final projection stage to format the output
|
|
1755
|
+
aggregateClauses.push({
|
|
1756
|
+
$project: {
|
|
1757
|
+
_id: 0,
|
|
1758
|
+
groupId: '$_id',
|
|
1759
|
+
facts: Object.fromEntries(facts.map(fact => [fact.factName, `$${fact.factName}`])),
|
|
1760
|
+
},
|
|
1761
|
+
});
|
|
1762
|
+
|
|
1763
|
+
// Build sort object from multiple sort terms
|
|
1764
|
+
if (sortTerms.length > 0) {
|
|
1765
|
+
const sortObject = {};
|
|
1766
|
+
const factNames = facts.map(fact => fact.factName);
|
|
1767
|
+
|
|
1768
|
+
sortTerms.forEach(sortTerm => {
|
|
1769
|
+
let sortFieldPath = 'groupId';
|
|
1770
|
+
|
|
1771
|
+
if (sortTerm.field !== 'groupId') {
|
|
1772
|
+
// Check if the field is one of the fact names
|
|
1773
|
+
if (factNames.includes(sortTerm.field)) {
|
|
1774
|
+
sortFieldPath = `facts.${sortTerm.field}`;
|
|
1775
|
+
}
|
|
1776
|
+
// If not found, default to groupId (already set)
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
sortObject[sortFieldPath] = sortTerm.direction;
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
// Add sort stage with all sort fields
|
|
1783
|
+
aggregateClauses.push({
|
|
1784
|
+
$sort: sortObject,
|
|
1785
|
+
});
|
|
1786
|
+
} else {
|
|
1787
|
+
// Default sort by groupId ascending if no sort terms provided
|
|
1788
|
+
aggregateClauses.push({
|
|
1789
|
+
$sort: { groupId: 1 },
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
// Add pagination if provided
|
|
1794
|
+
if (limitClause) {
|
|
1795
|
+
aggregateClauses.push(limitClause);
|
|
1796
|
+
}
|
|
1797
|
+
if (skipClause) {
|
|
1798
|
+
aggregateClauses.push(skipClause);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
return aggregateClauses;
|
|
1802
|
+
};
|
|
1803
|
+
|
|
1485
1804
|
const buildRootQuery = (name, includedTypes) => {
|
|
1486
1805
|
const rootQueryArgs = {};
|
|
1487
1806
|
rootQueryArgs.name = name;
|
|
@@ -1545,6 +1864,29 @@ const buildRootQuery = (name, includedTypes) => {
|
|
|
1545
1864
|
return result;
|
|
1546
1865
|
},
|
|
1547
1866
|
};
|
|
1867
|
+
|
|
1868
|
+
// Add aggregate endpoint
|
|
1869
|
+
const aggregateArgsObject = { ...argsObject };
|
|
1870
|
+
aggregateArgsObject.aggregation = {
|
|
1871
|
+
type: new GraphQLNonNull(QLTypeAggregationExpression),
|
|
1872
|
+
};
|
|
1873
|
+
|
|
1874
|
+
rootQueryArgs.fields[`${type.listEntitiesEndpointName}_aggregate`] = {
|
|
1875
|
+
type: new GraphQLList(QLTypeAggregationResult),
|
|
1876
|
+
args: aggregateArgsObject,
|
|
1877
|
+
async resolve(parent, args, context) {
|
|
1878
|
+
const params = {
|
|
1879
|
+
type,
|
|
1880
|
+
args,
|
|
1881
|
+
operation: 'aggregate',
|
|
1882
|
+
context,
|
|
1883
|
+
};
|
|
1884
|
+
excecuteMiddleware(params);
|
|
1885
|
+
const aggregateClauses = await buildAggregationQuery(args, type.gqltype, args.aggregation);
|
|
1886
|
+
const result = await type.model.aggregate(aggregateClauses);
|
|
1887
|
+
return result;
|
|
1888
|
+
},
|
|
1889
|
+
};
|
|
1548
1890
|
}
|
|
1549
1891
|
}
|
|
1550
1892
|
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
name: Build on master
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches: master
|
|
6
|
-
|
|
7
|
-
jobs:
|
|
8
|
-
build:
|
|
9
|
-
runs-on: ubuntu-24.04
|
|
10
|
-
steps:
|
|
11
|
-
- uses: actions/checkout@v2.3.4
|
|
12
|
-
- uses: actions/setup-node@v1.4.4
|
|
13
|
-
with:
|
|
14
|
-
node-version: 14
|
|
15
|
-
- run: npm ci
|
|
16
|
-
- run: npx json -f package.json peerDependencies | npx json -ka | xargs -i{} bash -c 'echo $0@$(npx json -f package.json peerDependencies.$0)' {} | xargs -i{} npm install --save-optional {}
|
|
17
|
-
- run: npm run lint
|
|
18
|
-
- run: npm test
|
|
19
|
-
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
name: Publish packages
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
create:
|
|
5
|
-
tags:
|
|
6
|
-
- 'v*'
|
|
7
|
-
|
|
8
|
-
jobs:
|
|
9
|
-
build:
|
|
10
|
-
runs-on: ubuntu-24.04
|
|
11
|
-
steps:
|
|
12
|
-
- uses: actions/checkout@v2.3.4
|
|
13
|
-
- uses: actions/setup-node@v1.4.4
|
|
14
|
-
with:
|
|
15
|
-
node-version: 14
|
|
16
|
-
- run: npm ci
|
|
17
|
-
- run: npx json -f package.json peerDependencies | npx json -ka | xargs -i{} bash -c 'echo $0@$(npx json -f package.json peerDependencies.$0)' {} | xargs -i{} npm install --save-optional {}
|
|
18
|
-
- run: npm test
|
|
19
|
-
|
|
20
|
-
publish-npm:
|
|
21
|
-
needs: build
|
|
22
|
-
runs-on: ubuntu-24.04
|
|
23
|
-
steps:
|
|
24
|
-
- uses: actions/checkout@v2.3.4
|
|
25
|
-
- uses: actions/setup-node@v1.4.4
|
|
26
|
-
with:
|
|
27
|
-
node-version: 14
|
|
28
|
-
registry-url: https://registry.npmjs.org/
|
|
29
|
-
- run: npm ci
|
|
30
|
-
- run: npm publish --access public
|
|
31
|
-
env:
|
|
32
|
-
NODE_AUTH_TOKEN: ${{secrets.NPMJS_TOKEN}}
|
|
33
|
-
publish-gpr:
|
|
34
|
-
needs: build
|
|
35
|
-
runs-on: ubuntu-24.04
|
|
36
|
-
steps:
|
|
37
|
-
- uses: actions/checkout@v2.3.4
|
|
38
|
-
- uses: actions/setup-node@v1.4.4
|
|
39
|
-
with:
|
|
40
|
-
node-version: 14
|
|
41
|
-
registry-url: https://npm.pkg.github.com/
|
|
42
|
-
- run: npm ci
|
|
43
|
-
- run: npm publish
|
|
44
|
-
env:
|
|
45
|
-
NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}}
|