UNPKG

apostrophe

Version:
1,361 lines (1,218 loc) • 88.6 kB
// This module provides schemas, a flexible and fast way to create new data // types by specifying the fields that should make them up. Schemas power // [@apostrophecms/piece-type](../@apostrophecms/piece-type/index.html), // [@apostrophecms/widget-type](../@apostrophecms/widget-type/index.html), // custom field types in page settings for // [@apostrophecms/page-type](../@apostrophecms/page-type/index.html) and more. // // A schema is simply an array of "plain old objects." // Each object describes one field in the schema via `type`, `name`, `label` // and other properties. // // See the [schema guide](../../tutorials/getting-started/schema-guide.html) // for a complete overview and list of schema field types. The methods // documented here on this page are most often used when you choose to work // independently with schemas, such as in a custom project that requires forms. const _ = require('lodash'); const { klona } = require('klona'); const { stripIndents } = require('common-tags'); const addFieldTypes = require('./lib/addFieldTypes'); const newInstance = require('./lib/newInstance.js'); module.exports = { options: { alias: 'schema' }, async init(self) { self.fieldTypes = {}; self.fieldsById = {}; self.arrayManagers = {}; self.objectManagers = {}; self.fieldMetadataComponents = []; self.uiManagerIndicators = []; self.enableBrowserData(); addFieldTypes(self); self.validatedSchemas = {}; // Universal ESM library, an async import is required. const { default: checkIfCondition, isExternalCondition } = await import('../../../lib/universal/check-if-conditions.mjs'); self.checkIfCondition = checkIfCondition; self.isExternalCondition = isExternalCondition; }, methods(self) { const defaultGroup = self.options.defaultGroup || { name: 'ungrouped', label: 'apostrophe:ungrouped' }; return { // Compose a schema based on addFields, removeFields, orderFields // and, occasionally, alterFields options. This method is great for // merging the schema requirements of subclasses with the schema // requirements of a superclass. See the @apostrophecms/schema // documentation for a thorough explanation of the use of each option. The // alterFields option should be avoided if your needs can be met via // another option. compose(options, module) { let schema = []; // Useful for finding good unit test cases /* self.apos.util.log( */ /* JSON.stringify( */ /** * _.pick(options, 'addFields', 'removeFields', 'arrangeFields'), */ /* null, */ /* ' ' */ /* ) */ /* ); */ if (options.addFields) { // loop over our addFields _.each(options.addFields, function (field) { let i; // remove it from the schema if we've already added it, last one // wins for (i = 0; i < schema.length; i++) { if (schema[i].name === field.name) { schema.splice(i, 1); break; } } // add the new field to the schema schema.push(field); }); } if (options.removeFields) { schema = _.filter(schema, function (field) { return !_.includes(options.removeFields, field.name); }); } if (options.requireFields) { _.each(options.requireFields, function (name) { const field = _.find(schema, function (field) { return field.name === name; }); if (field) { field.required = true; } }); } // If nothing else will do, just modify the schema with a function if (options.alterFields) { options.alterFields(schema); } const groups = self.composeGroups(schema, options.arrangeFields); // all fields in the schema will end up in this variable let newSchema = []; // loop over any groups and orders we want to respect _.each(groups, function (group) { _.each(group.fields, function (field) { // find the field we are ordering let f = _.find(schema, { name: field }); if (!f) { // May have already been migrated due to subclasses re-grouping // fields f = _.find(newSchema, { name: field }); } // make sure it exists if (f) { // set the group for this field const g = _.clone(group, true); delete g.fields; f.group = g; // push the field to the new schema, if it is a // duplicate due to subclasses pushing more // groupings, remove the earlier instance let fIndex = _.findIndex(newSchema, { name: field }); if (fIndex !== -1) { newSchema.splice(fIndex, 1); } newSchema.push(f); // remove the field from the old schema, if that is where we got // it from fIndex = _.findIndex(schema, { name: field }); if (fIndex !== -1) { schema.splice(fIndex, 1); } } }); }); // put remaining fields in the default group _.each(schema, function (field) { const g = _.clone(groups[0], true); delete g.fields; field.group = g; }); // add any fields not in defined groups to the end of the schema schema = newSchema.concat(schema); // If a field is not consecutive with other fields in its group, // move it after the last already encountered in its group, // to simplify rendering logic newSchema = []; const groupIndexes = {}; _.each(schema, function (field) { if (field.group && field.group.name) { if (_.has(groupIndexes, field.group.name)) { newSchema.splice(groupIndexes[field.group.name], 0, field); groupIndexes[field.group.name]++; } else { newSchema.push(field); groupIndexes[field.group.name] = newSchema.length; } } }); schema = newSchema; // Move the leftover group to the end, it's just too // obnoxious otherwise with one-off fields popping up // before title etc. schema = _.filter(schema, function (field) { return !(field.group && field.group.name === defaultGroup.name); }).concat(_.filter(schema, function (field) { return field.group && field.group.name === defaultGroup.name; })); // Shallowly clone the fields. This allows modules // like workflow to patch schema fields of various modules // without inadvertently impacting other apos instances // when running with @apostrophecms/multisite schema = _.map(schema, function (field) { return _.clone(field); }); _.each(schema, function(field) { // For use in resolving options like "choices" when they // contain a method name. For bc don't mess with possible // existing usages in custom schema field types predating // this feature self.setModuleName(field, module); }); return schema; }, composeGroups (schema, arrangeFields) { // always make sure there is a default group let groups = [ { name: defaultGroup.name, label: defaultGroup.label, fields: _.map(schema, 'name') } ]; // if we are getting arrangeFields and it's not empty if (arrangeFields && arrangeFields.length > 0) { // if it's full of strings, use them for the default group if (_.isString(arrangeFields[0])) { // if it's full of objects, those are groups, so use them groups[0].fields = arrangeFields; } else if (_.isPlainObject(arrangeFields[0])) { // reset the default group's fields, but keep it around, // in case they have fields they forgot to put in a group groups[0].fields = []; groups = groups.concat(arrangeFields); } } // If there is a later group with the same name, the later // one wins and the earlier is forgotten. Otherwise you can't // ever toss a field out of a group without putting it into // another one, which makes it impossible to un-group a // field and have it appear outside of tabs in the interface. // // A reconfigured group is ordered to the bottom of the list // of groups again, which has the intended effect if you // arrange all of the groups in your module config. However // it comes before any groups with the `last: true` flag that // were not reconfigured. Reconfiguring a group without that // flag clears it. const newGroups = []; _.each(groups, function (group) { const index = _.findIndex(newGroups, { name: group.name }); if (index !== -1) { newGroups.splice(index, 1); } let i = _.findIndex(newGroups, { last: true }); if (i === -1) { i = groups.length; } newGroups.splice(i, 0, group); }); return newGroups; }, // Recursively set moduleName property of the field and any subfields, // as might be found in array or object fields. `module` is an actual // module setModuleName(field, module) { field.moduleName = field.moduleName || (module && module.__meta.name); if ((field.type === 'array') || (field.type === 'object')) { _.each(field.schema || [], function(subfield) { self.setModuleName(subfield, module); }); } }, // refine is like compose, but it starts with an existing schema array // and amends it via the same options as compose. refine(schema, _options) { // Don't modify the original schema which may be in use elsewhere schema = _.cloneDeep(schema); // Deep clone is not required here, we just want // to modify the addFields property const options = _.clone(_options); options.addFields = schema.concat(options.addFields || []); // The arrangeFields option is trickier because we've already // done a compose() and so the groups are now denormalized as // properties of each field. Reconstruct the old // arrangeFields option so we can concatenate the new one const oldArrangeFields = []; _.each(schema, function (field) { if (field.group) { let group = _.find(oldArrangeFields, { name: field.group.name }); if (!group) { group = _.clone(field.group); group.fields = []; oldArrangeFields.push(group); } group.fields.push(field.name); } }); options.arrangeFields = oldArrangeFields.concat(options.arrangeFields || []); return self.compose(options); }, // Converts a flat schema (array of field objects) into a two // dimensional schema, broken up by groups toGroups(fields) { // bail on empty schemas if (fields.length === 0) { return []; } // bail if we're already in groups if (fields[0].type === 'group') { return fields; } const groups = []; let currentGroup = -1; _.each(fields, function (field) { if (field.contextual) { return; } if (!field.group) { field.group = { name: defaultGroup.name, label: defaultGroup.label }; } // first group, or not the current group if (groups.length === 0 || groups[currentGroup].name !== field.group.name) { groups.push({ type: 'group', name: field.group.name, label: field.group.label, fields: [] }); currentGroup++; } // add field to current group groups[currentGroup].fields.push(field); }); return groups; }, // Return a new schema containing only the fields named in the // `fields` array, while maintaining existing group relationships. // Any empty groups are dropped. Do NOT include group names // in `fields`. subset(schema, fields) { let groups; // check if we're already grouped if (schema[0].type === 'group') { // Don't modify the original schema which may be in use elsewhere groups = _.cloneDeep(schema); // loop over each group and remove fields from them that aren't in // this subset _.each(groups, function (group) { group.fields = _.filter(group.fields, function (field) { return _.includes(fields, field.name); }); }); // remove empty groups groups = _.filter(groups, function (group) { return group.fields.length > 0; }); return groups; } else { // otherwise this is a simple filter return _.filter(schema, function (field) { return _.includes(fields, field.name); }); } }, // Return a new object with all default settings // defined in the schema newInstance(schema) { return newInstance(schema, self); }, subsetInstance(schema, instance) { const subset = {}; _.each(schema, function (field) { if (field.type === 'group') { return; } const subsetCopy = self.fieldTypes[field.type].subsetCopy; if (!subsetCopy) { // These rules suffice for our standard fields subset[field.name] = instance[field.name]; if (field.idsStorage) { subset[field.idsStorage] = instance[field.idsStorage]; } if (field.fieldsStorage) { subset[field.fieldsStorage] = instance[field.fieldsStorage]; } } else { subsetCopy(field.name, instance, subset, field); } }); return subset; }, // Determine whether an object is empty according to the schema. // Note this is not the same thing as matching the defaults. A // nonempty string or array is never considered empty. A numeric // value of 0 is considered empty empty(schema, object) { return !_.find(schema, function (field) { // Return true if not empty const value = object[field.name]; if (value !== null && value !== undefined && value !== false) { const emptyTest = self.fieldTypes[field.type].empty; if (!emptyTest) { // Type has no method to check emptiness, so assume not empty return true; } return !emptyTest(field, value); } }); }, // Wrapper around isEqual method to get modified fields between two // documents instead of just getting a boolean, it will return an array of // the modified fields getChanges(req, schema, one, two) { return self.isEqual(req, schema, one, two, { getChanges: true }); }, // Compare two objects and return true only if their schema fields are // equal. // // Note that for relationship fields this comparison is based on the // idsStorage and fieldsStorage, which are updated at the time a document // is saved to the database, so it will not work on a document not yet // inserted or updated unless `prepareForStorage` is used. // // This method is invoked by the doc module to compare draft and published // documents and set the modified property of the draft, just before // updating the published version. // // When passing the option `getChange: true` it'll return an array of // changed fields in this case the method won't short circuit by directly // returning false when finding a changed field isEqual(req, schema, one, two, options = {}) { const changedFields = []; for (const field of schema) { const fieldType = self.fieldTypes[field.type]; if (fieldType.isEqual) { if (!fieldType.isEqual(req, field, one, two)) { if (options.getChanges) { changedFields.push(field.name); } else { return false; } } continue; } if ( !_.isEqual(one[field.name], two[field.name]) && !((one[field.name] == null) && (two[field.name] == null)) ) { if (options.getChanges) { changedFields.push(field.name); } else { return false; } } } return options.getChanges ? changedFields : true; }, // Index the object's fields for participation in Apostrophe search unless // `searchable: false` is set for the field in question indexFields(schema, object, texts) { _.each(schema, function (field) { if (field.searchable === false) { return; } const fieldType = self.fieldTypes[field.type]; if (!fieldType.index) { return; } fieldType.index(object[field.name], field, texts); }); }, async evaluateCondition( req, field, clause, destination, conditionalFields, followingValues = {} ) { const allValues = { ...followingValues, ...destination }; // 1. Evaluate all conditions without the externals. const savedExternalConditions = []; const result = self.checkIfCondition( allValues, clause, (propName, conditionValue, docValue) => { if (conditionalFields?.[propName] === false) { return false; } if (self.isExternalCondition(propName)) { savedExternalConditions.push({ name: propName, value: conditionValue }); } // Non-boolean return values are ignored } ); // 2. If the conditions evaluate to false or no external conditionss, stop. if (!result || savedExternalConditions.length === 0) { return result; } // 3. Otherwise, evaluate the external conditions. // Handle external conditions: // - `if: { 'methodName()': true }` // - `if: { 'moduleName:methodName()': 'expected value' }` const promises = savedExternalConditions.map(({ name }) => { return self.evaluateMethod( req, name, field.name, field.moduleName, destination._id ); }); try { const externalResults = await Promise.all(promises); return externalResults.every((externalResult, index) => { return externalResult === savedExternalConditions[index].value; }); } catch (error) { throw self.apos.error('invalid', error.message); } }, async isFieldRequired(req, field, destination) { return field.requiredIf ? await self.evaluateCondition(req, field, field.requiredIf, destination) : field.required; }, // Convert submitted `data` object according to `schema`, sanitizing it // and populating the appropriate properties of `destination` with it. // // Most field types may be converted as plaintext or in the // format used for Apostrophe schema forms, which in most cases // is identical to that in which they will be stored in the database. // In Apostrophe 3.x, field converters automatically determine whether // they are being given plaintext or form data. // // If the submission cannot be converted due to an error that can't be // sanitized, this method throws an exception consisting of an array // of objects with `path` and `error` properties. `path` is the field // name, or a dot-separated path to the field name if the error was in a // nested `array` or `object` schema field, and `error` is the error code // which may be a short string such as `required` or `min` that can be // used to set error class names, etc. If the error is not a string, it is // a database error etc. and should not be displayed in the browser // directly. // // ancestors consists of an array of objects where each represents // the context object at each level of nested sanitization, excluding // `destination` (the current level). This allows resolution of relative // `following` paths during sanitization. async convert( req, schema, data, destination, { fetchRelationships = true, ancestors = [], rootConvert = true } = {} ) { const options = { fetchRelationships, ancestors }; if (Array.isArray(req)) { throw new Error('convert invoked without a req, do you have one in your context?'); } const convertErrors = []; for (const field of schema) { if (field.readOnly) { continue; } // Fields that are contextual are left alone, not blanked out, if // they do not appear at all in the data object. if (field.contextual && !_.has(data, field.name)) { continue; } const { convert } = self.fieldTypes[field.type]; if (!convert) { continue; } try { const isRequired = await self.isFieldRequired(req, field, destination); await convert( req, { ...field, required: isRequired }, data, destination, { ...options, rootConvert: false } ); } catch (err) { const error = Array.isArray(err) ? self.apos.error('invalid', { errors: err }) : err; error.path = field.name; error.schemaPath = field.aposPath; convertErrors.push(error); } } if (!rootConvert) { if (convertErrors.length) { throw convertErrors; } return; } const nonVisibleFields = await self.getNonVisibleFields({ req, schema, destination }); const validErrors = await self.handleConvertErrors({ req, schema, convertErrors, destination, nonVisibleFields }); if (validErrors.length) { throw validErrors; } }, async getNonVisibleFields({ req, schema, destination, nonVisibleFields = new Set(), fieldPath = '', parentFollowingValues = {} }) { for (const field of schema) { const curPath = fieldPath ? `${fieldPath}.${field.name}` : field.name; const isVisible = await self.isVisible( req, schema, destination, field.name, parentFollowingValues ); if (!isVisible) { nonVisibleFields.add(curPath); continue; } if (!field.schema) { continue; } // Get following values for the current parent before // going deeper into the schema. parentFollowingValues = self.getNextFollowingValues( schema, destination, parentFollowingValues ); // Relationship does not support conditional fields right now if ([ 'array' /*, 'relationship' */].includes(field.type) && field.schema) { for (const arrayItem of destination[field.name] || []) { await self.getNonVisibleFields({ req, schema: field.schema, destination: arrayItem, nonVisibleFields, fieldPath: `${curPath}.${arrayItem._id}`, parentFollowingValues }); } } else if (field.type === 'object') { await self.getNonVisibleFields({ req, schema: field.schema, destination: destination[field.name], nonVisibleFields, fieldPath: curPath, parentFollowingValues }); } } return nonVisibleFields; }, async handleConvertErrors({ req, schema, convertErrors, nonVisibleFields, destination, destinationPath = '', hiddenAncestors = false }) { const validErrors = []; for (const error of convertErrors) { const [ destId, destPath ] = error.path.includes('.') ? error.path.split('.') : [ null, error.path ]; const curDestination = destId ? (destination?.items || destination || []).find(({ _id }) => _id === destId) : destination; const errorPath = destinationPath ? `${destinationPath}.${error.path}` : error.path; // Case were this error field hasn't been treated // Should check if path starts with, because parent can be invisible const nonVisibleField = hiddenAncestors || nonVisibleFields.has(errorPath); // We set default values only on final error fields if (nonVisibleField && !error.data?.errors) { const curSchema = self.getFieldLevelSchema(schema, error.schemaPath); self.setDefaultToInvisibleField(curDestination, curSchema, error.path); continue; } if (error.data?.errors) { const subErrors = await self.handleConvertErrors({ req, schema, convertErrors: error.data.errors, nonVisibleFields, destination: curDestination[destPath], destinationPath: errorPath, hiddenAncestors: nonVisibleField }); // If invalid error has no sub error, this one can be removed if (!subErrors.length) { continue; } error.data.errors = subErrors; } validErrors.push(error); } return validErrors; }, setDefaultToInvisibleField(destination, schema, fieldPath) { // Field path might contain the ID of the object in which it is // contained We just want the field name here const [ _id, fieldName ] = fieldPath.includes('.') ? fieldPath.split('.') : [ null, fieldPath ]; // It is not reasonable to enforce required, // min, max or anything else for fields // hidden via "if" as the user cannot correct it // and it will not be used. If the user changes // the conditional field later then they won't // be able to save until the erroneous field // is corrected const field = schema.find(field => field.name === fieldName); if (field) { // To protect against security issues, an invalid value // for a field that is not visible should be quietly discarded. // We only worry about this if the value is not valid, as otherwise // it's a kindness to save the work so the user can toggle back to it destination[field.name] = klona((field.def !== undefined) ? field.def : self.fieldTypes[field.type]?.def); } }, getFieldLevelSchema(schema, fieldPath) { if (!fieldPath || fieldPath === '/') { return schema; } let curSchema = schema; const parts = fieldPath.split('/'); parts.pop(); for (const part of parts) { const curField = curSchema.find(({ name }) => name === part); curSchema = curField.schema; } return curSchema; }, // Retrieve all `following` fields from a schema (ignore sub-schema), // merge it with the provided `parentFollowingValues` and apply the // `<` prefix to the keys of the resulting object to increase the nesting // level so that they can be passed to a sub-schema. getNextFollowingValues(schema, values, parentFollowingValues = {}) { const newFollowingValues = {}; for (const field of schema) { if (!field.following) { continue; } const following = Array.isArray(field.following) ? field.following : [ field.following ]; for (const followingField of following) { // Add the parent prefix to the following field newFollowingValues[`<${followingField}`] = values[followingField]; } } for (const [ name, value ] of Object.entries(parentFollowingValues)) { newFollowingValues[`<${name}`] = value; } return newFollowingValues; }, // Determine whether the given field is visible // based on `if` conditions of all fields async isVisible( req, schema, destination, name, followingValues = {} ) { const conditionalFields = {}; const errors = {}; while (true) { let change = false; for (const field of schema) { if (field.if) { try { const result = await self.evaluateCondition( req, field, field.if, destination, conditionalFields, followingValues ); const previous = conditionalFields[field.name]; if (previous !== result) { change = true; } conditionalFields[field.name] = result; } catch (error) { errors[field.name] = error; } } } // send the error related to the given field via the `name` param if (errors[name]) { throw errors[name]; } if (!change) { break; } } if (_.has(conditionalFields, name)) { return conditionalFields[name]; } else { return true; } }, async evaluateMethod( req, methodKey, fieldName, fieldModuleName, docId = null, optionalParenthesis = false, following = {}, featureType = 'field' ) { const [ methodDefinition, rest ] = methodKey.split('('); const hasParenthesis = rest !== undefined; if (!hasParenthesis && !optionalParenthesis) { throw new Error(`The method "${methodDefinition}" defined in the "${fieldName}" ${featureType} should be written with parenthesis: "${methodDefinition}()".`); } if (hasParenthesis && !methodKey.endsWith('()')) { self.apos.util.warn(`The method "${methodDefinition}" defined in the "${fieldName}" ${featureType} should be written without argument: "${methodDefinition}()".`); methodKey = methodDefinition + '()'; } const [ methodName, moduleName = fieldModuleName ] = methodDefinition .split(':') .reverse(); const module = self.apos.modules[moduleName]; if (!module) { throw new Error(`The "${moduleName}" module defined in the "${fieldName}" ${featureType} does not exist.`); } else if (!module[methodName]) { throw new Error(`The "${methodName}" method from "${moduleName}" module defined in the "${fieldName}" ${featureType} does not exist.`); } return module[methodName](req, { docId }, following); }, // Driver invoked by the "relationship" methods of the standard // relationship field types. // // All arguments must be present, however fieldsStorage // may be undefined to indicate none is needed. async relationshipDriver( req, method, reverse, items, idsStorage, fieldsStorage, objectField, options ) { if (!options) { options = {}; } const find = options.find; const builders = options.builders || {}; const getCriteria = options.getCriteria || {}; await method(items, idsStorage, fieldsStorage, objectField, ids => { const idsCriteria = {}; if (reverse) { idsCriteria[idsStorage] = { $in: ids }; } else { idsCriteria.aposDocId = { $in: ids }; } const criteria = { $and: [ getCriteria, idsCriteria ] }; const query = find(req, criteria); // Builders hardcoded as part of this relationship's options don't // require any sanitization query.applyBuilders(builders); return query.toArray(); }, self.apos.doc.toAposDocId); }, // Fetch all the relationships in the schema on the specified object or // array of objects. The withRelationships option may be omitted. // // If withRelationships is omitted, null or undefined, all the // relationships in the schema are performed, and also any relationships // specified by the 'withRelationships' option of each relationship field // in the schema, if any. And that's where it stops. Infinite recursion is // not possible. // // If withRelationships is specified and set to "false", // no relationships at all are performed. // // If withRelationships is set to an array of relationship names found // in the schema, then only those relationships are performed, ignoring // any 'withRelationships' options found in the schema. // // If a relationship name in the withRelationships array uses dot // notation, like this: // // _events._locations // // Then the related events are fetched, and the locations related to // those events are fetched, assuming that _events is defined as a // relationship in the original schema and _locations is defined as a // relationship in the schema for the events module. Multiple "dot // notation" relationships may share a prefix. // // Relationships are also supported in the schemas of array fields. async relate(req, schema, objectOrArray, withRelationships) { if (withRelationships === false) { // Relationships explicitly deactivated for this call return; } const objects = _.isArray(objectOrArray) ? objectOrArray : [ objectOrArray ]; if (!objects.length) { // Don't waste effort return; } // build an array of relationships of interest, found at any level // in the schema, even those nested in array schemas. Add // an _arrays property to each one which contains the names // of the array fields leading to this relationship, if any, so // we know where to store the results. Also set a // _dotPath property which can be used to identify relevant // relationships when the withRelationships option is present let relationships = []; function findRelationships(schema, arrays) { // Shallow clone of each relationship to allow // for independent _dotPath and _arrays properties // for different requests const _relationships = _.filter(schema, function (field) { return !!self.fieldTypes[field.type].relate; }).map(relationship => ({ ...relationship })); _.each(_relationships, function (relationship) { if (!arrays.length) { relationship._dotPath = relationship.name; } else { relationship._dotPath = arrays.join('.') + '.' + relationship.name; } // If we have more than one object we're not interested in // relationships with the ifOnlyOne restriction right now. if (objects.length > 1 && relationship.ifOnlyOne) { return; } relationship._arrays = _.clone(arrays); }); relationships = relationships.concat(_relationships); _.each(schema, function (field) { if (field.type === 'array' || field.type === 'object') { findRelationships(field.schema, arrays.concat(field.name)); } }); } findRelationships(schema, []); // The withRelationships option allows restriction of relationships. // Set to false it blocks all relationships. Set to an array, it allows // the relationships named within. Dot notation can be used to specify // relationships in array properties, or relationships reached via other // relationships. // // By default, all configured relationships will take place, but // withRelationships: false // will be passed when fetching the objects on the other end of the // relationship, so that infinite recursion never takes place. const withRelationshipsNext = {}; // Explicit withRelationships option passed to us if (Array.isArray(withRelationships)) { relationships = _.filter(relationships, function (relationship) { const dotPath = relationship._dotPath; let winner; _.each(withRelationships, function (withRelationshipName) { if (withRelationshipName === dotPath) { winner = true; return; } if (withRelationshipName.substr(0, dotPath.length + 1) === dotPath + '.') { if (!withRelationshipsNext[dotPath]) { withRelationshipsNext[dotPath] = []; } withRelationshipsNext[dotPath].push( withRelationshipName.substr(dotPath.length + 1) ); winner = true; } }); return winner; }); } else { // No explicit withRelationships option for us, so we do all the // relationships we're configured to do, and pass on the // withRelationships options we have configured for those _.each(relationships, function (relationship) { if (relationship.withRelationships) { withRelationshipsNext[relationship._dotPath] = relationship .withRelationships; } }); } for (const relationship of relationships) { const arrays = relationship._arrays; const _objects = findObjectsInArrays(objects, arrays); if (!relationship.name.match(/^_/)) { throw Error('Relationships should always be given names beginning with an underscore (_). Otherwise we would waste space in your database storing the results statically. There would also be a conflict with the array field withRelationships syntax. Relationship name is: ' + relationship._dotPath); } if (Array.isArray(relationship.withType)) { // Polymorphic join for (const type of relationship.withType) { const manager = self.apos.doc.getManager(type); if (!manager) { throw Error('I cannot find the instance type ' + type); } const find = manager.find; const relationships = withRelationshipsNext[relationship._dotPath] || false; const options = { find, builders: { relationships } }; const subname = relationship.name + ':' + type; const _relationship = _.assign({}, relationship, { name: subname, withType: type }); // Allow options to the get() method to be // specified in the relationship configuration if (_relationship.builders) { _.extend(options.builders, _relationship.builders); } if (_relationship.buildersByType && _relationship.buildersByType[type]) { _.extend(options.builders, _relationship.buildersByType[type]); } await self.apos.util.recursionGuard(req, `${_relationship.type}:${_relationship.withType}`, () => { // Allow options to the getter to be specified in the schema, return self.fieldTypes[_relationship.type] .relate(req, _relationship, _objects, options); }); _.each(_objects, function (object) { if (object[subname]) { if (Array.isArray(object[subname])) { object[relationship.name] = (object[relationship.name] || []) .concat(object[subname]); } else { object[relationship.name] = object[subname]; } } }); } if (relationship.idsStorage) { _.each(_objects, function (object) { if (object[relationship.name]) { const locale = `${req.locale}:${req.mode}`; object[relationship.name] = self.apos.util.orderById( object[relationship.idsStorage].map(id => `${id}:${locale}`), object[relationship.name] ); } }); } } const manager = self.apos.doc.getManager(relationship.withType); if (!manager) { throw Error('I cannot find the instance type ' + relationship.withType); } // If it has a getter, use it, otherwise supply one const find = manager.find; const relationships = withRelationshipsNext[relationship._dotPath] || false; const options = { find, builders: { relationships } }; // Allow options to the get() method to be // specified in the relationship configuration if (relationship.builders) { _.extend(options.builders, relationship.builders); } // If there is a projection for a reverse relationship, make sure it // includes the idsStorage and fieldsStorage for the relationship, // otherwise no related documents will be returned. Make sure the // projection is positive, not negative, before attempting to add more // positive assertions to it if ((relationship.type === 'relationshipReverse') && options.builders.project && Object.values(options.builders.project).some(v => !!v)) { if (relationship.idsStorage) { options.builders.project[relationship.idsStorage] = 1; } if (relationship.fieldsStorage) { options.builders.project[relationship.fieldsStorage] = 1; } } // Allow options to the getter to be specified in the schema await self.apos.util.recursionGuard(req, `${relationship.type}:${relationship.withType}`, () => { return self.fieldTypes[relationship.type] .relate(req, relationship, _objects, options); }); } function findObjectsInArrays(objects, arrays) { if (!arrays) { return []; } if (!arrays.length) { return objects; } const array = arrays[0]; let _objects = []; _.each(objects, function (object) { _objects = _objects.concat(object[array] || []); }); return findObjectsInArrays(_objects, arrays.slice(1)); } }, // In the given document or widget, update any underlying // storage needs required for relationships, arrays, etc., // such as populating the idsStorage and fieldsStorage // properties of relationship fields, or setting the // arrayName property of array items. This method is // always invoked for you by @apostrophecms/doc-type in a // beforeSave handler. This method also recursively invokes // itself as needed for relationships nested in widgets, // array fields and object fields. // // If a relationship field is present by name (such as `_products`) // in the document, that is taken as authoritative, and any // existing values in the `idsStorage` and `fieldsStorage` // are overwritten. If the relationship field is not present, the // existing values are left alone. This allows the developer // to safely update a document that was fetched with // `.relationships(false)`, provided the projection included // the ids. // // Currently `req` does not impact this, but that may change. prepareForStorage(req, doc, options = {}) { const can = (field) => { const canEdit = () => self.apos.permission.can( req, field.editPermission.action, field.editPermission.type ); const canView = () => self.apos.permission.can( req, field.viewPermission.action, field.viewPermission.type ); return options.permissions === false || (!field.withType && !field.editPermission && !field.viewPermission) || (field.withType && self.apos.permission.can(req, 'view', field.withType)) || (field.editPermission && canEdit()) || (field.viewPermission && canView()) || false; }; const handlers = { arrayItem: (field, object) => { if (!object || !can(field)) { return; } object._id = object._id || self.apos.util.generateId(); object.metaType = 'arrayItem'; object.scopedArrayName = field.scopedArrayName; }, object: (field, object) => { if (!object || !can(field)) { return; } object.metaType = 'object'; object.scopedObjectName = field.scopedObjectName; }, relationship: (field, doc) => { if (!Array.isArray(doc[field.name]) || !can(field)) { return; } doc[field.idsStorage] = doc[field.name] .map(relatedDoc => self.apos.doc.toAposDocId(relatedDoc)); if (field.fieldsStorage) { const fieldsById = doc[field.fieldsStorage] || {}; for (const relatedDoc of doc[field.name]) { if (relatedDoc._fields) { fieldsById[self.apos.doc.toAposDocId(relatedDoc)] = relatedDoc._fields; } } doc[field.fieldsStorage] = fieldsById; } } }; self.apos.doc.walkByMetaType(doc, handlers); }, simulateRelationshipsFromStorage(req, doc) { const handlers = { relationship: (field, object) => { const manager = self.apos.doc.getManager(field.withType); const setId = (id) => manager.options.localized !== false ? `${id}:${doc.aposLocale}` : id; const itemIds = object[field.idsStorage] || []; object[field.name] = itemIds.map(id => ({ _id: setId(id), _fields: object[field.fieldsStorage]?.[id] || {} })); } }; self.apos.doc.walkByMetaType(doc, handlers); }, // Add a new field type. The `type` object may contain the following // properties: // // ### `name` // // Required. The name of the field type, such as `select`. Use a unique // prefix to avoid collisions with future official Apostrophe field types. // // ### `convert` // // Required. An `async` function which takes `(req, field, data, // destination)`. The value of the field is drawn from the untrusted input // object `data` and sanitized if possible, then copied to the appropriate // property (or properties) of `destination`. // // `field` contains the schema field definition, useful to access // `def`, `min`, `max`, etc. // // If the field cannot be sanitized an error can be thrown. To signal an // error that can be examined by browser code and used for UI, throw a // string like `required`. If the field is a composite field (`array` or // `object`), throw an array of objects with `path` and `