@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/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
  }
@@ -1733,6 +2075,9 @@ export const addNoEndpointType = (gqltype) => {
1733
2075
 
1734
2076
  export { createValidatedScalar };
1735
2077
 
2078
+ export { default as validators } from './validators.js';
2079
+ export { default as scalars } from './scalars.js';
2080
+
1736
2081
  const createArgsForQuery = (argTypes) => {
1737
2082
  const argsObject = {};
1738
2083
 
package/src/scalars.js ADDED
@@ -0,0 +1,188 @@
1
+ import {
2
+ GraphQLString, GraphQLInt, GraphQLFloat,
3
+ } from 'graphql';
4
+ import { createValidatedScalar } from './index.js';
5
+
6
+ /**
7
+ * Email scalar - validates email format
8
+ * Type name: Email_String
9
+ */
10
+ export const EmailScalar = createValidatedScalar(
11
+ 'Email',
12
+ 'A valid email address',
13
+ GraphQLString,
14
+ (value) => {
15
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
16
+ if (!emailRegex.test(value)) {
17
+ throw new Error('Invalid email format');
18
+ }
19
+ },
20
+ );
21
+
22
+ /**
23
+ * URL scalar - validates URL format
24
+ * Type name: URL_String
25
+ */
26
+ export const URLScalar = createValidatedScalar(
27
+ 'URL',
28
+ 'A valid URL',
29
+ GraphQLString,
30
+ (value) => {
31
+ try {
32
+ new URL(value);
33
+ } catch {
34
+ throw new Error('Invalid URL format');
35
+ }
36
+ },
37
+ );
38
+
39
+ /**
40
+ * PositiveInt scalar - validates positive integers
41
+ * Type name: PositiveInt_Int
42
+ */
43
+ export const PositiveIntScalar = createValidatedScalar(
44
+ 'PositiveInt',
45
+ 'A positive integer',
46
+ GraphQLInt,
47
+ (value) => {
48
+ if (value <= 0) {
49
+ throw new Error('Value must be positive');
50
+ }
51
+ },
52
+ );
53
+
54
+ /**
55
+ * PositiveFloat scalar - validates positive floats
56
+ * Type name: PositiveFloat_Float
57
+ */
58
+ export const PositiveFloatScalar = createValidatedScalar(
59
+ 'PositiveFloat',
60
+ 'A positive float',
61
+ GraphQLFloat,
62
+ (value) => {
63
+ if (value <= 0) {
64
+ throw new Error('Value must be positive');
65
+ }
66
+ },
67
+ );
68
+
69
+ /**
70
+ * Factory function to create a bounded string scalar
71
+ * @param {string} name - Name for the scalar
72
+ * @param {number} min - Minimum length
73
+ * @param {number} max - Maximum length
74
+ * @returns {GraphQLScalarType} A scalar type with length validation
75
+ */
76
+ export const createBoundedStringScalar = (name, min, max) => {
77
+ return createValidatedScalar(
78
+ name,
79
+ `A string with length between ${min} and ${max} characters`,
80
+ GraphQLString,
81
+ (value) => {
82
+ if (typeof value !== 'string') {
83
+ throw new Error('Value must be a string');
84
+ }
85
+ if (min !== undefined && value.length < min) {
86
+ throw new Error(`String must be at least ${min} characters`);
87
+ }
88
+ if (max !== undefined && value.length > max) {
89
+ throw new Error(`String must be at most ${max} characters`);
90
+ }
91
+ },
92
+ );
93
+ };
94
+
95
+ /**
96
+ * Factory function to create a bounded integer scalar
97
+ * @param {string} name - Name for the scalar
98
+ * @param {number} min - Minimum value
99
+ * @param {number} max - Maximum value
100
+ * @returns {GraphQLScalarType} A scalar type with range validation
101
+ */
102
+ export const createBoundedIntScalar = (name, min, max) => {
103
+ return createValidatedScalar(
104
+ name,
105
+ `An integer between ${min} and ${max}`,
106
+ GraphQLInt,
107
+ (value) => {
108
+ if (typeof value !== 'number' || isNaN(value)) {
109
+ throw new Error('Value must be a number');
110
+ }
111
+ if (min !== undefined && value < min) {
112
+ throw new Error(`Value must be at least ${min}`);
113
+ }
114
+ if (max !== undefined && value > max) {
115
+ throw new Error(`Value must be at most ${max}`);
116
+ }
117
+ },
118
+ );
119
+ };
120
+
121
+ /**
122
+ * Factory function to create a bounded float scalar
123
+ * @param {string} name - Name for the scalar
124
+ * @param {number} min - Minimum value
125
+ * @param {number} max - Maximum value
126
+ * @returns {GraphQLScalarType} A scalar type with range validation
127
+ */
128
+ export const createBoundedFloatScalar = (name, min, max) => {
129
+ return createValidatedScalar(
130
+ name,
131
+ `A float between ${min} and ${max}`,
132
+ GraphQLFloat,
133
+ (value) => {
134
+ if (typeof value !== 'number' || isNaN(value)) {
135
+ throw new Error('Value must be a number');
136
+ }
137
+ if (min !== undefined && value < min) {
138
+ throw new Error(`Value must be at least ${min}`);
139
+ }
140
+ if (max !== undefined && value > max) {
141
+ throw new Error(`Value must be at most ${max}`);
142
+ }
143
+ },
144
+ );
145
+ };
146
+
147
+ /**
148
+ * Factory function to create a regex pattern string scalar
149
+ * @param {string} name - Name for the scalar
150
+ * @param {RegExp|string} pattern - Regex pattern to validate against
151
+ * @param {string} message - Error message if validation fails
152
+ * @returns {GraphQLScalarType} A scalar type with pattern validation
153
+ */
154
+ export const createPatternStringScalar = (name, pattern, message) => {
155
+ const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
156
+ const errorMessage = message || 'Value does not match required pattern';
157
+
158
+ return createValidatedScalar(
159
+ name,
160
+ `A string matching the pattern: ${pattern}`,
161
+ GraphQLString,
162
+ (value) => {
163
+ if (typeof value !== 'string') {
164
+ throw new Error('Value must be a string');
165
+ }
166
+ if (!regex.test(value)) {
167
+ throw new Error(errorMessage);
168
+ }
169
+ },
170
+ );
171
+ };
172
+
173
+ // Export all scalars as an object for convenience
174
+ const scalars = {
175
+ // Pre-built scalars
176
+ EmailScalar,
177
+ URLScalar,
178
+ PositiveIntScalar,
179
+ PositiveFloatScalar,
180
+ // Factory functions
181
+ createBoundedStringScalar,
182
+ createBoundedIntScalar,
183
+ createBoundedFloatScalar,
184
+ createPatternStringScalar,
185
+ };
186
+
187
+ export default scalars;
188
+