Merge pull request #19971 from strapi/fix/issue-12225

feat: support media deep filtering & relation shortcut filters
This commit is contained in:
Alexandre BODIN 2024-04-04 11:57:30 +02:00 committed by GitHub
commit 79f8c92d52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 316 additions and 17 deletions

View File

@ -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/**'

View File

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

View File

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

View File

@ -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
};
/**

View File

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

View File

@ -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<string, unknown> => 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, but 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);

View File

@ -375,4 +375,201 @@ describe('Upload plugin', () => {
data.dogs[0] = res.body;
});
});
describe('Filtering data based on media attributes', () => {
let uploadRes;
let dogRes;
beforeAll(async () => {
await Promise.all(
data.dogs.map((dog) => {
return strapi.entityService.delete('api::dog.dog', dog.data.id);
})
);
uploadRes = await rq({
method: 'POST',
url: '/upload',
formData: {
files: fs.createReadStream(path.join(__dirname, '../utils/rec.jpg')),
fileInfo: JSON.stringify({
alternativeText: 'rec',
caption: 'my caption',
}),
},
});
dogRes = await rq({
method: 'POST',
url: '/dogs',
body: {
data: {
profilePicture: {
id: uploadRes.body[0].id,
},
},
},
});
});
afterAll(async () => {
await rq({
method: 'DELETE',
url: `/dogs/${dogRes.body.data.id}`,
});
await rq({
method: 'DELETE',
url: `/upload/files/${uploadRes.body[0].id}`,
});
});
test('can filter on notNull', async () => {
let res;
res = await rq({
method: 'GET',
url: '/dogs',
qs: {
filters: {
profilePicture: { $notNull: true },
},
},
});
expect(res.body.data.length).toBe(1);
res = await rq({
method: 'GET',
url: '/dogs',
qs: {
filters: {
profilePicture: { $notNull: false },
},
},
});
expect(res.body.data.length).toBe(0);
});
test('can filter on null', async () => {
let res;
res = await rq({
method: 'GET',
url: '/dogs',
qs: {
filters: {
profilePicture: { $null: true },
},
},
});
expect(res.body.data.length).toBe(0);
res = await rq({
method: 'GET',
url: '/dogs',
qs: {
filters: {
profilePicture: { $null: false },
},
},
});
expect(res.body.data.length).toBe(1);
});
test('can filter on id', async () => {
let res;
res = await rq({
method: 'GET',
url: '/dogs',
qs: {
filters: {
profilePicture: uploadRes.body[0].id,
},
},
});
expect(res.body.data.length).toBe(1);
res = await rq({
method: 'GET',
url: '/dogs',
qs: {
filters: {
profilePicture: 999999999,
},
},
});
expect(res.body.data.length).toBe(0);
});
test('can filter media attribute', async () => {
let res;
res = await rq({
method: 'GET',
url: '/dogs',
qs: {
filters: {
profilePicture: { ext: '.jpg' },
},
},
});
expect(res.body.data.length).toBe(1);
res = await rq({
method: 'GET',
url: '/dogs',
qs: {
filters: {
profilePicture: { ext: '.pdf' },
},
},
});
expect(res.body.data.length).toBe(0);
});
test('can filter media attribute with operators', async () => {
let res;
res = await rq({
method: 'GET',
url: '/dogs',
qs: {
filters: {
profilePicture: {
caption: {
$contains: 'my',
},
},
},
},
});
expect(res.body.data.length).toBe(1);
res = await rq({
method: 'GET',
url: '/dogs',
qs: {
filters: {
profilePicture: {
caption: {
$contains: 'not',
},
},
},
},
});
expect(res.body.data.length).toBe(0);
});
});
});