From 94221c6f503072f9ded1aa85ed963417f2239d4f Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Thu, 29 Feb 2024 15:37:04 +0100 Subject: [PATCH] refactor: resolver logic (#46) * reset * refactor: resolver logic * doc: getting started * fix: use abstract-logger --- .c8rc | 8 - README.md | 171 +-- examples/getting-started.js | 229 +++ fixtures/artists-subgraph-with-entities.js | 94 -- fixtures/movies-subgraph-with-entities.js | 103 -- fixtures/songs-subgraph-with-entities.js | 103 -- lib/composer.js | 1351 ++++++++--------- lib/entity.js | 53 + lib/fields.js | 63 + lib/graphql-utils.js | 50 - lib/network.js | 18 +- lib/query-builder.js | 633 +++----- lib/query-lookup.js | 322 ++++ lib/result.js | 205 +++ lib/utils.js | 140 +- lib/validation.js | 301 +++- misc/visitor.js | 71 - package.json | 16 +- test/adapters.test.js | 700 +++++---- test/composer.test.js | 551 +++---- test/entities.test.js | 741 --------- test/entities/capabilities.test.js | 234 +++ .../on-composer.test.js} | 340 +++-- test/entities/on-subgraphs-1.test.js | 298 ++++ test/entities/on-subgraphs-2.test.js | 411 +++++ test/entities/on-subgraphs-3.test.js | 402 +++++ .../fixtures/artists.js | 2 +- .../fixtures/authors.js | 13 +- .../fixtures/books.js | 14 +- .../fixtures/cinemas.js | 2 +- test/fixtures/foods.js | 70 + .../fixtures/movies.js | 2 +- .../fixtures/reviews.js | 33 +- .../fixtures/songs.js | 2 +- test/helper.js | 213 +-- test/index.test.js | 440 ------ test/network.test.js | 103 +- test/options.test.js | 123 +- test/query-builder.test.js | 105 -- test/query.test.js | 410 +++++ test/result.test.js | 29 + test/runner.js | 22 - test/subscriptions.test.js | 199 --- 43 files changed, 5078 insertions(+), 4312 deletions(-) delete mode 100644 .c8rc create mode 100644 examples/getting-started.js delete mode 100644 fixtures/artists-subgraph-with-entities.js delete mode 100644 fixtures/movies-subgraph-with-entities.js delete mode 100644 fixtures/songs-subgraph-with-entities.js create mode 100644 lib/entity.js create mode 100644 lib/fields.js delete mode 100644 lib/graphql-utils.js create mode 100644 lib/query-lookup.js create mode 100644 lib/result.js delete mode 100644 misc/visitor.js delete mode 100644 test/entities.test.js create mode 100644 test/entities/capabilities.test.js rename test/{resolve-entities.test.js => entities/on-composer.test.js} (56%) create mode 100644 test/entities/on-subgraphs-1.test.js create mode 100644 test/entities/on-subgraphs-2.test.js create mode 100644 test/entities/on-subgraphs-3.test.js rename fixtures/artists-subgraph.js => test/fixtures/artists.js (92%) rename fixtures/authors-subgraph.js => test/fixtures/authors.js (88%) rename fixtures/books-subgraph.js => test/fixtures/books.js (74%) rename fixtures/cinemas-subgraph.js => test/fixtures/cinemas.js (93%) create mode 100644 test/fixtures/foods.js rename fixtures/movies-subgraph.js => test/fixtures/movies.js (92%) rename fixtures/reviews-subgraph.js => test/fixtures/reviews.js (86%) rename fixtures/songs-subgraph.js => test/fixtures/songs.js (92%) delete mode 100644 test/index.test.js delete mode 100644 test/query-builder.test.js create mode 100644 test/query.test.js create mode 100644 test/result.test.js delete mode 100644 test/runner.js delete mode 100644 test/subscriptions.test.js diff --git a/.c8rc b/.c8rc deleted file mode 100644 index b696d3d..0000000 --- a/.c8rc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "exclude": [ - "misc/*", - "examples/*", - "fixtures/*", - "test/*" - ] -} diff --git a/README.md b/README.md index 5b79af4..c77bf1f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ The GraphQL API Composer is a framework agnostic library for combining multiple GraphQL APIs, known as subgraphs, into a single API capable of querying data across any of its constituent subgraphs. -## Example +The Composer downloads each subgraph schema from the corresponding upstream servers. The subgraphs will be merged to create a supergraph API. The combined API exposes the original operations from each subgraph. Furthermore, types with the same name in multiple subgraphs are merged together into entities. + +## Getting Started Given the following `Books` subgraph schema: @@ -20,7 +22,7 @@ type Book { type Query { getBook(id: ID!): Book - getBooksByIds(ids: [ID]!): [Book]! + getBooks(ids: [ID]!): [Book]! } ``` @@ -58,23 +60,19 @@ type ReviewWithBook { type Query { getReview(id: ID!): Review - getReviewBook(id: ID!): Book - getReviewBookByIds(ids: [ID]!): [Book]! - getReviewsByBookId(id: ID!): [Review]! + getReviews(ids: [ID]!): Review + getReviewBook(bookId: ID!): Book } type Mutation { createReview(review: ReviewInput!): Review! } - -type Subscription { - reviewPosted: ReviewWithBook! -} ``` -The Composer will download each subgraph schema from the corresponding upstream servers. The two subgraphs will be merged to create a supergraph API. The combined API exposes the original operations from each subgraph. Furthermore, types with the same name in multiple subgraphs are merged together into entities. For example, when the `Books` and `Reviews` subgraphs are merged, the `Book` type becomes: +When the `Books` and `Reviews` subgraphs are merged, the `Book` type becomes: ```graphql +TODO type Book { # Common field used to uniquely identify a Book. id: ID! @@ -83,85 +81,94 @@ type Book { title: String genre: BookGenre - # nested types - author: Author - # Fields from the Reviews subgraph. + rate: Int reviews: [Review]! } ``` -The following example shows how the GraphQL API Composer can be used with Fastify and Mercurius. +The following example shows how the GraphQL API Composer can be used with Fastify and Mercurius - see [/examples/getting-started.js](/examples/getting-started.js) for the full code. ```js -'use strict'; +'use strict' + const { compose } = require('@platformatic/graphql-composer') const Fastify = require('fastify') const Mercurius = require('mercurius') -async function main() { +async function main () { + // Get schema information from subgraphs const composer = await compose({ subgraphs: [ - { // Books subgraph information. - // Subgraph server to connect to. + { + // Books subgraph information + name: 'books', + // Subgraph server to connect to server: { - host: 'localhost:3000', - // Endpoint for retrieving introspection schema. - composeEndpoint: '/graphql-composition', - // Endpoint for GraphQL queries. - graphqlEndpoint: '/graphql' + host: booksServiceHost }, + // Configuration for working with Book entities in this subgraph entities: { - // Configuration for working with Book entities in this subgraph. Book: { pkey: 'id', - // Resolver for retrieving multiple Books. + // Resolver for retrieving multiple Books resolver: { - name: 'getBooksByIds', - argsAdapter: 'ids.$>#id' + name: 'getBooks', + argsAdapter: (partialResults) => ({ + ids: partialResults.map(r => r.id) + }) } } - }, - }, - { // Reviews subgraph information. - ... - } - ], - // Hooks for subscriptions. - subscriptions: { - onError (ctx, topic, error) { - throw error; - }, - publish (ctx, topic, payload) { - ctx.pubsub.publish({ - topic, - payload - }) + } }, - subscribe (ctx, topic) { - return ctx.pubsub.subscribe(topic) - }, - unsubscribe (ctx, topic) { - ctx.pubsub.close() + { + // Reviews subgraph information + name: 'reviews', + server: { + host: reviewsServiceHost + }, + // Configuration for Review entity + entities: { + Review: { + pkey: 'id', + // Resolver for retrieving multiple Books + resolver: { + name: 'getReviews', + argsAdapter: (partialResults) => ({ + ids: partialResults.map(r => r.id) + }) + } + }, + // Book entity is here too + Book: { + pkey: 'id', + // Resolver for retrieving multiple Books + resolver: { + name: 'getReviewBooks', + argsAdapter: (partialResults) => ({ + bookIds: partialResults.map(r => r.id) + }) + } + } + } } - } + ] }) - // Create a Fastify server that uses the Mercurius GraphQL plugin. - const router = Fastify() + // Create a Fastify server that uses the Mercurius GraphQL plugin + const composerService = Fastify() - router.register(Mercurius, { + composerService.register(Mercurius, { schema: composer.toSdl(), resolvers: composer.resolvers, - subscription: true + graphiql: true }) - await router.ready() - // If subscriptions are used, the GraphQL router's server implementation - // should call onSubscriptionEnd() when a subscription ends. This tells - // the composer to clean up the resources associated with the subscription. - router.graphql.addHook('onSubscriptionEnd', composer.onSubscriptionEnd) - await router.listen() + await composerService.ready() + await composerService.listen() + + // Then query the composer service + // { getBook (id: 1) { id title reviews { content rating } } } } main() @@ -173,6 +180,9 @@ main() - Arguments - `config` (object, optional) - A configuration object with the following schema. + - `defaultArgsAdapter` (function, optional) - The default `argsAdapter` function for the entities. + - `addEntitiesResolvers` (boolean, optional) - automatically add entities types and resolvers accordingly with configuration, see [composer entities section](#composer-entities). + - `logger` (pino instance, optional) - The composer logger - `subgraphs` (array, optional) - Array of subgraph configuration objects with the following schema. - `name` (string, optional) - A unique name to identify the subgraph; if missing the default one is `#${index}`, where index is the subgraph index in the array. - `server` (object, required) - Configuration object for communicating with the subgraph server with the following schema: @@ -195,7 +205,7 @@ main() - `resolver` (object, optional) - The resolver definition to query the foreing entity, same structure as `entity.resolver`. - `many` (array of objects, optional) - Describe a 1-to-many relation - the reverse of the foreign key. - `type` (string, required) - The entity type where the entity is a foreign key. - - `fkey` (string, required) - The foreign key field in the referred entity. + - `fkey` (string, optional) - The foreign key field in the referred entity. - `as` (string, required) - When using `addEntitiesResolvers`, it defines the name of the relation as a field of the current one, as a list. - `pkey` (string, optional) - The primary key of the referred entity. - `subgraph` (string, optional) - The subgraph name of the referred entity, where the resolver is located; if missing is intended the self. @@ -203,26 +213,8 @@ main() - `onSubgraphError` (function, optional) - Hook called when an error occurs getting schema from a subgraph. The default function will throw the error. The arguments are: - `error` (error) - The error. - `subgraph` (string) - The erroring subgraph name. - - `subscriptions` (object, optional) - Subscription hooks. This is required if subscriptions are used. This object adheres to the following schema. - - `onError(ctx, topic, error)` (function, required) - Hook called when a subscription error occurs. The arguments are: - - `ctx` (any) - GraphQL context object. - - `topic` (string) - The subscription topic. - - `error` (error) - The subscription error. - - `publish(ctx, topic, payload)` (function, required) - Hook called to publish new data to a topic. The arguments are: - - `ctx` (any) - GraphQL context object. - - `topic` (string) - The subscription topic. - - `payload` (object) - The subscriptiondata to publish. - - `subscribe(ctx, topic)` (function, required) - Hook called to subscribe to a topic. The arguments are: - - `ctx` (any) - GraphQL context object. - - `topic` (string) - The subscription topic. - - `unsubscribe(ctx, topic)` (function, required) - Hook called to unsubscribe from a topic. The arguments are: - - `ctx` (any) - GraphQL context object. - - `topic` (string) - The subscription topic. - `queryTypeName` (string, optional) - The name of the `Query` type in the composed schema. **Default:** `'Query'`. - `mutationTypeName` (string, optional) - The name of the `Mutation` type in the composed schema. **Default:** `'Mutation'`. - - `subscriptionTypeName` (string, optional) - The name of the `Subscription` type in the composed schema. **Default:** `'Subscription'`. - - `defaultArgsAdapter` (function, optional) - The default `argsAdapter` function for the entities. - - `addEntitiesResolvers` (boolean, optional) - automatically add entities types and resolvers accordingly with configuration, see [composer entities section](#composer-entities). - Returns - A `Promise` that resolves with a `Composer` instance. @@ -254,26 +246,3 @@ Returns the supergraph schema as a GraphQL `IntrospectionQuery` object. This rep #### `composer.resolvers` An object containing the GraphQL resolver information for the supergraph. - -#### `onSubscriptionEnd(ctx, topic)` - - - Arguments - - `ctx` (any) - GraphQL context object. - - `topic` (string) - The subscription topic. - - Returns - - Nothing - -A function that should be called by the GraphQL router when a client subscription has ended. - ---- - -### Composer entities - -TODO explain: - -- entities: - - fkey - - many - - as - -- addEntitiesResolvers, how it works, what it does diff --git a/examples/getting-started.js b/examples/getting-started.js new file mode 100644 index 0000000..36f3472 --- /dev/null +++ b/examples/getting-started.js @@ -0,0 +1,229 @@ +'use strict' + +const { compose } = require('@platformatic/graphql-composer') +const Fastify = require('fastify') +const Mercurius = require('mercurius') +const { default: pino } = require('pino') + +const bookGql = { + schema: ` + enum BookGenre { + FICTION + NONFICTION + } + + type Book { + id: ID! + title: String + genre: BookGenre + } + + type Query { + getBook(id: ID!): Book + getBooks(ids: [ID]!): [Book]! + } +`, + data: { + library: { + 1: { + id: 1, + title: 'A Book About Things That Never Happened', + genre: 'FICTION' + }, + 2: { + id: 2, + title: 'A Book About Things That Really Happened', + genre: 'NONFICTION' + } + } + }, + resolvers: { + Query: { + getBook: (_, { id }) => bookGql.data.library[id], + getBooks: (_, { ids }) => ids.map((id) => bookGql.data.library[id]).filter(b => !!b) + } + } +} + +const reviewGql = { + schema: ` + input ReviewInput { + bookId: ID! + rating: Int! + content: String! + } + + type Review { + id: ID! + rating: Int! + content: String! + } + + type Book { + id: ID! + rate: Int + reviews: [Review]! + } + + type ReviewWithBook { + id: ID! + rating: Int! + content: String! + book: Book! + } + + type Query { + getReview(id: ID!): Review + getReviews(ids: [ID]!): Review + getReviewBooks(bookIds: [ID]!): [Book] + } + + type Mutation { + createReview(review: ReviewInput!): Review! + } +`, + + data: { + reviews: { + 1: { + id: 1, + rating: 2, + content: 'Would not read again.' + }, + 2: { + id: 2, + rating: 3, + content: 'So so.' + } + }, + books: { + 1: { + id: 1, + rate: 3, + reviews: [1] + }, + 2: { + id: 2, + rate: 4, + reviews: [2] + } + } + }, + resolvers: { + Query: { + getReview: (_, { id }) => reviewGql.data.reviews[id], + getReviews: (_, { ids }) => ids.map(id => reviewGql.data.reviews[id]).filter(b => !!b), + getReviewBooks (_, { bookIds }) { + return bookIds.map((id) => { + if (!reviewGql.data.books[id]) { return null } + const book = structuredClone(reviewGql.data.books[id]) + + book.reviews = book.reviews.map((rid) => { + return reviewGql.data.reviews[rid] + }) + + return book + }) + .filter(b => !!b) + } + }, + Mutation: { + createReview () { + // ... + } + } + } +} + +async function main () { + // Start the 2 sub services + const bookService = Fastify() + bookService.register(Mercurius, { + schema: bookGql.schema, + resolvers: bookGql.resolvers + }) + const booksServiceHost = await bookService.listen({ port: 3001 }) + + const reviewService = Fastify() + reviewService.register(Mercurius, { + schema: reviewGql.schema, + resolvers: reviewGql.resolvers + }) + const reviewsServiceHost = await reviewService.listen({ port: 3002 }) + + // Get schema information from subgraphs + const composer = await compose({ + logger: pino({ level: 'debug' }), + subgraphs: [ + { + // Books subgraph information + name: 'books', + // Subgraph server to connect to + server: { + host: booksServiceHost + }, + // Configuration for working with Book entities in this subgraph + entities: { + Book: { + pkey: 'id', + // Resolver for retrieving multiple Books + resolver: { + name: 'getBooks', + argsAdapter: (partialResults) => ({ + ids: partialResults.map(r => r.id) + }) + } + } + } + }, + { + // Reviews subgraph information + name: 'reviews', + server: { + host: reviewsServiceHost + }, + // Configuration for Review entity + entities: { + Review: { + pkey: 'id', + // Resolver for retrieving multiple Books + resolver: { + name: 'getReviews', + argsAdapter: (partialResults) => ({ + ids: partialResults.map(r => r.id) + }) + } + }, + // Book entity is here too + Book: { + pkey: 'id', + // Resolver for retrieving multiple Books + resolver: { + name: 'getReviewBooks', + argsAdapter: (partialResults) => ({ + bookIds: partialResults.map(r => r.id) + }) + } + } + } + } + ] + }) + + // Create a Fastify server that uses the Mercurius GraphQL plugin + const composerService = Fastify() + + composerService.register(Mercurius, { + schema: composer.toSdl(), + resolvers: composer.resolvers, + graphiql: true + }) + + await composerService.ready() + await composerService.listen({ port: 3000 }) + + // Then query the composer service + // { getBook (id: 1) { id title reviews { content rating } } } +} + +main() diff --git a/fixtures/artists-subgraph-with-entities.js b/fixtures/artists-subgraph-with-entities.js deleted file mode 100644 index fff7742..0000000 --- a/fixtures/artists-subgraph-with-entities.js +++ /dev/null @@ -1,94 +0,0 @@ -'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: { - resolver: { name: 'artists' }, - pkey: 'id', - many: [ - { - type: 'Movie', - as: 'movies', - pkey: 'id', - fkey: 'directorId', - subgraph: 'movies-subgraph', - resolver: { - name: 'getMoviesByArtists', - argsAdapter: (artistIds) => { - return { ids: artistIds } - }, - partialResults: (partialResults) => { - return partialResults.map(r => r.id) - } - } - }, - { - type: 'Song', - as: 'songs', - pkey: 'id', - fkey: 'singerId', - subgraph: 'songs-subgraph', - resolver: { - name: 'getSongsByArtists', - argsAdapter: (artistIds) => { - return { ids: artistIds } - }, - partialResults: (partialResults) => { - return partialResults.map(r => r.id) - } - } - } - ] - } -} - -module.exports = { name: 'artists', schema, reset, resolvers, entities, data } diff --git a/fixtures/movies-subgraph-with-entities.js b/fixtures/movies-subgraph-with-entities.js deleted file mode 100644 index 1348660..0000000 --- a/fixtures/movies-subgraph-with-entities.js +++ /dev/null @@ -1,103 +0,0 @@ -'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 { - getArtistsByMovies (ids: [ID!]!): [Artist] - getMoviesByArtists (ids: [ID!]!): [Movie] - } -` - -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: { - async movies (_, { ids }) { - return Object.values(data.movies).filter(m => ids.includes(String(m.id))) - }, - getArtistsByMovies: async (parent, { ids }, context, info) => { - return ids.map(id => ({ id })) - }, - getMoviesByArtists: async (parent, { ids }, context, info) => { - return Object.values(data.movies).filter(m => ids.includes(String(m.directorId))) - } - }, - Movie: { - director: (parent, args, context, info) => { - return parent?.directorId ? { id: parent.directorId } : null - } - }, - Artist: { - movies: (parent, args, context, info) => { - return Object.values(data.movies).filter(a => String(a.directorId) === String(parent.id)) - } - } -} - -const entities = { - Movie: { - resolver: { name: 'movies' }, - pkey: 'id', - fkeys: [ - { - type: 'Artist', - as: 'director', - field: 'directorId', - pkey: 'id', - resolver: { - name: 'getArtistsByMovies', - argsAdapter: (partialResults) => { - return { ids: partialResults.map(r => r.id) } - }, - partialResults: (partialResults) => { - return partialResults.map(r => ({ id: r.directorId })) - } - } - } - ] - } -} - -module.exports = { name: 'movies', schema, reset, resolvers, entities, data } diff --git a/fixtures/songs-subgraph-with-entities.js b/fixtures/songs-subgraph-with-entities.js deleted file mode 100644 index 6b35b5f..0000000 --- a/fixtures/songs-subgraph-with-entities.js +++ /dev/null @@ -1,103 +0,0 @@ -'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 { - getArtistsBySongs (ids: [ID!]!): [Artist] - getSongsByArtists (ids: [ID!]!): [Song] - } -` - -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(s => ids.includes(String(s.id))) - }, - getArtistsBySongs: async (parent, { ids }, context, info) => { - return ids.map(id => ({ id })) - }, - getSongsByArtists: async (parent, { ids }, context, info) => { - return Object.values(data.songs).filter(s => ids.includes(String(s.singerId))) - } - }, - Song: { - singer: (parent, args, context, info) => { - return parent?.singerId ? { id: parent.singerId } : null - } - }, - Artist: { - songs: (parent, args, context, info) => { - return Object.values(data.songs).filter(a => String(a.singerId) === String(parent.id)) - } - } -} - -const entities = { - Song: { - resolver: { name: 'songs' }, - pkey: 'id', - fkeys: [ - { - type: 'Artist', - as: 'singer', - field: 'singerId', - pkey: 'id', - resolver: { - name: 'getArtistsBySongs', - argsAdapter: (partialResults) => { - return { where: { singerId: { in: partialResults.map(r => r.id) } } } - }, - partialResults: (partialResults) => { - return partialResults.map(r => ({ id: r.singerId })) - } - } - } - ] - } -} - -module.exports = { name: 'songs', schema, reset, resolvers, entities, data } diff --git a/lib/composer.js b/lib/composer.js index 72c767a..5a77a4a 100644 --- a/lib/composer.js +++ b/lib/composer.js @@ -1,256 +1,105 @@ 'use strict' -const { once } = require('node:events') -const { isDeepStrictEqual } = require('node:util') + const { buildClientSchema, printSchema } = require('graphql') -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') -const { isObject, traverseResult, schemaTypeName, createDefaultArgsAdapter } = require('./utils') -const { - validateArray, - validateFunction, - validateObject, - validateString, - validateResolver -} = require('./validation') +const { validateComposerOptions } = require('./validation') +const { QUERY_TYPE, MUTATION_TYPE, mergeTypes, getMainType, createType, createField, createFieldId } = require('./fields') +const { buildQuery, queryParentResult } = require('./query-builder') +const { collectQueries } = require('./query-lookup') +const { unwrapFieldTypeName, objectDeepClone, pathJoin, schemaTypeName } = require('./utils') +const { mergeResult } = require('./result') +const { entityKeys } = require('./entity') + +const COMPOSER_SUBGRAPH_NAME = '__composer__' + +/** + * @typedef {Object} ComposerField + * @property {Object} src - graphql field definition + * @property {string} typeName - field type name, for example "Author" + * @property {Resolver} resolver + */ + +/** + * fields[typeName.fieldName][subgraphName] = { src, typeName, resolver } + * @typedef {Object.>} ComposerFields + * @example fields['Book.id']['books-subgraph'] = { src, typeName, resolver } + */ + +/** + * @typedef {Object} ComposerType + * @property {Object} src - graphql type definition + * @property {Map} fields - type field's, where the key is the field name + * @property {Entity} entity + */ + +/** + * types[typeName][subgraphName] = { src, fields, entity } + * @typedef {Object.>} ComposerTypes + * @example types['Book']['books-subgraph'] = { src, fields, entity } + */ class Composer { - #queryTypeName - #mutationTypeName - #subscriptionTypeName - #subgraphs - #subgraphsIndex - #pubsub - #types - #directives - #entities - constructor (options = {}) { - validateObject(options, 'options') - - const { - queryTypeName = 'Query', - mutationTypeName = 'Mutation', - subscriptionTypeName = 'Subscription', - subgraphs = [], - onSubgraphError = onError, - subscriptions, - addEntitiesResolvers - } = options - - let defaultArgsAdapter = options.defaultArgsAdapter - - this.addEntitiesResolvers = !!addEntitiesResolvers - - validateString(queryTypeName, 'queryTypeName') - validateString(mutationTypeName, 'mutationTypeName') - validateString(subscriptionTypeName, 'subscriptionTypeName') - validateArray(subgraphs, 'subgraphs') - validateFunction(onSubgraphError, 'onSubgraphError') - - if (defaultArgsAdapter) { - if (typeof defaultArgsAdapter === 'string') { - defaultArgsAdapter = metaline(defaultArgsAdapter) - } else { - validateFunction(defaultArgsAdapter, 'defaultArgsAdapter') - } - } - - if (subscriptions) { - validateObject(subscriptions, 'subscriptions') - const { onError, publish, subscribe, unsubscribe } = subscriptions - validateFunction(onError, 'subscriptions.onError') - validateFunction(publish, 'subscriptions.publish') - validateFunction(subscribe, 'subscriptions.subscribe') - validateFunction(unsubscribe, 'subscriptions.unsubscribe') - this.#pubsub = { onError, publish, subscribe, unsubscribe } - } - - const subgraphsCopy = [] - this.#subgraphsIndex = {} - this.#entities = {} - - for (let i = 0; i < subgraphs.length; ++i) { - const subgraph = subgraphs[i] - const subgraphName = subgraph?.name || '#' + i - - validateObject(subgraph, `subgraphs[${subgraphName}]`) - - const { - entities, - server - } = subgraph - - validateObject(server, `subgraphs[${subgraphName}].server`) - - const { - host, - composeEndpoint = '/.well-known/graphql-composition', - graphqlEndpoint = '/graphql' - } = server - - validateString(host, `subgraphs[${subgraphName}].server.host`) - validateString(composeEndpoint, `subgraphs[${subgraphName}].server.composeEndpoint`) - validateString(graphqlEndpoint, `subgraphs[${subgraphName}].server.graphqlEndpoint`) - - const entitiesCopy = Object.create(null) - if (entities) { - validateObject(entities, `subgraphs[${subgraphName}].entities`) - - const entityNames = Object.keys(entities) - - for (let j = 0; j < entityNames.length; ++j) { - const name = entityNames[j] - const value = entities[name] - - validateObject(value, `subgraphs[${subgraphName}].entities.${name}`) - - const { - pkey, - fkeys = [], - many = [], - resolver - } = value - - validateString(pkey, `subgraphs[${subgraphName}].entities.${name}.pkey`) - validateArray(fkeys, `subgraphs[${subgraphName}].entities.${name}.fkeys`) - validateArray(many, `subgraphs[${subgraphName}].entities.${name}.many`) - - for (let k = 0; k < fkeys.length; ++k) { - const fkey = fkeys[k] - for (const p of ['type']) { - validateString(fkey[p], `subgraphs[${subgraphName}].entities.${name}.fkeys[${k}].${p}`) - } - for (const p of ['field', 'as', 'pkey']) { - if (!fkey[p]) { continue } - 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) - } - } - } - - for (let k = 0; k < many.length; ++k) { - const m = many[k] - for (const p of ['type', 'fkey', 'as', 'pkey']) { - validateString(m[p], `subgraphs[${subgraphName}].entities.${name}.many[${k}].${p}`) - } - for (const p of ['subgraph']) { - 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) - } - } - - if (resolver) { - validateResolver(resolver, `subgraphs[${subgraphName}].entities.${name}.resolver`) - if (typeof resolver.argsAdapter === 'string') { - resolver.argsAdapter = metaline(resolver.argsAdapter) - } else if (!resolver.argsAdapter) { - resolver.argsAdapter = defaultArgsAdapter ?? createDefaultArgsAdapter(name, pkey) - } - } - - const entity = { - resolver, - pkey, - fkeys, - many - } - - entitiesCopy[name] = entity - - this.addEntity(name, entity) - } - } + this.mergedSchema = { _built: false } + this.schemas = [] + this.mainTypes = new Set() + this.resolvers = Object.create(null) - // Make a copy of the input so that no tampering occurs at runtime. It - // also protects against other things like weird getters. - const subgraphCopy = { - name: subgraphName, - server: { host, composeEndpoint, graphqlEndpoint }, - entities: entitiesCopy - } + const v = validateComposerOptions(options) - if (this.#subgraphsIndex[subgraphName]) { - throw new TypeError(`subgraphs name ${subgraphName} is not unique`) + this.logger = v.logger + this.schemaOptions = { + [QUERY_TYPE]: { + schemaPropertyName: 'queryType', + name: v.queryTypeName + }, + [MUTATION_TYPE]: { + schemaPropertyName: 'mutationType', + name: v.mutationTypeName } + } + this.addEntitiesResolvers = v.addEntitiesResolvers + this.onSubgraphError = v.onSubgraphError + this.subgraphs = v.subgraphs + this.defaultArgsAdapter = v.defaultArgsAdapter - this.#subgraphsIndex[subgraphName] = subgraphCopy - subgraphsCopy.push(subgraphCopy) + if (this.addEntitiesResolvers) { + this.entities = v.entities + } else { + this.entities = undefined } - this.#queryTypeName = queryTypeName - this.#mutationTypeName = mutationTypeName - this.#subscriptionTypeName = subscriptionTypeName - this.#subgraphs = subgraphsCopy - this.#types = new Map() - this.#directives = new Map() - this.resolvers = Object.create(null) - this.onSubgraphError = onSubgraphError - this.subscriptionMap = new Map() - this.onSubscriptionEnd = onSubscriptionEnd.bind(this) + /** @type ComposerTypes */ + this.types = {} + /** @type ComposerFields */ + this.fields = {} + + this.aliases = {} } toSchema () { - const types = [] - const directives = Array.from(this.#directives.values()) - let queryType = null - let mutationType = null - let subscriptionType = null - - if (this.#types.has(this.#queryTypeName)) { - queryType = { name: this.#queryTypeName } - } - - if (this.#types.has(this.#mutationTypeName)) { - mutationType = { name: this.#mutationTypeName } + const schema = { + queryType: undefined, + mutationType: undefined, + types: [], + // TODO support directives + directives: [] } - if (this.#types.has(this.#subscriptionTypeName)) { - subscriptionType = { name: this.#subscriptionTypeName } + for (const mainType of this.mainTypes.values()) { + const s = this.schemaOptions[mainType] + schema[s.schemaPropertyName] = { name: s.name } } - for (const value of this.#types.values()) { - types.push(value.schemaNode) + for (const type of this.mergedSchema.types.values()) { + schema.types.push(type.src) } - return { - __schema: { - queryType, - mutationType, - subscriptionType, - types, - directives - } - } + return { __schema: schema } } toSdl () { @@ -258,15 +107,66 @@ class Composer { } async compose () { - const schemas = await this.#fetchSubgraphSchemas() + this.schemas = await this.fetchSubgraphSchemas() - for (let i = 0; i < schemas.length; ++i) { - this.mergeSchema(schemas[i], this.#subgraphs[i]) + for (let i = 0; i < this.schemas.length; ++i) { + this.mergeSchema(this.schemas[i]) } if (this.addEntitiesResolvers) { await this.setupComposerSubgraph() } + + this.buildMergedSchema() + } + + // TODO return a copy to avoid writes on resolvers + getResolvers () { + return { ...this.resolvers } + } + + // TODO memoize + deferredSubgraph (fieldId) { + const field = this.fields[fieldId] + if (!field) { + throw new Error('DEFERRED_SUBGRAPH_NO_FIELD') + } + + // TODO can the field be resolved by multiple subgraphs? + const subgraphNames = Object.keys(field) + if (subgraphNames.length === 1) { + return subgraphNames[0] + } + + // ignore COMPOSER_SUBGRAPH_NAME because can't resolve fields + return subgraphNames.filter(s => s !== COMPOSER_SUBGRAPH_NAME)[0] + } + + buildMergedSchema () { + this.mergedSchema = { + types: new Map(), + _built: false + } + + const typeNames = Object.keys(this.types) + // TODO handle different Query or Mutation type name between subgraphs + for (let i = 0; i < typeNames.length; ++i) { + const typeName = typeNames[i] + + const subgraphNames = Object.keys(this.types[typeName]) + for (let j = 0; j < subgraphNames.length; ++j) { + const subgraphName = subgraphNames[j] + + const type = this.types[typeName][subgraphName] + + const t = this.mergedSchema.types.get(typeName) + + // TODO handle conflicts by name or type, add option to rename, mask or hide + this.mergedSchema.types.set(typeName, t ? mergeTypes(t, type) : objectDeepClone(type)) + } + } + + this.mergedSchema._built = true } /** @@ -283,396 +183,577 @@ class Composer { instance.get('/i', (_, reply) => reply.graphql(getIntrospectionQuery())) await instance.ready() + const subgraphName = COMPOSER_SUBGRAPH_NAME + + // set entities both in composer and each subgraph const subgraph = { - name: '__composer__', + name: subgraphName, server: { instance, graphqlEndpoint: '/graphql' }, - entities + entities: new Map(Object.entries(entities)) + } + this.subgraphs.set(subgraphName, subgraph) + + // collect all the alias + // ! aliases works only in options.entities with "addEntitiesResolvers" + for (const entityName of Object.keys(this.entities)) { + const entity = this.entities[entityName] + + // fill entities in composer + this.setEntity(entityName, entity.subgraph, entity) + this.types[entityName][entity.subgraph].entity = entity + + if (entity.fkeys) { + for (const fkey of entity.fkeys) { + if (!fkey.as) { continue } + const fieldId = createFieldId(entityName, fkey.as) + this.setAlias(fieldId, subgraphName, { ...fkey, parentType: entityName, entity, mode: 'fkey' }) + } + } + + if (entity.many) { + for (const many of entity.many) { + if (!many.as) { continue } + const fieldId = createFieldId(entityName, many.as) + this.setAlias(fieldId, subgraphName, { ...many, parentType: entityName, entity, mode: 'many' }) + } + } } - this.#subgraphs.push(subgraph) const introspection = await instance.inject('/i') const subgraphSchema = JSON.parse(introspection.body).data + subgraphSchema.subgraphName = subgraphName - this.mergeSchema(subgraphSchema, subgraph) + this.mergeSchema(subgraphSchema) + + // add aliases in fields and types + for (const fieldId of Object.keys(this.aliases)) { + const alias = this.aliases[fieldId][subgraphName] + const t = this.types[alias.parentType][alias.entity.subgraph] + const field = this.fields[fieldId][subgraphName].src + const f = this.addField(alias.parentType, field, alias.subgraph, t, alias.resolver) + t.fields.set(alias.as, f) + f.type = this.types[f.typeName][subgraphName] + } } - async #fetchSubgraphSchemas () { - const requests = this.#subgraphs.map((subgraph) => { + async fetchSubgraphSchemas () { + const subgraphs = Array.from(this.subgraphs.values()) + + const requests = subgraphs.map((subgraph) => { return fetchSubgraphSchema(subgraph.server) }) + const responses = await Promise.allSettled(requests) const schemas = [] for (let i = 0; i < responses.length; ++i) { const { status, value: introspection } = responses[i] + const subgraph = subgraphs[i] if (status !== 'fulfilled') { - const subgraph = this.#subgraphs[i] - const msg = `Could not process schema from '${subgraph.server.host}'` + const msg = `Could not process schema for subgraph '${subgraph.name}' from '${subgraph.server.host}'` this.onSubgraphError(new Error(msg, { cause: responses[i].reason }), subgraph.name) continue } + introspection.subgraphName = subgraph.name schemas.push(introspection) } return schemas } - mergeSchema ({ __schema: schema }, subgraph) { - // TODO(cjihrig): Support renaming types and handling conflicts. - // TODO(cjihrig): Handle directives too. - const queryType = schema?.queryType?.name - const mutationType = schema?.mutationType?.name - const subscriptionType = schema?.subscriptionType?.name - - if (queryType && !this.#types.has(this.#queryTypeName)) { - this.#types.set(this.#queryTypeName, { - schemaNode: createEmptyObject(this.#queryTypeName), - fieldMap: new Map() - }) - } - - if (mutationType && !this.#types.has(this.#mutationTypeName)) { - this.#types.set(this.#mutationTypeName, { - schemaNode: createEmptyObject(this.#mutationTypeName), - fieldMap: new Map() - }) - } - - if (subscriptionType && !this.#types.has(this.#subscriptionTypeName)) { - this.#types.set(this.#subscriptionTypeName, { - schemaNode: createEmptyObject(this.#subscriptionTypeName), - fieldMap: new Map() - }) + // TODO test subgraph with different Query or Mutation names + mergeSchema ({ __schema: schema, subgraphName }) { + if (!schema) { + return } for (let i = 0; i < schema.types.length; ++i) { - const type = schema.types[i] - const originalTypeName = type.name + const schemaType = schema.types[i] + const typeName = schemaType.name - if (originalTypeName.startsWith('__')) { - // Ignore built in types. + // Ignore built in types + if (typeName.startsWith('__')) { continue } - let typeName - let isQueryOrMutation = false - let isSubscription = false - - if (originalTypeName === queryType) { - typeName = this.#queryTypeName - isQueryOrMutation = true - } else if (originalTypeName === mutationType) { - typeName = this.#mutationTypeName - isQueryOrMutation = true - } else if (this.#pubsub && originalTypeName === subscriptionType) { - typeName = this.#subscriptionTypeName - isSubscription = true - } else { - typeName = originalTypeName + // Query or Mutation + const mainType = getMainType(schema, schemaType) + if (mainType) { + this.mainTypes.add(mainType) + } + + if (!Array.isArray(schemaType.fields)) { + this.addType(typeName, subgraphName, schemaType) + continue } - const existingType = this.#types.get(typeName) + const entity = this.getEntity(typeName, subgraphName) + const type = this.addType(typeName, subgraphName, schemaType, entity) + type.fields = new Map() + for (let i = 0; i < schemaType.fields.length; ++i) { + const field = schemaType.fields[i] + let resolver + + if (mainType) { + resolver = this.createResolver({ + typeName, + subgraphName, + fieldSrc: field + }) + + this.resolvers[typeName] ??= Object.create(null) + this.resolvers[typeName][field.name] = resolver + } + + // TODO alias for conflicting types, for example + // subgraph#1: type Pizza { id: ID } + // subgraph#2: type Pizza { id: Int! } + // options: { ... subgraph#1: { entities: { Pizza: { id: { as: 'optionalId' ... + // result: type Pizza { optionalId: ID (from subgraph#1), id: Int! (from subgraph#2) } - if (!existingType) { - this.#types.set(typeName, { - schemaNode: type, - fieldMap: new Map() - }) + // TODO option to hide fields + // note: type may not be available at this point, only typeName + + const f = this.addField(typeName, field, subgraphName, type, resolver) + type.fields.set(field.name, f) } + } - if (Array.isArray(type.fields)) { - const theType = this.#types.get(typeName) - const { fieldMap } = theType + // fill types in fields by typename + for (const fieldId of Object.keys(this.fields)) { + for (const subgraphName of Object.keys(this.fields[fieldId])) { + const f = this.fields[fieldId][subgraphName] + f.type = this.types[f.typeName][subgraphName] + } + } + } - if (this.#entities[typeName]) { - this.#entities[typeName].subgraphs.push(subgraph) - } + addType (typeName, subgraphName, type, entity) { + if (this.types[typeName] && this.types[typeName][subgraphName]) { + return this.types[typeName][subgraphName] + } - for (let i = 0; i < type.fields.length; ++i) { - const field = type.fields[i] - let existingField = fieldMap.get(field.name) + const t = createType({ name: typeName, src: type, entity }) + if (!this.types[typeName]) { + this.types[typeName] = { [subgraphName]: t } + return t + } - if (!existingField) { - existingField = { - schemaNode: field, - subgraphs: new Set() - } - fieldMap.set(field.name, existingField) - theType.schemaNode.fields.push(field) - } + this.types[typeName][subgraphName] = t + return t + } - existingField.subgraphs.add(subgraph) - } + addField (parentTypeName, field, subgraphName, parent, resolver) { + const fieldId = createFieldId(parentTypeName, field.name) + + if (this.fields[fieldId] && this.fields[fieldId][subgraphName]) { + this.logger.warn('TODO field already exists on subgraph') + return + } + + const typeName = unwrapFieldTypeName(field) + const f = createField({ name: field.name, typeName, src: field, parent, resolver }) + if (!this.fields[fieldId]) { + this.fields[fieldId] = { [subgraphName]: f } + return f + } + + this.fields[fieldId][subgraphName] = f + return f + } + + setEntity (typeName, subgraphName, entity) { + const subgraph = this.subgraphs.get(subgraphName) + if (subgraph) { + subgraph.entities.set(typeName, entity) + } + } + + getEntity (typeName, subgraphName) { + const subgraph = this.subgraphs.get(subgraphName) + if (subgraph) { + return subgraph.entities.get(typeName) + } + } + + /** + * get existing types on subgraph + */ + getTypes (subgraphName) { + // TODO memoize + const types = {} + const typeNames = Object.keys(this.types) + for (let i = 0; i < typeNames.length; ++i) { + const typeName = typeNames[i] + + const subgraphNames = Object.keys(this.types[typeName]) + for (let j = 0; j < subgraphNames.length; ++j) { + const s = subgraphNames[j] + if (s !== subgraphName) { continue } + + types[typeName] = this.types[typeName][s] } + } + return types + } - if (Array.isArray(type.fields)) { - for (let i = 0; i < type.fields.length; ++i) { - const field = type.fields[i] - const originalFieldName = field.name - // const fieldName = subgraph.renameQueries?.[originalFieldName] ?? originalFieldName; - const fieldName = originalFieldName - - // TODO(cjihrig): This is a hack. Use a transform visitor for this. - field.name = fieldName - // End hack. - - if (existingType) { - addFieldToType(existingType, field, subgraph) - } - - if (isQueryOrMutation) { - const resolver = this.#createResolver({ - // TODO(cjihrig): Audit fields included here. - type, - field, - fieldName: originalFieldName, - subgraph - }) - - this.resolvers[typeName] ??= Object.create(null) - this.resolvers[typeName][fieldName] = resolver - } else if (isSubscription) { - const subscribe = this.#createSubscription({ - type, - field, - fieldName: originalFieldName, - subgraph, - pubsub: this.#pubsub - }) - - this.resolvers[typeName] ??= Object.create(null) - this.resolvers[typeName][fieldName] = { subscribe } - } - } - } else if (existingType && - !isDeepStrictEqual(type, existingType.schemaNode)) { - // TODO(cjihrig): Revisit this. - throw new Error(`Duplicate non-entity type: ${typeName}`) + /** + * get existing types on subgraph + */ + getFields (subgraphName) { + // TODO memoize + const fields = {} + const fieldIds = Object.keys(this.fields) + for (let i = 0; i < fieldIds.length; ++i) { + const fieldName = fieldIds[i] + + const subgraphNames = Object.keys(this.fields[fieldName]) + for (let j = 0; j < subgraphNames.length; ++j) { + const s = subgraphNames[j] + if (s !== subgraphName) { continue } + + fields[fieldName] = this.fields[fieldName][s] } } + return fields } - #createResolver ({ type, field, fieldName, subgraph }) { - return async (parent, args, contextValue, info) => { - const ctx = createContext({ fieldName }) - const node = info.fieldNodes[0] - const schemaNode = type.fields.find((f) => { - return f.name === node.name.value - }) - const bareType = unwrapSchemaType(schemaNode.type) - const objType = this.#types.get(bareType.name) - const query = new QueryBuilder({ - node, - schemaNode, - path: [fieldName], - type: objType, - fields: [], - subgraph, - resolverName: info.fieldName, - root: true, + // an alias must: + // - exists in gql schema/s + // - have a resolver + // alias key: Type.field#sourceSubgraph where sourceSubgraph is the subgraph where it will be resolved/replaced + // note! alias.subgraph is the subgraph for the resolver, see that as alias[subgraphSource].subgraphTarget + setAlias (fieldId, subgraphName, alias) { + if (!this.aliases[fieldId]) { + this.aliases[fieldId] = { [subgraphName]: alias } + return + } + this.aliases[fieldId][subgraphName] = alias + } + + // TODO memoize + // subgraphName is the subgraph that resolves the alias + getAlias (fieldId, subgraphName) { + const alias = this.aliases[fieldId] + if (!alias) { return } + return Object.values(alias).find(a => a.subgraph === subgraphName) + } + + createResolver ({ typeName, subgraphName, fieldSrc }) { + return async (parent, args, context, info) => { + return this.runResolver({ typeName, subgraphName, fieldSrc, parent, args, context, info }) + } + } + + async runResolver ({ typeName, subgraphName, fieldSrc, parent, args, context, info }) { + const { queries, order } = this.collectQueries({ + typeName, + subgraphName, + fieldSrc, + parent, + args, + context, + info + }) + + const result = await this.runQueries(queries, order) + return result[fieldSrc.name] + } + + /** + * collect queries starting from the resolver fn + */ + collectQueries ({ + // resolver generator + typeName, subgraphName, fieldSrc, + // resolver args + parent, args, context, info + }) { + const types = this.getTypes(subgraphName) + const fields = this.getFields(subgraphName) + + const queries = new Map() + const order = new Set() + for (const queryFieldNode of info.fieldNodes) { + const fieldId = createFieldId(info.parentType.name, queryFieldNode.name.value) + // TODO createContext fn + const context = { info, - argsAdapter: null, - types: this.#types, - entities: this.#entities + done: [], + logger: this.logger + } + const q = collectQueries({ + subgraphName, + queryFieldNode, + fieldId, + args, + types, + fields, + aliases: this.aliases, + root: true, + context }) - await this.#runQuery(ctx, query) - return ctx.result[fieldName] + this.buildQueries(q, queries, order, context) } + + return { queries, order } } - #createSubscription ({ type, field, fieldName, subgraph, pubsub }) { - return async (parent, args, contextValue, info) => { - // TODO(cjihrig): Get this from config. - const wsUrl = (subgraph.server.host + subgraph.server.graphqlEndpoint).replace('http://', 'ws://') - let client // eslint-disable-line prefer-const - let topic // eslint-disable-line prefer-const - const shutdown = () => { - // Close the connection to the upstream subgraph. - try { - client?.unsubscribeAll() - client?.close() - } catch { } // Ignore error. - - // Close the connection from the client. - try { - pubsub.unsubscribe(contextValue, topic) - } catch { } // Ignore error. + // TODO add resolution strategy here + // - traverse horizontally, collect by subgraph/parent resolution + buildQueries (collectedQueries, queries, order, context) { + for (const deferred of collectedQueries.deferreds.values()) { + // in case of alias on deferred subgraph is not possible to get the field on lookup stage + if (!deferred.queryNode.field) { + deferred.queryNode.field = deferred.queryNode.parent.field + const field = Object.values(this.fields[deferred.queryNode.fieldId])[0] + deferred.keys = entityKeys({ field, entity: field.typeName }) + } else { + deferred.keys = entityKeys({ field: deferred.queryNode.field, entity: deferred.queryNode.field.typeName }) } - client = new SubscriptionClient(wsUrl, { - serviceName: 'composer', - failedConnectionCallback: shutdown, - failedReconnectCallback: shutdown - }) + const deferredQueriesBatch = this.buildDeferredQueries(deferred, context) + for (const deferredQueries of deferredQueriesBatch) { + this.buildQueries(deferredQueries, queries, order, context) + } + } - client.connect() - await once(client, 'ready') + // fill queries info + for (let [path, query] of collectedQueries.queries) { + const currentQuery = queries.get(path) + if (currentQuery) { + query = mergeQueries(currentQuery, query) + } - const ctx = createContext({ fieldName }) - const node = info.fieldNodes[0] - const schemaNode = type.fields.find((f) => { - return f.name === node.name.value - }) - const bareType = unwrapSchemaType(schemaNode.type) - const objType = this.#types.get(bareType.name) - const query = new QueryBuilder({ - node, - schemaNode, - path: [fieldName], - type: objType, - fields: [], - subgraph, - resolverName: info.fieldName, - root: true, - info, - argsAdapter: null, - types: this.#types, - entities: this.#entities - }) - const text = query.buildQuery(ctx) - topic = await client.createSubscription(text, {}, async (data) => { - try { - if (ctx.followups.size > 0) { - // A new context is needed each time the subscription is triggered - // and there are follow up queries. - const newCtx = createContext({ followups: ctx.followups, result: data.payload }) - - await this.#runFollowupQueries(newCtx, query) - data.payload = newCtx.result - } - await pubsub.publish(contextValue, topic, data.payload) - } catch (err) { - await pubsub.onError(contextValue, topic, err) - } - }) + // TODO calculate if entity keys are needed: keys are not needed when there are no deferred queries depending on quering entity + // TODO add entities data here: node, parent, as, resolver... + query.query.selection.push( + ...entityKeys({ field: query.field, subgraph: query.subgraphName }).map(key => ({ key }))) - this.subscriptionMap.set(contextValue.id, shutdown) - return await pubsub.subscribe(contextValue, topic) + // append deferred queries + queries.set(path, query) + order.add(path) } } - async #runQuery (ctx, query) { - const text = query.buildQuery(ctx) - // TODO debug(' run subgraph query', query.subgraph.name, text) - const data = await makeGraphqlRequest(text, query.subgraph.server) - // TODO debug(' result', data) - mergeResults(query, ctx.result, data) + buildDeferredQueries (deferred, context) { + this.logger.debug(' > composer.buildDeferredQueries') - await this.#runFollowupQueries(ctx, query) - } + const queries = [] + const subgraphs = {} + // group deferred fields by fields parent, and indirectly by subgraph + for (const { fieldName } of deferred.fields) { + const fieldId = createFieldId(deferred.queryNode.field.typeName, fieldName) + const subgraphName = this.deferredSubgraph(fieldId) + // TODO throw if no subgraph - // TODO? traverse the graph in horizontal instead of vertical? - // queries of same node could run in parallel - async #runFollowupQueries (ctx, query) { - // TODO(cjihrig): Need to query the primary owner first. - for (const followup of ctx.followups.values()) { - if (followup.solved) { continue } - if (this.isFollowupSolved(followup, query)) { - followup.solved = true - continue + const alias = this.getAlias(fieldId, subgraphName) + if (alias) { + this.addDeferredSubgraph(subgraphs, alias.subgraph, alias.type, deferred.keys, deferred.queryNode) + } else { + this.addDeferredSubgraph(subgraphs, subgraphName, deferred.queryNode.field.typeName, deferred.keys, deferred.queryNode) + subgraphs[subgraphName].fields.push({ fieldName }) } + } + + for (const subgraphName of Object.keys(subgraphs)) { + const d = subgraphs[subgraphName] + const types = this.getTypes(subgraphName) + const fields = this.getFields(subgraphName) + const selection = d.fields.map(f => f.fieldName) + + const fieldId = 'Query.' + d.resolver.name - const followupQuery = new QueryBuilder(followup) - followupQuery.operation = 'query' - await this.#runQuery(ctx, followupQuery) + // collectQueries on deferred subgraph + const q = collectQueries({ + subgraphName, + parent: deferred.queryNode, + queryFieldNode: deferred.queryNode.queryFieldNode, + path: deferred.queryNode.path, + fieldId, + selection: selection.length > 0 ? selection : null, + types, + fields, + aliases: this.aliases, + context, + // args? + resolver: d.resolver + }) + + for (const query of q.queries.values()) { + query.query.selection.push({ field: d.entity.pkey }) + } + + queries.push(q) } + + return queries + } + + addDeferredSubgraph (subgraphs, subgraphName, typeName, keys, queryNode) { + if (subgraphs[subgraphName]) { return } + + const entity = this.getEntity(typeName, subgraphName) + if (!entity) { + this.logger.error({ entityName: typeName, subgraphName }, 'missing entity, unable to compute deferred query') + throw new Error('UNABLE_BUILD_DEFERRED_QUERY_MISSING_ENTITY') + } + + const resolver = deferredResolver(entity, keys, subgraphName) + subgraphs[subgraphName] = { fields: [], resolver, entity, queryNode } } /** - * is followup already solved by the query - * @param {QueryBuilder} followup - * @param {QueryBuilder} query - * @returns {boolean} + * run queries to fullfil a request + * + * TODO setup queries plan here + * TODO collect queries by: subgraph, non-dependent, fields, keys for entities involved + * TODO run parallel / same subgraph when possible + * @param {Map} queries map (path, queryNode) + * @param {Set} order paths + * @returns {*} merged result */ - isFollowupSolved (followup, query) { - return followup.fields.some(followupField => { - return query.selectedFields.some(selectedField => { - return selectedField === followupField.schemaNode.name - }) - }) + async runQueries (queries, order) { + const result = {} + + // ! Set need to be reversed from inserting order + order = Array.from(order).reverse() + + for (const p of order) { + const q = queries.get(p) + const path = p.split('#')[0] + const parentResult = this.getParentResult(q, path) + + const { query, fieldName, keys } = buildQuery(q.query, parentResult) + // TODO query can have variables + this.logger.debug({ subgraph: q.subgraphName, path, query }, 'run subgraph query') + + const data = await makeGraphqlRequest(query, this.subgraphs.get(q.subgraphName).server) + + this.logger.debug({ path, query, data }, 'query result') + + q.result = data[fieldName] + q.keys = keys + + mergeResult(result, path, q, parentResult) + } + + return result } - // TODO add fields and subgraph for each field - // TODO handle different fields of entity by subgraph - an entity can be spread across different subgraphs - addEntity (name, entity) { - const e = this.#entities[name] + /** + * collect parent info by result to build query and merge the result + */ + getParentResult (queryNode, path) { + const q = queryParentResult(queryNode) + const result = { + path: [], + data: undefined, + keys: { + self: queryNode.field.type.entity?.pkey, + parent: undefined + } + } - const fkeys = entity.fkeys ?? [] - const many = entity.many ?? [] - const pkey = entity.pkey + if (!q) { + return result + } - if (!e) { - // TODO use Map on subgraphs - this.#entities[name] = { pkey, fkeys, many, subgraphs: [] } - } else { - e.fkeys = e.fkeys.concat(fkeys) - e.many = e.many.concat(many) + result.data = q.result + + // keys here are from "buildQuery" + if (q.keys.length > 0) { + const queryParentKey = parentKey(q.keys, path, queryNode.field.typeName) + if (queryParentKey) { + result.path = pathJoin(queryParentKey.path, queryParentKey.key).split('.') + result.keys.parent = result.path.at(-1) + + if (queryParentKey.as) { + result.as = queryParentKey.as + result.many = queryParentKey.many + } + } + } + + if (!result.keys.parent) { + result.keys.parent = q.field.type.entity?.pkey + result.path = [pathJoin(path, result.keys.parent)] } + + return result } /** * generate schema and resolvers to resolve subgraphs entities * from sugraphs schemas and entities configuration - * TODO test: no entities, no "many", no "as" */ resolveEntities () { const topSchema = [] const topResolvers = { Query: {} } - const topEntities = {} + const topEntities = new Map() const topSchemaQueries = [] const topQueriesResolvers = {} - const entitiesKeys = Object.keys(this.#entities) - if (entitiesKeys.length < 1) { + const entityNames = Object.keys(this.entities) + + if (entityNames.length < 1) { return { schema: undefined, resolvers: undefined, entities: undefined } } - for (const entityName of entitiesKeys) { - topEntities[entityName] = { - pkey: this.#entities[entityName].pkey, - fkeys: new Map() + for (const entityName of entityNames) { + const entity = this.entities[entityName] + + const e = { + subgraph: entity.subgraph, + resolver: entity.resolver, + pkey: entity.pkey, + fkeys: new Map(), + many: new Map() } - } - for (const entityName of entitiesKeys) { - const entity = this.#entities[entityName] const entitySchemaFields = {} const entityResolverFields = {} // pkey - const type = schemaTypeName(this.#types, entityName, entity.pkey) - // const pkey = { field: entity.pkey, type } + const type = schemaTypeName(this.types, entity.subgraph, entityName, entity.pkey) entitySchemaFields[entity.pkey] = type // fkeys - for (const fkey of entity.fkeys) { - setEntityFKey(topEntities[entityName].fkeys, fkey) - entitySchemaFields[fkey.as] = fkey.type - - // resolver will be replaced on query building + if (entity.fkeys) { + for (const fkey of entity.fkeys) { + setEntityFKey(e.fkeys, fkey) + entitySchemaFields[fkey.as] = fkey.type + } } // many - for (const many of entity.many) { - entitySchemaFields[many.as] = `[${many.type}]` - - // resolver will be replaced on query building + if (entity.many) { + for (const many of entity.many) { + setEntityMany(e.many, many) + entitySchemaFields[many.as] = `[${many.type}]` + } } - const fields = Object.entries(entitySchemaFields) .map(([k, v]) => `${k}: ${v}`) .join(', ') + + topEntities.set(entityName, e) topSchema.push(`type ${entityName} { ${fields} }`) topResolvers[entityName] = entityResolverFields } - // clenup outcome + // cleanup outcome + + for (const entity of topEntities.values()) { + entity.fkeys = Array.from(entity.fkeys.values()) + entity.many = Array.from(entity.many.values()) + } if (topSchemaQueries.length > 0) { topSchema.push(`type Query {\n ${topSchemaQueries.join('\n ')}\n}`) @@ -685,227 +766,84 @@ class Composer { for (const name of Object.keys(topEntities)) { const entity = topEntities[name] entity.fkeys = Array.from(entity.fkeys.values()) - this.addEntity(name, entity) + entity.many = Array.from(entity.many.values()) } - return { schema: topSchema.join('\n\n'), resolvers: topResolvers, entities: topEntities } + return { + schema: topSchema.join('\n\n'), + resolvers: topResolvers, + entities: Object.fromEntries(topEntities) + } } } -function mergeResults (query, partialResult, response) { - let { result, mergedPartial, mergedPartialParentNode } = selectResult(query, partialResult, response) - - if (mergedPartial === null) { - mergedPartialParentNode[query.path.at(-1)] = result - mergedPartial = result - } else if (Array.isArray(result) && result.length > 0) { - // TODO refactor this case, too many loops, split functions, memoize if possible - - const key = query.key - const parentKey = query.parentKey.field - const as = query.parentKey.as - const many = query.parentKey.many - - const resultIndex = new Map() - - // TODO get list from node result type? - const list = Array.isArray(result[0][key]) - - // TODO refactor as a matrix for every case - if (list) { - for (let i = 0; i < result.length; i++) { - for (let j = 0; j < result[i][key].length; j++) { - const s = resultIndex.get(result[i][key][j]) - if (s) { - resultIndex.set(result[i][key][j], s.concat(i)) - continue - } - resultIndex.set(result[i][key][j], [i]) - } - } - } else if (many) { - for (let i = 0; i < result.length; i++) { - const s = resultIndex.get(result[i][key]) - if (s) { - resultIndex.set(result[i][key], s.concat(i)) - continue - } - resultIndex.set(result[i][key], [i]) - } - } else { - for (let i = 0; i < result.length; i++) { - resultIndex.set(result[i][key], i) - } - } - - for (let i = 0; i < mergedPartial.length; i++) { - const merging = mergedPartial[i] - if (!merging) { continue } - - // no need to be recursive - if (Array.isArray(merging)) { - if (list || many) { - for (let j = 0; j < merging.length; j++) { - copyResults(result, resultIndex, merging[j], parentKey, as) - } - } else { - for (let j = 0; j < merging.length; j++) { - copyResult(result, resultIndex, merging[j], parentKey, as) - } +// TODO improve code logic, add unit tests +// key should be selected by parent type, as it is for parent/many +function parentKey (keys, path, type) { + let pkey + const fkeys = [] + + for (const k of keys) { + // ok-ish + if (k.pkey) { + pkey = k.pkey + continue + } + + if (path.indexOf(k.resolverPath) !== 0) { continue } + + // ok + if (k.parent && type === k.typeName) { + if (k.parent.many) { + return { + as: k.many.as, + many: k.many, + key: k.many.fkey, + path: pathJoin(k.resolverPath, k.many.as) } - continue } - - if (list || many) { - copyResults(result, resultIndex, merging, parentKey, as) - } else { - copyResult(result, resultIndex, merging, parentKey, as) - } - } - } else if (isObject(result)) { - // TODO copy object fn? - const fields = Object.keys(result) - for (let i = 0; i < fields.length; i++) { - mergedPartial[fields[i]] = result[fields[i]] } - } else { - // no result - mergedPartialParentNode[query.path.at(-1)] = result - } -} - -function createContext ({ fieldName, followups, result }) { - return { - path: [], - followups: followups ? new Map(followups) : new Map(), - result: result ?? { [fieldName]: null } - } -} -function copyResult (result, resultIndex, to, key, as) { - if (Array.isArray(to)) { - for (const line of to) { - copyResult(result, resultIndex, line, key, as) + // TODO improve + if (k.fkey) { + fkeys.push(k) } - return } - const index = resultIndex.get(to[key]) - if (index === undefined) { - // TODO if not nullable set it to an empty object - return - } - if (as) { - if (!to[as]) { - to[as] = {} - } - to = to[as] + // TODO should be removed and fkey should be returned in the loop above as soon as the fkey is been detected + if (fkeys.length > 0) { + const k = fkeys[0] + return { key: k.fkey.field ?? k.fkey.pkey, path: k.resolverPath } } - // TODO copy object fn? - const fields = Object.keys(result[index]) - for (let i = 0; i < fields.length; i++) { - to[fields[i]] = result[index][fields[i]] - } + if (pkey) { return { key: pkey, path } } } -function copyResults (result, resultIndex, to, key, as) { - let indexes - - if (Array.isArray(to)) { - for (const line of to) { - copyResults(result, resultIndex, line, key, as) - } - return - } - - // TODO refactor? - if (Array.isArray(to[key])) { - indexes = to[key].map(k => resultIndex.get(k)).flat() - } else { - indexes = resultIndex.get(to[key]) +function deferredResolver (entity, deferredKeys, subgraphName) { + if (!deferredKeys || deferredKeys.length < 1) { + return entity.resolver } - if (indexes === undefined) { - // TODO get if nullable from node result type - if (as && !to[as]) { - to[as] = [] + for (const key of deferredKeys) { + if (key.fkey && key.fkey.subgraph === subgraphName) { + return key.fkey.resolver } - - return - } - if (as) { - if (!to[as]) { - to[as] = [] - } - to = to[as] - } - - for (let i = 0; i < indexes.length; i++) { - // TODO copy object fn? - const fields = Object.keys(result[indexes[i]]) - const r = {} - for (let j = 0; j < fields.length; j++) { - r[fields[j]] = result[indexes[i]][fields[j]] + if (key.parent && key.parent.fkey && key.parent.fkey.subgraph === subgraphName) { + return key.parent.fkey.resolver } - to.push(r) - } -} - -function selectResult (query, partialResult, response) { - let result = response[query.resolverName] - let mergedPartial = partialResult - let mergedPartialParentNode = null - - for (let i = 0; i < query.path.length; ++i) { - const path = query.path[i] - mergedPartialParentNode = mergedPartial - mergedPartialParentNode[path] ??= null - - if (!mergedPartial && !mergedPartial[path]) { break } - - mergedPartial = traverseResult(mergedPartial, path) - } - - if (!query.root) { - if (Array.isArray(mergedPartial) && !Array.isArray(result)) { - result = [result] - } else if (!Array.isArray(mergedPartial) && Array.isArray(result) && result.length === 1) { - result = result[0] + if (key.parent && key.parent.many && key.parent.many.subgraph === subgraphName) { + return key.parent.many.resolver } } - if (Array.isArray(result)) { - result = result.filter(r => !!r) - } - - return { result, mergedPartial, mergedPartialParentNode } + return entity.resolver } -function addFieldToType (type, field, subgraph) { - const { schemaNode: schemaType, fieldMap } = type - const existingField = fieldMap.get(field.name) - - if (existingField) { - if (isDeepStrictEqual(field, existingField.schemaNode)) { - // There is an existing field that is identical to the new field. - existingField.subgraphs.add(subgraph) - return - } - - // There is an existing field that conflicts with the new one. - const msg = `Entity '${schemaType.name}' has conflicting types for ` + - `field '${field.name}'` - - throw new Error(msg) - } - - schemaType.fields.push(field) - fieldMap.set(field.name, { - schemaNode: field, - // TODO use Map on subgraphs - subgraphs: new Set() - }) +// TODO merge more query parts, for example args values +function mergeQueries (currentQuery, query) { + // no worries, fields will be filtered later on building + currentQuery.query.selection = currentQuery.query.selection.concat(query.query.selection) + return currentQuery } function setEntityFKey (fkeys, fkey) { @@ -913,12 +851,9 @@ function setEntityFKey (fkeys, fkey) { fkeys.set(index, fkey) } -function onSubscriptionEnd (ctx, topic) { - // Shut down the upstream connection. - this.subscriptionMap.get(topic)?.() - this.subscriptionMap.delete(topic) +function setEntityMany (manys, many) { + const index = `${many.type}.${many.field || many.pkey}` + manys.set(index, many) } -function onError (error) { throw error } - module.exports = { Composer } diff --git a/lib/entity.js b/lib/entity.js new file mode 100644 index 0000000..00f2885 --- /dev/null +++ b/lib/entity.js @@ -0,0 +1,53 @@ +'use strict' + +// TODO memoize +function entityKeys ({ field, subgraph, entity }) { + const keys = [] + + // entity keys + if (field.type.entity) { + if (field.type.entity.pkey) { + keys.push({ pkey: field.type.entity.pkey }) + } + if (field.type.entity.fkeys && field.type.entity.fkeys.length > 0) { + for (const fkey of field.type.entity.fkeys) { + if (fkey.as) { + keys.push({ fkey: fkey.field, as: fkey.as }) + } + } + } + if (field.type.entity.many && field.type.entity.many.length > 0) { + for (const many of field.type.entity.many) { + if (!entity || (entity === field.type.name)) { + keys.push({ many: many.pkey, as: many.as }) + } + } + } + } + + // parent keys + if (field.parent?.entity) { + if (field.parent?.entity.fkeys.length > 0) { + for (let i = 0; i < field.parent.entity.fkeys.length; i++) { + const key = field.parent.entity.fkeys[i] + if (field.typeName === key.type && (!subgraph || subgraph === key.subgraph)) { + keys.push({ parent: { fkey: key, typeName: field.parent.name }, entity }) + } + } + } + if (field.parent?.entity.many.length > 0) { + for (let i = 0; i < field.parent.entity.many.length; i++) { + const key = field.parent.entity.many[i] + if (field.typeName === key.type && (!subgraph || subgraph === key.subgraph)) { + keys.push({ parent: { many: key, typeName: field.parent.name }, entity }) + } + } + } + } + + return keys +} + +module.exports = { + entityKeys +} diff --git a/lib/fields.js b/lib/fields.js new file mode 100644 index 0000000..4f9383e --- /dev/null +++ b/lib/fields.js @@ -0,0 +1,63 @@ +'use strict' + +const QUERY_TYPE = 'QUERY' +const MUTATION_TYPE = 'MUTATION' + +/** + * merge only entity types + */ +function mergeTypes (t1, t2) { + if (t1.src.kind !== 'OBJECT' || !Array.isArray(t1.src.fields)) { + return t1 + } + t1.src.fields = t1.src.fields.concat(t2.src.fields) + + // TODO t1.fields = t1.fields.concat(t2.fields) + // TODO fields.resolvers + + return t1 +} + +// return Query or Mutation if type is one of them +// TODO Subscription +function getMainType (schema, type) { + if (schema.queryType?.name === type.name) { return QUERY_TYPE } + if (schema.mutationType?.name === type.name) { return MUTATION_TYPE } +} + +function createType ({ name, src, fields, entity }) { + return { + name, + src, + fields: fields ?? new Map(), + entity + } +} + +function createField ({ name, typeName, src, parent, resolver }) { + return { + name, + src, + parent, + typeName, + resolver + } +} + +function createFieldId (typeName, fieldName) { + return typeName && fieldName + ? `${typeName}.${fieldName}` + : '' +} + +module.exports = { + QUERY_TYPE, + MUTATION_TYPE, + + mergeTypes, + getMainType, + createType, + createField, + + createFieldId +} diff --git a/lib/graphql-utils.js b/lib/graphql-utils.js deleted file mode 100644 index 9f5179d..0000000 --- a/lib/graphql-utils.js +++ /dev/null @@ -1,50 +0,0 @@ -'use strict' - -function createEmptyObject (name) { - return { - kind: 'OBJECT', - name, - description: null, - fields: [], - inputFields: null, - interfaces: [], - enumValues: null, - possibleTypes: null - } -} - -function unwrapSchemaType (node) { - while (node.kind === 'NON_NULL' || node.kind === 'LIST') { - node = node.ofType - } - - return node -} - -function valueToArgumentString (node) { - const kind = node.kind - - if (kind === 'ObjectValue') { - const fields = node.fields.map((f) => { - const name = f.name.value - const value = valueToArgumentString(f.value) - - return `${name}: ${value}` - }) - - return `{ ${fields.join(', ')} }` - } else if (kind === 'ListValue') { - const values = node.values.map(v => valueToArgumentString(v)) - return `[ ${values.join(', ')} ]` - } else if (kind === 'StringValue') { - return `"${node.value}"` - } else { - return node.value - } -} - -module.exports = { - createEmptyObject, - unwrapSchemaType, - valueToArgumentString -} diff --git a/lib/network.js b/lib/network.js index 9187d50..67afdaa 100644 --- a/lib/network.js +++ b/lib/network.js @@ -51,16 +51,6 @@ async function fetchSubgraphSchema (server) { return introspection } -async function makeInstanceRequest (query, server) { - const response = await server.instance.inject({ - url: server.graphqlEndpoint, - method: 'POST', - headers: { 'content-type': 'application/json' }, - payload: { query } - }) - return JSON.parse(await response.body) -} - async function makeHttpRequest (query, server) { const graphqlEndpoint = `${server.host}${server.graphqlEndpoint}` @@ -72,14 +62,8 @@ async function makeHttpRequest (query, server) { return await response.body.json() } -// TODO optimize: add request in subgraph, to avoid the if async function makeGraphqlRequest (query, server) { - let r - if (server.host) { - r = await makeHttpRequest(query, server) - } else { - r = await makeInstanceRequest(query, server) - } + const r = await makeHttpRequest(query, server) const { data, errors } = r diff --git a/lib/query-builder.js b/lib/query-builder.js index bffd330..b845db5 100644 --- a/lib/query-builder.js +++ b/lib/query-builder.js @@ -1,452 +1,311 @@ 'use strict' -const { unwrapSchemaType, valueToArgumentString } = require('./graphql-utils') -const { toQueryArgs, keySelection, traverseResult, nodeTypeName, transitivePKey } = require('./utils') - -class QueryBuilder { - constructor (options) { - this.swap = options.swap - this.node = options.node - this.schemaNode = options.schemaNode - this.path = options.path.slice() - this.type = options.type - this.fields = options.fields - - this.subgraph = options.subgraph - this.resolverName = options.resolverName - this.resolverArgsAdapter = options.argsAdapter - this.resolverPartialResults = options.partialResults - - this.root = options.root - this.info = options.info - this.types = options.types - this.entities = options.entities - this.operation = this.info.operation.operation - - this.key = options.key - this.parentKey = options.parentKey // field, as, many - - this.selectedFields = [] - this.args = null - - for (const field of this.type.fieldMap.values()) { - this.fields.push(field) - } - } - - buildQuery (ctx) { - if (this.resolverArgsAdapter) { - let results = this.partialResults(ctx.result) - if (this.resolverPartialResults) { - results = this.runResolverPartialResults(results) - } - this.args = this.runArgsAdapter(results) - } - // TODO refactor the swap logic - // avoid to pass back and forth path between context and folloups - // keep the "result path" synced with "result" since it's needed to merge results and get partial results - // then work with a subpart/branch of ctx.result and then for followups - if (this.swap) { - ctx.path = this.path.slice() - } else { - ctx.path = this.path.slice(0, -1) - } +const { traverseResult } = require('./result') - const selectionSet = this.buildSelectionSet(ctx, this.node, this.schemaNode) - const computedArgs = this.buildArguments(this.node) - const query = `${this.operation} { ${this.resolverName}${computedArgs} ${selectionSet} }` +/** + * A QueryNode is a node on the query-tree resolution + * @typedef {Object} QueryNode + * @property {String} subgraphName + */ - return query +/** + * @returns {QueryNode} + */ +function createQueryNode ({ subgraphName, path, field, fieldId, queryFieldNode, parent, root, query, deferred }) { + if (deferred) { + return { deferred } } - /** - * returns the part of ctx.result, as array if ctx.result is not - * 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 p = this.path[i] - - result = traverseResult(result, p) - } - - if (!Array.isArray(result)) { - return [result] - } - - return result.flat(2).filter(r => !!r) + return { + subgraphName, + path, + field, + fieldId, + queryFieldNode, + parent, + root, + query, + result: undefined, + keys: [] } +} - runResolverPartialResults (results) { - let r - try { - r = this.resolverPartialResults(results) - } catch (err) { - const msg = `Error running partialResults for ${this.resolverName}` - throw new Error(msg, { cause: err }) - } - - // TODO validate results - if (r === null) { - throw new TypeError(`partialResults did not return an object. returned ${r}.`) - } +function createQuery ({ operation = '', resolver, selection, args }) { + return { operation, resolver, selection, args } +} - return r +/** + * @param {QueryNode} queryNode + */ +function createDeferredQuery ({ queryNode, resolverPath, fieldPath }) { + return { + subgraphName: undefined, + queryNode, + resolverPath, + fieldPath, + entity: undefined, + keys: undefined, + fields: [] } +} - runArgsAdapter (results) { - let args - try { - args = this.resolverArgsAdapter(results) - } catch (err) { - const msg = `Error running argsAdapter for ${this.resolverName}` - throw new Error(msg, { cause: err }) - } +function addDeferredQueryField (query, fieldName, queryFieldNode) { + query.fields.push({ fieldName, queryFieldNode }) +} - // TODO(cjihrig): Validate returned args object. - if (args === null || typeof args !== 'object') { - throw new TypeError(`argsAdapter did not return an object. returned ${args}.`) - } +/** + * uniforms any result to an array, filters null row + * @returns array + */ +function toArgsAdapterInput (result, path) { + if (!result) { return [] } - return args + if (!Array.isArray(result)) { + return [result] } - buildNodeArgs (node) { - const length = node.arguments?.length ?? 0 + let r = result.filter(r => !!r) - if (length === 0) { - return '' - } - - const mappedArgs = node.arguments.map((a) => { - const name = a.name.value - let value - - if (a.value.kind === 'Variable') { - const varName = a.value.name.value - const varValue = this.info.variableValues[varName] - // const varDef = this.info.operation.variableDefinitions.find((v) => { - // return v.variable.name.value === varName; - // }); - // TODO(cjihrig): Use varDef to find strings. - const isString = false - - if (typeof varValue === 'object') { - // TODO(cjihrig): Make this recursive and move to its own function. - const kvs = Object.keys(varValue).map((k) => { - let v = varValue[k] - - if (typeof v === 'string') { - v = `"${v}"` - } - - return `${k}: ${v}` - }).join(', ') - - value = `{ ${kvs} }` - } else { - value = isString ? `"${varValue}"` : varValue - } - } else { - value = valueToArgumentString(a.value) - } - - return `${name}: ${value}` - }) - - return `(${mappedArgs.join(', ')})` + if (!path) { + return r.flat() } - buildArguments (node) { - // using this.argsAdapter instead of this.args since could be falsy - if (this.resolverArgsAdapter) { - return toQueryArgs(this.args) + // TODO use a specific fn instead of traverseResult to speed up + // TODO write unit tests + let i = 0 + let start + // path can start in the middel of result + while (i < path.length - 1) { + const t = traverseResult(r, path[i]) + if (t) { + if (!start) { start = true } + r = t + } else { + if (start) { break } } - - return this.buildNodeArgs(node) + i++ } - buildSelectionSet (ctx, node, schemaNode) { - const selections = node.selectionSet?.selections - const length = selections?.length ?? 0 - - if (length === 0) { - return '' - } - - ctx.path.push(node.name.value) + return r.flat() +} - const bareType = unwrapSchemaType(schemaNode.type) - const type = this.types.get(bareType.name) - const { fieldMap } = type - const set = new Set() - let keyFields +function buildQuery (query, parentResult) { + const { selection, keys } = buildQuerySelection(query.selection) - for (let i = 0; i < length; ++i) { - const selection = selections[i] + if (query.resolver?.argsAdapter) { + // TODO try-catch, logs and so on - if (selection.kind === 'FragmentSpread' || selection.kind === 'InlineFragment') { - // TODO(cjihrig): The fragment probably only needs to be expanded if it - // is defined in the router. If it is defined by the subgraph server, it - // should know how to handle it. - const fragment = selection.kind === 'FragmentSpread' - ? this.info.fragments[selection.name.value] - : selection + // TODO filter duplicates in toArgsAdapterInput + let r = toArgsAdapterInput(parentResult.data, parentResult.path) - for (let i = 0; i < fragment.selectionSet.selections.length; ++i) { - const fragNode = fragment.selectionSet.selections[i] - let value = fragNode.name.value + if (query.resolver.partialResults) { + r = query.resolver.partialResults(r) + } - if (fragNode.selectionSet) { - // TODO(cjihrig): Need to make this whole branch equivalent to the else branch. - value += ` ${this.buildSelectionSet(ctx, fragNode, schemaNode)}` - } + query.args = runArgsAdapter(query.resolver.argsAdapter, r, query.resolver.name) + } - set.add(value) - } - } else { - let value = selection.name.value - const field = fieldMap.get(value) - const selectionSchemaNode = field?.schemaNode - - // Some nodes won't have a schema representation. For example, __typename. - if (selectionSchemaNode) { - if (!field.subgraphs.has(this.subgraph)) { - const { index, subgraph, swap } = this.followupIndex(field, ctx.path) - - let followup = ctx.followups.get(index) - if (!followup) { - const parentKeys = this.getKeyFields(type) - - let followupSchema, followupNode - if (swap) { - followupSchema = selectionSchemaNode - followupNode = selection - } else { - followupSchema = schemaNode - followupNode = node - } - - followup = this.createFollowup(ctx, followupSchema, followupNode, this.subgraph, type, field, subgraph, swap) - - keyFields = collectKeys(keyFields, parentKeys) - if (followup) { - ctx.followups.set(index, followup) - } - } else { - // TODO followup can have multiple entities, now it's suppose to have only one - - // TODO use a Set for followup.fields - followup.fields.push(field) - } - - continue - } + return { + query: `${query.operation} { ${query.resolver.name}${buildQueryArgs(query.args)} ${selection} }`, + fieldName: query.resolver.name, + keys + } +} - if (selection.arguments.length > 0) { - value += this.buildArguments(selection) - } +// get keys to reuse on merge results +function buildQuerySelection (selection, parent, wrap = true) { + if (!(selection && selection.length > 0)) { + return { selection: '', keys: [] } + } - if (selection.selectionSet) { - value += ` ${this.buildSelectionSet(ctx, selection, selectionSchemaNode)}` - } + const fields = new Set() + const keys = new Map() + for (let i = 0; i < selection.length; i++) { + if (selection[i].field) { + fields.add(selection[i].field) + } else if (selection[i].key) { + // these keys are from selection[i].deferreds + const k = selection[i].key + const keyField = k.pkey || k.fkey || k.many + + if (!keyField) { continue } + fields.add(toQuerySelection(keyField)) + keys.set('key' + keyId(k), k) + } else if (selection[i].selection) { + fields.add(buildQuerySelection(selection[i].selection, null, selection[i].wrap).selection) + } else if (selection[i].nested) { + fields.add(selection[i].parentField) + for (const nested of selection[i].nested.values()) { + const s = buildSubQuery(nested.query, nested) + fields.add(s.subquery) + for (const i of Object.keys(s.keys)) { + const k = s.keys[i] + keys.set(k.resolverPath + keyId(k), k) } - - set.add(value) } - } - - ctx.path.pop() - - // add entity pkey to selection, needed to match rows merging results - // no followups here - if (!keyFields) { - const entity = this.subgraph.entities[bareType.name] - if (!entity) { - // TODO onError: missing entity definition', typeName, 'in subgraph', this.subgraph.name) - } else { - // TODO keys generator fn - // TODO collect entity keys on composer mergeSchema to avoid collecting them every time - // TODO lookup for entities to resolve, no need to add all the fkeys always - keyFields = { - pkey: entity.pkey, - fkeys: [ - ...(entity.fkeys || []), - ...(entity.many || []) - .map(m => ({ field: m.pkey, as: m.as })) - ] + } else if (selection[i].deferreds) { + // add parent keys for deferred queries, needed to merge results + for (const deferred of selection[i].deferreds.values()) { + // src parent type + // from nested: parent type + const parentTypeName = selection[i].typeName + const dkeys = deferredKeys(deferred.keys, parentTypeName, selection[i].parentFieldName) + for (const dk of dkeys) { + fields.add(dk) + } + for (const i of Object.keys(deferred.keys)) { + const k = deferred.keys[i] + const p = deferred.resolverPath + keyId(k) + if (keys.has(p)) { continue } + + // TODO better code, these keys will be used in composer/mergeResults/parentKey + if (k.parent) { + keys.set(p, { + fkey: k.parent.fkey, + many: k.parent.many, + typeName: k.entity, + parent: k.parent, + + resolverPath: deferred.resolverPath, + fieldPath: deferred.fieldPath + }) + } else { + keys.set(p, { + ...k, + + resolverPath: deferred.resolverPath, + fieldPath: deferred.fieldPath + }) + } } } } - - if (keyFields) { - set.add(keySelection(keyFields.pkey)) - for (let i = 0; i < keyFields.fkeys.length; ++i) { - if (!keyFields.fkeys[i].field) { continue } - const keyFieldName = keySelection(keyFields.fkeys[i].field) - set.add(keyFieldName) - } - } - - // TODO improve structure - const selectedFields = Array.from(set) - this.selectedFields = selectedFields.map(f => f.split(' ')[0]) - - return `{ ${selectedFields.join(', ')} }` } - // TODO memoize by subgraph#entity - getKeyFields (type) { - const typeName = type.schemaNode.name + const qselection = wrap ? `{ ${Array.from(fields).join(' ')} }` : Array.from(fields).join(' ') + return { selection: qselection, keys: Array.from(keys.values()) } +} - const entity = this.subgraph.entities[typeName] +function buildSubQuery (query, parent) { + const s = buildQuerySelection(query.selection, parent) + return { + subquery: `${buildQueryArgs(query.args)} ${s.selection}`, + keys: s.keys + } +} - if (!entity) { - const pkey = transitivePKey(typeName, this.subgraph.entities) - if (!pkey) { - // TODO onError - throw new Error(`Unable to resolve entity ${typeName} in subgraph ${this.subgraph.name}`) - } - return { - pkey, - fkeys: [] - } - } else { - return { - pkey: entity.pkey, - fkeys: [ - ...(entity.fkeys || []), - ...(entity.many || []) - .map(m => ({ field: m.pkey, as: m.as })) - ] - } - } +function keyId (k) { + if (k.pkey) { return `#pkey.${k.pkey}` } + if (k.parent) { + if (k.parent.fkey) { return `#p.fkey.${k.parent.fkey.field}` } + if (k.parent.many) { return `#p.many.${k.parent.many.fkey}` } } - followupSubgraph (field) { - const fieldTypeName = nodeTypeName(field) + if (k.fkey) { return `#fkey.${k.fkey}` } + if (k.many) { return `#many.${k.many}` } +} - const entity = this.entities[fieldTypeName] - const subgraph = Array.from(field.subgraphs)[0] +// TODO unit test +function deferredKeys (keys, typeName, fieldName) { + return keys.map(k => { + if (k.parent) { + if (k.parent.fkey) { + if ((k.parent.fkey.as && typeName === k.parent.typeName)) { + return toQuerySelection(k.parent.fkey.field) + } else if (!k.parent.entity && typeName === k.parent.typeName) { + return toQuerySelection(k.parent.fkey.field || k.parent.fkey.pkey) + } + } - if (!entity) { - return { subgraph } + return '' } - // get the subgraph to resolve the entity - // if the resolver is not set, means the entity must be resolved by another subgraph - // in this way we save a roundtrip to a resolver on the current subgraph to resolve the entity - - // TODO use a Map on subgraph to avoid this loop - const entitySubgraph = entity.subgraphs.find(s => { - return s.name === subgraph.name - }) - - if (entitySubgraph.entities[fieldTypeName]?.resolver) { - return { subgraph, swap: true } + if (k.pkey) { + return toQuerySelection(k.pkey) } - // TODO add field filter condition - const s = entity.subgraphs.find(s => { - return !!s.entities[fieldTypeName].resolver - }) - - return { subgraph: s, swap: true } - } - - createFollowup (ctx, schemaNode, node, parentSubgraph, parentType, field, subgraph, swap) { - const parentTypeName = parentType.schemaNode.name - let fieldTypeName = parentTypeName + return '' + }) +} - const path = ctx.path.slice() +// TODO filter same values +function buildQueryArgs (v, root = true) { + if (v === undefined || v === null) { return '' } - if (swap) { - fieldTypeName = nodeTypeName(field) + if (Array.isArray(v)) { + const args = [] + for (let i = 0; i < v.length; i++) { + const arg = buildQueryArgs(v[i], false) + if (arg === '') { continue } + args.push(arg) } + return `[${args.join(', ')}]` + } - // TODO refactor: followupSubgraph can return both subgraph and entity and probably keys - // TODO handle error if entity or subgraphs don't exists - const parentEntity = parentSubgraph.entities[parentTypeName] - const entity = subgraph.entities[fieldTypeName] - const type = this.types.get(fieldTypeName) - - let manyEntity - let partialResults, parentKey - let argsAdapter = entity.resolver.argsAdapter - let resolverName = entity.resolver.name - let key = entity.pkey - - if (parentEntity?.many) { - manyEntity = parentEntity.many.find(m => m.type === fieldTypeName) - if (manyEntity) { - // TODO createKey fn - // reverse the keys as the many relation is inverse - key = manyEntity.fkey - parentKey = { field: manyEntity.pkey, as: manyEntity.as, many: true } - argsAdapter = manyEntity.resolver.argsAdapter - resolverName = manyEntity.resolver.name - partialResults = manyEntity.resolver.partialResults - } + if (typeof v === 'object') { + const keys = Object.keys(v) + const args = [] + for (let i = 0; i < keys.length; i++) { + const key = keys[i] + const value = buildQueryArgs(v[key], false) + if (value === '') { continue } + args.push(`${key}: ${value}`) } - if (!manyEntity && parentEntity?.fkeys) { - const foreignKey = parentEntity.fkeys.find(f => f.type === fieldTypeName) - if (foreignKey) { - parentKey = foreignKey - if (foreignKey.resolver.partialResults) { - partialResults = foreignKey.resolver.partialResults - } - } + if (root) { + return args?.length > 0 ? `(${args.join(',')})` : '' } + return `{ ${args.join(', ')} }` + } - if (!parentKey) { - parentKey = { field: key } - } + // TODO test: quotes + return typeof v === 'string' ? `"${v}"` : v.toString() +} - return { - swap, - path, - node, - schemaNode, - type, - resolverName, - argsAdapter, - partialResults, - subgraph, - info: this.info, - types: this.types, - entities: this.entities, - key, - parentKey, - root: false, - fields: [field], - solved: false - } - } +// TODO faster code +function toQuerySelection (key) { + return key.split('.').reduce((q, f, i) => { + return q + (i > 0 ? `{${f}}` : f) + }, '') +} - // TODO memoize - followupIndex (field, path) { - const { subgraph, swap } = this.followupSubgraph(field) +// get parent by result +// parent also has result keys +function queryParentResult (query) { + if (query.root) { return } - return { - index: `path:${path.join('/')}#sg:${subgraph.name}`, - subgraph, - swap - } + let parent = query.parent + while (parent) { + if (parent.root || parent.result) { return parent } + parent = parent.parent } + + return undefined } -function collectKeys (keyFields, parentKeys) { - if (!keyFields) { - return parentKeys +function runArgsAdapter (argsAdapter, results, resolverName) { + let args + try { + args = argsAdapter(results) + } catch (err) { + const msg = `Error running argsAdapter for ${resolverName}` + throw new Error(msg, { cause: err }) } - // TODO if parentKeys.pkey != keyFields.pkey throw Error + if (args === null || typeof args !== 'object') { + throw new TypeError(`argsAdapter did not return an object. returned ${args}.`) + } - keyFields.fkeys = keyFields.fkeys.concat(parentKeys.fkeys) - return keyFields + return args } -module.exports = { QueryBuilder } +module.exports = { + createQueryNode, + createQuery, + createDeferredQuery, + addDeferredQueryField, + + queryParentResult, + + buildQuery +} diff --git a/lib/query-lookup.js b/lib/query-lookup.js new file mode 100644 index 0000000..01aacca --- /dev/null +++ b/lib/query-lookup.js @@ -0,0 +1,322 @@ +'use strict' + +const { createFieldId } = require('./fields') +const { createQueryNode, createQuery, createDeferredQuery, addDeferredQueryField } = require('./query-builder') +const { mergeMaps, collectArgs, pathJoin } = require('./utils') + +/** + * !important: the "lookup" functions in there: + * - MUST only gather information to compose queries + * - MUST NOT contain any logic for deferred/entities or so + */ + +/** + * @typedef CollectedQueries + * @property {{Map}} queries - the list of query-nodes, identified by path / the path is the map key + * @property {{Map}} deferred - the list of deferred query-nodes, identified by path / the path is the map key + */ + +/** + * collect queries to subgraph to resolve a query, starting from the resolver + * queries can be on the same subgraph, so ready to be executed (when the parent node is complete and the parent result is used as args) + * or deferred, that means that the query need to be computed to be executed on another subgraph + * as for now, there's no strategy, queries execution is cascade traversing the request query schema vertically + * + * TODO refactor to a class, to avoid passing all the common stuff on recursion + * + * @returns {CollectedQueries} + */ +function collectQueries ({ + subgraphName, queryFieldNode, path = '', fieldId, parent, args, + // references + types, fields, aliases, + // root: collect root queries + root, + // override resolver + resolver, + selection, + // TODO createcontext fn + context +}) { + const queries = new Map() + const deferreds = new Map() + const order = new Set() + + const field = fields[fieldId] + if (!field) { + const resolverName = resolver ? resolver.name : queryFieldNode.name?.value + const queryNode = createQueryNode({ + subgraphName, + path, + field, + fieldId, + queryFieldNode, + parent, + root, + query: createQuery({ + operation: context.info ? context.info.operation.operation : '', + resolver: resolver ?? { name: resolverName }, + selection: [], + args + }) + }) + const querySelection = queryFieldNode + + addDeferredQuery({ deferreds, context, path, queryNode, querySelection, field }) + return { queries, deferreds, order } + } + + const rootScalar = root && field.src.type.kind === 'SCALAR' + const queryFieldSelections = rootScalar + ? [queryFieldNode] + : queryFieldNode.selectionSet?.selections + + if (!queryFieldSelections || queryFieldSelections.length < 1) { + return { queries, deferreds, order } + } + + const resolverName = resolver ? resolver.name : queryFieldNode.name?.value + const cpath = pathJoin(path, queryFieldNode.name?.value) + const fieldQueryPath = queryPath(cpath, subgraphName) + + const queryNode = createQueryNode({ + subgraphName, + path, + field, + fieldId, + queryFieldNode, + // TODO maybe parent and root are redundant + parent, + root, + query: createQuery({ + operation: context.info ? context.info.operation.operation : '', + // TODO createResolver fn + resolver: resolver ?? { name: resolverName }, + selection: [], + args + }) + }) + + // root query for a scalar type is a single query on current subgraph + if (rootScalar) { + order.add(fieldQueryPath) + queries.set(fieldQueryPath, queryNode) + return { queries, deferreds, order } + } + + const fieldTypeName = field?.typeName + // const fieldType = field && types[fieldTypeName] + + for (let i = 0; i < queryFieldSelections.length; ++i) { + const querySelection = queryFieldSelections[i] + + if (querySelection.kind === 'InlineFragment' || querySelection.kind === 'FragmentSpread') { + const fragment = querySelection.kind === 'FragmentSpread' + ? context.info.fragments[querySelection.name.value] + : querySelection + + // TODO if !field - shouldn't happen + const nested = collectNestedQueries({ + context, + fieldId: createFieldId(field.parent.name, field.name), + subgraphName, + path: cpath, + queryNode, + querySelection: fragment, + types, + fields + }) + + // unwrap fragment as selection + for (const n of nested.queries.values()) { + queryNode.query.selection.push({ selection: n.query.selection, wrap: false }) + } + collectDeferredQueries(queryNode, nested, deferreds) + + continue + } + + const selectionFieldName = querySelection.name.value + // querySelection.kind === 'Field' + if (selection && !selection.includes(selectionFieldName)) { + continue + } + + // meta field, for example `__typename` + if (selectionFieldName[0] === '_' && selectionFieldName[1] === '_') { + queryNode.query.selection.push({ field: selectionFieldName }) + continue + } + + // OLD const selectionField = getSelectionField(fieldType, selectionFieldName) + // const selectionField = getSelectionField(fields, fieldType, selectionFieldName) + const fieldId = createFieldId(fieldTypeName, selectionFieldName) + const selectionField = fields[fieldId] + + const nested = querySelection.selectionSet + const deferred = !selectionField + + if (deferred) { + if (context.done.includes(fieldQueryPath)) { continue } + + context.logger.debug(`deferred query for ${cpath} > ${selectionFieldName} on ${subgraphName}`) + + const alias = aliases[fieldId] + if (alias && nested) { + nesting({ + selectionFieldName, + deferreds, + context, + fieldId, + subgraphName, + path: cpath, + queryNode, + querySelection, + types, + fields, + aliases, + alias + }) + continue + } + + addDeferredQuery({ deferreds, context, subgraphName, path, queryNode, querySelection, field, selectionFieldName, types, fields }) + continue + } + + if (nested) { + nesting({ + selectionFieldName, + deferreds, + context, + fieldId, + subgraphName, + path: cpath, + queryNode, + querySelection, + types, + fields, + aliases + }) + continue + } + + // simple field + queryNode.query.selection.push({ field: selectionFieldName }) + } + + if (queryNode.query.selection.length > 0 || root) { + context.done.push(fieldQueryPath) + order.add(fieldQueryPath) + queries.set(fieldQueryPath, queryNode) + } + + return { queries, deferreds, order } +} + +function nesting ({ + selectionFieldName, + deferreds, + context, + fieldId, + subgraphName, + path, + queryNode, + querySelection, + types, + fields, + aliases, + alias +}) { + const nested = collectNestedQueries({ + context, + fieldId, + subgraphName, + path, + queryNode, + querySelection, + types, + fields, + aliases, + alias + }) + + if (nested.queries.size > 0) { + queryNode.query.selection.push({ parentField: querySelection.name.value, nested: nested.queries }) + } + + collectDeferredQueries(queryNode, nested, deferreds, selectionFieldName) +} + +function collectNestedQueries ({ + context, + fieldId, + subgraphName, + path, + queryNode, + querySelection, + types, + fields, + aliases, + alias +}) { + context.logger.debug({ fieldId, path }, 'query lookup, nested') + const args = collectArgs(querySelection.arguments, context.info) + const a = alias ? Object.values(alias)[0] : null + + return collectQueries({ + context, + subgraphName, + queryFieldNode: querySelection, + parent: queryNode, + path, + fieldId, + args, + types, + fields, + aliases, + typeName: a?.type + }) +} + +// TODO refactor to better param struct +function addDeferredQuery ({ deferreds, context, path, queryNode, querySelection, field, selectionFieldName = '' }) { + const fieldName = field?.name ?? queryNode.queryFieldNode.name.value + const queryPath = pathJoin(path, fieldName, selectionFieldName) + + const deferredParentPath = path + '>' + pathJoin(fieldName, selectionFieldName) + context.logger.debug('query lookup, add deferred query to get: ' + deferredParentPath) + // since the selection can't be resolved in the current subgraph, + // gather information to compose and merge the query later + + let deferred = deferreds.get(deferredParentPath) + if (!deferred) { + deferred = createDeferredQuery({ + resolverPath: path, + fieldPath: queryPath, + queryNode + }) + deferreds.set(deferredParentPath, deferred) + } + + addDeferredQueryField(deferred, selectionFieldName || fieldName, querySelection) +} + +function collectDeferredQueries (queryNode, nested, deferreds, selectionFieldName) { + if (nested.deferreds.size < 1) { return } + mergeMaps(deferreds, nested.deferreds) + queryNode.query.selection.push({ + deferreds: nested.deferreds, + typeName: queryNode.field?.typeName, + parentFieldName: selectionFieldName + }) +} + +function queryPath (cpath, subgraphName) { + return cpath + '#' + subgraphName +} + +module.exports = { + collectQueries, + collectNestedQueries +} diff --git a/lib/result.js b/lib/result.js new file mode 100644 index 0000000..3acd8b2 --- /dev/null +++ b/lib/result.js @@ -0,0 +1,205 @@ +'use strict' + +const { copyObjectByKeys } = require('./utils') + +function traverseResult (result, path) { + if (Array.isArray(result)) { + const r = [] + for (let i = 0; i < result.length; i++) { + const p = traverseResult(result[i], path) + + if (p === undefined) return + r[i] = p + } + return r + } + + return result[path] +} + +// important: working with references only, do not copy data +function mergeResult (mainResult, fullPath, queryNode, parentResult) { + const path = fullPath.split('.') + const mergingResult = queryNode.result + + if (path.length === 1 && mainResult[fullPath] === undefined) { + // root + mainResult[fullPath] = mergingResult + return + } + + // traverse result till bottom + let r = mainResult[path[0]] + let i = 1 + while (i < path.length) { + const t = traverseResult(r, path[i]) + if (!t) { break } + r = t + i++ + } + + // fill the missing result path + const fillPath = [] + for (let j = i; j < path.length; j++) { + fillPath.push(path[j]) + } + + if (!r) { + // copy reference + r = mergingResult + return + } + + const many = parentResult.as && parentResult.many + let key, parentKey, index + if (many) { + key = parentResult.many.fkey + parentKey = parentResult.many.pkey + index = resultIndex(mergingResult, key, true) + } else { + key = parentResult.keys.self + parentKey = parentResult.keys.parent + index = resultIndex(mergingResult, key) + } + + if (Array.isArray(r)) { + if (many) { + for (let i = 0; i < r.length; i++) { + copyResultRowList(r[i], mergingResult, index, parentKey, parentResult.path, fillPath) + } + } else { + for (let i = 0; i < r.length; i++) { + copyResultRow(r[i], mergingResult, index, parentKey, parentResult.path, fillPath) + } + } + return + } + + // r is an object + if (many) { + copyResultRowList(r, mergingResult, index, parentKey, parentResult.path, fillPath) + } else { + copyResultRow(r, mergingResult, index, parentKey, parentResult.path, fillPath) + } +} + +// !copyResultRow and copyResultRowList are similar but duplicated for performance reason +function copyResultRow (dst, src, srcIndex, parentKey, keyPath, fillPath) { + let traverseDst = dst + + if (Array.isArray(traverseDst)) { + for (let i = 0; i < traverseDst.length; i++) { + const row = traverseDst[i] + copyResultRow(row, src, srcIndex, parentKey, keyPath, fillPath) + } + return + } + + let fillIndex = 0 + + if (!traverseDst?.[parentKey]) { return } + const rowIndexes = srcIndex.map.get(traverseDst[parentKey]) + if (rowIndexes === undefined) { + // TODO if not nullable set "dst" to an empty object + return {} + } + + for (; fillIndex < fillPath.length; fillIndex++) { + if (!traverseDst[fillPath[fillIndex]]) { + // TODO get result type from types + traverseDst[fillPath[fillIndex]] = {} + } + traverseDst = traverseDst[fillPath[fillIndex]] + } + + for (let i = 0; i < rowIndexes.length; i++) { + copyObjectByKeys(traverseDst, src[rowIndexes[i]]) + } +} + +function copyResultRowList (dst, src, srcIndex, parentKey, keyPath, fillPath) { + let traverseDst = dst + + if (Array.isArray(traverseDst)) { + for (let i = 0; i < traverseDst.length; i++) { + const row = traverseDst[i] + copyResultRowList(row, src, srcIndex, parentKey, keyPath, fillPath) + } + return + } + + let fillIndex = 0 + + if (!traverseDst?.[parentKey]) { return } // TODO !undefined !null + + let rowIndexes = [] + // TODO more performant code + // design a different struct to avoid loops + if (Array.isArray(traverseDst[parentKey])) { + for (let i = 0; i < traverseDst[parentKey].length; i++) { + const indexes = srcIndex.map.get(traverseDst[parentKey][i]) + if (indexes) { rowIndexes = rowIndexes.concat(indexes) } + } + } else { + const indexes = srcIndex.map.get(traverseDst[parentKey]) + if (indexes) { rowIndexes = indexes } + } + + for (; fillIndex < fillPath.length; fillIndex++) { + if (!traverseDst[fillPath[fillIndex]]) { + // TODO get result type from types + if (fillIndex === fillPath.length - 1) { + // TODO more performant code + traverseDst[fillPath[fillIndex]] = [] + for (let i = 0; i < rowIndexes.length; i++) { + traverseDst[fillPath[fillIndex]].push(src[rowIndexes[i]]) + } + return + } + traverseDst[fillPath[fillIndex]] = {} + } + traverseDst = traverseDst[fillPath[fillIndex]] + } + + for (let i = 0; i < rowIndexes.length; i++) { + copyObjectByKeys(traverseDst, src[rowIndexes[i]]) + } +} + +function resultIndex (result, key) { + if (result.length < 1) { + return { list: false, map: new Map() } + } + const list = Array.isArray(result[0][key]) + const index = new Map() + + if (list) { + for (let i = 0; i < result.length; i++) { + for (let j = 0; j < result[i][key].length; j++) { + const s = index.get(result[i][key][j]) + if (s) { + index.set(result[i][key][j], s.concat(i)) + continue + } + index.set(result[i][key][j], [i]) + } + } + } else { + for (let i = 0; i < result.length; i++) { + const s = index.get(result[i][key]) + if (s) { + index.set(result[i][key], s.concat(i)) + continue + } + index.set(result[i][key], [i]) + } + } + + return { list, map: index } +} + +module.exports = { + traverseResult, + mergeResult, + copyResultRow +} diff --git a/lib/utils.js b/lib/utils.js index b247c50..ab7d8e6 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,6 +1,8 @@ 'use strict' -function createDefaultArgsAdapter (entityName, pkey) { +function defaultOnError (error) { throw error } + +function createDefaultArgsAdapter (pkey) { return function argsAdapter (partialResults) { return { [pkey + 's']: partialResults.map(r => r[pkey]) } } @@ -10,79 +12,115 @@ function isObject (obj) { return obj !== null && typeof obj === 'object' } -// TODO filter same values -function toQueryArgs (v, root = true) { - if (v === undefined || v === null) { return '' } +// deep clone with support for function type +function objectDeepClone (object) { + if (object === null || object === undefined) { + return object + } - if (Array.isArray(v)) { - const args = [] - for (let i = 0; i < v.length; i++) { - const arg = toQueryArgs(v[i], false) - if (arg === '') { continue } - args.push(arg) + if (Array.isArray(object)) { + const clone = [] + for (let i = 0; i < object.length; i++) { + clone[i] = objectDeepClone(object[i]) } - return `[${args.join(', ')}]` + return clone } - if (typeof v === 'object') { - const keys = Object.keys(v) - const args = [] + if (typeof object === 'object') { + const clone = {} + const keys = Object.keys(object) for (let i = 0; i < keys.length; i++) { const key = keys[i] - const value = toQueryArgs(v[key], false) - if (value === '') { continue } - args.push(`${key}: ${value}`) + clone[key] = objectDeepClone(object[key]) } - - if (root) { - return args ? `(${args.join(',')})` : '' - } - return `{ ${args.join(', ')} }` + return clone } - // TODO test: quotes - return typeof v === 'string' ? `"${v}"` : v.toString() + // TODO clone Map and Set too? + return object } -function keySelection (path) { - if (path.indexOf('.') === -1) { return path } +// copy values from src to to, recursively without overriding +function copyObjectByKeys (to, src) { + const keys = Object.keys(src) + for (let i = 0; i < keys.length; i++) { + if (typeof to[keys[i]] === 'object') { + copyObjectByKeys(to[keys[i]], src[keys[i]]) + } else { + to[keys[i]] ??= src[keys[i]] + } + } +} - return path.split('.').pop() +function mergeMaps (m1, m2) { + for (const [k, v] of m2) { + m1.set(k, v) + } } -/** - * get pkey from fkeys of entities in the subgraph - * TODO update to support composite pkey - */ -function transitivePKey (type, subgraphEntities) { - for (const entity of Object.values(subgraphEntities)) { - for (const fkey of entity?.fkeys) { - if (fkey.type === type) { - return fkey.pkey - } +function pathJoin (...args) { + let p = '' + for (let i = 0; i < args.length; i++) { + if (i > 0 && args[i] && p) { + p += '.' + args[i] + } else if (args[i]) { + p = args[i] } } + return p } -function traverseResult (result, path) { - if (Array.isArray(result)) { - result = result.map(r => { - const n = traverseResult(r, path) - return n - }) - return result +// -- gql utilities + +function unwrapFieldTypeName (field) { + return field.type.name || field.type.ofType.name || field.type.ofType.ofType.name +} + +function collectArgs (nodeArguments, info) { + if (!nodeArguments || nodeArguments.length < 1) { + return {} } - return result[path] ?? null + const args = {} + for (let i = 0; i < nodeArguments.length; i++) { + const a = nodeArguments[i] + const name = a.name.value + if (a.value.kind !== 'Variable') { + args[name] = a.value.value + continue + } + const varName = a.value.name.value + const varValue = info.variableValues[varName] + if (typeof varValue === 'object') { + // TODO check this + const object = {} + const keys = Object.keys(varValue) + for (let j = 0; j < keys.length; j++) { + object[keys[j]] = varValue[keys[j]] + } + args[name] = object + continue + } + args[name] = varValue + } + return args } -function schemaTypeName (types, entityName, field) { - const t = types.get(entityName).fieldMap.get(field).schemaNode.type +function schemaTypeName (types, subgraphName, entityName, fieldName) { + const t = types[entityName][subgraphName].fields.get(fieldName).src.type const notNull = t.kind === 'NON_NULL' ? '!' : '' return (t.name || t.ofType.name) + notNull } -function nodeTypeName (node) { - return node.schemaNode.type.name || node.schemaNode.type.ofType.name || node.schemaNode.type.ofType.ofType.name -} +module.exports = { + defaultOnError, + createDefaultArgsAdapter, + isObject, + objectDeepClone, + copyObjectByKeys, + mergeMaps, + pathJoin, -module.exports = { createDefaultArgsAdapter, isObject, keySelection, transitivePKey, traverseResult, toQueryArgs, schemaTypeName, nodeTypeName } + collectArgs, + unwrapFieldTypeName, + schemaTypeName +} diff --git a/lib/validation.js b/lib/validation.js index a2dac39..b9b4c2d 100644 --- a/lib/validation.js +++ b/lib/validation.js @@ -1,44 +1,315 @@ 'use strict' -const { isObject } = require('./utils') +const metaline = require('metaline') +const { isObject, defaultOnError, createDefaultArgsAdapter } = require('./utils') +const nullLogger = require('abstract-logging') -function validateArray (value, name) { +const DEFAULT_COMPOSE_ENDPOINT = '/.well-known/graphql-composition' +const DEFAULT_GRAPHQL_ENDPOINT = '/graphql' + +function validateArray (value, name, defaultValue) { + if (!value && defaultValue) { return defaultValue } if (!Array.isArray(value)) { throw new TypeError(`${name} must be an array`) } + + return value } -function validateFunction (value, name) { +function validateFunction (value, name, defaultValue) { + if (!value && defaultValue) { return defaultValue } if (typeof value !== 'function') { throw new TypeError(`${name} must be a function`) } + + return value } function validateObject (value, name) { if (!isObject(value)) { throw new TypeError(`${name} must be an object`) } + + return value } -function validateString (value, name) { +function validateString (value, name, defaultValue) { + if (!value && defaultValue) { return defaultValue } + if (typeof value !== 'string') { throw new TypeError(`${name} must be a string`) } + + return value } -function validateResolver (resolver, name) { - validateString(resolver.name, name + '.name') - if (resolver.argsAdapter && typeof resolver.argsAdapter === 'function') { - validateFunction(resolver.argsAdapter, name + '.argsAdapter') +function validateComposerOptions (options) { + validateObject(options, 'options') + + const validatedOptions = { + logger: options.logger ?? nullLogger, + queryTypeName: options.queryTypeName ?? 'Query', + mutationTypeName: options.mutationTypeName ?? 'Mutation', + addEntitiesResolvers: Boolean(options.addEntitiesResolvers) } - if (resolver.partialResults && typeof resolver.partialResults === 'function') { - validateFunction(resolver.partialResults, name + '.partialResults') + + validateString(validatedOptions.queryTypeName, 'queryTypeName') + validateString(validatedOptions.mutationTypeName, 'mutationTypeName') + validatedOptions.onSubgraphError = validateFunction(options.onSubgraphError, 'onSubgraphError', defaultOnError) + + validatedOptions.defaultArgsAdapter = validateDefaultArgsAdapterOptions(options.defaultArgsAdapter) + + validatedOptions.subgraphs = new Map() + if (options.subgraphs) { + validateArray(options.subgraphs, 'subgraphs') + + for (let i = 0; i < options.subgraphs.length; ++i) { + const subgraph = options.subgraphs[i] + const subgraphName = subgraph?.name || '#' + i + + if (validatedOptions.subgraphs.has(subgraphName)) { + throw new TypeError(`subgraphs name ${subgraphName} is not unique`) + } + + validatedOptions.subgraphs.set(subgraphName, validateSubgraphOptions(options.subgraphs[i], subgraphName, validatedOptions.defaultArgsAdapter)) + } } + + validatedOptions.entities = validateComposerEntities(options.entities, validatedOptions.defaultArgsAdapter) + + return validatedOptions +} + +function validateSubgraphOptions (subgraph, subgraphName, defaultArgsAdapter) { + const optionName = `subgraphs[${subgraphName}]` + validateObject(subgraph, optionName) + + const validatedSubgraph = { + name: subgraphName, + server: validateSubgraphServerOptions(subgraph.server, optionName), + entities: new Map() + } + + if (subgraph.entities) { + const optionEntitiesName = optionName + '.entities' + validateObject(subgraph.entities, optionEntitiesName) + + const entityNames = Object.keys(subgraph.entities) + for (let i = 0; i < entityNames.length; ++i) { + const entityName = entityNames[i] + + validatedSubgraph.entities.set(entityName, validateSubgraphEntityOptions(subgraph.entities[entityName], entityName, optionEntitiesName, subgraphName, defaultArgsAdapter)) + } + } + + return validatedSubgraph +} + +function validateSubgraphServerOptions (subgraphServer, optionName) { + const optionEntityServerName = optionName + '.server' + validateObject(subgraphServer, optionEntityServerName) + + const validatedServer = { + host: validateString(subgraphServer.host, optionEntityServerName + '.host'), + composeEndpoint: validateString(subgraphServer.composeEndpoint, optionEntityServerName + '.composeEndpoint', DEFAULT_COMPOSE_ENDPOINT), + graphqlEndpoint: validateString(subgraphServer.graphqlEndpoint, optionEntityServerName + '.graphqlEndpoint', DEFAULT_GRAPHQL_ENDPOINT) + } + + return validatedServer +} + +function validateSubgraphEntityOptions (entity, entityName, optionEntitiesName, subgraphName, defaultArgsAdapter) { + const optionEntityName = optionEntitiesName + `.${entityName}` + validateObject(entity, optionEntityName) + + const validatedEntity = { + pkey: validateString(entity.pkey, optionEntityName + '.pkey'), + fkeys: [], + many: [] + } + + if (entity.fkeys) { + const optionFKeyName = optionEntityName + '.fkeys' + validateArray(entity.fkeys, optionFKeyName) + for (let k = 0; k < entity.fkeys.length; ++k) { + validatedEntity.fkeys.push(validateSubgraphEntityFKeyOptions(entity.fkeys[k], optionFKeyName + `[${k}]`, subgraphName, defaultArgsAdapter)) + } + } + + if (entity.many) { + const optionManyName = optionEntityName + '.many' + validateArray(entity.many, optionManyName) + for (let k = 0; k < entity.many.length; ++k) { + validatedEntity.many.push(validateSubgraphEntityManyOptions(entity.many[k], optionManyName + `[${k}]`, subgraphName, defaultArgsAdapter)) + } + } + + if (entity.resolver) { + validatedEntity.resolver = validateResolver(entity.resolver, optionEntityName + '.resolver', defaultArgsAdapter ?? createDefaultArgsAdapter(validatedEntity.pkey)) + } + + return validatedEntity +} + +function validateSubgraphEntityFKeyOptions (fkey, optionName, defaultSubgraph, defaultArgsAdapter) { + const validatedFkey = {} + for (const p of ['type']) { + validatedFkey[p] = validateString(fkey[p], optionName + `.${p}`) + } + for (const p of ['field', 'as', 'pkey']) { + if (!fkey[p]) { continue } + validatedFkey[p] = validateString(fkey[p], optionName + `.${p}`) + } + + validatedFkey.subgraph = validateString(fkey.subgraph, optionName + '.subgraph', defaultSubgraph) + if (fkey.resolver) { + validatedFkey.resolver = validateResolver(fkey.resolver, optionName + '.resolver', defaultArgsAdapter ?? createDefaultArgsAdapter(fkey.pkey)) + } + + return validatedFkey +} + +function validateSubgraphEntityManyOptions (many, optionName, defaultSubgraph, defaultArgsAdapter) { + const validatedMany = {} + for (const p of ['type', 'pkey']) { + validatedMany[p] = validateString(many[p], optionName + `.${p}`) + } + for (const p of ['as']) { + if (!many[p]) { continue } + validatedMany[p] = validateString(many[p], optionName + `.${p}`) + } + if (many.fkey) { + validatedMany.fkey = validateString(many.fkey, optionName + '.fkey') + } + + validatedMany.subgraph = validateString(many.subgraph, optionName + '.subgraph', defaultSubgraph) + + validatedMany.resolver = validateResolver(many.resolver, optionName + '.resolver', defaultArgsAdapter ?? createDefaultArgsAdapter(many.pkey)) + + return validatedMany +} + +function validateResolver (resolver, optionName, defaultArgsAdapter) { + const validatedResolver = { + name: validateString(resolver.name, optionName + '.name'), + argsAdapter: undefined + } + + if (!resolver.argsAdapter) { + validatedResolver.argsAdapter = defaultArgsAdapter + } else if (typeof resolver.argsAdapter === 'string') { + validatedResolver.argsAdapter = metaline(resolver.argsAdapter) + } else { + validatedResolver.argsAdapter = validateFunction(resolver.argsAdapter, optionName + '.argsAdapter') + } + + if (resolver.partialResults) { + if (typeof resolver.partialResults === 'string') { + validatedResolver.partialResults = metaline(resolver.partialResults) + } else { + validatedResolver.partialResults = validateFunction(resolver.partialResults, optionName + '.partialResults') + } + } + + return validatedResolver +} + +function validateDefaultArgsAdapterOptions (defaultArgsAdapter) { + if (!defaultArgsAdapter) { + return + } + if (typeof defaultArgsAdapter === 'string') { + return metaline(defaultArgsAdapter) + } else { + validateFunction(defaultArgsAdapter, 'defaultArgsAdapter') + return defaultArgsAdapter + } +} + +function validateComposerEntities (entities, defaultArgsAdapter) { + if (!entities) { return } + + const optionEntitiesName = 'entities' + validateObject(entities, optionEntitiesName) + const validatedEntities = {} + + const entityNames = Object.keys(entities) + for (let i = 0; i < entityNames.length; ++i) { + const entityName = entityNames[i] + + validatedEntities[entityName] = validateComposerEntityOptions(entities[entityName], entityName, optionEntitiesName, defaultArgsAdapter) + } + + return validatedEntities +} + +function validateComposerEntityOptions (entity, entityName, optionEntitiesName, defaultArgsAdapter) { + const optionEntityName = optionEntitiesName + `.${entityName}` + validateObject(entity, optionEntityName) + + const validatedEntity = { + // TODO subgraph must be in subgraphs + subgraph: validateString(entity.subgraph, optionEntityName + '.subgraph'), + resolver: undefined, + pkey: validateString(entity.pkey, optionEntityName + '.pkey'), + fkeys: [], + many: [] + } + + if (entity.fkeys) { + const optionFKeyName = optionEntityName + '.fkeys' + validateArray(entity.fkeys, optionFKeyName) + for (let k = 0; k < entity.fkeys.length; ++k) { + validatedEntity.fkeys.push(validateComposerEntityFKeyOptions(entity.fkeys[k], optionFKeyName + `[${k}]`, defaultArgsAdapter)) + } + } + + if (entity.many) { + const optionManyName = optionEntityName + '.many' + validateArray(entity.many, optionManyName) + for (let k = 0; k < entity.many.length; ++k) { + validatedEntity.many.push(validateComposerEntityManyOptions(entity.many[k], optionManyName + `[${k}]`, defaultArgsAdapter)) + } + } + + validatedEntity.resolver = validateResolver(entity.resolver, optionEntityName + '.resolver', defaultArgsAdapter ?? createDefaultArgsAdapter(validatedEntity.pkey)) + + return validatedEntity +} + +function validateComposerEntityFKeyOptions (fkey, optionName, defaultArgsAdapter) { + const validatedFkey = {} + for (const p of ['type']) { + validatedFkey[p] = validateString(fkey[p], optionName + `.${p}`) + } + for (const p of ['field', 'as', 'pkey']) { + if (!fkey[p]) { continue } + validatedFkey[p] = validateString(fkey[p], optionName + `.${p}`) + } + + validatedFkey.subgraph = validateString(fkey.subgraph, optionName + '.subgraph') + validatedFkey.resolver = validateResolver(fkey.resolver, optionName + '.resolver', defaultArgsAdapter ?? createDefaultArgsAdapter(fkey.pkey)) + + return validatedFkey +} + +function validateComposerEntityManyOptions (many, optionName, defaultArgsAdapter) { + const validatedMany = {} + for (const p of ['type', 'pkey']) { + validatedMany[p] = validateString(many[p], optionName + `.${p}`) + } + for (const p of ['as']) { + if (!many[p]) { continue } + validatedMany[p] = validateString(many[p], optionName + `.${p}`) + } + validatedMany.fkey = validateString(many.fkey, optionName + '.fkey') + + validatedMany.subgraph = validateString(many.subgraph, optionName + '.subgraph') + + validatedMany.resolver = validateResolver(many.resolver, optionName + '.resolver', defaultArgsAdapter ?? createDefaultArgsAdapter(many.pkey)) + + return validatedMany } module.exports = { - validateArray, - validateFunction, - validateObject, - validateString, - validateResolver + validateComposerOptions } diff --git a/misc/visitor.js b/misc/visitor.js deleted file mode 100644 index aa36105..0000000 --- a/misc/visitor.js +++ /dev/null @@ -1,71 +0,0 @@ -'use strict' -function nop () {} - -const visitorDefaults = { - argument: nop, - directive: nop, - field: nop, - mutationType: nop, - queryType: nop, - schema: nop, - subscriptionType: nop, - type: nop -} - -function walkSchema (schema, visitor) { - const v = { ...visitorDefaults, ...visitor } - - visitSchema(schema, v) -} - -function visitSchema (node, visitor) { - const { __schema: s } = node - - visitor.schema(node) - - visitor.queryType(s.queryType) - - if (s.mutationType) { - visitor.mutationType(s.mutationType) - } - - if (s.subscriptionType) { - visitor.subscriptionType(s.subscriptionType) - } - - for (let i = 0; i < s.types.length; ++i) { - visitType(s.types[i], visitor) - } - - for (let i = 0; i < s.directives.length; ++i) { - visitor.directive(s.directives[i]) - } -} - -function visitArgument (node, visitor) { - visitor.argument(node) - visitType(node.type, visitor) -} - -function visitField (node, visitor) { - visitor.field(node) - visitType(node.type, visitor) - - if (Array.isArray(node.args)) { - for (let i = 0; i < node.args.length; ++i) { - visitArgument(node, visitor) - } - } -} - -function visitType (node, visitor) { - visitor.type(node) - - if (Array.isArray(node.fields)) { - for (let i = 0; i < node.fields.length; ++i) { - visitField(node.fields[i], visitor) - } - } -} - -module.exports = { walkSchema } diff --git a/package.json b/package.json index 9b21a43..3204389 100644 --- a/package.json +++ b/package.json @@ -4,23 +4,23 @@ "description": "GraphQL API Composer", "license": "Apache-2.0", "main": "lib/index.js", - "author": "Colin J. Ihrig (http://www.cjihrig.com/)", "scripts": { - "test": "npm run lint && c8 node --test test/runner.js", + "test": "npm run lint && borp --coverage", "lint": "standard | snazzy", "lint:fix": "standard --fix" }, "dependencies": { - "@mercuriusjs/subscription-client": "^1.0.0", - "fastify": "^4.24.3", + "abstract-logger": "^0.2.5", + "fastify": "^4.26.1", "graphql": "^16.8.1", - "mercurius": "^13.2.2", + "mercurius": "^13.3.3", "metaline": "^1.1.0", - "undici": "^6.0.0" + "pino": "^8.19.0", + "undici": "^6.6.2" }, "devDependencies": { - "c8": "^9.0.0", - "glob": "^10.3.10", + "borp": "^0.9.1", + "dedent": "^1.5.1", "snazzy": "^9.0.0", "standard": "^17.1.0" } diff --git a/test/adapters.test.js b/test/adapters.test.js index 8e6d040..b7aa854 100644 --- a/test/adapters.test.js +++ b/test/adapters.test.js @@ -2,398 +2,396 @@ const assert = require('node:assert/strict') const { test } = require('node:test') -const { buildComposer, graphqlRequest } = require('./helper') +const path = require('node:path') -test('should use metaline for adapters', async t => { - const composerOptions = { - addEntitiesResolvers: true, - 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' - } - } - ] - } - } - } - } - - const { service } = await buildComposer(t, ['artists-subgraph', 'movies-subgraph', 'cinemas-subgraph', 'songs-subgraph'], composerOptions) - - 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' } }] } - }, +const { compose } = require('../lib') +const { graphqlRequest, createComposerService, createGraphqlServices } = require('./helper') - { - 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' }] }] } - }, +test('throws if argsAdapter function does not return an object', async (t) => { + const query = 'query { getReviewBook(id: 1) { title } }' + const services = await createGraphqlServices(t, [ { - 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' } }] - } + name: 'books-subgraph', + file: path.join(__dirname, 'fixtures/books.js'), + listen: true }, - - // 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: [] } - ] - } - }, + name: 'reviews-subgraph', + file: path.join(__dirname, 'fixtures/reviews.js'), + listen: true + } + ]) + const options = { + subgraphs: services.map(service => ({ + entities: service.config.entities, + name: service.name, + server: { host: service.host } + })) + } - // 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' } }] }] } - }, + options.subgraphs[0].entities.Book.resolver.argsAdapter = () => 'nope' - // 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' }] } }] }] } - }, + const { service } = await createComposerService(t, { compose, options }) - // 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' }] }] } - }, + await assert.rejects(async () => { + await graphqlRequest(service, 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 + }) +}) - // 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' }] }] } - }, +test('throws if argsAdapter function throws an error', async (t) => { + const query = 'query { getReviewBook(id: 1) { title } }' + const services = await createGraphqlServices(t, [ { - 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' }] - }] - }] - }] - } + name: 'books-subgraph', + file: path.join(__dirname, 'fixtures/books.js'), + listen: true }, - { - 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: [] - }] - } + name: 'reviews-subgraph', + file: path.join(__dirname, 'fixtures/reviews.js'), + listen: true } - ] + ]) + const options = { + subgraphs: services.map(service => ({ + entities: service.config.entities, + name: service.name, + server: { host: service.host } + })) + } - for (const request of requests) { - const response = await graphqlRequest(service, request.query, request.variables) + options.subgraphs[0].entities.Book.resolver.argsAdapter = () => { throw new Error('boom') } - 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) - ) - } + const { service } = await createComposerService(t, { compose, options }) + + await assert.rejects(async () => { + await graphqlRequest(service, 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 + }) }) -test('should use default args adapter on many', async t => { +test('use metaline on adapter functions', async () => { const composerOptions = { addEntitiesResolvers: true, - defaultArgsAdapter: 'where.directorId.in.$', + defaultArgsAdapter: 'where.id.in.$>#id', entities: { - 'artists-subgraph': { - Artist: { - resolver: { - name: 'artists', - argsAdapter: 'where.id.in.$>#id' + Artist: { + subgraph: 'artists-subgraph', + 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' + } }, - 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', - argsAdapter: 'where.id.in.$>#id' - }, - pkey: 'id', - fkeys: [ - { - type: 'Artist', - as: 'director', - field: 'directorId', - pkey: 'id', - resolver: { - name: 'movies', - argsAdapter: 'where.directorId.in.$>#id', - partialResults: '$>id.#directorId' - } + Movie: { + subgraph: 'movies-subgraph', + resolver: { name: 'movies' }, + pkey: 'id', + fkeys: [ + { + type: 'Artist', + as: 'director', + field: 'directorId', + pkey: 'id', + subgraph: 'artists-subgraph', + resolver: { + name: 'artists', + argsAdapter: 'where.id.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' + } + } + ] + }, + Song: { + subgraph: 'songs-subgraph', + resolver: { name: 'songs' }, + pkey: 'id', + fkeys: [ + { + type: 'Artist', + as: 'singer', + field: 'singerId', + pkey: 'id', + subgraph: 'artists-subgraph', + resolver: { + name: 'artists', + argsAdapter: 'where.id.in.$>#id', + partialResults: '$>id.#singerId' + } + } + ] + }, + Cinema: { + subgraph: 'cinemas-subgraph', + 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' + } + } + ] } } } - const { service } = await buildComposer(t, ['artists-subgraph', 'movies-subgraph', 'cinemas-subgraph', 'songs-subgraph'], composerOptions) - - await service.listen() - - const query = '{ movies (where: { id: { in: ["10","11","12"] } }) { title, director { lastName } } }' - const expected = { movies: [{ title: 'Interstellar', director: { lastName: 'Nolan' } }, { title: 'Oppenheimer', director: { lastName: 'Nolan' } }, { title: 'La vita é bella', director: { lastName: 'Benigni' } }] } + await test('should generate entities resolvers for composer on top for multiple subgraphs', async t => { + const services = await createGraphqlServices(t, [ + { + name: 'artists-subgraph', + file: path.join(__dirname, 'fixtures/artists.js'), + listen: true + }, + { + name: 'movies-subgraph', + file: path.join(__dirname, 'fixtures/movies.js'), + listen: true + }, + { + name: 'songs-subgraph', + file: path.join(__dirname, 'fixtures/songs.js'), + listen: true + }, + { + name: 'cinemas-subgraph', + file: path.join(__dirname, 'fixtures/cinemas.js'), + listen: true + } + ]) - const response = await graphqlRequest(service, query) + const options = { + ...composerOptions, + subgraphs: services.map(service => ( + { + name: service.name, + server: { host: service.host } + } + )) + } - assert.deepStrictEqual(response, expected, 'should get expected result from composer service,' + - '\nquery: ' + query + - '\nexpected' + JSON.stringify(expected, null, 2) + - '\nresponse' + JSON.stringify(response, null, 2) - ) -}) + const { service } = await createComposerService(t, { compose, options }) -test('should use default args adapter on fkeys', async t => { - const composerOptions = { - addEntitiesResolvers: true, - defaultArgsAdapter: 'where.directorId.in.$>#id', - entities: { - 'artists-subgraph': { - Artist: { - resolver: { - name: 'artists', - argsAdapter: 'where.id.in.$>#id' - }, - pkey: 'id', - many: [ + const requests = [ + { + name: 'should query subgraphs entities / fkey #1', + 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' } }] } + }, + { + name: 'should query subgraphs entities / fkey #2', + 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' } }] + } + }, + { + name: 'should query subgraphs entities (many) on the same query', + 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: [] } + ] + } + }, + { + name: 'should query subgraphs nested entities (many)', + query: '{ artists (where: { id: { in: ["103", "101"] } }) { lastName movies { title cinemas { name } } } }', + expected: { + artists: [ { - type: 'Movie', - as: 'movies', - pkey: 'id', - fkey: 'directorId', - subgraph: 'movies-subgraph', - resolver: { - name: 'movies', - argsAdapter: 'where.directorId.in.$', - partialResults: '$>#id' - } + lastName: 'Nolan', + movies: [ + { title: 'Interstellar', cinemas: [{ name: 'Odeon' }, { name: 'Main Theatre' }] }, + { title: 'Oppenheimer', cinemas: [] }] + }, + { + lastName: 'Molko', + movies: [] + }] + } + }, + { + name: 'should query subgraphs nested entities (many and fkey)', + 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' }] } }] }] } + }, + { + name: 'should query subgraphs entities / many #1', + 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' }] }] } + }, + { + name: 'should query subgraphs entities / many #2', + 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' }] }] } + }, + { + name: 'should query subgraphs entities / many #3', + 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' } }] }] } + }, + { + name: 'should query subgraphs entities / many #4', + query: '{ cinemas (where: { id: { in: ["90", "91", "92"] } }) { name movies { title } } }', + expected: { + cinemas: [ + { + name: 'Odeon', + movies: [ + { title: 'Interstellar' }, + { title: 'La vita é bella' } + ] + }, + { + name: 'Film Forum', + movies: null + }, + { + name: 'Main Theatre', + movies: [ + { title: 'La vita é bella' }, + { title: 'Interstellar' } + ] } ] } }, - 'movies-subgraph': { - Movie: { - resolver: { - name: 'movies', - argsAdapter: 'where.id.in.$>#id' + { + name: 'should query subgraphs entities / nested many #1', + 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: [] + }] }, - pkey: 'id', - fkeys: [ + { + 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' }] + }] + }] + }] + } + }, + { + name: 'should query subgraphs entities / nested many #2', + 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' } } + ] + }, { - type: 'Artist', - as: 'director', - field: 'directorId', - pkey: 'id', - resolver: { - name: 'movies', - // argsAdapter: 'where.directorId.in.$>#id', - partialResults: '$>id.#directorId' - } - } - ] + name: 'Main Theatre', + movies: [ + { title: 'La vita é bella', director: { lastName: 'Benigni' } }, + { title: 'Interstellar', director: { lastName: 'Nolan' } } + ] + }] + }, + { + cinemas: [] + }] } - } - } - } - - const { service } = await buildComposer(t, ['artists-subgraph', 'movies-subgraph', 'cinemas-subgraph', 'songs-subgraph'], composerOptions) - - await service.listen() + }] - const query = '{ movies (where: { id: { in: ["10","11","12"] } }) { title, director { lastName } } }' - const expected = { movies: [{ title: 'Interstellar', director: { lastName: 'Nolan' } }, { title: 'Oppenheimer', director: { lastName: 'Nolan' } }, { title: 'La vita é bella', director: { lastName: 'Benigni' } }] } + for (const c of requests) { + if (!c) { continue } + await t.test(c.name, async (t) => { + const result = await graphqlRequest(service, c.query, c.variables) - const response = await graphqlRequest(service, query) - - assert.deepStrictEqual(response, expected, 'should get expected result from composer service,' + - '\nquery: ' + query + - '\nexpected' + JSON.stringify(expected, null, 2) + - '\nresponse' + JSON.stringify(response, null, 2) - ) + assert.deepStrictEqual(result, c.expected, 'should get expected result from composer service,' + + '\nquery: ' + c.query + + '\nexpected' + JSON.stringify(c.expected, null, 2) + + '\nresponse' + JSON.stringify(result, null, 2) + ) + }) + } + }) }) diff --git a/test/composer.test.js b/test/composer.test.js index 20b4b5f..570bfdb 100644 --- a/test/composer.test.js +++ b/test/composer.test.js @@ -1,373 +1,292 @@ 'use strict' const assert = require('node:assert') +const path = require('node:path') const { test } = require('node:test') +const dedent = require('dedent') +const { createGraphqlServices } = require('./helper') +const { Composer } = require('../lib/composer') +const { compose } = require('../') -const { compose } = require('../lib') -const { startGraphqlService } = require('./helper') - -test('should get sdl from composer', async (t) => { - const services = [ - { - schema: ` - type Query { +test.describe('merge schemas', () => { + test('should get sdl from composer', async (t) => { + const schemas = [ + { + schema: `type Query { + add(x: Int, y: Int): Int + }`, + resolvers: { Query: { add: (_, { x, y }) => x + y } } + }, + { + schema: `type Query { + mul(a: Int, b: Int): Int + }`, + resolvers: { Query: { mul: (_, { a, b }) => a * b } } + }, + { + schema: `type Query { + sub(x: Int, y: Int): Int + }`, + resolvers: { Query: { sub: (_, { x, y }) => x - y } } + }] + const expectedSdl = dedent`type Query { add(x: Int, y: Int): Int - }`, - resolvers: { Query: { add: (_, { x, y }) => x + y } } - }, - { - schema: ` - type Query { mul(a: Int, b: Int): Int - }`, - resolvers: { Query: { mul: (_, { a, b }) => a * b } } - }, - { - schema: ` - type Query { sub(x: Int, y: Int): Int - }`, - resolvers: { Query: { sub: (_, { x, y }) => x - y } } - }] - const expectedSdl = 'type Query {\n' + - ' add(x: Int, y: Int): Int\n' + - ' mul(a: Int, b: Int): Int\n' + - ' sub(x: Int, y: Int): Int\n' + - '}' - - for (const service of services) { - const instance = await startGraphqlService(t, { - mercurius: { - schema: service.schema, - resolvers: service.resolvers - }, - exposeIntrospection: { - path: '/get-introspection' - } - }) - service.host = await instance.listen() - } + }` - const composer = await compose({ - subgraphs: services.map(service => ( - { - server: { - host: service.host, - composeEndpoint: '/get-introspection', - graphqlEndpoint: '/graphql' - } - } - )) - }) + const services = await createGraphqlServices(t, + schemas.map(s => ({ + mercurius: { schema: s.schema, resolvers: s.resolvers }, + listen: true + }))) - await startGraphqlService(t, { - schema: composer.toSdl(), - resolvers: composer.resolvers + const options = { + subgraphs: services.map(service => ({ server: { host: service.host } })) + } + + const composer = new Composer(options) + await composer.compose() + + assert.strictEqual(composer.toSdl(), expectedSdl) }) - assert.strictEqual(expectedSdl, composer.toSdl()) -}) + test('should handle partial subgraphs', async (t) => { + const schemas = [ + { + schema: 'type Query { add(x: Int, y: Int): Int }', + resolvers: { Query: { add: (_, { x, y }) => x + y } } + }, + { + schema: 'type Query { mul(a: Int, b: Int): Int }', + resolvers: { Query: { mul: (_, { a, b }) => a * b } } + }, + { + schema: 'type Query { sub(x: Int, y: Int): Int }', + resolvers: { Query: { sub: (_, { x, y }) => x - y } } + }] + const expectedSdl1 = dedent`type Query { + add(x: Int, y: Int): Int + mul(a: Int, b: Int): Int + sub(x: Int, y: Int): Int + }` + const expectedSdl2 = dedent`type Query { + mul(a: Int, b: Int): Int + sub(x: Int, y: Int): Int + }` + + const services = await createGraphqlServices(t, + schemas.map(s => ({ + mercurius: { schema: s.schema, resolvers: s.resolvers }, + listen: true + }))) + + const subgraphs = services.map(service => ({ server: { host: service.host } })) -test('should handle partial subgraphs', async (t) => { - const services = [ - { - schema: 'type Query { add(x: Int, y: Int): Int }', - resolvers: { Query: { add: (_, { x, y }) => x + y } } - }, { - schema: 'type Query { mul(a: Int, b: Int): Int }', - resolvers: { Query: { mul: (_, { a, b }) => a * b } } - }, + let errors = 0 + const composer = await compose({ + onSubgraphError: () => { errors++ }, + subgraphs + }) + assert.strictEqual(errors, 0) + assert.strictEqual(expectedSdl1, composer.toSdl()) + } + + await services[0].service.close() + { - schema: 'type Query { sub(x: Int, y: Int): Int }', - resolvers: { Query: { sub: (_, { x, y }) => x - y } } - }] - - for (const service of services) { - service.instance = await startGraphqlService(t, { - mercurius: { - schema: service.schema, - resolvers: service.resolvers - }, - exposeIntrospection: { - path: '/get-introspection' - } - }) - service.host = await service.instance.listen() - } - const expectedSdl1 = 'type Query {\n' + - ' add(x: Int, y: Int): Int\n' + - ' mul(a: Int, b: Int): Int\n' + - ' sub(x: Int, y: Int): Int\n' + - '}' - const expectedSdl2 = 'type Query {\n' + - ' mul(a: Int, b: Int): Int\n' + - ' sub(x: Int, y: Int): Int\n' + - '}' - - { - let errors = 0 - const composer = await compose({ - onSubgraphError: () => { errors++ }, - subgraphs: services.map(service => ( - { - server: { - host: service.host, - composeEndpoint: '/get-introspection', - graphqlEndpoint: '/graphql' - } - } - )) - }) - assert.strictEqual(errors, 0) - assert.strictEqual(expectedSdl1, composer.toSdl()) - } + let errors = 0 + const composer = await compose({ + onSubgraphError: (error) => { + assert.strictEqual(error.message, `Could not process schema for subgraph '#0' from '${services[0].host}'`) + assert.match(error.cause.message, /connect ECONNREFUSED/) + errors++ + }, + subgraphs + }) - await services[0].instance.close() + assert.strictEqual(errors, 1) + assert.strictEqual(expectedSdl2, composer.toSdl()) + } + }) - { + test('should handle all the unreachable subgraphs', async (t) => { let errors = 0 + const composer = await compose({ onSubgraphError: (error) => { - assert.strictEqual(error.message, `Could not process schema from '${services[0].host}'`) - assert.match(error.cause.message, /connect ECONNREFUSED/) + assert.strictEqual(error.message, "Could not process schema for subgraph '#0' from 'http://unreachable.local'") + assert.match(error.cause.message, /getaddrinfo (ENOTFOUND|EAI_AGAIN) unreachable.local/) errors++ }, - subgraphs: services.map(service => ( - { - server: { - host: service.host, - composeEndpoint: '/get-introspection', - graphqlEndpoint: '/graphql' - } - } - )) + subgraphs: [ + { server: { host: 'http://unreachable.local' } } + ] }) - assert.strictEqual(errors, 1) - assert.strictEqual(expectedSdl2, composer.toSdl()) - } -}) -test('should handle all the unreachable subgraphs', async (t) => { - let errors = 0 - - const composer = await compose({ - onSubgraphError: (error) => { - assert.strictEqual(error.message, "Could not process schema from 'http://unreachable.local'") - assert.match(error.cause.message, /getaddrinfo (ENOTFOUND|EAI_AGAIN) unreachable.local/) - errors++ - }, - subgraphs: [ - { - server: { - host: 'http://unreachable.local' - } - } - ] + assert.strictEqual(errors, 1) + assert.strictEqual(composer.toSdl(), '') + assert.deepStrictEqual(composer.getResolvers(), {}) }) - assert.strictEqual(errors, 1) - assert.strictEqual('', composer.toSdl()) - assert.deepStrictEqual(Object.create(null), composer.resolvers) -}) - -test('should fire onSubgraphError retrieving subgraphs from unreachable services', async (t) => { - const services = [ - { - off: true, - schema: 'type Query { add(x: Int, y: Int): Int }', - resolvers: { Query: { add: (_, { x, y }) => x + y } } - }, - { - off: true, - schema: 'type Query { mul(a: Int, b: Int): Int }', - resolvers: { Query: { mul: (_, { a, b }) => a * b } } - }, - { - schema: 'type Query { sub(x: Int, y: Int): Int }', - resolvers: { Query: { sub: (_, { x, y }) => x - y } } - }] - const expectedSdl = 'type Query {\n' + - ' sub(x: Int, y: Int): Int\n' + - '}' - - for (const service of services) { - if (service.off) { - service.host = 'http://unreachable.local' - continue - } - service.instance = await startGraphqlService(t, { - mercurius: { - schema: service.schema, - resolvers: service.resolvers - } - }) - service.host = await service.instance.listen() - } - - let errors = 0 - const composer = await compose({ - onSubgraphError: () => { errors++ }, - subgraphs: services.map(service => ( + test('should fire onSubgraphError retrieving subgraphs from unreachable services', async (t) => { + const schemas = [ { - server: { - host: service.host - } - } - )) - }) + host: 'http://unreachable1.local', + schema: 'type Query { add(x: Int, y: Int): Int }', + resolvers: { Query: { add: (_, { x, y }) => x + y } } + }, + { + host: 'http://unreachable2.local', + schema: 'type Query { mul(a: Int, b: Int): Int }', + resolvers: { Query: { mul: (_, { a, b }) => a * b } } + }, + { + schema: 'type Query { sub(x: Int, y: Int): Int }', + resolvers: { Query: { sub: (_, { x, y }) => x - y } } + }] + const expectedSdl = dedent`type Query { + sub(x: Int, y: Int): Int + }` - assert.strictEqual(errors, 2) - assert.strictEqual(expectedSdl, composer.toSdl()) -}) + const services = await createGraphqlServices(t, + schemas.map(s => ({ + host: s.host, + mercurius: { schema: s.schema, resolvers: s.resolvers }, + listen: true + }))) -test('should handle partial subgraphs', async (t) => { - const services = [ - { - schema: 'type Query { add(x: Int, y: Int): Int }', - resolvers: { Query: { add: (_, { x, y }) => x + y } } - }, - { - schema: 'type Query { mul(a: Int, b: Int): Int }', - resolvers: { Query: { mul: (_, { a, b }) => a * b } } - }, - { - schema: 'type Query { sub(x: Int, y: Int): Int }', - resolvers: { Query: { sub: (_, { x, y }) => x - y } } - }] - - for (const service of services) { - service.instance = await startGraphqlService(t, { - mercurius: { - schema: service.schema, - resolvers: service.resolvers - }, - exposeIntrospection: { - path: '/get-introspection' - } - }) - service.host = await service.instance.listen() - } - const expectedSdl1 = 'type Query {\n' + - ' add(x: Int, y: Int): Int\n' + - ' mul(a: Int, b: Int): Int\n' + - ' sub(x: Int, y: Int): Int\n' + - '}' - const expectedSdl2 = 'type Query {\n' + - ' mul(a: Int, b: Int): Int\n' + - ' sub(x: Int, y: Int): Int\n' + - '}' - - { let errors = 0 const composer = await compose({ onSubgraphError: () => { errors++ }, subgraphs: services.map(service => ( - { - server: { - host: service.host, - composeEndpoint: '/get-introspection', - graphqlEndpoint: '/graphql' - } - } + { server: { host: service.config.host ?? service.host } } )) }) - assert.strictEqual(errors, 0) - assert.strictEqual(expectedSdl1, composer.toSdl()) - } - await services[0].instance.close() + assert.strictEqual(errors, 2) + assert.strictEqual(expectedSdl, composer.toSdl()) + }) - { + test('should handle all the unreachable subgraphs', async (t) => { let errors = 0 + const composer = await compose({ onSubgraphError: (error) => { - assert.strictEqual(error.message, `Could not process schema from '${services[0].host}'`) + assert.strictEqual(error.message, "Could not process schema for subgraph '#0' from 'http://unreachable.local'") errors++ }, + subgraphs: [ + { server: { host: 'http://unreachable.local' } } + ] + }) + + assert.strictEqual(errors, 1) + assert.strictEqual(composer.toSdl(), '') + assert.deepStrictEqual(composer.getResolvers(), {}) + }) + + test('should compose a single subgraph without entities', async t => { + const expectedSdl = dedent`input WhereConditionIn { + in: [ID!]! + } + + input ArtistsWhereCondition { + id: WhereConditionIn + } + + type Artist { + id: ID + firstName: String + lastName: String + profession: String + } + + type Query { + artists(where: ArtistsWhereCondition): [Artist] + }` + + const services = await createGraphqlServices(t, [{ + name: 'artists-subgraph', + file: path.join(__dirname, 'fixtures/artists.js'), + listen: true + }]) + + const options = { subgraphs: services.map(service => ( { - server: { - host: service.host, - composeEndpoint: '/get-introspection', - graphqlEndpoint: '/graphql' - } + name: service.name, + server: { host: service.host } } )) - }) - assert.strictEqual(errors, 1) - assert.strictEqual(expectedSdl2, composer.toSdl()) - } -}) + } -test('should handle all the unreachable subgraphs', async (t) => { - let errors = 0 + const composer = new Composer(options) + await composer.compose() - const composer = await compose({ - onSubgraphError: (error) => { - assert.strictEqual(error.message, "Could not process schema from 'http://unreachable.local'") - errors++ - }, - subgraphs: [ - { - server: { - host: 'http://unreachable.local' - } - } - ] + assert.strictEqual(composer.toSdl(), expectedSdl) }) - assert.strictEqual(errors, 1) - assert.strictEqual('', composer.toSdl()) - assert.deepStrictEqual(Object.create(null), composer.resolvers) -}) - -test('should fire onSubgraphError retrieving subgraphs from unreachable services', async (t) => { - const services = [ - { - off: true, - schema: 'type Query { add(x: Int, y: Int): Int }', - resolvers: { Query: { add: (_, { x, y }) => x + y } } - }, - { - off: true, - schema: 'type Query { mul(a: Int, b: Int): Int }', - resolvers: { Query: { mul: (_, { a, b }) => a * b } } - }, - { - schema: 'type Query { sub(x: Int, y: Int): Int }', - resolvers: { Query: { sub: (_, { x, y }) => x - y } } - }] - const expectedSdl = 'type Query {\n' + - ' sub(x: Int, y: Int): Int\n' + - '}' - - for (const service of services) { - if (service.off) { - service.host = 'http://unreachable.local' - continue + test('should compose multiple subgraphs without entities', async t => { + const expectedSdl = dedent`input WhereConditionIn { + in: [ID!]! } - service.instance = await startGraphqlService(t, { - mercurius: { - schema: service.schema, - resolvers: service.resolvers - } - }) - service.host = await service.instance.listen() - } + + input ArtistsWhereCondition { + id: WhereConditionIn + } + + type Artist { + id: ID + firstName: String + lastName: String + profession: String + } + + type Query { + artists(where: ArtistsWhereCondition): [Artist] + foods(where: FoodsWhereCondition): [Food] + } + + input FoodsWhereCondition { + id: WhereConditionIn + } + + type Food { + id: ID! + name: String + }` - let errors = 0 - const composer = await compose({ - onSubgraphError: () => { errors++ }, - subgraphs: services.map(service => ( + const services = await createGraphqlServices(t, [ { - server: { - host: service.host - } + name: 'artists-subgraph', + file: path.join(__dirname, 'fixtures/artists.js'), + listen: true + }, + { + name: 'foods-subgraph', + file: path.join(__dirname, 'fixtures/foods.js'), + listen: true } - )) + ]) + + const options = { + subgraphs: services.map(service => ( + { + name: service.name, + server: { host: service.host } + } + )) + } + + const composer = new Composer(options) + await composer.compose() + + assert.strictEqual(composer.toSdl(), expectedSdl) }) - assert.strictEqual(errors, 2) - assert.strictEqual(expectedSdl, composer.toSdl()) + // TODO test('should compose a single subgraph with entities') + + // TODO test('should compose multiple subgraphs with entities and entities resolvers on composer') }) diff --git a/test/entities.test.js b/test/entities.test.js deleted file mode 100644 index b1b4d6e..0000000 --- a/test/entities.test.js +++ /dev/null @@ -1,741 +0,0 @@ -'use strict' - -const assert = require('node:assert/strict') -const { test } = require('node:test') -const { graphqlRequest, startRouter } = require('./helper') - -test('throws if argsAdapter function does not return an object', async (t) => { - const overrides = { - subgraphs: { - 'books-subgraph': { - entities: { - Book: { resolver: { name: 'getBooksByIds', 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 - }) -}) - -test('throws if argsAdapter function throws', async (t) => { - const overrides = { - subgraphs: { - 'books-subgraph': { - entities: { - Book: { - resolver: { - name: '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 - }) -}) - -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: { - pkey: 'id', - fkeys: [{ pkey: 'author.id', type: 'Author' }], - resolver: { - name: 'getBooksByIds', - argsAdapter: (partialResults) => ({ ids: partialResults.map(r => r.id) }) - } - } - } - }, - 'authors-subgraph': { - entities: { - Author: { - pkey: 'id', - resolver: { - name: 'authors', - argsAdapter: (partialResults) => ({ where: { ids: { in: partialResults.map(r => r.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 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: { - pkey: 'id', - fkeys: [{ pkey: 'author.id', type: 'Author' }], - resolver: { - name: 'getBooksByIds', - argsAdapter: (partialResults) => ({ ids: partialResults.map(r => r.id) }) - } - } - } - }, - 'authors-subgraph': { - entities: { - Author: { - pkey: 'id', - resolver: { - name: 'authors', - 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) -}) - -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]) - } -}) - -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) -}) - -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) -}) - -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: { - pkey: 'id', - fkeys: [{ pkey: 'author.id', type: 'Author' }], - resolver: { - name: 'getBooksByIds', - argsAdapter: (partialResults) => ({ ids: partialResults.map(r => r.id) }) - } - } - } - }, - 'authors-subgraph': { - entities: { - Author: { - pkey: 'id', - resolver: { - name: 'authors', - argsAdapter: (partialResults) => ({ where: { ids: { in: partialResults.map(r => r.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 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: { - pkey: 'id', - fkeys: [{ pkey: 'author.id', type: 'Author' }], - resolver: { - name: 'getBooksByIds', - argsAdapter: (partialResults) => ({ ids: partialResults.map(r => r.id) }) - } - } - } - }, - 'authors-subgraph': { - entities: { - Author: { - pkey: 'id', - resolver: { - name: 'authors', - argsAdapter: (partialResults) => ({ where: { ids: { in: partialResults.map(r => r.id) } } }) - } - }, - Book: { - pkey: '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 - { - 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-with-entities', 'movies-subgraph-with-entities', 'songs-subgraph-with-entities'], 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,' + - '\nquery: ' + request.query + - '\nexpected' + JSON.stringify(request.expected, null, 2) + - '\nresponse' + JSON.stringify(response, null, 2)) - } -}) - -// 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/entities/capabilities.test.js b/test/entities/capabilities.test.js new file mode 100644 index 0000000..3fc1fdf --- /dev/null +++ b/test/entities/capabilities.test.js @@ -0,0 +1,234 @@ +'use strict' + +const assert = require('node:assert') +const path = require('node:path') +const { test } = require('node:test') + +const { createComposerService, createGraphqlServices, graphqlRequest } = require('../helper') +const { compose } = require('../../lib') + +test('resolves a partial entity from a single subgraph', async (t) => { + const query = ` + query { + getReviewBook(id: 1) { + id + reviews { + id + rating + content + } + } + } + ` + const expectedResult = { + getReviewBook: { + id: '1', + reviews: [ + { + id: '1', + rating: 2, + content: 'Would not read again.' + } + ] + } + } + + const services = await createGraphqlServices(t, [ + { + name: 'books-subgraph', + file: path.join(__dirname, '../fixtures/books.js'), + listen: true + }, + { + name: 'reviews-subgraph', + file: path.join(__dirname, '../fixtures/reviews.js'), + listen: true + } + ]) + + const options = { + subgraphs: services.map(service => ({ + name: service.name, + entities: service.config.entities, + server: { host: service.host } + })) + } + + const { service } = await createComposerService(t, { compose, options }) + + const result = await graphqlRequest(service, query) + assert.deepStrictEqual(result, expectedResult) +}) + +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 expectedResults = [{ + 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 services = await createGraphqlServices(t, [ + { + name: 'books-subgraph', + file: path.join(__dirname, '../fixtures/books.js'), + listen: true + }, + { + name: 'reviews-subgraph', + file: path.join(__dirname, '../fixtures/reviews.js'), + listen: true + } + ]) + + const options = { + subgraphs: services.map(service => ({ + name: service.name, + entities: service.config.entities, + server: { host: service.host } + })) + } + + const { service } = await createComposerService(t, { compose, options }) + + for (let i = 0; i < queries.length; i++) { + const result = await graphqlRequest(service, queries[i]) + assert.deepStrictEqual(result, expectedResults[i]) + } +}) + +test('resolves an entity across multiple subgraphs', async (t) => { + const cases = [ + { + name: 'should run a query from non-owner to owner subgraph', + query: `query { + getReviewBook(id: 1) { + id + title + genre + reviews { + id + rating + content + } + } + }`, + result: { + getReviewBook: { + id: '1', + title: 'A Book About Things That Never Happened', + genre: 'FICTION', + reviews: [ + { + id: '1', + rating: 2, + content: 'Would not read again.' + } + ] + } + } + }, + { + name: 'should run a query flows from owner to non-owner subgraph', + query: `query { + getBook(id: 1) { + id + title + genre + reviews { + id + rating + content + } + } + }`, + result: { + getBook: { + id: '1', + title: 'A Book About Things That Never Happened', + genre: 'FICTION', + reviews: [ + { + id: '1', + rating: 2, + content: 'Would not read again.' + } + ] + } + } + }, + { + name: 'should run a fetches key fields not in selection set', + query: `query { + getReviewBook(id: 1) { + # id not included and it is part of the keys. + title + genre + reviews { + id + rating + content + } + } + }`, + result: { + getReviewBook: { + title: 'A Book About Things That Never Happened', + genre: 'FICTION', + reviews: [ + { + id: '1', + rating: 2, + content: 'Would not read again.' + } + ] + } + } + } + ] + + let service + t.before(async () => { + const services = await createGraphqlServices(t, [ + { + name: 'books-subgraph', + file: path.join(__dirname, '../fixtures/books.js'), + listen: true + }, + { + name: 'reviews-subgraph', + file: path.join(__dirname, '../fixtures/reviews.js'), + listen: true + } + ]) + const options = { + subgraphs: services.map(service => ({ + name: service.name, + entities: service.config.entities, + server: { host: service.host } + })) + } + + const s = await createComposerService(t, { compose, options }) + service = s.service + }) + + for (const c of cases) { + await t.test(c.name, async (t) => { + const result = await graphqlRequest(service, c.query, c.variables) + + assert.deepStrictEqual(result, c.result) + }) + } +}) diff --git a/test/resolve-entities.test.js b/test/entities/on-composer.test.js similarity index 56% rename from test/resolve-entities.test.js rename to test/entities/on-composer.test.js index 108e6a6..b927742 100644 --- a/test/resolve-entities.test.js +++ b/test/entities/on-composer.test.js @@ -1,16 +1,21 @@ 'use strict' +const path = require('node:path') const assert = require('node:assert/strict') const { test } = require('node:test') -const { buildComposer, graphqlRequest, assertObject } = require('./helper') +const { compose } = require('../../lib') +const { Composer } = require('../../lib/composer') +const { createComposerService, createGraphqlServices, graphqlRequest, assertObject } = require('../helper') -const composerOptions = { - defaultArgsAdapter: (partialResults) => { - return { where: { id: { in: partialResults.map(r => r.id) } } } - }, - entities: { - 'artists-subgraph': { +test('composer on top', async () => { + const composerOptions = { + addEntitiesResolvers: true, + defaultArgsAdapter: (partialResults) => { + return { where: { id: { in: partialResults.map(r => r.id) } } } + }, + entities: { Artist: { + subgraph: 'artists-subgraph', resolver: { name: 'artists' }, pkey: 'id', many: [ @@ -47,10 +52,9 @@ const composerOptions = { } } ] - } - }, - 'movies-subgraph': { + }, Movie: { + subgraph: 'movies-subgraph', resolver: { name: 'movies' }, pkey: 'id', fkeys: [ @@ -59,10 +63,11 @@ const composerOptions = { as: 'director', field: 'directorId', pkey: 'id', + subgraph: 'artists-subgraph', resolver: { - name: 'movies', - argsAdapter: (movieIds) => { - return { where: { directorId: { in: movieIds.map(r => r.id) } } } + name: 'artists', + argsAdapter: (directorIds) => { + return { where: { id: { in: directorIds.map(r => r.id) } } } }, partialResults: (movies) => { return movies.map(r => ({ id: r.directorId })) @@ -88,10 +93,9 @@ const composerOptions = { } } ] - } - }, - 'songs-subgraph': { + }, Song: { + subgraph: 'songs-subgraph', resolver: { name: 'songs' }, pkey: 'id', fkeys: [ @@ -100,10 +104,11 @@ const composerOptions = { as: 'singer', field: 'singerId', pkey: 'id', + subgraph: 'artists-subgraph', resolver: { - name: 'songs', - argsAdapter: (songIds) => { - return { where: { singerId: { in: songIds.map(r => r.id) } } } + name: 'artists', + argsAdapter: (singerIds) => { + return { where: { id: { in: singerIds.map(r => r.id) } } } }, partialResults: (songs) => { return songs.map(r => ({ id: r.singerId })) @@ -111,10 +116,9 @@ const composerOptions = { } } ] - } - }, - 'cinemas-subgraph': { + }, Cinema: { + subgraph: 'cinemas-subgraph', resolver: { name: 'cinemas' }, pkey: 'id', many: [ @@ -138,16 +142,12 @@ const composerOptions = { } } } -} -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] }\n\n' + 'type Movie { id: ID!, director: Artist, cinemas: [Cinema] }\n\n' + 'type Song { id: ID!, singer: Artist }\n\n' + + 'type Cinema { id: ID!, movies: [Movie] }\n\n' + 'type Query {\n' + ' _composer: String \n' + '}' @@ -158,72 +158,202 @@ test('entities', async () => { Song: {}, Query: { _composer: () => { } } } + const fn = () => {} const expectedEntities = { Artist: { + subgraph: 'artists-subgraph', + resolver: { name: 'artists', argsAdapter: fn }, + pkey: 'id', fkeys: [], - pkey: 'id' + many: [ + { + type: 'Movie', + pkey: 'id', + as: 'movies', + fkey: 'directorId', + subgraph: 'movies-subgraph', + resolver: { + name: 'movies', + argsAdapter: fn, + partialResults: fn + } + }, + { + type: 'Song', + pkey: 'id', + as: 'songs', + fkey: 'singerId', + subgraph: 'songs-subgraph', + resolver: { + name: 'songs', + argsAdapter: fn, + partialResults: fn + } + } + ] }, Movie: { + subgraph: 'movies-subgraph', + resolver: { name: 'movies', argsAdapter: fn }, + pkey: 'id', fkeys: [ { - as: 'director', + type: 'Artist', field: 'directorId', + as: 'director', pkey: 'id', + subgraph: 'artists-subgraph', resolver: { - argsAdapter: () => { }, - name: 'movies', - partialResults: () => { } - }, - type: 'Artist' + name: 'artists', + argsAdapter: fn, + partialResults: fn + } } ], - pkey: 'id' + many: [ + { + type: 'Cinema', + pkey: 'id', + as: 'cinemas', + fkey: 'movieIds', + subgraph: 'cinemas-subgraph', + resolver: { + name: 'cinemas', + argsAdapter: fn, + partialResults: fn + } + } + ] }, Song: { + subgraph: 'songs-subgraph', + resolver: { name: 'songs', argsAdapter: fn }, + pkey: 'id', fkeys: [ { - as: 'singer', + type: 'Artist', field: 'singerId', + as: 'singer', pkey: 'id', + subgraph: 'artists-subgraph', resolver: { - argsAdapter: () => { }, - name: 'songs', - partialResults: () => { } - }, - type: 'Artist' + name: 'artists', + argsAdapter: fn, + partialResults: fn + } } ], - pkey: 'id' + many: [] + }, + Cinema: { + subgraph: 'cinemas-subgraph', + resolver: { name: 'cinemas', argsAdapter: fn }, + pkey: 'id', + fkeys: [], + many: [ + { + type: 'Movie', + pkey: 'movieIds', + as: 'movies', + fkey: 'id', + subgraph: 'movies-subgraph', + resolver: { + name: 'movies', + argsAdapter: fn, + partialResults: fn + } + } + ] } } - assert.strictEqual(schema, expectedSchema) + const services = await createGraphqlServices(t, [ + { + name: 'artists-subgraph', + file: path.join(__dirname, '../fixtures/artists.js'), + listen: true + }, + { + name: 'movies-subgraph', + file: path.join(__dirname, '../fixtures/movies.js'), + listen: true + }, + { + name: 'songs-subgraph', + file: path.join(__dirname, '../fixtures/songs.js'), + listen: true + }, + { + name: 'cinemas-subgraph', + file: path.join(__dirname, '../fixtures/cinemas.js'), + listen: true + } + ]) + + const options = { + ...composerOptions, + subgraphs: services.map(service => ( + { + name: service.name, + server: { host: service.host } + } + )) + } + const composer = new Composer(options) + await composer.compose() + + const { schema, resolvers, entities } = composer.resolveEntities() + 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 services = await createGraphqlServices(t, [ + { + name: 'artists-subgraph', + file: path.join(__dirname, '../fixtures/artists.js'), + listen: true + }, + { + name: 'movies-subgraph', + file: path.join(__dirname, '../fixtures/movies.js'), + listen: true + }, + { + name: 'songs-subgraph', + file: path.join(__dirname, '../fixtures/songs.js'), + listen: true + }, + { + name: 'cinemas-subgraph', + file: path.join(__dirname, '../fixtures/cinemas.js'), + listen: true + } + ]) - const { service } = await buildComposer(t, ['artists-subgraph', 'movies-subgraph', 'cinemas-subgraph', 'songs-subgraph'], options) + const options = { + ...composerOptions, + subgraphs: services.map(service => ( + { + name: service.name, + server: { host: service.host } + } + )) + } - await service.listen() + const { service } = await createComposerService(t, { compose, options }) const requests = [ { - query: '{ movies (where: { id: { in: ["10","11","12"] } }) { title, director { lastName } } }', + name: 'should query subgraphs entities / fkey #1', + 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' }] }] } - }, - { + name: 'should query subgraphs entities / fkey #2', query: '{ songs (where: { id: { in: [1,2,3] } }) { title, singer { firstName, lastName, profession } } }', expected: { songs: [ @@ -232,20 +362,9 @@ test('entities', async () => { { 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 } } }', + name: 'should query subgraphs entities (many) on the same query', + query: '{ artists (where: { id: { in: ["101","103","102"] } }) { lastName songs { title } movies { title } } }', expected: { artists: [ { lastName: 'Nolan', songs: [], movies: [{ title: 'Interstellar' }, { title: 'Oppenheimer' }] }, @@ -254,32 +373,71 @@ test('entities', async () => { ] } }, - - // 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' } }] }] } + name: 'should query subgraphs nested entities (many)', + query: '{ artists (where: { id: { in: ["103", "101"] } }) { lastName movies { title cinemas { name } } } }', + expected: { + artists: [ + { + lastName: 'Nolan', + movies: [ + { title: 'Interstellar', cinemas: [{ name: 'Odeon' }, { name: 'Main Theatre' }] }, + { title: 'Oppenheimer', cinemas: [] }] + }, + { + lastName: 'Molko', + movies: [] + }] + } }, - - // nested and nested { + name: 'should query subgraphs nested entities (many and fkey)', 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' }] }] } + name: 'should query subgraphs entities / many #1', + 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' }] }] } }, - - // many to many { + name: 'should query subgraphs entities / many #2', 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' }] }] } }, - { + name: 'should query subgraphs entities / many #3', + 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' } }] }] } + }, + { + name: 'should query subgraphs entities / many #4', + query: '{ cinemas (where: { id: { in: ["90", "91", "92"] } }) { name movies { title } } }', + expected: { + cinemas: [ + { + name: 'Odeon', + movies: [ + { title: 'Interstellar' }, + { title: 'La vita é bella' } + ] + }, + { + name: 'Film Forum', + movies: null + }, + { + name: 'Main Theatre', + movies: [ + { title: 'La vita é bella' }, + { title: 'Interstellar' } + ] + } + ] + } + }, + { + name: 'should query subgraphs entities / nested many #1', query: '{ artists (where: { id: { in: ["102", "101"] } }) { movies { title, cinemas { name, movies { title } } } } }', expected: { artists: [{ @@ -314,8 +472,8 @@ test('entities', async () => { }] } }, - { + name: 'should query subgraphs entities / nested many #2', query: '{ movies (where: { id: { in: ["10","11"] } }) { cinemas { name, movies { title, director { lastName} } } } }', expected: { movies: [{ @@ -338,17 +496,19 @@ test('entities', async () => { cinemas: [] }] } - } - ] + }] - for (const request of requests) { - const response = await graphqlRequest(service, request.query, request.variables) + for (const c of requests) { + if (!c) { continue } + await t.test(c.name, async (t) => { + const result = await graphqlRequest(service, c.query, c.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) - ) + assert.deepStrictEqual(result, c.expected, 'should get expected result from composer service,' + + '\nquery: ' + c.query + + '\nexpected' + JSON.stringify(c.expected, null, 2) + + '\nresponse' + JSON.stringify(result, null, 2) + ) + }) } }) }) diff --git a/test/entities/on-subgraphs-1.test.js b/test/entities/on-subgraphs-1.test.js new file mode 100644 index 0000000..c677808 --- /dev/null +++ b/test/entities/on-subgraphs-1.test.js @@ -0,0 +1,298 @@ +'use strict' + +'use strict' + +const assert = require('node:assert') +const { test } = require('node:test') + +const { createComposerService, createGraphqlServices, graphqlRequest } = require('../helper') +const { compose } = require('../../lib') + +const booksSubgraph = () => { + const schema = ` + enum BookGenre { + FICTION + NONFICTION + } + + type Book { + id: ID! + title: String + genre: BookGenre + author: Author + } + + type Author { + id: ID + } + + type Query { + getBook(id: ID!): Book + getBookTitle(id: ID!): String + getBooksByIds(ids: [ID]!): [Book]! + booksByAuthors(authorIds: [ID!]!): [Book] + } +` + const data = { library: null } + + function reset () { + data.library = { + 1: { + id: 1, + title: 'A Book About Things That Never Happened', + genre: 'FICTION', + authorId: 10 + }, + 2: { + id: 2, + title: 'A Book About Things That Really Happened', + genre: 'NONFICTION', + authorId: 10 + }, + 3: { + id: 3, + title: 'From the universe', + genre: 'FICTION', + authorId: 11 + }, + 4: { + id: 4, + title: 'From another world', + genre: 'FICTION', + authorId: 11 + } + } + } + + reset() + + const resolvers = { + Query: { + async getBook (_, { id }) { + return data.library[id] + }, + async getBookTitle (_, { id }) { + return data.library[id]?.title + }, + async getBooksByIds (_, { ids }) { + return ids + .map((id) => { return data.library[id] }) + .filter(b => !!b) + }, + booksByAuthors: (parent, { authorIds }) => Object.values(data.library).filter(book => authorIds.includes(String(book.authorId))) + }, + Book: { + author: (parent) => ({ id: parent.authorId || data.library[parent.id]?.authorId }) + } + } + const entities = { + Book: { + pkey: 'id', + fkeys: [{ + pkey: 'author.id', + type: 'Author' + }], + resolver: { + name: 'getBooksByIds', + argsAdapter: (partialResults) => ({ ids: partialResults.map(r => r.id) }) + } + } + } + + return { schema, resolvers, entities, data, reset } +} + +const authorsSubgraph = () => { + const schema = ` + input IdsIn { + in: [ID]! + } + input WhereIdsIn { + ids: IdsIn + } + + input AuthorInput { + firstName: String! + lastName: String! + } + + type AuthorTodo { + task: String + } + + type AuthorName { + firstName: String + lastName: String + } + + type Author { + id: ID + name: AuthorName + todos(priority: Int): [AuthorTodo] + } + + type BlogPostPublishEvent { + authorId: ID! + } + + type Query { + get(id: ID!): Author + list: [Author] + authors (where: WhereIdsIn): [Author] + } + ` + + const data = { + authors: null, + todos: null + } + + function reset () { + data.authors = { + 1: { + id: 1, + name: { + firstName: 'Peter', + lastName: 'Pluck' + } + }, + 2: { + id: 2, + name: { + firstName: 'John', + lastName: 'Writer' + } + }, + 10: { + id: 10, + name: { + firstName: 'John Jr.', + lastName: 'Johnson' + } + }, + 11: { + id: 11, + name: { + firstName: 'Cindy', + lastName: 'Connor' + } + } + } + + data.todos = { + 1: { + id: 1, + authorId: 1, + priority: 10, + task: 'Write another book' + }, + 2: { + id: 2, + authorId: 1, + priority: 5, + task: 'Get really creative' + }, + 3: { + id: 3, + authorId: 2, + priority: 8, + task: 'Buy an ice-cream' + } + } + } + + reset() + + const resolvers = { + Query: { + get (_, { id }) { + return data.authors[id] + }, + list () { + return Object.values(data.authors) + }, + authors (_, args) { + return Object.values(data.authors).filter(a => args.where.ids.in.includes(String(a.id))) + } + }, + Author: { + async todos (parent, { priority }) { + return Object.values(data.todos).filter((t) => { + return String(t.authorId) === parent.id && String(t.priority) === priority + }) + } + } + } + + const entities = { + Author: { + pkey: 'id', + resolver: { + name: 'authors', + argsAdapter: (partialResults) => ({ where: { ids: { in: partialResults.map(r => r.id) } } }) + } + } + } + + return { schema, resolvers, entities, data, reset } +} + +async function setupComposer (t) { + const books = booksSubgraph() + const authors = authorsSubgraph() + + const services = await createGraphqlServices(t, [ + { + name: 'books-subgraph', + mercurius: { + schema: books.schema, + resolvers: books.resolvers + }, + entities: books.entities, + listen: true + }, + { + name: 'authors-subgraph', + mercurius: { + schema: authors.schema, + resolvers: authors.resolvers + }, + entities: authors.entities, + listen: true + } + ]) + + const options = { + subgraphs: services.map(service => ({ + name: service.name, + server: { host: service.host }, + entities: service.config.entities + })) + } + + const { service } = await createComposerService(t, { compose, options }) + return service +} + +test('should resolve foreign types referenced in different results', async (t) => { + const query = `{ + booksByAuthors(authorIds: [10, 11, 12]) { + title author { name { firstName, lastName } } + } + }` + + const expectedResult = { + 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 service = await setupComposer(t) + + const result = await graphqlRequest(service, query) + + assert.deepStrictEqual(result, expectedResult) +}) diff --git a/test/entities/on-subgraphs-2.test.js b/test/entities/on-subgraphs-2.test.js new file mode 100644 index 0000000..fc2f22f --- /dev/null +++ b/test/entities/on-subgraphs-2.test.js @@ -0,0 +1,411 @@ +'use strict' + +const assert = require('node:assert') +const { test } = require('node:test') + +const { createComposerService, createGraphqlServices, graphqlRequest } = require('../helper') +const { compose } = require('../../lib') + +const booksSubgraph = () => { + const schema = ` + enum BookGenre { + FICTION + NONFICTION + } + + type Author { + id: ID + } + + type Book { + id: ID! + title: String + genre: BookGenre + author: Author + } + + type Query { + getBook(id: ID!): Book + getBookTitle(id: ID!): String + getBooksByIds(ids: [ID]!): [Book]! + } +` + const data = { library: null } + + function reset () { + data.library = { + 1: { + id: 1, + title: 'A Book About Things That Never Happened', + genre: 'FICTION', + authorId: 1 + }, + 2: { + id: 2, + title: 'A Book About Things That Really Happened', + genre: 'NONFICTION', + authorId: 2 + }, + 3: { + id: 3, + title: 'Uknown memories', + genre: 'NONFICTION', + authorId: -1 + } + } + } + + reset() + + const resolvers = { + Query: { + getBook (_, { id }) { + return data.library[id] + }, + getBookTitle (_, { id }) { + return data.library[id]?.title + }, + getBooksByIds (_, { ids }) { + return ids + .map((id) => { return data.library[id] }) + .filter(b => !!b) + } + }, + Book: { + author: (parent) => ({ id: parent.authorId || data.library[parent.id]?.authorId }) + } + } + const entities = { + Book: { + pkey: 'id', + fkeys: [{ pkey: 'author.id', type: 'Author' }], + resolver: { + name: 'getBooksByIds', + argsAdapter: (partialResults) => ({ ids: partialResults.map(r => r.id) }) + } + } + } + + return { schema, resolvers, entities, data, reset } +} + +const authorsSubgraph = () => { + const schema = ` + input IdsIn { + in: [ID]! + } + input WhereIdsIn { + ids: IdsIn + } + + input AuthorInput { + firstName: String! + lastName: String! + } + + type AuthorTodo { + task: String + } + + type AuthorName { + firstName: String + lastName: String + } + + type Author { + id: ID + name: AuthorName + todos(id: ID!): [AuthorTodo] + } + + type BlogPostPublishEvent { + authorId: ID! + } + + type Query { + get(id: ID!): Author + list: [Author] + authors (where: WhereIdsIn): [Author] + } +` + + const data = { + authors: null, + todos: null + } + + function reset () { + data.authors = { + 1: { + id: 1, + name: { + firstName: 'Peter', + lastName: 'Pluck' + } + }, + 2: { + id: 2, + name: { + firstName: 'John', + lastName: 'Writer' + } + } + } + + data.todos = { + 1: { + id: 1, + authorId: 1, + task: 'Write another book' + }, + 2: { + id: 2, + authorId: 1, + task: 'Get really creative' + } + } + } + + reset() + + const resolvers = { + Query: { + get (_, { id }) { + return data.authors[id] + }, + list () { + return Object.values(data.authors) + }, + authors (_, args) { + return Object.values(data.authors).filter(a => args.where.ids.in.includes(String(a.id))) + } + }, + Author: { + todos (parent, { priority }) { + return Object.values(data.todos).filter((t) => { + return String(t.authorId) === parent.id && String(t.priority) === priority + }) + } + } + } + + const entities = { + Author: { + pkey: 'id', + resolver: { + name: 'authors', + argsAdapter: (partialResults) => ({ where: { ids: { in: partialResults.map(r => r.id) } } }) + } + } + } + + return { schema, resolvers, entities, data, reset } +} + +const reviewsSubgraph = () => { + const schema = ` + input ReviewInput { + bookId: ID! + rating: Int! + content: String! + } + + type Review { + id: ID! + rating: Int! + content: String! + } + + type Book { + id: ID! + reviews: [Review]! + } + + type ReviewWithBook { + id: ID! + rating: Int! + content: String! + book: Book! + } + + type Query { + getReview(id: ID!): Review + getReviewBook(id: ID!): Book + getReviewBookByIds(ids: [ID]!): [Book]! + getReviewsByBookId(id: ID!): [Review]! + } +` + + const data = { reviews: null, books: null } + + function reset () { + data.reviews = { + 1: { + id: 1, + rating: 2, + content: 'Would not read again.' + }, + 2: { + id: 2, + rating: 3, + content: 'So so.' + }, + 3: { + id: 3, + rating: 5, + content: 'Wow.' + }, + 4: { + id: 4, + rating: 1, + content: 'Good to start the fire.' + } + } + + data.books = { + 1: { + id: 1, + reviews: [1] + }, + 2: { + id: 2, + reviews: [2] + }, + 3: { + id: 3, + reviews: [2, 3, 4] + }, + 4: { + id: 4, + reviews: [] + } + } + } + + reset() + + const resolvers = { + Query: { + getReview (_, { id }) { + return data.reviews[id] + }, + getReviewBook (_, { id }) { + if (!data.books[id]) { return null } + const book = structuredClone(data.books[id]) + book.reviews = book.reviews.map((rid) => data.reviews[rid]) + return book + }, + getReviewsByBookId (_, { id }) { + return data.books[id]?.reviews.map((rid) => { + return data.reviews[rid] + }) + }, + getReviewBookByIds (_, { ids }) { + return ids.map((id) => { + if (!data.books[id]) { return null } + const book = structuredClone(data.books[id]) + book.reviews = book.reviews.map((rid) => data.reviews[rid]) + return book + }) + } + } + } + + const entities = { + Book: { + pkey: 'id', + resolver: { + name: 'getReviewBookByIds', + argsAdapter (partialResults) { + return { + ids: partialResults.map(r => r.id) + } + } + } + } + } + + return { schema, resolvers, entities, data, reset } +} + +async function setupComposer (t) { + const books = booksSubgraph() + const authors = authorsSubgraph() + const reviews = reviewsSubgraph() + + const services = await createGraphqlServices(t, [ + { + name: 'books-subgraph', + mercurius: { + schema: books.schema, + resolvers: books.resolvers + }, + entities: books.entities, + listen: true + }, + { + name: 'authors-subgraph', + mercurius: { + schema: authors.schema, + resolvers: authors.resolvers + }, + entities: authors.entities, + listen: true + }, + { + name: 'reviews-subgraph', + mercurius: { + schema: reviews.schema, + resolvers: reviews.resolvers + }, + entities: reviews.entities, + listen: true + } + ]) + + const options = { + subgraphs: services.map(service => ({ + name: service.name, + server: { host: service.host }, + entities: service.config.entities + })) + } + + const { service } = await createComposerService(t, { compose, options }) + return service +} + +test('should resolve foreign types with nested keys', async (t) => { + const query = `{ + getReviewBookByIds(ids: [1,2,3]) { + title + author { name { lastName } } + reviews { rating } + } + }` + + const expectedResult = { + 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 service = await setupComposer(t) + + const result = await graphqlRequest(service, query) + + assert.deepStrictEqual(result, expectedResult) +}) diff --git a/test/entities/on-subgraphs-3.test.js b/test/entities/on-subgraphs-3.test.js new file mode 100644 index 0000000..ba0980d --- /dev/null +++ b/test/entities/on-subgraphs-3.test.js @@ -0,0 +1,402 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') + +const { createComposerService, createGraphqlServices, graphqlRequest } = require('../helper') +const { compose } = require('../../lib') + +function artistsSubgraph () { + 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: { + resolver: { name: 'artists' }, + pkey: 'id' + } + } + + return { schema, reset, resolvers, entities, data } +} +function songsSubgraphs () { + const schema = ` + type Song { + id: ID! + title: String + singerId: ID + singer: Artist + } + + type Artist { + id: ID + songs: [Song] + } + + type Query { + songs(ids: [ID!]!): [Song] + artistsSongs(artistIds: [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: { + songs (_, { ids }) { + return Object.values(data.songs).filter(s => ids.includes(String(s.id))) + }, + artistsSongs (_, { artistIds }) { + return Object.values(Object.values(data.songs) + .reduce((artists, song) => { + if (!artistIds.includes(String(song.singerId))) { return artists } + if (artists[song.singerId]) { + artists[song.singerId].songs.push(song) + } else { + artists[song.singerId] = { + id: song.singerId, + songs: [song] + } + } + return artists + }, {}) + ) + } + }, + Song: { + singer: (parent, args, context, info) => { + return parent?.singerId ? { id: parent.singerId } : null + } + }, + Artist: { + songs: (parent, args, context, info) => { + return Object.values(data.songs).filter(a => String(a.singerId) === String(parent.id)) + } + } + } + + const entities = { + Song: { + resolver: { name: 'songs' }, + pkey: 'id', + fkeys: [ + { + type: 'Artist', + field: 'singerId', + pkey: 'id', + subgraph: 'artists-subgraph', + resolver: { + name: 'artists', + partialResults: (partialResults) => { + return partialResults.map(r => ({ id: r.singerId })) + } + } + } + ] + }, + Artist: { + pkey: 'id', + resolver: { + name: 'artistsSongs', + argsAdapter: (partialResults) => { + return { artistIds: partialResults.map(r => r?.id) } + } + } + } + } + + return { schema, reset, resolvers, entities, data } +} + +function moviesSubgraph () { + const schema = ` + type Movie { + id: ID! + title: String + directorId: ID + director: Artist + } + + type Artist { + id: ID + movies: [Movie] + } + + type Query { + movies(ids: [ID!]!): [Movie] + artistsMovies(artistIds: [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(m => ids.includes(String(m.id))) + }, + artistsMovies (_, { artistIds }) { + return Object.values(Object.values(data.movies) + .reduce((artists, movie) => { + if (!artistIds.includes(String(movie.directorId))) { return artists } + if (artists[movie.directorId]) { + artists[movie.directorId].movies.push(movie) + } else { + artists[movie.directorId] = { + id: movie.directorId, + movies: [movie] + } + } + return artists + }, {}) + ) + } + }, + Movie: { + director: (parent, args, context, info) => { + return parent?.directorId ? { id: parent.directorId } : null + } + }, + Artist: { + movies: (parent, args, context, info) => { + return Object.values(data.movies).filter(a => String(a.directorId) === String(parent.id)) + } + } + } + + const entities = { + Movie: { + resolver: { name: 'movies' }, + pkey: 'id', + fkeys: [ + { + type: 'Artist', + field: 'directorId', + pkey: 'id', + subgraph: 'artists-subgraph', + resolver: { + name: 'artists', + partialResults: (partialResults) => { + return partialResults.map(r => ({ id: r.directorId })) + } + } + } + ] + }, + Artist: { + pkey: 'id', + resolver: { + name: 'artistsMovies', + argsAdapter: (partialResults) => { + return { artistIds: partialResults.map(r => r?.id) } + } + } + } + } + + return { schema, reset, resolvers, entities, data } +} + +test('entities on subgraph, scenario #3: entities with 1-1, 1-2-m, m-2-m relations solved on subgraphs', async (t) => { + let service + + t.before(async () => { + const artists = artistsSubgraph() + const movies = moviesSubgraph() + const songs = songsSubgraphs() + + const services = await createGraphqlServices(t, [ + { + name: 'artists-subgraph', + mercurius: { + schema: artists.schema, + resolvers: artists.resolvers + }, + entities: artists.entities, + listen: true + }, + { + name: 'movies-subgraph', + mercurius: { + schema: movies.schema, + resolvers: movies.resolvers + }, + entities: movies.entities, + listen: true + }, + { + name: 'songs-subgraph', + mercurius: { + schema: songs.schema, + resolvers: songs.resolvers + }, + entities: songs.entities, + listen: true + } + ]) + + const options = { + defaultArgsAdapter: (partialResults) => { + return { ids: partialResults.map(r => r?.id) } + }, + subgraphs: services.map(service => ({ + name: service.name, + server: { host: service.host }, + entities: service.config.entities + })) + } + + const s = await createComposerService(t, { compose, options }) + service = s.service + }) + + const requests = [ + { + name: 'should run a query that resolve entities with a 1-to-1 relation', + query: '{ songs (ids: [1,2,3]) { title, singer { firstName, lastName, profession } } }', + result: { + 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' } }] + } + }, + + { + name: 'should run a query with double nested results', + query: '{ artists (ids: ["103"]) { songs { title, singer { firstName, lastName } } } }', + result: { artists: [{ songs: [{ title: 'Every you every me', singer: { firstName: 'Brian', lastName: 'Molko' } }, { title: 'The bitter end', singer: { firstName: 'Brian', lastName: 'Molko' } }] }] } + }, + + { + name: 'should run a query that resolve entities with a 1-to-many relation', + query: '{ artists (ids: ["103","102"]) { lastName, songs { title } } }', + result: { + artists: [ + { lastName: 'Benigni', songs: [{ title: 'Vieni via con me' }] }, + { lastName: 'Molko', songs: [{ title: 'Every you every me' }, { title: 'The bitter end' }] }] + } + }, + + { + name: 'should run a query that resolve multiple entities on different subgrapgh on the same node', + query: '{ artists (ids: ["103","101","102"]) { lastName, songs { title }, movies { title } } }', + result: { + artists: [ + { lastName: 'Nolan', songs: null, 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: null } + ] + } + }, + + { + name: 'should run a query with insane nested results', + query: '{ artists (ids: ["103"]) { songs { singer { songs { singer { songs { title } }} } } } }', + result: { 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' }] } }] } }] }] } + } + ] + + for (const c of requests) { + await t.test(c.name, async (t) => { + const result = await graphqlRequest(service, c.query, c.variables) + + assert.deepStrictEqual(result, c.result) + }) + } +}) diff --git a/fixtures/artists-subgraph.js b/test/fixtures/artists.js similarity index 92% rename from fixtures/artists-subgraph.js rename to test/fixtures/artists.js index 31cf332..291e8de 100644 --- a/fixtures/artists-subgraph.js +++ b/test/fixtures/artists.js @@ -59,4 +59,4 @@ const resolvers = { } } -module.exports = { name: 'artists-subgraph', schema, reset, resolvers, data } +module.exports = { schema, reset, resolvers, data } diff --git a/fixtures/authors-subgraph.js b/test/fixtures/authors.js similarity index 88% rename from fixtures/authors-subgraph.js rename to test/fixtures/authors.js index 9e76831..2df5dd9 100644 --- a/fixtures/authors-subgraph.js +++ b/test/fixtures/authors.js @@ -35,10 +35,6 @@ const schema = ` batchCreateAuthor(authors: [AuthorInput]!): [Author]! publishBlogPost(authorId: ID!): Boolean! } - - type Subscription { - postPublished: BlogPostPublishEvent - } ` const data = { @@ -129,13 +125,6 @@ const resolvers = { return true } }, - Subscription: { - postPublished: { - subscribe: (root, args, ctx) => { - return ctx.pubsub.subscribe('PUBLISH_BLOG_POST') - } - } - }, Author: { async todos (_, { id }) { return Object.values(data.todos).filter((t) => { @@ -145,4 +134,4 @@ const resolvers = { } } -module.exports = { name: 'authors', schema, reset, resolvers, data } +module.exports = { schema, reset, resolvers, data } diff --git a/fixtures/books-subgraph.js b/test/fixtures/books.js similarity index 74% rename from fixtures/books-subgraph.js rename to test/fixtures/books.js index c2de2b6..2cfc0eb 100644 --- a/fixtures/books-subgraph.js +++ b/test/fixtures/books.js @@ -38,14 +38,16 @@ reset() const resolvers = { Query: { - async getBook (_, { id }) { + getBook (_, { id }) { return data.library[id] }, - async getBookTitle (_, { id }) { + getBookTitle (_, { id }) { return data.library[id]?.title }, - async getBooksByIds (_, { ids }) { - return ids.map((id) => { return data.library[id] }) + getBooksByIds (_, { ids }) { + return ids + .map((id) => { return data.library[id] }) + .filter(b => !!b) } } } @@ -56,11 +58,11 @@ const entities = { name: 'getBooksByIds', argsAdapter (partialResults) { return { - ids: partialResults.map(r => r.id) + ids: partialResults.map(r => r.bookId) } } } } } -module.exports = { name: 'books', entities, reset, resolvers, schema, data } +module.exports = { entities, reset, resolvers, schema, data } diff --git a/fixtures/cinemas-subgraph.js b/test/fixtures/cinemas.js similarity index 93% rename from fixtures/cinemas-subgraph.js rename to test/fixtures/cinemas.js index af382bc..6b48654 100644 --- a/fixtures/cinemas-subgraph.js +++ b/test/fixtures/cinemas.js @@ -68,4 +68,4 @@ const resolvers = { } } -module.exports = { name: 'cinemas-subgraph', schema, reset, resolvers, data } +module.exports = { schema, reset, resolvers, data } diff --git a/test/fixtures/foods.js b/test/fixtures/foods.js new file mode 100644 index 0000000..df4221f --- /dev/null +++ b/test/fixtures/foods.js @@ -0,0 +1,70 @@ +'use strict' + +const schema = ` + input WhereConditionIn { + in: [ID!]! + } + + input FoodsWhereCondition { + id: WhereConditionIn + } + + type Food { + id: ID! + name: String + } + + type Query { + foods(where: FoodsWhereCondition): [Food] + } +` + +const data = { + foods: null +} + +function reset () { + data.foods = { + 50: { + id: 50, + name: 'Pizza margherita' + }, + 51: { + id: 51, + name: 'Pizza boscaiola' + }, + 52: { + id: 52, + name: 'Pizza capricciosa' + }, + 60: { + id: 60, + name: 'Spaghetti carbonara' + }, + 61: { + id: 61, + name: 'Tagliolini scoglio' + }, + 62: { + id: 62, + name: 'Pici cacio e pepe' + }, + 63: { + id: 63, + name: 'Grigliata mista' + } + } +} + +reset() + +const resolvers = { + Query: { + foods (_, { where }) { + return Object.values(data.foods) + .filter(a => where.id.in.includes(String(a.id))) + } + } +} + +module.exports = { schema, reset, resolvers, data } diff --git a/fixtures/movies-subgraph.js b/test/fixtures/movies.js similarity index 92% rename from fixtures/movies-subgraph.js rename to test/fixtures/movies.js index f7234f7..35a951a 100644 --- a/fixtures/movies-subgraph.js +++ b/test/fixtures/movies.js @@ -59,4 +59,4 @@ const resolvers = { } } -module.exports = { name: 'movies-subgraph', schema, reset, resolvers, data } +module.exports = { schema, reset, resolvers, data } diff --git a/fixtures/reviews-subgraph.js b/test/fixtures/reviews.js similarity index 86% rename from fixtures/reviews-subgraph.js rename to test/fixtures/reviews.js index 4f7817f..3a377dc 100644 --- a/fixtures/reviews-subgraph.js +++ b/test/fixtures/reviews.js @@ -13,7 +13,8 @@ const schema = ` } type Book { - id: ID! + bookId: ID! + rate: Int reviews: [Review]! } @@ -34,10 +35,6 @@ const schema = ` type Mutation { createReview(review: ReviewInput!): Review! } - - type Subscription { - reviewPosted: ReviewWithBook! - } ` let reviews let books @@ -68,19 +65,23 @@ function reset () { books = { 1: { - id: 1, + bookId: 1, + rate: 3, reviews: [1] }, 2: { - id: 2, + bookId: 2, + rate: 4, reviews: [2] }, 3: { - id: 3, + bookId: 3, + rate: 5, reviews: [2, 3, 4] }, 4: { - id: 4, + bookId: 4, + rate: null, reviews: [] } } @@ -119,6 +120,7 @@ const resolvers = { return book }) + .filter(b => !!b) } }, Mutation: { @@ -128,7 +130,7 @@ const resolvers = { const review = { id, rating, content } reviews[id] = review - books[bookId] ??= { id: bookId, reviews: [] } + books[bookId] ??= { bookId, reviews: [] } const book = books[bookId] book.reviews.push(id) context.app.graphql.pubsub.publish({ @@ -148,18 +150,11 @@ const resolvers = { return review } - }, - Subscription: { - reviewPosted: { - subscribe: (root, args, ctx) => { - return ctx.pubsub.subscribe('REVIEW_POSTED') - } - } } } const entities = { Book: { - pkey: 'id', + pkey: 'bookId', resolver: { name: 'getReviewBookByIds', argsAdapter (partialResults) { @@ -171,4 +166,4 @@ const entities = { } } -module.exports = { name: 'reviews', entities, reset, resolvers, schema } +module.exports = { entities, reset, resolvers, schema } diff --git a/fixtures/songs-subgraph.js b/test/fixtures/songs.js similarity index 92% rename from fixtures/songs-subgraph.js rename to test/fixtures/songs.js index 3b91d16..a16f836 100644 --- a/fixtures/songs-subgraph.js +++ b/test/fixtures/songs.js @@ -59,4 +59,4 @@ const resolvers = { } } -module.exports = { name: 'songs-subgraph', schema, reset, resolvers, data } +module.exports = { schema, reset, resolvers, data } diff --git a/test/helper.js b/test/helper.js index 436b2d0..542cd05 100644 --- a/test/helper.js +++ b/test/helper.js @@ -1,166 +1,28 @@ 'use strict' -const { strictEqual } = require('node:assert') -const { join } = require('node:path') -const Fastify = require('fastify') + +const assert = require('node:assert') const { getIntrospectionQuery } = require('graphql') +const Fastify = require('fastify') const Mercurius = require('mercurius') -const { compose } = require('../lib') -const assert = require('node:assert') -const fixturesDir = join(__dirname, '..', 'fixtures') - -async function startRouter (t, subgraphs, overrides = {}, extend) { - const promises = subgraphs.map(async (subgraph) => { - const subgraphFile = join(fixturesDir, subgraph) - delete require.cache[require.resolve(subgraphFile)] - const { - name, - entities = {}, - reset, - resolvers, - schema, - data - } = require(subgraphFile) - const server = Fastify() - - t.after(async () => { - try { - await server.close() - } catch {} // Ignore errors. - }) - - reset() - - const subgraphOverrides = overrides?.subgraphs?.[subgraph] - - if (subgraphOverrides) { - if (subgraphOverrides.entities) { - for (const [k, v] of Object.entries(subgraphOverrides.entities)) { - entities[k] = { ...entities[k], ...v } - } - } - } - - server.register(Mercurius, { schema, resolvers, subscription: true }) - server.get('/.well-known/graphql-composition', async function (req, reply) { - const introspectionQuery = getIntrospectionQuery() - - return reply.graphql(introspectionQuery) - }) - - const extendServer = extend?.[subgraph] - if (extendServer) { - await server.ready() - const { schema, resolvers } = extendServer(data) - schema && server.graphql.extendSchema(schema) - resolvers && server.graphql.defineResolvers(resolvers) - } - - const host = await server.listen() - return { - name, - entities, - server: { - host, - composeEndpoint: '/.well-known/graphql-composition', - graphqlEndpoint: '/graphql' - } - } - }) - const subgraphConfigs = await Promise.all(promises) - const subscriptionRecorder = [] - const defaultSubscriptionHandler = { - onError (ctx, topic, error) { - subscriptionRecorder.push({ action: 'error', topic, error }) - }, - publish (ctx, topic, payload) { - subscriptionRecorder.push({ action: 'publish', topic, payload }) - ctx.pubsub.publish({ - topic, - payload - }) - }, - subscribe (ctx, topic) { - subscriptionRecorder.push({ action: 'subscribe', topic }) - return ctx.pubsub.subscribe(topic) - }, - unsubscribe (ctx, topic) { - subscriptionRecorder.push({ action: 'unsubscribe', topic }) - ctx.pubsub.close() - } - } - const composerOptions = { - ...overrides, - subgraphs: subgraphConfigs, - subscriptions: { ...defaultSubscriptionHandler, ...overrides.subscriptions } - } - const composer = await compose(composerOptions) - const router = Fastify() - t.after(async () => { - try { - await router.close() - } catch {} // Ignore errors. - }) - - router.register(Mercurius, { - schema: composer.toSdl(), - resolvers: composer.resolvers, - subscription: true - }) +const introspectionQuery = getIntrospectionQuery() - await router.ready() - router.graphql.addHook('onSubscriptionEnd', composer.onSubscriptionEnd) - router._subscriptionRecorder = subscriptionRecorder - return router -} - -async function buildComposer (t, subgraphs, options) { - const promises = subgraphs.map(async (subgraph) => { - const subgraphFile = join(fixturesDir, subgraph) - delete require.cache[require.resolve(subgraphFile)] - const { - name, - resolvers, - schema - } = require(subgraphFile) - const server = Fastify() - t.after(async () => { try { await server.close() } catch {} }) - - server.register(Mercurius, { schema, resolvers, graphiql: true }) - server.get('/.well-known/graphql-composition', async function (req, reply) { - return reply.graphql(getIntrospectionQuery()) - }) - - return { - name, - entities: options.entities[subgraph], - server: { - host: await server.listen(), - composeEndpoint: '/.well-known/graphql-composition', - graphqlEndpoint: '/graphql' - } - } - }) - - const composerOptions = { - ...options, - subgraphs: await Promise.all(promises) - } - const composer = await compose(composerOptions) +async function createComposerService (t, { compose, options }) { + const composer = await compose(options) const service = Fastify() - t.after(async () => { try { await service.close() } catch {} }) + t.after(async () => { try { await service.close() } catch { } }) service.register(Mercurius, { schema: composer.toSdl(), - resolvers: composer.resolvers, + resolvers: composer.getResolvers(), graphiql: true }) return { composer, service } } -async function graphqlRequest (app, query, variables) { - const response = await app.inject({ +async function graphqlRequest (service, query, variables) { + const response = await service.inject({ path: '/graphql', method: 'POST', headers: { @@ -175,31 +37,72 @@ async function graphqlRequest (app, query, variables) { throw errors } - strictEqual(response.statusCode, 200) + assert.strictEqual(response.statusCode, 200) return data } -async function startGraphqlService (t, { fastify, mercurius, exposeIntrospection = {} }) { +async function createGraphqlServiceFromFile (t, { file, fastify, exposeIntrospection = {} }) { + delete require.cache[require.resolve(file)] + const config = require(file) + + const service = Fastify(fastify ?? { logger: false }) + service.register(Mercurius, { schema: config.schema, resolvers: config.resolvers }) + + if (exposeIntrospection) { + service.get(exposeIntrospection.path || '/.well-known/graphql-composition', async function (req, reply) { + return reply.graphql(introspectionQuery) + }) + } + + t.after(async () => { + try { await service.close() } catch { } + }) + + return { service, config } +} + +async function createGraphqlServiceFromConfig (t, { fastify, mercurius, exposeIntrospection = {} }) { const service = Fastify(fastify ?? { logger: false }) service.register(Mercurius, mercurius) if (exposeIntrospection) { service.get(exposeIntrospection.path || '/.well-known/graphql-composition', async function (req, reply) { - return reply.graphql(getIntrospectionQuery()) + return reply.graphql(introspectionQuery) }) } t.after(async () => { - try { - await service.close() - } catch { } // Ignore errors. + try { await service.close() } catch { } }) return service } +async function createGraphqlServices (t, servicesConfig) { + const services = [] + for (const config of servicesConfig) { + let service + if (config.file) { + const s = await createGraphqlServiceFromFile(t, config) + service = s.service + config.reset = s.config.reset + config.data = s.config.data + config.entities = s.config.entities + } else if (config.mercurius) { + service = await createGraphqlServiceFromConfig(t, config) + } + const s = { name: config.name, config, service } + if (config.listen) { + s.host = await service.listen() + } + services.push(s) + } + + return services +} + function assertObject (actual, expected) { for (const k of Object.keys(expected)) { if (typeof expected[k] === 'function' && typeof actual[k] === 'function') { continue } @@ -211,4 +114,4 @@ function assertObject (actual, expected) { } } -module.exports = { graphqlRequest, startRouter, buildComposer, startGraphqlService, assertObject } +module.exports = { graphqlRequest, createComposerService, createGraphqlServices, assertObject } diff --git a/test/index.test.js b/test/index.test.js deleted file mode 100644 index 5293109..0000000 --- a/test/index.test.js +++ /dev/null @@ -1,440 +0,0 @@ -'use strict' -const { deepStrictEqual } = require('node:assert') -const { test } = require('node:test') -const { graphqlRequest, startRouter } = require('./helper') - -test('proxy a simple single query to a single subgraph', async (t) => { - const router = await startRouter(t, ['authors-subgraph']) - const query = ` - query { - list { - id name { firstName lastName } - } - } - ` - const data = await graphqlRequest(router, query) - - deepStrictEqual(data, { - list: [{ id: '1', name: { firstName: 'Peter', lastName: 'Pluck' } }, { id: '2', name: { firstName: 'John', lastName: 'Writer' } }] - }) -}) - -test('query with a literal argument', async (t) => { - const router = await startRouter(t, ['books-subgraph']) - const query = ` - query { - getBook(id: 1) { - id genre - } - } - ` - const data = await graphqlRequest(router, query) - - deepStrictEqual(data, { - getBook: { id: '1', genre: 'FICTION' } - }) -}) - -test('query with a variable argument', async (t) => { - const router = await startRouter(t, ['books-subgraph']) - const query = ` - query GetBookById($id: ID!) { - getBook(id: $id) { - id genre - } - } - ` - const data = await graphqlRequest(router, query, { id: 2 }) - - deepStrictEqual(data, { - getBook: { id: '2', genre: 'NONFICTION' } - }) -}) - -test('nested query with a literal argument', async (t) => { - const router = await startRouter(t, ['authors-subgraph']) - const query = ` - query { - list { - id - name { - firstName - lastName - } - todos(id: 2) { - task - } - } - } - ` - const data = await graphqlRequest(router, query) - - deepStrictEqual(data, { - list: [ - { - id: '1', - name: { - firstName: 'Peter', - lastName: 'Pluck' - }, - todos: [ - { - task: 'Get really creative' - } - ] - }, - { - id: '2', - name: { - firstName: 'John', - lastName: 'Writer' - }, - todos: [ - { - task: 'Get really creative' - } - ] - } - ] - }) -}) - -test('nested query with a variable argument', async (t) => { - const router = await startRouter(t, ['authors-subgraph']) - const query = ` - query GetAuthorListWithTodos($id: ID!) { - list { - id - name { - firstName - lastName - } - todos(id: $id) { - task - } - } - } - ` - const data = await graphqlRequest(router, query, { id: 1 }) - - deepStrictEqual(data, { - list: [ - { - id: '1', - name: { - firstName: 'Peter', - lastName: 'Pluck' - }, - todos: [ - { - task: 'Write another book' - } - ] - }, - { - id: '2', - name: { - firstName: 'John', - lastName: 'Writer' - }, - todos: [ - { - task: 'Write another book' - } - ] - } - ] - }) -}) - -test('support query aliases', async (t) => { - const router = await startRouter(t, ['books-subgraph']) - const query = ` - query { - aliasedGetBook: getBook(id: 1) { - id genre - } - } - ` - const data = await graphqlRequest(router, query) - - deepStrictEqual(data, { - aliasedGetBook: { id: '1', genre: 'FICTION' } - }) -}) - -test('scalar return type', async (t) => { - const router = await startRouter(t, ['books-subgraph']) - const query = ` - query { - getBookTitle(id: 1) - } - ` - const data = await graphqlRequest(router, query) - - deepStrictEqual(data, { - getBookTitle: 'A Book About Things That Never Happened' - }) -}) - -test('query with meta fields', async (t) => { - const router = await startRouter(t, ['books-subgraph']) - const query = ` - query { - getBook(id: 1) { - __typename - ...on Book { id, genre } - } - } - ` - const data = await graphqlRequest(router, query) - - deepStrictEqual(data, { - getBook: { - __typename: 'Book', - id: '1', - genre: 'FICTION' - } - }) -}) - -test('query with a fragment', async (t) => { - const router = await startRouter(t, ['books-subgraph']) - const query = ` - fragment bookFields on Book { id title genre } - - query GetBookById($id: ID!) { - getBook(id: $id) { - ...bookFields - } - } - ` - const data = await graphqlRequest(router, query, { id: 1 }) - - deepStrictEqual(data, { - getBook: { - id: '1', - genre: 'FICTION', - title: 'A Book About Things That Never Happened' - } - }) -}) - -test('resolves a partial entity from a single subgraph', async (t) => { - const router = await startRouter(t, ['books-subgraph', 'reviews-subgraph']) - const query = ` - query { - getReviewBook(id: 1) { - id - reviews { - id - rating - content - } - } - } - ` - const data = await graphqlRequest(router, query) - - deepStrictEqual(data, { - getReviewBook: { - id: '1', - reviews: [ - { - id: '1', - rating: 2, - content: 'Would not read again.' - } - ] - } - }) -}) - -test('resolves an entity across multiple subgraphs', async (t) => { - const router = await startRouter(t, ['books-subgraph', 'reviews-subgraph']) - - await t.test('query flows from non-owner to owner subgraph', async (t) => { - const query = ` - query { - getReviewBook(id: 1) { - id - title - genre - reviews { - id - rating - content - } - } - } - ` - const data = await graphqlRequest(router, query) - - deepStrictEqual(data, { - getReviewBook: { - id: '1', - title: 'A Book About Things That Never Happened', - genre: 'FICTION', - reviews: [ - { - id: '1', - rating: 2, - content: 'Would not read again.' - } - ] - } - }) - }) - - await t.test('query flows from owner to non-owner subgraph', async (t) => { - const query = ` - query { - getBook(id: 1) { - id - title - genre - reviews { - id - rating - content - } - } - } - ` - const data = await graphqlRequest(router, query) - - deepStrictEqual(data, { - getBook: { - id: '1', - title: 'A Book About Things That Never Happened', - genre: 'FICTION', - reviews: [ - { - id: '1', - rating: 2, - content: 'Would not read again.' - } - ] - } - }) - }) - - await t.test('fetches key fields not in selection set', async (t) => { - const query = ` - query { - getReviewBook(id: 1) { - # id not included and it is part of the keys. - title - genre - reviews { - id - rating - content - } - } - } - ` - const data = await graphqlRequest(router, query) - - deepStrictEqual(data, { - getReviewBook: { - title: 'A Book About Things That Never Happened', - genre: 'FICTION', - reviews: [ - { - id: '1', - rating: 2, - content: 'Would not read again.' - } - ] - } - }) - }) -}) - -test('multiple queries in a single request', async (t) => { - const router = await startRouter(t, ['authors-subgraph', 'books-subgraph']) - const query = ` - query { - getBook(id: 2) { - id genre - } - list { - id name { firstName lastName } - } - } - ` - const data = await graphqlRequest(router, query) - - deepStrictEqual(data, { - getBook: { id: '2', genre: 'NONFICTION' }, - list: [{ id: '1', name: { firstName: 'Peter', lastName: 'Pluck' } }, { id: '2', name: { firstName: 'John', lastName: 'Writer' } }] - }) -}) - -test('Mutations', async () => { - await test('simple mutation', async (t) => { - const router = await startRouter(t, ['authors-subgraph']) - const query = ` - mutation CreateAuthor($author: AuthorInput!) { - createAuthor(author: $author) { - id name { firstName lastName } - } - } - ` - const author = { firstName: 'John', lastName: 'Johnson' } - const data = await graphqlRequest(router, query, { author }) - - deepStrictEqual(data, { - createAuthor: { - id: '3', - name: { firstName: 'John', lastName: 'Johnson' } - } - }) - }) - - await test('simple mutation with input object literal', async (t) => { - const router = await startRouter(t, ['authors-subgraph']) - const query = ` - mutation { - createAuthor(author: { firstName: "Tuco", lastName: "Gustavo" }) { - id name { firstName lastName } - } - } - ` - const data = await graphqlRequest(router, query) - - deepStrictEqual(data, { - createAuthor: { - id: '3', - name: { firstName: 'Tuco', lastName: 'Gustavo' } - } - }) - }) - - await test('mutation with input array', async (t) => { - const router = await startRouter(t, ['authors-subgraph']) - const query = ` - mutation { - batchCreateAuthor(authors: [ - { firstName: "Ernesto", lastName: "de la Cruz" }, - { firstName: "Hector", lastName: "Rivera" }, - ]) { - id name { firstName lastName } - } - } - ` - const data = await graphqlRequest(router, query) - - deepStrictEqual(data, { - batchCreateAuthor: [{ - id: '3', - name: { firstName: 'Ernesto', lastName: 'de la Cruz' } - }, - { - id: '4', - name: { firstName: 'Hector', lastName: 'Rivera' } - }] - }) - }) -}) diff --git a/test/network.test.js b/test/network.test.js index 1cc1eef..6d3ad23 100644 --- a/test/network.test.js +++ b/test/network.test.js @@ -4,40 +4,35 @@ const assert = require('node:assert') const { test } = require('node:test') const { NoSchemaIntrospectionCustomRule } = require('graphql') -const { compose } = require('../lib') -const { startGraphqlService } = require('./helper') +const { compose } = require('../') +const { createGraphqlServices } = require('./helper') -const service = { +const gql = { schema: 'type Query {\n add(x: Int, y: Int): Int\n}', resolvers: { Query: { add: (_, { x, y }) => x + y } } } -test('should get the schema from a subgraph service from composeEndpoint', async (t) => { - const expectedSdl = service.schema +test('should get the schema from a subgraph service from a custom composeEndpoint', async (t) => { + const expectedSdl = gql.schema - const instance = await startGraphqlService(t, { - mercurius: { - schema: service.schema, - resolvers: service.resolvers - }, - exposeIntrospection: { - path: '/get-introspection' - } - }) - service.host = await instance.listen() + const [service] = await createGraphqlServices(t, + [{ + mercurius: { ...gql }, + exposeIntrospection: { path: '/get-introspection' }, + listen: true + }] + ) let errors = 0 const composer = await compose({ onSubgraphError: () => { errors++ }, - subgraphs: [ - { - server: { - host: service.host, - composeEndpoint: '/get-introspection', - graphqlEndpoint: '/graphql' - } + subgraphs: [{ + server: { + host: service.host, + composeEndpoint: '/get-introspection', + graphqlEndpoint: '/graphql' } - ] + }] }) assert.strictEqual(errors, 0) @@ -45,28 +40,20 @@ test('should get the schema from a subgraph service from composeEndpoint', async }) test('should get the schema from a subgraph service from graphqlEndpoint using introspection query', async (t) => { - const expectedSdl = service.schema + const expectedSdl = gql.schema - const instance = await startGraphqlService(t, { - mercurius: { - schema: service.schema, - resolvers: service.resolvers - }, - exposeIntrospection: false - }) - service.host = await instance.listen() + const [service] = await createGraphqlServices(t, + [{ + mercurius: { ...gql }, + exposeIntrospection: false, + listen: true + }] + ) let errors = 0 const composer = await compose({ onSubgraphError: () => { errors++ }, - subgraphs: [ - { - server: { - host: service.host, - graphqlEndpoint: '/graphql' - } - } - ] + subgraphs: [{ server: { host: service.host } }] }) assert.strictEqual(errors, 0) @@ -74,20 +61,18 @@ test('should get the schema from a subgraph service from graphqlEndpoint using i }) test('should get error when is not possible to get the schema from a subgraph neither from composeEndpoint and using introspection query', async (t) => { - const instance = await startGraphqlService(t, { - mercurius: { - schema: service.schema, - resolvers: service.resolvers, - validationRules: [NoSchemaIntrospectionCustomRule] - }, - exposeIntrospection: false - }) - service.host = await instance.listen() + const [service] = await createGraphqlServices(t, + [{ + mercurius: { ...gql, validationRules: [NoSchemaIntrospectionCustomRule] }, + exposeIntrospection: false, + listen: true + }] + ) let errors = 0 await compose({ onSubgraphError: (error) => { - const expectedErrorMessage = `Could not process schema from '${service.host}'` + const expectedErrorMessage = `Could not process schema for subgraph '#0' from '${service.host}'` const expectedErrorCauseMessage = `Invalid introspection schema received from ${service.host}/custom-compose, ${service.host}/graphql` assert.strictEqual(error.message, expectedErrorMessage) assert.strictEqual(error.cause.message, expectedErrorCauseMessage) @@ -100,20 +85,18 @@ test('should get error when is not possible to get the schema from a subgraph ne }) test('should get error when composeEndpoint and graphqlEndpoint are both unreachable', async (t) => { - const instance = await startGraphqlService(t, { - mercurius: { - schema: service.schema, - resolvers: service.resolvers, - path: '/graph-custom-path' - }, - exposeIntrospection: false - }) - service.host = await instance.listen() + const [service] = await createGraphqlServices(t, + [{ + mercurius: { ...gql, path: '/graph-custom-path' }, + exposeIntrospection: false, + listen: true + }] + ) let errors = 0 await compose({ onSubgraphError: (error) => { - const expectedErrorMessage = `Could not process schema from '${service.host}'` + const expectedErrorMessage = `Could not process schema for subgraph '#0' from '${service.host}'` const expectedErrorCauseMessage = `Unable to get schema from ${service.host}/.well-known/graphql-composition (response 404) nor ${service.host}/graphql (response 404)` assert.strictEqual(error.message, expectedErrorMessage) assert.strictEqual(error.cause.message, expectedErrorCauseMessage) diff --git a/test/options.test.js b/test/options.test.js index 7eefbbf..49deca5 100644 --- a/test/options.test.js +++ b/test/options.test.js @@ -1,109 +1,80 @@ 'use strict' const assert = require('node:assert') const { test } = require('node:test') +const path = require('node:path') const { compose } = require('../lib') -const { startGraphqlService, graphqlRequest, startRouter } = require('./helper') - -test('should build a service using composer without subscriptions', async (t) => { - let calls = 0 - - const service = await startGraphqlService(t, { - mercurius: { - schema: ` - type Query { - add(x: Int, y: Int): Int - }`, - resolvers: { - Query: { - async add (_, { x, y }) { - calls++ - return x + y - } - } - } - } - }) - const host = await service.listen() - - const composer = await compose({ - subgraphs: [{ server: { host } }] - }) - - const router = await startGraphqlService(t, { - mercurius: { - schema: composer.toSdl(), - resolvers: composer.resolvers - } - }) - - await router.listen({ port: 0 }) - - const query = '{ add(x: 2, y: 2) }' - const data = await graphqlRequest(router, query) - assert.deepStrictEqual(data, { add: 4 }) - - assert.strictEqual(calls, 1) -}) +const { graphqlRequest, createGraphqlServices, createComposerService } = require('./helper') test('should use the defaultArgsAdapter provided in options', async (t) => { const query = '{ getReviewBook(id: 1) { title } }' const expectedResponse = { getReviewBook: { title: 'A Book About Things That Never Happened' } } let calls = 0 - const overrides = { + + const services = await createGraphqlServices(t, [ + { + name: 'books-subgraph', + file: path.join(__dirname, 'fixtures/books.js'), + listen: true + }, + { + name: 'reviews-subgraph', + file: path.join(__dirname, 'fixtures/reviews.js'), + listen: true + } + ]) + const options = { defaultArgsAdapter: (partialResults) => { calls++ - return { ids: partialResults.map(r => r.id) } + return { ids: partialResults.map(r => r.bookId) } }, - subgraphs: { - 'books-subgraph': { - entities: { - Book: { - pkey: 'id', - resolver: { - name: 'getBooksByIds', - argsAdapter: undefined - } - } - } - } - } + subgraphs: services.map(service => ({ + entities: service.config.entities, + name: service.name, + server: { host: service.host } + })) } - const router = await startRouter(t, ['books-subgraph', 'reviews-subgraph'], overrides) + options.subgraphs[0].entities.Book.resolver.argsAdapter = undefined - const response = await graphqlRequest(router, query) + const { service } = await createComposerService(t, { compose, options }) + const result = await graphqlRequest(service, query) assert.strictEqual(calls, 1) - assert.deepStrictEqual(response, expectedResponse) + assert.deepStrictEqual(result, expectedResponse) }) test('should use the generic argsAdapter if not provided', async (t) => { const query = '{ getReviewBook(id: 1) { title } }' const expectedResponse = { getReviewBook: { title: 'A Book About Things That Never Happened' } } - const overrides = { - subgraphs: { - 'books-subgraph': { - entities: { - Book: { - pkey: 'id', - resolver: { - name: 'getBooksByIds', - argsAdapter: undefined - } - } - } - } + const services = await createGraphqlServices(t, [ + { + name: 'books-subgraph', + file: path.join(__dirname, 'fixtures/books.js'), + listen: true + }, + { + name: 'reviews-subgraph', + file: path.join(__dirname, 'fixtures/reviews.js'), + listen: true } + ]) + const options = { + subgraphs: services.map(service => ({ + entities: service.config.entities, + name: service.name, + server: { host: service.host } + })) } - const router = await startRouter(t, ['books-subgraph', 'reviews-subgraph'], overrides) + options.subgraphs[1].entities.Book.resolver.argsAdapter = undefined - const response = await graphqlRequest(router, query) + const { service } = await createComposerService(t, { compose, options }) + const result = await graphqlRequest(service, query) - assert.deepStrictEqual(response, expectedResponse) + assert.deepStrictEqual(result, expectedResponse) }) const cases = [ diff --git a/test/query-builder.test.js b/test/query-builder.test.js deleted file mode 100644 index dea92e4..0000000 --- a/test/query-builder.test.js +++ /dev/null @@ -1,105 +0,0 @@ -'use strict' - -const assert = require('node:assert') -const t = require('node:test') -const { QueryBuilder } = require('../lib/query-builder') - -t.test('QueryBuilder', (t) => { - t.test('partialResults', () => { - const cases = [ - { - result: { getReviewBook: { id: '1', reviews: [{ id: '1', rating: 2, content: 'Would not read again.' }] } }, - path: ['getReviewBook'], - expected: [{ id: '1', reviews: [{ id: '1', rating: 2, content: 'Would not read again.' }] }] - }, - { - result: { getBooks: [{ id: '1', title: 'A Book About Things That Never Happened', genre: 'FICTION' }] }, - path: ['getBooks'], - expected: [{ id: '1', title: 'A Book About Things That Never Happened', genre: 'FICTION' }] - }, - { - result: { getReviewBook: { id: '1', reviews: [{ id: '1', rating: 2, content: 'Would not read again.' }] } }, - path: ['getReviewBook'], - expected: [{ id: '1', reviews: [{ id: '1', rating: 2, content: 'Would not read again.' }] }] - }, - { - result: { getReviewBookByIds: [{ reviews: [{ rating: 2 }], id: '1' }, { reviews: [{ rating: 3 }], id: '2' }, { reviews: [{ rating: 3 }, { rating: 5 }, { rating: 1 }], id: '3' }] }, - path: ['getReviewBookByIds'], - expected: [{ reviews: [{ rating: 2 }], id: '1' }, { reviews: [{ rating: 3 }], id: '2' }, { reviews: [{ rating: 3 }, { rating: 5 }, { rating: 1 }], id: '3' }] - }, - { - result: { getReviewBookByIds: [{ reviews: [{ rating: 2 }], id: '1' }, { reviews: [{ rating: 3 }], id: '2' }, { reviews: [{ rating: 3 }, { rating: 5 }, { rating: 1 }], id: '3' }] }, - path: ['getReviewBookByIds', 'author'], - expected: [null, null, null] - }, - { - result: { getReviewBookByIds: [{ reviews: [{ rating: 2 }], id: '1' }, { reviews: [{ rating: 3 }], id: '2' }, { reviews: [{ rating: 3 }, { rating: 5 }, { rating: 1 }], id: '3' }] }, - path: ['getReviewBookByIds'], - expected: [{ reviews: [{ rating: 2 }], id: '1' }, { reviews: [{ rating: 3 }], id: '2' }, { reviews: [{ rating: 3 }, { rating: 5 }, { rating: 1 }], id: '3' }] - }, - { - result: { getReviewBookByIds: [{ reviews: [{ rating: 2 }], id: '1' }] }, - path: ['getReviewBookByIds'], - expected: [{ reviews: [{ rating: 2 }], id: '1' }] - }, - { - result: { getReviewBookByIds: [] }, - path: ['getReviewBookByIds'], - expected: [] - }, - { - result: { booksByAuthors: [{ title: 'A Book About Things That Never Happened', author: { id: '10' }, id: '1' }, { title: 'A Book About Things That Really Happened', author: { id: '10' }, id: '2' }, { title: 'From the universe', author: { id: '11' }, id: '3' }, { title: 'From another world', author: { id: '11' }, id: '4' }] }, - path: ['booksByAuthors', 'author'], - expected: [{ id: '10' }, { id: '10' }, { id: '11' }, { id: '11' }] - }, - { - result: { reviewPosted: { id: '5', rating: 10, content: 'Not sure', book: { id: '1', reviews: [{ id: '1', rating: 2, content: 'Would not read again.' }, { id: '5', rating: 10, content: 'Not sure' }] } } }, - path: ['reviewPosted', 'book'], - expected: [{ id: '1', reviews: [{ id: '1', rating: 2, content: 'Would not read again.' }, { id: '5', rating: 10, content: 'Not sure' }] }] - }, - { - result: { booksByAuthors: [{ title: 'A Book About Things That Never Happened', author: { id: '10' }, id: '1' }, { title: 'A Book About Things That Really Happened', author: { id: '10' }, id: '2' }, { title: 'Watering the plants', author: { id: '11' }, id: '3' }, { title: 'Pruning the branches', author: { id: '11' }, id: '4' }] }, - path: ['booksByAuthors', 'author'], - expected: [{ id: '10' }, { id: '10' }, { id: '11' }, { id: '11' }] - }, - { - result: { artists: [{ lastName: 'Singer1', songs: [{ id: '2' }], id: '201' }, { lastName: 'Singer2', songs: [{ id: '3' }, { id: '4' }], id: '301' }, { lastName: 'Singer3', songs: [{ id: '5' }, { id: '6' }], id: '401' }] }, - path: ['artists', 'songs'], - expected: [{ id: '2' }, { id: '3' }, { id: '4' }, { id: '5' }, { id: '6' }] - }, - { - only: true, - test: 'should traverse lists of lists', - result: { - getReviewBooks: { - reviews: [ - { books: [{ title: 'book#1.1' }, { title: 'book#1.2' }] }, - { books: [{ title: 'book#2.1' }] }, - { books: [{ title: 'book#3.1' }, { title: 'book#3.2' }] } - ] - } - }, - path: ['getReviewBooks', 'reviews', 'books'], - expected: [{ title: 'book#1.1' }, { title: 'book#1.2' }, { title: 'book#2.1' }, { title: 'book#3.1' }, { title: 'book#3.2' }] - }, - { - result: { booksByAuthors: [{ title: 'A Book About Things That Never Happened', author: { id: '10' }, id: '1' }, { title: 'A Book About Things That Really Happened', author: { id: '10' }, id: '2' }, { title: 'Watering the plants', author: { id: '11' }, id: '3' }, { title: 'Pruning the branches', author: { id: '11' }, id: '4' }] }, - path: ['booksByAuthors', 'author', 'books'] - } - ] - - const options = { - info: { operation: { operation: 'query' } }, - type: { fieldMap: new Map() } - } - - for (let i = 0; i < cases.length; i++) { - const c = cases[i] - if (!c.only) { continue } - const q = new QueryBuilder({ path: c.path, ...options }) - const testName = c.test ?? 'case #' + (i + 1) - - assert.deepStrictEqual(q.partialResults(c.result), c.expected, testName) - } - }) -}) diff --git a/test/query.test.js b/test/query.test.js new file mode 100644 index 0000000..04a172a --- /dev/null +++ b/test/query.test.js @@ -0,0 +1,410 @@ +'use strict' + +const assert = require('node:assert') +const path = require('node:path') +const { test } = require('node:test') + +const { createComposerService, createGraphqlServices, graphqlRequest } = require('./helper') +const { compose } = require('../lib') + +test('should run a query to a single subgraph', async t => { + const query = '{ artists (where: { id: { in: ["103","102"] } }) { lastName } }' + const expectedResult = { artists: [{ lastName: 'Benigni' }, { lastName: 'Molko' }] } + + const services = await createGraphqlServices(t, [{ + name: 'artists-subgraph', + file: path.join(__dirname, 'fixtures/artists.js'), + listen: true + }]) + const options = { + subgraphs: services.map(service => ({ + name: service.name, + server: { host: service.host } + })) + } + + const { service } = await createComposerService(t, { compose, options }) + const result = await graphqlRequest(service, query) + + assert.deepStrictEqual(result, expectedResult) +}) + +test('should run a query to a single subgraph, with a nested type', async (t) => { + const query = ` + query { + list { + id name { firstName lastName } + } + } + ` + const expectedResult = { list: [{ id: '1', name: { firstName: 'Peter', lastName: 'Pluck' } }, { id: '2', name: { firstName: 'John', lastName: 'Writer' } }] } + + const services = await createGraphqlServices(t, [{ + name: 'authors-subgraph', + file: path.join(__dirname, 'fixtures/authors.js'), + listen: true + }]) + const options = { + subgraphs: services.map(service => ({ + name: service.name, + server: { host: service.host } + })) + } + + const { service } = await createComposerService(t, { compose, options }) + const result = await graphqlRequest(service, query) + + assert.deepStrictEqual(result, expectedResult) +}) + +test('should run a query with single result on multiple subgraphs', async t => { + const query = '{ getBook(id: 1) { id, title, genre, rate } }' + const expectedResult = { getBook: { id: '1', title: 'A Book About Things That Never Happened', genre: 'FICTION', rate: 3 } } + + const services = await createGraphqlServices(t, [ + { + name: 'books-subgraph', + file: path.join(__dirname, 'fixtures/books.js'), + listen: true + }, + { + name: 'reviews-subgraph', + file: path.join(__dirname, 'fixtures/reviews.js'), + listen: true + } + ]) + const options = { + subgraphs: services.map(service => ({ + entities: service.config.entities, + name: service.name, + server: { host: service.host } + })) + } + + const { service } = await createComposerService(t, { compose, options }) + const result = await graphqlRequest(service, query) + + assert.deepStrictEqual(result, expectedResult) +}) + +test('should run a query with list result on multiple subgraphs', async t => { + const query = '{ getBooksByIds(ids: [1,2,3]) { id, title, rate } }' + const expectedResult = { + + getBooksByIds: [ + { + id: '1', + rate: 3, + title: 'A Book About Things That Never Happened' + }, + { + id: '2', + rate: 4, + title: 'A Book About Things That Really Happened' + } + ] + } + + const services = await createGraphqlServices(t, [ + { + name: 'books-subgraph', + file: path.join(__dirname, 'fixtures/books.js'), + listen: true + }, + { + name: 'reviews-subgraph', + file: path.join(__dirname, 'fixtures/reviews.js'), + listen: true + } + ]) + const options = { + subgraphs: services.map(service => ({ + entities: service.config.entities, + name: service.name, + server: { host: service.host } + })) + } + + const { service } = await createComposerService(t, { compose, options }) + const result = await graphqlRequest(service, query) + + assert.deepStrictEqual(result, expectedResult) +}) + +test('should run a query that has nulls in results', async (t) => { + const query = + `{ + getReviewBookByIds(ids: [99,1,101,2]) { + title + reviews { rating } + } + }` + + const expectedResult = { + getReviewBookByIds: [ + { + reviews: [{ rating: 2 }], + title: 'A Book About Things That Never Happened' + }, + { + reviews: [{ rating: 3 }], + title: 'A Book About Things That Really Happened' + } + ] + } + + const services = await createGraphqlServices(t, [ + { + name: 'books-subgraph', + file: path.join(__dirname, 'fixtures/books.js'), + listen: true + }, + { + name: 'reviews-subgraph', + file: path.join(__dirname, 'fixtures/reviews.js'), + listen: true + } + ]) + const options = { + subgraphs: services.map(service => ({ + entities: service.config.entities, + name: service.name, + server: { host: service.host } + })) + } + + const { service } = await createComposerService(t, { compose, options }) + const result = await graphqlRequest(service, query) + + assert.deepStrictEqual(result, expectedResult) +}) + +test('should run a query that has null results', async (t) => { + const query = '{ getReviewBookByIds(ids: [-1,-2,-3]) { title reviews { rating } } }' + + const expectedResult = { + getReviewBookByIds: [] + } + + const services = await createGraphqlServices(t, [ + { + name: 'books-subgraph', + file: path.join(__dirname, 'fixtures/books.js'), + listen: true + }, + { + name: 'reviews-subgraph', + file: path.join(__dirname, 'fixtures/reviews.js'), + listen: true + } + ]) + const options = { + subgraphs: services.map(service => ({ + entities: service.config.entities, + name: service.name, + server: { host: service.host } + })) + } + + const { service } = await createComposerService(t, { compose, options }) + const result = await graphqlRequest(service, query) + + assert.deepStrictEqual(result, expectedResult) +}) + +test('query capabilities', async t => { + const capabilities = [ + { + name: 'should run a query with a literal argument', + query: 'query { getBook(id: 1) { id genre } }', + result: { getBook: { id: '1', genre: 'FICTION' } } + }, + { + name: 'should run a query with a variable argument', + query: 'query GetBookById($id: ID!) { getBook(id: $id) { id genre } }', + variables: { id: 2 }, + result: { getBook: { id: '2', genre: 'NONFICTION' } } + }, + { + name: 'should run a query with aliases', + query: 'query { aliasedGetBook: getBook(id: 1) { id genre } }', + result: { aliasedGetBook: { id: '1', genre: 'FICTION' } } + }, + { + name: 'should run a query returning a scalar type', + query: 'query { getBookTitle (id: 1) }', + result: { getBookTitle: 'A Book About Things That Never Happened' } + }, + { + name: 'should run a query with meta fields', + query: '{ getBook(id: 1) { __typename ...on Book { id, genre } } }', + result: { getBook: { __typename: 'Book', id: '1', genre: 'FICTION' } } + }, + { + name: 'should run a query with a fragment', + query: `fragment bookFields on Book { id title genre } + query GetBookById($id: ID!) { getBook(id: $id) { ...bookFields } }`, + variables: { id: 1 }, + result: { getBook: { id: '1', genre: 'FICTION', title: 'A Book About Things That Never Happened' } } + }, + { + name: 'should run a query with a literal argument', + query: 'query { list { id name { firstName lastName } todos (id: 2) { task } } }', + result: { + list: [ + { id: '1', name: { firstName: 'Peter', lastName: 'Pluck' }, todos: [{ task: 'Get really creative' }] }, + { id: '2', name: { firstName: 'John', lastName: 'Writer' }, todos: [{ task: 'Get really creative' }] } + ] + } + }, + { + name: 'should run a query query with a variable argument', + query: 'query GetAuthorListWithTodos ($id: ID!) { list { id name { firstName lastName } todos(id: $id) { task } } }', + variables: { id: 1 }, + result: { + list: [ + { id: '1', name: { firstName: 'Peter', lastName: 'Pluck' }, todos: [{ task: 'Write another book' }] }, + { id: '2', name: { firstName: 'John', lastName: 'Writer' }, todos: [{ task: 'Write another book' }] } + ] + } + }, + { + name: 'should run multiple queries in a single request', + query: `query { + getBook(id: 2) { id genre } + list { id name { firstName lastName } } + }`, + result: { + getBook: { id: '2', genre: 'NONFICTION' }, + list: [{ id: '1', name: { firstName: 'Peter', lastName: 'Pluck' } }, { id: '2', name: { firstName: 'John', lastName: 'Writer' } }] + } + } + ] + + let service + t.before(async () => { + const services = await createGraphqlServices(t, [ + { + name: 'books-subgraph', + file: path.join(__dirname, 'fixtures/books.js'), + listen: true + }, + { + name: 'authors-subgraph', + file: path.join(__dirname, 'fixtures/authors.js'), + listen: true + } + ]) + const options = { + subgraphs: services.map(service => ({ + name: service.name, + server: { host: service.host } + })) + } + + const s = await createComposerService(t, { compose, options }) + service = s.service + }) + + for (const c of capabilities) { + await t.test(c.name, async (t) => { + const result = await graphqlRequest(service, c.query, c.variables) + + assert.deepStrictEqual(result, c.result) + }) + } +}) + +test('mutations', async t => { + const mutations = [ + { + name: 'should run a mutation query with variables', + query: ` + mutation CreateAuthor($author: AuthorInput!) { + createAuthor(author: $author) { + id name { firstName lastName } + } + } + `, + variables: { author: { firstName: 'John', lastName: 'Johnson' } }, + result: { + createAuthor: { + id: '3', + name: { firstName: 'John', lastName: 'Johnson' } + } + } + }, + + { + name: 'should run a mutation query with input object literal', + query: ` + mutation { + createAuthor(author: { firstName: "Tuco", lastName: "Gustavo" }) { + id name { firstName lastName } + } + } + `, + result: { createAuthor: { id: '3', name: { firstName: 'Tuco', lastName: 'Gustavo' } } } + }, + + { + name: 'should run a mutation query with an array as input', + query: ` + mutation { + batchCreateAuthor(authors: [ + { firstName: "Ernesto", lastName: "de la Cruz" }, + { firstName: "Hector", lastName: "Rivera" }, + ]) { + id name { firstName lastName } + } + } + `, + result: { + batchCreateAuthor: [{ + id: '3', + name: { firstName: 'Ernesto', lastName: 'de la Cruz' } + }, + { + id: '4', + name: { firstName: 'Hector', lastName: 'Rivera' } + }] + } + }] + + let service, services + t.before(async () => { + services = await createGraphqlServices(t, [ + { + name: 'books-subgraph', + file: path.join(__dirname, 'fixtures/books.js'), + listen: true + }, + { + name: 'authors-subgraph', + file: path.join(__dirname, 'fixtures/authors.js'), + listen: true + } + ]) + const options = { + subgraphs: services.map(service => ({ + name: service.name, + server: { host: service.host } + })) + } + + const s = await createComposerService(t, { compose, options }) + service = s.service + }) + + t.beforeEach(() => { + services.forEach(s => s.config.reset()) + }) + + for (const c of mutations) { + await t.test(c.name, async (t) => { + const result = await graphqlRequest(service, c.query, c.variables) + + assert.deepStrictEqual(result, c.result) + }) + } +}) diff --git a/test/result.test.js b/test/result.test.js new file mode 100644 index 0000000..e0f00fe --- /dev/null +++ b/test/result.test.js @@ -0,0 +1,29 @@ +'use strict' + +const assert = require('node:assert') +const { test } = require('node:test') +const { copyResultRow } = require('../lib/result') + +test('copyResultRow unit test', t => { + const dst = [{ title: 'Every you every me', id: '1', singerId: '103' }, { title: 'The bitter end', id: '2', singerId: '103' }] + const src = [{ firstName: 'Brian', id: '103', lastName: 'Molko' }] + const srcIndex = { list: false, map: new Map([['103', [0]]]) } + const parentKey = 'singerId' + const keyPath = ['songs', 'singerId'] + const fillPath = ['singer'] + + copyResultRow(dst, src, srcIndex, parentKey, keyPath, fillPath) + + assert.deepStrictEqual(dst, [{ + title: 'Every you every me', + id: '1', + singerId: '103', + singer: { firstName: 'Brian', id: '103', lastName: 'Molko' } + }, + { + title: 'The bitter end', + id: '2', + singerId: '103', + singer: { firstName: 'Brian', id: '103', lastName: 'Molko' } + }]) +}) diff --git a/test/runner.js b/test/runner.js deleted file mode 100644 index e1c27f9..0000000 --- a/test/runner.js +++ /dev/null @@ -1,22 +0,0 @@ -'use strict' - -const { tap, spec } = require('node:test/reporters') -const { run } = require('node:test') -const glob = require('glob').globSync - -/* eslint-disable new-cap */ -const reporter = process.stdout.isTTY ? new spec() : tap - -const files = glob('test/**/*.test.js') - -const stream = run({ - files, - timeout: 30_000, - concurrency: files.length -}) - -stream.on('test:fail', () => { - process.exitCode = 1 -}) - -stream.compose(reporter).pipe(process.stdout) diff --git a/test/subscriptions.test.js b/test/subscriptions.test.js deleted file mode 100644 index f21d95f..0000000 --- a/test/subscriptions.test.js +++ /dev/null @@ -1,199 +0,0 @@ -'use strict' -const assert = require('node:assert') -const { once } = require('node:events') -const { test } = require('node:test') -const { setTimeout: sleep } = require('node:timers/promises') -const { SubscriptionClient } = require('@mercuriusjs/subscription-client') -const { graphqlRequest, startRouter } = require('./helper') - -test('simple subscription', async (t) => { - const router = await startRouter(t, ['authors-subgraph']) - - await router.listen() - const wsUrl = `ws://localhost:${router.server.address().port}/graphql` - const client = new SubscriptionClient(wsUrl, { serviceName: 'test' }) - - t.after(() => { - try { - client.unsubscribeAll() - client.close() - } catch {} // Ignore any errors. The client should already be closed. - }) - - client.connect() - await once(client, 'ready') - client.createSubscription(` - subscription { - postPublished { - authorId - } - } - `, {}, (data) => { - client.emit('message', data.payload) - }) - await sleep(200) // Make sure the subscription has finished setting up. - const mutation = await graphqlRequest(router, ` - mutation { - publishBlogPost(authorId: "2299") - } - `) - assert.deepStrictEqual(mutation, { publishBlogPost: true }) - const [message] = await once(client, 'message') - assert.deepStrictEqual(message, { postPublished: { authorId: '2299' } }) - - const mutation2 = await graphqlRequest(router, ` - mutation { - publishBlogPost(authorId: "3333") - } - `) - assert.deepStrictEqual(mutation2, { publishBlogPost: true }) - const [message2] = await once(client, 'message') - assert.deepStrictEqual(message2, { postPublished: { authorId: '3333' } }) - - client.unsubscribeAll() - client.close() - await sleep(200) // Make sure the subscription has finished tearing down. - - const mutation3 = await graphqlRequest(router, ` - mutation { - publishBlogPost(authorId: "4444") - } - `) - assert.deepStrictEqual(mutation3, { publishBlogPost: true }) - assert.deepStrictEqual(router._subscriptionRecorder, [ - { action: 'subscribe', topic: '1' }, - { - action: 'publish', - topic: '1', - payload: { postPublished: { authorId: '2299' } } - }, - { - action: 'publish', - topic: '1', - payload: { postPublished: { authorId: '3333' } } - }, - { action: 'unsubscribe', topic: '1' } - ]) -}) - -test('subscription with followup queries', async (t) => { - const router = await startRouter(t, ['books-subgraph', 'reviews-subgraph']) - - await router.listen() - const wsUrl = `ws://localhost:${router.server.address().port}/graphql` - const client = new SubscriptionClient(wsUrl, { serviceName: 'test' }) - - t.after(() => { - try { - client.unsubscribeAll() - client.close() - } catch {} // Ignore any errors. The client should already be closed. - }) - - client.connect() - await once(client, 'ready') - client.createSubscription(` - subscription { - reviewPosted { - id rating content book { id title genre reviews { id rating content } } - } - } - `, {}, (data) => { - client.emit('message', data.payload) - }) - await sleep(200) // Make sure the subscription has finished setting up. - const mutation = await graphqlRequest(router, ` - mutation { - createReview(review: { bookId: "1", rating: 10, content: "Not sure" }) { - id rating content - } - } - `) - assert.deepStrictEqual(mutation, { - createReview: { - id: '5', - rating: 10, - content: 'Not sure' - } - }) - const [message] = await once(client, 'message') - assert.deepStrictEqual(message, { - reviewPosted: { - id: '5', - rating: 10, - content: 'Not sure', - book: { - id: '1', - genre: 'FICTION', - title: 'A Book About Things That Never Happened', - reviews: [ - { - id: '1', - rating: 2, - content: 'Would not read again.' - }, - { - id: '5', - rating: 10, - content: 'Not sure' - } - ] - } - } - }) -}) - -test('subscription errors are propagated', async (t) => { - const overrides = { - subscriptions: { - onError (ctx, topic, error) { - client.emit('subscriptionError', error) - }, - publish (ctx, topic, payload) { - throw new Error('boom') - } - } - } - const subgraphs = ['books-subgraph', 'reviews-subgraph'] - const router = await startRouter(t, subgraphs, overrides) - - await router.listen() - const wsUrl = `ws://localhost:${router.server.address().port}/graphql` - const client = new SubscriptionClient(wsUrl, { serviceName: 'test' }) - - t.after(() => { - try { - client.unsubscribeAll() - client.close() - } catch {} // Ignore any errors. The client should already be closed. - }) - - client.connect() - await once(client, 'ready') - client.createSubscription(` - subscription { - reviewPosted { - id rating content book { id title genre reviews { id rating content } } - } - } - `, {}, (data) => { - client.emit('message', data.payload) - }) - await sleep(200) // Make sure the subscription has finished setting up. - const mutation = await graphqlRequest(router, ` - mutation { - createReview(review: { bookId: "1", rating: 10, content: "Not sure" }) { - id rating content - } - } - `) - assert.deepStrictEqual(mutation, { - createReview: { - id: '5', - rating: 10, - content: 'Not sure' - } - }) - const [error] = await once(client, 'subscriptionError') - assert.strictEqual(error.message, 'boom') -})