diff --git a/packages/core/src/fields/types/relationship/index.ts b/packages/core/src/fields/types/relationship/index.ts index c8e36c26233..8c94c982326 100644 --- a/packages/core/src/fields/types/relationship/index.ts +++ b/packages/core/src/fields/types/relationship/index.ts @@ -92,9 +92,7 @@ export const relationship = const [foreignListKey, foreignFieldKey] = ref.split('.'); const foreignList = lists[foreignListKey]; if (!foreignList) { - throw new Error( - `Unable to resolve list '${foreignListKey}' for field ${listKey}.${fieldKey}` - ); + throw new Error(`${listKey}.${fieldKey} points to ${ref}, but ${ref} doesn't exist`); } const foreignListTypes = foreignList.types; diff --git a/packages/core/src/lib/core/resolve-relationships.ts b/packages/core/src/lib/core/resolve-relationships.ts index 9060677c5e6..e69bd75ba14 100644 --- a/packages/core/src/lib/core/resolve-relationships.ts +++ b/packages/core/src/lib/core/resolve-relationships.ts @@ -129,32 +129,21 @@ export function resolveRelationships( alreadyResolvedTwoSidedRelationships.add(foreignRef); const foreignField = foreignUnresolvedList.fields[field.field]?.dbField; if (!foreignField) { - throw new Error( - `The relationship field at ${localRef} points to ${foreignRef} but no field at ${foreignRef} exists` - ); + throw new Error(`${localRef} points to ${foreignRef}, but ${foreignRef} doesn't exist`); } if (foreignField.kind !== 'relation') { throw new Error( - `The relationship field at ${localRef} points to ${foreignRef} but ${foreignRef} is not a relationship field` - ); - } - - if (foreignField.list !== listKey) { - throw new Error( - `The relationship field at ${localRef} points to ${foreignRef} but ${foreignRef} points to the list ${foreignField.list} rather than ${listKey}` - ); - } - - if (foreignField.field === undefined) { - throw new Error( - `The relationship field at ${localRef} points to ${foreignRef}, ${localRef} points to ${listKey} correctly but does not point to the ${fieldPath} field when it should` + `${localRef} points to ${foreignRef}, but ${foreignRef} is not a relationship field` ); } - if (foreignField.field !== fieldPath) { + const actualRef = foreignField.field + ? `${foreignField.list}.${foreignField.field}` + : foreignField.list; + if (actualRef !== localRef) { throw new Error( - `The relationship field at ${localRef} points to ${foreignRef}, ${localRef} points to ${listKey} correctly but points to the ${foreignField.field} field instead of ${fieldPath}` + `${localRef} points to ${foreignRef}, ${foreignRef} points to ${actualRef}, expected ${foreignRef} to point to ${localRef}` ); } diff --git a/tests/api-tests/fields/types/fixtures/relationship/implementation.test.ts b/tests/api-tests/fields/types/fixtures/relationship/implementation.test.ts deleted file mode 100644 index 16f1203937d..00000000000 --- a/tests/api-tests/fields/types/fixtures/relationship/implementation.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { assertInputObjectType, printType, assertObjectType, parse } from 'graphql'; -import { createSystem, initConfig } from '@keystone-6/core/system'; -import { config, list } from '@keystone-6/core'; -import { text, relationship } from '@keystone-6/core/fields'; -import { allowAll } from '@keystone-6/core/access'; - -const fieldKey = 'foo'; - -const getSchema = (field: any) => { - return createSystem( - initConfig( - config({ - db: { url: 'file:./thing.db', provider: 'sqlite' }, - lists: { - Zip: list({ fields: { thing: text() }, access: allowAll }), - Test: list({ - access: allowAll, - fields: { - [fieldKey]: field, - }, - }), - }, - }) - ) - ).graphQLSchema; -}; - -describe('Type Generation', () => { - test('inputs for relationship fields in create args', () => { - const relMany = getSchema(relationship({ many: true, ref: 'Zip' })); - expect( - assertInputObjectType(relMany.getType('TestCreateInput')).getFields().foo.type.toString() - ).toEqual('ZipRelateToManyForCreateInput'); - - const relSingle = getSchema(relationship({ many: false, ref: 'Zip' })); - expect( - assertInputObjectType(relSingle.getType('TestCreateInput')).getFields().foo.type.toString() - ).toEqual('ZipRelateToOneForCreateInput'); - }); - - test('inputs for relationship fields in update args', () => { - const relMany = getSchema(relationship({ many: true, ref: 'Zip' })); - expect( - assertInputObjectType(relMany.getType('TestUpdateInput')).getFields().foo.type.toString() - ).toEqual('ZipRelateToManyForUpdateInput'); - - const relSingle = getSchema(relationship({ many: false, ref: 'Zip' })); - expect( - assertInputObjectType(relSingle.getType('TestUpdateInput')).getFields().foo.type.toString() - ).toEqual('ZipRelateToOneForUpdateInput'); - }); - - test('to-one for create relationship nested mutation input', () => { - const schema = getSchema(relationship({ many: false, ref: 'Zip' })); - - expect(printType(schema.getType('ZipRelateToOneForCreateInput')!)).toMatchInlineSnapshot(` -"input ZipRelateToOneForCreateInput { - create: ZipCreateInput - connect: ZipWhereUniqueInput -}" -`); - }); - - test('to-one for update relationship nested mutation input', () => { - const schema = getSchema(relationship({ many: false, ref: 'Zip' })); - - expect(printType(schema.getType('ZipRelateToOneForUpdateInput')!)).toMatchInlineSnapshot(` -"input ZipRelateToOneForUpdateInput { - create: ZipCreateInput - connect: ZipWhereUniqueInput - disconnect: Boolean -}" -`); - }); - - test('to-many for create relationship nested mutation input', () => { - const schema = getSchema(relationship({ many: true, ref: 'Zip' })); - - expect(printType(schema.getType('ZipRelateToManyForCreateInput')!)).toMatchInlineSnapshot(` -"input ZipRelateToManyForCreateInput { - create: [ZipCreateInput!] - connect: [ZipWhereUniqueInput!] -}" -`); - }); - - test('to-many for update relationship nested mutation input', () => { - const schema = getSchema(relationship({ many: true, ref: 'Zip' })); - - expect(printType(schema.getType('ZipRelateToManyForUpdateInput')!)).toMatchInlineSnapshot(` -"input ZipRelateToManyForUpdateInput { - disconnect: [ZipWhereUniqueInput!] - set: [ZipWhereUniqueInput!] - create: [ZipCreateInput!] - connect: [ZipWhereUniqueInput!] -}" -`); - }); - - test('to-one relationships cannot be filtered at the field level', () => { - const schema = getSchema(relationship({ many: false, ref: 'Zip' })); - - expect( - ( - parse(printType(assertObjectType(schema.getType('Test')))).definitions[0] as any - ).fields.find((x: any) => x.name.value === fieldKey) - ).toMatchObject({ - kind: 'FieldDefinition', - name: { - value: fieldKey, - }, - arguments: [], - type: { - name: { - value: 'Zip', - }, - }, - }); - }); - - test('to-many relationships can be filtered at the field level', () => { - const schema = getSchema(relationship({ many: true, ref: 'Zip' })); - - expect(printType(schema.getType('Test')!)).toMatchInlineSnapshot(` -"type Test { - id: ID! - foo(where: ZipWhereInput! = {}, orderBy: [ZipOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: ZipWhereUniqueInput): [Zip!] - fooCount(where: ZipWhereInput! = {}): Int -}" -`); - }); -}); - -describe('Referenced list errors', () => { - test('throws when list not found', async () => { - expect(() => getSchema(relationship({ ref: 'DoesNotExist' }))).toThrow( - "Unable to resolve list 'DoesNotExist' for field Test.foo" - ); - }); - - test('does not throw when no two way relationship specified', async () => { - getSchema(relationship({ many: true, ref: 'Zip' })); - }); - - test('throws when field on list not found', async () => { - expect(() => getSchema(relationship({ many: true, ref: 'Zip.bar' }))).toThrow( - 'The relationship field at Test.foo points to Zip.bar but no field at Zip.bar exists' - ); - }); -}); diff --git a/tests/api-tests/fields/types/relationship.test.ts b/tests/api-tests/fields/types/relationship.test.ts new file mode 100644 index 00000000000..e21188cfaea --- /dev/null +++ b/tests/api-tests/fields/types/relationship.test.ts @@ -0,0 +1,257 @@ +import { assertInputObjectType, printType, assertObjectType, parse } from 'graphql'; +import { createSystem, initConfig } from '@keystone-6/core/system'; +import type { ListSchemaConfig } from '@keystone-6/core/types'; +import { config, list } from '@keystone-6/core'; +import { text, relationship } from '@keystone-6/core/fields'; +import { allowAll } from '@keystone-6/core/access'; + +const fieldKey = 'foo'; + +function getSchema(field: { ref: string; many?: boolean }) { + return createSystem( + initConfig( + config({ + db: { url: 'file:./thing.db', provider: 'sqlite' }, + lists: { + Zip: list({ fields: { thing: text() }, access: allowAll }), + Test: list({ + access: allowAll, + fields: { + [fieldKey]: relationship(field), + }, + }), + }, + }) + ) + ).graphQLSchema; +} + +describe('Type Generation', () => { + test('inputs for relationship fields in create args', () => { + const relMany = getSchema({ many: true, ref: 'Zip' }); + expect( + assertInputObjectType(relMany.getType('TestCreateInput')).getFields().foo.type.toString() + ).toEqual('ZipRelateToManyForCreateInput'); + + const relSingle = getSchema({ many: false, ref: 'Zip' }); + expect( + assertInputObjectType(relSingle.getType('TestCreateInput')).getFields().foo.type.toString() + ).toEqual('ZipRelateToOneForCreateInput'); + }); + + test('inputs for relationship fields in update args', () => { + const relMany = getSchema({ many: true, ref: 'Zip' }); + expect( + assertInputObjectType(relMany.getType('TestUpdateInput')).getFields().foo.type.toString() + ).toEqual('ZipRelateToManyForUpdateInput'); + + const relSingle = getSchema({ many: false, ref: 'Zip' }); + expect( + assertInputObjectType(relSingle.getType('TestUpdateInput')).getFields().foo.type.toString() + ).toEqual('ZipRelateToOneForUpdateInput'); + }); + + test('to-one for create relationship nested mutation input', () => { + const schema = getSchema({ many: false, ref: 'Zip' }); + + expect(printType(schema.getType('ZipRelateToOneForCreateInput')!)).toMatchInlineSnapshot(` +"input ZipRelateToOneForCreateInput { + create: ZipCreateInput + connect: ZipWhereUniqueInput +}" +`); + }); + + test('to-one for update relationship nested mutation input', () => { + const schema = getSchema({ many: false, ref: 'Zip' }); + + expect(printType(schema.getType('ZipRelateToOneForUpdateInput')!)).toMatchInlineSnapshot(` +"input ZipRelateToOneForUpdateInput { + create: ZipCreateInput + connect: ZipWhereUniqueInput + disconnect: Boolean +}" +`); + }); + + test('to-many for create relationship nested mutation input', () => { + const schema = getSchema({ many: true, ref: 'Zip' }); + + expect(printType(schema.getType('ZipRelateToManyForCreateInput')!)).toMatchInlineSnapshot(` +"input ZipRelateToManyForCreateInput { + create: [ZipCreateInput!] + connect: [ZipWhereUniqueInput!] +}" +`); + }); + + test('to-many for update relationship nested mutation input', () => { + const schema = getSchema({ many: true, ref: 'Zip' }); + + expect(printType(schema.getType('ZipRelateToManyForUpdateInput')!)).toMatchInlineSnapshot(` +"input ZipRelateToManyForUpdateInput { + disconnect: [ZipWhereUniqueInput!] + set: [ZipWhereUniqueInput!] + create: [ZipCreateInput!] + connect: [ZipWhereUniqueInput!] +}" +`); + }); + + test('to-one relationships cannot be filtered at the field level', () => { + const schema = getSchema({ many: false, ref: 'Zip' }); + + expect( + ( + parse(printType(assertObjectType(schema.getType('Test')))).definitions[0] as any + ).fields.find((x: any) => x.name.value === fieldKey) + ).toMatchObject({ + kind: 'FieldDefinition', + name: { + value: fieldKey, + }, + arguments: [], + type: { + name: { + value: 'Zip', + }, + }, + }); + }); + + test('to-many relationships can be filtered at the field level', () => { + const schema = getSchema({ many: true, ref: 'Zip' }); + + expect(printType(schema.getType('Test')!)).toMatchInlineSnapshot(` +"type Test { + id: ID! + foo(where: ZipWhereInput! = {}, orderBy: [ZipOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: ZipWhereUniqueInput): [Zip!] + fooCount(where: ZipWhereInput! = {}): Int +}" +`); + }); +}); + +describe('Reference errors', () => { + function tryf(lists: ListSchemaConfig) { + return createSystem( + initConfig( + config({ + db: { url: 'file:./thing.db', provider: 'sqlite' }, + lists, + }) + ) + ).graphQLSchema; + } + + const fixtures = { + 'list not found': { + lists: { + Foo: list({ + access: allowAll, + fields: { + bar: relationship({ ref: 'Abc.def' }), + }, + }), + }, + error: `Foo.bar points to Abc.def, but Abc.def doesn't exist`, + }, + 'field not found': { + lists: { + Foo: list({ + access: allowAll, + fields: { + bar: relationship({ ref: 'Abc.def' }), + }, + }), + Abc: list({ + access: allowAll, + fields: {}, + }), + }, + error: `Foo.bar points to Abc.def, but Abc.def doesn't exist`, + }, + '1-way / 2-way conflict': { + lists: { + Foo: list({ + access: allowAll, + fields: { + bar: relationship({ ref: 'Abc.def' }), + }, + }), + Abc: list({ + access: allowAll, + fields: { + def: relationship({ ref: 'Foo' }), + }, + }), + }, + error: `Foo.bar points to Abc.def, Abc.def points to Foo, expected Abc.def to point to Foo.bar`, + }, + '3-way / 2-way conflict': { + lists: { + Foo: list({ + access: allowAll, + fields: { + bar: relationship({ ref: 'Abc.def' }), + }, + }), + Abc: list({ + access: allowAll, + fields: { + def: relationship({ ref: 'Foo.bazzz' }), + }, + }), + }, + error: `Foo.bar points to Abc.def, Abc.def points to Foo.bazzz, expected Abc.def to point to Foo.bar`, + }, + 'field wrong type': { + lists: { + Foo: list({ + access: allowAll, + fields: { + bar: relationship({ ref: 'Abc.def' }), + }, + }), + Abc: list({ + access: allowAll, + fields: { + def: text(), + }, + }), + }, + error: `Foo.bar points to Abc.def, but Abc.def is not a relationship field`, + }, + '1-way relationships': { + lists: { + Foo: list({ + access: allowAll, + fields: { + bar: relationship({ ref: 'Abc' }), + }, + }), + Abc: list({ + access: allowAll, + fields: {}, + }), + }, + error: null, + }, + }; + + for (const [description, { lists, error }] of Object.entries(fixtures)) { + if (error) { + test(`throws for ${description}`, () => { + expect(() => { + tryf(lists); + }).toThrow(error); + }); + } else { + test(`does not throw for ${description}`, () => { + expect(() => { + tryf(lists); + }).not.toThrow(); + }); + } + } +});