diff --git a/README.md b/README.md index 5d4c7a0..787d651 100644 --- a/README.md +++ b/README.md @@ -118,9 +118,7 @@ async function main() { // Resolver for retrieving multiple Books. resolver: { name: 'getBooksByIds', - argsAdapter: (partialResults) => { - return { ids: partialResults.map(r => r.id) } - } + argsAdapter: 'ids.$>#id' } } }, diff --git a/lib/composer.js b/lib/composer.js index f9c9e63..d3e3988 100644 --- a/lib/composer.js +++ b/lib/composer.js @@ -6,6 +6,7 @@ const { SubscriptionClient } = require('@mercuriusjs/subscription-client') const { getIntrospectionQuery } = require('graphql') const fastify = require('fastify') const mercurius = require('mercurius') +const metaline = require('metaline') const { createEmptyObject, unwrapSchemaType } = require('./graphql-utils') const { fetchSubgraphSchema, makeGraphqlRequest } = require('./network') const { QueryBuilder } = require('./query-builder') @@ -39,10 +40,11 @@ class Composer { subgraphs = [], onSubgraphError = onError, subscriptions, - defaultArgsAdapter, addEntitiesResolvers } = options + let defaultArgsAdapter = options.defaultArgsAdapter + this.addEntitiesResolvers = !!addEntitiesResolvers validateString(queryTypeName, 'queryTypeName') @@ -52,6 +54,9 @@ class Composer { validateFunction(onSubgraphError, 'onSubgraphError') if (defaultArgsAdapter) { + if (typeof defaultArgsAdapter === 'string') { + defaultArgsAdapter = metaline(defaultArgsAdapter) + } validateFunction(defaultArgsAdapter, 'defaultArgsAdapter') } @@ -122,7 +127,15 @@ class Composer { validateString(fkey[p], `subgraphs[${subgraphName}].entities.${name}.fkeys[${k}].${p}`) } if (fkey.resolver) { + if (typeof fkey.resolver.argsAdapter === 'string') { + fkey.resolver.argsAdapter = metaline(fkey.resolver.argsAdapter) + } + if (typeof fkey.resolver.partialResults === 'string') { + fkey.resolver.partialResults = metaline(fkey.resolver.partialResults) + } + validateResolver(fkey.resolver, `subgraphs[${subgraphName}].entities.${name}.fkeys[${k}].resolver`) + if (!fkey.resolver.argsAdapter) { fkey.resolver.argsAdapter = defaultArgsAdapter ?? createDefaultArgsAdapter(name, fkey.pkey) } @@ -138,6 +151,14 @@ class Composer { if (!m[p]) { continue } validateString(m[p], `subgraphs[${subgraphName}].entities.${name}.many[${k}].${p}`) } + + if (typeof m.resolver.argsAdapter === 'string') { + m.resolver.argsAdapter = metaline(m.resolver.argsAdapter) + } + if (typeof m.resolver.partialResults === 'string') { + m.resolver.partialResults = metaline(m.resolver.partialResults) + } + validateResolver(m.resolver, `subgraphs[${subgraphName}].entities.${name}.many[${k}].resolver`) if (!m.resolver.argsAdapter) { m.resolver.argsAdapter = defaultArgsAdapter ?? createDefaultArgsAdapter(name, m.pkey) @@ -146,7 +167,9 @@ class Composer { if (resolver) { validateResolver(resolver, `subgraphs[${subgraphName}].entities.${name}.resolver`) - if (!resolver.argsAdapter) { + if (typeof resolver.argsAdapter === 'string') { + resolver.argsAdapter = metaline(resolver.argsAdapter) + } else if (!resolver.argsAdapter) { resolver.argsAdapter = defaultArgsAdapter ?? createDefaultArgsAdapter(name, pkey) } } diff --git a/package.json b/package.json index e26d3d2..cf52e1d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "fastify": "^4.24.3", "graphql": "^16.8.1", "mercurius": "^13.2.2", + "metaline": "^1.1.0", "undici": "^6.0.0" }, "devDependencies": { diff --git a/test/resolve-entities-metaline.test.js b/test/resolve-entities-metaline.test.js new file mode 100644 index 0000000..204a9d8 --- /dev/null +++ b/test/resolve-entities-metaline.test.js @@ -0,0 +1,332 @@ +'use strict' + +const assert = require('node:assert/strict') +const { test } = require('node:test') +const { buildComposer, graphqlRequest, assertObject } = require('./helper') + +const composerOptions = { + defaultArgsAdapter: 'where.id.in.$>#id', + entities: { + 'artists-subgraph': { + Artist: { + resolver: { name: 'artists' }, + pkey: 'id', + many: [ + { + type: 'Movie', + as: 'movies', + pkey: 'id', + fkey: 'directorId', + subgraph: 'movies-subgraph', + resolver: { + name: 'movies', + argsAdapter: 'where.directorId.in.$', + partialResults: '$>#id' + } + }, + { + type: 'Song', + as: 'songs', + pkey: 'id', + fkey: 'singerId', + subgraph: 'songs-subgraph', + resolver: { + name: 'songs', + argsAdapter: 'where.singerId.in.$', + partialResults: '$>#id' + } + } + ] + } + }, + 'movies-subgraph': { + Movie: { + resolver: { name: 'movies' }, + pkey: 'id', + fkeys: [ + { + type: 'Artist', + as: 'director', + field: 'directorId', + pkey: 'id', + resolver: { + name: 'movies', + argsAdapter: 'where.directorId.in.$>#id', + partialResults: '$>id.#directorId' + } + } + ], + many: [ + { + type: 'Cinema', + as: 'cinemas', + pkey: 'id', + fkey: 'movieIds', + subgraph: 'cinemas-subgraph', + resolver: { + name: 'cinemas', + argsAdapter: 'where.movieIds.in.$', + partialResults: '$>#id' + } + } + ] + } + }, + 'songs-subgraph': { + Song: { + resolver: { name: 'songs' }, + pkey: 'id', + fkeys: [ + { + type: 'Artist', + as: 'singer', + field: 'singerId', + pkey: 'id', + resolver: { + name: 'songs', + argsAdapter: 'where.singerId.in.$>#id', + partialResults: '$>id.#singerId' + } + } + ] + } + }, + 'cinemas-subgraph': { + Cinema: { + resolver: { name: 'cinemas' }, + pkey: 'id', + many: [ + { + type: 'Movie', + as: 'movies', + pkey: 'movieIds', + fkey: 'id', + subgraph: 'movies-subgraph', + resolver: { + name: 'movies', + argsAdapter: 'where.id.in.$', + partialResults: '$>#movieIds' + } + } + ] + } + } + } +} + +test('entities', async () => { + await test('should generate entities for composer on top for multiple subgraphs', async t => { + const { composer } = await buildComposer(t, ['artists-subgraph', 'movies-subgraph', 'songs-subgraph'], composerOptions) + const { schema, resolvers, entities } = composer.resolveEntities() + + const expectedSchema = + `type Artist { id: ID, movies: [Movie], songs: [Song] } + +type Movie { id: ID!, director: Artist, cinemas: [Cinema] } + +type Song { id: ID!, singer: Artist } + +type Query { + _composer: String +}` + + const expectedResolvers = { + Artist: {}, + Movie: {}, + Song: {}, + Query: { _composer: () => {} } + } + + const expectedEntities = { + Artist: { + fkeys: [], + pkey: 'id' + }, + Movie: { + fkeys: [ + { + as: 'director', + field: 'directorId', + pkey: 'id', + resolver: { + argsAdapter: () => {}, + name: 'movies', + partialResults: () => {} + }, + type: 'Artist' + } + ], + pkey: 'id' + }, + Song: { + fkeys: [ + { + as: 'singer', + field: 'singerId', + pkey: 'id', + resolver: { + argsAdapter: () => {}, + name: 'songs', + partialResults: () => {} + }, + type: 'Artist' + } + ], + pkey: 'id' + } + } + + assert.strictEqual(schema, expectedSchema) + + assertObject(resolvers, expectedResolvers) + assertObject(entities, expectedEntities) + }) + + await test('should generate entities resolvers for composer on top for multiple subgraphs', async t => { + const options = { ...composerOptions } + options.addEntitiesResolvers = true + + const { service } = await buildComposer(t, ['artists-subgraph', 'movies-subgraph', 'cinemas-subgraph', 'songs-subgraph'], options) + + await service.listen() + + const requests = [ + { + query: '{ movies (where: { id: { in: ["10","11","12"] } }) { title, director { lastName } } }', + expected: { movies: [{ title: 'Interstellar', director: { lastName: 'Nolan' } }, { title: 'Oppenheimer', director: { lastName: 'Nolan' } }, { title: 'La vita é bella', director: { lastName: 'Benigni' } }] } + }, + + { + query: '{ movies (where: { id: { in: ["10","11","12"] } }) { title, cinemas { name } } }', + expected: { movies: [{ title: 'Interstellar', cinemas: [{ name: 'Odeon' }, { name: 'Main Theatre' }] }, { title: 'Oppenheimer', cinemas: [] }, { title: 'La vita é bella', cinemas: [{ name: 'Odeon' }, { name: 'Main Theatre' }] }] } + }, + + { + query: '{ songs (where: { id: { in: [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 (where: { id: { in: ["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 (where: { id: { in: ["101","103","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 (where: { id: { in: ["103", "101"] } }) { firstName, songs { title, singer { firstName } } } }', + expected: { artists: [{ firstName: 'Christopher', songs: [] }, { firstName: 'Brian', songs: [{ title: 'Every you every me', singer: { firstName: 'Brian' } }, { title: 'The bitter end', singer: { firstName: 'Brian' } }] }] } + }, + + // nested and nested + { + query: '{ artists (where: { id: { in: ["103"] } }) { songs { singer { firstName, songs { title } } } } }', + expected: { artists: [{ songs: [{ singer: { firstName: 'Brian', songs: [{ title: 'Every you every me' }, { title: 'The bitter end' }] } }, { singer: { firstName: 'Brian', songs: [{ title: 'Every you every me' }, { title: 'The bitter end' }] } }] }] } + }, + + // many to many + { + query: '{ cinemas (where: { id: { in: ["90", "91", "92"] } }) { movies { title } } }', + expected: { cinemas: [{ movies: [{ title: 'Interstellar' }, { title: 'La vita é bella' }] }, { movies: [] }, { movies: [{ title: 'La vita é bella' }, { title: 'Interstellar' }] }] } + }, + + // many to many + { + query: '{ movies (where: { id: { in: ["10", "11", "12"] } }) { title, cinemas { name } } }', + expected: { movies: [{ title: 'Interstellar', cinemas: [{ name: 'Odeon' }, { name: 'Main Theatre' }] }, { title: 'Oppenheimer', cinemas: [] }, { title: 'La vita é bella', cinemas: [{ name: 'Odeon' }, { name: 'Main Theatre' }] }] } + }, + + { + query: '{ artists (where: { id: { in: ["102", "101"] } }) { movies { title, cinemas { name, movies { title } } } } }', + expected: { + artists: [{ + movies: [{ + title: 'Interstellar', + cinemas: [{ + name: 'Odeon', + movies: [{ title: 'Interstellar' }, { title: 'La vita é bella' }] + }, + { + name: 'Main Theatre', + movies: [{ title: 'La vita é bella' }, { title: 'Interstellar' }] + }] + }, + { + title: 'Oppenheimer', + cinemas: [] + }] + }, + { + movies: [{ + title: 'La vita é bella', + cinemas: [{ + name: 'Odeon', + movies: [{ title: 'Interstellar' }, { title: 'La vita é bella' }] + }, + { + name: 'Main Theatre', + movies: [{ title: 'La vita é bella' }, { title: 'Interstellar' }] + }] + }] + }] + } + }, + + { + query: '{ movies (where: { id: { in: ["10","11"] } }) { cinemas { name, movies { title, director { lastName} } } } }', + expected: { + movies: [{ + cinemas: [{ + name: 'Odeon', + movies: [ + { title: 'Interstellar', director: { lastName: 'Nolan' } }, + { title: 'La vita é bella', director: { lastName: 'Benigni' } } + ] + }, + { + name: 'Main Theatre', + movies: [ + { title: 'La vita é bella', director: { lastName: 'Benigni' } }, + { title: 'Interstellar', director: { lastName: 'Nolan' } } + ] + }] + }, + { + cinemas: [] + }] + } + } + ] + + for (const request of requests) { + const response = await graphqlRequest(service, request.query, request.variables) + + assert.deepStrictEqual(response, request.expected, 'should get expected result from composer service,' + + '\nquery: ' + request.query + + '\nexpected' + JSON.stringify(request.expected, null, 2) + + '\nresponse' + JSON.stringify(response, null, 2) + ) + } + }) +})