@simtlix/simfinity-js 2.0.2 → 2.2.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 +415 -3
- package/package.json +1 -1
- package/src/index.js +345 -0
- package/src/scalars.js +188 -0
- package/src/validators.js +250 -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
|
|
@@ -1100,9 +1101,117 @@ mutation {
|
|
|
1100
1101
|
|
|
1101
1102
|
## ✅ Validations
|
|
1102
1103
|
|
|
1103
|
-
###
|
|
1104
|
+
### Declarative Validation Helpers
|
|
1104
1105
|
|
|
1105
|
-
|
|
1106
|
+
Simfinity.js provides built-in validation helpers to simplify common validation patterns, eliminating verbose boilerplate code.
|
|
1107
|
+
|
|
1108
|
+
#### Using Validators
|
|
1109
|
+
|
|
1110
|
+
```javascript
|
|
1111
|
+
const { validators } = require('@simtlix/simfinity-js');
|
|
1112
|
+
|
|
1113
|
+
const PersonType = new GraphQLObjectType({
|
|
1114
|
+
name: 'Person',
|
|
1115
|
+
fields: () => ({
|
|
1116
|
+
id: { type: GraphQLID },
|
|
1117
|
+
name: {
|
|
1118
|
+
type: GraphQLString,
|
|
1119
|
+
extensions: {
|
|
1120
|
+
validations: validators.stringLength('Name', 2, 100)
|
|
1121
|
+
}
|
|
1122
|
+
},
|
|
1123
|
+
email: {
|
|
1124
|
+
type: GraphQLString,
|
|
1125
|
+
extensions: {
|
|
1126
|
+
validations: validators.email()
|
|
1127
|
+
}
|
|
1128
|
+
},
|
|
1129
|
+
website: {
|
|
1130
|
+
type: GraphQLString,
|
|
1131
|
+
extensions: {
|
|
1132
|
+
validations: validators.url()
|
|
1133
|
+
}
|
|
1134
|
+
},
|
|
1135
|
+
age: {
|
|
1136
|
+
type: GraphQLInt,
|
|
1137
|
+
extensions: {
|
|
1138
|
+
validations: validators.numberRange('Age', 0, 120)
|
|
1139
|
+
}
|
|
1140
|
+
},
|
|
1141
|
+
price: {
|
|
1142
|
+
type: GraphQLFloat,
|
|
1143
|
+
extensions: {
|
|
1144
|
+
validations: validators.positive('Price')
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
})
|
|
1148
|
+
});
|
|
1149
|
+
```
|
|
1150
|
+
|
|
1151
|
+
#### Available Validators
|
|
1152
|
+
|
|
1153
|
+
**String Validators:**
|
|
1154
|
+
- `validators.stringLength(name, min, max)` - Validates string length with min/max bounds (required for CREATE)
|
|
1155
|
+
- `validators.maxLength(name, max)` - Validates maximum string length
|
|
1156
|
+
- `validators.pattern(name, regex, message)` - Validates against a regex pattern
|
|
1157
|
+
- `validators.email()` - Validates email format
|
|
1158
|
+
- `validators.url()` - Validates URL format
|
|
1159
|
+
|
|
1160
|
+
**Number Validators:**
|
|
1161
|
+
- `validators.numberRange(name, min, max)` - Validates number range
|
|
1162
|
+
- `validators.positive(name)` - Ensures number is positive
|
|
1163
|
+
|
|
1164
|
+
**Array Validators:**
|
|
1165
|
+
- `validators.arrayLength(name, maxItems, itemValidator)` - Validates array length and optionally each item
|
|
1166
|
+
|
|
1167
|
+
**Date Validators:**
|
|
1168
|
+
- `validators.dateFormat(name, format)` - Validates date format
|
|
1169
|
+
- `validators.futureDate(name)` - Ensures date is in the future
|
|
1170
|
+
|
|
1171
|
+
#### Validator Features
|
|
1172
|
+
|
|
1173
|
+
- **Automatic Operation Handling**: Validators work for both `CREATE` (save) and `UPDATE` operations
|
|
1174
|
+
- **Smart Validation**: For CREATE operations, values are required. For UPDATE operations, undefined/null values are allowed (field might not be updated)
|
|
1175
|
+
- **Consistent Error Messages**: All validators throw `SimfinityError` with appropriate messages
|
|
1176
|
+
|
|
1177
|
+
#### Example: Multiple Validators
|
|
1178
|
+
|
|
1179
|
+
```javascript
|
|
1180
|
+
const ProductType = new GraphQLObjectType({
|
|
1181
|
+
name: 'Product',
|
|
1182
|
+
fields: () => ({
|
|
1183
|
+
id: { type: GraphQLID },
|
|
1184
|
+
name: {
|
|
1185
|
+
type: GraphQLString,
|
|
1186
|
+
extensions: {
|
|
1187
|
+
validations: validators.stringLength('Product Name', 3, 200)
|
|
1188
|
+
}
|
|
1189
|
+
},
|
|
1190
|
+
sku: {
|
|
1191
|
+
type: GraphQLString,
|
|
1192
|
+
extensions: {
|
|
1193
|
+
validations: validators.pattern('SKU', /^[A-Z0-9-]+$/, 'SKU must be uppercase alphanumeric with hyphens')
|
|
1194
|
+
}
|
|
1195
|
+
},
|
|
1196
|
+
price: {
|
|
1197
|
+
type: GraphQLFloat,
|
|
1198
|
+
extensions: {
|
|
1199
|
+
validations: validators.positive('Price')
|
|
1200
|
+
}
|
|
1201
|
+
},
|
|
1202
|
+
tags: {
|
|
1203
|
+
type: new GraphQLList(GraphQLString),
|
|
1204
|
+
extensions: {
|
|
1205
|
+
validations: validators.arrayLength('Tags', 10)
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
})
|
|
1209
|
+
});
|
|
1210
|
+
```
|
|
1211
|
+
|
|
1212
|
+
### Field-Level Validations (Manual)
|
|
1213
|
+
|
|
1214
|
+
For custom validation logic, you can still write manual validators:
|
|
1106
1215
|
|
|
1107
1216
|
```javascript
|
|
1108
1217
|
const { SimfinityError } = require('@simtlix/simfinity-js');
|
|
@@ -1188,7 +1297,78 @@ const OrderType = new GraphQLObjectType({
|
|
|
1188
1297
|
|
|
1189
1298
|
### Custom Validated Scalar Types
|
|
1190
1299
|
|
|
1191
|
-
Create custom scalar types with built-in validation. The generated type names follow the pattern `{name}_{baseScalarTypeName}
|
|
1300
|
+
Create custom scalar types with built-in validation. The generated type names follow the pattern `{name}_{baseScalarTypeName}`.
|
|
1301
|
+
|
|
1302
|
+
#### Pre-built Scalars
|
|
1303
|
+
|
|
1304
|
+
Simfinity.js provides ready-to-use validated scalars for common patterns:
|
|
1305
|
+
|
|
1306
|
+
```javascript
|
|
1307
|
+
const { scalars } = require('@simtlix/simfinity-js');
|
|
1308
|
+
|
|
1309
|
+
const UserType = new GraphQLObjectType({
|
|
1310
|
+
name: 'User',
|
|
1311
|
+
fields: () => ({
|
|
1312
|
+
id: { type: GraphQLID },
|
|
1313
|
+
email: { type: scalars.EmailScalar }, // Type name: Email_String
|
|
1314
|
+
website: { type: scalars.URLScalar }, // Type name: URL_String
|
|
1315
|
+
age: { type: scalars.PositiveIntScalar }, // Type name: PositiveInt_Int
|
|
1316
|
+
price: { type: scalars.PositiveFloatScalar } // Type name: PositiveFloat_Float
|
|
1317
|
+
}),
|
|
1318
|
+
});
|
|
1319
|
+
```
|
|
1320
|
+
|
|
1321
|
+
**Available Pre-built Scalars:**
|
|
1322
|
+
- `scalars.EmailScalar` - Validates email format (`Email_String`)
|
|
1323
|
+
- `scalars.URLScalar` - Validates URL format (`URL_String`)
|
|
1324
|
+
- `scalars.PositiveIntScalar` - Validates positive integers (`PositiveInt_Int`)
|
|
1325
|
+
- `scalars.PositiveFloatScalar` - Validates positive floats (`PositiveFloat_Float`)
|
|
1326
|
+
|
|
1327
|
+
#### Factory Functions for Custom Scalars
|
|
1328
|
+
|
|
1329
|
+
Create custom validated scalars with parameters:
|
|
1330
|
+
|
|
1331
|
+
```javascript
|
|
1332
|
+
const { scalars } = require('@simtlix/simfinity-js');
|
|
1333
|
+
|
|
1334
|
+
// Create a bounded string scalar (name length between 2-100 characters)
|
|
1335
|
+
const NameScalar = scalars.createBoundedStringScalar('Name', 2, 100);
|
|
1336
|
+
|
|
1337
|
+
// Create a bounded integer scalar (age between 0-120)
|
|
1338
|
+
const AgeScalar = scalars.createBoundedIntScalar('Age', 0, 120);
|
|
1339
|
+
|
|
1340
|
+
// Create a bounded float scalar (rating between 0-10)
|
|
1341
|
+
const RatingScalar = scalars.createBoundedFloatScalar('Rating', 0, 10);
|
|
1342
|
+
|
|
1343
|
+
// Create a pattern-based string scalar (phone number format)
|
|
1344
|
+
const PhoneScalar = scalars.createPatternStringScalar(
|
|
1345
|
+
'Phone',
|
|
1346
|
+
/^\+?[\d\s\-()]+$/,
|
|
1347
|
+
'Invalid phone number format'
|
|
1348
|
+
);
|
|
1349
|
+
|
|
1350
|
+
// Use in your types
|
|
1351
|
+
const PersonType = new GraphQLObjectType({
|
|
1352
|
+
name: 'Person',
|
|
1353
|
+
fields: () => ({
|
|
1354
|
+
id: { type: GraphQLID },
|
|
1355
|
+
name: { type: NameScalar }, // Type name: Name_String
|
|
1356
|
+
age: { type: AgeScalar }, // Type name: Age_Int
|
|
1357
|
+
rating: { type: RatingScalar }, // Type name: Rating_Float
|
|
1358
|
+
phone: { type: PhoneScalar } // Type name: Phone_String
|
|
1359
|
+
}),
|
|
1360
|
+
});
|
|
1361
|
+
```
|
|
1362
|
+
|
|
1363
|
+
**Available Factory Functions:**
|
|
1364
|
+
- `scalars.createBoundedStringScalar(name, min, max)` - String with length bounds
|
|
1365
|
+
- `scalars.createBoundedIntScalar(name, min, max)` - Integer with range validation
|
|
1366
|
+
- `scalars.createBoundedFloatScalar(name, min, max)` - Float with range validation
|
|
1367
|
+
- `scalars.createPatternStringScalar(name, pattern, message)` - String with regex pattern validation
|
|
1368
|
+
|
|
1369
|
+
#### Creating Custom Scalars Manually
|
|
1370
|
+
|
|
1371
|
+
You can also create custom scalars using `createValidatedScalar` directly:
|
|
1192
1372
|
|
|
1193
1373
|
```javascript
|
|
1194
1374
|
const { GraphQLString, GraphQLInt } = require('graphql');
|
|
@@ -1358,6 +1538,238 @@ console.log(UserType.getFields()); // Access GraphQL fields
|
|
|
1358
1538
|
const BookInput = simfinity.getInputType(BookType);
|
|
1359
1539
|
```
|
|
1360
1540
|
|
|
1541
|
+
## 📊 Aggregation Queries
|
|
1542
|
+
|
|
1543
|
+
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.
|
|
1544
|
+
|
|
1545
|
+
### Overview
|
|
1546
|
+
|
|
1547
|
+
For each entity type registered with `connect()`, an additional aggregation endpoint is automatically generated with the format `{entityname}_aggregate`.
|
|
1548
|
+
|
|
1549
|
+
### Features
|
|
1550
|
+
|
|
1551
|
+
- **Group By**: Group results by any field (direct or related entity field path)
|
|
1552
|
+
- **Aggregation Operations**: SUM, COUNT, AVG, MIN, MAX
|
|
1553
|
+
- **Filtering**: Use the same filter parameters as regular queries
|
|
1554
|
+
- **Sorting**: Sort by groupId or any calculated fact (metrics), with support for multiple sort fields
|
|
1555
|
+
- **Pagination**: Use the same pagination parameters as regular queries
|
|
1556
|
+
- **Related Entity Fields**: Group by or aggregate on fields from related entities using dot notation
|
|
1557
|
+
|
|
1558
|
+
### GraphQL Types
|
|
1559
|
+
|
|
1560
|
+
#### QLAggregationOperation (Enum)
|
|
1561
|
+
- `SUM`: Sum of numeric values
|
|
1562
|
+
- `COUNT`: Count of records
|
|
1563
|
+
- `AVG`: Average of numeric values
|
|
1564
|
+
- `MIN`: Minimum value
|
|
1565
|
+
- `MAX`: Maximum value
|
|
1566
|
+
|
|
1567
|
+
#### QLTypeAggregationFact (Input)
|
|
1568
|
+
```graphql
|
|
1569
|
+
input QLTypeAggregationFact {
|
|
1570
|
+
operation: QLAggregationOperation!
|
|
1571
|
+
factName: String!
|
|
1572
|
+
path: String!
|
|
1573
|
+
}
|
|
1574
|
+
```
|
|
1575
|
+
|
|
1576
|
+
#### QLTypeAggregationExpression (Input)
|
|
1577
|
+
```graphql
|
|
1578
|
+
input QLTypeAggregationExpression {
|
|
1579
|
+
groupId: String!
|
|
1580
|
+
facts: [QLTypeAggregationFact!]!
|
|
1581
|
+
}
|
|
1582
|
+
```
|
|
1583
|
+
|
|
1584
|
+
#### QLTypeAggregationResult (Output)
|
|
1585
|
+
```graphql
|
|
1586
|
+
type QLTypeAggregationResult {
|
|
1587
|
+
groupId: JSON
|
|
1588
|
+
facts: JSON
|
|
1589
|
+
}
|
|
1590
|
+
```
|
|
1591
|
+
|
|
1592
|
+
### Quick Examples
|
|
1593
|
+
|
|
1594
|
+
#### Simple Group By
|
|
1595
|
+
```graphql
|
|
1596
|
+
query {
|
|
1597
|
+
series_aggregate(
|
|
1598
|
+
aggregation: {
|
|
1599
|
+
groupId: "category"
|
|
1600
|
+
facts: [
|
|
1601
|
+
{ operation: COUNT, factName: "total", path: "id" }
|
|
1602
|
+
]
|
|
1603
|
+
}
|
|
1604
|
+
) {
|
|
1605
|
+
groupId
|
|
1606
|
+
facts
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
```
|
|
1610
|
+
|
|
1611
|
+
#### Group By Related Entity
|
|
1612
|
+
```graphql
|
|
1613
|
+
query {
|
|
1614
|
+
series_aggregate(
|
|
1615
|
+
aggregation: {
|
|
1616
|
+
groupId: "country.name"
|
|
1617
|
+
facts: [
|
|
1618
|
+
{ operation: COUNT, factName: "count", path: "id" }
|
|
1619
|
+
{ operation: AVG, factName: "avgRating", path: "rating" }
|
|
1620
|
+
]
|
|
1621
|
+
}
|
|
1622
|
+
) {
|
|
1623
|
+
groupId
|
|
1624
|
+
facts
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
```
|
|
1628
|
+
|
|
1629
|
+
#### Multiple Aggregation Facts
|
|
1630
|
+
```graphql
|
|
1631
|
+
query {
|
|
1632
|
+
series_aggregate(
|
|
1633
|
+
aggregation: {
|
|
1634
|
+
groupId: "category"
|
|
1635
|
+
facts: [
|
|
1636
|
+
{ operation: COUNT, factName: "total", path: "id" }
|
|
1637
|
+
{ operation: SUM, factName: "totalEpisodes", path: "episodeCount" }
|
|
1638
|
+
{ operation: AVG, factName: "avgRating", path: "rating" }
|
|
1639
|
+
{ operation: MIN, factName: "minRating", path: "rating" }
|
|
1640
|
+
{ operation: MAX, factName: "maxRating", path: "rating" }
|
|
1641
|
+
]
|
|
1642
|
+
}
|
|
1643
|
+
) {
|
|
1644
|
+
groupId
|
|
1645
|
+
facts
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
```
|
|
1649
|
+
|
|
1650
|
+
#### With Filtering
|
|
1651
|
+
```graphql
|
|
1652
|
+
query {
|
|
1653
|
+
series_aggregate(
|
|
1654
|
+
rating: { operator: GTE, value: 8.0 }
|
|
1655
|
+
aggregation: {
|
|
1656
|
+
groupId: "category"
|
|
1657
|
+
facts: [
|
|
1658
|
+
{ operation: COUNT, factName: "highRated", path: "id" }
|
|
1659
|
+
]
|
|
1660
|
+
}
|
|
1661
|
+
) {
|
|
1662
|
+
groupId
|
|
1663
|
+
facts
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
```
|
|
1667
|
+
|
|
1668
|
+
#### Sorting by Multiple Fields
|
|
1669
|
+
```graphql
|
|
1670
|
+
query {
|
|
1671
|
+
series_aggregate(
|
|
1672
|
+
sort: {
|
|
1673
|
+
terms: [
|
|
1674
|
+
{ field: "total", order: "DESC" }, # Sort by count first
|
|
1675
|
+
{ field: "groupId", order: "ASC" } # Then by name
|
|
1676
|
+
]
|
|
1677
|
+
}
|
|
1678
|
+
aggregation: {
|
|
1679
|
+
groupId: "category"
|
|
1680
|
+
facts: [
|
|
1681
|
+
{ operation: COUNT, factName: "total", path: "id" }
|
|
1682
|
+
{ operation: AVG, factName: "avgRating", path: "rating" }
|
|
1683
|
+
]
|
|
1684
|
+
}
|
|
1685
|
+
) {
|
|
1686
|
+
groupId
|
|
1687
|
+
facts
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
```
|
|
1691
|
+
|
|
1692
|
+
#### With Pagination (Top 5)
|
|
1693
|
+
```graphql
|
|
1694
|
+
query {
|
|
1695
|
+
series_aggregate(
|
|
1696
|
+
sort: {
|
|
1697
|
+
terms: [{ field: "total", order: "DESC" }]
|
|
1698
|
+
}
|
|
1699
|
+
pagination: {
|
|
1700
|
+
page: 1
|
|
1701
|
+
size: 5
|
|
1702
|
+
}
|
|
1703
|
+
aggregation: {
|
|
1704
|
+
groupId: "category"
|
|
1705
|
+
facts: [
|
|
1706
|
+
{ operation: COUNT, factName: "total", path: "id" }
|
|
1707
|
+
]
|
|
1708
|
+
}
|
|
1709
|
+
) {
|
|
1710
|
+
groupId
|
|
1711
|
+
facts
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
```
|
|
1715
|
+
|
|
1716
|
+
### Field Path Resolution
|
|
1717
|
+
|
|
1718
|
+
The `groupId` and `path` parameters support:
|
|
1719
|
+
|
|
1720
|
+
1. **Direct Fields**: Simple field names from the entity
|
|
1721
|
+
- Example: `"category"`, `"rating"`, `"id"`
|
|
1722
|
+
|
|
1723
|
+
2. **Related Entity Fields**: Dot notation for fields in related entities
|
|
1724
|
+
- Example: `"country.name"`, `"studio.foundedYear"`
|
|
1725
|
+
|
|
1726
|
+
3. **Nested Related Entities**: Multiple levels of relationships
|
|
1727
|
+
- Example: `"country.region.name"`
|
|
1728
|
+
|
|
1729
|
+
### Sorting Options
|
|
1730
|
+
|
|
1731
|
+
- Sort by **groupId** or **any fact name**
|
|
1732
|
+
- **Multiple sort fields supported** - results are sorted by the first field, then by the second field for ties, etc.
|
|
1733
|
+
- Set the `field` parameter to:
|
|
1734
|
+
- `"groupId"` to sort by the grouping field
|
|
1735
|
+
- Any fact name (e.g., `"avgRating"`, `"total"`) to sort by that calculated metric
|
|
1736
|
+
- The `order` parameter (ASC/DESC) determines the sort direction for each field
|
|
1737
|
+
- If a field doesn't match groupId or any fact name, it defaults to groupId
|
|
1738
|
+
- If no sort is specified, defaults to sorting by groupId ascending
|
|
1739
|
+
|
|
1740
|
+
### Pagination Notes
|
|
1741
|
+
|
|
1742
|
+
- The `page` and `size` parameters work as expected
|
|
1743
|
+
- The `count` parameter is **ignored** for aggregation queries
|
|
1744
|
+
- Pagination is applied **after** grouping and sorting
|
|
1745
|
+
|
|
1746
|
+
### MongoDB Translation
|
|
1747
|
+
|
|
1748
|
+
Aggregation queries are translated to efficient MongoDB aggregation pipelines:
|
|
1749
|
+
|
|
1750
|
+
1. **$lookup**: Joins with related entity collections
|
|
1751
|
+
2. **$unwind**: Flattens joined arrays
|
|
1752
|
+
3. **$match**: Applies filters (before grouping)
|
|
1753
|
+
4. **$group**: Groups by the specified field with aggregation operations
|
|
1754
|
+
5. **$project**: Formats final output with groupId and facts fields
|
|
1755
|
+
6. **$sort**: Sorts results by groupId or facts (with multiple fields support)
|
|
1756
|
+
7. **$limit** / **$skip**: Applied for pagination (after sorting)
|
|
1757
|
+
|
|
1758
|
+
### Result Structure
|
|
1759
|
+
|
|
1760
|
+
Results are returned in a consistent format:
|
|
1761
|
+
```json
|
|
1762
|
+
{
|
|
1763
|
+
"groupId": <value>,
|
|
1764
|
+
"facts": {
|
|
1765
|
+
"factName1": <calculated_value>,
|
|
1766
|
+
"factName2": <calculated_value>
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
```
|
|
1770
|
+
|
|
1771
|
+
For complete documentation with more examples, see [AGGREGATION_EXAMPLE.md](./AGGREGATION_EXAMPLE.md) and [AGGREGATION_CHANGES_SUMMARY.md](./AGGREGATION_CHANGES_SUMMARY.md).
|
|
1772
|
+
|
|
1361
1773
|
## 📚 Complete Example
|
|
1362
1774
|
|
|
1363
1775
|
Here's a complete bookstore example with relationships, validations, and state machines:
|