From 1c6ba4468eab1440931c88929affd5b4c593f377 Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Fri, 29 Mar 2024 06:26:24 -0300 Subject: [PATCH] feat(modules-sdk): remote query retrieve (#6849) What: Remote Joiner options to check if keys exist on entry points or relations --- .changeset/young-scissors-shake.md | 7 + .../__tests__/modules/remote-query.spec.ts | 205 ++++++++++++++++++ packages/modules-sdk/src/medusa-app.ts | 9 +- packages/modules-sdk/src/remote-query.ts | 6 +- .../src/__mocks__/joiner/mock_data.ts | 5 + .../__tests__/joiner/remote-joiner-data.ts | 37 +++- .../src/__tests__/joiner/remote-joiner.ts | 10 +- .../orchestration/src/joiner/remote-joiner.ts | 102 +++++++-- packages/types/src/joiner/index.ts | 5 + packages/types/src/modules-sdk/index.ts | 4 +- 10 files changed, 356 insertions(+), 34 deletions(-) create mode 100644 .changeset/young-scissors-shake.md create mode 100644 integration-tests/modules/__tests__/modules/remote-query.spec.ts diff --git a/.changeset/young-scissors-shake.md b/.changeset/young-scissors-shake.md new file mode 100644 index 0000000000..0f1a5899f8 --- /dev/null +++ b/.changeset/young-scissors-shake.md @@ -0,0 +1,7 @@ +--- +"@medusajs/modules-sdk": patch +"@medusajs/orchestration": patch +"@medusajs/types": patch +--- + +Remote Joiner options to check if keys exist on entrypoints or relations diff --git a/integration-tests/modules/__tests__/modules/remote-query.spec.ts b/integration-tests/modules/__tests__/modules/remote-query.spec.ts new file mode 100644 index 0000000000..f762f8b551 --- /dev/null +++ b/integration-tests/modules/__tests__/modules/remote-query.spec.ts @@ -0,0 +1,205 @@ +import { ModuleRegistrationName, Modules } from "@medusajs/modules-sdk" +import { IPaymentModuleService, IRegionModuleService } from "@medusajs/types" +import { ContainerRegistrationKeys } from "@medusajs/utils" +import { medusaIntegrationTestRunner } from "medusa-test-utils" +import { createAdminUser } from "../../..//helpers/create-admin-user" +import { adminHeaders } from "../../../helpers/create-admin-user" + +jest.setTimeout(50000) + +const env = { MEDUSA_FF_MEDUSA_V2: true } + +medusaIntegrationTestRunner({ + env, + testSuite: ({ dbConnection, getContainer, api }) => { + describe("Remote Query", () => { + let appContainer + let regionModule: IRegionModuleService + let paymentModule: IPaymentModuleService + let remoteQuery + let remoteLink + + beforeAll(async () => { + appContainer = getContainer() + regionModule = appContainer.resolve(ModuleRegistrationName.REGION) + paymentModule = appContainer.resolve(ModuleRegistrationName.PAYMENT) + remoteQuery = appContainer.resolve( + ContainerRegistrationKeys.REMOTE_QUERY + ) + remoteLink = appContainer.resolve(ContainerRegistrationKeys.REMOTE_LINK) + }) + + beforeEach(async () => { + await createAdminUser(dbConnection, adminHeaders, appContainer) + }) + + it("should fail to retrieve a single non-existing id", async () => { + const region = await regionModule.create({ + name: "Test Region", + currency_code: "usd", + countries: ["us"], + }) + + const getRegion = await remoteQuery({ + region: { + fields: ["id", "currency_code"], + __args: { + id: region.id, + }, + }, + }) + + expect(getRegion).toEqual([ + { + id: region.id, + currency_code: "usd", + }, + ]) + + const getNonExistingRegion = remoteQuery( + { + region: { + fields: ["id", "currency_code"], + __args: { + id: "region_123", + }, + }, + }, + undefined, + { throwIfKeyNotFound: true } + ) + + expect(getNonExistingRegion).rejects.toThrow( + "region id not found: region_123" + ) + }) + + it("should fail if a expected relation is not found", async () => { + const region = await regionModule.create({ + name: "Test Region", + currency_code: "usd", + countries: ["us"], + }) + + const regionWithPayment = await regionModule.create({ + name: "Test W/ Payment", + currency_code: "brl", + countries: ["br"], + }) + + const regionNoLink = await regionModule.create({ + name: "No link", + currency_code: "eur", + countries: ["dk"], + }) + + await remoteLink.create([ + { + [Modules.REGION]: { + region_id: region.id, + }, + [Modules.PAYMENT]: { + payment_provider_id: "pp_system_default_non_existent", + }, + }, + { + [Modules.REGION]: { + region_id: regionWithPayment.id, + }, + [Modules.PAYMENT]: { + payment_provider_id: "pp_system_default", // default payment provider auto created + }, + }, + ]) + + // Validate all relations, including the link + expect( + remoteQuery( + { + region: { + fields: ["id"], + __args: { + id: regionNoLink.id, + }, + payment_providers: { + fields: ["id"], + }, + }, + }, + undefined, + { + throwIfRelationNotFound: true, + } + ) + ).rejects.toThrow( + `regionRegionPaymentPaymentProviderLink region_id not found: ${regionNoLink.id}` + ) + + // Only validate the relations with Payment. It doesn't fail because the link didn't return any data + expect( + remoteQuery( + { + region: { + fields: ["id"], + __args: { + id: regionNoLink.id, + }, + payment_providers: { + fields: ["id"], + }, + }, + }, + undefined, + { + throwIfRelationNotFound: [Modules.PAYMENT], + } + ) + ).resolves.toHaveLength(1) + + // The link exists, but the payment doesn't + expect( + remoteQuery( + { + region: { + fields: ["id"], + __args: { + id: region.id, + }, + payment_providers: { + fields: ["id"], + }, + }, + }, + undefined, + { + throwIfRelationNotFound: [Modules.PAYMENT], + } + ) + ).rejects.toThrow( + "payment id not found: pp_system_default_non_existent" + ) + + // everything is fine + expect( + remoteQuery( + { + region: { + fields: ["id"], + __args: { + id: regionWithPayment.id, + }, + payment_providers: { + fields: ["id"], + }, + }, + }, + undefined, + { + throwIfRelationNotFound: [Modules.PAYMENT], + } + ) + ).resolves.toHaveLength(1) + }) + }) + }, +}) diff --git a/packages/modules-sdk/src/medusa-app.ts b/packages/modules-sdk/src/medusa-app.ts index 4f81cd2ad3..b01f135e85 100644 --- a/packages/modules-sdk/src/medusa-app.ts +++ b/packages/modules-sdk/src/medusa-app.ts @@ -12,6 +12,7 @@ import { ModuleExports, ModuleJoinerConfig, ModuleServiceInitializeOptions, + RemoteJoinerOptions, RemoteJoinerQuery, RemoteQueryFunction, } from "@medusajs/types" @@ -241,7 +242,8 @@ async function MedusaApp_({ link: RemoteLink | undefined query: ( query: string | RemoteJoinerQuery | object, - variables?: Record + variables?: Record, + options?: RemoteJoinerOptions ) => Promise entitiesMap?: Record notFound?: Record> @@ -345,9 +347,10 @@ async function MedusaApp_({ const query = async ( query: string | RemoteJoinerQuery | object, - variables?: Record + variables?: Record, + options?: RemoteJoinerOptions ) => { - return await remoteQuery.query(query, variables) + return await remoteQuery.query(query, variables, options) } const runMigrations: RunMigrationFn = async ( diff --git a/packages/modules-sdk/src/remote-query.ts b/packages/modules-sdk/src/remote-query.ts index 99d28af172..9fd5cce274 100644 --- a/packages/modules-sdk/src/remote-query.ts +++ b/packages/modules-sdk/src/remote-query.ts @@ -15,6 +15,7 @@ import { } from "@medusajs/types" import { isString, toPascalCase } from "@medusajs/utils" +import { RemoteJoinerOptions } from "@medusajs/types" import { MedusaModule } from "./medusa-module" export class RemoteQuery { @@ -230,7 +231,8 @@ export class RemoteQuery { public async query( query: string | RemoteJoinerQuery | object, - variables?: Record + variables?: Record, + options?: RemoteJoinerOptions ): Promise { let finalQuery: RemoteJoinerQuery = query as RemoteJoinerQuery @@ -240,6 +242,6 @@ export class RemoteQuery { finalQuery = toRemoteJoinerQuery(query, variables) } - return await this.remoteJoiner.query(finalQuery) + return await this.remoteJoiner.query(finalQuery, options) } } diff --git a/packages/orchestration/src/__mocks__/joiner/mock_data.ts b/packages/orchestration/src/__mocks__/joiner/mock_data.ts index a298c44ccf..b5d8f9261b 100644 --- a/packages/orchestration/src/__mocks__/joiner/mock_data.ts +++ b/packages/orchestration/src/__mocks__/joiner/mock_data.ts @@ -129,6 +129,11 @@ export const mockServiceList = (serviceName) => { }) } + // mock filtering on service order + if (serviceName === "orderService" && data.options?.id) { + resultset = resultset.filter((item) => data.options.id.includes(item.id)) + } + return { data: resultset, path: serviceName === "productService" ? "rows" : undefined, diff --git a/packages/orchestration/src/__tests__/joiner/remote-joiner-data.ts b/packages/orchestration/src/__tests__/joiner/remote-joiner-data.ts index 54ecbe554a..26d023f6a9 100644 --- a/packages/orchestration/src/__tests__/joiner/remote-joiner-data.ts +++ b/packages/orchestration/src/__tests__/joiner/remote-joiner-data.ts @@ -241,12 +241,7 @@ describe("RemoteJoiner", () => { fields: ["name"], }, ], - args: [ - { - name: "id", - value: "3", - }, - ], + args: [], } const data = await joiner.query(query) @@ -802,4 +797,34 @@ describe("RemoteJoiner", () => { `Service with alias "user" was not found.` ) }) + + it("Should throw when any key of the entrypoint isn't found", async () => { + const query = RemoteJoiner.parseQuery(` + query { + order (id: 201) { + id + number + } + } + `) + const data = await joiner.query(query, { + throwIfKeyNotFound: true, + }) + + expect(data.length).toEqual(1) + + const queryNotFound = RemoteJoiner.parseQuery(` + query { + order (id: "ord_1234556") { + id + number + } + } + `) + const dataNotFound = joiner.query(queryNotFound, { + throwIfKeyNotFound: true, + }) + + expect(dataNotFound).rejects.toThrowError("order id not found: ord_1234556") + }) }) diff --git a/packages/orchestration/src/__tests__/joiner/remote-joiner.ts b/packages/orchestration/src/__tests__/joiner/remote-joiner.ts index 4e78bb927d..7fe8e470e4 100644 --- a/packages/orchestration/src/__tests__/joiner/remote-joiner.ts +++ b/packages/orchestration/src/__tests__/joiner/remote-joiner.ts @@ -569,19 +569,13 @@ describe("RemoteJoiner", () => { fields: ["name"], }, ], - args: [ - { - name: "id", - value: "3", - }, - ], } await joiner.query(query) expect(serviceMock.orderService).toHaveBeenCalledTimes(1) expect(serviceMock.orderService).toHaveBeenCalledWith({ - args: [], + args: undefined, fields: ["number", "date", "products", "user_id"], expands: { products: { @@ -589,7 +583,7 @@ describe("RemoteJoiner", () => { fields: ["product_id"], }, }, - options: { id: ["3"] }, + options: { id: undefined }, }) expect(serviceMock.userService).toHaveBeenCalledTimes(1) diff --git a/packages/orchestration/src/joiner/remote-joiner.ts b/packages/orchestration/src/joiner/remote-joiner.ts index 4b1c0c4085..178644bf69 100644 --- a/packages/orchestration/src/joiner/remote-joiner.ts +++ b/packages/orchestration/src/joiner/remote-joiner.ts @@ -8,7 +8,8 @@ import { RemoteNestedExpands, } from "@medusajs/types" -import { deduplicate, isDefined, isString } from "@medusajs/utils" +import { RemoteJoinerOptions } from "@medusajs/types" +import { MedusaError, deduplicate, isDefined, isString } from "@medusajs/utils" import GraphQLParser from "./graphql-ast" const BASE_PATH = "_root" @@ -331,7 +332,8 @@ export class RemoteJoiner { expand: RemoteExpandProperty, pkField: string, ids?: (unknown | unknown[])[], - relationship?: any + relationship?: any, + options?: RemoteJoinerOptions ): Promise<{ data: unknown[] | { [path: string]: unknown } path?: string @@ -372,6 +374,15 @@ export class RemoteJoiner { resData = Array.isArray(resData) ? resData : [resData] + this.checkIfKeysExist( + uniqueIds, + resData, + expand, + pkField, + relationship, + options + ) + const filteredDataArray = resData.map((data: any) => RemoteJoiner.filterFields(data, expand.fields, expand.expands) ) @@ -385,6 +396,47 @@ export class RemoteJoiner { return response } + private checkIfKeysExist( + uniqueIds: unknown[] | undefined, + resData: any[], + expand: RemoteExpandProperty, + pkField: string, + relationship?: any, + options?: RemoteJoinerOptions + ) { + if ( + !( + isDefined(uniqueIds) && + ((options?.throwIfKeyNotFound && !isDefined(relationship)) || + (options?.throwIfRelationNotFound && isDefined(relationship))) + ) + ) { + return + } + + if (isDefined(relationship)) { + if ( + Array.isArray(options?.throwIfRelationNotFound) && + !options?.throwIfRelationNotFound.includes(relationship.serviceName) + ) { + return + } + } + + const notFound = new Set(uniqueIds) + resData.forEach((data) => { + notFound.delete(data[pkField]) + }) + + if (notFound.size > 0) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `${expand.serviceConfig.serviceName} ${pkField} not found: ` + + Array.from(notFound).join(", ") + ) + } + } + private handleFieldAliases( items: any[], parsedExpands: Map, @@ -466,7 +518,8 @@ export class RemoteJoiner { private async handleExpands( items: any[], parsedExpands: Map, - implodeMapping: InternalImplodeMapping[] = [] + implodeMapping: InternalImplodeMapping[] = [], + options?: RemoteJoinerOptions ): Promise { if (!parsedExpands) { return @@ -488,7 +541,12 @@ export class RemoteJoiner { } if (nestedItems.length > 0) { - await this.expandProperty(nestedItems, expand.parentConfig!, expand) + await this.expandProperty( + nestedItems, + expand.parentConfig!, + expand, + options + ) } } @@ -498,7 +556,8 @@ export class RemoteJoiner { private async expandProperty( items: any[], parentServiceConfig: JoinerServiceConfig, - expand?: RemoteExpandProperty + expand?: RemoteExpandProperty, + options?: RemoteJoinerOptions ): Promise { if (!expand) { return @@ -509,14 +568,20 @@ export class RemoteJoiner { ) if (relationship) { - await this.expandRelationshipProperty(items, expand, relationship) + await this.expandRelationshipProperty( + items, + expand, + relationship, + options + ) } } private async expandRelationshipProperty( items: any[], expand: RemoteExpandProperty, - relationship: JoinerRelationship + relationship: JoinerRelationship, + options?: RemoteJoinerOptions ): Promise { const field = relationship.inverse ? relationship.primaryKey @@ -552,7 +617,8 @@ export class RemoteJoiner { expand, field, idsToFetch, - relationship + relationship, + options ) const joinFields = relationship.inverse @@ -602,14 +668,16 @@ export class RemoteJoiner { query: RemoteJoinerQuery, serviceConfig: JoinerServiceConfig, expands: RemoteJoinerQuery["expands"], - implodeMapping: InternalImplodeMapping[] + implodeMapping: InternalImplodeMapping[], + options?: RemoteJoinerOptions ): Map { const parsedExpands = this.parseProperties( initialService, query, serviceConfig, expands, - implodeMapping + implodeMapping, + options ) const groupedExpands = this.groupExpands(parsedExpands) @@ -622,7 +690,8 @@ export class RemoteJoiner { query: RemoteJoinerQuery, serviceConfig: JoinerServiceConfig, expands: RemoteJoinerQuery["expands"], - implodeMapping: InternalImplodeMapping[] + implodeMapping: InternalImplodeMapping[], + options?: RemoteJoinerOptions ): Map { const aliasRealPathMap = new Map() const parsedExpands = new Map() @@ -913,7 +982,10 @@ export class RemoteJoiner { return mergedExpands } - async query(queryObj: RemoteJoinerQuery): Promise { + async query( + queryObj: RemoteJoinerQuery, + options?: RemoteJoinerOptions + ): Promise { const serviceConfig = this.getServiceConfig( queryObj.service, queryObj.alias @@ -960,7 +1032,8 @@ export class RemoteJoiner { root, pkName, primaryKeyArg?.value, - undefined + undefined, + options ) const data = response.path ? response.data[response.path!] : response.data @@ -968,7 +1041,8 @@ export class RemoteJoiner { await this.handleExpands( Array.isArray(data) ? data : [data], parsedExpands, - implodeMapping + implodeMapping, + options ) return response.data diff --git a/packages/types/src/joiner/index.ts b/packages/types/src/joiner/index.ts index 9d33ae069f..7d15fced9d 100644 --- a/packages/types/src/joiner/index.ts +++ b/packages/types/src/joiner/index.ts @@ -83,6 +83,11 @@ export interface RemoteJoinerQuery { directives?: { [field: string]: JoinerDirective[] } } +export interface RemoteJoinerOptions { + throwIfKeyNotFound?: boolean + throwIfRelationNotFound?: boolean | string[] +} + export interface RemoteNestedExpands { [key: string]: { fields: string[] diff --git a/packages/types/src/modules-sdk/index.ts b/packages/types/src/modules-sdk/index.ts index f2cdb042aa..4aaf939a71 100644 --- a/packages/types/src/modules-sdk/index.ts +++ b/packages/types/src/modules-sdk/index.ts @@ -1,6 +1,7 @@ import { JoinerRelationship, JoinerServiceConfig, + RemoteJoinerOptions, RemoteJoinerQuery, } from "../joiner" @@ -278,7 +279,8 @@ export type ModuleBootstrapDeclaration = export type RemoteQueryFunction = ( query: string | RemoteJoinerQuery | object, - variables?: Record + variables?: Record, + options?: RemoteJoinerOptions ) => Promise | null export interface IModuleService {