diff --git a/.changeset/silent-eyes-yell.md b/.changeset/silent-eyes-yell.md new file mode 100644 index 00000000000..225c05a0214 --- /dev/null +++ b/.changeset/silent-eyes-yell.md @@ -0,0 +1,14 @@ +--- +'@keystonejs/adapter-knex': major +'@keystonejs/adapter-mongoose': major +'@keystonejs/fields': major +'@keystonejs/keystone': major +'@keystonejs/mongo-join-builder': major +--- + +## Release - Arcade + +This release introduces a **new and improved data schema** for Keystone. +The new data schema simplifies the way your data is stored and will unlock the development of new functionality within Keystone. + +> **Important:** You will need to make changes to your database to take advantage of the new data schema. Please read the full [release notes](https://www.keystonejs.com/discussions/new-data-schema) for instructions on updating your database. diff --git a/api-tests/relationships/crud-self-ref/many-to-many-one-sided.test.js b/api-tests/relationships/crud-self-ref/many-to-many-one-sided.test.js new file mode 100644 index 00000000000..eb069747af4 --- /dev/null +++ b/api-tests/relationships/crud-self-ref/many-to-many-one-sided.test.js @@ -0,0 +1,378 @@ +const { gen, sampleOne } = require('testcheck'); +const { Text, Relationship } = require('@keystonejs/fields'); +const cuid = require('cuid'); +const { multiAdapterRunners, setupServer, graphqlRequest } = require('@keystonejs/test-utils'); + +const alphanumGenerator = gen.alphaNumString.notEmpty(); + +jest.setTimeout(6000000); + +const createInitialData = async keystone => { + const { data } = await graphqlRequest({ + keystone, + query: ` +mutation { + createUsers(data: [{ data: { name: "${sampleOne( + alphanumGenerator + )}" } }, { data: { name: "${sampleOne(alphanumGenerator)}" } }, { data: { name: "${sampleOne( + alphanumGenerator + )}" } }]) { id } +} +`, + }); + return { users: data.createUsers }; +}; + +const createUserAndFriend = async keystone => { + const { + data: { createUser }, + } = await graphqlRequest({ + keystone, + query: ` +mutation { + createUser(data: { + friends: { create: [{ name: "${sampleOne(alphanumGenerator)}" }] } + }) { id friends { id } } +}`, + }); + const { User, Friend } = await getUserAndFriend( + keystone, + createUser.id, + createUser.friends[0].id + ); + + // Sanity check the links are setup correctly + expect(User.friends.map(({ id }) => id.toString())).toStrictEqual([Friend.id.toString()]); + + return { user: createUser, friend: createUser.friends[0] }; +}; + +const getUserAndFriend = async (keystone, userId, friendId) => { + const { data } = await graphqlRequest({ + keystone, + query: ` + { + User(where: { id: "${userId}"} ) { id friends { id } } + Friend: User(where: { id: "${friendId}"} ) { id } + }`, + }); + return data; +}; + +const createReadData = async keystone => { + // create locations [A, A, B, B, C, C]; + const { data } = await graphqlRequest({ + keystone, + query: `mutation create($users: [UsersCreateInput]) { createUsers(data: $users) { id name } }`, + variables: { + users: ['A', 'A', 'B', 'B', 'C', 'C', 'D', 'D', 'E'].map(name => ({ data: { name } })), + }, + }); + const { createUsers } = data; + await Promise.all( + [ + [0, 1, 2, 3, 4, 5], // -> (A1) -> [A, A, B, B, C, C] + [0, 2, 4], // -> (A2) -> [A, B, C] + [0, 1], // -> (B1) -> [A, A] + [0, 2], // -> (B2) -> [A, B] + [0, 4], // -> (C1) -> [A, C] + [2, 3], // -> (C2) -> [B, B] + [0], // -> (D1) -> [A] + [2], // -> (D2) -> [B] + [], // -> (E1) -> [] + ].map(async (locationIdxs, j) => { + const ids = locationIdxs.map(i => ({ id: createUsers[i].id })); + const { data } = await graphqlRequest({ + keystone, + query: `mutation update($friends: [UserWhereUniqueInput], $user: ID!) { updateUser(id: $user data: { + friends: { connect: $friends } + }) { id friends { name }}}`, + variables: { friends: ids, user: createUsers[j].id }, + }); + return data.updateUser; + }) + ); +}; + +multiAdapterRunners().map(({ runner, adapterName }) => + describe(`Adapter: ${adapterName}`, () => { + // 1:1 relationships are symmetric in how they behave, but + // are (in general) implemented in a non-symmetric way. For example, + // in postgres we may decide to store a single foreign key on just + // one of the tables involved. As such, we want to ensure that our + // tests work correctly no matter which side of the relationship is + // defined first. + const createUserList = keystone => + keystone.createList('User', { + fields: { + name: { type: Text }, + friends: { type: Relationship, ref: 'User', many: true }, + }, + }); + const createLists = createUserList; + + describe(`Many-to-many relationships`, () => { + function setupKeystone(adapterName) { + return setupServer({ + adapterName, + name: `ks5-testdb-${cuid()}`, + createLists, + }); + } + + describe('Read', () => { + test( + '_some', + runner(setupKeystone, async ({ keystone }) => { + await createReadData(keystone); + await Promise.all( + [ + ['A', 6], + ['B', 5], + ['C', 3], + ['D', 0], + ].map(async ([name, count]) => { + const { data } = await graphqlRequest({ + keystone, + query: `{ allUsers(where: { friends_some: { name: "${name}"}}) { id }}`, + }); + expect(data.allUsers.length).toEqual(count); + }) + ); + }) + ); + test( + '_none', + runner(setupKeystone, async ({ keystone }) => { + await createReadData(keystone); + await Promise.all( + [ + ['A', 3], + ['B', 4], + ['C', 6], + ['D', 9], + ].map(async ([name, count]) => { + const { data } = await graphqlRequest({ + keystone, + query: `{ allUsers(where: { friends_none: { name: "${name}"}}) { id }}`, + }); + expect(data.allUsers.length).toEqual(count); + }) + ); + }) + ); + test( + '_every', + runner(setupKeystone, async ({ keystone }) => { + await createReadData(keystone); + await Promise.all( + [ + ['A', 3], + ['B', 3], + ['C', 1], + ['D', 1], + ].map(async ([name, count]) => { + const { data } = await graphqlRequest({ + keystone, + query: `{ allUsers(where: { friends_every: { name: "${name}"}}) { id }}`, + }); + expect(data.allUsers.length).toEqual(count); + }) + ); + }) + ); + }); + + describe('Create', () => { + test( + 'With connect', + runner(setupKeystone, async ({ keystone }) => { + const { users } = await createInitialData(keystone); + const user = users[0]; + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + createUser(data: { + friends: { connect: [{ id: "${user.id}" }] } + }) { id friends { id } } + } + `, + }); + expect(errors).toBe(undefined); + expect(data.createUser.friends.map(({ id }) => id.toString())).toEqual([user.id]); + + const { User, Friend } = await getUserAndFriend(keystone, data.createUser.id, user.id); + // Everything should now be connected + expect(User.friends.map(({ id }) => id.toString())).toEqual([Friend.id.toString()]); + }) + ); + + test( + 'With create', + runner(setupKeystone, async ({ keystone }) => { + const friendName = sampleOne(alphanumGenerator); + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + createUser(data: { + friends: { create: [{ name: "${friendName}" }] } + }) { id friends { id } } + } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend( + keystone, + data.createUser.id, + data.createUser.friends[0].id + ); + + // Everything should now be connected + expect(User.friends.map(({ id }) => id.toString())).toEqual([Friend.id.toString()]); + }) + ); + }); + + describe('Update', () => { + test( + 'With connect', + runner(setupKeystone, async ({ keystone }) => { + // Manually setup a connected Company <-> Location + const { user, friend } = await createUserAndFriend(keystone); + + // Sanity check the links don't yet exist + // `...not.toBe(expect.anything())` allows null and undefined values + expect(user.friends).not.toBe(expect.anything()); + + const { errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + updateUser( + id: "${user.id}", + data: { friends: { connect: [{ id: "${friend.id}" }] } } + ) { id friends { id } } } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend(keystone, user.id, friend.id); + // Everything should now be connected + expect(User.friends.map(({ id }) => id.toString())).toEqual([Friend.id.toString()]); + }) + ); + + test( + 'With create', + runner(setupKeystone, async ({ keystone }) => { + const { users } = await createInitialData(keystone); + let user = users[0]; + const friendName = sampleOne(alphanumGenerator); + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + updateUser( + id: "${user.id}", + data: { friends: { create: [{ name: "${friendName}" }] } } + ) { id friends { id name } } + } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend( + keystone, + user.id, + data.updateUser.friends[0].id + ); + + // Everything should now be connected + expect(User.friends.map(({ id }) => id.toString())).toEqual([Friend.id.toString()]); + }) + ); + + test( + 'With disconnect', + runner(setupKeystone, async ({ keystone }) => { + // Manually setup a connected Company <-> Location + const { user, friend } = await createUserAndFriend(keystone); + + // Run the query to disconnect the location from company + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + updateUser( + id: "${user.id}", + data: { friends: { disconnect: [{ id: "${friend.id}" }] } } + ) { id friends { id name } } + } + `, + }); + expect(errors).toBe(undefined); + expect(data.updateUser.id).toEqual(user.id); + expect(data.updateUser.friends).toEqual([]); + + // Check the link has been broken + const result = await getUserAndFriend(keystone, user.id, friend.id); + expect(result.User.friends).toEqual([]); + }) + ); + + test( + 'With disconnectAll', + runner(setupKeystone, async ({ keystone }) => { + // Manually setup a connected Company <-> Location + const { user, friend } = await createUserAndFriend(keystone); + + // Run the query to disconnect the location from company + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + updateUser( + id: "${user.id}", + data: { friends: { disconnectAll: true } } + ) { id friends { id name } } + } + `, + }); + expect(errors).toBe(undefined); + expect(data.updateUser.id).toEqual(user.id); + expect(data.updateUser.friends).toEqual([]); + + // Check the link has been broken + const result = await getUserAndFriend(keystone, user.id, friend.id); + expect(result.User.friends).toEqual([]); + }) + ); + }); + + describe('Delete', () => { + test( + 'delete', + runner(setupKeystone, async ({ keystone }) => { + // Manually setup a connected Company <-> Location + const { user, friend } = await createUserAndFriend(keystone); + + // Run the query to disconnect the location from company + const { data, errors } = await graphqlRequest({ + keystone, + query: `mutation { deleteUser(id: "${user.id}") { id } } `, + }); + expect(errors).toBe(undefined); + expect(data.deleteUser.id).toBe(user.id); + + // Check the link has been broken + const result = await getUserAndFriend(keystone, user.id, friend.id); + expect(result.User).toBe(null); + }) + ); + }); + }); + }) +); diff --git a/api-tests/relationships/crud-self-ref/many-to-many.test.js b/api-tests/relationships/crud-self-ref/many-to-many.test.js new file mode 100644 index 00000000000..10976de637b --- /dev/null +++ b/api-tests/relationships/crud-self-ref/many-to-many.test.js @@ -0,0 +1,501 @@ +const { gen, sampleOne } = require('testcheck'); +const { Text, Relationship } = require('@keystonejs/fields'); +const cuid = require('cuid'); +const { multiAdapterRunners, setupServer, graphqlRequest } = require('@keystonejs/test-utils'); + +const alphanumGenerator = gen.alphaNumString.notEmpty(); + +jest.setTimeout(6000000); + +const createInitialData = async keystone => { + const { data } = await graphqlRequest({ + keystone, + query: ` +mutation { + createUsers(data: [{ data: { name: "${sampleOne( + alphanumGenerator + )}" } }, { data: { name: "${sampleOne(alphanumGenerator)}" } }, { data: { name: "${sampleOne( + alphanumGenerator + )}" } }]) { id } +} +`, + }); + return { users: data.createUsers }; +}; + +const createUserAndFriend = async keystone => { + const { + data: { createUser }, + } = await graphqlRequest({ + keystone, + query: ` +mutation { + createUser(data: { + friends: { create: [{ name: "${sampleOne(alphanumGenerator)}" }] } + }) { id friends { id } } +}`, + }); + const { User, Friend } = await getUserAndFriend( + keystone, + createUser.id, + createUser.friends[0].id + ); + + // Sanity check the links are setup correctly + expect(User.friends[0].id.toString()).toBe(Friend.id.toString()); + expect(Friend.friendOf[0].id.toString()).toBe(User.id.toString()); + + return { user: createUser, friend: createUser.friends[0] }; +}; + +const getUserAndFriend = async (keystone, userId, friendId) => { + const { data } = await graphqlRequest({ + keystone, + query: ` + { + User(where: { id: "${userId}"} ) { id friends { id } } + Friend: User(where: { id: "${friendId}"} ) { id friendOf { id } } + }`, + }); + return data; +}; + +const createReadData = async keystone => { + // create locations [A, A, B, B, C, C]; + const { data } = await graphqlRequest({ + keystone, + query: `mutation create($users: [UsersCreateInput]) { createUsers(data: $users) { id name } }`, + variables: { + users: ['A', 'A', 'B', 'B', 'C', 'C', 'D', 'D', 'E'].map(name => ({ data: { name } })), + }, + }); + const { createUsers } = data; + await Promise.all( + [ + [0, 1, 2, 3, 4, 5], // -> [A, A, B, B, C, C] + [0, 2, 4], // -> [A, B, C] + [0, 1], // -> [A, A] + [0, 2], // -> [A, B] + [0, 4], // -> [A, C] + [2, 3], // -> [B, B] + [0], // -> [A] + [2], // -> [B] + [], // -> [] + ].map(async (locationIdxs, j) => { + const ids = locationIdxs.map(i => ({ id: createUsers[i].id })); + const { data } = await graphqlRequest({ + keystone, + query: `mutation update($friends: [UserWhereUniqueInput], $user: ID!) { updateUser(id: $user data: { + friends: { connect: $friends } + }) { id friends { name }}}`, + variables: { friends: ids, user: createUsers[j].id }, + }); + return data.updateUser; + }) + ); +}; + +multiAdapterRunners().map(({ runner, adapterName }) => + describe(`Adapter: ${adapterName}`, () => { + // 1:1 relationships are symmetric in how they behave, but + // are (in general) implemented in a non-symmetric way. For example, + // in postgres we may decide to store a single foreign key on just + // one of the tables involved. As such, we want to ensure that our + // tests work correctly no matter which side of the relationship is + // defined first. + const createListsLR = keystone => { + keystone.createList('User', { + fields: { + name: { type: Text }, + friends: { type: Relationship, ref: 'User.friendOf', many: true }, + friendOf: { type: Relationship, ref: 'User.friends', many: true }, + }, + }); + }; + const createListsRL = keystone => { + keystone.createList('User', { + fields: { + name: { type: Text }, + friendOf: { type: Relationship, ref: 'User.friends', many: true }, + friends: { type: Relationship, ref: 'User.friendOf', many: true }, + }, + }); + }; + [ + [createListsLR, 'Left -> Right'], + [createListsRL, 'Right -> Left'], + ].forEach(([createLists, order]) => { + describe(`Many-to-many relationships - ${order}`, () => { + function setupKeystone(adapterName) { + return setupServer({ + adapterName, + name: `ks5-testdb-${cuid()}`, + createLists, + }); + } + + describe('Read', () => { + test( + '_some', + runner(setupKeystone, async ({ keystone }) => { + await createReadData(keystone); + await Promise.all( + [ + ['A', 6], + ['B', 5], + ['C', 3], + ['D', 0], + ].map(async ([name, count]) => { + const { data } = await graphqlRequest({ + keystone, + query: `{ allUsers(where: { friends_some: { name: "${name}"}}) { id }}`, + }); + expect(data.allUsers.length).toEqual(count); + }) + ); + }) + ); + test( + '_none', + runner(setupKeystone, async ({ keystone }) => { + await createReadData(keystone); + await Promise.all( + [ + ['A', 3], + ['B', 4], + ['C', 6], + ['D', 9], + ].map(async ([name, count]) => { + const { data } = await graphqlRequest({ + keystone, + query: `{ allUsers(where: { friends_none: { name: "${name}"}}) { id }}`, + }); + expect(data.allUsers.length).toEqual(count); + }) + ); + }) + ); + test( + '_every', + runner(setupKeystone, async ({ keystone }) => { + await createReadData(keystone); + await Promise.all( + [ + ['A', 3], + ['B', 3], + ['C', 1], + ['D', 1], + ].map(async ([name, count]) => { + const { data } = await graphqlRequest({ + keystone, + query: `{ allUsers(where: { friends_every: { name: "${name}"}}) { id }}`, + }); + expect(data.allUsers.length).toEqual(count); + }) + ); + }) + ); + }); + + describe('Create', () => { + test( + 'With connect', + runner(setupKeystone, async ({ keystone }) => { + const { users } = await createInitialData(keystone); + const friend = users[0]; + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + createUser(data: { + friends: { connect: [{ id: "${friend.id}" }] } + }) { id friends { id } } + } + `, + }); + expect(errors).toBe(undefined); + expect(data.createUser.friends[0].id.toString()).toEqual(friend.id); + + const { User, Friend } = await getUserAndFriend( + keystone, + data.createUser.id, + friend.id + ); + + // Everything should now be connected + expect(User.friends.map(({ id }) => id.toString())).toEqual([Friend.id.toString()]); + expect(Friend.friendOf.map(({ id }) => id.toString())).toEqual([User.id.toString()]); + }) + ); + + test( + 'With create', + runner(setupKeystone, async ({ keystone }) => { + const locationName = sampleOne(alphanumGenerator); + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + createUser(data: { + friends: { create: [{ name: "${locationName}" }] } + }) { id friends { id } } + } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend( + keystone, + data.createUser.id, + data.createUser.friends[0].id + ); + + // Everything should now be connected + expect(User.friends.map(({ id }) => id.toString())).toEqual([Friend.id.toString()]); + expect(Friend.friendOf.map(({ id }) => id.toString())).toEqual([User.id.toString()]); + }) + ); + + test( + 'With nested connect', + runner(setupKeystone, async ({ keystone }) => { + const { users } = await createInitialData(keystone); + const user = users[0]; + const friendName = sampleOne(alphanumGenerator); + + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + createUser(data: { + friends: { create: [{ name: "${friendName}" friendOf: { connect: [{ id: "${user.id}" }] } }] } + }) { id friends { id friendOf { id } } } + } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend( + keystone, + data.createUser.id, + data.createUser.friends[0].id + ); + // Everything should now be connected + expect(User.friends.map(({ id }) => id.toString())).toEqual([Friend.id.toString()]); + expect(Friend.friendOf.length).toEqual(2); + + const { + data: { allUsers }, + } = await graphqlRequest({ + keystone, + query: `{ allUsers { id friends { id friendOf { id } } } }`, + }); + + // Both companies should have a location, and the location should have two companies + const linkedUsers = allUsers.filter(({ id }) => id === user.id || id === User.id); + linkedUsers.forEach(({ friends }) => { + expect(friends.map(({ id }) => id)).toEqual([Friend.id.toString()]); + }); + expect(linkedUsers[0].friends[0].friendOf).toEqual([ + { id: linkedUsers[0].id }, + { id: linkedUsers[1].id }, + ]); + }) + ); + + test( + 'With nested create', + runner(setupKeystone, async ({ keystone }) => { + const friendName = sampleOne(alphanumGenerator); + const userName = sampleOne(alphanumGenerator); + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + createUser(data: { + friends: { create: [{ name: "${friendName}" friendOf: { create: [{ name: "${userName}" }] } }] } + }) { id friends { id friendOf { id } } } + } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend( + keystone, + data.createUser.id, + data.createUser.friends[0].id + ); + + // Everything should now be connected + expect(User.friends.map(({ id }) => id.toString())).toEqual([Friend.id.toString()]); + expect(Friend.friendOf.length).toEqual(2); + + // Both companies should have a location, and the location should have two companies + const { + data: { allUsers }, + } = await graphqlRequest({ + keystone, + query: `{ allUsers { id friends { id friendOf { id } } } }`, + }); + allUsers.forEach(({ id, friends }) => { + if (id === Friend.id) { + expect(friends.map(({ id }) => id)).toEqual([]); + } else { + expect(friends.map(({ id }) => id)).toEqual([Friend.id.toString()]); + } + }); + expect(allUsers[0].friends[0].friendOf).toEqual([ + { id: allUsers[0].id }, + { id: allUsers[2].id }, + ]); + }) + ); + }); + + describe('Update', () => { + test( + 'With connect', + runner(setupKeystone, async ({ keystone }) => { + // Manually setup a connected Company <-> Location + const { user, friend } = await createUserAndFriend(keystone); + + // Sanity check the links don't yet exist + // `...not.toBe(expect.anything())` allows null and undefined values + expect(user.friends).not.toBe(expect.anything()); + expect(friend.friendOf).not.toBe(expect.anything()); + + const { errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + updateUser( + id: "${user.id}", + data: { friends: { connect: [{ id: "${friend.id}" }] } } + ) { id friends { id } } } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend(keystone, user.id, friend.id); + // Everything should now be connected + expect(User.friends.map(({ id }) => id.toString())).toEqual([Friend.id.toString()]); + expect(Friend.friendOf.map(({ id }) => id.toString())).toEqual([User.id.toString()]); + }) + ); + + test( + 'With create', + runner(setupKeystone, async ({ keystone }) => { + const { users } = await createInitialData(keystone); + let user = users[0]; + const locationName = sampleOne(alphanumGenerator); + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + updateUser( + id: "${user.id}", + data: { friends: { create: [{ name: "${locationName}" }] } } + ) { id friends { id name } } + } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend( + keystone, + user.id, + data.updateUser.friends[0].id + ); + + // Everything should now be connected + expect(User.friends.map(({ id }) => id.toString())).toEqual([Friend.id.toString()]); + expect(Friend.friendOf.map(({ id }) => id.toString())).toEqual([User.id.toString()]); + }) + ); + + test( + 'With disconnect', + runner(setupKeystone, async ({ keystone }) => { + // Manually setup a connected Company <-> Location + const { user, friend } = await createUserAndFriend(keystone); + + // Run the query to disconnect the location from company + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + updateUser( + id: "${user.id}", + data: { friends: { disconnect: [{ id: "${friend.id}" }] } } + ) { id friends { id name } } + } + `, + }); + expect(errors).toBe(undefined); + expect(data.updateUser.id).toEqual(user.id); + expect(data.updateUser.friends).toEqual([]); + + // Check the link has been broken + const result = await getUserAndFriend(keystone, user.id, friend.id); + expect(result.User.friends).toEqual([]); + expect(result.Friend.friendOf).toEqual([]); + }) + ); + + test( + 'With disconnectAll', + runner(setupKeystone, async ({ keystone }) => { + // Manually setup a connected Company <-> Location + const { user, friend } = await createUserAndFriend(keystone); + + // Run the query to disconnect the location from company + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + updateUser( + id: "${user.id}", + data: { friends: { disconnectAll: true } } + ) { id friends { id name } } + } + `, + }); + expect(errors).toBe(undefined); + expect(data.updateUser.id).toEqual(user.id); + expect(data.updateUser.friends).toEqual([]); + + // Check the link has been broken + const result = await getUserAndFriend(keystone, user.id, friend.id); + expect(result.User.friends).toEqual([]); + expect(result.Friend.friendOf).toEqual([]); + }) + ); + }); + + describe('Delete', () => { + test( + 'delete', + runner(setupKeystone, async ({ keystone }) => { + // Manually setup a connected Company <-> Location + const { user, friend } = await createUserAndFriend(keystone); + + // Run the query to disconnect the location from company + const { data, errors } = await graphqlRequest({ + keystone, + query: `mutation { deleteUser(id: "${user.id}") { id } } `, + }); + expect(errors).toBe(undefined); + expect(data.deleteUser.id).toBe(user.id); + + // Check the link has been broken + const result = await getUserAndFriend(keystone, user.id, friend.id); + expect(result.User).toBe(null); + expect(result.Friend.friendOf).toEqual([]); + }) + ); + }); + }); + }); + }) +); diff --git a/api-tests/relationships/crud-self-ref/one-to-many-one-sided.test.js b/api-tests/relationships/crud-self-ref/one-to-many-one-sided.test.js new file mode 100644 index 00000000000..8c2473cc9c8 --- /dev/null +++ b/api-tests/relationships/crud-self-ref/one-to-many-one-sided.test.js @@ -0,0 +1,276 @@ +const { gen, sampleOne } = require('testcheck'); +const { Text, Relationship } = require('@keystonejs/fields'); +const cuid = require('cuid'); +const { multiAdapterRunners, setupServer, graphqlRequest } = require('@keystonejs/test-utils'); + +const alphanumGenerator = gen.alphaNumString.notEmpty(); + +jest.setTimeout(6000000); + +const createInitialData = async keystone => { + const { data } = await graphqlRequest({ + keystone, + query: ` +mutation { + createUsers(data: [{ data: { name: "${sampleOne( + alphanumGenerator + )}" } }, { data: { name: "${sampleOne(alphanumGenerator)}" } }, { data: { name: "${sampleOne( + alphanumGenerator + )}" } }]) { id } +} +`, + }); + return { users: data.createUsers }; +}; + +const createUserAndFriend = async keystone => { + const { + data: { createUser }, + } = await graphqlRequest({ + keystone, + query: ` +mutation { + createUser(data: { + friend: { create: { name: "${sampleOne(alphanumGenerator)}" } } + }) { id friend { id } } +}`, + }); + const { User, Friend } = await getUserAndFriend(keystone, createUser.id, createUser.friend.id); + + // Sanity check the links are setup correctly + expect(User.friend.id.toString()).toBe(Friend.id.toString()); + + return { user: createUser, friend: createUser.friend }; +}; + +const getUserAndFriend = async (keystone, userId, friendId) => { + const { data } = await graphqlRequest({ + keystone, + query: ` + { + User(where: { id: "${userId}"} ) { id friend { id } } + Friend: User(where: { id: "${friendId}"} ) { id } + }`, + }); + return data; +}; + +multiAdapterRunners().map(({ runner, adapterName }) => + describe(`Adapter: ${adapterName}`, () => { + // 1:1 relationships are symmetric in how they behave, but + // are (in general) implemented in a non-symmetric way. For example, + // in postgres we may decide to store a single foreign key on just + // one of the tables involved. As such, we want to ensure that our + // tests work correctly no matter which side of the relationship is + // defined first. + const createUserList = keystone => + keystone.createList('User', { + fields: { + name: { type: Text }, + friend: { type: Relationship, ref: 'User' }, + }, + }); + const createLists = createUserList; + + describe(`One-to-many relationships `, () => { + function setupKeystone(adapterName) { + return setupServer({ + adapterName, + name: `ks5-testdb-${cuid()}`, + createLists, + }); + } + + describe('Create', () => { + test( + 'With connect', + runner(setupKeystone, async ({ keystone }) => { + const { users } = await createInitialData(keystone); + const user = users[0]; + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + createUser(data: { + friend: { connect: { id: "${user.id}" } } + }) { id friend { id } } + } + `, + }); + expect(errors).toBe(undefined); + expect(data.createUser.friend.id.toString()).toBe(user.id.toString()); + + const { User, Friend } = await getUserAndFriend(keystone, data.createUser.id, user.id); + // Everything should now be connected + expect(User.friend.id.toString()).toBe(Friend.id.toString()); + }) + ); + + test( + 'With create', + runner(setupKeystone, async ({ keystone }) => { + const friendName = sampleOne(alphanumGenerator); + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + createUser(data: { + friend: { create: { name: "${friendName}" } } + }) { id friend { id } } + } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend( + keystone, + data.createUser.id, + data.createUser.friend.id + ); + + // Everything should now be connected + expect(User.friend.id.toString()).toBe(Friend.id.toString()); + }) + ); + }); + + describe('Update', () => { + test( + 'With connect', + runner(setupKeystone, async ({ keystone }) => { + // Manually setup a connected Company <-> Location + const { user, friend } = await createUserAndFriend(keystone); + + // Sanity check the links don't yet exist + // `...not.toBe(expect.anything())` allows null and undefined values + expect(user.friend).not.toBe(expect.anything()); + + const { errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + updateUser( + id: "${user.id}", + data: { friend: { connect: { id: "${friend.id}" } } } + ) { id friend { id } } } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend(keystone, user.id, friend.id); + // Everything should now be connected + expect(User.friend.id.toString()).toBe(Friend.id.toString()); + }) + ); + + test( + 'With create', + runner(setupKeystone, async ({ keystone }) => { + const { users } = await createInitialData(keystone); + let user = users[0]; + const friendName = sampleOne(alphanumGenerator); + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + updateUser( + id: "${user.id}", + data: { friend: { create: { name: "${friendName}" } } } + ) { id friend { id name } } + } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend( + keystone, + user.id, + data.updateUser.friend.id + ); + + // Everything should now be connected + expect(User.friend.id.toString()).toBe(Friend.id.toString()); + }) + ); + + test( + 'With disconnect', + runner(setupKeystone, async ({ keystone }) => { + // Manually setup a connected Company <-> Location + const { friend, user } = await createUserAndFriend(keystone); + + // Run the query to disconnect the location from company + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + updateUser( + id: "${user.id}", + data: { friend: { disconnect: { id: "${friend.id}" } } } + ) { id friend { id name } } + } + `, + }); + expect(errors).toBe(undefined); + expect(data.updateUser.id).toEqual(user.id); + expect(data.updateUser.friend).toBe(null); + + // Check the link has been broken + const result = await getUserAndFriend(keystone, user.id, friend.id); + expect(result.User.friend).toBe(null); + }) + ); + + test( + 'With disconnectAll', + runner(setupKeystone, async ({ keystone }) => { + // Manually setup a connected Company <-> Location + const { friend, user } = await createUserAndFriend(keystone); + + // Run the query to disconnect the location from company + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + updateUser( + id: "${user.id}", + data: { friend: { disconnectAll: true } } + ) { id friend { id name } } + } + `, + }); + expect(errors).toBe(undefined); + expect(data.updateUser.id).toEqual(user.id); + expect(data.updateUser.friend).toBe(null); + + // Check the link has been broken + const result = await getUserAndFriend(keystone, user.id, friend.id); + expect(result.User.friend).toBe(null); + }) + ); + }); + + describe('Delete', () => { + test( + 'delete', + runner(setupKeystone, async ({ keystone }) => { + // Manually setup a connected Company <-> Location + const { friend, user } = await createUserAndFriend(keystone); + + // Run the query to disconnect the location from company + const { data, errors } = await graphqlRequest({ + keystone, + query: `mutation { deleteUser(id: "${user.id}") { id } } `, + }); + expect(errors).toBe(undefined); + expect(data.deleteUser.id).toBe(user.id); + + // Check the link has been broken + const result = await getUserAndFriend(keystone, user.id, friend.id); + expect(result.User).toBe(null); + }) + ); + }); + }); + }) +); diff --git a/api-tests/relationships/crud-self-ref/one-to-many.test.js b/api-tests/relationships/crud-self-ref/one-to-many.test.js new file mode 100644 index 00000000000..501f82b2c27 --- /dev/null +++ b/api-tests/relationships/crud-self-ref/one-to-many.test.js @@ -0,0 +1,516 @@ +const { gen, sampleOne } = require('testcheck'); +const { Text, Relationship } = require('@keystonejs/fields'); +const cuid = require('cuid'); +const { multiAdapterRunners, setupServer, graphqlRequest } = require('@keystonejs/test-utils'); + +const alphanumGenerator = gen.alphaNumString.notEmpty(); + +jest.setTimeout(6000000); + +const createInitialData = async keystone => { + const { data } = await graphqlRequest({ + keystone, + query: ` +mutation { + createUsers(data: [{ data: { name: "${sampleOne( + alphanumGenerator + )}" } }, { data: { name: "${sampleOne(alphanumGenerator)}" } }, { data: { name: "${sampleOne( + alphanumGenerator + )}" } }]) { id } +} +`, + }); + return { users: data.createUsers }; +}; + +const createUserAndFriend = async keystone => { + const { + data: { createUser }, + } = await graphqlRequest({ + keystone, + query: ` +mutation { + createUser(data: { + friends: { create: [{ name: "${sampleOne(alphanumGenerator)}" }] } + }) { id friends { id } } +}`, + }); + const { User, Friend } = await getUserAndFriend( + keystone, + createUser.id, + createUser.friends[0].id + ); + + // Sanity check the links are setup correctly + expect(User.friends.map(({ id }) => id.toString())).toEqual([Friend.id]); + expect(Friend.friendOf.id.toString()).toBe(User.id.toString()); + + return { user: createUser, friend: createUser.friends[0] }; +}; + +const getUserAndFriend = async (keystone, userId, friendId) => { + const { data } = await graphqlRequest({ + keystone, + query: ` + { + User(where: { id: "${userId}"} ) { id friends { id } } + Friend: User(where: { id: "${friendId}"} ) { id friendOf { id } } + }`, + }); + return data; +}; + +const createReadData = async keystone => { + // create locations [A, A, B, B, C, C]; + const { data } = await graphqlRequest({ + keystone, + query: `mutation create($users: [UsersCreateInput]) { createUsers(data: $users) { id name } }`, + variables: { + users: ['A', 'A', 'B', 'B', 'C', 'C'].map(name => ({ data: { name } })), + }, + }); + const { createUsers } = data; + await Promise.all( + Object.entries({ + ABC: [0, 2, 4], // -> [A, B, C] + AB: [1, 3], // -> [A, B] + C: [5], // -> [C] + '': [], // -> [] + }).map(async ([name, locationIdxs]) => { + const ids = locationIdxs.map(i => ({ id: createUsers[i].id })); + const { data } = await graphqlRequest({ + keystone, + query: `mutation create($friends: [UserWhereUniqueInput], $name: String) { createUser(data: { + name: $name + friends: { connect: $friends } + }) { id friends { name }}}`, + variables: { friends: ids, name }, + }); + return data.updateUser; + }) + ); +}; + +multiAdapterRunners().map(({ runner, adapterName }) => + describe(`Adapter: ${adapterName}`, () => { + // 1:1 relationships are symmetric in how they behave, but + // are (in general) implemented in a non-symmetric way. For example, + // in postgres we may decide to store a single foreign key on just + // one of the tables involved. As such, we want to ensure that our + // tests work correctly no matter which side of the relationship is + // defined first. + const createListsLR = keystone => { + keystone.createList('User', { + fields: { + name: { type: Text }, + friends: { type: Relationship, ref: 'User.friendOf', many: true }, + friendOf: { type: Relationship, ref: 'User.friends' }, + }, + }); + }; + const createListsRL = keystone => { + keystone.createList('User', { + fields: { + name: { type: Text }, + friendOf: { type: Relationship, ref: 'User.friends' }, + friends: { type: Relationship, ref: 'User.friendOf', many: true }, + }, + }); + }; + + [ + [createListsLR, 'Left -> Right'], + [createListsRL, 'Right -> Left'], + ].forEach(([createLists, order]) => { + describe(`One-to-many relationships - ${order}`, () => { + function setupKeystone(adapterName) { + return setupServer({ + adapterName, + name: `ks5-testdb-${cuid()}`, + createLists, + }); + } + + describe('Read', () => { + test( + 'one', + runner(setupKeystone, async ({ keystone }) => { + await createReadData(keystone); + await Promise.all( + [ + ['A', 5], + ['B', 5], + ['C', 4], + ['D', 0], + ].map(async ([name, count]) => { + const { data } = await graphqlRequest({ + keystone, + query: `{ allUsers(where: { friendOf: { name_contains: "${name}"}}) { id }}`, + }); + expect(data.allUsers.length).toEqual(count); + }) + ); + }) + ); + test( + '_some', + runner(setupKeystone, async ({ keystone }) => { + await createReadData(keystone); + await Promise.all( + [ + ['A', 2], + ['B', 2], + ['C', 2], + ['D', 0], + ].map(async ([name, count]) => { + const { data } = await graphqlRequest({ + keystone, + query: `{ allUsers(where: { friends_some: { name: "${name}"}}) { id }}`, + }); + expect(data.allUsers.length).toEqual(count); + }) + ); + }) + ); + test( + '_none', + runner(setupKeystone, async ({ keystone }) => { + await createReadData(keystone); + await Promise.all( + [ + ['A', 2 + 6], + ['B', 2 + 6], + ['C', 2 + 6], + ['D', 4 + 6], + ].map(async ([name, count]) => { + const { data } = await graphqlRequest({ + keystone, + query: `{ allUsers(where: { friends_none: { name: "${name}"}}) { id }}`, + }); + expect(data.allUsers.length).toEqual(count); + }) + ); + }) + ); + test( + '_every', + runner(setupKeystone, async ({ keystone }) => { + await createReadData(keystone); + await Promise.all( + [ + ['A', 1 + 6], + ['B', 1 + 6], + ['C', 2 + 6], + ['D', 1 + 6], + ].map(async ([name, count]) => { + const { data } = await graphqlRequest({ + keystone, + query: `{ allUsers(where: { friends_every: { name: "${name}"}}) { id }}`, + }); + expect(data.allUsers.length).toEqual(count); + }) + ); + }) + ); + }); + + describe('Create', () => { + test( + 'With connect', + runner(setupKeystone, async ({ keystone }) => { + const { users } = await createInitialData(keystone); + const user = users[0]; + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + createUser(data: { + friends: { connect: [{ id: "${user.id}" }] } + }) { id friends { id } } + } + `, + }); + expect(errors).toBe(undefined); + expect(data.createUser.friends.map(({ id }) => id.toString())).toEqual([user.id]); + + const { User, Friend } = await getUserAndFriend( + keystone, + data.createUser.id, + user.id + ); + + // Everything should now be connected + expect(data.createUser.friends.map(({ id }) => id.toString())).toEqual([user.id]); + expect(Friend.friendOf.id.toString()).toBe(User.id.toString()); + }) + ); + + test( + 'With create', + runner(setupKeystone, async ({ keystone }) => { + const friendName = sampleOne(alphanumGenerator); + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + createUser(data: { + friends: { create: [{ name: "${friendName}" }] } + }) { id friends { id } } + } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend( + keystone, + data.createUser.id, + data.createUser.friends[0].id + ); + + // Everything should now be connected + expect(User.friends.map(({ id }) => id.toString())).toEqual([Friend.id.toString()]); + expect(Friend.friendOf.id.toString()).toBe(User.id.toString()); + }) + ); + + test( + 'With nested connect', + runner(setupKeystone, async ({ keystone }) => { + const { users } = await createInitialData(keystone); + const user = users[0]; + const friendName = sampleOne(alphanumGenerator); + + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + createUser(data: { + friends: { create: [{ name: "${friendName}" friendOf: { connect: { id: "${user.id}" } } }] } + }) { id friends { id friendOf { id } } } + } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend( + keystone, + data.createUser.id, + data.createUser.friends[0].id + ); + + // Everything should now be connected + expect(User.friends.map(({ id }) => id.toString())).toEqual([Friend.id]); + expect(Friend.friendOf.id.toString()).toBe(User.id.toString()); + + const { + data: { allUsers }, + } = await graphqlRequest({ + keystone, + query: `{ allUsers { id friends { id friendOf { id } } } }`, + }); + + // The nested company should not have a location + expect(allUsers.filter(({ id }) => id === User.id)[0].friends[0].friendOf.id).toEqual( + User.id + ); + allUsers + .filter(({ id }) => id !== User.id) + .forEach(user => { + expect(user.friends).toEqual([]); + }); + }) + ); + + test( + 'With nested create', + runner(setupKeystone, async ({ keystone }) => { + const friendName = sampleOne(alphanumGenerator); + const friendOfName = sampleOne(alphanumGenerator); + + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + createUser(data: { + friends: { create: [{ name: "${friendName}" friendOf: { create: { name: "${friendOfName}" } } }] } + }) { id friends { id friendOf { id } } } + } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend( + keystone, + data.createUser.id, + data.createUser.friends[0].id + ); + // Everything should now be connected + expect(User.friends.map(({ id }) => id.toString())).toEqual([Friend.id]); + expect(Friend.friendOf.id.toString()).toBe(User.id.toString()); + + // The nested company should not have a location + const { + data: { allUsers }, + } = await graphqlRequest({ + keystone, + query: `{ allUsers { id friends { id friendOf { id } } } }`, + }); + expect(allUsers.filter(({ id }) => id === User.id)[0].friends[0].friendOf.id).toEqual( + User.id + ); + allUsers + .filter(({ id }) => id !== User.id) + .forEach(user => { + expect(user.friends).toEqual([]); + }); + }) + ); + }); + + describe('Update', () => { + test( + 'With connect', + runner(setupKeystone, async ({ keystone }) => { + // Manually setup a connected Company <-> Location + const { user, friend } = await createUserAndFriend(keystone); + + // Sanity check the links don't yet exist + // `...not.toBe(expect.anything())` allows null and undefined values + expect(user.friends).not.toBe(expect.anything()); + expect(friend.friendOf).not.toBe(expect.anything()); + + const { errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + updateUser( + id: "${user.id}", + data: { friends: { connect: [{ id: "${friend.id}" }] } } + ) { id friends { id } } } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend(keystone, user.id, friend.id); + // Everything should now be connected + expect(User.friends.map(({ id }) => id.toString())).toEqual([Friend.id.toString()]); + expect(Friend.friendOf.id.toString()).toBe(User.id.toString()); + }) + ); + + test( + 'With create', + runner(setupKeystone, async ({ keystone }) => { + const { users } = await createInitialData(keystone); + let user = users[0]; + const friendName = sampleOne(alphanumGenerator); + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + updateUser( + id: "${user.id}", + data: { friends: { create: [{ name: "${friendName}" }] } } + ) { id friends { id name } } + } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend( + keystone, + user.id, + data.updateUser.friends[0].id + ); + + // Everything should now be connected + expect(User.friends.map(({ id }) => id.toString())).toEqual([Friend.id.toString()]); + expect(Friend.friendOf.id.toString()).toBe(User.id.toString()); + }) + ); + + test( + 'With disconnect', + runner(setupKeystone, async ({ keystone }) => { + // Manually setup a connected Company <-> Location + const { user, friend } = await createUserAndFriend(keystone); + + // Run the query to disconnect the location from company + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + updateUser( + id: "${user.id}", + data: { friends: { disconnect: [{ id: "${friend.id}" }] } } + ) { id friends { id name } } + } + `, + }); + expect(errors).toBe(undefined); + expect(data.updateUser.id).toEqual(user.id); + expect(data.updateUser.friends).toEqual([]); + + // Check the link has been broken + const result = await getUserAndFriend(keystone, user.id, friend.id); + expect(result.User.friends).toEqual([]); + expect(result.Friend.friendOf).toBe(null); + }) + ); + + test( + 'With disconnectAll', + runner(setupKeystone, async ({ keystone }) => { + // Manually setup a connected Company <-> Location + const { user, friend } = await createUserAndFriend(keystone); + + // Run the query to disconnect the location from company + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + updateUser( + id: "${user.id}", + data: { friends: { disconnectAll: true } } + ) { id friends { id name } } + } + `, + }); + expect(errors).toBe(undefined); + expect(data.updateUser.id).toEqual(user.id); + expect(data.updateUser.friends).toEqual([]); + + // Check the link has been broken + const result = await getUserAndFriend(keystone, user.id, friend.id); + expect(result.User.friends).toEqual([]); + expect(result.Friend.friendOf).toBe(null); + }) + ); + }); + + describe('Delete', () => { + test( + 'delete', + runner(setupKeystone, async ({ keystone }) => { + // Manually setup a connected Company <-> Location + const { user, friend } = await createUserAndFriend(keystone); + + // Run the query to disconnect the location from company + const { data, errors } = await graphqlRequest({ + keystone, + query: `mutation { deleteUser(id: "${user.id}") { id } } `, + }); + expect(errors).toBe(undefined); + expect(data.deleteUser.id).toBe(user.id); + + // Check the link has been broken + const result = await getUserAndFriend(keystone, user.id, friend.id); + expect(result.User).toBe(null); + expect(result.Friend.friendOf).toBe(null); + }) + ); + }); + }); + }); + }) +); diff --git a/api-tests/relationships/crud-self-ref/one-to-one.test.js b/api-tests/relationships/crud-self-ref/one-to-one.test.js new file mode 100644 index 00000000000..cd0ed884c8a --- /dev/null +++ b/api-tests/relationships/crud-self-ref/one-to-one.test.js @@ -0,0 +1,396 @@ +const { gen, sampleOne } = require('testcheck'); +const { Text, Relationship } = require('@keystonejs/fields'); +const cuid = require('cuid'); +const { multiAdapterRunners, setupServer, graphqlRequest } = require('@keystonejs/test-utils'); + +const alphanumGenerator = gen.alphaNumString.notEmpty(); + +jest.setTimeout(6000000); + +const createInitialData = async keystone => { + const { data } = await graphqlRequest({ + keystone, + query: ` +mutation { + createUsers(data: [{ data: { name: "${sampleOne( + alphanumGenerator + )}" } }, { data: { name: "${sampleOne(alphanumGenerator)}" } }, { data: { name: "${sampleOne( + alphanumGenerator + )}" } }]) { id } +} +`, + }); + return { users: data.createUsers }; +}; + +const createUserAndFriend = async keystone => { + const { + data: { createUser }, + } = await graphqlRequest({ + keystone, + query: ` +mutation { + createUser(data: { + friend: { create: { name: "${sampleOne(alphanumGenerator)}" } } + }) { id friend { id } } +}`, + }); + const { User, Friend } = await getUserAndFriend(keystone, createUser.id, createUser.friend.id); + + // Sanity check the links are setup correctly + expect(User.friend.id.toString()).toBe(Friend.id.toString()); + expect(Friend.friendOf.id.toString()).toBe(User.id.toString()); + + return { user: createUser, friend: createUser.friend }; +}; + +const getUserAndFriend = async (keystone, userId, friendId) => { + const { data } = await graphqlRequest({ + keystone, + query: ` + { + User(where: { id: "${userId}"} ) { id friend { id } } + Friend: User(where: { id: "${friendId}"} ) { id friendOf { id } } + }`, + }); + return data; +}; + +multiAdapterRunners().map(({ runner, adapterName }) => + describe(`Adapter: ${adapterName}`, () => { + // 1:1 relationships are symmetric in how they behave, but + // are (in general) implemented in a non-symmetric way. For example, + // in postgres we may decide to store a single foreign key on just + // one of the tables involved. As such, we want to ensure that our + // tests work correctly no matter which side of the relationship is + // defined first. + const createListsLR = keystone => { + keystone.createList('User', { + fields: { + name: { type: Text }, + friend: { type: Relationship, ref: 'User.friendOf' }, + friendOf: { type: Relationship, ref: 'User.friend' }, + }, + }); + }; + const createListsRL = keystone => { + keystone.createList('User', { + fields: { + name: { type: Text }, + friendOf: { type: Relationship, ref: 'User.friend' }, + friend: { type: Relationship, ref: 'User.friendOf' }, + }, + }); + }; + + [ + [createListsLR, 'Left -> Right'], + [createListsRL, 'Right -> Left'], + ].forEach(([createLists, order]) => { + describe(`One-to-one relationships - ${order}`, () => { + function setupKeystone(adapterName) { + return setupServer({ + adapterName, + name: `ks5-testdb-${cuid()}`, + createLists, + }); + } + + describe('Create', () => { + test( + 'With connect', + runner(setupKeystone, async ({ keystone }) => { + const { users } = await createInitialData(keystone); + const user = users[0]; + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + createUser(data: { + friend: { connect: { id: "${user.id}" } } + }) { id friend { id } } + } + `, + }); + expect(errors).toBe(undefined); + expect(data.createUser.friend.id.toString()).toEqual(user.id); + + const { User, Friend } = await getUserAndFriend( + keystone, + data.createUser.id, + user.id + ); + // Everything should now be connected + expect(User.friend.id.toString()).toBe(Friend.id.toString()); + expect(Friend.friendOf.id.toString()).toBe(User.id.toString()); + }) + ); + + test( + 'With create', + runner(setupKeystone, async ({ keystone }) => { + const friendName = sampleOne(alphanumGenerator); + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + createUser(data: { + friend: { create: { name: "${friendName}" } } + }) { id friend { id } } + } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend( + keystone, + data.createUser.id, + data.createUser.friend.id + ); + + // Everything should now be connected + expect(User.friend.id.toString()).toBe(Friend.id.toString()); + expect(Friend.friendOf.id.toString()).toBe(User.id.toString()); + }) + ); + + test( + 'With nested connect', + runner(setupKeystone, async ({ keystone }) => { + const { users } = await createInitialData(keystone); + const user = users[0]; + const friendName = sampleOne(alphanumGenerator); + + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + createUser(data: { + friend: { create: { name: "${friendName}" friendOf: { connect: { id: "${user.id}" } } } } + }) { id friend { id friendOf { id } } } + } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend( + keystone, + data.createUser.id, + data.createUser.friend.id + ); + // Everything should now be connected + expect(User.friend.id.toString()).toBe(Friend.id.toString()); + expect(Friend.friendOf.id.toString()).toBe(User.id.toString()); + + const { + data: { allUsers }, + } = await graphqlRequest({ + keystone, + query: `{ allUsers { id friend { id friendOf { id }} } }`, + }); + + // The nested company should not have a location + expect(allUsers.filter(({ id }) => id === User.id)[0].friend.friendOf.id).toEqual( + User.id + ); + allUsers + .filter(({ id }) => id !== User.id) + .forEach(user => { + expect(user.friend).toBe(null); + }); + }) + ); + + test( + 'With nested create', + runner(setupKeystone, async ({ keystone }) => { + const friendName = sampleOne(alphanumGenerator); + const friendOfName = sampleOne(alphanumGenerator); + + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + createUser(data: { + friend: { create: { name: "${friendName}" friendOf: { create: { name: "${friendOfName}" } } } } + }) { id friend { id friendOf { id } } } + } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend( + keystone, + data.createUser.id, + data.createUser.friend.id + ); + // Everything should now be connected + expect(User.friend.id.toString()).toBe(Friend.id.toString()); + expect(Friend.friendOf.id.toString()).toBe(User.id.toString()); + + // The nested company should not have a location + const { + data: { allUsers }, + } = await graphqlRequest({ + keystone, + query: `{ allUsers { id friend { id friendOf { id }} } }`, + }); + expect(allUsers.filter(({ id }) => id === User.id)[0].friend.friendOf.id).toEqual( + User.id + ); + allUsers + .filter(({ id }) => id !== User.id) + .forEach(user => { + expect(user.friend).toBe(null); + }); + }) + ); + }); + + describe('Update', () => { + test( + 'With connect', + runner(setupKeystone, async ({ keystone }) => { + // Manually setup a connected Company <-> Location + const { user, friend } = await createUserAndFriend(keystone); + + // Sanity check the links don't yet exist + // `...not.toBe(expect.anything())` allows null and undefined values + expect(user.friend).not.toBe(expect.anything()); + expect(friend.friendOf).not.toBe(expect.anything()); + + const { errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + updateUser( + id: "${user.id}", + data: { friend: { connect: { id: "${friend.id}" } } } + ) { id friend { id } } } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend(keystone, user.id, friend.id); + // Everything should now be connected + expect(User.friend.id.toString()).toBe(Friend.id.toString()); + expect(Friend.friendOf.id.toString()).toBe(User.id.toString()); + }) + ); + + test( + 'With create', + runner(setupKeystone, async ({ keystone }) => { + const { users } = await createInitialData(keystone); + let user = users[0]; + const friendName = sampleOne(alphanumGenerator); + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + updateUser( + id: "${user.id}", + data: { friend: { create: { name: "${friendName}" } } } + ) { id friend { id name } } + } + `, + }); + expect(errors).toBe(undefined); + + const { User, Friend } = await getUserAndFriend( + keystone, + user.id, + data.updateUser.friend.id + ); + + // Everything should now be connected + expect(User.friend.id.toString()).toBe(Friend.id.toString()); + expect(Friend.friendOf.id.toString()).toBe(User.id.toString()); + }) + ); + + test( + 'With disconnect', + runner(setupKeystone, async ({ keystone }) => { + // Manually setup a connected Company <-> Location + const { user, friend } = await createUserAndFriend(keystone); + + // Run the query to disconnect the location from company + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + updateUser( + id: "${user.id}", + data: { friend: { disconnect: { id: "${friend.id}" } } } + ) { id friend { id name } } + } + `, + }); + expect(errors).toBe(undefined); + expect(data.updateUser.id).toEqual(user.id); + expect(data.updateUser.friend).toBe(null); + + // Check the link has been broken + const result = await getUserAndFriend(keystone, user.id, friend.id); + expect(result.User.friend).toBe(null); + expect(result.Friend.friendOf).toBe(null); + }) + ); + + test( + 'With disconnectAll', + runner(setupKeystone, async ({ keystone }) => { + // Manually setup a connected Company <-> Location + const { user, friend } = await createUserAndFriend(keystone); + + // Run the query to disconnect the location from company + const { data, errors } = await graphqlRequest({ + keystone, + query: ` + mutation { + updateUser( + id: "${user.id}", + data: { friend: { disconnectAll: true } } + ) { id friend { id name } } + } + `, + }); + expect(errors).toBe(undefined); + expect(data.updateUser.id).toEqual(user.id); + expect(data.updateUser.friend).toBe(null); + + // Check the link has been broken + const result = await getUserAndFriend(keystone, user.id, friend.id); + expect(result.User.friend).toBe(null); + expect(result.Friend.friendOf).toBe(null); + }) + ); + }); + + describe('Delete', () => { + test( + 'delete', + runner(setupKeystone, async ({ keystone }) => { + // Manually setup a connected Company <-> Location + const { user, friend } = await createUserAndFriend(keystone); + + // Run the query to disconnect the location from company + const { data, errors } = await graphqlRequest({ + keystone, + query: `mutation { deleteUser(id: "${user.id}") { id } } `, + }); + expect(errors).toBe(undefined); + expect(data.deleteUser.id).toBe(user.id); + + // Check the link has been broken + const result = await getUserAndFriend(keystone, user.id, friend.id); + expect(result.User).toBe(null); + expect(result.Friend.friendOf).toBe(null); + }) + ); + }); + }); + }); + }) +); diff --git a/api-tests/relationships/crud/many-to-many-no-ref.test.js b/api-tests/relationships/crud/many-to-many-one-sided.test.js similarity index 100% rename from api-tests/relationships/crud/many-to-many-no-ref.test.js rename to api-tests/relationships/crud/many-to-many-one-sided.test.js diff --git a/api-tests/relationships/crud/one-to-many-no-ref.test.js b/api-tests/relationships/crud/one-to-many-one-sided.test.js similarity index 100% rename from api-tests/relationships/crud/one-to-many-no-ref.test.js rename to api-tests/relationships/crud/one-to-many-one-sided.test.js diff --git a/api-tests/relationships/crud/one-to-many.test.js b/api-tests/relationships/crud/one-to-many.test.js index 88ceac642d5..248046c10d2 100644 --- a/api-tests/relationships/crud/one-to-many.test.js +++ b/api-tests/relationships/crud/one-to-many.test.js @@ -289,7 +289,7 @@ multiAdapterRunners().map(({ runner, adapterName }) => }) ); - test.failing( + test( 'With nested connect', runner(setupKeystone, async ({ keystone }) => { const { companies } = await createInitialData(keystone); @@ -337,7 +337,7 @@ multiAdapterRunners().map(({ runner, adapterName }) => }) ); - test.failing( + test( 'With nested create', runner(setupKeystone, async ({ keystone }) => { const locationName = sampleOne(alphanumGenerator); diff --git a/api-tests/relationships/crud/one-to-one.test.js b/api-tests/relationships/crud/one-to-one.test.js index e798ed7be27..4467fafed78 100644 --- a/api-tests/relationships/crud/one-to-one.test.js +++ b/api-tests/relationships/crud/one-to-one.test.js @@ -168,7 +168,7 @@ multiAdapterRunners().map(({ runner, adapterName }) => }) ); - test.failing( + test( 'With nested connect', runner(setupKeystone, async ({ keystone }) => { const { companies } = await createInitialData(keystone); @@ -215,7 +215,7 @@ multiAdapterRunners().map(({ runner, adapterName }) => }) ); - test.failing( + test( 'With nested create', runner(setupKeystone, async ({ keystone }) => { const locationName = sampleOne(alphanumGenerator); diff --git a/api-tests/relationships/nested-mutations/reconnect-many-to-one.test.js b/api-tests/relationships/nested-mutations/reconnect-many-to-one.test.js index 306a45c6c11..b1c0d32a1b0 100644 --- a/api-tests/relationships/nested-mutations/reconnect-many-to-one.test.js +++ b/api-tests/relationships/nested-mutations/reconnect-many-to-one.test.js @@ -27,7 +27,7 @@ function setupKeystone(adapterName) { multiAdapterRunners().map(({ runner, adapterName }) => describe(`Adapter: ${adapterName}`, () => { describe('Reconnect', () => { - test.failing( + test( 'Reconnect from the many side', runner(setupKeystone, async ({ keystone, create }) => { // Create some notes diff --git a/api-tests/relationships/nested-mutations/two-way-backreference/to-one-required.test.js b/api-tests/relationships/nested-mutations/two-way-backreference/to-one-required.test.js index e6b0fcb6905..b6001053e8e 100644 --- a/api-tests/relationships/nested-mutations/two-way-backreference/to-one-required.test.js +++ b/api-tests/relationships/nested-mutations/two-way-backreference/to-one-required.test.js @@ -56,12 +56,9 @@ multiAdapterRunners().map(({ runner, adapterName }) => const companyId = data.createCompany.id; const locationId = data.createCompany.location.id; - const location = await findById('Location', locationId); const company = await findById('Company', companyId); - - // Everything should now be connected + // Everything should now be connected. 1:1 has a single connection on the first list defined. expect(company.location.toString()).toBe(locationId.toString()); - expect(location.company.toString()).toBe(companyId.toString()); }) ); }); diff --git a/demo-projects/relationships/index.js b/demo-projects/relationships/index.js index 83162b8cb19..b557be0fad2 100644 --- a/demo-projects/relationships/index.js +++ b/demo-projects/relationships/index.js @@ -1,21 +1,29 @@ const { Keystone } = require('@keystonejs/keystone'); const { MongooseAdapter } = require('@keystonejs/adapter-mongoose'); +const { KnexAdapter } = require('@keystonejs/adapter-knex'); const { Text, Relationship } = require('@keystonejs/fields'); const { GraphQLApp } = require('@keystonejs/app-graphql'); const { AdminUIApp } = require('@keystonejs/app-admin-ui'); const relType = process.env.REL_TYPE || 'one_one_to_many'; +const adapter = process.env.MONGO + ? new MongooseAdapter() + : new KnexAdapter({ + knexOptions: { connection: 'postgres://keystone5:k3yst0n3@localhost:5432/keystone' }, + }); + const keystone = new Keystone({ name: 'Keystone Relationships', - adapter: new MongooseAdapter(), + adapter, onConnect: async keystone => { - const executeQuery = keystone._buildQueryHelper( - keystone.getGraphQlContext({ skipAccessControl: true, schemaName: 'public' }) - ); - let query; - if (['one_one_to_many', 'two_one_to_many', 'two_one_to_one'].includes(relType)) { - query = ` + if (!process.env.CREATE_TABLES) { + const executeQuery = keystone._buildQueryHelper( + keystone.getGraphQlContext({ skipAccessControl: true, schemaName: 'public' }) + ); + let query; + if (['one_one_to_many', 'two_one_to_many', 'two_one_to_one'].includes(relType)) { + query = ` mutation { createPost( data: { @@ -28,8 +36,8 @@ const keystone = new Keystone({ } } `; - } else if (['one_many_to_many', 'two_many_to_many'].includes(relType)) { - query = ` + } else if (['one_many_to_many', 'two_many_to_many'].includes(relType)) { + query = ` mutation { createPost( data: { @@ -42,8 +50,10 @@ const keystone = new Keystone({ } } `; + } + await executeQuery(query); + process.exit(0); } - await executeQuery(query); }, }); diff --git a/demo-projects/relationships/package.json b/demo-projects/relationships/package.json index 5d67ada5cb6..29246d2f371 100644 --- a/demo-projects/relationships/package.json +++ b/demo-projects/relationships/package.json @@ -11,10 +11,12 @@ "scripts": { "dev": "cross-env NODE_ENV=development DISABLE_LOGGING=true keystone dev", "build": "cross-env NODE_ENV=production keystone build", + "create-tables": "cross-env NODE_ENV=production CREATE_TABLES=true keystone create-tables", "start": "cross-env NODE_ENV=production keystone start", "rels": "cross-env NODE_ENV=production keystone upgrade-relationships" }, "dependencies": { + "@keystonejs/adapter-knex": "^8.0.0", "@keystonejs/adapter-mongoose": "^7.0.0", "@keystonejs/app-admin-ui": "^5.9.4", "@keystonejs/app-graphql": "^5.1.5", diff --git a/docs/discussions/database-schema.md b/docs/discussions/database-schema.md new file mode 100644 index 00000000000..ff49009cce8 --- /dev/null +++ b/docs/discussions/database-schema.md @@ -0,0 +1,98 @@ + + +# Database Schema + +Keystone models its data using `Lists`, which comprise of `Fields`. +In order to store data we need to translate the Keystone data model into an appropriate form for the underlying data store. +This transformation is handled by the [database adapters](/docs/quick-start/adapters.md). + +This transformation is generaly reasonably simple. +A `List` called `User` in Keystone will have table called `Users` in PostgreSQL or a collection called `users` in MongoDB. +For most field types there is also a one to to correspondance between a Keystone `Field` and a PostgreSQL column or MongoDB field. +Each field type is responsible for articulating the exact correspondance, which includes the storage types and any auxillary data that needs to be stored. + +The most complicated aspect of the database schema is the representation of relationships. +To understand the storage of relationships you should first make sure you understand the basic ideas behind [Keystone relationships](/docs/discussions/relationships.md). + +## One to many + +```javascript +keystone.createList('User', { + fields: { + name: { type: Text }, + }, +}); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + author: { type: Relationship, ref: 'User', many: false }, + }, +}); +``` + +If we consider the above `one to many` relationship we know that each `Post` has a single `author` of type `User`. +This means that we `Post` needs to store a reference to a single `User`. + +In PostgreSQL this is stored as a [foreign key column](https://www.postgresql.org/docs/12/ddl-constraints.html#DDL-CONSTRAINTS-FK) called `author` on the `Posts` table, +In MongoDB it is stored as a field called `author` on the `posts` collection with type `ObjectID`. + +The two-sided cases is handled identically to the one-sided case. + +## Many to many + +```javascript +keystone.createList('User', { + fields: { + name: { type: Text }, + }, +}); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + authors: { type: Relationship, ref: 'User', many: true }, + }, +}); +``` + +If we consider the above `many to many` relationship we know that each `Post` has a multiple `authors` of type `User`. +This means that we `Post` needs to store multiple reference to `Users`, and also each `User` can be referenced by multiple `Posts`. + +To store this information we use a join table with two columns. +One column holds a reference to `Posts` and the other holds a reference to `Users`. +In PostgreSQL this is implemented as a table where the contents of each column is a [foreign key](https://www.postgresql.org/docs/12/ddl-constraints.html#DDL-CONSTRAINTS-FK) referencing the respective table. +In MongoDB this is implemented as a collection the the contents of each field is an `ObjectID` referencing the respective column. + +The two-sided cases is handled using the same pattern, however the generated table/collection and column/fields names will be different. + +## One to one + +```javascript +keystone.createList('User', { + fields: { + name: { type: Text }, + post: { type: Relationship, ref: 'Post.author', many: false }, + }, +}); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + author: { type: Relationship, ref: 'User.post', many: false }, + }, +}); +``` + +If we consider the above `one to one` relationship we know that each `Post` has a single `author`, and each `User` is the auther of a single `Post`. +This is similar to the `one to many` case, however now because of the symmetry of the configuration it is possible to store the data on either the `Post` or `User` table. + +To break this symmetry we pick the list with the name that comes first alphabetically, so in this case `Post`. +Just as in the `one to many` case, in PostgreSQL the data is stored as a [foreign key column](https://www.postgresql.org/docs/12/ddl-constraints.html#DDL-CONSTRAINTS-FK) called `author` on the `Posts` table, +In MongoDB it is stored as a field called `author` on the `posts` collection with type `ObjectID`. diff --git a/docs/discussions/index.md b/docs/discussions/index.md new file mode 100644 index 00000000000..fc9d46bfd75 --- /dev/null +++ b/docs/discussions/index.md @@ -0,0 +1,6 @@ + + +# Discussions diff --git a/docs/discussions/new-data-schema.md b/docs/discussions/new-data-schema.md new file mode 100644 index 00000000000..20f89609948 --- /dev/null +++ b/docs/discussions/new-data-schema.md @@ -0,0 +1,92 @@ + + +# Arcade - A New Data Schema For Keystone + +> **Note:** This document refers to a set of package releases which are all part of one Keystone release. +> These package releases are collectively known as the `Arcade` release of Keystone. The packages included are: +> +> - `@keystonejs/adapter-knex`: `9.0.0` +> - `@keystonejs/adapter-mongoose`: `8.0.0` +> - `@keystonejs/fields`: `9.0.0` +> - `@keystonejs/keystone`: `8.0.0` +> - `@keystonejs/mongo-join-builder`: `7.0.0` + +We are excited to announce a **new and improved data schema** for Keystone. +The new data schema simplifies the way your data is stored and will unlock the development of new functionality within Keystone. + +> **Important:** You will need to make changes to your database to take advantage of the new data schema. Read on for details of what has changed and why, or jump straight to the [schema upgrade guide](/docs/guides/relationship-migration.md). + +## Background + +Keystone provides an extremely powerful graphQL API which includes filtering, sorting, and nested queries and mutations. +The full CRUD API is generated from the simple `List` definitions provided by you, the developer. +All of this functionality is powered by our _database adapters_, which convert your graphQL queries into SQL/NoSQL queries and then convert the results back to graphQL. + +Keystone needs to know about, and manage, the schema of the underlying database so it can correctly construct its queries. +We designed our database schemas when we first developed the database adapters, and they have served us very well. +In particular, we have come a long way with our support for complex relationships using the database schemas we initially developed. +By keeping a consistent schema, users have been able to stay up to date with Keystone updates without having to make changes to their data. + +Unfortunately we have now outgrown this original schema. +More and more we are finding that certain bugs are hard to fix, and certain features difficult to implement, because of the limitations of our initial design. +While it has served us well, it's time for an upgrade. + +## The Problem + +The key challenge in designing our schema is how we represent relationships between lists. +Our initial designe borrowed heavily from a `MongDB` inspired pattern, where each object was responsible for tracking its related items. +This made the initial implementation very simple, particularly for the `MongoDB` adapter. +The `PostgreSQL` adapter was more complex, as it had to emulate the patterns from `MongoDB`, but it also worked. + +One of the design trade-offs in the initial schema was that we denormalised the data in order to simplify the query generation. +This means we stored duplicated data in the database, but we could very quickly perform lookups without requiring complex queries. + +Unfortunately, this trade off is no longer working in our favour. +Maintaining the denormalised data is now more complex than generating queries against normalised data. +We are finding that some reported bugs are difficult to resolve due to the complex nature of resolving denormalised data. +There are also more sophisticated relationship patterns, such as ordered relationships, which are too difficult to implement in the current schema. + +## The Solution + +To address these problems at their core we have changed our data schema to be normalised and to eliminate the duplicated data. +This means that our query generation code has become more complex, but this trade off gains us a number of benefits: + +1. Eliminates duplicated data in the database. +2. Uses a more conventional database schema design, particular for `PostgreSQL`. +3. More robust query generation. +4. More extensible relationship patterns. + +### No more duplicated data + +Eliminating duplicated data removes the risk of the data getting out of sync, and also simplied all of our `create`, `update`, and `delete` operations. + +### Conventional database schema + +The new database schema matches much more closely with the schema a database engineer would design if they were building this schema by hand. + +### More robust queries + +As part of this change we have introduced a much more comprehensive set of tests, which push the graphQL API to its limit. +These additional tests allowed us to find and fix a number of corner case bugs from the initial implementation. + +### More extensible relationships + +The internal representation of relationships within Keystone is now much more sophisticated. +This will allow us to extend the kind of modelling that Keystone provides to include things like ordered relationships. + +## Updating your database + +In order to take advantage of these improvements you will need to make some changes to your database. +In some instances this will simply involving removing tables or columns which are no longer required. +In other cases you will need to rename some tables or columns and possibly move data from one table to another. + +To assist with this process we have written a [Schema Upgrade Guide](/docs/guides/relationship-migration.md), which will take you through the steps to safely transition your database. + +## Summary + +The new Keystone data schema will simplify and improve the storage of your crucial system data, and will unlock +We appreciate that making changes to your production database can be a daunting task, but we hope to make this process as smooth as possible. +We are looking forward to building on these change to provide an even more powerful Keystone in the future 🚀 diff --git a/docs/discussions/relationships.md b/docs/discussions/relationships.md new file mode 100644 index 00000000000..b165dd65a91 --- /dev/null +++ b/docs/discussions/relationships.md @@ -0,0 +1,255 @@ + + +# Relationships + +Keystone allows you to model your data as a collection of related `Lists`. +For example, a blogging application might have lists called `Post` and `User`, where each post has a single author. +This would be represented in Keystone by a relationship between the `Post` and `User` lists. + +## Defining a Relationship + +Relationships are implemented using the `Relationship` field type and defined along with other fields in `createLists`. +For our blog example, we could define: + +```javascript +keystone.createList('User', { fields: { name: { type: Text } } }); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + author: { type: Relationship, ref: 'User', many: false }, + }, +}); +``` + +The `Relationship` field type takes a config option `ref` which is able to reference another list in the application. +In this case, the `author` field will hold a reference to the `User` list. + +If we wanted to allow a post to have multiple authors we could change our definition to + +```javascript + authors: { type: Relationship, ref: 'User', many: true }, +``` + +We have used `many: true` to indicate that the post relates to multiple `Users`, who are the `authors` of that post. +The default configuration is `many: false`, which indicates that each post is related to exactly one user. + +### One sided vs Two sided + +In our example we know the authors of each post. +We can access this information from our GraphQL API by querying for the `authors` field of a post. + +```graphQL +Query { + allPosts { + title + content + authors { + name + } + } +} +``` + +If we can find all `authors` of a post, this implies there is enough information available to find all posts written by a particular user. +To access to this information from the `Users` list as well, we update our list definitions as such: + +```javascript +keystone.createList('User', { + fields: { + name: { type: Text }, + posts: { type: Relationship, ref: 'Post.authors', many: true }, + }, +}); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + authors: { type: Relationship, ref: 'User.posts', many: true }, + }, +}); +``` + +We have now added a `posts` field to the `User` list, and changed the `ref` config of the `authors` field. +We now have two `Relationship` fields, but importantly, we still **only have one relationship**. +The two fields simply represent different sides of the one relationship. + +This type of configuration is called a _two-sided_ relationship, while the original configuration without `posts` was a _one-sided_ relationship. + +We can now write the following query to find all the posts written by each user: + +```graphQL +Query { + allUsers { + name + posts { + title + content + } + } +} +``` + +There are some important things to remember when defining a two-sided relationship: + +- Even though there are two fields, there is only one relationship between the lists. +- The `ref` config must be formatted as `.` and both sides must refer to each other. +- Both fields are sharing the same data. If you change the author of a post, that post will no longer show up in the original author's `posts`. + +## Self referential lists + +In the above examples we defined relationships between two different lists, `Users` and `Posts`. +It is also possible to define relationships which refer to the same list. +For example if we wanted to implement a Twitter style following relationship we could define: + +```javascript +keystone.createList('User', { + fields: { + name: { type: Text }, + follows: { type: Relationship, ref: 'User', many: true }, + }, +}); +``` + +This one-sided relationship allows us to keep track of who each user is following. +We could turn this into a two-sided relationship to also access the followers of each user: + +```javascript +keystone.createList('User', { + fields: { + name: { type: Text }, + follows: { type: Relationship, ref: 'User.followers', many: true }, + followers: { type: Relationship, ref: 'User.follows', many: true }, + }, +}); +``` + +The only relationship configuration not currently supported is having a field reference _itself_, e.g. `friends: { type: Relationship, ref: 'User.friends', many: true }`. + +## Cardinality + +The _cardinality_ of a relationship is the number items which can exist on either side of the relationship. +In general, each side can have either `one` or `many` related items. +Since each relationship has two sides this means we can have `one to one`, `one to many` and `many to many` relationships. + +The cardinality of your relationship is controlled by the use of the `many` config option. +In two-sided relationships the `many` option on both sides must be considered. +The follow examples will demonstrate how to set up each type of cardinality in the context of our blog. + +### One Sided + +#### One to many + +Each post has a single author, and each user can have multiple posts, however we cannot directly access a users' posts. + +```javascript +keystone.createList('User', { + fields: { + name: { type: Text }, + }, +}); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + author: { type: Relationship, ref: 'User', many: false }, + }, +}); +``` + +#### Many to many + +Each post has multiple authors, and each user can have multiple posts, however we cannot directly access a users' posts. + +```javascript +keystone.createList('User', { + fields: { + name: { type: Text }, + }, +}); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + authors: { type: Relationship, ref: 'User', many: true }, + }, +}); +``` + +### Two Sided + +#### One to one + +Each post has a single author, and each user is only allowed to write one post. + +```javascript +keystone.createList('User', { + fields: { + name: { type: Text }, + post: { type: Relationship, ref: 'Post.author', many: false }, + }, +}); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + author: { type: Relationship, ref: 'User.post', many: false }, + }, +}); +``` + +#### One to many + +Each post has a single author, and each user can have multiple posts. + +```javascript +keystone.createList('User', { + fields: { + name: { type: Text }, + posts: { type: Relationship, ref: 'Post.author', many: true }, + }, +}); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + author: { type: Relationship, ref: 'User.posts', many: false }, + }, +}); +``` + +#### Many to many + +Each post can have multiple authors, and each user can have multiple posts. + +```javascript +keystone.createList('User', { + fields: { + name: { type: Text }, + posts: { type: Relationship, ref: 'Post.authors', many: true }, + }, +}); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + authors: { type: Relationship, ref: 'User.posts', many: true }, + }, +}); +``` + +## Summary + +Keystone relationships are managed using the `Relationship` field type. +They can be configured as one-sided or two-sided by the `ref` config option, and the cardinality can be set using the `many` flag. +If you need help deciding which options to use, please consult the [relationship configuration guide](/docs/guides/relationships.md). diff --git a/docs/guides/apps.md b/docs/guides/apps.md index a45d9bcd524..78f5ed65309 100644 --- a/docs/guides/apps.md +++ b/docs/guides/apps.md @@ -72,6 +72,7 @@ Other interesting Keystone compatible Apps are: If you need to provide your own custom middleware for your system you can create a custom app and include it in your exported `apps`. + ```javascript title=index.js class CustomApp { prepareMiddleware({ keystone, dev, distDir }) { @@ -89,4 +90,5 @@ module.exports = { ], }; ``` + diff --git a/docs/guides/graphql-philosophy.md b/docs/guides/graphql-philosophy.md index ce3d9a65596..2de647fc557 100644 --- a/docs/guides/graphql-philosophy.md +++ b/docs/guides/graphql-philosophy.md @@ -123,6 +123,7 @@ Nested Mutations are useful when you need to make changes to more than one Domai For example, imagine a UI where an author could update their bio at the same time as creating a post. The mutation would look something like: + ```graphql mutation { createPost(data: { @@ -137,6 +138,7 @@ mutation { } } ``` + Note the `data.author.update` object, this is the _Nested Mutation_. Beyond `update` there are also other operations you may wish to perform: diff --git a/docs/guides/mutation-lifecycle.md b/docs/guides/mutation-lifecycle.md index b14adb51d96..09717b8b911 100644 --- a/docs/guides/mutation-lifecycle.md +++ b/docs/guides/mutation-lifecycle.md @@ -53,8 +53,6 @@ This transaction encapsulates a database transaction, as well as any state requi This transaction is used by all the nested mutations of the operation. -It is committed after the [resolve backlinks](#7-resolve-backlinks-createupdatedelete) step of the root operation. - The Operational Phase for a `many` mutation consists of the the Operational Phase for the corresponding `single` mutation performed in parallel over each of the target items. Each of these `single` mutations is executed within its own transaction. @@ -117,7 +115,7 @@ Custom field types can override this behaviour by defining the method `getDefaul Relationship fields do not currently support default values. -#### 2a. Resolve Relationship (`create/update`) +#### 2. Resolve Relationship (`create/update`) The create and update mutations specify the value of relationship fields using the [nested mutation] pattern. @@ -131,16 +129,6 @@ Any errors thrown by this nested `createMutation` will cause the current mutatio As well as resolving the IDs and performing any nested create mutations, this step must also track. -#### 2b. Register Backlinks (`delete`) - -When deleting an item with relationship fields, it is important that any backlinks to the deleted item are also removed. - -A backlink exists when a relationship field is configured with a `ref` attribute of the form `listRef.fieldRef`. - -During this step, any backlinks which need to be updated are identified and registered internally. - -The actual update step for these backlinks will be performed during the `Resolve backlinks` step, once all other pre-hooks and database operations have been completed on the primary target list. - #### 3. Resolve Input (`create/update`) The `resolveInput` hook allows the developer to modify the incoming item before it is inserted/updated within the database. @@ -165,18 +153,7 @@ For full details of how and when to use these hooks, please consult the [API doc The database operation is where the keystone database adapter is used to make the requested changes in the database. -#### 7. Resolve Backlinks (`create/update/delete`) - -During this stage, all pending backlinks which need to be updated on referenced lists are resolved. -This involves performing an `updateMutation` on the referenced list, performing either a `connect` or `disconnect` operation on the referenced relationship field. - -Unlike the `Resolve relationship` step, this operation will only ever nest one level deep. - -It can still result in either an `AccessDeniedError` or `ValidationFailureError`. - -As with `Resolve relationship`, the nested `AfterChange` hooks will be returned an added to the stack of deferred hooks for this mutation. - -#### 8. After Operation (`create/update/delete`) +#### 7. After Operation (`create/update/delete`) The `afterChange` and `afterDelete` hooks are only executed once all database operations for the mutation have been completed and the transaction has been finalised. This means that the database is in a consistent state when this hook is executed. diff --git a/docs/guides/new-schema-cheatsheet.md b/docs/guides/new-schema-cheatsheet.md new file mode 100644 index 00000000000..a31cf9bd36e --- /dev/null +++ b/docs/guides/new-schema-cheatsheet.md @@ -0,0 +1,166 @@ + + +# New Schema Cheatsheet + +This cheatsheet summarises the changes needed to update your database to use the new Keystone database schema, introduced in the [`Aracde`](/docs/discussions/new-data-schema.md) release. +For full instructions please consult the [migration guide](/docs/guides/relationship-migration.md). + +## One to Many (one-sided) + +### Example list config + +```javascript +keystone.createList('User', { fields: { name: { type: Text } } }); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + author: { type: Relationship, ref: 'User', many: false }, + }, +}); +``` + +### Migration Strategy + +- No changes are required for these relationships. + +## Many to Many (one-sided) + +### Example list config + +```javascript +keystone.createList('User', { fields: { name: { type: Text } } }); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + authors: { type: Relationship, ref: 'User', many: true }, + }, +}); +``` + +### Migration Strategy + +#### PostgreSQL + +- Rename `Post_authors` to `Post_authors_many`. +- Rename `Post_id` to `Post_left_id` and `User_id` to `User_right_id`. + +#### MongoDB + +- Create a collection `post_authors_manies` with fields `Post_left_id` and `User_right_id`. +- Move the data from `posts.authors` into `post_authors_manies`. +- Delete `posts.authors`. + +## One to Many (two-sided) + +### Example list config + +```javascript +keystone.createList('User', { + fields: { + name: { type: Text }, + posts: { type: Relationship, ref: 'Post.author', many: true }, + }, +}); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + author: { type: Relationship, ref: 'User.posts', many: false }, + }, +}); +``` + +### Migration Strategy + +#### PostgreSQL + +- Drop the `User_posts` table. + +#### MongoDB + +- Remove `users.posts`. + +## Many to Many (two-sided) + +### Example list config + +```javascript +keystone.createList('User', { + fields: { + name: { type: Text }, + posts: { type: Relationship, ref: 'Post.authors', many: true }, + }, +}); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + authors: { type: Relationship, ref: 'User.posts', many: true }, + }, +}); +``` + +### Migration Strategy + +#### PostgreSQL + +- Drop the `Post_authors` table. +- Rename `User_posts` to `User_posts_Post_authors`. +- Rename `User_id` to `User_left_id` and `Post_id` to `Post_right_id`. + +#### MongoDB + +- Create a collection `user_posts_post_authors` with fields `User_left_id` and `Post_right_id`. +- Move the data from `users.posts` into `user_posts_post_authors`. +- Delete `users.posts`. +- Delete `posts.authors`. + +## One to One (two-sided) + +### Example list config + +```javascript +keystone.createList('User', { + fields: { + name: { type: Text }, + post: { type: Relationship, ref: 'Post.author', many: false }, + }, +}); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + author: { type: Relationship, ref: 'User.post', many: false }, + }, +}); +``` + +### Migration Strategy + +#### PostgreSQL + +One to one relationships in the `before` state had a foreign key column on each table. +In the `after` state, only one of these is stored. +Because of the symmetry of the one to one relationship, Keystone makes an arbitrary decision about which column to use. + +- Identify the foreign key column which is no longer required, and delete it. +- In our example above we would delete the `Post.author` column. + +#### MongoDB + +One to one relationships in the `before` state had a field in each collection. +In the `after` state, only one of these is stored. +Because of the symmetry of the one to one relationship, Keystone makes an arbitrary decision about which field to use. + +- Identify the field which is no longer required, and delete it. +- In our example above we would delete the `post.author` field. diff --git a/docs/guides/relationship-migration.md b/docs/guides/relationship-migration.md new file mode 100644 index 00000000000..e3b43ae20e1 --- /dev/null +++ b/docs/guides/relationship-migration.md @@ -0,0 +1,191 @@ + + +# Relationship Migration Guide + +In the [`Arcade`](/docs/discussions/new-data-schema.md) release of Keystone we [changed the database schema](/docs/discussions/new-data-schema.md) which Keystone uses to store its data. +This means that if you are upgrading to these new packages you will need to perform a migration on your database in order for it to continue working. + +This document will help you understand the changes to the database schema, which will help you understand the migrations you need to perform. + +We recommend familiarising yourself with the [relationships](/docs/discussions/relationships.md) documentation to make sure you understand the terminology used in this document. + +## Overview + +There are four steps to updating your database + +1. Take a backup of your production database. +2. Identify the changes required for your system. +3. Apply the changes to your database. +4. Deploy and test your application. + +The specifics of how to do each of these steps will depend on the particulars of your deployment. + +## Database backup + +It is vitally important that you take a backup of your database before performing any changes. +It is also crucial that you are able to restore your database if need be. + +If you are managing your own database, please consult the documentation for your database. +If you are using a managed database, you should consult the documentation for your service, as they likely already provide systems for backing up and restoring your database. + +> **Important:** Making changes to your database schema includes a risk of **complete data loss** if you make a mistake. Do not attempt updating your database unless you are certain you can safely recover from a data loss event. + +### MongoDB + +The [official MongoDB documentation](https://docs.mongodb.com/manual/tutorial/backup-and-restore-tools/) prodives details on how to use `mongodump` and `mongorestore` to backup and restore your database. + +### PostgreSQL + +The [official PostgreSQL documentation](https://www.postgresql.org/docs/12/backup.html) provides a number of different techniques for backing up and restoring your database. + +## Identify required changes + +The next step is to identify the changes you need to make to your database. +To assist with this you can use the command `keystone upgrade-relationships` +This tool will analyse your relationships and generate a summary of the changes you need to make in your database. +We recommend adding it as a script into your `package.json` file and running it with `yarn`. + +```bash +keystone upgrade-relationships +``` + +By default this command will look for an export called `keystone` in your `index.js` file. +If you have a custom server setup, you can indicate a different entry file with + +```bash +keystone upgrade-relationships --entry +``` + +Your entry file must export a `Keystone` object called `keystone`, and this needs to have all of your lists configured using `createList`. +This command will not connect to your database and will not start any express servers. + +The output you see will give you a summary of all the relationships in your system, and details of what actions you need to take to update your database. + +#### MongoDB + +```shell title=Output showLanguage=false allowCopy=false +ℹ Command: keystone upgrade-relationships +One-sided: one to many + Todo.author -> User + * No action required +One-sided: many to many + Todo.reviewers -> User + * Create a collection todo_reviewers_manies with fields Todo_left_id and User_right_id + * Move the data from todos.reviewers into todo_reviewers_manies + * Delete todos.reviewers +Two-sided: one to one + Todo.leadAuthor -> User.leadPost + * Delete users.leadPost +Two-sided: one to many + Todo.publisher -> User.published + * Delete users.published +Two-sided: many to many + Todo.readers -> User.readPosts + * Create a collection todo_readers_user_readposts with fields Todo_left_id and User_right_id + * Move the data from todos.readers into todo_readers_user_readposts + * Delete todos.readers + * Delete users.readPosts +``` + +#### PostgreSQL + +```shell title=Output showLanguage=false allowCopy=false +ℹ Command: keystone upgrade-relationships +One-sided: one to many + Todo.author -> User + * No action required +One-sided: many to many + Todo.reviewers -> User + * Rename table Todo_reviewers to Todo_reviewers_many + * Rename column Todo_id to Todo_left_id + * Rename column User_id to User_right_id +Two-sided: one to one + Todo.leadAuthor -> User.leadPost + * Delete column User.leadPost +Two-sided: one to many + Todo.publisher -> User.published + * Drop table User_published +Two-sided: many to many + Todo.readers -> User.readPosts + * Drop table User_readPosts + * Rename table Todo_readers to Todo_readers_User_readPosts + * Rename column Todo_id to Todo_left_id + * Rename column User_id to User_right_id +``` + +### Generate migrations + +The `upgrade-relationships` script can also be used to generate migration code which you can directly run against your database using the `--migration` flag. + +```bash +keystone upgrade-relationships --migration +``` + +> **Important:** Always be careful when running auto-generated migration code. +> Be sure to manually verify that the changes are doing what you want, as incorrect migrations can lead to data loss. + +#### MongoDB + +```javascript allowCopy=false showLanguage=false +db.todos.find({}).forEach(function(doc) { + doc.reviewers.forEach(function(itemId) { + db.todo_reviewers_manies.insert({ Todo_left_id: doc._id, User_right_id: itemId }); + }); +}); +db.todos.updateMany({}, { $unset: { reviewers: 1 } }); +db.users.updateMany({}, { $unset: { leadPost: 1 } }); +db.users.updateMany({}, { $unset: { published: 1 } }); +db.todos.find({}).forEach(function(doc) { + doc.readers.forEach(function(itemId) { + db.todo_readers_user_readposts.insert({ Todo_left_id: doc._id, User_right_id: itemId }); + }); +}); +db.todos.updateMany({}, { $unset: { readers: 1 } }); +db.users.updateMany({}, { $unset: { readPosts: 1 } }); +``` + +#### PostgreSQL + +```SQL allowCopy=false +ALTER TABLE public."Todo_reviewers" RENAME TO "Todo_reviewers_many"; +ALTER TABLE public."Todo_reviewers_many" RENAME COLUMN "Todo_id" TO "Todo_left_id"; +ALTER TABLE public."Todo_reviewers_many" RENAME COLUMN "User_id" TO "User_right_id"; +ALTER TABLE public."User" DROP COLUMN "leadPost"; +DROP TABLE public."User_published" +DROP TABLE public."User_readPosts" +ALTER TABLE public."Todo_readers" RENAME TO "Todo_readers_User_readPosts"; +ALTER TABLE public."Todo_readers_User_readPosts" RENAME COLUMN "Todo_id" TO "Todo_left_id"; +ALTER TABLE public."Todo_readers_User_readPosts" RENAME COLUMN "User_id" TO "User_right_id"; +``` + +### Cheatsheet + +If you want a handy reference to consult without needing to execute scripts then please consult the [new schema cheatsheet](/docs/guides/new-schema-cheatsheet.md). + +## Apply changes + +The next step is to apply the required changes to your database. +Keystone provides a lot of flexibility in how and where you deploy your database. +This means that there is no one-size-fits-all solution for the best approach to making changes to your database. + +If you already have an established schema migration process then you can simply continue to follow that process, using the output of the `upgrade-relationships` script as the content for a new migration. + +If you do not have an existing schema migration process then the best place to start is the [migrations guide](/docs/guides/migrations.md). +This document outlines a number of different approaches to performing database migrations. + +## Test and deploy + +The final step is to test and deploy your upgraded Keystone system. +If you have successfully migrated your database then should be able to start Keystone and have it connect to your updated database. +Keystone does not dictate how or where you run your deployments, so you should follow your existing processes for this step. + +It is advisable to do a test deployment in a controlled, non-production environment. +This will allow you to verify that your application is working correctly with the upgraded database. + +## Summary + +Congratulations, you have upgraded your Keystone system to the new and improved data schema! +If you experience any issues with the above process, please [create an issue](https://github.com/keystonejs/keystone/issues) on Github and we will endeavour to help you out. diff --git a/docs/guides/relationships.md b/docs/guides/relationships.md new file mode 100644 index 00000000000..deb74c079aa --- /dev/null +++ b/docs/guides/relationships.md @@ -0,0 +1,209 @@ + + +# Configuring Relationships + +Keystone allows you to model your data by declaring [relationships](/docs/discussions/relationships.md) between lists. +There are a number of different ways you can configure relationships, depending on how you want to model your data and how you need to access it from the graphQL API. +This guide will step you through the decision making process to help you get all your configurations just right. + +Throughout this guide we will use the example of a blog application which has `Users` and `Posts`. + +## One-sided vs two-sided + +A relationship in Keystone exists _between_ two lists. +In our blog, the concept of _authorship_ (who wrote a post) can be represented as a relationship between the `User` and the `Post` lists. + +The first question you need to consider is which list do you want to be able to access the relationship from in your graphQL API? +In our blog we might want to be able to ask about a `user's posts`, a `post's author`, or possibly both. +If you only need to access one side of the relationship then you want to configure a _one-sided_ relationship. If you need both, then you want to configure a _two-sided_ relationship. + +Let's assume that each post in our blog has a single author and look at how we would use the `ref` option to configure both a one-sided and two-sided relationship. + +### One-sided + +If we want to know who the author of a post is, but we're not interested in querying for all the posts written by a given user we can set up a one-sided relationship as follow: + +```javascript +keystone.createList('User', { fields: { name: { type: Text } } }); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + author: { type: Relationship, ref: 'User', many: false }, + }, +}); +``` + +In the `author` field we have specified `ref: 'User'` to indicate that this field relates to an item in the `User` list. +We can now write the following query to find the author for each post: + +```graphQL +Query { + allPosts { + title + content + author { + name + } + } +} +``` + +### Two-sided + +If we also want to access all the posts written by a user then we need to use a two-sided relationship. + +```javascript +keystone.createList('User', { + fields: { + name: { type: Text }, + posts: { type: Relationship, ref: 'Post.author', many: true }, + }, +}); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + author: { type: Relationship, ref: 'User.posts', many: false }, + }, +}); +``` + +In this case we have a `Relationship` field on both lists, and they both have a `ref` config in the form `.`. +These `Relationship` fields represent the two sides of the relationship, and we can now use the following graphQL query as well: + +```graphQL +Query { + allUsers { + name + posts { + title + content + } + } +} +``` + +## Cardinality + +The second question we need to ask is what the _cardinality_ of our relationship should be. +The _cardinality_ of a relationship is the number items which can exist on either side of the relationship. +In our blog do we want each post to have exactly one author, or can it have multiple authors? +Are users allowed to write more than one post or do we want to restrict them to exactly one post each for some reason? +The answers to these questions will give us the cardinality of our relationship. + +There are three types of cardinality, `one to many`, `many to many`, and `one to one`, and they can be configured using the `many` config option. + +### One to many + +If we want a blog where each post can have **one** author, and each user can be the author of **many** posts, then we have a `one to many` relationship. +We can configure a one-sided version of this: + +```javascript +keystone.createList('User', { fields: { name: { type: Text } } }); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + author: { type: Relationship, ref: 'User', many: false }, + }, +}); +``` + +or a two-sided version: + +```javascript +keystone.createList('User', { + fields: { + name: { type: Text }, + posts: { type: Relationship, ref: 'Post.author', many: true }, + }, +}); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + author: { type: Relationship, ref: 'User.posts', many: false }, + }, +}); +``` + +Note that we have used `many: false` in the `author` field and `many: true` in the `posts` field. + +### Many to many + +If we want a blog where each post can have **many** authors, and each user can be the author of **many** posts, then we have a `many to many` relationship. +We can configure a one-sided version of this: + +```javascript +keystone.createList('User', { fields: { name: { type: Text } } }); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + authors: { type: Relationship, ref: 'User', many: true }, + }, +}); +``` + +or a two-sided version: + +```javascript +keystone.createList('User', { + fields: { + name: { type: Text }, + posts: { type: Relationship, ref: 'Post.authors', many: true }, + }, +}); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + authors: { type: Relationship, ref: 'User.posts', many: true }, + }, +}); +``` + +Note that we have used `many: true` in both the `authors` and `posts` fields. + +### One to one + +If we want a blog where each post has exactly **one** author, and each user is restricted to writing exactly **one** post, then we have a `one to one` relationship. +In this case we can only specify this with a two-sided relationship: + +```javascript +keystone.createList('User', { + fields: { + name: { type: Text }, + post: { type: Relationship, ref: 'Post.author', many: false }, + }, +}); + +keystone.createList('Post', { + fields: { + title: { type: Text }, + content: { type: Text }, + author: { type: Relationship, ref: 'User.post', many: false }, + }, +}); +``` + +Note that we have used `many: false` in both the `authors` and `posts` fields. + +## Summary + +When configuring a relationship in Keystone there are two key questions you need to answer: + +- Do I want a one-sided or two-sided relationship? +- What is the cardinality of my relationship? + +Once you know the answers to these questions you can configure your relationship using the `ref` and `many` options. diff --git a/docs/quick-start/README.md b/docs/quick-start/README.md index fc0f2067fb8..3090926acfa 100644 --- a/docs/quick-start/README.md +++ b/docs/quick-start/README.md @@ -57,20 +57,20 @@ You are now running your very own Keystone application! Here's what you get out Your simple todo application is up and running: -- [http://localhost:3000](http://localhost:3000) +- ### Admin UI Your application also has an Admin UI, which lets you directly manipulate the data in your database: -- [http://localhost:3000/admin](http://localhost:3000/admin) +- ### GraphQL API Both your application and the Admin UI are powered by a GraphQL API. Keystone provides a web interface for this API at this URL: -- [http://localhost:3000/admin/graphiql](http://localhost:3000/admin/graphiql) +- ## Next steps diff --git a/docs/tutorials/relationships.md b/docs/tutorials/relationships.md index c066f678e31..13b7e077cb4 100644 --- a/docs/tutorials/relationships.md +++ b/docs/tutorials/relationships.md @@ -53,14 +53,13 @@ task: { ``` Now we can set a task for the User from the admin panel. But something is wrong! -When we pick a task for the user and then check this task, the assignee is -incorrect. This can be solved by using a `Back Reference`. +When we pick a task for the user and then check this task, the `assignee` is incorrect. +This is because we have create two separate one-sided relationships. +What we want is a single two-sided relationship. -## Back references - -`Back references` are Keystone's mechanism that can overwrite fields of the -referenced entity. It is better seen in action, so let's write some code first. +## Setting up a two-sided relationship between User and Todo +In order to indicate that `task` and `assignee` are just two different sides of a single relationship, we need to update our configurations In `User.js` adjust the `task` field to the following: ```diff title=/lists/User.js allowCopy=false showLanguage=false @@ -109,5 +108,6 @@ UI you can pick multiple tasks for a user. See also: +- [Relationships](/docs/discussions/relationships.md) - [Schema - Lists & Fields](/docs/guides/schema.md) - [Field Types - Relationship](/packages/fields/src/types/Relationship/README.md) diff --git a/packages/adapter-knex/lib/adapter-knex.js b/packages/adapter-knex/lib/adapter-knex.js index 5ddbf6a37b5..e3f854b15d3 100644 --- a/packages/adapter-knex/lib/adapter-knex.js +++ b/packages/adapter-knex/lib/adapter-knex.js @@ -12,7 +12,6 @@ const { arrayToObject, resolveAllKeys, identity, - asyncForEach, } = require('@keystonejs/utils'); const slugify = require('@sindresorhus/slugify'); @@ -24,6 +23,7 @@ class KnexAdapter extends BaseKeystoneAdapter { this.minVer = '9.6.5'; this.schemaName = schemaName; this.listAdapterClass = this.listAdapterClass || this.defaultListAdapterClass; + this.rels = undefined; } async _connect({ name }) { @@ -37,7 +37,6 @@ class KnexAdapter extends BaseKeystoneAdapter { knexConnection = `postgres://localhost/${defaultDbName}`; logger.warn(`No Knex connection URI specified. Defaulting to '${knexConnection}'`); } - this.knex = knex({ client: this.client, connection: knexConnection, @@ -69,19 +68,16 @@ class KnexAdapter extends BaseKeystoneAdapter { } async postConnect({ rels }) { + this.rels = rels; Object.values(this.listAdapters).forEach(listAdapter => { listAdapter._postConnect({ rels }); }); - // Run this only if explicity configured and still never in production - if (this.config.dropDatabase && process.env.NODE_ENV !== 'production') { - if (process.env.NODE_ENV !== 'test') { - console.log('Knex adapter: Dropping database'); - } - await this.dropDatabase(); - } else { + if (!this.config.dropDatabase || process.env.NODE_ENV === 'production') { return []; } + + await this.dropDatabase(); return this._createTables(); } @@ -99,44 +95,43 @@ class KnexAdapter extends BaseKeystoneAdapter { } const fkResult = []; - await asyncForEach(Object.values(this.listAdapters), async listAdapter => { + for (const { left, right, cardinality, tableName } of this.rels) { try { - const relationshipAdapters = listAdapter.fieldAdapters.filter( - adapter => adapter.isRelationship - ); - - // Add foreign key constraints on this table - await this.schema().table(listAdapter.tableName, table => { - relationshipAdapters - .filter(adapter => !adapter.config.many) - .forEach(adapter => - table - .foreign(adapter.path) - .references('id') - .inTable(`${this.schemaName}.${adapter.getRefListAdapter().tableName}`) - ); - }); - - // Create adjacency tables for the 'many' relationships - await Promise.all( - relationshipAdapters - .filter(adapter => adapter.config.many) - .map(adapter => - this._createAdjacencyTable({ - tableName: listAdapter._manyTable(adapter.path), - relationshipFa: adapter, - leftListAdapter: listAdapter, - }) - ) - ); + if (cardinality === 'N:N') { + await this._createAdjacencyTable({ left, tableName }); + } else if (cardinality === '1:N') { + // create a FK on the right + await this.schema().table(right.listKey, table => { + table + .foreign(right.path) + .references('id') + .inTable(`${this.schemaName}.${left.adapter.listAdapter.tableName}`); + }); + } else if (cardinality === 'N:1') { + // create a FK on the left + await this.schema().table(left.listKey, table => { + table + .foreign(left.path) + .references('id') + .inTable(`${this.schemaName}.${left.adapter.refListKey}`); + }); + } else { + // 1:1, do it on the left. (c.f. Relationship/Implementation.js:addToTableSchema()) + await this.schema().table(left.listKey, table => { + table + .foreign(left.path) + .references('id') + .inTable(`${this.schemaName}.${left.adapter.refListKey}`); + }); + } } catch (err) { fkResult.push({ isRejected: true, reason: err }); } - }); + } return fkResult; } - async _createAdjacencyTable({ tableName, relationshipFa, leftListAdapter }) { + async _createAdjacencyTable({ left, tableName }) { // Create an adjacency table for the (many to many) relationship field adapter provided const dbAdapter = this; try { @@ -151,12 +146,14 @@ class KnexAdapter extends BaseKeystoneAdapter { } // To be clear.. + const { near, far } = left.adapter.listAdapter._getNearFar(left.adapter); + const leftListAdapter = left.adapter.listAdapter; const leftPkFa = leftListAdapter.getPrimaryKeyAdapter(); - const leftFkPath = `${leftListAdapter.key}_${leftPkFa.path}`; + const leftFkPath = near; - const rightListAdapter = dbAdapter.getListAdapterByKey(relationshipFa.refListKey); + const rightListAdapter = dbAdapter.getListAdapterByKey(left.adapter.refListKey); const rightPkFa = rightListAdapter.getPrimaryKeyAdapter(); - const rightFkPath = `${rightListAdapter.key}_${leftPkFa.path}`; + const rightFkPath = far; // So right now, apparently, `many: true` indicates many-to-many // It's not clear how isUnique would be configured at the moment @@ -201,11 +198,19 @@ class KnexAdapter extends BaseKeystoneAdapter { this.knex.destroy(); } - // This will completely drop the backing database. Use wisely. + // This will drop all the tables in the backing database. Use wisely. dropDatabase() { - const tables = Object.values(this.listAdapters) - .map(listAdapter => `"${this.schemaName}"."${listAdapter.tableName}"`) - .join(','); + if (process.env.NODE_ENV !== 'test') { + console.log('Knex adapter: Dropping database'); + } + const tables = [ + ...Object.values(this.listAdapters).map( + listAdapter => `"${this.schemaName}"."${listAdapter.tableName}"` + ), + ...this.rels + .filter(({ cardinality }) => cardinality === 'N:N') + .map(({ tableName }) => tableName), + ].join(','); return this.knex.raw(`DROP TABLE IF EXISTS ${tables} CASCADE`); } @@ -275,19 +280,36 @@ class KnexListAdapter extends BaseListAdapter { return this.parentAdapter.getQueryBuilder(); } - _manyTable(relationshipFieldPath) { - return `${this.key}_${relationshipFieldPath}`; + _manyTable(relationshipAdapter) { + return relationshipAdapter.rel.tableName; } async createTable() { // Let the field adapter add what it needs to the table schema await this._schema().createTable(this.tableName, table => { - this.fieldAdapters.forEach(adapter => adapter.addToTableSchema(table)); + this.fieldAdapters.forEach(adapter => adapter.addToTableSchema(table, this.rels)); }); } ////////// Mutations ////////// + async _unsetOneToOneValues(realData) { + // If there's a 1:1 FK in the real data we need to go and + // delete it from any other item; + await Promise.all( + Object.entries(realData) + .map(([key, value]) => ({ value, adapter: this.fieldAdaptersByPath[key] })) + .filter(({ adapter }) => adapter && adapter.isRelationship) + .filter( + ({ value, adapter: { rel } }) => + rel.cardinality === '1:1' && rel.tableName === this.tableName && value !== null + ) + .map(({ value, adapter: { rel: { tableName, columnName } } }) => + this._setNullByValue({ tableName, columnName, value }) + ) + ); + } + async _processNonRealFields(data, processFunction) { return resolveAllKeys( arrayToObject( @@ -302,6 +324,13 @@ class KnexListAdapter extends BaseListAdapter { ); } + _getNearFar(fieldAdapter) { + const { rel, path, listAdapter } = fieldAdapter; + const { columnNames } = rel; + const columnKey = `${listAdapter.key}.${path}`; + return columnNames[columnKey]; + } + async _createSingle(realData) { const item = ( await this._query() @@ -320,26 +349,35 @@ class KnexListAdapter extends BaseListAdapter { } async _createOrUpdateField({ value, adapter, itemId }) { - const rel = { - cardinality: 'N:N', - tableName: this._manyTable(adapter.path), - columnNames: { [this.key]: { near: `${this.key}_id`, far: adapter.refListId } }, - }; - const { cardinality, tableName, columnNames } = rel; + const { cardinality, columnName, tableName } = adapter.rel; + // N:N - put it in the many table + // 1:N - put it in the FK col of the other table + // 1:1 - put it in the FK col of the other table if (cardinality === '1:1') { - // Implement me + if (value !== null) { + return this._query() + .table(tableName) + .where('id', value) + .update({ [columnName]: itemId }) + .returning('id'); + } else { + return null; + } } else { const values = value; // Rename this because we have a many situation - if (values && values.length) { + if (values.length) { if (cardinality === 'N:N') { - const itemCol = columnNames[this.key].near; - const otherCol = columnNames[this.key].far; + const { near, far } = this._getNearFar(adapter); return this._query() - .insert(values.map(id => ({ [itemCol]: itemId, [otherCol]: id }))) + .insert(values.map(id => ({ [near]: itemId, [far]: id }))) .into(tableName) - .returning(otherCol); + .returning(far); } else { - // Implement me + return this._query() + .table(tableName) + .whereIn('id', values) // 1:N + .update({ [columnName]: itemId }) + .returning('id'); } } else { return []; @@ -350,20 +388,30 @@ class KnexListAdapter extends BaseListAdapter { async _create(data) { const realData = pick(data, this.realKeys); + // Unset any real 1:1 fields + await this._unsetOneToOneValues(realData); + // Insert the real data into the table const { item, itemId } = await this._createSingle(realData); - // For every many-field, update the many-table + // For every non-real-field, update the corresponding FK/join table. const manyItem = await this._processNonRealFields(data, async ({ value, adapter }) => this._createOrUpdateField({ value, adapter, itemId }) ); + // This currently over-populates the returned item. + // We should only be populating non-many fields, but the non-real-fields are generally many, + // which we want to ignore, with the exception of 1:1 fields with the FK on the other table, + // which we want to actually keep! return { ...item, ...manyItem }; } async _update(id, data) { const realData = pick(data, this.realKeys); + // Unset any real 1:1 fields + await this._unsetOneToOneValues(realData); + // Update the real data const query = this._query() .table(this.tableName) @@ -375,21 +423,22 @@ class KnexListAdapter extends BaseListAdapter { // For every many-field, update the many-table await this._processNonRealFields(data, async ({ path, value: newValues, adapter }) => { - const { refListId } = adapter; - const rel = { - cardinality: 'N:N', - tableName: this._manyTable(path), - columnNames: { [this.key]: { near: `${this.key}_id`, far: refListId } }, - }; - const { cardinality, tableName, columnNames } = rel; + const { cardinality, columnName, tableName } = adapter.rel; let value; // Future task: Is there some way to combine the following three // operations into a single query? if (cardinality !== '1:1') { // Work out what we've currently got - const selectCol = columnNames[this.key].far; - const matchCol = columnNames[this.key].near; + let matchCol, selectCol; + if (cardinality === 'N:N') { + const { near, far } = this._getNearFar(adapter); + matchCol = near; + selectCol = far; + } else { + matchCol = columnName; + selectCol = 'id'; + } const currentRefIds = ( await this._query() .select(selectCol) @@ -408,12 +457,21 @@ class KnexListAdapter extends BaseListAdapter { .whereIn(selectCol, needsDelete) // far side .del(); } else { - // Implement me + await this._query() + .table(tableName) + .whereIn(selectCol, needsDelete) + .update({ [columnName]: null }); } } value = newValues.filter(id => !currentRefIds.includes(id)); } else { - // Implement me + // If there are values, update the other side to point to me, + // otherwise, delete the thing that was pointing to me + if (newValues === null) { + const selectCol = columnName === path ? 'id' : columnName; + await this._setNullByValue({ tableName, columnName: selectCol, value: item.id }); + } + value = newValues; } await this._createOrUpdateField({ value, adapter, itemId: item.id }); }); @@ -422,23 +480,23 @@ class KnexListAdapter extends BaseListAdapter { async _delete(id) { // Traverse all other lists and remove references to this item + // We can't just traverse our own fields, because we might have been + // a silent partner in a relationship, so we have no self-knowledge of it. await Promise.all( Object.values(this.parentAdapter.listAdapters).map(adapter => Promise.all( adapter.fieldAdapters - .filter(a => a.isRelationship && a.refListKey === this.key) - .map(a => { - const rel = { - cardinality: a.config.many ? 'N:N' : '1:N', - columnName: a.path, - tableName: a.config.many ? adapter._manyTable(a.path) : adapter.tableName, - columnNames: { [this.key]: { near: a.refListId } }, - }; - const { cardinality, columnName, tableName, columnNames } = rel; + .filter( + a => a.isRelationship && a.refListKey === this.key && a.rel.tableName !== this.key + ) // If I (a list adapter) an implicated in the .rel of this field adapter + .map(fieldAdapter => { + const { cardinality, columnName, tableName } = fieldAdapter.rel; if (cardinality === 'N:N') { + // FIXME: There is a User <-> User case which isn't captured here. + const { far } = adapter._getNearFar(fieldAdapter); return this._query() .table(tableName) - .where(columnNames[this.key].near, id) + .where(far, id) .del(); } else { return this._setNullByValue({ tableName, columnName, value: id }); @@ -448,6 +506,15 @@ class KnexListAdapter extends BaseListAdapter { ) ); + // Now traverse all self-referential relationships and sort them right out. + await Promise.all( + this.rels + .filter(({ tableName }) => tableName === this.key) + .map(({ columnName, tableName }) => + this._setNullByValue({ tableName, columnName, value: id }) + ) + ); + // Delete the actual item return this._query() .table(this.tableName) @@ -491,6 +558,11 @@ class QueryBuilder { this._nextBaseTableAliasId = 0; const baseTableAlias = this._getNextBaseTableAlias(); this._query = listAdapter._query().from(`${listAdapter.tableName} as ${baseTableAlias}`); + + if (search) { + console.log('Knex adapter does not currently support search!'); + } + if (meta) { // SELECT count from as t0 this._query.count(); @@ -500,31 +572,30 @@ class QueryBuilder { } this._addJoins(this._query, listAdapter, where, baseTableAlias); + + // Joins/where to effectively translate us onto a different list if (Object.keys(from).length) { - const rel = { - cardinality: 'N:N', - tableName: from.fromList.adapter._manyTable(from.fromField), - columnNames: { - [listAdapter.key]: { - near: `${listAdapter.key}_id`, - far: `${from.fromList.adapter.key}_id`, - }, - }, - }; - const { cardinality, tableName, columnNames } = rel; + const a = from.fromList.adapter.fieldAdaptersByPath[from.fromField]; + const { cardinality, tableName, columnName } = a.rel; const otherTableAlias = this._getNextBaseTableAlias(); if (cardinality === 'N:N') { - const { near, far } = columnNames[listAdapter.key]; + const { near, far } = from.fromList.adapter._getNearFar(a); this._query.leftOuterJoin( `${tableName} as ${otherTableAlias}`, - `${otherTableAlias}.${near}`, + `${otherTableAlias}.${far}`, `${baseTableAlias}.id` ); this._query.whereRaw('true'); - this._query.andWhere(`${otherTableAlias}.${far}`, `=`, from.fromId); + this._query.andWhere(`${otherTableAlias}.${near}`, `=`, from.fromId); } else { - // Implement me + this._query.leftOuterJoin( + `${tableName} as ${otherTableAlias}`, + `${baseTableAlias}.${columnName}`, + `${otherTableAlias}.id` + ); + this._query.whereRaw('true'); + this._query.andWhere(`${baseTableAlias}.${columnName}`, `=`, from.fromId); } } else { // Dumb sentinel to avoid juggling where() vs andWhere() @@ -587,6 +658,27 @@ class QueryBuilder { // We perform joins on non-many relationship fields which are mentioned in the where query. // Joins are performed as left outer joins on fromTable.fromCol to toTable.id _addJoins(query, listAdapter, where, tableAlias) { + // Insert joins to handle 1:1 relationships where the FK is stored on the other table. + // We join against the other table and select its ID as the path name, so that it appears + // as if it existed on the primary table all along! + listAdapter.fieldAdapters + .filter(a => a.isRelationship && a.rel.cardinality === '1:1' && a.rel.right === a.field) + .forEach(a => { + const { tableName, columnName } = a.rel; + const otherTableAlias = `${tableAlias}__${a.path}_11`; + if (!this._tableAliases[otherTableAlias]) { + this._tableAliases[otherTableAlias] = true; + // LEFT OUTERJOIN on ... table>. = . SELECT . as + query + .leftOuterJoin( + `${tableName} as ${otherTableAlias}`, + `${otherTableAlias}.${columnName}`, + `${tableAlias}.id` + ) + .select(`${otherTableAlias}.id as ${a.path}`); + } + }); + const joinPaths = Object.keys(where).filter( path => !this._getQueryConditionByPath(listAdapter, path) ); @@ -645,7 +737,7 @@ class QueryBuilder { }); } else { // We have a relationship field - const fieldAdapter = listAdapter.fieldAdaptersByPath[path]; + let fieldAdapter = listAdapter.fieldAdaptersByPath[path]; if (fieldAdapter) { // Non-many relationship. Traverse the sub-query, using the referenced list as a root. const otherListAdapter = listAdapter.getListAdapterByKey(fieldAdapter.refListKey); @@ -653,33 +745,32 @@ class QueryBuilder { } else { // Many relationship const [p, constraintType] = path.split('_'); - const rel = { - cardinality: 'N:N', - tableName: listAdapter._manyTable(p), - columnNames: { - [listAdapter.key]: { - near: `${listAdapter.key}_id`, - far: `${listAdapter.fieldAdaptersByPath[p].refListKey}_id`, - }, - }, - }; - const { cardinality, tableName, columnNames } = rel; + fieldAdapter = listAdapter.fieldAdaptersByPath[p]; + const { rel } = fieldAdapter; + const { cardinality, tableName, columnName } = rel; const subBaseTableAlias = this._getNextBaseTableAlias(); - const otherList = listAdapter.fieldAdaptersByPath[p].refListKey; + const otherList = fieldAdapter.refListKey; const otherListAdapter = listAdapter.getListAdapterByKey(otherList); const subQuery = listAdapter._query(); let otherTableAlias; if (cardinality === '1:N' || cardinality === 'N:1') { - // Implement me + otherTableAlias = subBaseTableAlias; + subQuery + .select(`${subBaseTableAlias}.${columnName}`) + .from(`${tableName} as ${subBaseTableAlias}`); + // We need to filter out nulls before passing back to the top level query + // otherwise postgres will give very incorrect answers. + subQuery.whereNotNull(columnName); } else { + const { near, far } = listAdapter._getNearFar(fieldAdapter); otherTableAlias = `${subBaseTableAlias}__${p}`; subQuery - .select(`${subBaseTableAlias}.${columnNames[listAdapter.key].near}`) + .select(`${subBaseTableAlias}.${near}`) .from(`${tableName} as ${subBaseTableAlias}`); subQuery.innerJoin( `${otherListAdapter.tableName} as ${otherTableAlias}`, `${otherTableAlias}.id`, - `${subBaseTableAlias}.${columnNames[listAdapter.key].far}` + `${subBaseTableAlias}.${far}` ); } this._addJoins(subQuery, otherListAdapter, where[path], otherTableAlias); @@ -725,7 +816,14 @@ class KnexFieldAdapter extends BaseFieldAdapter { } _hasRealKeys() { - return !(this.isRelationship && this.config.many); + // We don't have a "real key" (i.e. a column in the table) if: + // * We're a N:N + // * We're the right hand side of a 1:1 + // * We're the 1 side of a 1:N or N:1 (e.g we are the one with config: many) + return !( + this.isRelationship && + (this.config.many || (this.rel.cardinality === '1:1' && this.rel.right.adapter === this)) + ); } // Gives us a way to reference knex when configuring DB-level defaults, eg: diff --git a/packages/adapter-mongoose/lib/adapter-mongoose.js b/packages/adapter-mongoose/lib/adapter-mongoose.js index 9260b9010d0..24a9311f2ff 100644 --- a/packages/adapter-mongoose/lib/adapter-mongoose.js +++ b/packages/adapter-mongoose/lib/adapter-mongoose.js @@ -2,13 +2,16 @@ const mongoose = require('mongoose'); const pSettle = require('p-settle'); const { + arrayToObject, escapeRegExp, pick, + omit, getType, mapKeys, mapKeyNames, identity, mergeWhereClause, + resolveAllKeys, versionGreaterOrEqualTo, } = require('@keystonejs/utils'); @@ -30,6 +33,7 @@ class MongooseAdapter extends BaseKeystoneAdapter { this.mongoose.set('debug', true); } this.listAdapterClass = this.listAdapterClass || this.defaultListAdapterClass; + this._manyModels = {}; } async _connect({ name }) { @@ -51,7 +55,7 @@ class MongooseAdapter extends BaseKeystoneAdapter { uri = `mongodb://localhost/${defaultDbName}`; logger.warn(`No MongoDB connection URI specified. Defaulting to '${uri}'`); } - + console.log(uri); await this.mongoose.connect(uri, { useNewUrlParser: true, useFindAndModify: false, @@ -63,15 +67,58 @@ class MongooseAdapter extends BaseKeystoneAdapter { // Setup all schemas Object.values(this.listAdapters).forEach(listAdapter => { listAdapter.fieldAdapters.forEach(fieldAdapter => { - fieldAdapter.addToMongooseSchema(listAdapter.schema, listAdapter.mongoose); + fieldAdapter.addToMongooseSchema(listAdapter.schema, listAdapter.mongoose, rels); }); }); + // Setup models for N:N tables, I guess? + for (const rel of rels.filter(({ cardinality }) => cardinality === 'N:N')) { + await this._createAdjacencyTable(rel); + } + + // Then... return await pSettle( - Object.values(this.listAdapters).map(listAdapter => listAdapter.postConnect({ rels })) + Object.values(this.listAdapters).map(listAdapter => listAdapter._postConnect({ rels })) ); } + async _createAdjacencyTable({ left, tableName, columnNames }) { + const schema = new this.mongoose.Schema({}, { ...DEFAULT_MODEL_SCHEMA_OPTIONS }); + + const columnKey = `${left.listKey}.${left.path}`; + const leftFkPath = columnNames[columnKey].near; + + const rightFkPath = columnNames[columnKey].far; + + schema.add({ [leftFkPath]: {} }); + schema.add({ [rightFkPath]: {} }); + // 4th param is 'skipInit' which avoids calling `model.init()`. + // We call model.init() later, after we have a connection up and running to + // avoid issues with Mongoose's lazy queue and setting up the indexes. + const model = this.mongoose.model(tableName, schema, null, true); + this._manyModels[tableName] = model; + // Ensure we wait for any new indexes to be built + await model.init(); + // Then ensure the indexes are all correct + // The indexes can become out of sync if the database was modified + // manually, or if the code has been updated. In both cases, the + // _existence_ of an index (not the configuration) will cause Mongoose + // to think everything is fine. + // So, here we must manually force mongoose to check the _configuration_ + // of the existing indexes before moving on. + // NOTE: Why bother with `model.init()` first? Because Mongoose will + // always try to create new indexes on model creation in the background, + // so we have to wait for that async process to finish before trying to + // sync up indexes. + // NOTE: There's a potential race condition here when two application + // instances both try to recreate the indexes by first dropping then + // creating. See + // http://thecodebarbarian.com/whats-new-in-mongoose-5-2-syncindexes + // NOTE: If an index has changed and needs recreating, this can have a + // performance impact when dealing with large datasets! + await model.syncIndexes(); + } + disconnect() { return this.mongoose.disconnect(); } @@ -138,6 +185,7 @@ class MongooseListAdapter extends BaseListAdapter { this.model = null; this.rels = undefined; + this.realKeys = []; } /** @@ -147,13 +195,18 @@ class MongooseListAdapter extends BaseListAdapter { * * @return Promise<> */ - async postConnect({ rels }) { + async _postConnect({ rels }) { this.rels = rels; this.fieldAdapters.forEach(fieldAdapter => { fieldAdapter.rel = rels.find( ({ left, right }) => left.adapter === fieldAdapter || (right && right.adapter === fieldAdapter) ); + if (fieldAdapter._hasRealKeys()) { + this.realKeys.push( + ...(fieldAdapter.realKeys ? fieldAdapter.realKeys : [fieldAdapter.path]) + ); + } }); if (this.configureMongooseSchema) { this.configureMongooseSchema(this.schema, { mongoose: this.mongoose }); @@ -163,7 +216,7 @@ class MongooseListAdapter extends BaseListAdapter { // We call model.init() later, after we have a connection up and running to // avoid issues with Mongoose's lazy queue and setting up the indexes. this.model = this.mongoose.model(this.key, this.schema, null, true); - + this.parentAdapter._manyModels[this.key] = this.model; // Ensure we wait for any new indexes to be built await this.model.init(); // Then ensure the indexes are all correct @@ -186,24 +239,234 @@ class MongooseListAdapter extends BaseListAdapter { return this.model.syncIndexes(); } + _getModel(tableName) { + return this.parentAdapter._manyModels[tableName]; + } + ////////// Mutations ////////// - _create(data) { - return this.model.create(data); + async _unsetOneToOneValues(realData) { + // If there's a 1:1 FK in the real data we need to go and + // delete it from any other item; + await Promise.all( + Object.entries(realData) + .map(([key, value]) => ({ value, adapter: this.fieldAdaptersByPath[key] })) + .filter(({ adapter }) => adapter && adapter.isRelationship) + .filter( + ({ value, adapter: { rel } }) => + rel.cardinality === '1:1' && rel.tableName === this.key && value !== null + ) + .map(({ value, adapter: { rel: { tableName, columnName } } }) => + this._setNullByValue({ tableName, columnName, value }) + ) + ); } - _delete(id) { - return this.model.deleteOne({ _id: id }).then(result => result.deletedCount); + async _processNonRealFields(data, processFunction) { + return resolveAllKeys( + arrayToObject( + Object.entries(omit(data, this.realKeys)).map(([path, value]) => ({ + path, + value, + adapter: this.fieldAdaptersByPath[path], + })), + 'path', + processFunction + ) + ); + } + + _getNearFar(fieldAdapter) { + const { rel, path, listAdapter } = fieldAdapter; + const { columnNames } = rel; + const columnKey = `${listAdapter.key}.${path}`; + return columnNames[columnKey]; } - _update(id, data) { + async _createSingle(realData) { + const item = (await this.model.create(realData)).toObject(); + + const itemId = item._id; + return { item, itemId }; + } + + async _setNullByValue({ tableName, columnName, value }) { + return this._getModel(tableName).updateMany( + { [columnName]: { $eq: value } }, + { [columnName]: null } + ); + } + + async _createOrUpdateField({ value, adapter, itemId }) { + const { cardinality, columnName, tableName } = adapter.rel; + // N:N - put it in the many table + // 1:N - put it in the FK col of the other table + // 1:1 - put it in the FK col of the other table + if (cardinality === '1:1') { + if (value !== null) { + await this._getModel(tableName).updateMany({ _id: value }, { [columnName]: itemId }); + return value; + } else { + return null; + } + } else { + const values = value; // Rename this because we have a many situation + if (values.length) { + if (cardinality === 'N:N') { + const { near, far } = this._getNearFar(adapter); + return ( + await this._getModel(tableName).create( + values.map(id => ({ + [near]: mongoose.Types.ObjectId(itemId), + [far]: mongoose.Types.ObjectId(id), + })) + ) + ).map(x => x[far]); + } else { + await this._getModel(tableName).updateMany( + { _id: { $in: values } }, + { [columnName]: itemId } + ); + return values; + } + } else { + return []; + } + } + } + + async _create(data) { + const realData = pick(data, this.realKeys); + + // Unset any real 1:1 fields + await this._unsetOneToOneValues(realData); + + // Insert the real data into the table + const { item, itemId } = await this._createSingle(realData); + + // For every non-real-field, update the corresponding FK/join table. + const manyItem = await this._processNonRealFields(data, async ({ value, adapter }) => + this._createOrUpdateField({ value, adapter, itemId }) + ); + + // This currently over-populates the returned item. + // We should only be populating non-many fields, but the non-real-fields are generally many, + // which we want to ignore, with the exception of 1:1 fields with the FK on the other table, + // which we want to actually keep! + return { ...item, ...manyItem }; + } + + async _update(id, data) { + const realData = pick(data, this.realKeys); + + // Unset any real 1:1 fields + await this._unsetOneToOneValues(realData); + + // Update the real data // Avoid any kind of injection attack by explicitly doing a `$set` operation // Return the modified item, not the original - return this.model.findByIdAndUpdate( + const item = await this.model.findByIdAndUpdate( id, - { $set: data }, + { $set: realData }, { new: true, runValidators: true, context: 'query' } ); + + // For every many-field, update the many-table + await this._processNonRealFields(data, async ({ path, value: newValues, adapter }) => { + const { cardinality, columnName, tableName } = adapter.rel; + let value; + // Future task: Is there some way to combine the following three + // operations into a single query? + + if (cardinality !== '1:1') { + // Work out what we've currently got + let matchCol, selectCol; + if (cardinality === 'N:N') { + const { near, far } = this._getNearFar(adapter); + matchCol = near; + selectCol = far; + } else { + matchCol = columnName; + selectCol = '_id'; + } + const currentRefIds = ( + await this._getModel(tableName).aggregate([ + { $match: { [matchCol]: mongoose.Types.ObjectId(item.id) } }, + ]) + ).map(x => x[selectCol].toString()); + + // Delete what needs to be deleted + const needsDelete = currentRefIds.filter(x => !newValues.includes(x)); + if (needsDelete.length) { + if (cardinality === 'N:N') { + await this._getModel(tableName).deleteMany({ + $and: [ + { [matchCol]: { $eq: item._id } }, + { [selectCol]: { $in: needsDelete.map(id => mongoose.Types.ObjectId(id)) } }, + ], + }); + } else { + await this._getModel(tableName).updateMany( + { [selectCol]: { $in: needsDelete.map(id => mongoose.Types.ObjectId(id)) } }, + { [columnName]: null } + ); + } + } + value = newValues.filter(id => !currentRefIds.includes(id)); + } else { + // If there are values, update the other side to point to me, + // otherwise, delete the thing that was pointing to me + if (newValues === null) { + const selectCol = columnName === path ? '_id' : columnName; + await this._setNullByValue({ tableName, columnName: selectCol, value: item.id }); + } + value = newValues; + } + await this._createOrUpdateField({ value, adapter, itemId: item.id }); + }); + return (await this._itemsQuery({ where: { id: item.id }, first: 1 }))[0] || null; + } + + async _delete(id) { + id = mongoose.Types.ObjectId(id); + // Traverse all other lists and remove references to this item + // We can't just traverse our own fields, because we might have been + // a silent partner in a relationship, so we have no self-knowledge of it. + await Promise.all( + Object.values(this.parentAdapter.listAdapters).map(adapter => + Promise.all( + adapter.fieldAdapters + .filter( + a => + a.isRelationship && + a.refListKey === this.key && + this._getModel(a.rel.tableName) !== this.model + ) // If I (a list adapter) an implicated in the .rel of this field adapter + .map(fieldAdapter => { + const { cardinality, columnName, tableName } = fieldAdapter.rel; + if (cardinality === 'N:N') { + // FIXME: There is a User <-> User case which isn't captured here. + const { far } = this._getNearFar(fieldAdapter); + return this._getModel(tableName).deleteMany({ [far]: { $eq: id } }); + } else { + return this._setNullByValue({ tableName, columnName, value: id }); + } + }) + ) + ) + ); + + // Now traverse all self-referential relationships and sort them right out. + await Promise.all( + this.rels + .filter(({ tableName }) => tableName === this.key) + .map(({ columnName, tableName }) => + this._setNullByValue({ tableName, columnName, value: id }) + ) + ); + + // Delete the actual item + return this.model.deleteOne({ _id: id }).then(result => result.deletedCount); } ////////// Queries ////////// @@ -220,15 +483,29 @@ class MongooseListAdapter extends BaseListAdapter { async _itemsQuery(args, { meta = false, from, include } = {}) { if (from && Object.keys(from).length) { - const ids = await from.fromList.adapter._itemsQuery( - { where: { id: from.fromId } }, - { include: from.fromField } - ); - if (ids.length) { - args = mergeWhereClause(args, { id: { $in: ids[0][from.fromField] || [] } }); + const { rel } = from.fromList.adapter.fieldAdaptersByPath[from.fromField]; + const { cardinality, tableName, columnName, columnNames } = rel; + let ids = []; + if (cardinality === 'N:N') { + const a = from.fromList.adapter.fieldAdaptersByPath[from.fromField]; + const columnKey = `${from.fromList.adapter.key}.${a.path}`; + ids = await this._getModel(tableName).aggregate([ + { + $match: { + [columnNames[columnKey].near]: { $eq: mongoose.Types.ObjectId(from.fromId) }, + }, + }, + ]); + ids = ids.map(x => x[columnNames[columnKey].far]); + } else { + ids = await this._getModel(tableName).aggregate([ + { $match: { [columnName]: mongoose.Types.ObjectId(from.fromId) } }, + ]); + ids = ids.map(x => x._id); } - } + args = mergeWhereClause(args, { id: { $in: ids || [] } }); + } // Convert the args `where` clauses and modifiers into a data structure // which can be consumed by the queryParser. Modifiers are prefixed with a // $ symbol (e.g. skip => $skip) to be identified by the tokenizer. @@ -257,9 +534,31 @@ class MongooseListAdapter extends BaseListAdapter { const queryTree = queryParser({ listAdapter: this }, query, [], include); + // 1:1 relationship magic + const lookups = []; + this.fieldAdapters + .filter(a => a.isRelationship && a.rel.cardinality === '1:1' && a.rel.right === a.field) + .forEach(a => { + const { tableName, columnName } = a.rel; + const tmpName = `__${a.path}`; + lookups.push( + { + $lookup: { + from: this._getModel(tableName).collection.name, + as: tmpName, + localField: '_id', + foreignField: columnName, + }, + }, + { $unwind: { path: `$${tmpName}`, preserveNullAndEmptyArrays: true } }, + { $addFields: { [a.path]: `$${tmpName}._id` } }, + { $project: { [tmpName]: 0 } } + ); + }); // Run the query against the given database and collection - return this.model - .aggregate(pipelineBuilder(queryTree)) + const pipeline = pipelineBuilder(queryTree); + const ret = await this.model + .aggregate([...pipeline, ...lookups]) .exec() .then(foundItems => { if (meta) { @@ -272,6 +571,7 @@ class MongooseListAdapter extends BaseListAdapter { } return foundItems; }); + return ret; } } @@ -287,6 +587,17 @@ class MongooseFieldAdapter extends BaseFieldAdapter { // this.mongooseOptions = this.config.mongooseOptions || {}; } + _hasRealKeys() { + // We don't have a "real key" (i.e. a column in the table) if: + // * We're a N:N + // * We're the right hand side of a 1:1 + // * We're the 1 side of a 1:N or N:1 (e.g we are the one with config: many) + return !( + this.isRelationship && + (this.config.many || (this.rel.cardinality === '1:1' && this.rel.right.adapter === this)) + ); + } + addToMongooseSchema() { throw new Error(`Field type [${this.fieldName}] does not implement addToMongooseSchema()`); } diff --git a/packages/adapter-mongoose/tests/list-adapter.test.js b/packages/adapter-mongoose/tests/list-adapter.test.js index 87d98775479..f75a961d0fc 100644 --- a/packages/adapter-mongoose/tests/list-adapter.test.js +++ b/packages/adapter-mongoose/tests/list-adapter.test.js @@ -59,6 +59,7 @@ describe('MongooseListAdapter', () => { field: { config: { many: true }, many: true }, path: 'posts', dbPath: 'posts', + rel: { cardinality: '1:N', tableName: 'Post', columnName: 'author' }, }, ]; @@ -70,9 +71,9 @@ describe('MongooseListAdapter', () => { $lookup: { as: expect.any(String), from: 'posts', - let: expect.any(Object), + let: { tmpVar: '$_id' }, pipeline: [ - { $match: { $expr: { $in: ['$_id', expect.any(String)] } } }, + { $match: { $expr: { $eq: ['$author', '$$tmpVar'] } } }, { $match: { name: { $eq: 'foo' } } }, { $addFields: { id: '$_id' } }, { $project: { posts: 0 } }, @@ -93,9 +94,9 @@ describe('MongooseListAdapter', () => { $lookup: { as: expect.any(String), from: 'posts', - let: expect.any(Object), + let: { tmpVar: '$_id' }, pipeline: [ - { $match: { $expr: { $in: ['$_id', expect.any(String)] } } }, + { $match: { $expr: { $eq: ['$author', '$$tmpVar'] } } }, { $match: { name: { $eq: 'foo' } } }, { $addFields: { id: '$_id' } }, { $project: { posts: 0 } }, @@ -123,15 +124,6 @@ describe('MongooseListAdapter', () => { getQueryConditions: () => ({ title: value => ({ title: { $eq: value } }) }), field: { config: { many: false }, many: false }, }, - { - isRelationship: true, - getQueryConditions: () => ({}), - supportsRelationshipQuery: query => query === 'posts_some', - getRefListAdapter: () => ({ model: { collection: { name: 'posts' } } }), - field: { config: { many: true }, many: true }, - path: 'posts', - dbPath: 'posts', - }, ]; const userListAdapter = createListAdapter(MongooseListAdapter, 'user'); @@ -144,6 +136,7 @@ describe('MongooseListAdapter', () => { field: { config: { many: true }, many: true }, path: 'posts', dbPath: 'posts', + rel: { cardinality: '1:N', tableName: 'Post', columnName: 'author' }, }, ]; @@ -156,9 +149,9 @@ describe('MongooseListAdapter', () => { $lookup: { as: expect.any(String), from: 'posts', - let: expect.any(Object), + let: { tmpVar: '$_id' }, pipeline: [ - { $match: { $expr: { $in: ['$_id', expect.any(String)] } } }, + { $match: { $expr: { $eq: ['$author', '$$tmpVar'] } } }, { $match: { $and: [{ name: { $eq: 'foo' } }, { title: { $eq: 'bar' } }] } }, { $addFields: { id: '$_id' } }, { $project: { posts: 0 } }, @@ -179,9 +172,9 @@ describe('MongooseListAdapter', () => { $lookup: { as: expect.any(String), from: 'posts', - let: expect.any(Object), + let: { tmpVar: '$_id' }, pipeline: [ - { $match: { $expr: { $in: ['$_id', expect.any(String)] } } }, + { $match: { $expr: { $eq: ['$author', '$$tmpVar'] } } }, { $match: { $or: [{ name: { $eq: 'foo' } }, { title: { $eq: 'bar' } }] } }, { $addFields: { id: '$_id' } }, { $project: { posts: 0 } }, diff --git a/packages/fields/src/types/Relationship/Implementation.js b/packages/fields/src/types/Relationship/Implementation.js index 620c106502f..5fd0ab6ef71 100644 --- a/packages/fields/src/types/Relationship/Implementation.js +++ b/packages/fields/src/types/Relationship/Implementation.js @@ -10,7 +10,6 @@ const { import { Implementation } from '../../Implementation'; import { resolveNested } from './nested-mutations'; -import { enqueueBacklinkOperations } from './backlinks'; export class Relationship extends Implementation { constructor(path, { ref, many, withMeta }) { @@ -228,42 +227,9 @@ export class Relationship extends Implementation { mutationState, }); - // Enqueue backlink operations for the connections and disconnections - if (refField) { - enqueueBacklinkOperations( - { connect: [...create, ...connect], disconnect }, - mutationState.queues, - getItem || Promise.resolve(item), - listInfo.local, - listInfo.foreign - ); - } - return { create, connect, disconnect, currentValue }; } - registerBacklink(data, item, mutationState) { - // Early out for null'd field - if (!data) { - return; - } - - const { refList, refField } = this.tryResolveRefList(); - if (refField) { - enqueueBacklinkOperations( - { disconnect: this.many ? data : [data] }, - mutationState.queues, - Promise.resolve(item), - { list: this.getListByKey(this.listKey), field: this }, - { list: refList, field: refField } - ); - } - // TODO: Cascade _deletion_ of any related items (not just setting the - // reference to null) - // Accept a config option for cascading: https://www.prisma.io/docs/1.4/reference/service-configuration/data-modelling-(sdl)-eiroozae8u/#the-@relation-directive - // Beware of circular delete hooks! - } - getGqlAuxTypes({ schemaName }) { const { refList } = this.tryResolveRefList(); if (!refList.access[schemaName].update) { @@ -348,14 +314,26 @@ export class MongoRelationshipInterface extends MongooseFieldAdapter { this.isRelationship = true; } - addToMongooseSchema(schema) { - const { - refListKey: ref, - config: { many }, - } = this; - const type = many ? [ObjectId] : ObjectId; - const schemaOptions = { type, ref }; - schema.add({ [this.path]: this.mergeSchemaOptions(schemaOptions, this.config) }); + addToMongooseSchema(schema, mongoose, rels) { + // If we're relating to 'many' things, we don't store ids in this table + if (!this.field.many) { + // If we're the right hand side of a 1:1 relationship, do nothing. + const { right, cardinality } = rels.find( + ({ left, right }) => left.adapter === this || (right && right.adapter === this) + ); + if (cardinality === '1:1' && right && right.adapter === this) { + return; + } + + // Otherwise, we're are hosting a foreign key + const { + refListKey: ref, + config: { many }, + } = this; + const type = many ? [ObjectId] : ObjectId; // FIXME: redundant? + const schemaOptions = { type, ref }; + schema.add({ [this.path]: this.mergeSchemaOptions(schemaOptions, this.config) }); + } } getRefListAdapter() { @@ -394,7 +372,6 @@ export class KnexRelationshipInterface extends KnexFieldAdapter { const [refListKey, refFieldPath] = this.config.ref.split('.'); this.refListKey = refListKey; this.refFieldPath = refFieldPath; - this.refListId = `${refListKey}_id`; } // Override the isNotNullable defaulting logic; default to false, not field.isRequired @@ -413,9 +390,16 @@ export class KnexRelationshipInterface extends KnexFieldAdapter { return this.getListByKey(this.refListKey).adapter; } - addToTableSchema(table) { + addToTableSchema(table, rels) { // If we're relating to 'many' things, we don't store ids in this table if (!this.field.many) { + // If we're the right hand side of a 1:1 relationship, do nothing. + const { right, cardinality } = rels.find( + ({ left, right }) => left.adapter === this || (right && right.adapter === this) + ); + if (cardinality === '1:1' && right && right.adapter === this) { + return; + } // The foreign key needs to do this work for us; we don't know what type it is const refList = this.getListByKey(this.refListKey); const refId = refList.getPrimaryKey(); diff --git a/packages/fields/src/types/Relationship/backlinks.js b/packages/fields/src/types/Relationship/backlinks.js deleted file mode 100644 index 2fa2ed308e0..00000000000 --- a/packages/fields/src/types/Relationship/backlinks.js +++ /dev/null @@ -1,56 +0,0 @@ -function _queueIdForOperation({ queue, foreign, local, done }) { - // The queueID encodes the full list/field path and ID of both the foreign and local fields - const f = info => `${info.list.key}.${info.field.path}.${info.id}`; - const queueId = `${f(local)}->${f(foreign)}`; - - // It may have already been added elsewhere, so we don't want to add it again - if (!queue.has(queueId)) { - queue.set(queueId, { local, foreign: { id: foreign.id }, done }); - } -} - -export function enqueueBacklinkOperations(operations, queues, getItem, local, foreign) { - Object.entries(operations).forEach(([operation, idsToOperateOn = []]) => { - queues[operation] = queues[operation] || new Map(); - const queue = queues[operation]; - // NOTE: We don't return this promises, we expect it to be fulfilled at a - // future date and don't want to wait for it now. - getItem.then(item => { - const _local = { ...local, id: item.id }; - idsToOperateOn.forEach(id => { - const _foreign = { ...foreign, id }; - // Enqueue the backlink operation (foreign -> local) - _queueIdForOperation({ queue, foreign: _local, local: _foreign, done: false }); - - // Effectively dequeue the forward link operation (local -> foreign) - // To avoid any circular updates with the above disconnect, we flag this - // item as having already been connected/disconnected - _queueIdForOperation({ queue, foreign: _foreign, local: _local, done: true }); - }); - }); - }); -} - -export async function resolveBacklinks(context, mutationState) { - await Promise.all( - Object.entries(mutationState.queues).map(async ([operation, queue]) => { - for (let queuedWork of queue.values()) { - if (queuedWork.done) { - continue; - } - // Flag it as handled so we don't try again in a nested update - // NOTE: We do this first before any other work below to avoid async issues - // To avoid issues with looping and Map()s, we directly set the value on the - // object as stored in the Map, and don't try to update the Map() itself. - queuedWork.done = true; - - // Run update of local.path >> foreign.id - // NOTE: This relies on the user having `update` permissions on the local list. - const { local, foreign } = queuedWork; - const { path, many } = local.field; - const clause = { [path]: { [operation]: many ? [foreign] : foreign } }; - await local.list.updateMutation(local.id.toString(), clause, context, mutationState); - } - }) - ); -} diff --git a/packages/fields/src/types/Relationship/index.js b/packages/fields/src/types/Relationship/index.js index a0ccca9596f..ec63302b006 100644 --- a/packages/fields/src/types/Relationship/index.js +++ b/packages/fields/src/types/Relationship/index.js @@ -3,7 +3,6 @@ import { MongoRelationshipInterface, KnexRelationshipInterface, } from './Implementation'; -import { resolveBacklinks } from './backlinks'; import { importView } from '@keystonejs/build-field-types'; export default { @@ -20,5 +19,4 @@ export default { mongoose: MongoRelationshipInterface, knex: KnexRelationshipInterface, }, - resolveBacklinks, }; diff --git a/packages/keystone/bin/commands/upgrade-relationships.js b/packages/keystone/bin/commands/upgrade-relationships.js new file mode 100644 index 00000000000..2a05bf5a473 --- /dev/null +++ b/packages/keystone/bin/commands/upgrade-relationships.js @@ -0,0 +1,199 @@ +const path = require('path'); +const chalk = require('chalk'); +const terminalLink = require('terminal-link'); +const { DEFAULT_ENTRY } = require('../../constants'); +const { getEntryFileFullPath } = require('../utils'); + +const c = s => chalk.cyan(s); + +// Mongoose operations +const deleteField = (migration, field, _pluralize) => + migration + ? `db.${_pluralize(field.listKey)}.updateMany({}, { $unset: { "${field.path}": 1 } })` + : ` * Delete ${c(`${_pluralize(field.listKey)}.${field.path}`)}`; + +const moveData = (migration, left, tableName, near, far, _pluralize) => + migration + ? `db.${_pluralize(left.listKey)}.find({}).forEach(function(doc){ doc.${ + left.path + }.forEach(function(itemId) { db.${_pluralize( + tableName + )}.insert({ ${near}: doc._id, ${far}: itemId }) } ) });` + : ` * Create a collection ${c(_pluralize(tableName))} with fields ${c(near)} and ${c( + far + )}\n * Move the data from ${c(`${_pluralize(left.listKey)}.${left.path}`)} into ${c( + _pluralize(tableName) + )}`; + +// Postgres operations +const dropTable = (migration, tableName, schemaName) => + migration ? `DROP TABLE ${schemaName}."${tableName}"` : ` * Drop table ${c(tableName)}`; + +const dropColumn = (migration, tableName, columnName, schemaName) => + migration + ? `ALTER TABLE ${schemaName}."${tableName}" DROP COLUMN "${columnName}";` + : ` * Delete column ${c(`${tableName}.${columnName}`)}`; + +const renameColumn = (migration, from, to, schemaName, tableName) => + migration + ? `ALTER TABLE ${schemaName}."${tableName}" RENAME COLUMN "${from}" TO "${to}";` + : ` * Rename column ${c(from)} to ${c(to)}`; + +const renameTable = (migration, from, to, schemaName) => + migration + ? `ALTER TABLE ${schemaName}."${from}" RENAME TO "${to}";` + : ` * Rename table ${c(from)} to ${c(to)}`; + +const ttyLink = (url, text) => { + const link = terminalLink(url, url, { fallback: () => url }); + console.log(`🔗 ${chalk.green(text)}\t${link}`); +}; + +const printArrow = ({ migration, left, right }) => { + if (!migration) { + if (right) { + console.log(` ${left.listKey}.${left.path} -> ${right.listKey}.${right.path}`); + } else { + console.log(` ${left.listKey}.${left.path} -> ${left.refListKey}`); + } + } +}; + +const strategySummary = ( + { one_one_to_many, one_many_to_many, two_one_to_one, two_one_to_many, two_many_to_many }, + keystone, + migration +) => { + const mongo = !!keystone.adapters.MongooseAdapter; + + if (!migration) { + console.log(chalk.bold('One-sided: one to many')); + } + one_one_to_many.forEach(({ left }) => { + printArrow({ migration, left }); + if (!migration) { + console.log(' * No action required'); + } + }); + + if (!migration) { + console.log(chalk.bold('One-sided: many to many')); + } + one_many_to_many.forEach(({ left, columnNames, tableName }) => { + const { near, far } = columnNames[`${left.listKey}.${left.path}`]; + printArrow({ migration, left }); + if (mongo) { + const { _pluralize } = keystone.adapters.MongooseAdapter.mongoose; + console.log(moveData(migration, left, tableName, near, far, _pluralize)); + console.log(deleteField(migration, left, _pluralize)); + } else { + const { schemaName } = keystone.adapters.KnexAdapter; + console.log(renameTable(migration, `${left.listKey}_${left.path}`, tableName, schemaName)); + console.log(renameColumn(migration, `${left.listKey}_id`, near, schemaName, tableName)); + console.log(renameColumn(migration, `${left.refListKey}_id`, far, schemaName, tableName)); + } + }); + + if (!migration) { + console.log(chalk.bold('Two-sided: one to one')); + } + two_one_to_one.forEach(({ left, right }) => { + printArrow({ migration, left, right }); + if (mongo) { + const { _pluralize } = keystone.adapters.MongooseAdapter.mongoose; + console.log(deleteField(migration, right, _pluralize)); + } else { + const { schemaName } = keystone.adapters.KnexAdapter; + console.log(dropColumn(migration, right.listKey, right.path, schemaName)); + } + }); + + if (!migration) { + console.log(chalk.bold('Two-sided: one to many')); + } + two_one_to_many.forEach(({ left, right, tableName }) => { + const dropper = left.listKey === tableName ? right : left; + printArrow({ migration, left, right }); + if (mongo) { + const { _pluralize } = keystone.adapters.MongooseAdapter.mongoose; + console.log(deleteField(migration, dropper, _pluralize)); + } else { + const { schemaName } = keystone.adapters.KnexAdapter; + console.log(dropTable(migration, `${dropper.listKey}_${dropper.path}`, schemaName)); + } + }); + + if (!migration) { + console.log(chalk.bold('Two-sided: many to many')); + } + two_many_to_many.forEach(({ left, right, tableName, columnNames }) => { + const { near, far } = columnNames[`${left.listKey}.${left.path}`]; + printArrow({ migration, left, right }); + if (mongo) { + const { _pluralize } = keystone.adapters.MongooseAdapter.mongoose; + console.log(moveData(migration, left, tableName, near, far, _pluralize)); + console.log(deleteField(migration, left, _pluralize)); + console.log(deleteField(migration, right, _pluralize)); + } else { + const { schemaName } = keystone.adapters.KnexAdapter; + console.log(dropTable(migration, `${right.listKey}_${right.path}`, schemaName)); + console.log(renameTable(migration, `${left.listKey}_${left.path}`, tableName, schemaName)); + console.log(renameColumn(migration, `${left.listKey}_id`, near, schemaName, tableName)); + console.log(renameColumn(migration, `${left.refListKey}_id`, far, schemaName, tableName)); + } + }); +}; + +const upgradeRelationships = async (args, entryFile) => { + const migration = !!args['--migration']; + // Allow the spinner time to flush its output to the console. + await new Promise(resolve => setTimeout(resolve, 100)); + const { keystone } = require(path.resolve(entryFile)); + + const rels = keystone._consolidateRelationships(); + + const one_one_to_many = rels.filter(({ right, cardinality }) => !right && cardinality !== 'N:N'); + const one_many_to_many = rels.filter(({ right, cardinality }) => !right && cardinality === 'N:N'); + + const two_one_to_one = rels.filter(({ right, cardinality }) => right && cardinality === '1:1'); + const two_one_to_many = rels.filter( + ({ right, cardinality }) => right && (cardinality === '1:N' || cardinality === 'N:1') + ); + const two_many_to_many = rels.filter(({ right, cardinality }) => right && cardinality === 'N:N'); + + strategySummary( + { + one_one_to_many, + one_many_to_many, + two_one_to_one, + two_one_to_many, + two_many_to_many, + }, + keystone, + migration + ); + console.log(''); + ttyLink('https://www.keystonejs.com/discussions/relationships', 'More info on relationships'); + process.exit(0); +}; + +module.exports = { + // prettier-ignore + spec: { + '--entry': String, + '--migration': Boolean, + }, + help: ({ exeName }) => ` + Usage + $ ${exeName} upgrade-relationships + + Options + --entry Entry file exporting keystone instance [${DEFAULT_ENTRY}] + --migration Generate code which can be used in a migration script + `, + exec: async (args, { exeName, _cwd = process.cwd() } = {}, spinner) => { + spinner.stop(); // no thank you + const entryFile = await getEntryFileFullPath(args, { exeName, _cwd }); + return upgradeRelationships(args, entryFile); + }, +}; diff --git a/packages/keystone/lib/Keystone/index.js b/packages/keystone/lib/Keystone/index.js index 3daaf84263a..f25a5655ced 100644 --- a/packages/keystone/lib/Keystone/index.js +++ b/packages/keystone/lib/Keystone/index.js @@ -362,6 +362,89 @@ module.exports = class Keystone { ); } + // Ensure that the left/right pattern is always the same no matter what order + // the lists and fields are defined. + Object.values(rels).forEach(rel => { + const { left, right } = rel; + if (right) { + const order = left.listKey.localeCompare(right.listKey); + if (order > 0) { + // left comes after right, so swap them. + rel.left = right; + rel.right = left; + } else if (order === 0) { + // self referential list, so check the paths. + if (left.path.localeCompare(right.path) > 0) { + rel.left = right; + rel.right = left; + } + } + } + }); + + Object.values(rels).forEach(rel => { + const { left, right } = rel; + let cardinality; + if (left.config.many) { + if (right) { + if (right.config.many) { + cardinality = 'N:N'; + } else { + cardinality = '1:N'; + } + } else { + // right not specified, have to assume that it's N:N + cardinality = 'N:N'; + } + } else { + if (right) { + if (right.config.many) { + cardinality = 'N:1'; + } else { + cardinality = '1:1'; + } + } else { + // right not specified, have to assume that it's N:1 + cardinality = 'N:1'; + } + } + rel.cardinality = cardinality; + + let tableName; + let columnName; + if (cardinality === 'N:N') { + tableName = right + ? `${left.listKey}_${left.path}_${right.listKey}_${right.path}` + : `${left.listKey}_${left.path}_many`; + if (right) { + const leftKey = `${left.listKey}.${left.path}`; + const rightKey = `${right.listKey}.${right.path}`; + rel.columnNames = { + [leftKey]: { near: `${left.listKey}_left_id`, far: `${right.listKey}_right_id` }, + [rightKey]: { near: `${right.listKey}_right_id`, far: `${left.listKey}_left_id` }, + }; + } else { + const leftKey = `${left.listKey}.${left.path}`; + const rightKey = `${left.config.ref}`; + rel.columnNames = { + [leftKey]: { near: `${left.listKey}_left_id`, far: `${left.config.ref}_right_id` }, + [rightKey]: { near: `${left.config.ref}_right_id`, far: `${left.listKey}_left_id` }, + }; + } + } else if (cardinality === '1:1') { + tableName = left.listKey; + columnName = left.path; + } else if (cardinality === '1:N') { + tableName = right.listKey; + columnName = right.path; + } else { + tableName = left.listKey; + columnName = left.path; + } + rel.tableName = tableName; + rel.columnName = columnName; + }); + return Object.values(rels); } diff --git a/packages/keystone/lib/List/index.js b/packages/keystone/lib/List/index.js index 3513eb8c66d..3a21245323a 100644 --- a/packages/keystone/lib/List/index.js +++ b/packages/keystone/lib/List/index.js @@ -1060,13 +1060,6 @@ module.exports = class List { }; } - async _registerBacklinks(existingItem, mutationState) { - const fields = this.fields.filter(field => field.isRelationship); - await this._mapToFields(fields, field => - field.registerBacklink(existingItem[field.path], existingItem, mutationState) - ); - } - async _resolveDefaults({ context, originalInput }) { const args = { context, @@ -1266,13 +1259,11 @@ module.exports = class List { } async _nestedMutation(mutationState, context, mutation) { - const { Relationship } = require('@keystonejs/fields'); // Set up a fresh mutation state if we're the root mutation const isRootMutation = !mutationState; if (isRootMutation) { mutationState = { afterChangeStack: [], // post-hook stack - queues: {}, // backlink queues transaction: {}, // transaction }; } @@ -1280,9 +1271,6 @@ module.exports = class List { // Perform the mutation const { result, afterHook } = await mutation(mutationState); - // resolve backlinks - await Relationship.resolveBacklinks(context, mutationState); - // Push after-hook onto the stack and resolve all if we're the root. const { afterChangeStack } = mutationState; afterChangeStack.push(afterHook); @@ -1486,9 +1474,7 @@ module.exports = class List { async _deleteSingle(existingItem, context, mutationState) { const operation = 'delete'; - return await this._nestedMutation(mutationState, context, async mutationState => { - await this._registerBacklinks(existingItem, mutationState); - + return await this._nestedMutation(mutationState, context, async () => { await this._validateDelete(existingItem, context, operation); await this._beforeDelete(existingItem, context, operation); diff --git a/packages/mongo-join-builder/README.md b/packages/mongo-join-builder/README.md index cf56cefca01..756b5ba6262 100644 --- a/packages/mongo-join-builder/README.md +++ b/packages/mongo-join-builder/README.md @@ -111,31 +111,12 @@ db.orders.aggregate([ as: 'abc123_items', let: { tmpVar: '$items' }, pipeline: [ - { - $match: { - $and: [{ name: { $regex: /a/ } }, { $expr: { $in: ['$_id', '$$tmpVar'] } }], - }, - }, - { - $addFields: { - id: '$_id', - }, - }, + { $match: { $expr: { $eq: [`$foreignKey`, '$$tmpVar'] } } }, + { $match: { $and: [{ name: { $regex: /a/ } }] } }, + { $addFields: { id: '$_id' } }, ], }, }, - { - $addFields: { - abc123_items_every: { $eq: [{ $size: '$abc123_items' }, { $size: '$items' }] }, - abc123_items_none: { $eq: [{ $size: '$abc123_items' }, 0] }, - abc123_items_some: { - $and: [ - { $gt: [{ $size: '$abc123_items' }, 0] }, - { $lte: [{ $size: '$abc123_items' }, { $size: '$items' }] }, - ], - }, - }, - }, { $match: { $and: [{ abc123_items_every: { $eq: true } }], diff --git a/packages/mongo-join-builder/lib/join-builder.js b/packages/mongo-join-builder/lib/join-builder.js index 76e4c84829b..f9ce1df8367 100644 --- a/packages/mongo-join-builder/lib/join-builder.js +++ b/packages/mongo-join-builder/lib/join-builder.js @@ -43,29 +43,88 @@ const { flatten, defaultObj } = require('@keystonejs/utils'); } */ +const lookupStage = ({ from, as, targetKey, foreignKey, extraPipeline = [] }) => ({ + $lookup: { + from, + as, + let: { tmpVar: `$${targetKey}` }, + pipeline: [{ $match: { $expr: { $eq: [`$${foreignKey}`, '$$tmpVar'] } } }, ...extraPipeline], + }, +}); + function relationshipPipeline(relationship) { - const { field, many, from, uniqueField } = relationship.relationshipInfo; - return [ - { - $lookup: { + const { from, thisTable, path, rel, filterType, uniqueField } = relationship.relationshipInfo; + const { cardinality, columnNames } = rel; + const extraPipeline = pipelineBuilder(relationship); + const extraField = `${uniqueField}_all`; + if (cardinality === '1:N' || cardinality === 'N:1') { + // Perform a single FK join + let targetKey, foreignKey; + // FIXME: I feel like the logic here could use some revie + if (filterType !== 'only' && rel.right && rel.left.listKey === rel.right.listKey) { + targetKey = '_id'; + foreignKey = rel.columnName; + } else { + targetKey = rel.tableName === thisTable ? rel.columnName : '_id'; + foreignKey = rel.tableName === thisTable ? '_id' : rel.columnName; + } + return [ + // Join against all the items which match the relationship filter condition + lookupStage({ from, as: uniqueField, targetKey, foreignKey, extraPipeline }), + // Match against *all* the items. Required for the _every condition. + filterType === 'every' && lookupStage({ from, as: extraField, targetKey, foreignKey }), + ]; + } else { + // Perform a pair of joins through the join table + const { farCollection } = relationship.relationshipInfo; + const columnKey = `${thisTable}.${path}`; + return [ + // Join against all the items which match the relationship filter condition + lookupStage({ from, as: uniqueField, - // We use `ifNull` here to handle the case unique to mongo where a record may be - // entirely missing a field (or have the value set to `null`). - let: { tmpVar: many ? { $ifNull: [`$${field}`, []] } : `$${field}` }, - pipeline: [ - // The ID / list of IDs we're joining by. Do this very first so it limits any work - // required in subsequent steps / $and's. - { $match: { $expr: { [many ? '$in' : '$eq']: ['$_id', `$$tmpVar`] } } }, - ...pipelineBuilder(relationship), + targetKey: '_id', + foreignKey: columnNames[columnKey].near, + extraPipeline: [ + lookupStage({ + from: farCollection, + as: `${uniqueField}_0`, + targetKey: columnNames[columnKey].far, + foreignKey: '_id', + extraPipeline, + }), + { $match: { $expr: { $gt: [{ $size: `$${uniqueField}_0` }, 0] } } }, ], - }, - }, - ]; + }), + // Match against *all* the items. Required for the _every condition. + filterType === 'every' && + lookupStage({ + from, + as: extraField, + targetKey: '_id', + foreignKey: columnNames[columnKey].near, + extraPipeline: [ + lookupStage({ + from: farCollection, + as: `${uniqueField}_0`, + targetKey: columnNames[columnKey].far, + foreignKey: '_id', + }), + ], + }), + ]; + } } function pipelineBuilder({ relationships, matchTerm, excludeFields, postJoinPipeline }) { - excludeFields.push(...relationships.map(({ relationshipInfo }) => relationshipInfo.uniqueField)); + excludeFields.push( + ...flatten( + relationships.map(({ relationshipInfo: { uniqueField, filterType } }) => [ + uniqueField, + filterType === 'every' && `${uniqueField}_all`, + ]) + ).filter(i => i) + ); return [ ...flatten(relationships.map(relationshipPipeline)), matchTerm && { $match: matchTerm }, diff --git a/packages/mongo-join-builder/lib/query-parser.js b/packages/mongo-join-builder/lib/query-parser.js index 2cd16cc7922..41725183ef3 100644 --- a/packages/mongo-join-builder/lib/query-parser.js +++ b/packages/mongo-join-builder/lib/query-parser.js @@ -1,4 +1,3 @@ -const cuid = require('cuid'); const { getType, flatten } = require('@keystonejs/utils'); const { simpleTokenizer, relationshipTokenizer, modifierTokenizer } = require('./tokenizers'); @@ -16,7 +15,7 @@ const flattenQueries = (parsedQueries, joinOp) => ({ relationships: flatten(parsedQueries.map(q => q.relationships || [])), }); -function queryParser({ listAdapter, getUID = cuid }, query, pathSoFar = [], include) { +function queryParser({ listAdapter, getUID }, query, pathSoFar = [], include) { if (getType(query) !== 'Object') { throw new Error( `Expected an Object for query, got ${getType(query)} at path ${pathSoFar.join('.')}` @@ -44,12 +43,7 @@ function queryParser({ listAdapter, getUID = cuid }, query, pathSoFar = [], incl } } else if (getType(value) === 'Object') { // A relationship query component - const { matchTerm, relationshipInfo } = relationshipTokenizer( - listAdapter, - key, - path, - getUID(key) - ); + const { matchTerm, relationshipInfo } = relationshipTokenizer(listAdapter, key, path, getUID); return { // matchTerm is our filtering expression. This determines if the // parent item is included in the final list diff --git a/packages/mongo-join-builder/lib/tokenizers.js b/packages/mongo-join-builder/lib/tokenizers.js index 838a44e9736..78b4a03a31a 100644 --- a/packages/mongo-join-builder/lib/tokenizers.js +++ b/packages/mongo-join-builder/lib/tokenizers.js @@ -1,3 +1,4 @@ +const cuid = require('cuid'); const { objMerge, getType, escapeRegExp } = require('@keystonejs/utils'); const getRelatedListAdapterFromQueryPath = (listAdapter, queryPath) => { @@ -51,7 +52,7 @@ const getRelatedListAdapterFromQueryPath = (listAdapter, queryPath) => { return foundListAdapter; }; -const relationshipTokenizer = (listAdapter, queryKey, path, uid) => { +const relationshipTokenizer = (listAdapter, queryKey, path, getUID = cuid) => { const refListAdapter = getRelatedListAdapterFromQueryPath(listAdapter, path); const fieldAdapter = refListAdapter.findFieldAdapterForQuerySegment(queryKey); @@ -64,15 +65,13 @@ const relationshipTokenizer = (listAdapter, queryKey, path, uid) => { [`${fieldAdapter.path}_some`]: 'some', [`${fieldAdapter.path}_none`]: 'none', }[queryKey]; - - // We use `ifNull` here to handle the case unique to mongo where a record may be - // entirely missing a field (or have the value set to `null`). - const field = fieldAdapter.path; - const uniqueField = `${uid}_${field}`; + const refListAdapter2 = fieldAdapter.getRefListAdapter(); + const { rel } = fieldAdapter; + const uniqueField = `${getUID(queryKey)}_${fieldAdapter.path}`; const fieldSize = { $size: `$${uniqueField}` }; const expr = { only: { $eq: [fieldSize, 1] }, - every: { $eq: [fieldSize, { $size: { $ifNull: [`$${field}`, []] } }] }, + every: { $eq: [fieldSize, { $size: `$${uniqueField}_all` }] }, none: { $eq: [fieldSize, 0] }, some: { $gt: [fieldSize, 0] }, }[filterType]; @@ -80,10 +79,17 @@ const relationshipTokenizer = (listAdapter, queryKey, path, uid) => { return { matchTerm: { $expr: expr }, relationshipInfo: { - from: fieldAdapter.getRefListAdapter().model.collection.name, // the collection name to join with - field: fieldAdapter.path, // The field on this collection - many: fieldAdapter.field.many, // Flag this is a to-many relationship + from: + rel.cardinality === 'N:N' + ? refListAdapter2._getModel(rel.tableName).collection.name + : refListAdapter2.model.collection.name, // the collection name to join with + thisTable: refListAdapter.key, + path: fieldAdapter.path, + rel, + filterType, uniqueField, + // N:N only + farCollection: refListAdapter2.model.collection.name, }, }; }; diff --git a/packages/mongo-join-builder/tests/index.test.js b/packages/mongo-join-builder/tests/index.test.js index 416b6209d0d..70fca2de341 100644 --- a/packages/mongo-join-builder/tests/index.test.js +++ b/packages/mongo-join-builder/tests/index.test.js @@ -24,18 +24,29 @@ describe('Test main export', () => { $lookup: { from: 'posts', as: 'posts_every_posts', - let: { tmpVar: { $ifNull: ['$posts', []] } }, + let: { tmpVar: `$_id` }, pipeline: [ - { $match: { $expr: { $in: ['$_id', '$$tmpVar'] } } }, + { $match: { $expr: { $eq: [`$author`, '$$tmpVar'] } } }, { $lookup: { - from: 'tags', + from: 'posts_tags', as: 'tags_some_tags', - let: { tmpVar: { $ifNull: ['$tags', []] } }, + let: { tmpVar: `$_id` }, pipeline: [ - { $match: { $expr: { $in: ['$_id', '$$tmpVar'] } } }, - { $match: { name: { $eq: 'foo' } } }, - { $addFields: { id: '$_id' } }, + { $match: { $expr: { $eq: [`$Post_id`, '$$tmpVar'] } } }, + { + $lookup: { + from: 'tags', + as: 'tags_some_tags_0', + let: { tmpVar: '$Tag_id' }, + pipeline: [ + { $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }, + { $match: { name: { $eq: 'foo' } } }, + { $addFields: { id: '$_id' } }, + ], + }, + }, + { $match: { $expr: { $gt: [{ $size: '$tags_some_tags_0' }, 0] } } }, ], }, }, @@ -52,6 +63,14 @@ describe('Test main export', () => { ], }, }, + { + $lookup: { + from: 'posts', + as: 'posts_every_posts_all', + let: { tmpVar: '$_id' }, + pipeline: [{ $match: { $expr: { $eq: [`$author`, '$$tmpVar'] } } }], + }, + }, { $match: { $and: [ @@ -59,14 +78,14 @@ describe('Test main export', () => { { age: { $eq: 23 } }, { $expr: { - $eq: [{ $size: '$posts_every_posts' }, { $size: { $ifNull: ['$posts', []] } }], + $eq: [{ $size: '$posts_every_posts' }, { $size: '$posts_every_posts_all' }], }, }, ], }, }, { $addFields: { id: '$_id' } }, - { $project: { posts_every_posts: 0 } }, + { $project: { posts_every_posts: 0, posts_every_posts_all: 0 } }, ]); }); }); diff --git a/packages/mongo-join-builder/tests/join-builder.test.js b/packages/mongo-join-builder/tests/join-builder.test.js index eb2bb4d9f8f..295929fc2d5 100644 --- a/packages/mongo-join-builder/tests/join-builder.test.js +++ b/packages/mongo-join-builder/tests/join-builder.test.js @@ -43,9 +43,9 @@ describe('join builder', () => { matchTerm: { name: { $eq: 'Alice' } }, relationshipInfo: { from: 'users', - field: 'author', - many: false, uniqueField: 'abc123_author', + rel: { cardinality: '1:N', columnName: 'author' }, + filterType: 'one', }, excludeFields: [], postJoinPipeline: [], @@ -70,7 +70,7 @@ describe('join builder', () => { as: 'abc123_author', let: { tmpVar: '$author' }, pipeline: [ - { $match: { $expr: { $eq: ['$_id', '$$tmpVar'] } } }, + { $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }, { $match: { name: { $eq: 'Alice' } } }, { $addFields: { id: '$_id' } }, ], @@ -106,9 +106,9 @@ describe('join builder', () => { { relationshipInfo: { from: 'posts', - field: 'posts', - many: true, uniqueField: 'abc123_posts', + rel: { cardinality: '1:N', columnName: 'author' }, + filterType: 'every', }, postJoinPipeline: [], excludeFields: [], @@ -119,7 +119,7 @@ describe('join builder', () => { $and: [ { name: { $eq: 'foobar' } }, { age: { $eq: 23 } }, - { $expr: { $gt: [{ $size: '$abc123_posts' }, 0] } }, + { $expr: { $eq: [{ $size: '$abc123_posts' }, { $size: '$abc123_posts_all' }] } }, ], }, excludeFields: [], @@ -131,24 +131,32 @@ describe('join builder', () => { $lookup: { from: 'posts', as: 'abc123_posts', - let: { tmpVar: { $ifNull: ['$posts', []] } }, + let: { tmpVar: `$author` }, pipeline: [ - { $match: { $expr: { $in: ['$_id', '$$tmpVar'] } } }, + { $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }, { $addFields: { id: '$_id' } }, ], }, }, + { + $lookup: { + from: 'posts', + as: 'abc123_posts_all', + let: { tmpVar: `$author` }, + pipeline: [{ $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }], + }, + }, { $match: { $and: [ { name: { $eq: 'foobar' } }, { age: { $eq: 23 } }, - { $expr: { $gt: [{ $size: '$abc123_posts' }, 0] } }, + { $expr: { $eq: [{ $size: '$abc123_posts' }, { $size: '$abc123_posts_all' }] } }, ], }, }, { $addFields: { id: '$_id' } }, - { $project: { abc123_posts: 0 } }, + { $project: { abc123_posts: 0, abc123_posts_all: 0 } }, ]); }); @@ -168,9 +176,9 @@ describe('join builder', () => { { relationshipInfo: { from: 'posts', - field: 'posts', - many: true, uniqueField: 'abc123_posts', + rel: { cardinality: '1:N', columnName: 'author' }, + filterType: 'every', }, postJoinPipeline: [{ $orderBy: 'title' }], relationships: [], @@ -181,7 +189,7 @@ describe('join builder', () => { $and: [ { name: { $eq: 'foobar' } }, { age: { $eq: 23 } }, - { $expr: { $eq: [{ $size: '$abc123_posts' }, { $size: { $ifNull: ['$posts', []] } }] } }, + { $expr: { $eq: [{ $size: '$abc123_posts' }, { $size: '$abc123_posts_all' }] } }, ], }, excludeFields: [], @@ -193,27 +201,33 @@ describe('join builder', () => { $lookup: { from: 'posts', as: 'abc123_posts', - let: { tmpVar: { $ifNull: ['$posts', []] } }, + let: { tmpVar: `$author` }, pipeline: [ - { $match: { $expr: { $in: ['$_id', '$$tmpVar'] } } }, + { $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }, { $addFields: { id: '$_id' } }, { $orderBy: 'title' }, ], }, }, + { + $lookup: { + from: 'posts', + as: 'abc123_posts_all', + let: { tmpVar: `$author` }, + pipeline: [{ $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }], + }, + }, { $match: { $and: [ { name: { $eq: 'foobar' } }, { age: { $eq: 23 } }, - { - $expr: { $eq: [{ $size: '$abc123_posts' }, { $size: { $ifNull: ['$posts', []] } }] }, - }, + { $expr: { $eq: [{ $size: '$abc123_posts' }, { $size: '$abc123_posts_all' }] } }, ], }, }, { $addFields: { id: '$_id' } }, - { $project: { abc123_posts: 0 } }, + { $project: { abc123_posts: 0, abc123_posts_all: 0 } }, { $limit: 10 }, ]); }); @@ -240,14 +254,14 @@ describe('join builder', () => { const pipeline = pipelineBuilder({ relationships: [ { - matchTerm: { - $and: [{ title: { $eq: 'hello' } }, { $expr: { $gt: [{ $size: '$def456_tags' }, 0] } }], - }, relationshipInfo: { from: 'posts', - field: 'posts', - many: true, uniqueField: 'abc123_posts', + rel: { cardinality: '1:N', columnName: 'author' }, + filterType: 'every', + }, + matchTerm: { + $and: [{ title: { $eq: 'hello' } }, { $expr: { $gt: [{ $size: '$def456_tags' }, 0] } }], }, postJoinPipeline: [], excludeFields: [], @@ -257,17 +271,24 @@ describe('join builder', () => { $and: [ { name: { $eq: 'React' } }, { - $expr: { - $eq: [{ $size: '$xyz890_posts' }, { $size: { $ifNull: ['$posts', []] } }], - }, + $expr: { $eq: [{ $size: '$xyz890_posts' }, { $size: '$xyz890_posts_all' }] }, }, ], }, relationshipInfo: { - from: 'tags', - field: 'tags', - many: true, + from: 'posts_tags', uniqueField: 'def456_tags', + path: 'tags', + rel: { + cardinality: 'N:N', + columnNames: { + 'Post.tags': { near: 'Post_id', far: 'Tag_id' }, + 'Tag.posts': { near: 'Tag_id', far: 'Post_id' }, + }, + }, + thisTable: 'Post', + filterType: 'some', + farCollection: 'tags', }, postJoinPipeline: [], excludeFields: [], @@ -275,10 +296,20 @@ describe('join builder', () => { { matchTerm: { published: { $eq: true } }, relationshipInfo: { - from: 'posts', - field: 'posts', - many: true, + from: 'posts_tags', uniqueField: 'xyz890_posts', + path: 'posts', + rel: { + cardinality: 'N:N', + columnNames: { + 'Post.tags': { near: 'Post_id', far: 'Tag_id' }, + 'Tag.posts': { near: 'Tag_id', far: 'Post_id' }, + }, + tableName: 'Posts_Tags', + }, + thisTable: 'Tag', + filterType: 'every', + farCollection: 'posts', }, postJoinPipeline: [], excludeFields: [], @@ -293,7 +324,7 @@ describe('join builder', () => { $and: [ { name: { $eq: 'foobar' } }, { age: { $eq: 23 } }, - { $expr: { $gt: [{ $size: '$abc123_posts' }, 0] } }, + { $expr: { $eq: [{ $size: '$abc123_posts' }, { $size: '$abc123_posts_all' }] } }, ], }, excludeFields: [], @@ -305,46 +336,82 @@ describe('join builder', () => { $lookup: { from: 'posts', as: 'abc123_posts', - let: { tmpVar: { $ifNull: ['$posts', []] } }, + let: { tmpVar: `$author` }, pipeline: [ - { $match: { $expr: { $in: ['$_id', '$$tmpVar'] } } }, + { $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }, { $lookup: { - from: 'tags', + from: 'posts_tags', as: 'def456_tags', - let: { tmpVar: { $ifNull: ['$tags', []] } }, + let: { tmpVar: `$_id` }, pipeline: [ - { $match: { $expr: { $in: ['$_id', '$$tmpVar'] } } }, + { $match: { $expr: { $eq: [`$Post_id`, '$$tmpVar'] } } }, { $lookup: { - from: 'posts', - as: 'xyz890_posts', - let: { tmpVar: { $ifNull: ['$posts', []] } }, + from: 'tags', + as: 'def456_tags_0', + let: { tmpVar: '$Tag_id' }, pipeline: [ - { $match: { $expr: { $in: ['$_id', '$$tmpVar'] } } }, - { $match: { published: { $eq: true } } }, - { $addFields: { id: '$_id' } }, - ], - }, - }, - - { - $match: { - $and: [ - { name: { $eq: 'React' } }, + { $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }, { - $expr: { - $eq: [ - { $size: '$xyz890_posts' }, - { $size: { $ifNull: ['$posts', []] } }, + $lookup: { + from: 'posts_tags', + as: 'xyz890_posts', + let: { tmpVar: `$_id` }, + pipeline: [ + { $match: { $expr: { $eq: [`$Tag_id`, '$$tmpVar'] } } }, + { + $lookup: { + from: 'posts', + as: 'xyz890_posts_0', + let: { tmpVar: `$Post_id` }, + pipeline: [ + { $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }, + { $match: { published: { $eq: true } } }, + { $addFields: { id: '$_id' } }, + ], + }, + }, + { $match: { $expr: { $gt: [{ $size: '$xyz890_posts_0' }, 0] } } }, ], }, }, + { + $lookup: { + from: 'posts_tags', + as: 'xyz890_posts_all', + let: { tmpVar: `$_id` }, + pipeline: [ + { $match: { $expr: { $eq: [`$Tag_id`, '$$tmpVar'] } } }, + { + $lookup: { + from: 'posts', + as: 'xyz890_posts_0', + let: { tmpVar: `$Post_id` }, + pipeline: [{ $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }], + }, + }, + ], + }, + }, + { + $match: { + $and: [ + { name: { $eq: 'React' } }, + { + $expr: { + $eq: [{ $size: '$xyz890_posts' }, { $size: '$xyz890_posts_all' }], + }, + }, + ], + }, + }, + { $addFields: { id: '$_id' } }, + { $project: { xyz890_posts: 0, xyz890_posts_all: 0 } }, ], }, }, - { $addFields: { id: '$_id' } }, - { $project: { xyz890_posts: 0 } }, + { $match: { $expr: { $gt: [{ $size: '$def456_tags_0' }, 0] } } }, ], }, }, @@ -361,17 +428,25 @@ describe('join builder', () => { ], }, }, + { + $lookup: { + from: 'posts', + as: 'abc123_posts_all', + let: { tmpVar: `$author` }, + pipeline: [{ $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }], + }, + }, { $match: { $and: [ { name: { $eq: 'foobar' } }, { age: { $eq: 23 } }, - { $expr: { $gt: [{ $size: '$abc123_posts' }, 0] } }, + { $expr: { $eq: [{ $size: '$abc123_posts' }, { $size: '$abc123_posts_all' }] } }, ], }, }, { $addFields: { id: '$_id' } }, - { $project: { abc123_posts: 0 } }, + { $project: { abc123_posts: 0, abc123_posts_all: 0 } }, ]); }); @@ -403,9 +478,9 @@ describe('join builder', () => { }, relationshipInfo: { from: 'posts', - field: 'posts', - many: true, uniqueField: 'zip567_posts', + rel: { cardinality: '1:N', columnName: 'author' }, + filterType: 'every', }, postJoinPipeline: [], excludeFields: [], @@ -413,10 +488,19 @@ describe('join builder', () => { { matchTerm: { name: { $eq: 'foo' } }, relationshipInfo: { - from: 'labels', - field: 'labels', - many: true, + from: 'posts_labels', uniqueField: 'quux987_labels', + path: 'labels', + rel: { + cardinality: 'N:N', + columnNames: { + 'Post.labels': { near: 'Post_id', far: 'Label_id' }, + 'Label.posts': { near: 'Label_id', far: 'Post_id' }, + }, + }, + thisTable: 'Post', + filterType: 'some', + farCollection: 'labels', }, postJoinPipeline: [], excludeFields: [], @@ -429,11 +513,7 @@ describe('join builder', () => { $and: [ { name: { $eq: 'foobar' } }, { age: { $eq: 23 } }, - { - $expr: { - $eq: [{ $size: '$zip567_posts' }, { $size: { $ifNull: ['$posts', []] } }], - }, - }, + { $expr: { $eq: [{ $size: '$zip567_posts' }, { $size: '$zip567_posts_all' }] } }, ], }, excludeFields: [], @@ -445,18 +525,29 @@ describe('join builder', () => { $lookup: { from: 'posts', as: 'zip567_posts', - let: { tmpVar: { $ifNull: ['$posts', []] } }, + let: { tmpVar: `$author` }, pipeline: [ - { $match: { $expr: { $in: ['$_id', '$$tmpVar'] } } }, + { $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }, { $lookup: { - from: 'labels', + from: 'posts_labels', as: 'quux987_labels', - let: { tmpVar: { $ifNull: ['$labels', []] } }, + let: { tmpVar: `$_id` }, pipeline: [ - { $match: { $expr: { $in: ['$_id', '$$tmpVar'] } } }, - { $match: { name: { $eq: 'foo' } } }, - { $addFields: { id: '$_id' } }, + { $match: { $expr: { $eq: [`$Post_id`, '$$tmpVar'] } } }, + { + $lookup: { + from: 'labels', + as: 'quux987_labels_0', + let: { tmpVar: `$Label_id` }, + pipeline: [ + { $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }, + { $match: { name: { $eq: 'foo' } } }, + { $addFields: { id: '$_id' } }, + ], + }, + }, + { $match: { $expr: { $gt: [{ $size: '$quux987_labels_0' }, 0] } } }, ], }, }, @@ -473,21 +564,25 @@ describe('join builder', () => { ], }, }, + { + $lookup: { + from: 'posts', + as: 'zip567_posts_all', + let: { tmpVar: `$author` }, + pipeline: [{ $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }], + }, + }, { $match: { $and: [ { name: { $eq: 'foobar' } }, { age: { $eq: 23 } }, - { - $expr: { - $eq: [{ $size: '$zip567_posts' }, { $size: { $ifNull: ['$posts', []] } }], - }, - }, + { $expr: { $eq: [{ $size: '$zip567_posts' }, { $size: '$zip567_posts_all' }] } }, ], }, }, { $addFields: { id: '$_id' } }, - { $project: { zip567_posts: 0 } }, + { $project: { zip567_posts: 0, zip567_posts_all: 0 } }, ]); }); @@ -519,9 +614,9 @@ describe('join builder', () => { }, relationshipInfo: { from: 'posts', - field: 'posts', - many: true, uniqueField: 'zip567_posts', + rel: { cardinality: '1:N', columnName: 'author' }, + filterType: 'every', }, postJoinPipeline: [], excludeFields: [], @@ -529,10 +624,19 @@ describe('join builder', () => { { matchTerm: { name: { $eq: 'foo' } }, relationshipInfo: { - from: 'labels', - field: 'labels', - many: true, + from: 'posts_labels', uniqueField: 'quux987_labels', + path: 'labels', + rel: { + cardinality: 'N:N', + columnNames: { + 'Post.labels': { near: 'Post_id', far: 'Label_id' }, + 'Label.posts': { near: 'Label_id', far: 'Post_id' }, + }, + }, + thisTable: 'Post', + filterType: 'some', + farCollection: 'labels', }, postJoinPipeline: [], excludeFields: [], @@ -545,11 +649,7 @@ describe('join builder', () => { $or: [ { name: { $eq: 'foobar' } }, { age: { $eq: 23 } }, - { - $expr: { - $eq: [{ $size: '$zip567_posts' }, { $size: { $ifNull: ['$posts', []] } }], - }, - }, + { $expr: { $eq: [{ $size: '$zip567_posts' }, { $size: '$zip567_posts_all' }] } }, ], }, excludeFields: [], @@ -561,18 +661,29 @@ describe('join builder', () => { $lookup: { from: 'posts', as: 'zip567_posts', - let: { tmpVar: { $ifNull: ['$posts', []] } }, + let: { tmpVar: `$author` }, pipeline: [ - { $match: { $expr: { $in: ['$_id', '$$tmpVar'] } } }, + { $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }, { $lookup: { - from: 'labels', + from: 'posts_labels', as: 'quux987_labels', - let: { tmpVar: { $ifNull: ['$labels', []] } }, + let: { tmpVar: `$_id` }, pipeline: [ - { $match: { $expr: { $in: ['$_id', '$$tmpVar'] } } }, - { $match: { name: { $eq: 'foo' } } }, - { $addFields: { id: '$_id' } }, + { $match: { $expr: { $eq: [`$Post_id`, '$$tmpVar'] } } }, + { + $lookup: { + from: 'labels', + as: 'quux987_labels_0', + let: { tmpVar: `$Label_id` }, + pipeline: [ + { $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }, + { $match: { name: { $eq: 'foo' } } }, + { $addFields: { id: '$_id' } }, + ], + }, + }, + { $match: { $expr: { $gt: [{ $size: '$quux987_labels_0' }, 0] } } }, ], }, }, @@ -589,21 +700,25 @@ describe('join builder', () => { ], }, }, + { + $lookup: { + from: 'posts', + as: 'zip567_posts_all', + let: { tmpVar: `$author` }, + pipeline: [{ $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }], + }, + }, { $match: { $or: [ { name: { $eq: 'foobar' } }, { age: { $eq: 23 } }, - { - $expr: { - $eq: [{ $size: '$zip567_posts' }, { $size: { $ifNull: ['$posts', []] } }], - }, - }, + { $expr: { $eq: [{ $size: '$zip567_posts' }, { $size: '$zip567_posts_all' }] } }, ], }, }, { $addFields: { id: '$_id' } }, - { $project: { zip567_posts: 0 } }, + { $project: { zip567_posts: 0, zip567_posts_all: 0 } }, ]); }); @@ -636,9 +751,9 @@ describe('join builder', () => { relationshipInfo: { from: 'posts', - field: 'posts', - many: true, uniqueField: 'zip567_posts', + rel: { cardinality: '1:N', columnName: 'author' }, + filterType: 'every', }, postJoinPipeline: [], excludeFields: [], @@ -646,10 +761,19 @@ describe('join builder', () => { { matchTerm: { name: { $eq: 'foo' } }, relationshipInfo: { - from: 'labels', - field: 'labels', - many: true, + from: 'posts_labels', uniqueField: 'quux987_labels', + path: 'labels', + rel: { + cardinality: 'N:N', + columnNames: { + 'Post.labels': { near: 'Post_id', far: 'Label_id' }, + 'Label.posts': { near: 'Label_id', far: 'Post_id' }, + }, + }, + thisTable: 'Post', + filterType: 'some', + farCollection: 'labels', }, postJoinPipeline: [], excludeFields: [], @@ -664,7 +788,7 @@ describe('join builder', () => { { age: { $eq: 23 } }, { $expr: { - $eq: [{ $size: '$zip567_posts' }, { $size: { $ifNull: ['$posts', []] } }], + $eq: [{ $size: '$zip567_posts' }, { $size: '$zip567_posts_all' }], }, }, ], @@ -678,18 +802,29 @@ describe('join builder', () => { $lookup: { from: 'posts', as: 'zip567_posts', - let: { tmpVar: { $ifNull: ['$posts', []] } }, + let: { tmpVar: `$author` }, pipeline: [ - { $match: { $expr: { $in: ['$_id', '$$tmpVar'] } } }, + { $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }, { $lookup: { - from: 'labels', + from: 'posts_labels', as: 'quux987_labels', - let: { tmpVar: { $ifNull: ['$labels', []] } }, + let: { tmpVar: `$_id` }, pipeline: [ - { $match: { $expr: { $in: ['$_id', '$$tmpVar'] } } }, - { $match: { name: { $eq: 'foo' } } }, - { $addFields: { id: '$_id' } }, + { $match: { $expr: { $eq: [`$Post_id`, '$$tmpVar'] } } }, + { + $lookup: { + from: 'labels', + as: 'quux987_labels_0', + let: { tmpVar: `$Label_id` }, + pipeline: [ + { $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }, + { $match: { name: { $eq: 'foo' } } }, + { $addFields: { id: '$_id' } }, + ], + }, + }, + { $match: { $expr: { $gt: [{ $size: '$quux987_labels_0' }, 0] } } }, ], }, }, @@ -706,21 +841,25 @@ describe('join builder', () => { ], }, }, + { + $lookup: { + from: 'posts', + as: 'zip567_posts_all', + let: { tmpVar: `$author` }, + pipeline: [{ $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }], + }, + }, { $match: { $and: [ { name: { $eq: 'foobar' } }, { age: { $eq: 23 } }, - { - $expr: { - $eq: [{ $size: '$zip567_posts' }, { $size: { $ifNull: ['$posts', []] } }], - }, - }, + { $expr: { $eq: [{ $size: '$zip567_posts' }, { $size: '$zip567_posts_all' }] } }, ], }, }, { $addFields: { id: '$_id' } }, - { $project: { zip567_posts: 0 } }, + { $project: { zip567_posts: 0, zip567_posts_all: 0 } }, ]); }); @@ -752,9 +891,9 @@ describe('join builder', () => { }, relationshipInfo: { from: 'posts', - field: 'posts', - many: true, uniqueField: 'zip567_posts', + rel: { cardinality: '1:N', columnName: 'author' }, + filterType: 'every', }, postJoinPipeline: [], excludeFields: [], @@ -762,10 +901,19 @@ describe('join builder', () => { { matchTerm: { name: { $eq: 'foo' } }, relationshipInfo: { - from: 'labels', - field: 'labels', - many: true, + from: 'posts_labels', uniqueField: 'quux987_labels', + path: 'labels', + rel: { + cardinality: 'N:N', + columnNames: { + 'Post.labels': { near: 'Post_id', far: 'Label_id' }, + 'Label.posts': { near: 'Label_id', far: 'Post_id' }, + }, + }, + thisTable: 'Post', + filterType: 'some', + farCollection: 'labels', }, postJoinPipeline: [], excludeFields: [], @@ -778,7 +926,7 @@ describe('join builder', () => { $or: [ { name: { $eq: 'foobar' } }, { age: { $eq: 23 } }, - { $expr: { $eq: [{ $size: '$zip567_posts' }, { $size: { $ifNull: ['$posts', []] } }] } }, + { $expr: { $eq: [{ $size: '$zip567_posts' }, { $size: '$zip567_posts_all' }] } }, ], }, excludeFields: [], @@ -790,18 +938,29 @@ describe('join builder', () => { $lookup: { from: 'posts', as: 'zip567_posts', - let: { tmpVar: { $ifNull: ['$posts', []] } }, + let: { tmpVar: `$author` }, pipeline: [ - { $match: { $expr: { $in: ['$_id', '$$tmpVar'] } } }, + { $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }, { $lookup: { - from: 'labels', + from: 'posts_labels', as: 'quux987_labels', - let: { tmpVar: { $ifNull: ['$labels', []] } }, + let: { tmpVar: `$_id` }, pipeline: [ - { $match: { $expr: { $in: ['$_id', '$$tmpVar'] } } }, - { $match: { name: { $eq: 'foo' } } }, - { $addFields: { id: '$_id' } }, + { $match: { $expr: { $eq: [`$Post_id`, '$$tmpVar'] } } }, + { + $lookup: { + from: 'labels', + as: 'quux987_labels_0', + let: { tmpVar: `$Label_id` }, + pipeline: [ + { $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }, + { $match: { name: { $eq: 'foo' } } }, + { $addFields: { id: '$_id' } }, + ], + }, + }, + { $match: { $expr: { $gt: [{ $size: '$quux987_labels_0' }, 0] } } }, ], }, }, @@ -818,19 +977,25 @@ describe('join builder', () => { ], }, }, + { + $lookup: { + from: 'posts', + as: 'zip567_posts_all', + let: { tmpVar: `$author` }, + pipeline: [{ $match: { $expr: { $eq: [`$_id`, '$$tmpVar'] } } }], + }, + }, { $match: { $or: [ { name: { $eq: 'foobar' } }, { age: { $eq: 23 } }, - { - $expr: { $eq: [{ $size: '$zip567_posts' }, { $size: { $ifNull: ['$posts', []] } }] }, - }, + { $expr: { $eq: [{ $size: '$zip567_posts' }, { $size: '$zip567_posts_all' }] } }, ], }, }, { $addFields: { id: '$_id' } }, - { $project: { zip567_posts: 0 } }, + { $project: { zip567_posts: 0, zip567_posts_all: 0 } }, ]); }); }); diff --git a/packages/mongo-join-builder/tests/mongo-results.test.js b/packages/mongo-join-builder/tests/mongo-results.test.js index 3d151397881..6b53837f008 100644 --- a/packages/mongo-join-builder/tests/mongo-results.test.js +++ b/packages/mongo-join-builder/tests/mongo-results.test.js @@ -141,17 +141,42 @@ describe('Testing against real data', () => { const usersCollection = mongoDb.collection('users'); const postsCollection = mongoDb.collection('posts'); - const { insertedIds } = await postsCollection.insertMany([ - { title: 'Hello world', status: 'published' }, - { title: 'Testing', status: 'published' }, - { title: 'An awesome post', status: 'draft' }, - { title: 'Another Thing', status: 'published' }, + const { insertedIds } = await usersCollection.insertMany([ + { + name: 'Jess', + type: 'author', + }, + { + name: 'Alice', + type: 'author', + }, + { + name: 'Sam', + type: 'editor', + }, ]); - await usersCollection.insertMany([ - { name: 'Jess', type: 'author', posts: [insertedIds[0], insertedIds[2]] }, - { name: 'Alice', type: 'author', posts: [insertedIds[1], insertedIds[3]] }, - { name: 'Sam', type: 'editor', posts: [insertedIds[3]] }, + await postsCollection.insertMany([ + { + title: 'Hello world', + status: 'published', + author: insertedIds[0], + }, + { + title: 'Testing', + status: 'published', + author: insertedIds[1], + }, + { + title: 'An awesome post', + status: 'draft', + author: insertedIds[0], + }, + { + title: 'Another Thing', + status: 'published', + author: insertedIds[1], + }, ]); const query = { type: 'author' }; @@ -159,8 +184,14 @@ describe('Testing against real data', () => { const result = await builder(query, getAggregate(mongoDb, 'users')); expect(result).toMatchObject([ - { name: 'Jess', type: 'author', posts: [insertedIds[0], insertedIds[2]] }, - { name: 'Alice', type: 'author', posts: [insertedIds[1], insertedIds[3]] }, + { + name: 'Jess', + type: 'author', + }, + { + name: 'Alice', + type: 'author', + }, ]); }); @@ -170,18 +201,46 @@ describe('Testing against real data', () => { const usersCollection = mongoDb.collection('users'); const postsCollection = mongoDb.collection('posts'); - const { insertedIds } = await postsCollection.insertMany([ - { title: 'Hello world', status: 'published' }, - { title: 'Testing', status: 'published' }, - { title: 'An awesome post', status: 'draft' }, - { title: 'Another Thing', status: 'published' }, + const { insertedIds } = await usersCollection.insertMany([ + { + name: 'Jess', + type: 'author', + }, + { + name: 'Alice', + type: 'author', + }, + { + name: 'Sam', + type: 'author', + }, + { + name: 'Alex', + type: 'editor', + }, ]); - await usersCollection.insertMany([ - { name: 'Jess', type: 'author', posts: [insertedIds[0], insertedIds[2]] }, - { name: 'Alice', type: 'author', posts: [insertedIds[1], insertedIds[3]] }, - { name: 'Sam', type: 'author', posts: [insertedIds[3]] }, - { name: 'Alex', type: 'editor', posts: [insertedIds[3]] }, + await postsCollection.insertMany([ + { + title: 'Hello world', + status: 'published', + author: insertedIds[0], + }, + { + title: 'Testing', + status: 'published', + author: insertedIds[1], + }, + { + title: 'An awesome post', + status: 'draft', + author: insertedIds[0], + }, + { + title: 'Another Thing', + status: 'published', + author: insertedIds[1], + }, ]); const query = { @@ -192,7 +251,10 @@ describe('Testing against real data', () => { const result = await builder(query, getAggregate(mongoDb, 'users')); expect(result).toMatchObject([ - { name: 'Alice', type: 'author', posts: [insertedIds[1], insertedIds[3]] }, + { + name: 'Alice', + type: 'author', + }, ]); }); @@ -202,26 +264,39 @@ describe('Testing against real data', () => { const usersCollection = mongoDb.collection('users'); const postsCollection = mongoDb.collection('posts'); - const { insertedIds } = await postsCollection.insertMany([ - { title: 'Hello world', status: 'published' }, - { title: 'Testing', status: 'published' }, - { title: 'An awesome post', status: 'draft' }, - { title: 'Another Thing', status: 'published' }, + const { insertedIds } = await usersCollection.insertMany([ + { name: 'Jess', type: 'author' }, + { name: 'Alice', type: 'author' }, + { name: 'Sam', type: 'editor' }, ]); - await usersCollection.insertMany([ - { name: 'Jess', type: 'author', posts: [insertedIds[0], insertedIds[2]] }, - { name: 'Alice', type: 'author', posts: [insertedIds[1], insertedIds[3]] }, - { name: 'Sam', type: 'editor', posts: [insertedIds[3]] }, + await postsCollection.insertMany([ + { + title: 'Hello world', + status: 'published', + author: insertedIds[0], + }, + { + title: 'Testing', + status: 'published', + author: insertedIds[1], + }, + { + title: 'An awesome post', + status: 'draft', + author: insertedIds[0], + }, + { + title: 'Another Thing', + status: 'published', + author: insertedIds[2], + }, ]); const query = { type: 'author', posts_every: { status: 'published' } }; - const result = await builder(query, getAggregate(mongoDb, 'users')); - expect(result).toMatchObject([ - { name: 'Alice', type: 'author', posts: [insertedIds[1], insertedIds[3]] }, - ]); + expect(result).toMatchObject([{ name: 'Alice', type: 'author' }]); }); test('performs to-many relationship queries with nested AND', async () => { @@ -230,17 +305,37 @@ describe('Testing against real data', () => { const usersCollection = mongoDb.collection('users'); const postsCollection = mongoDb.collection('posts'); - const { insertedIds } = await postsCollection.insertMany([ - { title: 'Hello world', status: 'published', approved: true }, - { title: 'Testing', status: 'published', approved: true }, - { title: 'An awesome post', status: 'draft', approved: true }, - { title: 'Another Thing', status: 'published', approved: true }, + const { insertedIds } = await usersCollection.insertMany([ + { name: 'Jess', type: 'author' }, + { name: 'Alice', type: 'author' }, + { name: 'Sam', type: 'editor' }, ]); - await usersCollection.insertMany([ - { name: 'Jess', type: 'author', posts: [insertedIds[0], insertedIds[2]] }, - { name: 'Alice', type: 'author', posts: [insertedIds[1], insertedIds[3]] }, - { name: 'Sam', type: 'editor', posts: [insertedIds[3]] }, + await postsCollection.insertMany([ + { + title: 'Hello world', + status: 'published', + approved: true, + author: insertedIds[0], + }, + { + title: 'Testing', + status: 'published', + approved: true, + author: insertedIds[1], + }, + { + title: 'An awesome post', + status: 'draft', + approved: true, + author: insertedIds[0], + }, + { + title: 'Another Thing', + status: 'published', + approved: true, + author: insertedIds[2], + }, ]); const query = { @@ -249,10 +344,7 @@ describe('Testing against real data', () => { }; const result = await builder(query, getAggregate(mongoDb, 'users')); - - expect(result).toMatchObject([ - { name: 'Alice', type: 'author', posts: [insertedIds[1], insertedIds[3]] }, - ]); + expect(result).toMatchObject([{ name: 'Alice', type: 'author' }]); }); test('performs AND query with nested to-many relationship', async () => { @@ -261,25 +353,37 @@ describe('Testing against real data', () => { const usersCollection = mongoDb.collection('users'); const postsCollection = mongoDb.collection('posts'); - const { insertedIds } = await postsCollection.insertMany([ - { title: 'Hello world', status: 'published' }, - { title: 'Testing', status: 'published' }, - { title: 'An awesome post', status: 'draft' }, - { title: 'Another Thing', status: 'published' }, + const { insertedIds } = await usersCollection.insertMany([ + { name: 'Jess', type: 'author' }, + { name: 'Alice', type: 'author' }, + { name: 'Sam', type: 'editor' }, ]); - await usersCollection.insertMany([ - { name: 'Jess', type: 'author', posts: [insertedIds[0], insertedIds[2]] }, - { name: 'Alice', type: 'author', posts: [insertedIds[1], insertedIds[3]] }, - { name: 'Sam', type: 'editor', posts: [insertedIds[3]] }, + await postsCollection.insertMany([ + { + title: 'Hello world', + status: 'published', + author: insertedIds[0], + }, + { + title: 'Testing', + status: 'published', + author: insertedIds[1], + }, + { + title: 'An awesome post', + status: 'draft', + author: insertedIds[0], + }, + { + title: 'Another Thing', + status: 'published', + author: insertedIds[2], + }, ]); const query = { AND: [{ type: 'author' }, { posts_every: { status: 'published' } }] }; - const result = await builder(query, getAggregate(mongoDb, 'users')); - - expect(result).toMatchObject([ - { name: 'Alice', type: 'author', posts: [insertedIds[1], insertedIds[3]] }, - ]); + expect(result).toMatchObject([{ name: 'Alice', type: 'author' }]); }); }); diff --git a/packages/mongo-join-builder/tests/query-parser.test.js b/packages/mongo-join-builder/tests/query-parser.test.js index 23613ff85e5..f24bccc7c02 100644 --- a/packages/mongo-join-builder/tests/query-parser.test.js +++ b/packages/mongo-join-builder/tests/query-parser.test.js @@ -83,9 +83,11 @@ describe('query parser', () => { matchTerm: { title: { $eq: 'hello' } }, relationshipInfo: { from: 'posts', - field: 'posts', - many: true, uniqueField: 'posts_posts', + thisTable: 'User', + rel: {}, + filterType: 'only', + farCollection: 'posts', }, postJoinPipeline: [{ $sort: { title: 1 } }], relationships: [], @@ -114,9 +116,11 @@ describe('query parser', () => { matchTerm: { title: { $eq: 'hello' } }, relationshipInfo: { from: 'posts', - field: 'posts', - many: true, uniqueField: 'posts_posts', + thisTable: 'User', + rel: {}, + filterType: 'only', + farCollection: 'posts', }, postJoinPipeline: [], relationships: [], @@ -144,9 +148,11 @@ describe('query parser', () => { matchTerm: undefined, relationshipInfo: { from: 'posts', - field: 'posts', - many: true, uniqueField: 'posts_posts', + thisTable: 'User', + rel: {}, + filterType: 'only', + farCollection: 'posts', }, postJoinPipeline: [], relationships: [], @@ -174,9 +180,11 @@ describe('query parser', () => { matchTerm: { name: { $eq: 'hello' } }, relationshipInfo: { from: 'company-collection', - field: 'company', - many: false, uniqueField: 'company_company', + thisTable: 'User', + rel: {}, + filterType: 'only', + farCollection: 'company-collection', }, postJoinPipeline: [], relationships: [], @@ -216,9 +224,11 @@ describe('query parser', () => { }, relationshipInfo: { from: 'posts', - field: 'posts', - many: true, uniqueField: 'posts_every_posts', + thisTable: 'User', + rel: {}, + filterType: 'every', + farCollection: 'posts', }, postJoinPipeline: [], relationships: [ @@ -228,29 +238,30 @@ describe('query parser', () => { { name: { $eq: 'React' } }, { $expr: { - $eq: [ - { $size: '$posts_every_posts' }, - { $size: { $ifNull: ['$posts', []] } }, - ], + $eq: [{ $size: '$posts_every_posts' }, { $size: '$posts_every_posts_all' }], }, }, ], }, relationshipInfo: { - from: 'tags', - field: 'tags', - many: true, + from: 'posts_tags', uniqueField: 'tags_some_tags', + thisTable: 'Post', + rel: {}, + filterType: 'some', + farCollection: 'tags', }, postJoinPipeline: [], relationships: [ { matchTerm: { title: { $eq: 'foo' } }, relationshipInfo: { - from: 'posts', - field: 'posts', - many: true, + from: 'posts_tags', uniqueField: 'posts_every_posts', + thisTable: 'Tag', + rel: {}, + filterType: 'every', + farCollection: 'posts', }, postJoinPipeline: [], relationships: [], @@ -266,7 +277,7 @@ describe('query parser', () => { { age: { $eq: 23 } }, { $expr: { - $eq: [{ $size: '$posts_every_posts' }, { $size: { $ifNull: ['$posts', []] } }], + $eq: [{ $size: '$posts_every_posts' }, { $size: '$posts_every_posts_all' }], }, }, ], @@ -296,20 +307,24 @@ describe('query parser', () => { ], }, relationshipInfo: { - field: 'posts', from: 'posts', - many: true, uniqueField: 'posts_every_posts', + thisTable: 'User', + rel: {}, + filterType: 'every', + farCollection: 'posts', }, postJoinPipeline: [], relationships: [ { matchTerm: { name: { $eq: 'foo' } }, relationshipInfo: { - field: 'tags', - from: 'tags', - many: true, + from: 'posts_tags', uniqueField: 'tags_some_tags', + thisTable: 'Post', + rel: {}, + filterType: 'some', + farCollection: 'tags', }, postJoinPipeline: [], relationships: [], @@ -323,7 +338,7 @@ describe('query parser', () => { { age: { $eq: 23 } }, { $expr: { - $eq: [{ $size: '$posts_every_posts' }, { $size: { $ifNull: ['$posts', []] } }], + $eq: [{ $size: '$posts_every_posts' }, { $size: '$posts_every_posts_all' }], }, }, ], @@ -353,20 +368,24 @@ describe('query parser', () => { ], }, relationshipInfo: { - field: 'posts', from: 'posts', - many: true, uniqueField: 'posts_every_posts', + thisTable: 'User', + rel: {}, + filterType: 'every', + farCollection: 'posts', }, postJoinPipeline: [], relationships: [ { matchTerm: { name: { $eq: 'foo' } }, relationshipInfo: { - field: 'tags', - from: 'tags', - many: true, + from: 'posts_tags', uniqueField: 'tags_some_tags', + thisTable: 'Post', + rel: {}, + filterType: 'some', + farCollection: 'tags', }, postJoinPipeline: [], relationships: [], @@ -380,7 +399,7 @@ describe('query parser', () => { { age: { $eq: 23 } }, { $expr: { - $eq: [{ $size: '$posts_every_posts' }, { $size: { $ifNull: ['$posts', []] } }], + $eq: [{ $size: '$posts_every_posts' }, { $size: '$posts_every_posts_all' }], }, }, ], @@ -410,20 +429,24 @@ describe('query parser', () => { ], }, relationshipInfo: { - field: 'posts', from: 'posts', - many: true, uniqueField: 'posts_every_posts', + thisTable: 'User', + rel: {}, + filterType: 'every', + farCollection: 'posts', }, postJoinPipeline: [], relationships: [ { matchTerm: { name: { $eq: 'foo' } }, relationshipInfo: { - field: 'tags', - from: 'tags', - many: true, + from: 'posts_tags', uniqueField: 'tags_some_tags', + thisTable: 'Post', + rel: {}, + filterType: 'some', + farCollection: 'tags', }, postJoinPipeline: [], relationships: [], @@ -437,7 +460,7 @@ describe('query parser', () => { { age: { $eq: 23 } }, { $expr: { - $eq: [{ $size: '$posts_every_posts' }, { $size: { $ifNull: ['$posts', []] } }], + $eq: [{ $size: '$posts_every_posts' }, { $size: '$posts_every_posts_all' }], }, }, ], @@ -467,20 +490,24 @@ describe('query parser', () => { ], }, relationshipInfo: { - field: 'posts', from: 'posts', - many: true, uniqueField: 'posts_every_posts', + thisTable: 'User', + rel: {}, + filterType: 'every', + farCollection: 'posts', }, postJoinPipeline: [], relationships: [ { matchTerm: { name: { $eq: 'foo' } }, relationshipInfo: { - field: 'tags', - from: 'tags', - many: true, + from: 'posts_tags', uniqueField: 'tags_some_tags', + thisTable: 'Post', + rel: {}, + filterType: 'some', + farCollection: 'tags', }, postJoinPipeline: [], relationships: [], @@ -494,7 +521,7 @@ describe('query parser', () => { { age: { $eq: 23 } }, { $expr: { - $eq: [{ $size: '$posts_every_posts' }, { $size: { $ifNull: ['$posts', []] } }], + $eq: [{ $size: '$posts_every_posts' }, { $size: '$posts_every_posts_all' }], }, }, ], diff --git a/packages/mongo-join-builder/tests/relationship.test.js b/packages/mongo-join-builder/tests/relationship.test.js index 4bebfd8ea1b..a29dceb25a4 100644 --- a/packages/mongo-join-builder/tests/relationship.test.js +++ b/packages/mongo-join-builder/tests/relationship.test.js @@ -3,20 +3,23 @@ const { relationshipTokenizer } = require('../lib/tokenizers'); describe('Relationship tokenizer', () => { test('Uses correct conditions', () => { const relationshipConditions = { - getRefListAdapter: () => ({ model: { collection: { name: 'name' } } }), + getRefListAdapter: () => ({ key: 'Bar', model: { collection: { name: 'name' } } }), field: { many: false }, path: 'name', + rel: {}, }; const findFieldAdapterForQuerySegment = jest.fn(() => relationshipConditions); - const listAdapter = { findFieldAdapterForQuerySegment }; + const listAdapter = { findFieldAdapterForQuerySegment, key: 'Foo' }; - expect(relationshipTokenizer(listAdapter, 'name', ['name'], 'abc123')).toMatchObject({ + expect(relationshipTokenizer(listAdapter, 'name', ['name'], () => 'abc123')).toMatchObject({ matchTerm: { $expr: { $eq: [{ $size: '$abc123_name' }, 1] } }, relationshipInfo: { - field: 'name', from: 'name', - many: false, + thisTable: 'Foo', + rel: {}, + filterType: 'only', uniqueField: 'abc123_name', + farCollection: 'name', }, }); expect(findFieldAdapterForQuerySegment).toHaveBeenCalledTimes(1); @@ -26,7 +29,7 @@ describe('Relationship tokenizer', () => { const findFieldAdapterForQuerySegment = jest.fn(() => {}); const listAdapter = { findFieldAdapterForQuerySegment }; - const result = relationshipTokenizer(listAdapter, 'name', ['name']); + const result = relationshipTokenizer(listAdapter, 'name', ['name'], () => {}); expect(result).toMatchObject({}); expect(findFieldAdapterForQuerySegment).toHaveBeenCalledTimes(1); }); diff --git a/packages/mongo-join-builder/tests/utils.js b/packages/mongo-join-builder/tests/utils.js index f2849ec7ffa..f53d709e177 100644 --- a/packages/mongo-join-builder/tests/utils.js +++ b/packages/mongo-join-builder/tests/utils.js @@ -4,6 +4,7 @@ const findFieldAdapterForQuerySegment = ({ fieldAdapters }) => segment => const tagsAdapter = { key: 'Tag', model: { collection: { name: 'tags' } }, + _getModel: () => ({ collection: { name: 'posts_tags' } }), fieldAdapters: [ { dbPath: 'name', @@ -16,6 +17,7 @@ const tagsAdapter = { const postsAdapter = { key: 'Post', model: { collection: { name: 'posts' } }, + _getModel: () => ({ collection: { name: 'posts_tags' } }), fieldAdapters: [ { dbPath: 'title', @@ -28,6 +30,14 @@ const postsAdapter = { { path: 'tags', field: { many: true }, + rel: { + cardinality: 'N:N', + columnNames: { + 'Tag.posts': { near: 'Tag_id', far: 'Post_id' }, + 'Post.tags': { near: 'Post_id', far: 'Tag_id' }, + }, + collectionName: 'posts_tags', + }, getQueryConditions: () => {}, getRefListAdapter: () => tagsAdapter, }, @@ -62,6 +72,7 @@ const listAdapter = { { path: 'company', field: { many: false }, + rel: { columnNames: { User: {}, Company: {} } }, getQueryConditions: () => {}, getRefListAdapter: () => ({ model: { collection: { name: 'company-collection' } }, @@ -80,12 +91,26 @@ listAdapter.fieldAdapters.push({ getQueryConditions: () => {}, path: 'posts', field: { many: true }, + rel: { + cardinality: '1:N', + columnNames: { Tag: {}, Post: {} }, + columnName: 'author', + tableName: 'Post', + }, getRefListAdapter: () => postsAdapter, }); tagsAdapter.fieldAdapters.push({ path: 'posts', field: { many: true }, + rel: { + cardinality: 'N:N', + columnNames: { + 'Tag.posts': { near: 'Tag_id', far: 'Post_id' }, + 'Post.tags': { near: 'Post_id', far: 'Tag_id' }, + }, + collectionName: 'posts_tags', + }, getQueryConditions: () => {}, getRefListAdapter: () => postsAdapter, }); @@ -94,6 +119,12 @@ postsAdapter.fieldAdapters.push({ getQueryConditions: () => {}, path: 'author', field: { many: false }, + rel: { + cardinality: '1:N', + columnNames: { Tag: {}, Post: {} }, + columnName: 'author', + tableName: 'Post', + }, getRefListAdapter: () => listAdapter, });