Files
medusa-store/packages/modules/index/src/utils/build-config.ts
Adrien de Peretti 3084008fc9 feat(index): Provide a similar API to Query (#9193)
**What**
Align the index engine API to be similar to the Query API

## Example

```ts
        // Benefit from the same level of typing like the remote query

        const { data, metadata } = await indexEngine.query<'product'>({
          fields: [
            "product.*",
            "product.variants.*",
            "product.variants.prices.*",
          ],
          filters: {
            product: {
              variants: {
                prices: {
                  amount: { $gt: 50 },
                },
              },
            },
          },
          pagination: {
            order: {
              product: {
                variants: {
                  prices: {
                    amount: "DESC",
                  },
                },
              },
            },
          },
        })
```
2024-09-20 10:02:42 +00:00

735 lines
23 KiB
TypeScript

import { makeExecutableSchema } from "@graphql-tools/schema"
import {
cleanGraphQLSchema,
gqlGetFieldsAndRelations,
MedusaModule,
} from "@medusajs/modules-sdk"
import {
IndexTypes,
JoinerServiceConfigAlias,
ModuleJoinerConfig,
ModuleJoinerRelationship,
} from "@medusajs/types"
import { CommonEvents } from "@medusajs/utils"
import { schemaObjectRepresentationPropertiesToOmit } from "@types"
import { Kind, ObjectTypeDefinitionNode } from "graphql/index"
export const CustomDirectives = {
Listeners: {
configurationPropertyName: "listeners",
isRequired: true,
name: "Listeners",
directive: "@Listeners",
definition: "directive @Listeners (values: [String!]) on OBJECT",
},
}
export function makeSchemaExecutable(inputSchema: string) {
const { schema: cleanedSchema } = cleanGraphQLSchema(inputSchema)
return makeExecutableSchema({ typeDefs: cleanedSchema })
}
function extractNameFromAlias(
alias: JoinerServiceConfigAlias | JoinerServiceConfigAlias[]
) {
const alias_ = Array.isArray(alias) ? alias[0] : alias
const names = Array.isArray(alias_?.name) ? alias_?.name : [alias_?.name]
return names[0]
}
function retrieveAliasForEntity(entityName: string, aliases) {
aliases = aliases ? (Array.isArray(aliases) ? aliases : [aliases]) : []
for (const alias of aliases) {
const names = Array.isArray(alias.name) ? alias.name : [alias.name]
if (alias.entity === entityName) {
return names[0]
}
for (const name of names) {
if (name.toLowerCase() === entityName.toLowerCase()) {
return name
}
}
}
}
function retrieveModuleAndAlias(entityName, moduleJoinerConfigs) {
let relatedModule
let alias
for (const moduleJoinerConfig of moduleJoinerConfigs) {
const moduleSchema = moduleJoinerConfig.schema
const moduleAliases = moduleJoinerConfig.alias
/**
* If the entity exist in the module schema, then the current module is the
* one we are looking for.
*
* If the module does not have any schema, then we need to base the search
* on the provided aliases. in any case, we try to get both
*/
if (moduleSchema) {
const executableSchema = makeSchemaExecutable(moduleSchema)
const entitiesMap = executableSchema.getTypeMap()
if (entitiesMap[entityName]) {
relatedModule = moduleJoinerConfig
}
}
if (relatedModule && moduleAliases) {
alias = retrieveAliasForEntity(entityName, moduleJoinerConfig.alias)
}
if (relatedModule) {
break
}
}
if (!relatedModule) {
return { relatedModule: null, alias: null }
}
if (!alias) {
throw new Error(
`Index Module error, the module ${relatedModule?.serviceName} has a schema but does not have any alias for the entity ${entityName}. Please add an alias to the module configuration and the entity it correspond to in the args under the entity property.`
)
}
return { relatedModule, alias }
}
// TODO: rename util
function retrieveLinkModuleAndAlias({
primaryEntity,
primaryModuleConfig,
foreignEntity,
foreignModuleConfig,
moduleJoinerConfigs,
}: {
primaryEntity: string
primaryModuleConfig: ModuleJoinerConfig
foreignEntity: string
foreignModuleConfig: ModuleJoinerConfig
moduleJoinerConfigs: ModuleJoinerConfig[]
}): {
entityName: string
alias: string
linkModuleConfig: ModuleJoinerConfig
intermediateEntityNames: string[]
}[] {
const linkModulesMetadata: {
entityName: string
alias: string
linkModuleConfig: ModuleJoinerConfig
intermediateEntityNames: string[]
}[] = []
for (const linkModuleJoinerConfig of moduleJoinerConfigs.filter(
(config) => config.isLink && !config.isReadOnlyLink
)) {
const linkPrimary =
linkModuleJoinerConfig.relationships![0] as ModuleJoinerRelationship
const linkForeign =
linkModuleJoinerConfig.relationships![1] as ModuleJoinerRelationship
if (
linkPrimary.serviceName === primaryModuleConfig.serviceName &&
linkForeign.serviceName === foreignModuleConfig.serviceName
) {
const primaryEntityLinkableKey = linkPrimary.foreignKey
const isTheForeignKeyEntityEqualPrimaryEntity =
primaryModuleConfig.linkableKeys?.[primaryEntityLinkableKey] ===
primaryEntity
const foreignEntityLinkableKey = linkForeign.foreignKey
const isTheForeignKeyEntityEqualForeignEntity =
foreignModuleConfig.linkableKeys?.[foreignEntityLinkableKey] ===
foreignEntity
const linkName = linkModuleJoinerConfig.extends?.find((extend) => {
return (
extend.serviceName === primaryModuleConfig.serviceName &&
extend.relationship.primaryKey === primaryEntityLinkableKey
)
})?.relationship.serviceName
if (!linkName) {
throw new Error(
`Index Module error, unable to retrieve the link module name for the services ${primaryModuleConfig.serviceName} - ${foreignModuleConfig.serviceName}. Please be sure that the extend relationship service name is set correctly`
)
}
if (!linkModuleJoinerConfig.alias?.[0]?.entity) {
throw new Error(
`Index Module error, unable to retrieve the link module entity name for the services ${primaryModuleConfig.serviceName} - ${foreignModuleConfig.serviceName}. Please be sure that the link module alias has an entity property in the args.`
)
}
if (
isTheForeignKeyEntityEqualPrimaryEntity &&
isTheForeignKeyEntityEqualForeignEntity
) {
/**
* The link will become the parent of the foreign entity, that is why the alias must be the one that correspond to the extended foreign module
*/
linkModulesMetadata.push({
entityName: linkModuleJoinerConfig.alias[0].entity,
alias: extractNameFromAlias(linkModuleJoinerConfig.alias),
linkModuleConfig: linkModuleJoinerConfig,
intermediateEntityNames: [],
})
} else {
const intermediateEntityName =
foreignModuleConfig.linkableKeys![foreignEntityLinkableKey]
if (!foreignModuleConfig.schema) {
throw new Error(
`Index Module error, unable to retrieve the intermediate entity name for the services ${primaryModuleConfig.serviceName} - ${foreignModuleConfig.serviceName}. Please be sure that the foreign module ${foreignModuleConfig.serviceName} has a schema.`
)
}
const executableSchema = makeSchemaExecutable(
foreignModuleConfig.schema
)
const entitiesMap = executableSchema.getTypeMap()
let intermediateEntities: string[] = []
let foundCount = 0
const isForeignEntityChildOfIntermediateEntity = (
entityName
): boolean => {
for (const entityType of Object.values(entitiesMap)) {
if (
entityType.astNode?.kind === "ObjectTypeDefinition" &&
entityType.astNode?.fields?.some((field) => {
return (field.type as any)?.type?.name?.value === entityName
})
) {
if (entityType.name === intermediateEntityName) {
++foundCount
return true
} else {
const test = isForeignEntityChildOfIntermediateEntity(
entityType.name
)
if (test) {
intermediateEntities.push(entityType.name)
}
}
}
}
return false
}
isForeignEntityChildOfIntermediateEntity(foreignEntity)
if (foundCount !== 1) {
throw new Error(
`Index Module error, unable to retrieve the intermediate entities for the services ${primaryModuleConfig.serviceName} - ${foreignModuleConfig.serviceName} between ${foreignEntity} and ${intermediateEntityName}. Multiple paths or no path found. Please check your schema in ${foreignModuleConfig.serviceName}`
)
}
intermediateEntities.push(intermediateEntityName!)
/**
* The link will become the parent of the foreign entity, that is why the alias must be the one that correspond to the extended foreign module
*/
linkModulesMetadata.push({
entityName: linkModuleJoinerConfig.alias[0].entity,
alias: extractNameFromAlias(linkModuleJoinerConfig.alias),
linkModuleConfig: linkModuleJoinerConfig,
intermediateEntityNames: intermediateEntities,
})
}
}
}
if (!linkModulesMetadata.length) {
// TODO: change to use the logger
console.warn(
`Index Module warning, unable to retrieve the link module that correspond to the entities ${primaryEntity} - ${foreignEntity}.`
)
}
return linkModulesMetadata
}
function getObjectRepresentationRef(
entityName,
{ objectRepresentationRef }
): IndexTypes.SchemaObjectEntityRepresentation {
return (objectRepresentationRef[entityName] ??= {
entity: entityName,
parents: [],
alias: "",
listeners: [],
moduleConfig: null,
fields: [],
})
}
function setCustomDirectives(currentObjectRepresentationRef, directives) {
for (const customDirectiveConfiguration of Object.values(CustomDirectives)) {
const directive = directives.find(
(typeDirective) =>
typeDirective.name.value === customDirectiveConfiguration.name
)
if (!directive) {
return
}
// Only support array directive value for now
currentObjectRepresentationRef[
customDirectiveConfiguration.configurationPropertyName
] = ((directive.arguments[0].value as any)?.values ?? []).map(
(v) => v.value
)
}
}
function processEntity(
entityName: string,
{
entitiesMap,
moduleJoinerConfigs,
objectRepresentationRef,
}: {
entitiesMap: any
moduleJoinerConfigs: ModuleJoinerConfig[]
objectRepresentationRef: IndexTypes.SchemaObjectRepresentation
}
) {
/**
* Get the reference to the object representation for the current entity.
*/
const currentObjectRepresentationRef = getObjectRepresentationRef(
entityName,
{
objectRepresentationRef,
}
)
/**
* Retrieve and set the custom directives for the current entity.
*/
setCustomDirectives(
currentObjectRepresentationRef,
entitiesMap[entityName].astNode?.directives ?? []
)
currentObjectRepresentationRef.fields =
gqlGetFieldsAndRelations(entitiesMap, entityName) ?? []
/**
* Retrieve the module and alias for the current entity.
*/
const { relatedModule: currentEntityModule, alias } = retrieveModuleAndAlias(
entityName,
moduleJoinerConfigs
)
if (
!currentEntityModule &&
currentObjectRepresentationRef.listeners.length > 0
) {
const example = JSON.stringify({
alias: [
{
name: "entity-alias",
entity: entityName,
},
],
})
throw new Error(
`Index Module error, unable to retrieve the module that corresponds to the entity ${entityName}.\nPlease add the entity to the module schema or add an alias to the joiner config like the example below:\n${example}`
)
}
if (currentEntityModule) {
objectRepresentationRef._serviceNameModuleConfigMap[
currentEntityModule.serviceName
] = currentEntityModule
currentObjectRepresentationRef.moduleConfig = currentEntityModule
currentObjectRepresentationRef.alias = alias
}
/**
* Retrieve the parent entities for the current entity.
*/
const schemaParentEntity = Object.values(entitiesMap).filter((value: any) => {
return (
value.astNode &&
(value.astNode as ObjectTypeDefinitionNode).fields?.some((field: any) => {
let currentType = field.type
while (currentType.type) {
currentType = currentType.type
}
return currentType.name?.value === entityName
})
)
})
if (!schemaParentEntity.length) {
return
}
/**
* If the current entity has parent entities, then we need to process them.
*/
const parentEntityNames = schemaParentEntity.map((parent: any) => {
return parent.name
})
for (const parent of parentEntityNames) {
/**
* Retrieve the parent entity field in the schema
*/
const entityFieldInParent = (
entitiesMap[parent].astNode as any
)?.fields?.find((field) => {
let currentType = field.type
while (currentType.type) {
currentType = currentType.type
}
return currentType.name?.value === entityName
})
const isEntityListInParent =
entityFieldInParent.type.kind === Kind.LIST_TYPE
const entityTargetPropertyNameInParent = entityFieldInParent.name.value
/**
* Retrieve the parent entity object representation reference.
*/
const parentObjectRepresentationRef = getObjectRepresentationRef(parent, {
objectRepresentationRef,
})
const parentModuleConfig = parentObjectRepresentationRef.moduleConfig
// If the entity is not part of any module, just set the parent and continue
if (!currentObjectRepresentationRef.moduleConfig) {
currentObjectRepresentationRef.parents.push({
ref: parentObjectRepresentationRef,
targetProp: entityTargetPropertyNameInParent,
isList: isEntityListInParent,
})
continue
}
/**
* If the parent entity and the current entity are part of the same servive then configure the parent and
* add the parent id as a field to the current entity.
*/
if (
currentObjectRepresentationRef.moduleConfig.serviceName ===
parentModuleConfig.serviceName ||
parentModuleConfig.isLink
) {
currentObjectRepresentationRef.parents.push({
ref: parentObjectRepresentationRef,
targetProp: entityTargetPropertyNameInParent,
isList: isEntityListInParent,
})
currentObjectRepresentationRef.fields.push(
parentObjectRepresentationRef.alias + ".id"
)
} else {
/**
* If the parent entity and the current entity are not part of the same service then we need to
* find the link module that join them.
*/
const linkModuleMetadatas = retrieveLinkModuleAndAlias({
primaryEntity: parentObjectRepresentationRef.entity,
primaryModuleConfig: parentModuleConfig,
foreignEntity: currentObjectRepresentationRef.entity,
foreignModuleConfig: currentEntityModule,
moduleJoinerConfigs,
})
for (const linkModuleMetadata of linkModuleMetadatas) {
const linkObjectRepresentationRef = getObjectRepresentationRef(
linkModuleMetadata.entityName,
{ objectRepresentationRef }
)
objectRepresentationRef._serviceNameModuleConfigMap[
linkModuleMetadata.linkModuleConfig.serviceName ||
linkModuleMetadata.entityName
] = currentEntityModule
/**
* Add the schema parent entity as a parent to the link module and configure it.
*/
linkObjectRepresentationRef.parents = [
{
ref: parentObjectRepresentationRef,
targetProp: linkModuleMetadata.alias,
},
]
linkObjectRepresentationRef.alias = linkModuleMetadata.alias
linkObjectRepresentationRef.listeners = [
`${linkModuleMetadata.entityName}.${CommonEvents.ATTACHED}`,
`${linkModuleMetadata.entityName}.${CommonEvents.DETACHED}`,
]
linkObjectRepresentationRef.moduleConfig =
linkModuleMetadata.linkModuleConfig
linkObjectRepresentationRef.fields = [
"id",
...linkModuleMetadata.linkModuleConfig
.relationships!.map(
(relationship) =>
[
parentModuleConfig.serviceName,
currentEntityModule.serviceName,
].includes(relationship.serviceName) && relationship.foreignKey
)
.filter((v): v is string => Boolean(v)),
]
/**
* If the current entity is not the entity that is used to join the link module and the parent entity
* then we need to add the new entity that join them and then add the link as its parent
* before setting the new entity as the true parent of the current entity.
*/
for (
let i = linkModuleMetadata.intermediateEntityNames.length - 1;
i >= 0;
--i
) {
const intermediateEntityName =
linkModuleMetadata.intermediateEntityNames[i]
const isLastIntermediateEntity =
i === linkModuleMetadata.intermediateEntityNames.length - 1
const parentIntermediateEntityRef = isLastIntermediateEntity
? linkObjectRepresentationRef
: objectRepresentationRef[
linkModuleMetadata.intermediateEntityNames[i + 1]
]
const {
relatedModule: intermediateEntityModule,
alias: intermediateEntityAlias,
} = retrieveModuleAndAlias(
intermediateEntityName,
moduleJoinerConfigs
)
const intermediateEntityObjectRepresentationRef =
getObjectRepresentationRef(intermediateEntityName, {
objectRepresentationRef,
})
objectRepresentationRef._serviceNameModuleConfigMap[
intermediateEntityModule.serviceName
] = intermediateEntityModule
intermediateEntityObjectRepresentationRef.parents.push({
ref: parentIntermediateEntityRef,
targetProp: intermediateEntityAlias,
isList: true, // TODO: check if it is a list in retrieveLinkModuleAndAlias and return the intermediate entity names + isList for each
})
intermediateEntityObjectRepresentationRef.alias =
intermediateEntityAlias
intermediateEntityObjectRepresentationRef.listeners = [
intermediateEntityName + "." + CommonEvents.CREATED,
intermediateEntityName + "." + CommonEvents.UPDATED,
]
intermediateEntityObjectRepresentationRef.moduleConfig =
intermediateEntityModule
intermediateEntityObjectRepresentationRef.fields = ["id"]
/**
* We push the parent id only between intermediate entities but not between intermediate and link
*/
if (!isLastIntermediateEntity) {
intermediateEntityObjectRepresentationRef.fields.push(
parentIntermediateEntityRef.alias + ".id"
)
}
}
/**
* If there is any intermediate entity then we need to set the last one as the parent field for the current entity.
* otherwise there is not need to set the link id field into the current entity.
*/
let currentParentIntermediateRef = linkObjectRepresentationRef
if (linkModuleMetadata.intermediateEntityNames.length) {
currentParentIntermediateRef =
objectRepresentationRef[
linkModuleMetadata.intermediateEntityNames[0]
]
currentObjectRepresentationRef.fields.push(
currentParentIntermediateRef.alias + ".id"
)
}
currentObjectRepresentationRef.parents.push({
ref: currentParentIntermediateRef,
inSchemaRef: parentObjectRepresentationRef,
targetProp: entityTargetPropertyNameInParent,
isList: isEntityListInParent,
})
}
}
}
}
/**
* Build a special object which will be used to retrieve the correct
* object representation using path tree
*
* @example
* {
* _schemaPropertiesMap: {
* "product": <ProductRef>
* "product.variants": <ProductVariantRef>
* }
* }
*/
function buildAliasMap(
objectRepresentation: IndexTypes.SchemaObjectRepresentation
) {
const aliasMap: IndexTypes.SchemaObjectRepresentation["_schemaPropertiesMap"] =
{}
function recursivelyBuildAliasPath(
current,
alias = "",
aliases: { alias: string; shortCutOf?: string }[] = []
): { alias: string; shortCutOf?: string }[] {
if (current.parents?.length) {
for (const parentEntity of current.parents) {
/**
* Here we build the alias from child to parent to get it as parent to child
*/
const _aliases = recursivelyBuildAliasPath(
parentEntity.ref,
`${parentEntity.targetProp}${alias ? "." + alias : ""}`
).map((alias) => ({ alias: alias.alias }))
aliases.push(..._aliases)
/**
* Now if there is a inSchemaRef it means that we had inferred a link module
* and we want to get the alias path as it would be in the schema provided
* and it become the short cut path of the full path above
*/
if (parentEntity.inSchemaRef) {
const shortCutOf = _aliases.map((a) => a.alias)[0]
const _aliasesShortCut = recursivelyBuildAliasPath(
parentEntity.inSchemaRef,
`${parentEntity.targetProp}${alias ? "." + alias : ""}`
).map((alias_) => {
return {
alias: alias_.alias,
// It has to be the same entry point
shortCutOf:
shortCutOf.split(".")[0] === alias_.alias.split(".")[0]
? shortCutOf
: undefined,
}
})
aliases.push(..._aliasesShortCut)
}
}
}
aliases.push({ alias: current.alias + (alias ? "." + alias : "") })
return aliases
}
for (const objectRepresentationKey of Object.keys(
objectRepresentation
).filter(
(key) => !schemaObjectRepresentationPropertiesToOmit.includes(key)
)) {
const entityRepresentationRef =
objectRepresentation[objectRepresentationKey]
const aliases = recursivelyBuildAliasPath(entityRepresentationRef)
for (const alias of aliases) {
aliasMap[alias.alias] = {
ref: entityRepresentationRef,
}
if (alias.shortCutOf) {
aliasMap[alias.alias]["shortCutOf"] = alias.shortCutOf
}
}
}
return aliasMap
}
/**
* This util build an internal representation object from the provided schema.
* It will resolve all modules, fields, link module representation to build
* the appropriate representation for the index module.
*
* This representation will be used to re construct the expected output object from a search
* but can also be used for anything since the relation tree is available through ref.
*
* @param schema
*/
export function buildSchemaObjectRepresentation(
schema
): [IndexTypes.SchemaObjectRepresentation, Record<string, any>] {
const moduleJoinerConfigs = MedusaModule.getAllJoinerConfigs()
const augmentedSchema = CustomDirectives.Listeners.definition + schema
const executableSchema = makeSchemaExecutable(augmentedSchema)
const entitiesMap = executableSchema.getTypeMap()
const objectRepresentation = {
_serviceNameModuleConfigMap: {},
} as IndexTypes.SchemaObjectRepresentation
Object.entries(entitiesMap).forEach(([entityName, entityMapValue]) => {
if (!entityMapValue.astNode) {
return
}
processEntity(entityName, {
entitiesMap,
moduleJoinerConfigs,
objectRepresentationRef: objectRepresentation,
})
})
objectRepresentation._schemaPropertiesMap =
buildAliasMap(objectRepresentation)
return [objectRepresentation, entitiesMap]
}