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 <adrien.deperetti@gmail.com>
Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2025-04-29 12:10:31 +02:00
1 parent 8a3f639f01
commit b868a4ef4d
37 files changed
+3285 -755

No files matched your search

+11
View File
@@ -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
@@ -143,7 +143,7 @@ medusaIntegrationTestRunner({
) )
expect(resultset.metadata).toEqual({ expect(resultset.metadata).toEqual({
count: 2, estimate_count: expect.any(Number),
skip: 0, skip: 0,
take: 10, take: 10,
}) })
@@ -59,7 +59,7 @@ async function populateData(
} }
medusaIntegrationTestRunner({ medusaIntegrationTestRunner({
testSuite: ({ getContainer, dbConnection, api, dbConfig }) => { testSuite: ({ getContainer, dbConnection, api }) => {
let indexEngine: IndexTypes.IIndexService let indexEngine: IndexTypes.IIndexService
let appContainer let appContainer
@@ -93,6 +93,7 @@ medusaIntegrationTestRunner({
;(indexEngine as any).storageProvider_.onApplicationStart = jest.fn() ;(indexEngine as any).storageProvider_.onApplicationStart = jest.fn()
// Trigger a sync // Trigger a sync
;(indexEngine as any).schemaObjectRepresentation_ = null
await (indexEngine as any).onApplicationStart_() await (indexEngine as any).onApplicationStart_()
// 28 ms - 6511 records // 28 ms - 6511 records
@@ -138,6 +139,7 @@ medusaIntegrationTestRunner({
;(indexEngine as any).storageProvider_.onApplicationStart = jest.fn() ;(indexEngine as any).storageProvider_.onApplicationStart = jest.fn()
// Trigger a sync // Trigger a sync
;(indexEngine as any).schemaObjectRepresentation_ = null
await (indexEngine as any).onApplicationStart_() await (indexEngine as any).onApplicationStart_()
const { data: results } = await indexEngine.query<"product">({ const { data: results } = await indexEngine.query<"product">({
@@ -172,8 +174,8 @@ medusaIntegrationTestRunner({
} }
`, `,
} }
// Trigger a sync // Trigger a sync
;(indexEngine as any).schemaObjectRepresentation_ = null
await (indexEngine as any).onApplicationStart_() await (indexEngine as any).onApplicationStart_()
await setTimeout(3000) await setTimeout(3000)
@@ -26,6 +26,7 @@ import {
} from "@medusajs/utils" } from "@medusajs/utils"
import { pgConnectionLoader } from "./database" import { pgConnectionLoader } from "./database"
import type { Knex } from "@mikro-orm/knex"
import { aliasTo, asValue } from "awilix" import { aliasTo, asValue } from "awilix"
import { configManager } from "./config" import { configManager } from "./config"
import { import {
@@ -33,7 +34,6 @@ import {
container as mainContainer, container as mainContainer,
MedusaContainer, MedusaContainer,
} from "./container" } from "./container"
import type { Knex } from "@mikro-orm/knex"
export class MedusaAppLoader { export class MedusaAppLoader {
/** /**
@@ -88,6 +88,7 @@ export class MedusaAppLoader {
const def = {} as ModuleDefinition const def = {} as ModuleDefinition
def.key ??= key def.key ??= key
def.label ??= ModulesDefinition[key]?.label ?? upperCaseFirst(key) def.label ??= ModulesDefinition[key]?.label ?? upperCaseFirst(key)
def.dependencies ??= ModulesDefinition[key]?.dependencies
def.isQueryable = ModulesDefinition[key]?.isQueryable ?? true def.isQueryable = ModulesDefinition[key]?.isQueryable ?? true
const orignalDef = value?.definition ?? ModulesDefinition[key] const orignalDef = value?.definition ?? ModulesDefinition[key]
@@ -22,6 +22,7 @@ import {
isString, isString,
MedusaModuleProviderType, MedusaModuleProviderType,
MedusaModuleType, MedusaModuleType,
Modules,
ModulesSdkUtils, ModulesSdkUtils,
toMikroOrmEntities, toMikroOrmEntities,
} from "@medusajs/utils" } from "@medusajs/utils"
@@ -223,7 +224,8 @@ export async function loadInternalModule(args: {
ContainerRegistrationKeys.MANAGER, ContainerRegistrationKeys.MANAGER,
ContainerRegistrationKeys.CONFIG_MODULE, ContainerRegistrationKeys.CONFIG_MODULE,
ContainerRegistrationKeys.LOGGER, ContainerRegistrationKeys.LOGGER,
ContainerRegistrationKeys.PG_CONNECTION ContainerRegistrationKeys.PG_CONNECTION,
Modules.EVENT_BUS
) )
for (const dependency of dependencies) { for (const dependency of dependencies) {
+4
View File
@@ -456,3 +456,7 @@ export type TransformObjectMethodToAsync<T extends object> = {
? TransformObjectMethodToAsync<T[K]> ? TransformObjectMethodToAsync<T[K]>
: T[K] : T[K]
} }
export type QueryContextType = Record<string, any> & {
__type?: "QueryContext"
}
@@ -38,6 +38,10 @@ export type PaginatedResponse<T> = {
* The total number of items. * The total number of items.
*/ */
count: number count: number
/**
* The estimated number of items.
*/
estimate_count?: number
} & T } & T
export type BatchResponse<T> = { export type BatchResponse<T> = {
@@ -59,7 +63,7 @@ export type BatchResponse<T> = {
ids: string[] ids: string[]
/** /**
* The type of the items that were deleted. * The type of the items that were deleted.
* *
* @example * @example
* "product" * "product"
*/ */
@@ -9,6 +9,7 @@ describe("IndexQueryConfig", () => {
expectTypeOf<IndexConfig["fields"]>().toEqualTypeOf< expectTypeOf<IndexConfig["fields"]>().toEqualTypeOf<
( (
| "*"
| "id" | "id"
| "title" | "title"
| "variants.*" | "variants.*"
@@ -31,10 +31,21 @@ export type SchemaObjectEntityRepresentation = {
*/ */
targetProp: string targetProp: string
/**
* The property the parent is assigned to in my side
*/
inverseSideProp: string
/** /**
* Are the data expected to be a list or not * Are the data expected to be a list or not
*/ */
isList?: boolean 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]: { [key: string]: {
shortCutOf?: string shortCutOf?: string
ref: SchemaObjectEntityRepresentation ref: SchemaObjectEntityRepresentation
isInverse?: boolean
isList?: boolean
} }
} }
@@ -1,5 +1,4 @@
import { ExcludedProps, TypeOnly } from "./common" import { ExcludedProps, TypeOnly } from "./common"
type Marker = [never, 0, 1, 2, 3, 4] type Marker = [never, 0, 1, 2, 3, 4]
type RawBigNumberPrefix = "raw_" type RawBigNumberPrefix = "raw_"
@@ -17,7 +16,7 @@ export type ObjectToIndexFields<
MaybeT, MaybeT,
Depth extends number = 2, Depth extends number = 2,
Exclusion extends string[] = [], Exclusion extends string[] = [],
T = TypeOnly<MaybeT> T = TypeOnly<MaybeT> & { "*": "*" }
> = Depth extends never > = Depth extends never
? never ? never
: T extends object : T extends object
@@ -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 { IndexServiceEntryPoints } from "../index-service-entry-points"
import { ObjectToIndexFields } from "./query-input-config-fields" import { ObjectToIndexFields } from "./query-input-config-fields"
import { IndexFilters } from "./query-input-config-filters" import { IndexFilters } from "./query-input-config-filters"
import { IndexOrderBy } from "./query-input-config-order-by"
export type IndexQueryInput<TEntry extends string> = {
/**
* 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<TEntry>
}
/**
* Filters to apply on the retrieved items.
*/
filters?: IndexFilters<TEntry>
/**
* 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<TEntry extends string> = { export type IndexQueryConfig<TEntry extends string> = {
fields: ObjectToIndexFields< fields: ObjectToIndexFields<
@@ -13,14 +62,14 @@ export type IndexQueryConfig<TEntry extends string> = {
>[] >[]
filters?: IndexFilters<TEntry> filters?: IndexFilters<TEntry>
joinFilters?: IndexFilters<TEntry> joinFilters?: IndexFilters<TEntry>
pagination?: Partial<RemoteQueryInput<TEntry>["pagination"]> pagination?: Partial<IndexQueryInput<TEntry>["pagination"]>
keepFilteredEntities?: boolean keepFilteredEntities?: boolean
} }
export type QueryFunctionReturnPagination = { export type QueryFunctionReturnPagination = {
skip?: number skip: number
take?: number take: number
count?: number estimate_count: number
} }
/** /**
+1
View File
@@ -21,6 +21,7 @@ export type JoinerRelationship = {
export interface JoinerServiceConfigAlias { export interface JoinerServiceConfigAlias {
name: string | string[] name: string | string[]
entity?: string entity?: string
filterable?: string[]
/** /**
* Extra arguments to pass to the remoteFetchData callback * Extra arguments to pass to the remoteFetchData callback
*/ */
@@ -248,6 +248,11 @@ export declare type ModuleJoinerRelationship = JoinerRelationship & {
* If true, the link joiner will cascade deleting the relationship * If true, the link joiner will cascade deleting the relationship
*/ */
deleteCascade?: boolean deleteCascade?: boolean
/**
* The fields to be filterable by the Index module using query.index
*/
filterable?: string[]
/** /**
* Allow multiple relationships to exist for this * Allow multiple relationships to exist for this
* entity * entity
@@ -1,10 +1,10 @@
import { QueryContextType } from "../common"
import { IndexOrderBy } from "../index-data/query-config/query-input-config-order-by" import { IndexOrderBy } from "../index-data/query-config/query-input-config-order-by"
import { ObjectToRemoteQueryFields } from "./object-to-remote-query-fields" import { ObjectToRemoteQueryFields } from "./object-to-remote-query-fields"
import { RemoteQueryEntryPoints } from "./remote-query-entry-points" import { RemoteQueryEntryPoints } from "./remote-query-entry-points"
import { RemoteQueryFilters } from "./to-remote-query" import { RemoteQueryFilters } from "./to-remote-query"
export type RemoteQueryObjectConfig<TEntry extends string> = { export type RemoteQueryObjectConfig<TEntry extends string> = {
// 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 entryPoint: TEntry | keyof RemoteQueryEntryPoints
variables?: any variables?: any
fields: ObjectToRemoteQueryFields< fields: ObjectToRemoteQueryFields<
@@ -26,7 +26,6 @@ export type RemoteQueryObjectFromStringResult<
} }
export type RemoteQueryInput<TEntry extends string> = { export type RemoteQueryInput<TEntry extends string> = {
// 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`. * The name of the entity to retrieve. For example, `product`.
*/ */
@@ -67,7 +66,7 @@ export type RemoteQueryInput<TEntry extends string> = {
/** /**
* Apply a query context on the retrieved data. For example, to retrieve product prices for a certain context. * 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. * Apply a `withDeleted` flag on the retrieved data to retrieve soft deleted items.
*/ */
@@ -1,4 +1,8 @@
import { Prettify } from "../common" import { Prettify } from "../common"
import {
IndexQueryInput,
QueryResultSet,
} from "../index-data/query-config/query-input-config"
import { RemoteJoinerOptions, RemoteJoinerQuery } from "../joiner" import { RemoteJoinerOptions, RemoteJoinerQuery } from "../joiner"
import { RemoteQueryEntryPoints } from "./remote-query-entry-points" import { RemoteQueryEntryPoints } from "./remote-query-entry-points"
import { import {
@@ -6,7 +10,6 @@ import {
RemoteQueryObjectConfig, RemoteQueryObjectConfig,
RemoteQueryObjectFromStringResult, RemoteQueryObjectFromStringResult,
} from "./remote-query-object-from-string" } from "./remote-query-object-from-string"
import { RemoteQueryFilters } from "./to-remote-query"
/*type ExcludedProps = "__typename"*/ /*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 * that simplifies the input it accepts and returns
* a normalized/consistent output. * a normalized/consistent output.
*/ */
export type QueryIndexFunction = { export type QueryIndexFunction = {
<const TEntry extends string>( <const TEntry extends string>(queryOptions: IndexQueryInput<TEntry>): Promise<
queryOptions: RemoteQueryInput<TEntry> & { Prettify<QueryResultSet<TEntry>>
joinFilters?: RemoteQueryFilters<TEntry> >
},
options?: RemoteJoinerOptions
): Promise<Prettify<GraphResultSet<TEntry>>>
} }
/*export type RemoteQueryReturnedData<TEntry extends string> = /*export type RemoteQueryReturnedData<TEntry extends string> =
@@ -69,7 +69,7 @@ describe("GraphQL builder", () => {
id: ID! id: ID!
username: String! username: String!
email: Email! email: Email!
spend_limit: String! spend_limit: Float!
phones: [String]! phones: [String]!
group_id:String! group_id:String!
group: Group! group: Group!
@@ -9,7 +9,7 @@ const GRAPHQL_TYPES = {
boolean: "Boolean", boolean: "Boolean",
dateTime: "DateTime", dateTime: "DateTime",
number: "Int", number: "Int",
bigNumber: "String", bigNumber: "Float",
text: "String", text: "String",
json: "JSON", json: "JSON",
array: "[String]", array: "[String]",
@@ -18,6 +18,7 @@ type InputSource = {
alias?: string alias?: string
linkable: string linkable: string
primaryKey: string primaryKey: string
filterable?: string[]
} }
type ReadOnlyInputSource = { type ReadOnlyInputSource = {
@@ -42,6 +43,7 @@ type InputOptions = {
field?: string field?: string
isList?: boolean isList?: boolean
deleteCascade?: boolean deleteCascade?: boolean
filterable?: string[]
} }
type Shortcut = { type Shortcut = {
@@ -87,6 +89,7 @@ type ModuleLinkableKeyConfig = {
alias: string alias: string
hasMany?: boolean hasMany?: boolean
shortcut?: Shortcut | Shortcut[] shortcut?: Shortcut | Shortcut[]
filterable?: string[]
} }
function isInputOptions(input: any): input is InputOptions { function isInputOptions(input: any): input is InputOptions {
@@ -141,6 +144,7 @@ function prepareServiceConfig(
isList: false, isList: false,
hasMany: false, hasMany: false,
deleteCascade: false, deleteCascade: false,
filterable: source.filterable,
module: source.serviceName, module: source.serviceName,
entity: source.entity, entity: source.entity,
} }
@@ -159,6 +163,7 @@ function prepareServiceConfig(
isList: input.isList ?? false, isList: input.isList ?? false,
hasMany, hasMany,
deleteCascade: input.deleteCascade ?? false, deleteCascade: input.deleteCascade ?? false,
filterable: input.filterable,
module: source.serviceName, module: source.serviceName,
entity: source.entity, entity: source.entity,
} }
@@ -192,6 +197,17 @@ export function defineLink(
const serviceBObj = prepareServiceConfig(rightService) const serviceBObj = prepareServiceConfig(rightService)
if (linkServiceOptions?.readOnly) { 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( return defineReadOnlyLink(
serviceAObj, serviceAObj,
serviceBObj, serviceBObj,
@@ -378,6 +394,7 @@ ${serviceBObj.module}: {
methodSuffix: serviceAMethodSuffix, methodSuffix: serviceAMethodSuffix,
}, },
deleteCascade: serviceAObj.deleteCascade, deleteCascade: serviceAObj.deleteCascade,
filterable: serviceAObj.filterable,
hasMany: serviceAObj.hasMany, hasMany: serviceAObj.hasMany,
}, },
{ {
@@ -390,6 +407,7 @@ ${serviceBObj.module}: {
methodSuffix: serviceBMethodSuffix, methodSuffix: serviceBMethodSuffix,
}, },
deleteCascade: serviceBObj.deleteCascade, deleteCascade: serviceBObj.deleteCascade,
filterable: serviceBObj.filterable,
hasMany: serviceBObj.hasMany, hasMany: serviceBObj.hasMany,
}, },
], ],
@@ -1,11 +1,13 @@
type QueryContextType = { import { QueryContextType } from "@medusajs/types"
type QueryContexFnType = {
(query: Record<string, unknown>): Record<string, unknown> (query: Record<string, unknown>): Record<string, unknown>
isQueryContext: (obj: any) => boolean isQueryContext: (obj: any) => boolean
} }
const __type = "QueryContext" const __type = "QueryContext"
function QueryContextFn(query: Record<string, unknown>) { function QueryContextFn(query: Record<string, unknown>): QueryContextType {
return { return {
...query, ...query,
__type, __type,
@@ -16,4 +18,4 @@ QueryContextFn.isQueryContext = (obj: any) => {
return obj.__type === __type return obj.__type === __type
} }
export const QueryContext: QueryContextType = QueryContextFn export const QueryContext: QueryContexFnType = QueryContextFn
@@ -79,7 +79,7 @@ async function getProductsWithIndexEngine(
res.json({ res.json({
products: products.map(remapProductResponse), products: products.map(remapProductResponse),
count: metadata!.count, count: metadata!.estimate_count,
offset: metadata!.skip, offset: metadata!.skip,
limit: metadata!.take, limit: metadata!.take,
}) })
@@ -1,6 +1,6 @@
import { featureFlagRouter } from "@medusajs/framework" import { featureFlagRouter } from "@medusajs/framework"
import { MedusaResponse } from "@medusajs/framework/http" import { MedusaResponse } from "@medusajs/framework/http"
import { HttpTypes } from "@medusajs/framework/types" import { HttpTypes, QueryContextType } from "@medusajs/framework/types"
import { import {
ContainerRegistrationKeys, ContainerRegistrationKeys,
isPresent, isPresent,
@@ -36,7 +36,7 @@ async function getProductsWithIndexEngine(
) { ) {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const context: object = {} const context: QueryContextType = {}
const withInventoryQuantity = req.queryConfig.fields.some((field) => const withInventoryQuantity = req.queryConfig.fields.some((field) =>
field.includes("variants.inventory_quantity") field.includes("variants.inventory_quantity")
) )
@@ -80,7 +80,8 @@ async function getProductsWithIndexEngine(
await wrapProductsWithTaxPrices(req, products) await wrapProductsWithTaxPrices(req, products)
res.json({ res.json({
products, products,
count: metadata!.count, count: metadata!.estimate_count,
estimate_count: metadata!.estimate_count,
offset: metadata!.skip, offset: metadata!.skip,
limit: metadata!.take, limit: metadata!.take,
}) })
@@ -414,7 +414,6 @@ describe("IndexModuleService query", function () {
}, },
}) })
// NULLS LAST (DESC = first)
expect(data).toEqual([ expect(data).toEqual([
{ {
id: "prod_2", id: "prod_2",
@@ -526,7 +525,7 @@ describe("IndexModuleService query", function () {
}) })
expect(metadata).toEqual({ expect(metadata).toEqual({
count: 1, estimate_count: expect.any(Number),
skip: 0, skip: 0,
take: 100, take: 100,
}) })
@@ -575,7 +574,7 @@ describe("IndexModuleService query", function () {
}) })
expect(metadata).toEqual({ expect(metadata).toEqual({
count: 1, estimate_count: expect.any(Number),
skip: 0, skip: 0,
take: 100, 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 () => { it("should query products filtering by price and returning the complete entity", async () => {
const { data, metadata } = await module.query({ const { data, metadata } = await module.query({
fields: ["product.*", "product.variants.*", "product.variants.prices.*"], fields: ["product.*", "product.variants.*", "product.variants.prices.*"],
@@ -651,7 +730,7 @@ describe("IndexModuleService query", function () {
}) })
expect(metadata).toEqual({ expect(metadata).toEqual({
count: 1, estimate_count: expect.any(Number),
skip: 0, skip: 0,
take: 100, take: 100,
}) })
@@ -736,11 +815,16 @@ describe("IndexModuleService query", function () {
pagination: { pagination: {
take: 1, take: 1,
skip: 1, skip: 1,
order: {
product: {
id: "ASC",
},
},
}, },
}) })
expect(metadata).toEqual({ expect(metadata).toEqual({
count: 2, estimate_count: expect.any(Number),
skip: 1, skip: 1,
take: 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 () => { it("should query products filtering by deep nested levels", async () => {
const { data, metadata } = await module.query({ const { data, metadata } = await module.query({
fields: ["product.*"], fields: ["product.*"],
@@ -849,7 +862,7 @@ describe("IndexModuleService query", function () {
}) })
expect(metadata).toEqual({ expect(metadata).toEqual({
count: 1, estimate_count: expect.any(Number),
skip: 0, skip: 0,
take: 1, 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,
},
],
},
],
},
])
})
}) })
@@ -3,11 +3,11 @@ import { Migration } from "@mikro-orm/migrations"
export class Migration20231019174230 extends Migration { export class Migration20231019174230 extends Migration {
async up(): Promise<void> { async up(): Promise<void> {
this.addSql( 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( 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");`
) )
} }
} }
@@ -10,6 +10,7 @@ import {
import { import {
MikroOrmBaseRepository as BaseRepository, MikroOrmBaseRepository as BaseRepository,
ContainerRegistrationKeys, ContainerRegistrationKeys,
GraphQLUtils,
Modules, Modules,
ModulesSdkUtils, ModulesSdkUtils,
} from "@medusajs/framework/utils" } from "@medusajs/framework/utils"
@@ -20,6 +21,7 @@ import {
defaultSchema, defaultSchema,
gqlSchemaToTypes, gqlSchemaToTypes,
} from "@utils" } from "@utils"
import { baseGraphqlSchema } from "../utils/base-graphql-schema"
import { DataSynchronizer } from "./data-synchronizer" import { DataSynchronizer } from "./data-synchronizer"
type InjectedDependencies = { type InjectedDependencies = {
@@ -105,7 +107,7 @@ export default class IndexModuleService
protected async onApplicationStart_() { protected async onApplicationStart_() {
try { try {
this.buildSchemaObjectRepresentation_() const executableSchema = this.buildSchemaObjectRepresentation_()
this.storageProvider_ = new this.storageProviderCtr_( this.storageProvider_ = new this.storageProviderCtr_(
this.container_, this.container_,
@@ -122,7 +124,7 @@ export default class IndexModuleService
await this.storageProvider_.onApplicationStart() await this.storageProvider_.onApplicationStart()
} }
await gqlSchemaToTypes(this.moduleOptions_.schema ?? defaultSchema) await gqlSchemaToTypes(executableSchema!)
this.dataSynchronizer_.onApplicationStart({ this.dataSynchronizer_.onApplicationStart({
schemaObjectRepresentation: this.schemaObjectRepresentation_, schemaObjectRepresentation: this.schemaObjectRepresentation_,
@@ -174,24 +176,21 @@ export default class IndexModuleService
} }
} }
private buildSchemaObjectRepresentation_() { private buildSchemaObjectRepresentation_():
| GraphQLUtils.GraphQLSchema
| undefined {
if (this.schemaObjectRepresentation_) { if (this.schemaObjectRepresentation_) {
return this.schemaObjectRepresentation_ return
} }
const baseSchema = ` const { objectRepresentation, entitiesMap, executableSchema } =
scalar DateTime buildSchemaObjectRepresentation(
scalar Date baseGraphqlSchema + (this.moduleOptions_.schema ?? defaultSchema)
scalar Time )
scalar JSON
`
const [objectRepresentation, entityMap] = buildSchemaObjectRepresentation(
baseSchema + (this.moduleOptions_.schema ?? defaultSchema)
)
this.schemaObjectRepresentation_ = objectRepresentation this.schemaObjectRepresentation_ = objectRepresentation
this.schemaEntitiesMap_ = entityMap this.schemaEntitiesMap_ = entitiesMap
return this.schemaObjectRepresentation_ return executableSchema
} }
} }
@@ -68,37 +68,6 @@ export class PostgresProvider implements IndexTypes.StorageProvider {
this.schemaObjectRepresentation_ = options.schemaObjectRepresentation this.schemaObjectRepresentation_ = options.schemaObjectRepresentation
this.schemaEntitiesMap_ = options.entityMap 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() { async onApplicationStart() {
@@ -138,7 +107,7 @@ export class PostgresProvider implements IndexTypes.StorageProvider {
const parentAlias = field.split(".")[0] const parentAlias = field.split(".")[0]
const parentSchemaObjectRepresentation = const parentSchemaObjectRepresentation =
schemaEntityObjectRepresentation.parents.find( schemaEntityObjectRepresentation.parents.find(
(parent) => parent.ref.alias === parentAlias (parent) => parent.inverseSideProp === parentAlias
) )
if (!parentSchemaObjectRepresentation) { if (!parentSchemaObjectRepresentation) {
@@ -304,7 +273,6 @@ export class PostgresProvider implements IndexTypes.StorageProvider {
schema: this.schemaObjectRepresentation_, schema: this.schemaObjectRepresentation_,
entityMap: this.schemaEntitiesMap_, entityMap: this.schemaEntitiesMap_,
knex: connection.getKnex(), knex: connection.getKnex(),
rawConfig: config,
selector: { selector: {
select, select,
where, where,
@@ -316,26 +284,30 @@ export class PostgresProvider implements IndexTypes.StorageProvider {
keepFilteredEntities, keepFilteredEntities,
orderBy, orderBy,
}, },
rawConfig: config,
requestedFields, requestedFields,
}) })
const sql = qb.buildQuery({ const { sql, sqlCount } = qb.buildQuery({
hasPagination, hasPagination,
returnIdOnly: !!keepFilteredEntities, returnIdOnly: !!keepFilteredEntities,
hasCount, 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 = const resultMetadata: IndexTypes.QueryFunctionReturnPagination | undefined =
hasPagination hasPagination
? { ? ({
count: hasCount estimate_count: hasCount
? parseInt(resultSet[0]?.count_total ?? 0) ? parseInt(countResult![0]?.estimate_count ?? 0)
: undefined, : undefined,
skip, skip,
take, take,
} } as IndexTypes.QueryFunctionReturnPagination)
: undefined : undefined
if (keepFilteredEntities) { if (keepFilteredEntities) {
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,6 @@
export const baseGraphqlSchema = `
scalar DateTime
scalar Date
scalar Time
scalar JSON
`
File diff suppressed because it is too large. Load diff
@@ -9,6 +9,8 @@ export async function createPartitions(
const activeSchema = manager.config.get("schema") const activeSchema = manager.config.get("schema")
? `"${manager.config.get("schema")}".` ? `"${manager.config.get("schema")}".`
: "" : ""
const createdPartitions: Set<string> = new Set()
const partitions = Object.keys(schemaObjectRepresentation) const partitions = Object.keys(schemaObjectRepresentation)
.filter( .filter(
(key) => (key) =>
@@ -17,16 +19,30 @@ export async function createPartitions(
) )
.map((key) => { .map((key) => {
const cName = key.toLowerCase() const cName = key.toLowerCase()
if (createdPartitions.has(cName)) {
return []
}
createdPartitions.add(cName)
const part: string[] = [] const part: string[] = []
part.push( part.push(
`CREATE TABLE IF NOT EXISTS ${activeSchema}cat_${cName} PARTITION OF ${activeSchema}index_data FOR VALUES IN ('${key}')` `CREATE TABLE IF NOT EXISTS ${activeSchema}cat_${cName} PARTITION OF ${activeSchema}index_data FOR VALUES IN ('${key}')`
) )
for (const parent of schemaObjectRepresentation[key].parents) { for (const parent of schemaObjectRepresentation[key].parents) {
const pKey = `${parent.ref.entity}-${key}` if (parent.isInverse) {
const pName = `${parent.ref.entity}${key}`.toLowerCase() continue
}
const pName = `cat_pivot_${parent.ref.entity}${key}`.toLowerCase()
if (createdPartitions.has(pName)) {
continue
}
createdPartitions.add(pName)
part.push( 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 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 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) { 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( 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() .flat()
// Execute index creation commands separately to avoid blocking
for (const cmd of indexCreationCommands) { for (const cmd of indexCreationCommands) {
try { try {
await manager.execute(cmd) await manager.execute(cmd)
} catch (error) { } catch (error) {
// Log error but continue with other indexes
console.error(`Failed to create index: ${error.message}`) console.error(`Failed to create index: ${error.message}`)
} }
} }
partitions.push(`analyse ${activeSchema}index_data`) // Create count estimate function
partitions.push(`analyse ${activeSchema}index_relation`) 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("; ")) await manager.execute(partitions.join("; "))
} }
@@ -2,7 +2,7 @@ import { Modules } from "@medusajs/utils"
export const defaultSchema = ` export const defaultSchema = `
type Product @Listeners(values: ["${Modules.PRODUCT}.product.created", "${Modules.PRODUCT}.product.updated", "${Modules.PRODUCT}.product.deleted"]) { type Product @Listeners(values: ["${Modules.PRODUCT}.product.created", "${Modules.PRODUCT}.product.updated", "${Modules.PRODUCT}.product.deleted"]) {
id: String id: ID
title: String title: String
handle: String handle: String
status: 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"]) { 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 product_id: String
sku: 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"]) { type Price @Listeners(values: ["${Modules.PRICING}.price.created", "${Modules.PRICING}.price.updated", "${Modules.PRICING}.price.deleted"]) {
id: String id: ID
amount: Float amount: Float
currency_code: String 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"]) { 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 is_disabled: Boolean
} }
` `
@@ -1,18 +1,18 @@
import { MedusaModule } from "@medusajs/framework/modules-sdk" import { MedusaModule } from "@medusajs/framework/modules-sdk"
import { import {
FileSystem, FileSystem,
GraphQLUtils,
gqlSchemaToTypes as ModulesSdkGqlSchemaToTypes, gqlSchemaToTypes as ModulesSdkGqlSchemaToTypes,
} from "@medusajs/framework/utils" } from "@medusajs/framework/utils"
import { join } from "path" import { join } from "path"
import * as process from "process" import * as process from "process"
import { CustomDirectives, makeSchemaExecutable } from "./build-config"
export async function gqlSchemaToTypes(schema: string) { export async function gqlSchemaToTypes(
const augmentedSchema = CustomDirectives.Listeners.definition + schema executableSchema: GraphQLUtils.GraphQLSchema
const executableSchema = makeSchemaExecutable(augmentedSchema)! ) {
const filename = "index-service-entry-points" const filename = "index-service-entry-points"
const filenameWithExt = filename + ".d.ts" const filenameWithExt = filename + ".d.ts"
const dir = join(process.cwd(), ".medusa") const dir = join(process.cwd(), ".medusa/types")
await ModulesSdkGqlSchemaToTypes({ await ModulesSdkGqlSchemaToTypes({
schema: executableSchema, schema: executableSchema,
+347 -303
View File
@@ -1,15 +1,42 @@
import { IndexTypes } from "@medusajs/framework/types" import { IndexTypes } from "@medusajs/framework/types"
import { import {
GraphQLUtils,
isDefined, isDefined,
isObject, isObject,
isPresent,
isString, isString,
unflattenObjectKeys, unflattenObjectKeys,
} from "@medusajs/framework/utils" } from "@medusajs/framework/utils"
import { Knex } from "@mikro-orm/knex" import { Knex } from "@mikro-orm/knex"
import { OrderBy, QueryFormat, QueryOptions, Select } from "@types" 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 = { export const OPERATOR_MAP = {
$eq: "=", $eq: "=",
$lt: "<", $lt: "<",
@@ -91,17 +118,10 @@ export class QueryBuilder {
throw new Error(`Field ${field} is not indexed.`) throw new Error(`Field ${field} is not indexed.`)
} }
let currentType = fieldRef.type const fieldType = fieldRef.type.toString()
let isArray = false const isArray = fieldType.startsWith("[")
while (currentType.ofType) { const currentType = fieldType.replace(/\[|\]|\!/g, "")
if (currentType instanceof GraphQLUtils.GraphQLList) { return currentType + (isArray ? "[]" : "")
isArray = true
}
currentType = currentType.ofType
}
return currentType.name + (isArray ? "[]" : "")
} }
private transformValueToType(path, field, value) { private transformValueToType(path, field, value) {
@@ -244,9 +264,8 @@ export class QueryBuilder {
field, field,
value[subKey] 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) { if (operator === "=" && subValue === null) {
operator = "IS" operator = "IS"
} else if (operator === "!=" && subValue === null) { } else if (operator === "!=" && subValue === null) {
@@ -254,18 +273,65 @@ export class QueryBuilder {
} }
if (operator === "=") { if (operator === "=") {
builder.whereRaw( const hasId = field[field.length - 1] === "id"
`${aliasMapping[attr]}.data @> '${getPathOperation( if (hasId) {
attr, builder.whereRaw(`${aliasMapping[attr]}.id = ?`, subValue)
field as string[], } else {
subValue builder.whereRaw(
)}'::jsonb` `${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 { } else {
builder.whereRaw( const potentialIdFields = field[field.length - 1]
`(${aliasMapping[attr]}.data${nested}->>?)${castType} ${operator} ?`, const hasId = potentialIdFields === "id"
[...field, ...val] 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 { } else {
throw new Error(`Unsupported operator: ${subKey}`) throw new Error(`Unsupported operator: ${subKey}`)
@@ -281,29 +347,60 @@ export class QueryBuilder {
return return
} }
const castType = this.getPostgresCastType(attr, field).cast
const inPlaceholders = value.map(() => "?").join(",") const inPlaceholders = value.map(() => "?").join(",")
builder.whereRaw( const hasId = field[field.length - 1] === "id"
`(${aliasMapping[attr]}.data${nested}->>?)${castType} IN (${inPlaceholders})`, if (hasId) {
[...field, ...value]
)
} else if (isDefined(value)) {
const operator = value === null ? "IS" : "="
if (operator === "=") {
builder.whereRaw( builder.whereRaw(
`${aliasMapping[attr]}.data @> '${getPathOperation( `${aliasMapping[attr]}.id IN (${inPlaceholders})`,
attr, [...value]
field as string[],
value
)}'::jsonb`
) )
} else { } else {
const castType = this.getPostgresCastType(attr, field).cast const jsonbValues = value.map((item) =>
builder.whereRaw( JSON.stringify({ [nested]: item === null ? null : item })
`(${aliasMapping[attr]}.data${nested}->>?)${castType} ${operator} ?`,
[...field, value]
) )
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 return builder
} }
private getShortAlias(aliasMapping, alias: string) { private getShortAlias(aliasMapping, alias, level = 0) {
aliasMapping.__aliasIndex ??= 0 aliasMapping.__aliasIndex ??= 0
if (aliasMapping[alias]) { if (aliasMapping[alias]) {
return aliasMapping[alias] return aliasMapping[alias]
} }
aliasMapping[alias] = "t_" + aliasMapping.__aliasIndex++ + "_" aliasMapping[alias] =
"t_" + aliasMapping.__aliasIndex++ + (level > 0 ? `_${level}` : "")
return aliasMapping[alias] return aliasMapping[alias]
} }
@@ -327,7 +425,7 @@ export class QueryBuilder {
private buildQueryParts( private buildQueryParts(
structure: Select, structure: Select,
parentAlias: string, parentAlias: string,
parentEntity: string, parentEntity: IndexTypes.SchemaObjectEntityRepresentation["parents"][0],
parentProperty: string, parentProperty: string,
aliasPath: string[] = [], aliasPath: string[] = [],
level = 0, level = 0,
@@ -337,23 +435,30 @@ export class QueryBuilder {
const isSelectableField = this.allSchemaFields.has(parentProperty) const isSelectableField = this.allSchemaFields.has(parentProperty)
const entities = this.getEntity(currentAliasPath, false) const entities = this.getEntity(currentAliasPath, false)
const entityRef = entities?.ref!
// !entityRef.alias means the object has not table, it's a nested object // !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 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 // We don't need to build the query parts for this as there is no join
return [] return []
} }
const mainEntity = entityRef.entity const mainEntity = entities
const mainAlias = const mainAlias = this.getShortAlias(
this.getShortAlias(aliasMapping, mainEntity.toLowerCase()) + level 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) { if (!entities.shortCutOf) {
allEntities.push({ allEntities.push({
entity: mainEntity, entity: entities,
parEntity: parentEntity, parEntity: parentEntity,
parAlias: parentAlias, parAlias: parentAlias,
alias: mainAlias, alias: mainAlias,
@@ -372,7 +477,7 @@ export class QueryBuilder {
intermediateAlias.pop() intermediateAlias.pop()
if (intermediateEntity.ref.entity === parentEntity) { if (intermediateEntity.ref.entity === parentEntity?.ref.entity) {
break break
} }
@@ -383,20 +488,20 @@ export class QueryBuilder {
const alias = const alias =
this.getShortAlias( this.getShortAlias(
aliasMapping, aliasMapping,
intermediateEntity.ref.entity.toLowerCase() intermediateEntity.ref.entity.toLowerCase(),
level
) + ) +
level +
"_" + "_" +
x x
const parAlias = const parAlias =
parentIntermediateEntity.ref.entity === parentEntity parentIntermediateEntity.ref.entity === parentEntity?.ref.entity
? parentAlias ? parentAlias
: this.getShortAlias( : this.getShortAlias(
aliasMapping, aliasMapping,
parentIntermediateEntity.ref.entity.toLowerCase() parentIntermediateEntity.ref.entity.toLowerCase(),
level
) + ) +
level +
"_" + "_" +
(x + 1) (x + 1)
@@ -405,8 +510,9 @@ export class QueryBuilder {
} }
allEntities.unshift({ allEntities.unshift({
entity: intermediateEntity.ref.entity, entity: intermediateEntity as any,
parEntity: parentIntermediateEntity.ref.entity, parEntity:
parentIntermediateEntity as IndexTypes.SchemaObjectEntityRepresentation["parents"][0],
parAlias, parAlias,
alias, alias,
}) })
@@ -421,18 +527,41 @@ export class QueryBuilder {
aliasMapping[currentAliasPath] = alias aliasMapping[currentAliasPath] = alias
if (level > 0) { if (level > 0) {
const cName = entity.toLowerCase() const cName = entity.ref.entity.toLowerCase()
const pName = `${parEntity}${entity}`.toLowerCase()
let joinTable = `cat_${cName} AS ${alias}` let joinTable = `cat_${cName} AS ${alias}`
const pivotTable = `cat_pivot_${pName}` if (entity.isInverse || parEntity.isInverse) {
joinBuilder.leftJoin( const pName =
`${pivotTable} AS ${alias}_ref`, `${entity.ref.entity}${parEntity.ref.entity}`.toLowerCase()
`${alias}_ref.parent_id`, const pivotTable = `cat_pivot_${pName}`
`${parAlias}.id`
) joinBuilder.leftJoin(
joinBuilder.leftJoin(joinTable, `${alias}.id`, `${alias}_ref.child_id`) `${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 joinWhere = this.selector.joinWhere ?? {}
const joinKey = Object.keys(joinWhere).find((key) => { const joinKey = Object.keys(joinWhere).find((key) => {
@@ -441,7 +570,7 @@ export class QueryBuilder {
const curPath = k.join(".") const curPath = k.join(".")
if (curPath === currentAliasPath) { if (curPath === currentAliasPath) {
const relEntity = this.getEntity(curPath, false) const relEntity = this.getEntity(curPath, false)
return relEntity?.ref?.entity === entity return relEntity?.ref?.entity === entity.ref.entity
} }
return false return false
@@ -469,7 +598,7 @@ export class QueryBuilder {
this.buildQueryParts( this.buildQueryParts(
childStructure, childStructure,
mainAlias, mainAlias,
mainEntity, mainEntity as any,
child, child,
aliasPath.concat(parentProperty), aliasPath.concat(parentProperty),
level + 1, level + 1,
@@ -499,9 +628,14 @@ export class QueryBuilder {
const parentAliasPath = aliasPath.join(".") const parentAliasPath = aliasPath.join(".")
const alias = aliasMapping[parentAliasPath] const alias = aliasMapping[parentAliasPath]
delete selectParts[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 return selectParts
} }
@@ -572,9 +706,7 @@ export class QueryBuilder {
hasPagination?: boolean hasPagination?: boolean
hasCount?: boolean hasCount?: boolean
returnIdOnly?: boolean returnIdOnly?: boolean
}): string { }): { sql: string; sqlCount?: string } {
const queryBuilder = this.knex.queryBuilder()
const selectOnlyStructure = this.selector.select const selectOnlyStructure = this.selector.select
const structure = this.requestedFields const structure = this.requestedFields
const filter = this.selector.where ?? {} const filter = this.selector.where ?? {}
@@ -584,17 +716,19 @@ export class QueryBuilder {
const orderBy = this.transformOrderBy( const orderBy = this.transformOrderBy(
(order && !Array.isArray(order) ? [order] : order) ?? [] (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 rootKey = this.getStructureKeys(structure)[0]
const rootStructure = structure[rootKey] as Select const rootStructure = structure[rootKey] as Select
const entity = this.getEntity(rootKey)!.ref.entity const entity = this.getEntity(rootKey)!
const rootEntity = entity.toLowerCase() const rootEntity = entity.ref.entity.toLowerCase()
const aliasMapping: { [path: string]: string } = {} const aliasMapping: { [path: string]: string } = {}
let hasTextSearch: boolean = false let hasTextSearch: boolean = false
let textSearchQuery: string | null = null let textSearchQuery: string | null = null
const searchQueryFilterProp = `${rootEntity}.q` const searchQueryFilterProp = `${rootKey}.q`
if (searchQueryFilterProp in filter) { if (searchQueryFilterProp in filter) {
if (!filter[searchQueryFilterProp]) { 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( const joinParts = this.buildQueryParts(
rootStructure, filterSortStructure,
"", "",
entity, entity as IndexTypes.SchemaObjectEntityRepresentation["parents"][0],
rootKey, rootKey,
[], [],
0, 0,
@@ -617,35 +759,72 @@ export class QueryBuilder {
) )
const rootAlias = aliasMapping[rootKey] 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( innerQueryBuilder.distinct(`${rootAlias}.id`)
`cat_${rootEntity} AS ${this.getShortAlias(aliasMapping, rootEntity)}`
const orderBySelects: Array<string | Knex.Raw> = []
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<any> = `${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) => { joinParts.forEach((joinPart) => {
queryBuilder.joinRaw(joinPart) innerQueryBuilder.joinRaw(joinPart)
}) })
let searchWhereParts: string[] = []
if (hasTextSearch) { if (hasTextSearch) {
/** const searchWhereParts = [
* Build the search where parts for the query,. `${rootAlias}.${
* Apply the search query to the search vector column for every joined tabled except
* the pivot joined table.
*/
searchWhereParts = [
`${this.getShortAlias(aliasMapping, rootEntity)}.${
this.#searchVectorColumnName this.#searchVectorColumnName
} @@ plainto_tsquery('simple', '${textSearchQuery}')`, } @@ plainto_tsquery('simple', ?)`,
...joinParts.flatMap((part) => { ...joinParts.flatMap((part) => {
const aliases = part const aliases = part
.split(" as ") .split(" as ")
@@ -657,233 +836,94 @@ export class QueryBuilder {
(alias) => (alias) =>
`${alias}.${ `${alias}.${
this.#searchVectorColumnName this.#searchVectorColumnName
} @@ plainto_tsquery('simple', '${textSearchQuery}')` } @@ plainto_tsquery('simple', ?)`
) )
}), }),
] ]
innerQueryBuilder.whereRaw(
queryBuilder.whereRaw(`(${searchWhereParts.join(" OR ")})`) `(${searchWhereParts.join(" OR ")})`,
} Array(searchWhereParts.length).fill(textSearchQuery)
// 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
) )
} }
let take_ = !isNaN(+take!) ? +take! : 15 this.parseWhere(aliasMapping, filter, innerQueryBuilder)
let skip_ = !isNaN(+skip!) ? +skip! : 0
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) { if (hasPagination) {
cte = this.buildCTEData({ innerQueryBuilder.limit(take_)
hasCount, if (skip_ > 0) {
searchWhereParts, innerQueryBuilder.offset(skip_)
take: take_,
skip: skip_,
orderBy,
})
if (hasCount) {
queryBuilder.select(this.knex.raw("pd.count_total"))
} }
queryBuilder.joinRaw(
`JOIN paginated_data AS pd ON ${rootAlias}.id = pd.id`
)
} }
return cte + queryBuilder.toQuery() const innerQueryAlias = "paginated_ids"
}
public buildCTEData({ outerQueryBuilder.from(
hasCount, `cat_${rootEntity} AS ${this.getShortAlias(aliasMapping, rootKey)}`
searchWhereParts = [], )
skip,
take,
orderBy,
}: {
hasCount: boolean
searchWhereParts: string[]
skip?: number
take: number
orderBy: OrderBy
}): string {
const queryBuilder = this.knex.queryBuilder()
const hasWhere = isPresent(this.rawConfig?.filters) || isPresent(orderBy) outerQueryBuilder.joinRaw(
const structure = `INNER JOIN (${innerQueryBuilder.toQuery()}) AS ${innerQueryAlias} ON ${rootAlias}.id = ${innerQueryAlias}.id`
hasWhere && !searchWhereParts.length )
? unflattenObjectKeys({
...(this.rawConfig?.filters
? unflattenObjectKeys(this.rawConfig?.filters)
: {}),
...orderBy,
})
: this.requestedFields
const rootKey = this.getStructureKeys(structure)[0] this.parseWhere(aliasMapping, filter, outerQueryBuilder)
const rootStructure = structure[rootKey] as Select const joinPartsOuterQuery = this.buildQueryParts(
const entity = this.getEntity(rootKey)!.ref.entity
const rootEntity = entity.toLowerCase()
const aliasMapping: { [path: string]: string } = {}
const joinParts = this.buildQueryParts(
rootStructure, rootStructure,
"", "",
entity, entity as IndexTypes.SchemaObjectEntityRepresentation["parents"][0],
rootKey, rootKey,
[], [],
0, 0,
aliasMapping 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( const finalSql = outerQueryBuilder.toQuery()
`cat_${rootEntity} AS ${this.getShortAlias(aliasMapping, rootEntity)}`
)
if (hasWhere) { return {
joinParts.forEach((joinPart) => { sql: finalSql,
queryBuilder.joinRaw(joinPart) sqlCount: countQuery?.toQuery?.(),
})
if (searchWhereParts.length) {
queryBuilder.whereRaw(`(${searchWhereParts.join(" OR ")})`)
}
this.parseWhere(aliasMapping, this.selector.where!, queryBuilder)
} }
// 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( public buildObjectFromResultset(
resultSet: Record<string, any>[] resultSet: Record<string, any>[]
): Record<string, any>[] { ): Record<string, any>[] {
@@ -894,7 +934,11 @@ export class QueryBuilder {
const isListMap: { [path: string]: boolean } = {} const isListMap: { [path: string]: boolean } = {}
const referenceMap: { [key: string]: any } = {} const referenceMap: { [key: string]: any } = {}
const pathDetails: { const pathDetails: {
[key: string]: { property: string; parents: string[]; parentPath: string } [key: string]: {
property: string
parents: string[]
parentPath: string
}
} = {} } = {}
const initializeMaps = (structure: Select, path: string[]) => { const initializeMaps = (structure: Select, path: string[]) => {
@@ -127,17 +127,15 @@ export class Configuration {
} }
if (idxSyncData.length > 0) { if (idxSyncData.length > 0) {
if (updatedConfig.length > 0) { const ids = await this.#indexSyncService.list({
const ids = await this.#indexSyncService.list({ entity: idxSyncData.map((c) => c.entity),
entity: updatedConfig.map((c) => c.entity), })
}) idxSyncData.forEach((sync) => {
idxSyncData.forEach((sync) => { const id = ids.find((i) => i.entity === sync.entity)?.id
const id = ids.find((i) => i.entity === sync.entity)?.id if (id) {
if (id) { sync.id = id
sync.id = id }
} })
})
}
await this.#indexSyncService.upsert(idxSyncData) await this.#indexSyncService.upsert(idxSyncData)
} }
@@ -178,13 +178,13 @@ export function generateGraphQLSchema(
// Link table relationships // Link table relationships
const primaryField = doesPrimaryExportSchema const primaryField = doesPrimaryExportSchema
? `${camelToSnakeCase(primary.alias)}: ${toPascalCase( ? `${camelToSnakeCase(primary.alias)}: ${toPascalCase(
composeTableName(primary.serviceName) primary.entity ?? composeTableName(primary.serviceName)
)}` )}`
: "" : ""
const foreignField = doesForeignExportSchema const foreignField = doesForeignExportSchema
? `${camelToSnakeCase(foreign.alias)}: ${toPascalCase( ? `${camelToSnakeCase(foreign.alias)}: ${toPascalCase(
composeTableName(foreign.serviceName) foreign.entity ?? composeTableName(foreign.serviceName)
)}` )}`
: "" : ""
@@ -1,8 +1,6 @@
import { defineJoinerConfig, Modules } from "@medusajs/framework/utils" import { defineJoinerConfig, Modules } from "@medusajs/framework/utils"
import { Price, PriceList, PricePreference, PriceSet } from "@models" import { Price, PriceList, PricePreference, PriceSet } from "@models"
import { default as schema } from "./schema"
export const joinerConfig = defineJoinerConfig(Modules.PRICING, { export const joinerConfig = defineJoinerConfig(Modules.PRICING, {
schema,
models: [PriceSet, PriceList, Price, PricePreference], models: [PriceSet, PriceList, Price, PricePreference],
}) })
@@ -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