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:
Carlos R. L. Rodrigues
2025-04-29 07:10:31 -03:00
committed by GitHub
parent 8a3f639f01
commit b868a4ef4d
37 changed files with 3285 additions and 755 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -9,6 +9,8 @@ export async function createPartitions(
const activeSchema = manager.config.get("schema")
? `"${manager.config.get("schema")}".`
: ""
const createdPartitions: Set<string> = new Set()
const partitions = Object.keys(schemaObjectRepresentation)
.filter(
(key) =>
@@ -17,16 +19,30 @@ export async function createPartitions(
)
.map((key) => {
const cName = key.toLowerCase()
if (createdPartitions.has(cName)) {
return []
}
createdPartitions.add(cName)
const part: string[] = []
part.push(
`CREATE TABLE IF NOT EXISTS ${activeSchema}cat_${cName} PARTITION OF ${activeSchema}index_data FOR VALUES IN ('${key}')`
)
for (const parent of schemaObjectRepresentation[key].parents) {
const pKey = `${parent.ref.entity}-${key}`
const pName = `${parent.ref.entity}${key}`.toLowerCase()
if (parent.isInverse) {
continue
}
const pName = `cat_pivot_${parent.ref.entity}${key}`.toLowerCase()
if (createdPartitions.has(pName)) {
continue
}
createdPartitions.add(pName)
part.push(
`CREATE TABLE IF NOT EXISTS ${activeSchema}cat_pivot_${pName} PARTITION OF ${activeSchema}index_relation FOR VALUES IN ('${pKey}')`
`CREATE TABLE IF NOT EXISTS ${activeSchema}${pName} PARTITION OF ${activeSchema}index_relation FOR VALUES IN ('${parent.ref.entity}-${key}')`
)
}
return part
@@ -58,11 +74,14 @@ export async function createPartitions(
`CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_cat_${cName}_id" ON ${activeSchema}cat_${cName} ("id")`
)
// create child id index on pivot partitions
for (const parent of schemaObjectRepresentation[key].parents) {
const pName = `${parent.ref.entity}${key}`.toLowerCase()
if (parent.isInverse) {
continue
}
const pName = `cat_pivot_${parent.ref.entity}${key}`.toLowerCase()
part.push(
`CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_cat_pivot_${pName}_child_id" ON ${activeSchema}cat_pivot_${pName} ("child_id")`
`CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_${pName}_child_id" ON ${activeSchema}${pName} ("child_id")`
)
}
@@ -70,18 +89,25 @@ export async function createPartitions(
})
.flat()
// Execute index creation commands separately to avoid blocking
for (const cmd of indexCreationCommands) {
try {
await manager.execute(cmd)
} catch (error) {
// Log error but continue with other indexes
console.error(`Failed to create index: ${error.message}`)
}
}
partitions.push(`analyse ${activeSchema}index_data`)
partitions.push(`analyse ${activeSchema}index_relation`)
// Create count estimate function
partitions.push(`
CREATE OR REPLACE FUNCTION count_estimate(query text) RETURNS bigint AS $$
DECLARE
plan jsonb;
BEGIN
EXECUTE 'EXPLAIN (FORMAT JSON) ' || query INTO plan;
RETURN (plan->0->'Plan'->>'Plan Rows')::bigint;
END;
$$ LANGUAGE plpgsql;
`)
await manager.execute(partitions.join("; "))
}

View File

@@ -2,7 +2,7 @@ import { Modules } from "@medusajs/utils"
export const defaultSchema = `
type Product @Listeners(values: ["${Modules.PRODUCT}.product.created", "${Modules.PRODUCT}.product.updated", "${Modules.PRODUCT}.product.deleted"]) {
id: String
id: ID
title: String
handle: String
status: String
@@ -18,7 +18,7 @@ export const defaultSchema = `
}
type ProductVariant @Listeners(values: ["${Modules.PRODUCT}.product-variant.created", "${Modules.PRODUCT}.product-variant.updated", "${Modules.PRODUCT}.product-variant.deleted"]) {
id: String
id: ID
product_id: String
sku: String
@@ -26,13 +26,13 @@ export const defaultSchema = `
}
type Price @Listeners(values: ["${Modules.PRICING}.price.created", "${Modules.PRICING}.price.updated", "${Modules.PRICING}.price.deleted"]) {
id: String
id: ID
amount: Float
currency_code: String
}
type SalesChannel @Listeners(values: ["${Modules.SALES_CHANNEL}.sales_channel.created", "${Modules.SALES_CHANNEL}.sales_channel.updated", "${Modules.SALES_CHANNEL}.sales_channel.deleted"]) {
id: String
id: ID
is_disabled: Boolean
}
`

View File

@@ -1,18 +1,18 @@
import { MedusaModule } from "@medusajs/framework/modules-sdk"
import {
FileSystem,
GraphQLUtils,
gqlSchemaToTypes as ModulesSdkGqlSchemaToTypes,
} from "@medusajs/framework/utils"
import { join } from "path"
import * as process from "process"
import { CustomDirectives, makeSchemaExecutable } from "./build-config"
export async function gqlSchemaToTypes(schema: string) {
const augmentedSchema = CustomDirectives.Listeners.definition + schema
const executableSchema = makeSchemaExecutable(augmentedSchema)!
export async function gqlSchemaToTypes(
executableSchema: GraphQLUtils.GraphQLSchema
) {
const filename = "index-service-entry-points"
const filenameWithExt = filename + ".d.ts"
const dir = join(process.cwd(), ".medusa")
const dir = join(process.cwd(), ".medusa/types")
await ModulesSdkGqlSchemaToTypes({
schema: executableSchema,

View File

@@ -1,15 +1,42 @@
import { IndexTypes } from "@medusajs/framework/types"
import {
GraphQLUtils,
isDefined,
isObject,
isPresent,
isString,
unflattenObjectKeys,
} from "@medusajs/framework/utils"
import { Knex } from "@mikro-orm/knex"
import { OrderBy, QueryFormat, QueryOptions, Select } from "@types"
function escapeJsonPathString(val: string): string {
// Escape for JSONPath string
return val.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/'/g, "\\'")
}
function buildSafeJsonPathQuery(
field: string,
operator: string,
value: any
): string {
let jsonPathOperator = operator
if (operator === "=") {
jsonPathOperator = "=="
} else if (operator.toUpperCase().includes("LIKE")) {
jsonPathOperator = "like_regex"
}
if (typeof value === "string") {
let val = value
if (jsonPathOperator === "like_regex") {
// Convert SQL LIKE wildcards to regex
val = val.replace(/%/g, ".*").replace(/_/g, ".")
}
value = `"${escapeJsonPathString(val)}"`
}
return `$.${field} ${jsonPathOperator} ${value}`
}
export const OPERATOR_MAP = {
$eq: "=",
$lt: "<",
@@ -91,17 +118,10 @@ export class QueryBuilder {
throw new Error(`Field ${field} is not indexed.`)
}
let currentType = fieldRef.type
let isArray = false
while (currentType.ofType) {
if (currentType instanceof GraphQLUtils.GraphQLList) {
isArray = true
}
currentType = currentType.ofType
}
return currentType.name + (isArray ? "[]" : "")
const fieldType = fieldRef.type.toString()
const isArray = fieldType.startsWith("[")
const currentType = fieldType.replace(/\[|\]|\!/g, "")
return currentType + (isArray ? "[]" : "")
}
private transformValueToType(path, field, value) {
@@ -244,9 +264,8 @@ export class QueryBuilder {
field,
value[subKey]
)
const castType = this.getPostgresCastType(attr, [field]).cast
const val = operator === "IN" ? subValue : [subValue]
let val = operator === "IN" ? subValue : [subValue]
if (operator === "=" && subValue === null) {
operator = "IS"
} else if (operator === "!=" && subValue === null) {
@@ -254,18 +273,65 @@ export class QueryBuilder {
}
if (operator === "=") {
builder.whereRaw(
`${aliasMapping[attr]}.data @> '${getPathOperation(
attr,
field as string[],
subValue
)}'::jsonb`
)
const hasId = field[field.length - 1] === "id"
if (hasId) {
builder.whereRaw(`${aliasMapping[attr]}.id = ?`, subValue)
} else {
builder.whereRaw(
`${aliasMapping[attr]}.data @> '${getPathOperation(
attr,
field as string[],
subValue
)}'::jsonb`
)
}
} else if (operator === "IN") {
if (val && !Array.isArray(val)) {
val = [val]
}
if (!val || val.length === 0) {
return
}
const inPlaceholders = val.map(() => "?").join(",")
const hasId = field[field.length - 1] === "id"
if (hasId) {
builder.whereRaw(
`${aliasMapping[attr]}.id IN (${inPlaceholders})`,
val
)
} else {
const targetField = field[field.length - 1] as string
const jsonbValues = val.map((item) =>
JSON.stringify({
[targetField]: item === null ? null : item,
})
)
builder.whereRaw(
`${aliasMapping[attr]}.data${nested} @> ANY(ARRAY[${inPlaceholders}]::JSONB[])`,
jsonbValues
)
}
} else {
builder.whereRaw(
`(${aliasMapping[attr]}.data${nested}->>?)${castType} ${operator} ?`,
[...field, ...val]
)
const potentialIdFields = field[field.length - 1]
const hasId = potentialIdFields === "id"
if (hasId) {
builder.whereRaw(`(${aliasMapping[attr]}.id) ${operator} ?`, [
...val,
])
} else {
const targetField = field[field.length - 1] as string
const jsonPath = buildSafeJsonPathQuery(
targetField,
operator,
val[0]
)
builder.whereRaw(`${aliasMapping[attr]}.data${nested} @@ ?`, [
jsonPath,
])
}
}
} else {
throw new Error(`Unsupported operator: ${subKey}`)
@@ -281,29 +347,60 @@ export class QueryBuilder {
return
}
const castType = this.getPostgresCastType(attr, field).cast
const inPlaceholders = value.map(() => "?").join(",")
builder.whereRaw(
`(${aliasMapping[attr]}.data${nested}->>?)${castType} IN (${inPlaceholders})`,
[...field, ...value]
)
} else if (isDefined(value)) {
const operator = value === null ? "IS" : "="
if (operator === "=") {
const hasId = field[field.length - 1] === "id"
if (hasId) {
builder.whereRaw(
`${aliasMapping[attr]}.data @> '${getPathOperation(
attr,
field as string[],
value
)}'::jsonb`
`${aliasMapping[attr]}.id IN (${inPlaceholders})`,
[...value]
)
} else {
const castType = this.getPostgresCastType(attr, field).cast
builder.whereRaw(
`(${aliasMapping[attr]}.data${nested}->>?)${castType} ${operator} ?`,
[...field, value]
const jsonbValues = value.map((item) =>
JSON.stringify({ [nested]: item === null ? null : item })
)
builder.whereRaw(
`${aliasMapping[attr]}.data IN ANY(ARRAY[${inPlaceholders}]::JSONB[])`,
jsonbValues
)
}
} else if (isDefined(value)) {
let operator = "="
if (operator === "=") {
const hasId = field[field.length - 1] === "id"
if (hasId) {
builder.whereRaw(`${aliasMapping[attr]}.id = ?`, value)
} else {
builder.whereRaw(
`${aliasMapping[attr]}.data @> '${getPathOperation(
attr,
field as string[],
value
)}'::jsonb`
)
}
} else {
if (value === null) {
operator = "IS"
}
const hasId = field[field.length - 1] === "id"
if (hasId) {
builder.whereRaw(`(${aliasMapping[attr]}.id) ${operator} ?`, [
value,
])
} else {
const targetField = field[field.length - 1] as string
const jsonPath = buildSafeJsonPathQuery(
targetField,
operator,
value
)
builder.whereRaw(`${aliasMapping[attr]}.data${nested} @@ ?`, [
jsonPath,
])
}
}
}
}
@@ -312,14 +409,15 @@ export class QueryBuilder {
return builder
}
private getShortAlias(aliasMapping, alias: string) {
private getShortAlias(aliasMapping, alias, level = 0) {
aliasMapping.__aliasIndex ??= 0
if (aliasMapping[alias]) {
return aliasMapping[alias]
}
aliasMapping[alias] = "t_" + aliasMapping.__aliasIndex++ + "_"
aliasMapping[alias] =
"t_" + aliasMapping.__aliasIndex++ + (level > 0 ? `_${level}` : "")
return aliasMapping[alias]
}
@@ -327,7 +425,7 @@ export class QueryBuilder {
private buildQueryParts(
structure: Select,
parentAlias: string,
parentEntity: string,
parentEntity: IndexTypes.SchemaObjectEntityRepresentation["parents"][0],
parentProperty: string,
aliasPath: string[] = [],
level = 0,
@@ -337,23 +435,30 @@ export class QueryBuilder {
const isSelectableField = this.allSchemaFields.has(parentProperty)
const entities = this.getEntity(currentAliasPath, false)
const entityRef = entities?.ref!
// !entityRef.alias means the object has not table, it's a nested object
if (isSelectableField || !entities || !entityRef?.alias) {
if (isSelectableField || !entities || !entities?.ref?.alias) {
// We are currently selecting a specific field of the parent entity or the entity is not found on the index schema
// We don't need to build the query parts for this as there is no join
return []
}
const mainEntity = entityRef.entity
const mainAlias =
this.getShortAlias(aliasMapping, mainEntity.toLowerCase()) + level
const mainEntity = entities
const mainAlias = this.getShortAlias(
aliasMapping,
mainEntity.ref.entity.toLowerCase(),
level
)
const allEntities: any[] = []
const allEntities: {
entity: IndexTypes.SchemaPropertiesMap[0]
parEntity: IndexTypes.SchemaObjectEntityRepresentation["parents"][0]
parAlias: string
alias: string
}[] = []
if (!entities.shortCutOf) {
allEntities.push({
entity: mainEntity,
entity: entities,
parEntity: parentEntity,
parAlias: parentAlias,
alias: mainAlias,
@@ -372,7 +477,7 @@ export class QueryBuilder {
intermediateAlias.pop()
if (intermediateEntity.ref.entity === parentEntity) {
if (intermediateEntity.ref.entity === parentEntity?.ref.entity) {
break
}
@@ -383,20 +488,20 @@ export class QueryBuilder {
const alias =
this.getShortAlias(
aliasMapping,
intermediateEntity.ref.entity.toLowerCase()
intermediateEntity.ref.entity.toLowerCase(),
level
) +
level +
"_" +
x
const parAlias =
parentIntermediateEntity.ref.entity === parentEntity
parentIntermediateEntity.ref.entity === parentEntity?.ref.entity
? parentAlias
: this.getShortAlias(
aliasMapping,
parentIntermediateEntity.ref.entity.toLowerCase()
parentIntermediateEntity.ref.entity.toLowerCase(),
level
) +
level +
"_" +
(x + 1)
@@ -405,8 +510,9 @@ export class QueryBuilder {
}
allEntities.unshift({
entity: intermediateEntity.ref.entity,
parEntity: parentIntermediateEntity.ref.entity,
entity: intermediateEntity as any,
parEntity:
parentIntermediateEntity as IndexTypes.SchemaObjectEntityRepresentation["parents"][0],
parAlias,
alias,
})
@@ -421,18 +527,41 @@ export class QueryBuilder {
aliasMapping[currentAliasPath] = alias
if (level > 0) {
const cName = entity.toLowerCase()
const pName = `${parEntity}${entity}`.toLowerCase()
const cName = entity.ref.entity.toLowerCase()
let joinTable = `cat_${cName} AS ${alias}`
const pivotTable = `cat_pivot_${pName}`
joinBuilder.leftJoin(
`${pivotTable} AS ${alias}_ref`,
`${alias}_ref.parent_id`,
`${parAlias}.id`
)
joinBuilder.leftJoin(joinTable, `${alias}.id`, `${alias}_ref.child_id`)
if (entity.isInverse || parEntity.isInverse) {
const pName =
`${entity.ref.entity}${parEntity.ref.entity}`.toLowerCase()
const pivotTable = `cat_pivot_${pName}`
joinBuilder.leftJoin(
`${pivotTable} AS ${alias}_ref`,
`${alias}_ref.child_id`,
`${parAlias}.id`
)
joinBuilder.leftJoin(
joinTable,
`${alias}.id`,
`${alias}_ref.parent_id`
)
} else {
const pName =
`${parEntity.ref.entity}${entity.ref.entity}`.toLowerCase()
const pivotTable = `cat_pivot_${pName}`
joinBuilder.leftJoin(
`${pivotTable} AS ${alias}_ref`,
`${alias}_ref.parent_id`,
`${parAlias}.id`
)
joinBuilder.leftJoin(
joinTable,
`${alias}.id`,
`${alias}_ref.child_id`
)
}
const joinWhere = this.selector.joinWhere ?? {}
const joinKey = Object.keys(joinWhere).find((key) => {
@@ -441,7 +570,7 @@ export class QueryBuilder {
const curPath = k.join(".")
if (curPath === currentAliasPath) {
const relEntity = this.getEntity(curPath, false)
return relEntity?.ref?.entity === entity
return relEntity?.ref?.entity === entity.ref.entity
}
return false
@@ -469,7 +598,7 @@ export class QueryBuilder {
this.buildQueryParts(
childStructure,
mainAlias,
mainEntity,
mainEntity as any,
child,
aliasPath.concat(parentProperty),
level + 1,
@@ -499,9 +628,14 @@ export class QueryBuilder {
const parentAliasPath = aliasPath.join(".")
const alias = aliasMapping[parentAliasPath]
delete selectParts[parentAliasPath]
selectParts[currentAliasPath] = this.knex.raw(
`${alias}.data->'${parentProperty}'`
)
if (parentProperty === "id") {
selectParts[currentAliasPath] = `${alias}.id`
} else {
selectParts[currentAliasPath] = this.knex.raw(
`${alias}.data->'${parentProperty}'`
)
}
return selectParts
}
@@ -572,9 +706,7 @@ export class QueryBuilder {
hasPagination?: boolean
hasCount?: boolean
returnIdOnly?: boolean
}): string {
const queryBuilder = this.knex.queryBuilder()
}): { sql: string; sqlCount?: string } {
const selectOnlyStructure = this.selector.select
const structure = this.requestedFields
const filter = this.selector.where ?? {}
@@ -584,17 +716,19 @@ export class QueryBuilder {
const orderBy = this.transformOrderBy(
(order && !Array.isArray(order) ? [order] : order) ?? []
)
const take_ = !isNaN(+take!) ? +take! : 15
const skip_ = !isNaN(+skip!) ? +skip! : 0
const rootKey = this.getStructureKeys(structure)[0]
const rootStructure = structure[rootKey] as Select
const entity = this.getEntity(rootKey)!.ref.entity
const rootEntity = entity.toLowerCase()
const entity = this.getEntity(rootKey)!
const rootEntity = entity.ref.entity.toLowerCase()
const aliasMapping: { [path: string]: string } = {}
let hasTextSearch: boolean = false
let textSearchQuery: string | null = null
const searchQueryFilterProp = `${rootEntity}.q`
const searchQueryFilterProp = `${rootKey}.q`
if (searchQueryFilterProp in filter) {
if (!filter[searchQueryFilterProp]) {
@@ -606,10 +740,18 @@ export class QueryBuilder {
}
}
const filterSortStructure =
unflattenObjectKeys({
...(this.rawConfig?.filters
? unflattenObjectKeys(this.rawConfig?.filters)
: {}),
...orderBy,
})[rootKey] ?? {}
const joinParts = this.buildQueryParts(
rootStructure,
filterSortStructure,
"",
entity,
entity as IndexTypes.SchemaObjectEntityRepresentation["parents"][0],
rootKey,
[],
0,
@@ -617,35 +759,72 @@ export class QueryBuilder {
)
const rootAlias = aliasMapping[rootKey]
const selectParts = !returnIdOnly
? this.buildSelectParts(
selectOnlyStructure[rootKey] as Select,
rootKey,
aliasMapping
)
: { [rootKey + ".id"]: `${rootAlias}.id` }
queryBuilder.select(selectParts)
const innerQueryBuilder = this.knex.queryBuilder()
// Outer query to select the full data based on the paginated IDs
const outerQueryBuilder = this.knex.queryBuilder()
queryBuilder.from(
`cat_${rootEntity} AS ${this.getShortAlias(aliasMapping, rootEntity)}`
innerQueryBuilder.distinct(`${rootAlias}.id`)
const orderBySelects: Array<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) => {
queryBuilder.joinRaw(joinPart)
innerQueryBuilder.joinRaw(joinPart)
})
let searchWhereParts: string[] = []
if (hasTextSearch) {
/**
* Build the search where parts for the query,.
* Apply the search query to the search vector column for every joined tabled except
* the pivot joined table.
*/
searchWhereParts = [
`${this.getShortAlias(aliasMapping, rootEntity)}.${
const searchWhereParts = [
`${rootAlias}.${
this.#searchVectorColumnName
} @@ plainto_tsquery('simple', '${textSearchQuery}')`,
} @@ plainto_tsquery('simple', ?)`,
...joinParts.flatMap((part) => {
const aliases = part
.split(" as ")
@@ -657,233 +836,94 @@ export class QueryBuilder {
(alias) =>
`${alias}.${
this.#searchVectorColumnName
} @@ plainto_tsquery('simple', '${textSearchQuery}')`
} @@ plainto_tsquery('simple', ?)`
)
}),
]
queryBuilder.whereRaw(`(${searchWhereParts.join(" OR ")})`)
}
// WHERE clause
this.parseWhere(aliasMapping, filter, queryBuilder)
// ORDER BY clause
for (const aliasPath in orderBy) {
const path = aliasPath.split(".")
const field = path.pop()
const attr = path.join(".")
const pgType = this.getPostgresCastType(attr, [field])
const alias = aliasMapping[attr]
const direction = orderBy[aliasPath]
queryBuilder.orderByRaw(
`(${alias}.data->>'${field}')${pgType.cast}` + " " + direction
innerQueryBuilder.whereRaw(
`(${searchWhereParts.join(" OR ")})`,
Array(searchWhereParts.length).fill(textSearchQuery)
)
}
let take_ = !isNaN(+take!) ? +take! : 15
let skip_ = !isNaN(+skip!) ? +skip! : 0
this.parseWhere(aliasMapping, filter, innerQueryBuilder)
let cte = ""
// Group by root ID in the inner query
if (orderBySelects.length > 0) {
innerQueryBuilder.groupBy(`${rootAlias}.id`)
}
if (orderByClauses.length > 0) {
innerQueryBuilder.orderByRaw(orderByClauses.join(", "))
} else {
innerQueryBuilder.orderBy(`${rootAlias}.id`, "ASC")
}
// Count query to estimate the number of results in parallel
let countQuery: Knex.Raw | undefined
if (hasCount) {
const estimateQuery = innerQueryBuilder.clone()
estimateQuery.clearSelect().select(1)
estimateQuery.clearOrder()
estimateQuery.clearCounters()
countQuery = this.knex.raw(
`SELECT count_estimate(?) AS estimate_count`,
estimateQuery.toQuery()
)
}
// Apply pagination to the inner query
if (hasPagination) {
cte = this.buildCTEData({
hasCount,
searchWhereParts,
take: take_,
skip: skip_,
orderBy,
})
if (hasCount) {
queryBuilder.select(this.knex.raw("pd.count_total"))
innerQueryBuilder.limit(take_)
if (skip_ > 0) {
innerQueryBuilder.offset(skip_)
}
queryBuilder.joinRaw(
`JOIN paginated_data AS pd ON ${rootAlias}.id = pd.id`
)
}
return cte + queryBuilder.toQuery()
}
const innerQueryAlias = "paginated_ids"
public buildCTEData({
hasCount,
searchWhereParts = [],
skip,
take,
orderBy,
}: {
hasCount: boolean
searchWhereParts: string[]
skip?: number
take: number
orderBy: OrderBy
}): string {
const queryBuilder = this.knex.queryBuilder()
outerQueryBuilder.from(
`cat_${rootEntity} AS ${this.getShortAlias(aliasMapping, rootKey)}`
)
const hasWhere = isPresent(this.rawConfig?.filters) || isPresent(orderBy)
const structure =
hasWhere && !searchWhereParts.length
? unflattenObjectKeys({
...(this.rawConfig?.filters
? unflattenObjectKeys(this.rawConfig?.filters)
: {}),
...orderBy,
})
: this.requestedFields
outerQueryBuilder.joinRaw(
`INNER JOIN (${innerQueryBuilder.toQuery()}) AS ${innerQueryAlias} ON ${rootAlias}.id = ${innerQueryAlias}.id`
)
const rootKey = this.getStructureKeys(structure)[0]
this.parseWhere(aliasMapping, filter, outerQueryBuilder)
const rootStructure = structure[rootKey] as Select
const entity = this.getEntity(rootKey)!.ref.entity
const rootEntity = entity.toLowerCase()
const aliasMapping: { [path: string]: string } = {}
const joinParts = this.buildQueryParts(
const joinPartsOuterQuery = this.buildQueryParts(
rootStructure,
"",
entity,
entity as IndexTypes.SchemaObjectEntityRepresentation["parents"][0],
rootKey,
[],
0,
aliasMapping
)
joinPartsOuterQuery.forEach((joinPart) => {
outerQueryBuilder.joinRaw(joinPart)
})
const rootAlias = aliasMapping[rootKey]
const finalSelectParts = !returnIdOnly
? this.buildSelectParts(
selectOnlyStructure[rootKey] as Select,
rootKey,
aliasMapping
)
: { [`${rootKey}.id`]: `${rootAlias}.id` }
queryBuilder.select(this.knex.raw(`${rootAlias}.id as id`))
outerQueryBuilder.select(finalSelectParts)
queryBuilder.from(
`cat_${rootEntity} AS ${this.getShortAlias(aliasMapping, rootEntity)}`
)
const finalSql = outerQueryBuilder.toQuery()
if (hasWhere) {
joinParts.forEach((joinPart) => {
queryBuilder.joinRaw(joinPart)
})
if (searchWhereParts.length) {
queryBuilder.whereRaw(`(${searchWhereParts.join(" OR ")})`)
}
this.parseWhere(aliasMapping, this.selector.where!, queryBuilder)
return {
sql: finalSql,
sqlCount: countQuery?.toQuery?.(),
}
// ORDER BY clause
const orderAliases: string[] = []
for (const aliasPath in orderBy) {
const path = aliasPath.split(".")
const field = path.pop()
const attr = path.join(".")
const pgType = this.getPostgresCastType(attr, [field])
const alias = aliasMapping[attr]
const direction = orderBy[aliasPath]
const orderAlias = `"${alias}.data->>'${field}'"`
orderAliases.push(orderAlias + " " + direction)
// transform the order by clause to a select MIN/MAX
queryBuilder.select(
direction === "ASC"
? this.knex.raw(
`MIN((${alias}.data->>'${field}')${pgType.cast}) as ${orderAlias}`
)
: this.knex.raw(
`MAX((${alias}.data->>'${field}')${pgType.cast}) as ${orderAlias}`
)
)
}
queryBuilder.groupByRaw(`${rootAlias}.id`)
const countSubQuery = hasCount
? `, (SELECT count(id) FROM data_select) as count_total`
: ""
return `
WITH data_select AS (
${queryBuilder.toQuery()}
),
paginated_data AS (
SELECT id ${countSubQuery}
FROM data_select
${orderAliases.length ? "ORDER BY " + orderAliases.join(", ") : ""}
LIMIT ${take}
${skip ? `OFFSET ${skip}` : ""}
)
`
}
// NOTE: We are keeping the bellow code for now as reference to alternative implementation for us. DO NOT REMOVE
// public buildQueryCount(): string {
// const queryBuilder = this.knex.queryBuilder()
// const hasWhere = isPresent(this.rawConfig?.filters)
// const structure = hasWhere ? this.rawConfig?.filters! : this.structure
// const rootKey = this.getStructureKeys(structure)[0]
// const rootStructure = structure[rootKey] as Select
// const entity = this.getEntity(rootKey)!.ref.entity
// const rootEntity = entity.toLowerCase()
// const aliasMapping: { [path: string]: string } = {}
// const joinParts = this.buildQueryParts(
// rootStructure,
// "",
// entity,
// rootKey,
// [],
// 0,
// aliasMapping
// )
// const rootAlias = aliasMapping[rootKey]
// queryBuilder.select(this.knex.raw(`COUNT(${rootAlias}.id) as count`))
// queryBuilder.from(
// `cat_${rootEntity} AS ${this.getShortAlias(aliasMapping, rootEntity)}`
// )
// const self = this
// if (hasWhere && joinParts.length) {
// const fromExistsRaw = joinParts.shift()!
// const [joinPartsExists, fromExistsPart] =
// fromExistsRaw.split(" left join ")
// const [fromExists, whereExists] = fromExistsPart.split(" on ")
// joinParts.unshift(joinPartsExists)
// queryBuilder.whereExists(function () {
// this.select(self.knex.raw(`1`))
// this.from(self.knex.raw(`${fromExists}`))
// this.joinRaw(joinParts.join("\n"))
// if (hasWhere) {
// self.parseWhere(aliasMapping, self.selector.where!, this)
// this.whereRaw(self.knex.raw(whereExists))
// return
// }
// this.whereRaw(self.knex.raw(whereExists))
// })
// } else {
// queryBuilder.whereExists(function () {
// this.select(self.knex.raw(`1`))
// if (hasWhere) {
// self.parseWhere(aliasMapping, self.selector.where!, this)
// }
// })
// }
// return queryBuilder.toQuery()
// }
public buildObjectFromResultset(
resultSet: Record<string, any>[]
): Record<string, any>[] {
@@ -894,7 +934,11 @@ export class QueryBuilder {
const isListMap: { [path: string]: boolean } = {}
const referenceMap: { [key: string]: any } = {}
const pathDetails: {
[key: string]: { property: string; parents: string[]; parentPath: string }
[key: string]: {
property: string
parents: string[]
parentPath: string
}
} = {}
const initializeMaps = (structure: Select, path: string[]) => {

View File

@@ -127,17 +127,15 @@ export class Configuration {
}
if (idxSyncData.length > 0) {
if (updatedConfig.length > 0) {
const ids = await this.#indexSyncService.list({
entity: updatedConfig.map((c) => c.entity),
})
idxSyncData.forEach((sync) => {
const id = ids.find((i) => i.entity === sync.entity)?.id
if (id) {
sync.id = id
}
})
}
const ids = await this.#indexSyncService.list({
entity: idxSyncData.map((c) => c.entity),
})
idxSyncData.forEach((sync) => {
const id = ids.find((i) => i.entity === sync.entity)?.id
if (id) {
sync.id = id
}
})
await this.#indexSyncService.upsert(idxSyncData)
}