Files
strapi/tests/api/core/database/db.test.api.js
Weijie Sun 40413d376a fix: deleteMany respects filters combined with relation (#25420)
* 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>
2026-05-27 09:42:06 +00:00

520 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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);
});
});