Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PB-845]: feat/calculate-folder-size #259

Merged
merged 1 commit into from
Jan 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 3 additions & 17 deletions src/common/base-http.exception.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,11 @@
import { HttpStatus } from '@nestjs/common';

export class BaseHttpException extends Error {
private readonly _statusCode: number;
private readonly _code: string;

constructor(
message: string,
statusCode = HttpStatus.INTERNAL_SERVER_ERROR,
code?: string,
public readonly message: string,
public readonly statusCode = HttpStatus.INTERNAL_SERVER_ERROR,
public readonly code?: string,
) {
super(message);
this.message = message;
this._statusCode = statusCode;
this._code = code;
}

get statusCode(): number {
return this._statusCode;
}

get code(): string {
return this._code;
}
}
6 changes: 2 additions & 4 deletions src/modules/file/file.usecase.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,8 @@ import {
SequelizeShareRepository,
ShareModel,
} from '../share/share.repository';
import {
FolderModel,
SequelizeFolderRepository,
} from '../folder/folder.repository';
import { SequelizeFolderRepository } from '../folder/folder.repository';
import { FolderModel } from '../folder/folder.model';
import { SequelizeUserRepository, UserModel } from '../user/user.repository';
import { BridgeModule } from '../../externals/bridge/bridge.module';
import { BridgeService } from '../../externals/bridge/bridge.service';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { HttpStatus } from '@nestjs/common';
import { BaseHttpException } from '../../../common/base-http.exception';

export class CalculateFolderSizeTimeoutException extends BaseHttpException {
constructor(
message = 'Calculate folder size timeout',
code = 'CALCULATE_FOLDER_SIZE_TIMEOUT',
statusCode = HttpStatus.UNPROCESSABLE_ENTITY,
) {
super(message, statusCode, code);
}
}
50 changes: 50 additions & 0 deletions src/modules/folder/folder.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { createMock } from '@golevelup/ts-jest';
import { Test, TestingModule } from '@nestjs/testing';
import { newFolder } from '../../../test/fixtures';
import { FileUseCases } from '../file/file.usecase';
import { FolderController } from './folder.controller';
import { Folder } from './folder.domain';
import { FolderUseCases } from './folder.usecase';
import { CalculateFolderSizeTimeoutException } from './exception/calculate-folder-size-timeout.exception';

describe('FolderController', () => {
let folderController: FolderController;
let folderUseCases: FolderUseCases;
let folder: Folder;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [FolderController],
providers: [
{ provide: FolderUseCases, useValue: createMock() },
{ provide: FileUseCases, useValue: createMock() },
],
}).compile();

folderController = module.get<FolderController>(FolderController);
folderUseCases = module.get<FolderUseCases>(FolderUseCases);
folder = newFolder();
});

describe('get folder size', () => {
it('When get folder size is requested, then return the folder size', async () => {
const expectedSize = 100;
jest
.spyOn(folderUseCases, 'getFolderSizeByUuid')
.mockResolvedValue(expectedSize);

const result = await folderController.getFolderSize(folder.uuid);
expect(result).toEqual({ size: expectedSize });
});

it('When get folder size times out, then throw an exception', async () => {
jest
.spyOn(folderUseCases, 'getFolderSizeByUuid')
.mockRejectedValue(new CalculateFolderSizeTimeoutException());

await expect(folderController.getFolderSize(folder.uuid)).rejects.toThrow(
CalculateFolderSizeTimeoutException,
);
});
});
});
12 changes: 11 additions & 1 deletion src/modules/folder/folder.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
NotImplementedException,
Param,
Query,
UseFilters,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { FolderUseCases } from './folder.usecase';
Expand All @@ -19,6 +20,7 @@ import { Folder, SortableFolderAttributes } from './folder.domain';
import { FileStatus, SortableFileAttributes } from '../file/file.domain';
import logger from '../../externals/logger';
import { validate } from 'uuid';
import { HttpExceptionFilter } from '../../lib/http/http-exception.filter';

const foldersStatuses = ['ALL', 'EXISTS', 'TRASHED', 'DELETED'] as const;

Expand Down Expand Up @@ -255,7 +257,7 @@ export class FolderController {
@UserDecorator() user: User,
@Query('limit') limit: number,
@Query('offset') offset: number,
@Query('status') status: typeof foldersStatuses[number],
@Query('status') status: (typeof foldersStatuses)[number],
@Query('updatedAt') updatedAt?: string,
) {
if (!status) {
Expand Down Expand Up @@ -418,4 +420,12 @@ export class FolderController {
});
}
}

@UseFilters(new HttpExceptionFilter())
@Get(':uuid/size')
async getFolderSize(@Param('uuid') folderUuid: Folder['uuid']) {
const size = await this.folderUseCases.getFolderSizeByUuid(folderUuid);

return { size };
}
}
97 changes: 97 additions & 0 deletions src/modules/folder/folder.repository.spec.ts
edisonjpadilla marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { createMock } from '@golevelup/ts-jest';
import { CalculateFolderSizeTimeoutException } from './exception/calculate-folder-size-timeout.exception';
import { SequelizeFolderRepository } from './folder.repository';
import { FolderModel } from './folder.model';
import { Folder } from './folder.domain';
import { newFolder } from '../../../test/fixtures';

jest.mock('./folder.model', () => ({
FolderModel: {
sequelize: {
query: jest.fn(() => Promise.resolve([[{ totalsize: 100 }]])),
},
},
}));

describe('SequelizeFolderRepository', () => {
const TIMEOUT_ERROR_CODE = '57014';

let repository: SequelizeFolderRepository;
let folderModel: typeof FolderModel;
let folder: Folder;

beforeEach(async () => {
folderModel = createMock<typeof FolderModel>();

repository = new SequelizeFolderRepository(folderModel);

folder = newFolder();
});

describe('calculate folder size', () => {
it('When calculate folder size is requested, then it works', async () => {
const calculateSizeQuery = `
edisonjpadilla marked this conversation as resolved.
Show resolved Hide resolved
WITH RECURSIVE folder_recursive AS (
SELECT
fl1.uuid,
fl1.parent_uuid,
f1.size AS filesize,
1 AS row_num,
fl1.user_id as owner_id
FROM folders fl1
LEFT JOIN files f1 ON f1.folder_uuid = fl1.uuid
WHERE fl1.uuid = :folderUuid
AND fl1.removed = FALSE
AND fl1.deleted = FALSE
AND f1.status != 'DELETED'

UNION ALL

SELECT
fl2.uuid,
fl2.parent_uuid,
f2.size AS filesize,
fr.row_num + 1,
fr.owner_id
FROM folders fl2
INNER JOIN files f2 ON f2.folder_uuid = fl2.uuid
INNER JOIN folder_recursive fr ON fr.uuid = fl2.parent_uuid
WHERE fr.row_num < 100000
AND fl2.user_id = fr.owner_id
AND fl2.removed = FALSE
AND fl2.deleted = FALSE
AND f2.status != 'DELETED'
)
SELECT COALESCE(SUM(filesize), 0) AS totalsize FROM folder_recursive;
`;

jest
.spyOn(FolderModel.sequelize, 'query')
.mockResolvedValue([[{ totalsize: 100 }]] as any);

const size = await repository.calculateFolderSize(folder.uuid);

expect(size).toBeGreaterThanOrEqual(0);
expect(FolderModel.sequelize.query).toHaveBeenCalledWith(
calculateSizeQuery,
{
replacements: {
folderUuid: folder.uuid,
},
},
);
});

it('When the folder size calculation times out, then throw an exception', async () => {
jest.spyOn(FolderModel.sequelize, 'query').mockRejectedValue({
original: {
code: TIMEOUT_ERROR_CODE,
},
});

await expect(repository.calculateFolderSize(folder.uuid)).rejects.toThrow(
CalculateFolderSizeTimeoutException,
);
});
});
});
62 changes: 58 additions & 4 deletions src/modules/folder/folder.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import { v4 } from 'uuid';
import { Folder } from './folder.domain';
import { FolderAttributes } from './folder.attributes';

import { UserModel } from '../user/user.model';
import { User } from '../user/user.domain';
import { UserAttributes } from '../user/user.attributes';
import { Pagination } from '../../lib/pagination';
import { FolderModel } from './folder.model';
import { SharingModel } from '../sharing/models';
import { CalculateFolderSizeTimeoutException } from './exception/calculate-folder-size-timeout.exception';

function mapSnakeCaseToCamelCase(data) {
const camelCasedObject = {};
Expand Down Expand Up @@ -59,15 +59,14 @@ export interface FolderRepository {
): Promise<void>;
deleteById(folderId: FolderAttributes['id']): Promise<void>;
clearOrphansFolders(userId: FolderAttributes['userId']): Promise<number>;
calculateFolderSize(folderUuid: string): Promise<number>;
}

@Injectable()
export class SequelizeFolderRepository implements FolderRepository {
constructor(
@InjectModel(FolderModel)
private folderModel: typeof FolderModel,
@InjectModel(UserModel)
private userModel: typeof UserModel,
) {}

async findAllCursor(
Expand Down Expand Up @@ -447,6 +446,62 @@ export class SequelizeFolderRepository implements FolderRepository {
return folders.map((folder) => this.toDomain(folder));
}

async calculateFolderSize(folderUuid: string): Promise<number> {
try {
const calculateSizeQuery = `
WITH RECURSIVE folder_recursive AS (
SELECT
fl1.uuid,
fl1.parent_uuid,
f1.size AS filesize,
1 AS row_num,
fl1.user_id as owner_id
FROM folders fl1
LEFT JOIN files f1 ON f1.folder_uuid = fl1.uuid
WHERE fl1.uuid = :folderUuid
AND fl1.removed = FALSE
AND fl1.deleted = FALSE
AND f1.status != 'DELETED'

UNION ALL

SELECT
fl2.uuid,
fl2.parent_uuid,
f2.size AS filesize,
fr.row_num + 1,
fr.owner_id
FROM folders fl2
INNER JOIN files f2 ON f2.folder_uuid = fl2.uuid
INNER JOIN folder_recursive fr ON fr.uuid = fl2.parent_uuid
edisonjpadilla marked this conversation as resolved.
Show resolved Hide resolved
WHERE fr.row_num < 100000
AND fl2.user_id = fr.owner_id
AND fl2.removed = FALSE
AND fl2.deleted = FALSE
AND f2.status != 'DELETED'
)
SELECT COALESCE(SUM(filesize), 0) AS totalsize FROM folder_recursive;
`;

const [[{ totalsize }]]: any = await FolderModel.sequelize.query(
calculateSizeQuery,
{
replacements: {
folderUuid,
},
},
);

return +totalsize;
} catch (error) {
if (error.original?.code === '57014') {
throw new CalculateFolderSizeTimeoutException();
}

throw error;
}
}

private toDomain(model: FolderModel): Folder {
return Folder.build({
...model.toJSON(),
Expand All @@ -459,4 +514,3 @@ export class SequelizeFolderRepository implements FolderRepository {
return domain.toJSON();
}
}
export { FolderModel };
32 changes: 32 additions & 0 deletions src/modules/folder/folder.usecase.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import { FolderModel } from './folder.model';
import { FileModel } from '../file/file.model';
import { SequelizeThumbnailRepository } from '../thumbnail/thumbnail.repository';
import { ThumbnailModel } from '../thumbnail/thumbnail.model';
import { CalculateFolderSizeTimeoutException } from './exception/calculate-folder-size-timeout.exception';
import { newFolder } from '../../../test/fixtures';

const folderId = 4;
const userId = 1;
Expand Down Expand Up @@ -492,4 +494,34 @@ describe('FolderUseCases', () => {
}
});
});

describe('get folder size', () => {
const folder = newFolder();

it('When the folder size is requested to be calculated, then it works', async () => {
const mockSize = 123456789;

jest
.spyOn(folderRepository, 'calculateFolderSize')
.mockResolvedValueOnce(mockSize);

const result = await service.getFolderSizeByUuid(folder.uuid);

expect(result).toBe(mockSize);
expect(folderRepository.calculateFolderSize).toHaveBeenCalledTimes(1);
expect(folderRepository.calculateFolderSize).toHaveBeenCalledWith(
folder.uuid,
);
});

it('When the folder size times out, then throw an exception', async () => {
jest
.spyOn(folderRepository, 'calculateFolderSize')
sg-gs marked this conversation as resolved.
Show resolved Hide resolved
.mockRejectedValueOnce(new CalculateFolderSizeTimeoutException());

await expect(service.getFolderSizeByUuid(folder.uuid)).rejects.toThrow(
CalculateFolderSizeTimeoutException,
);
});
});
});
Loading
Loading