diff --git a/.github/filters.yaml b/.github/filters.yaml index c18a128e6c..de7ccd8421 100644 --- a/.github/filters.yaml +++ b/.github/filters.yaml @@ -7,6 +7,7 @@ backend: - 'packages/{utils,generators,cli,providers}/**' - 'packages/core/*/{lib,bin,ee}/**' - 'tests/api/**' + - 'packages/core/database/**' frontend: - '.github/actions/yarn-nm-install/*.yml' - '.github/workflows/**' diff --git a/examples/getstarted/src/api/kitchensink/content-types/kitchensink/schema.json b/examples/getstarted/src/api/kitchensink/content-types/kitchensink/schema.json index 32933683cb..bca8abcb18 100644 --- a/examples/getstarted/src/api/kitchensink/content-types/kitchensink/schema.json +++ b/examples/getstarted/src/api/kitchensink/content-types/kitchensink/schema.json @@ -134,6 +134,12 @@ "type": "relation", "relation": "morphToMany" }, + "morph_one": { + "type": "relation", + "relation": "morphOne", + "target": "api::tag.tag", + "morphBy": "taggable" + }, "custom_field": { "type": "customField", "customField": "plugin::color-picker.color" diff --git a/examples/getstarted/src/api/tag/content-types/tag/schema.json b/examples/getstarted/src/api/tag/content-types/tag/schema.json index b7f7db77a8..ebfb85eb58 100644 --- a/examples/getstarted/src/api/tag/content-types/tag/schema.json +++ b/examples/getstarted/src/api/tag/content-types/tag/schema.json @@ -38,6 +38,19 @@ "relation": "oneToOne", "target": "api::kitchensink.kitchensink", "mappedBy": "one_to_one_tag" + }, + "taggable": { + "type": "relation", + "relation": "morphToOne", + "morphColumn": { + "typeColumn": { + "name": "taggable_type" + }, + "idColumn": { + "name": "taggable_id", + "referencedColumn": "id" + } + } } } } diff --git a/packages/core/database/src/metadata/relations.ts b/packages/core/database/src/metadata/relations.ts index 0c9ddb1f86..2f39b5e507 100644 --- a/packages/core/database/src/metadata/relations.ts +++ b/packages/core/database/src/metadata/relations.ts @@ -209,13 +209,13 @@ const createManyToMany = ( * set info in the traget */ const createMorphToOne = (attributeName: string, attribute: Relation.MorphToOne) => { - const idColumnName = 'target_id'; - const typeColumnName = 'target_type'; + // TODO: (breaking) support ${attributeName}_id and ${attributeName}_type as default column names + const idColumnName = `target_id`; + const typeColumnName = `target_type`; Object.assign(attribute, { owner: true, - morphColumn: { - // TODO: add referenced column + morphColumn: attribute.morphColumn ?? { typeColumn: { name: typeColumnName, }, @@ -225,8 +225,6 @@ const createMorphToOne = (attributeName: string, attribute: Relation.MorphToOne) }, }, }); - - // TODO: implement bidirectional }; /** diff --git a/packages/core/database/src/query/helpers/join.ts b/packages/core/database/src/query/helpers/join.ts index 8b89891328..75257865b2 100644 --- a/packages/core/database/src/query/helpers/join.ts +++ b/packages/core/database/src/query/helpers/join.ts @@ -54,7 +54,7 @@ const createPivotJoin = ( }; const createJoin = (ctx: Ctx, { alias, refAlias, attributeName, attribute }: JoinOptions) => { - const { db, qb } = ctx; + const { db, qb, uid } = ctx; if (attribute.type !== 'relation') { throw new Error(`Cannot join on non relational field ${attributeName}`); @@ -62,6 +62,61 @@ const createJoin = (ctx: Ctx, { alias, refAlias, attributeName, attribute }: Joi const targetMeta = db.metadata.get(attribute.target); + if (['morphOne', 'morphMany'].includes(attribute.relation)) { + const targetAttribute = targetMeta.attributes[attribute.morphBy]; + + // @ts-expect-error - morphBy is not defined on the attribute + const { joinTable, morphColumn } = targetAttribute; + + if (morphColumn) { + const subAlias = refAlias || qb.getAlias(); + + qb.join({ + alias: subAlias, + referencedTable: targetMeta.tableName, + referencedColumn: morphColumn.idColumn.name, + rootColumn: morphColumn.idColumn.referencedColumn, + rootTable: alias, + on: { + [morphColumn.typeColumn.name]: uid, + ...morphColumn.on, + }, + }); + + return subAlias; + } + + if (joinTable) { + const joinAlias = qb.getAlias(); + + qb.join({ + alias: joinAlias, + referencedTable: joinTable.name, + referencedColumn: joinTable.morphColumn.idColumn.name, + rootColumn: joinTable.morphColumn.idColumn.referencedColumn, + rootTable: alias, + on: { + [joinTable.morphColumn.typeColumn.name]: uid, + field: attributeName, + }, + }); + + const subAlias = refAlias || qb.getAlias(); + + qb.join({ + alias: subAlias, + referencedTable: targetMeta.tableName, + referencedColumn: joinTable.joinColumn.referencedColumn, + rootColumn: joinTable.joinColumn.name, + rootTable: joinAlias, + }); + + return subAlias; + } + + return alias; + } + const { joinColumn } = attribute; if (joinColumn) { diff --git a/packages/core/database/src/query/helpers/where.ts b/packages/core/database/src/query/helpers/where.ts index 2fa9da561d..72f2c6f428 100644 --- a/packages/core/database/src/query/helpers/where.ts +++ b/packages/core/database/src/query/helpers/where.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ -import { isArray, castArray, keys, isPlainObject } from 'lodash/fp'; +import { isArray, castArray, isPlainObject } from 'lodash/fp'; import type { Knex } from 'knex'; -import { isOperatorOfType } from '@strapi/utils'; +import { isOperator, isOperatorOfType } from '@strapi/utils'; import * as types from '../../utils/types'; import { createField } from '../../fields'; import { createJoin } from './join'; @@ -12,6 +12,8 @@ import { isKnexQuery } from '../../utils/knex'; import type { Ctx } from '../types'; import type { Attribute } from '../../types'; +type WhereCtx = Ctx & { alias?: string; isGroupRoot?: boolean }; + const isRecord = (value: unknown): value is Record => isPlainObject(value); const castValue = (value: unknown, attribute: Attribute | null) => { @@ -72,7 +74,34 @@ const processNested = (where: unknown, ctx: WhereCtx) => { return processWhere(where, ctx); }; -type WhereCtx = Ctx & { alias?: string }; +const processRelationWhere = (where: unknown, ctx: WhereCtx) => { + const { qb, alias } = ctx; + + const idAlias = qb.aliasColumn('id', alias); + if (!isRecord(where)) { + return { [idAlias]: where }; + } + + const keys = Object.keys(where); + const operatorKeys = keys.filter((key) => isOperator(key)); + + if (operatorKeys.length > 0 && operatorKeys.length !== keys.length) { + throw new Error(`Operator and non-operator keys cannot be mixed in a relation where clause`); + } + + if (operatorKeys.length > 1) { + throw new Error( + `Only one operator key is allowed in a relation where clause found ${operatorKeys}` + ); + } + + if (operatorKeys.length === 1) { + const operator = operatorKeys[0]; + return { [idAlias]: { [operator]: processNested(where[operator], ctx) } }; + } + + return processWhere(where, ctx); +}; /** * Process where parameter @@ -100,8 +129,12 @@ function processWhere( for (const key of Object.keys(where)) { const value = where[key]; - // if operator $and $or then loop over them - if (isOperatorOfType('group', key) && Array.isArray(value)) { + // if operator $and $or -> process recursively + if (isOperatorOfType('group', key)) { + if (!Array.isArray(value)) { + throw new Error(`Operator ${key} must be an array`); + } + filters[key] = value.map((sub) => processNested(sub, ctx)); continue; } @@ -132,17 +165,13 @@ function processWhere( attribute, }); - let nestedWhere = processNested(value, { + const nestedWhere = processRelationWhere(value, { db, qb, alias: subAlias, uid: attribute.target, }); - if (!isRecord(nestedWhere) || isOperatorOfType('where', keys(nestedWhere)[0])) { - nestedWhere = { [qb.aliasColumn('id', subAlias)]: nestedWhere }; - } - // TODO: use a better merge logic (push to $and when collisions) Object.assign(filters, nestedWhere);