diff --git a/demo/collections/CustomID.ts b/demo/collections/CustomID.ts new file mode 100644 index 0000000000..94239486f8 --- /dev/null +++ b/demo/collections/CustomID.ts @@ -0,0 +1,19 @@ +import { CollectionConfig } from '../../src/collections/config/types'; + +const CustomID: CollectionConfig = { + slug: 'custom-id', + labels: { + singular: 'CustomID', + plural: 'CustomIDs', + }, + id: Number, + fields: [ + { + name: 'name', + type: 'text', + required: true, + }, + ], +}; + +export default CustomID; diff --git a/demo/collections/RelationshipA.ts b/demo/collections/RelationshipA.ts index 0da9cbb70d..0d66c21f2e 100644 --- a/demo/collections/RelationshipA.ts +++ b/demo/collections/RelationshipA.ts @@ -49,6 +49,14 @@ const RelationshipA: CollectionConfig = { relationTo: 'relationship-b', hasMany: false, }, + { + name: 'customID', + label: 'CustomID Relation', + type: 'relationship', + relationTo: 'custom-id', + hasMany: true, + localized: true, + }, ], timestamps: true, }; diff --git a/demo/payload.config.ts b/demo/payload.config.ts index 237c102c9e..834c59ef75 100644 --- a/demo/payload.config.ts +++ b/demo/payload.config.ts @@ -9,6 +9,7 @@ import Conditions from './collections/Conditions'; import CustomComponents from './collections/CustomComponents'; import File from './collections/File'; import Blocks from './collections/Blocks'; +import CustomID from './collections/CustomID'; import DefaultValues from './collections/DefaultValues'; import HiddenFields from './collections/HiddenFields'; import Hooks from './collections/Hooks'; @@ -64,6 +65,7 @@ export default buildConfig({ Code, Conditions, CustomComponents, + CustomID, File, DefaultValues, Blocks, diff --git a/src/admin/components/elements/SortColumn/index.tsx b/src/admin/components/elements/SortColumn/index.tsx index bb595202bc..039c72d0a2 100644 --- a/src/admin/components/elements/SortColumn/index.tsx +++ b/src/admin/components/elements/SortColumn/index.tsx @@ -17,9 +17,8 @@ const SortColumn: React.FC = (props) => { handleChange(sort); }, [sort, handleChange]); - const formattedName = name === 'id' ? '_id' : name; - const desc = `-${formattedName}`; - const asc = formattedName; + const desc = `-${name}`; + const asc = name; const ascClasses = [`${baseClass}__asc`]; if (sort === asc) ascClasses.push(`${baseClass}--active`); diff --git a/src/collections/graphql/init.ts b/src/collections/graphql/init.ts index 2c572f19da..4ebe3efd8b 100644 --- a/src/collections/graphql/init.ts +++ b/src/collections/graphql/init.ts @@ -1,5 +1,11 @@ import { DateTimeResolver } from 'graphql-scalars'; -import { GraphQLString, GraphQLObjectType, GraphQLBoolean, GraphQLNonNull, GraphQLInt } from 'graphql'; +import { + GraphQLString, + GraphQLObjectType, + GraphQLBoolean, + GraphQLNonNull, + GraphQLInt, +} from 'graphql'; import formatName from '../../graphql/utilities/formatName'; import buildPaginatedListType from '../../graphql/schema/buildPaginatedListType'; @@ -43,9 +49,21 @@ function registerCollections(): void { collection.graphQL = {}; + let idType; + let idFieldType; + switch (collection.config.id) { + case Number: + idType = GraphQLInt; + idFieldType = 'number'; + break; + + default: + idType = GraphQLString; + idFieldType = 'text'; + } const baseFields: BaseFields = { id: { - type: new GraphQLNonNull(GraphQLString), + type: new GraphQLNonNull(idType), }, }; @@ -53,6 +71,13 @@ function registerCollections(): void { ...fields, ]; + if (collection.config.id) { + whereInputFields.push({ + name: 'id', + type: collection.config.id, + }); + } + if (timestamps) { baseFields.createdAt = { type: new GraphQLNonNull(DateTimeResolver), @@ -100,7 +125,7 @@ function registerCollections(): void { const mutationInputFields = collection.config.id ? [{ name: 'id', - type: 'text', + type: idFieldType, required: true, }, ...fields] : fields; @@ -113,7 +138,7 @@ function registerCollections(): void { collection.graphQL.updateMutationInputType = new GraphQLNonNull(this.buildMutationInputType( `${singularLabel}Update`, - fields, + mutationInputFields, `${singularLabel}Update`, true, )); @@ -121,7 +146,7 @@ function registerCollections(): void { this.Query.fields[singularLabel] = { type: collection.graphQL.type, args: { - id: { type: GraphQLString }, + id: { type: idType }, ...(this.config.localization ? { locale: { type: this.types.localeInputType }, fallbackLocale: { type: this.types.fallbackLocaleInputType }, @@ -156,7 +181,7 @@ function registerCollections(): void { this.Mutation.fields[`update${singularLabel}`] = { type: collection.graphQL.type, args: { - id: { type: new GraphQLNonNull(GraphQLString) }, + id: { type: new GraphQLNonNull(idType) }, data: { type: collection.graphQL.updateMutationInputType }, }, resolve: update(collection), @@ -165,7 +190,7 @@ function registerCollections(): void { this.Mutation.fields[`delete${singularLabel}`] = { type: collection.graphQL.type, args: { - id: { type: new GraphQLNonNull(GraphQLString) }, + id: { type: new GraphQLNonNull(idType) }, }, resolve: deleteResolver(collection), }; diff --git a/src/collections/graphql/resolvers/resolvers.spec.js b/src/collections/graphql/resolvers/resolvers.spec.js index 4d8c446472..1fa99237d0 100644 --- a/src/collections/graphql/resolvers/resolvers.spec.js +++ b/src/collections/graphql/resolvers/resolvers.spec.js @@ -279,4 +279,139 @@ describe('GrahpQL Resolvers', () => { expect(typeof error.response.errors[0].message).toBe('string'); }); }); + + describe('Custom ID', () => { + it('should create', async () => { + const id = 10; + const query = `mutation { + createCustomID(data: { + id: ${id}, + name: "custom" + }) { + id, + name + } + }`; + const response = await client.request(query); + const data = response.createCustomID; + expect(data.id).toStrictEqual(id); + }); + + it('should update', async () => { + const id = 11; + const name = 'custom name'; + + const query = ` + mutation { + createCustomID(data: { + id: ${id}, + name: "${name}" + }) { + id + name + } + }`; + + await client.request(query); + const updatedName = 'updated name'; + + const update = ` + mutation { + updateCustomID(id: ${id} data: {name: "${updatedName}"}) { + name + } + }`; + + const response = await client.request(update); + const data = response.updateCustomID; + + expect(data.name).toStrictEqual(updatedName); + expect(data.name).not.toStrictEqual(name); + }); + + it('should query on id', async () => { + const id = 15; + const name = 'custom name'; + + const create = `mutation { + createCustomID(data: { + id: ${id}, + name: "${name}" + }) { + id + name + } + }`; + + await client.request(create); + + const query = ` + query { + CustomIDs(where: { id: { equals: ${id} } }) { + docs { + id + name + } + } + }`; + const response = await client.request(query); + const [doc] = response.CustomIDs.docs; + expect(doc.id).toStrictEqual(id); + expect(doc.name).toStrictEqual(name); + }); + + it('should delete', async () => { + const id = 12; + const query = `mutation { + createCustomID(data: { + id: ${id}, + name: "delete me" + }) { + id + name + } + }`; + + await client.request(query); + + const deleteMutation = `mutation { + deleteCustomID(id: ${id}) { + id + } + }`; + const deleteResponse = await client.request(deleteMutation); + const deletedId = deleteResponse.deleteCustomID.id; + + expect(deletedId).toStrictEqual(id); + }); + + it('should allow relationships', async () => { + const id = 13; + const query = `mutation { + createCustomID(data: { + id: ${id}, + name: "relate me" + }) { + id + name + } + }`; + + await client.request(query); + const relation = `mutation { + createRelationshipA(data: { + customID: [ ${id} ] + }) { + customID { + id + } + } + }`; + const relationResponse = await client.request(relation); + const { customID } = relationResponse.createRelationshipA; + + expect(customID).toHaveLength(1); + expect(customID).toHaveLength(1); + }); + }); }); diff --git a/src/collections/operations/create.ts b/src/collections/operations/create.ts index 727f80d906..5552b8d5c3 100644 --- a/src/collections/operations/create.ts +++ b/src/collections/operations/create.ts @@ -229,6 +229,8 @@ async function create(this: Payload, incomingArgs: Arguments): Promise let result: Document = doc.toJSON({ virtuals: true }); const verificationToken = result._verificationToken; + // custom id type reset + result.id = result._id; result = JSON.stringify(result); result = JSON.parse(result); result = sanitizeInternalFields(result); diff --git a/src/collections/operations/delete.ts b/src/collections/operations/delete.ts index 57d0f98ad5..b7138084e6 100644 --- a/src/collections/operations/delete.ts +++ b/src/collections/operations/delete.ts @@ -135,6 +135,8 @@ async function deleteQuery(incomingArgs: Arguments): Promise { let result: Document = doc.toJSON({ virtuals: true }); + // custom id type reset + result.id = result._id; result = JSON.stringify(result); result = JSON.parse(result); result = sanitizeInternalFields(result); diff --git a/src/collections/operations/find.ts b/src/collections/operations/find.ts index 8eeab70501..53579f6c68 100644 --- a/src/collections/operations/find.ts +++ b/src/collections/operations/find.ts @@ -100,6 +100,8 @@ async function find(incomingArgs: Arguments): Promise { } else { sort = '-_id'; } + } else if (sort === 'id' || sort === '-id') { + sort = sort.replace('id', '_id'); } const optionsToExecute = { diff --git a/src/collections/operations/update.ts b/src/collections/operations/update.ts index c516696f98..2b579f49e4 100644 --- a/src/collections/operations/update.ts +++ b/src/collections/operations/update.ts @@ -265,6 +265,9 @@ async function update(incomingArgs: Arguments): Promise { } result = result.toJSON({ virtuals: true }); + + // custom id type reset + result.id = result._id; result = JSON.stringify(result); result = JSON.parse(result); result = sanitizeInternalFields(result); diff --git a/src/collections/tests/collections.spec.js b/src/collections/tests/collections.spec.js index 736d515a7c..ce34a1d539 100644 --- a/src/collections/tests/collections.spec.js +++ b/src/collections/tests/collections.spec.js @@ -548,4 +548,92 @@ describe('Collections - REST', () => { expect(failedResponse.status).toStrictEqual(500); }); }); + + describe('Custom ID', () => { + const document = { + id: 1, + name: 'name', + }; + let data; + beforeAll(async (done) => { + // create document + const create = await fetch(`${url}/api/custom-id`, { + body: JSON.stringify(document), + headers, + method: 'post', + }); + data = await create.json(); + done(); + }); + + + it('should create collections with custom ID', async () => { + expect(data.doc.id).toBe(document.id); + }); + + it('should read collections by custom ID', async () => { + const response = await fetch(`${url}/api/custom-id/${document.id}`, { + headers, + method: 'get', + }); + + const result = await response.json(); + + expect(result.id).toStrictEqual(document.id); + expect(result.name).toStrictEqual(document.name); + }); + + it('should update collection by custom ID', async () => { + const updatedDoc = { id: 'cannot-update-id', name: 'updated' }; + const response = await fetch(`${url}/api/custom-id/${document.id}`, { + headers, + body: JSON.stringify(updatedDoc), + method: 'put', + }); + + const result = await response.json(); + + expect(result.doc.id).not.toStrictEqual(updatedDoc.id); + expect(result.doc.name).not.toStrictEqual(document.name); + expect(result.doc.name).toStrictEqual(updatedDoc.name); + }); + + it('should delete collection by custom ID', async () => { + const doc = { + id: 2, + name: 'delete me', + }; + const createResponse = await fetch(`${url}/api/custom-id`, { + body: JSON.stringify(doc), + headers, + method: 'post', + }); + const result = await createResponse.json(); + const response = await fetch(`${url}/api/custom-id/${result.doc.id}`, { + headers, + method: 'delete', + }); + + expect(response.status).toBe(200); + const deleteData = await response.json(); + expect(deleteData.id).toBe(doc.id); + }); + + it('should allow querying by custom ID', async () => { + const response = await fetch(`${url}/api/custom-id?where[id][equals]=${document.id}`, { + headers, + method: 'get', + }); + const emptyResponse = await fetch(`${url}/api/custom-id?where[id][equals]=900`, { + headers, + method: 'get', + }); + + const result = await response.json(); + const emptyResult = await emptyResponse.json(); + + expect(result.docs).toHaveLength(1); + expect(emptyResult.docs).toHaveLength(0); + }); + }); }); diff --git a/src/collections/tests/relationships.spec.js b/src/collections/tests/relationships.spec.js index a81189f080..38ed8ebd87 100644 --- a/src/collections/tests/relationships.spec.js +++ b/src/collections/tests/relationships.spec.js @@ -103,5 +103,31 @@ describe('Collections - REST', () => { expect(doc.postMaxDepth).toBe(documentB.id); expect(doc.postMaxDepth).not.toHaveProperty('post'); }); + + it('should allow a custom id relation', async () => { + const customID = { + id: 30, + name: 'custom', + }; + + const newCustomID = await fetch(`${url}/api/custom-id`, { + headers, + body: JSON.stringify(customID), + method: 'post', + }); + + const custom = await newCustomID.json(); + const response = await fetch(`${url}/api/relationship-a/${documentA.id}`, { + headers, + body: JSON.stringify({ + ...documentA, + post: documentB.id, + customID: [custom.doc.id], + }), + method: 'put', + }); + const { doc } = await response.json(); + expect(doc.customID[0].id).toBe(customID.id); + }); }); }); diff --git a/src/graphql/schema/buildMutationInputType.ts b/src/graphql/schema/buildMutationInputType.ts index 32d9933309..0e04f4c28b 100644 --- a/src/graphql/schema/buildMutationInputType.ts +++ b/src/graphql/schema/buildMutationInputType.ts @@ -4,6 +4,7 @@ import { GraphQLEnumType, GraphQLFloat, GraphQLInputObjectType, + GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLScalarType, @@ -16,6 +17,17 @@ import formatName from '../utilities/formatName'; import combineParentName from '../utilities/combineParentName'; import { ArrayField, Field, FieldWithSubFields, GroupField, RelationshipField, RowField, SelectField } from '../../fields/config/types'; import { toWords } from '../../utilities/formatLabels'; +import payload from '../../index'; + +const getCollectionIDType = (id) => { + switch (id) { + case Number: + return GraphQLInt; + + default: + return GraphQLString; + } +}; function buildMutationInputType(name: string, fields: Field[], parentName: string, forceNullable = false): GraphQLInputObjectType { const fieldToSchemaMap = { @@ -85,9 +97,11 @@ function buildMutationInputType(name: string, fields: Field[], parentName: strin }), {}), }), }, - value: { type: GraphQLString }, + value: { type: GraphQLJSON }, }, }); + } else { + type = getCollectionIDType(payload.collections[relationTo].config.id); } return { type: field.hasMany ? new GraphQLList(type) : type }; diff --git a/src/graphql/schema/buildWhereInputType.ts b/src/graphql/schema/buildWhereInputType.ts index 7863bedcaf..1079cbe1f3 100644 --- a/src/graphql/schema/buildWhereInputType.ts +++ b/src/graphql/schema/buildWhereInputType.ts @@ -344,7 +344,7 @@ const buildWhereInputType = (name: string, fields: Field[], parentName: string): fieldTypes.id = { type: withOperators( { name: 'id' } as Field, - GraphQLString, + GraphQLJSON, parentName, [...operators.equality, ...operators.contains], ),