diff --git a/packages/keystone/lib/ListTypes/list.js b/packages/keystone/lib/ListTypes/list.js index acd7bed5afd..18b0064f7b5 100644 --- a/packages/keystone/lib/ListTypes/list.js +++ b/packages/keystone/lib/ListTypes/list.js @@ -364,110 +364,11 @@ module.exports = class List { .filter(field => field.access[schemaName][access]); // If it's globally set to false, makes sense to never let it be updated } - /** Equivalent to getFieldsWithAccess but includes `id` fields. */ getAllFieldsWithAccess({ schemaName, access }) { + // Equivalent to getFieldsWithAccess but includes `id` fields. return this.fields.filter(field => field.access[schemaName][access]); } - getGqlTypes({ schemaName }) { - const schemaAccess = this.access[schemaName]; - const types = []; - - // We want to include `id` fields - // If read is globally set to false, makes sense to never show it - const readFields = this.getAllFieldsWithAccess({ schemaName, access: 'read' }); - if ( - schemaAccess.read || - schemaAccess.create || - schemaAccess.update || - schemaAccess.delete || - schemaAccess.auth - ) { - types.push( - ...flatten(this.fields.map(field => field.getGqlAuxTypes({ schemaName }))), - ` - """ ${this.schemaDoc || 'A keystone list'} """ - type ${this.gqlNames.outputTypeName} { - """ - This virtual field will be resolved in one of the following ways (in this order): - 1. Execution of 'labelResolver' set on the ${this.key} List config, or - 2. As an alias to the field set on 'labelField' in the ${this.key} List config, or - 3. As an alias to a 'name' field on the ${this.key} List (if one exists), or - 4. As an alias to the 'id' field on the ${this.key} List. - """ - _label_: String - ${flatten( - readFields.map(field => - field.schemaDoc - ? `""" ${field.schemaDoc} """ ${field.gqlOutputFields({ schemaName })}` - : field.gqlOutputFields({ schemaName }) - ) - ).join('\n')} - }`, - - // https://github.com/opencrud/opencrud/blob/master/spec/2-relational/2-2-queries/2-2-3-filters.md#boolean-expressions - ` - input ${this.gqlNames.whereInputName} { - AND: [${this.gqlNames.whereInputName}] - OR: [${this.gqlNames.whereInputName}] - - ${flatten(readFields.map(field => field.gqlQueryInputFields({ schemaName }))).join('\n')} - }`, - // TODO: Include other `unique` fields and allow filtering by them - ` - input ${this.gqlNames.whereUniqueInputName} { - id: ID! - }` - ); - - const sortOptions = flatten( - readFields.map(({ path, isOrderable }) => - // Explicitly allow sorting by id - isOrderable || path === 'id' ? [`${path}_ASC`, `${path}_DESC`] : [] - ) - ); - - if (sortOptions.length) { - types.push(` - enum ${this.gqlNames.listSortName} { - ${sortOptions.join('\n')} - } - `); - } - } - - const updateFields = this.getFieldsWithAccess({ schemaName, access: 'update' }); - if (schemaAccess.update && updateFields.length) { - types.push(` - input ${this.gqlNames.updateInputName} { - ${flatten(updateFields.map(field => field.gqlUpdateInputFields)).join('\n')} - } - `); - types.push(` - input ${this.gqlNames.updateManyInputName} { - id: ID! - data: ${this.gqlNames.updateInputName} - } - `); - } - - const createFields = this.getFieldsWithAccess({ schemaName, access: 'create' }); - if (schemaAccess.create && createFields.length) { - types.push(` - input ${this.gqlNames.createInputName} { - ${flatten(createFields.map(field => field.gqlCreateInputFields)).join('\n')} - } - `); - types.push(` - input ${this.gqlNames.createManyInputName} { - data: ${this.gqlNames.createInputName} - } - `); - } - - return types; - } - getGraphqlFilterFragment() { return [ `where: ${this.gqlNames.whereInputName}`, @@ -479,52 +380,22 @@ module.exports = class List { ]; } - getGqlQueries({ schemaName }) { - const schemaAccess = this.access[schemaName]; - // All the auxiliary queries the fields want to add - const queries = flatten(this.fields.map(field => field.getGqlAuxQueries())); - - // If `read` is either `true`, or a function (we don't care what the result - // of the function is, that'll get executed at a later time) - if (schemaAccess.read) { - queries.push( - ` - """ Search for all ${this.gqlNames.outputTypeName} items which match the where clause. """ - ${this.gqlNames.listQueryName}( - ${this.getGraphqlFilterFragment().join('\n')} - ): [${this.gqlNames.outputTypeName}]`, - - ` - """ Search for the ${this.gqlNames.outputTypeName} item with the matching ID. """ - ${this.gqlNames.itemQueryName}( - where: ${this.gqlNames.whereUniqueInputName}! - ): ${this.gqlNames.outputTypeName}`, - - ` - """ Perform a meta-query on all ${ - this.gqlNames.outputTypeName - } items which match the where clause. """ - ${this.gqlNames.listQueryMetaName}( - ${this.getGraphqlFilterFragment().join('\n')} - ): _QueryMeta`, - - ` - """ Retrieve the meta-data for the ${this.gqlNames.itemQueryName} list. """ - ${this.gqlNames.listMetaName}: _ListMeta` - ); - } - - return queries; - } - getFieldsRelatedTo(listKey) { return this.fields.filter( ({ isRelationship, refListKey }) => isRelationship && refListKey === listKey ); } - // Wrap the "inner" resolver for a single output field with list-specific modifiers + getFieldByPath(path) { + return this.fieldsByPath[path]; + } + + getPrimaryKey() { + return this.fieldsByPath['id']; + } + _wrapFieldResolver(field, innerResolver) { + // Wrap the "inner" resolver for a single output field with list-specific modifiers return async (item, args, context, info) => { // Check access const operation = 'read'; @@ -554,113 +425,6 @@ module.exports = class List { }; } - gqlFieldResolvers({ schemaName }) { - const schemaAccess = this.access[schemaName]; - if (!schemaAccess.read) { - return {}; - } - const fieldResolvers = { - // TODO: The `_label_` output field currently circumvents access control - _label_: this.labelResolver, - ...objMerge( - this.fields - .filter(field => field.access[schemaName].read) - .map(field => - // Get the resolvers for the (possibly multiple) output fields and wrap each with list-specific modifiers - mapKeys(field.gqlOutputFieldResolvers({ schemaName }), innerResolver => - this._wrapFieldResolver(field, innerResolver) - ) - ) - ), - }; - return { [this.gqlNames.outputTypeName]: fieldResolvers }; - } - - gqlAuxFieldResolvers({ schemaName }) { - const schemaAccess = this.access[schemaName]; - if ( - schemaAccess.read || - schemaAccess.create || - schemaAccess.update || - schemaAccess.delete || - schemaAccess.auth - ) { - return objMerge(this.fields.map(field => field.gqlAuxFieldResolvers({ schemaName }))); - } - return {}; - } - - gqlAuxQueryResolvers() { - // TODO: Obey the same ACL rules based on parent type - return objMerge(this.fields.map(field => field.gqlAuxQueryResolvers())); - } - - gqlAuxMutationResolvers() { - // TODO: Obey the same ACL rules based on parent type - return objMerge(this.fields.map(field => field.gqlAuxMutationResolvers())); - } - - getGqlMutations({ schemaName }) { - const schemaAccess = this.access[schemaName]; - const mutations = flatten(this.fields.map(field => field.getGqlAuxMutations())); - - // NOTE: We only check for truthy as it could be `true`, or a function (the - // function is executed later in the resolver) - - const createFields = this.getFieldsWithAccess({ schemaName, access: 'create' }); - if (schemaAccess.create && createFields.length) { - mutations.push(` - """ Create a single ${this.gqlNames.outputTypeName} item. """ - ${this.gqlNames.createMutationName}( - data: ${this.gqlNames.createInputName} - ): ${this.gqlNames.outputTypeName} - `); - - mutations.push(` - """ Create multiple ${this.gqlNames.outputTypeName} items. """ - ${this.gqlNames.createManyMutationName}( - data: [${this.gqlNames.createManyInputName}] - ): [${this.gqlNames.outputTypeName}] - `); - } - - const updateFields = this.getFieldsWithAccess({ schemaName, access: 'update' }); - if (schemaAccess.update && updateFields.length) { - mutations.push(` - """ Update a single ${this.gqlNames.outputTypeName} item by ID. """ - ${this.gqlNames.updateMutationName}( - id: ID! - data: ${this.gqlNames.updateInputName} - ): ${this.gqlNames.outputTypeName} - `); - - mutations.push(` - """ Update multiple ${this.gqlNames.outputTypeName} items by ID. """ - ${this.gqlNames.updateManyMutationName}( - data: [${this.gqlNames.updateManyInputName}] - ): [${this.gqlNames.outputTypeName}] - `); - } - - if (schemaAccess.delete) { - mutations.push(` - """ Delete a single ${this.gqlNames.outputTypeName} item by ID. """ - ${this.gqlNames.deleteMutationName}( - id: ID! - ): ${this.gqlNames.outputTypeName} - `); - - mutations.push(` - """ Delete multiple ${this.gqlNames.outputTypeName} items by ID. """ - ${this.gqlNames.deleteManyMutationName}( - ids: [ID!] - ): [${this.gqlNames.outputTypeName}] - `); - } - - return mutations; - } - async checkFieldAccess(operation, itemsToUpdate, context, { gqlName, ...extraInternalData }) { const restrictedFields = []; for (const { existingItem, id, data } of itemsToUpdate) { @@ -812,30 +576,6 @@ module.exports = class List { ); } - gqlQueryResolvers({ schemaName }) { - const schemaAccess = this.access[schemaName]; - let resolvers = {}; - - // If set to false, we can confidently remove these resolvers entirely from - // the graphql schema - if (schemaAccess.read) { - resolvers = { - [this.gqlNames.listQueryName]: (_, args, context, info) => - this.listQuery(args, context, this.gqlNames.listQueryName, info), - - [this.gqlNames.listQueryMetaName]: (_, args, context, info) => - this.listQueryMeta(args, context, this.gqlNames.listQueryMetaName, info), - - [this.gqlNames.listMetaName]: (_, args, context) => this.listMeta(context), - - [this.gqlNames.itemQueryName]: (_, args, context, info) => - this.itemQuery(args, context, this.gqlNames.itemQueryName, info), - }; - } - - return resolvers; - } - async listQuery(args, context, gqlName, info, from) { const access = await this.checkListAccess(context, undefined, 'read', { gqlName }); @@ -862,40 +602,6 @@ module.exports = class List { }; } - listMeta(context) { - return { - key: this.key, - name: this.key, - // Return these as functions so they're lazily evaluated depending - // on what the user requested - // Evalutation takes place in ../Keystone/index.js - // NOTE: These could return a Boolean or a JSON object (if using the - // declarative syntax) - getAccess: () => ({ - getCreate: () => context.getListAccessControlForUser(this.key, undefined, 'create'), - getRead: () => context.getListAccessControlForUser(this.key, undefined, 'read'), - getUpdate: () => context.getListAccessControlForUser(this.key, undefined, 'update'), - getDelete: () => context.getListAccessControlForUser(this.key, undefined, 'delete'), - getAuth: () => context.getAuthAccessControlForUser(this.key), - }), - getSchema: () => { - const queries = [ - this.gqlNames.itemQueryName, - this.gqlNames.listQueryName, - this.gqlNames.listQueryMetaName, - ]; - - // NOTE: Other fields on this type are resolved in the main resolver in - // ../Keystone/index.js - return { - type: this.gqlNames.outputTypeName, - queries, - key: this.key, - }; - }, - }; - } - async itemQuery( // prettier-ignore { where: { id } }, @@ -992,39 +698,7 @@ module.exports = class List { return results; } - gqlMutationResolvers({ schemaName }) { - const schemaAccess = this.access[schemaName]; - const mutationResolvers = {}; - - const createFields = this.getFieldsWithAccess({ schemaName, access: 'create' }); - if (schemaAccess.create && createFields.length) { - mutationResolvers[this.gqlNames.createMutationName] = (_, { data }, context) => - this.createMutation(data, context); - - mutationResolvers[this.gqlNames.createManyMutationName] = (_, { data }, context) => - this.createManyMutation(data, context); - } - - const updateFields = this.getFieldsWithAccess({ schemaName, access: 'update' }); - if (schemaAccess.update && updateFields.length) { - mutationResolvers[this.gqlNames.updateMutationName] = (_, { id, data }, context) => - this.updateMutation(id, data, context); - - mutationResolvers[this.gqlNames.updateManyMutationName] = (_, { data }, context) => - this.updateManyMutation(data, context); - } - - if (schemaAccess.delete) { - mutationResolvers[this.gqlNames.deleteMutationName] = (_, { id }, context) => - this.deleteMutation(id, context); - - mutationResolvers[this.gqlNames.deleteManyMutationName] = (_, { ids }, context) => - this.deleteManyMutation(ids, context); - } - - return mutationResolvers; - } - + // Mutation resolvers _throwValidationFailure(errors, operation, data = {}) { throw new ValidationFailureError({ data: { @@ -1281,8 +955,8 @@ module.exports = class List { await this._runHook(args, existingItem, 'afterDelete'); } - // Used to apply hooks that only produce side effects async _runHook(args, fieldObject, hookName) { + // Used to apply hooks that only produce side effects const fields = this._fieldsFromObject(fieldObject); await this._mapToFields(fields, field => field[hookName](args)); await this._mapToFields( @@ -1529,10 +1203,339 @@ module.exports = class List { }); } - getFieldByPath(path) { - return this.fieldsByPath[path]; + // Methods called from ListCRUDProvider + getGqlTypes({ schemaName }) { + const schemaAccess = this.access[schemaName]; + const types = []; + + // We want to include `id` fields + // If read is globally set to false, makes sense to never show it + const readFields = this.getAllFieldsWithAccess({ schemaName, access: 'read' }); + if ( + schemaAccess.read || + schemaAccess.create || + schemaAccess.update || + schemaAccess.delete || + schemaAccess.auth + ) { + types.push( + ...flatten(this.fields.map(field => field.getGqlAuxTypes({ schemaName }))), + ` + """ ${this.schemaDoc || 'A keystone list'} """ + type ${this.gqlNames.outputTypeName} { + """ + This virtual field will be resolved in one of the following ways (in this order): + 1. Execution of 'labelResolver' set on the ${this.key} List config, or + 2. As an alias to the field set on 'labelField' in the ${this.key} List config, or + 3. As an alias to a 'name' field on the ${this.key} List (if one exists), or + 4. As an alias to the 'id' field on the ${this.key} List. + """ + _label_: String + ${flatten( + readFields.map(field => + field.schemaDoc + ? `""" ${field.schemaDoc} """ ${field.gqlOutputFields({ schemaName })}` + : field.gqlOutputFields({ schemaName }) + ) + ).join('\n')} + }`, + + // https://github.com/opencrud/opencrud/blob/master/spec/2-relational/2-2-queries/2-2-3-filters.md#boolean-expressions + ` + input ${this.gqlNames.whereInputName} { + AND: [${this.gqlNames.whereInputName}] + OR: [${this.gqlNames.whereInputName}] + + ${flatten(readFields.map(field => field.gqlQueryInputFields({ schemaName }))).join('\n')} + }`, + // TODO: Include other `unique` fields and allow filtering by them + ` + input ${this.gqlNames.whereUniqueInputName} { + id: ID! + }` + ); + + const sortOptions = flatten( + readFields.map(({ path, isOrderable }) => + // Explicitly allow sorting by id + isOrderable || path === 'id' ? [`${path}_ASC`, `${path}_DESC`] : [] + ) + ); + + if (sortOptions.length) { + types.push(` + enum ${this.gqlNames.listSortName} { + ${sortOptions.join('\n')} + } + `); + } + } + + const updateFields = this.getFieldsWithAccess({ schemaName, access: 'update' }); + if (schemaAccess.update && updateFields.length) { + types.push(` + input ${this.gqlNames.updateInputName} { + ${flatten(updateFields.map(field => field.gqlUpdateInputFields)).join('\n')} + } + `); + types.push(` + input ${this.gqlNames.updateManyInputName} { + id: ID! + data: ${this.gqlNames.updateInputName} + } + `); + } + + const createFields = this.getFieldsWithAccess({ schemaName, access: 'create' }); + if (schemaAccess.create && createFields.length) { + types.push(` + input ${this.gqlNames.createInputName} { + ${flatten(createFields.map(field => field.gqlCreateInputFields)).join('\n')} + } + `); + types.push(` + input ${this.gqlNames.createManyInputName} { + data: ${this.gqlNames.createInputName} + } + `); + } + + return types; } - getPrimaryKey() { - return this.fieldsByPath['id']; + + getGqlQueries({ schemaName }) { + const schemaAccess = this.access[schemaName]; + // All the auxiliary queries the fields want to add + const queries = flatten(this.fields.map(field => field.getGqlAuxQueries())); + + // If `read` is either `true`, or a function (we don't care what the result + // of the function is, that'll get executed at a later time) + if (schemaAccess.read) { + queries.push( + ` + """ Search for all ${this.gqlNames.outputTypeName} items which match the where clause. """ + ${this.gqlNames.listQueryName}( + ${this.getGraphqlFilterFragment().join('\n')} + ): [${this.gqlNames.outputTypeName}]`, + + ` + """ Search for the ${this.gqlNames.outputTypeName} item with the matching ID. """ + ${this.gqlNames.itemQueryName}( + where: ${this.gqlNames.whereUniqueInputName}! + ): ${this.gqlNames.outputTypeName}`, + + ` + """ Perform a meta-query on all ${ + this.gqlNames.outputTypeName + } items which match the where clause. """ + ${this.gqlNames.listQueryMetaName}( + ${this.getGraphqlFilterFragment().join('\n')} + ): _QueryMeta`, + + ` + """ Retrieve the meta-data for the ${this.gqlNames.itemQueryName} list. """ + ${this.gqlNames.listMetaName}: _ListMeta` + ); + } + + return queries; + } + + getGqlMutations({ schemaName }) { + const schemaAccess = this.access[schemaName]; + const mutations = flatten(this.fields.map(field => field.getGqlAuxMutations())); + + // NOTE: We only check for truthy as it could be `true`, or a function (the + // function is executed later in the resolver) + + const createFields = this.getFieldsWithAccess({ schemaName, access: 'create' }); + if (schemaAccess.create && createFields.length) { + mutations.push(` + """ Create a single ${this.gqlNames.outputTypeName} item. """ + ${this.gqlNames.createMutationName}( + data: ${this.gqlNames.createInputName} + ): ${this.gqlNames.outputTypeName} + `); + + mutations.push(` + """ Create multiple ${this.gqlNames.outputTypeName} items. """ + ${this.gqlNames.createManyMutationName}( + data: [${this.gqlNames.createManyInputName}] + ): [${this.gqlNames.outputTypeName}] + `); + } + + const updateFields = this.getFieldsWithAccess({ schemaName, access: 'update' }); + if (schemaAccess.update && updateFields.length) { + mutations.push(` + """ Update a single ${this.gqlNames.outputTypeName} item by ID. """ + ${this.gqlNames.updateMutationName}( + id: ID! + data: ${this.gqlNames.updateInputName} + ): ${this.gqlNames.outputTypeName} + `); + + mutations.push(` + """ Update multiple ${this.gqlNames.outputTypeName} items by ID. """ + ${this.gqlNames.updateManyMutationName}( + data: [${this.gqlNames.updateManyInputName}] + ): [${this.gqlNames.outputTypeName}] + `); + } + + if (schemaAccess.delete) { + mutations.push(` + """ Delete a single ${this.gqlNames.outputTypeName} item by ID. """ + ${this.gqlNames.deleteMutationName}( + id: ID! + ): ${this.gqlNames.outputTypeName} + `); + + mutations.push(` + """ Delete multiple ${this.gqlNames.outputTypeName} items by ID. """ + ${this.gqlNames.deleteManyMutationName}( + ids: [ID!] + ): [${this.gqlNames.outputTypeName}] + `); + } + + return mutations; + } + + gqlAuxFieldResolvers({ schemaName }) { + const schemaAccess = this.access[schemaName]; + if ( + schemaAccess.read || + schemaAccess.create || + schemaAccess.update || + schemaAccess.delete || + schemaAccess.auth + ) { + return objMerge(this.fields.map(field => field.gqlAuxFieldResolvers({ schemaName }))); + } + return {}; + } + + gqlFieldResolvers({ schemaName }) { + const schemaAccess = this.access[schemaName]; + if (!schemaAccess.read) { + return {}; + } + const fieldResolvers = { + // TODO: The `_label_` output field currently circumvents access control + _label_: this.labelResolver, + ...objMerge( + this.fields + .filter(field => field.access[schemaName].read) + .map(field => + // Get the resolvers for the (possibly multiple) output fields and wrap each with list-specific modifiers + mapKeys(field.gqlOutputFieldResolvers({ schemaName }), innerResolver => + this._wrapFieldResolver(field, innerResolver) + ) + ) + ), + }; + return { [this.gqlNames.outputTypeName]: fieldResolvers }; + } + + gqlAuxQueryResolvers() { + // TODO: Obey the same ACL rules based on parent type + return objMerge(this.fields.map(field => field.gqlAuxQueryResolvers())); + } + + gqlQueryResolvers({ schemaName }) { + const schemaAccess = this.access[schemaName]; + let resolvers = {}; + + // If set to false, we can confidently remove these resolvers entirely from + // the graphql schema + if (schemaAccess.read) { + resolvers = { + [this.gqlNames.listQueryName]: (_, args, context, info) => + this.listQuery(args, context, this.gqlNames.listQueryName, info), + + [this.gqlNames.listQueryMetaName]: (_, args, context, info) => + this.listQueryMeta(args, context, this.gqlNames.listQueryMetaName, info), + + [this.gqlNames.listMetaName]: (_, args, context) => this.listMeta(context), + + [this.gqlNames.itemQueryName]: (_, args, context, info) => + this.itemQuery(args, context, this.gqlNames.itemQueryName, info), + }; + } + + return resolvers; + } + + listMeta(context) { + return { + key: this.key, + name: this.key, + // Return these as functions so they're lazily evaluated depending + // on what the user requested + // Evalutation takes place in ../Keystone/index.js + // NOTE: These could return a Boolean or a JSON object (if using the + // declarative syntax) + getAccess: () => ({ + getCreate: () => context.getListAccessControlForUser(this.key, undefined, 'create'), + getRead: () => context.getListAccessControlForUser(this.key, undefined, 'read'), + getUpdate: () => context.getListAccessControlForUser(this.key, undefined, 'update'), + getDelete: () => context.getListAccessControlForUser(this.key, undefined, 'delete'), + getAuth: () => context.getAuthAccessControlForUser(this.key), + }), + getSchema: () => { + const queries = [ + this.gqlNames.itemQueryName, + this.gqlNames.listQueryName, + this.gqlNames.listQueryMetaName, + ]; + + // NOTE: Other fields on this type are resolved in the main resolver in + // ../Keystone/index.js + return { + type: this.gqlNames.outputTypeName, + queries, + key: this.key, + }; + }, + }; + } + + gqlAuxMutationResolvers() { + // TODO: Obey the same ACL rules based on parent type + return objMerge(this.fields.map(field => field.gqlAuxMutationResolvers())); + } + + gqlMutationResolvers({ schemaName }) { + const schemaAccess = this.access[schemaName]; + const mutationResolvers = {}; + + const createFields = this.getFieldsWithAccess({ schemaName, access: 'create' }); + if (schemaAccess.create && createFields.length) { + mutationResolvers[this.gqlNames.createMutationName] = (_, { data }, context) => + this.createMutation(data, context); + + mutationResolvers[this.gqlNames.createManyMutationName] = (_, { data }, context) => + this.createManyMutation(data, context); + } + + const updateFields = this.getFieldsWithAccess({ schemaName, access: 'update' }); + if (schemaAccess.update && updateFields.length) { + mutationResolvers[this.gqlNames.updateMutationName] = (_, { id, data }, context) => + this.updateMutation(id, data, context); + + mutationResolvers[this.gqlNames.updateManyMutationName] = (_, { data }, context) => + this.updateManyMutation(data, context); + } + + if (schemaAccess.delete) { + mutationResolvers[this.gqlNames.deleteMutationName] = (_, { id }, context) => + this.deleteMutation(id, context); + + mutationResolvers[this.gqlNames.deleteManyMutationName] = (_, { ids }, context) => + this.deleteManyMutation(ids, context); + } + + return mutationResolvers; } };