Skip to content

Commit

Permalink
fix: nested foreign entities (#27)
Browse files Browse the repository at this point in the history
* fix: nested foreing entities
  • Loading branch information
simone-sanfratello authored Nov 8, 2023
1 parent 56c50d3 commit 9cecd9e
Show file tree
Hide file tree
Showing 4 changed files with 284 additions and 16 deletions.
35 changes: 25 additions & 10 deletions lib/composer.js
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,11 @@ class Composer {
continue
}

if (!query.root) {
const parentPath = query.path.slice(0, -1)
followup.path = parentPath.concat(followup.path)
}

const followupQuery = new QueryBuilder(followup)
// All followups are query operations.
followupQuery.operation = 'query'
Expand Down Expand Up @@ -529,25 +534,35 @@ function mergeResults (query, partialResult, response) {
for (let i = 0; i < mergedPartial.length; i++) {
const merging = mergedPartial[i]

const index = resultIndex.get(merging[originKey])
if (index === undefined) { continue }

const fields = Object.keys(result[index])
for (let j = 0; j < fields.length; j++) {
// TODO if key === originKey && fields[j] === key continue
merging[fields[j]] = result[index][fields[j]]
// no need to be recursive
if (Array.isArray(merging)) {
for (let j = 0; j < merging.length; j++) {
copyResult(result, resultIndex, merging[j], originKey)
}
continue
}

copyResult(result, resultIndex, merging, originKey)
}
} else if (isObject(result)) {
for (const [k, v] of Object.entries(result)) {
mergedPartial[k] = v
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
}
}

return mergedPartial
function copyResult (result, resultIndex, to, key) {
const index = resultIndex.get(to[key])
if (index === undefined) { return }

const fields = Object.keys(result[index])
for (let i = 0; i < fields.length; i++) {
to[fields[i]] = result[index][fields[i]]
}
}

function selectResult (query, partialResult, response) {
Expand Down
16 changes: 10 additions & 6 deletions lib/query-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,21 @@ class QueryBuilder {
partialResults (result) {
for (let i = 0; i < this.path.length; ++i) {
const path = this.path[i]
const node = result[path]

if (Array.isArray(result)) {
result = result.map(r => r[path])
result = result.map(r => r[path] ?? null)
continue
}
result = node

const node = result[path]
result = node ?? null
}

if (!Array.isArray(result)) {
result = [result]
}

return Array.isArray(result)
? result
: [result]
return result.flat()
}

runArgsAdapter (results) {
Expand Down
144 changes: 144 additions & 0 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,150 @@ test('entities', async () => {
deepStrictEqual(response, expectedResponse)
})

await test('should resolve nested foreign types with lists in result', async (t) => {
const query = `{
booksByAuthors(authorIds: [10,11,12]) {
title
author {
name { firstName, lastName }
books {
reviews { rating }
}
}
}
}`

const expectedResponse = { booksByAuthors: [{ title: 'A Book About Things That Never Happened', author: { name: { firstName: 'Mark', lastName: 'Dark' }, books: [{ reviews: [{ rating: 2 }] }, { reviews: [{ rating: 3 }] }] } }, { title: 'A Book About Things That Really Happened', author: { name: { firstName: 'Mark', lastName: 'Dark' }, books: [{ reviews: [{ rating: 2 }] }, { reviews: [{ rating: 3 }] }] } }, { title: 'Watering the plants', author: { name: { firstName: 'Daisy', lastName: 'Dyson' }, books: [{ reviews: [{ rating: 3 }, { rating: 5 }, { rating: 1 }] }, { reviews: [] }] } }, { title: 'Pruning the branches', author: { name: { firstName: 'Daisy', lastName: 'Dyson' }, books: [{ reviews: [{ rating: 3 }, { rating: 5 }, { rating: 1 }] }, { reviews: [] }] } }] }

const extend = {
'authors-subgraph': (data) => {
data.authors[10] = {
id: 10,
name: {
firstName: 'Mark',
lastName: 'Dark'
}
}
data.authors[11] = {
id: 11,
name: {
firstName: 'Daisy',
lastName: 'Dyson'
}
}

return {
schema: `
input IdsIn {
in: [ID]!
}
input WhereIdsIn {
ids: IdsIn
}
type Book {
id: ID!
}
extend type Query {
authors (where: WhereIdsIn): [Author]
}
extend type Author {
books: [Book]
}
`,
resolvers: {
Query: {
authors: (_, args) => Object.values(data.authors).filter(a => args.where.ids.in.includes(String(a.id)))
},
Author: {
books: (author, args, context, info) => {
// pretend to call books-subgraph service
const books = {
10: [{ id: 1 }, { id: 2 }],
11: [{ id: 3 }, { id: 4 }]
}
return books[author?.id]
}
}
}
}
},
'books-subgraph': (data) => {
data.library[1].authorId = 10
data.library[2].authorId = 10
data.library[3] = {
id: 3,
title: 'Watering the plants',
genre: 'NONFICTION',
authorId: 11
}
data.library[4] = {
id: 4,
title: 'Pruning the branches',
genre: 'NONFICTION',
authorId: 11
}

return {
schema: `
type Author {
id: ID
}
extend type Book {
author: Author
}
extend type Query {
booksByAuthors(authorIds: [ID!]!): [Book]
}
`,
resolvers: {
Book: {
author: (parent) => ({ id: parent.authorId || data.library[parent.id]?.authorId })
},
Query: {
booksByAuthors: (parent, { authorIds }) => Object.values(data.library).filter(book => authorIds.includes(String(book.authorId)))
}
}
}
}
}
const overrides = {
subgraphs: {
'books-subgraph': {
entities: {
Book: {
referenceListResolverName: 'getBooksByIds',
argsAdapter: (partialResults) => ({ ids: partialResults.map(r => r.id) }),
keys: [{ field: 'id', type: 'Book' }, { field: 'author.id', type: 'Author' }]
}
}
},
'authors-subgraph': {
entities: {
Author: {
referenceListResolverName: 'authors',
argsAdapter: (partialResults) => ({ where: { ids: { in: partialResults.map(r => r.id) } } }),
keys: [{ field: 'id', type: 'Author' }]
},
Book: {
keys: [{ field: 'id' }]
}
}
}
}
}

const router = await startRouter(t, ['authors-subgraph', 'books-subgraph', 'reviews-subgraph'], overrides, extend)

const response = await graphqlRequest(router, query)

deepStrictEqual(response, expectedResponse)
})

// TODO results: list, single, nulls, partials
// TODO when an entity is spread across multiple subgraphs
// TODO should throw error (timeout?) resolving type entity
Expand Down
105 changes: 105 additions & 0 deletions test/query-builder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
'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)
}
})
})

0 comments on commit 9cecd9e

Please sign in to comment.