diff --git a/fixtures/artists-subgraph.js b/fixtures/artists-subgraph.js new file mode 100644 index 0000000..8f80382 --- /dev/null +++ b/fixtures/artists-subgraph.js @@ -0,0 +1,60 @@ +'use strict' + +const schema = ` + type Artist { + id: ID + firstName: String + lastName: String + profession: String + } + + type Query { + artists(ids: [ID!]!): [Artist] + } +` + +const data = { + artists: null +} + +function reset () { + data.artists = { + 101: { + id: 101, + firstName: 'Christopher', + lastName: 'Nolan', + profession: 'Director' + }, + 102: { + id: 102, + firstName: 'Roberto', + lastName: 'Benigni', + profession: 'Director' + }, + 103: { + id: 103, + firstName: 'Brian', + lastName: 'Molko', + profession: 'Singer' + } + } +} + +reset() + +const resolvers = { + Query: { + artists (_, { ids }) { + return Object.values(data.artists).filter(a => ids.includes(String(a.id))) + } + } +} + +const entities = { + Artist: { + referenceListResolverName: 'artists', + keys: [{ field: 'id' }] + } +} + +module.exports = { name: 'artists', schema, reset, resolvers, entities, data } diff --git a/fixtures/movies-subgraph.js b/fixtures/movies-subgraph.js new file mode 100644 index 0000000..ded5464 --- /dev/null +++ b/fixtures/movies-subgraph.js @@ -0,0 +1,90 @@ +'use strict' + +const schema = ` + type Movie { + id: ID! + title: String + directorId: ID + } + + type Query { + movies(ids: [ID!]!): [Movie] + } + + type Artist { + id: ID + movies: [Movie] + } + + extend type Movie { + director: Artist + } + + extend type Query { + movieArtists (ids: [ID!]!): [Artist] + } +` + +const data = { + movies: null +} + +function reset () { + data.movies = { + 10: { + id: 10, + title: 'Interstellar', + directorId: 101 + }, + 11: { + id: 11, + title: 'Oppenheimer', + directorId: 101 + }, + 12: { + id: 12, + title: 'La vita é bella', + directorId: 102 + } + } +} + +reset() + +const resolvers = { + Query: { + movies (_, { ids }) { + return Object.values(data.movies).filter(a => ids.includes(String(a.id))) + }, + movieArtists: async (parent, args, context, info) => { + return args.ids.map(id => ({ id })) + } + }, + Movie: { + director: (parent, args, context, info) => { + return parent?.directorId ? { id: parent.directorId } : null + } + }, + Artist: { + // TODO dataloader here + movies: (parent, args, context, info) => { + return Object.values(data.songs).filter(a => String(a.directorId) === String(parent.id)) + } + } +} + +const entities = { + Movie: { + referenceListResolverName: 'movies', + keys: [{ field: 'id' }, { field: 'directorId', type: 'Artist' }] + }, + Artist: { + referenceListResolverName: 'movieArtists', + argsAdapter: (partialResults) => { + return { ids: partialResults.map(r => r.id) } + }, + keys: [{ field: 'id' }] + } +} + +module.exports = { name: 'movies', schema, reset, resolvers, entities, data } diff --git a/fixtures/songs-subgraph.js b/fixtures/songs-subgraph.js new file mode 100644 index 0000000..7e2fdd1 --- /dev/null +++ b/fixtures/songs-subgraph.js @@ -0,0 +1,90 @@ +'use strict' + +const schema = ` + type Song { + id: ID! + title: String + singerId: ID + } + + type Query { + songs(ids: [ID!]!): [Song] + } + + type Artist { + id: ID + songs: [Song] + } + + extend type Song { + singer: Artist + } + + extend type Query { + songArtists (ids: [ID!]!): [Artist] + } +` + +const data = { + songs: null +} + +function reset () { + data.songs = { + 1: { + id: 1, + title: 'Every you every me', + singerId: 103 + }, + 2: { + id: 2, + title: 'The bitter end', + singerId: 103 + }, + 3: { + id: 3, + title: 'Vieni via con me', + singerId: 102 + } + } +} + +reset() + +const resolvers = { + Query: { + async songs (_, { ids }) { + return Object.values(data.songs).filter(a => ids.includes(String(a.id))) + }, + songArtists: async (parent, args, context, info) => { + return args.ids.map(id => ({ id })) + } + }, + Song: { + singer: (parent, args, context, info) => { + return parent?.singerId ? { id: parent.singerId } : null + } + }, + Artist: { + // TODO dataloader here + songs: (parent, args, context, info) => { + return Object.values(data.songs).filter(a => String(a.singerId) === String(parent.id)) + } + } +} + +const entities = { + Song: { + referenceListResolverName: 'songs', + keys: [{ field: 'id' }, { field: 'singerId', type: 'Artist' }] + }, + Artist: { + referenceListResolverName: 'songArtists', + argsAdapter: (partialResults) => { + return { ids: partialResults.map(r => r.id) } + }, + keys: [{ field: 'id' }] + } +} + +module.exports = { name: 'songs', schema, reset, resolvers, entities, data } diff --git a/lib/composer.js b/lib/composer.js index e799b2c..dbe53d0 100644 --- a/lib/composer.js +++ b/lib/composer.js @@ -6,7 +6,7 @@ const { SubscriptionClient } = require('@mercuriusjs/subscription-client') const { createEmptyObject, unwrapSchemaType } = require('./graphql-utils') const { fetchSubgraphSchema, makeGraphqlRequest } = require('./network') const { QueryBuilder } = require('./query-builder') -const { isObject, keySelection, createDefaultArgsAdapter } = require('./utils') +const { isObject, keySelection, createDefaultArgsAdapter, traverseResult } = require('./utils') const { validateArray, validateFunction, @@ -533,6 +533,7 @@ function mergeResults (query, partialResult, response) { for (let i = 0; i < mergedPartial.length; i++) { const merging = mergedPartial[i] + if (!merging) { continue } // no need to be recursive if (Array.isArray(merging)) { @@ -577,12 +578,7 @@ function selectResult (query, partialResult, response) { if (!mergedPartial && !mergedPartial[path]) { break } - const node = mergedPartial[path] - if (Array.isArray(mergedPartial)) { - mergedPartial = mergedPartial.map(r => r[path]) - continue - } - mergedPartial = node + mergedPartial = traverseResult(mergedPartial, path) } if (!query.root) { diff --git a/lib/query-builder.js b/lib/query-builder.js index eb9e1cd..2440f36 100644 --- a/lib/query-builder.js +++ b/lib/query-builder.js @@ -1,6 +1,6 @@ 'use strict' const { unwrapSchemaType, valueToArgumentString } = require('./graphql-utils') -const { toQueryArgs, transitiveKeys, keySelection } = require('./utils') +const { toQueryArgs, transitiveKeys, keySelection, traverseResult } = require('./utils') class QueryBuilder { constructor (options) { @@ -46,23 +46,19 @@ class QueryBuilder { * need to to have the same result format for argsAdapter */ partialResults (result) { + if (!result) { return [] } + for (let i = 0; i < this.path.length; ++i) { const path = this.path[i] - if (Array.isArray(result)) { - result = result.map(r => r[path] ?? null) - continue - } - - const node = result[path] - result = node ?? null + result = traverseResult(result, path) } if (!Array.isArray(result)) { - result = [result] + return [result] } - return result.flat() + return result.flat().filter(r => !!r) } runArgsAdapter (results) { @@ -151,7 +147,7 @@ class QueryBuilder { const type = this.types.get(bareType.name) const { fieldMap } = type const set = new Set() - let keyFields + let keyFields = [] for (let i = 0; i < length; ++i) { const s = selections[i] @@ -183,52 +179,20 @@ class QueryBuilder { // Some nodes won't have a schema representation. For example, __typename. if (selectionSchemaNode) { if (!field.subgraphs.has(this.subgraph)) { - let followup = ctx.followups.get(node) + const index = this.followupIndex(node, field) + let followup = ctx.followups.get(index) if (!followup) { - const typeName = type.schemaNode.name - - const entity = this.subgraph.entities[typeName] - if (!entity) { - const keys = transitiveKeys(typeName, this.subgraph.entities) - if (!keys) { - // TODO onError - throw new Error(`Unable to resolve entity ${typeName} in subgraph ${this.subgraph.name}`) - } - keyFields = keys - } else { - keyFields = entity.keys - } - - // TODO(cjihrig): Double check the next line. - const nextSubgraph = Array.from(field.subgraphs)[0] - // TODO(cjihrig): Throw if this doesn't exist. - const entityOnNextSubgraph = nextSubgraph.entities[typeName] - const { referenceListResolverName: resolverName, argsAdapter } = entityOnNextSubgraph - - if (resolverName) { - followup = { - path: ctx.path.slice(), - node, - schemaNode, - type, - resolverName, - argsAdapter, - info: this.info, - types: this.types, - subgraph: nextSubgraph, - keys: entityOnNextSubgraph.keys.filter(k => k.type === typeName), - originKeys: keyFields.filter(k => k.type === typeName), - root: false, - fields: [], - solved: false - } - - ctx.followups.set(node, followup) + keyFields = this.getKeyFields(type) + followup = this.createFollowup(ctx, schemaNode, node, type, field, keyFields) + if (followup) { + ctx.followups.set(index, followup) } + } else { + // TODO use a Set + followup.fields.push(field) } - followup.fields.push(field) continue } @@ -241,7 +205,7 @@ class QueryBuilder { } // add entity keys to selection, needed to match rows merging results - if (!keyFields) { + if (keyFields.length < 1) { const typeName = type.schemaNode.name const entity = this.subgraph.entities[typeName] if (!entity) { @@ -258,17 +222,71 @@ class QueryBuilder { ctx.path.pop() - if (keyFields) { - for (let i = 0; i < keyFields.length; ++i) { - const keyFieldName = keySelection(keyFields[i].field) - set.add(keyFieldName) + for (let i = 0; i < keyFields.length; ++i) { + const keyFieldName = keySelection(keyFields[i].field) + set.add(keyFieldName) + } + + // TODO improve structure + const selectedFields = Array.from(set) + this.selectedFields = selectedFields.map(f => f.split(' ')[0]) + + return `{ ${selectedFields.join(', ')} }` + } + + getKeyFields (type) { + const typeName = type.schemaNode.name + + const entity = this.subgraph.entities[typeName] + if (!entity) { + const keys = transitiveKeys(typeName, this.subgraph.entities) + if (!keys) { + // TODO onError + throw new Error(`Unable to resolve entity ${typeName} in subgraph ${this.subgraph.name}`) } + return keys + } else { + return entity.keys } + } - // TODO more accurate, now includes nested fields as string, for example 'reviews { rating }', should be 'reviews' - this.selectedFields = Array.from(set) + followupSubgraph (field) { + // TODO(cjihrig): Double check the next line. + return Array.from(field.subgraphs)[0] + } + + createFollowup (ctx, schemaNode, node, type, field, keyFields) { + const typeName = type.schemaNode.name + + const nextSubgraph = this.followupSubgraph(field) + // TODO(cjihrig): Throw if this doesn't exist. + const entityOnNextSubgraph = nextSubgraph.entities[typeName] + const { referenceListResolverName: resolverName, argsAdapter } = entityOnNextSubgraph + + if (!resolverName) { return } + + return { + path: ctx.path.slice(), + node, + schemaNode, + type, + resolverName, + argsAdapter, + info: this.info, + types: this.types, + subgraph: nextSubgraph, + keys: entityOnNextSubgraph.keys.filter(k => k.type === typeName), + originKeys: keyFields.filter(k => k.type === typeName), + root: false, + fields: [field], + solved: false + } + } - return `{ ${this.selectedFields.join(', ')} }` + // TODO memoize + followupIndex (node, field) { + const subgraph = this.followupSubgraph(field) + return `src:${node.name.loc.source.body}#name:${node.name.value}#sg:${subgraph.name}@${subgraph.server.host}` } } diff --git a/lib/utils.js b/lib/utils.js index ad79b60..80f469b 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -60,4 +60,15 @@ function transitiveKeys (type, subgraphEntities) { } } -module.exports = { createDefaultArgsAdapter, isObject, keySelection, transitiveKeys, toQueryArgs } +function traverseResult (result, path) { + if (Array.isArray(result)) { + result = result.map(r => { + const n = traverseResult(r, path) + return n + }) + return result + } + return result[path] ?? null +} + +module.exports = { createDefaultArgsAdapter, isObject, keySelection, transitiveKeys, traverseResult, toQueryArgs } diff --git a/test/entities.test.js b/test/entities.test.js new file mode 100644 index 0000000..10f3108 --- /dev/null +++ b/test/entities.test.js @@ -0,0 +1,718 @@ +'use strict' + +const assert = require('node:assert/strict') +const { test } = require('node:test') +const { graphqlRequest, startRouter } = require('./helper') + +test('entities', async () => { + await test('throws if argsAdapter function does not return an object', async (t) => { + const overrides = { + subgraphs: { + 'books-subgraph': { + entities: { + Book: { argsAdapter: () => 'nope' } + } + } + } + } + + const router = await startRouter(t, ['books-subgraph', 'reviews-subgraph'], overrides) + const query = ` + query { + getReviewBook(id: 1) { + id + title + genre + reviews { + id + rating + content + } + } + } + ` + + await assert.rejects(async () => { + await graphqlRequest(router, query) + }, (err) => { + assert.strictEqual(Array.isArray(err), true) + assert.strictEqual(err.length, 1) + assert.strictEqual(err[0].message, 'argsAdapter did not return an object. returned nope.') + assert.deepStrictEqual(err[0].path, ['getReviewBook']) + return true + }) + }) + + await test('throws if argsAdapter function throws', async (t) => { + const overrides = { + subgraphs: { + 'books-subgraph': { + entities: { + Book: { + referenceListResolverName: 'getBooksByIds', + argsAdapter: () => { throw new Error('boom') } + } + } + } + } + } + + const router = await startRouter(t, ['books-subgraph', 'reviews-subgraph'], overrides) + const query = ` + query { + getReviewBook(id: 1) { + title + } + } + ` + + await assert.rejects(async () => { + await graphqlRequest(router, query) + }, (err) => { + assert.strictEqual(Array.isArray(err), true) + assert.strictEqual(err.length, 1) + assert.strictEqual(err[0].message, 'Error running argsAdapter for getBooksByIds') + assert.deepStrictEqual(err[0].path, ['getReviewBook']) + return true + }) + }) + + await test('should resolve foreign types with nested keys', async (t) => { + const query = `{ + getReviewBookByIds(ids: [1,2,3]) { + title + author { name { lastName } } + reviews { rating } + } + }` + + const expectedResponse = { + getReviewBookByIds: + [{ + title: 'A Book About Things That Never Happened', + author: { name: { lastName: 'Pluck' } }, + reviews: [{ rating: 2 }] + }, + { + title: 'A Book About Things That Really Happened', + author: { name: { lastName: 'Writer' } }, + reviews: [{ rating: 3 }] + }, + { + title: 'Uknown memories', + author: { name: null }, + reviews: [{ rating: 3 }, { rating: 5 }, { rating: 1 }] + }] + } + + const extend = { + 'authors-subgraph': (data) => { + return { + schema: ` + input IdsIn { + in: [ID]! + } + input WhereIdsIn { + ids: IdsIn + } + + extend type Query { + authors (where: WhereIdsIn): [Author] + } + `, + resolvers: { + Query: { + authors: (_, args) => Object.values(data.authors).filter(a => args.where.ids.in.includes(String(a.id))) + } + } + } + }, + 'books-subgraph': (data) => { + data.library[1].authorId = 1 + data.library[2].authorId = 2 + data.library[3] = { + id: 3, + title: 'Uknown memories', + genre: 'NONFICTION', + authorId: -1 + } + + return { + schema: ` + type Author { + id: ID + } + + extend type Book { + author: Author + } + `, + resolvers: { + Book: { + author: (parent) => ({ id: parent.authorId || data.library[parent.id]?.authorId }) + } + } + } + } + } + const overrides = { + subgraphs: { + 'books-subgraph': { + entities: { + Book: { + referenceListResolverName: 'getBooksByIds', + argsAdapter: (partialResults) => ({ ids: partialResults.map(r => r.id) }), + keys: [{ field: 'id', type: 'Book' }, { field: 'author.id', type: 'Author' }] + } + } + }, + 'authors-subgraph': { + entities: { + Author: { + referenceListResolverName: 'authors', + argsAdapter: (partialResults) => ({ where: { ids: { in: partialResults.map(r => r.id) } } }), + keys: [{ field: 'id', type: 'Author' }] + } + } + } + } + } + + const router = await startRouter(t, ['authors-subgraph', 'books-subgraph', 'reviews-subgraph'], overrides, extend) + + const response = await graphqlRequest(router, query) + + assert.deepStrictEqual(response, expectedResponse) + }) + + await test('should not involve type keys that are not in the selection', async (t) => { + const query = `{ + getReviewBookByIds(ids: [1,2,3]) { + title + reviews { rating } + } + }` + + const expectedResponse = { + getReviewBookByIds: + [{ + title: 'A Book About Things That Never Happened', + reviews: [{ rating: 2 }] + }, + { + title: 'A Book About Things That Really Happened', + reviews: [{ rating: 3 }] + }, + { + title: 'Uknown memories', + reviews: [{ rating: 3 }, { rating: 5 }, { rating: 1 }] + }] + } + + let calls = 0 + const extend = { + 'authors-subgraph': (data) => { + return { + schema: ` + input IdsIn { + in: [ID]! + } + input WhereIdsIn { + ids: IdsIn + } + + extend type Query { + authors (where: WhereIdsIn): [Author] + } + `, + resolvers: { + Query: { + authors: (_, args) => { + calls++ + return [] + } + } + } + } + }, + 'books-subgraph': (data) => { + data.library[1].authorId = 1 + data.library[2].authorId = 2 + data.library[3] = { + id: 3, + title: 'Uknown memories', + genre: 'NONFICTION', + authorId: -1 + } + + return { + schema: ` + type Author { + id: ID + } + + extend type Book { + author: Author + } + `, + resolvers: { + Book: { + author: (parent) => { + calls++ + return { id: null } + } + } + } + } + } + } + const overrides = { + subgraphs: { + 'books-subgraph': { + entities: { + Book: { + referenceListResolverName: 'getBooksByIds', + keys: [{ field: 'id', type: 'Book' }, { field: 'author.id', type: 'Author' }], + argsAdapter: (partialResults) => ({ ids: partialResults.map(r => r.id) }) + } + } + }, + 'authors-subgraph': { + entities: { + Author: { + referenceListResolverName: 'authors', + keys: [{ field: 'id', type: 'Author' }], + argsAdapter: () => { + calls++ + return [] + } + } + } + } + } + } + + const router = await startRouter(t, ['authors-subgraph', 'books-subgraph', 'reviews-subgraph'], overrides, extend) + + const response = await graphqlRequest(router, query) + + assert.strictEqual(calls, 0) + assert.deepStrictEqual(response, expectedResponse) + }) + + await test('should run the same query with different args', async (t) => { + const queries = [ + `{ + getReviewBookByIds(ids: [1]) { + title + reviews { rating } + } + }`, + `{ + getReviewBookByIds(ids: [2]) { + title + reviews { rating } + } + }` + ] + + const expectedResponses = [{ + getReviewBookByIds: [{ + reviews: [{ rating: 2 }], + title: 'A Book About Things That Never Happened' + }] + }, + { + getReviewBookByIds: [{ + reviews: [{ rating: 3 }], + title: 'A Book About Things That Really Happened' + }] + }] + + const router = await startRouter(t, ['books-subgraph', 'reviews-subgraph']) + + for (let i = 0; i < queries.length; i++) { + const response = await graphqlRequest(router, queries[i]) + assert.deepStrictEqual(response, expectedResponses[i]) + } + }) + + await test('should handle null in results', async (t) => { + const query = + `{ + getReviewBookByIds(ids: [99,1,101]) { + title + reviews { rating } + } + }` + + const expectedResponse = { + getReviewBookByIds: [{ + reviews: [{ rating: 2 }], + title: 'A Book About Things That Never Happened' + }] + } + + const router = await startRouter(t, ['books-subgraph', 'reviews-subgraph']) + + const response = await graphqlRequest(router, query) + assert.deepStrictEqual(response, expectedResponse) + }) + + await test('should handle null results', async (t) => { + const query = + `{ + getReviewBookByIds(ids: [-1,-2,-3]) { + title + reviews { rating } + } + }` + + const expectedResponse = { + getReviewBookByIds: [] + } + + const router = await startRouter(t, ['books-subgraph', 'reviews-subgraph']) + + const response = await graphqlRequest(router, query) + assert.deepStrictEqual(response, expectedResponse) + }) + + await test('should resolve foreign types referenced in different results', async (t) => { + const query = `{ + booksByAuthors(authorIds: [10,11,12]) { + title + author { name { firstName, lastName } } + } + }` + const expectedResponse = { + booksByAuthors: [ + { title: 'A Book About Things That Never Happened', author: { name: { firstName: 'John Jr.', lastName: 'Johnson' } } }, + { title: 'A Book About Things That Really Happened', author: { name: { firstName: 'John Jr.', lastName: 'Johnson' } } }, + { title: 'From the universe', author: { name: { firstName: 'Cindy', lastName: 'Connor' } } }, + { title: 'From another world', author: { name: { firstName: 'Cindy', lastName: 'Connor' } } } + ] + } + + const extend = { + 'authors-subgraph': (data) => { + data.authors[10] = { + id: 10, + name: { + firstName: 'John Jr.', + lastName: 'Johnson' + } + } + data.authors[11] = { + id: 11, + name: { + firstName: 'Cindy', + lastName: 'Connor' + } + } + + return { + schema: ` + input IdsIn { + in: [ID]! + } + input WhereIdsIn { + ids: IdsIn + } + + extend type Query { + authors (where: WhereIdsIn): [Author] + } + `, + resolvers: { + Query: { + authors: (_, args) => Object.values(data.authors).filter(a => args.where.ids.in.includes(String(a.id))) + } + } + } + }, + 'books-subgraph': (data) => { + data.library[1].authorId = 10 + data.library[2].authorId = 10 + data.library[3] = { + id: 3, + title: 'From the universe', + genre: 'FICTION', + authorId: 11 + } + data.library[4] = { + id: 4, + title: 'From another world', + genre: 'FICTION', + authorId: 11 + } + + return { + schema: ` + type Author { + id: ID + } + + extend type Book { + author: Author + } + + extend type Query { + booksByAuthors(authorIds: [ID!]!): [Book] + } + `, + resolvers: { + Book: { + author: (parent) => ({ id: parent.authorId || data.library[parent.id]?.authorId }) + }, + Query: { + booksByAuthors: (parent, { authorIds }) => Object.values(data.library).filter(book => authorIds.includes(String(book.authorId))) + } + } + } + } + } + const overrides = { + subgraphs: { + 'books-subgraph': { + entities: { + Book: { + referenceListResolverName: 'getBooksByIds', + argsAdapter: (partialResults) => ({ ids: partialResults.map(r => r.id) }), + keys: [{ field: 'id', type: 'Book' }, { field: 'author.id', type: 'Author' }] + } + } + }, + 'authors-subgraph': { + entities: { + Author: { + referenceListResolverName: 'authors', + argsAdapter: (partialResults) => ({ where: { ids: { in: partialResults.map(r => r.id) } } }), + keys: [{ field: 'id', type: 'Author' }] + } + } + } + } + } + + const router = await startRouter(t, ['authors-subgraph', 'books-subgraph', 'reviews-subgraph'], overrides, extend) + + const response = await graphqlRequest(router, query) + + assert.deepStrictEqual(response, expectedResponse) + }) + + await test('should resolve nested foreign types with lists in result', async (t) => { + const query = `{ + booksByAuthors(authorIds: [10,11,12]) { + title + author { + name { firstName, lastName } + books { + reviews { rating } + } + } + } + }` + + const expectedResponse = { booksByAuthors: [{ title: 'A Book About Things That Never Happened', author: { name: { firstName: 'Mark', lastName: 'Dark' }, books: [{ reviews: [{ rating: 2 }] }, { reviews: [{ rating: 3 }] }] } }, { title: 'A Book About Things That Really Happened', author: { name: { firstName: 'Mark', lastName: 'Dark' }, books: [{ reviews: [{ rating: 2 }] }, { reviews: [{ rating: 3 }] }] } }, { title: 'Watering the plants', author: { name: { firstName: 'Daisy', lastName: 'Dyson' }, books: [{ reviews: [{ rating: 3 }, { rating: 5 }, { rating: 1 }] }, { reviews: [] }] } }, { title: 'Pruning the branches', author: { name: { firstName: 'Daisy', lastName: 'Dyson' }, books: [{ reviews: [{ rating: 3 }, { rating: 5 }, { rating: 1 }] }, { reviews: [] }] } }] } + + const extend = { + 'authors-subgraph': (data) => { + data.authors[10] = { + id: 10, + name: { + firstName: 'Mark', + lastName: 'Dark' + } + } + data.authors[11] = { + id: 11, + name: { + firstName: 'Daisy', + lastName: 'Dyson' + } + } + + return { + schema: ` + input IdsIn { + in: [ID]! + } + input WhereIdsIn { + ids: IdsIn + } + + type Book { + id: ID! + } + + extend type Query { + authors (where: WhereIdsIn): [Author] + } + + extend type Author { + books: [Book] + } + `, + resolvers: { + Query: { + authors: (_, args) => Object.values(data.authors).filter(a => args.where.ids.in.includes(String(a.id))) + }, + Author: { + books: (author, args, context, info) => { + // pretend to call books-subgraph service + const books = { + 10: [{ id: 1 }, { id: 2 }], + 11: [{ id: 3 }, { id: 4 }] + } + return books[author?.id] + } + } + } + } + }, + 'books-subgraph': (data) => { + data.library[1].authorId = 10 + data.library[2].authorId = 10 + data.library[3] = { + id: 3, + title: 'Watering the plants', + genre: 'NONFICTION', + authorId: 11 + } + data.library[4] = { + id: 4, + title: 'Pruning the branches', + genre: 'NONFICTION', + authorId: 11 + } + + return { + schema: ` + type Author { + id: ID + } + + extend type Book { + author: Author + } + + extend type Query { + booksByAuthors(authorIds: [ID!]!): [Book] + } + `, + resolvers: { + Book: { + author: (parent) => ({ id: parent.authorId || data.library[parent.id]?.authorId }) + }, + Query: { + booksByAuthors: (parent, { authorIds }) => Object.values(data.library).filter(book => authorIds.includes(String(book.authorId))) + } + } + } + } + } + const overrides = { + subgraphs: { + 'books-subgraph': { + entities: { + Book: { + referenceListResolverName: 'getBooksByIds', + argsAdapter: (partialResults) => ({ ids: partialResults.map(r => r.id) }), + keys: [{ field: 'id', type: 'Book' }, { field: 'author.id', type: 'Author' }] + } + } + }, + 'authors-subgraph': { + entities: { + Author: { + referenceListResolverName: 'authors', + argsAdapter: (partialResults) => ({ where: { ids: { in: partialResults.map(r => r.id) } } }), + keys: [{ field: 'id', type: 'Author' }] + }, + Book: { + keys: [{ field: 'id' }] + } + } + } + } + } + + const router = await startRouter(t, ['authors-subgraph', 'books-subgraph', 'reviews-subgraph'], overrides, extend) + + const response = await graphqlRequest(router, query) + + assert.deepStrictEqual(response, expectedResponse) + }) + + test('should use multiple subgraphs', async t => { + const requests = [ + // query multiple services + { + query: '{ songs (ids: [1,2,3]) { title, singer { firstName, lastName, profession } } }', + expected: { + songs: [ + { title: 'Every you every me', singer: { firstName: 'Brian', lastName: 'Molko', profession: 'Singer' } }, + { title: 'The bitter end', singer: { firstName: 'Brian', lastName: 'Molko', profession: 'Singer' } }, + { title: 'Vieni via con me', singer: { firstName: 'Roberto', lastName: 'Benigni', profession: 'Director' } }] + } + }, + + // get all songs by singer + { + query: '{ artists (ids: ["103","102"]) { lastName, songs { title } } }', + expected: { + artists: [ + { lastName: 'Benigni', songs: [{ title: 'Vieni via con me' }] }, + { lastName: 'Molko', songs: [{ title: 'Every you every me' }, { title: 'The bitter end' }] }] + } + }, + + // query multiple subgraph on the same node + { + query: '{ artists (ids: ["103","101","102"]) { lastName, songs { title }, movies { title } } }', + expected: { + artists: [ + { lastName: 'Nolan', songs: [], movies: [{ title: 'Interstellar' }, { title: 'Oppenheimer' }] }, + { lastName: 'Benigni', songs: [{ title: 'Vieni via con me' }], movies: [{ title: 'La vita é bella' }] }, + { lastName: 'Molko', songs: [{ title: 'Every you every me' }, { title: 'The bitter end' }], movies: [] } + ] + } + }, + + // double nested + { + query: '{ artists (ids: ["103"]) { songs { title, singer { firstName, lastName } } } }', + expected: { artists: [{ songs: [{ title: 'Every you every me', singer: { firstName: 'Brian', lastName: 'Molko' } }, { title: 'The bitter end', singer: { firstName: 'Brian', lastName: 'Molko' } }] }] } + }, + + // nested and nested times + { + query: '{ artists (ids: ["103"]) { songs { singer { songs { singer { songs { title } }} } } } }', + expected: { artists: [{ songs: [{ singer: { songs: [{ singer: { songs: [{ title: 'Every you every me' }, { title: 'The bitter end' }] } }, { singer: { songs: [{ title: 'Every you every me' }, { title: 'The bitter end' }] } }] } }, { singer: { songs: [{ singer: { songs: [{ title: 'Every you every me' }, { title: 'The bitter end' }] } }, { singer: { songs: [{ title: 'Every you every me' }, { title: 'The bitter end' }] } }] } }] }] } + } + ] + + const info = { + defaultArgsAdapter: (partialResults) => { + return { ids: partialResults.map(r => r?.id) } + } + } + + const composer = await startRouter(t, ['artists-subgraph', 'movies-subgraph', 'songs-subgraph'], info) + + for (const request of requests) { + const response = await graphqlRequest(composer, request.query, request.variables) + + assert.deepStrictEqual(response, request.expected, 'should get expected result from composer service for query\n' + request.query) + } + }) + + // TODO results: list, single, nulls, partials + // TODO when an entity is spread across multiple subgraphs + // TODO should throw error (timeout?) resolving type entity + +// TODO crud ops +// TODO subscriptions +}) diff --git a/test/index.test.js b/test/index.test.js index 6ea013a..5293109 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1,5 +1,5 @@ 'use strict' -const { deepStrictEqual, rejects, strictEqual } = require('node:assert') +const { deepStrictEqual } = require('node:assert') const { test } = require('node:test') const { graphqlRequest, startRouter } = require('./helper') @@ -438,651 +438,3 @@ test('Mutations', async () => { }) }) }) - -test('entities', async () => { - await test('throws if argsAdapter function does not return an object', async (t) => { - const overrides = { - subgraphs: { - 'books-subgraph': { - entities: { - Book: { argsAdapter: () => 'nope' } - } - } - } - } - - const router = await startRouter(t, ['books-subgraph', 'reviews-subgraph'], overrides) - const query = ` - query { - getReviewBook(id: 1) { - id - title - genre - reviews { - id - rating - content - } - } - } - ` - - await rejects(async () => { - await graphqlRequest(router, query) - }, (err) => { - strictEqual(Array.isArray(err), true) - strictEqual(err.length, 1) - strictEqual(err[0].message, 'argsAdapter did not return an object. returned nope.') - deepStrictEqual(err[0].path, ['getReviewBook']) - return true - }) - }) - - await test('throws if argsAdapter function throws', async (t) => { - const overrides = { - subgraphs: { - 'books-subgraph': { - entities: { - Book: { - referenceListResolverName: 'getBooksByIds', - argsAdapter: () => { throw new Error('boom') } - } - } - } - } - } - - const router = await startRouter(t, ['books-subgraph', 'reviews-subgraph'], overrides) - const query = ` - query { - getReviewBook(id: 1) { - title - } - } - ` - - await rejects(async () => { - await graphqlRequest(router, query) - }, (err) => { - strictEqual(Array.isArray(err), true) - strictEqual(err.length, 1) - strictEqual(err[0].message, 'Error running argsAdapter for getBooksByIds') - deepStrictEqual(err[0].path, ['getReviewBook']) - return true - }) - }) - - await test('should resolve foreign types with nested keys', async (t) => { - const query = `{ - getReviewBookByIds(ids: [1,2,3]) { - title - author { name { lastName } } - reviews { rating } - } - }` - - const expectedResponse = { - getReviewBookByIds: - [{ - title: 'A Book About Things That Never Happened', - author: { name: { lastName: 'Pluck' } }, - reviews: [{ rating: 2 }] - }, - { - title: 'A Book About Things That Really Happened', - author: { name: { lastName: 'Writer' } }, - reviews: [{ rating: 3 }] - }, - { - title: 'Uknown memories', - author: { name: null }, - reviews: [{ rating: 3 }, { rating: 5 }, { rating: 1 }] - }] - } - - const extend = { - 'authors-subgraph': (data) => { - return { - schema: ` - input IdsIn { - in: [ID]! - } - input WhereIdsIn { - ids: IdsIn - } - - extend type Query { - authors (where: WhereIdsIn): [Author] - } - `, - resolvers: { - Query: { - authors: (_, args) => Object.values(data.authors).filter(a => args.where.ids.in.includes(String(a.id))) - } - } - } - }, - 'books-subgraph': (data) => { - data.library[1].authorId = 1 - data.library[2].authorId = 2 - data.library[3] = { - id: 3, - title: 'Uknown memories', - genre: 'NONFICTION', - authorId: -1 - } - - return { - schema: ` - type Author { - id: ID - } - - extend type Book { - author: Author - } - `, - resolvers: { - Book: { - author: (parent) => ({ id: parent.authorId || data.library[parent.id]?.authorId }) - } - } - } - } - } - const overrides = { - subgraphs: { - 'books-subgraph': { - entities: { - Book: { - referenceListResolverName: 'getBooksByIds', - argsAdapter: (partialResults) => ({ ids: partialResults.map(r => r.id) }), - keys: [{ field: 'id', type: 'Book' }, { field: 'author.id', type: 'Author' }] - } - } - }, - 'authors-subgraph': { - entities: { - Author: { - referenceListResolverName: 'authors', - argsAdapter: (partialResults) => ({ where: { ids: { in: partialResults.map(r => r.id) } } }), - keys: [{ field: 'id', type: 'Author' }] - } - } - } - } - } - - const router = await startRouter(t, ['authors-subgraph', 'books-subgraph', 'reviews-subgraph'], overrides, extend) - - const response = await graphqlRequest(router, query) - - deepStrictEqual(response, expectedResponse) - }) - - await test('should not involve type keys that are not in the selection', async (t) => { - const query = `{ - getReviewBookByIds(ids: [1,2,3]) { - title - reviews { rating } - } - }` - - const expectedResponse = { - getReviewBookByIds: - [{ - title: 'A Book About Things That Never Happened', - reviews: [{ rating: 2 }] - }, - { - title: 'A Book About Things That Really Happened', - reviews: [{ rating: 3 }] - }, - { - title: 'Uknown memories', - reviews: [{ rating: 3 }, { rating: 5 }, { rating: 1 }] - }] - } - - let calls = 0 - const extend = { - 'authors-subgraph': (data) => { - return { - schema: ` - input IdsIn { - in: [ID]! - } - input WhereIdsIn { - ids: IdsIn - } - - extend type Query { - authors (where: WhereIdsIn): [Author] - } - `, - resolvers: { - Query: { - authors: (_, args) => { - calls++ - return [] - } - } - } - } - }, - 'books-subgraph': (data) => { - data.library[1].authorId = 1 - data.library[2].authorId = 2 - data.library[3] = { - id: 3, - title: 'Uknown memories', - genre: 'NONFICTION', - authorId: -1 - } - - return { - schema: ` - type Author { - id: ID - } - - extend type Book { - author: Author - } - `, - resolvers: { - Book: { - author: (parent) => { - calls++ - return { id: null } - } - } - } - } - } - } - const overrides = { - subgraphs: { - 'books-subgraph': { - entities: { - Book: { - referenceListResolverName: 'getBooksByIds', - keys: [{ field: 'id', type: 'Book' }, { field: 'author.id', type: 'Author' }], - argsAdapter: (partialResults) => ({ ids: partialResults.map(r => r.id) }) - } - } - }, - 'authors-subgraph': { - entities: { - Author: { - referenceListResolverName: 'authors', - keys: [{ field: 'id', type: 'Author' }], - argsAdapter: () => { - calls++ - return [] - } - } - } - } - } - } - - const router = await startRouter(t, ['authors-subgraph', 'books-subgraph', 'reviews-subgraph'], overrides, extend) - - const response = await graphqlRequest(router, query) - - strictEqual(calls, 0) - deepStrictEqual(response, expectedResponse) - }) - - await test('should run the same query with different args', async (t) => { - const queries = [ - `{ - getReviewBookByIds(ids: [1]) { - title - reviews { rating } - } - }`, - `{ - getReviewBookByIds(ids: [2]) { - title - reviews { rating } - } - }` - ] - - const expectedResponses = [{ - getReviewBookByIds: [{ - reviews: [{ rating: 2 }], - title: 'A Book About Things That Never Happened' - }] - }, - { - getReviewBookByIds: [{ - reviews: [{ rating: 3 }], - title: 'A Book About Things That Really Happened' - }] - }] - - const router = await startRouter(t, ['books-subgraph', 'reviews-subgraph']) - - for (let i = 0; i < queries.length; i++) { - const response = await graphqlRequest(router, queries[i]) - deepStrictEqual(response, expectedResponses[i]) - } - }) - - await test('should handle null in results', async (t) => { - const query = - `{ - getReviewBookByIds(ids: [99,1,101]) { - title - reviews { rating } - } - }` - - const expectedResponse = { - getReviewBookByIds: [{ - reviews: [{ rating: 2 }], - title: 'A Book About Things That Never Happened' - }] - } - - const router = await startRouter(t, ['books-subgraph', 'reviews-subgraph']) - - const response = await graphqlRequest(router, query) - deepStrictEqual(response, expectedResponse) - }) - - await test('should handle null results', async (t) => { - const query = - `{ - getReviewBookByIds(ids: [-1,-2,-3]) { - title - reviews { rating } - } - }` - - const expectedResponse = { - getReviewBookByIds: [] - } - - const router = await startRouter(t, ['books-subgraph', 'reviews-subgraph']) - - const response = await graphqlRequest(router, query) - deepStrictEqual(response, expectedResponse) - }) - - await test('should resolve foreign types referenced in different results', async (t) => { - const query = `{ - booksByAuthors(authorIds: [10,11,12]) { - title - author { name { firstName, lastName } } - } - }` - const expectedResponse = { - booksByAuthors: [ - { title: 'A Book About Things That Never Happened', author: { name: { firstName: 'John Jr.', lastName: 'Johnson' } } }, - { title: 'A Book About Things That Really Happened', author: { name: { firstName: 'John Jr.', lastName: 'Johnson' } } }, - { title: 'From the universe', author: { name: { firstName: 'Cindy', lastName: 'Connor' } } }, - { title: 'From another world', author: { name: { firstName: 'Cindy', lastName: 'Connor' } } } - ] - } - - const extend = { - 'authors-subgraph': (data) => { - data.authors[10] = { - id: 10, - name: { - firstName: 'John Jr.', - lastName: 'Johnson' - } - } - data.authors[11] = { - id: 11, - name: { - firstName: 'Cindy', - lastName: 'Connor' - } - } - - return { - schema: ` - input IdsIn { - in: [ID]! - } - input WhereIdsIn { - ids: IdsIn - } - - extend type Query { - authors (where: WhereIdsIn): [Author] - } - `, - resolvers: { - Query: { - authors: (_, args) => Object.values(data.authors).filter(a => args.where.ids.in.includes(String(a.id))) - } - } - } - }, - 'books-subgraph': (data) => { - data.library[1].authorId = 10 - data.library[2].authorId = 10 - data.library[3] = { - id: 3, - title: 'From the universe', - genre: 'FICTION', - authorId: 11 - } - data.library[4] = { - id: 4, - title: 'From another world', - genre: 'FICTION', - authorId: 11 - } - - return { - schema: ` - type Author { - id: ID - } - - extend type Book { - author: Author - } - - extend type Query { - booksByAuthors(authorIds: [ID!]!): [Book] - } - `, - resolvers: { - Book: { - author: (parent) => ({ id: parent.authorId || data.library[parent.id]?.authorId }) - }, - Query: { - booksByAuthors: (parent, { authorIds }) => Object.values(data.library).filter(book => authorIds.includes(String(book.authorId))) - } - } - } - } - } - const overrides = { - subgraphs: { - 'books-subgraph': { - entities: { - Book: { - referenceListResolverName: 'getBooksByIds', - argsAdapter: (partialResults) => ({ ids: partialResults.map(r => r.id) }), - keys: [{ field: 'id', type: 'Book' }, { field: 'author.id', type: 'Author' }] - } - } - }, - 'authors-subgraph': { - entities: { - Author: { - referenceListResolverName: 'authors', - argsAdapter: (partialResults) => ({ where: { ids: { in: partialResults.map(r => r.id) } } }), - keys: [{ field: 'id', type: 'Author' }] - } - } - } - } - } - - const router = await startRouter(t, ['authors-subgraph', 'books-subgraph', 'reviews-subgraph'], overrides, extend) - - const response = await graphqlRequest(router, query) - - deepStrictEqual(response, expectedResponse) - }) - - await test('should resolve nested foreign types with lists in result', async (t) => { - const query = `{ - booksByAuthors(authorIds: [10,11,12]) { - title - author { - name { firstName, lastName } - books { - reviews { rating } - } - } - } - }` - - const expectedResponse = { booksByAuthors: [{ title: 'A Book About Things That Never Happened', author: { name: { firstName: 'Mark', lastName: 'Dark' }, books: [{ reviews: [{ rating: 2 }] }, { reviews: [{ rating: 3 }] }] } }, { title: 'A Book About Things That Really Happened', author: { name: { firstName: 'Mark', lastName: 'Dark' }, books: [{ reviews: [{ rating: 2 }] }, { reviews: [{ rating: 3 }] }] } }, { title: 'Watering the plants', author: { name: { firstName: 'Daisy', lastName: 'Dyson' }, books: [{ reviews: [{ rating: 3 }, { rating: 5 }, { rating: 1 }] }, { reviews: [] }] } }, { title: 'Pruning the branches', author: { name: { firstName: 'Daisy', lastName: 'Dyson' }, books: [{ reviews: [{ rating: 3 }, { rating: 5 }, { rating: 1 }] }, { reviews: [] }] } }] } - - const extend = { - 'authors-subgraph': (data) => { - data.authors[10] = { - id: 10, - name: { - firstName: 'Mark', - lastName: 'Dark' - } - } - data.authors[11] = { - id: 11, - name: { - firstName: 'Daisy', - lastName: 'Dyson' - } - } - - return { - schema: ` - input IdsIn { - in: [ID]! - } - input WhereIdsIn { - ids: IdsIn - } - - type Book { - id: ID! - } - - extend type Query { - authors (where: WhereIdsIn): [Author] - } - - extend type Author { - books: [Book] - } - `, - resolvers: { - Query: { - authors: (_, args) => Object.values(data.authors).filter(a => args.where.ids.in.includes(String(a.id))) - }, - Author: { - books: (author, args, context, info) => { - // pretend to call books-subgraph service - const books = { - 10: [{ id: 1 }, { id: 2 }], - 11: [{ id: 3 }, { id: 4 }] - } - return books[author?.id] - } - } - } - } - }, - 'books-subgraph': (data) => { - data.library[1].authorId = 10 - data.library[2].authorId = 10 - data.library[3] = { - id: 3, - title: 'Watering the plants', - genre: 'NONFICTION', - authorId: 11 - } - data.library[4] = { - id: 4, - title: 'Pruning the branches', - genre: 'NONFICTION', - authorId: 11 - } - - return { - schema: ` - type Author { - id: ID - } - - extend type Book { - author: Author - } - - extend type Query { - booksByAuthors(authorIds: [ID!]!): [Book] - } - `, - resolvers: { - Book: { - author: (parent) => ({ id: parent.authorId || data.library[parent.id]?.authorId }) - }, - Query: { - booksByAuthors: (parent, { authorIds }) => Object.values(data.library).filter(book => authorIds.includes(String(book.authorId))) - } - } - } - } - } - const overrides = { - subgraphs: { - 'books-subgraph': { - entities: { - Book: { - referenceListResolverName: 'getBooksByIds', - argsAdapter: (partialResults) => ({ ids: partialResults.map(r => r.id) }), - keys: [{ field: 'id', type: 'Book' }, { field: 'author.id', type: 'Author' }] - } - } - }, - 'authors-subgraph': { - entities: { - Author: { - referenceListResolverName: 'authors', - argsAdapter: (partialResults) => ({ where: { ids: { in: partialResults.map(r => r.id) } } }), - keys: [{ field: 'id', type: 'Author' }] - }, - Book: { - keys: [{ field: 'id' }] - } - } - } - } - } - - const router = await startRouter(t, ['authors-subgraph', 'books-subgraph', 'reviews-subgraph'], overrides, extend) - - const response = await graphqlRequest(router, query) - - deepStrictEqual(response, expectedResponse) - }) - - // TODO results: list, single, nulls, partials - // TODO when an entity is spread across multiple subgraphs - // TODO should throw error (timeout?) resolving type entity - // TODO nested repeated "followup" -})