diff --git a/API_DOCS.md b/API_DOCS.md index d07d8af..b1a7b43 100644 --- a/API_DOCS.md +++ b/API_DOCS.md @@ -94,6 +94,8 @@ resources: commaSeparatedBatchKey: ?string (can only use if isBatchResource=true) isResponseDictionary: ?boolean (can only use if isBatchResource=true) isBatchKeyASet: ?boolean (can only use if isBatchResource=true) + propertyBatchKey: ?string (can only use if isBatchResource=true) + propertyNewKey: ?string (can only use if isBatchResource=true) typings: language: flow @@ -125,6 +127,8 @@ Describes the shape and behaviour of the resources object you will pass to `getL | `commaSeparatedBatchKey` | (Optional) Set to true if the interface of the resource takes the batch key as a comma separated list (rather than an array of IDs, as is more common). Default: false | | `isResponseDictionary` | (Optional) Set to true if the batch resource returns the results as a dictionary with key mapped to values (instead of a list of items). If this option is supplied `reorderResultsByKey` should not be. Default: false | | `isBatchKeyASet` | (Optional) Set to true if the interface of the resource takes the batch key as a set (rather than an array). For example, when using a generated clientlib based on swagger where `uniqueItems: true` is set for the batchKey parameter. Default: false. | +| `propertyBatchKey` | (Optional) (Optional) The argument to the resource that represents the nested list of optional properties we want to fetch. (e.g. usually 'properties' or 'features'). | +| `propertyNewKey` | (Optional) (Optional) The argument we'll replace the propertyBatchKey with - should be a singular version of the propertyBatchKey (e.g. usually 'property' or 'feature'). | ### `typings` diff --git a/__tests__/implementation.test.js b/__tests__/implementation.test.js index f4e3767..fdecf73 100644 --- a/__tests__/implementation.test.js +++ b/__tests__/implementation.test.js @@ -344,7 +344,6 @@ test('batch endpoint that rejects', async () => { { foo_id: 4, include_extra_info: true }, { foo_id: 5, include_extra_info: true }, ]); - // NonError comes from the default error handler which uses ensure-error expect(results).toMatchObject([ expect.toBeError(/yikes/, 'NonError'), @@ -1217,3 +1216,659 @@ test('bail if errorHandler does not return an error', async () => { ]); }); }); + +test('batch endpoint (multiple requests) with propertyBatchKey', async () => { + const config = { + resources: { + foo: { + isBatchResource: true, + docsLink: 'example.com/docs/bar', + batchKey: 'foo_ids', + newKey: 'foo_id', + propertyBatchKey: 'properties', + propertyNewKey: 'property', + mergePropertyConfig: 'PropertyAtTopLevel', + }, + }, + }; + + const resources = { + foo: ({ foo_ids, properties, include_extra_info }) => { + if (_.isEqual(foo_ids, [2, 1])) { + expect(include_extra_info).toBe(false); + return Promise.resolve([ + { foo_id: 1, rating: 3, name: 'Burger King' }, + { foo_id: 2, rating: 4, name: 'In N Out' }, + ]); + } + + if (_.isEqual(foo_ids, [3])) { + expect(include_extra_info).toBe(true); + return Promise.resolve([ + { + foo_id: 3, + rating: 5, + name: 'Shake Shack', + extra_stuff: 'lorem ipsum', + }, + ]); + } + }, + }; + + await createDataLoaders(config, async (getLoaders) => { + const loaders = getLoaders(resources); + + const results = await loaders.foo.loadMany([ + { foo_id: 2, property: 'name', include_extra_info: false }, + { foo_id: 1, property: 'rating', include_extra_info: false }, + { foo_id: 3, property: 'rating', include_extra_info: true }, + ]); + + expect(results).toEqual([ + { foo_id: 2, name: 'In N Out' }, + { foo_id: 1, rating: 3 }, + { foo_id: 3, rating: 5 }, + ]); + }); +}); + +test('batch endpoint (multiple requests) with propertyBatchKey returned in a nested object ', async () => { + const config = { + resources: { + foo: { + isBatchResource: true, + docsLink: 'example.com/docs/bar', + batchKey: 'foo_ids', + newKey: 'foo_id', + propertyBatchKey: 'properties', + propertyNewKey: 'property', + mergePropertyConfig: 'PropertyInNestedObject', + }, + }, + }; + + const resources = { + foo: ({ foo_ids, properties, include_extra_info }) => { + if (_.isEqual(foo_ids, [2, 1])) { + expect(include_extra_info).toBe(false); + return Promise.resolve([ + { foo_id: 1, properties: { rating: 3, name: 'Burger King' } }, + { foo_id: 2, properties: { rating: 4, name: 'In N Out' } }, + ]); + } + + if (_.isEqual(foo_ids, [3])) { + expect(include_extra_info).toBe(true); + return Promise.resolve([ + { + foo_id: 3, + properties: { + rating: 5, + name: 'Shake Shack', + }, + }, + ]); + } + }, + }; + + await createDataLoaders(config, async (getLoaders) => { + const loaders = getLoaders(resources); + + const results = await loaders.foo.loadMany([ + { foo_id: 2, property: 'name', include_extra_info: false }, + { foo_id: 1, property: 'rating', include_extra_info: false }, + { foo_id: 3, property: 'rating', include_extra_info: true }, + ]); + + expect(results).toEqual([ + { foo_id: 2, properties: { name: 'In N Out' } }, + { foo_id: 1, properties: { rating: 3 } }, + { foo_id: 3, properties: { rating: 5 } }, + ]); + }); +}); + +test('batch endpoint (with commaSeparatedBatchKey) with propertyBatchKey', async () => { + const config = { + resources: { + foo: { + isBatchResource: true, + docsLink: 'example.com/docs/bar', + batchKey: 'foo_ids', + newKey: 'foo_id', + commaSeparatedBatchKey: true, + propertyBatchKey: 'properties', + propertyNewKey: 'property', + mergePropertyConfig: 'PropertyInNestedObject', + }, + }, + }; + + const resources = { + foo: ({ foo_ids }) => { + expect(foo_ids).toEqual('2,1,3'); + return Promise.resolve([ + { foo_id: 1, properties: { rating: 3, name: 'Burger King' } }, + { foo_id: 2, properties: { rating: 4, name: 'Shake Shack' } }, + { foo_id: 3, properties: { rating: 5, name: 'In N Out' } }, + ]); + }, + }; + + await createDataLoaders(config, async (getLoaders) => { + const loaders = getLoaders(resources); + + const results = await loaders.foo.loadMany([ + { foo_id: 2, property: 'name' }, + { foo_id: 1, property: 'rating' }, + { foo_id: 3, property: 'rating' }, + ]); + expect(results).toEqual([ + { foo_id: 2, properties: { name: 'Shake Shack' } }, + { foo_id: 1, properties: { rating: 3 } }, + { foo_id: 3, properties: { rating: 5 } }, + ]); + }); +}); + +test('batch endpoint (with nestedPath) with propertyBatchKey', async () => { + const config = { + resources: { + foo: { + isBatchResource: true, + docsLink: 'example.com/docs/bar', + batchKey: 'foo_ids', + newKey: 'foo_id', + nestedPath: 'foos', + propertyBatchKey: 'properties', + propertyNewKey: 'property', + mergePropertyConfig: 'PropertyInNestedObject', + }, + }, + }; + + const resources = { + foo: ({ foo_ids }) => { + expect(foo_ids).toEqual([2, 1, 3]); + return Promise.resolve({ + foos: [ + { foo_id: 1, properties: { rating: 3, name: 'Burger King' } }, + { foo_id: 2, properties: { rating: 4, name: 'Shake Shack' } }, + { foo_id: 3, properties: { rating: 5, name: 'In N Out' } }, + ], + }); + }, + }; + + await createDataLoaders(config, async (getLoaders) => { + const loaders = getLoaders(resources); + + const results = await loaders.foo.loadMany([ + { foo_id: 2, property: 'name' }, + { foo_id: 1, property: 'rating' }, + { foo_id: 3, property: 'rating' }, + ]); + expect(results).toEqual([ + { foo_id: 2, properties: { name: 'Shake Shack' } }, + { foo_id: 1, properties: { rating: 3 } }, + { foo_id: 3, properties: { rating: 5 } }, + ]); + }); +}); + +test('batch endpoint (multiple requests) with propertyBatchKey that rejects', async () => { + const config = { + resources: { + foo: { + isBatchResource: true, + docsLink: 'example.com/docs/bar', + batchKey: 'foo_ids', + newKey: 'foo_id', + propertyBatchKey: 'properties', + propertyNewKey: 'property', + mergePropertyConfig: 'PropertyAtTopLevel', + }, + }, + }; + + const resources = { + foo: ({ foo_ids, properties, include_extra_info }) => { + if (_.isEqual(foo_ids, [1, 3])) { + expect(include_extra_info).toBe(false); + return Promise.reject('yikes'); + } + if (_.isEqual(foo_ids, [2, 4, 5])) { + expect(include_extra_info).toBe(true); + return Promise.resolve([ + { + foo_id: 2, + name: 'Burger King', + rating: 3, + }, + { + foo_id: 4, + name: 'In N Out', + rating: 3.5, + }, + { + foo_id: 5, + name: 'Shake Shack', + rating: 4, + extra_stuff: 'lorem ipsum', + }, + ]); + } + }, + }; + + await createDataLoaders(config, async (getLoaders) => { + const loaders = getLoaders(resources); + + const results = await loaders.foo.loadMany([ + { foo_id: 1, property: 'name', include_extra_info: false }, + { foo_id: 2, property: 'rating', include_extra_info: true }, + { foo_id: 3, property: 'name', include_extra_info: false }, + { foo_id: 4, property: 'rating', include_extra_info: true }, + { foo_id: 5, property: 'name', include_extra_info: true }, + ]); + + expect(results).toMatchObject([ + expect.toBeError(/yikes/, 'NonError'), + { foo_id: 2, rating: 3 }, + expect.toBeError(/yikes/, 'NonError'), + { foo_id: 4, rating: 3.5 }, + { foo_id: 5, name: 'Shake Shack' }, + ]); + }); +}); + +test('batch endpoint (multiple requests) with propertyBatchKey error handling', async () => { + const config = { + resources: { + foo: { + isBatchResource: true, + docsLink: 'example.com/docs/bar', + batchKey: 'foo_ids', + newKey: 'foo_id', + propertyBatchKey: 'properties', + propertyNewKey: 'property', + mergePropertyConfig: 'PropertyAtTopLevel', + }, + }, + }; + + const resources = { + foo: ({ foo_ids, properties, include_extra_info }) => { + if (_.isEqual(foo_ids, [2, 4, 5])) { + expect(include_extra_info).toBe(true); + return Promise.resolve([ + { + foo_id: 2, + name: 'Burger King', + rating: 3, + }, + { + foo_id: 4, + name: 'In N Out', + rating: 3.5, + }, + { + foo_id: 5, + name: 'Shake Shack', + rating: 4, + extra_stuff: 'lorem ipsum', + }, + ]); + } + if (_.isEqual(foo_ids, [1, 3])) { + expect(include_extra_info).toBe(false); + throw new Error('yikes'); + } + }, + }; + + await createDataLoaders(config, async (getLoaders) => { + const loaders = getLoaders(resources); + + const results = await loaders.foo.loadMany([ + { foo_id: 1, property: 'name', include_extra_info: false }, + { foo_id: 2, property: 'rating', include_extra_info: true }, + { foo_id: 3, property: 'name', include_extra_info: false }, + { foo_id: 4, property: 'rating', include_extra_info: true }, + { foo_id: 5, property: 'name', include_extra_info: true }, + ]); + + expect(results).toMatchObject([ + expect.toBeError(/yikes/), + { foo_id: 2, rating: 3 }, + expect.toBeError(/yikes/), + { foo_id: 4, rating: 3.5 }, + { foo_id: 5, name: 'Shake Shack' }, + ]); + }); +}); + +test('batch endpoint with propertyBatchKey without reorderResultsByKey throws error for response with non existant items', async () => { + const config = { + resources: { + foo: { + isBatchResource: true, + docsLink: 'example.com/docs/bar', + batchKey: 'foo_ids', + newKey: 'foo_id', + propertyBatchKey: 'properties', + propertyNewKey: 'property', + mergePropertyConfig: 'PropertyAtTopLevel', + }, + }, + }; + + const resources = { + foo: ({ foo_ids, bar }) => { + if (_.isEqual(foo_ids, [1, 2, 3])) { + return Promise.resolve([ + { + foo_id: 1, + name: 'Shake Shack', + rating: 4, + }, + // deliberately omit 2 + { + foo_id: 3, + name: 'Burger King', + rating: 3, + }, + ]); + } else if (_.isEqual(foo_ids, [4])) { + return Promise.resolve([ + { + foo_id: 4, + name: 'In N Out', + rating: 3.5, + }, + ]); + } + }, + }; + + await createDataLoaders(config, async (getLoaders) => { + const loaders = getLoaders(resources); + + const results = await loaders.foo.loadMany([ + { foo_id: 1, property: 'name', include_extra_info: true }, + { foo_id: 2, property: 'rating', include_extra_info: true }, + { foo_id: 3, property: 'rating', include_extra_info: true }, + { foo_id: 4, property: 'rating', include_extra_info: false }, + ]); + + expect(results).toMatchObject([ + { foo_id: 1, name: 'Shake Shack' }, + expect.toBeError( + 'Could not find newKey = "2" and propertyNewKey = "rating" in the response dict. Or your endpoint does not follow the contract we support.', + 'BatchItemNotFoundError', + ), + { foo_id: 3, rating: 3 }, + { foo_id: 4, rating: 3.5 }, + ]); + }); +}); + +test('batch endpoint with propertyBatchKey with isResponseDictionary with a missing item', async () => { + const config = { + resources: { + foo: { + isBatchResource: true, + docsLink: 'example.com/docs/foos', + batchKey: 'foo_ids', + newKey: 'foo_id', + isResponseDictionary: true, + propertyBatchKey: 'properties', + propertyNewKey: 'property', + mergePropertyConfig: 'PropertyInNestedObject', + }, + }, + }; + + const resources = { + foo: ({ foo_ids }) => { + expect(foo_ids).toEqual([2, 1, 3]); + return Promise.resolve({ + 1: { foo_id: 1, properties: { rating: 3, name: 'Burger King' } }, + 3: { foo_id: 3, properties: { rating: 4, name: 'Shake Shack' } }, + }); + }, + }; + + await createDataLoaders(config, async (getLoaders) => { + const loaders = getLoaders(resources); + + const results = await loaders.foo.loadMany([ + { foo_id: 2, property: 'name' }, + { foo_id: 1, property: 'rating' }, + { foo_id: 3, property: 'rating' }, + ]); + expect(results).toEqual([ + expect.toBeError( + 'Could not find newKey = "2" and propertyNewKey = "name" in the response dict. Or your endpoint does not follow the contract we support.', + 'BatchItemNotFoundError', + ), + { foo_id: 1, properties: { rating: 3 } }, + { foo_id: 3, properties: { rating: 4 } }, + ]); + }); +}); + +test('batch endpoint (multiple requests) with propertyBatchKey different response structure', async () => { + const config = { + resources: { + foo: { + isBatchResource: true, + docsLink: 'example.com/docs/bar', + batchKey: 'foo_ids', + newKey: 'foo_id', + propertyBatchKey: 'properties', + propertyNewKey: 'property', + mergePropertyConfig: 'IdPropertyPair', + }, + }, + }; + + const resources = { + foo: ({ foo_ids, properties, include_extra_info }) => { + if (_.isEqual(foo_ids, [2, 1]) && _.isEqual(properties, ['name', 'rating'])) { + expect(include_extra_info).toBe(false); + return Promise.resolve([ + { 1: { rating: 3, name: 'Burger King' } }, + { 2: { rating: 4, name: 'In N Out' } }, + ]); + } + + if (_.isEqual(foo_ids, [3]) && _.isEqual(properties, ['rating'])) { + expect(include_extra_info).toBe(true); + return Promise.resolve([ + { + 3: { + rating: 5, + extra_stuff: 'lorem ipsum', + }, + }, + ]); + } + }, + }; + + await createDataLoaders(config, async (getLoaders) => { + const loaders = getLoaders(resources); + + const results = await loaders.foo.loadMany([ + { foo_id: 2, property: 'name', include_extra_info: false }, + { foo_id: 1, property: 'rating', include_extra_info: false }, + { foo_id: 3, property: 'rating', include_extra_info: true }, + ]); + + expect(results).toEqual([{ 2: { name: 'In N Out' } }, { 1: { rating: 3 } }, { 3: { rating: 5 } }]); + }); +}); + +test('batch endpoint with propertyBatchKey with reorderResultsByKey handles response with non existant items', async () => { + const config = { + resources: { + foo: { + isBatchResource: true, + docsLink: 'example.com/docs/bar', + batchKey: 'foo_ids', + newKey: 'foo_id', + reorderResultsByKey: 'foo_id', + propertyBatchKey: 'properties', + propertyNewKey: 'property', + mergePropertyConfig: 'PropertyInNestedObject', + }, + }, + }; + + const resources = { + foo: ({ foo_ids, bar }) => { + if (_.isEqual(foo_ids, [1, 2, 3])) { + return Promise.resolve([ + { foo_id: 3, properties: { rating: 4, name: 'Shake Shack' } }, + { foo_id: 1, properties: { rating: 3, name: 'Burger King' } }, + // deliberately omit 2 + ]); + } else if (_.isEqual(foo_ids, [4])) { + return Promise.resolve([{ foo_id: 4, properties: { rating: 5, name: 'In N Out' } }]); + } + }, + }; + await createDataLoaders(config, async (getLoaders) => { + const loaders = getLoaders(resources); + + const results = await loaders.foo.loadMany([ + { foo_id: 1, property: 'rating', bar: true }, + { foo_id: 2, property: 'name', bar: true }, + { foo_id: 3, property: 'rating', bar: true }, + { foo_id: 4, property: 'rating', bar: false }, + ]); + + expect(results).toMatchObject([ + { foo_id: 1, properties: { rating: 3 } }, + expect.toBeError( + 'Could not find newKey = "2" and propertyNewKey = "name" in the response dict. Or your endpoint does not follow the contract we support.', + 'BatchItemNotFoundError', + ), + { foo_id: 3, properties: { rating: 4 } }, + { foo_id: 4, properties: { rating: 5 } }, + ]); + }); +}); + +test('batch endpoint with propertyBatchKey (multiple requests, error handling - non array response)', async () => { + const config = { + eek: true, + resources: { + foo: { + isBatchResource: true, + docsLink: 'example.com/docs/bar', + batchKey: 'foo_ids', + newKey: 'foo_id', + propertyBatchKey: 'properties', + propertyNewKey: 'property', + mergePropertyConfig: 'PropertyAtTopLevel', + }, + }, + }; + + const resources = { + foo: ({ foo_ids, include_extra_info }) => { + if (_.isEqual(foo_ids, [1, 3])) { + expect(include_extra_info).toBe(false); + return new Error('yikes'); + } + + if (_.isEqual(foo_ids, [2, 4, 5])) { + expect(include_extra_info).toBe(true); + // Deliberately returning an object, not an array + return Promise.resolve({ + foo: 'bar', + }); + } + }, + }; + + await createDataLoaders(config, async (getLoaders) => { + const loaders = getLoaders(resources); + + const results = await loaders.foo.loadMany([ + { foo_id: 1, property: 'name', include_extra_info: false }, + { foo_id: 2, property: 'rating', include_extra_info: true }, + { foo_id: 3, property: 'rating', include_extra_info: false }, + { foo_id: 4, property: 'name', include_extra_info: true }, + { foo_id: 5, property: 'name', include_extra_info: true }, + ]); + + expect(results).toMatchObject([ + expect.toBeError('yikes'), + expect.toBeError('[dataloader-codegen :: foo] Expected response to be an array'), + expect.toBeError('yikes'), + expect.toBeError('[dataloader-codegen :: foo] Expected response to be an array'), + expect.toBeError('[dataloader-codegen :: foo] Expected response to be an array'), + ]); + }); +}); + +test('batch endpoint with propertyBatchKey (multiple requests, error handling, with reordering)', async () => { + const config = { + resources: { + foo: { + isBatchResource: true, + docsLink: 'example.com/docs/bar', + batchKey: 'foo_ids', + newKey: 'foo_id', + reorderResultsByKey: 'foo_id', + propertyBatchKey: 'properties', + propertyNewKey: 'property', + mergePropertyConfig: 'PropertyInNestedObject', + }, + }, + }; + + const resources = { + foo: ({ foo_ids, include_extra_info }) => { + if (_.isEqual(foo_ids, [1, 3])) { + expect(include_extra_info).toBe(false); + return new Error('yikes'); + } + + if (_.isEqual(foo_ids, [2, 4, 5])) { + expect(include_extra_info).toBe(true); + // return items deliberately out of order + return Promise.resolve([ + { foo_id: 4, properties: { rating: 4, name: 'Shake Shack' } }, + { foo_id: 5, properties: { rating: 3, name: 'Burger King' } }, + { foo_id: 2, properties: { rating: 5, name: 'In N Out' } }, + ]); + } + }, + }; + + await createDataLoaders(config, async (getLoaders) => { + const loaders = getLoaders(resources); + + const results = await loaders.foo.loadMany([ + { foo_id: 1, property: 'name', include_extra_info: false }, + { foo_id: 2, property: 'rating', include_extra_info: true }, + { foo_id: 3, property: 'rating', include_extra_info: false }, + { foo_id: 4, property: 'name', include_extra_info: true }, + { foo_id: 5, property: 'name', include_extra_info: true }, + ]); + + expect(results).toEqual([ + expect.toBeError(/yikes/), + { foo_id: 2, properties: { rating: 5 } }, + expect.toBeError(/yikes/), + { foo_id: 4, properties: { name: 'Shake Shack' } }, + { foo_id: 5, properties: { name: 'Burger King' } }, + ]); + }); +}); diff --git a/examples/swapi/swapi-loaders.js b/examples/swapi/swapi-loaders.js index 4f840c5..2e141dc 100644 --- a/examples/swapi/swapi-loaders.js +++ b/examples/swapi/swapi-loaders.js @@ -13,10 +13,12 @@ import { cacheKeyOptions, CaughtResourceError, defaultErrorHandler, + getBatchKeysForPartitionItems, partitionItems, resultsDictToList, sortByKeys, unPartitionResults, + unPartitionResultsByBatchKeyPartition, } from 'dataloader-codegen/lib/runtimeHelpers'; /** @@ -185,6 +187,10 @@ export type LoadersType = $ReadOnly<{| $Call]>, 'film_ids', >, + properties: $PropertyType< + $Call]>, + 'properties', + >, }, >, ...{| @@ -192,6 +198,10 @@ export type LoadersType = $ReadOnly<{| $PropertyType<$Call]>, 'film_ids'>, 0, >, + property: $ElementType< + $PropertyType<$Call]>, 'properties'>, + 0, + >, |}, |}, $ElementType< @@ -207,6 +217,78 @@ export type LoadersType = $ReadOnly<{| // This third argument is the cache key type. Since we use objectHash in cacheKeyOptions, this is "string". string, >, + getFilmsV3: DataLoader< + {| + ...$Diff< + $Call]>, + { + film_ids: $PropertyType< + $Call]>, + 'film_ids', + >, + properties: $PropertyType< + $Call]>, + 'properties', + >, + }, + >, + ...{| + film_id: $ElementType< + $PropertyType<$Call]>, 'film_ids'>, + 0, + >, + property: $ElementType< + $PropertyType<$Call]>, 'properties'>, + 0, + >, + |}, + |}, + $ElementType< + $Call< + ExtractPromisedReturnValue<[$Call]>]>, + $PropertyType, + >, + 0, + >, + // This third argument is the cache key type. Since we use objectHash in cacheKeyOptions, this is "string". + string, + >, + getFilmsV4: DataLoader< + {| + ...$Diff< + $Call]>, + { + film_ids: $PropertyType< + $Call]>, + 'film_ids', + >, + properties: $PropertyType< + $Call]>, + 'properties', + >, + }, + >, + ...{| + film_id: $ElementType< + $PropertyType<$Call]>, 'film_ids'>, + 0, + >, + property: $ElementType< + $PropertyType<$Call]>, 'properties'>, + 0, + >, + |}, + |}, + $ElementType< + $Call< + ExtractPromisedReturnValue<[$Call]>]>, + $PropertyType, + >, + 0, + >, + // This third argument is the cache key type. Since we use objectHash in cacheKeyOptions, this is "string". + string, + >, getRoot: DataLoader< $Call]>, $Call< @@ -335,9 +417,28 @@ export default function getLoaders(resources: ResourcesType, options?: DataLoade * Returns: * `[ [ 0, 2 ], [ 1 ] ]` * + * We could also have more than one batch key. + * + * Example: + * + * ```js + * partitionItems([ + * { [bar_id: 7, property: 'property_1'], include_extra_info: true }, + * { [bar_id: 8, property: 'property_2'], include_extra_info: false }, + * { [bar_id: 9, property: 'property_3'], include_extra_info: true }, + * ], 'bar_id') + * ``` + * + * Returns: + * `[ [ 0, 2 ], [ 1 ] ]` * We'll refer to each element in the group as a "request ID". */ - const requestGroups = partitionItems('planet_id', keys); + let requestGroups; + if (false && false) { + requestGroups = partitionItems(['planet_id', 'undefined'], keys); + } else { + requestGroups = partitionItems('planet_id', keys); + } // Map the request groups to a list of Promises - one for each request const groupedResults = await Promise.all( @@ -487,8 +588,66 @@ export default function getLoaders(resources: ResourcesType, options?: DataLoade }), ); - // Split the results back up into the order that they were requested - return unPartitionResults(requestGroups, groupedResults); + /** + * When there's propertyBatchKey and propertyNewKey, the resource might + * contain less number of items that we requested. We need the value of batchKey and + * propertyBatchKey in requests group to help us split the results back up into the + * order that they were requested. + * + * + * Example: + * + * getBatchKeyForPartitionItems( + * 'bar_id', + * ['bar_id', 'property'], + * [ + * { bar_id: 2, property: 'name', include_extra_info: true }, + * { bar_id: 3, property: 'rating', include_extra_info: false }, + * { bar_id: 4, property: 'rating', include_extra_info: true }, + * ]) + * + * + * Returns: + * [ [ 2, 4 ], [ 3 ] ] + * + * getBatchKeyForPartitionItems( + * 'property', + * ['bar_id', 'property'], + * [ + * { bar_id: 2, property: 'name', include_extra_info: true }, + * { bar_id: 3, property: 'rating', include_extra_info: false }, + * { bar_id: 4, property: 'rating', include_extra_info: true }, + * ]) + * + * + * Returns: + * [ [ 'name', 'rating' ], [ 'rating' ] ] + */ + if (false && false && false) { + const batchKeyPartition = getBatchKeysForPartitionItems( + 'planet_id', + ['planet_id', 'undefined'], + keys, + ); + const propertyBatchKeyPartiion = getBatchKeysForPartitionItems( + 'undefined', + ['planet_id', 'undefined'], + keys, + ); + + return unPartitionResultsByBatchKeyPartition( + 'planet_id', + 'undefined', + 'undefined', + batchKeyPartition, + propertyBatchKeyPartiion, + requestGroups, + groupedResults, + ); + } else { + // Split the results back up into the order that they were requested + return unPartitionResults(requestGroups, groupedResults); + } }, { ...cacheKeyOptions, @@ -609,9 +768,28 @@ export default function getLoaders(resources: ResourcesType, options?: DataLoade * Returns: * `[ [ 0, 2 ], [ 1 ] ]` * + * We could also have more than one batch key. + * + * Example: + * + * ```js + * partitionItems([ + * { [bar_id: 7, property: 'property_1'], include_extra_info: true }, + * { [bar_id: 8, property: 'property_2'], include_extra_info: false }, + * { [bar_id: 9, property: 'property_3'], include_extra_info: true }, + * ], 'bar_id') + * ``` + * + * Returns: + * `[ [ 0, 2 ], [ 1 ] ]` * We'll refer to each element in the group as a "request ID". */ - const requestGroups = partitionItems('person_id', keys); + let requestGroups; + if (false && false) { + requestGroups = partitionItems(['person_id', 'undefined'], keys); + } else { + requestGroups = partitionItems('person_id', keys); + } // Map the request groups to a list of Promises - one for each request const groupedResults = await Promise.all( @@ -758,8 +936,66 @@ export default function getLoaders(resources: ResourcesType, options?: DataLoade }), ); - // Split the results back up into the order that they were requested - return unPartitionResults(requestGroups, groupedResults); + /** + * When there's propertyBatchKey and propertyNewKey, the resource might + * contain less number of items that we requested. We need the value of batchKey and + * propertyBatchKey in requests group to help us split the results back up into the + * order that they were requested. + * + * + * Example: + * + * getBatchKeyForPartitionItems( + * 'bar_id', + * ['bar_id', 'property'], + * [ + * { bar_id: 2, property: 'name', include_extra_info: true }, + * { bar_id: 3, property: 'rating', include_extra_info: false }, + * { bar_id: 4, property: 'rating', include_extra_info: true }, + * ]) + * + * + * Returns: + * [ [ 2, 4 ], [ 3 ] ] + * + * getBatchKeyForPartitionItems( + * 'property', + * ['bar_id', 'property'], + * [ + * { bar_id: 2, property: 'name', include_extra_info: true }, + * { bar_id: 3, property: 'rating', include_extra_info: false }, + * { bar_id: 4, property: 'rating', include_extra_info: true }, + * ]) + * + * + * Returns: + * [ [ 'name', 'rating' ], [ 'rating' ] ] + */ + if (false && false && false) { + const batchKeyPartition = getBatchKeysForPartitionItems( + 'person_id', + ['person_id', 'undefined'], + keys, + ); + const propertyBatchKeyPartiion = getBatchKeysForPartitionItems( + 'undefined', + ['person_id', 'undefined'], + keys, + ); + + return unPartitionResultsByBatchKeyPartition( + 'person_id', + 'undefined', + 'undefined', + batchKeyPartition, + propertyBatchKeyPartiion, + requestGroups, + groupedResults, + ); + } else { + // Split the results back up into the order that they were requested + return unPartitionResults(requestGroups, groupedResults); + } }, { ...cacheKeyOptions, @@ -880,9 +1116,28 @@ export default function getLoaders(resources: ResourcesType, options?: DataLoade * Returns: * `[ [ 0, 2 ], [ 1 ] ]` * + * We could also have more than one batch key. + * + * Example: + * + * ```js + * partitionItems([ + * { [bar_id: 7, property: 'property_1'], include_extra_info: true }, + * { [bar_id: 8, property: 'property_2'], include_extra_info: false }, + * { [bar_id: 9, property: 'property_3'], include_extra_info: true }, + * ], 'bar_id') + * ``` + * + * Returns: + * `[ [ 0, 2 ], [ 1 ] ]` * We'll refer to each element in the group as a "request ID". */ - const requestGroups = partitionItems('vehicle_id', keys); + let requestGroups; + if (false && false) { + requestGroups = partitionItems(['vehicle_id', 'undefined'], keys); + } else { + requestGroups = partitionItems('vehicle_id', keys); + } // Map the request groups to a list of Promises - one for each request const groupedResults = await Promise.all( @@ -1032,8 +1287,66 @@ export default function getLoaders(resources: ResourcesType, options?: DataLoade }), ); - // Split the results back up into the order that they were requested - return unPartitionResults(requestGroups, groupedResults); + /** + * When there's propertyBatchKey and propertyNewKey, the resource might + * contain less number of items that we requested. We need the value of batchKey and + * propertyBatchKey in requests group to help us split the results back up into the + * order that they were requested. + * + * + * Example: + * + * getBatchKeyForPartitionItems( + * 'bar_id', + * ['bar_id', 'property'], + * [ + * { bar_id: 2, property: 'name', include_extra_info: true }, + * { bar_id: 3, property: 'rating', include_extra_info: false }, + * { bar_id: 4, property: 'rating', include_extra_info: true }, + * ]) + * + * + * Returns: + * [ [ 2, 4 ], [ 3 ] ] + * + * getBatchKeyForPartitionItems( + * 'property', + * ['bar_id', 'property'], + * [ + * { bar_id: 2, property: 'name', include_extra_info: true }, + * { bar_id: 3, property: 'rating', include_extra_info: false }, + * { bar_id: 4, property: 'rating', include_extra_info: true }, + * ]) + * + * + * Returns: + * [ [ 'name', 'rating' ], [ 'rating' ] ] + */ + if (false && false && false) { + const batchKeyPartition = getBatchKeysForPartitionItems( + 'vehicle_id', + ['vehicle_id', 'undefined'], + keys, + ); + const propertyBatchKeyPartiion = getBatchKeysForPartitionItems( + 'undefined', + ['vehicle_id', 'undefined'], + keys, + ); + + return unPartitionResultsByBatchKeyPartition( + 'vehicle_id', + 'undefined', + 'undefined', + batchKeyPartition, + propertyBatchKeyPartiion, + requestGroups, + groupedResults, + ); + } else { + // Split the results back up into the order that they were requested + return unPartitionResults(requestGroups, groupedResults); + } }, { ...cacheKeyOptions, @@ -1163,9 +1476,28 @@ export default function getLoaders(resources: ResourcesType, options?: DataLoade * Returns: * `[ [ 0, 2 ], [ 1 ] ]` * + * We could also have more than one batch key. + * + * Example: + * + * ```js + * partitionItems([ + * { [bar_id: 7, property: 'property_1'], include_extra_info: true }, + * { [bar_id: 8, property: 'property_2'], include_extra_info: false }, + * { [bar_id: 9, property: 'property_3'], include_extra_info: true }, + * ], 'bar_id') + * ``` + * + * Returns: + * `[ [ 0, 2 ], [ 1 ] ]` * We'll refer to each element in the group as a "request ID". */ - const requestGroups = partitionItems('film_id', keys); + let requestGroups; + if (false && false) { + requestGroups = partitionItems(['film_id', 'undefined'], keys); + } else { + requestGroups = partitionItems('film_id', keys); + } // Map the request groups to a list of Promises - one for each request const groupedResults = await Promise.all( @@ -1310,8 +1642,62 @@ export default function getLoaders(resources: ResourcesType, options?: DataLoade }), ); - // Split the results back up into the order that they were requested - return unPartitionResults(requestGroups, groupedResults); + /** + * When there's propertyBatchKey and propertyNewKey, the resource might + * contain less number of items that we requested. We need the value of batchKey and + * propertyBatchKey in requests group to help us split the results back up into the + * order that they were requested. + * + * + * Example: + * + * getBatchKeyForPartitionItems( + * 'bar_id', + * ['bar_id', 'property'], + * [ + * { bar_id: 2, property: 'name', include_extra_info: true }, + * { bar_id: 3, property: 'rating', include_extra_info: false }, + * { bar_id: 4, property: 'rating', include_extra_info: true }, + * ]) + * + * + * Returns: + * [ [ 2, 4 ], [ 3 ] ] + * + * getBatchKeyForPartitionItems( + * 'property', + * ['bar_id', 'property'], + * [ + * { bar_id: 2, property: 'name', include_extra_info: true }, + * { bar_id: 3, property: 'rating', include_extra_info: false }, + * { bar_id: 4, property: 'rating', include_extra_info: true }, + * ]) + * + * + * Returns: + * [ [ 'name', 'rating' ], [ 'rating' ] ] + */ + if (false && false && false) { + const batchKeyPartition = getBatchKeysForPartitionItems('film_id', ['film_id', 'undefined'], keys); + const propertyBatchKeyPartiion = getBatchKeysForPartitionItems( + 'undefined', + ['film_id', 'undefined'], + keys, + ); + + return unPartitionResultsByBatchKeyPartition( + 'film_id', + 'undefined', + 'undefined', + batchKeyPartition, + propertyBatchKeyPartiion, + requestGroups, + groupedResults, + ); + } else { + // Split the results back up into the order that they were requested + return unPartitionResults(requestGroups, groupedResults); + } }, { ...cacheKeyOptions, @@ -1326,6 +1712,10 @@ export default function getLoaders(resources: ResourcesType, options?: DataLoade $Call]>, 'film_ids', >, + properties: $PropertyType< + $Call]>, + 'properties', + >, }, >, ...{| @@ -1333,6 +1723,10 @@ export default function getLoaders(resources: ResourcesType, options?: DataLoade $PropertyType<$Call]>, 'film_ids'>, 0, >, + property: $ElementType< + $PropertyType<$Call]>, 'properties'>, + 0, + >, |}, |}, $ElementType< @@ -1361,7 +1755,10 @@ export default function getLoaders(resources: ResourcesType, options?: DataLoade * "isBatchResource": true, * "batchKey": "film_ids", * "newKey": "film_id", - * "nestedPath": "properties" + * "propertyBatchKey": "properties", + * "propertyNewKey": "property", + * "nestedPath": "properties", + * "mergePropertyConfig": "PropertyAtTopLevel" * } * ``` */ @@ -1436,9 +1833,28 @@ export default function getLoaders(resources: ResourcesType, options?: DataLoade * Returns: * `[ [ 0, 2 ], [ 1 ] ]` * + * We could also have more than one batch key. + * + * Example: + * + * ```js + * partitionItems([ + * { [bar_id: 7, property: 'property_1'], include_extra_info: true }, + * { [bar_id: 8, property: 'property_2'], include_extra_info: false }, + * { [bar_id: 9, property: 'property_3'], include_extra_info: true }, + * ], 'bar_id') + * ``` + * + * Returns: + * `[ [ 0, 2 ], [ 1 ] ]` * We'll refer to each element in the group as a "request ID". */ - const requestGroups = partitionItems('film_id', keys); + let requestGroups; + if (true && true) { + requestGroups = partitionItems(['film_id', 'property'], keys); + } else { + requestGroups = partitionItems('film_id', keys); + } // Map the request groups to a list of Promises - one for each request const groupedResults = await Promise.all( @@ -1456,8 +1872,9 @@ export default function getLoaders(resources: ResourcesType, options?: DataLoade // @see https://github.com/Yelp/dataloader-codegen/issues/56 const resourceArgs = [ { - ..._.omit(requests[0], 'film_id'), + ..._.omit(requests[0], 'film_id', 'property'), ['film_ids']: requests.map((k) => k['film_id']), + ['properties']: requests.map((k) => k['property']), }, ]; @@ -1555,32 +1972,6 @@ export default function getLoaders(resources: ResourcesType, options?: DataLoade } } - if (!(response instanceof Error)) { - /** - * Check to see the resource contains the same number - * of items that we requested. If not, since there's - * no "reorderResultsByKey" specified for this resource, - * we don't know _which_ key's response is missing. Therefore - * it's unsafe to return the response array back. - */ - if (response.length !== requests.length) { - /** - * We must return errors for all keys in this group :( - */ - response = new BatchItemNotFoundError( - [ - `[dataloader-codegen :: getFilmsV2] Resource returned ${response.length} items, but we requested ${requests.length} items.`, - 'Add reorderResultsByKey to the config for this resource to be able to handle a partial response.', - ].join(' '), - ); - - // Tell flow that BatchItemNotFoundError extends Error. - // It's an issue with flowgen package, but not an issue with Flow. - // @see https://github.com/Yelp/dataloader-codegen/pull/35#discussion_r394777533 - invariant(response instanceof Error, 'expected BatchItemNotFoundError to be an Error'); - } - } - /** * If the resource returns an Error, we'll want to copy and * return that error as the return value for every request in @@ -1622,8 +2013,728 @@ export default function getLoaders(resources: ResourcesType, options?: DataLoade }), ); - // Split the results back up into the order that they were requested - return unPartitionResults(requestGroups, groupedResults); + /** + * When there's propertyBatchKey and propertyNewKey, the resource might + * contain less number of items that we requested. We need the value of batchKey and + * propertyBatchKey in requests group to help us split the results back up into the + * order that they were requested. + * + * + * Example: + * + * getBatchKeyForPartitionItems( + * 'bar_id', + * ['bar_id', 'property'], + * [ + * { bar_id: 2, property: 'name', include_extra_info: true }, + * { bar_id: 3, property: 'rating', include_extra_info: false }, + * { bar_id: 4, property: 'rating', include_extra_info: true }, + * ]) + * + * + * Returns: + * [ [ 2, 4 ], [ 3 ] ] + * + * getBatchKeyForPartitionItems( + * 'property', + * ['bar_id', 'property'], + * [ + * { bar_id: 2, property: 'name', include_extra_info: true }, + * { bar_id: 3, property: 'rating', include_extra_info: false }, + * { bar_id: 4, property: 'rating', include_extra_info: true }, + * ]) + * + * + * Returns: + * [ [ 'name', 'rating' ], [ 'rating' ] ] + */ + if (true && true && true) { + const batchKeyPartition = getBatchKeysForPartitionItems('film_id', ['film_id', 'property'], keys); + const propertyBatchKeyPartiion = getBatchKeysForPartitionItems( + 'property', + ['film_id', 'property'], + keys, + ); + + return unPartitionResultsByBatchKeyPartition( + 'film_id', + 'properties', + 'PropertyAtTopLevel', + batchKeyPartition, + propertyBatchKeyPartiion, + requestGroups, + groupedResults, + ); + } else { + // Split the results back up into the order that they were requested + return unPartitionResults(requestGroups, groupedResults); + } + }, + { + ...cacheKeyOptions, + }, + ), + getFilmsV3: new DataLoader< + {| + ...$Diff< + $Call]>, + { + film_ids: $PropertyType< + $Call]>, + 'film_ids', + >, + properties: $PropertyType< + $Call]>, + 'properties', + >, + }, + >, + ...{| + film_id: $ElementType< + $PropertyType<$Call]>, 'film_ids'>, + 0, + >, + property: $ElementType< + $PropertyType<$Call]>, 'properties'>, + 0, + >, + |}, + |}, + $ElementType< + $Call< + ExtractPromisedReturnValue<[$Call]>]>, + $PropertyType, + >, + 0, + >, + // This third argument is the cache key type. Since we use objectHash in cacheKeyOptions, this is "string". + string, + >( + /** + * =============================================================== + * Generated DataLoader: getFilmsV3 + * =============================================================== + * + * Resource Config: + * + * ```json + * { + * "docsLink": "https://swapi.dev/documentation#films", + * "isBatchResource": true, + * "batchKey": "film_ids", + * "newKey": "film_id", + * "propertyBatchKey": "properties", + * "propertyNewKey": "property", + * "mergePropertyConfig": "PropertyInNestedObject" + * } + * ``` + */ + async (keys) => { + invariant( + typeof resources.getFilmsV3 === 'function', + [ + '[dataloader-codegen :: getFilmsV3] resources.getFilmsV3 is not a function.', + 'Did you pass in an instance of getFilmsV3 to "getLoaders"?', + ].join(' '), + ); + + /** + * Chunk up the "keys" array to create a set of "request groups". + * + * We're about to hit a batch resource. In addition to the batch + * key, the resource may take other arguments too. When batching + * up requests, we'll want to look out for where those other + * arguments differ, and send multiple requests so we don't get + * back the wrong info. + * + * In other words, we'll potentially want to send _multiple_ + * requests to the underlying resource batch method in this + * dataloader body. + * + * ~~~ Why? ~~~ + * + * Consider what happens when we get called with arguments where + * the non-batch keys differ. + * + * Example: + * + * ```js + * loaders.foo.load({ foo_id: 2, include_private_data: true }); + * loaders.foo.load({ foo_id: 3, include_private_data: false }); + * loaders.foo.load({ foo_id: 4, include_private_data: false }); + * ``` + * + * If we collected everything up and tried to send the one + * request to the resource as a batch request, how do we know + * what the value for "include_private_data" should be? We're + * going to have to group these up up and send two requests to + * the resource to make sure we're requesting the right stuff. + * + * e.g. We'd need to make the following set of underlying resource + * calls: + * + * ```js + * foo({ foo_ids: [ 2 ], include_private_data: true }); + * foo({ foo_ids: [ 3, 4 ], include_private_data: false }); + * ``` + * + * ~~~ tl;dr ~~~ + * + * When we have calls to .load with differing non batch key args, + * we'll need to send multiple requests to the underlying + * resource to make sure we get the right results back. + * + * Let's create the request groups, where each element in the + * group refers to a position in "keys" (i.e. a call to .load) + * + * Example: + * + * ```js + * partitionItems([ + * { bar_id: 7, include_extra_info: true }, + * { bar_id: 8, include_extra_info: false }, + * { bar_id: 9, include_extra_info: true }, + * ], 'bar_id') + * ``` + * + * Returns: + * `[ [ 0, 2 ], [ 1 ] ]` + * + * We could also have more than one batch key. + * + * Example: + * + * ```js + * partitionItems([ + * { [bar_id: 7, property: 'property_1'], include_extra_info: true }, + * { [bar_id: 8, property: 'property_2'], include_extra_info: false }, + * { [bar_id: 9, property: 'property_3'], include_extra_info: true }, + * ], 'bar_id') + * ``` + * + * Returns: + * `[ [ 0, 2 ], [ 1 ] ]` + * We'll refer to each element in the group as a "request ID". + */ + let requestGroups; + if (true && true) { + requestGroups = partitionItems(['film_id', 'property'], keys); + } else { + requestGroups = partitionItems('film_id', keys); + } + + // Map the request groups to a list of Promises - one for each request + const groupedResults = await Promise.all( + requestGroups.map(async (requestIDs) => { + /** + * Select a set of elements in "keys", where all non-batch + * keys should be identical. + * + * We're going to smoosh all these together into one payload to + * send to the resource as a batch request! + */ + const requests = requestIDs.map((id) => keys[id]); + + // For now, we assume that the dataloader key should be the first argument to the resource + // @see https://github.com/Yelp/dataloader-codegen/issues/56 + const resourceArgs = [ + { + ..._.omit(requests[0], 'film_id', 'property'), + ['film_ids']: requests.map((k) => k['film_id']), + ['properties']: requests.map((k) => k['property']), + }, + ]; + + let response = await (async (_resourceArgs) => { + // Make a re-assignable variable so flow/eslint doesn't complain + let __resourceArgs = _resourceArgs; + + if (options && options.resourceMiddleware && options.resourceMiddleware.before) { + __resourceArgs = await options.resourceMiddleware.before( + ['getFilmsV3'], + __resourceArgs, + ); + } + + let _response; + try { + // Finally, call the resource! + _response = await resources.getFilmsV3(...__resourceArgs); + } catch (error) { + const errorHandler = + options && typeof options.errorHandler === 'function' + ? options.errorHandler + : defaultErrorHandler; + + /** + * Apply some error handling to catch and handle all errors/rejected promises. errorHandler must return an Error object. + * + * If we let errors here go unhandled here, it will bubble up and DataLoader will return an error for all + * keys requested. We can do slightly better by returning the error object for just the keys in this batch request. + */ + _response = await errorHandler(['getFilmsV3'], error); + + // Check that errorHandler actually returned an Error object, and turn it into one if not. + if (!(_response instanceof Error)) { + _response = new Error( + [ + `[dataloader-codegen :: getFilmsV3] Caught an error, but errorHandler did not return an Error object.`, + `Instead, got ${typeof _response}: ${util.inspect(_response)}`, + ].join(' '), + ); + } + } + + if (options && options.resourceMiddleware && options.resourceMiddleware.after) { + _response = await options.resourceMiddleware.after(['getFilmsV3'], _response); + } + + return _response; + })(resourceArgs); + + if (!(response instanceof Error)) { + } + + if (!(response instanceof Error)) { + if (!Array.isArray(response)) { + response = new Error( + ['[dataloader-codegen :: getFilmsV3]', 'Expected response to be an array!'].join( + ' ', + ), + ); + } + } + + /** + * If the resource returns an Error, we'll want to copy and + * return that error as the return value for every request in + * this group. + * + * This allow the error to be cached, and allows the rest of the + * requests made by this DataLoader to succeed. + * + * @see https://github.com/graphql/dataloader#caching-errors + */ + if (response instanceof Error) { + response = requestIDs.map((requestId) => { + /** + * Since we're returning an error object and not the + * expected return type from the resource, this element + * would be unsortable, since it wouldn't have the + * "reorderResultsByKey" attribute. + * + * Let's add it to the error object, as "reorderResultsByValue". + * + * (If we didn't specify that this resource needs + * sorting, then this will be "null" and won't be used.) + */ + const reorderResultsByValue = null; + + // Tell flow that "response" is actually an error object. + // (This is so we can pass it as 'cause' to CaughtResourceError) + invariant(response instanceof Error, 'expected response to be an error'); + + return new CaughtResourceError( + `[dataloader-codegen :: getFilmsV3] Caught error during call to resource. Error: ${response.stack}`, + response, + reorderResultsByValue, + ); + }); + } + + return response; + }), + ); + + /** + * When there's propertyBatchKey and propertyNewKey, the resource might + * contain less number of items that we requested. We need the value of batchKey and + * propertyBatchKey in requests group to help us split the results back up into the + * order that they were requested. + * + * + * Example: + * + * getBatchKeyForPartitionItems( + * 'bar_id', + * ['bar_id', 'property'], + * [ + * { bar_id: 2, property: 'name', include_extra_info: true }, + * { bar_id: 3, property: 'rating', include_extra_info: false }, + * { bar_id: 4, property: 'rating', include_extra_info: true }, + * ]) + * + * + * Returns: + * [ [ 2, 4 ], [ 3 ] ] + * + * getBatchKeyForPartitionItems( + * 'property', + * ['bar_id', 'property'], + * [ + * { bar_id: 2, property: 'name', include_extra_info: true }, + * { bar_id: 3, property: 'rating', include_extra_info: false }, + * { bar_id: 4, property: 'rating', include_extra_info: true }, + * ]) + * + * + * Returns: + * [ [ 'name', 'rating' ], [ 'rating' ] ] + */ + if (true && true && true) { + const batchKeyPartition = getBatchKeysForPartitionItems('film_id', ['film_id', 'property'], keys); + const propertyBatchKeyPartiion = getBatchKeysForPartitionItems( + 'property', + ['film_id', 'property'], + keys, + ); + + return unPartitionResultsByBatchKeyPartition( + 'film_id', + 'properties', + 'PropertyInNestedObject', + batchKeyPartition, + propertyBatchKeyPartiion, + requestGroups, + groupedResults, + ); + } else { + // Split the results back up into the order that they were requested + return unPartitionResults(requestGroups, groupedResults); + } + }, + { + ...cacheKeyOptions, + }, + ), + getFilmsV4: new DataLoader< + {| + ...$Diff< + $Call]>, + { + film_ids: $PropertyType< + $Call]>, + 'film_ids', + >, + properties: $PropertyType< + $Call]>, + 'properties', + >, + }, + >, + ...{| + film_id: $ElementType< + $PropertyType<$Call]>, 'film_ids'>, + 0, + >, + property: $ElementType< + $PropertyType<$Call]>, 'properties'>, + 0, + >, + |}, + |}, + $ElementType< + $Call< + ExtractPromisedReturnValue<[$Call]>]>, + $PropertyType, + >, + 0, + >, + // This third argument is the cache key type. Since we use objectHash in cacheKeyOptions, this is "string". + string, + >( + /** + * =============================================================== + * Generated DataLoader: getFilmsV4 + * =============================================================== + * + * Resource Config: + * + * ```json + * { + * "docsLink": "https://swapi.dev/documentation#films", + * "isBatchResource": true, + * "batchKey": "film_ids", + * "newKey": "film_id", + * "propertyBatchKey": "properties", + * "propertyNewKey": "property", + * "mergePropertyConfig": "IdPropertyPair" + * } + * ``` + */ + async (keys) => { + invariant( + typeof resources.getFilmsV4 === 'function', + [ + '[dataloader-codegen :: getFilmsV4] resources.getFilmsV4 is not a function.', + 'Did you pass in an instance of getFilmsV4 to "getLoaders"?', + ].join(' '), + ); + + /** + * Chunk up the "keys" array to create a set of "request groups". + * + * We're about to hit a batch resource. In addition to the batch + * key, the resource may take other arguments too. When batching + * up requests, we'll want to look out for where those other + * arguments differ, and send multiple requests so we don't get + * back the wrong info. + * + * In other words, we'll potentially want to send _multiple_ + * requests to the underlying resource batch method in this + * dataloader body. + * + * ~~~ Why? ~~~ + * + * Consider what happens when we get called with arguments where + * the non-batch keys differ. + * + * Example: + * + * ```js + * loaders.foo.load({ foo_id: 2, include_private_data: true }); + * loaders.foo.load({ foo_id: 3, include_private_data: false }); + * loaders.foo.load({ foo_id: 4, include_private_data: false }); + * ``` + * + * If we collected everything up and tried to send the one + * request to the resource as a batch request, how do we know + * what the value for "include_private_data" should be? We're + * going to have to group these up up and send two requests to + * the resource to make sure we're requesting the right stuff. + * + * e.g. We'd need to make the following set of underlying resource + * calls: + * + * ```js + * foo({ foo_ids: [ 2 ], include_private_data: true }); + * foo({ foo_ids: [ 3, 4 ], include_private_data: false }); + * ``` + * + * ~~~ tl;dr ~~~ + * + * When we have calls to .load with differing non batch key args, + * we'll need to send multiple requests to the underlying + * resource to make sure we get the right results back. + * + * Let's create the request groups, where each element in the + * group refers to a position in "keys" (i.e. a call to .load) + * + * Example: + * + * ```js + * partitionItems([ + * { bar_id: 7, include_extra_info: true }, + * { bar_id: 8, include_extra_info: false }, + * { bar_id: 9, include_extra_info: true }, + * ], 'bar_id') + * ``` + * + * Returns: + * `[ [ 0, 2 ], [ 1 ] ]` + * + * We could also have more than one batch key. + * + * Example: + * + * ```js + * partitionItems([ + * { [bar_id: 7, property: 'property_1'], include_extra_info: true }, + * { [bar_id: 8, property: 'property_2'], include_extra_info: false }, + * { [bar_id: 9, property: 'property_3'], include_extra_info: true }, + * ], 'bar_id') + * ``` + * + * Returns: + * `[ [ 0, 2 ], [ 1 ] ]` + * We'll refer to each element in the group as a "request ID". + */ + let requestGroups; + if (true && true) { + requestGroups = partitionItems(['film_id', 'property'], keys); + } else { + requestGroups = partitionItems('film_id', keys); + } + + // Map the request groups to a list of Promises - one for each request + const groupedResults = await Promise.all( + requestGroups.map(async (requestIDs) => { + /** + * Select a set of elements in "keys", where all non-batch + * keys should be identical. + * + * We're going to smoosh all these together into one payload to + * send to the resource as a batch request! + */ + const requests = requestIDs.map((id) => keys[id]); + + // For now, we assume that the dataloader key should be the first argument to the resource + // @see https://github.com/Yelp/dataloader-codegen/issues/56 + const resourceArgs = [ + { + ..._.omit(requests[0], 'film_id', 'property'), + ['film_ids']: requests.map((k) => k['film_id']), + ['properties']: requests.map((k) => k['property']), + }, + ]; + + let response = await (async (_resourceArgs) => { + // Make a re-assignable variable so flow/eslint doesn't complain + let __resourceArgs = _resourceArgs; + + if (options && options.resourceMiddleware && options.resourceMiddleware.before) { + __resourceArgs = await options.resourceMiddleware.before( + ['getFilmsV4'], + __resourceArgs, + ); + } + + let _response; + try { + // Finally, call the resource! + _response = await resources.getFilmsV4(...__resourceArgs); + } catch (error) { + const errorHandler = + options && typeof options.errorHandler === 'function' + ? options.errorHandler + : defaultErrorHandler; + + /** + * Apply some error handling to catch and handle all errors/rejected promises. errorHandler must return an Error object. + * + * If we let errors here go unhandled here, it will bubble up and DataLoader will return an error for all + * keys requested. We can do slightly better by returning the error object for just the keys in this batch request. + */ + _response = await errorHandler(['getFilmsV4'], error); + + // Check that errorHandler actually returned an Error object, and turn it into one if not. + if (!(_response instanceof Error)) { + _response = new Error( + [ + `[dataloader-codegen :: getFilmsV4] Caught an error, but errorHandler did not return an Error object.`, + `Instead, got ${typeof _response}: ${util.inspect(_response)}`, + ].join(' '), + ); + } + } + + if (options && options.resourceMiddleware && options.resourceMiddleware.after) { + _response = await options.resourceMiddleware.after(['getFilmsV4'], _response); + } + + return _response; + })(resourceArgs); + + if (!(response instanceof Error)) { + } + + if (!(response instanceof Error)) { + if (!Array.isArray(response)) { + response = new Error( + ['[dataloader-codegen :: getFilmsV4]', 'Expected response to be an array!'].join( + ' ', + ), + ); + } + } + + /** + * If the resource returns an Error, we'll want to copy and + * return that error as the return value for every request in + * this group. + * + * This allow the error to be cached, and allows the rest of the + * requests made by this DataLoader to succeed. + * + * @see https://github.com/graphql/dataloader#caching-errors + */ + if (response instanceof Error) { + response = requestIDs.map((requestId) => { + /** + * Since we're returning an error object and not the + * expected return type from the resource, this element + * would be unsortable, since it wouldn't have the + * "reorderResultsByKey" attribute. + * + * Let's add it to the error object, as "reorderResultsByValue". + * + * (If we didn't specify that this resource needs + * sorting, then this will be "null" and won't be used.) + */ + const reorderResultsByValue = null; + + // Tell flow that "response" is actually an error object. + // (This is so we can pass it as 'cause' to CaughtResourceError) + invariant(response instanceof Error, 'expected response to be an error'); + + return new CaughtResourceError( + `[dataloader-codegen :: getFilmsV4] Caught error during call to resource. Error: ${response.stack}`, + response, + reorderResultsByValue, + ); + }); + } + + return response; + }), + ); + + /** + * When there's propertyBatchKey and propertyNewKey, the resource might + * contain less number of items that we requested. We need the value of batchKey and + * propertyBatchKey in requests group to help us split the results back up into the + * order that they were requested. + * + * + * Example: + * + * getBatchKeyForPartitionItems( + * 'bar_id', + * ['bar_id', 'property'], + * [ + * { bar_id: 2, property: 'name', include_extra_info: true }, + * { bar_id: 3, property: 'rating', include_extra_info: false }, + * { bar_id: 4, property: 'rating', include_extra_info: true }, + * ]) + * + * + * Returns: + * [ [ 2, 4 ], [ 3 ] ] + * + * getBatchKeyForPartitionItems( + * 'property', + * ['bar_id', 'property'], + * [ + * { bar_id: 2, property: 'name', include_extra_info: true }, + * { bar_id: 3, property: 'rating', include_extra_info: false }, + * { bar_id: 4, property: 'rating', include_extra_info: true }, + * ]) + * + * + * Returns: + * [ [ 'name', 'rating' ], [ 'rating' ] ] + */ + if (true && true && true) { + const batchKeyPartition = getBatchKeysForPartitionItems('film_id', ['film_id', 'property'], keys); + const propertyBatchKeyPartiion = getBatchKeysForPartitionItems( + 'property', + ['film_id', 'property'], + keys, + ); + + return unPartitionResultsByBatchKeyPartition( + 'film_id', + 'properties', + 'IdPropertyPair', + batchKeyPartition, + propertyBatchKeyPartiion, + requestGroups, + groupedResults, + ); + } else { + // Split the results back up into the order that they were requested + return unPartitionResults(requestGroups, groupedResults); + } }, { ...cacheKeyOptions, diff --git a/examples/swapi/swapi-server.js b/examples/swapi/swapi-server.js index f0a4119..b7367ce 100644 --- a/examples/swapi/swapi-server.js +++ b/examples/swapi/swapi-server.js @@ -24,6 +24,8 @@ const createSWAPIServer = () => { planet(id: Int): Planet film(id: Int): Film filmv2(id: Int): Film + filmv3(id: Int): Film + filmv4(id: Int): Film } `); @@ -123,7 +125,7 @@ const createSWAPIServer = () => { } async title() { - const response = await swapiLoaders.getFilmsV2.load({ film_id: this.id, properties: ['title'] }); + const response = await swapiLoaders.getFilmsV2.load({ film_id: this.id, property: 'title' }); if (response instanceof Error) { return response; @@ -135,7 +137,7 @@ const createSWAPIServer = () => { } async episodeNumber() { - const response = await swapiLoaders.getFilmsV2.load({ film_id: this.id, properties: ['episode_id'] }); + const response = await swapiLoaders.getFilmsV2.load({ film_id: this.id, property: 'episode_id' }); if (response instanceof Error) { return response; @@ -147,7 +149,7 @@ const createSWAPIServer = () => { } async director() { - const response = await swapiLoaders.getFilmsV2.load({ film_id: this.id, properties: ['director'] }); + const response = await swapiLoaders.getFilmsV2.load({ film_id: this.id, property: 'director' }); if (response instanceof Error) { return response; @@ -159,6 +161,94 @@ const createSWAPIServer = () => { } } + class FilmModelV3 { + id: number; + + constructor(id: number) { + this.id = id; + } + + async title() { + const response = await swapiLoaders.getFilmsV3.load({ film_id: this.id, property: 'title' }); + + if (response instanceof Error) { + return response; + } + + if (response) { + return response.properties.title; + } + } + + async episodeNumber() { + const response = await swapiLoaders.getFilmsV3.load({ film_id: this.id, property: 'episode_id' }); + + if (response instanceof Error) { + return response; + } + + if (response) { + return response.properties.episode_id; + } + } + + async director() { + const response = await swapiLoaders.getFilmsV3.load({ film_id: this.id, property: 'director' }); + + if (response instanceof Error) { + return response; + } + + if (response) { + return response.properties.director; + } + } + } + + class FilmModelV4 { + id: number; + + constructor(id: number) { + this.id = id; + } + + async title() { + const response = await swapiLoaders.getFilmsV4.load({ film_id: this.id, property: 'title' }); + + if (response instanceof Error) { + return response; + } + + if (response) { + return response[this.id].title; + } + } + + async episodeNumber() { + const response = await swapiLoaders.getFilmsV4.load({ film_id: this.id, property: 'episode_id' }); + + if (response instanceof Error) { + return response; + } + + if (response) { + return response[this.id].episode_id; + } + } + + async director() { + const response = await swapiLoaders.getFilmsV4.load({ film_id: this.id, property: 'director' }); + + if (response instanceof Error) { + return response; + } + + if (response) { + return response[this.id].director; + } + } + } + const root = { planet: ({ id }) => { return new PlanetModel(id); @@ -169,6 +259,12 @@ const createSWAPIServer = () => { filmv2: ({ id }) => { return new FilmModelV2(id); }, + filmv3: ({ id }) => { + return new FilmModelV3(id); + }, + filmv4: ({ id }) => { + return new FilmModelV4(id); + }, }; return { schema, root }; @@ -203,11 +299,22 @@ runQuery(/* GraphQL */ ` episodeNumber director } + theBestV2: filmv2(id: 4) { title episodeNumber director } + theBestV3: filmv3(id: 4) { + title + episodeNumber + director + } + theBestV4: filmv4(id: 4) { + title + episodeNumber + director + } } `).then((result) => { console.log(JSON.stringify(result, null, 4)); diff --git a/examples/swapi/swapi.dataloader-config.yaml b/examples/swapi/swapi.dataloader-config.yaml index 205eab6..cef00cd 100644 --- a/examples/swapi/swapi.dataloader-config.yaml +++ b/examples/swapi/swapi.dataloader-config.yaml @@ -33,7 +33,26 @@ resources: isBatchResource: true batchKey: film_ids newKey: film_id + propertyBatchKey: properties + propertyNewKey: property nestedPath: properties + mergePropertyConfig: PropertyAtTopLevel + getFilmsV3: + docsLink: https://swapi.dev/documentation#films + isBatchResource: true + batchKey: film_ids + newKey: film_id + propertyBatchKey: properties + propertyNewKey: property + mergePropertyConfig: PropertyInNestedObject + getFilmsV4: + docsLink: https://swapi.dev/documentation#films + isBatchResource: true + batchKey: film_ids + newKey: film_id + propertyBatchKey: properties + propertyNewKey: property + mergePropertyConfig: IdPropertyPair getRoot: docsLink: https://swapi.dev/documentation#root isBatchResource: false diff --git a/examples/swapi/swapi.js b/examples/swapi/swapi.js index 7a4568e..f637f5c 100644 --- a/examples/swapi/swapi.js +++ b/examples/swapi/swapi.js @@ -3,6 +3,9 @@ * @flow */ +import { property } from 'lodash'; +import { number } from 'yargs'; + const url = require('url'); const fetch = require('node-fetch').default; const SWAPI_URL = 'https://swapi.dev/api/'; @@ -70,6 +73,25 @@ export type SWAPI_Film_V2 = $ReadOnly<{| |}>, |}>; +export type SWAPI_Film_V3 = $ReadOnly<{| + film_id: number, + properties: {| + title: string, + episode_id: number, + director: string, + producer: string, + |}, +|}>; + +export type SWAPI_Film_V4 = $ReadOnly<{| + [number]: {| + title: string, + episode_id: number, + director: string, + producer: string, + |}, +|}>; + export type SWAPI_Vehicle = $ReadOnly<{| name: string, key: string, @@ -89,11 +111,20 @@ export type SWAPIClientlibTypes = {| getPeople: ({| people_ids: $ReadOnlyArray |}) => Promise<$ReadOnlyArray>, getVehicles: ({| vehicle_ids: $ReadOnlyArray |}) => Promise<$ReadOnlyArray>, getFilms: ({| film_ids: Set |}) => Promise<$ReadOnlyArray>, + getRoot: ({||}) => Promise, + // create fake resource with different interfaces to test batch properties feature getFilmsV2: ({| film_ids: $ReadOnlyArray, properties: $ReadOnlyArray, |}) => Promise, - getRoot: ({||}) => Promise, + getFilmsV3: ({| + film_ids: $ReadOnlyArray, + properties: $ReadOnlyArray, + |}) => Promise<$ReadOnlyArray>, + getFilmsV4: ({| + film_ids: $ReadOnlyArray, + properties: $ReadOnlyArray, + |}) => Promise<$ReadOnlyArray>, |}; module.exports = function (): SWAPIClientlibTypes { @@ -114,6 +145,7 @@ module.exports = function (): SWAPIClientlibTypes { Promise.all( [...film_ids].map((id) => fetch(url.resolve(SWAPI_URL, `films/${id}`)).then((res) => res.json())), ), + getRoot: ({}) => fetch(SWAPI_URL).then((res) => res.json()), getFilmsV2: ({ film_ids, properties }) => { return Promise.resolve({ properties: [ @@ -127,6 +159,30 @@ module.exports = function (): SWAPIClientlibTypes { ], }); }, - getRoot: ({}) => fetch(SWAPI_URL).then((res) => res.json()), + getFilmsV3: ({ film_ids, properties }) => { + return Promise.resolve([ + { + film_id: 4, + properties: { + director: 'George Lucas', + producer: 'Rick McCallum', + episode_id: 1, + title: 'The Phantom Menace', + }, + }, + ]); + }, + getFilmsV4: ({ film_ids, properties }) => { + return Promise.resolve([ + { + 4: { + director: 'George Lucas', + producer: 'Rick McCallum', + episode_id: 1, + title: 'The Phantom Menace', + }, + }, + ]); + }, }; }; diff --git a/schema.json b/schema.json index c4bfe37..bf3fffc 100644 --- a/schema.json +++ b/schema.json @@ -97,6 +97,18 @@ "isBatchKeyASet": { "type": "boolean", "description": "(Optional) Set to true if the interface of the resource takes the batch key as a set (rather than an array). For example, when using a generated clientlib based on swagger where `uniqueItems: true` is set for the batchKey parameter. Default: false" + }, + "propertyBatchKey": { + "type": "string", + "description": "(Optional) The argument to the resource that represents the nested list of optional properties we want to fetch. (e.g. usually 'properties' or 'features')" + }, + "propertyNewKey": { + "type": "string", + "description": "(Optional) The argument we'll replace the propertyBatchKey with - should be a singular version of the propertyBatchKey (e.g. usually 'property' or 'feature')" + }, + "mergePropertyConfig": { + "type": "string", + "description": "(Optional) A string enum to control what style of the resource it is. Currently, 'PropertyInNestedObject', 'PropertyAtTopLevel' and 'IdPropertyPair' are the only 3 supported styles" } } }, diff --git a/src/codegen.ts b/src/codegen.ts index 95803bf..7102225 100644 --- a/src/codegen.ts +++ b/src/codegen.ts @@ -61,10 +61,12 @@ export default function codegen( cacheKeyOptions, CaughtResourceError, defaultErrorHandler, + getBatchKeysForPartitionItems, partitionItems, resultsDictToList, sortByKeys, unPartitionResults, + unPartitionResultsByBatchKeyPartition, } from '${runtimeHelpers}'; diff --git a/src/config.ts b/src/config.ts index ffe7301..864697b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,10 +16,22 @@ export interface GlobalConfig { resources: any; } +export enum mergePropertyOptions { + // case 1: properties are returned in a nested object, id is at the top level. + PropertyInNestedObject = 'PropertyInNestedObject', + // case 2: properties are not returned in a nested object, but spread at top level as well. + PropertyAtTopLevel = 'PropertyAtTopLevel', + // case 3: id and its properties are returned as a key-value pair at the top level. + IdPropertyPair = 'IdPropertyPair', +} + export interface BatchResourceConfig { isBatchResource: true; batchKey: string; newKey: string; + propertyBatchKey: string; + propertyNewKey: string; + mergePropertyConfig: mergePropertyOptions; reorderResultsByKey?: string; nestedPath?: string; commaSeparatedBatchKey?: boolean; diff --git a/src/genTypeFlow.ts b/src/genTypeFlow.ts index be963fd..d9ebf48 100644 --- a/src/genTypeFlow.ts +++ b/src/genTypeFlow.ts @@ -68,6 +68,16 @@ export function getLoaderTypeKey(resourceConfig: ResourceConfig, resourcePath: R )}`; } + if (typeof resourceConfig.propertyNewKey === 'string' && typeof resourceConfig.propertyBatchKey === 'string') { + return `{| + ...$Diff<${resourceArgs}, { + ${resourceConfig.batchKey}: $PropertyType<${resourceArgs}, '${resourceConfig.batchKey}'>, + ${resourceConfig.propertyBatchKey}: $PropertyType<${resourceArgs}, '${resourceConfig.propertyBatchKey}'> + }>, + ...{| ${newKeyType}, ${resourceConfig.propertyNewKey}: $ElementType<$PropertyType<${resourceArgs}, '${resourceConfig.propertyBatchKey}'>, 0> |}, + |}`; + } + return `{| ...$Diff<${resourceArgs}, { ${resourceConfig.batchKey}: $PropertyType<${resourceArgs}, '${resourceConfig.batchKey}'> diff --git a/src/implementation.ts b/src/implementation.ts index e844ca2..d76d3e4 100644 --- a/src/implementation.ts +++ b/src/implementation.ts @@ -163,9 +163,30 @@ function getBatchLoader(resourceConfig: BatchResourceConfig, resourcePath: Reado * Returns: * \`[ [ 0, 2 ], [ 1 ] ]\` * + * We could also have more than one batch key. + * + * Example: + * + * \`\`\`js + * partitionItems([ + * { [bar_id: 7, property: 'property_1'], include_extra_info: true }, + * { [bar_id: 8, property: 'property_2'], include_extra_info: false }, + * { [bar_id: 9, property: 'property_3'], include_extra_info: true }, + * ], 'bar_id') + * \`\`\` + * + * Returns: + * \`[ [ 0, 2 ], [ 1 ] ]\` * We'll refer to each element in the group as a "request ID". */ - const requestGroups = partitionItems('${resourceConfig.newKey}', keys); + let requestGroups; + if (${typeof resourceConfig.propertyNewKey === 'string'} && ${ + typeof resourceConfig.propertyBatchKey === 'string' + }) { + requestGroups = partitionItems(['${resourceConfig.newKey}', '${resourceConfig.propertyNewKey}'], keys); + } else { + requestGroups = partitionItems('${resourceConfig.newKey}', keys); + } // Map the request groups to a list of Promises - one for each request const groupedResults = await Promise.all(requestGroups.map(async requestIDs => { @@ -179,23 +200,44 @@ function getBatchLoader(resourceConfig: BatchResourceConfig, resourcePath: Reado const requests = requestIDs.map(id => keys[id]); ${(() => { - const { batchKey, newKey, commaSeparatedBatchKey } = resourceConfig; + const { + batchKey, + newKey, + propertyBatchKey, + propertyNewKey, + commaSeparatedBatchKey, + } = resourceConfig; let batchKeyParam = `['${batchKey}']: requests.map(k => k['${newKey}'])`; if (commaSeparatedBatchKey === true) { batchKeyParam = `${batchKeyParam}.join(',')`; } - return ` - // For now, we assume that the dataloader key should be the first argument to the resource - // @see https://github.com/Yelp/dataloader-codegen/issues/56 - const resourceArgs = [{ - ..._.omit(requests[0], '${resourceConfig.newKey}'), - ${batchKeyParam}, - }]; - `; + if (typeof propertyNewKey === 'string' && typeof propertyBatchKey === 'string') { + let propertyBatchKeyParam = `['${propertyBatchKey}']: requests.map(k => k['${propertyNewKey}'])`; + if (commaSeparatedBatchKey === true) { + propertyBatchKeyParam = `${propertyBatchKeyParam}.join(',')`; + } + return ` + // For now, we assume that the dataloader key should be the first argument to the resource + // @see https://github.com/Yelp/dataloader-codegen/issues/56 + const resourceArgs = [{ + ..._.omit(requests[0], '${resourceConfig.newKey}', '${resourceConfig.propertyNewKey}'), + ${batchKeyParam}, + ${propertyBatchKeyParam}, + }]; + `; + } else { + return ` + // For now, we assume that the dataloader key should be the first argument to the resource + // @see https://github.com/Yelp/dataloader-codegen/issues/56 + const resourceArgs = [{ + ..._.omit(requests[0], '${resourceConfig.newKey}'), + ${batchKeyParam}, + }]; + `; + } })()} - let response = await ${callResource(resourceConfig, resourcePath)}(resourceArgs); if (!(response instanceof Error)) { @@ -272,9 +314,23 @@ function getBatchLoader(resourceConfig: BatchResourceConfig, resourcePath: Reado } ${(() => { - const { reorderResultsByKey, isResponseDictionary } = resourceConfig; - - if (!isResponseDictionary && reorderResultsByKey == null) { + const { + reorderResultsByKey, + isResponseDictionary, + propertyBatchKey, + propertyNewKey, + } = resourceConfig; + + if ( + !isResponseDictionary && + reorderResultsByKey == null && + /** + * When there's propertyBatchKey and propertyNewKey, the resource might + * contain less number of items that we requested. It's valid, so we + * should skip the check. + */ + !(typeof propertyNewKey === 'string' && typeof propertyBatchKey === 'string') + ) { return ` if (!(response instanceof Error)) { /** @@ -387,8 +443,69 @@ function getBatchLoader(resourceConfig: BatchResourceConfig, resourcePath: Reado return response; })) - // Split the results back up into the order that they were requested - return unPartitionResults(requestGroups, groupedResults); + /** + * When there's propertyBatchKey and propertyNewKey, the resource might + * contain less number of items that we requested. We need the value of batchKey and + * propertyBatchKey in requests group to help us split the results back up into the + * order that they were requested. + * + * + * Example: + * + * getBatchKeyForPartitionItems( + * 'bar_id', + * ['bar_id', 'property'], + * [ + * { bar_id: 2, property: 'name', include_extra_info: true }, + * { bar_id: 3, property: 'rating', include_extra_info: false }, + * { bar_id: 4, property: 'rating', include_extra_info: true }, + * ]) + * + * + * Returns: + * [ [ 2, 4 ], [ 3 ] ] + * + * getBatchKeyForPartitionItems( + * 'property', + * ['bar_id', 'property'], + * [ + * { bar_id: 2, property: 'name', include_extra_info: true }, + * { bar_id: 3, property: 'rating', include_extra_info: false }, + * { bar_id: 4, property: 'rating', include_extra_info: true }, + * ]) + * + * + * Returns: + * [ [ 'name', 'rating' ], [ 'rating' ] ] + */ + if ( + ${typeof resourceConfig.propertyNewKey === 'string'} && + ${typeof resourceConfig.propertyBatchKey === 'string'} && + ${typeof resourceConfig.mergePropertyConfig === 'string'}) { + const batchKeyPartition = getBatchKeysForPartitionItems( + '${resourceConfig.newKey}', + ['${resourceConfig.newKey}', '${resourceConfig.propertyNewKey}'], + keys + ); + const propertyBatchKeyPartiion = getBatchKeysForPartitionItems( + '${resourceConfig.propertyNewKey}', + ['${resourceConfig.newKey}', '${resourceConfig.propertyNewKey}'], + keys + ); + + return unPartitionResultsByBatchKeyPartition( + '${resourceConfig.newKey}', + '${resourceConfig.propertyBatchKey}', + '${resourceConfig.mergePropertyConfig}', + batchKeyPartition, + propertyBatchKeyPartiion, + requestGroups, + groupedResults + ); + } else { + // Split the results back up into the order that they were requested + return unPartitionResults(requestGroups, groupedResults); + } }, { ${ @@ -442,6 +559,5 @@ export default function getLoaderImplementation(resourceConfig: ResourceConfig, const loader = resourceConfig.isBatchResource ? getBatchLoader(resourceConfig, resourcePath) : getNonBatchLoader(resourceConfig, resourcePath); - return loader; } diff --git a/src/runtimeHelpers.ts b/src/runtimeHelpers.ts index 97375ee..b653a2e 100644 --- a/src/runtimeHelpers.ts +++ b/src/runtimeHelpers.ts @@ -8,6 +8,7 @@ import AggregateError from 'aggregate-error'; import ensureError from 'ensure-error'; import invariant from 'assert'; import objectHash from 'object-hash'; +import { mergePropertyOptions } from './config'; export function errorPrefix(resourcePath: ReadonlyArray): string { return `[dataloader-codegen :: ${resourcePath.join('.')}]`; @@ -62,17 +63,17 @@ export const cacheKeyOptions = { /** * Take in all objects passed to .load(), and bucket them by the non - * batchKey attributes. + * batch keys (i.e. `batchKey` and `propertyBatchKey`) attributes. * * We use this to chunk up the requests to the resource. * * Example: * ```js - * partitionItems([ + * partitionItems('bar_id', [ * { bar_id: 2, include_extra_info: true }, * { bar_id: 3, include_extra_info: false }, * { bar_id: 4, include_extra_info: true }, - * ], 'bar_id') + * ]) * ``` * * Returns: @@ -80,13 +81,16 @@ export const cacheKeyOptions = { * * TODO: add generic instead of 'object' for the items array argument */ -export function partitionItems(ignoreKey: string, items: ReadonlyArray): ReadonlyArray> { +export function partitionItems( + ignoreKeys: Array | string, + items: ReadonlyArray, +): ReadonlyArray> { const groups: { [key: string]: Array; } = {}; items.forEach((item, i) => { - const hash = objectHash(_.omit(item, ignoreKey), { algorithm: 'passthrough' }); + const hash = objectHash(_.omit(item, ignoreKeys), { algorithm: 'passthrough' }); groups[hash] = groups[hash] || []; groups[hash].push(i); }); @@ -94,6 +98,47 @@ export function partitionItems(ignoreKey: string, items: ReadonlyArray): return Object.values(groups); } +/** + * Take in all objects passed to .load(), and bucket them by the non + * batch keys (i.e. `batchKey` and `propertyBatchKey`) attributes. + * Return batch keys value for each partition items. + * + * This function is only called when we have propertyBatchKey, and it's + * used to map result to the order of requests. + * + * Example: + * ```js + * getBatchKeyForPartitionItems( + * 'bar_id', + * ['bar_id', 'property'], + * [ + * { bar_id: 2, property: 'name', include_extra_info: true }, + * { bar_id: 3, property: 'rating', include_extra_info: false }, + * { bar_id: 2, property: 'rating', include_extra_info: true }, + * ]) + * ``` + * + * Returns: + * `[ [ 2, 2 ], [ 3 ] ]` + * + * TODO: add generic instead of 'object' for the items array argument + */ +export function getBatchKeysForPartitionItems( + batchKey: string, + ignoreKeys: Array, + items: ReadonlyArray, +): ReadonlyArray> { + const groups: { + [key: string]: Array; + } = {}; + items.forEach((item, i) => { + const hash = objectHash(_.omit(item, ignoreKeys), { algorithm: 'passthrough' }); + groups[hash] = groups[hash] || []; + groups[hash].push(items[i][batchKey]); + }); + + return Object.values(groups); +} /** * Utility function to sort array of objects by a list of corresponding IDs * @@ -253,6 +298,205 @@ export function unPartitionResults( }); } +/** + * Perform the inverse mapping from partitionItems on the nested results we get + * back from the service. This function is only called when we have propertyBatchKey and propertyNewKey. + * We only three different kind of response contract. + * + * Case 1: propertyBatchKey is returned in a nested object, newKey is at the top level. + * If we have 'bar_id' as newKey and 'properties' as propertyBatchKey, + * the resultGroups should look like this: + * [ + * [ { bar_id: 2, properties: { name: 'Burger King', rating: 3 } } ], + * [ { bar_id: 1, properties: { name: 'In N Out', rating: 4 } } ] + * ], + * + * Case 2: propertyBatchKey is not returned in a nested object, but spread at top level as well. + * If we have 'bar_id' as newKey and 'properties' as propertyBatchKey, + * the resultGroups should look like this: + * [ + * [ { bar_id: 2, name: 'Burger King', rating: 3 } ], + * [ { bar_id: 1, name: 'In N Out', rating: 4 } ] + * ], + * + * Case 3: newKey and its properties are returned as a key-value pair at the top level. + * If we have 'bar_id' as newKey and 'properties' as propertyBatchKey, + * the resultGroups should look like this: + * [ + * [ { 2: { name: 'Burger King', rating: 3 } } ], + * [ { 1: { name: 'In N Out', rating: 4 } } ] + * ], + * + * IMPORTANT NOTE: The contract must have a one-to-one correspondence between the input propertyBatchKey and the output propertyBatchKey. + * i.e. if we have property: 'name' in the request, the response must have 'name' in it, and no extra data assciated with it. + * + * Example + * Request args: + * [ + * { bar_id: 2, property: 'name', include_extra_info: true }, + * { bar_id: 1, property: 'rating', include_extra_info: false }, + * { bar_id: 2, property: 'rating', include_extra_info: true }, + * ] + * + * ```js + * unPartitionResultsByBatchKeyPartition( + * newKey = 'bar_id', + * propertyBatchKey = 'properties', + * batchKeyPartition = [ [2, 2], [1] ], + * propertyBatchKeyPartion = [ ['name', 'rating'], ['rating'] ], + * requestGroups = [ [0, 2], [1] ], + * resultGroups = [ + * [ { bar_id: 2, name: 'Burger King', rating: 3 } ], + * [ { bar_id: 1, name: 'In N Out', rating: 4 } ] + * ], + * ) + * ``` + * + * Returns: + * ``` + * [ + * { bar_id: 2, name: 'Burger King' }, + * { bar_id: 1, rating: 4 }, + * { bar_id: 2, rating: 3 }, + * ] + */ +export function unPartitionResultsByBatchKeyPartition>( + newKey: string, + propertyBatchKey: string, + mergePropertyConfig: mergePropertyOptions, + batchKeyPartition: ReadonlyArray>, + propertyBatchKeyPartion: ReadonlyArray>, + /** Should be a nested array of IDs, as generated by partitionItems */ + requestGroups: ReadonlyArray>, + /** The results back from the service, in the same shape as groups */ + resultGroups: ReadonlyArray>, +): ReadonlyArray { + /** + * e.g. with our inputs, produce: + * ```js + * [ + * [ + * { order: 0, result: { bar_id: 2, name: 'Burger King' }, + * { order: 2, result: { bar_id: 2, rating: 3 } }, + * ], + * [ + * { order: 1, result: { bar_id: 1, rating: 4 } }, + * ] + * ] + * ``` + */ + const zippedGroups: ReadonlyArray> = requestGroups.map( + (ids, i) => { + return ids.map((id, j) => { + let result = null; + for (const resultElement of Object.values(resultGroups)[i]) { + // There's error in the result we should return + if (resultElement instanceof CaughtResourceError) { + result = resultElement; + break; + } + + /** + * Case 1: propertyBatchKey is returned in a nested object, newKey is at the top level. + * e.g.: { bar_id: 2, properties: { name: 'Burger King', rating: 3 } } + * If requested property exist in resultElement, we restructure data, so that we only return + * the property we asked for. + */ + if (mergePropertyConfig == 'PropertyInNestedObject') { + if ( + Object.values(resultElement).includes(batchKeyPartition[i][j]) && + resultElement.hasOwnProperty(propertyBatchKey) && + resultElement[propertyBatchKey].hasOwnProperty(propertyBatchKeyPartion[i][j]) + ) { + result = { + ...resultElement, + } as Record; + result[propertyBatchKey] = { + [propertyBatchKeyPartion[i][j]]: + resultElement[propertyBatchKey][propertyBatchKeyPartion[i][j]], + }; + break; + } + } else if (mergePropertyConfig == 'PropertyAtTopLevel') { + /** + * Case 2: propertyBatchKey is not returned in a nested object, but spread at top level as well. + * e.g.: { bar_id: 2, name: 'Burger King', rating: 3 } + * We restructure data, so that we only leave newKey and the property we asked for at the top level. + */ + if (Object.values(resultElement).includes(batchKeyPartition[i][j])) { + result = Object.assign( + {}, + ...[newKey, propertyBatchKeyPartion[i][j]].map((key) => ({ + [key]: resultElement[key], + })), + ); + break; + } + } else if (mergePropertyConfig == 'IdPropertyPair') { + /** + * Case 3: newKey and its properties are returned as a key-value pair at the top level. + * e.g.: { 2: { name: 'Burger King', rating: 3 } } + * If requested property exist in resultElement, we restructure data, so that we only return + * the property we asked for. + */ + if ( + resultElement.hasOwnProperty(batchKeyPartition[i][j]) && + resultElement[batchKeyPartition[i][j]].hasOwnProperty(propertyBatchKeyPartion[i][j]) + ) { + result = { + [batchKeyPartition[i][j]]: { + [propertyBatchKeyPartion[i][j]]: + resultElement[batchKeyPartition[i][j]][propertyBatchKeyPartion[i][j]], + }, + }; + break; + } + } + } + + // If requested property doesn't exist in resultElement, we should throw BatchItemNotFoundError error. + if (result === null) { + return { + order: id, + result: new BatchItemNotFoundError( + [ + `Could not find newKey = "${batchKeyPartition[i][j]}" and propertyNewKey = "${propertyBatchKeyPartion[i][j]}" in the response dict.`, + `Or your endpoint does not follow the contract we support.`, + ].join(' '), + ), + }; + } else { + return { order: id, result: result }; + } + }); + }, + ); + /** + * Flatten and sort the groups - e.g.: + * ```js + * [ + * { order: 0, result: { bar_id: 2, name: 'Burger King' } }, + * { order: 1, result: { bar_id: 1, rating: 4 } }, + * { order: 2, result: { bar_id: 2, rating: 3 } } + * ] + * ``` + */ + const sortedResults: ReadonlyArray<{ order: number; result: T | Error }> = _.sortBy(_.flatten(zippedGroups), [ + 'order', + ]); + + // Now that we have a sorted array, return the actual results! + return sortedResults + .map((r) => r.result) + .map((result) => { + if (result instanceof CaughtResourceError) { + return result.cause; + } else { + return result; + } + }); +} + /** * Turn a dictionary of results into an ordered list *