diff --git a/packages/core/modules-sdk/src/medusa-app.ts b/packages/core/modules-sdk/src/medusa-app.ts index 5830f9244b..4a43943070 100644 --- a/packages/core/modules-sdk/src/medusa-app.ts +++ b/packages/core/modules-sdk/src/medusa-app.ts @@ -48,7 +48,7 @@ declare module "@medusajs/types" { [ContainerRegistrationKeys.CONFIG_MODULE]: ConfigModule [ContainerRegistrationKeys.PG_CONNECTION]: Knex [ContainerRegistrationKeys.REMOTE_QUERY]: RemoteQueryFunction - [ContainerRegistrationKeys.QUERY]: RemoteQueryFunction + [ContainerRegistrationKeys.QUERY]: Omit [ContainerRegistrationKeys.LOGGER]: Logger } } @@ -520,7 +520,7 @@ async function MedusaApp_({ onApplicationStart, modules: allModules, link: remoteLink, - query: createQuery(remoteQuery), + query: createQuery(remoteQuery) as any, // TODO: rm any once we remove the old RemoteQueryFunction and rely on the Query object instead, entitiesMap: schema.getTypeMap(), gqlSchema: schema, notFound, diff --git a/packages/core/modules-sdk/src/remote-query/__fixtures__/remote-query-type.ts b/packages/core/modules-sdk/src/remote-query/__fixtures__/remote-query-type.ts new file mode 100644 index 0000000000..86b9248367 --- /dev/null +++ b/packages/core/modules-sdk/src/remote-query/__fixtures__/remote-query-type.ts @@ -0,0 +1,454 @@ +export type Maybe = T | null +export type InputMaybe = Maybe +export type Exact = { + [K in keyof T]: T[K] +} +export type MakeOptional = Omit & { + [SubKey in K]?: Maybe +} +export type MakeMaybe = Omit & { + [SubKey in K]: Maybe +} +export type MakeEmpty< + T extends { [key: string]: unknown }, + K extends keyof T +> = { [_ in K]?: never } +export type Incremental = + | T + | { + [P in keyof T]?: P extends " $fragmentName" | "__typename" ? T[P] : never + } + +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string } + String: { input: string; output: string } + Boolean: { input: boolean; output: boolean } + Int: { input: number; output: number } + Float: { input: number; output: number } + DateTime: { input: Date | string; output: Date | string } + JSON: { input: Record; output: Record } +} + +export type SimpleProduct = { + id: Scalars["ID"]["output"] + handle: string + title?: Scalars["String"]["output"] + variants?: Maybe>>> + sales_channels_link?: Array< + Pick + > + sales_channels?: Array> +} + +export type Product = { + __typename?: "Product" + id: Scalars["ID"]["output"] + handle: Scalars["String"]["output"] + title: Scalars["String"]["output"] + description?: Scalars["String"]["output"] + variants?: Array + sales_channels_link?: Array + sales_channels?: Array + metadata?: Maybe +} + +export type ProductVariant = { + __typename?: "ProductVariant" + id: Scalars["ID"]["output"] + handle: Scalars["String"]["output"] + title: Scalars["String"]["output"] + sku: Scalars["String"]["output"] + product?: Maybe +} + +export type ProductCategory = { + __typename?: "ProductCategory" + id: Scalars["ID"]["output"] + handle: Scalars["String"]["output"] + title?: Maybe +} + +export type SalesChannel = { + __typename?: "SalesChannel" + id: Scalars["ID"]["output"] + name?: Maybe + description?: Maybe + created_at?: Maybe + updated_at?: Maybe + products_link?: Maybe>> + api_keys_link?: Maybe>> + locations_link?: Maybe>> +} + +export type LinkCartPaymentCollection = { + __typename?: "LinkCartPaymentCollection" + cart_id: Scalars["String"]["output"] + payment_collection_id: Scalars["String"]["output"] + createdAt: Scalars["String"]["output"] + updatedAt: Scalars["String"]["output"] + deletedAt?: Maybe +} + +export type LinkCartPromotion = { + __typename?: "LinkCartPromotion" + cart_id: Scalars["String"]["output"] + promotion_id: Scalars["String"]["output"] + createdAt: Scalars["String"]["output"] + updatedAt: Scalars["String"]["output"] + deletedAt?: Maybe +} + +export type LinkLocationFulfillmentProvider = { + __typename?: "LinkLocationFulfillmentProvider" + stock_location_id: Scalars["String"]["output"] + fulfillment_provider_id: Scalars["String"]["output"] + createdAt: Scalars["String"]["output"] + updatedAt: Scalars["String"]["output"] + deletedAt?: Maybe +} + +export type LinkLocationFulfillmentSet = { + __typename?: "LinkLocationFulfillmentSet" + stock_location_id: Scalars["String"]["output"] + fulfillment_set_id: Scalars["String"]["output"] + createdAt: Scalars["String"]["output"] + updatedAt: Scalars["String"]["output"] + deletedAt?: Maybe +} + +export type LinkOrderCart = { + __typename?: "LinkOrderCart" + order_id: Scalars["String"]["output"] + cart_id: Scalars["String"]["output"] + createdAt: Scalars["String"]["output"] + updatedAt: Scalars["String"]["output"] + deletedAt?: Maybe +} + +export type LinkOrderFulfillment = { + __typename?: "LinkOrderFulfillment" + order_id: Scalars["String"]["output"] + fulfillment_id: Scalars["String"]["output"] + createdAt: Scalars["String"]["output"] + updatedAt: Scalars["String"]["output"] + deletedAt?: Maybe +} + +export type LinkOrderPaymentCollection = { + __typename?: "LinkOrderPaymentCollection" + order_id: Scalars["String"]["output"] + payment_collection_id: Scalars["String"]["output"] + createdAt: Scalars["String"]["output"] + updatedAt: Scalars["String"]["output"] + deletedAt?: Maybe +} + +export type LinkOrderPromotion = { + __typename?: "LinkOrderPromotion" + order_id: Scalars["String"]["output"] + promotion_id: Scalars["String"]["output"] + createdAt: Scalars["String"]["output"] + updatedAt: Scalars["String"]["output"] + deletedAt?: Maybe +} + +export type LinkReturnFulfillment = { + __typename?: "LinkReturnFulfillment" + return_id: Scalars["String"]["output"] + fulfillment_id: Scalars["String"]["output"] + createdAt: Scalars["String"]["output"] + updatedAt: Scalars["String"]["output"] + deletedAt?: Maybe +} + +export type LinkProductSalesChannel = { + __typename?: "LinkProductSalesChannel" + product_id: Scalars["String"]["output"] + sales_channel_id: Scalars["String"]["output"] + product?: Maybe + sales_channel?: Maybe + createdAt: Scalars["String"]["output"] + updatedAt: Scalars["String"]["output"] + deletedAt?: Maybe +} + +export type LinkProductVariantInventoryItem = { + __typename?: "LinkProductVariantInventoryItem" + variant_id: Scalars["String"]["output"] + inventory_item_id: Scalars["String"]["output"] + required_quantity: Scalars["Int"]["output"] + variant?: Maybe + createdAt: Scalars["String"]["output"] + updatedAt: Scalars["String"]["output"] + deletedAt?: Maybe +} + +export type LinkProductVariantPriceSet = { + __typename?: "LinkProductVariantPriceSet" + variant_id: Scalars["String"]["output"] + price_set_id: Scalars["String"]["output"] + variant?: Maybe + createdAt: Scalars["String"]["output"] + updatedAt: Scalars["String"]["output"] + deletedAt?: Maybe +} + +export type LinkPublishableApiKeySalesChannel = { + __typename?: "LinkPublishableApiKeySalesChannel" + publishable_key_id: Scalars["String"]["output"] + sales_channel_id: Scalars["String"]["output"] + sales_channel?: Maybe + createdAt: Scalars["String"]["output"] + updatedAt: Scalars["String"]["output"] + deletedAt?: Maybe +} + +export type LinkRegionPaymentProvider = { + __typename?: "LinkRegionPaymentProvider" + region_id: Scalars["String"]["output"] + payment_provider_id: Scalars["String"]["output"] + createdAt: Scalars["String"]["output"] + updatedAt: Scalars["String"]["output"] + deletedAt?: Maybe +} + +export type LinkSalesChannelStockLocation = { + __typename?: "LinkSalesChannelStockLocation" + sales_channel_id: Scalars["String"]["output"] + stock_location_id: Scalars["String"]["output"] + sales_channel?: Maybe + createdAt: Scalars["String"]["output"] + updatedAt: Scalars["String"]["output"] + deletedAt?: Maybe +} + +export type LinkShippingOptionPriceSet = { + __typename?: "LinkShippingOptionPriceSet" + shipping_option_id: Scalars["String"]["output"] + price_set_id: Scalars["String"]["output"] + createdAt: Scalars["String"]["output"] + updatedAt: Scalars["String"]["output"] + deletedAt?: Maybe +} + +export interface FixtureEntryPoints { + file: any + files: any + workflow_execution: any + workflow_executions: any + inventory_items: any + inventory_item: any + inventory: any + reservation: any + reservations: any + reservation_item: any + reservation_items: any + inventory_level: any + inventory_levels: any + stock_location_address: any + stock_location_addresses: any + stock_location: any + stock_locations: any + price_set: any + price_sets: any + price_list: any + price_lists: any + price: any + prices: any + price_preference: any + price_preferences: any + product_variant: ProductVariant + product_variants: ProductVariant + variant: ProductVariant + variants: ProductVariant + product: Product + products: Product + simple_product: SimpleProduct + product_option: any + product_options: any + product_type: any + product_types: any + product_image: any + product_images: any + product_tag: any + product_tags: any + product_collection: any + product_collections: any + product_category: ProductCategory + product_categories: ProductCategory + sales_channel: SalesChannel + sales_channels: SalesChannel + customer_address: any + customer_addresses: any + customer_group_customer: any + customer_group_customers: any + customer_group: any + customer_groups: any + customer: any + customers: any + cart: any + carts: any + address: any + addresses: any + line_item: any + line_items: any + line_item_adjustment: any + line_item_adjustments: any + line_item_tax_line: any + line_item_tax_lines: any + shipping_method: any + shipping_methods: any + shipping_method_adjustment: any + shipping_method_adjustments: any + shipping_method_tax_line: any + shipping_method_tax_lines: any + promotion: any + promotions: any + campaign: any + campaigns: any + promotion_rule: any + promotion_rules: any + api_key: any + api_keys: any + tax_rate: any + tax_rates: any + tax_region: any + tax_regions: any + tax_rate_rule: any + tax_rate_rules: any + tax_provider: any + tax_providers: any + store: any + stores: any + store_currency: any + store_currencies: any + user: any + users: any + invite: any + invites: any + auth_identity: any + auth_identities: any + order: any + orders: any + order_address: any + order_addresses: any + order_line_item: any + order_line_items: any + order_line_item_adjustment: any + order_line_item_adjustments: any + order_line_item_tax_line: any + order_line_item_tax_lines: any + order_shipping_method: any + order_shipping_methods: any + order_shipping_method_adjustment: any + order_shipping_method_adjustments: any + order_shipping_method_tax_line: any + order_shipping_method_tax_lines: any + order_transaction: any + order_transactions: any + order_change: any + order_changes: any + order_change_action: any + order_change_actions: any + order_item: any + order_items: any + order_summary: any + order_summaries: any + order_shipping: any + order_shippings: any + return_reason: any + return_reasons: any + return: any + returns: any + return_item: any + return_items: any + order_claim: any + order_claims: any + order_claim_item: any + order_claim_items: any + order_claim_item_image: any + order_claim_item_images: any + order_exchange: any + order_exchanges: any + order_exchange_item: any + order_exchange_items: any + payment: any + payments: any + payment_collection: any + payment_collections: any + payment_provider: any + payment_providers: any + payment_session: any + payment_sessions: any + refund_reason: any + refund_reasons: any + fulfillment_address: any + fulfillment_addresses: any + fulfillment_item: any + fulfillment_items: any + fulfillment_label: any + fulfillment_labels: any + fulfillment_provider: any + fulfillment_providers: any + fulfillment_set: any + fulfillment_sets: any + fulfillment: any + fulfillments: any + geo_zone: any + geo_zones: any + service_zone: any + service_zones: any + shipping_option_rule: any + shipping_option_rules: any + shipping_option_type: any + shipping_option_types: any + shipping_option: any + shipping_options: any + shipping_profile: any + shipping_profiles: any + notification: any + notifications: any + region: any + regions: any + country: any + countries: any + currency: any + currencies: any + cart_payment_collection: LinkCartPaymentCollection + cart_payment_collections: LinkCartPaymentCollection + cart_promotion: LinkCartPromotion + cart_promotions: LinkCartPromotion + location_fulfillment_provider: LinkLocationFulfillmentProvider + location_fulfillment_providers: LinkLocationFulfillmentProvider + location_fulfillment_set: LinkLocationFulfillmentSet + location_fulfillment_sets: LinkLocationFulfillmentSet + order_cart: LinkOrderCart + order_carts: LinkOrderCart + order_fulfillment: LinkOrderFulfillment + order_fulfillments: LinkOrderFulfillment + order_payment_collection: LinkOrderPaymentCollection + order_payment_collections: LinkOrderPaymentCollection + order_promotion: LinkOrderPromotion + order_promotions: LinkOrderPromotion + return_fulfillment: LinkReturnFulfillment + return_fulfillments: LinkReturnFulfillment + product_sales_channel: LinkProductSalesChannel + product_sales_channels: LinkProductSalesChannel + product_variant_inventory_item: LinkProductVariantInventoryItem + product_variant_inventory_items: LinkProductVariantInventoryItem + product_variant_price_set: LinkProductVariantPriceSet + product_variant_price_sets: LinkProductVariantPriceSet + publishable_api_key_sales_channel: LinkPublishableApiKeySalesChannel + publishable_api_key_sales_channels: LinkPublishableApiKeySalesChannel + region_payment_provider: LinkRegionPaymentProvider + region_payment_providers: LinkRegionPaymentProvider + sales_channel_location: LinkSalesChannelStockLocation + sales_channel_locations: LinkSalesChannelStockLocation + shipping_option_price_set: LinkShippingOptionPriceSet + shipping_option_price_sets: LinkShippingOptionPriceSet +} + +declare module "@medusajs/types" { + export interface RemoteQueryEntryPoints extends FixtureEntryPoints {} +} diff --git a/packages/core/modules-sdk/src/remote-query/__tests__/to-remote-query.ts b/packages/core/modules-sdk/src/remote-query/__tests__/to-remote-query.ts new file mode 100644 index 0000000000..5f0ccd30e1 --- /dev/null +++ b/packages/core/modules-sdk/src/remote-query/__tests__/to-remote-query.ts @@ -0,0 +1,218 @@ +import { QueryContext, QueryFilter } from "@medusajs/utils" +import "../__fixtures__/remote-query-type" +import { toRemoteQuery } from "../to-remote-query" + +describe("toRemoteQuery", () => { + it("should transform a query with top level filtering", () => { + const format = toRemoteQuery({ + entity: "product", + fields: ["id", "handle", "description"], + filters: QueryFilter<"product">({ + handle: { + $ilike: "abc%", + }, + }), + }) + + expect(format).toEqual({ + product: { + __fields: ["id", "handle", "description"], + __args: { + filters: { + handle: { + $ilike: "abc%", + }, + }, + }, + }, + }) + }) + + it("should transform a query with pagination", () => { + const format = toRemoteQuery({ + entity: "product", + fields: ["id", "handle", "description"], + pagination: { + skip: 5, + take: 10, + }, + }) + + expect(format).toEqual({ + product: { + __fields: ["id", "handle", "description"], + __args: { + skip: 5, + take: 10, + }, + }, + }) + }) + + it("should transform a query with top level filtering and pagination", () => { + const format = toRemoteQuery({ + entity: "product", + fields: ["id", "handle", "description"], + pagination: { + skip: 5, + take: 10, + }, + filters: QueryFilter<"product">({ + handle: { + $ilike: "abc%", + }, + }), + }) + + expect(format).toEqual({ + product: { + __fields: ["id", "handle", "description"], + __args: { + skip: 5, + take: 10, + filters: { + handle: { + $ilike: "abc%", + }, + }, + }, + }, + }) + }) + + it("should transform a query with filters and context into remote query input [1]", () => { + const format = toRemoteQuery({ + entity: "product", + fields: [ + "id", + "description", + "variants.title", + "variants.calculated_price", + "variants.options.*", + ], + filters: { + variants: QueryFilter<"variants">({ + sku: { + $ilike: "abc%", + }, + }), + }, + context: { + variants: { + calculated_price: QueryContext({ + region_id: "reg_123", + currency_code: "usd", + }), + }, + }, + }) + + expect(format).toEqual({ + product: { + __fields: ["id", "description"], + variants: { + __args: { + filters: { + sku: { + $ilike: "abc%", + }, + }, + }, + calculated_price: { + __args: { + context: { + region_id: "reg_123", + currency_code: "usd", + }, + }, + }, + __fields: ["title", "calculated_price"], + options: { + __fields: ["*"], + }, + }, + }, + }) + }) + + it("should transform a query with filters and context into remote query input [2]", () => { + const langContext = QueryContext({ + context: { + lang: "pt-br", + }, + }) + + const format = toRemoteQuery({ + entity: "product", + fields: [ + "id", + "title", + "description", + "product_translation.*", + "categories.*", + "categories.category_translation.*", + "variants.*", + "variants.variant_translation.*", + ], + filters: QueryFilter<"product">({ + id: "prod_01J742X0QPFW3R2ZFRTRC34FS8", + }), + context: { + product_translation: langContext, + categories: { + category_translation: langContext, + }, + variants: { + variant_translation: langContext, + }, + }, + }) + + expect(format).toEqual({ + product: { + __fields: ["id", "title", "description"], + __args: { + filters: { + id: "prod_01J742X0QPFW3R2ZFRTRC34FS8", + }, + }, + product_translation: { + __args: { + context: { + context: { + lang: "pt-br", + }, + }, + }, + __fields: ["*"], + }, + categories: { + category_translation: { + __args: { + context: { + context: { + lang: "pt-br", + }, + }, + }, + __fields: ["*"], + }, + __fields: ["*"], + }, + variants: { + variant_translation: { + __args: { + context: { + context: { + lang: "pt-br", + }, + }, + }, + __fields: ["*"], + }, + __fields: ["*"], + }, + }, + }) + }) +}) diff --git a/packages/core/modules-sdk/src/remote-query/query.ts b/packages/core/modules-sdk/src/remote-query/query.ts index eb9793c640..925643ebfd 100644 --- a/packages/core/modules-sdk/src/remote-query/query.ts +++ b/packages/core/modules-sdk/src/remote-query/query.ts @@ -1,10 +1,10 @@ -import { RemoteQuery } from "./remote-query" import { GraphResultSet, RemoteJoinerOptions, RemoteJoinerQuery, RemoteQueryFunction, RemoteQueryFunctionReturnPagination, + RemoteQueryInput, RemoteQueryObjectConfig, RemoteQueryObjectFromStringResult, } from "@medusajs/types" @@ -13,6 +13,8 @@ import { MedusaError, remoteQueryObjectFromString, } from "@medusajs/utils" +import { RemoteQuery } from "./remote-query" +import { toRemoteQuery } from "./to-remote-query" /** * API wrapper around the remoteQuery @@ -25,7 +27,7 @@ export class Query { */ static traceGraphQuery?: ( queryFn: () => Promise, - queryOptions: RemoteQueryObjectConfig + queryOptions: RemoteQueryInput ) => Promise /** @@ -58,14 +60,16 @@ export class Query { #unwrapQueryConfig( config: - | RemoteQueryObjectConfig | RemoteQueryObjectFromStringResult + | RemoteQueryObjectConfig | RemoteJoinerQuery ): object { let normalizedQuery: any = config if ("__value" in config) { normalizedQuery = config.__value + } else if ("entity" in normalizedQuery) { + normalizedQuery = toRemoteQuery(normalizedQuery) } else if ( "entryPoint" in normalizedQuery || "service" in normalizedQuery @@ -95,6 +99,7 @@ export class Query { async query( queryOptions: + | RemoteQueryInput | RemoteQueryObjectConfig | RemoteQueryObjectFromStringResult | RemoteJoinerQuery, @@ -110,7 +115,7 @@ export class Query { const config = this.#unwrapQueryConfig(queryOptions) if (Query.traceRemoteQuery) { return await Query.traceRemoteQuery( - async () => this.#remoteQuery.query(config, undefined, options), + async () => await this.#remoteQuery.query(config, undefined, options), queryOptions ) } @@ -133,10 +138,10 @@ export class Query { * returns a result set */ async graph( - queryOptions: RemoteQueryObjectConfig, + queryOptions: RemoteQueryInput, options?: RemoteJoinerOptions ): Promise> { - const normalizedQuery = remoteQueryObjectFromString(queryOptions).__value + const normalizedQuery = toRemoteQuery(queryOptions) let response: | any[] | { rows: any[]; metadata: RemoteQueryFunctionReturnPagination } @@ -148,8 +153,8 @@ export class Query { if (Query.traceGraphQuery) { response = await Query.traceGraphQuery( async () => - this.#remoteQuery.query(normalizedQuery, undefined, options), - queryOptions + await this.#remoteQuery.query(normalizedQuery, undefined, options), + queryOptions as RemoteQueryInput ) } else { response = await this.#remoteQuery.query( @@ -167,7 +172,7 @@ export class Query { * API wrapper around the remoteQuery with backward compatibility support * @param remoteQuery */ -export function createQuery(remoteQuery: RemoteQuery): RemoteQueryFunction { +export function createQuery(remoteQuery: RemoteQuery) { const query = new Query(remoteQuery) function backwardCompatibleQuery(...args: any[]) { @@ -177,5 +182,5 @@ export function createQuery(remoteQuery: RemoteQuery): RemoteQueryFunction { backwardCompatibleQuery.graph = query.graph.bind(query) backwardCompatibleQuery.gql = query.gql.bind(query) - return backwardCompatibleQuery + return backwardCompatibleQuery as Omit } diff --git a/packages/core/modules-sdk/src/remote-query/to-remote-query.ts b/packages/core/modules-sdk/src/remote-query/to-remote-query.ts new file mode 100644 index 0000000000..2bf79b0a6e --- /dev/null +++ b/packages/core/modules-sdk/src/remote-query/to-remote-query.ts @@ -0,0 +1,120 @@ +import { + RemoteQueryEntryPoints, + RemoteQueryFilters, + RemoteQueryGraph, + RemoteQueryObjectConfig, +} from "@medusajs/types" +import { QueryContext, QueryFilter, isObject } from "@medusajs/utils" + +const FIELDS = "__fields" +const ARGUMENTS = "__args" + +/** + * convert a specific API configuration to a remote query object + * + * @param config + * + * @example + * const remoteQueryObject = toRemoteQuery({ + * entity: "product", + * fields, + * filters: { variants: QueryFilter({ sku: "abc" }) }, + * context: { + * variants: { calculated_price: QueryContext({ region_id: "reg_123" }) } + * } + * }); + * + * console.log(remoteQueryObject); + */ + +export function toRemoteQuery(config: { + entity: TEntity | keyof RemoteQueryEntryPoints + fields: RemoteQueryObjectConfig["fields"] + filters?: RemoteQueryFilters + pagination?: { + skip?: number + take?: number + } + context?: Record +}): RemoteQueryGraph { + const { entity, fields = [], filters = {}, context = {} } = config + + const joinerQuery: Record = { + [entity]: { + __fields: [], + }, + } + + function processNestedObjects( + target: any, + source: Record, + topLevel = true + ) { + for (const key in source) { + const src = topLevel ? source : source[key] + + if (!isObject(src)) { + target[key] = src + continue + } + + if (QueryContext.isQueryContext(src) || QueryFilter.isQueryFilter(src)) { + const normalizedFilters = { ...src } as any + delete normalizedFilters.__type + + const prop = QueryFilter.isQueryFilter(src) ? "filters" : "context" + + if (topLevel) { + target[ARGUMENTS] ??= {} + target[ARGUMENTS][prop] = normalizedFilters + } else { + target[key] ??= {} + target[key][ARGUMENTS] ??= {} + target[key][ARGUMENTS][prop] = normalizedFilters + } + } else { + if (!topLevel) { + target[key] ??= {} + } + + const nextTarget = topLevel ? target : target[key] + processNestedObjects(nextTarget, src, false) + } + } + } + + // Process filters and context recursively + processNestedObjects(joinerQuery[entity], filters) + processNestedObjects(joinerQuery[entity], context) + + for (const field of fields) { + const fieldAsString = field as string + if (!fieldAsString.includes(".")) { + joinerQuery[entity][FIELDS].push(field) + continue + } + + const fieldSegments = fieldAsString.split(".") + const fieldProperty = fieldSegments.pop() + + let combinedPath = "" + const deepConfigRef = fieldSegments.reduce((acc, curr) => { + combinedPath = combinedPath ? combinedPath + "." + curr : curr + acc[curr] ??= {} + return acc[curr] + }, joinerQuery[entity]) + + deepConfigRef[FIELDS] ??= [] + deepConfigRef[FIELDS].push(fieldProperty) + } + + if (config.pagination) { + joinerQuery[entity][ARGUMENTS] ??= {} + joinerQuery[entity][ARGUMENTS] = { + ...joinerQuery[entity][ARGUMENTS], + ...config.pagination, + } + } + + return joinerQuery as RemoteQueryGraph +} diff --git a/packages/core/modules-sdk/src/utils/gql-schema-to-types.ts b/packages/core/modules-sdk/src/utils/gql-schema-to-types.ts index 85c938e37b..d1a464bb2e 100644 --- a/packages/core/modules-sdk/src/utils/gql-schema-to-types.ts +++ b/packages/core/modules-sdk/src/utils/gql-schema-to-types.ts @@ -78,8 +78,11 @@ export async function gqlSchemaToTypes({ documents: [], config: { scalars: { - DateTime: { output: "Date | string" }, - JSON: { output: "Record" }, + DateTime: { input: "Date | string", output: "Date | string" }, + JSON: { + input: "Record", + output: "Record", + }, }, }, filename: "", diff --git a/packages/core/orchestration/src/joiner/helpers.ts b/packages/core/orchestration/src/joiner/helpers.ts index bc612c0867..e779910c1d 100644 --- a/packages/core/orchestration/src/joiner/helpers.ts +++ b/packages/core/orchestration/src/joiner/helpers.ts @@ -17,7 +17,7 @@ export function toRemoteJoinerQuery( const canExpand = typeof value === "object" && - !["fields", "__args", "__directives"].includes(key) + !["fields", "__fields", "__args", "__directives"].includes(key) // TODO: deprecate "fields" if (!canExpand) { continue @@ -65,8 +65,8 @@ export function toRemoteJoinerQuery( ) } - if (value.fields) { - reference.fields = value.fields + if (value.__fields || value.fields) { + reference.fields = value.__fields ?? value.fields } if (isEntryPoint) { diff --git a/packages/core/types/src/modules-sdk/__fixtures__/remote-query.ts b/packages/core/types/src/modules-sdk/__fixtures__/remote-query.ts index a2991f6c10..5169ee35fb 100644 --- a/packages/core/types/src/modules-sdk/__fixtures__/remote-query.ts +++ b/packages/core/types/src/modules-sdk/__fixtures__/remote-query.ts @@ -26,25 +26,23 @@ export type Scalars = { Boolean: { input: boolean; output: boolean } Int: { input: number; output: number } Float: { input: number; output: number } - DateTime: { - input: { output: "Date | string" } - output: { output: "Date | string" } - } - JSON: { - input: { output: "Record" } - output: { output: "Record" } - } + DateTime: { input: Date | string; output: Date | string } + JSON: { input: Record; output: Record } } export type SimpleProduct = { + __typename?: "SimpleProduct" id: Scalars["ID"]["output"] handle: string title?: Scalars["String"]["output"] - variants?: Maybe>>> + variants?: Maybe>>> sales_channels_link?: Array< - Pick + Pick< + LinkProductSalesChannel, + "product_id" | "sales_channel_id" | "__typename" + > > - sales_channels?: Array> + sales_channels?: Array> } export type Product = { diff --git a/packages/core/types/src/modules-sdk/__tests__/object-to-remote-query-fields.spec.ts b/packages/core/types/src/modules-sdk/__tests__/object-to-remote-query-fields.spec.ts index 5f97bf7f46..3a47b11c1e 100644 --- a/packages/core/types/src/modules-sdk/__tests__/object-to-remote-query-fields.spec.ts +++ b/packages/core/types/src/modules-sdk/__tests__/object-to-remote-query-fields.spec.ts @@ -9,11 +9,13 @@ describe("ObjectToRemoteQueryFields", () => { description: string date: Date variants: { + __typename: string id: string sku: string title: string }[] sales_channel: { + __typename: string id: string name: string value: string @@ -51,12 +53,14 @@ describe("ObjectToRemoteQueryFields", () => { date: Date variants: Maybe< Maybe<{ + __typename: string id: string sku: string title: string }>[] > sales_channel?: Maybe<{ + __typename: string id: string name: string value: string diff --git a/packages/core/types/src/modules-sdk/__tests__/query.spec.ts b/packages/core/types/src/modules-sdk/__tests__/query.spec.ts index 611ef58bfb..da93f07926 100644 --- a/packages/core/types/src/modules-sdk/__tests__/query.spec.ts +++ b/packages/core/types/src/modules-sdk/__tests__/query.spec.ts @@ -10,7 +10,7 @@ describe("Query", () => { it("should infer return type of a known entry", async () => { const graph = (() => {}) as unknown as QueryGraphFunction const result = await graph({ - entryPoint: "product", + entity: "product", fields: ["handle", "id"], }) @@ -23,7 +23,7 @@ describe("Query", () => { it("should infer as any for an known entry", async () => { const graph = (() => {}) as unknown as QueryGraphFunction const result = await graph({ - entryPoint: "foo", + entity: "foo", fields: ["handle", "id"], }) diff --git a/packages/core/types/src/modules-sdk/index.ts b/packages/core/types/src/modules-sdk/index.ts index f3496b1c0e..2b0dc21792 100644 --- a/packages/core/types/src/modules-sdk/index.ts +++ b/packages/core/types/src/modules-sdk/index.ts @@ -4,11 +4,18 @@ import { MedusaContainer } from "../common" import { RepositoryService } from "../dal" import { Logger } from "../logger" import { + RemoteQueryGraph, + RemoteQueryInput, RemoteQueryObjectConfig, RemoteQueryObjectFromStringResult, } from "./remote-query-object-from-string" -export { RemoteQueryObjectConfig, RemoteQueryObjectFromStringResult } +export { + RemoteQueryGraph, + RemoteQueryInput, + RemoteQueryObjectConfig, + RemoteQueryObjectFromStringResult, +} export type Constructor = new (...args: any[]) => T | (new () => T) @@ -17,6 +24,8 @@ export * from "./medusa-internal-service" export * from "./module-provider" export * from "./remote-query" export * from "./remote-query-entry-points" +export * from "./to-remote-query" +export * from "./query-filter" export type LogLevel = | "query" diff --git a/packages/core/types/src/modules-sdk/object-to-remote-query-fields.ts b/packages/core/types/src/modules-sdk/object-to-remote-query-fields.ts index d0980d99fd..ccaba8c400 100644 --- a/packages/core/types/src/modules-sdk/object-to-remote-query-fields.ts +++ b/packages/core/types/src/modules-sdk/object-to-remote-query-fields.ts @@ -1,7 +1,6 @@ type Marker = [never, 0, 1, 2, 3, 4] -type ExcludedProps = ["__typename"] -type SpecialNonRelationProps = ["metadata"] +type ExcludedProps = "__typename" type RawBigNumberPrefix = "raw_" type ExpandStarSelector< @@ -26,12 +25,9 @@ export type ObjectToRemoteQueryFields< ? { [K in keyof T]: K extends // handle big number `${RawBigNumberPrefix}${string}` - ? Exclude - : // handle metadata - K extends SpecialNonRelationProps[number] ? Exclude : // Special props that should be excluded - K extends ExcludedProps[number] + K extends ExcludedProps ? never : // Prevent recursive reference to itself K extends Exclusion[number] @@ -39,21 +35,25 @@ export type ObjectToRemoteQueryFields< : TypeOnly extends Array ? TypeOnly extends Date ? Exclude - : TypeOnly extends object + : TypeOnly extends { __typename: any } ? `${Exclude}.${ExpandStarSelector< TypeOnly, Marker[Depth], [K & string, ...Exclusion] >}` + : TypeOnly extends object + ? Exclude : never : TypeOnly extends Date ? Exclude - : TypeOnly extends object + : TypeOnly extends { __typename: any } ? `${Exclude}.${ExpandStarSelector< TypeOnly, Marker[Depth], [K & string, ...Exclusion] >}` + : T[K] extends object + ? Exclude : Exclude }[keyof T] : never diff --git a/packages/core/types/src/modules-sdk/query-filter.ts b/packages/core/types/src/modules-sdk/query-filter.ts new file mode 100644 index 0000000000..0571b90798 --- /dev/null +++ b/packages/core/types/src/modules-sdk/query-filter.ts @@ -0,0 +1,9 @@ +import { RemoteQueryFilters } from "./to-remote-query" + +export type QueryFilterType = { + ( + query: RemoteQueryFilters + ): RemoteQueryFilters & { __type: "QueryFilter" } + + isQueryFilter: (obj: any) => boolean +} 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 4dcabe0e8d..392b334bcf 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,5 +1,6 @@ -import { RemoteQueryEntryPoints } from "./remote-query-entry-points" 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 @@ -20,3 +21,25 @@ export type RemoteQueryObjectFromStringResult< __TConfig: TConfig __value: object } + +export type RemoteQueryInput = { + // service: string This property is still supported under the hood but part of the type due to types missmatch towards fields + entity: TEntry | keyof RemoteQueryEntryPoints + fields: ObjectToRemoteQueryFields< + RemoteQueryEntryPoints[TEntry & keyof RemoteQueryEntryPoints] + > extends never + ? string[] + : ObjectToRemoteQueryFields< + RemoteQueryEntryPoints[TEntry & keyof RemoteQueryEntryPoints] + >[] + pagination?: { + skip: number + take?: number + } + filters?: RemoteQueryFilters + context?: any +} + +export type RemoteQueryGraph = { + __TConfig: RemoteQueryObjectConfig +} diff --git a/packages/core/types/src/modules-sdk/remote-query.ts b/packages/core/types/src/modules-sdk/remote-query.ts index 13baa4e1ac..e682c9dfa7 100644 --- a/packages/core/types/src/modules-sdk/remote-query.ts +++ b/packages/core/types/src/modules-sdk/remote-query.ts @@ -2,6 +2,7 @@ import { Prettify } from "../common" import { RemoteJoinerOptions, RemoteJoinerQuery } from "../joiner" import { RemoteQueryEntryPoints } from "./remote-query-entry-points" import { + RemoteQueryInput, RemoteQueryObjectConfig, RemoteQueryObjectFromStringResult, } from "./remote-query-object-from-string" @@ -32,7 +33,7 @@ export type GraphResultSet = { */ export type QueryGraphFunction = { ( - queryConfig: RemoteQueryObjectConfig, + queryConfig: RemoteQueryInput, options?: RemoteJoinerOptions ): Promise>> } diff --git a/packages/core/types/src/modules-sdk/to-remote-query.ts b/packages/core/types/src/modules-sdk/to-remote-query.ts new file mode 100644 index 0000000000..a73f7a9913 --- /dev/null +++ b/packages/core/types/src/modules-sdk/to-remote-query.ts @@ -0,0 +1,72 @@ +import { Prettify } from "../common" +import { OperatorMap } from "../dal" +import { RemoteQueryEntryPoints } from "./remote-query-entry-points" + +type ExcludedProps = "__typename" +type Depth = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] +type CleanupObject = Prettify, ExcludedProps>> +type OmitNever = { + [K in keyof T as T[K] extends never ? never : K]: T[K] +} +type TypeOnly = Required> + +type ExtractFiltersOperators< + MaybeT, + Lim extends number = Depth[2], + Exclusion extends string[] = [], + T = TypeOnly +> = { + [Key in keyof T]?: Key extends Exclusion[number] + ? never + : Key extends ExcludedProps + ? never + : T[Key] extends string | number | boolean | Date + ? T[Key] | OperatorMap + : T[Key] extends Array + ? TypeOnly extends { __typename: any } + ? RemoteQueryFilters< + Key & string, + T, + [Key & string, ...Exclusion], + Depth[Lim] + > + : R extends object + ? CleanupObject + : never + : T[Key] extends { __typename: any } + ? RemoteQueryFilters< + Key & string, + T[Key], + [Key & string, ...Exclusion], + Depth[Lim] + > + : T[Key] extends object + ? CleanupObject + : never +} + +/** + * Extract all available filters from a remote entry point deeply + */ +export type RemoteQueryFilters< + TEntry extends string, + RemoteQueryEntryPointsLevel = RemoteQueryEntryPoints, + Exclusion extends string[] = [], + Lim extends number = Depth[3] +> = Lim extends number + ? TEntry extends keyof RemoteQueryEntryPointsLevel + ? TypeOnly extends Array + ? Prettify< + OmitNever> + > + : Prettify< + OmitNever< + ExtractFiltersOperators< + RemoteQueryEntryPointsLevel[TEntry], + Lim, + [TEntry, ...Exclusion] + > + > + > + : Record + : never diff --git a/packages/core/utils/src/modules-sdk/_remote-joiner.ts b/packages/core/utils/src/modules-sdk/_remote-joiner.ts new file mode 100644 index 0000000000..7952e4fd97 --- /dev/null +++ b/packages/core/utils/src/modules-sdk/_remote-joiner.ts @@ -0,0 +1,1157 @@ +/* +import { + InternalJoinerServiceConfig, + JoinerRelationship, + JoinerServiceConfigAlias, + ModuleJoinerConfig, + RemoteExpandProperty, + RemoteJoinerQuery, + RemoteNestedExpands, +} from "@medusajs/types" + +import { RemoteJoinerOptions } from "@medusajs/types" +import { MedusaError, deduplicate, isDefined, isString } from "@medusajs/utils" +import GraphQLParser from "./graphql-ast" + +const BASE_PATH = "_root" + +export type RemoteFetchDataCallback = ( + expand: RemoteExpandProperty, + keyField: string, + ids?: (unknown | unknown[])[], + relationship?: any +) => Promise<{ + data: unknown[] | { [path: string]: unknown } + path?: string +}> + +type InternalImplodeMapping = { + location: string[] + property: string + path: string[] + isList?: boolean +} + +export class RemoteJoiner { + private serviceConfigCache: Map = + new Map() + + private static filterFields( + data: any, + fields?: string[], + expands?: RemoteNestedExpands + ): Record | undefined { + if (!fields || !data) { + return data + } + + let filteredData: Record = {} + + if (fields.includes("*")) { + // select all fields + filteredData = data + } else { + filteredData = fields.reduce((acc: any, field: string) => { + const fieldValue = data?.[field] + + if (isDefined(fieldValue)) { + acc[field] = data?.[field] + } + + return acc + }, {}) + } + + if (expands) { + for (const key of Object.keys(expands ?? {})) { + const expand = expands[key] + if (expand) { + if (Array.isArray(data[key])) { + filteredData[key] = data[key].map((item: any) => + RemoteJoiner.filterFields(item, expand.fields, expand.expands) + ) + } else { + const filteredFields = RemoteJoiner.filterFields( + data[key], + expand.fields, + expand.expands + ) + + if (isDefined(filteredFields)) { + filteredData[key] = RemoteJoiner.filterFields( + data[key], + expand.fields, + expand.expands + ) + } + } + } + } + } + + return (Object.keys(filteredData).length && filteredData) || undefined + } + + private static getNestedItems(items: any[], property: string): any[] { + const result: unknown[] = [] + for (const item of items) { + const allValues = item?.[property] ?? [] + const values = Array.isArray(allValues) ? allValues : [allValues] + for (const value of values) { + if (isDefined(value)) { + result.push(value) + } + } + } + + return result + } + + private static createRelatedDataMap( + relatedDataArray: any[], + joinFields: string[] + ): Map { + return relatedDataArray.reduce((acc, data) => { + const joinValues = joinFields.map((field) => data[field]) + const key = joinValues.length === 1 ? joinValues[0] : joinValues.join(",") + + let isArray = Array.isArray(acc[key]) + if (isDefined(acc[key]) && !isArray) { + acc[key] = [acc[key]] + isArray = true + } + + if (isArray) { + acc[key].push(data) + } else { + acc[key] = data + } + return acc + }, {}) + } + + static parseQuery( + graphqlQuery: string, + variables?: Record + ): RemoteJoinerQuery { + const parser = new GraphQLParser(graphqlQuery, variables) + return parser.parseQuery() + } + + constructor( + private serviceConfigs: ModuleJoinerConfig[], + private remoteFetchData: RemoteFetchDataCallback, + private options: { + autoCreateServiceNameAlias?: boolean + } = {} + ) { + this.options.autoCreateServiceNameAlias ??= true + + this.serviceConfigs = this.buildReferences( + JSON.parse(JSON.stringify(serviceConfigs)) + ) + } + + public setFetchDataCallback(remoteFetchData: RemoteFetchDataCallback): void { + this.remoteFetchData = remoteFetchData + } + + private getRelationshipKey(alias: string, entity?: string): string { + if (entity) { + return `${entity}.${alias}` + } + + return `${alias}` + } + + private getRelationship( + relationships: Map, + alias: string, + entity?: string + ): JoinerRelationship | undefined { + let relKey = this.getRelationshipKey(alias, entity) + + let rel = relationships.get(relKey) + if (!rel) { + relKey = this.getRelationshipKey(alias) + rel = relationships.get(relKey) + } + return rel + } + + private buildReferences(serviceConfigs: ModuleJoinerConfig[]) { + const expandedRelationships: Map< + string, + { fieldAlias; relationships: Map } + > = new Map() + + for (const service of serviceConfigs) { + const service_ = service as Omit & { + relationships?: Map + } + + if (this.serviceConfigCache.has(service_.serviceName!)) { + throw new Error(`Service "${service_.serviceName}" is already defined.`) + } + + service_.fieldAlias ??= {} + service_.extends ??= [] + service_.relationships ??= new Map() + + if (Array.isArray(service_.relationships)) { + const relationships = new Map() + for (const relationship of service_.relationships) { + const relKey = this.getRelationshipKey( + relationship.alias, + relationship.entity + ) + relationships.set(relKey, relationship) + relationships.set(relationship.alias, relationship) + } + service_.relationships = relationships + } + + // add aliases + const isReadOnlyDefinition = + !isDefined(service_.serviceName) || service_.isReadOnlyLink + if (!isReadOnlyDefinition) { + service_.alias ??= [] + + if (!Array.isArray(service_.alias)) { + service_.alias = [service_.alias] + } + + if (this.options.autoCreateServiceNameAlias) { + service_.alias.push({ name: service_.serviceName! }) + } + + // handle alias.name as array + for (let idx = 0; idx < service_.alias.length; idx++) { + const alias = service_.alias[idx] + if (!Array.isArray(alias.name)) { + continue + } + + for (const name of alias.name) { + service_.alias.push({ + name, + entity: alias.entity, + args: alias.args, + }) + } + service_.alias.splice(idx, 1) + idx-- + } + + // self-reference + for (const alias of service_.alias) { + if (this.serviceConfigCache.has(`alias_${alias.name}`)) { + const defined = this.serviceConfigCache.get(`alias_${alias.name}`) + + if (service_.serviceName === defined?.serviceName) { + continue + } + + throw new Error( + `Cannot add alias "${alias.name}" for "${service_.serviceName}". It is already defined for Service "${defined?.serviceName}".` + ) + } + + const args = + service_.args || alias.args + ? { ...service_.args, ...alias.args } + : undefined + + const relKey = this.getRelationshipKey( + alias.name as string, + alias.entity + ) + const relation = { + alias: alias.name as string, + entity: alias.entity, + foreignKey: alias.name + "_id", + primaryKey: "id", + serviceName: service_.serviceName!, + args, + } + service_.relationships?.set(relKey, relation) + service_.relationships?.set(alias.name as string, relation) + this.cacheServiceConfig(serviceConfigs, undefined, alias) + } + + this.cacheServiceConfig(serviceConfigs, service_.serviceName) + } + + for (const extend of service_.extends) { + if (!expandedRelationships.has(extend.serviceName)) { + expandedRelationships.set(extend.serviceName, { + fieldAlias: {}, + relationships: new Map(), + }) + } + + const service_ = expandedRelationships.get(extend.serviceName)! + + const relKey = this.getRelationshipKey( + extend.relationship.alias, + extend.relationship.entity + ) + + service_.relationships.set(relKey, extend.relationship) + service_.relationships.set( + extend.relationship.alias, + extend.relationship + ) + Object.assign(service_.fieldAlias ?? {}, extend.fieldAlias) + } + } + + for (const [ + serviceName, + { fieldAlias, relationships }, + ] of expandedRelationships) { + if (!this.serviceConfigCache.has(serviceName)) { + throw new Error(`Service "${serviceName}" was not found`) + } + + const service_ = this.serviceConfigCache.get(serviceName)! + relationships.forEach((relationship, alias) => { + const relKey = this.getRelationshipKey(alias, relationship.entity) + service_.relationships!.set(relKey, relationship) + service_.relationships!.set(alias, relationship) + }) + Object.assign(service_.fieldAlias!, fieldAlias ?? {}) + + if (Object.keys(service_.fieldAlias!).length) { + const conflictAliases = Array.from( + service_.relationships!.keys() + ).filter((alias) => fieldAlias[alias]) + + if (conflictAliases.length) { + throw new Error( + `Conflict configuration for service "${serviceName}". The following aliases are already defined as relationships: ${conflictAliases.join( + ", " + )}` + ) + } + } + } + + return serviceConfigs + } + + private getServiceConfig( + serviceName?: string, + entity?: string, + serviceAlias?: string + ): InternalJoinerServiceConfig | undefined { + if (entity) { + const name = `entity_${serviceName}_${entity}` + const entityRef = this.serviceConfigCache.get(name) + if (entityRef) { + return entityRef + } + } + + if (serviceAlias) { + const name = `alias_${serviceAlias}` + return this.serviceConfigCache.get(name) + } + + return this.serviceConfigCache.get(serviceName!) + } + + private cacheServiceConfig( + serviceConfigs, + serviceName?: string, + serviceAlias?: JoinerServiceConfigAlias + ): void { + if (serviceAlias) { + const name = `alias_${serviceAlias.name}` + if (!this.serviceConfigCache.has(name)) { + let aliasConfig: JoinerServiceConfigAlias | undefined + const config = serviceConfigs.find((conf) => { + const aliases = conf.alias as JoinerServiceConfigAlias[] + const hasArgs = aliases?.find( + (alias) => alias.name === serviceAlias.name + ) + aliasConfig = hasArgs + return hasArgs + }) + + if (config) { + const serviceConfig = { + ...config, + entity: serviceAlias.entity, + } + + if (aliasConfig) { + serviceConfig.args = { ...config?.args, ...aliasConfig?.args } + } + this.serviceConfigCache.set(name, serviceConfig) + + if (serviceAlias.entity) { + const entityName = `entity_${serviceName}_${serviceAlias.entity}` + this.serviceConfigCache.set(entityName, serviceConfig) + } + } + } + return + } + + const config = serviceConfigs.find( + (config) => config.serviceName === serviceName + ) + this.serviceConfigCache.set(serviceName!, config) + } + + private async fetchData( + expand: RemoteExpandProperty, + pkField: string, + ids?: (unknown | unknown[])[], + relationship?: any, + options?: RemoteJoinerOptions + ): Promise<{ + data: unknown[] | { [path: string]: unknown } + path?: string + }> { + let uniqueIds = Array.isArray(ids) ? ids : ids ? [ids] : undefined + + if (uniqueIds) { + const isCompositeKey = Array.isArray(uniqueIds[0]) + if (isCompositeKey) { + const seen = new Set() + uniqueIds = uniqueIds.filter((idArray) => { + const key = JSON.stringify(idArray) + const isNew = !seen.has(key) + seen.add(key) + return isNew + }) + } else { + uniqueIds = Array.from(new Set(uniqueIds.flat())) + } + + uniqueIds = uniqueIds.filter((id) => isDefined(id)) + } + + if (relationship) { + pkField = relationship.inverse + ? relationship.foreignKey.split(".").pop()! + : relationship.primaryKey + } + + const response = await this.remoteFetchData( + expand, + pkField, + uniqueIds, + relationship + ) + + const isObj = isDefined(response.path) + let resData = isObj ? response.data[response.path!] : response.data + + resData = isDefined(resData) + ? Array.isArray(resData) + ? resData + : [resData] + : [] + + this.checkIfKeysExist( + uniqueIds, + resData, + expand, + pkField, + relationship, + options + ) + + const filteredDataArray = resData.map((data: any) => + RemoteJoiner.filterFields(data, expand.fields, expand.expands) + ) + + if (isObj) { + response.data[response.path!] = filteredDataArray + } else { + response.data = filteredDataArray + } + + return response + } + + private checkIfKeysExist( + uniqueIds: unknown[] | undefined, + resData: any[], + expand: RemoteExpandProperty, + pkField: string, + relationship?: any, + options?: RemoteJoinerOptions + ) { + if ( + !( + isDefined(uniqueIds) && + ((options?.throwIfKeyNotFound && !isDefined(relationship)) || + (options?.throwIfRelationNotFound && isDefined(relationship))) + ) + ) { + return + } + + if (isDefined(relationship)) { + if ( + Array.isArray(options?.throwIfRelationNotFound) && + !options?.throwIfRelationNotFound.includes(relationship.serviceName) + ) { + return + } + } + + const notFound = new Set(uniqueIds) + resData.forEach((data) => { + notFound.delete(data[pkField]) + }) + + if (notFound.size > 0) { + const entityOrServiceName = + expand.serviceConfig.entity ?? + expand.serviceConfig.args?.methodSuffix ?? + expand.serviceConfig.serviceName + + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `${entityOrServiceName} ${pkField} not found: ` + + Array.from(notFound).join(", ") + ) + } + } + + private handleFieldAliases( + items: any[], + parsedExpands: Map, + implodeMapping: InternalImplodeMapping[] + ) { + const getChildren = (item: any, prop: string) => { + if (Array.isArray(item)) { + return item.flatMap((currentItem) => currentItem[prop]) + } else { + return item[prop] + } + } + const removeChildren = (item: any, prop: string) => { + if (Array.isArray(item)) { + item.forEach((currentItem) => delete currentItem[prop]) + } else { + delete item[prop] + } + } + + const cleanup: [any, string][] = [] + for (const alias of implodeMapping) { + const propPath = alias.path + + let itemsLocation = items + for (const locationProp of alias.location) { + propPath.shift() + itemsLocation = RemoteJoiner.getNestedItems(itemsLocation, locationProp) + } + + itemsLocation.forEach((locationItem) => { + if (!locationItem) { + return + } + + let currentItems = locationItem + let parentRemoveItems: any = null + + const curPath: string[] = [BASE_PATH].concat(alias.location) + for (const prop of propPath) { + if (!isDefined(currentItems)) { + break + } + + curPath.push(prop) + + const config = parsedExpands.get(curPath.join(".")) as any + if (config?.isAliasMapping && parentRemoveItems === null) { + parentRemoveItems = [currentItems, prop] + } + + currentItems = getChildren(currentItems, prop) + } + + if (Array.isArray(currentItems)) { + if (currentItems.length < 2 && !alias.isList) { + locationItem[alias.property] = currentItems.shift() + } else { + locationItem[alias.property] = currentItems + } + } else { + locationItem[alias.property] = alias.isList + ? isDefined(currentItems) + ? [currentItems] + : [] + : currentItems + } + + if (parentRemoveItems !== null) { + cleanup.push(parentRemoveItems) + } + }) + } + + for (const parentRemoveItems of cleanup) { + const [remItems, path] = parentRemoveItems + removeChildren(remItems, path) + } + } + + private async handleExpands( + items: any[], + parsedExpands: Map, + implodeMapping: InternalImplodeMapping[] = [], + options?: RemoteJoinerOptions + ): Promise { + if (!parsedExpands) { + return + } + + for (const [expandedPath, expand] of parsedExpands.entries()) { + if (expandedPath === BASE_PATH) { + continue + } + + let nestedItems = items + const expandedPathLevels = expandedPath.split(".") + + for (let idx = 1; idx < expandedPathLevels.length - 1; idx++) { + nestedItems = RemoteJoiner.getNestedItems( + nestedItems, + expandedPathLevels[idx] + ) + } + + if (nestedItems.length > 0) { + await this.expandProperty( + nestedItems, + expand.parentConfig!, + expand, + options + ) + } + } + + this.handleFieldAliases(items, parsedExpands, implodeMapping) + } + + private async expandProperty( + items: any[], + parentServiceConfig: InternalJoinerServiceConfig, + expand?: RemoteExpandProperty, + options?: RemoteJoinerOptions + ): Promise { + if (!expand) { + return + } + + const relationship = this.getRelationship( + parentServiceConfig?.relationships!, + expand.property, + expand.parentConfig?.entity + ) + + if (relationship) { + await this.expandRelationshipProperty( + items, + expand, + relationship, + options + ) + } + } + + private async expandRelationshipProperty( + items: any[], + expand: RemoteExpandProperty, + relationship: JoinerRelationship, + options?: RemoteJoinerOptions + ): Promise { + const field = relationship.inverse + ? relationship.primaryKey + : relationship.foreignKey.split(".").pop()! + const fieldsArray = field.split(",") + + const idsToFetch: any[] = [] + + items.forEach((item) => { + const values = fieldsArray.map((field) => item?.[field]) + + if (values.length === fieldsArray.length && !item?.[relationship.alias]) { + if (fieldsArray.length === 1) { + if (!idsToFetch.includes(values[0])) { + idsToFetch.push(values[0]) + } + } else { + // composite key + const valuesString = values.join(",") + + if (!idsToFetch.some((id) => id.join(",") === valuesString)) { + idsToFetch.push(values) + } + } + } + }) + + if (idsToFetch.length === 0) { + return + } + + const relatedDataArray = await this.fetchData( + expand, + field, + idsToFetch, + relationship, + options + ) + + const joinFields = relationship.inverse + ? relationship.foreignKey.split(",") + : relationship.primaryKey.split(",") + + const relData = relatedDataArray.path + ? relatedDataArray.data[relatedDataArray.path!] + : relatedDataArray.data + + const relatedDataMap = RemoteJoiner.createRelatedDataMap( + relData, + joinFields + ) + + items.forEach((item) => { + if (!item || item[relationship.alias]) { + return + } + + const itemKey = fieldsArray.map((field) => item[field]).join(",") + + if (Array.isArray(item[field])) { + item[relationship.alias] = item[field].map((id) => { + if (relationship.isList && !Array.isArray(relatedDataMap[id])) { + relatedDataMap[id] = isDefined(relatedDataMap[id]) + ? [relatedDataMap[id]] + : [] + } + + return relatedDataMap[id] + }) + } else { + if (relationship.isList && !Array.isArray(relatedDataMap[itemKey])) { + relatedDataMap[itemKey] = isDefined(relatedDataMap[itemKey]) + ? [relatedDataMap[itemKey]] + : [] + } + + item[relationship.alias] = relatedDataMap[itemKey] + } + }) + } + + private parseExpands( + initialService: RemoteExpandProperty, + query: RemoteJoinerQuery, + serviceConfig: InternalJoinerServiceConfig, + expands: RemoteJoinerQuery["expands"], + implodeMapping: InternalImplodeMapping[], + options?: RemoteJoinerOptions + ): Map { + const parsedExpands = this.parseProperties( + initialService, + query, + serviceConfig, + expands, + implodeMapping, + options + ) + + const groupedExpands = this.groupExpands(parsedExpands) + + return groupedExpands + } + + private parseProperties( + initialService: RemoteExpandProperty, + query: RemoteJoinerQuery, + serviceConfig: InternalJoinerServiceConfig, + expands: RemoteJoinerQuery["expands"], + implodeMapping: InternalImplodeMapping[], + options?: RemoteJoinerOptions + ): Map { + const aliasRealPathMap = new Map() + const parsedExpands = new Map() + parsedExpands.set(BASE_PATH, initialService) + + const forwardArgumentsOnPath: string[] = [] + + for (const expand of expands || []) { + const properties = expand.property.split(".") + const currentPath: string[] = [] + const currentAliasPath: string[] = [] + let currentServiceConfig = serviceConfig + + for (const prop of properties) { + const fieldAlias = currentServiceConfig.fieldAlias ?? {} + + if (fieldAlias[prop]) { + const aliasPath = [BASE_PATH, ...currentPath, prop].join(".") + + const lastServiceConfig = this.parseAlias({ + aliasPath, + aliasRealPathMap, + expands, + expand, + property: prop, + parsedExpands, + currentServiceConfig, + currentPath, + implodeMapping, + forwardArgumentsOnPath, + }) + + currentAliasPath.push(prop) + + currentServiceConfig = lastServiceConfig + + continue + } + + const fullPath = [BASE_PATH, ...currentPath, prop].join(".") + const fullAliasPath = [BASE_PATH, ...currentAliasPath, prop].join(".") + + const relationship = this.getRelationship( + currentServiceConfig.relationships!, + prop, + currentServiceConfig.entity + ) + + const isCurrentProp = + fullPath === BASE_PATH + "." + expand.property || + fullAliasPath == BASE_PATH + "." + expand.property + + let fields: string[] = isCurrentProp ? expand.fields ?? [] : [] + const args = isCurrentProp ? expand.args : [] + + if (relationship) { + const parentExpand = + parsedExpands.get([BASE_PATH, ...currentPath].join(".")) || query + + if (parentExpand) { + const parRelField = relationship.inverse + ? relationship.primaryKey + : relationship.foreignKey.split(".").pop()! + + parentExpand.fields ??= [] + + parentExpand.fields = parentExpand.fields + .concat(parRelField.split(",")) + .filter((field) => field !== relationship.alias) + + parentExpand.fields = deduplicate(parentExpand.fields) + + const relField = relationship.inverse + ? relationship.foreignKey.split(".").pop()! + : relationship.primaryKey + fields = fields.concat(relField.split(",")) + } + + currentServiceConfig = this.getServiceConfig( + relationship.serviceName, + relationship.entity + )! + + if (!currentServiceConfig) { + throw new Error( + `Target service not found: ${relationship.serviceName}` + ) + } + } + + const isAliasMapping = (expand as any).isAliasMapping + if (!parsedExpands.has(fullPath)) { + let parentPath = [BASE_PATH, ...currentPath].join(".") + + if (aliasRealPathMap.has(parentPath)) { + parentPath = aliasRealPathMap + .get(parentPath)! + .slice(0, -1) + .join(".") + } + + parsedExpands.set(fullPath, { + property: prop, + serviceConfig: currentServiceConfig, + fields, + args: isAliasMapping + ? forwardArgumentsOnPath.includes(fullPath) + ? args + : undefined + : args, + isAliasMapping: isAliasMapping, + parent: parentPath, + parentConfig: parsedExpands.get(parentPath).serviceConfig, + }) + } else { + const exp = parsedExpands.get(fullPath) + + if (forwardArgumentsOnPath.includes(fullPath) && args) { + exp.args = (exp.args || []).concat(args) + } + exp.isAliasMapping ??= isAliasMapping + + if (fields) { + exp.fields = deduplicate((exp.fields ?? []).concat(fields)) + } + } + + currentPath.push(prop) + currentAliasPath.push(prop) + } + } + + return parsedExpands + } + + private parseAlias({ + aliasPath, + aliasRealPathMap, + expands, + expand, + property, + parsedExpands, + currentServiceConfig, + currentPath, + implodeMapping, + forwardArgumentsOnPath, + }) { + const serviceConfig = currentServiceConfig + const fieldAlias = currentServiceConfig.fieldAlias ?? {} + const alias = fieldAlias[property] as any + + const path = isString(alias) ? alias : alias.path + const fieldAliasIsList = isString(alias) ? false : !!alias.isList + const fullPath = [...currentPath.concat(path.split("."))] + + if (aliasRealPathMap.has(aliasPath)) { + currentPath.push(...path.split(".")) + + const fullPath = [BASE_PATH, ...currentPath].join(".") + return parsedExpands.get(fullPath).serviceConfig + } + + // remove alias from fields + const parentPath = [BASE_PATH, ...currentPath].join(".") + const parentExpands = parsedExpands.get(parentPath) + parentExpands.fields = parentExpands.fields?.filter( + (field) => field !== property + ) + + forwardArgumentsOnPath.push( + ...(alias?.forwardArgumentsOnPath || []).map( + (forPath) => BASE_PATH + "." + currentPath.concat(forPath).join(".") + ) + ) + + const parentFieldAlias = fullPath[Math.max(fullPath.length - 2, 0)] + implodeMapping.push({ + location: [...currentPath], + property, + path: fullPath, + isList: + fieldAliasIsList || + !!this.getRelationship( + serviceConfig.relationships!, + parentFieldAlias, + currentServiceConfig.entity + )?.isList, + }) + + const extMapping = expands as unknown[] + + const fullAliasProp = fullPath.join(".") + const middlePath = path.split(".") + let curMiddlePath = currentPath + for (const path of middlePath) { + curMiddlePath = curMiddlePath.concat(path) + + const midProp = curMiddlePath.join(".") + const existingExpand = expands.find((exp) => exp.property === midProp) + + const extraExtends = { + ...(midProp === fullAliasProp ? expand : {}), + property: midProp, + isAliasMapping: !existingExpand, + } + + if (forwardArgumentsOnPath.includes(BASE_PATH + "." + midProp)) { + extraExtends.args = (existingExpand?.args ?? []).concat( + expand?.args ?? [] + ) + } + + extMapping.push(extraExtends) + } + + const partialPath: string[] = [] + for (const partial of path.split(".")) { + const relationship = this.getRelationship( + currentServiceConfig.relationships, + partial, + currentServiceConfig.entity + ) + + if (relationship) { + currentServiceConfig = this.getServiceConfig( + relationship.serviceName, + relationship.entity + )! + + if (!currentServiceConfig) { + throw new Error( + `Target service not found: ${relationship.serviceName}` + ) + } + } + + const completePath = [ + BASE_PATH, + ...currentPath.concat(partialPath), + partial, + ] + const parentPath = completePath.slice(0, -1).join(".") + + partialPath.push(partial) + + parsedExpands.set(completePath.join("."), { + property: partial, + serviceConfig: currentServiceConfig, + parent: parentPath, + parentConfig: parsedExpands.get(parentPath).serviceConfig, + }) + } + + currentPath.push(...path.split(".")) + aliasRealPathMap.set(aliasPath, [BASE_PATH, ...currentPath]) + + return currentServiceConfig + } + + private groupExpands( + parsedExpands: Map + ): Map { + const mergedExpands = new Map(parsedExpands) + const mergedPaths = new Map() + + for (const [path, expand] of mergedExpands.entries()) { + const currentServiceName = expand.serviceConfig.serviceName + let parentPath = expand.parent + + while (parentPath) { + const parentExpand = + mergedExpands.get(parentPath) ?? mergedPaths.get(parentPath) + if ( + !parentExpand || + parentExpand.serviceConfig.serviceName !== currentServiceName + ) { + break + } + + // Merge the current expand into its parent + const nestedKeys = path.split(".").slice(parentPath.split(".").length) + let targetExpand = parentExpand as Omit< + RemoteExpandProperty, + "expands" + > & { expands?: {} } + + for (const key of nestedKeys) { + targetExpand.expands ??= {} + targetExpand = targetExpand.expands[key] ??= {} + } + + targetExpand.fields = [...new Set(expand.fields)] + targetExpand.args = expand.args + + mergedExpands.delete(path) + mergedPaths.set(path, expand) + + parentPath = parentExpand.parent + } + } + + return mergedExpands + } + + async query( + queryObj: RemoteJoinerQuery, + options?: RemoteJoinerOptions + ): Promise { + const serviceConfig = this.getServiceConfig( + queryObj.service, + undefined, + queryObj.alias + ) + + if (!serviceConfig) { + if (queryObj.alias) { + throw new Error(`Service with alias "${queryObj.alias}" was not found.`) + } + + throw new Error(`Service "${queryObj.service}" was not found.`) + } + + let pkName = serviceConfig.primaryKeys[0] + const primaryKeyArg = queryObj.args?.find((arg) => { + const inc = serviceConfig.primaryKeys.includes(arg.name) + if (inc) { + pkName = arg.name + } + return inc + }) + const otherArgs = queryObj.args?.filter( + (arg) => !serviceConfig.primaryKeys.includes(arg.name) + ) + + const implodeMapping: InternalImplodeMapping[] = [] + const parsedExpands = this.parseExpands( + { + property: "", + parent: "", + serviceConfig, + fields: queryObj.fields, + args: otherArgs, + }, + queryObj, + serviceConfig, + queryObj.expands!, + implodeMapping + ) + + const root = parsedExpands.get(BASE_PATH)! + + const response = await this.fetchData( + root, + pkName, + primaryKeyArg?.value, + undefined, + options + ) + + const data = response.path ? response.data[response.path!] : response.data + + await this.handleExpands( + Array.isArray(data) ? data : [data], + parsedExpands, + implodeMapping, + options + ) + + return response.data + } +} +*/ diff --git a/packages/core/utils/src/modules-sdk/index.ts b/packages/core/utils/src/modules-sdk/index.ts index d8ff0499fb..bac0a1f2bc 100644 --- a/packages/core/utils/src/modules-sdk/index.ts +++ b/packages/core/utils/src/modules-sdk/index.ts @@ -1,6 +1,7 @@ export * from "./build-query" export * from "./create-pg-connection" export * from "./decorators" +export * from "./define-link" export * from "./definition" export * from "./event-builder-factory" export * from "./joiner-config-builder" @@ -14,4 +15,5 @@ export * from "./medusa-service" export * from "./migration-scripts" export * from "./mikro-orm-cli-config-builder" export * from "./module" -export * from "./define-link" +export * from "./query-context" +export * from "./query-filter" diff --git a/packages/core/utils/src/modules-sdk/query-context.ts b/packages/core/utils/src/modules-sdk/query-context.ts new file mode 100644 index 0000000000..51a2f1cf2b --- /dev/null +++ b/packages/core/utils/src/modules-sdk/query-context.ts @@ -0,0 +1,19 @@ +type QueryContextType = { + (query: Record): Record + isQueryContext: (obj: any) => boolean +} + +const __type = "QueryContext" + +function QueryContextFn(query: Record) { + return { + ...query, + __type, + } +} + +QueryContextFn.isQueryContext = (obj: any) => { + return obj.__type === __type +} + +export const QueryContext: QueryContextType = QueryContextFn diff --git a/packages/core/utils/src/modules-sdk/query-filter.ts b/packages/core/utils/src/modules-sdk/query-filter.ts new file mode 100644 index 0000000000..9c8b9bb232 --- /dev/null +++ b/packages/core/utils/src/modules-sdk/query-filter.ts @@ -0,0 +1,18 @@ +import { RemoteQueryFilters } from "@medusajs/types" + +const __type = "QueryFilter" + +export function QueryFilterFn( + query: RemoteQueryFilters +): RemoteQueryFilters & { __type: "QueryFilter" } { + return { + ...query, + __type, + } +} + +QueryFilterFn.isQueryFilter = (obj: any) => { + return obj.__type === __type +} + +export const QueryFilter = QueryFilterFn diff --git a/packages/medusa/src/instrumentation/index.ts b/packages/medusa/src/instrumentation/index.ts index eeca98652a..e32989f649 100644 --- a/packages/medusa/src/instrumentation/index.ts +++ b/packages/medusa/src/instrumentation/index.ts @@ -1,14 +1,14 @@ -import { snakeCase } from "lodash" -import { NodeSDK } from "@opentelemetry/sdk-node" -import { Resource } from "@opentelemetry/resources" +import { Query, RoutesLoader, Tracer } from "@medusajs/framework" import { SpanStatusCode } from "@opentelemetry/api" -import { RoutesLoader, Tracer, Query } from "@medusajs/framework" -import { - type SpanExporter, - SimpleSpanProcessor, -} from "@opentelemetry/sdk-trace-node" -import { PgInstrumentation } from "@opentelemetry/instrumentation-pg" import type { Instrumentation } from "@opentelemetry/instrumentation" +import { PgInstrumentation } from "@opentelemetry/instrumentation-pg" +import { Resource } from "@opentelemetry/resources" +import { NodeSDK } from "@opentelemetry/sdk-node" +import { + SimpleSpanProcessor, + type SpanExporter, +} from "@opentelemetry/sdk-trace-node" +import { snakeCase } from "lodash" import start from "../commands/start" @@ -132,7 +132,7 @@ export function instrumentRemoteQuery() { Query.instrument.graphQuery(async function (queryFn, queryOptions) { return await QueryTracer.trace( - `query.graph: ${queryOptions.entryPoint}`, + `query.graph: ${queryOptions.entity}`, async (span) => { span.setAttributes({ "query.fields": queryOptions.fields,