/* Mutations have four steps: 1. Validation If the mutation call is not trusted (i.e. it comes from a GraphQL mutation), we'll run all validate steps: - Check that the current user has permission to insert/edit each field. - Add userId to document (insert only). - Run validation callbacks. 2. Sync Callbacks The second step is to run the mutation argument through all the sync callbacks. 3. Operation We then perform the insert/update/remove operation. 4. Async Callbacks Finally, *after* the operation is performed, we execute any async callbacks. Being async, they won't hold up the mutation and slow down its response time to the client. */ import { runCallbacks, runCallbacksAsync } from '../modules/index.js'; import { createError } from 'apollo-errors'; import { validateDocument, validateData, dataToModifier, modifierToData } from '../modules/validation.js'; import { registerSetting } from '../modules/settings.js'; import { debug, debugGroup, debugGroupEnd } from '../modules/debug.js'; import { Connectors } from './connectors.js'; import pickBy from 'lodash/pickBy'; import clone from 'lodash/clone'; import isEmpty from 'lodash/isEmpty'; registerSetting('database', 'mongo', 'Which database to use for your back-end'); export const createMutator = async ({ collection, document, data, currentUser, validate, context }) => { const { collectionName, typeName } = collection.options; debug(''); debugGroup(`--------------- start \x1b[36m${collectionName} Create Mutator\x1b[0m ---------------`); debug(`validate: ${validate}`); debug(document || data); // we don't want to modify the original document let newDocument = Object.assign({}, document || data); const schema = collection.simpleSchema()._schema; if (validate) { const validationErrors = validateDocument(newDocument, collection, context); // run validation callbacks newDocument = await runCallbacks({ name: `${typeName.toLowerCase()}.create.validate`, iterator: newDocument, properties: { currentUser, validationErrors, collection }}); newDocument = await runCallbacks({ name: '*.create.validate', iterator: newDocument, properties: { currentUser, validationErrors, collection }}); // OpenCRUD backwards compatibility newDocument = await runCallbacks(`${collectionName.toLowerCase()}.new.validate`, newDocument, currentUser, validationErrors); if (validationErrors.length) { const NewDocumentValidationError = createError('app.validation_error', {message: 'app.new_document_validation_error'}); throw new NewDocumentValidationError({data: {break: true, errors: validationErrors}}); } } // if user is logged in, check if userId field is in the schema and add it to document if needed if (currentUser) { const userIdInSchema = Object.keys(schema).find(key => key === 'userId'); if (!!userIdInSchema && !newDocument.userId) newDocument.userId = currentUser._id; } /* run onCreate step note: cannot use forEach with async/await. See https://stackoverflow.com/a/37576787/649299 note: clone arguments in case callbacks modify them */ for(let fieldName of Object.keys(schema)) { let autoValue; if (schema[fieldName].onCreate) { // OpenCRUD backwards compatibility: keep both newDocument and data for now, but phase our newDocument eventually // eslint-disable-next-line no-await-in-loop autoValue = await schema[fieldName].onCreate({ newDocument: clone(newDocument), data: clone(newDocument), currentUser }); } else if (schema[fieldName].onInsert) { // OpenCRUD backwards compatibility // eslint-disable-next-line no-await-in-loop autoValue = await schema[fieldName].onInsert(clone(newDocument), currentUser); } if (typeof autoValue !== 'undefined') { newDocument[fieldName] = autoValue; } } // TODO: find that info in GraphQL mutations // if (Meteor.isServer && this.connection) { // post.userIP = this.connection.clientAddress; // post.userAgent = this.connection.httpHeaders['user-agent']; // } // run sync callbacks newDocument = await runCallbacks({ name: `${typeName.toLowerCase()}.create.before`, iterator: newDocument, properties: { currentUser, collection }}); newDocument = await runCallbacks({ name: '*.create.before', iterator: newDocument, properties: { currentUser, collection }}); // OpenCRUD backwards compatibility newDocument = await runCallbacks(`${collectionName.toLowerCase()}.new.before`, newDocument, currentUser); newDocument = await runCallbacks(`${collectionName.toLowerCase()}.new.sync`, newDocument, currentUser); // add _id to document newDocument._id = await Connectors.create(collection, newDocument); // run any post-operation sync callbacks newDocument = await runCallbacks({ name: `${typeName.toLowerCase()}.create.after`, iterator: newDocument, properties: { currentUser, collection }}); newDocument = await runCallbacks({ name: '*.create.after', iterator: newDocument, properties: { currentUser, collection }}); // OpenCRUD backwards compatibility newDocument = await runCallbacks(`${collectionName.toLowerCase()}.new.after`, newDocument, currentUser); // get fresh copy of document from db // TODO: not needed? const insertedDocument = await Connectors.get(collection, newDocument._id); // run async callbacks // note: query for document to get fresh document with collection-hooks effects applied await runCallbacksAsync({ name: `${typeName.toLowerCase()}.create.async`, properties: { insertedDocument, currentUser, collection }}); await runCallbacksAsync({ name: '*.create.async', properties: { insertedDocument, currentUser, collection }}); // OpenCRUD backwards compatibility await runCallbacksAsync(`${collectionName.toLowerCase()}.new.async`, insertedDocument, currentUser, collection); debug('\x1b[33m=> created new document: \x1b[0m'); debug(newDocument); debugGroupEnd(); debug(`--------------- end \x1b[36m${collectionName} Create Mutator\x1b[0m ---------------`); debug(''); return { data: newDocument }; } export const updateMutator = async ({ collection, documentId, selector, data, set = {}, unset = {}, currentUser, validate, context, document }) => { const { collectionName, typeName } = collection.options; const schema = collection.simpleSchema()._schema; // OpenCRUD backwards compatibility selector = selector || { _id: documentId }; data = data || modifierToData({ $set: set, $unset: unset }); if (isEmpty(selector)) { throw new Error('Selector cannot be empty'); } // get original document from database or arguments document = document || await Connectors.get(collection, selector); if (!document) { throw new Error(`Could not find document to update for selector: ${JSON.stringify(selector)}`); } debug(''); debugGroup(`--------------- start \x1b[36m${collectionName} Update Mutator\x1b[0m ---------------`); debug('// collectionName: ', collectionName); debug('// selector: ', selector); debug('// data: ', data); if (validate) { let validationErrors; validationErrors = validateData(data, document, collection, context); data = await runCallbacks({ name: `${typeName.toLowerCase()}.update.validate`, iterator: data, properties: { document, currentUser, validationErrors, collection }}); data = await runCallbacks({ name: '*.update.validate', iterator: data, properties: { document, currentUser, validationErrors, collection }}); // OpenCRUD backwards compatibility data = modifierToData(await runCallbacks(`${collectionName.toLowerCase()}.edit.validate`, dataToModifier(data), document, currentUser, validationErrors)); if (validationErrors.length) { // eslint-disable-next-line no-console console.log('// validationErrors'); // eslint-disable-next-line no-console console.log(validationErrors); const EditDocumentValidationError = createError('app.validation_error', { message: 'app.edit_document_validation_error' }); throw new EditDocumentValidationError({data: {break: true, errors: validationErrors}}); } } // get a "preview" of the new document let newDocument = { ...document, ...data}; newDocument = pickBy(newDocument, f => f !== null); // run onUpdate step for(let fieldName of Object.keys(schema)) { let autoValue; if (schema[fieldName].onUpdate) { // eslint-disable-next-line no-await-in-loop autoValue = await schema[fieldName].onUpdate({ data: clone(data), document, currentUser, newDocument }); } else if (schema[fieldName].onEdit) { // OpenCRUD backwards compatibility // eslint-disable-next-line no-await-in-loop autoValue = await schema[fieldName].onEdit(dataToModifier(clone(data)), document, currentUser, newDocument); } if (typeof autoValue !== 'undefined') { data[fieldName] = autoValue; } } // run sync callbacks data = await runCallbacks({ name: `${typeName.toLowerCase()}.update.before`, iterator: data, properties: { document, currentUser, newDocument, collection }}); data = await runCallbacks({ name: '*.update.before', iterator: data, properties: { document, currentUser, newDocument, collection }}); // OpenCRUD backwards compatibility data = modifierToData(await runCallbacks(`${collectionName.toLowerCase()}.edit.before`, dataToModifier(data), document, currentUser, newDocument)); data = modifierToData(await runCallbacks(`${collectionName.toLowerCase()}.edit.sync`, dataToModifier(data), document, currentUser, newDocument)); // update connector requires a modifier, so get it from data const modifier = dataToModifier(data); // remove empty modifiers if (_.isEmpty(modifier.$set)) { delete modifier.$set; } if (_.isEmpty(modifier.$unset)) { delete modifier.$unset; } if (!_.isEmpty(modifier)) { // update document await Connectors.update(collection, selector, modifier, { removeEmptyStrings: false }); // get fresh copy of document from db newDocument = await Connectors.get(collection, selector); // TODO: add support for caching by other indexes to Dataloader // https://github.com/VulcanJS/Vulcan/issues/2000 // clear cache if needed if (selector.documentId && collection.loader) { collection.loader.clear(selector.documentId); } } // run any post-operation sync callbacks newDocument = await runCallbacks({ name: `${typeName.toLowerCase()}.update.after`, iterator: newDocument, properties: { document, currentUser, collection }}); newDocument = await runCallbacks({ name: '*.update.after', iterator: newDocument, properties: { document, currentUser, collection }}); // OpenCRUD backwards compatibility newDocument = await runCallbacks(`${collectionName.toLowerCase()}.edit.after`, newDocument, document, currentUser); // run async callbacks await runCallbacksAsync({ name: `${typeName.toLowerCase()}.update.async`, properties: { newDocument, document, currentUser, collection }}); await runCallbacksAsync({ name: '*.update.async', properties: { newDocument, document, currentUser, collection }}); // OpenCRUD backwards compatibility await runCallbacksAsync(`${collectionName.toLowerCase()}.edit.async`, newDocument, document, currentUser, collection); debug('\x1b[33m=> updated document with modifier: \x1b[0m'); debug('// modifier: ', modifier) debugGroupEnd(); debug(`--------------- end \x1b[36m${collectionName} Update Mutator\x1b[0m ---------------`); debug(''); return { data: newDocument }; } export const deleteMutator = async ({ collection, documentId, selector, currentUser, validate, context, document }) => { const { collectionName, typeName } = collection.options; debug(''); debugGroup(`--------------- start \x1b[36m${collectionName} Delete Mutator\x1b[0m ---------------`); debug('// collectionName: ', collectionName); debug('// selector: ', selector); const schema = collection.simpleSchema()._schema; // OpenCRUD backwards compatibility selector = selector || { _id: documentId }; if (isEmpty(selector)) { throw new Error('Selector cannot be empty'); } document = document || await Connectors.get(collection, selector); if (!document) { throw new Error(`Could not find document to delete for selector: ${JSON.stringify(selector)}`); } // if document is not trusted, run validation callbacks if (validate) { document = await runCallbacks({ name: `${typeName.toLowerCase()}.delete.validate`, iterator: document, properties: { currentUser, collection }}); document = await runCallbacks({ name: '*.delete.validate', iterator: document, properties: { currentUser, collection }}); // OpenCRUD backwards compatibility document = await runCallbacks(`${collectionName.toLowerCase()}.remove.validate`, document, currentUser); } // run onRemove step for(let fieldName of Object.keys(schema)) { if (schema[fieldName].onDelete) { // eslint-disable-next-line no-await-in-loop await schema[fieldName].onDelete({ document, currentUser }); } else if (schema[fieldName].onRemove) { // OpenCRUD backwards compatibility // eslint-disable-next-line no-await-in-loop await schema[fieldName].onRemove(document, currentUser); } } await runCallbacks({ name: `${typeName.toLowerCase()}.delete.before`, iterator: document, properties: { currentUser, collection }}); await runCallbacks({ name: '*.delete.before', iterator: document, properties: { currentUser, collection }}); // OpenCRUD backwards compatibility await runCallbacks(`${collectionName.toLowerCase()}.remove.before`, document, currentUser); await runCallbacks(`${collectionName.toLowerCase()}.remove.sync`, document, currentUser); await Connectors.delete(collection, selector); // TODO: add support for caching by other indexes to Dataloader // clear cache if needed if (selector.documentId && collection.loader) { collection.loader.clear(selector.documentId); } await runCallbacksAsync({ name: `${typeName.toLowerCase()}.delete.async`, properties: { document, currentUser, collection }}); await runCallbacksAsync({ name: '*.delete.async', properties: { document, currentUser, collection }}); // OpenCRUD backwards compatibility await runCallbacksAsync(`${collectionName.toLowerCase()}.remove.async`, document, currentUser, collection); debugGroupEnd(); debug(`--------------- end \x1b[36m${collectionName} Delete Mutator\x1b[0m ---------------`); debug(''); return { data: document }; } // OpenCRUD backwards compatibility export const newMutation = createMutator; export const editMutation = updateMutator; export const removeMutation = deleteMutator; export const newMutator = createMutator; export const editMutator = updateMutator; export const removeMutator = deleteMutator;