mirror of
https://github.com/strapi/strapi.git
synced 2026-06-02 16:27:47 +00:00
40413d376a
* Use query builder init in deleteMany * test(api): deleteMany respects filters combined with relation * fix(database): pick filter params in deleteMany init Apply _q, where, and filters through init like count instead of where-only or full findMany params, avoiding populate/pagination surprises. Expand API and unit tests for relation filters, count parity, and ignored param keys. * chore(database): clarify deleteMany relation cleanup TODO Replace the vague bulk-query TODO with a concrete note that deleteMany still skips per-row deleteRelations unlike single delete(). --------- Co-authored-by: Ben Irvin <ben.irvin@strapi.io> Co-authored-by: Ben Irvin <ben@innerdvations.com>
520 lines
15 KiB
JavaScript
520 lines
15 KiB
JavaScript
'use strict';
|
||
|
||
// `strapi.db.query` behaviour: createMany batching, deep filters, deleteMany (incl. relation + filters parity).
|
||
const { createStrapiInstance } = require('api-tests/strapi');
|
||
const { createTestBuilder } = require('api-tests/builder');
|
||
|
||
const builder = createTestBuilder();
|
||
let strapi;
|
||
|
||
const testCT = {
|
||
displayName: 'test',
|
||
singularName: 'test',
|
||
pluralName: 'tests',
|
||
kind: 'collectionType',
|
||
attributes: {
|
||
name: {
|
||
type: 'string',
|
||
},
|
||
related: {
|
||
type: 'relation',
|
||
relation: 'manyToOne',
|
||
target: 'api::related.related',
|
||
},
|
||
},
|
||
};
|
||
|
||
const relatedCT = {
|
||
displayName: 'related',
|
||
singularName: 'related',
|
||
pluralName: 'relateds',
|
||
kind: 'collectionType',
|
||
attributes: {
|
||
title: {
|
||
type: 'string',
|
||
},
|
||
},
|
||
};
|
||
|
||
const fixtures = {
|
||
test: ({ related }) => {
|
||
return [
|
||
{
|
||
name: 'Hugo LLORIS',
|
||
related: related[0].id,
|
||
},
|
||
{
|
||
name: 'Samuel UMTITI',
|
||
related: related[1].id,
|
||
},
|
||
{
|
||
name: 'Lucas HERNANDEZ',
|
||
related: related[0].id,
|
||
},
|
||
];
|
||
},
|
||
related: [
|
||
{
|
||
title: 'Category A',
|
||
},
|
||
{
|
||
title: 'Category B',
|
||
},
|
||
],
|
||
};
|
||
|
||
const TEST_UID = 'api::test.test';
|
||
const RELATED_UID = 'api::related.related';
|
||
|
||
/** Fixture rows: Hugo + Lucas → Category A; Samuel → Category B */
|
||
const ALL_ENTRY_NAMES = ['Hugo LLORIS', 'Lucas HERNANDEZ', 'Samuel UMTITI'];
|
||
const ALL_RELATED_TITLES = ['Category A', 'Category B'];
|
||
|
||
/**
|
||
* deleteMany filter matrix (GH#11998, PR #25420).
|
||
* Each case asserts count/deleteMany parity and the surviving row names.
|
||
*/
|
||
const deleteManyFilterCases = [
|
||
// --- where only ---
|
||
{
|
||
label: 'where: scalar field',
|
||
params: { where: { name: 'Hugo LLORIS' } },
|
||
deletedCount: 1,
|
||
remaining: ['Lucas HERNANDEZ', 'Samuel UMTITI'],
|
||
},
|
||
{
|
||
label: 'where: nested relation',
|
||
params: { where: { related: { title: 'Category A' } } },
|
||
deletedCount: 2,
|
||
remaining: ['Samuel UMTITI'],
|
||
},
|
||
{
|
||
label: 'where: nested relation (other side)',
|
||
params: { where: { related: { title: 'Category B' } } },
|
||
deletedCount: 1,
|
||
remaining: ['Hugo LLORIS', 'Lucas HERNANDEZ'],
|
||
},
|
||
{
|
||
label: 'where: $or across scalars',
|
||
params: { where: { $or: [{ name: 'Hugo LLORIS' }, { name: 'Samuel UMTITI' }] } },
|
||
deletedCount: 2,
|
||
remaining: ['Lucas HERNANDEZ'],
|
||
},
|
||
{
|
||
label: 'where: no matches',
|
||
params: { where: { name: 'Nobody' } },
|
||
deletedCount: 0,
|
||
remaining: ALL_ENTRY_NAMES,
|
||
},
|
||
// --- filters only (same engine as where, separate param key) ---
|
||
{
|
||
label: 'filters: scalar field',
|
||
params: { filters: { name: 'Samuel UMTITI' } },
|
||
deletedCount: 1,
|
||
remaining: ['Hugo LLORIS', 'Lucas HERNANDEZ'],
|
||
},
|
||
{
|
||
label: 'filters: nested relation',
|
||
params: { filters: { related: { title: 'Category A' } } },
|
||
deletedCount: 2,
|
||
remaining: ['Samuel UMTITI'],
|
||
},
|
||
// --- where + filters (AND) ---
|
||
{
|
||
label: 'where + filters: relation where, scalar filters',
|
||
params: {
|
||
where: { related: { title: 'Category A' } },
|
||
filters: { name: 'Hugo LLORIS' },
|
||
},
|
||
deletedCount: 1,
|
||
remaining: ['Lucas HERNANDEZ', 'Samuel UMTITI'],
|
||
},
|
||
{
|
||
label: 'where + filters: scalar where, relation filters',
|
||
params: {
|
||
where: { name: 'Lucas HERNANDEZ' },
|
||
filters: { related: { title: 'Category A' } },
|
||
},
|
||
deletedCount: 1,
|
||
remaining: ['Hugo LLORIS', 'Samuel UMTITI'],
|
||
},
|
||
{
|
||
label: 'where + filters: both scalar (intersection)',
|
||
params: {
|
||
where: { name: { $containsi: 'loris' } },
|
||
filters: { name: { $containsi: 'hugo' } },
|
||
},
|
||
deletedCount: 1,
|
||
remaining: ['Lucas HERNANDEZ', 'Samuel UMTITI'],
|
||
},
|
||
{
|
||
label: 'where + filters: contradictory (no rows)',
|
||
params: {
|
||
where: { name: 'Samuel UMTITI' },
|
||
filters: { related: { title: 'Category A' } },
|
||
},
|
||
deletedCount: 0,
|
||
remaining: ALL_ENTRY_NAMES,
|
||
},
|
||
// --- _q (root-entity text search; does not search relations) ---
|
||
{
|
||
label: '_q: single name match (UMTITI)',
|
||
params: { _q: 'UMTITI' },
|
||
deletedCount: 1,
|
||
remaining: ['Hugo LLORIS', 'Lucas HERNANDEZ'],
|
||
},
|
||
{
|
||
label: '_q: single name match (HERNANDEZ)',
|
||
params: { _q: 'HERNANDEZ' },
|
||
deletedCount: 1,
|
||
remaining: ['Hugo LLORIS', 'Samuel UMTITI'],
|
||
},
|
||
{
|
||
label: '_q: no match on root fields',
|
||
params: { _q: 'Category A' },
|
||
deletedCount: 0,
|
||
remaining: ALL_ENTRY_NAMES,
|
||
},
|
||
// --- _q combined with where / filters ---
|
||
{
|
||
label: '_q + where: search narrowed by relation',
|
||
params: {
|
||
_q: 'LLORIS',
|
||
where: { related: { title: 'Category A' } },
|
||
},
|
||
deletedCount: 1,
|
||
remaining: ['Lucas HERNANDEZ', 'Samuel UMTITI'],
|
||
},
|
||
{
|
||
label: '_q + filters: search narrowed by relation',
|
||
params: {
|
||
_q: 'UMTITI',
|
||
filters: { related: { title: 'Category B' } },
|
||
},
|
||
deletedCount: 1,
|
||
remaining: ['Hugo LLORIS', 'Lucas HERNANDEZ'],
|
||
},
|
||
{
|
||
label: '_q + where + filters: full stack',
|
||
params: {
|
||
_q: 'H',
|
||
where: { related: { title: 'Category A' } },
|
||
filters: { name: { $containsi: 'lucas' } },
|
||
},
|
||
deletedCount: 1,
|
||
remaining: ['Hugo LLORIS', 'Samuel UMTITI'],
|
||
},
|
||
];
|
||
|
||
/**
|
||
* Relation-filtered deleteMany must remove source rows only — never the joined targets.
|
||
* `getParams` is optional for cases that need runtime ids from the DB.
|
||
*/
|
||
const deleteManyPreservesRelationTargetsCases = [
|
||
{
|
||
label: 'where: nested relation title',
|
||
params: { where: { related: { title: 'Category A' } } },
|
||
deletedCount: 2,
|
||
remaining: ['Samuel UMTITI'],
|
||
},
|
||
{
|
||
label: 'filters: nested relation title',
|
||
params: { filters: { related: { title: 'Category A' } } },
|
||
deletedCount: 2,
|
||
remaining: ['Samuel UMTITI'],
|
||
},
|
||
{
|
||
label: 'where: nested relation id',
|
||
getParams: async () => {
|
||
const categoryA = await strapi.db
|
||
.query(RELATED_UID)
|
||
.findOne({ where: { title: 'Category A' } });
|
||
return { where: { related: { id: categoryA.id } } };
|
||
},
|
||
deletedCount: 2,
|
||
remaining: ['Samuel UMTITI'],
|
||
},
|
||
{
|
||
label: 'where: single linked row (other category untouched)',
|
||
params: { where: { related: { title: 'Category B' } } },
|
||
deletedCount: 1,
|
||
remaining: ['Hugo LLORIS', 'Lucas HERNANDEZ'],
|
||
},
|
||
{
|
||
label: 'where + filters: relation filter with scalar intersection',
|
||
params: {
|
||
where: { related: { title: 'Category A' } },
|
||
filters: { name: 'Hugo LLORIS' },
|
||
},
|
||
deletedCount: 1,
|
||
remaining: ['Lucas HERNANDEZ', 'Samuel UMTITI'],
|
||
},
|
||
{
|
||
label: '_q + where: relation join for filtering only',
|
||
params: {
|
||
_q: 'LLORIS',
|
||
where: { related: { title: 'Category A' } },
|
||
},
|
||
deletedCount: 1,
|
||
remaining: ['Lucas HERNANDEZ', 'Samuel UMTITI'],
|
||
},
|
||
{
|
||
label: 'unfiltered deleteMany on source collection',
|
||
params: {},
|
||
deletedCount: 3,
|
||
remaining: [],
|
||
},
|
||
];
|
||
|
||
/**
|
||
* deleteMany uses pick(['_q', 'where', 'filters']) — same filter keys as count, not full findMany params.
|
||
* Pagination/populate/select must not change which rows are deleted.
|
||
*/
|
||
const deleteManyIgnoredParamCases = [
|
||
{
|
||
label: 'limit is ignored',
|
||
params: { where: { related: { title: 'Category A' } }, limit: 1 },
|
||
deletedCount: 2,
|
||
remaining: ['Samuel UMTITI'],
|
||
expectedCount: 2,
|
||
},
|
||
{
|
||
label: 'offset + orderBy + limit are ignored',
|
||
params: {
|
||
where: { related: { title: 'Category A' } },
|
||
orderBy: { name: 'asc' },
|
||
limit: 1,
|
||
offset: 1,
|
||
},
|
||
deletedCount: 2,
|
||
remaining: ['Samuel UMTITI'],
|
||
expectedCount: 2,
|
||
},
|
||
{
|
||
label: 'populate is ignored (no error, same as where-only)',
|
||
params: {
|
||
where: { related: { title: 'Category A' } },
|
||
populate: ['related'],
|
||
},
|
||
deletedCount: 2,
|
||
remaining: ['Samuel UMTITI'],
|
||
expectedCount: 2,
|
||
},
|
||
{
|
||
label: 'select is ignored',
|
||
params: {
|
||
where: { related: { title: 'Category B' } },
|
||
select: ['name'],
|
||
},
|
||
deletedCount: 1,
|
||
remaining: ['Hugo LLORIS', 'Lucas HERNANDEZ'],
|
||
expectedCount: 1,
|
||
},
|
||
{
|
||
label: 'findMany-shaped spread ignores pagination and populate',
|
||
params: {
|
||
where: { related: { title: 'Category A' } },
|
||
orderBy: { name: 'asc' },
|
||
limit: 1,
|
||
offset: 0,
|
||
populate: ['related'],
|
||
},
|
||
deletedCount: 2,
|
||
remaining: ['Samuel UMTITI'],
|
||
expectedCount: 2,
|
||
},
|
||
{
|
||
label: 'filters honored, limit ignored',
|
||
params: {
|
||
filters: { name: { $containsi: 'e' } },
|
||
limit: 1,
|
||
},
|
||
deletedCount: 2,
|
||
remaining: ['Hugo LLORIS'],
|
||
expectedCount: 2,
|
||
},
|
||
];
|
||
|
||
/** deleteMany and count share the same picked filter keys. */
|
||
const deleteManyCountParityCases = [
|
||
{ label: 'where only', params: { where: { related: { title: 'Category A' } } } },
|
||
{ label: 'filters only', params: { filters: { name: 'Samuel UMTITI' } } },
|
||
{
|
||
label: 'where + filters',
|
||
params: { where: { related: { title: 'Category A' } }, filters: { name: 'Hugo LLORIS' } },
|
||
},
|
||
{ label: '_q + where', params: { _q: 'LLORIS', where: { related: { title: 'Category A' } } } },
|
||
{ label: 'unfiltered', params: {} },
|
||
];
|
||
|
||
describe('Deep Filtering API', () => {
|
||
beforeEach(async () => {
|
||
await builder
|
||
.addContentTypes([relatedCT, testCT])
|
||
.addFixtures(relatedCT.singularName, fixtures.related)
|
||
.addFixtures(testCT.singularName, fixtures.test)
|
||
.build();
|
||
|
||
strapi = await createStrapiInstance();
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await strapi.destroy();
|
||
await builder.cleanup();
|
||
});
|
||
|
||
test('Return an array of ids on createMany', async () => {
|
||
const res = await strapi.db
|
||
.query('api::test.test')
|
||
.createMany({ data: [{ name: 'foo' }, { name: 'bar' }] });
|
||
|
||
expect(res).toMatchObject({ count: expect.any(Number) });
|
||
expect(Array.isArray(res.ids)).toBe(true);
|
||
expect(res.ids.length).toBe(2);
|
||
});
|
||
|
||
/**
|
||
* createMany with 550 items (GH#25198): on SQLite, batching keeps each insert batch ≤500.
|
||
*/
|
||
test('createMany with 550 items succeeds on all databases (batched insert)', async () => {
|
||
const count = 550;
|
||
const data = Array.from({ length: count }, (_, i) => ({ name: `item-${i}` }));
|
||
const res = await strapi.db.query('api::test.test').createMany({ data });
|
||
|
||
expect(res.count).toBe(count);
|
||
expect(Array.isArray(res.ids)).toBe(true);
|
||
expect(res.ids.length).toBe(count);
|
||
}, 120000);
|
||
});
|
||
|
||
/**
|
||
* deleteMany filter matrix — one Strapi boot, reset rows between cases.
|
||
* Avoids 18× listen/destroy cycles (~3 min) and reduces open-handle noise at teardown.
|
||
*/
|
||
describe('deleteMany filter params', () => {
|
||
const resetTestEntries = async () => {
|
||
await strapi.db.query(TEST_UID).deleteMany({});
|
||
const related = await strapi.db
|
||
.query('api::related.related')
|
||
.findMany({ orderBy: { id: 'asc' } });
|
||
|
||
for (const entry of fixtures.test({ related })) {
|
||
await strapi.db.query(TEST_UID).create({ data: entry });
|
||
}
|
||
};
|
||
|
||
beforeAll(async () => {
|
||
await builder
|
||
.addContentTypes([relatedCT, testCT])
|
||
.addFixtures(relatedCT.singularName, fixtures.related)
|
||
.addFixtures(testCT.singularName, fixtures.test)
|
||
.build();
|
||
|
||
strapi = await createStrapiInstance();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await strapi.destroy();
|
||
await builder.cleanup();
|
||
});
|
||
|
||
beforeEach(async () => {
|
||
await resetTestEntries();
|
||
});
|
||
|
||
test.each(deleteManyFilterCases)(
|
||
'deleteMany ($label): count/deleteMany agree and remaining rows match',
|
||
async ({ params, deletedCount, remaining }) => {
|
||
expect(await strapi.db.query(TEST_UID).count(params)).toBe(deletedCount);
|
||
|
||
const deleteRes = await strapi.db.query(TEST_UID).deleteMany(params);
|
||
expect(deleteRes.count).toBe(deletedCount);
|
||
|
||
const remainingNames = (await strapi.db.query(TEST_UID).findMany())
|
||
.map((entry) => entry.name)
|
||
.sort();
|
||
|
||
expect(remainingNames).toEqual([...remaining].sort());
|
||
}
|
||
);
|
||
|
||
test.each(deleteManyPreservesRelationTargetsCases)(
|
||
'deleteMany ($label): does not delete relation targets used in the filter',
|
||
async ({ params, getParams, deletedCount, remaining }) => {
|
||
const relatedBefore = await strapi.db.query(RELATED_UID).findMany({ orderBy: { id: 'asc' } });
|
||
|
||
expect(relatedBefore.map((entry) => entry.title)).toEqual(ALL_RELATED_TITLES);
|
||
|
||
const queryParams = getParams ? await getParams() : params;
|
||
|
||
expect(await strapi.db.query(TEST_UID).count(queryParams)).toBe(deletedCount);
|
||
|
||
const deleteRes = await strapi.db.query(TEST_UID).deleteMany(queryParams);
|
||
expect(deleteRes.count).toBe(deletedCount);
|
||
|
||
const remainingNames = (await strapi.db.query(TEST_UID).findMany())
|
||
.map((entry) => entry.name)
|
||
.sort();
|
||
expect(remainingNames).toEqual([...remaining].sort());
|
||
|
||
const relatedAfter = await strapi.db.query(RELATED_UID).findMany({ orderBy: { id: 'asc' } });
|
||
|
||
expect(relatedAfter).toHaveLength(relatedBefore.length);
|
||
relatedAfter.forEach((entry, index) => {
|
||
expect(entry.id).toBe(relatedBefore[index].id);
|
||
expect(entry.title).toBe(relatedBefore[index].title);
|
||
});
|
||
|
||
// Surviving source rows keep a valid relation to an untouched target.
|
||
if (remaining.length > 0) {
|
||
const survivors = await strapi.db.query(TEST_UID).findMany({
|
||
populate: { related: { select: ['title'] } },
|
||
});
|
||
|
||
survivors.forEach((entry) => {
|
||
expect(entry.related).toBeDefined();
|
||
expect(ALL_RELATED_TITLES).toContain(entry.related.title);
|
||
});
|
||
}
|
||
}
|
||
);
|
||
|
||
test.each(deleteManyIgnoredParamCases)(
|
||
'deleteMany ignored params ($label)',
|
||
async ({ params, deletedCount, remaining, expectedCount }) => {
|
||
expect(await strapi.db.query(TEST_UID).count(params)).toBe(expectedCount);
|
||
|
||
const deleteRes = await strapi.db.query(TEST_UID).deleteMany(params);
|
||
expect(deleteRes.count).toBe(deletedCount);
|
||
expect(deleteRes.count).toBe(expectedCount);
|
||
|
||
const remainingNames = (await strapi.db.query(TEST_UID).findMany())
|
||
.map((entry) => entry.name)
|
||
.sort();
|
||
expect(remainingNames).toEqual([...remaining].sort());
|
||
|
||
const relatedAfter = await strapi.db.query(RELATED_UID).findMany();
|
||
expect(relatedAfter.map((entry) => entry.title).sort()).toEqual(
|
||
[...ALL_RELATED_TITLES].sort()
|
||
);
|
||
}
|
||
);
|
||
|
||
test.each(deleteManyCountParityCases)(
|
||
'deleteMany ($label): delete count matches count() with the same params',
|
||
async ({ params }) => {
|
||
expect(await strapi.db.query(TEST_UID).count(params)).toBe(
|
||
(await strapi.db.query(TEST_UID).deleteMany(params)).count
|
||
);
|
||
}
|
||
);
|
||
|
||
test('deleteMany: limit in params does not cap deletions (unlike findMany)', async () => {
|
||
const params = { where: { related: { title: 'Category A' } }, limit: 1 };
|
||
|
||
expect(await strapi.db.query(TEST_UID).findMany(params)).toHaveLength(1);
|
||
expect(await strapi.db.query(TEST_UID).count(params)).toBe(2);
|
||
expect((await strapi.db.query(TEST_UID).deleteMany(params)).count).toBe(2);
|
||
});
|
||
});
|