diff --git a/packages/vulcan-lib/lib/modules/graphql/defaultFragment.js b/packages/vulcan-lib/lib/modules/graphql/defaultFragment.js index 2a8765c393..302dac1494 100644 --- a/packages/vulcan-lib/lib/modules/graphql/defaultFragment.js +++ b/packages/vulcan-lib/lib/modules/graphql/defaultFragment.js @@ -2,93 +2,126 @@ * Generates the default fragment for a collection * = a fragment containing all fields */ -import { getFragmentFieldNames } from '../schema_utils'; +import { getFragmentFieldNames, createSchema } from '../schema_utils'; import { isBlackbox } from '../simpleSchema_utils'; +import { registerFragment } from '../fragments.js'; + const intlSuffix = '_intl'; // get fragment for a whole object (root schema or nested schema of an object or an array) -const getObjectFragment = ({ - schema, - fragmentName, - options +export const getObjectFragment = ({ + schema, + fragmentName, + options, }) => { - const fieldNames = getFragmentFieldNames({ schema, options }); - const childFragments = fieldNames.length && fieldNames.map(fieldName => getFieldFragment({ - schema, - fieldName, - options, - getObjectFragment: getObjectFragment - })) - // remove empty values - .filter(f => !!f); - if (childFragments.length) { - return `${fragmentName} { ${childFragments.join('\n')} }`; - } - return null; + const fieldNames = getFragmentFieldNames({ schema, options }); + const childFragments = fieldNames.length && fieldNames.map(fieldName => getFieldFragment({ + schema, + fieldName, + options, + getObjectFragment: getObjectFragment, + })) + // remove empty values + .filter(f => !!f); + if (childFragments.length) { + return `${fragmentName} { ${childFragments.join('\n')} }`; + } + return null; }; // get fragment for a specific field (either the field name or a nested fragment) export const getFieldFragment = ({ - schema, - fieldName, - options, - getObjectFragment = getObjectFragment // a callback to call on nested schema + schema, + fieldName, + options, + getObjectFragment = getObjectFragment, // a callback to call on nested schema }) => { - // intl - if (fieldName.slice(-5) === intlSuffix) { - return `${fieldName}{ locale value }`; - } - if (fieldName === '_id') return fieldName; - const field = schema[fieldName]; - - const fieldType = field.type.singleType; - const fieldTypeName = - typeof fieldType === 'object' ? 'Object' : typeof fieldType === 'function' ? fieldType.name : fieldType; - - switch (fieldTypeName) { - case 'Object': - if (!isBlackbox(field) && fieldType._schema) { - return getObjectFragment({ - fragmentName: fieldName, - schema: fieldType._schema, - options - }) || null; - } - return fieldName; - case 'Array': - const arrayItemFieldName = `${fieldName}.$`; - const arrayItemField = schema[arrayItemFieldName]; - // note: make sure field has an associated array item field - if (arrayItemField) { - // child will either be native value or a an object (first case) - const arrayItemFieldType = arrayItemField.type.singleType; - if (!isBlackbox(field) && arrayItemFieldType._schema) { - return getObjectFragment({ - fragmentName: fieldName, - schema: arrayItemFieldType._schema, - options - }) || null; - } - } - return fieldName; - default: - return fieldName; // fragment = fieldName - } + // intl + if (fieldName.slice(-5) === intlSuffix) { + return `${fieldName}{ locale value }`; + } + if (fieldName === '_id') return fieldName; + const field = schema[fieldName]; + + const fieldType = field.type.singleType; + const fieldTypeName = + typeof fieldType === 'object' ? 'Object' : typeof fieldType === 'function' ? fieldType.name : fieldType; + + switch (fieldTypeName) { + case 'Object': + if (!isBlackbox(field) && fieldType._schema) { + return getObjectFragment({ + fragmentName: fieldName, + schema: fieldType._schema, + options, + }) || null; + } + return fieldName; + case 'Array': + const arrayItemFieldName = `${fieldName}.$`; + const arrayItemField = schema[arrayItemFieldName]; + // note: make sure field has an associated array item field + if (arrayItemField) { + // child will either be native value or a an object (first case) + const arrayItemFieldType = arrayItemField.type.singleType; + if (!isBlackbox(field) && arrayItemFieldType._schema) { + return getObjectFragment({ + fragmentName: fieldName, + schema: arrayItemFieldType._schema, + options, + }) || null; + } + } + return fieldName; + default: + return fieldName; // fragment = fieldName + } }; + /* Create default "dumb" gql fragment object for a given collection */ export const getDefaultFragmentText = (collection, options = { onlyViewable: true }) => { - const schema = collection.simpleSchema()._schema; - return getObjectFragment({ - schema, - fragmentName: `fragment ${collection.options.collectionName}DefaultFragment on ${collection.typeName}`, - options - }) || null; + const schema = collection.simpleSchema()._schema; + return getObjectFragment({ + schema, + fragmentName: `fragment ${collection.options.collectionName}DefaultFragment on ${collection.typeName}`, + options, + }) || null; }; -export default getDefaultFragmentText; \ No newline at end of file +export default getDefaultFragmentText; + + +/** + * Generates and registers a default fragment for a typeName registered using `registerCustomQuery()` + * @param {string} typeName The GraphQL Type registered using `registerCustomQuery()` + * @param {Object|SimpleSchema} [schema] Schema definition object to convert to a fragment + * @param {String} [fragmentName] The fragment's name; if omitted `${typeName}DefaultFragment` will be used + * @param {[String]} [defaultCanRead] Fields in the schema without `canRead` will be assigned these read permissions + * @param {Object} options Options sent to `getObjectFragment()` + */ +export const registerCustomDefaultFragment = function ({ + typeName, + schema, + fragmentName, + defaultCanRead, + options = { onlyViewable: true }, + }) { + const simpleSchema = createSchema(schema, undefined, undefined, defaultCanRead); + schema = simpleSchema._schema; + + fragmentName = fragmentName || `${typeName}DefaultFragment`; + + const defaultFragment = getObjectFragment({ + schema, + fragmentName: `fragment ${fragmentName} on ${typeName}`, + options, + }); + + if (defaultFragment) registerFragment(defaultFragment); +}; diff --git a/packages/vulcan-lib/lib/modules/graphql_templates/types.js b/packages/vulcan-lib/lib/modules/graphql_templates/types.js index ed5f7ed8a1..a3e66e2664 100644 --- a/packages/vulcan-lib/lib/modules/graphql_templates/types.js +++ b/packages/vulcan-lib/lib/modules/graphql_templates/types.js @@ -46,7 +46,7 @@ type Movie{ */ export const mainTypeTemplate = ({ typeName, description, interfaces, fields }) => `${description ? `# ${description}` : ''} -type ${typeName} ${interfaces.length ? `implements ${interfaces.join(', ')} ` : ''}{ +type ${typeName} ${interfaces?.length ? `implements ${interfaces.join(', ')} ` : ''}{ ${convertToGraphQL(fields, ' ')} } `; diff --git a/packages/vulcan-lib/lib/modules/schema_utils.js b/packages/vulcan-lib/lib/modules/schema_utils.js index 9cd95f9e7a..104135e429 100644 --- a/packages/vulcan-lib/lib/modules/schema_utils.js +++ b/packages/vulcan-lib/lib/modules/schema_utils.js @@ -2,6 +2,7 @@ import _reject from 'lodash/reject'; import _keys from 'lodash/keys'; import { Collections } from './collections.js'; import { getNestedSchema, getArrayChild, isBlackbox } from 'meteor/vulcan:lib/lib/modules/simpleSchema_utils'; +import MongoObject from 'mongo-object'; import _isArray from 'lodash/isArray'; import _get from 'lodash/get'; import _isEmpty from 'lodash/isEmpty'; @@ -23,11 +24,28 @@ export const formattedDateResolver = fieldName => { }; }; -export const createSchema = (schema, apiSchema = {}, dbSchema = {}) => { - let modifiedSchema = { ...schema }; +export const createSchema = (schema, apiSchema = {}, dbSchema = {}, defaultCanRead) => { + let modifiedSchema = schema._schema ? { ...schema._schema } : { ...schema }; Object.keys(modifiedSchema).forEach(fieldName => { - const field = schema[fieldName]; + // support SimpleSchema Shorthand Definitions + // https://github.com/aldeed/simpl-schema#shorthand-definitions + if (Array.isArray(modifiedSchema[fieldName])) { + modifiedSchema[fieldName] = { type: Array, arrayItem: { type: modifiedSchema[fieldName][0] } }; + if (modifiedSchema[fieldName].arrayItem.type === Object) { + modifiedSchema[fieldName].arrayItem.blackbox = true; + } + } else if (!MongoObject.isBasicObject(modifiedSchema[fieldName])) { + modifiedSchema[fieldName] = { type: modifiedSchema[fieldName] }; + if (modifiedSchema[fieldName].type === Object) { + modifiedSchema[fieldName].blackbox = true; + } + } + if (!modifiedSchema[fieldName].canRead && defaultCanRead) { + modifiedSchema[fieldName].canRead = defaultCanRead; + } + + const field = modifiedSchema[fieldName]; const { arrayItem, type, canRead } = field; if (field.resolveAs) { @@ -57,7 +75,8 @@ export const createSchema = (schema, apiSchema = {}, dbSchema = {}) => { // or as a resolveAs field, then add fieldFormatted to apiSchema const formattedFieldName = `${fieldName}Formatted`; - if (type === Date && !schema[formattedFieldName] && !(_get(field, 'resolveAs.fieldName', '') === formattedFieldName)) { + if (type === Date && !modifiedSchema[formattedFieldName] && + !(_get(field, 'resolveAs.fieldName', '') === formattedFieldName)) { apiSchema[formattedFieldName] = { typeName: 'String', canRead, @@ -71,7 +90,7 @@ export const createSchema = (schema, apiSchema = {}, dbSchema = {}) => { if (!_isEmpty(apiSchema)) { Object.keys(apiSchema).forEach(fieldName => { const field = apiSchema[fieldName]; - const { canRead = ['guests'], description, ...resolveAs } = field; + const { canRead = defaultCanRead, description, ...resolveAs } = field; modifiedSchema[fieldName] = { type: Object, optional: true, @@ -85,7 +104,7 @@ export const createSchema = (schema, apiSchema = {}, dbSchema = {}) => { // for added security, remove any API-related permission checks from db fields const filteredDbSchema = {}; - const blacklistedFields = [ 'canRead', 'canCreate', 'canUpdate']; + const blacklistedFields = ['canRead', 'canCreate', 'canUpdate']; Object.keys(dbSchema).forEach(dbFieldName => { filteredDbSchema[dbFieldName] = _omit(dbSchema[dbFieldName], blacklistedFields); }); @@ -135,15 +154,16 @@ export const shouldAddOriginalField = (fieldName, field) => { const resolveAsArray = Array.isArray(field.resolveAs) ? field.resolveAs : [field.resolveAs]; const removeOriginalField = resolveAsArray.some( - resolveAs => resolveAs.addOriginalField === false || resolveAs.fieldName === fieldName || typeof resolveAs.fieldName === 'undefined' + resolveAs => resolveAs.addOriginalField === false || resolveAs.fieldName === fieldName || + typeof resolveAs.fieldName === 'undefined', ); return !removeOriginalField; }; // list fields that can be included in the default fragment for a schema -export const getFragmentFieldNames = ({ schema, options }) => +export const getFragmentFieldNames = ({ schema, options = {} }) => _reject(_keys(schema), fieldName => { /* - + Exclude a field from the default fragment if 1. it has a resolver and original field should not be added 2. it has $ in its name @@ -165,7 +185,7 @@ export const getFragmentFieldNames = ({ schema, options }) => /* -Check if a type corresponds to a collection or else +Check if a type corresponds to a collection or else is just a regular or custom scalar type. */ @@ -174,12 +194,13 @@ export const isCollectionType = typeName => /** * Iterate over a document fields and run a callback with side effect - * Works recursively for nested fields and arrays of objects (but excluding blackboxed objects, native JSON, and arrays of native values) + * Works recursively for nested fields and arrays of objects (but excluding blackboxed objects, native JSON, and + * arrays of native values) * @param {*} document Current document * @param {*} schema Document schema - * @param {*} callback Called on each field with the corresponding field schema, including fields of nested objects and arrays of nested object + * @param {*} callback Called on each field with the corresponding field schema, including fields of nested objects + * and arrays of nested object * @param {*} currentPath Global path of the document (to track recursive calls) - * @param {*} isNested Differentiate nested fields */ export const forEachDocumentField = (document, schema, callback, currentPath = '') => { if (!document) return; diff --git a/packages/vulcan-lib/lib/server/graphql/index.js b/packages/vulcan-lib/lib/server/graphql/index.js index ba7cb48862..15d05e0289 100644 --- a/packages/vulcan-lib/lib/server/graphql/index.js +++ b/packages/vulcan-lib/lib/server/graphql/index.js @@ -1,2 +1,4 @@ export * from './graphql'; -export * from './typedefs'; \ No newline at end of file +export * from './registerCustomQuery'; +export * from './schemaFields'; +export * from './typedefs'; diff --git a/packages/vulcan-lib/lib/server/graphql/registerCustomQuery.js b/packages/vulcan-lib/lib/server/graphql/registerCustomQuery.js new file mode 100644 index 0000000000..c19498df23 --- /dev/null +++ b/packages/vulcan-lib/lib/server/graphql/registerCustomQuery.js @@ -0,0 +1,102 @@ +import { createSchema } from '../../modules/schema_utils.js'; +import { getSetting } from '../../modules/settings.js'; +import { debug, debugGroup, debugGroupEnd } from '../../modules/debug.js'; +import { addGraphQLQuery, addGraphQLResolvers, GraphQLSchema } from './graphql.js'; +import { getSchemaFields } from './schemaFields'; +import { + multiInputType, + multiOutputType, + multiQueryType, + mainTypeTemplate, + multiInputTemplate, + multiOutputTemplate, + fieldFilterInputTemplate, + fieldSortInputTemplate, +} from '../../modules/graphql_templates/index.js'; + + +/** + * Registers a custom Q + * @param typeName + * @param description + * @param resolver + * @param filterable + * @param defaultCanRead + * @param schema + * @param graphQLType + * @return {{totalCount, results}} + */ +export const registerCustomQuery = function ({ typeName, + description, + resolver, + filterable, + defaultCanRead, + schema, + graphQLType = null }) { + if (!schema && !graphQLType) { + throw new Error(`When registering ${typeName}, ` + + `you must pass either schema or graphQLType to registerCustomQuery()`); + } + + const aggregate = { + + name: typeName, + + description, + + async resolver(root, args, context, info) { + const { input = {} } = args; + const { enableCache = false } = input; + const { cacheControl } = info; + + debug(''); + debugGroup(`------------- start \x1b[35m${typeName}\x1b[0m resolver -------------`); + debug(`Input: ${JSON.stringify(input)}`); + + if (cacheControl && enableCache) { + const maxAge = getSetting('graphQL.cacheMaxAge'); + cacheControl.setCacheHint({ maxAge }); + } + + const { results, totalCount } = await resolver(root, args, context, info); + + debug(`\x1b[33m=> ${results.length} of ${totalCount} documents returned\x1b[0m`); + debugGroupEnd(); + debug(`------------- end \x1b[35m${typeName}\x1b[0m resolver -------------`); + debug(''); + + // return results + return { results, totalCount }; + }, + + }; + + if (schema) { + const simpleSchema = createSchema(schema, undefined, undefined, defaultCanRead); + schema = simpleSchema._schema; + const schemaFields = getSchemaFields(schema, typeName); + graphQLType = graphQLType || mainTypeTemplate({ + typeName, + fields: schemaFields.fields.readable, + description, + }); + } + + const graphQLSchemas = []; + graphQLSchemas.push(graphQLType); + graphQLSchemas.push(fieldFilterInputTemplate({ typeName, fields: filterable })); + graphQLSchemas.push(fieldSortInputTemplate({ typeName, fields: filterable })); + graphQLSchemas.push(multiInputTemplate({ typeName })); + graphQLSchemas.push(multiOutputTemplate({ typeName })); + + const graphQLSchema = graphQLSchemas.join('\n'); + GraphQLSchema.addSchema(graphQLSchema); + + addGraphQLQuery(`${multiQueryType(typeName)}(input: ${multiInputType(typeName)}): ${multiOutputType(typeName)}`); + addGraphQLResolvers({ + Query: { + [multiQueryType(typeName)]: aggregate.resolver.bind(aggregate), + }, + }); + +};