From b868a4ef4deb7df09d6156a907f4c883cd55a3fd Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Tue, 29 Apr 2025 07:10:31 -0300 Subject: [PATCH] feat(index): add filterable fields to link definition (#11898) * feat(index): add filterable fields to link definition * rm comment * break recursion * validate read only links * validate filterable * gql schema array * link parents * isInverse * push id when not present * Fix ciruclar relationships and add tests to ensure proper behaviour (part 1) * log and fallback to entity.alias * cleanup and fixes * cleanup and fixes * cleanup and fixes * fix get attributes * gql type * unit test * array inference * rm only * package.json * pacvkage.json * fix link retrieval on duplicated entity type and aliases + tests * link parents as array * Match only parent entity * rm comment * remove hard coded schema * extend types * unit test * test * types * pagination type * type * fix integration tests * Improve performance of in selection * use @@ to filter property * escape jsonPath * add Event Bus by default * changeset * rm postgres analyze * estimate count * new query * parent aliases * inner query w/ filter and sort relations * address comments --------- Co-authored-by: adrien2p Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> --- .changeset/small-jokes-shake.md | 11 + .../__tests__/index/query-index.spec.ts | 2 +- .../modules/__tests__/index/search.spec.ts | 2 +- .../modules/__tests__/index/sync.spec.ts | 4 +- .../core/framework/src/medusa-app-loader.ts | 3 +- .../src/loaders/utils/load-internal.ts | 4 +- packages/core/types/src/common/common.ts | 4 + .../core/types/src/http/common/response.ts | 6 +- .../src/index-data/__tests__/index.spec.ts | 1 + packages/core/types/src/index-data/common.ts | 13 + .../query-config/query-input-config-fields.ts | 3 +- .../query-config/query-input-config.ts | 59 +- packages/core/types/src/joiner/index.ts | 1 + packages/core/types/src/modules-sdk/index.ts | 5 + .../remote-query-object-from-string.ts | 5 +- .../types/src/modules-sdk/remote-query.ts | 16 +- .../src/dml/__tests__/create-graphql.spec.ts | 2 +- .../helpers/graphql-builder/get-attribute.ts | 2 +- .../core/utils/src/modules-sdk/define-link.ts | 18 + .../utils/src/modules-sdk/query-context.ts | 8 +- .../medusa/src/api/admin/products/route.ts | 2 +- .../medusa/src/api/store/products/route.ts | 7 +- .../__tests__/query-builder.spec.ts | 215 +- .../src/migrations/Migration20231019174230.ts | 4 +- .../src/services/index-module-service.ts | 29 +- .../index/src/services/postgres-provider.ts | 50 +- .../src/utils/__tests__/build-config.spec.ts | 1857 +++++++++++++++++ .../index/src/utils/base-graphql-schema.ts | 6 + .../modules/index/src/utils/build-config.ts | 902 ++++++-- .../index/src/utils/create-partitions.ts | 46 +- .../modules/index/src/utils/default-schema.ts | 8 +- .../modules/index/src/utils/gql-to-types.ts | 10 +- .../modules/index/src/utils/query-builder.ts | 650 +++--- .../index/src/utils/sync/configuration.ts | 20 +- .../link-modules/src/utils/generate-schema.ts | 4 +- packages/modules/pricing/src/joiner-config.ts | 2 - packages/modules/pricing/src/schema/index.ts | 59 - 37 files changed, 3285 insertions(+), 755 deletions(-) create mode 100644 .changeset/small-jokes-shake.md create mode 100644 packages/modules/index/src/utils/__tests__/build-config.spec.ts create mode 100644 packages/modules/index/src/utils/base-graphql-schema.ts delete mode 100644 packages/modules/pricing/src/schema/index.ts diff --git a/.changeset/small-jokes-shake.md b/.changeset/small-jokes-shake.md new file mode 100644 index 0000000000..ca695dd5c9 --- /dev/null +++ b/.changeset/small-jokes-shake.md @@ -0,0 +1,11 @@ +--- +"@medusajs/index": patch +"@medusajs/types": patch +"@medusajs/utils": patch +"@medusajs/framework": patch +"@medusajs/link-modules": patch +"@medusajs/pricing": patch +"@medusajs/modules-sdk": patch +--- + +feat(index): add filterable fields to link definition diff --git a/integration-tests/modules/__tests__/index/query-index.spec.ts b/integration-tests/modules/__tests__/index/query-index.spec.ts index c778348652..424e0d7195 100644 --- a/integration-tests/modules/__tests__/index/query-index.spec.ts +++ b/integration-tests/modules/__tests__/index/query-index.spec.ts @@ -143,7 +143,7 @@ medusaIntegrationTestRunner({ ) expect(resultset.metadata).toEqual({ - count: 2, + estimate_count: expect.any(Number), skip: 0, take: 10, }) diff --git a/integration-tests/modules/__tests__/index/search.spec.ts b/integration-tests/modules/__tests__/index/search.spec.ts index 30b0f35f6e..6ce0235479 100644 --- a/integration-tests/modules/__tests__/index/search.spec.ts +++ b/integration-tests/modules/__tests__/index/search.spec.ts @@ -59,7 +59,7 @@ async function populateData( } medusaIntegrationTestRunner({ - testSuite: ({ getContainer, dbConnection, api, dbConfig }) => { + testSuite: ({ getContainer, dbConnection, api }) => { let indexEngine: IndexTypes.IIndexService let appContainer diff --git a/integration-tests/modules/__tests__/index/sync.spec.ts b/integration-tests/modules/__tests__/index/sync.spec.ts index e1a2dd382a..4136c69b7a 100644 --- a/integration-tests/modules/__tests__/index/sync.spec.ts +++ b/integration-tests/modules/__tests__/index/sync.spec.ts @@ -93,6 +93,7 @@ medusaIntegrationTestRunner({ ;(indexEngine as any).storageProvider_.onApplicationStart = jest.fn() // Trigger a sync + ;(indexEngine as any).schemaObjectRepresentation_ = null await (indexEngine as any).onApplicationStart_() // 28 ms - 6511 records @@ -138,6 +139,7 @@ medusaIntegrationTestRunner({ ;(indexEngine as any).storageProvider_.onApplicationStart = jest.fn() // Trigger a sync + ;(indexEngine as any).schemaObjectRepresentation_ = null await (indexEngine as any).onApplicationStart_() const { data: results } = await indexEngine.query<"product">({ @@ -172,8 +174,8 @@ medusaIntegrationTestRunner({ } `, } - // Trigger a sync + ;(indexEngine as any).schemaObjectRepresentation_ = null await (indexEngine as any).onApplicationStart_() await setTimeout(3000) diff --git a/packages/core/framework/src/medusa-app-loader.ts b/packages/core/framework/src/medusa-app-loader.ts index 23eefe1c00..e7cfdca6c3 100644 --- a/packages/core/framework/src/medusa-app-loader.ts +++ b/packages/core/framework/src/medusa-app-loader.ts @@ -26,6 +26,7 @@ import { } from "@medusajs/utils" import { pgConnectionLoader } from "./database" +import type { Knex } from "@mikro-orm/knex" import { aliasTo, asValue } from "awilix" import { configManager } from "./config" import { @@ -33,7 +34,6 @@ import { container as mainContainer, MedusaContainer, } from "./container" -import type { Knex } from "@mikro-orm/knex" export class MedusaAppLoader { /** @@ -88,6 +88,7 @@ export class MedusaAppLoader { const def = {} as ModuleDefinition def.key ??= key def.label ??= ModulesDefinition[key]?.label ?? upperCaseFirst(key) + def.dependencies ??= ModulesDefinition[key]?.dependencies def.isQueryable = ModulesDefinition[key]?.isQueryable ?? true const orignalDef = value?.definition ?? ModulesDefinition[key] diff --git a/packages/core/modules-sdk/src/loaders/utils/load-internal.ts b/packages/core/modules-sdk/src/loaders/utils/load-internal.ts index 7782ca6d52..7241450ccf 100644 --- a/packages/core/modules-sdk/src/loaders/utils/load-internal.ts +++ b/packages/core/modules-sdk/src/loaders/utils/load-internal.ts @@ -22,6 +22,7 @@ import { isString, MedusaModuleProviderType, MedusaModuleType, + Modules, ModulesSdkUtils, toMikroOrmEntities, } from "@medusajs/utils" @@ -223,7 +224,8 @@ export async function loadInternalModule(args: { ContainerRegistrationKeys.MANAGER, ContainerRegistrationKeys.CONFIG_MODULE, ContainerRegistrationKeys.LOGGER, - ContainerRegistrationKeys.PG_CONNECTION + ContainerRegistrationKeys.PG_CONNECTION, + Modules.EVENT_BUS ) for (const dependency of dependencies) { diff --git a/packages/core/types/src/common/common.ts b/packages/core/types/src/common/common.ts index 03af610182..2939ed4950 100644 --- a/packages/core/types/src/common/common.ts +++ b/packages/core/types/src/common/common.ts @@ -456,3 +456,7 @@ export type TransformObjectMethodToAsync = { ? TransformObjectMethodToAsync : T[K] } + +export type QueryContextType = Record & { + __type?: "QueryContext" +} diff --git a/packages/core/types/src/http/common/response.ts b/packages/core/types/src/http/common/response.ts index 281f48c885..ce0741b858 100644 --- a/packages/core/types/src/http/common/response.ts +++ b/packages/core/types/src/http/common/response.ts @@ -38,6 +38,10 @@ export type PaginatedResponse = { * The total number of items. */ count: number + /** + * The estimated number of items. + */ + estimate_count?: number } & T export type BatchResponse = { @@ -59,7 +63,7 @@ export type BatchResponse = { ids: string[] /** * The type of the items that were deleted. - * + * * @example * "product" */ diff --git a/packages/core/types/src/index-data/__tests__/index.spec.ts b/packages/core/types/src/index-data/__tests__/index.spec.ts index 6bc52e8140..1440280606 100644 --- a/packages/core/types/src/index-data/__tests__/index.spec.ts +++ b/packages/core/types/src/index-data/__tests__/index.spec.ts @@ -9,6 +9,7 @@ describe("IndexQueryConfig", () => { expectTypeOf().toEqualTypeOf< ( + | "*" | "id" | "title" | "variants.*" diff --git a/packages/core/types/src/index-data/common.ts b/packages/core/types/src/index-data/common.ts index a82ae5a3bc..13b053b070 100644 --- a/packages/core/types/src/index-data/common.ts +++ b/packages/core/types/src/index-data/common.ts @@ -31,10 +31,21 @@ export type SchemaObjectEntityRepresentation = { */ targetProp: string + /** + * The property the parent is assigned to in my side + */ + inverseSideProp: string + /** * Are the data expected to be a list or not */ isList?: boolean + + /** + * Whether the entity is the inverse of the link (not the owner.): + * e.g: order -> cart, order is the owner, cart is the inverse + */ + isInverse?: boolean }[] /** @@ -69,6 +80,8 @@ export type SchemaPropertiesMap = { [key: string]: { shortCutOf?: string ref: SchemaObjectEntityRepresentation + isInverse?: boolean + isList?: boolean } } diff --git a/packages/core/types/src/index-data/query-config/query-input-config-fields.ts b/packages/core/types/src/index-data/query-config/query-input-config-fields.ts index 5def2e1e49..66063a57ea 100644 --- a/packages/core/types/src/index-data/query-config/query-input-config-fields.ts +++ b/packages/core/types/src/index-data/query-config/query-input-config-fields.ts @@ -1,5 +1,4 @@ import { ExcludedProps, TypeOnly } from "./common" - type Marker = [never, 0, 1, 2, 3, 4] type RawBigNumberPrefix = "raw_" @@ -17,7 +16,7 @@ export type ObjectToIndexFields< MaybeT, Depth extends number = 2, Exclusion extends string[] = [], - T = TypeOnly + T = TypeOnly & { "*": "*" } > = Depth extends never ? never : T extends object diff --git a/packages/core/types/src/index-data/query-config/query-input-config.ts b/packages/core/types/src/index-data/query-config/query-input-config.ts index 1a1e660c3a..0f03468a76 100644 --- a/packages/core/types/src/index-data/query-config/query-input-config.ts +++ b/packages/core/types/src/index-data/query-config/query-input-config.ts @@ -1,7 +1,56 @@ -import { RemoteQueryInput } from "../../modules-sdk/remote-query-object-from-string" +import { QueryContextType } from "../../common" import { IndexServiceEntryPoints } from "../index-service-entry-points" import { ObjectToIndexFields } from "./query-input-config-fields" import { IndexFilters } from "./query-input-config-filters" +import { IndexOrderBy } from "./query-input-config-order-by" + +export type IndexQueryInput = { + /** + * The name of the entity to retrieve. For example, `product`. + */ + entity: TEntry | keyof IndexServiceEntryPoints + /** + * The fields and relations to retrieve in the entity. + */ + fields: ObjectToIndexFields< + IndexServiceEntryPoints[TEntry & keyof IndexServiceEntryPoints] + > extends never + ? string[] + : + | ObjectToIndexFields< + IndexServiceEntryPoints[TEntry & keyof IndexServiceEntryPoints] + >[] + | string[] + /** + * Pagination configurations for the returned list of items. + */ + pagination?: { + /** + * The number of items to skip before retrieving the returned items. + */ + skip?: number + /** + * The maximum number of items to return. + */ + take?: number + /** + * Sort by field names in ascending or descending order. + */ + order?: IndexOrderBy + } + /** + * Filters to apply on the retrieved items. + */ + filters?: IndexFilters + /** + * Apply a query context on the retrieved data. For example, to retrieve product prices for a certain context. + */ + context?: QueryContextType + /** + * Apply a `withDeleted` flag on the retrieved data to retrieve soft deleted items. + */ + withDeleted?: boolean +} export type IndexQueryConfig = { fields: ObjectToIndexFields< @@ -13,14 +62,14 @@ export type IndexQueryConfig = { >[] filters?: IndexFilters joinFilters?: IndexFilters - pagination?: Partial["pagination"]> + pagination?: Partial["pagination"]> keepFilteredEntities?: boolean } export type QueryFunctionReturnPagination = { - skip?: number - take?: number - count?: number + skip: number + take: number + estimate_count: number } /** diff --git a/packages/core/types/src/joiner/index.ts b/packages/core/types/src/joiner/index.ts index 79558c5755..9e336f7412 100644 --- a/packages/core/types/src/joiner/index.ts +++ b/packages/core/types/src/joiner/index.ts @@ -21,6 +21,7 @@ export type JoinerRelationship = { export interface JoinerServiceConfigAlias { name: string | string[] entity?: string + filterable?: string[] /** * Extra arguments to pass to the remoteFetchData callback */ diff --git a/packages/core/types/src/modules-sdk/index.ts b/packages/core/types/src/modules-sdk/index.ts index ca533ef85f..5a8c442695 100644 --- a/packages/core/types/src/modules-sdk/index.ts +++ b/packages/core/types/src/modules-sdk/index.ts @@ -248,6 +248,11 @@ export declare type ModuleJoinerRelationship = JoinerRelationship & { * If true, the link joiner will cascade deleting the relationship */ deleteCascade?: boolean + + /** + * The fields to be filterable by the Index module using query.index + */ + filterable?: string[] /** * Allow multiple relationships to exist for this * entity diff --git a/packages/core/types/src/modules-sdk/remote-query-object-from-string.ts b/packages/core/types/src/modules-sdk/remote-query-object-from-string.ts index a4e3725e4b..58e89ef266 100644 --- a/packages/core/types/src/modules-sdk/remote-query-object-from-string.ts +++ b/packages/core/types/src/modules-sdk/remote-query-object-from-string.ts @@ -1,10 +1,10 @@ +import { QueryContextType } from "../common" import { IndexOrderBy } from "../index-data/query-config/query-input-config-order-by" import { ObjectToRemoteQueryFields } from "./object-to-remote-query-fields" import { RemoteQueryEntryPoints } from "./remote-query-entry-points" import { RemoteQueryFilters } from "./to-remote-query" export type RemoteQueryObjectConfig = { - // service: string This property is still supported under the hood but part of the type due to types missmatch towards fields entryPoint: TEntry | keyof RemoteQueryEntryPoints variables?: any fields: ObjectToRemoteQueryFields< @@ -26,7 +26,6 @@ export type RemoteQueryObjectFromStringResult< } export type RemoteQueryInput = { - // service: string This property is still supported under the hood but part of the type due to types missmatch towards fields /** * The name of the entity to retrieve. For example, `product`. */ @@ -67,7 +66,7 @@ export type RemoteQueryInput = { /** * Apply a query context on the retrieved data. For example, to retrieve product prices for a certain context. */ - context?: any + context?: QueryContextType /** * Apply a `withDeleted` flag on the retrieved data to retrieve soft deleted items. */ diff --git a/packages/core/types/src/modules-sdk/remote-query.ts b/packages/core/types/src/modules-sdk/remote-query.ts index fb676a9360..4ab9cce4cb 100644 --- a/packages/core/types/src/modules-sdk/remote-query.ts +++ b/packages/core/types/src/modules-sdk/remote-query.ts @@ -1,4 +1,8 @@ import { Prettify } from "../common" +import { + IndexQueryInput, + QueryResultSet, +} from "../index-data/query-config/query-input-config" import { RemoteJoinerOptions, RemoteJoinerQuery } from "../joiner" import { RemoteQueryEntryPoints } from "./remote-query-entry-points" import { @@ -6,7 +10,6 @@ import { RemoteQueryObjectConfig, RemoteQueryObjectFromStringResult, } from "./remote-query-object-from-string" -import { RemoteQueryFilters } from "./to-remote-query" /*type ExcludedProps = "__typename"*/ @@ -40,17 +43,14 @@ export type QueryGraphFunction = { } /** - * QueryIndexFunction is a wrapper on top of remoteQuery + * QueryIndexFunction is a wrapper on top of indexModule * that simplifies the input it accepts and returns * a normalized/consistent output. */ export type QueryIndexFunction = { - ( - queryOptions: RemoteQueryInput & { - joinFilters?: RemoteQueryFilters - }, - options?: RemoteJoinerOptions - ): Promise>> + (queryOptions: IndexQueryInput): Promise< + Prettify> + > } /*export type RemoteQueryReturnedData = diff --git a/packages/core/utils/src/dml/__tests__/create-graphql.spec.ts b/packages/core/utils/src/dml/__tests__/create-graphql.spec.ts index b140072f06..b6710df767 100644 --- a/packages/core/utils/src/dml/__tests__/create-graphql.spec.ts +++ b/packages/core/utils/src/dml/__tests__/create-graphql.spec.ts @@ -69,7 +69,7 @@ describe("GraphQL builder", () => { id: ID! username: String! email: Email! - spend_limit: String! + spend_limit: Float! phones: [String]! group_id:String! group: Group! diff --git a/packages/core/utils/src/dml/helpers/graphql-builder/get-attribute.ts b/packages/core/utils/src/dml/helpers/graphql-builder/get-attribute.ts index b89e412484..5a26d9e8c8 100644 --- a/packages/core/utils/src/dml/helpers/graphql-builder/get-attribute.ts +++ b/packages/core/utils/src/dml/helpers/graphql-builder/get-attribute.ts @@ -9,7 +9,7 @@ const GRAPHQL_TYPES = { boolean: "Boolean", dateTime: "DateTime", number: "Int", - bigNumber: "String", + bigNumber: "Float", text: "String", json: "JSON", array: "[String]", diff --git a/packages/core/utils/src/modules-sdk/define-link.ts b/packages/core/utils/src/modules-sdk/define-link.ts index a79c52afd0..8ebc095e3a 100644 --- a/packages/core/utils/src/modules-sdk/define-link.ts +++ b/packages/core/utils/src/modules-sdk/define-link.ts @@ -18,6 +18,7 @@ type InputSource = { alias?: string linkable: string primaryKey: string + filterable?: string[] } type ReadOnlyInputSource = { @@ -42,6 +43,7 @@ type InputOptions = { field?: string isList?: boolean deleteCascade?: boolean + filterable?: string[] } type Shortcut = { @@ -87,6 +89,7 @@ type ModuleLinkableKeyConfig = { alias: string hasMany?: boolean shortcut?: Shortcut | Shortcut[] + filterable?: string[] } function isInputOptions(input: any): input is InputOptions { @@ -141,6 +144,7 @@ function prepareServiceConfig( isList: false, hasMany: false, deleteCascade: false, + filterable: source.filterable, module: source.serviceName, entity: source.entity, } @@ -159,6 +163,7 @@ function prepareServiceConfig( isList: input.isList ?? false, hasMany, deleteCascade: input.deleteCascade ?? false, + filterable: input.filterable, module: source.serviceName, entity: source.entity, } @@ -192,6 +197,17 @@ export function defineLink( const serviceBObj = prepareServiceConfig(rightService) if (linkServiceOptions?.readOnly) { + if (!leftService.linkable || !leftService.field) { + throw new Error( + `ReadOnly link requires "linkable" and "field" to be defined for the left service.` + ) + } else if ( + (leftService as DefineLinkInputSource).filterable || + (rightService as DefineLinkInputSource).filterable + ) { + throw new Error(`ReadOnly link does not support filterable fields.`) + } + return defineReadOnlyLink( serviceAObj, serviceBObj, @@ -378,6 +394,7 @@ ${serviceBObj.module}: { methodSuffix: serviceAMethodSuffix, }, deleteCascade: serviceAObj.deleteCascade, + filterable: serviceAObj.filterable, hasMany: serviceAObj.hasMany, }, { @@ -390,6 +407,7 @@ ${serviceBObj.module}: { methodSuffix: serviceBMethodSuffix, }, deleteCascade: serviceBObj.deleteCascade, + filterable: serviceBObj.filterable, hasMany: serviceBObj.hasMany, }, ], diff --git a/packages/core/utils/src/modules-sdk/query-context.ts b/packages/core/utils/src/modules-sdk/query-context.ts index 51a2f1cf2b..0a9871b758 100644 --- a/packages/core/utils/src/modules-sdk/query-context.ts +++ b/packages/core/utils/src/modules-sdk/query-context.ts @@ -1,11 +1,13 @@ -type QueryContextType = { +import { QueryContextType } from "@medusajs/types" + +type QueryContexFnType = { (query: Record): Record isQueryContext: (obj: any) => boolean } const __type = "QueryContext" -function QueryContextFn(query: Record) { +function QueryContextFn(query: Record): QueryContextType { return { ...query, __type, @@ -16,4 +18,4 @@ QueryContextFn.isQueryContext = (obj: any) => { return obj.__type === __type } -export const QueryContext: QueryContextType = QueryContextFn +export const QueryContext: QueryContexFnType = QueryContextFn diff --git a/packages/medusa/src/api/admin/products/route.ts b/packages/medusa/src/api/admin/products/route.ts index 034fbddb59..88b56250da 100644 --- a/packages/medusa/src/api/admin/products/route.ts +++ b/packages/medusa/src/api/admin/products/route.ts @@ -79,7 +79,7 @@ async function getProductsWithIndexEngine( res.json({ products: products.map(remapProductResponse), - count: metadata!.count, + count: metadata!.estimate_count, offset: metadata!.skip, limit: metadata!.take, }) diff --git a/packages/medusa/src/api/store/products/route.ts b/packages/medusa/src/api/store/products/route.ts index ef4210619f..a6dec0c297 100644 --- a/packages/medusa/src/api/store/products/route.ts +++ b/packages/medusa/src/api/store/products/route.ts @@ -1,6 +1,6 @@ import { featureFlagRouter } from "@medusajs/framework" import { MedusaResponse } from "@medusajs/framework/http" -import { HttpTypes } from "@medusajs/framework/types" +import { HttpTypes, QueryContextType } from "@medusajs/framework/types" import { ContainerRegistrationKeys, isPresent, @@ -36,7 +36,7 @@ async function getProductsWithIndexEngine( ) { const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) - const context: object = {} + const context: QueryContextType = {} const withInventoryQuantity = req.queryConfig.fields.some((field) => field.includes("variants.inventory_quantity") ) @@ -80,7 +80,8 @@ async function getProductsWithIndexEngine( await wrapProductsWithTaxPrices(req, products) res.json({ products, - count: metadata!.count, + count: metadata!.estimate_count, + estimate_count: metadata!.estimate_count, offset: metadata!.skip, limit: metadata!.take, }) diff --git a/packages/modules/index/integration-tests/__tests__/query-builder.spec.ts b/packages/modules/index/integration-tests/__tests__/query-builder.spec.ts index 35562b40de..afe83e40c4 100644 --- a/packages/modules/index/integration-tests/__tests__/query-builder.spec.ts +++ b/packages/modules/index/integration-tests/__tests__/query-builder.spec.ts @@ -414,7 +414,6 @@ describe("IndexModuleService query", function () { }, }) - // NULLS LAST (DESC = first) expect(data).toEqual([ { id: "prod_2", @@ -526,7 +525,7 @@ describe("IndexModuleService query", function () { }) expect(metadata).toEqual({ - count: 1, + estimate_count: expect.any(Number), skip: 0, take: 100, }) @@ -575,7 +574,7 @@ describe("IndexModuleService query", function () { }) expect(metadata).toEqual({ - count: 1, + estimate_count: expect.any(Number), skip: 0, take: 100, }) @@ -631,6 +630,86 @@ describe("IndexModuleService query", function () { ]) }) + it("should filter using IN operator with array of strings", async () => { + const { data } = await module.query({ + fields: ["product.id", "product.variants.*"], + filters: { + product: { + variants: { + sku: { $in: ["sku 123", "aaa test aaa", "does-not-exist"] }, + }, + }, + }, + pagination: { + order: { + product: { + variants: { + prices: { + amount: "DESC", + }, + }, + }, + }, + }, + }) + + expect(data).toEqual([ + { + id: "prod_1", + variants: [ + { + id: "var_1", + sku: "aaa test aaa", + }, + { + id: "var_2", + sku: "sku 123", + }, + ], + }, + ]) + }) + + it("should filter using IN operator with array of strings", async () => { + const { data } = await module.query({ + fields: ["product.id", "product.variants.*"], + filters: { + product: { + variants: { + sku: { $in: ["sku 123", "aaa test aaa", "does-not-exist"] }, + }, + }, + }, + pagination: { + order: { + product: { + variants: { + prices: { + amount: "DESC", + }, + }, + }, + }, + }, + }) + + expect(data).toEqual([ + { + id: "prod_1", + variants: [ + { + id: "var_1", + sku: "aaa test aaa", + }, + { + id: "var_2", + sku: "sku 123", + }, + ], + }, + ]) + }) + it("should query products filtering by price and returning the complete entity", async () => { const { data, metadata } = await module.query({ fields: ["product.*", "product.variants.*", "product.variants.prices.*"], @@ -651,7 +730,7 @@ describe("IndexModuleService query", function () { }) expect(metadata).toEqual({ - count: 1, + estimate_count: expect.any(Number), skip: 0, take: 100, }) @@ -736,11 +815,16 @@ describe("IndexModuleService query", function () { pagination: { take: 1, skip: 1, + order: { + product: { + id: "ASC", + }, + }, }, }) expect(metadata).toEqual({ - count: 2, + estimate_count: expect.any(Number), skip: 1, take: 1, }) @@ -759,77 +843,6 @@ describe("IndexModuleService query", function () { ]) }) - it("should handle null values on where clause", async () => { - const { data: data_, metadata } = await module.query({ - fields: ["product.*", "product.variants.*", "product.variants.prices.*"], - filters: { - product: { - variants: { - sku: null, - }, - }, - }, - pagination: { - take: 100, - skip: 0, - }, - }) - - expect(metadata).toEqual({ - count: 1, - skip: 0, - take: 100, - }) - - expect(data_).toEqual([ - { - id: "prod_2", - deep: { a: 1, obj: { b: 15 } }, - title: "Product 2 title", - variants: [], - }, - ]) - - const { data, metadata: metadata2 } = await module.query({ - fields: ["product.*", "product.variants.*", "product.variants.prices.*"], - filters: { - product: { - variants: { - sku: { $ne: null }, - }, - }, - }, - pagination: { - take: 100, - skip: 0, - }, - }) - - expect(metadata2).toEqual({ - count: 1, - skip: 0, - take: 100, - }) - - expect(data).toEqual([ - { - id: "prod_1", - variants: [ - { - id: "var_1", - sku: "aaa test aaa", - prices: [{ id: "money_amount_1", amount: 100 }], - }, - { - id: "var_2", - sku: "sku 123", - prices: [{ id: "money_amount_2", amount: 10 }], - }, - ], - }, - ]) - }) - it("should query products filtering by deep nested levels", async () => { const { data, metadata } = await module.query({ fields: ["product.*"], @@ -849,7 +862,7 @@ describe("IndexModuleService query", function () { }) expect(metadata).toEqual({ - count: 1, + estimate_count: expect.any(Number), skip: 0, take: 1, }) @@ -866,4 +879,52 @@ describe("IndexModuleService query", function () { }, ]) }) + + it("should query products filtering by prices bigger than 20", async () => { + const { data, metadata } = await module.query({ + fields: ["product.*", "product.variants.*", "product.variants.prices.*"], + filters: { + product: { + variants: { + prices: { + amount: { $gt: 20 }, + }, + }, + }, + }, + pagination: { + take: 100, + skip: 0, + order: { + product: { + created_at: "ASC", + }, + }, + }, + }) + + expect(metadata).toEqual({ + estimate_count: expect.any(Number), + skip: 0, + take: 100, + }) + + expect(data).toEqual([ + { + id: "prod_1", + variants: [ + { + id: "var_1", + sku: "aaa test aaa", + prices: [ + { + id: "money_amount_1", + amount: 100, + }, + ], + }, + ], + }, + ]) + }) }) diff --git a/packages/modules/index/src/migrations/Migration20231019174230.ts b/packages/modules/index/src/migrations/Migration20231019174230.ts index 5c49765a55..7e2089850c 100644 --- a/packages/modules/index/src/migrations/Migration20231019174230.ts +++ b/packages/modules/index/src/migrations/Migration20231019174230.ts @@ -3,11 +3,11 @@ import { Migration } from "@mikro-orm/migrations" export class Migration20231019174230 extends Migration { async up(): Promise { this.addSql( - `create table "index_data" ("id" text not null, "name" text not null, "data" jsonb not null default '{}', constraint "index_data_pkey" primary key ("id", "name")) PARTITION BY LIST("name");` + `create table IF NOT EXISTS "index_data" ("id" text not null, "name" text not null, "data" jsonb not null default '{}', constraint "index_data_pkey" primary key ("id", "name")) PARTITION BY LIST("name");` ) this.addSql( - `create table "index_relation" ("id" bigserial, "pivot" text not null, "parent_id" text not null, "parent_name" text not null, "child_id" text not null, "child_name" text not null, constraint "index_relation_pkey" primary key ("id", "pivot")) PARTITION BY LIST("pivot");` + `create table IF NOT EXISTS "index_relation" ("id" bigserial, "pivot" text not null, "parent_id" text not null, "parent_name" text not null, "child_id" text not null, "child_name" text not null, constraint "index_relation_pkey" primary key ("id", "pivot")) PARTITION BY LIST("pivot");` ) } } diff --git a/packages/modules/index/src/services/index-module-service.ts b/packages/modules/index/src/services/index-module-service.ts index a0369871a4..c0ebec02e8 100644 --- a/packages/modules/index/src/services/index-module-service.ts +++ b/packages/modules/index/src/services/index-module-service.ts @@ -10,6 +10,7 @@ import { import { MikroOrmBaseRepository as BaseRepository, ContainerRegistrationKeys, + GraphQLUtils, Modules, ModulesSdkUtils, } from "@medusajs/framework/utils" @@ -20,6 +21,7 @@ import { defaultSchema, gqlSchemaToTypes, } from "@utils" +import { baseGraphqlSchema } from "../utils/base-graphql-schema" import { DataSynchronizer } from "./data-synchronizer" type InjectedDependencies = { @@ -105,7 +107,7 @@ export default class IndexModuleService protected async onApplicationStart_() { try { - this.buildSchemaObjectRepresentation_() + const executableSchema = this.buildSchemaObjectRepresentation_() this.storageProvider_ = new this.storageProviderCtr_( this.container_, @@ -122,7 +124,7 @@ export default class IndexModuleService await this.storageProvider_.onApplicationStart() } - await gqlSchemaToTypes(this.moduleOptions_.schema ?? defaultSchema) + await gqlSchemaToTypes(executableSchema!) this.dataSynchronizer_.onApplicationStart({ schemaObjectRepresentation: this.schemaObjectRepresentation_, @@ -174,24 +176,21 @@ export default class IndexModuleService } } - private buildSchemaObjectRepresentation_() { + private buildSchemaObjectRepresentation_(): + | GraphQLUtils.GraphQLSchema + | undefined { if (this.schemaObjectRepresentation_) { - return this.schemaObjectRepresentation_ + return } - const baseSchema = ` - scalar DateTime - scalar Date - scalar Time - scalar JSON - ` - const [objectRepresentation, entityMap] = buildSchemaObjectRepresentation( - baseSchema + (this.moduleOptions_.schema ?? defaultSchema) - ) + const { objectRepresentation, entitiesMap, executableSchema } = + buildSchemaObjectRepresentation( + baseGraphqlSchema + (this.moduleOptions_.schema ?? defaultSchema) + ) this.schemaObjectRepresentation_ = objectRepresentation - this.schemaEntitiesMap_ = entityMap + this.schemaEntitiesMap_ = entitiesMap - return this.schemaObjectRepresentation_ + return executableSchema } } diff --git a/packages/modules/index/src/services/postgres-provider.ts b/packages/modules/index/src/services/postgres-provider.ts index 13ac0461ed..93f9b768cc 100644 --- a/packages/modules/index/src/services/postgres-provider.ts +++ b/packages/modules/index/src/services/postgres-provider.ts @@ -68,37 +68,6 @@ export class PostgresProvider implements IndexTypes.StorageProvider { this.schemaObjectRepresentation_ = options.schemaObjectRepresentation this.schemaEntitiesMap_ = options.entityMap - - // Add a new column for each key that can be found in the jsonb data column to perform indexes and query on it. - // So far, the execution time is about the same - /*;(async () => { - const query = [ - ...new Set( - Object.keys(this.schemaObjectRepresentation_) - .filter( - (key) => - ![ - "_serviceNameModuleConfigMap", - "_schemaPropertiesMap", - ].includes(key) - ) - .map((key) => { - return this.schemaObjectRepresentation_[key].fields.filter( - (field) => !field.includes(".") - ) - }) - .flat() - ), - ].map( - (field) => - "ALTER TABLE index_data ADD IF NOT EXISTS " + - field + - " text GENERATED ALWAYS AS (NEW.data->>'" + - field + - "') STORED" - ) - await this.manager_.execute(query.join(";")) - })()*/ } async onApplicationStart() { @@ -138,7 +107,7 @@ export class PostgresProvider implements IndexTypes.StorageProvider { const parentAlias = field.split(".")[0] const parentSchemaObjectRepresentation = schemaEntityObjectRepresentation.parents.find( - (parent) => parent.ref.alias === parentAlias + (parent) => parent.inverseSideProp === parentAlias ) if (!parentSchemaObjectRepresentation) { @@ -304,7 +273,6 @@ export class PostgresProvider implements IndexTypes.StorageProvider { schema: this.schemaObjectRepresentation_, entityMap: this.schemaEntitiesMap_, knex: connection.getKnex(), - rawConfig: config, selector: { select, where, @@ -316,26 +284,30 @@ export class PostgresProvider implements IndexTypes.StorageProvider { keepFilteredEntities, orderBy, }, + rawConfig: config, requestedFields, }) - const sql = qb.buildQuery({ + const { sql, sqlCount } = qb.buildQuery({ hasPagination, returnIdOnly: !!keepFilteredEntities, hasCount, }) - const resultSet = await manager.execute(sql) + const [resultSet, countResult] = await Promise.all([ + manager.execute(sql), + hasCount ? manager.execute(sqlCount!) : null, + ]) const resultMetadata: IndexTypes.QueryFunctionReturnPagination | undefined = hasPagination - ? { - count: hasCount - ? parseInt(resultSet[0]?.count_total ?? 0) + ? ({ + estimate_count: hasCount + ? parseInt(countResult![0]?.estimate_count ?? 0) : undefined, skip, take, - } + } as IndexTypes.QueryFunctionReturnPagination) : undefined if (keepFilteredEntities) { diff --git a/packages/modules/index/src/utils/__tests__/build-config.spec.ts b/packages/modules/index/src/utils/__tests__/build-config.spec.ts new file mode 100644 index 0000000000..98acf0cf21 --- /dev/null +++ b/packages/modules/index/src/utils/__tests__/build-config.spec.ts @@ -0,0 +1,1857 @@ +import { MedusaModule } from "@medusajs/framework/modules-sdk" +import { buildSchemaObjectRepresentation } from "../build-config" + +// Mock MedusaModule only +jest.mock("@medusajs/framework/modules-sdk", () => ({ + MedusaModule: { + getAllJoinerConfigs: jest.fn(), + }, +})) + +// No need to mock @medusajs/framework/utils since we're using the actual implementations + +describe("buildSchemaObjectRepresentation", () => { + // Setup mocks before each test + beforeEach(() => { + jest.clearAllMocks() + + // Mock MedusaModule.getAllJoinerConfigs + ;(MedusaModule.getAllJoinerConfigs as jest.Mock).mockReturnValue([]) + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it("should a simple object representation for a single entity", () => { + const schema = "type Product { id: ID! }" + + const output = buildSchemaObjectRepresentation(schema) + + delete (output as any).executableSchema + + expect(output).toEqual({ + objectRepresentation: { + _serviceNameModuleConfigMap: {}, + Product: { + entity: "Product", + parents: [], + alias: "", + listeners: [], + moduleConfig: null, + fields: ["id"], + }, + _schemaPropertiesMap: { + "": { + isInverse: false, + isList: undefined, + ref: { + entity: "Product", + parents: [], + alias: "", + listeners: [], + moduleConfig: null, + fields: ["id"], + }, + }, + }, + }, + entitiesMap: expect.objectContaining({ + Product: expect.objectContaining({ + name: "Product", + }), + }), + }) + }) + + it("should process entities", () => { + const moduleSchema = ` + type Product { + id: ID! + title: String! + } + ` + + const productModuleJoinerConfig = { + serviceName: "ProductService", + schema: moduleSchema, + alias: [ + { + name: "product", + entity: "Product", + }, + ], + } + + ;(MedusaModule.getAllJoinerConfigs as jest.Mock).mockReturnValue([ + productModuleJoinerConfig, + ]) + + const indexSchema = ` + type Product @Listeners(values: ["product.created"]) { + id: ID! + title: String! + } + ` + + const { objectRepresentation, entitiesMap } = + buildSchemaObjectRepresentation(indexSchema) + + expect(entitiesMap).toEqual( + expect.objectContaining({ + Product: expect.objectContaining({ + name: "Product", + _fields: { + id: expect.objectContaining({ + name: "id", + }), + title: expect.objectContaining({ + name: "title", + }), + }, + }), + }) + ) + + expect(objectRepresentation["Product"]).toBeDefined() + expect(objectRepresentation["Product"].entity).toBe("Product") + expect(objectRepresentation["Product"].listeners).toEqual([ + "product.created", + ]) + expect(objectRepresentation["Product"].alias).toBe("product") + + expect(objectRepresentation["Product"].fields).toBeDefined() + expect(objectRepresentation["Product"].fields).toEqual(["id", "title"]) + + expect(objectRepresentation._schemaPropertiesMap["product"]).toBeDefined() + expect(objectRepresentation._schemaPropertiesMap["product"].ref).toEqual({ + entity: "Product", + parents: [], + alias: "product", + listeners: ["product.created"], + moduleConfig: productModuleJoinerConfig, + fields: ["id", "title"], + }) + }) + + it("should handle parent-child relationships between entities", () => { + const schema = ` + type Product { + id: ID! + title: String! + variants: [ProductVariant!] + } + + type ProductVariant { + id: ID! + title: String! + product: Product! + } + ` + + const productModuleJoinerConfig = { + serviceName: "ProductService", + schema: schema, + alias: [ + { + name: "product", + entity: "Product", + }, + { + name: "variant", + entity: "ProductVariant", + }, + ], + } + + ;(MedusaModule.getAllJoinerConfigs as jest.Mock).mockReturnValue([ + productModuleJoinerConfig, + ]) + + const indexSchema = ` + type Product @Listeners(values: ["product.created"]) { + id: ID! + title: String! + variants: [ProductVariant!] + } + + type ProductVariant @Listeners(values: ["variant.created"]) { + id: ID! + title: String! + } + ` + + const { objectRepresentation, entitiesMap } = + buildSchemaObjectRepresentation(indexSchema) + + expect(entitiesMap).toEqual( + expect.objectContaining({ + Product: expect.objectContaining({ + name: "Product", + _fields: { + id: expect.objectContaining({ + name: "id", + }), + title: expect.objectContaining({ + name: "title", + }), + variants: expect.objectContaining({ + name: "variants", + }), + }, + }), + }) + ) + + expect(objectRepresentation["Product"]).toBeDefined() + expect(objectRepresentation["Product"].entity).toBe("Product") + expect(objectRepresentation["Product"].parents).toEqual([]) + expect(objectRepresentation["Product"].listeners).toEqual([ + "product.created", + ]) + expect(objectRepresentation["Product"].alias).toBe("product") + + expect(objectRepresentation["Product"].fields).toBeDefined() + expect(objectRepresentation["Product"].fields).toEqual(["id", "title"]) + + expect(objectRepresentation["ProductVariant"]).toBeDefined() + expect(objectRepresentation["ProductVariant"].entity).toBe("ProductVariant") + expect(objectRepresentation["ProductVariant"].parents).toEqual([ + expect.objectContaining({ + ref: objectRepresentation["Product"], + targetProp: "variants", + }), + ]) + + const productRefExpectation = { + entity: "Product", + parents: [], + alias: "product", + listeners: ["product.created"], + moduleConfig: productModuleJoinerConfig, + fields: ["id", "title"], + } + + expect(objectRepresentation._schemaPropertiesMap["product"].ref).toEqual( + productRefExpectation + ) + + const variantRefExpectation = { + entity: "ProductVariant", + parents: [ + { + ref: productRefExpectation, + targetProp: "variants", + inverseSideProp: "product", + isList: true, + }, + ], + alias: "variant", + listeners: ["variant.created"], + moduleConfig: productModuleJoinerConfig, + fields: ["id", "title", "product.id"], + } + + expect(objectRepresentation._schemaPropertiesMap["variant"]).toBeDefined() + expect(objectRepresentation._schemaPropertiesMap["variant"].ref).toEqual( + variantRefExpectation + ) + + expect( + objectRepresentation._schemaPropertiesMap["product.variants"] + ).toBeDefined() + expect( + objectRepresentation._schemaPropertiesMap["product.variants"].ref + ).toEqual(variantRefExpectation) + }) + + it("should handle deep nested parent-child relationships between entities", () => { + const schema = ` + type Category { + id: ID! + name: String! + products: [Product!] + } + + type Product { + id: ID! + title: String! + variants: [ProductVariant!] + category: Category! + } + + type ProductVariant { + id: ID! + title: String! + options: [VariantOption!] + product: Product! + } + + type VariantOption { + id: ID! + value: String! + product_variant: ProductVariant! + } + ` + + const moduleJoinerConfig = { + serviceName: "ProductService", + schema: schema, + alias: [ + { + name: "category", + entity: "Category", + }, + { + name: "product", + entity: "Product", + }, + { + name: "variant", + entity: "ProductVariant", + }, + { + name: "option", + entity: "VariantOption", + }, + ], + } + + ;(MedusaModule.getAllJoinerConfigs as jest.Mock).mockReturnValue([ + moduleJoinerConfig, + ]) + + const indexSchema = ` + type Category @Listeners(values: ["category.created"]) { + id: ID! + name: String! + products: [Product!] + } + + type Product @Listeners(values: ["product.created"]) { + id: ID! + title: String! + variants: [ProductVariant!] + } + + type ProductVariant @Listeners(values: ["variant.created"]) { + id: ID! + title: String! + options: [VariantOption!] + } + + type VariantOption @Listeners(values: ["option.created"]) { + id: ID! + value: String! + } + ` + + const { objectRepresentation, entitiesMap } = + buildSchemaObjectRepresentation(indexSchema) + + // Verify entitiesMap structure + expect(entitiesMap).toEqual( + expect.objectContaining({ + Category: expect.objectContaining({ + name: "Category", + _fields: { + id: expect.objectContaining({ + name: "id", + }), + name: expect.objectContaining({ + name: "name", + }), + products: expect.objectContaining({ + name: "products", + }), + }, + }), + Product: expect.objectContaining({ + name: "Product", + _fields: { + id: expect.objectContaining({ + name: "id", + }), + title: expect.objectContaining({ + name: "title", + }), + variants: expect.objectContaining({ + name: "variants", + }), + }, + }), + ProductVariant: expect.objectContaining({ + name: "ProductVariant", + _fields: { + id: expect.objectContaining({ + name: "id", + }), + title: expect.objectContaining({ + name: "title", + }), + options: expect.objectContaining({ + name: "options", + }), + }, + }), + VariantOption: expect.objectContaining({ + name: "VariantOption", + _fields: { + id: expect.objectContaining({ + name: "id", + }), + value: expect.objectContaining({ + name: "value", + }), + }, + }), + }) + ) + + // Verify that all entities exist in the objectRepresentation + expect(objectRepresentation["Category"]).toBeDefined() + expect(objectRepresentation["Category"].entity).toBe("Category") + expect(objectRepresentation["Category"].parents).toEqual([]) + expect(objectRepresentation["Category"].listeners).toEqual([ + "category.created", + ]) + expect(objectRepresentation["Category"].alias).toBe("category") + expect(objectRepresentation["Category"].fields).toEqual(["id", "name"]) + + expect(objectRepresentation["Product"]).toBeDefined() + expect(objectRepresentation["Product"].entity).toBe("Product") + expect(objectRepresentation["Product"].listeners).toEqual([ + "product.created", + ]) + expect(objectRepresentation["Product"].alias).toBe("product") + expect(objectRepresentation["Product"].fields).toEqual([ + "id", + "title", + "category.id", + ]) + + expect(objectRepresentation["ProductVariant"]).toBeDefined() + expect(objectRepresentation["ProductVariant"].entity).toBe("ProductVariant") + expect(objectRepresentation["ProductVariant"].listeners).toEqual([ + "variant.created", + ]) + expect(objectRepresentation["ProductVariant"].alias).toBe("variant") + expect(objectRepresentation["ProductVariant"].fields).toEqual([ + "id", + "title", + "product.id", + ]) + + expect(objectRepresentation["VariantOption"]).toBeDefined() + expect(objectRepresentation["VariantOption"].entity).toBe("VariantOption") + expect(objectRepresentation["VariantOption"].listeners).toEqual([ + "option.created", + ]) + expect(objectRepresentation["VariantOption"].alias).toBe("option") + expect(objectRepresentation["VariantOption"].fields).toEqual([ + "id", + "value", + "product_variant.id", + ]) + + // Check parent-child relationships + expect(objectRepresentation["Product"].parents).toEqual([ + expect.objectContaining({ + ref: objectRepresentation["Category"], + targetProp: "products", + }), + ]) + + expect(objectRepresentation["ProductVariant"].parents).toEqual([ + expect.objectContaining({ + ref: objectRepresentation["Product"], + targetProp: "variants", + }), + ]) + + expect(objectRepresentation["VariantOption"].parents).toEqual([ + expect.objectContaining({ + ref: objectRepresentation["ProductVariant"], + targetProp: "options", + }), + ]) + + // Create reference expectation objects for each entity + const categoryRefExpectation = { + entity: "Category", + parents: [], + alias: "category", + listeners: ["category.created"], + moduleConfig: moduleJoinerConfig, + fields: ["id", "name"], + } + + const productRefExpectation = { + entity: "Product", + parents: [ + { + ref: categoryRefExpectation, + targetProp: "products", + inverseSideProp: "category", + isList: true, + }, + ], + alias: "product", + listeners: ["product.created"], + moduleConfig: moduleJoinerConfig, + fields: ["id", "title", "category.id"], + } + + const variantRefExpectation = { + entity: "ProductVariant", + parents: [ + { + ref: productRefExpectation, + targetProp: "variants", + inverseSideProp: "product", + isList: true, + }, + ], + alias: "variant", + listeners: ["variant.created"], + moduleConfig: moduleJoinerConfig, + fields: ["id", "title", "product.id"], + } + + const optionRefExpectation = { + entity: "VariantOption", + parents: [ + { + ref: variantRefExpectation, + targetProp: "options", + inverseSideProp: "product_variant", + isList: true, + }, + ], + alias: "option", + listeners: ["option.created"], + moduleConfig: moduleJoinerConfig, + fields: ["id", "value", "product_variant.id"], + } + + // Check that aliases are correctly set in the schema properties map + expect(objectRepresentation._schemaPropertiesMap["category"]).toBeDefined() + expect(objectRepresentation._schemaPropertiesMap["category"].ref).toEqual( + categoryRefExpectation + ) + + expect(objectRepresentation._schemaPropertiesMap["product"]).toBeDefined() + expect(objectRepresentation._schemaPropertiesMap["product"].ref).toEqual( + productRefExpectation + ) + + expect(objectRepresentation._schemaPropertiesMap["variant"]).toBeDefined() + expect(objectRepresentation._schemaPropertiesMap["variant"].ref).toEqual( + variantRefExpectation + ) + + expect(objectRepresentation._schemaPropertiesMap["option"]).toBeDefined() + expect(objectRepresentation._schemaPropertiesMap["option"].ref).toEqual( + optionRefExpectation + ) + + // Check nested paths + expect( + objectRepresentation._schemaPropertiesMap["category.products"] + ).toBeDefined() + expect( + objectRepresentation._schemaPropertiesMap["category.products"].ref + ).toEqual(productRefExpectation) + + expect( + objectRepresentation._schemaPropertiesMap["product.variants"] + ).toBeDefined() + expect( + objectRepresentation._schemaPropertiesMap["product.variants"].ref + ).toEqual(variantRefExpectation) + + expect( + objectRepresentation._schemaPropertiesMap["variant.options"] + ).toBeDefined() + expect( + objectRepresentation._schemaPropertiesMap["variant.options"].ref + ).toEqual(optionRefExpectation) + }) + + it("should handle entities with various field types", () => { + const schema = ` + type Product { + id: ID! + title: String! + price: Float! + inStock: Boolean! + inventory: Int! + metadata: JSON + createdAt: DateTime + tags: [String!] + } + ` + + const productModuleJoinerConfig = { + serviceName: "ProductService", + schema: schema, + alias: [ + { + name: "product", + entity: "Product", + }, + ], + } + + ;(MedusaModule.getAllJoinerConfigs as jest.Mock).mockReturnValue([ + productModuleJoinerConfig, + ]) + + const indexSchema = ` + type Product @Listeners(values: ["product.created"]) { + id: ID! + title: String! + price: Float! + inStock: Boolean! + inventory: Int! + metadata: JSON + createdAt: DateTime + tags: [String!] + } + + scalar JSON + scalar DateTime + ` + + const { objectRepresentation, entitiesMap } = + buildSchemaObjectRepresentation(indexSchema) + + // Verify entitiesMap structure + expect(entitiesMap).toEqual( + expect.objectContaining({ + Product: expect.objectContaining({ + name: "Product", + _fields: { + id: expect.objectContaining({ + name: "id", + }), + title: expect.objectContaining({ + name: "title", + }), + price: expect.objectContaining({ + name: "price", + }), + inStock: expect.objectContaining({ + name: "inStock", + }), + inventory: expect.objectContaining({ + name: "inventory", + }), + metadata: expect.objectContaining({ + name: "metadata", + }), + createdAt: expect.objectContaining({ + name: "createdAt", + }), + tags: expect.objectContaining({ + name: "tags", + }), + }, + }), + }) + ) + + expect(objectRepresentation["Product"]).toBeDefined() + expect(objectRepresentation["Product"].entity).toBe("Product") + expect(objectRepresentation["Product"].parents).toEqual([]) + expect(objectRepresentation["Product"].listeners).toEqual([ + "product.created", + ]) + expect(objectRepresentation["Product"].alias).toBe("product") + expect(objectRepresentation["Product"].moduleConfig).toBe( + productModuleJoinerConfig + ) + + // Check that all fields are included + expect(objectRepresentation["Product"].fields).toEqual([ + "id", + "title", + "price", + "inStock", + "inventory", + "metadata", + "createdAt", + "tags", + ]) + + // Create reference expectation object for the entity + const productRefExpectation = { + entity: "Product", + parents: [], + alias: "product", + listeners: ["product.created"], + moduleConfig: productModuleJoinerConfig, + fields: [ + "id", + "title", + "price", + "inStock", + "inventory", + "metadata", + "createdAt", + "tags", + ], + } + + // Check that alias is correctly set in the schema properties map + expect(objectRepresentation._schemaPropertiesMap["product"]).toBeDefined() + expect(objectRepresentation._schemaPropertiesMap["product"].ref).toEqual( + productRefExpectation + ) + + // Check that module config is set correctly in the service map + expect(objectRepresentation._serviceNameModuleConfigMap).toEqual( + expect.objectContaining({ + ProductService: productModuleJoinerConfig, + }) + ) + }) + + it("should handle entities with multiple listeners", () => { + const schema = ` + type Product { + id: ID! + title: String! + } + ` + + const productModuleJoinerConfig = { + serviceName: "ProductService", + schema: schema, + alias: [ + { + name: "product", + entity: "Product", + }, + ], + } + + ;(MedusaModule.getAllJoinerConfigs as jest.Mock).mockReturnValue([ + productModuleJoinerConfig, + ]) + + const indexSchema = ` + type Product @Listeners(values: ["product.created", "product.updated", "product.deleted"]) { + id: ID! + title: String! + } + ` + + const { objectRepresentation, entitiesMap } = + buildSchemaObjectRepresentation(indexSchema) + + // Verify entitiesMap structure + expect(entitiesMap).toEqual( + expect.objectContaining({ + Product: expect.objectContaining({ + name: "Product", + _fields: { + id: expect.objectContaining({ + name: "id", + }), + title: expect.objectContaining({ + name: "title", + }), + }, + }), + }) + ) + + expect(objectRepresentation["Product"]).toBeDefined() + expect(objectRepresentation["Product"].entity).toBe("Product") + expect(objectRepresentation["Product"].parents).toEqual([]) + expect(objectRepresentation["Product"].listeners).toEqual([ + "product.created", + "product.updated", + "product.deleted", + ]) + expect(objectRepresentation["Product"].alias).toBe("product") + expect(objectRepresentation["Product"].moduleConfig).toBe( + productModuleJoinerConfig + ) + expect(objectRepresentation["Product"].fields).toEqual(["id", "title"]) + + // Create reference expectation object for the entity + const productRefExpectation = { + entity: "Product", + parents: [], + alias: "product", + listeners: ["product.created", "product.updated", "product.deleted"], + moduleConfig: productModuleJoinerConfig, + fields: ["id", "title"], + } + + // Check that alias is correctly set in the schema properties map + expect(objectRepresentation._schemaPropertiesMap["product"]).toBeDefined() + expect(objectRepresentation._schemaPropertiesMap["product"].ref).toEqual( + productRefExpectation + ) + + // Check that module config is set correctly in the service map + expect(objectRepresentation._serviceNameModuleConfigMap).toEqual( + expect.objectContaining({ + ProductService: productModuleJoinerConfig, + }) + ) + }) + + it("should handle link modules between entities from different services specifying link relationships", () => { + const productSchema = ` + type Product { + id: ID! + title: String! + } + ` + + const orderSchema = ` + type Order { + id: ID! + code: String! + } + ` + + const orderItemSchema = ` + type OrderItem { + id: ID! + quantity: Int! + product_id: ID! + order_id: ID! + } + ` + + const productModuleJoinerConfig = { + serviceName: "ProductService", + schema: productSchema, + alias: [ + { + name: "product", + entity: "Product", + }, + ], + linkableKeys: { + product_id: "Product", + }, + } + + const orderModuleJoinerConfig = { + serviceName: "OrderService", + schema: orderSchema, + alias: [ + { + name: "order", + entity: "Order", + }, + ], + linkableKeys: { + order_id: "Order", + }, + } + + const orderItemLinkModuleJoinerConfig = { + serviceName: "OrderItemService", + isLink: true, + schema: orderItemSchema, + alias: [ + { + name: "order_item", + entity: "OrderItem", + }, + ], + relationships: [ + { + serviceName: "ProductService", + foreignKey: "product_id", + }, + { + serviceName: "OrderService", + foreignKey: "order_id", + }, + ], + extends: [ + { + serviceName: "OrderService", + relationship: { + serviceName: "OrderItemService", + primaryKey: "order_id", + }, + }, + { + serviceName: "ProductService", + relationship: { + serviceName: "OrderItemService", + primaryKey: "product_id", + }, + }, + ], + } + + ;(MedusaModule.getAllJoinerConfigs as jest.Mock).mockReturnValue([ + productModuleJoinerConfig, + orderModuleJoinerConfig, + orderItemLinkModuleJoinerConfig, + ]) + + const indexSchema = ` + type Product @Listeners(values: ["product.created"]) { + id: ID! + title: String! + order_items: [OrderItem!] + } + + type Order @Listeners(values: ["order.created"]) { + id: ID! + code: String! + product_items: [OrderItem!] + } + + type OrderItem @Listeners(values: ["order_item.created"]) { + id: ID! + quantity: Int! + product_id: ID! + product: Product! + order_id: ID! + order: Order! + } + ` + + const { objectRepresentation, entitiesMap } = + buildSchemaObjectRepresentation(indexSchema) + + // Verify entitiesMap structure + expect(entitiesMap).toEqual( + expect.objectContaining({ + Product: expect.objectContaining({ + name: "Product", + _fields: { + id: expect.objectContaining({ + name: "id", + }), + title: expect.objectContaining({ + name: "title", + }), + order_items: expect.objectContaining({ + name: "order_items", + }), + }, + }), + Order: expect.objectContaining({ + name: "Order", + _fields: { + id: expect.objectContaining({ + name: "id", + }), + code: expect.objectContaining({ + name: "code", + }), + product_items: expect.objectContaining({ + name: "product_items", + }), + }, + }), + OrderItem: expect.objectContaining({ + name: "OrderItem", + _fields: { + id: expect.objectContaining({ + name: "id", + }), + quantity: expect.objectContaining({ + name: "quantity", + }), + product: expect.objectContaining({ + name: "product", + }), + order: expect.objectContaining({ + name: "order", + }), + product_id: expect.objectContaining({ + name: "product_id", + }), + order_id: expect.objectContaining({ + name: "order_id", + }), + }, + }), + }) + ) + + // Verify that all entities exist in the objectRepresentation + expect(objectRepresentation["Product"]).toBeDefined() + expect(objectRepresentation["Product"].entity).toBe("Product") + expect(objectRepresentation["Product"].parents).toEqual([ + { + ref: expect.objectContaining({ + entity: "OrderItem", + }), + targetProp: "product", + inverseSideProp: "order_items", + isList: false, + }, + ]) + expect(objectRepresentation["Product"].listeners).toEqual([ + "product.created", + ]) + expect(objectRepresentation["Product"].alias).toBe("product") + expect(objectRepresentation["Product"].moduleConfig).toBe( + productModuleJoinerConfig + ) + expect(objectRepresentation["Product"].fields).toEqual([ + "id", + "title", + "order_items.id", + ]) + + expect(objectRepresentation["Order"]).toBeDefined() + expect(objectRepresentation["Order"].entity).toBe("Order") + expect(objectRepresentation["Order"].parents).toEqual([ + { + ref: expect.objectContaining({ + entity: "OrderItem", + }), + targetProp: "order", + inverseSideProp: "product_items", + isList: false, + }, + ]) + expect(objectRepresentation["Order"].listeners).toEqual(["order.created"]) + expect(objectRepresentation["Order"].alias).toBe("order") + expect(objectRepresentation["Order"].moduleConfig).toBe( + orderModuleJoinerConfig + ) + expect(objectRepresentation["Order"].fields).toEqual([ + "id", + "code", + "product_items.id", + ]) + + expect(objectRepresentation["OrderItem"]).toBeDefined() + expect(objectRepresentation["OrderItem"].entity).toBe("OrderItem") + expect(objectRepresentation["OrderItem"].parents).toEqual( + expect.arrayContaining([ + { + ref: expect.objectContaining({ + entity: "Order", + }), + targetProp: "product_items", + inverseSideProp: "order", + isList: true, + }, + { + ref: expect.objectContaining({ + entity: "Product", + }), + targetProp: "order_items", + inverseSideProp: "product", + isList: true, + }, + ]) + ) + expect(objectRepresentation["OrderItem"].listeners).toEqual([ + "order_item.created", + ]) + expect(objectRepresentation["OrderItem"].alias).toBe("order_item") + expect(objectRepresentation["OrderItem"].moduleConfig).toBe( + orderItemLinkModuleJoinerConfig + ) + expect(objectRepresentation["OrderItem"].fields).toEqual([ + "id", + "quantity", + "product_id", + "order_id", + "product.id", + "order.id", + ]) + + // Check that links between services are properly set up + expect(objectRepresentation._serviceNameModuleConfigMap).toEqual( + expect.objectContaining({ + ProductService: productModuleJoinerConfig, + OrderService: orderModuleJoinerConfig, + OrderItemService: orderItemLinkModuleJoinerConfig, + }) + ) + }) + + it("should handle link modules between entities from different services without specifying link relationships", () => { + const productSchema = ` + type Product { + id: ID! + title: String! + variants: [ProductVariant!] + } + + type ProductVariant { + id: ID! + title: String! + product_id: ID! + product: Product! + } + ` + + const priceSchema = ` + type PriceSet { + id: ID! + prices: [Price!] + } + + type Price { + id: ID! + amount: Float! + currency_code: String! + price_set_id: ID! + price_set: PriceSet! + } + ` + + const productModuleJoinerConfig = { + serviceName: "ProductService", + schema: productSchema, + alias: [ + { + name: "product", + entity: "Product", + }, + { + name: "product_variant", + entity: "ProductVariant", + }, + ], + linkableKeys: { + product_id: "Product", + variant_id: "ProductVariant", + }, + } + + const priceModuleJoinerConfig = { + serviceName: "PriceService", + schema: priceSchema, + alias: [ + { + name: "price_set", + entity: "PriceSet", + }, + { + name: "price", + entity: "Price", + }, + ], + linkableKeys: { + price_set_id: "PriceSet", + }, + } + + const productVariantPriceSetLinkModuleJoinerConfig = { + serviceName: "ProductVariantPriceSetService", + isLink: true, + schema: ` + type ProductVariantPriceSetLink { + id: ID! + product_variant_id: ID! + price_set_id: ID! + product_variant: ProductVariant! + price_set: [PriceSet!] + } + + extend type ProductVariant { + product_variant_price_set_link: ProductVariantPriceSetLink! + } + + extend type PriceSet { + product_variant_price_set_link: ProductVariantPriceSetLink! + } + `, + alias: [ + { + name: "product_variant_price_set_link", + entity: "ProductVariantPriceSetLink", + }, + ], + relationships: [ + { + serviceName: "ProductService", + foreignKey: "product_variant_id", + entity: "ProductVariant", + }, + { + serviceName: "PriceService", + foreignKey: "price_set_id", + entity: "PriceSet", + }, + ], + extends: [ + { + serviceName: "ProductService", + relationship: { + fieldAlias: { + prices: "product_variant_price_set_link.price_set.prices", + isList: true, + }, + serviceName: "ProductVariantPriceSetService", + primaryKey: "product_variant_id", + isList: true, + }, + }, + { + serviceName: "PriceService", + relationship: { + fieldAlias: { + product_variant: "product_variant_price_set_link.product_variant", + }, + serviceName: "ProductVariantPriceSetService", + primaryKey: "price_set_id", + }, + }, + ], + } + + ;(MedusaModule.getAllJoinerConfigs as jest.Mock).mockReturnValue([ + productModuleJoinerConfig, + priceModuleJoinerConfig, + productVariantPriceSetLinkModuleJoinerConfig, + ]) + + const indexSchema = ` + type Product @Listeners(values: ["product.created"]) { + id: ID + title: String + + variants: [ProductVariant] + } + + type ProductVariant @Listeners(values: ["product_variant.created"]) { + id: ID + product_id: String + title: String + + prices: [Price] + } + + type Price @Listeners(values: ["price.created"]) { + id: ID + amount: Float + currency_code: String + } + ` + + const { objectRepresentation, entitiesMap } = + buildSchemaObjectRepresentation(indexSchema) + + // Verify entitiesMap structure + expect(entitiesMap).toEqual( + expect.objectContaining({ + Product: expect.objectContaining({ + name: "Product", + _fields: { + id: expect.objectContaining({ + name: "id", + }), + title: expect.objectContaining({ + name: "title", + }), + variants: expect.objectContaining({ + name: "variants", + }), + }, + }), + ProductVariant: expect.objectContaining({ + name: "ProductVariant", + _fields: { + id: expect.objectContaining({ + name: "id", + }), + product_id: expect.objectContaining({ + name: "product_id", + }), + title: expect.objectContaining({ + name: "title", + }), + prices: expect.objectContaining({ + name: "prices", + }), + }, + }), + Price: expect.objectContaining({ + name: "Price", + _fields: { + id: expect.objectContaining({ + name: "id", + }), + amount: expect.objectContaining({ + name: "amount", + }), + currency_code: expect.objectContaining({ + name: "currency_code", + }), + }, + }), + }) + ) + + // Verify that all entities exist in the objectRepresentation + expect(objectRepresentation["Product"]).toBeDefined() + expect(objectRepresentation["Product"].entity).toBe("Product") + expect(objectRepresentation["Product"].parents).toEqual([]) + expect(objectRepresentation["Product"].listeners).toEqual([ + "product.created", + ]) + expect(objectRepresentation["Product"].alias).toBe("product") + expect(objectRepresentation["Product"].moduleConfig).toBe( + productModuleJoinerConfig + ) + expect(objectRepresentation["Product"].fields).toEqual(["id", "title"]) + + expect(objectRepresentation["ProductVariant"]).toBeDefined() + expect(objectRepresentation["ProductVariant"].entity).toBe("ProductVariant") + expect(objectRepresentation["ProductVariant"].parents).toEqual([ + { + ref: objectRepresentation["Product"], + targetProp: "variants", + inverseSideProp: "product", + isList: true, + }, + ]) + expect(objectRepresentation["ProductVariant"].listeners).toEqual([ + "product_variant.created", + ]) + expect(objectRepresentation["ProductVariant"].alias).toBe("product_variant") + expect(objectRepresentation["ProductVariant"].moduleConfig).toBe( + productModuleJoinerConfig + ) + expect(objectRepresentation["ProductVariant"].fields).toEqual([ + "id", + "product_id", + "title", + "product.id", + ]) + + expect(objectRepresentation["ProductVariantPriceSetLink"]).toBeDefined() + expect(objectRepresentation["ProductVariantPriceSetLink"].entity).toBe( + "ProductVariantPriceSetLink" + ) + expect(objectRepresentation["ProductVariantPriceSetLink"].parents).toEqual([ + { + ref: objectRepresentation["ProductVariant"], + inverseSideProp: "product_variant", + targetProp: "product_variant_price_set_link", + isList: false, + isInverse: false, + }, + ]) + expect(objectRepresentation["ProductVariantPriceSetLink"].fields).toEqual([ + "id", + "product_variant_id", + "price_set_id", + ]) + + expect(objectRepresentation["PriceSet"]).toBeDefined() + expect(objectRepresentation["PriceSet"].entity).toBe("PriceSet") + expect(objectRepresentation["PriceSet"].listeners).toEqual([ + "price-service.price-set.created", + "price-service.price-set.updated", + "price-service.price-set.deleted", + ]) + expect(objectRepresentation["PriceSet"].parents).toEqual([ + { + ref: objectRepresentation["ProductVariantPriceSetLink"], + targetProp: "price_set", + inverseSideProp: "product_variant_price_set_link", + isList: true, + }, + ]) + expect(objectRepresentation["PriceSet"].fields).toEqual(["id"]) + expect(objectRepresentation["PriceSet"].moduleConfig).toBe( + priceModuleJoinerConfig + ) + + expect(objectRepresentation["Price"]).toBeDefined() + expect(objectRepresentation["Price"].entity).toBe("Price") + expect(objectRepresentation["Price"].parents).toEqual([ + { + inSchemaRef: objectRepresentation["ProductVariant"], + ref: objectRepresentation["PriceSet"], + targetProp: "prices", + inverseSideProp: "price_set", + isList: true, + }, + ]) + expect(objectRepresentation["Price"].fields).toEqual([ + "id", + "amount", + "currency_code", + "price_set.id", + ]) + expect(objectRepresentation["Price"].moduleConfig).toBe( + priceModuleJoinerConfig + ) + + // Check that links between services are properly set up + expect(objectRepresentation._serviceNameModuleConfigMap).toEqual( + expect.objectContaining({ + ProductService: productModuleJoinerConfig, + PriceService: priceModuleJoinerConfig, + ProductVariantPriceSetService: + productVariantPriceSetLinkModuleJoinerConfig, + }) + ) + }) + + it("should handle link modules between entities from different services without specifying link relationships and multiple field aliasees for the same entity type with cyclic relationships", () => { + const productSchema = ` + type Product { + id: ID! + title: String! + variants: [ProductVariant!] + } + + type ProductVariant { + id: ID! + title: String! + product_id: ID! + product: Product! + } + ` + + const priceSchema = ` + type Price { + id: ID! + amount: Float! + currency_code: String! + } + ` + + const productModuleJoinerConfig = { + serviceName: "ProductService", + schema: productSchema, + alias: [ + { + name: "product", + entity: "Product", + }, + { + name: "product_variant", + entity: "ProductVariant", + }, + ], + linkableKeys: { + product_id: "Product", + variant_id: "ProductVariant", + }, + } + + const priceModuleJoinerConfig = { + serviceName: "PriceService", + schema: priceSchema, + alias: [ + { + name: "price", + entity: "Price", + }, + ], + linkableKeys: { + price_id: "Price", + }, + } + + const productPriceLinkModuleJoinerConfig = { + serviceName: "ProductPriceService", + isLink: true, + schema: ` + type ProductPriceLink { + id: ID! + product_id: ID! + price_id: ID! + product: Product! + price: Price! + } + + extend type Product { + product_price_link: ProductPriceLink! + } + + extend type Price { + product_price_link: ProductPriceLink! + } + `, + alias: [ + { + name: "product_price_link", + entity: "ProductPriceLink", + }, + ], + relationships: [ + { + serviceName: "ProductService", + foreignKey: "product_id", + entity: "Product", + }, + { + serviceName: "PriceService", + foreignKey: "price_id", + entity: "Price", + }, + ], + extends: [ + { + serviceName: "ProductService", + relationship: { + fieldAlias: { + prices: "product_price_link.price", + isList: true, + }, + serviceName: "ProductPriceService", + primaryKey: "product_id", + isList: true, + }, + }, + { + serviceName: "PriceService", + relationship: { + fieldAlias: { + product: "product_price_link.product", + }, + serviceName: "ProductPriceService", + primaryKey: "price_id", + }, + }, + ], + } + + const productVariantPriceLinkModuleJoinerConfig = { + serviceName: "ProductVariantPriceService", + isLink: true, + schema: ` + type ProductVariantPriceLink { + id: ID! + product_variant_id: ID! + price_id: ID! + product_variant: ProductVariant! + price: Price! + } + + extend type ProductVariant { + product_variant_price_link: ProductVariantPriceLink! + } + + extend type Price { + product_variant_price_link: ProductVariantPriceLink! + } + `, + alias: [ + { + name: "product_variant_price_link", + entity: "ProductVariantPriceLink", + }, + ], + relationships: [ + { + serviceName: "ProductService", + foreignKey: "variant_id", + entity: "ProductVariant", + }, + { + serviceName: "PriceService", + foreignKey: "price_id", + entity: "Price", + }, + ], + extends: [ + { + serviceName: "ProductService", + relationship: { + fieldAlias: { + product_variant: "product_variant_price_link.product_variant", + }, + serviceName: "ProductVariantPriceService", + primaryKey: "variant_id", + isList: true, + }, + }, + { + serviceName: "PriceService", + relationship: { + fieldAlias: { + price: "product_variant_price_link.price", + }, + serviceName: "ProductVariantPriceService", + primaryKey: "price_id", + }, + }, + ], + } + + ;(MedusaModule.getAllJoinerConfigs as jest.Mock).mockReturnValue([ + productModuleJoinerConfig, + priceModuleJoinerConfig, + productPriceLinkModuleJoinerConfig, + productVariantPriceLinkModuleJoinerConfig, + ]) + + const indexSchema = ` + type Product @Listeners(values: ["product.created"]) { + id: ID + title: String + + prices: [Price] + } + + type ProductVariant @Listeners(values: ["product_variant.created"]) { + id: ID + title: String + + prices: [Price] + } + + type Price @Listeners(values: ["price.created"]) { + id: ID + amount: Float + currency_code: String + product_variant: ProductVariant + product: Product + } + ` + + const { objectRepresentation, entitiesMap } = + buildSchemaObjectRepresentation(indexSchema) + + // Verify entitiesMap structure + expect(entitiesMap).toEqual( + expect.objectContaining({ + Product: expect.objectContaining({ + name: "Product", + _fields: { + id: expect.objectContaining({ + name: "id", + }), + title: expect.objectContaining({ + name: "title", + }), + prices: expect.objectContaining({ + name: "prices", + }), + }, + }), + ProductVariant: expect.objectContaining({ + name: "ProductVariant", + _fields: { + id: expect.objectContaining({ + name: "id", + }), + title: expect.objectContaining({ + name: "title", + }), + prices: expect.objectContaining({ + name: "prices", + }), + }, + }), + Price: expect.objectContaining({ + name: "Price", + _fields: { + id: expect.objectContaining({ + name: "id", + }), + amount: expect.objectContaining({ + name: "amount", + }), + currency_code: expect.objectContaining({ + name: "currency_code", + }), + product_variant: expect.objectContaining({ + name: "product_variant", + }), + product: expect.objectContaining({ + name: "product", + }), + }, + }), + }) + ) + + // Verify that all entities exist in the objectRepresentation + expect(objectRepresentation["Product"]).toBeDefined() + expect(objectRepresentation["Product"].entity).toBe("Product") + expect(objectRepresentation["Product"].parents).toEqual([ + expect.objectContaining({ + inSchemaRef: expect.objectContaining({ + entity: "Price", + }), + ref: expect.objectContaining({ + entity: "ProductPriceLink", + }), + targetProp: "product", + inverseSideProp: "product_price_link", + isList: false, + }), + ]) + expect(objectRepresentation["Product"].listeners).toEqual([ + "product.created", + ]) + expect(objectRepresentation["Product"].alias).toBe("product") + expect(objectRepresentation["Product"].moduleConfig).toBe( + productModuleJoinerConfig + ) + expect(objectRepresentation["Product"].fields).toEqual(["id", "title"]) + + expect(objectRepresentation["ProductVariant"]).toBeDefined() + expect(objectRepresentation["ProductVariant"].entity).toBe("ProductVariant") + expect(objectRepresentation["ProductVariant"].parents).toEqual([ + expect.objectContaining({ + inSchemaRef: expect.objectContaining({ + entity: "Price", + }), + ref: expect.objectContaining({ + entity: "ProductVariantPriceLink", + }), + targetProp: "product_variant", + inverseSideProp: "product_variant_price_link", + isList: false, + }), + ]) + expect(objectRepresentation["ProductVariant"].listeners).toEqual([ + "product_variant.created", + ]) + expect(objectRepresentation["ProductVariant"].alias).toBe("product_variant") + expect(objectRepresentation["ProductVariant"].moduleConfig).toBe( + productModuleJoinerConfig + ) + expect(objectRepresentation["ProductVariant"].fields).toEqual([ + "id", + "title", + ]) + + expect(objectRepresentation["ProductVariantPriceLink"]).toBeDefined() + expect(objectRepresentation["ProductVariantPriceLink"].entity).toBe( + "ProductVariantPriceLink" + ) + + expect(objectRepresentation["ProductVariantPriceLink"].parents).toEqual([ + expect.objectContaining({ + ref: expect.objectContaining({ + entity: "Price", + }), + targetProp: "product_variant_price_link", + inverseSideProp: "price", + isList: false, + isInverse: true, + }), + expect.objectContaining({ + ref: expect.objectContaining({ + entity: "ProductVariant", + }), + targetProp: "product_variant_price_link", + inverseSideProp: "product_variant", + isList: false, + isInverse: false, + }), + ]) + expect(objectRepresentation["ProductVariantPriceLink"].fields).toEqual([ + "id", + "variant_id", + "price_id", + ]) + + expect(objectRepresentation["ProductPriceLink"]).toBeDefined() + expect(objectRepresentation["ProductPriceLink"].entity).toBe( + "ProductPriceLink" + ) + + expect(objectRepresentation["ProductPriceLink"].parents).toEqual([ + expect.objectContaining({ + ref: expect.objectContaining({ + entity: "Product", + }), + inverseSideProp: "product", + targetProp: "product_price_link", + isList: false, + isInverse: false, + }), + expect.objectContaining({ + ref: expect.objectContaining({ + entity: "Price", + }), + inverseSideProp: "price", + targetProp: "product_price_link", + isList: false, + isInverse: true, + }), + ]) + expect(objectRepresentation["ProductPriceLink"].fields).toEqual([ + "id", + "product_id", + "price_id", + ]) + + expect(objectRepresentation["Price"]).toBeDefined() + expect(objectRepresentation["Price"].entity).toBe("Price") + expect(objectRepresentation["Price"].parents).toEqual([ + expect.objectContaining({ + inSchemaRef: expect.objectContaining({ + entity: "Product", + }), + ref: expect.objectContaining({ + entity: "ProductPriceLink", + }), + targetProp: "prices", + inverseSideProp: "product_price_link", + isList: false, + }), + expect.objectContaining({ + inSchemaRef: expect.objectContaining({ + entity: "ProductVariant", + }), + ref: expect.objectContaining({ + entity: "ProductVariantPriceLink", + }), + targetProp: "prices", + inverseSideProp: "product_variant_price_link", + isList: false, + }), + ]) + expect(objectRepresentation["Price"].fields).toEqual([ + "id", + "amount", + "currency_code", + ]) + expect(objectRepresentation["Price"].moduleConfig).toBe( + priceModuleJoinerConfig + ) + + // Check that links between services are properly set up + expect(objectRepresentation._serviceNameModuleConfigMap).toEqual( + expect.objectContaining({ + ProductService: productModuleJoinerConfig, + PriceService: priceModuleJoinerConfig, + ProductPriceService: productPriceLinkModuleJoinerConfig, + ProductVariantPriceService: productVariantPriceLinkModuleJoinerConfig, + }) + ) + }) + + it("should throw an error when an entity with listeners doesn't have a corresponding module", () => { + const indexSchema = ` + type Product @Listeners(values: ["product.created"]) { + id: ID! + title: String! + } + ` + + // Return empty array for getAllJoinerConfigs so there's no module for the Product entity + ;(MedusaModule.getAllJoinerConfigs as jest.Mock).mockReturnValue([]) + + // The function should throw an error because the entity has listeners but no module + expect(() => { + buildSchemaObjectRepresentation(indexSchema) + }).toThrow( + /unable to retrieve the module that corresponds to the entity Product/ + ) + }) +}) diff --git a/packages/modules/index/src/utils/base-graphql-schema.ts b/packages/modules/index/src/utils/base-graphql-schema.ts new file mode 100644 index 0000000000..2a29024b99 --- /dev/null +++ b/packages/modules/index/src/utils/base-graphql-schema.ts @@ -0,0 +1,6 @@ +export const baseGraphqlSchema = ` + scalar DateTime + scalar Date + scalar Time + scalar JSON +` diff --git a/packages/modules/index/src/utils/build-config.ts b/packages/modules/index/src/utils/build-config.ts index d61b93ec9f..94c11668d0 100644 --- a/packages/modules/index/src/utils/build-config.ts +++ b/packages/modules/index/src/utils/build-config.ts @@ -9,8 +9,11 @@ import { buildModuleResourceEventName, CommonEvents, GraphQLUtils, + kebabCase, + lowerCaseFirst, } from "@medusajs/framework/utils" import { schemaObjectRepresentationPropertiesToOmit } from "@types" +import { baseGraphqlSchema } from "./base-graphql-schema" export const CustomDirectives = { Listeners: { @@ -34,6 +37,78 @@ export function makeSchemaExecutable(inputSchema: string) { }) } +/** + * Retrieve the property name of the source entity that corresponds to the target entity + * @param sourceEntityName - The name of the source entity + * @param targetEntityName - The name of the target entity + * @param entitiesMap - The map of entities configured in the module + * @param servicesEntityMap - The map of entities configured in the services + * @param node - The node of the source entity + * @returns The property name and if it is an array of the source entity property that corresponds to the target entity + */ +function retrieveEntityPropByType({ + sourceEntityName, + targetEntityName, + entitiesMap, + servicesEntityMap, + node, +}: { + sourceEntityName?: string + targetEntityName: string + entitiesMap?: Record + servicesEntityMap?: Record + node?: any +}): { name: string; isArray: boolean } | undefined { + if (!node && !entitiesMap && !servicesEntityMap) { + throw new Error( + "Index Module error, unable to retrieve the entity property by type. Please provide either the entitiesMap and servicesEntityMap or the node." + ) + } + + const retrieveFieldNode = ( + node: any + ): { name: string; isArray: boolean } | undefined => { + const astNode = node?.astNode + const fields = astNode?.fields ?? [] + + for (const field of fields) { + let type = field.type + let isArray = false + while (type.type) { + if (type.kind === GraphQLUtils.Kind.LIST_TYPE) { + isArray = true + } + type = type.type + } + if (type.name?.value === targetEntityName) { + return { name: field.name?.value, isArray } + } + } + + return + } + + let prop: any + if (node) { + prop = retrieveFieldNode(node) + } + + if (entitiesMap && !prop) { + prop = retrieveFieldNode(entitiesMap[sourceEntityName!]) + } + + if (servicesEntityMap && !prop) { + prop = retrieveFieldNode(servicesEntityMap[sourceEntityName!]) + } + + return ( + prop && { + name: prop?.name, + isArray: prop?.isArray, + } + ) +} + function extractNameFromAlias( alias: JoinerServiceConfigAlias | JoinerServiceConfigAlias[] ) { @@ -114,23 +189,33 @@ function retrieveLinkModuleAndAlias({ foreignEntity, foreignModuleConfig, moduleJoinerConfigs, + servicesEntityMap, + entitiesMap, }: { primaryEntity: string primaryModuleConfig: ModuleJoinerConfig foreignEntity: string foreignModuleConfig: ModuleJoinerConfig moduleJoinerConfigs: ModuleJoinerConfig[] + servicesEntityMap: Record + entitiesMap: Record }): { entityName: string alias: string linkModuleConfig: ModuleJoinerConfig intermediateEntityNames: string[] + isInverse?: boolean + isList?: boolean + inverseSideProp?: string }[] { const linkModulesMetadata: { entityName: string alias: string linkModuleConfig: ModuleJoinerConfig intermediateEntityNames: string[] + isInverse?: boolean + inverseSideProp?: string + isList?: boolean }[] = [] for (const linkModuleJoinerConfig of moduleJoinerConfigs.filter( @@ -141,128 +226,202 @@ function retrieveLinkModuleAndAlias({ const linkForeign = linkModuleJoinerConfig.relationships![1] as ModuleJoinerRelationship - if ( + const isDirectMatch = linkPrimary.serviceName === primaryModuleConfig.serviceName && - linkForeign.serviceName === foreignModuleConfig.serviceName + linkForeign.serviceName === foreignModuleConfig.serviceName && + linkPrimary.entity === primaryEntity + + const isInverseMatch = + linkPrimary.serviceName === foreignModuleConfig.serviceName && + linkForeign.serviceName === primaryModuleConfig.serviceName && + linkPrimary.entity === foreignEntity + + if (!(isDirectMatch || isInverseMatch)) { + continue + } + + const primaryEntityLinkableKey = isDirectMatch + ? linkPrimary.foreignKey + : linkForeign.foreignKey + const isTheForeignKeyEntityEqualPrimaryEntity = + primaryModuleConfig.linkableKeys?.[primaryEntityLinkableKey] === + primaryEntity + + const foreignEntityLinkableKey = isDirectMatch + ? linkForeign.foreignKey + : linkPrimary.foreignKey + const isTheForeignKeyEntityEqualForeignEntity = + foreignModuleConfig.linkableKeys?.[foreignEntityLinkableKey] === + foreignEntity + + const relationshipsTypes = + linkModuleJoinerConfig.relationships + ?.map((relationship) => relationship.entity!) + .filter(Boolean) ?? [] + + const filteredEntitiesMap = { + [linkModuleJoinerConfig.alias?.[0].entity]: + entitiesMap[linkModuleJoinerConfig.alias?.[0].entity], + } + const filteredServicesEntityMap = { + [linkModuleJoinerConfig.alias?.[0].entity]: + servicesEntityMap[linkModuleJoinerConfig.alias?.[0].entity], + } + + for (const relationshipType of relationshipsTypes) { + filteredEntitiesMap[relationshipType] = entitiesMap[relationshipType] + filteredServicesEntityMap[relationshipType] = + servicesEntityMap[relationshipType] + } + + const linkName = linkModuleJoinerConfig.extends?.find((extend) => { + return ( + (extend.serviceName === primaryModuleConfig.serviceName || + extend.relationship.serviceName === + foreignModuleConfig.serviceName) && + (extend.relationship.primaryKey === primaryEntityLinkableKey || + extend.relationship.primaryKey === foreignEntityLinkableKey) + ) + })?.relationship.serviceName + + if (!linkName) { + throw new Error( + `Index Module error, unable to retrieve the link module name for the services ${primaryModuleConfig.serviceName} - ${foreignModuleConfig.serviceName}. Please be sure that the extend relationship service name is set correctly` + ) + } + + if (!linkModuleJoinerConfig.alias?.[0]?.entity) { + throw new Error( + `Index Module error, unable to retrieve the link module entity name for the services ${primaryModuleConfig.serviceName} - ${foreignModuleConfig.serviceName}. Please be sure that the link module alias has an entity property in the args.` + ) + } + + if ( + isTheForeignKeyEntityEqualPrimaryEntity && + isTheForeignKeyEntityEqualForeignEntity ) { - const primaryEntityLinkableKey = linkPrimary.foreignKey - const isTheForeignKeyEntityEqualPrimaryEntity = - primaryModuleConfig.linkableKeys?.[primaryEntityLinkableKey] === - primaryEntity + /** + * The link will become the parent of the foreign entity, that is why the alias must be the one that correspond to the extended foreign module + */ - const foreignEntityLinkableKey = linkForeign.foreignKey - const isTheForeignKeyEntityEqualForeignEntity = - foreignModuleConfig.linkableKeys?.[foreignEntityLinkableKey] === - foreignEntity + const inverseSideProp = retrieveEntityPropByType({ + targetEntityName: primaryEntity, + sourceEntityName: linkModuleJoinerConfig.alias[0].entity, + servicesEntityMap: filteredServicesEntityMap, + entitiesMap: filteredEntitiesMap, + }) - const linkName = linkModuleJoinerConfig.extends?.find((extend) => { - return ( - extend.serviceName === primaryModuleConfig.serviceName && - extend.relationship.primaryKey === primaryEntityLinkableKey - ) - })?.relationship.serviceName + linkModulesMetadata.push({ + entityName: linkModuleJoinerConfig.alias[0].entity, + alias: extractNameFromAlias(linkModuleJoinerConfig.alias), + linkModuleConfig: linkModuleJoinerConfig, + intermediateEntityNames: [], + isInverse: isInverseMatch, + isList: inverseSideProp?.isArray, + inverseSideProp: inverseSideProp?.name, + }) + } else { + const intermediateEntityName = + foreignModuleConfig.linkableKeys![foreignEntityLinkableKey] - if (!linkName) { + const moduleSchema = isDirectMatch + ? foreignModuleConfig.schema! + : primaryModuleConfig.schema! + + if (!moduleSchema) { throw new Error( - `Index Module error, unable to retrieve the link module name for the services ${primaryModuleConfig.serviceName} - ${foreignModuleConfig.serviceName}. Please be sure that the extend relationship service name is set correctly` + `Index Module error, unable to retrieve the intermediate entity name for the services ${primaryModuleConfig.serviceName} - ${foreignModuleConfig.serviceName}. Please be sure that the foreign module ${foreignModuleConfig.serviceName} has a schema.` ) } - if (!linkModuleJoinerConfig.alias?.[0]?.entity) { - throw new Error( - `Index Module error, unable to retrieve the link module entity name for the services ${primaryModuleConfig.serviceName} - ${foreignModuleConfig.serviceName}. Please be sure that the link module alias has an entity property in the args.` - ) - } - - if ( - isTheForeignKeyEntityEqualPrimaryEntity && - isTheForeignKeyEntityEqualForeignEntity - ) { - /** - * The link will become the parent of the foreign entity, that is why the alias must be the one that correspond to the extended foreign module - */ - - linkModulesMetadata.push({ - entityName: linkModuleJoinerConfig.alias[0].entity, - alias: extractNameFromAlias(linkModuleJoinerConfig.alias), - linkModuleConfig: linkModuleJoinerConfig, - intermediateEntityNames: [], - }) - } else { - const intermediateEntityName = - foreignModuleConfig.linkableKeys![foreignEntityLinkableKey] - - if (!foreignModuleConfig.schema) { - throw new Error( - `Index Module error, unable to retrieve the intermediate entity name for the services ${primaryModuleConfig.serviceName} - ${foreignModuleConfig.serviceName}. Please be sure that the foreign module ${foreignModuleConfig.serviceName} has a schema.` - ) + const entitiesMap = servicesEntityMap + let intermediateEntities: string[] = [] + let foundCount = 0 + let foundName: string | null = null + const isForeignEntityChildOfIntermediateEntity = ( + entityName: string, + visited: Set = new Set() + ): boolean => { + if (visited.has(entityName)) { + return false } + visited.add(entityName) - const executableSchema = makeSchemaExecutable( - foreignModuleConfig.schema - ) - if (!executableSchema) { - continue - } + for (const entityType of Object.values(entitiesMap)) { + const inverseSideProp = retrieveEntityPropByType({ + node: entityType, + targetEntityName: entityName, + }) - const entitiesMap = executableSchema.getTypeMap() - - let intermediateEntities: string[] = [] - let foundCount = 0 - - const isForeignEntityChildOfIntermediateEntity = ( - entityName - ): boolean => { - for (const entityType of Object.values(entitiesMap)) { - if ( - entityType.astNode?.kind === "ObjectTypeDefinition" && - entityType.astNode?.fields?.some((field) => { - return (field.type as any)?.type?.name?.value === entityName - }) - ) { - if (entityType.name === intermediateEntityName) { - ++foundCount + if ( + entityType.astNode?.kind === "ObjectTypeDefinition" && + inverseSideProp + ) { + if (entityType.name === intermediateEntityName) { + foundName = entityType.name + ++foundCount + return true + } else { + const inverseSideProp = isForeignEntityChildOfIntermediateEntity( + entityType.name, + visited + ) + if (inverseSideProp) { + intermediateEntities.push(entityType.name) return true - } else { - const test = isForeignEntityChildOfIntermediateEntity( - entityType.name - ) - if (test) { - intermediateEntities.push(entityType.name) - } } } } - - return false } - - isForeignEntityChildOfIntermediateEntity(foreignEntity) - - if (foundCount !== 1) { - throw new Error( - `Index Module error, unable to retrieve the intermediate entities for the services ${primaryModuleConfig.serviceName} - ${foreignModuleConfig.serviceName} between ${foreignEntity} and ${intermediateEntityName}. Multiple paths or no path found. Please check your schema in ${foreignModuleConfig.serviceName}` - ) - } - - intermediateEntities.push(intermediateEntityName!) - - /** - * The link will become the parent of the foreign entity, that is why the alias must be the one that correspond to the extended foreign module - */ - - linkModulesMetadata.push({ - entityName: linkModuleJoinerConfig.alias[0].entity, - alias: extractNameFromAlias(linkModuleJoinerConfig.alias), - linkModuleConfig: linkModuleJoinerConfig, - intermediateEntityNames: intermediateEntities, - }) + return false } + + isForeignEntityChildOfIntermediateEntity( + isDirectMatch ? foreignEntity : primaryEntity + ) + + if (foundCount !== 1) { + throw new Error( + `Index Module error, unable to retrieve the intermediate entities for the services ${ + primaryModuleConfig.serviceName + } - ${foreignModuleConfig.serviceName} between ${ + isDirectMatch ? foreignEntity : primaryEntity + } and ${intermediateEntityName}. Multiple paths or no path found. Please check your schema in ${ + foreignModuleConfig.serviceName + }` + ) + } + + intermediateEntities.push(foundName!) + + /** + * The link will become the parent of the foreign entity, that is why the alias must be the one that correspond to the extended foreign module + */ + + const directInverseSideProp = retrieveEntityPropByType({ + targetEntityName: isDirectMatch + ? primaryEntity + : linkModuleJoinerConfig.alias[0].entity, + sourceEntityName: isDirectMatch + ? linkModuleJoinerConfig.alias[0].entity + : primaryEntity, + servicesEntityMap: filteredServicesEntityMap, + entitiesMap: filteredEntitiesMap, + }) + + linkModulesMetadata.push({ + entityName: linkModuleJoinerConfig.alias[0].entity, + alias: extractNameFromAlias(linkModuleJoinerConfig.alias), + linkModuleConfig: linkModuleJoinerConfig, + intermediateEntityNames: intermediateEntities, + inverseSideProp: directInverseSideProp?.name, + isList: directInverseSideProp?.isArray, + isInverse: isInverseMatch, + }) } } if (!linkModulesMetadata.length) { - // TODO: change to use the logger console.warn( `Index Module warning, unable to retrieve the link module that correspond to the entities ${primaryEntity} - ${foreignEntity}.` ) @@ -309,10 +468,12 @@ function processEntity( entityName: string, { entitiesMap, + servicesEntityMap, moduleJoinerConfigs, objectRepresentationRef, }: { entitiesMap: any + servicesEntityMap: any moduleJoinerConfigs: ModuleJoinerConfig[] objectRepresentationRef: IndexTypes.SchemaObjectRepresentation } @@ -337,8 +498,15 @@ function processEntity( entitiesMap[entityName].astNode?.directives ?? [] ) + // Merge and deduplicate the fields + currentObjectRepresentationRef.fields ??= [] currentObjectRepresentationRef.fields = - GraphQLUtils.gqlGetFieldsAndRelations(entitiesMap, entityName) ?? [] + currentObjectRepresentationRef.fields.concat( + ...(GraphQLUtils.gqlGetFieldsAndRelations(entitiesMap, entityName) ?? []) + ) + currentObjectRepresentationRef.fields = Array.from( + new Set(currentObjectRepresentationRef.fields) + ) /** * Retrieve the module and alias for the current entity. @@ -409,23 +577,27 @@ function processEntity( * Retrieve the parent entity field in the schema */ - const entityFieldInParent = ( - entitiesMap[parent].astNode as any - )?.fields?.find((field) => { - let currentType = field.type - while (currentType.type) { - currentType = currentType.type - } - return currentType.name?.value === entityName - }) + const entityFieldInParent = retrieveEntityPropByType({ + sourceEntityName: parent, + targetEntityName: entityName, + entitiesMap, + servicesEntityMap, + })! - const isEntityListInParent = - entityFieldInParent.type.kind === GraphQLUtils.Kind.LIST_TYPE - const entityTargetPropertyNameInParent = entityFieldInParent.name.value + const entityTargetPropertyNameInParent = entityFieldInParent.name + const entityTargetPropertyIsListInParent = entityFieldInParent.isArray /** * Retrieve the parent entity object representation reference. */ + if (!objectRepresentationRef[parent]) { + processEntity(parent, { + entitiesMap, + servicesEntityMap, + moduleJoinerConfigs, + objectRepresentationRef, + }) + } const parentObjectRepresentationRef = getObjectRepresentationRef(parent, { objectRepresentationRef, @@ -434,33 +606,78 @@ function processEntity( // If the entity is not part of any module, just set the parent and continue if (!currentObjectRepresentationRef.moduleConfig) { - currentObjectRepresentationRef.parents.push({ - ref: parentObjectRepresentationRef, - targetProp: entityTargetPropertyNameInParent, - isList: isEntityListInParent, + const parentAlreadyExists = currentObjectRepresentationRef.parents.some( + (existingParent) => + existingParent.ref?.entity === parentObjectRepresentationRef.entity && + existingParent.targetProp === entityTargetPropertyNameInParent + ) + + const parentPropertyNameWithinCurrentEntity = retrieveEntityPropByType({ + sourceEntityName: entityName, + targetEntityName: parent, + entitiesMap, + servicesEntityMap, }) + + if (!parentAlreadyExists) { + currentObjectRepresentationRef.parents.push({ + ref: parentObjectRepresentationRef, + targetProp: entityTargetPropertyNameInParent, + inverseSideProp: parentPropertyNameWithinCurrentEntity?.name!, + isList: entityTargetPropertyIsListInParent, + }) + } else { + return + } + continue } /** - * If the parent entity and the current entity are part of the same servive then configure the parent and + * If the parent entity and the current entity are part of the same service then configure the parent and * add the parent id as a field to the current entity. */ if ( currentObjectRepresentationRef.moduleConfig.serviceName === parentModuleConfig.serviceName || - parentModuleConfig.isLink + parentModuleConfig.isLink || + currentObjectRepresentationRef.moduleConfig.isLink ) { - currentObjectRepresentationRef.parents.push({ - ref: parentObjectRepresentationRef, - targetProp: entityTargetPropertyNameInParent, - isList: isEntityListInParent, + const parentPropertyNameWithinCurrentEntity = retrieveEntityPropByType({ + sourceEntityName: entityName, + targetEntityName: parent, + entitiesMap, + servicesEntityMap, }) - currentObjectRepresentationRef.fields.push( - parentObjectRepresentationRef.alias + ".id" + const parentAlreadyExists = currentObjectRepresentationRef.parents.some( + (existingParent) => + existingParent.ref?.entity === parentObjectRepresentationRef.entity && + existingParent.targetProp === entityTargetPropertyNameInParent ) + + if (!parentAlreadyExists) { + currentObjectRepresentationRef.parents.push({ + ref: parentObjectRepresentationRef, + targetProp: entityTargetPropertyNameInParent, + inverseSideProp: parentPropertyNameWithinCurrentEntity?.name!, + isList: entityTargetPropertyIsListInParent, + }) + } + + const propertyToAdd = parentPropertyNameWithinCurrentEntity?.name + ".id" + + if ( + parentPropertyNameWithinCurrentEntity && + !currentObjectRepresentationRef.fields.includes(propertyToAdd) + ) { + currentObjectRepresentationRef.fields.push(propertyToAdd) + } + + if (parentAlreadyExists) { + return + } } else { /** * If the parent entity and the current entity are not part of the same service then we need to @@ -473,6 +690,8 @@ function processEntity( foreignEntity: currentObjectRepresentationRef.entity, foreignModuleConfig: currentEntityModule, moduleJoinerConfigs, + servicesEntityMap, + entitiesMap, }) for (const linkModuleMetadata of linkModuleMetadatas) { @@ -484,18 +703,28 @@ function processEntity( objectRepresentationRef._serviceNameModuleConfigMap[ linkModuleMetadata.linkModuleConfig.serviceName || linkModuleMetadata.entityName - ] = currentEntityModule + ] = linkModuleMetadata.linkModuleConfig /** * Add the schema parent entity as a parent to the link module and configure it. */ - linkObjectRepresentationRef.parents = [ - { + linkObjectRepresentationRef.parents ??= [] + + if ( + !linkObjectRepresentationRef.parents.some( + (parent) => + parent.ref.entity === parentObjectRepresentationRef.entity + ) + ) { + linkObjectRepresentationRef.parents.push({ ref: parentObjectRepresentationRef, targetProp: linkModuleMetadata.alias, - }, - ] + inverseSideProp: linkModuleMetadata.inverseSideProp ?? "", + isList: linkModuleMetadata.isList, + isInverse: linkModuleMetadata.isInverse, + }) + } linkObjectRepresentationRef.alias = linkModuleMetadata.alias linkObjectRepresentationRef.listeners = [ `${linkModuleMetadata.entityName}.${CommonEvents.ATTACHED}`, @@ -557,30 +786,61 @@ function processEntity( intermediateEntityModule.serviceName ] = intermediateEntityModule - intermediateEntityObjectRepresentationRef.parents.push({ + const parentPropertyNameWithinIntermediateEntity = + retrieveEntityPropByType({ + sourceEntityName: intermediateEntityName, + targetEntityName: parentIntermediateEntityRef.entity, + entitiesMap: entitiesMap, + servicesEntityMap: servicesEntityMap, + }) + + const intermediateEntityTargetPropertyIsListInParent = + retrieveEntityPropByType({ + sourceEntityName: parentIntermediateEntityRef.entity, + targetEntityName: intermediateEntityName, + entitiesMap: entitiesMap, + servicesEntityMap: servicesEntityMap, + })?.isArray + + const parentRef = { ref: parentIntermediateEntityRef, targetProp: intermediateEntityAlias, - isList: true, // TODO: check if it is a list in retrieveLinkModuleAndAlias and return the intermediate entity names + isList for each - }) + inverseSideProp: parentPropertyNameWithinIntermediateEntity?.name!, + isList: intermediateEntityTargetPropertyIsListInParent, + } + + const parentAlreadyExists = + intermediateEntityObjectRepresentationRef.parents.some( + (existingParent) => + existingParent.ref?.entity === + parentIntermediateEntityRef.entity && + existingParent.targetProp === intermediateEntityAlias + ) + + if (!parentAlreadyExists) { + intermediateEntityObjectRepresentationRef.parents.push(parentRef) + } intermediateEntityObjectRepresentationRef.alias = intermediateEntityAlias - + const kebabCasedServiceName = lowerCaseFirst( + kebabCase(intermediateEntityModule.serviceName) + ) intermediateEntityObjectRepresentationRef.listeners = [ buildModuleResourceEventName({ action: CommonEvents.CREATED, objectName: intermediateEntityName, - prefix: intermediateEntityModule.serviceName, + prefix: kebabCasedServiceName, }), buildModuleResourceEventName({ action: CommonEvents.UPDATED, objectName: intermediateEntityName, - prefix: intermediateEntityModule.serviceName, + prefix: kebabCasedServiceName, }), buildModuleResourceEventName({ action: CommonEvents.DELETED, objectName: intermediateEntityName, - prefix: intermediateEntityModule.serviceName, + prefix: kebabCasedServiceName, }), ] intermediateEntityObjectRepresentationRef.moduleConfig = @@ -592,9 +852,19 @@ function processEntity( */ if (!isLastIntermediateEntity) { - intermediateEntityObjectRepresentationRef.fields.push( - parentIntermediateEntityRef.alias + ".id" - ) + const propertyToAdd = + parentPropertyNameWithinIntermediateEntity?.name + ".id" + + if ( + parentPropertyNameWithinIntermediateEntity && + !intermediateEntityObjectRepresentationRef.fields.includes( + propertyToAdd + ) + ) { + intermediateEntityObjectRepresentationRef.fields.push( + propertyToAdd + ) + } } } @@ -609,22 +879,74 @@ function processEntity( objectRepresentationRef[ linkModuleMetadata.intermediateEntityNames[0] ] - currentObjectRepresentationRef.fields.push( - currentParentIntermediateRef.alias + ".id" - ) + + const parentPropertyNameWithinCurrentEntity = + retrieveEntityPropByType({ + sourceEntityName: currentObjectRepresentationRef.entity, + targetEntityName: currentParentIntermediateRef.entity, + entitiesMap: servicesEntityMap, + }) + + const propertyToAdd = + parentPropertyNameWithinCurrentEntity?.name + ".id" + + if ( + parentPropertyNameWithinCurrentEntity && + !currentObjectRepresentationRef.fields.includes(propertyToAdd) + ) { + currentObjectRepresentationRef.fields.push(propertyToAdd) + } } - currentObjectRepresentationRef.parents.push({ - ref: currentParentIntermediateRef, - inSchemaRef: parentObjectRepresentationRef, - targetProp: entityTargetPropertyNameInParent, - isList: isEntityListInParent, + const parentPropertyNameWithinCurrentEntity = retrieveEntityPropByType({ + sourceEntityName: currentObjectRepresentationRef.entity, + targetEntityName: currentParentIntermediateRef.entity, + entitiesMap: servicesEntityMap, }) + + const entityTargetPropertyIsListInParent = retrieveEntityPropByType({ + sourceEntityName: currentParentIntermediateRef.entity, + targetEntityName: currentObjectRepresentationRef.entity, + entitiesMap: entitiesMap, + servicesEntityMap: servicesEntityMap, + })?.isArray + + const parentAlreadyExists = currentObjectRepresentationRef.parents.some( + (existingParent) => + existingParent.ref?.entity === + currentParentIntermediateRef.entity && + existingParent.targetProp === entityTargetPropertyNameInParent + ) + + if (!parentAlreadyExists) { + currentObjectRepresentationRef.parents.push({ + ref: currentParentIntermediateRef, + inSchemaRef: parentObjectRepresentationRef, + targetProp: entityTargetPropertyNameInParent, + inverseSideProp: parentPropertyNameWithinCurrentEntity?.name!, + isList: entityTargetPropertyIsListInParent, + }) + } } } } } +function getServicesEntityMap( + moduleJoinerConfigs, + addtionalSchema: string = "" +) { + return makeSchemaExecutable( + baseGraphqlSchema + + "\n" + + moduleJoinerConfigs + .map((joinerConfig) => joinerConfig?.schema ?? "") + .join("\n") + + "\n" + + addtionalSchema + )!.getTypeMap() +} + /** * Build a special object which will be used to retrieve the correct * object representation using path tree @@ -637,6 +959,7 @@ function processEntity( * } * } */ + function buildAliasMap( objectRepresentation: IndexTypes.SchemaObjectRepresentation ) { @@ -644,51 +967,119 @@ function buildAliasMap( {} function recursivelyBuildAliasPath( - current, - alias = "", - aliases: { alias: string; shortCutOf?: string }[] = [] - ): { alias: string; shortCutOf?: string }[] { - if (current.parents?.length) { - for (const parentEntity of current.parents) { - /** - * Here we build the alias from child to parent to get it as parent to child - */ + current: IndexTypes.SchemaObjectEntityRepresentation, + parentPath = "", + aliases: { + alias: string + shortCutOf?: string + isInverse?: boolean + isList?: boolean + }[] = [], + visited: Set = new Set(), + pathStack: string[] = [] + ): { + alias: string + shortCutOf?: string + isInverse?: boolean + isList?: boolean + }[] { + const pathIdentifier = `${current.entity}:${parentPath}` - const _aliases = recursivelyBuildAliasPath( - parentEntity.ref, - `${parentEntity.targetProp}${alias ? "." + alias : ""}` - ).map((alias) => ({ alias: alias.alias })) + if (pathStack.includes(pathIdentifier)) { + return [] + } - aliases.push(..._aliases) + pathStack.push(pathIdentifier) - /** - * Now if there is a inSchemaRef it means that we had inferred a link module - * and we want to get the alias path as it would be in the schema provided - * and it become the short cut path of the full path above - */ + if (visited.has(current.entity)) { + pathStack.pop() + return [] + } - if (parentEntity.inSchemaRef) { - const shortCutOf = _aliases.map((a) => a.alias)[0] - const _aliasesShortCut = recursivelyBuildAliasPath( - parentEntity.inSchemaRef, - `${parentEntity.targetProp}${alias ? "." + alias : ""}` - ).map((alias_) => { - return { - alias: alias_.alias, - // It has to be the same entry point - shortCutOf: - shortCutOf.split(".")[0] === alias_.alias.split(".")[0] - ? shortCutOf - : undefined, + visited.add(current.entity) + + for (const parentEntity of current.parents) { + const newParentPath = parentPath + ? `${parentEntity.targetProp}.${parentPath}` + : parentEntity.targetProp + + const newVisited = new Set(visited) + const newPathStack = [...pathStack] + + const parentAliases = recursivelyBuildAliasPath( + parentEntity.ref, + newParentPath, + [], + newVisited, + newPathStack + ).map((aliasObj) => ({ + alias: aliasObj.alias, + isInverse: parentEntity.isInverse, + isList: parentEntity.isList, + })) + + aliases.push(...parentAliases) + + // Handle shortcut paths via inSchemaRef + if (parentEntity.inSchemaRef) { + const shortCutOf = parentAliases[0] + if (!shortCutOf) { + continue + } + + const shortcutAliases_ = recursivelyBuildAliasPath( + parentEntity.inSchemaRef, + newParentPath, + [], + new Set(visited), + [...pathStack] + ) + + // Assign all shortcut aliases to the parent aliases + const shortcutAliases: any[] = [] + shortcutAliases_.forEach((shortcutAlias) => { + parentAliases.forEach((aliasObj) => { + let isSamePath = + aliasObj.alias.split(".")[0] === shortcutAlias.alias.split(".")[0] + + if (!isSamePath) { + return } - }) - aliases.push(..._aliasesShortCut) + shortcutAliases.push({ + alias: shortcutAlias.alias, + shortCutOf: aliasObj.alias, + isList: parentEntity.isList ?? true, + isInverse: shortcutAlias.isInverse, + }) + }) + }) + + // Only add shortcut aliases if they don’t duplicate existing paths + for (const shortcut of shortcutAliases) { + if (!aliases.some((a) => a.alias === shortcut?.alias)) { + aliases.push(shortcut!) + } } } } - aliases.push({ alias: current.alias + (alias ? "." + alias : "") }) + // Add the current entity's alias, avoiding duplication + const pathSegments = parentPath ? parentPath.split(".") : [] + const baseAlias = + pathSegments.length && pathSegments[0] === current.alias + ? parentPath // If parentPath already starts with this alias, use it as-is + : `${current.alias}${parentPath ? `.${parentPath}` : ""}` + + if (!aliases.some((a) => a.alias === baseAlias)) { + aliases.push({ + alias: baseAlias, + isInverse: false, + }) + } + + pathStack.pop() + visited.delete(current.entity) return aliases } @@ -700,16 +1091,18 @@ function buildAliasMap( )) { const entityRepresentationRef = objectRepresentation[objectRepresentationKey] - const aliases = recursivelyBuildAliasPath(entityRepresentationRef) for (const alias of aliases) { - aliasMap[alias.alias] = { - ref: entityRepresentationRef, + if (aliasMap[alias.alias] && !alias.shortCutOf) { + continue } - if (alias.shortCutOf) { - aliasMap[alias.alias]["shortCutOf"] = alias.shortCutOf + aliasMap[alias.alias] = { + ref: entityRepresentationRef, + shortCutOf: alias.shortCutOf, + isList: alias.isList, + isInverse: alias.isInverse, } } } @@ -717,6 +1110,109 @@ function buildAliasMap( return aliasMap } +function buildSchemaFromFilterableLinks( + moduleJoinerConfigs: ModuleJoinerConfig[], + servicesEntityMap: Record +): string { + const allFilterable = moduleJoinerConfigs.flatMap((config) => { + const entities: any[] = [] + + const schema = config.schema + + if (config.isLink) { + if (!config.relationships?.some((r) => r.filterable?.length)) { + return [] + } + + for (const relationship of config.relationships) { + relationship.filterable ??= [] + if ( + !relationship.filterable?.length || + !relationship.filterable?.includes("id") + ) { + relationship.filterable.push("id") + } + + const fieldAliasMap: Record = {} + for (const extend of config.extends ?? []) { + fieldAliasMap[extend.serviceName] = Object.keys( + extend.fieldAlias ?? {} + ) + fieldAliasMap[extend.serviceName].push(extend.relationship.alias) + } + + const serviceName = relationship.serviceName + entities.push({ + serviceName, + entity: relationship.entity, + fields: Array.from( + new Set( + relationship.filterable.concat(fieldAliasMap[serviceName] ?? []) + ) + ), + schema, + }) + } + + return entities + } + + let aliases = config.alias ?? [] + aliases = (Array.isArray(aliases) ? aliases : [aliases]).filter( + (a) => a.filterable?.length && a.entity + ) + + for (const alias of aliases) { + entities.push({ + serviceName: config.serviceName, + entity: alias.entity, + fields: Array.from(new Set(alias.filterable)), + schema, + }) + } + return entities + }) + + const getGqlType = (entity, field) => { + const fieldRef = (servicesEntityMap[entity] as any)?._fields?.[field] + + if (!fieldRef) { + return + } + + const fieldType = fieldRef.type.toString() + const isArray = fieldType.startsWith("[") + const currentType = fieldType.replace(/\[|\]|\!/g, "") + + return isArray ? `[${currentType}]` : currentType + } + + const schema = allFilterable + .map(({ serviceName, entity, fields, schema }) => { + if (!schema) { + return + } + + const normalizedEntity = lowerCaseFirst(kebabCase(entity)) + const events = `@Listeners(values: ["${serviceName}.${normalizedEntity}.created", "${serviceName}.${normalizedEntity}.updated", "${serviceName}.${normalizedEntity}.deleted"])` + + const fieldDefinitions = fields + .map((field) => { + const type = getGqlType(entity, field) ?? "String" + + return ` ${field}: ${type}` + }) + .join("\n") + + return `type ${entity} ${events} { +${fieldDefinitions} +}` + }) + .join("\n\n") + + return schema +} + /** * This util build an internal representation object from the provided schema. * It will resolve all modules, fields, link module representation to build @@ -727,11 +1223,26 @@ function buildAliasMap( * * @param schema */ -export function buildSchemaObjectRepresentation( - schema -): [IndexTypes.SchemaObjectRepresentation, Record] { +export function buildSchemaObjectRepresentation(schema: string): { + objectRepresentation: IndexTypes.SchemaObjectRepresentation + entitiesMap: Record + executableSchema: GraphQLUtils.GraphQLSchema +} { const moduleJoinerConfigs = MedusaModule.getAllJoinerConfigs() - const augmentedSchema = CustomDirectives.Listeners.definition + schema + + const servicesEntityMap = getServicesEntityMap(moduleJoinerConfigs) + const filterableEntities = buildSchemaFromFilterableLinks( + moduleJoinerConfigs, + servicesEntityMap + ) + + const augmentedSchema = + CustomDirectives.Listeners.definition + + "\n" + + schema + + "\n" + + filterableEntities + const executableSchema = makeSchemaExecutable(augmentedSchema)! const entitiesMap = executableSchema.getTypeMap() @@ -746,6 +1257,7 @@ export function buildSchemaObjectRepresentation( processEntity(entityName, { entitiesMap, + servicesEntityMap, moduleJoinerConfigs, objectRepresentationRef: objectRepresentation, }) @@ -754,5 +1266,9 @@ export function buildSchemaObjectRepresentation( objectRepresentation._schemaPropertiesMap = buildAliasMap(objectRepresentation) - return [objectRepresentation, entitiesMap] + return { + objectRepresentation, + entitiesMap, + executableSchema, + } } diff --git a/packages/modules/index/src/utils/create-partitions.ts b/packages/modules/index/src/utils/create-partitions.ts index 9c5efa6133..9bf083a80e 100644 --- a/packages/modules/index/src/utils/create-partitions.ts +++ b/packages/modules/index/src/utils/create-partitions.ts @@ -9,6 +9,8 @@ export async function createPartitions( const activeSchema = manager.config.get("schema") ? `"${manager.config.get("schema")}".` : "" + + const createdPartitions: Set = new Set() const partitions = Object.keys(schemaObjectRepresentation) .filter( (key) => @@ -17,16 +19,30 @@ export async function createPartitions( ) .map((key) => { const cName = key.toLowerCase() + + if (createdPartitions.has(cName)) { + return [] + } + createdPartitions.add(cName) + const part: string[] = [] part.push( `CREATE TABLE IF NOT EXISTS ${activeSchema}cat_${cName} PARTITION OF ${activeSchema}index_data FOR VALUES IN ('${key}')` ) for (const parent of schemaObjectRepresentation[key].parents) { - const pKey = `${parent.ref.entity}-${key}` - const pName = `${parent.ref.entity}${key}`.toLowerCase() + if (parent.isInverse) { + continue + } + + const pName = `cat_pivot_${parent.ref.entity}${key}`.toLowerCase() + if (createdPartitions.has(pName)) { + continue + } + createdPartitions.add(pName) + part.push( - `CREATE TABLE IF NOT EXISTS ${activeSchema}cat_pivot_${pName} PARTITION OF ${activeSchema}index_relation FOR VALUES IN ('${pKey}')` + `CREATE TABLE IF NOT EXISTS ${activeSchema}${pName} PARTITION OF ${activeSchema}index_relation FOR VALUES IN ('${parent.ref.entity}-${key}')` ) } return part @@ -58,11 +74,14 @@ export async function createPartitions( `CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_cat_${cName}_id" ON ${activeSchema}cat_${cName} ("id")` ) - // create child id index on pivot partitions for (const parent of schemaObjectRepresentation[key].parents) { - const pName = `${parent.ref.entity}${key}`.toLowerCase() + if (parent.isInverse) { + continue + } + + const pName = `cat_pivot_${parent.ref.entity}${key}`.toLowerCase() part.push( - `CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_cat_pivot_${pName}_child_id" ON ${activeSchema}cat_pivot_${pName} ("child_id")` + `CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_${pName}_child_id" ON ${activeSchema}${pName} ("child_id")` ) } @@ -70,18 +89,25 @@ export async function createPartitions( }) .flat() - // Execute index creation commands separately to avoid blocking for (const cmd of indexCreationCommands) { try { await manager.execute(cmd) } catch (error) { - // Log error but continue with other indexes console.error(`Failed to create index: ${error.message}`) } } - partitions.push(`analyse ${activeSchema}index_data`) - partitions.push(`analyse ${activeSchema}index_relation`) + // Create count estimate function + partitions.push(` + CREATE OR REPLACE FUNCTION count_estimate(query text) RETURNS bigint AS $$ + DECLARE + plan jsonb; + BEGIN + EXECUTE 'EXPLAIN (FORMAT JSON) ' || query INTO plan; + RETURN (plan->0->'Plan'->>'Plan Rows')::bigint; + END; + $$ LANGUAGE plpgsql; + `) await manager.execute(partitions.join("; ")) } diff --git a/packages/modules/index/src/utils/default-schema.ts b/packages/modules/index/src/utils/default-schema.ts index a2ad8c3f39..1d900daf68 100644 --- a/packages/modules/index/src/utils/default-schema.ts +++ b/packages/modules/index/src/utils/default-schema.ts @@ -2,7 +2,7 @@ import { Modules } from "@medusajs/utils" export const defaultSchema = ` type Product @Listeners(values: ["${Modules.PRODUCT}.product.created", "${Modules.PRODUCT}.product.updated", "${Modules.PRODUCT}.product.deleted"]) { - id: String + id: ID title: String handle: String status: String @@ -18,7 +18,7 @@ export const defaultSchema = ` } type ProductVariant @Listeners(values: ["${Modules.PRODUCT}.product-variant.created", "${Modules.PRODUCT}.product-variant.updated", "${Modules.PRODUCT}.product-variant.deleted"]) { - id: String + id: ID product_id: String sku: String @@ -26,13 +26,13 @@ export const defaultSchema = ` } type Price @Listeners(values: ["${Modules.PRICING}.price.created", "${Modules.PRICING}.price.updated", "${Modules.PRICING}.price.deleted"]) { - id: String + id: ID amount: Float currency_code: String } type SalesChannel @Listeners(values: ["${Modules.SALES_CHANNEL}.sales_channel.created", "${Modules.SALES_CHANNEL}.sales_channel.updated", "${Modules.SALES_CHANNEL}.sales_channel.deleted"]) { - id: String + id: ID is_disabled: Boolean } ` diff --git a/packages/modules/index/src/utils/gql-to-types.ts b/packages/modules/index/src/utils/gql-to-types.ts index 83248cf476..7b3dda716f 100644 --- a/packages/modules/index/src/utils/gql-to-types.ts +++ b/packages/modules/index/src/utils/gql-to-types.ts @@ -1,18 +1,18 @@ import { MedusaModule } from "@medusajs/framework/modules-sdk" import { FileSystem, + GraphQLUtils, gqlSchemaToTypes as ModulesSdkGqlSchemaToTypes, } from "@medusajs/framework/utils" import { join } from "path" import * as process from "process" -import { CustomDirectives, makeSchemaExecutable } from "./build-config" -export async function gqlSchemaToTypes(schema: string) { - const augmentedSchema = CustomDirectives.Listeners.definition + schema - const executableSchema = makeSchemaExecutable(augmentedSchema)! +export async function gqlSchemaToTypes( + executableSchema: GraphQLUtils.GraphQLSchema +) { const filename = "index-service-entry-points" const filenameWithExt = filename + ".d.ts" - const dir = join(process.cwd(), ".medusa") + const dir = join(process.cwd(), ".medusa/types") await ModulesSdkGqlSchemaToTypes({ schema: executableSchema, diff --git a/packages/modules/index/src/utils/query-builder.ts b/packages/modules/index/src/utils/query-builder.ts index 20271d4d51..5d34a41068 100644 --- a/packages/modules/index/src/utils/query-builder.ts +++ b/packages/modules/index/src/utils/query-builder.ts @@ -1,15 +1,42 @@ import { IndexTypes } from "@medusajs/framework/types" import { - GraphQLUtils, isDefined, isObject, - isPresent, isString, unflattenObjectKeys, } from "@medusajs/framework/utils" import { Knex } from "@mikro-orm/knex" import { OrderBy, QueryFormat, QueryOptions, Select } from "@types" +function escapeJsonPathString(val: string): string { + // Escape for JSONPath string + return val.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/'/g, "\\'") +} + +function buildSafeJsonPathQuery( + field: string, + operator: string, + value: any +): string { + let jsonPathOperator = operator + if (operator === "=") { + jsonPathOperator = "==" + } else if (operator.toUpperCase().includes("LIKE")) { + jsonPathOperator = "like_regex" + } + + if (typeof value === "string") { + let val = value + if (jsonPathOperator === "like_regex") { + // Convert SQL LIKE wildcards to regex + val = val.replace(/%/g, ".*").replace(/_/g, ".") + } + value = `"${escapeJsonPathString(val)}"` + } + + return `$.${field} ${jsonPathOperator} ${value}` +} + export const OPERATOR_MAP = { $eq: "=", $lt: "<", @@ -91,17 +118,10 @@ export class QueryBuilder { throw new Error(`Field ${field} is not indexed.`) } - let currentType = fieldRef.type - let isArray = false - while (currentType.ofType) { - if (currentType instanceof GraphQLUtils.GraphQLList) { - isArray = true - } - - currentType = currentType.ofType - } - - return currentType.name + (isArray ? "[]" : "") + const fieldType = fieldRef.type.toString() + const isArray = fieldType.startsWith("[") + const currentType = fieldType.replace(/\[|\]|\!/g, "") + return currentType + (isArray ? "[]" : "") } private transformValueToType(path, field, value) { @@ -244,9 +264,8 @@ export class QueryBuilder { field, value[subKey] ) - const castType = this.getPostgresCastType(attr, [field]).cast - const val = operator === "IN" ? subValue : [subValue] + let val = operator === "IN" ? subValue : [subValue] if (operator === "=" && subValue === null) { operator = "IS" } else if (operator === "!=" && subValue === null) { @@ -254,18 +273,65 @@ export class QueryBuilder { } if (operator === "=") { - builder.whereRaw( - `${aliasMapping[attr]}.data @> '${getPathOperation( - attr, - field as string[], - subValue - )}'::jsonb` - ) + const hasId = field[field.length - 1] === "id" + if (hasId) { + builder.whereRaw(`${aliasMapping[attr]}.id = ?`, subValue) + } else { + builder.whereRaw( + `${aliasMapping[attr]}.data @> '${getPathOperation( + attr, + field as string[], + subValue + )}'::jsonb` + ) + } + } else if (operator === "IN") { + if (val && !Array.isArray(val)) { + val = [val] + } + if (!val || val.length === 0) { + return + } + + const inPlaceholders = val.map(() => "?").join(",") + const hasId = field[field.length - 1] === "id" + if (hasId) { + builder.whereRaw( + `${aliasMapping[attr]}.id IN (${inPlaceholders})`, + val + ) + } else { + const targetField = field[field.length - 1] as string + + const jsonbValues = val.map((item) => + JSON.stringify({ + [targetField]: item === null ? null : item, + }) + ) + builder.whereRaw( + `${aliasMapping[attr]}.data${nested} @> ANY(ARRAY[${inPlaceholders}]::JSONB[])`, + jsonbValues + ) + } } else { - builder.whereRaw( - `(${aliasMapping[attr]}.data${nested}->>?)${castType} ${operator} ?`, - [...field, ...val] - ) + const potentialIdFields = field[field.length - 1] + const hasId = potentialIdFields === "id" + if (hasId) { + builder.whereRaw(`(${aliasMapping[attr]}.id) ${operator} ?`, [ + ...val, + ]) + } else { + const targetField = field[field.length - 1] as string + + const jsonPath = buildSafeJsonPathQuery( + targetField, + operator, + val[0] + ) + builder.whereRaw(`${aliasMapping[attr]}.data${nested} @@ ?`, [ + jsonPath, + ]) + } } } else { throw new Error(`Unsupported operator: ${subKey}`) @@ -281,29 +347,60 @@ export class QueryBuilder { return } - const castType = this.getPostgresCastType(attr, field).cast const inPlaceholders = value.map(() => "?").join(",") - builder.whereRaw( - `(${aliasMapping[attr]}.data${nested}->>?)${castType} IN (${inPlaceholders})`, - [...field, ...value] - ) - } else if (isDefined(value)) { - const operator = value === null ? "IS" : "=" - - if (operator === "=") { + const hasId = field[field.length - 1] === "id" + if (hasId) { builder.whereRaw( - `${aliasMapping[attr]}.data @> '${getPathOperation( - attr, - field as string[], - value - )}'::jsonb` + `${aliasMapping[attr]}.id IN (${inPlaceholders})`, + [...value] ) } else { - const castType = this.getPostgresCastType(attr, field).cast - builder.whereRaw( - `(${aliasMapping[attr]}.data${nested}->>?)${castType} ${operator} ?`, - [...field, value] + const jsonbValues = value.map((item) => + JSON.stringify({ [nested]: item === null ? null : item }) ) + builder.whereRaw( + `${aliasMapping[attr]}.data IN ANY(ARRAY[${inPlaceholders}]::JSONB[])`, + jsonbValues + ) + } + } else if (isDefined(value)) { + let operator = "=" + + if (operator === "=") { + const hasId = field[field.length - 1] === "id" + if (hasId) { + builder.whereRaw(`${aliasMapping[attr]}.id = ?`, value) + } else { + builder.whereRaw( + `${aliasMapping[attr]}.data @> '${getPathOperation( + attr, + field as string[], + value + )}'::jsonb` + ) + } + } else { + if (value === null) { + operator = "IS" + } + + const hasId = field[field.length - 1] === "id" + if (hasId) { + builder.whereRaw(`(${aliasMapping[attr]}.id) ${operator} ?`, [ + value, + ]) + } else { + const targetField = field[field.length - 1] as string + + const jsonPath = buildSafeJsonPathQuery( + targetField, + operator, + value + ) + builder.whereRaw(`${aliasMapping[attr]}.data${nested} @@ ?`, [ + jsonPath, + ]) + } } } } @@ -312,14 +409,15 @@ export class QueryBuilder { return builder } - private getShortAlias(aliasMapping, alias: string) { + private getShortAlias(aliasMapping, alias, level = 0) { aliasMapping.__aliasIndex ??= 0 if (aliasMapping[alias]) { return aliasMapping[alias] } - aliasMapping[alias] = "t_" + aliasMapping.__aliasIndex++ + "_" + aliasMapping[alias] = + "t_" + aliasMapping.__aliasIndex++ + (level > 0 ? `_${level}` : "") return aliasMapping[alias] } @@ -327,7 +425,7 @@ export class QueryBuilder { private buildQueryParts( structure: Select, parentAlias: string, - parentEntity: string, + parentEntity: IndexTypes.SchemaObjectEntityRepresentation["parents"][0], parentProperty: string, aliasPath: string[] = [], level = 0, @@ -337,23 +435,30 @@ export class QueryBuilder { const isSelectableField = this.allSchemaFields.has(parentProperty) const entities = this.getEntity(currentAliasPath, false) - const entityRef = entities?.ref! // !entityRef.alias means the object has not table, it's a nested object - if (isSelectableField || !entities || !entityRef?.alias) { + if (isSelectableField || !entities || !entities?.ref?.alias) { // We are currently selecting a specific field of the parent entity or the entity is not found on the index schema // We don't need to build the query parts for this as there is no join return [] } - const mainEntity = entityRef.entity - const mainAlias = - this.getShortAlias(aliasMapping, mainEntity.toLowerCase()) + level + const mainEntity = entities + const mainAlias = this.getShortAlias( + aliasMapping, + mainEntity.ref.entity.toLowerCase(), + level + ) - const allEntities: any[] = [] + const allEntities: { + entity: IndexTypes.SchemaPropertiesMap[0] + parEntity: IndexTypes.SchemaObjectEntityRepresentation["parents"][0] + parAlias: string + alias: string + }[] = [] if (!entities.shortCutOf) { allEntities.push({ - entity: mainEntity, + entity: entities, parEntity: parentEntity, parAlias: parentAlias, alias: mainAlias, @@ -372,7 +477,7 @@ export class QueryBuilder { intermediateAlias.pop() - if (intermediateEntity.ref.entity === parentEntity) { + if (intermediateEntity.ref.entity === parentEntity?.ref.entity) { break } @@ -383,20 +488,20 @@ export class QueryBuilder { const alias = this.getShortAlias( aliasMapping, - intermediateEntity.ref.entity.toLowerCase() + intermediateEntity.ref.entity.toLowerCase(), + level ) + - level + "_" + x const parAlias = - parentIntermediateEntity.ref.entity === parentEntity + parentIntermediateEntity.ref.entity === parentEntity?.ref.entity ? parentAlias : this.getShortAlias( aliasMapping, - parentIntermediateEntity.ref.entity.toLowerCase() + parentIntermediateEntity.ref.entity.toLowerCase(), + level ) + - level + "_" + (x + 1) @@ -405,8 +510,9 @@ export class QueryBuilder { } allEntities.unshift({ - entity: intermediateEntity.ref.entity, - parEntity: parentIntermediateEntity.ref.entity, + entity: intermediateEntity as any, + parEntity: + parentIntermediateEntity as IndexTypes.SchemaObjectEntityRepresentation["parents"][0], parAlias, alias, }) @@ -421,18 +527,41 @@ export class QueryBuilder { aliasMapping[currentAliasPath] = alias if (level > 0) { - const cName = entity.toLowerCase() - const pName = `${parEntity}${entity}`.toLowerCase() + const cName = entity.ref.entity.toLowerCase() let joinTable = `cat_${cName} AS ${alias}` - const pivotTable = `cat_pivot_${pName}` - joinBuilder.leftJoin( - `${pivotTable} AS ${alias}_ref`, - `${alias}_ref.parent_id`, - `${parAlias}.id` - ) - joinBuilder.leftJoin(joinTable, `${alias}.id`, `${alias}_ref.child_id`) + if (entity.isInverse || parEntity.isInverse) { + const pName = + `${entity.ref.entity}${parEntity.ref.entity}`.toLowerCase() + const pivotTable = `cat_pivot_${pName}` + + joinBuilder.leftJoin( + `${pivotTable} AS ${alias}_ref`, + `${alias}_ref.child_id`, + `${parAlias}.id` + ) + joinBuilder.leftJoin( + joinTable, + `${alias}.id`, + `${alias}_ref.parent_id` + ) + } else { + const pName = + `${parEntity.ref.entity}${entity.ref.entity}`.toLowerCase() + const pivotTable = `cat_pivot_${pName}` + + joinBuilder.leftJoin( + `${pivotTable} AS ${alias}_ref`, + `${alias}_ref.parent_id`, + `${parAlias}.id` + ) + joinBuilder.leftJoin( + joinTable, + `${alias}.id`, + `${alias}_ref.child_id` + ) + } const joinWhere = this.selector.joinWhere ?? {} const joinKey = Object.keys(joinWhere).find((key) => { @@ -441,7 +570,7 @@ export class QueryBuilder { const curPath = k.join(".") if (curPath === currentAliasPath) { const relEntity = this.getEntity(curPath, false) - return relEntity?.ref?.entity === entity + return relEntity?.ref?.entity === entity.ref.entity } return false @@ -469,7 +598,7 @@ export class QueryBuilder { this.buildQueryParts( childStructure, mainAlias, - mainEntity, + mainEntity as any, child, aliasPath.concat(parentProperty), level + 1, @@ -499,9 +628,14 @@ export class QueryBuilder { const parentAliasPath = aliasPath.join(".") const alias = aliasMapping[parentAliasPath] delete selectParts[parentAliasPath] - selectParts[currentAliasPath] = this.knex.raw( - `${alias}.data->'${parentProperty}'` - ) + + if (parentProperty === "id") { + selectParts[currentAliasPath] = `${alias}.id` + } else { + selectParts[currentAliasPath] = this.knex.raw( + `${alias}.data->'${parentProperty}'` + ) + } return selectParts } @@ -572,9 +706,7 @@ export class QueryBuilder { hasPagination?: boolean hasCount?: boolean returnIdOnly?: boolean - }): string { - const queryBuilder = this.knex.queryBuilder() - + }): { sql: string; sqlCount?: string } { const selectOnlyStructure = this.selector.select const structure = this.requestedFields const filter = this.selector.where ?? {} @@ -584,17 +716,19 @@ export class QueryBuilder { const orderBy = this.transformOrderBy( (order && !Array.isArray(order) ? [order] : order) ?? [] ) + const take_ = !isNaN(+take!) ? +take! : 15 + const skip_ = !isNaN(+skip!) ? +skip! : 0 const rootKey = this.getStructureKeys(structure)[0] const rootStructure = structure[rootKey] as Select - const entity = this.getEntity(rootKey)!.ref.entity - const rootEntity = entity.toLowerCase() + const entity = this.getEntity(rootKey)! + const rootEntity = entity.ref.entity.toLowerCase() const aliasMapping: { [path: string]: string } = {} let hasTextSearch: boolean = false let textSearchQuery: string | null = null - const searchQueryFilterProp = `${rootEntity}.q` + const searchQueryFilterProp = `${rootKey}.q` if (searchQueryFilterProp in filter) { if (!filter[searchQueryFilterProp]) { @@ -606,10 +740,18 @@ export class QueryBuilder { } } + const filterSortStructure = + unflattenObjectKeys({ + ...(this.rawConfig?.filters + ? unflattenObjectKeys(this.rawConfig?.filters) + : {}), + ...orderBy, + })[rootKey] ?? {} + const joinParts = this.buildQueryParts( - rootStructure, + filterSortStructure, "", - entity, + entity as IndexTypes.SchemaObjectEntityRepresentation["parents"][0], rootKey, [], 0, @@ -617,35 +759,72 @@ export class QueryBuilder { ) const rootAlias = aliasMapping[rootKey] - const selectParts = !returnIdOnly - ? this.buildSelectParts( - selectOnlyStructure[rootKey] as Select, - rootKey, - aliasMapping - ) - : { [rootKey + ".id"]: `${rootAlias}.id` } - queryBuilder.select(selectParts) + const innerQueryBuilder = this.knex.queryBuilder() + // Outer query to select the full data based on the paginated IDs + const outerQueryBuilder = this.knex.queryBuilder() - queryBuilder.from( - `cat_${rootEntity} AS ${this.getShortAlias(aliasMapping, rootEntity)}` + innerQueryBuilder.distinct(`${rootAlias}.id`) + + const orderBySelects: Array = [] + const orderByClauses: string[] = [] + + for (const aliasPath in orderBy) { + const path = aliasPath.split(".") + const field = path.pop()! + const attr = path.join(".") + const alias = aliasMapping[attr] + const direction = orderBy[aliasPath] + const pgType = this.getPostgresCastType(attr, [field]) + const hasId = field === "id" + + let orderExpression: + | string + | Knex.Raw = `${rootAlias}.id ${direction}` + + if (alias) { + const aggregateAlias = `"${aliasPath}_agg"` + let aggregateExpression = `(${alias}.data->>'${field}')${pgType.cast}` + + if (hasId) { + aggregateExpression = `${alias}.id` + } else { + orderBySelects.push( + direction === "ASC" + ? this.knex.raw( + `MIN(${aggregateExpression}) AS ${aggregateAlias}` + ) + : this.knex.raw( + `MAX(${aggregateExpression}) AS ${aggregateAlias}` + ) + ) + orderExpression = `${aggregateAlias} ${direction}` + } + + outerQueryBuilder.orderByRaw(`${aggregateExpression} ${direction}`) + } + + orderByClauses.push(orderExpression as string) + } + + // Add ordering columns to the select list of the inner query + if (orderBySelects.length > 0) { + innerQueryBuilder.select(orderBySelects) + } + + innerQueryBuilder.from( + `cat_${rootEntity} AS ${this.getShortAlias(aliasMapping, rootKey)}` ) joinParts.forEach((joinPart) => { - queryBuilder.joinRaw(joinPart) + innerQueryBuilder.joinRaw(joinPart) }) - let searchWhereParts: string[] = [] if (hasTextSearch) { - /** - * Build the search where parts for the query,. - * Apply the search query to the search vector column for every joined tabled except - * the pivot joined table. - */ - searchWhereParts = [ - `${this.getShortAlias(aliasMapping, rootEntity)}.${ + const searchWhereParts = [ + `${rootAlias}.${ this.#searchVectorColumnName - } @@ plainto_tsquery('simple', '${textSearchQuery}')`, + } @@ plainto_tsquery('simple', ?)`, ...joinParts.flatMap((part) => { const aliases = part .split(" as ") @@ -657,233 +836,94 @@ export class QueryBuilder { (alias) => `${alias}.${ this.#searchVectorColumnName - } @@ plainto_tsquery('simple', '${textSearchQuery}')` + } @@ plainto_tsquery('simple', ?)` ) }), ] - - queryBuilder.whereRaw(`(${searchWhereParts.join(" OR ")})`) - } - - // WHERE clause - this.parseWhere(aliasMapping, filter, queryBuilder) - - // ORDER BY clause - for (const aliasPath in orderBy) { - const path = aliasPath.split(".") - const field = path.pop() - const attr = path.join(".") - - const pgType = this.getPostgresCastType(attr, [field]) - const alias = aliasMapping[attr] - const direction = orderBy[aliasPath] - - queryBuilder.orderByRaw( - `(${alias}.data->>'${field}')${pgType.cast}` + " " + direction + innerQueryBuilder.whereRaw( + `(${searchWhereParts.join(" OR ")})`, + Array(searchWhereParts.length).fill(textSearchQuery) ) } - let take_ = !isNaN(+take!) ? +take! : 15 - let skip_ = !isNaN(+skip!) ? +skip! : 0 + this.parseWhere(aliasMapping, filter, innerQueryBuilder) - let cte = "" + // Group by root ID in the inner query + if (orderBySelects.length > 0) { + innerQueryBuilder.groupBy(`${rootAlias}.id`) + } + + if (orderByClauses.length > 0) { + innerQueryBuilder.orderByRaw(orderByClauses.join(", ")) + } else { + innerQueryBuilder.orderBy(`${rootAlias}.id`, "ASC") + } + + // Count query to estimate the number of results in parallel + let countQuery: Knex.Raw | undefined + if (hasCount) { + const estimateQuery = innerQueryBuilder.clone() + estimateQuery.clearSelect().select(1) + estimateQuery.clearOrder() + estimateQuery.clearCounters() + + countQuery = this.knex.raw( + `SELECT count_estimate(?) AS estimate_count`, + estimateQuery.toQuery() + ) + } + + // Apply pagination to the inner query if (hasPagination) { - cte = this.buildCTEData({ - hasCount, - searchWhereParts, - take: take_, - skip: skip_, - orderBy, - }) - - if (hasCount) { - queryBuilder.select(this.knex.raw("pd.count_total")) + innerQueryBuilder.limit(take_) + if (skip_ > 0) { + innerQueryBuilder.offset(skip_) } - - queryBuilder.joinRaw( - `JOIN paginated_data AS pd ON ${rootAlias}.id = pd.id` - ) } - return cte + queryBuilder.toQuery() - } + const innerQueryAlias = "paginated_ids" - public buildCTEData({ - hasCount, - searchWhereParts = [], - skip, - take, - orderBy, - }: { - hasCount: boolean - searchWhereParts: string[] - skip?: number - take: number - orderBy: OrderBy - }): string { - const queryBuilder = this.knex.queryBuilder() + outerQueryBuilder.from( + `cat_${rootEntity} AS ${this.getShortAlias(aliasMapping, rootKey)}` + ) - const hasWhere = isPresent(this.rawConfig?.filters) || isPresent(orderBy) - const structure = - hasWhere && !searchWhereParts.length - ? unflattenObjectKeys({ - ...(this.rawConfig?.filters - ? unflattenObjectKeys(this.rawConfig?.filters) - : {}), - ...orderBy, - }) - : this.requestedFields + outerQueryBuilder.joinRaw( + `INNER JOIN (${innerQueryBuilder.toQuery()}) AS ${innerQueryAlias} ON ${rootAlias}.id = ${innerQueryAlias}.id` + ) - const rootKey = this.getStructureKeys(structure)[0] + this.parseWhere(aliasMapping, filter, outerQueryBuilder) - const rootStructure = structure[rootKey] as Select - - const entity = this.getEntity(rootKey)!.ref.entity - const rootEntity = entity.toLowerCase() - const aliasMapping: { [path: string]: string } = {} - - const joinParts = this.buildQueryParts( + const joinPartsOuterQuery = this.buildQueryParts( rootStructure, "", - entity, + entity as IndexTypes.SchemaObjectEntityRepresentation["parents"][0], rootKey, [], 0, aliasMapping ) + joinPartsOuterQuery.forEach((joinPart) => { + outerQueryBuilder.joinRaw(joinPart) + }) - const rootAlias = aliasMapping[rootKey] + const finalSelectParts = !returnIdOnly + ? this.buildSelectParts( + selectOnlyStructure[rootKey] as Select, + rootKey, + aliasMapping + ) + : { [`${rootKey}.id`]: `${rootAlias}.id` } - queryBuilder.select(this.knex.raw(`${rootAlias}.id as id`)) + outerQueryBuilder.select(finalSelectParts) - queryBuilder.from( - `cat_${rootEntity} AS ${this.getShortAlias(aliasMapping, rootEntity)}` - ) + const finalSql = outerQueryBuilder.toQuery() - if (hasWhere) { - joinParts.forEach((joinPart) => { - queryBuilder.joinRaw(joinPart) - }) - - if (searchWhereParts.length) { - queryBuilder.whereRaw(`(${searchWhereParts.join(" OR ")})`) - } - - this.parseWhere(aliasMapping, this.selector.where!, queryBuilder) + return { + sql: finalSql, + sqlCount: countQuery?.toQuery?.(), } - - // ORDER BY clause - const orderAliases: string[] = [] - for (const aliasPath in orderBy) { - const path = aliasPath.split(".") - const field = path.pop() - const attr = path.join(".") - - const pgType = this.getPostgresCastType(attr, [field]) - - const alias = aliasMapping[attr] - const direction = orderBy[aliasPath] - - const orderAlias = `"${alias}.data->>'${field}'"` - orderAliases.push(orderAlias + " " + direction) - - // transform the order by clause to a select MIN/MAX - queryBuilder.select( - direction === "ASC" - ? this.knex.raw( - `MIN((${alias}.data->>'${field}')${pgType.cast}) as ${orderAlias}` - ) - : this.knex.raw( - `MAX((${alias}.data->>'${field}')${pgType.cast}) as ${orderAlias}` - ) - ) - } - - queryBuilder.groupByRaw(`${rootAlias}.id`) - - const countSubQuery = hasCount - ? `, (SELECT count(id) FROM data_select) as count_total` - : "" - - return ` - WITH data_select AS ( - ${queryBuilder.toQuery()} - ), - paginated_data AS ( - SELECT id ${countSubQuery} - FROM data_select - ${orderAliases.length ? "ORDER BY " + orderAliases.join(", ") : ""} - LIMIT ${take} - ${skip ? `OFFSET ${skip}` : ""} - ) - ` } - // NOTE: We are keeping the bellow code for now as reference to alternative implementation for us. DO NOT REMOVE - // public buildQueryCount(): string { - // const queryBuilder = this.knex.queryBuilder() - - // const hasWhere = isPresent(this.rawConfig?.filters) - // const structure = hasWhere ? this.rawConfig?.filters! : this.structure - - // const rootKey = this.getStructureKeys(structure)[0] - - // const rootStructure = structure[rootKey] as Select - - // const entity = this.getEntity(rootKey)!.ref.entity - // const rootEntity = entity.toLowerCase() - // const aliasMapping: { [path: string]: string } = {} - - // const joinParts = this.buildQueryParts( - // rootStructure, - // "", - // entity, - // rootKey, - // [], - // 0, - // aliasMapping - // ) - - // const rootAlias = aliasMapping[rootKey] - - // queryBuilder.select(this.knex.raw(`COUNT(${rootAlias}.id) as count`)) - - // queryBuilder.from( - // `cat_${rootEntity} AS ${this.getShortAlias(aliasMapping, rootEntity)}` - // ) - - // const self = this - // if (hasWhere && joinParts.length) { - // const fromExistsRaw = joinParts.shift()! - // const [joinPartsExists, fromExistsPart] = - // fromExistsRaw.split(" left join ") - // const [fromExists, whereExists] = fromExistsPart.split(" on ") - // joinParts.unshift(joinPartsExists) - - // queryBuilder.whereExists(function () { - // this.select(self.knex.raw(`1`)) - // this.from(self.knex.raw(`${fromExists}`)) - // this.joinRaw(joinParts.join("\n")) - // if (hasWhere) { - // self.parseWhere(aliasMapping, self.selector.where!, this) - // this.whereRaw(self.knex.raw(whereExists)) - // return - // } - - // this.whereRaw(self.knex.raw(whereExists)) - // }) - // } else { - // queryBuilder.whereExists(function () { - // this.select(self.knex.raw(`1`)) - // if (hasWhere) { - // self.parseWhere(aliasMapping, self.selector.where!, this) - // } - // }) - // } - - // return queryBuilder.toQuery() - // } - public buildObjectFromResultset( resultSet: Record[] ): Record[] { @@ -894,7 +934,11 @@ export class QueryBuilder { const isListMap: { [path: string]: boolean } = {} const referenceMap: { [key: string]: any } = {} const pathDetails: { - [key: string]: { property: string; parents: string[]; parentPath: string } + [key: string]: { + property: string + parents: string[] + parentPath: string + } } = {} const initializeMaps = (structure: Select, path: string[]) => { diff --git a/packages/modules/index/src/utils/sync/configuration.ts b/packages/modules/index/src/utils/sync/configuration.ts index e3fd2916e0..ae37c3cf06 100644 --- a/packages/modules/index/src/utils/sync/configuration.ts +++ b/packages/modules/index/src/utils/sync/configuration.ts @@ -127,17 +127,15 @@ export class Configuration { } if (idxSyncData.length > 0) { - if (updatedConfig.length > 0) { - const ids = await this.#indexSyncService.list({ - entity: updatedConfig.map((c) => c.entity), - }) - idxSyncData.forEach((sync) => { - const id = ids.find((i) => i.entity === sync.entity)?.id - if (id) { - sync.id = id - } - }) - } + const ids = await this.#indexSyncService.list({ + entity: idxSyncData.map((c) => c.entity), + }) + idxSyncData.forEach((sync) => { + const id = ids.find((i) => i.entity === sync.entity)?.id + if (id) { + sync.id = id + } + }) await this.#indexSyncService.upsert(idxSyncData) } diff --git a/packages/modules/link-modules/src/utils/generate-schema.ts b/packages/modules/link-modules/src/utils/generate-schema.ts index 449e3a0c4f..44daeeb0b3 100644 --- a/packages/modules/link-modules/src/utils/generate-schema.ts +++ b/packages/modules/link-modules/src/utils/generate-schema.ts @@ -178,13 +178,13 @@ export function generateGraphQLSchema( // Link table relationships const primaryField = doesPrimaryExportSchema ? `${camelToSnakeCase(primary.alias)}: ${toPascalCase( - composeTableName(primary.serviceName) + primary.entity ?? composeTableName(primary.serviceName) )}` : "" const foreignField = doesForeignExportSchema ? `${camelToSnakeCase(foreign.alias)}: ${toPascalCase( - composeTableName(foreign.serviceName) + foreign.entity ?? composeTableName(foreign.serviceName) )}` : "" diff --git a/packages/modules/pricing/src/joiner-config.ts b/packages/modules/pricing/src/joiner-config.ts index bd286bb463..96e8f77163 100644 --- a/packages/modules/pricing/src/joiner-config.ts +++ b/packages/modules/pricing/src/joiner-config.ts @@ -1,8 +1,6 @@ import { defineJoinerConfig, Modules } from "@medusajs/framework/utils" import { Price, PriceList, PricePreference, PriceSet } from "@models" -import { default as schema } from "./schema" export const joinerConfig = defineJoinerConfig(Modules.PRICING, { - schema, models: [PriceSet, PriceList, Price, PricePreference], }) diff --git a/packages/modules/pricing/src/schema/index.ts b/packages/modules/pricing/src/schema/index.ts deleted file mode 100644 index 8abfe7f6b0..0000000000 --- a/packages/modules/pricing/src/schema/index.ts +++ /dev/null @@ -1,59 +0,0 @@ -export const schema = ` -type PriceSet { - id: ID! - prices: [Price] - calculated_price: CalculatedPriceSet -} - -type Price { - id: ID! - currency_code: String - amount: Float - min_quantity: Float - max_quantity: Float - rules_count: Int - price_rules: [PriceRule] - created_at: DateTime - updated_at: DateTime - deleted_at: DateTime -} - -type PriceRule { - id: ID! - price_set_id: String! - price_set: PriceSet - attribute: String! - value: String! - priority: Int! - price_id: String! - price_list_id: String! - created_at: DateTime - updated_at: DateTime - deleted_at: DateTime -} - -type CalculatedPriceSet { - id: ID! - is_calculated_price_price_list: Boolean - is_calculated_price_tax_inclusive: Boolean - calculated_amount: Float - raw_calculated_amount: JSON - is_original_price_price_list: Boolean - is_original_price_tax_inclusive: Boolean - original_amount: Float - raw_original_amount: JSON - currency_code: String - calculated_price: PriceDetails - original_price: PriceDetails -} - -type PriceDetails { - id: ID - price_list_id: String - price_list_type: String - min_quantity: Float - max_quantity: Float -} -` - -export default schema