mirror of
https://github.com/strapi/strapi.git
synced 2025-07-16 05:22:05 +00:00
613 lines
17 KiB
JavaScript
613 lines
17 KiB
JavaScript
'use strict';
|
|
|
|
const _ = require('lodash');
|
|
const { contentTypes } = require('@strapi/utils');
|
|
|
|
const {
|
|
hasDraftAndPublish,
|
|
constants: { DP_PUB_STATE_LIVE },
|
|
} = contentTypes;
|
|
|
|
const DynamicZoneScalar = require('../types/dynamiczoneScalar');
|
|
|
|
const { formatModelConnectionsGQL } = require('./build-aggregation');
|
|
const types = require('./type-builder');
|
|
const {
|
|
actionExists,
|
|
mergeSchemas,
|
|
convertToParams,
|
|
convertToQuery,
|
|
amountLimiting,
|
|
createDefaultSchema,
|
|
} = require('./utils');
|
|
const { toSDL, getTypeDescription } = require('./schema-definitions');
|
|
const { toSingular, toPlural } = require('./naming');
|
|
const { buildQuery, buildMutation } = require('./resolvers-builder');
|
|
|
|
const OPTIONS = Symbol();
|
|
|
|
const FIND_QUERY_ARGUMENTS = {
|
|
sort: 'String',
|
|
limit: 'Int',
|
|
start: 'Int',
|
|
where: 'JSON',
|
|
publicationState: 'PublicationState',
|
|
};
|
|
|
|
const FIND_ONE_QUERY_ARGUMENTS = {
|
|
id: 'ID!',
|
|
publicationState: 'PublicationState',
|
|
};
|
|
|
|
/**
|
|
* Builds a graphql schema from all the contentTypes & components loaded
|
|
* @param {{ schema: object }} ctx
|
|
* @returns {object}
|
|
*/
|
|
const buildShadowCrud = ctx => {
|
|
const models = Object.values(strapi.contentTypes).filter(model => model.plugin !== 'admin');
|
|
const components = Object.values(strapi.components);
|
|
|
|
const allSchemas = buildModels([...models, ...components], ctx);
|
|
|
|
return mergeSchemas(createDefaultSchema(), ...allSchemas);
|
|
};
|
|
|
|
const assignOptions = (element, parent) => {
|
|
if (Array.isArray(element)) {
|
|
return element.map(el => assignOptions(el, parent));
|
|
}
|
|
|
|
return _.set(element, OPTIONS, _.get(parent, OPTIONS, {}));
|
|
};
|
|
|
|
const isQueryEnabled = (schema, name) => {
|
|
return _.get(schema, `resolver.Query.${name}`) !== false;
|
|
};
|
|
|
|
const getQueryInfo = (schema, name) => {
|
|
return _.get(schema, `resolver.Query.${name}`, {});
|
|
};
|
|
|
|
const isMutationEnabled = (schema, name) => {
|
|
return _.get(schema, `resolver.Mutation.${name}`) !== false;
|
|
};
|
|
|
|
const getMutationInfo = (schema, name) => {
|
|
return _.get(schema, `resolver.Mutation.${name}`, {});
|
|
};
|
|
|
|
const isTypeAttributeEnabled = (model, attr) =>
|
|
_.get(strapi.plugins.graphql, `config._schema.graphql.type.${model.globalId}.${attr}`) !== false;
|
|
const isNotPrivate = _.curry((model, attributeName) => {
|
|
return !contentTypes.isPrivateAttribute(model, attributeName);
|
|
});
|
|
|
|
const wrapPublicationStateResolver = query => async (parent, args, ctx, ast) => {
|
|
const results = await query(parent, args, ctx, ast);
|
|
|
|
const queryOptions = _.pick(args, 'publicationState');
|
|
return assignOptions(results, { [OPTIONS]: queryOptions });
|
|
};
|
|
|
|
const buildTypeDefObj = model => {
|
|
const { associations = [], attributes, primaryKey, globalId } = model;
|
|
|
|
const typeDef = {
|
|
id: 'ID!',
|
|
[primaryKey]: 'ID!',
|
|
};
|
|
|
|
// Add timestamps attributes.
|
|
if (_.isArray(_.get(model, 'options.timestamps'))) {
|
|
const [createdAtKey, updatedAtKey] = model.options.timestamps;
|
|
typeDef[createdAtKey] = 'DateTime!';
|
|
typeDef[updatedAtKey] = 'DateTime!';
|
|
}
|
|
|
|
Object.keys(attributes)
|
|
.filter(isNotPrivate(model))
|
|
.filter(attributeName => isTypeAttributeEnabled(model, attributeName))
|
|
.forEach(attributeName => {
|
|
const attribute = attributes[attributeName];
|
|
// Convert our type to the GraphQL type.
|
|
typeDef[attributeName] = types.convertType({
|
|
attribute,
|
|
modelName: globalId,
|
|
attributeName,
|
|
});
|
|
});
|
|
|
|
// Change field definition for collection relations
|
|
associations
|
|
.filter(association => association.type === 'collection')
|
|
.filter(association => isNotPrivate(model, association.alias))
|
|
.filter(attributeName => isTypeAttributeEnabled(model, attributeName))
|
|
.forEach(association => {
|
|
typeDef[`${association.alias}(sort: String, limit: Int, start: Int, where: JSON)`] =
|
|
typeDef[association.alias];
|
|
|
|
delete typeDef[association.alias];
|
|
});
|
|
|
|
return typeDef;
|
|
};
|
|
|
|
const generateEnumDefinitions = (model, globalId) => {
|
|
const { attributes } = model;
|
|
return Object.keys(attributes)
|
|
.filter(attribute => attributes[attribute].type === 'enumeration')
|
|
.filter(attribute => isTypeAttributeEnabled(model, attribute))
|
|
.map(attribute => {
|
|
const definition = attributes[attribute];
|
|
|
|
const name = types.convertEnumType(definition, globalId, attribute);
|
|
const values = definition.enum.map(v => `\t${v}`).join('\n');
|
|
return `enum ${name} {\n${values}\n}\n`;
|
|
})
|
|
.join('');
|
|
};
|
|
|
|
const generateDynamicZoneDefinitions = (attributes, globalId, schema) => {
|
|
Object.keys(attributes)
|
|
.filter(attribute => attributes[attribute].type === 'dynamiczone')
|
|
.forEach(attribute => {
|
|
const { components } = attributes[attribute];
|
|
|
|
const typeName = `${globalId}${_.upperFirst(_.camelCase(attribute))}DynamicZone`;
|
|
|
|
if (components.length === 0) {
|
|
// Create dummy type because graphql doesn't support empty ones
|
|
schema.definition += `type ${typeName} { _:Boolean}`;
|
|
} else {
|
|
const componentsTypeNames = components.map(componentUID => {
|
|
const compo = strapi.components[componentUID];
|
|
if (!compo) {
|
|
throw new Error(
|
|
`Trying to creating dynamiczone type with unkown component ${componentUID}`
|
|
);
|
|
}
|
|
|
|
return compo.globalId;
|
|
});
|
|
|
|
const unionType = `union ${typeName} = ${componentsTypeNames.join(' | ')}`;
|
|
|
|
schema.definition += `\n${unionType}\n`;
|
|
}
|
|
|
|
const inputTypeName = `${typeName}Input`;
|
|
schema.definition += `\nscalar ${inputTypeName}\n`;
|
|
|
|
schema.resolvers[typeName] = {
|
|
__resolveType(obj) {
|
|
return strapi.components[obj.__component].globalId;
|
|
},
|
|
};
|
|
|
|
schema.resolvers[inputTypeName] = new DynamicZoneScalar({
|
|
name: inputTypeName,
|
|
attribute,
|
|
globalId,
|
|
components,
|
|
});
|
|
});
|
|
};
|
|
|
|
const initQueryOptions = (targetModel, parent) => {
|
|
if (hasDraftAndPublish(targetModel)) {
|
|
return {
|
|
_publicationState: _.get(parent, [OPTIONS, 'publicationState'], DP_PUB_STATE_LIVE),
|
|
};
|
|
}
|
|
|
|
return {};
|
|
};
|
|
|
|
const buildAssocResolvers = model => {
|
|
const { primaryKey, associations = [] } = model;
|
|
|
|
return associations
|
|
.filter(association => isNotPrivate(model, association.alias))
|
|
.filter(association => isTypeAttributeEnabled(model, association.alias))
|
|
.reduce((resolver, association) => {
|
|
const target = association.model || association.collection;
|
|
const targetModel = strapi.getModel(target, association.plugin);
|
|
|
|
const { nature, alias } = association;
|
|
|
|
switch (nature) {
|
|
case 'oneToManyMorph':
|
|
case 'manyMorphToOne':
|
|
case 'manyMorphToMany':
|
|
case 'manyToManyMorph': {
|
|
resolver[alias] = async obj => {
|
|
if (obj[alias]) {
|
|
return assignOptions(obj[alias], obj);
|
|
}
|
|
|
|
const params = {
|
|
...initQueryOptions(targetModel, obj),
|
|
id: obj[primaryKey],
|
|
};
|
|
|
|
const entry = await strapi.query(model.uid).findOne(params, [alias]);
|
|
|
|
return assignOptions(entry[alias], obj);
|
|
};
|
|
break;
|
|
}
|
|
default: {
|
|
resolver[alias] = async (obj, options) => {
|
|
// force component relations to be refetched
|
|
if (model.modelType === 'component') {
|
|
obj[alias] = _.get(obj[alias], targetModel.primaryKey, obj[alias]);
|
|
}
|
|
|
|
const loader = strapi.plugins.graphql.services['data-loaders'].loaders[targetModel.uid];
|
|
|
|
const localId = obj[model.primaryKey];
|
|
const targetPK = targetModel.primaryKey;
|
|
const foreignId = _.get(obj[alias], targetModel.primaryKey, obj[alias]);
|
|
|
|
const params = {
|
|
...initQueryOptions(targetModel, obj),
|
|
...convertToParams(_.omit(amountLimiting(options), 'where')),
|
|
...convertToQuery(options.where),
|
|
};
|
|
|
|
if (['oneToOne', 'oneWay', 'manyToOne'].includes(nature)) {
|
|
if (!_.has(obj, alias) || _.isNil(foreignId)) {
|
|
return null;
|
|
}
|
|
|
|
// check this is an entity and not a mongo ID
|
|
if (_.has(obj[alias], targetPK)) {
|
|
return assignOptions(obj[alias], obj);
|
|
}
|
|
|
|
const query = {
|
|
single: true,
|
|
filters: {
|
|
...params,
|
|
[targetPK]: foreignId,
|
|
},
|
|
};
|
|
|
|
return loader.load(query).then(r => assignOptions(r, obj));
|
|
}
|
|
|
|
if (
|
|
nature === 'oneToMany' ||
|
|
(nature === 'manyToMany' && association.dominant !== true)
|
|
) {
|
|
const { via } = association;
|
|
|
|
const filters = {
|
|
...params,
|
|
[via]: localId,
|
|
};
|
|
|
|
return loader.load({ filters }).then(r => assignOptions(r, obj));
|
|
}
|
|
|
|
if (
|
|
nature === 'manyWay' ||
|
|
(nature === 'manyToMany' && association.dominant === true)
|
|
) {
|
|
let targetIds = [];
|
|
|
|
// find the related ids to query them and apply the filters
|
|
if (Array.isArray(obj[alias])) {
|
|
targetIds = obj[alias].map(value => value[targetPK] || value);
|
|
} else {
|
|
const entry = await strapi
|
|
.query(model.uid)
|
|
.findOne({ [primaryKey]: obj[primaryKey] }, [alias]);
|
|
|
|
if (_.isEmpty(entry[alias])) {
|
|
return [];
|
|
}
|
|
|
|
targetIds = entry[alias].map(el => el[targetPK]);
|
|
}
|
|
|
|
const filters = {
|
|
...params,
|
|
[`${targetPK}_in`]: targetIds.map(_.toString),
|
|
};
|
|
|
|
return loader.load({ filters }).then(r => assignOptions(r, obj));
|
|
}
|
|
};
|
|
break;
|
|
}
|
|
}
|
|
|
|
return resolver;
|
|
}, {});
|
|
};
|
|
|
|
/**
|
|
* Construct the GraphQL query & definition and apply the right resolvers.
|
|
*
|
|
* @return Object
|
|
*/
|
|
const buildModels = (models, ctx) => {
|
|
return models.map(model => {
|
|
const { kind, modelType } = model;
|
|
|
|
if (modelType === 'component') {
|
|
return buildComponent(model);
|
|
}
|
|
|
|
switch (kind) {
|
|
case 'singleType':
|
|
return buildSingleType(model, ctx);
|
|
default:
|
|
return buildCollectionType(model, ctx);
|
|
}
|
|
});
|
|
};
|
|
|
|
const buildModelDefinition = (model, globalType = {}) => {
|
|
const { globalId, primaryKey } = model;
|
|
|
|
const typeDefObj = buildTypeDefObj(model);
|
|
|
|
const schema = {
|
|
definition: '',
|
|
query: {},
|
|
mutation: {},
|
|
resolvers: {
|
|
Query: {},
|
|
Mutation: {},
|
|
[globalId]: {
|
|
id: parent => parent[primaryKey] || parent.id,
|
|
...buildAssocResolvers(model),
|
|
},
|
|
},
|
|
typeDefObj,
|
|
};
|
|
|
|
schema.definition += generateEnumDefinitions(model, globalId);
|
|
generateDynamicZoneDefinitions(model.attributes, globalId, schema);
|
|
|
|
const description = getTypeDescription(globalType, model);
|
|
const fields = toSDL(typeDefObj, globalType, model);
|
|
const typeDef = `${description}type ${globalId} {${fields}}\n`;
|
|
|
|
schema.definition += typeDef;
|
|
return schema;
|
|
};
|
|
|
|
const buildComponent = component => {
|
|
const { globalId } = component;
|
|
const schema = buildModelDefinition(component);
|
|
|
|
schema.definition += types.generateInputModel(component, globalId, {
|
|
allowIds: true,
|
|
});
|
|
|
|
return schema;
|
|
};
|
|
|
|
const buildSingleType = (model, ctx) => {
|
|
const { uid, modelName } = model;
|
|
|
|
const singularName = toSingular(modelName);
|
|
|
|
const globalType = _.get(ctx.schema, `type.${model.globalId}`, {});
|
|
|
|
const localSchema = buildModelDefinition(model, globalType);
|
|
|
|
// Add definition to the schema but this type won't be "queriable" or "mutable".
|
|
if (globalType === false) {
|
|
return localSchema;
|
|
}
|
|
|
|
if (isQueryEnabled(ctx.schema, singularName)) {
|
|
const resolverOpts = {
|
|
resolver: `${uid}.find`,
|
|
...getQueryInfo(ctx.schema, singularName),
|
|
};
|
|
|
|
const resolver = buildQuery(singularName, resolverOpts);
|
|
|
|
const query = {
|
|
query: {
|
|
[singularName]: {
|
|
args: {
|
|
publicationState: 'PublicationState',
|
|
...(resolverOpts.args || {}),
|
|
},
|
|
type: model.globalId,
|
|
},
|
|
},
|
|
resolvers: {
|
|
Query: {
|
|
[singularName]: wrapPublicationStateResolver(resolver),
|
|
},
|
|
},
|
|
};
|
|
|
|
_.merge(localSchema, query);
|
|
}
|
|
|
|
// Add model Input definition.
|
|
localSchema.definition += types.generateInputModel(model, modelName);
|
|
|
|
// build every mutation
|
|
['update', 'delete'].forEach(action => {
|
|
const mutationSchema = buildMutationTypeDef({ model, action }, ctx);
|
|
|
|
mergeSchemas(localSchema, mutationSchema);
|
|
});
|
|
|
|
return localSchema;
|
|
};
|
|
|
|
const buildCollectionType = (model, ctx) => {
|
|
const { plugin, modelName, uid } = model;
|
|
|
|
const singularName = toSingular(modelName);
|
|
const pluralName = toPlural(modelName);
|
|
|
|
const globalType = _.get(ctx.schema, `type.${model.globalId}`, {});
|
|
|
|
const localSchema = buildModelDefinition(model, globalType);
|
|
const { typeDefObj } = localSchema;
|
|
|
|
// Add definition to the schema but this type won't be "queriable" or "mutable".
|
|
if (globalType === false) {
|
|
return localSchema;
|
|
}
|
|
|
|
if (isQueryEnabled(ctx.schema, singularName)) {
|
|
const resolverOpts = {
|
|
resolver: `${uid}.findOne`,
|
|
...getQueryInfo(ctx.schema, singularName),
|
|
};
|
|
|
|
if (actionExists(resolverOpts)) {
|
|
const resolver = buildQuery(singularName, resolverOpts);
|
|
|
|
const query = {
|
|
query: {
|
|
[singularName]: {
|
|
args: {
|
|
...FIND_ONE_QUERY_ARGUMENTS,
|
|
...(resolverOpts.args || {}),
|
|
},
|
|
type: model.globalId,
|
|
},
|
|
},
|
|
resolvers: {
|
|
Query: {
|
|
[singularName]: wrapPublicationStateResolver(resolver),
|
|
},
|
|
},
|
|
};
|
|
|
|
_.merge(localSchema, query);
|
|
}
|
|
}
|
|
|
|
if (isQueryEnabled(ctx.schema, pluralName)) {
|
|
const resolverOpts = {
|
|
resolver: `${uid}.find`,
|
|
...getQueryInfo(ctx.schema, pluralName),
|
|
};
|
|
|
|
if (actionExists(resolverOpts)) {
|
|
const resolver = buildQuery(pluralName, resolverOpts);
|
|
|
|
const query = {
|
|
query: {
|
|
[pluralName]: {
|
|
args: {
|
|
...FIND_QUERY_ARGUMENTS,
|
|
...(resolverOpts.args || {}),
|
|
},
|
|
type: `[${model.globalId}]`,
|
|
},
|
|
},
|
|
resolvers: {
|
|
Query: {
|
|
[pluralName]: wrapPublicationStateResolver(resolver),
|
|
},
|
|
},
|
|
};
|
|
|
|
_.merge(localSchema, query);
|
|
|
|
if (isQueryEnabled(ctx.schema, `${pluralName}Connection`)) {
|
|
// Generate the aggregation for the given model
|
|
const aggregationSchema = formatModelConnectionsGQL({
|
|
fields: typeDefObj,
|
|
model,
|
|
name: modelName,
|
|
resolver: resolverOpts,
|
|
plugin,
|
|
});
|
|
|
|
mergeSchemas(localSchema, aggregationSchema);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add model Input definition.
|
|
localSchema.definition += types.generateInputModel(model, modelName);
|
|
|
|
// build every mutation
|
|
['create', 'update', 'delete'].forEach(action => {
|
|
const mutationSchema = buildMutationTypeDef({ model, action }, ctx);
|
|
mergeSchemas(localSchema, mutationSchema);
|
|
});
|
|
|
|
return localSchema;
|
|
};
|
|
|
|
// TODO:
|
|
// - Implement batch methods (need to update the content-manager as well).
|
|
// - Implement nested transactional methods (create/update).
|
|
const buildMutationTypeDef = ({ model, action }, ctx) => {
|
|
const capitalizedName = _.upperFirst(toSingular(model.modelName));
|
|
const mutationName = `${action}${capitalizedName}`;
|
|
|
|
const resolverOpts = {
|
|
resolver: `${model.uid}.${action}`,
|
|
transformOutput: result => ({ [toSingular(model.modelName)]: result }),
|
|
...getMutationInfo(ctx.schema, mutationName),
|
|
isShadowCrud: true,
|
|
};
|
|
|
|
if (!actionExists(resolverOpts)) {
|
|
return {};
|
|
}
|
|
|
|
const definition = types.generateInputPayloadArguments({
|
|
model,
|
|
name: model.modelName,
|
|
mutationName,
|
|
action,
|
|
});
|
|
|
|
// ignore if disabled
|
|
if (!isMutationEnabled(ctx.schema, mutationName)) {
|
|
return {
|
|
definition,
|
|
};
|
|
}
|
|
|
|
const { kind } = model;
|
|
|
|
const args = {};
|
|
|
|
if (kind !== 'singleType' || action !== 'delete') {
|
|
Object.assign(args, {
|
|
input: `${mutationName}Input`,
|
|
});
|
|
}
|
|
|
|
return {
|
|
definition,
|
|
mutation: {
|
|
[mutationName]: {
|
|
args: {
|
|
...args,
|
|
...(resolverOpts.args || {}),
|
|
},
|
|
type: `${mutationName}Payload`,
|
|
},
|
|
},
|
|
resolvers: {
|
|
Mutation: {
|
|
[mutationName]: buildMutation(mutationName, resolverOpts),
|
|
},
|
|
},
|
|
};
|
|
};
|
|
|
|
module.exports = buildShadowCrud;
|