mirror of
https://github.com/strapi/strapi.git
synced 2025-09-18 21:08:54 +00:00
Implement Graphql Aggregation Feature
This commit is contained in:
parent
8c7e31f1c3
commit
f0af912825
@ -17,6 +17,424 @@ const GraphQLJSON = require('graphql-type-json');
|
||||
const policyUtils = require('strapi-utils').policy;
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* Returns all fields of type primitive
|
||||
*
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
isPrimitiveType: (_type) => {
|
||||
const type = _type.replace('!', '');
|
||||
return (
|
||||
type === 'Int' ||
|
||||
type === 'Float' ||
|
||||
type === 'String' ||
|
||||
type === 'Boolean'
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns all fields that are not of type array
|
||||
*
|
||||
* @returns {Boolean}
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* isNotOfTypeArray([String])
|
||||
* // => false
|
||||
* isNotOfTypeArray(String!)
|
||||
* // => true
|
||||
*/
|
||||
isNotOfTypeArray: (_type) => {
|
||||
const arrayRegexp = /(\[\w+\])/;
|
||||
const type = _type.replace('!', '');
|
||||
return !arrayRegexp.test(type);
|
||||
},
|
||||
|
||||
/**
|
||||
* Convert non-primitive type to string (non-primitive types corresponds to a reference to an other model)
|
||||
*
|
||||
* @returns {String}
|
||||
*/
|
||||
extractType: function (_type) {
|
||||
return this.isPrimitiveType(_type)
|
||||
? _type.replace('!', '')
|
||||
: 'ID';
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns a list of fields that have type included in fieldTypes.
|
||||
*/
|
||||
getFieldsByTypes: (fields, typeCheck, returnType) => {
|
||||
return _.reduce(fields, (acc, fieldType, fieldName) => {
|
||||
if (typeCheck(fieldType)) {
|
||||
acc[fieldName] = returnType(fieldType, fieldName);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
},
|
||||
|
||||
/**
|
||||
* Use the field resolver otherwise fall through the field value
|
||||
*
|
||||
* @returns {function}
|
||||
*/
|
||||
fieldResolver: (field, key) => {
|
||||
return (object) => {
|
||||
const resolver = field.resolve || function resolver(obj, options, context) { // eslint-disable-line no-unused-vars
|
||||
return obj[key];
|
||||
};
|
||||
return resolver(object);
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Create fields resolvers
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
createFieldsResolver: function(fields, resolver, typeCheck) {
|
||||
return _.reduce(fields, (acc, field, key) => {
|
||||
// Check if the field is of the correct type
|
||||
if (typeCheck(field)) {
|
||||
return _.set(acc, key, (obj, options, context) => {
|
||||
return resolver(obj, options, context, this.fieldResolver(field, key), key, obj, field);
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
},
|
||||
|
||||
/**
|
||||
* Build the mongoose aggregator by applying the filters
|
||||
*/
|
||||
getModelAggregator: function (model, filters = {}) {
|
||||
const aggregation = model.aggregate();
|
||||
if (!_.isEmpty(filters.where)) {
|
||||
aggregation.match(filters.where);
|
||||
}
|
||||
if (filters.limit) {
|
||||
aggregation.limit(filters.limit);
|
||||
}
|
||||
return aggregation;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create the resolvers for each aggregation field
|
||||
*
|
||||
* @return {Object}
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* const model = // Strapi model
|
||||
*
|
||||
* const fields = {
|
||||
* username: String,
|
||||
* age: Int,
|
||||
* }
|
||||
*
|
||||
* const typeCheck = (type) => type === 'Int' || type === 'Float',
|
||||
*
|
||||
* const fieldsResoler = createAggregationFieldsResolver(model, fields, 'sum', typeCheck);
|
||||
*
|
||||
* // => {
|
||||
* age: function ageResolver() { .... }
|
||||
* }
|
||||
*/
|
||||
createAggregationFieldsResolver: function (model, fields, operation, typeCheck) {
|
||||
return this.createFieldsResolver(fields, async (obj, options, context, fieldResolver, fieldKey) => { // eslint-disable-line no-unused-vars
|
||||
const result = await this.getModelAggregator(model, obj).group({
|
||||
_id: null,
|
||||
[fieldKey]: { [`$${operation}`]: `$${fieldKey}` }
|
||||
});
|
||||
return _.get(result, `0.${fieldKey}`);
|
||||
}, typeCheck);
|
||||
},
|
||||
|
||||
/**
|
||||
* Correctly format the data returned by the group by
|
||||
*/
|
||||
preProcessGroupByData: function ({ result, fieldKey, filters, modelName }) {
|
||||
const _result = _.toArray(result);
|
||||
return _.map(_result, (value) => {
|
||||
const params = Object.assign(
|
||||
{},
|
||||
this.convertToParams(_.omit(filters, 'where')),
|
||||
filters.where,
|
||||
{
|
||||
[fieldKey]: value._id,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
key: value._id,
|
||||
connection: strapi.utils.models.convertParams(modelName, params),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Create the resolvers for each group by field
|
||||
*
|
||||
* @return {Object}
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* const model = // Strapi model
|
||||
* const fields = {
|
||||
* username: [UserConnectionUsername],
|
||||
* email: [UserConnectionEmail],
|
||||
* }
|
||||
* const fieldsResoler = createGroupByFieldsResolver(model, fields);
|
||||
*
|
||||
* // => {
|
||||
* username: function usernameResolver() { .... }
|
||||
* email: function emailResolver() { .... }
|
||||
* }
|
||||
*/
|
||||
createGroupByFieldsResolver: function (model, fields, name) {
|
||||
return this.createFieldsResolver(fields, async (obj, options, context, fieldResolver, fieldKey) => {
|
||||
const result = await this.getModelAggregator(model, obj).group({
|
||||
_id: `$${fieldKey}`,
|
||||
});
|
||||
|
||||
return this.preProcessGroupByData({
|
||||
result,
|
||||
fieldKey,
|
||||
filters: obj,
|
||||
modelName: name,
|
||||
});
|
||||
}, () => true);
|
||||
},
|
||||
|
||||
/**
|
||||
* This method is the entry point to the GraphQL's Aggregation.
|
||||
* It takes as param the model and its fields and it'll create the aggregation types and resolver to it
|
||||
* Example:
|
||||
* type User {
|
||||
* username: String,
|
||||
* age: Int,
|
||||
* }
|
||||
*
|
||||
* It'll create
|
||||
* type UserConnection {
|
||||
* values: [User],
|
||||
* groupBy: UserGroupBy,
|
||||
* aggreate: UserAggregate
|
||||
* }
|
||||
*
|
||||
* type UserAggregate {
|
||||
* count: Int
|
||||
* sum: UserAggregateSum
|
||||
* avg: UserAggregateAvg
|
||||
* }
|
||||
*
|
||||
* type UserAggregateSum {
|
||||
* age: Float
|
||||
* }
|
||||
*
|
||||
* type UserGroupBy {
|
||||
* username: [UserConnectionUsername]
|
||||
* age: [UserConnectionAge]
|
||||
* }
|
||||
*
|
||||
* type UserConnectionUsername {
|
||||
* key: String
|
||||
* connection: UserConnection
|
||||
* }
|
||||
*
|
||||
* type UserConnectionAge {
|
||||
* key: Int
|
||||
* connection: UserConnection
|
||||
* }
|
||||
*
|
||||
*/
|
||||
formatModelConnectionsGQL: function(fields, model, name, modelResolver) {
|
||||
const { globalId } = model;
|
||||
|
||||
const connectionGlobalId = `${globalId}Connection`;
|
||||
const aggregatorFormat = this.formatConnectionAggregator(fields, model, name);
|
||||
const groupByFormat = this.formatConnectionGroupBy(fields, model, name);
|
||||
const connectionFields = {
|
||||
values: `[${globalId}]`,
|
||||
groupBy: `${globalId}GroupBy`,
|
||||
aggregate: `${globalId}Aggregator`,
|
||||
};
|
||||
|
||||
let modelConnectionTypes = `type ${connectionGlobalId} {${this.formatGQL(connectionFields)}}\n\n`;
|
||||
if (aggregatorFormat) {
|
||||
modelConnectionTypes += aggregatorFormat.type;
|
||||
}
|
||||
modelConnectionTypes += groupByFormat.type;
|
||||
|
||||
return {
|
||||
globalId: connectionGlobalId,
|
||||
type: modelConnectionTypes,
|
||||
query: {
|
||||
[`${pluralize.plural(name)}Connection(sort: String, limit: Int, start: Int, where: JSON)`]: connectionGlobalId,
|
||||
},
|
||||
resolver: {
|
||||
Query: {
|
||||
[`${pluralize.plural(name)}Connection`]: (obj, options, context) => { // eslint-disable-line no-unused-vars
|
||||
const params = Object.assign(
|
||||
{},
|
||||
this.convertToParams(_.omit(options, 'where')),
|
||||
options.where
|
||||
);
|
||||
return strapi.utils.models.convertParams(name, params);
|
||||
}
|
||||
},
|
||||
[connectionGlobalId]: {
|
||||
values: (obj, option, context) => {
|
||||
// Object here contains the key/value of the field that has been grouped-by
|
||||
// for instance obj = { where: { country: 'USA' } } so the values here needs to be filtered according to the parent value
|
||||
return modelResolver(obj, obj, context);
|
||||
},
|
||||
groupBy: (obj, option, context) => { // eslint-disable-line no-unused-vars
|
||||
// There is noting to resolve here, it's the aggregation resolver that will take care of it
|
||||
return obj;
|
||||
},
|
||||
aggregate: (obj, option, context) => { // eslint-disable-line no-unused-vars
|
||||
// There is noting to resolve here, it's the aggregation resolver that will take care of it
|
||||
return obj;
|
||||
},
|
||||
},
|
||||
...aggregatorFormat.resolver,
|
||||
...groupByFormat.resolver,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate the connection type of each non-array field of the model
|
||||
*
|
||||
* @return {String}
|
||||
*/
|
||||
generateConnectionFieldsTypes: function (fields, model) {
|
||||
const { globalId } = model;
|
||||
|
||||
const primitiveFields = this.getFieldsByTypes(
|
||||
fields,
|
||||
this.isNotOfTypeArray,
|
||||
type => this.extractType(type),
|
||||
);
|
||||
|
||||
const connectionFields = _.mapValues(primitiveFields, (fieldType) => ({
|
||||
key: fieldType,
|
||||
connection: `${globalId}Connection`,
|
||||
}));
|
||||
|
||||
let connectionFieldsTypes = '';
|
||||
_.forEach(primitiveFields, (fieldValue, fieldKey) => {
|
||||
connectionFieldsTypes += `type ${globalId}Connection${_.upperFirst(fieldKey)} {${this.formatGQL(connectionFields[fieldKey])}}\n\n`;
|
||||
});
|
||||
|
||||
return connectionFieldsTypes;
|
||||
},
|
||||
|
||||
formatConnectionGroupBy: function(fields, model, name) {
|
||||
const { globalId } = model;
|
||||
const groupByGlobalId = `${globalId}GroupBy`;
|
||||
|
||||
// Extract all primitive fields and change their types
|
||||
const groupByFields = this.getFieldsByTypes(
|
||||
fields,
|
||||
this.isNotOfTypeArray,
|
||||
(fieldType, fieldName) => `[${globalId}Connection${_.upperFirst(fieldName)}]`,
|
||||
);
|
||||
|
||||
// Get the generated field types
|
||||
let groupByTypes = `type ${groupByGlobalId} {${this.formatGQL(groupByFields)}}\n\n`;
|
||||
groupByTypes += this.generateConnectionFieldsTypes(fields, model);
|
||||
|
||||
return {
|
||||
globalId: groupByGlobalId,
|
||||
type: groupByTypes,
|
||||
resolver: {
|
||||
[groupByGlobalId]: this.createGroupByFieldsResolver(model, groupByFields, name),
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
formatConnectionAggregator: function(fields, model) {
|
||||
const { globalId } = model;
|
||||
|
||||
// Extract all fields of type Integer and Float and change their type to Float
|
||||
const numericFields = this.getFieldsByTypes(fields, (fieldType) => {
|
||||
return fieldType === 'Int' || fieldType === 'Float';
|
||||
}, () => 'Float');
|
||||
|
||||
// Don't create an aggregator field if the model has not number fields
|
||||
const aggregatorGlobalId = `${globalId}Aggregator`;
|
||||
const initialFields = {
|
||||
count: 'Int',
|
||||
};
|
||||
|
||||
// Only add the aggregator's operations if there are some numeric fields
|
||||
if (!_.isEmpty(numericFields)) {
|
||||
['sum', 'avg', 'min', 'max'].forEach((agg) => {
|
||||
initialFields[agg] = `${aggregatorGlobalId}${_.startCase(agg)}`;
|
||||
});
|
||||
}
|
||||
|
||||
const gqlNumberFormat = this.formatGQL(numericFields);
|
||||
let aggregatorTypes = `type ${aggregatorGlobalId} {${this.formatGQL(initialFields)}}\n\n`;
|
||||
|
||||
let resolvers = {
|
||||
[aggregatorGlobalId]: {
|
||||
count: async (obj, options, context) => { // eslint-disable-line no-unused-vars
|
||||
// Object here corresponds to the filter that needs to be applied to the aggregation
|
||||
const result = await this.getModelAggregator(model, obj).group({
|
||||
_id: null,
|
||||
count: { $sum: 1 }
|
||||
});
|
||||
|
||||
return _.get(result, '0.count');
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
// Only add the aggregator's operations types and resolver if there are some numeric fields
|
||||
if (!_.isEmpty(numericFields)) {
|
||||
// Returns the actual object and handle aggregation in the query resolvers
|
||||
const defaultAggregatorFunc = (obj, options, context) => { // eslint-disable-line no-unused-vars
|
||||
return obj;
|
||||
};
|
||||
|
||||
aggregatorTypes += `type ${aggregatorGlobalId}Sum {${gqlNumberFormat}}\n\n`;
|
||||
aggregatorTypes += `type ${aggregatorGlobalId}Avg {${gqlNumberFormat}}\n\n`;
|
||||
aggregatorTypes += `type ${aggregatorGlobalId}Min {${gqlNumberFormat}}\n\n`;
|
||||
aggregatorTypes += `type ${aggregatorGlobalId}Max {${gqlNumberFormat}}\n\n`;
|
||||
|
||||
_.merge(resolvers[aggregatorGlobalId], {
|
||||
sum: defaultAggregatorFunc,
|
||||
avg: defaultAggregatorFunc,
|
||||
min: defaultAggregatorFunc,
|
||||
max: defaultAggregatorFunc,
|
||||
});
|
||||
|
||||
resolvers = {
|
||||
...resolvers,
|
||||
[`${aggregatorGlobalId}Sum`]: this.createAggregationFieldsResolver(model, fields, 'sum', (fieldType) => {
|
||||
return fieldType === 'Int' || fieldType === 'Float';
|
||||
}),
|
||||
[`${aggregatorGlobalId}Avg`]: this.createAggregationFieldsResolver(model, fields, 'avg', (fieldType) => {
|
||||
return fieldType === 'Int' || fieldType === 'Float';
|
||||
}),
|
||||
[`${aggregatorGlobalId}Min`]: this.createAggregationFieldsResolver(model, fields, 'min', (fieldType) => {
|
||||
return fieldType === 'Int' || fieldType === 'Float';
|
||||
}),
|
||||
[`${aggregatorGlobalId}Max`]: this.createAggregationFieldsResolver(model, fields, 'max', (fieldType) => {
|
||||
return fieldType === 'Int' || fieldType === 'Float';
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
globalId: aggregatorGlobalId,
|
||||
type: aggregatorTypes,
|
||||
resolver: resolvers,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Receive an Object and return a string which is following the GraphQL specs.
|
||||
@ -554,6 +972,18 @@ module.exports = {
|
||||
}
|
||||
});
|
||||
|
||||
// Generation the aggregation for the given model
|
||||
const modelAggregator = this.formatModelConnectionsGQL(attributes, model, name, queries.plural);
|
||||
if (modelAggregator) {
|
||||
acc.definition += modelAggregator.type;
|
||||
if (!acc.resolver[modelAggregator.globalId]) {
|
||||
acc.resolver[modelAggregator.globalId] = {};
|
||||
}
|
||||
|
||||
_.merge(acc.resolver, modelAggregator.resolver);
|
||||
_.merge(acc.query, modelAggregator.query);
|
||||
}
|
||||
|
||||
// Build associations queries.
|
||||
(model.associations || []).forEach(association => {
|
||||
switch (association.nature) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user