mirror of
https://github.com/strapi/strapi.git
synced 2025-06-27 00:41:25 +00:00
Merge pull request #19971 from strapi/fix/issue-12225
feat: support media deep filtering & relation shortcut filters
This commit is contained in:
commit
79f8c92d52
1
.github/filters.yaml
vendored
1
.github/filters.yaml
vendored
@ -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/**'
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user