Merge pull request #265 from Hexastack/256-request-move-blocks-between-categories

feat: move blocks between categories
This commit is contained in:
Med Marrouchi
2024-11-21 15:09:56 +01:00
committed by GitHub
12 changed files with 650 additions and 17 deletions

View File

@@ -255,6 +255,28 @@ export class BlockController extends BaseController<
return await this.blockService.create(block);
}
/**
* Updates multiple blocks by their IDs.
* @param ids - IDs of blocks to be updated.
* @param payload - The data to update blocks with.
* @returns A Promise that resolves to the updates if successful.
*/
@CsrfCheck(true)
@Patch('bulk')
async updateMany(@Body() body: { ids: string[]; payload: BlockUpdateDto }) {
if (!body.ids || body.ids.length === 0) {
throw new BadRequestException('No IDs provided to perform the update');
}
const updates = await this.blockService.updateMany(
{
_id: { $in: body.ids },
},
body.payload,
);
return updates;
}
/**
* Updates a specific block by ID.
*

View File

@@ -20,8 +20,8 @@ import {
rootMongooseTestModule,
} from '@/utils/test/test';
import { BlockModel, Block } from '../schemas/block.schema';
import { CategoryModel, Category } from '../schemas/category.schema';
import { Block, BlockModel } from '../schemas/block.schema';
import { Category, CategoryModel } from '../schemas/category.schema';
import { LabelModel } from '../schemas/label.schema';
import { BlockRepository } from './block.repository';
@@ -34,6 +34,9 @@ describe('BlockRepository', () => {
let category: Category;
let hasPreviousBlocks: Block;
let hasNextBlocks: Block;
let validIds: string[];
let validCategory: string;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
@@ -45,6 +48,9 @@ describe('BlockRepository', () => {
blockRepository = module.get<BlockRepository>(BlockRepository);
categoryRepository = module.get<CategoryRepository>(CategoryRepository);
blockModel = module.get<Model<Block>>(getModelToken('Block'));
validIds = ['64abc1234def567890fedcba', '64abc1234def567890fedcbc'];
validCategory = '64def5678abc123490fedcba';
category = await categoryRepository.findOne({ label: 'default' });
hasPreviousBlocks = await blockRepository.findOne({
name: 'hasPreviousBlocks',
@@ -107,4 +113,195 @@ describe('BlockRepository', () => {
expect(result).toEqualPayload(blocksWithCategory);
});
});
describe('preUpdate', () => {
it('should remove references to a moved block when updating category', async () => {
const mockUpdateMany = jest.spyOn(blockRepository, 'updateMany');
const criteria = { _id: validIds[0] };
const updates = { $set: { category: validCategory } };
const mockFindOne = jest
.spyOn(blockRepository, 'findOne')
.mockResolvedValue({
id: validIds[0],
category: 'oldCategory',
} as Block);
await blockRepository.preUpdate({} as any, criteria, updates);
expect(mockFindOne).toHaveBeenCalledWith(criteria);
expect(mockUpdateMany).toHaveBeenCalledTimes(2);
expect(mockUpdateMany).toHaveBeenNthCalledWith(
1,
{ nextBlocks: validIds[0] },
{ $pull: { nextBlocks: validIds[0] } },
);
expect(mockUpdateMany).toHaveBeenNthCalledWith(
2,
{ attachedBlock: validIds[0] },
{ $set: { attachedBlock: null } },
);
});
it('should do nothing if no block is found for the criteria', async () => {
const mockFindOne = jest
.spyOn(blockRepository, 'findOne')
.mockResolvedValue(null);
const mockUpdateMany = jest.spyOn(blockRepository, 'updateMany');
await blockRepository.preUpdate(
{} as any,
{ _id: 'nonexistent' },
{ $set: { category: 'newCategory' } },
);
expect(mockFindOne).toHaveBeenCalledWith({ _id: 'nonexistent' });
expect(mockUpdateMany).not.toHaveBeenCalled();
});
});
describe('prepareBlocksInCategoryUpdateScope', () => {
it('should update blocks within the scope based on category and ids', async () => {
jest.spyOn(blockRepository, 'findOne').mockResolvedValue({
id: validIds[0],
category: 'oldCategory',
nextBlocks: [validIds[1]],
attachedBlock: validIds[1],
} as Block);
const mockUpdateOne = jest.spyOn(blockRepository, 'updateOne');
await blockRepository.prepareBlocksInCategoryUpdateScope(
validCategory,
validIds,
);
expect(mockUpdateOne).toHaveBeenCalledWith(validIds[0], {
nextBlocks: [validIds[1]],
attachedBlock: validIds[1],
});
});
it('should not update blocks if the category already matches', async () => {
jest.spyOn(blockRepository, 'findOne').mockResolvedValue({
id: validIds[0],
category: validCategory,
nextBlocks: [],
attachedBlock: null,
} as Block);
const mockUpdateOne = jest.spyOn(blockRepository, 'updateOne');
await blockRepository.prepareBlocksInCategoryUpdateScope(
validCategory,
validIds,
);
expect(mockUpdateOne).not.toHaveBeenCalled();
});
});
describe('prepareBlocksOutOfCategoryUpdateScope', () => {
it('should update blocks outside the scope by removing references from attachedBlock', async () => {
const otherBlocks = [
{
id: '64abc1234def567890fedcab',
attachedBlock: validIds[0],
nextBlocks: [validIds[0]],
},
] as Block[];
const mockUpdateOne = jest.spyOn(blockRepository, 'updateOne');
await blockRepository.prepareBlocksOutOfCategoryUpdateScope(
otherBlocks,
validIds,
);
expect(mockUpdateOne).toHaveBeenCalledWith('64abc1234def567890fedcab', {
attachedBlock: null,
});
});
it('should update blocks outside the scope by removing references from nextBlocks', async () => {
const otherBlocks = [
{
id: '64abc1234def567890fedcab',
attachedBlock: null,
nextBlocks: [validIds[0], validIds[1]],
},
] as Block[];
const mockUpdateOne = jest.spyOn(blockRepository, 'updateOne');
await blockRepository.prepareBlocksOutOfCategoryUpdateScope(otherBlocks, [
validIds[0],
]);
expect(mockUpdateOne).toHaveBeenCalledWith('64abc1234def567890fedcab', {
nextBlocks: [validIds[1]],
});
});
});
describe('preUpdateMany', () => {
it('should update blocks in and out of the scope', async () => {
const mockFind = jest.spyOn(blockRepository, 'find').mockResolvedValue([
{
id: '64abc1234def567890fedcab',
attachedBlock: validIds[0],
nextBlocks: [validIds[0]],
},
] as Block[]);
const prepareBlocksInCategoryUpdateScope = jest.spyOn(
blockRepository,
'prepareBlocksInCategoryUpdateScope',
);
const prepareBlocksOutOfCategoryUpdateScope = jest.spyOn(
blockRepository,
'prepareBlocksOutOfCategoryUpdateScope',
);
await blockRepository.preUpdateMany(
{} as any,
{ _id: { $in: validIds } },
{ $set: { category: validCategory } },
);
expect(mockFind).toHaveBeenCalled();
expect(prepareBlocksInCategoryUpdateScope).toHaveBeenCalledWith(
validCategory,
['64abc1234def567890fedcab'],
);
expect(prepareBlocksOutOfCategoryUpdateScope).toHaveBeenCalledWith(
[
{
id: '64abc1234def567890fedcab',
attachedBlock: validIds[0],
nextBlocks: [validIds[0]],
},
],
['64abc1234def567890fedcab'],
);
});
it('should not perform updates if no category is provided', async () => {
const mockFind = jest.spyOn(blockRepository, 'find');
const prepareBlocksInCategoryUpdateScope = jest.spyOn(
blockRepository,
'prepareBlocksInCategoryUpdateScope',
);
const prepareBlocksOutOfCategoryUpdateScope = jest.spyOn(
blockRepository,
'prepareBlocksOutOfCategoryUpdateScope',
);
await blockRepository.preUpdateMany({} as any, {}, { $set: {} });
expect(mockFind).not.toHaveBeenCalled();
expect(prepareBlocksInCategoryUpdateScope).not.toHaveBeenCalled();
expect(prepareBlocksOutOfCategoryUpdateScope).not.toHaveBeenCalled();
});
});
});

View File

@@ -89,14 +89,138 @@ export class BlockRepository extends BaseRepository<
Block,
'findOneAndUpdate'
>,
_criteria: TFilterQuery<Block>,
_updates:
criteria: TFilterQuery<Block>,
updates:
| UpdateWithAggregationPipeline
| UpdateQuery<Document<Block, any, any>>,
): Promise<void> {
const updates: BlockUpdateDto = _updates?.['$set'];
const update: BlockUpdateDto = updates?.['$set'];
this.checkDeprecatedAttachmentUrl(updates);
if (update?.category) {
const movedBlock: Block = await this.findOne(criteria);
if (!movedBlock) {
return;
}
// Find and update blocks that reference the moved block
await this.updateMany(
{ nextBlocks: movedBlock.id },
{ $pull: { nextBlocks: movedBlock.id } },
);
await this.updateMany(
{ attachedBlock: movedBlock.id },
{ $set: { attachedBlock: null } },
);
}
this.checkDeprecatedAttachmentUrl(update);
}
/**
* Pre-processing logic for updating blocks.
*
* @param query - The query to update blocks.
* @param criteria - The filter criteria for the update query.
* @param updates - The update data.
*/
async preUpdateMany(
_query: Query<
Document<Block, any, any>,
Document<Block, any, any>,
unknown,
Block,
'updateMany',
Record<string, never>
>,
criteria: TFilterQuery<Block>,
updates: UpdateQuery<Document<Block, any, any>>,
): Promise<void> {
const categoryId: string = updates.$set.category;
if (categoryId) {
const movedBlocks = await this.find(criteria);
if (movedBlocks.length) {
const ids: string[] = movedBlocks.map(({ id }) => id);
// Step 1: Map IDs and Category
const objIds = ids.map((id) => new Types.ObjectId(id));
const objCategoryId = new Types.ObjectId(categoryId);
// Step 2: Find other blocks
const otherBlocks = await this.find({
_id: { $nin: objIds },
category: { $ne: objCategoryId },
$or: [
{ attachedBlock: { $in: objIds } },
{ nextBlocks: { $in: objIds } },
],
});
// Step 3: Update blocks in the provided scope
await this.prepareBlocksInCategoryUpdateScope(categoryId, ids);
// Step 4: Update external blocks
await this.prepareBlocksOutOfCategoryUpdateScope(otherBlocks, ids);
}
}
}
/**
* Updates blocks within a specified category scope.
* Ensures nextBlocks and attachedBlock are consistent with the provided IDs and category.
*
* @param category - The category
* @param ids - IDs representing the blocks to update.
* @returns A promise that resolves once all updates within the scope are complete.
*/
async prepareBlocksInCategoryUpdateScope(
category: string,
ids: string[],
): Promise<void> {
for (const id of ids) {
const oldState: Block = await this.findOne(id);
if (oldState.category !== category) {
const updatedNextBlocks = oldState.nextBlocks.filter((nextBlock) =>
ids.includes(nextBlock),
);
const updatedAttachedBlock = ids.includes(oldState.attachedBlock || '')
? oldState.attachedBlock
: null;
await this.updateOne(id, {
nextBlocks: updatedNextBlocks,
attachedBlock: updatedAttachedBlock,
});
}
}
}
/**
* Updates blocks outside the specified category scope by removing references to the provided IDs.
* Handles updates to both attachedBlock and nextBlocks.
*
* @param otherBlocks - An array of blocks outside the provided category scope.
* @param ids - An array of the Ids to disassociate.
* @returns A promise that resolves once all external block updates are complete.
*/
async prepareBlocksOutOfCategoryUpdateScope(
otherBlocks: Block[],
ids: string[],
): Promise<void> {
for (const block of otherBlocks) {
if (ids.includes(block.attachedBlock)) {
await this.updateOne(block.id, { attachedBlock: null });
}
const nextBlocks = block.nextBlocks.filter(
(nextBlock) => !ids.includes(nextBlock),
);
if (nextBlocks.length > 0) {
await this.updateOne(block.id, { nextBlocks });
}
}
}
/**

View File

@@ -8,7 +8,7 @@
import { getModelToken } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import mongoose, { Model } from 'mongoose';
import { Model, Types } from 'mongoose';
import { DummyRepository } from '@/utils/test/dummy/repositories/dummy.repository';
import { closeInMongodConnection } from '@/utils/test/test';
@@ -150,7 +150,7 @@ describe('BaseRepository', () => {
expect(spyBeforeUpdate).toHaveBeenCalledWith(
expect.objectContaining({ $useProjection: true }),
{
_id: new mongoose.Types.ObjectId(created.id),
_id: new Types.ObjectId(created.id),
},
expect.objectContaining({ $set: expect.objectContaining(mockUpdate) }),
);
@@ -202,7 +202,7 @@ describe('BaseRepository', () => {
expect(spyBeforeDelete).toHaveBeenCalledWith(
expect.objectContaining({ $useProjection: true }),
{
_id: new mongoose.Types.ObjectId(createdId),
_id: new Types.ObjectId(createdId),
},
);
expect(spyAfterDelete).toHaveBeenCalledWith(

View File

@@ -38,10 +38,12 @@ export type DeleteResult = {
export enum EHook {
preCreate = 'preCreate',
preUpdate = 'preUpdate',
preUpdateMany = 'preUpdateMany',
preDelete = 'preDelete',
preValidate = 'preValidate',
postCreate = 'postCreate',
postUpdate = 'postUpdate',
postUpdateMany = 'postUpdateMany',
postDelete = 'postDelete',
postValidate = 'postValidate',
}
@@ -157,6 +159,28 @@ export abstract class BaseRepository<
);
});
hooks?.updateMany.pre.execute(async function () {
const query = this as Query<D, D, unknown, T, 'updateMany'>;
const criteria = query.getFilter();
const updates = query.getUpdate();
await repository.preUpdateMany(query, criteria, updates);
repository.emitter.emit(
repository.getEventName(EHook.preUpdateMany),
criteria,
updates?.['$set'],
);
});
hooks?.updateMany.post.execute(async function (updated: any) {
const query = this as Query<D, D, unknown, T, 'updateMany'>;
await repository.postUpdateMany(query, updated);
repository.emitter.emit(
repository.getEventName(EHook.postUpdateMany),
updated,
);
});
hooks?.findOneAndUpdate.post.execute(async function (
updated: HydratedDocument<T>,
) {
@@ -319,7 +343,7 @@ export abstract class BaseRepository<
async updateOne<D extends Partial<U>>(
criteria: string | TFilterQuery<T>,
dto: D,
dto: UpdateQuery<D>,
): Promise<T> {
const query = this.model.findOneAndUpdate<T>(
{
@@ -335,7 +359,10 @@ export abstract class BaseRepository<
return await this.executeOne(query, this.cls);
}
async updateMany<D extends Partial<U>>(filter: TFilterQuery<T>, dto: D) {
async updateMany<D extends Partial<U>>(
filter: TFilterQuery<T>,
dto: UpdateQuery<D>,
) {
return await this.model.updateMany<T>(filter, {
$set: dto,
});
@@ -375,6 +402,21 @@ export abstract class BaseRepository<
// Nothing ...
}
async preUpdateMany(
_query: Query<D, D, unknown, T, 'updateMany'>,
_criteria: TFilterQuery<T>,
_updates: UpdateWithAggregationPipeline | UpdateQuery<D>,
) {
// Nothing ...
}
async postUpdateMany(
_query: Query<D, D, unknown, T, 'updateMany'>,
_updated: any,
) {
// Nothing ...
}
async postUpdate(
_query: Query<D, D, unknown, T, 'findOneAndUpdate'>,
_updated: T,

View File

@@ -17,7 +17,7 @@ enum LifecycleOperation {
// InsertMany = 'insertMany',
// Update = 'update',
// UpdateOne = 'updateOne',
// UpdateMany = 'updateMany',
UpdateMany = 'updateMany',
}
type PreHook = (...args: any[]) => void;
@@ -69,7 +69,7 @@ export class LifecycleHookManager {
// insertMany: ['pre'],
// update: ['pre', 'post'],
// updateOne: ['pre', 'post'],
// updateMany: ['pre', 'post'],
updateMany: ['pre', 'post'],
};
const lifecycleHooks: LifecycleHooks = {} as LifecycleHooks;