Merge pull request #563 from Hexastack/562-issue-strict-null-check
Some checks failed
Build and Push Docker API Image / build-and-push (push) Has been cancelled
Build and Push Docker Base Image / build-and-push (push) Has been cancelled
Build and Push Docker UI Image / build-and-push (push) Has been cancelled

feat: implement strict null checks
This commit is contained in:
Med Marrouchi 2025-01-15 18:03:15 +01:00 committed by GitHub
commit 47e8056a15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 128 additions and 87 deletions

View File

@ -27,7 +27,7 @@ import {
import { attachment, attachmentFile } from '../mocks/attachment.mock'; import { attachment, attachmentFile } from '../mocks/attachment.mock';
import { AttachmentRepository } from '../repositories/attachment.repository'; import { AttachmentRepository } from '../repositories/attachment.repository';
import { AttachmentModel, Attachment } from '../schemas/attachment.schema'; import { Attachment, AttachmentModel } from '../schemas/attachment.schema';
import { AttachmentService } from '../services/attachment.service'; import { AttachmentService } from '../services/attachment.service';
import { AttachmentController } from './attachment.controller'; import { AttachmentController } from './attachment.controller';
@ -55,14 +55,12 @@ describe('AttachmentController', () => {
attachmentController = attachmentController =
module.get<AttachmentController>(AttachmentController); module.get<AttachmentController>(AttachmentController);
attachmentService = module.get<AttachmentService>(AttachmentService); attachmentService = module.get<AttachmentService>(AttachmentService);
attachmentToDelete = await attachmentService.findOne({ attachmentToDelete = (await attachmentService.findOne({
name: 'store1.jpg', name: 'store1.jpg',
}); }))!;
}); });
afterAll(async () => { afterAll(closeInMongodConnection);
await closeInMongodConnection();
});
afterEach(jest.clearAllMocks); afterEach(jest.clearAllMocks);
@ -79,7 +77,7 @@ describe('AttachmentController', () => {
describe('Upload', () => { describe('Upload', () => {
it('should throw BadRequestException if no file is selected to be uploaded', async () => { it('should throw BadRequestException if no file is selected to be uploaded', async () => {
const promiseResult = attachmentController.uploadFile({ const promiseResult = attachmentController.uploadFile({
file: undefined, file: [],
}); });
await expect(promiseResult).rejects.toThrow( await expect(promiseResult).rejects.toThrow(
new BadRequestException('No file was selected'), new BadRequestException('No file was selected'),
@ -117,9 +115,9 @@ describe('AttachmentController', () => {
it('should download the attachment by id', async () => { it('should download the attachment by id', async () => {
jest.spyOn(attachmentService, 'findOne'); jest.spyOn(attachmentService, 'findOne');
const storedAttachment = await attachmentService.findOne({ const storedAttachment = (await attachmentService.findOne({
name: 'store1.jpg', name: 'store1.jpg',
}); }))!;
const result = await attachmentController.download({ const result = await attachmentController.download({
id: storedAttachment.id, id: storedAttachment.id,
}); });
@ -127,7 +125,7 @@ describe('AttachmentController', () => {
expect(attachmentService.findOne).toHaveBeenCalledWith( expect(attachmentService.findOne).toHaveBeenCalledWith(
storedAttachment.id, storedAttachment.id,
); );
expect(result.options).toEqual({ expect(result?.options).toEqual({
type: storedAttachment.type, type: storedAttachment.type,
length: storedAttachment.size, length: storedAttachment.size,
disposition: `attachment; filename="${encodeURIComponent( disposition: `attachment; filename="${encodeURIComponent(

View File

@ -256,9 +256,15 @@ export class AttachmentService extends BaseService<Attachment> {
async download( async download(
attachment: Attachment, attachment: Attachment,
rootDir = config.parameters.uploadDir, rootDir = config.parameters.uploadDir,
) { ): Promise<StreamableFile> {
if (this.getStoragePlugin()) { if (this.getStoragePlugin()) {
return await this.getStoragePlugin()?.download(attachment); const streamableFile =
await this.getStoragePlugin()?.download(attachment);
if (!streamableFile) {
throw new NotFoundException('No file was found');
}
return streamableFile;
} else { } else {
const path = resolve(join(rootDir, attachment.location)); const path = resolve(join(rootDir, attachment.location));

View File

@ -100,32 +100,31 @@ export default class LlmNluHelper
* *
* @returns An array of objects representing the found entities, with their `value`, `start`, and `end` positions. * @returns An array of objects representing the found entities, with their `value`, `start`, and `end` positions.
*/ */
private findKeywordEntities( private findKeywordEntities(text: string, entity: NlpEntityFull) {
text: string, return (
entity: NlpEntityFull, entity.values
): NLU.ParseEntity[] { .flatMap(({ value, expressions }) => {
return entity.values const allValues = [value, ...expressions];
.flatMap(({ value, expressions }) => {
const allValues = [value, ...expressions];
// Filter the terms that are found in the text // Filter the terms that are found in the text
return allValues return allValues
.flatMap((term) => { .flatMap((term) => {
const regex = new RegExp(`\\b${term}\\b`, 'g'); const regex = new RegExp(`\\b${term}\\b`, 'g');
const matches = [...text.matchAll(regex)]; const matches = [...text.matchAll(regex)];
// Map matches to FoundEntity format // Map matches to FoundEntity format
return matches.map((match) => ({ return matches.map((match) => ({
entity: entity.name, entity: entity.name,
value: term, value: term,
start: match.index!, start: match.index!,
end: match.index! + term.length, end: match.index! + term.length,
confidence: 1, confidence: 1,
})); }));
}) })
.shift(); .shift();
}) })
.filter((v) => !!v); .filter((v) => !!v) || []
);
} }
async predict(text: string): Promise<NLU.ParseEntities> { async predict(text: string): Promise<NLU.ParseEntities> {
@ -133,7 +132,7 @@ export default class LlmNluHelper
const helper = await this.helperService.getDefaultLlmHelper(); const helper = await this.helperService.getDefaultLlmHelper();
const defaultLanguage = await this.languageService.getDefaultLanguage(); const defaultLanguage = await this.languageService.getDefaultLanguage();
// Detect language // Detect language
const language = await helper.generateStructuredResponse<string>( const language = await helper.generateStructuredResponse<string>?.(
`input text: ${text}`, `input text: ${text}`,
settings.model, settings.model,
this.languageClassifierPrompt, this.languageClassifierPrompt,
@ -147,13 +146,13 @@ export default class LlmNluHelper
{ {
entity: 'language', entity: 'language',
value: language || defaultLanguage.code, value: language || defaultLanguage.code,
confidence: undefined, confidence: 100,
}, },
]; ];
for await (const { name, doc, prompt, values } of this for await (const { name, doc, prompt, values } of this
.traitClassifierPrompts) { .traitClassifierPrompts) {
const allowedValues = values.map(({ value }) => value); const allowedValues = values.map(({ value }) => value);
const result = await helper.generateStructuredResponse<string>( const result = await helper.generateStructuredResponse<string>?.(
`input text: ${text}`, `input text: ${text}`,
settings.model, settings.model,
prompt, prompt,
@ -163,12 +162,13 @@ export default class LlmNluHelper
enum: allowedValues.concat('unknown'), enum: allowedValues.concat('unknown'),
}, },
); );
const safeValue = result.toLowerCase().trim(); const safeValue = result?.toLowerCase().trim();
const value = allowedValues.includes(safeValue) ? safeValue : ''; const value =
safeValue && allowedValues.includes(safeValue) ? safeValue : '';
traits.push({ traits.push({
entity: name, entity: name,
value, value,
confidence: undefined, confidence: 100,
}); });
} }
@ -179,7 +179,7 @@ export default class LlmNluHelper
}); });
const entities = keywordEntities.flatMap((keywordEntity) => const entities = keywordEntities.flatMap((keywordEntity) =>
this.findKeywordEntities(text, keywordEntity), this.findKeywordEntities(text, keywordEntity),
); ) as NLU.ParseEntity[];
return { entities: traits.concat(entities) }; return { entities: traits.concat(entities) };
} }

View File

@ -191,7 +191,7 @@ describe('MigrationService', () => {
await service.run({ await service.run({
action: MigrationAction.UP, action: MigrationAction.UP,
version: null, version: undefined,
isAutoMigrate: false, isAutoMigrate: false,
}); });

View File

@ -29,6 +29,7 @@ import { Migration, MigrationDocument } from './migration.schema';
import { import {
MigrationAction, MigrationAction,
MigrationName, MigrationName,
MigrationRunOneParams,
MigrationRunParams, MigrationRunParams,
MigrationSuccessCallback, MigrationSuccessCallback,
MigrationVersion, MigrationVersion,
@ -239,7 +240,7 @@ module.exports = {
* *
* @returns Resolves when the migration action is successfully executed or stops if the migration already exists. * @returns Resolves when the migration action is successfully executed or stops if the migration already exists.
*/ */
private async runOne({ version, action }: MigrationRunParams) { private async runOne({ version, action }: MigrationRunOneParams) {
// Verify DB status // Verify DB status
const { exist, migrationDocument } = await this.verifyStatus({ const { exist, migrationDocument } = await this.verifyStatus({
version, version,
@ -258,7 +259,7 @@ module.exports = {
attachmentService: this.attachmentService, attachmentService: this.attachmentService,
}); });
if (result) { if (result && migrationDocument) {
await this.successCallback({ await this.successCallback({
version, version,
action, action,

View File

@ -442,7 +442,10 @@ const migrateAttachmentMessages = async ({
type: response.headers['content-type'], type: response.headers['content-type'],
channel: {}, channel: {},
}); });
await updateAttachmentId(msg._id, attachment.id);
if (attachment) {
await updateAttachmentId(msg._id, attachment.id);
}
} }
} else { } else {
logger.warn( logger.warn(

View File

@ -28,6 +28,10 @@ export interface MigrationRunParams {
isAutoMigrate?: boolean; isAutoMigrate?: boolean;
} }
export interface MigrationRunOneParams extends MigrationRunParams {
version: MigrationVersion;
}
export interface MigrationSuccessCallback extends MigrationRunParams { export interface MigrationSuccessCallback extends MigrationRunParams {
migrationDocument: MigrationDocument; migrationDocument: MigrationDocument;
} }

View File

@ -109,7 +109,7 @@ describe('NlpEntityController', () => {
acc.push({ acc.push({
...curr, ...curr,
values: nlpValueFixtures.filter( values: nlpValueFixtures.filter(
({ entity }) => parseInt(entity) === index, ({ entity }) => parseInt(entity!) === index,
) as NlpEntityFull['values'], ) as NlpEntityFull['values'],
lookups: curr.lookups!, lookups: curr.lookups!,
builtin: curr.builtin!, builtin: curr.builtin!,

View File

@ -98,7 +98,7 @@ describe('NlpValueController', () => {
acc.push({ acc.push({
...curr, ...curr,
entity: nlpEntityFixtures[ entity: nlpEntityFixtures[
parseInt(curr.entity) parseInt(curr.entity!)
] as NlpValueFull['entity'], ] as NlpValueFull['entity'],
builtin: curr.builtin!, builtin: curr.builtin!,
expressions: curr.expressions!, expressions: curr.expressions!,
@ -125,7 +125,7 @@ describe('NlpValueController', () => {
(acc, curr) => { (acc, curr) => {
const ValueWithEntities = { const ValueWithEntities = {
...curr, ...curr,
entity: nlpEntities[parseInt(curr.entity)].id, entity: curr.entity ? nlpEntities[parseInt(curr.entity!)].id : null,
expressions: curr.expressions!, expressions: curr.expressions!,
metadata: curr.metadata!, metadata: curr.metadata!,
builtin: curr.builtin!, builtin: curr.builtin!,
@ -133,7 +133,7 @@ describe('NlpValueController', () => {
acc.push(ValueWithEntities); acc.push(ValueWithEntities);
return acc; return acc;
}, },
[] as TFixtures<NlpValue>[], [] as TFixtures<NlpValueCreateDto>[],
); );
expect(result).toEqualPayload(nlpValueFixturesWithEntities); expect(result).toEqualPayload(nlpValueFixturesWithEntities);
}); });

View File

@ -75,8 +75,9 @@ export class NlpValueController extends BaseController<
this.validate({ this.validate({
dto: createNlpValueDto, dto: createNlpValueDto,
allowedIds: { allowedIds: {
entity: (await this.nlpEntityService.findOne(createNlpValueDto.entity)) entity: createNlpValueDto.entity
?.id, ? (await this.nlpEntityService.findOne(createNlpValueDto.entity))?.id
: null,
}, },
}); });
return await this.nlpValueService.create(createNlpValueDto); return await this.nlpValueService.create(createNlpValueDto);

View File

@ -6,7 +6,6 @@
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/ */
import { PartialType } from '@nestjs/mapped-types';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { import {
IsArray, IsArray,
@ -49,11 +48,42 @@ export class NlpValueCreateDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@IsObjectId({ message: 'Entity must be a valid ObjectId' }) @IsObjectId({ message: 'Entity must be a valid ObjectId' })
entity: string; entity: string | null;
} }
export class NlpValueUpdateDto extends PartialType(NlpValueCreateDto) {} export class NlpValueUpdateDto {
@ApiPropertyOptional({ description: 'Foreign ID', type: String })
@IsOptional()
@IsString()
foreign_id?: string;
@ApiPropertyOptional({ description: 'Nlp value', type: String })
@IsOptional()
@IsString()
value?: string;
@ApiPropertyOptional({
description: 'Nlp value expressions',
isArray: true,
type: Array,
})
@IsOptional()
@IsArray()
expressions?: string[];
@ApiPropertyOptional({ description: 'Nlp value entity', type: String })
@IsOptional()
@IsString()
@IsObjectId({ message: 'Entity must be a valid ObjectId' })
entity?: string | null;
@ApiPropertyOptional({ description: 'Nlp value is builtin', type: Boolean })
@IsOptional()
@IsBoolean()
builtin?: boolean;
}
export type NlpValueDto = DtoConfig<{ export type NlpValueDto = DtoConfig<{
create: NlpValueCreateDto; create: NlpValueCreateDto;
update: NlpValueUpdateDto;
}>; }>;

View File

@ -87,7 +87,7 @@ describe('NlpValueRepository', () => {
const ValueWithEntities = { const ValueWithEntities = {
...curr, ...curr,
entity: nlpEntityFixtures[ entity: nlpEntityFixtures[
parseInt(curr.entity) parseInt(curr.entity!)
] as NlpValueFull['entity'], ] as NlpValueFull['entity'],
builtin: curr.builtin!, builtin: curr.builtin!,
expressions: curr.expressions!, expressions: curr.expressions!,

View File

@ -38,7 +38,7 @@ export class NlpValueSeeder extends BaseSeeder<
const entities = await this.nlpEntityRepository.findAll(); const entities = await this.nlpEntityRepository.findAll();
const modelDtos = models.map((v) => ({ const modelDtos = models.map((v) => ({
...v, ...v,
entity: entities.find(({ name }) => name === v.entity)?.id, entity: entities.find(({ name }) => name === v.entity)?.id || null,
})); }));
await this.repository.createMany(modelDtos); await this.repository.createMany(modelDtos);
return true; return true;

View File

@ -69,9 +69,7 @@ describe('NlpValueService', () => {
nlpValues = await nlpValueRepository.findAll(); nlpValues = await nlpValueRepository.findAll();
}); });
afterAll(async () => { afterAll(closeInMongodConnection);
await closeInMongodConnection();
});
afterEach(jest.clearAllMocks); afterEach(jest.clearAllMocks);
@ -94,9 +92,7 @@ describe('NlpValueService', () => {
(acc, curr) => { (acc, curr) => {
const ValueWithEntities = { const ValueWithEntities = {
...curr, ...curr,
entity: nlpEntityFixtures[ entity: nlpEntityFixtures[parseInt(curr.entity!)] as NlpEntity,
parseInt(curr.entity)
] as NlpValueFull['entity'],
expressions: curr.expressions!, expressions: curr.expressions!,
metadata: curr.metadata!, metadata: curr.metadata!,
builtin: curr.builtin!, builtin: curr.builtin!,

View File

@ -11,11 +11,7 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { DeleteResult } from '@/utils/generics/base-repository'; import { DeleteResult } from '@/utils/generics/base-repository';
import { BaseService } from '@/utils/generics/base-service'; import { BaseService } from '@/utils/generics/base-service';
import { import { NlpValueCreateDto, NlpValueDto } from '../dto/nlp-value.dto';
NlpValueCreateDto,
NlpValueDto,
NlpValueUpdateDto,
} from '../dto/nlp-value.dto';
import { NlpValueRepository } from '../repositories/nlp-value.repository'; import { NlpValueRepository } from '../repositories/nlp-value.repository';
import { NlpEntity } from '../schemas/nlp-entity.schema'; import { NlpEntity } from '../schemas/nlp-entity.schema';
import { import {
@ -139,7 +135,7 @@ export class NlpValueService extends BaseService<
expressions: vMap[e.value].expressions?.concat([ expressions: vMap[e.value].expressions?.concat([
sampleText.slice(e.start, e.end), sampleText.slice(e.start, e.end),
]), ]),
} as NlpValueUpdateDto); });
}); });
await Promise.all(synonymsToAdd); await Promise.all(synonymsToAdd);

View File

@ -478,7 +478,7 @@ export abstract class BaseRepository<
async updateOne<D extends Partial<U>>( async updateOne<D extends Partial<U>>(
criteria: string | TFilterQuery<T>, criteria: string | TFilterQuery<T>,
dto: UpdateQuery<D>, dto: UpdateQuery<DtoInfer<DtoAction.Update, Dto, D>>,
options: QueryOptions<D> | null = { options: QueryOptions<D> | null = {
new: true, new: true,
}, },

View File

@ -177,7 +177,7 @@ export abstract class BaseService<
async updateOne( async updateOne(
criteria: string | TFilterQuery<T>, criteria: string | TFilterQuery<T>,
dto: Partial<U>, dto: DtoInfer<DtoAction.Update, Dto, Partial<U>>,
options?: QueryOptions<Partial<U>> | null, options?: QueryOptions<Partial<U>> | null,
): Promise<T | null> { ): Promise<T | null> {
return await this.repository.updateOne(criteria, dto, options); return await this.repository.updateOne(criteria, dto, options);

View File

@ -48,7 +48,7 @@ export async function moveFiles(
const files = await fs.promises.readdir(sourceFolder); const files = await fs.promises.readdir(sourceFolder);
// Filter only files (skip directories) // Filter only files (skip directories)
const filePaths = []; const filePaths: string[] = [];
for (const file of files) { for (const file of files) {
const filePath = join(sourceFolder, file); const filePath = join(sourceFolder, file);
const stat = await fs.promises.stat(filePath); const stat = await fs.promises.stat(filePath);

View File

@ -7,10 +7,10 @@
*/ */
import { import {
Injectable,
PipeTransform,
ArgumentMetadata, ArgumentMetadata,
BadRequestException, BadRequestException,
Injectable,
PipeTransform,
} from '@nestjs/common'; } from '@nestjs/common';
import { plainToClass } from 'class-transformer'; import { plainToClass } from 'class-transformer';
import { validate } from 'class-validator'; import { validate } from 'class-validator';
@ -33,8 +33,13 @@ export class ObjectIdPipe implements PipeTransform<string, Promise<string>> {
async transform(value: string, { type, data }: ArgumentMetadata) { async transform(value: string, { type, data }: ArgumentMetadata) {
if (typeof value === 'string' && data === 'id' && type === 'param') { if (typeof value === 'string' && data === 'id' && type === 'param') {
const errors = await this.getErrors(value); const errors = await this.getErrors(value);
if (errors) if (errors) {
throw new BadRequestException(Object.values(errors.constraints)[0]); throw new BadRequestException(
errors?.constraints
? Object.values(errors.constraints)[0]
: errors.toString(),
);
}
} else if ( } else if (
typeof value === 'object' && typeof value === 'object' &&
Object.keys(value).length > 1 && Object.keys(value).length > 1 &&
@ -45,10 +50,13 @@ export class ObjectIdPipe implements PipeTransform<string, Promise<string>> {
if (param.startsWith('id')) { if (param.startsWith('id')) {
const errors = await this.getErrors(String(paramValue)); const errors = await this.getErrors(String(paramValue));
if (errors) if (errors) {
throw new BadRequestException( throw new BadRequestException(
Object.values(errors.constraints)[0], errors?.constraints
? Object.values(errors.constraints)[0]
: errors.toString(),
); );
}
} }
}), }),
); );

View File

@ -142,7 +142,7 @@ export const installConversationTypeFixtures = async () => {
conversationFixtures.map((conversationFixture) => ({ conversationFixtures.map((conversationFixture) => ({
...conversationFixture, ...conversationFixture,
sender: subscribers[parseInt(conversationFixture.sender)].id, sender: subscribers[parseInt(conversationFixture.sender)].id,
current: conversationFixture?.current current: conversationFixture.current
? blocks[parseInt(conversationFixture.current)]?.id ? blocks[parseInt(conversationFixture.current)]?.id
: undefined, : undefined,
next: conversationFixture.next?.map((n) => blocks[parseInt(n)].id), next: conversationFixture.next?.map((n) => blocks[parseInt(n)].id),

View File

@ -51,12 +51,10 @@ export const installNlpValueFixtures = async () => {
const NlpValue = mongoose.model(NlpValueModel.name, NlpValueModel.schema); const NlpValue = mongoose.model(NlpValueModel.name, NlpValueModel.schema);
const nlpValues = await NlpValue.insertMany( const nlpValues = await NlpValue.insertMany(
nlpValueFixtures.map((v) => { nlpValueFixtures.map((v) => ({
return { ...v,
...v, entity: v?.entity ? nlpEntities[parseInt(v.entity)].id : null,
entity: nlpEntities[parseInt(v.entity)].id, })),
};
}),
); );
return { nlpEntities, nlpValues }; return { nlpEntities, nlpValues };
}; };

View File

@ -13,7 +13,7 @@
"baseUrl": "./", "baseUrl": "./",
"incremental": true, "incremental": true,
"skipLibCheck": true, "skipLibCheck": true,
"strictNullChecks": false, "strictNullChecks": true,
"strictPropertyInitialization": false, "strictPropertyInitialization": false,
"noImplicitAny": false, "noImplicitAny": false,
"strictBindCallApply": false, "strictBindCallApply": false,