From d5825644a45963235720b13a69711315aae15d91 Mon Sep 17 00:00:00 2001 From: Roland Schneider Date: Thu, 20 Nov 2025 00:03:36 +0100 Subject: [PATCH] improve entity/crud generation --- src/services/angular-generator.service.ts | 22 +- src/services/crud-generator.service.ts | 341 +++--------------- src/services/entity-generator.service.ts | 17 +- .../data-provider.service.ts.tpl | 5 +- .../angular-generic/table.component.ts.tpl | 2 +- src/templates/nestjs/controller.ts.tpl | 44 +++ src/templates/nestjs/create-dto.ts.tpl | 4 + src/templates/nestjs/module.ts.tpl | 12 + src/templates/nestjs/query-dto.ts.tpl | 9 + src/templates/nestjs/service.ts.tpl | 101 ++++++ src/templates/nestjs/update-dto.ts.tpl | 4 + 11 files changed, 261 insertions(+), 300 deletions(-) create mode 100644 src/templates/nestjs/controller.ts.tpl create mode 100644 src/templates/nestjs/create-dto.ts.tpl create mode 100644 src/templates/nestjs/module.ts.tpl create mode 100644 src/templates/nestjs/query-dto.ts.tpl create mode 100644 src/templates/nestjs/service.ts.tpl create mode 100644 src/templates/nestjs/update-dto.ts.tpl diff --git a/src/services/angular-generator.service.ts b/src/services/angular-generator.service.ts index 6e8df5c..71d84d8 100644 --- a/src/services/angular-generator.service.ts +++ b/src/services/angular-generator.service.ts @@ -186,23 +186,33 @@ private async generateTableComponent(tableName: string, columns: TableColumn[], const singular = this.toSingular(tableName); const pascal = this.toPascalCase(singular); return { - singular: singular, - plural: tableName, + singular: this.toKebabCase(singular), + plural: this.toKebabCase(tableName), pascal: pascal, camel: this.toCamelCase(singular), title: pascal, - kebab: singular, + kebab: this.toKebabCase(singular), }; } private toSingular(name: string): string { - if (name.endsWith('ies')) return name.slice(0, -3) + 'y'; - return name.endsWith('s') ? name.slice(0, -1) : name; + const kebab = this.toKebabCase(name); + if (kebab.endsWith('ies')) return kebab.slice(0, -3) + 'y'; + return kebab.endsWith('s') ? kebab.slice(0, -1) : kebab; } private toPascalCase(text: string): string { - return text.replace(/(^\w|-\w|_w)/g, (c) => c.replace(/[-_]/, '').toUpperCase()); + return this.toKebabCase(text) + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); } private toCamelCase(text: string): string { const pascal = this.toPascalCase(text); return pascal.charAt(0).toLowerCase() + pascal.slice(1); } + private toKebabCase(text: string): string { + return text + .replace(/_/g, '-') // Replace underscores with hyphens + .replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2') // Add hyphen before uppercase letters + .toLowerCase(); + } } \ No newline at end of file diff --git a/src/services/crud-generator.service.ts b/src/services/crud-generator.service.ts index 161514e..3fc505f 100644 --- a/src/services/crud-generator.service.ts +++ b/src/services/crud-generator.service.ts @@ -1,370 +1,131 @@ // src/services/crud-generator.service.ts import { Injectable } from '@nestjs/common'; import { ConfigService } from './config.service'; +import { ModuleUpdaterService } from './module-updater.service'; +import { TemplateService } from './template.service'; +import { TableColumn } from 'typeorm'; import * as path from 'path'; import * as fs from 'fs'; import { promises as fsPromises } from 'fs'; -import { ModuleUpdaterService } from './module-updater.service'; -import { TableColumn } from 'typeorm'; interface NamingConvention { + [key: string]: string; singular: string; plural: string; pascal: string; camel: string; - searchableFields + searchableFields: string; } @Injectable() export class CrudGeneratorService { - constructor(private readonly configService: ConfigService, private readonly moduleUpdaterService: ModuleUpdaterService,) { - } + constructor( + private readonly configService: ConfigService, + private readonly moduleUpdaterService: ModuleUpdaterService, + private readonly templateService: TemplateService, + ) {} public async generate(tableName: string, columns?: TableColumn[]): Promise { console.log(`Generating CRUD module for table: ${tableName}...`); - const names = this.getNamingConvention(tableName,columns); + const names = this.getNamingConvention(tableName, columns); const config = this.configService.get(); const serverRoot = path.resolve(process.cwd(), config.server.path); + + // Use the CORRECT kebab-cased name for the directory const moduleDir = path.join(serverRoot, 'src', names.singular); - - try { + // Calculate entity paths relative to their destination files here. + const entityFullPath = path.join(serverRoot, config.server.entitiesPath, `${names.singular}.entity.ts`); + names['entityPathForModule'] = path.relative(moduleDir, entityFullPath).replace(/\\/g, '/').replace('.ts', ''); + names['entityPathForDtos'] = path.relative(path.join(moduleDir, 'dto'), entityFullPath).replace(/\\/g, '/').replace('.ts', ''); + await this.generateModuleFile(names, moduleDir); await this.generateControllerFile(names, moduleDir); await this.generateServiceFile(names, moduleDir); await this.generateDtoFiles(names, moduleDir); - console.log(`✅ CRUD module for "${tableName}" created successfully in: ${moduleDir}`); - - // --- 3. CALL THE UPDATER --- const moduleFileName = `${names.plural}.module.ts`; const fullModulePath = path.join(moduleDir, moduleFileName); await this.moduleUpdaterService.addImportToAppModule(`${names.pascal}sModule`, fullModulePath); - // --- END OF CALL --- - // We can now remove the old warning! + console.log(`✅ CRUD module for "${tableName}" created successfully in: ${moduleDir}`); console.log('\n✨ AppModule has been updated automatically! ✨'); - } catch (error) { console.error(`❌ An error occurred during CRUD generation:`, error.message); } } - private isStringBasedType(dbType: string): boolean { - return dbType.includes('char') || dbType.includes('text') || dbType === 'uuid'; - } - private async generateModuleFile(names: NamingConvention, moduleDir: string) { - const template = this.getModuleTemplate(names); + // Use the pre-calculated path + const content = this.templateService.render('nestjs/module.ts.tpl', { ...names, entityPath: names['entityPathForModule'] }); const filePath = path.join(moduleDir, `${names.plural}.module.ts`); await fsPromises.mkdir(moduleDir, { recursive: true }); - fs.writeFileSync(filePath, template); + fs.writeFileSync(filePath, content); } private async generateControllerFile(names: NamingConvention, moduleDir: string) { - const template = this.getControllerTemplate(names); + const content = this.templateService.render('nestjs/controller.ts.tpl', names); const filePath = path.join(moduleDir, `${names.plural}.controller.ts`); await fsPromises.mkdir(moduleDir, { recursive: true }); - fs.writeFileSync(filePath, template); + fs.writeFileSync(filePath, content); } private async generateServiceFile(names: NamingConvention, moduleDir: string) { - const template = this.getServiceTemplate(names); + // Use the pre-calculated path + const content = this.templateService.render('nestjs/service.ts.tpl', { ...names, entityPath: names['entityPathForModule'] }); const filePath = path.join(moduleDir, `${names.plural}.service.ts`); await fsPromises.mkdir(moduleDir, { recursive: true }); - fs.writeFileSync(filePath, template); + fs.writeFileSync(filePath, content); } - // --- END OF MISSING METHODS --- - private async generateDtoFiles(names: NamingConvention, moduleDir: string) { const dtoDir = path.join(moduleDir, 'dto'); await fsPromises.mkdir(dtoDir, { recursive: true }); - fs.writeFileSync(path.join(dtoDir, `create-${names.singular}.dto.ts`), this.getCreateDtoTemplate(names)); - fs.writeFileSync(path.join(dtoDir, `update-${names.singular}.dto.ts`), this.getUpdateDtoTemplate(names)); - fs.writeFileSync(path.join(dtoDir, `query-${names.singular}.dto.ts`), this.getQueryDtoTemplate(names)); + // Use the pre-calculated path + const dtoNames = { ...names, entityPath: names['entityPathForDtos'] }; + + fs.writeFileSync(path.join(dtoDir, `create-${names.singular}.dto.ts`), this.templateService.render('nestjs/create-dto.ts.tpl', dtoNames)); + fs.writeFileSync(path.join(dtoDir, `update-${names.singular}.dto.ts`), this.templateService.render('nestjs/update-dto.ts.tpl', dtoNames)); + fs.writeFileSync(path.join(dtoDir, `query-${names.singular}.dto.ts`), this.templateService.render('nestjs/query-dto.ts.tpl', dtoNames)); } - // --- TEMPLATES --- - // (All the template methods from the previous response go here, unchanged) + // --- NAMING HELPERS --- - private getQueryDtoTemplate(names: NamingConvention): string { - return `import { IsOptional, IsString, IsNumber, IsIn, IsBoolean } from 'class-validator'; -import { Type } from 'class-transformer'; - -export class Query${names.pascal}Dto { - @IsOptional() - @Type(() => Number) - @IsNumber() - page?: number; - - @IsOptional() - @Type(() => Number) - @IsNumber() - limit?: number; - - @IsOptional() - @IsString() - sortBy?: string; // Should be a property of the ${names.pascal} entity - - @IsOptional() - @IsIn(['ASC', 'DESC']) - order?: 'ASC' | 'DESC'; - - // --- Add other filterable properties below --- - // @IsOptional() - // @IsString() - // name?: string; - - // @IsOptional() - // @Type(() => Boolean) - // @IsBoolean() - // is_available?: boolean; -} -`; - } - - private getControllerTemplate(names: NamingConvention): string { - return `import { Controller, Get, Post, Body, Patch, Param, Delete, Query, ParseIntPipe, DefaultValuePipe } from '@nestjs/common'; -import { ${names.pascal}sService } from './${names.plural}.service'; -import { Create${names.pascal}Dto } from './dto/create-${names.singular}.dto'; -import { Update${names.pascal}Dto } from './dto/update-${names.singular}.dto'; -import { Query${names.pascal}Dto } from './dto/query-${names.singular}.dto'; - -@Controller('${names.plural}') -export class ${names.pascal}sController { - constructor(private readonly ${names.camel}sService: ${names.pascal}sService) {} - - @Post() - create(@Body() create${names.pascal}Dto: Create${names.pascal}Dto) { - return this.${names.camel}sService.create(create${names.pascal}Dto); - } - - @Get() - findAll(@Query() queryParams: Query${names.pascal}Dto) { - return this.${names.camel}sService.findAll(queryParams); - } - - @Get('search') - search( - @Query('q') term: string, - @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, - @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, - ) { - return this.${names.camel}sService.search(term, { page, limit }); - } - - @Get(':id') - findOne(@Param('id', ParseIntPipe) id: number) { - return this.${names.camel}sService.findOne(id); - } - - - - @Patch(':id') - update(@Param('id', ParseIntPipe) id: number, @Body() update${names.pascal}Dto: Update${names.pascal}Dto) { - return this.${names.camel}sService.update(id, update${names.pascal}Dto); - } - - @Delete(':id') - remove(@Param('id', ParseIntPipe) id: number) { - return this.${names.camel}sService.remove(id); - } -} -`; - } - -// In src/services/crud-generator.service.ts - - private getServiceTemplate(names: NamingConvention): string { - const entityPath = path.relative(path.join('src', names.singular), path.join(this.configService.get().server.entitiesPath, `${names.singular}.entity`)).replace(/\\/g, '/'); - - return `import { Injectable, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, FindManyOptions, FindOptionsWhere, ILike } from 'typeorm'; -import { Create${names.pascal}Dto } from './dto/create-${names.singular}.dto'; -import { Update${names.pascal}Dto } from './dto/update-${names.singular}.dto'; -import { Query${names.pascal}Dto } from './dto/query-${names.singular}.dto'; -import { ${names.pascal} } from '${entityPath}'; - -type QueryConfigItem = { - param: keyof Omit; - dbField: keyof ${names.pascal}; - operator: 'equals' | 'like'; -}; - -@Injectable() -export class ${names.pascal}sService { - constructor( - @InjectRepository(${names.pascal}) - private readonly ${names.camel}Repository: Repository<${names.pascal}>, - ) {} - - private readonly searchableFields: (keyof ${names.pascal})[] = [ - ${names.searchableFields} - ]; - - create(create${names.pascal}Dto: Create${names.pascal}Dto) { - const newRecord = this.${names.camel}Repository.create(create${names.pascal}Dto); - return this.${names.camel}Repository.save(newRecord); - } - - async findAll(queryParams: Query${names.pascal}Dto) { - const { page = 1, limit = 0, sortBy, order, ...filters } = queryParams; - - const queryConfig: QueryConfigItem[] = [ - // Example: { param: 'name', dbField: 'name', operator: 'like' }, - // Example: { param: 'is_available', dbField: 'is_available', operator: 'equals' }, - ]; - - // --- START OF THE FIX --- - - // 1. Create a loosely typed object to build the where clause. - const whereClause: { [key: string]: any } = {}; - - // 2. Populate it dynamically. This avoids the TypeScript error. - for (const config of queryConfig) { - if (filters[config.param] !== undefined) { - if (config.operator === 'like') { - whereClause[config.dbField] = ILike(\`%\${filters[config.param]}%\`); - } else { - whereClause[config.dbField] = filters[config.param]; - } - } - } - - // 3. Assign the complete, built clause to the strongly-typed options. - const findOptions: FindManyOptions<${names.pascal}> = { - where: whereClause as FindOptionsWhere<${names.pascal}>, - }; - - // --- END OF THE FIX --- - - const paginated = limit > 0; - - if (paginated) { - findOptions.skip = (page - 1) * limit; - findOptions.take = limit; - } - - if (sortBy && order) { - findOptions.order = { [sortBy]: order }; - } - - const [data, totalItems] = await this.${names.camel}Repository.findAndCount(findOptions); - - if (!paginated) { - return { data, total: data.length }; - } - - return { - data, - meta: { - totalItems, - itemCount: data.length, - itemsPerPage: limit, - totalPages: Math.ceil(totalItems / limit), - currentPage: page, - }, - }; - } - - - async search(term: string, options: { page: number; limit: number }) { - - if (this.searchableFields.length === 0) { - console.warn('Search is not configured for this entity. Please populate the searchableFields array in the service.'); - return { data: [], meta: { totalItems: 0, itemCount: 0, itemsPerPage: options.limit, totalPages: 0, currentPage: options.page } }; - } - - const whereConditions = this.searchableFields.map(field => ({ - [field]: ILike('%'+term+'%'), - })); - - const [data, totalItems] = await this.${names.camel}Repository.findAndCount({ - where: whereConditions, - skip: (options.page - 1) * options.limit, - take: options.limit, - }); - - return { - data, - meta: { - totalItems, - itemCount: data.length, - itemsPerPage: options.limit, - totalPages: Math.ceil(totalItems / options.limit), - currentPage: options.page, - }, - }; - } - - async findOne(id: number) { - const record = await this.${names.camel}Repository.findOneBy({ id: id as any }); - if (!record) { - throw new NotFoundException(\`${names.pascal} with ID \${id} not found\`); - } - return record; - } - - async update(id: number, update${names.pascal}Dto: Update${names.pascal}Dto) { - const record = await this.findOne(id); - Object.assign(record, update${names.pascal}Dto); - return this.${names.camel}Repository.save(record); - } - - async remove(id: number) { - const result = await this.${names.camel}Repository.delete(id); - if (result.affected === 0) { - throw new NotFoundException(\`${names.pascal} with ID \${id} not found\`); - } - return { deleted: true, id }; - } -} -`; - } - - private getModuleTemplate(names: NamingConvention): string { - const entityPath = path.relative(path.join('src', names.singular), path.join(this.configService.get().server.entitiesPath, `${names.singular}.entity`)).replace(/\\/g, '/'); - return `import { Module } from '@nestjs/common';\nimport { TypeOrmModule } from '@nestjs/typeorm';\nimport { ${names.pascal}sService } from './${names.plural}.service';\nimport { ${names.pascal}sController } from './${names.plural}.controller';\nimport { ${names.pascal} } from '${entityPath}';\n\n@Module({\n imports: [TypeOrmModule.forFeature([${names.pascal}])],\n controllers: [${names.pascal}sController],\n providers: [${names.pascal}sService],\n})\nexport class ${names.pascal}sModule {}\n`; - } - - private getCreateDtoTemplate(names: NamingConvention): string { - const entityPath = path.relative(path.join('src', names.singular, 'dto'), path.join(this.configService.get().server.entitiesPath, `${names.singular}.entity`)).replace(/\\/g, '/'); - return `import { OmitType } from '@nestjs/mapped-types';\nimport { ${names.pascal} } from '${entityPath}';\n\n// NOTE: Use class-validator decorators here for production-grade validation.\nexport class Create${names.pascal}Dto extends OmitType(${names.pascal}, ['id']) {}\n`; - } - - private getUpdateDtoTemplate(names: NamingConvention): string { - return `import { PartialType } from '@nestjs/mapped-types';\nimport { Create${names.pascal}Dto } from './create-${names.singular}.dto';\n\nexport class Update${names.pascal}Dto extends PartialType(Create${names.pascal}Dto) {}\n`; + private isStringBasedType(dbType: string): boolean { + return dbType.includes('char') || dbType.includes('text') || dbType === 'uuid'; } private getNamingConvention(tableName: string, columns?: TableColumn[]): NamingConvention { const singular = this.toSingular(tableName); + const pascal = this.toPascalCase(singular); const searchableFields = (columns || []) .filter(c => this.isStringBasedType(c.type)) - .map(c => `'${c.name}'`); // Wrap in quotes for the array + .map(c => `'${c.name}'`) + .join(',\n '); + return { - singular: singular, - plural: tableName, - pascal: this.toPascalCase(singular), + singular: this.toKebabCase(singular), + plural: this.toKebabCase(tableName), + pascal: pascal, camel: this.toCamelCase(singular), - searchableFields + searchableFields: searchableFields }; } - private toSingular(name: string): string { - if (name.endsWith('ies')) return name.slice(0, -3) + 'y'; - return name.endsWith('s') ? name.slice(0, -1) : name; + const kebab = this.toKebabCase(name); + if (kebab.endsWith('ies')) return kebab.slice(0, -3) + 'y'; + return kebab.endsWith('s') ? kebab.slice(0, -1) : kebab; } - private toPascalCase(text: string): string { - return text.replace(/(^\w|-\w|_w)/g, (c) => c.replace(/[-_]/, '').toUpperCase()); + return this.toKebabCase(text).split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(''); } - private toCamelCase(text: string): string { const pascal = this.toPascalCase(text); return pascal.charAt(0).toLowerCase() + pascal.slice(1); } + private toKebabCase(text: string): string { + return text.replace(/_/g, '-').replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase(); + } } \ No newline at end of file diff --git a/src/services/entity-generator.service.ts b/src/services/entity-generator.service.ts index 29ebcbf..cc7ac6e 100644 --- a/src/services/entity-generator.service.ts +++ b/src/services/entity-generator.service.ts @@ -187,6 +187,21 @@ export class ${className} {${props} } private toPascalCase(text: string): string { - return text.replace(/(^\w|-\w|_w)/g, (c) => c.replace(/[-_]/, '').toUpperCase()); + return this.toKebabCase(text) + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); + } + + private toCamelCase(text: string): string { + const pascal = this.toPascalCase(text); + return pascal.charAt(0).toLowerCase() + pascal.slice(1); + } + + private toKebabCase(text: string): string { + return text + .replace(/_/g, '-') // Replace underscores with hyphens + .replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2') // Add hyphen before uppercase letters + .toLowerCase(); } } \ No newline at end of file diff --git a/src/templates/angular-generic/data-provider.service.ts.tpl b/src/templates/angular-generic/data-provider.service.ts.tpl index 4c0a1aa..8f262b5 100644 --- a/src/templates/angular-generic/data-provider.service.ts.tpl +++ b/src/templates/angular-generic/data-provider.service.ts.tpl @@ -14,8 +14,9 @@ export class {{pascal}}DataProvider implements DataProvider<{{pascal}}> { private {{camel}}Service = inject({{pascal}}Service); getData(options?: GetDataOptions): Observable> { - // The generic table's params are compatible with our NestJS Query DTO - return this.{{camel}}Service.find(options?.params ?? {}).pipe( + const {q,page,limit} = options?.params ?? {}; + // The generic table's params are compatible with our NestJS Query DTO + return this.{{camel}}Service.search(q ?? '',page,limit, ).pipe( map((res) => { // Adapt the paginated response to the GetDataResponse format return { data: res }; diff --git a/src/templates/angular-generic/table.component.ts.tpl b/src/templates/angular-generic/table.component.ts.tpl index 3adb103..67af696 100644 --- a/src/templates/angular-generic/table.component.ts.tpl +++ b/src/templates/angular-generic/table.component.ts.tpl @@ -12,7 +12,7 @@ import { ActionDefinition, GenericActionColumn, } from '../../../../components/generic-action-column/generic-action-column'; -import { ProductService } from '../../services/{{singular}}.service'; +import { {{pascal}}Service } from '../../services/{{singular}}.service'; import { BehaviorSubject } from 'rxjs'; @Component({ diff --git a/src/templates/nestjs/controller.ts.tpl b/src/templates/nestjs/controller.ts.tpl new file mode 100644 index 0000000..fd2d434 --- /dev/null +++ b/src/templates/nestjs/controller.ts.tpl @@ -0,0 +1,44 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete, Query, ParseIntPipe, DefaultValuePipe } from '@nestjs/common'; +import { {{pascal}}sService } from './{{plural}}.service'; +import { Create{{pascal}}Dto } from './dto/create-{{singular}}.dto'; +import { Update{{pascal}}Dto } from './dto/update-{{singular}}.dto'; +import { Query{{pascal}}Dto } from './dto/query-{{singular}}.dto'; + +@Controller('{{plural}}') +export class {{pascal}}sController { + constructor(private readonly {{camel}}sService: {{pascal}}sService) {} + + @Post() + create(@Body() create{{pascal}}Dto: Create{{pascal}}Dto) { + return this.{{camel}}sService.create(create{{pascal}}Dto); + } + + @Get() + findAll(@Query() queryParams: Query{{pascal}}Dto) { + return this.{{camel}}sService.findAll(queryParams); + } + + @Get('search') + search( + @Query('q') term: string, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, + ) { + return this.{{camel}}sService.search(term, { page, limit }); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.{{camel}}sService.findOne(id); + } + + @Patch(':id') + update(@Param('id', ParseIntPipe) id: number, @Body() update{{pascal}}Dto: Update{{pascal}}Dto) { + return this.{{camel}}sService.update(id, update{{pascal}}Dto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.{{camel}}sService.remove(id); + } +} \ No newline at end of file diff --git a/src/templates/nestjs/create-dto.ts.tpl b/src/templates/nestjs/create-dto.ts.tpl new file mode 100644 index 0000000..3319b5d --- /dev/null +++ b/src/templates/nestjs/create-dto.ts.tpl @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/mapped-types'; +import { {{pascal}} } from '{{entityPath}}'; + +export class Create{{pascal}}Dto extends OmitType({{pascal}}, ['id']) {} \ No newline at end of file diff --git a/src/templates/nestjs/module.ts.tpl b/src/templates/nestjs/module.ts.tpl new file mode 100644 index 0000000..49c76a7 --- /dev/null +++ b/src/templates/nestjs/module.ts.tpl @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { {{pascal}}sService } from './{{plural}}.service'; +import { {{pascal}}sController } from './{{plural}}.controller'; +import { {{pascal}} } from '{{entityPath}}'; + +@Module({ + imports: [TypeOrmModule.forFeature([{{pascal}}])], + controllers: [{{pascal}}sController], + providers: [{{pascal}}sService], +}) +export class {{pascal}}sModule {} \ No newline at end of file diff --git a/src/templates/nestjs/query-dto.ts.tpl b/src/templates/nestjs/query-dto.ts.tpl new file mode 100644 index 0000000..89c9ef2 --- /dev/null +++ b/src/templates/nestjs/query-dto.ts.tpl @@ -0,0 +1,9 @@ +import { IsOptional, IsString, IsNumber, IsIn, IsBoolean } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class Query{{pascal}}Dto { + @IsOptional() @Type(() => Number) @IsNumber() page?: number; + @IsOptional() @Type(() => Number) @IsNumber() limit?: number; + @IsOptional() @IsString() sortBy?: string; + @IsOptional() @IsIn(['ASC', 'DESC']) order?: 'ASC' | 'DESC'; +} \ No newline at end of file diff --git a/src/templates/nestjs/service.ts.tpl b/src/templates/nestjs/service.ts.tpl new file mode 100644 index 0000000..e1c4007 --- /dev/null +++ b/src/templates/nestjs/service.ts.tpl @@ -0,0 +1,101 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindManyOptions, FindOptionsWhere, ILike } from 'typeorm'; +import { Create{{pascal}}Dto } from './dto/create-{{singular}}.dto'; +import { Update{{pascal}}Dto } from './dto/update-{{singular}}.dto'; +import { Query{{pascal}}Dto } from './dto/query-{{singular}}.dto'; +import { {{pascal}} } from '{{entityPath}}'; + +type QueryConfigItem = { + param: keyof Omit; + dbField: keyof {{pascal}}; + operator: 'equals' | 'like'; +}; + +@Injectable() +export class {{pascal}}sService { + constructor( + @InjectRepository({{pascal}}) + private readonly {{camel}}Repository: Repository<{{pascal}}>, + ) {} + + private readonly searchableFields: (keyof {{pascal}})[] = [ + {{searchableFields}} + ]; + + create(create{{pascal}}Dto: Create{{pascal}}Dto) { + const newRecord = this.{{camel}}Repository.create(create{{pascal}}Dto); + return this.{{camel}}Repository.save(newRecord); + } + + async findAll(queryParams: Query{{pascal}}Dto) { + const { page = 1, limit = 0, sortBy, order, ...filters } = queryParams; + const queryConfig: QueryConfigItem[] = []; + const whereClause: { [key: string]: any } = {}; + for (const config of queryConfig) { + if (filters[config.param] !== undefined) { + if (config.operator === 'like') { + whereClause[config.dbField] = ILike(`%${filters[config.param]}%`); + } else { + whereClause[config.dbField] = filters[config.param]; + } + } + } + const findOptions: FindManyOptions<{{pascal}}> = { where: whereClause as FindOptionsWhere<{{pascal}}> }; + const paginated = limit > 0; + if (paginated) { + findOptions.skip = (page - 1) * limit; + findOptions.take = limit; + } + if (sortBy && order) { + findOptions.order = { [sortBy]: order }; + } + const [data, totalItems] = await this.{{camel}}Repository.findAndCount(findOptions); + if (!paginated) { + return { data, total: data.length }; + } + return { + data, + meta: { totalItems, itemCount: data.length, itemsPerPage: limit, totalPages: Math.ceil(totalItems / limit), currentPage: page }, + }; + } + + async search(term: string, options: { page: number; limit: number }) { + if (this.searchableFields.length === 0) { + console.warn('Search is not configured for this entity.'); + return { data: [], meta: { totalItems: 0, itemCount: 0, itemsPerPage: options.limit, totalPages: 0, currentPage: options.page } }; + } + const whereConditions = this.searchableFields.map(field => ({ [field]: ILike(`%${term}%`) })); + const [data, totalItems] = await this.{{camel}}Repository.findAndCount({ + where: whereConditions, + skip: (options.page - 1) * options.limit, + take: options.limit, + }); + return { + data, + meta: { totalItems, itemCount: data.length, itemsPerPage: options.limit, totalPages: Math.ceil(totalItems / options.limit), currentPage: options.page }, + }; + } + + async findOne(id: number) { + const record = await this.{{camel}}Repository.findOneBy({ id: id as any }); + if (!record) { + throw new NotFoundException(`{{pascal}} with ID ${id} not found`); + } + return record; + } + + async update(id: number, update{{pascal}}Dto: Update{{pascal}}Dto) { + const record = await this.findOne(id); + Object.assign(record, update{{pascal}}Dto); + return this.{{camel}}Repository.save(record); + } + + async remove(id: number) { + const result = await this.{{camel}}Repository.delete(id); + if (result.affected === 0) { + throw new NotFoundException(`{{pascal}} with ID ${id} not found`); + } + return { deleted: true, id }; + } +} \ No newline at end of file diff --git a/src/templates/nestjs/update-dto.ts.tpl b/src/templates/nestjs/update-dto.ts.tpl new file mode 100644 index 0000000..7b7a863 --- /dev/null +++ b/src/templates/nestjs/update-dto.ts.tpl @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { Create{{pascal}}Dto } from './create-{{singular}}.dto'; + +export class Update{{pascal}}Dto extends PartialType(Create{{pascal}}Dto) {} \ No newline at end of file