feat(index): Index module foundation (#9095)

**What**
Index module foundation

Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>
This commit is contained in:
Adrien de Peretti
2024-09-18 19:04:04 +00:00
committed by GitHub
co-authored by Carlos R. L. Rodrigues
parent 3cfcd075ae
commit 58167b5dfa
53 changed files with 4796 additions and 1201 deletions
@@ -0,0 +1,734 @@
import { makeExecutableSchema } from "@graphql-tools/schema"
import {
cleanGraphQLSchema,
gqlGetFieldsAndRelations,
MedusaModule,
} from "@medusajs/modules-sdk"
import {
JoinerServiceConfigAlias,
ModuleJoinerConfig,
ModuleJoinerRelationship,
} from "@medusajs/types"
import { CommonEvents } from "@medusajs/utils"
import {
SchemaObjectEntityRepresentation,
SchemaObjectRepresentation,
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",
},
}
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 }
): 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: 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: SchemaObjectRepresentation) {
const aliasMap: 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
): [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 SchemaObjectRepresentation
Object.entries(entitiesMap).forEach(([entityName, entityMapValue]) => {
if (!entityMapValue.astNode) {
return
}
processEntity(entityName, {
entitiesMap,
moduleJoinerConfigs,
objectRepresentationRef: objectRepresentation,
})
})
objectRepresentation._schemaPropertiesMap =
buildAliasMap(objectRepresentation)
return [objectRepresentation, entitiesMap]
}
@@ -0,0 +1,46 @@
import { SqlEntityManager } from "@mikro-orm/postgresql"
import {
SchemaObjectRepresentation,
schemaObjectRepresentationPropertiesToOmit,
} from "../types"
export async function createPartitions(
schemaObjectRepresentation: SchemaObjectRepresentation,
manager: SqlEntityManager
): Promise<void> {
const activeSchema = manager.config.get("schema")
? `"${manager.config.get("schema")}".`
: ""
const partitions = Object.keys(schemaObjectRepresentation)
.filter(
(key) =>
!schemaObjectRepresentationPropertiesToOmit.includes(key) &&
schemaObjectRepresentation[key].listeners.length > 0
)
.map((key) => {
const cName = key.toLowerCase()
const part: string[] = []
part.push(
`CREATE TABLE IF NOT EXISTS ${activeSchema}cat_${cName} PARTITION OF ${activeSchema}index_data FOR VALUES IN ('${key}')`
)
for (const parent of schemaObjectRepresentation[key].parents) {
const pKey = `${parent.ref.entity}-${key}`
const pName = `${parent.ref.entity}${key}`.toLowerCase()
part.push(
`CREATE TABLE IF NOT EXISTS ${activeSchema}cat_pivot_${pName} PARTITION OF ${activeSchema}index_relation FOR VALUES IN ('${pKey}')`
)
}
return part
})
.flat()
if (!partitions.length) {
return
}
partitions.push(`analyse ${activeSchema}index_data`)
partitions.push(`analyse ${activeSchema}index_relation`)
await manager.execute(partitions.join("; "))
}
@@ -0,0 +1,19 @@
export const defaultSchema = `
type Product @Listeners(values: ["Product.product.created", "Product.product.updated", "Product.product.deleted"]) {
id: String
title: String
variants: [ProductVariant]
}
type ProductVariant @Listeners(values: ["Product.product-variant.created", "Product.product-variant.updated", "Product.product-variant.deleted"]) {
id: String
product_id: String
sku: String
prices: [Price]
}
type Price @Listeners(values: ["Pricing.price.created", "Pricing.price.updated", "Pricing.price.deleted"]) {
amount: Int
currency_code: String
}
`
@@ -0,0 +1,3 @@
export * from "./query-builder"
export * from "./create-partitions"
export * from "./build-config"
@@ -0,0 +1,628 @@
import { isObject, isString } from "@medusajs/utils"
import { GraphQLList } from "graphql"
import { Knex } from "knex"
import {
OrderBy,
QueryFormat,
QueryOptions,
SchemaObjectRepresentation,
SchemaPropertiesMap,
Select,
} from "../types"
export class QueryBuilder {
private readonly structure: Select
private readonly entityMap: Record<string, any>
private readonly knex: Knex
private readonly selector: QueryFormat
private readonly options?: QueryOptions
private readonly schema: SchemaObjectRepresentation
constructor(args: {
schema: SchemaObjectRepresentation
entityMap: Record<string, any>
knex: Knex
selector: QueryFormat
options?: QueryOptions
}) {
this.schema = args.schema
this.entityMap = args.entityMap
this.selector = args.selector
this.options = args.options
this.knex = args.knex
this.structure = this.selector.select
}
private getStructureKeys(structure) {
return Object.keys(structure ?? {}).filter((key) => key !== "entity")
}
private getEntity(path): SchemaPropertiesMap[0] {
if (!this.schema._schemaPropertiesMap[path]) {
throw new Error(`Could not find entity for path: ${path}`)
}
return this.schema._schemaPropertiesMap[path]
}
private getGraphQLType(path, field) {
const entity = this.getEntity(path)?.ref?.entity
const fieldRef = this.entityMap[entity]._fields[field]
if (!fieldRef) {
throw new Error(`Field ${field} not found in the entityMap.`)
}
let currentType = fieldRef.type
let isArray = false
while (currentType.ofType) {
if (currentType instanceof GraphQLList) {
isArray = true
}
currentType = currentType.ofType
}
return currentType.name + (isArray ? "[]" : "")
}
private transformValueToType(path, field, value) {
if (value === null) {
return null
}
const typeToFn = {
Int: (val) => parseInt(val, 10),
Float: (val) => parseFloat(val),
String: (val) => String(val),
Boolean: (val) => Boolean(val),
ID: (val) => String(val),
Date: (val) => new Date(val).toISOString(),
Time: (val) => new Date(`1970-01-01T${val}Z`).toISOString(),
}
const fullPath = [path, ...field]
const prop = fullPath.pop()
const fieldPath = fullPath.join(".")
const graphqlType = this.getGraphQLType(fieldPath, prop).replace("[]", "")
const fn = typeToFn[graphqlType]
if (Array.isArray(value)) {
return value.map((v) => (!fn ? v : fn(v)))
}
return !fn ? value : fn(value)
}
private getPostgresCastType(path, field) {
const graphqlToPostgresTypeMap = {
Int: "::int",
Float: "::double precision",
Boolean: "::boolean",
Date: "::timestamp",
Time: "::time",
"": "",
}
const fullPath = [path, ...field]
const prop = fullPath.pop()
const fieldPath = fullPath.join(".")
let graphqlType = this.getGraphQLType(fieldPath, prop)
const isList = graphqlType.endsWith("[]")
graphqlType = graphqlType.replace("[]", "")
return (
(graphqlToPostgresTypeMap[graphqlType] ?? "") + (isList ? "[]" : "") ?? ""
)
}
private parseWhere(
aliasMapping: { [path: string]: string },
obj: object,
builder: Knex.QueryBuilder
) {
const OPERATOR_MAP = {
$eq: "=",
$lt: "<",
$gt: ">",
$lte: "<=",
$gte: ">=",
$ne: "!=",
$in: "IN",
$is: "IS",
$like: "LIKE",
$ilike: "ILIKE",
}
const keys = Object.keys(obj)
const getPathAndField = (key: string) => {
const path = key.split(".")
const field = [path.pop()]
while (!aliasMapping[path.join(".")] && path.length > 0) {
field.unshift(path.pop())
}
const attr = path.join(".")
return { field, attr }
}
const getPathOperation = (
attr: string,
path: string[],
value: unknown
): string => {
const partialPath = path.length > 1 ? path.slice(0, -1) : path
const val = this.transformValueToType(attr, partialPath, value)
const result = path.reduceRight((acc, key) => {
return { [key]: acc }
}, val)
return JSON.stringify(result)
}
keys.forEach((key) => {
let value = obj[key]
if ((key === "$and" || key === "$or") && !Array.isArray(value)) {
value = [value]
}
if (key === "$and" && Array.isArray(value)) {
builder.where((qb) => {
value.forEach((cond) => {
qb.andWhere((subBuilder) =>
this.parseWhere(aliasMapping, cond, subBuilder)
)
})
})
} else if (key === "$or" && Array.isArray(value)) {
builder.where((qb) => {
value.forEach((cond) => {
qb.orWhere((subBuilder) =>
this.parseWhere(aliasMapping, cond, subBuilder)
)
})
})
} else if (isObject(value) && !Array.isArray(value)) {
const subKeys = Object.keys(value)
subKeys.forEach((subKey) => {
let operator = OPERATOR_MAP[subKey]
if (operator) {
const { field, attr } = getPathAndField(key)
const nested = new Array(field.length).join("->?")
const subValue = this.transformValueToType(
attr,
field,
value[subKey]
)
const castType = this.getPostgresCastType(attr, field)
const val = operator === "IN" ? subValue : [subValue]
if (operator === "=" && subValue === null) {
operator = "IS"
}
if (operator === "=") {
builder.whereRaw(
`${aliasMapping[attr]}.data @> '${getPathOperation(
attr,
field as string[],
subValue
)}'::jsonb`
)
} else {
builder.whereRaw(
`(${aliasMapping[attr]}.data${nested}->>?)${castType} ${operator} ?`,
[...field, ...val]
)
}
} else {
throw new Error(`Unsupported operator: ${subKey}`)
}
})
} else {
const { field, attr } = getPathAndField(key)
const nested = new Array(field.length).join("->?")
value = this.transformValueToType(attr, field, value)
if (Array.isArray(value)) {
const castType = this.getPostgresCastType(attr, field)
const inPlaceholders = value.map(() => "?").join(",")
builder.whereRaw(
`(${aliasMapping[attr]}.data${nested}->>?)${castType} IN (${inPlaceholders})`,
[...field, ...value]
)
} else {
const operator = value === null ? "IS" : "="
if (operator === "=") {
builder.whereRaw(
`${aliasMapping[attr]}.data @> '${getPathOperation(
attr,
field as string[],
value
)}'::jsonb`
)
} else {
const castType = this.getPostgresCastType(attr, field)
builder.whereRaw(
`(${aliasMapping[attr]}.data${nested}->>?)${castType} ${operator} ?`,
[...field, value]
)
}
}
}
})
return builder
}
private buildQueryParts(
structure: Select,
parentAlias: string,
parentEntity: string,
parentProperty: string,
aliasPath: string[] = [],
level = 0,
aliasMapping: { [path: string]: string } = {}
): string[] {
const currentAliasPath = [...aliasPath, parentProperty].join(".")
const entities = this.getEntity(currentAliasPath)
const mainEntity = entities.ref.entity
const mainAlias = mainEntity.toLowerCase() + level
const allEntities: any[] = []
if (!entities.shortCutOf) {
allEntities.push({
entity: mainEntity,
parEntity: parentEntity,
parAlias: parentAlias,
alias: mainAlias,
})
} else {
const intermediateAlias = entities.shortCutOf.split(".")
for (let i = intermediateAlias.length - 1, x = 0; i >= 0; i--, x++) {
const intermediateEntity = this.getEntity(intermediateAlias.join("."))
intermediateAlias.pop()
if (intermediateEntity.ref.entity === parentEntity) {
break
}
const parentIntermediateEntity = this.getEntity(
intermediateAlias.join(".")
)
const alias =
intermediateEntity.ref.entity.toLowerCase() + level + "_" + x
const parAlias =
parentIntermediateEntity.ref.entity === parentEntity
? parentAlias
: parentIntermediateEntity.ref.entity.toLowerCase() +
level +
"_" +
(x + 1)
if (x === 0) {
aliasMapping[currentAliasPath] = alias
}
allEntities.unshift({
entity: intermediateEntity.ref.entity,
parEntity: parentIntermediateEntity.ref.entity,
parAlias,
alias,
})
}
}
let queryParts: string[] = []
for (const join of allEntities) {
const { alias, entity, parEntity, parAlias } = join
aliasMapping[currentAliasPath] = alias
if (level > 0) {
const subQuery = this.knex.queryBuilder()
const knex = this.knex
subQuery
.select(`${alias}.id`, `${alias}.data`)
.from("index_data AS " + alias)
.join(`index_relation AS ${alias}_ref`, function () {
this.on(
`${alias}_ref.pivot`,
"=",
knex.raw("?", [`${parEntity}-${entity}`])
)
.andOn(`${alias}_ref.parent_id`, "=", `${parAlias}.id`)
.andOn(`${alias}.id`, "=", `${alias}_ref.child_id`)
})
.where(`${alias}.name`, "=", knex.raw("?", [entity]))
const joinWhere = this.selector.joinWhere ?? {}
const joinKey = Object.keys(joinWhere).find((key) => {
const k = key.split(".")
k.pop()
return k.join(".") === currentAliasPath
})
if (joinKey) {
this.parseWhere(
aliasMapping,
{ [joinKey]: joinWhere[joinKey] },
subQuery
)
}
queryParts.push(`LEFT JOIN LATERAL (
${subQuery.toQuery()}
) ${alias} ON TRUE`)
}
}
const children = this.getStructureKeys(structure)
for (const child of children) {
const childStructure = structure[child] as Select
queryParts = queryParts.concat(
this.buildQueryParts(
childStructure,
mainAlias,
mainEntity,
child,
aliasPath.concat(parentProperty),
level + 1,
aliasMapping
)
)
}
return queryParts
}
private buildSelectParts(
structure: Select,
parentProperty: string,
aliasMapping: { [path: string]: string },
aliasPath: string[] = [],
selectParts: object = {}
): object {
const currentAliasPath = [...aliasPath, parentProperty].join(".")
const alias = aliasMapping[currentAliasPath]
selectParts[currentAliasPath] = `${alias}.data`
selectParts[currentAliasPath + ".id"] = `${alias}.id`
const children = this.getStructureKeys(structure)
for (const child of children) {
const childStructure = structure[child] as Select
this.buildSelectParts(
childStructure,
child,
aliasMapping,
aliasPath.concat(parentProperty),
selectParts
)
}
return selectParts
}
private transformOrderBy(arr: (object | string)[]): OrderBy {
const result = {}
const map = new Map()
map.set(true, "ASC")
map.set(1, "ASC")
map.set("ASC", "ASC")
map.set(false, "DESC")
map.set(-1, "DESC")
map.set("DESC", "DESC")
function nested(obj, prefix = "") {
const keys = Object.keys(obj)
if (!keys.length) {
return
} else if (keys.length > 1) {
throw new Error("Order by only supports one key per object.")
}
const key = keys[0]
let value = obj[key]
if (isObject(value)) {
nested(value, prefix + key + ".")
} else {
if (isString(value)) {
value = value.toUpperCase()
}
result[prefix + key] = map.get(value) ?? "ASC"
}
}
arr.forEach((obj) => nested(obj))
return result
}
public buildQuery(countAllResults = true, returnIdOnly = false): string {
const queryBuilder = this.knex.queryBuilder()
const structure = this.structure
const filter = this.selector.where ?? {}
const { orderBy: order, skip, take } = this.options ?? {}
const orderBy = this.transformOrderBy(
(order && !Array.isArray(order) ? [order] : order) ?? []
)
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]
const selectParts = !returnIdOnly
? this.buildSelectParts(rootStructure, rootKey, aliasMapping)
: { [rootKey + ".id"]: `${rootAlias}.id` }
if (countAllResults) {
selectParts["offset_"] = this.knex.raw(
`DENSE_RANK() OVER (ORDER BY ${rootEntity}0.id)`
)
}
queryBuilder.select(selectParts)
queryBuilder.from(`index_data AS ${rootEntity}0`)
joinParts.forEach((joinPart) => {
queryBuilder.joinRaw(joinPart)
})
queryBuilder.where(`${aliasMapping[rootEntity]}.name`, "=", entity)
// 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 alias = aliasMapping[attr]
const direction = orderBy[aliasPath]
queryBuilder.orderByRaw(`${alias}.data->>'${field}' ${direction}`)
}
let sql = `WITH data AS (${queryBuilder.toQuery()})
SELECT * ${
countAllResults ? ", (SELECT max(offset_) FROM data) AS count" : ""
}
FROM data`
let take_ = !isNaN(+take!) ? +take! : 15
let skip_ = !isNaN(+skip!) ? +skip! : 0
if (typeof take === "number" || typeof skip === "number") {
sql += `
WHERE offset_ > ${skip_}
AND offset_ <= ${skip_ + take_}
`
}
return sql
}
public buildObjectFromResultset(
resultSet: Record<string, any>[]
): Record<string, any>[] {
const structure = this.structure
const rootKey = this.getStructureKeys(structure)[0]
const maps: { [key: string]: { [id: string]: Record<string, any> } } = {}
const isListMap: { [path: string]: boolean } = {}
const referenceMap: { [key: string]: any } = {}
const pathDetails: {
[key: string]: { property: string; parents: string[]; parentPath: string }
} = {}
const initializeMaps = (structure: Select, path: string[]) => {
const currentPath = path.join(".")
maps[currentPath] = {}
if (path.length > 1) {
const property = path[path.length - 1]
const parents = path.slice(0, -1)
const parentPath = parents.join(".")
isListMap[currentPath] = !!this.getEntity(currentPath).ref.parents.find(
(p) => p.targetProp === property
)?.isList
pathDetails[currentPath] = { property, parents, parentPath }
}
const children = this.getStructureKeys(structure)
for (const key of children) {
initializeMaps(structure[key] as Select, [...path, key])
}
}
initializeMaps(structure[rootKey] as Select, [rootKey])
function buildReferenceKey(
path: string[],
id: string,
row: Record<string, any>
) {
let current = ""
let key = ""
for (const p of path) {
current += `${p}`
key += row[`${current}.id`] + "."
current += "."
}
return key + id
}
resultSet.forEach((row) => {
for (const path in maps) {
const id = row[`${path}.id`]
// root level
if (!pathDetails[path]) {
if (!maps[path][id]) {
maps[path][id] = row[path] || undefined
}
continue
}
const { property, parents, parentPath } = pathDetails[path]
const referenceKey = buildReferenceKey(parents, id, row)
if (referenceMap[referenceKey]) {
continue
}
maps[path][id] = row[path] || undefined
const parentObj = maps[parentPath][row[`${parentPath}.id`]]
if (!parentObj) {
continue
}
const isList = isListMap[parentPath + "." + property]
if (isList) {
parentObj[property] ??= []
}
if (maps[path][id] !== undefined) {
if (isList) {
parentObj[property].push(maps[path][id])
} else {
parentObj[property] = maps[path][id]
}
}
referenceMap[referenceKey] = true
}
})
return Object.values(maps[rootKey] ?? {})
}
}